Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0404cdc8ee | ||
| 9dc177f527 | |||
| 655603bb59 | |||
|
|
fb9e8bc277 | ||
| 803a076f98 | |||
| 86d284b353 | |||
| 6207d18b3c | |||
|
|
8d2e35d1bc | ||
| 6845398755 | |||
| be52b0fa08 | |||
|
|
298bcfd1e3 | ||
| fce1736312 | |||
|
|
e11304d093 | ||
| 60d76d5165 | |||
|
|
373e700a07 | ||
| f7b25261cc | |||
|
|
41e1b0db9d | ||
| d1022635d2 | |||
|
|
2e6be4b0b3 | ||
| 0d97676edd | |||
|
|
f8e8058de3 | ||
| a1577a3b97 | |||
|
|
80f1df6588 | ||
| 375fa74309 | |||
|
|
0862094e00 | ||
| 45d66bbe28 | |||
|
|
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
|
||||||
|
|||||||
@@ -27,16 +27,24 @@ android {
|
|||||||
applicationId "com.paarrot.app"
|
applicationId "com.paarrot.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1777176952
|
versionCode 1783526741
|
||||||
versionName "2026-04-26.041552.151"
|
versionName "2026-07-08.160541.766"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
// 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'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public class MainActivity extends BridgeActivity {
|
|||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
registerPlugin(SyncServicePlugin.class);
|
registerPlugin(SyncServicePlugin.class);
|
||||||
registerPlugin(ShareHandlerPlugin.class);
|
registerPlugin(ShareHandlerPlugin.class);
|
||||||
|
registerPlugin(PluginStoragePlugin.class);
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
250
android/app/src/main/java/com/paarrot/app/PluginStoragePlugin.kt
Normal file
250
android/app/src/main/java/com/paarrot/app/PluginStoragePlugin.kt
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
package com.paarrot.app
|
||||||
|
|
||||||
|
import com.getcapacitor.JSArray
|
||||||
|
import com.getcapacitor.JSObject
|
||||||
|
import com.getcapacitor.Plugin
|
||||||
|
import com.getcapacitor.PluginCall
|
||||||
|
import com.getcapacitor.PluginMethod
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
|
||||||
|
/** Capacitor plugin that stores and manages user extension plugins on Android. */
|
||||||
|
@CapacitorPlugin(name = "PluginStorage")
|
||||||
|
class PluginStoragePlugin : Plugin() {
|
||||||
|
|
||||||
|
private fun pluginsDir(): File {
|
||||||
|
val dir = File(context.filesDir, "plugins")
|
||||||
|
dir.mkdirs()
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun getPath(call: PluginCall) {
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", true)
|
||||||
|
result.put("data", pluginsDir().absolutePath)
|
||||||
|
call.resolve(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun list(call: PluginCall) {
|
||||||
|
try {
|
||||||
|
val plugins = pluginsDir().listFiles()?.filter { it.isDirectory } ?: emptyList()
|
||||||
|
val array = JSArray()
|
||||||
|
|
||||||
|
for (dir in plugins) {
|
||||||
|
val metadata = readMetadata(dir)
|
||||||
|
val obj = JSObject()
|
||||||
|
obj.put("id", dir.name)
|
||||||
|
obj.put("name", metadata?.optString("name", dir.name) ?: dir.name)
|
||||||
|
obj.put("version", metadata?.optString("version", "") ?: "")
|
||||||
|
obj.put("description", metadata?.optString("description", "") ?: "")
|
||||||
|
obj.put("author", metadata?.optString("author", "") ?: "")
|
||||||
|
obj.put("repository", metadata?.optString("repository", "") ?: "")
|
||||||
|
obj.put("thumbnail", metadata?.optString("thumbnail", "") ?: "")
|
||||||
|
obj.put("homepage", metadata?.optString("homepage", "") ?: "")
|
||||||
|
obj.put("tags", metadataTagsToJsArray(metadata))
|
||||||
|
obj.put("installedDate", formatInstalledDate(dir.lastModified()))
|
||||||
|
obj.put("path", dir.absolutePath)
|
||||||
|
array.put(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", true)
|
||||||
|
result.put("data", array)
|
||||||
|
call.resolve(result)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", false)
|
||||||
|
result.put("error", e.message ?: "Failed to list plugins")
|
||||||
|
call.resolve(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun download(call: PluginCall) {
|
||||||
|
val pluginId = call.getString("pluginId")
|
||||||
|
?: return call.reject("pluginId required")
|
||||||
|
val downloadUrl = call.getString("downloadUrl")
|
||||||
|
?: return call.reject("downloadUrl required")
|
||||||
|
val name = call.getString("name") ?: pluginId
|
||||||
|
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
val destDir = File(pluginsDir(), pluginId)
|
||||||
|
if (destDir.exists()) {
|
||||||
|
destDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
destDir.mkdirs()
|
||||||
|
|
||||||
|
val tempZip = File(context.cacheDir, "plugin-download-$pluginId.zip")
|
||||||
|
downloadFile(downloadUrl, tempZip)
|
||||||
|
extractZip(tempZip, destDir)
|
||||||
|
tempZip.delete()
|
||||||
|
|
||||||
|
normalisePluginLayout(destDir)
|
||||||
|
|
||||||
|
if (!File(destDir, "index.js").exists()) {
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", false)
|
||||||
|
result.put("error", "Downloaded plugin is missing index.js")
|
||||||
|
call.resolve(result)
|
||||||
|
return@Thread
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", true)
|
||||||
|
val data = JSObject()
|
||||||
|
data.put("path", destDir.absolutePath)
|
||||||
|
result.put("data", data)
|
||||||
|
call.resolve(result)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", false)
|
||||||
|
result.put("error", e.message ?: "Failed to download plugin: $name")
|
||||||
|
call.resolve(result)
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun uninstall(call: PluginCall) {
|
||||||
|
val pluginId = call.getString("pluginId")
|
||||||
|
?: return call.reject("pluginId required")
|
||||||
|
|
||||||
|
try {
|
||||||
|
val destDir = File(pluginsDir(), pluginId)
|
||||||
|
if (destDir.exists()) {
|
||||||
|
destDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", true)
|
||||||
|
call.resolve(result)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", false)
|
||||||
|
result.put("error", e.message ?: "Failed to uninstall plugin")
|
||||||
|
call.resolve(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun readPluginCode(call: PluginCall) {
|
||||||
|
val pluginId = call.getString("pluginId")
|
||||||
|
?: return call.reject("pluginId required")
|
||||||
|
|
||||||
|
val indexFile = File(pluginsDir(), "$pluginId/index.js")
|
||||||
|
if (!indexFile.exists()) {
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", false)
|
||||||
|
result.put("error", "Plugin not found: $pluginId")
|
||||||
|
call.resolve(result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", true)
|
||||||
|
result.put("data", indexFile.readText())
|
||||||
|
call.resolve(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readMetadata(pluginDir: File): JSONObject? {
|
||||||
|
val metadataFile = File(pluginDir, "plugin-metadata.json")
|
||||||
|
if (!metadataFile.exists()) return null
|
||||||
|
|
||||||
|
return try {
|
||||||
|
JSONObject(metadataFile.readText())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun metadataTagsToJsArray(metadata: JSONObject?): JSArray {
|
||||||
|
val tags = JSArray()
|
||||||
|
val jsonTags = metadata?.optJSONArray("tags") ?: return tags
|
||||||
|
for (i in 0 until jsonTags.length()) {
|
||||||
|
tags.put(jsonTags.optString(i))
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatInstalledDate(timestamp: Long): String {
|
||||||
|
val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
|
||||||
|
return formatter.format(Date(timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadFile(downloadUrl: String, destFile: File) {
|
||||||
|
val connection = (URL(downloadUrl).openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = 30_000
|
||||||
|
readTimeout = 60_000
|
||||||
|
instanceFollowRedirects = true
|
||||||
|
requestMethod = "GET"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
connection.connect()
|
||||||
|
if (connection.responseCode !in 200..299) {
|
||||||
|
throw IllegalStateException("HTTP ${connection.responseCode}")
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.inputStream.use { input ->
|
||||||
|
FileOutputStream(destFile).use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractZip(zipFile: File, destDir: File) {
|
||||||
|
destDir.mkdirs()
|
||||||
|
val destPath = destDir.canonicalPath + File.separator
|
||||||
|
|
||||||
|
ZipInputStream(zipFile.inputStream()).use { zipInput ->
|
||||||
|
var entry = zipInput.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
val outFile = File(destDir, entry.name)
|
||||||
|
if (!outFile.canonicalPath.startsWith(destPath)) {
|
||||||
|
throw SecurityException("Zip entry outside target directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
outFile.mkdirs()
|
||||||
|
} else {
|
||||||
|
outFile.parentFile?.mkdirs()
|
||||||
|
FileOutputStream(outFile).use { output ->
|
||||||
|
zipInput.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zipInput.closeEntry()
|
||||||
|
entry = zipInput.nextEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flatten single-root zip layouts so index.js ends up directly under the plugin dir. */
|
||||||
|
private fun normalisePluginLayout(destDir: File) {
|
||||||
|
if (File(destDir, "index.js").exists()) return
|
||||||
|
|
||||||
|
val children = destDir.listFiles()?.filter { it.isDirectory } ?: return
|
||||||
|
if (children.size != 1) return
|
||||||
|
|
||||||
|
val nested = children[0]
|
||||||
|
nested.listFiles()?.forEach { child ->
|
||||||
|
child.renameTo(File(destDir, child.name))
|
||||||
|
}
|
||||||
|
nested.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
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...f67f08f1db
287
overlay/src/app/components/editor/Editor.tsx
Normal file
287
overlay/src/app/components/editor/Editor.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
/* 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,
|
||||||
|
ReactEditor,
|
||||||
|
} 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 mobile autocorrect replacement that causes text duplication in Slate
|
||||||
|
if (inputEvent.inputType === 'insertReplacementText') {
|
||||||
|
const data = inputEvent.data || inputEvent.dataTransfer?.getData('text/plain');
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const domRanges = inputEvent.getTargetRanges?.() ?? [];
|
||||||
|
if (domRanges.length > 0) {
|
||||||
|
const slateRange = ReactEditor.toSlateRange(editor, domRanges[0], {
|
||||||
|
exactMatch: false,
|
||||||
|
suppressThrow: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (slateRange) {
|
||||||
|
Transforms.select(editor, slateRange);
|
||||||
|
Transforms.delete(editor);
|
||||||
|
editor.insertText(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { selection } = editor;
|
||||||
|
if (!selection) return;
|
||||||
|
|
||||||
|
if (!Range.isCollapsed(selection)) {
|
||||||
|
Transforms.delete(editor, { at: selection });
|
||||||
|
} else {
|
||||||
|
const wordBefore = Editor.before(editor, selection.anchor, { unit: 'word' });
|
||||||
|
if (wordBefore) {
|
||||||
|
const wordRange = { anchor: wordBefore, focus: selection.anchor };
|
||||||
|
Transforms.select(editor, wordRange);
|
||||||
|
Transforms.delete(editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -5,6 +5,7 @@ import FocusTrap from 'focus-trap-react';
|
|||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Header,
|
Header,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -62,6 +63,7 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [confirmRoomId, setConfirmRoomId] = useState<string | null>(null);
|
||||||
|
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
const allRoomsSet = useAllJoinedRoomsSet();
|
const allRoomsSet = useAllJoinedRoomsSet();
|
||||||
@@ -107,16 +109,26 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
|
|||||||
const handleRoomClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
|
const handleRoomClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
const roomId = evt.currentTarget.getAttribute('data-room-id');
|
const roomId = evt.currentTarget.getAttribute('data-room-id');
|
||||||
if (roomId) {
|
if (roomId) {
|
||||||
onPick(roomId);
|
setConfirmRoomId(roomId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(() => {
|
||||||
|
if (confirmRoomId) {
|
||||||
|
onPick(confirmRoomId);
|
||||||
|
}
|
||||||
|
}, [confirmRoomId, onPick]);
|
||||||
|
|
||||||
|
const handleCancelConfirm = useCallback(() => {
|
||||||
|
setConfirmRoomId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const previewText = share.text?.slice(0, 80) ?? share.subject?.slice(0, 80);
|
const previewText = share.text?.slice(0, 80) ?? share.subject?.slice(0, 80);
|
||||||
const fileCount = share.files.length;
|
const fileCount = share.files.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<>
|
||||||
<OverlayCenter>
|
<Overlay open>
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
@@ -125,16 +137,25 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal size="300">
|
<Box
|
||||||
<Box grow="Yes" direction="Column" style={{ maxHeight: '85vh' }}>
|
grow="Yes"
|
||||||
|
direction="Column"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 999,
|
||||||
|
backgroundColor: '#1a1d23',
|
||||||
|
paddingTop: '60px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Header
|
<Header
|
||||||
size="500"
|
size="500"
|
||||||
style={{ padding: config.space.S200, paddingLeft: config.space.S400 }}
|
style={{ padding: config.space.S400 }}
|
||||||
>
|
>
|
||||||
<Box grow="Yes" direction="Column" gap="100">
|
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0, maxWidth: '80%' }}>
|
||||||
<Text size="H4">Share to Room</Text>
|
<Text size="H4">Share to Room</Text>
|
||||||
{(previewText || fileCount > 0) && (
|
{(previewText || fileCount > 0) && (
|
||||||
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
|
<Text size="T200" priority="300" style={{ opacity: 0.7, wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
||||||
{previewText
|
{previewText
|
||||||
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
|
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
|
||||||
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
|
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
|
||||||
@@ -150,9 +171,8 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
|
|||||||
|
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
padding: `0 ${config.space.S300}`,
|
padding: config.space.S200,
|
||||||
paddingTop: config.space.S200,
|
paddingTop: config.space.S300,
|
||||||
paddingBottom: config.space.S200,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
@@ -163,14 +183,12 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
|
|||||||
variant="Background"
|
variant="Background"
|
||||||
outlined
|
outlined
|
||||||
autoFocus
|
autoFocus
|
||||||
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Scroll ref={scrollRef} size="300" hideTrack style={{ flex: 1, minHeight: 0 }}>
|
<Scroll ref={scrollRef} size="300" hideTrack style={{ flex: 1, minHeight: 0 }}>
|
||||||
<Box
|
<Box style={{ padding: config.space.S300 }} direction="Column">
|
||||||
style={{ padding: config.space.S300, paddingTop: 0 }}
|
|
||||||
direction="Column"
|
|
||||||
>
|
|
||||||
{vItems.length === 0 && (
|
{vItems.length === 0 && (
|
||||||
<Box
|
<Box
|
||||||
style={{ padding: `${config.space.S700} 0` }}
|
style={{ padding: `${config.space.S700} 0` }}
|
||||||
@@ -189,9 +207,7 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box
|
<Box style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
|
||||||
style={{ position: 'relative', height: virtualizer.getTotalSize() }}
|
|
||||||
>
|
|
||||||
{vItems.map((vItem) => {
|
{vItems.map((vItem) => {
|
||||||
const roomId = items[vItem.index];
|
const roomId = items[vItem.index];
|
||||||
const room = getRoom(roomId);
|
const room = getRoom(roomId);
|
||||||
@@ -247,9 +263,54 @@ export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerPro
|
|||||||
</Box>
|
</Box>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
</Box>
|
</Box>
|
||||||
|
</FocusTrap>
|
||||||
|
</Overlay>
|
||||||
|
|
||||||
|
{confirmRoomId && (() => {
|
||||||
|
const room = getRoom(confirmRoomId);
|
||||||
|
const roomName = room?.name ?? confirmRoomId;
|
||||||
|
const isDm = mDirects.has(confirmRoomId);
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: handleCancelConfirm,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal style={{ maxWidth: '280px' }}>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S300 }}>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="H4">Share to {isDm ? 'Person' : 'Room'}?</Text>
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
Send to <strong>{roomName}</strong>?
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box gap="200" justifyContent="End">
|
||||||
|
<Button
|
||||||
|
onClick={handleCancelConfirm}
|
||||||
|
variant="Secondary"
|
||||||
|
size="400"
|
||||||
|
>
|
||||||
|
<Text size="B400">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
variant="Primary"
|
||||||
|
size="400"
|
||||||
|
>
|
||||||
|
<Text size="B400">Share</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</Modal>
|
</Modal>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
</OverlayCenter>
|
</OverlayCenter>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
);
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
149
overlay/src/app/features/settings/plugins/PluginAPI.ts
Normal file
149
overlay/src/app/features/settings/plugins/PluginAPI.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Re-exports the full @paarrot/plugin-manager API and creates the shared
|
||||||
|
* singleton PluginRegistry instance for this application.
|
||||||
|
*
|
||||||
|
* All other modules in this folder should import from here rather than
|
||||||
|
* directly from the package so the singleton is guaranteed to be shared.
|
||||||
|
*/
|
||||||
|
export {
|
||||||
|
PluginMarketplaceClient,
|
||||||
|
PluginMarketplaceManager,
|
||||||
|
PluginRegistry,
|
||||||
|
createPluginContext,
|
||||||
|
generateThemeCSS,
|
||||||
|
MemoryStorage,
|
||||||
|
} from '@paarrot/plugin-manager';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
IPluginStorage,
|
||||||
|
IPluginEventClient,
|
||||||
|
PluginMarketplaceClientOptions,
|
||||||
|
PluginMarketplaceHostAdapter,
|
||||||
|
PluginMarketplaceInstalledRecord,
|
||||||
|
PluginMarketplaceManagerOptions,
|
||||||
|
PluginRegistryOptions,
|
||||||
|
PluginContextOptions,
|
||||||
|
PluginMetadata,
|
||||||
|
InstalledPlugin,
|
||||||
|
PluginIndex,
|
||||||
|
ThemeColorGroup,
|
||||||
|
ThemePaletteGroup,
|
||||||
|
PluginThemeColors,
|
||||||
|
PluginTheme,
|
||||||
|
CommandArg,
|
||||||
|
PluginCommand,
|
||||||
|
MessageInterceptor,
|
||||||
|
MessageContext,
|
||||||
|
CustomRenderer,
|
||||||
|
SettingDefinition,
|
||||||
|
SettingsSchema,
|
||||||
|
NotificationOptions,
|
||||||
|
PluginContext,
|
||||||
|
PluginSettingsSection,
|
||||||
|
UILocation,
|
||||||
|
UIButtonDefinition,
|
||||||
|
UIButtonPosition,
|
||||||
|
Plugin,
|
||||||
|
PluginLogEntry,
|
||||||
|
} from '@paarrot/plugin-manager';
|
||||||
|
|
||||||
|
export { PluginTab } from '@paarrot/plugin-manager';
|
||||||
|
|
||||||
|
import {
|
||||||
|
PluginMarketplaceClient,
|
||||||
|
PluginMarketplaceManager,
|
||||||
|
PluginRegistry,
|
||||||
|
} from '@paarrot/plugin-manager';
|
||||||
|
import { getPluginHost } from '../../../utils/pluginHost';
|
||||||
|
|
||||||
|
const PLUGIN_INDEX_URL =
|
||||||
|
import.meta.env.VITE_PLUGIN_INDEX_URL ||
|
||||||
|
`https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json?cache_burst=${(Math.random()*256).toString()}`;
|
||||||
|
const PLUGIN_BASE_URL =
|
||||||
|
import.meta.env.VITE_PLUGIN_BASE_URL ||
|
||||||
|
'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/';
|
||||||
|
|
||||||
|
function injectThemeStyle(styleId: string, css: string): void {
|
||||||
|
let el = document.getElementById(styleId) as HTMLStyleElement | null;
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('style');
|
||||||
|
el.id = styleId;
|
||||||
|
document.head.appendChild(el);
|
||||||
|
}
|
||||||
|
el.textContent = css;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeThemeStyle(styleId: string): void {
|
||||||
|
document.getElementById(styleId)?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared singleton registry for the entire application. */
|
||||||
|
export const pluginRegistry = new PluginRegistry({
|
||||||
|
storage: localStorage,
|
||||||
|
onThemeRegistered: (themeId, _className, css) =>
|
||||||
|
injectThemeStyle(`plugin-theme-${themeId}`, css),
|
||||||
|
onThemeUnregistered: (themeId) => removeThemeStyle(`plugin-theme-${themeId}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Shared marketplace client for remote plugin directory reads. */
|
||||||
|
export const pluginMarketplaceClient = new PluginMarketplaceClient({
|
||||||
|
indexUrl: PLUGIN_INDEX_URL,
|
||||||
|
baseUrl: PLUGIN_BASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared marketplace manager for install state, directory fetch, and enable flags.
|
||||||
|
*/
|
||||||
|
export const pluginMarketplaceManager = new PluginMarketplaceManager({
|
||||||
|
client: pluginMarketplaceClient,
|
||||||
|
storage: localStorage,
|
||||||
|
registry: pluginRegistry,
|
||||||
|
host: {
|
||||||
|
listInstalledPlugins: async () => {
|
||||||
|
const host = getPluginHost();
|
||||||
|
if (!host) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await host.list();
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
throw new Error(result.error ?? 'Failed to list installed plugins');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data.map((plugin) => ({
|
||||||
|
id: plugin.id,
|
||||||
|
name: plugin.name,
|
||||||
|
version: plugin.version,
|
||||||
|
description: plugin.description,
|
||||||
|
author: plugin.author,
|
||||||
|
repository: plugin.repository,
|
||||||
|
thumbnail: plugin.thumbnail,
|
||||||
|
homepage: plugin.homepage,
|
||||||
|
tags: plugin.tags,
|
||||||
|
installedDate: plugin.installedDate,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
installPlugin: async (plugin) => {
|
||||||
|
const host = getPluginHost();
|
||||||
|
if (!host) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await host.download(plugin.id, plugin.downloadUrl, plugin.name);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error ?? 'Failed to install plugin');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
uninstallPlugin: async (pluginId) => {
|
||||||
|
const host = getPluginHost();
|
||||||
|
if (!host) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await host.uninstall(pluginId);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error ?? 'Failed to uninstall plugin');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
190
overlay/src/app/features/settings/plugins/PluginLoader.tsx
Normal file
190
overlay/src/app/features/settings/plugins/PluginLoader.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { Plugin, createPluginContext } from '@paarrot/plugin-manager';
|
||||||
|
import { pluginMarketplaceManager, pluginRegistry } from './PluginAPI';
|
||||||
|
import { sendNotification } from '../../../utils/tauri';
|
||||||
|
import { getPluginHost } from '../../../utils/pluginHost';
|
||||||
|
import { dispatchPluginButtonsChanged } from './PluginButtonSlot';
|
||||||
|
|
||||||
|
interface PluginLoaderProps {
|
||||||
|
matrixClient: MatrixClient;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that loads and initializes enabled plugins.
|
||||||
|
* Should be placed high in the component tree after MatrixClient is available.
|
||||||
|
*/
|
||||||
|
export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
|
||||||
|
console.log('[PluginLoader] Component rendering, matrixClient:', !!matrixClient);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadPlugins = async () => {
|
||||||
|
try {
|
||||||
|
const pluginHost = getPluginHost();
|
||||||
|
console.log('[PluginLoader] Loading plugins...');
|
||||||
|
console.log('[PluginLoader] plugin host available:', !!pluginHost);
|
||||||
|
|
||||||
|
if (!pluginHost) {
|
||||||
|
console.log('[PluginLoader] Running in web mode, plugins not supported');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginsToLoad = await pluginMarketplaceManager.listEnabledPlugins();
|
||||||
|
|
||||||
|
console.log('[PluginLoader] Plugins to load:', pluginsToLoad.length);
|
||||||
|
console.log('[PluginLoader] Plugin list:', pluginsToLoad.map((p) => p.id));
|
||||||
|
|
||||||
|
for (const installedPlugin of pluginsToLoad) {
|
||||||
|
try {
|
||||||
|
console.log(`[PluginLoader] Loading plugin: ${installedPlugin.id}`);
|
||||||
|
|
||||||
|
const pluginModule = await loadPluginModule(installedPlugin.id);
|
||||||
|
const plugin = normalisePlugin(pluginModule);
|
||||||
|
|
||||||
|
if (!plugin || typeof plugin !== 'object') {
|
||||||
|
console.error(
|
||||||
|
`[PluginLoader] Plugin ${installedPlugin.id} does not export a valid plugin object`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof plugin.onLoad !== 'function') {
|
||||||
|
console.error(
|
||||||
|
`[PluginLoader] Plugin ${installedPlugin.id} does not have an onLoad function`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = createPluginContext(
|
||||||
|
{
|
||||||
|
pluginId: installedPlugin.id,
|
||||||
|
eventClient: matrixClient as any,
|
||||||
|
onNotify: async (opts) => {
|
||||||
|
await sendNotification({
|
||||||
|
title: opts.title,
|
||||||
|
body: opts.body,
|
||||||
|
path: undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pluginRegistry
|
||||||
|
);
|
||||||
|
|
||||||
|
const compatContext = Object.assign(context as Record<string, unknown>, {
|
||||||
|
matrixClient,
|
||||||
|
matrix: {
|
||||||
|
on: (eventType: string, handler: (...args: unknown[]) => void) =>
|
||||||
|
matrixClient.on(eventType as any, handler as any),
|
||||||
|
off: (eventType: string, handler: (...args: unknown[]) => void) =>
|
||||||
|
matrixClient.off(eventType as any, handler as any),
|
||||||
|
},
|
||||||
|
React,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[PluginLoader] compatContext.React:', !!compatContext.React);
|
||||||
|
console.log('[PluginLoader] compatContext.ui:', !!compatContext.ui);
|
||||||
|
console.log('[PluginLoader] About to register plugin and call onLoad...');
|
||||||
|
|
||||||
|
pluginRegistry.registerPlugin(installedPlugin.id, plugin, context);
|
||||||
|
await plugin.onLoad(compatContext as any);
|
||||||
|
console.log(`[PluginLoader] Successfully loaded plugin: ${installedPlugin.id}`);
|
||||||
|
dispatchPluginButtonsChanged();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[PluginLoader] Failed to load plugin ${installedPlugin.id}:`, error);
|
||||||
|
pluginRegistry.addLog(installedPlugin.id, 'error', ['Failed to load:', error]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PluginLoader] Error loading plugins:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPlugins();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log('[PluginLoader] Cleaning up plugins');
|
||||||
|
pluginRegistry.clear();
|
||||||
|
dispatchPluginButtonsChanged();
|
||||||
|
};
|
||||||
|
}, [matrixClient]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads and evaluates a plugin's JS module from the native filesystem host.
|
||||||
|
* Supports both CommonJS and ESM-style exports.
|
||||||
|
*/
|
||||||
|
async function loadPluginModule(pluginId: string): Promise<any> {
|
||||||
|
const pluginHost = getPluginHost();
|
||||||
|
if (!pluginHost) {
|
||||||
|
throw new Error('Plugins are only supported in the desktop or Android app');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await pluginHost.readPluginCode(pluginId);
|
||||||
|
if (!response.success || !response.data) {
|
||||||
|
throw new Error(response.error ?? 'Failed to read plugin code');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginExports: any = {};
|
||||||
|
const mod = { exports: pluginExports };
|
||||||
|
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-new-func
|
||||||
|
const pluginFunction = new Function('module', 'exports', response.data);
|
||||||
|
pluginFunction(mod, pluginExports);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof SyntaxError)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(response.data)}`;
|
||||||
|
const importedModule = await import(/* @vite-ignore */ dataUrl);
|
||||||
|
return importedModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mod.exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes raw plugin exports to current plugin interface.
|
||||||
|
* Supports native `onLoad` and legacy `activate` / `deactivate` plugins.
|
||||||
|
*/
|
||||||
|
function normalisePlugin(raw: Record<string, unknown>): Plugin {
|
||||||
|
if (typeof raw.onLoad === 'function') {
|
||||||
|
return raw as unknown as Plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof raw.activate === 'function') {
|
||||||
|
const activate = raw.activate as (ctx: unknown) => void | Promise<void>;
|
||||||
|
const deactivate = raw.deactivate as ((ctx?: unknown) => void | Promise<void>) | undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: typeof raw.name === 'string' ? raw.name : undefined,
|
||||||
|
version: typeof raw.version === 'string' ? raw.version : undefined,
|
||||||
|
onLoad: async (ctx) => {
|
||||||
|
const legacyCtx = Object.assign(Object.create(ctx as object), {
|
||||||
|
registerHook: (name: string) =>
|
||||||
|
ctx.log(`[compat] registerHook("${name}") - not supported`),
|
||||||
|
runHook: (name: string) => {
|
||||||
|
ctx.log(`[compat] runHook("${name}")`);
|
||||||
|
return Promise.resolve([]);
|
||||||
|
},
|
||||||
|
getConfig: (key: string) => ctx.settings.get(key),
|
||||||
|
on: (event: string, handler: (...args: unknown[]) => void) =>
|
||||||
|
ctx.events.on(event, handler),
|
||||||
|
});
|
||||||
|
|
||||||
|
await activate(legacyCtx);
|
||||||
|
},
|
||||||
|
onUnload: deactivate
|
||||||
|
? async () => {
|
||||||
|
await deactivate();
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw as unknown as Plugin;
|
||||||
|
}
|
||||||
159
overlay/src/app/pages/auth/ServerPicker.tsx
Normal file
159
overlay/src/app/pages/auth/ServerPicker.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import React, {
|
||||||
|
ChangeEventHandler,
|
||||||
|
FocusEventHandler,
|
||||||
|
KeyboardEventHandler,
|
||||||
|
MouseEventHandler,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Input,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
PopOut,
|
||||||
|
RectCords,
|
||||||
|
Text,
|
||||||
|
config,
|
||||||
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
|
||||||
|
import { useDebounce } from '../../hooks/useDebounce';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
export function ServerPicker({
|
||||||
|
server,
|
||||||
|
serverList,
|
||||||
|
allowCustomServer,
|
||||||
|
onServerChange,
|
||||||
|
}: {
|
||||||
|
server: string;
|
||||||
|
serverList: string[];
|
||||||
|
allowCustomServer?: boolean;
|
||||||
|
onServerChange: (server: string) => void;
|
||||||
|
}) {
|
||||||
|
const [serverMenuAnchor, setServerMenuAnchor] = useState<RectCords>();
|
||||||
|
const serverInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only sync input when server changes externally (e.g., from menu selection)
|
||||||
|
// and input is not focused to avoid cursor jumping during typing
|
||||||
|
if (
|
||||||
|
serverInputRef.current &&
|
||||||
|
serverInputRef.current.value !== server &&
|
||||||
|
document.activeElement !== serverInputRef.current
|
||||||
|
) {
|
||||||
|
serverInputRef.current.value = server;
|
||||||
|
}
|
||||||
|
}, [server]);
|
||||||
|
|
||||||
|
const debounceServerSelect = useDebounce(onServerChange, { wait: 700 });
|
||||||
|
|
||||||
|
const handleServerChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
const inputServer = evt.target.value.trim();
|
||||||
|
if (inputServer) debounceServerSelect(inputServer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur: FocusEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
const inputServer = evt.target.value.trim();
|
||||||
|
if (inputServer && inputServer !== server) {
|
||||||
|
onServerChange(inputServer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
if (evt.key === 'ArrowDown') {
|
||||||
|
evt.preventDefault();
|
||||||
|
setServerMenuAnchor(undefined);
|
||||||
|
}
|
||||||
|
if (evt.key === 'Enter') {
|
||||||
|
evt.preventDefault();
|
||||||
|
const inputServer = evt.currentTarget.value.trim();
|
||||||
|
if (inputServer) onServerChange(inputServer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServerSelect: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
const selectedServer = evt.currentTarget.getAttribute('data-server');
|
||||||
|
if (selectedServer) {
|
||||||
|
onServerChange(selectedServer);
|
||||||
|
}
|
||||||
|
setServerMenuAnchor(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenServerMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
||||||
|
const target = evt.currentTarget.parentElement ?? evt.currentTarget;
|
||||||
|
setServerMenuAnchor(target.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={serverInputRef}
|
||||||
|
style={{ paddingRight: config.space.S200 }}
|
||||||
|
variant={allowCustomServer ? 'Background' : 'Surface'}
|
||||||
|
outlined
|
||||||
|
defaultValue={server}
|
||||||
|
onChange={handleServerChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
size="500"
|
||||||
|
readOnly={!allowCustomServer}
|
||||||
|
onClick={allowCustomServer ? undefined : handleOpenServerMenu}
|
||||||
|
after={
|
||||||
|
serverList.length === 0 || (serverList.length === 1 && !allowCustomServer) ? undefined : (
|
||||||
|
<PopOut
|
||||||
|
anchor={serverMenuAnchor}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
offset={4}
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setServerMenuAnchor(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
|
||||||
|
<Text size="L400">Homeserver List</Text>
|
||||||
|
</Header>
|
||||||
|
<div style={{ padding: config.space.S100, paddingTop: 0 }}>
|
||||||
|
{serverList?.map((serverName) => (
|
||||||
|
<MenuItem
|
||||||
|
key={serverName}
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={serverName === server}
|
||||||
|
data-server={serverName}
|
||||||
|
onClick={handleServerSelect}
|
||||||
|
>
|
||||||
|
<Text>{serverName}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleOpenServerMenu}
|
||||||
|
variant={allowCustomServer ? 'Background' : 'Surface'}
|
||||||
|
size="300"
|
||||||
|
aria-pressed={!!serverMenuAnchor}
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.ChevronBottom} />
|
||||||
|
</IconButton>
|
||||||
|
</PopOut>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -517,86 +517,118 @@ function TaskbarFlashStopper() {
|
|||||||
|
|
||||||
function AndroidShareIntentHandler() {
|
function AndroidShareIntentHandler() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
const navigate = useNavigate();
|
||||||
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
|
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
|
||||||
const [pickedRoomId, setPickedRoomId] = useState<string | null>(null);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(pickedRoomId ?? '__android_share__'));
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const setUploadItems = useSetAtom(roomIdToUploadItemsAtomFamily(pickedRoomId ?? '__android_share__'));
|
|
||||||
|
|
||||||
const applyPendingShare = useCallback(
|
const applyPendingShare = useCallback(
|
||||||
async (share: AndroidSharePayload, roomId: string) => {
|
async (share: AndroidSharePayload, roomId: string) => {
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return false;
|
if (!room) return false;
|
||||||
|
|
||||||
|
// Send text message if present
|
||||||
const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
|
const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
|
||||||
(value): value is string => !!value
|
(value): value is string => !!value
|
||||||
);
|
);
|
||||||
if (nextParts.length > 0) {
|
if (nextParts.length > 0) {
|
||||||
const existingText = toPlainText(msgDraft, isMarkdown).trim();
|
const textContent = {
|
||||||
const nextText = existingText ? `${existingText}\n${nextParts.join('\n')}` : nextParts.join('\n');
|
msgtype: 'm.text' as const,
|
||||||
setMsgDraft(plainToEditorInput(nextText, isMarkdown));
|
body: nextParts.join('\n'),
|
||||||
|
};
|
||||||
|
await mx.sendMessage(roomId, textContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upload and send files if present
|
||||||
if (share.files.length > 0) {
|
if (share.files.length > 0) {
|
||||||
const uploadItems: TUploadItem[] = [];
|
|
||||||
for (const sharedFile of share.files) {
|
for (const sharedFile of share.files) {
|
||||||
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
|
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
|
||||||
|
|
||||||
|
let fileToUpload = originalFile;
|
||||||
|
let encInfo: any = undefined;
|
||||||
|
|
||||||
if (room.hasEncryptionStateEvent()) {
|
if (room.hasEncryptionStateEvent()) {
|
||||||
const encrypted = await encryptFile(originalFile);
|
const encrypted = await encryptFile(originalFile);
|
||||||
uploadItems.push({
|
fileToUpload = encrypted.file;
|
||||||
file: encrypted.file,
|
encInfo = encrypted.encInfo;
|
||||||
originalFile,
|
|
||||||
metadata: { markedAsSpoiler: false },
|
|
||||||
encInfo: encrypted.encInfo,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadItems.push({
|
// Upload file
|
||||||
file: originalFile,
|
const uploadResult = await mx.uploadContent(fileToUpload);
|
||||||
originalFile,
|
const mxc = uploadResult?.content_uri;
|
||||||
metadata: { markedAsSpoiler: false },
|
if (!mxc) continue;
|
||||||
encInfo: undefined,
|
|
||||||
});
|
// Determine message type and send
|
||||||
|
const fileType = originalFile.type;
|
||||||
|
let msgtype = 'm.file' as const;
|
||||||
|
if (fileType.startsWith('image/')) msgtype = 'm.image' as const;
|
||||||
|
else if (fileType.startsWith('video/')) msgtype = 'm.video' as const;
|
||||||
|
else if (fileType.startsWith('audio/')) msgtype = 'm.audio' as const;
|
||||||
|
|
||||||
|
const fileContent: any = {
|
||||||
|
msgtype,
|
||||||
|
body: originalFile.name,
|
||||||
|
filename: originalFile.name,
|
||||||
|
info: {
|
||||||
|
mimetype: originalFile.type,
|
||||||
|
size: originalFile.size,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (encInfo) {
|
||||||
|
fileContent.file = {
|
||||||
|
...encInfo,
|
||||||
|
url: mxc,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
fileContent.url = mxc;
|
||||||
}
|
}
|
||||||
|
|
||||||
setUploadItems({
|
await mx.sendMessage(roomId, fileContent);
|
||||||
type: 'PUT',
|
}
|
||||||
item: uploadItems,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await clearPendingAndroidShare();
|
await clearPendingAndroidShare();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[isMarkdown, msgDraft, mx, setMsgDraft, setUploadItems]
|
[mx]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePickRoom = useCallback(
|
const handlePickRoom = useCallback(
|
||||||
(roomId: string) => {
|
(roomId: string) => {
|
||||||
setPickedRoomId(roomId);
|
|
||||||
if (!pendingShare) return;
|
if (!pendingShare) return;
|
||||||
|
|
||||||
applyPendingShare(pendingShare, roomId)
|
applyPendingShare(pendingShare, roomId)
|
||||||
.then((applied) => {
|
.then((applied) => {
|
||||||
if (applied) {
|
if (applied) {
|
||||||
setPendingShare(null);
|
setPendingShare(null);
|
||||||
setPickedRoomId(null);
|
|
||||||
|
// Navigate to the selected room
|
||||||
|
const isDirect = mDirects.has(roomId);
|
||||||
|
if (isDirect) {
|
||||||
|
navigate(getDirectRoomPath(roomId));
|
||||||
|
} else {
|
||||||
|
const parents = roomToParents.get(roomId);
|
||||||
|
const parent = parents && parents.length > 0 ? parents[0] : undefined;
|
||||||
|
if (parent) {
|
||||||
|
navigate(getSpaceRoomPath(parent, roomId));
|
||||||
|
} else {
|
||||||
|
navigate(getHomeRoomPath(roomId));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('[AndroidShare] Failed to apply share after pick:', err);
|
console.error('[AndroidShare] Failed to apply share after pick:', err);
|
||||||
setPendingShare(null);
|
setPendingShare(null);
|
||||||
setPickedRoomId(null);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[applyPendingShare, pendingShare]
|
[applyPendingShare, pendingShare, navigate, mDirects, roomToParents]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
clearPendingAndroidShare().catch(() => {});
|
clearPendingAndroidShare().catch(() => {});
|
||||||
setPendingShare(null);
|
setPendingShare(null);
|
||||||
setPickedRoomId(null);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
72
overlay/src/app/utils/pluginHost.ts
Normal file
72
overlay/src/app/utils/pluginHost.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||||
|
|
||||||
|
export type PluginHostListItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version?: string;
|
||||||
|
description?: string;
|
||||||
|
author?: string;
|
||||||
|
repository?: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
homepage?: string;
|
||||||
|
tags?: string[];
|
||||||
|
installedDate: string;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginHostResult<T> = {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PluginHostAPI {
|
||||||
|
getPath: () => Promise<PluginHostResult<string>>;
|
||||||
|
download: (
|
||||||
|
pluginId: string,
|
||||||
|
downloadUrl: string,
|
||||||
|
name: string
|
||||||
|
) => Promise<PluginHostResult<{ path: string }>>;
|
||||||
|
list: () => Promise<PluginHostResult<PluginHostListItem[]>>;
|
||||||
|
uninstall: (pluginId: string) => Promise<PluginHostResult<void>>;
|
||||||
|
readPluginCode: (pluginId: string) => Promise<PluginHostResult<string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginStoragePlugin {
|
||||||
|
getPath(): Promise<PluginHostResult<string>>;
|
||||||
|
download(options: {
|
||||||
|
pluginId: string;
|
||||||
|
downloadUrl: string;
|
||||||
|
name: string;
|
||||||
|
}): Promise<PluginHostResult<{ path: string }>>;
|
||||||
|
list(): Promise<PluginHostResult<PluginHostListItem[]>>;
|
||||||
|
uninstall(options: { pluginId: string }): Promise<PluginHostResult<void>>;
|
||||||
|
readPluginCode(options: { pluginId: string }): Promise<PluginHostResult<string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PluginStorage = registerPlugin<PluginStoragePlugin>('PluginStorage');
|
||||||
|
|
||||||
|
const androidPluginHost: PluginHostAPI = {
|
||||||
|
getPath: () => PluginStorage.getPath(),
|
||||||
|
download: (pluginId, downloadUrl, name) =>
|
||||||
|
PluginStorage.download({ pluginId, downloadUrl, name }),
|
||||||
|
list: () => PluginStorage.list(),
|
||||||
|
uninstall: (pluginId) => PluginStorage.uninstall({ pluginId }),
|
||||||
|
readPluginCode: (pluginId) => PluginStorage.readPluginCode({ pluginId }),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Returns true when a native plugin filesystem host is available. */
|
||||||
|
export const isPluginHostSupported = (): boolean => getPluginHost() !== null;
|
||||||
|
|
||||||
|
/** Returns the active plugin host for Electron or Android. */
|
||||||
|
export const getPluginHost = (): PluginHostAPI | null => {
|
||||||
|
if (window.electron?.plugins) {
|
||||||
|
return window.electron.plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android') {
|
||||||
|
return androidPluginHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paarrot",
|
"name": "paarrot",
|
||||||
"version": "4.11.90",
|
"version": "4.11.109",
|
||||||
"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