Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
919bc310a7 | ||
| badcfb160c | |||
|
|
1b35f71bb8 | ||
| 96349bf200 | |||
| 472d30940d | |||
|
|
ae9d2bfeb6 | ||
| 61b82d1f40 | |||
|
|
b008011d5f | ||
| 938f000f65 | |||
|
|
7fdf369883 | ||
| c9883a1279 | |||
|
|
5f09b0dd71 | ||
| 0e436f076e | |||
| 2281b31894 | |||
|
|
0eda439897 | ||
| a1bd03b277 | |||
|
|
1c2904898f | ||
| 0fe41a55c1 | |||
| af2f7dbcbc |
4
android/.gitignore
vendored
4
android/.gitignore
vendored
@@ -52,10 +52,8 @@ captures/
|
|||||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||||
.idea/navEditor.xml
|
.idea/navEditor.xml
|
||||||
|
|
||||||
# Keystore files - NEVER commit these!
|
# Keystore files
|
||||||
*.jks
|
*.jks
|
||||||
*.keystore
|
|
||||||
keystore.properties
|
|
||||||
|
|
||||||
# External native build folder generated in Android Studio 2.2 and later
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
|
|||||||
@@ -35,8 +35,16 @@ android {
|
|||||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
}
|
}
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
minifyEnabled false
|
minifyEnabled false
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
|
import android.media.AudioAttributes
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -37,6 +41,11 @@ class MatrixSyncService : Service() {
|
|||||||
/** In-memory cache of MXID → display name to avoid redundant API calls per service run. */
|
/** In-memory cache of MXID → display name to avoid redundant API calls per service run. */
|
||||||
private val displayNameCache = HashMap<String, String>(16)
|
private val displayNameCache = HashMap<String, String>(16)
|
||||||
|
|
||||||
|
/** In-memory cache of MXID → avatar bitmap (null = no avatar or fetch failed). */
|
||||||
|
private val avatarCache = HashMap<String, Bitmap?>(16)
|
||||||
|
|
||||||
|
private data class UserProfile(val displayName: String, val avatar: Bitmap?)
|
||||||
|
|
||||||
override fun onBind(intent: Intent?) = null
|
override fun onBind(intent: Intent?) = null
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
@@ -165,24 +174,45 @@ class MatrixSyncService : Service() {
|
|||||||
if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
|
if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
|
||||||
|
|
||||||
val content = event.optJSONObject("content") ?: continue
|
val content = event.optJSONObject("content") ?: continue
|
||||||
val body = content.optString("body").takeIf { it.isNotBlank() } ?: continue
|
val msgtype = content.optString("msgtype")
|
||||||
|
val rawBody = content.optString("body")
|
||||||
|
val body = when (msgtype) {
|
||||||
|
"m.image" -> if (rawBody.isNotBlank()) "📷 $rawBody" else "📷 Photo"
|
||||||
|
"m.video" -> if (rawBody.isNotBlank()) "🎥 $rawBody" else "🎥 Video"
|
||||||
|
"m.audio" -> if (rawBody.isNotBlank()) "🎵 $rawBody" else "🎵 Audio"
|
||||||
|
"m.file" -> if (rawBody.isNotBlank()) "📎 $rawBody" else "📎 File"
|
||||||
|
"m.sticker" -> if (rawBody.isNotBlank()) "🖼️ $rawBody" else "🖼️ Sticker"
|
||||||
|
else -> rawBody.takeIf { it.isNotBlank() } ?: continue
|
||||||
|
}
|
||||||
|
|
||||||
val sender = event.optString("sender")
|
val sender = event.optString("sender")
|
||||||
val senderName = resolveDisplayName(sender, homeserver, token)
|
val profile = resolveProfile(sender, homeserver, token)
|
||||||
|
|
||||||
showMessageNotification(nm, senderName, body)
|
// For unencrypted image/sticker messages, try to download a preview bitmap.
|
||||||
|
// Encrypted messages have a `file` object instead of a top-level `url`.
|
||||||
|
val inlineImage: Bitmap? = if (
|
||||||
|
(msgtype == "m.image" || msgtype == "m.sticker") && !content.has("file")
|
||||||
|
) {
|
||||||
|
content.optString("url").takeIf { it.startsWith("mxc://") }
|
||||||
|
?.let { mxcToDownloadUrl(it, homeserver) }
|
||||||
|
?.let { downloadBitmap(it, token) }
|
||||||
|
} else null
|
||||||
|
|
||||||
|
showMessageNotification(nm, profile.displayName, body, profile.avatar, inlineImage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveDisplayName(mxid: String, homeserver: String, token: String): String {
|
private fun resolveProfile(mxid: String, homeserver: String, token: String): UserProfile {
|
||||||
displayNameCache[mxid]?.let { return it }
|
val cachedName = displayNameCache[mxid]
|
||||||
|
if (cachedName != null) return UserProfile(cachedName, avatarCache[mxid])
|
||||||
|
|
||||||
val fallback = mxid.substringAfter("@").substringBefore(":")
|
val fallback = mxid.substringAfter("@").substringBefore(":")
|
||||||
return try {
|
return try {
|
||||||
val encodedId = URLEncoder.encode(mxid, "UTF-8")
|
val encodedId = URLEncoder.encode(mxid, "UTF-8")
|
||||||
val url = "$homeserver/_matrix/client/v3/profile/$encodedId/displayname"
|
val url = "$homeserver/_matrix/client/v3/profile/$encodedId"
|
||||||
val conn = URL(url).openConnection() as HttpURLConnection
|
val conn = URL(url).openConnection() as HttpURLConnection
|
||||||
val name = try {
|
val (displayName, avatarMxc) = try {
|
||||||
conn.requestMethod = "GET"
|
conn.requestMethod = "GET"
|
||||||
conn.setRequestProperty("Authorization", "Bearer $token")
|
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||||
conn.setRequestProperty("Accept", "application/json")
|
conn.setRequestProperty("Accept", "application/json")
|
||||||
@@ -190,22 +220,73 @@ class MatrixSyncService : Service() {
|
|||||||
conn.readTimeout = 4_000
|
conn.readTimeout = 4_000
|
||||||
if (conn.responseCode == 200) {
|
if (conn.responseCode == 200) {
|
||||||
val body = conn.inputStream.bufferedReader().readText()
|
val body = conn.inputStream.bufferedReader().readText()
|
||||||
JSONObject(body).optString("displayname").takeIf { it.isNotBlank() } ?: fallback
|
val obj = JSONObject(body)
|
||||||
|
val name = obj.optString("displayname").takeIf { it.isNotBlank() } ?: fallback
|
||||||
|
val mxc = obj.optString("avatar_url").takeIf { it.startsWith("mxc://") }
|
||||||
|
Pair(name, mxc)
|
||||||
} else {
|
} else {
|
||||||
fallback
|
Pair(fallback, null)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
conn.disconnect()
|
conn.disconnect()
|
||||||
}
|
}
|
||||||
displayNameCache[mxid] = name
|
displayNameCache[mxid] = displayName
|
||||||
name
|
val avatar = avatarMxc
|
||||||
|
?.let { mxcToThumbnailUrl(it, homeserver, size = 96) }
|
||||||
|
?.let { downloadBitmap(it, token) }
|
||||||
|
avatarCache[mxid] = avatar
|
||||||
|
UserProfile(displayName, avatar)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Could not resolve display name for $mxid: ${e.message}")
|
Log.w(TAG, "Could not resolve profile for $mxid: ${e.message}")
|
||||||
fallback
|
displayNameCache[mxid] = fallback
|
||||||
|
avatarCache[mxid] = null
|
||||||
|
UserProfile(fallback, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showMessageNotification(nm: NotificationManager, sender: String, body: String) {
|
private fun mxcToThumbnailUrl(mxcUrl: String, homeserver: String, size: Int): String? {
|
||||||
|
val withoutScheme = mxcUrl.removePrefix("mxc://")
|
||||||
|
val slash = withoutScheme.indexOf('/')
|
||||||
|
if (slash < 0) return null
|
||||||
|
val serverName = withoutScheme.substring(0, slash)
|
||||||
|
val mediaId = withoutScheme.substring(slash + 1)
|
||||||
|
return "$homeserver/_matrix/media/v3/thumbnail/$serverName/$mediaId?width=$size&height=$size&method=crop"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mxcToDownloadUrl(mxcUrl: String, homeserver: String): String? {
|
||||||
|
val withoutScheme = mxcUrl.removePrefix("mxc://")
|
||||||
|
val slash = withoutScheme.indexOf('/')
|
||||||
|
if (slash < 0) return null
|
||||||
|
val serverName = withoutScheme.substring(0, slash)
|
||||||
|
val mediaId = withoutScheme.substring(slash + 1)
|
||||||
|
return "$homeserver/_matrix/media/v3/download/$serverName/$mediaId"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadBitmap(urlString: String, token: String): Bitmap? {
|
||||||
|
return try {
|
||||||
|
val conn = URL(urlString).openConnection() as HttpURLConnection
|
||||||
|
try {
|
||||||
|
conn.requestMethod = "GET"
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||||
|
conn.connectTimeout = 5_000
|
||||||
|
conn.readTimeout = 10_000
|
||||||
|
if (conn.responseCode == 200) BitmapFactory.decodeStream(conn.inputStream) else null
|
||||||
|
} finally {
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to download bitmap from $urlString: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showMessageNotification(
|
||||||
|
nm: NotificationManager,
|
||||||
|
sender: String,
|
||||||
|
body: String,
|
||||||
|
largeIcon: Bitmap? = null,
|
||||||
|
inlineImage: Bitmap? = null,
|
||||||
|
) {
|
||||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
?: Intent(this, MainActivity::class.java)
|
?: Intent(this, MainActivity::class.java)
|
||||||
val pi = PendingIntent.getActivity(
|
val pi = PendingIntent.getActivity(
|
||||||
@@ -213,16 +294,25 @@ class MatrixSyncService : Service() {
|
|||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(this, CHANNEL_MESSAGES)
|
val builder = NotificationCompat.Builder(this, CHANNEL_MESSAGES)
|
||||||
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
||||||
.setContentTitle(sender)
|
.setContentTitle(sender)
|
||||||
.setContentText(body)
|
.setContentText(body)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setContentIntent(pi)
|
.setContentIntent(pi)
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
.build()
|
|
||||||
|
|
||||||
nm.notify(System.currentTimeMillis().toInt(), notification)
|
if (largeIcon != null) builder.setLargeIcon(largeIcon)
|
||||||
|
|
||||||
|
if (inlineImage != null) {
|
||||||
|
builder.setStyle(
|
||||||
|
NotificationCompat.BigPictureStyle()
|
||||||
|
.bigPicture(inlineImage)
|
||||||
|
.bigLargeIcon(null as Bitmap?)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
nm.notify(System.currentTimeMillis().toInt(), builder.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildStatusNotification(): Notification {
|
private fun buildStatusNotification(): Notification {
|
||||||
@@ -258,10 +348,20 @@ class MatrixSyncService : Service() {
|
|||||||
|
|
||||||
private fun ensureMessageChannel(nm: NotificationManager) {
|
private fun ensureMessageChannel(nm: NotificationManager) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val soundUri = Uri.parse("android.resource://$packageName/${R.raw.paarrot_notification}")
|
||||||
val channel = NotificationChannel(
|
val channel = NotificationChannel(
|
||||||
CHANNEL_MESSAGES, "Messages",
|
CHANNEL_MESSAGES, "Messages",
|
||||||
NotificationManager.IMPORTANCE_HIGH,
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
).apply { description = "Matrix message notifications" }
|
).apply {
|
||||||
|
description = "Matrix message notifications"
|
||||||
|
setSound(
|
||||||
|
soundUri,
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
nm.createNotificationChannel(channel)
|
nm.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,7 +378,7 @@ class MatrixSyncService : Service() {
|
|||||||
private const val KEY_LAST_WAKE_MS = "last_wake_ms"
|
private const val KEY_LAST_WAKE_MS = "last_wake_ms"
|
||||||
private const val NOTIF_ID_STATUS = 1001
|
private const val NOTIF_ID_STATUS = 1001
|
||||||
private const val CHANNEL_STATUS = "sync_status"
|
private const val CHANNEL_STATUS = "sync_status"
|
||||||
private const val CHANNEL_MESSAGES = "messages"
|
private const val CHANNEL_MESSAGES = "messages_paarrot"
|
||||||
private const val MIN_WAKE_INTERVAL_MS = 7_500L
|
private const val MIN_WAKE_INTERVAL_MS = 7_500L
|
||||||
const val MODE_ONE_SHOT = "one_shot"
|
const val MODE_ONE_SHOT = "one_shot"
|
||||||
|
|
||||||
|
|||||||
BIN
android/app/src/main/res/raw/paarrot_notification.ogg
Normal file
BIN
android/app/src/main/res/raw/paarrot_notification.ogg
Normal file
Binary file not shown.
4
android/keystore.properties
Normal file
4
android/keystore.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
storeFile=../paarrot-release.keystore
|
||||||
|
storePassword=paarrot2026
|
||||||
|
keyAlias=paarrot
|
||||||
|
keyPassword=paarrot2026
|
||||||
BIN
android/paarrot-release.keystore
Normal file
BIN
android/paarrot-release.keystore
Normal file
Binary file not shown.
2
cinny
2
cinny
Submodule cinny updated: ef0aa2a847...7ef9939d8a
269
overlay/src/app/components/editor/Editor.tsx
Normal file
269
overlay/src/app/components/editor/Editor.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
/* eslint-disable no-param-reassign */
|
||||||
|
import React, {
|
||||||
|
ClipboardEventHandler,
|
||||||
|
KeyboardEventHandler,
|
||||||
|
ReactNode,
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
import { Box, Scroll, Text } from 'folds';
|
||||||
|
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate';
|
||||||
|
import {
|
||||||
|
Slate,
|
||||||
|
Editable,
|
||||||
|
withReact,
|
||||||
|
RenderLeafProps,
|
||||||
|
RenderElementProps,
|
||||||
|
RenderPlaceholderProps,
|
||||||
|
} from 'slate-react';
|
||||||
|
import { withHistory } from 'slate-history';
|
||||||
|
import { BlockType } from './types';
|
||||||
|
import { RenderElement, RenderLeaf } from './Elements';
|
||||||
|
import { CustomElement } from './slate';
|
||||||
|
import * as css from './Editor.css';
|
||||||
|
import { toggleKeyboardShortcut } from './keyboard';
|
||||||
|
import { createCommandElement } from './utils';
|
||||||
|
|
||||||
|
const initialValue: CustomElement[] = [
|
||||||
|
{
|
||||||
|
type: BlockType.Paragraph,
|
||||||
|
children: [{ text: '' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const withInline = (editor: Editor): Editor => {
|
||||||
|
const { isInline } = editor;
|
||||||
|
|
||||||
|
editor.isInline = (element) =>
|
||||||
|
[BlockType.Mention, BlockType.Emoticon, BlockType.Link, BlockType.Command].includes(
|
||||||
|
element.type
|
||||||
|
) || isInline(element);
|
||||||
|
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
const withVoid = (editor: Editor): Editor => {
|
||||||
|
const { isVoid } = editor;
|
||||||
|
|
||||||
|
editor.isVoid = (element) =>
|
||||||
|
[BlockType.Mention, BlockType.Emoticon, BlockType.Command].includes(element.type) ||
|
||||||
|
isVoid(element);
|
||||||
|
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CommandValidatorFn = (commandName: string) => boolean;
|
||||||
|
|
||||||
|
let commandValidator: CommandValidatorFn | null = null;
|
||||||
|
|
||||||
|
export const setCommandValidator = (validator: CommandValidatorFn) => {
|
||||||
|
commandValidator = validator;
|
||||||
|
};
|
||||||
|
|
||||||
|
const withCommandAutoConvert = (editor: Editor): Editor => {
|
||||||
|
// Removed auto-conversion on space to prevent focus issues
|
||||||
|
// Commands are now highlighted visually via decorations
|
||||||
|
// and executed when Enter is pressed
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useEditor = (): Editor => {
|
||||||
|
const [editor] = useState(() =>
|
||||||
|
withCommandAutoConvert(withInline(withVoid(withReact(withHistory(createEditor())))))
|
||||||
|
);
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditorChangeHandler = (value: Descendant[]) => void;
|
||||||
|
type CustomEditorProps = {
|
||||||
|
editableName?: string;
|
||||||
|
top?: ReactNode;
|
||||||
|
bottom?: ReactNode;
|
||||||
|
before?: ReactNode;
|
||||||
|
after?: ReactNode;
|
||||||
|
maxHeight?: string;
|
||||||
|
editor: Editor;
|
||||||
|
placeholder?: string;
|
||||||
|
onKeyDown?: KeyboardEventHandler;
|
||||||
|
onKeyUp?: KeyboardEventHandler;
|
||||||
|
onChange?: EditorChangeHandler;
|
||||||
|
onPaste?: ClipboardEventHandler;
|
||||||
|
};
|
||||||
|
export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
editableName,
|
||||||
|
top,
|
||||||
|
bottom,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
maxHeight = '50vh',
|
||||||
|
editor,
|
||||||
|
placeholder,
|
||||||
|
onKeyDown,
|
||||||
|
onKeyUp,
|
||||||
|
onChange,
|
||||||
|
onPaste,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const renderElement = useCallback(
|
||||||
|
(props: RenderElementProps) => <RenderElement {...props} />,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []);
|
||||||
|
|
||||||
|
const decorate = useCallback(([node, path]: [any, number[]]) => {
|
||||||
|
const ranges: any[] = [];
|
||||||
|
|
||||||
|
// Only decorate text nodes in the first paragraph
|
||||||
|
if (
|
||||||
|
path.length === 2 &&
|
||||||
|
path[0] === 0 &&
|
||||||
|
path[1] === 0 &&
|
||||||
|
SlateText.isText(node)
|
||||||
|
) {
|
||||||
|
const firstChild = editor.children[0];
|
||||||
|
|
||||||
|
if (SlateElement.isElement(firstChild) && firstChild.type === BlockType.Paragraph) {
|
||||||
|
const [firstInline, secondInline] = firstChild.children;
|
||||||
|
|
||||||
|
// Don't decorate if we already have a CommandElement
|
||||||
|
if (SlateElement.isElement(secondInline) && secondInline.type === BlockType.Command) {
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the text matches /command pattern
|
||||||
|
const text = node.text;
|
||||||
|
const match = text.match(/^(\s*\/\w+)/);
|
||||||
|
|
||||||
|
if (match && commandValidator) {
|
||||||
|
const commandText = match[1].trim();
|
||||||
|
const commandName = commandText.substring(1);
|
||||||
|
|
||||||
|
if (commandValidator(commandName)) {
|
||||||
|
// Create decoration for the command text
|
||||||
|
ranges.push({
|
||||||
|
anchor: { path, offset: 0 },
|
||||||
|
focus: { path, offset: match[1].length },
|
||||||
|
pendingCommand: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleKeydown: KeyboardEventHandler = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
onKeyDown?.(evt);
|
||||||
|
const shortcutToggled = toggleKeyboardShortcut(editor, evt);
|
||||||
|
if (shortcutToggled) evt.preventDefault();
|
||||||
|
},
|
||||||
|
[editor, onKeyDown]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBeforeInput = useCallback(
|
||||||
|
(event: Event) => {
|
||||||
|
const inputEvent = event as InputEvent;
|
||||||
|
|
||||||
|
// Handle autocorrect replacement that causes text duplication
|
||||||
|
if (inputEvent.inputType === 'insertReplacementText' ||
|
||||||
|
inputEvent.inputType === 'insertFromComposition') {
|
||||||
|
const { selection } = editor;
|
||||||
|
if (!selection) return;
|
||||||
|
|
||||||
|
// Get the data being inserted
|
||||||
|
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// If there's selected text, delete it first
|
||||||
|
if (selection && !Range.isCollapsed(selection)) {
|
||||||
|
Transforms.delete(editor, { at: selection });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the replacement text
|
||||||
|
editor.insertText(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
);
|
||||||
|
|
||||||
|
const editableRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const editableElement = editableRef.current?.querySelector('[data-slate-editor="true"]');
|
||||||
|
if (!editableElement) return;
|
||||||
|
|
||||||
|
editableElement.addEventListener('beforeinput', handleBeforeInput, { capture: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
editableElement.removeEventListener('beforeinput', handleBeforeInput, { capture: true });
|
||||||
|
};
|
||||||
|
}, [handleBeforeInput]);
|
||||||
|
|
||||||
|
const renderPlaceholder = useCallback(
|
||||||
|
({ attributes, children }: RenderPlaceholderProps) => (
|
||||||
|
<span {...attributes} className={css.EditorPlaceholderContainer}>
|
||||||
|
{/* Inner component to style the actual text position and appearance */}
|
||||||
|
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.Editor} ref={ref}>
|
||||||
|
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
||||||
|
{top}
|
||||||
|
<Box alignItems="Start">
|
||||||
|
{before && (
|
||||||
|
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
|
||||||
|
{before}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Scroll
|
||||||
|
className={css.EditorTextareaScroll}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
style={{ maxHeight }}
|
||||||
|
size="300"
|
||||||
|
visibility="Hover"
|
||||||
|
hideTrack
|
||||||
|
ref={editableRef}
|
||||||
|
>
|
||||||
|
<Editable
|
||||||
|
data-editable-name={editableName}
|
||||||
|
className={css.EditorTextarea}
|
||||||
|
placeholder={placeholder}
|
||||||
|
renderPlaceholder={renderPlaceholder}
|
||||||
|
renderElement={renderElement}
|
||||||
|
renderLeaf={renderLeaf}
|
||||||
|
decorate={decorate}
|
||||||
|
onKeyDown={handleKeydown}
|
||||||
|
onKeyUp={onKeyUp}
|
||||||
|
onPaste={onPaste}
|
||||||
|
/>
|
||||||
|
</Scroll>
|
||||||
|
{after && (
|
||||||
|
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
|
||||||
|
{after}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{bottom}
|
||||||
|
</Slate>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.90",
|
"version": "4.11.98",
|
||||||
"description": "Paarrot - A Matrix client based on Cinny",
|
"description": "Paarrot - A Matrix client based on Cinny",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user