Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1121839d4 | ||
| 585eeecb1b | |||
| 72dbbe29b9 | |||
|
|
b6000c1db8 | ||
| 83b4cbbd8e | |||
|
|
8753ed2600 | ||
| cced6b8ab9 | |||
| 82effc9680 | |||
|
|
deefb40277 | ||
| b496c70190 | |||
|
|
5a4384ea1b | ||
| cc974414f8 | |||
| 3245f41073 | |||
|
|
8b51d78db6 | ||
| e27ec9d7ab | |||
|
|
5c18e38314 | ||
| 64e1de954a | |||
|
|
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 | |||
|
|
0a4a9aa636 | ||
| 746d0aa384 | |||
| c263b681d4 | |||
|
|
6f583d1227 | ||
| 6df60c8348 | |||
| 79d46857f9 | |||
|
|
c9ac92a0c4 | ||
| cb44bc92e4 | |||
| bc07974b4b | |||
|
|
abe44f6ad1 | ||
| c17e1c74ee | |||
|
|
255bfe2262 | ||
| 8eea325e21 | |||
| 3afb2ee80d | |||
|
|
245188c1a1 | ||
| 1d2057d75a | |||
| d8343d089b | |||
|
|
5cc087330c | ||
| 4163f22f71 | |||
|
|
ed0761bd20 | ||
| 6fdaa8dfd3 | |||
|
|
88d0a3f887 | ||
| 240d39593e | |||
| f06d11711b | |||
|
|
75e1fe6812 | ||
| f8c1e0641d | |||
| 6795e692d6 | |||
|
|
1c42974fe8 | ||
| 2cde79e87d |
@@ -1,7 +1,7 @@
|
||||
# Paarrot
|
||||
# Paarrot
|
||||
|
||||
Paarrot is a Matrix client focusing primarily on simple, elegant and secure interface. The desktop app is built with Electron and based on Cinny.
|
||||
|
||||
|
||||
## Download
|
||||
|
||||
Installers for Windows and Linux can be downloaded from [releases](http://synbox.ruv.wtf:8418/litruv/cinny-desktop/releases).
|
||||
|
||||
4
android/.gitignore
vendored
4
android/.gitignore
vendored
@@ -53,9 +53,7 @@ captures/
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
*.jks
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
|
||||
61
android/SIGNING_SETUP.md
Normal file
61
android/SIGNING_SETUP.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Android App Signing Setup
|
||||
|
||||
## Why This Matters
|
||||
Android requires apps to be signed with the same key for updates to work. Without proper signing, users cannot update your app - they must uninstall and reinstall, losing all data.
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Create a Keystore (if you don't have one)
|
||||
|
||||
Run this command in the `android/` directory:
|
||||
|
||||
```bash
|
||||
keytool -genkey -v -keystore paarrot-release.keystore -alias paarrot -keyalg RSA -keysize 2048 -validity 10000
|
||||
```
|
||||
|
||||
You'll be asked for:
|
||||
- Keystore password (remember this!)
|
||||
- Key password (remember this!)
|
||||
- Your name, organization, etc.
|
||||
|
||||
**IMPORTANT**: Back up this keystore file and remember the passwords! If you lose either, you cannot update your app.
|
||||
|
||||
### 2. Create keystore.properties
|
||||
|
||||
Copy the example file:
|
||||
|
||||
```bash
|
||||
cp keystore.properties.example keystore.properties
|
||||
```
|
||||
|
||||
Then edit `keystore.properties` and fill in:
|
||||
- `storeFile`: path to your keystore (e.g., `paarrot-release.keystore`)
|
||||
- `storePassword`: the keystore password you set
|
||||
- `keyAlias`: the alias (e.g., `paarrot`)
|
||||
- `keyPassword`: the key password you set
|
||||
|
||||
### 3. Verify
|
||||
|
||||
The file `keystore.properties` should be gitignored (check `.gitignore`).
|
||||
|
||||
### 4. Build
|
||||
|
||||
Now your release builds will be properly signed:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
|
||||
The signed APK will be in: `app/build/outputs/apk/release/`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Updates not installing"
|
||||
- You're using a different keystore than the original APK
|
||||
- Solution: Use the same keystore that was used for the first release
|
||||
|
||||
### "keystore.properties not found"
|
||||
- Normal for debug builds
|
||||
- Only needed for release builds
|
||||
- Create the file following step 2 above
|
||||
@@ -1,26 +1,56 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
// Load keystore properties
|
||||
def keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
def keystoreProperties = new Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.paarrot.app"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
storeFile file(keystoreProperties['storeFile'])
|
||||
storePassword keystoreProperties['storePassword']
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
keyPassword keystoreProperties['keyPassword']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.paarrot.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
versionCode 1783582912
|
||||
versionName "2026-07-09.074152.772"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// 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
|
||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
@@ -43,6 +73,7 @@ dependencies {
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
implementation 'org.unifiedpush.android:connector:3.3.2'
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
|
||||
@@ -22,6 +22,24 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/*" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
@@ -40,6 +58,14 @@
|
||||
android:exported="false"
|
||||
android:stopWithTask="false" />
|
||||
|
||||
<service
|
||||
android:name=".UnifiedPushService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:exported="true">
|
||||
|
||||
@@ -3,11 +3,9 @@ package com.paarrot.app
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
* Restarts [MatrixSyncService] after the device boots, using credentials
|
||||
* previously persisted by [SyncServicePlugin].
|
||||
* Re-registers UnifiedPush after boot when credentials still exist.
|
||||
*/
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
@@ -15,22 +13,9 @@ class BootReceiver : BroadcastReceiver() {
|
||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
|
||||
val prefs = context.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||
val homeserver = prefs.getString(MatrixSyncService.EXTRA_HOMESERVER, null) ?: return
|
||||
val token = prefs.getString(MatrixSyncService.EXTRA_TOKEN, null) ?: return
|
||||
val userId = prefs.getString(MatrixSyncService.EXTRA_USER_ID, null) ?: ""
|
||||
val deviceId = prefs.getString(MatrixSyncService.EXTRA_DEVICE_ID, null) ?: ""
|
||||
prefs.getString(MatrixSyncService.EXTRA_HOMESERVER, null) ?: return
|
||||
prefs.getString(MatrixSyncService.EXTRA_TOKEN, null) ?: return
|
||||
|
||||
val serviceIntent = Intent(context, MatrixSyncService::class.java).apply {
|
||||
putExtra(MatrixSyncService.EXTRA_HOMESERVER, homeserver)
|
||||
putExtra(MatrixSyncService.EXTRA_TOKEN, token)
|
||||
putExtra(MatrixSyncService.EXTRA_USER_ID, userId)
|
||||
putExtra(MatrixSyncService.EXTRA_DEVICE_ID, deviceId)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(serviceIntent)
|
||||
} else {
|
||||
context.startService(serviceIntent)
|
||||
}
|
||||
UnifiedPushManager.register(context, activity = null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ public class MainActivity extends BridgeActivity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(SyncServicePlugin.class);
|
||||
registerPlugin(ShareHandlerPlugin.class);
|
||||
registerPlugin(PluginStoragePlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,18 +5,20 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.media.AudioAttributes
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.core.app.NotificationCompat
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
@@ -25,12 +27,8 @@ import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* Persistent ForegroundService that maintains a Matrix /sync long-poll loop,
|
||||
* showing native notifications for new messages when the main app process is alive
|
||||
* but the UI is not in the foreground, and when the app has been swiped away.
|
||||
*
|
||||
* Start via [SyncServicePlugin]. Credentials are persisted in SharedPreferences
|
||||
* so [BootReceiver] can restart it after a device reboot.
|
||||
* ForegroundService that performs a short, one-shot Matrix /sync fetch after
|
||||
* a wake ping from UnifiedPush or a manual diagnostic trigger.
|
||||
*/
|
||||
class MatrixSyncService : Service() {
|
||||
|
||||
@@ -40,6 +38,14 @@ class MatrixSyncService : Service() {
|
||||
/** Event IDs already shown as notifications this session — prevents duplicates on restart. */
|
||||
private val shownEventIds = HashSet<String>(64)
|
||||
|
||||
/** In-memory cache of MXID → display name to avoid redundant API calls per service run. */
|
||||
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 onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@@ -57,13 +63,20 @@ class MatrixSyncService : Service() {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
val triggerReason = intent?.getStringExtra(EXTRA_TRIGGER_REASON) ?: MODE_ONE_SHOT
|
||||
startForeground(NOTIF_ID_STATUS, buildStatusNotification())
|
||||
|
||||
serviceScope.launch {
|
||||
runSyncLoop(homeserver, token, userId)
|
||||
try {
|
||||
runSingleSyncFetch(homeserver, token, userId)
|
||||
Log.d(TAG, "One-shot sync completed (reason=$triggerReason)")
|
||||
} finally {
|
||||
stopForegroundCompat()
|
||||
stopSelf(startId)
|
||||
}
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
@@ -71,44 +84,44 @@ class MatrixSyncService : Service() {
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
private suspend fun runSyncLoop(homeserver: String, token: String, userId: String) {
|
||||
private suspend fun runSingleSyncFetch(homeserver: String, token: String, userId: String) {
|
||||
val prefs = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
var since = prefs.getString(KEY_SINCE, null)
|
||||
var isFirstSync = since == null
|
||||
val since = prefs.getString(KEY_SINCE, null)
|
||||
val isFirstSync = since == null
|
||||
|
||||
while (serviceScope.isActive) {
|
||||
try {
|
||||
val url = buildSyncUrl(homeserver.trimEnd('/'), since)
|
||||
val (responseCode, body) = doHttpGet(url, token)
|
||||
try {
|
||||
val url = buildSyncUrl(homeserver.trimEnd('/'), since)
|
||||
val (responseCode, body) = doHttpGet(url, token)
|
||||
|
||||
when (responseCode) {
|
||||
200 -> {
|
||||
val json = JSONObject(body ?: "{}")
|
||||
val nextBatch = json.optString("next_batch").takeIf { it.isNotBlank() }
|
||||
when (responseCode) {
|
||||
200 -> {
|
||||
val json = JSONObject(body ?: "{}")
|
||||
val nextBatch = json.optString("next_batch").takeIf { it.isNotBlank() }
|
||||
|
||||
if (nextBatch != null) {
|
||||
prefs.edit().putString(KEY_SINCE, nextBatch).apply()
|
||||
if (nextBatch != null) {
|
||||
prefs.edit().putString(KEY_SINCE, nextBatch).apply()
|
||||
|
||||
if (!isFirstSync && !appInForeground) {
|
||||
processRoomEvents(json, userId)
|
||||
}
|
||||
isFirstSync = false
|
||||
since = nextBatch
|
||||
if (!isFirstSync && !appInForeground) {
|
||||
processRoomEvents(json, userId)
|
||||
}
|
||||
}
|
||||
401, 403 -> {
|
||||
Log.w(TAG, "Auth error $responseCode — stopping sync")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
else -> delay(BACKOFF_ERRS_MS)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Sync error: ${e.message}")
|
||||
delay(BACKOFF_ERRS_MS)
|
||||
401, 403 -> {
|
||||
Log.w(TAG, "Auth error $responseCode — clearing credentials")
|
||||
applicationContext
|
||||
.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.clear()
|
||||
.apply()
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Sync fetch failed with HTTP $responseCode")
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Sync error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +129,7 @@ class MatrixSyncService : Service() {
|
||||
val filter = """{"room":{"timeline":{"limit":10,"types":["m.room.message"]},"state":{"types":[]},"account_data":{"types":[]},"ephemeral":{"types":[]}},"account_data":{"types":[]},"presence":{"types":[]}}"""
|
||||
val encodedFilter = URLEncoder.encode(filter, "UTF-8")
|
||||
val sinceParam = if (since != null) "&since=${URLEncoder.encode(since, "UTF-8")}" else ""
|
||||
return "$base/_matrix/client/v3/sync?timeout=30000&filter=$encodedFilter$sinceParam"
|
||||
return "$base/_matrix/client/v3/sync?timeout=12000&filter=$encodedFilter$sinceParam"
|
||||
}
|
||||
|
||||
private suspend fun doHttpGet(urlString: String, token: String): Pair<Int, String?> =
|
||||
@@ -139,6 +152,9 @@ class MatrixSyncService : Service() {
|
||||
}
|
||||
|
||||
private fun processRoomEvents(sync: JSONObject, myUserId: String) {
|
||||
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||
val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return
|
||||
val token = prefs.getString(EXTRA_TOKEN, null) ?: return
|
||||
val joinedRooms = sync.optJSONObject("rooms")?.optJSONObject("join") ?: return
|
||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
ensureMessageChannel(nm)
|
||||
@@ -158,16 +174,119 @@ class MatrixSyncService : Service() {
|
||||
if (eventId.isNotBlank() && !shownEventIds.add(eventId)) continue
|
||||
|
||||
val content = event.optJSONObject("content") ?: continue
|
||||
val body = content.optString("body").takeIf { it.isNotBlank() } ?: continue
|
||||
val sender = event.optString("sender")
|
||||
val senderName = sender.substringAfter("@").substringBefore(":")
|
||||
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
|
||||
}
|
||||
|
||||
showMessageNotification(nm, senderName, body)
|
||||
val sender = event.optString("sender")
|
||||
val profile = resolveProfile(sender, homeserver, token)
|
||||
|
||||
// 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 showMessageNotification(nm: NotificationManager, sender: String, body: String) {
|
||||
private fun resolveProfile(mxid: String, homeserver: String, token: String): UserProfile {
|
||||
val cachedName = displayNameCache[mxid]
|
||||
if (cachedName != null) return UserProfile(cachedName, avatarCache[mxid])
|
||||
|
||||
val fallback = mxid.substringAfter("@").substringBefore(":")
|
||||
return try {
|
||||
val encodedId = URLEncoder.encode(mxid, "UTF-8")
|
||||
val url = "$homeserver/_matrix/client/v3/profile/$encodedId"
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
val (displayName, avatarMxc) = try {
|
||||
conn.requestMethod = "GET"
|
||||
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||
conn.setRequestProperty("Accept", "application/json")
|
||||
conn.connectTimeout = 4_000
|
||||
conn.readTimeout = 4_000
|
||||
if (conn.responseCode == 200) {
|
||||
val body = conn.inputStream.bufferedReader().readText()
|
||||
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 {
|
||||
Pair(fallback, null)
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
displayNameCache[mxid] = displayName
|
||||
val avatar = avatarMxc
|
||||
?.let { mxcToThumbnailUrl(it, homeserver, size = 96) }
|
||||
?.let { downloadBitmap(it, token) }
|
||||
avatarCache[mxid] = avatar
|
||||
UserProfile(displayName, avatar)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not resolve profile for $mxid: ${e.message}")
|
||||
displayNameCache[mxid] = fallback
|
||||
avatarCache[mxid] = null
|
||||
UserProfile(fallback, null)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
?: Intent(this, MainActivity::class.java)
|
||||
val pi = PendingIntent.getActivity(
|
||||
@@ -175,16 +294,25 @@ class MatrixSyncService : Service() {
|
||||
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)
|
||||
.setContentTitle(sender)
|
||||
.setContentText(body)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pi)
|
||||
.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 {
|
||||
@@ -203,18 +331,37 @@ class MatrixSyncService : Service() {
|
||||
return NotificationCompat.Builder(this, CHANNEL_STATUS)
|
||||
.setSmallIcon(R.drawable.ic_stat_paarrot)
|
||||
.setContentTitle("Paarrot")
|
||||
.setContentText("Connected")
|
||||
.setContentText("Checking for new messages")
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun stopForegroundCompat() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureMessageChannel(nm: NotificationManager) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val soundUri = Uri.parse("android.resource://$packageName/${R.raw.paarrot_notification}")
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_MESSAGES, "Messages",
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -225,15 +372,52 @@ class MatrixSyncService : Service() {
|
||||
const val EXTRA_TOKEN = "access_token"
|
||||
const val EXTRA_USER_ID = "user_id"
|
||||
const val EXTRA_DEVICE_ID = "device_id"
|
||||
const val EXTRA_TRIGGER_REASON = "trigger_reason"
|
||||
const val PREFS = "matrix_sync_prefs"
|
||||
const val KEY_SINCE = "since_token"
|
||||
private const val KEY_LAST_WAKE_MS = "last_wake_ms"
|
||||
private const val NOTIF_ID_STATUS = 1001
|
||||
private const val CHANNEL_STATUS = "sync_status"
|
||||
private const val CHANNEL_MESSAGES = "messages"
|
||||
private const val BACKOFF_ERRS_MS = 10_000L
|
||||
private const val CHANNEL_MESSAGES = "messages_paarrot"
|
||||
private const val MIN_WAKE_INTERVAL_MS = 7_500L
|
||||
const val MODE_ONE_SHOT = "one_shot"
|
||||
|
||||
/** Set by [SyncServicePlugin] — true when the Capacitor WebView UI is visible. */
|
||||
@Volatile
|
||||
var appInForeground = false
|
||||
|
||||
/**
|
||||
* Starts a one-shot sync fetch if credentials are available and the call is not rate-limited.
|
||||
*/
|
||||
fun requestSyncFetch(context: Context, reason: String) {
|
||||
val credsPrefs = context.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||
val homeserver = credsPrefs.getString(EXTRA_HOMESERVER, null) ?: return
|
||||
val token = credsPrefs.getString(EXTRA_TOKEN, null) ?: return
|
||||
val userId = credsPrefs.getString(EXTRA_USER_ID, null) ?: ""
|
||||
val deviceId = credsPrefs.getString(EXTRA_DEVICE_ID, null) ?: ""
|
||||
|
||||
val syncPrefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val now = System.currentTimeMillis()
|
||||
val lastWake = syncPrefs.getLong(KEY_LAST_WAKE_MS, 0L)
|
||||
if (now - lastWake < MIN_WAKE_INTERVAL_MS) {
|
||||
Log.d(TAG, "Skipping wake: rate-limited ($reason)")
|
||||
return
|
||||
}
|
||||
syncPrefs.edit().putLong(KEY_LAST_WAKE_MS, now).apply()
|
||||
|
||||
val intent = Intent(context, MatrixSyncService::class.java).apply {
|
||||
putExtra(EXTRA_HOMESERVER, homeserver)
|
||||
putExtra(EXTRA_TOKEN, token)
|
||||
putExtra(EXTRA_USER_ID, userId)
|
||||
putExtra(EXTRA_DEVICE_ID, deviceId)
|
||||
putExtra(EXTRA_TRIGGER_REASON, reason)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
196
android/app/src/main/java/com/paarrot/app/ShareHandlerPlugin.kt
Normal file
196
android/app/src/main/java/com/paarrot/app/ShareHandlerPlugin.kt
Normal file
@@ -0,0 +1,196 @@
|
||||
package com.paarrot.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
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 java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
/** Payload for a single shared file. */
|
||||
data class SharedFilePayload(
|
||||
val path: String,
|
||||
val name: String,
|
||||
val mimeType: String,
|
||||
val size: Long,
|
||||
)
|
||||
|
||||
/** Full payload delivered to JS when a share intent is received. */
|
||||
data class SharePayload(
|
||||
val text: String?,
|
||||
val subject: String?,
|
||||
val files: List<SharedFilePayload>,
|
||||
val receivedAt: Long,
|
||||
) {
|
||||
/** Serialize to a [JSObject] for the Capacitor bridge. */
|
||||
fun toJsObject(): JSObject {
|
||||
val obj = JSObject()
|
||||
obj.put("text", text)
|
||||
obj.put("subject", subject)
|
||||
|
||||
val filesArray = JSArray()
|
||||
for (f in files) {
|
||||
val fileObj = JSObject()
|
||||
fileObj.put("path", f.path)
|
||||
fileObj.put("name", f.name)
|
||||
fileObj.put("mimeType", f.mimeType)
|
||||
fileObj.put("size", f.size)
|
||||
filesArray.put(fileObj)
|
||||
}
|
||||
obj.put("files", filesArray)
|
||||
obj.put("receivedAt", receivedAt)
|
||||
return obj
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton that bridges between Android activity lifecycle and the Capacitor plugin. */
|
||||
object ShareIntentStore {
|
||||
var plugin: ShareHandlerPlugin? = null
|
||||
var pendingShare: SharePayload? = null
|
||||
|
||||
/** Parse [intent] and, if it is a share action, store the payload and notify JS. */
|
||||
fun handleIntent(context: Context, intent: Intent?) {
|
||||
if (intent == null) return
|
||||
val action = intent.action ?: return
|
||||
|
||||
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
|
||||
|
||||
val receivedAt = System.currentTimeMillis()
|
||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
|
||||
|
||||
val uris = mutableListOf<Uri>()
|
||||
if (action == Intent.ACTION_SEND_MULTIPLE) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val list = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
|
||||
}
|
||||
list?.let { uris.addAll(it) }
|
||||
} else {
|
||||
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM)
|
||||
}
|
||||
uri?.let { uris.add(it) }
|
||||
}
|
||||
|
||||
val copiedFiles = uris.mapNotNull { copySharedUri(context, it, receivedAt) }
|
||||
|
||||
val payload = SharePayload(
|
||||
text = text,
|
||||
subject = subject,
|
||||
files = copiedFiles,
|
||||
receivedAt = receivedAt,
|
||||
)
|
||||
|
||||
pendingShare = payload
|
||||
plugin?.emitShare(payload)
|
||||
}
|
||||
|
||||
private fun copySharedUri(context: Context, uri: Uri, receivedAt: Long): SharedFilePayload? {
|
||||
return try {
|
||||
val resolver = context.contentResolver
|
||||
val mimeType = resolver.getType(uri) ?: "application/octet-stream"
|
||||
|
||||
val originalName = resolver.query(uri, arrayOf("_display_name"), null, null, null)
|
||||
?.use { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||
} ?: uri.lastPathSegment ?: "shared_file"
|
||||
|
||||
val safeName = sanitizeFileName(originalName)
|
||||
val destDir = File(context.cacheDir, "shared-intents/$receivedAt")
|
||||
destDir.mkdirs()
|
||||
|
||||
var destFile = File(destDir, safeName)
|
||||
var counter = 1
|
||||
while (destFile.exists()) {
|
||||
val dot = safeName.lastIndexOf('.')
|
||||
val base = if (dot >= 0) safeName.substring(0, dot) else safeName
|
||||
val ext = if (dot >= 0) safeName.substring(dot) else ""
|
||||
destFile = File(destDir, "${base}_$counter$ext")
|
||||
counter++
|
||||
}
|
||||
|
||||
resolver.openInputStream(uri)?.use { input ->
|
||||
FileOutputStream(destFile).use { output -> input.copyTo(output) }
|
||||
}
|
||||
|
||||
SharedFilePayload(
|
||||
path = destFile.absolutePath,
|
||||
name = destFile.name,
|
||||
mimeType = mimeType,
|
||||
size = destFile.length(),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ShareHandlerPlugin", "Failed to copy shared URI: $uri", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitizeFileName(name: String): String {
|
||||
return name.replace(Regex("[^a-zA-Z0-9._\\-]"), "_").take(128).ifEmpty { "file" }
|
||||
}
|
||||
|
||||
/** Remove cached files from a previous share and clear the stored payload. */
|
||||
fun clearIntent(receivedAt: Long?) {
|
||||
if (receivedAt != null) {
|
||||
val dir = plugin?.context?.let { File(it.cacheDir, "shared-intents/$receivedAt") }
|
||||
dir?.deleteRecursively()
|
||||
}
|
||||
pendingShare = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Capacitor plugin that exposes Android share intents to JavaScript. */
|
||||
@CapacitorPlugin(name = "AndroidShareHandler")
|
||||
class ShareHandlerPlugin : Plugin() {
|
||||
|
||||
override fun load() {
|
||||
ShareIntentStore.plugin = this
|
||||
ShareIntentStore.handleIntent(context, activity.intent)
|
||||
}
|
||||
|
||||
override fun handleOnNewIntent(intent: Intent?) {
|
||||
super.handleOnNewIntent(intent)
|
||||
ShareIntentStore.handleIntent(context, intent)
|
||||
}
|
||||
|
||||
/** Fire the `shareReceived` event toward the JS layer. */
|
||||
fun emitShare(payload: SharePayload) {
|
||||
notifyListeners("shareReceived", payload.toJsObject())
|
||||
}
|
||||
|
||||
/** Returns the pending share payload (if any) as a JS object. */
|
||||
@PluginMethod
|
||||
fun getPendingShare(call: PluginCall) {
|
||||
val pending = ShareIntentStore.pendingShare
|
||||
if (pending == null) {
|
||||
val result = JSObject()
|
||||
result.put("share", JSObject.NULL)
|
||||
call.resolve(result)
|
||||
} else {
|
||||
val result = JSObject()
|
||||
result.put("share", pending.toJsObject())
|
||||
call.resolve(result)
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears the pending share and removes any copied temp files. */
|
||||
@PluginMethod
|
||||
fun clearPendingShare(call: PluginCall) {
|
||||
ShareIntentStore.clearIntent(ShareIntentStore.pendingShare?.receivedAt)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package com.paarrot.app
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
@@ -13,19 +12,30 @@ import com.getcapacitor.annotation.CapacitorPlugin
|
||||
* Capacitor plugin that controls [MatrixSyncService] from JS.
|
||||
*
|
||||
* JS API:
|
||||
* - `start({ homeserverUrl, accessToken, userId, deviceId })` — start/update sync service
|
||||
* - `stop()` — stop sync service and clear persisted credentials
|
||||
* - `start({ homeserverUrl, accessToken, userId, deviceId })` — persist credentials and register UnifiedPush
|
||||
* - `triggerPing({ reason })` — force a one-shot fetch (for testing/manual wake)
|
||||
* - `stop()` — clear persisted credentials and unregister UnifiedPush
|
||||
* - `setAppForeground({ foreground })` — tell the service whether the app UI is visible
|
||||
* - `getStatus()` — returns `{ running: boolean }`
|
||||
* - `getStatus()` — returns current fetch and UnifiedPush state
|
||||
*/
|
||||
@CapacitorPlugin(name = "MatrixBackgroundSync")
|
||||
class SyncServicePlugin : Plugin() {
|
||||
|
||||
/**
|
||||
* Starts the [MatrixSyncService] with the provided Matrix credentials.
|
||||
* Persists credentials in SharedPreferences so [BootReceiver] can restart
|
||||
* the service after a device reboot.
|
||||
*/
|
||||
fun emitUnifiedPushEvent(eventName: String, payload: JSObject) {
|
||||
notifyListeners(eventName, payload, true)
|
||||
}
|
||||
|
||||
override fun load() {
|
||||
super.load()
|
||||
UnifiedPushManager.setPlugin(this)
|
||||
}
|
||||
|
||||
override fun handleOnDestroy() {
|
||||
UnifiedPushManager.clearPlugin(this)
|
||||
super.handleOnDestroy()
|
||||
}
|
||||
|
||||
/** Persists Matrix credentials and starts UnifiedPush registration. */
|
||||
@PluginMethod
|
||||
fun start(call: PluginCall) {
|
||||
val homeserver = call.getString("homeserverUrl")
|
||||
@@ -42,19 +52,16 @@ class SyncServicePlugin : Plugin() {
|
||||
.putString(MatrixSyncService.EXTRA_DEVICE_ID, deviceId)
|
||||
.apply()
|
||||
|
||||
val intent = Intent(context, MatrixSyncService::class.java).apply {
|
||||
putExtra(MatrixSyncService.EXTRA_HOMESERVER, homeserver)
|
||||
putExtra(MatrixSyncService.EXTRA_TOKEN, token)
|
||||
putExtra(MatrixSyncService.EXTRA_USER_ID, userId)
|
||||
putExtra(MatrixSyncService.EXTRA_DEVICE_ID, deviceId)
|
||||
}
|
||||
UnifiedPushManager.register(context, activity)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
/** Manually triggers a one-shot sync fetch (mainly for diagnostics/testing). */
|
||||
@PluginMethod
|
||||
fun triggerPing(call: PluginCall) {
|
||||
val reason = call.getString("reason") ?: "manual_plugin_ping"
|
||||
MatrixSyncService.requestSyncFetch(context, reason)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@@ -63,6 +70,7 @@ class SyncServicePlugin : Plugin() {
|
||||
fun stop(call: PluginCall) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||
context.stopService(Intent(context, MatrixSyncService::class.java))
|
||||
UnifiedPushManager.unregister(context)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@@ -85,7 +93,8 @@ class SyncServicePlugin : Plugin() {
|
||||
@Suppress("DEPRECATION")
|
||||
val running = am.getRunningServices(Int.MAX_VALUE)
|
||||
.any { it.service.className == MatrixSyncService::class.java.name }
|
||||
call.resolve(JSObject().apply { put("running", running) })
|
||||
val result = UnifiedPushManager.getStatus(context).apply { put("running", running) }
|
||||
call.resolve(result)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
149
android/app/src/main/java/com/paarrot/app/UnifiedPushManager.kt
Normal file
149
android/app/src/main/java/com/paarrot/app/UnifiedPushManager.kt
Normal file
@@ -0,0 +1,149 @@
|
||||
package com.paarrot.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.getcapacitor.JSObject
|
||||
import org.unifiedpush.android.connector.FailedReason
|
||||
import org.unifiedpush.android.connector.INSTANCE_DEFAULT
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
|
||||
/** Coordinates UnifiedPush registration state and bridges events back to JS. */
|
||||
object UnifiedPushManager {
|
||||
private const val TAG = "UnifiedPushManager"
|
||||
private const val PREFS = "unifiedpush_prefs"
|
||||
private const val KEY_ENDPOINT = "endpoint"
|
||||
private const val KEY_INSTANCE = "instance"
|
||||
private const val EVENT_NEW_ENDPOINT = "unifiedPushNewEndpoint"
|
||||
private const val EVENT_UNREGISTERED = "unifiedPushUnregistered"
|
||||
private const val EVENT_REGISTRATION_FAILED = "unifiedPushRegistrationFailed"
|
||||
private const val DEFAULT_MESSAGE = "Paarrot notifications"
|
||||
|
||||
@Volatile
|
||||
private var plugin: SyncServicePlugin? = null
|
||||
|
||||
fun setPlugin(plugin: SyncServicePlugin) {
|
||||
this.plugin = plugin
|
||||
}
|
||||
|
||||
fun clearPlugin(plugin: SyncServicePlugin) {
|
||||
if (this.plugin === plugin) {
|
||||
this.plugin = null
|
||||
}
|
||||
}
|
||||
|
||||
fun register(context: Context, activity: Activity?) {
|
||||
val savedDistributor = runCatching { UnifiedPush.getSavedDistributor(context) }.getOrNull().orEmpty()
|
||||
if (savedDistributor.isNotBlank()) {
|
||||
requestRegistration(context)
|
||||
return
|
||||
}
|
||||
|
||||
if (activity == null) {
|
||||
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
|
||||
return
|
||||
}
|
||||
|
||||
UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { success ->
|
||||
if (success) {
|
||||
requestRegistration(context)
|
||||
} else {
|
||||
dispatchRegistrationFailed(FailedReason.ACTION_REQUIRED.name, INSTANCE_DEFAULT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unregister(context: Context) {
|
||||
runCatching {
|
||||
UnifiedPush.unregister(context, INSTANCE_DEFAULT)
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Failed to unregister UnifiedPush: ${it.message}")
|
||||
}
|
||||
val previousEndpoint = getEndpoint(context)
|
||||
clearEndpoint(context)
|
||||
dispatchUnregistered(previousEndpoint, INSTANCE_DEFAULT)
|
||||
}
|
||||
|
||||
fun getStatus(context: Context): JSObject {
|
||||
val status = JSObject()
|
||||
status.put("endpoint", getEndpoint(context) ?: "")
|
||||
status.put("instance", getInstance(context) ?: INSTANCE_DEFAULT)
|
||||
status.put("registered", !getEndpoint(context).isNullOrBlank())
|
||||
status.put("distributor", runCatching { UnifiedPush.getSavedDistributor(context) }.getOrDefault(""))
|
||||
status.put(
|
||||
"distributors",
|
||||
runCatching { UnifiedPush.getDistributors(context) }.getOrDefault(emptyList<String>())
|
||||
)
|
||||
return status
|
||||
}
|
||||
|
||||
fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
|
||||
val previousEndpoint = getEndpoint(context)
|
||||
persistEndpoint(context, endpoint, instance)
|
||||
val payload = JSObject().apply {
|
||||
put("endpoint", endpoint)
|
||||
put("instance", instance)
|
||||
put("previousEndpoint", previousEndpoint ?: "")
|
||||
}
|
||||
dispatch(EVENT_NEW_ENDPOINT, payload)
|
||||
}
|
||||
|
||||
fun onRegistrationFailed(reason: String, instance: String) {
|
||||
dispatchRegistrationFailed(reason, instance)
|
||||
}
|
||||
|
||||
fun onUnregistered(context: Context, instance: String) {
|
||||
val previousEndpoint = getEndpoint(context)
|
||||
clearEndpoint(context)
|
||||
dispatchUnregistered(previousEndpoint, instance)
|
||||
}
|
||||
|
||||
private fun requestRegistration(context: Context) {
|
||||
runCatching {
|
||||
UnifiedPush.register(context, INSTANCE_DEFAULT, DEFAULT_MESSAGE, null)
|
||||
}.onFailure {
|
||||
Log.w(TAG, "UnifiedPush register failed: ${it.message}")
|
||||
dispatchRegistrationFailed(FailedReason.INTERNAL_ERROR.name, INSTANCE_DEFAULT)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatchUnregistered(previousEndpoint: String?, instance: String) {
|
||||
val payload = JSObject().apply {
|
||||
put("previousEndpoint", previousEndpoint ?: "")
|
||||
put("instance", instance)
|
||||
}
|
||||
dispatch(EVENT_UNREGISTERED, payload)
|
||||
}
|
||||
|
||||
private fun dispatchRegistrationFailed(reason: String, instance: String) {
|
||||
val payload = JSObject().apply {
|
||||
put("reason", reason)
|
||||
put("instance", instance)
|
||||
}
|
||||
dispatch(EVENT_REGISTRATION_FAILED, payload)
|
||||
}
|
||||
|
||||
private fun dispatch(eventName: String, payload: JSObject) {
|
||||
plugin?.emitUnifiedPushEvent(eventName, payload)
|
||||
}
|
||||
|
||||
private fun persistEndpoint(context: Context, endpoint: String, instance: String) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString(KEY_ENDPOINT, endpoint)
|
||||
.putString(KEY_INSTANCE, instance)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun clearEndpoint(context: Context) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.remove(KEY_ENDPOINT)
|
||||
.remove(KEY_INSTANCE)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun getEndpoint(context: Context): String? =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_ENDPOINT, null)
|
||||
|
||||
private fun getInstance(context: Context): String? =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_INSTANCE, null)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.paarrot.app
|
||||
|
||||
import android.util.Log
|
||||
import org.unifiedpush.android.connector.FailedReason
|
||||
import org.unifiedpush.android.connector.PushService
|
||||
import org.unifiedpush.android.connector.data.PushEndpoint
|
||||
import org.unifiedpush.android.connector.data.PushMessage
|
||||
|
||||
/** Receives UnifiedPush events and turns push messages into Matrix fetch wakes. */
|
||||
class UnifiedPushService : PushService() {
|
||||
|
||||
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
|
||||
Log.i(TAG, "UnifiedPush endpoint updated for instance=$instance")
|
||||
UnifiedPushManager.onNewEndpoint(applicationContext, endpoint.url, instance)
|
||||
}
|
||||
|
||||
override fun onMessage(message: PushMessage, instance: String) {
|
||||
Log.d(TAG, "UnifiedPush wake ping received for instance=$instance, bytes=${message.content.size}")
|
||||
MatrixSyncService.requestSyncFetch(applicationContext, "unifiedpush_message")
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
|
||||
Log.w(TAG, "UnifiedPush registration failed: $reason")
|
||||
UnifiedPushManager.onRegistrationFailed(reason.name, instance)
|
||||
}
|
||||
|
||||
override fun onUnregistered(instance: String) {
|
||||
Log.i(TAG, "UnifiedPush unregistered for instance=$instance")
|
||||
UnifiedPushManager.onUnregistered(applicationContext, instance)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "UnifiedPushService"
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M2,9.1C3.8,5.7 7.2,4 11.6,4c4.4,0 7.8,1.7 10.4,5.1l-2,1.6c-1.7,-1.5 -3.8,-2.4 -6.4,-2.7c-0.2,1.6 -1.6,2.9 -3.3,2.9c-1.7,0 -3.1,-1.2 -3.3,-2.8C5.1,8.5 3.5,9.1 2,10z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M8.7,11.5c-2.8,0 -5.1,2.3 -5.1,5.1c0,3 2.4,5.4 5.5,5.4c2.9,0 5.4,-1.3 7,-3.7c-0.5,0.1 -0.9,0.2 -1.4,0.2c-3.5,0 -6.4,-2.8 -6.4,-6.3c0,-0.2 0,-0.4 0,-0.7z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M14.7,12.3c3,0.1 5.3,1.6 7.3,4.2c-1.8,1.5 -4.1,2.4 -6.9,2.8c1,-1 1.6,-2.3 1.6,-3.8c0,-1.2 -0.3,-2.3 -0.9,-3.2z" />
|
||||
android:viewportWidth="18"
|
||||
android:viewportHeight="18">
|
||||
<!--
|
||||
Converted from public/res/svg/notification.svg.
|
||||
Transforms flattened: outer(1.05922,0,0,1.05922,-0.5628,-0.4805)
|
||||
x inner(1.6762,0,0,1.6762,-4.5071,-10.7901)
|
||||
= scale 1.7754, translate(-5.337,-11.908)
|
||||
-->
|
||||
<group
|
||||
android:scaleX="1.7754"
|
||||
android:scaleY="1.7754"
|
||||
android:translateX="-5.337"
|
||||
android:translateY="-11.908">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M4.624,14.382C4.624,14.382 1.358,10.79 5.108,8.336C5.501,8.316 7.14,8.899 7.18,10.368C7.22,11.836 5.641,11.172 5.108,11.856C4.575,12.54 4.454,14.121 4.624,14.382Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M5.08,13.547C5.08,13.547 7.28,14.842 7.291,10.74C7.592,10.971 8.588,12.107 7.713,13.777C6.838,15.446 5.392,14.322 5.08,13.547Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M5.921,14.763C5.584,14.449 6.374,15.403 6.704,16.642C6.873,16.232 8.282,17.562 11.166,14.247C12.324,12.581 13.244,11.279 11.249,8.874C9.254,6.469 6.507,6.615 5.469,7.581C4.431,8.546 5.35,7.566 6.653,8.667C7.52,9.399 7.444,10.431 7.444,10.431C7.774,10.551 9.12,12.332 7.935,14.099C7.205,15.187 6.259,15.076 5.921,14.763Z" />
|
||||
</group>
|
||||
</vector>
|
||||
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.
@@ -2,7 +2,7 @@
|
||||
|
||||
buildscript {
|
||||
ext {
|
||||
kotlin_version = '1.9.25'
|
||||
kotlin_version = '2.3.0'
|
||||
}
|
||||
|
||||
repositories {
|
||||
|
||||
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
|
||||
14
android/keystore.properties.example
Normal file
14
android/keystore.properties.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Copy this file to keystore.properties and fill in your actual values
|
||||
# NEVER commit keystore.properties to git!
|
||||
|
||||
# Path to your keystore file (relative to android/ directory)
|
||||
storeFile=paarrot-release.keystore
|
||||
|
||||
# Keystore password
|
||||
storePassword=your_keystore_password_here
|
||||
|
||||
# Key alias
|
||||
keyAlias=paarrot
|
||||
|
||||
# Key password
|
||||
keyPassword=your_key_password_here
|
||||
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: 9eb5e4fa32...9c7f71896c
BIN
full_logcat.txt
Normal file
BIN
full_logcat.txt
Normal file
Binary file not shown.
32
overlay/src/app/components/AnimatedOutlet.tsx
Normal file
32
overlay/src/app/components/AnimatedOutlet.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { useCompactNav } from '../hooks/useCompactNav';
|
||||
import { MobileSwipeGestureHost } from './mobile/MobileSwipeGestureHost';
|
||||
|
||||
/**
|
||||
* Wrapper for Outlet that adds route-based animation
|
||||
* Forces remount on route change by using location as key
|
||||
*/
|
||||
export function AnimatedOutlet() {
|
||||
const location = useLocation();
|
||||
const compact = useCompactNav();
|
||||
|
||||
return (
|
||||
<MobileSwipeGestureHost>
|
||||
<div
|
||||
key={location.pathname}
|
||||
{...(!compact && { 'data-route-transition': 'true' })}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</MobileSwipeGestureHost>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
);
|
||||
357
overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx
Normal file
357
overlay/src/app/components/mobile/MobileSwipeGestureHost.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||
import { useCompactNav } from '../../hooks/useCompactNav';
|
||||
import { useBackRoute } from '../../hooks/useBackRoute';
|
||||
import { startReplyToEvent } from '../../features/room/replyToMessage';
|
||||
import { mobileSwipeReplyBridgeRef } from './mobileSwipeReplyBridge';
|
||||
import * as css from './mobile-gestures.css';
|
||||
|
||||
const EDGE_WIDTH = 28;
|
||||
const BACK_COMMIT_RATIO = 0.25;
|
||||
const BACK_COMMIT_MIN = 56;
|
||||
const REPLY_THRESHOLD = 48;
|
||||
const REPLY_MAX = 96;
|
||||
const AXIS_LOCK_PX = 10;
|
||||
const AXIS_DOMINANCE = 1.2;
|
||||
const MAX_START_Y_RATIO = 0.88;
|
||||
|
||||
type GesturePhase = 'idle' | 'pending' | 'back' | 'reply';
|
||||
|
||||
type GestureState = {
|
||||
phase: GesturePhase;
|
||||
touchId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
messageEl: HTMLElement | null;
|
||||
messageId: string | null;
|
||||
edgeBack: boolean;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
const IDLE_STATE: GestureState = {
|
||||
phase: 'idle',
|
||||
touchId: -1,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
messageEl: null,
|
||||
messageId: null,
|
||||
edgeBack: false,
|
||||
offset: 0,
|
||||
};
|
||||
|
||||
type MobileSwipeGestureHostProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const shouldIgnoreBackTarget = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof Element)) return true;
|
||||
return Boolean(
|
||||
target.closest(
|
||||
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-back="true"]'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const shouldIgnoreReplyTarget = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof Element)) return true;
|
||||
return Boolean(
|
||||
target.closest(
|
||||
'input, textarea, [contenteditable="true"], [data-allow-text-selection="true"], [data-carousel-scroller], [data-disable-swipe-reply="true"]'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const findMessageElement = (target: EventTarget | null): HTMLElement | null => {
|
||||
if (!(target instanceof Element)) return null;
|
||||
return target.closest('[data-message-id]') as HTMLElement | null;
|
||||
};
|
||||
|
||||
const applyBackOffset = (dx: number, width: number): number => {
|
||||
const positive = Math.max(0, dx);
|
||||
if (positive <= width) return positive;
|
||||
return width + (positive - width) * 0.22;
|
||||
};
|
||||
|
||||
const setElementTransform = (el: HTMLElement | null, px: number) => {
|
||||
if (!el) return;
|
||||
el.style.transition = 'none';
|
||||
el.style.transform = `translateX(${px}px)`;
|
||||
};
|
||||
|
||||
const resetElementTransform = (el: HTMLElement | null) => {
|
||||
if (!el) return;
|
||||
el.style.transition = 'none';
|
||||
el.style.transform = '';
|
||||
};
|
||||
|
||||
export function MobileSwipeGestureHost({ children }: MobileSwipeGestureHostProps) {
|
||||
const compact = useCompactNav();
|
||||
const { canGoBack, goBack } = useBackRoute();
|
||||
const backEnabled = compact && canGoBack;
|
||||
const replyEnabled = compact;
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const gestureRef = useRef<GestureState>({ ...IDLE_STATE });
|
||||
const scrollLockRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const unlockScroll = useCallback(() => {
|
||||
if (scrollLockRef.current) {
|
||||
scrollLockRef.current.style.overflow = '';
|
||||
scrollLockRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const lockScroll = useCallback((anchor: HTMLElement) => {
|
||||
unlockScroll();
|
||||
let el: HTMLElement | null = anchor;
|
||||
while (el && el !== document.body) {
|
||||
const style = window.getComputedStyle(el);
|
||||
if (
|
||||
(style.overflowY === 'auto' || style.overflowY === 'scroll') &&
|
||||
el.scrollHeight > el.clientHeight + 1
|
||||
) {
|
||||
scrollLockRef.current = el;
|
||||
el.style.overflow = 'hidden';
|
||||
return;
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
}, [unlockScroll]);
|
||||
|
||||
const resetBackTransform = useCallback(() => {
|
||||
resetElementTransform(contentRef.current);
|
||||
document.documentElement.classList.remove('mobile-gesture-lock');
|
||||
}, []);
|
||||
|
||||
const resetReplyTransform = useCallback((messageEl?: HTMLElement | null) => {
|
||||
const bridge = mobileSwipeReplyBridgeRef.current;
|
||||
const el = messageEl ?? gestureRef.current.messageEl;
|
||||
resetElementTransform(el);
|
||||
bridge?.setIndicator(null, false);
|
||||
document.documentElement.classList.remove('mobile-gesture-lock');
|
||||
}, []);
|
||||
|
||||
const resetGesture = useCallback(
|
||||
(messageEl?: HTMLElement | null) => {
|
||||
resetBackTransform();
|
||||
resetReplyTransform(messageEl);
|
||||
unlockScroll();
|
||||
gestureRef.current = { ...IDLE_STATE };
|
||||
},
|
||||
[resetBackTransform, resetReplyTransform, unlockScroll]
|
||||
);
|
||||
|
||||
const updateReplyIndicator = useCallback((messageEl: HTMLElement, offset: number) => {
|
||||
const bridge = mobileSwipeReplyBridgeRef.current;
|
||||
if (!bridge?.layerEl) return;
|
||||
const messageRect = messageEl.getBoundingClientRect();
|
||||
const layerRect = bridge.layerEl.getBoundingClientRect();
|
||||
const top = messageRect.top - layerRect.top + messageRect.height / 2 - 18;
|
||||
bridge.setIndicator(top, Math.abs(offset) >= REPLY_THRESHOLD * 0.6);
|
||||
}, []);
|
||||
|
||||
const lockGesture = useCallback(
|
||||
(phase: 'back' | 'reply', scrollAnchor?: HTMLElement | null) => {
|
||||
gestureRef.current.phase = phase;
|
||||
document.documentElement.classList.add('mobile-gesture-lock');
|
||||
if (scrollAnchor) {
|
||||
lockScroll(scrollAnchor);
|
||||
}
|
||||
},
|
||||
[lockScroll]
|
||||
);
|
||||
|
||||
const finishGesture = useCallback(() => {
|
||||
const gesture = gestureRef.current;
|
||||
if (gesture.phase === 'idle' || gesture.phase === 'pending') {
|
||||
resetGesture();
|
||||
return;
|
||||
}
|
||||
|
||||
if (gesture.phase === 'back') {
|
||||
const width = rootRef.current?.clientWidth ?? window.innerWidth;
|
||||
const shouldCommit =
|
||||
gesture.offset >= Math.max(width * BACK_COMMIT_RATIO, BACK_COMMIT_MIN);
|
||||
|
||||
resetGesture();
|
||||
if (shouldCommit) {
|
||||
goBack();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (gesture.phase === 'reply' && gesture.messageEl && gesture.messageId) {
|
||||
const bridge = mobileSwipeReplyBridgeRef.current;
|
||||
const shouldReply = Math.abs(gesture.offset) >= REPLY_THRESHOLD;
|
||||
const { messageEl, messageId } = gesture;
|
||||
|
||||
resetGesture(messageEl);
|
||||
|
||||
if (shouldReply && bridge) {
|
||||
startReplyToEvent(bridge.room, messageId, bridge.setReplyDraft, bridge.editor);
|
||||
}
|
||||
}
|
||||
}, [goBack, resetGesture]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!compact) return;
|
||||
|
||||
const getActiveTouch = (evt: TouchEvent, touchId: number) =>
|
||||
Array.from(evt.touches).find((touch) => touch.identifier === touchId);
|
||||
|
||||
const getEndedTouch = (evt: TouchEvent, touchId: number) =>
|
||||
Array.from(evt.changedTouches).find((touch) => touch.identifier === touchId);
|
||||
|
||||
const handleTouchStart = (evt: TouchEvent) => {
|
||||
if (gestureRef.current.phase !== 'idle') return;
|
||||
|
||||
const touch = evt.changedTouches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const target = evt.target;
|
||||
if (touch.clientY > window.innerHeight * MAX_START_Y_RATIO) return;
|
||||
|
||||
const messageEl = findMessageElement(target);
|
||||
const messageId = messageEl?.getAttribute('data-message-id') ?? null;
|
||||
|
||||
const canStartBack = backEnabled && !shouldIgnoreBackTarget(target);
|
||||
const canStartReply =
|
||||
replyEnabled &&
|
||||
messageEl &&
|
||||
messageId &&
|
||||
!shouldIgnoreReplyTarget(target) &&
|
||||
mobileSwipeReplyBridgeRef.current;
|
||||
|
||||
if (!canStartBack && !canStartReply) return;
|
||||
|
||||
if (touch.clientX <= EDGE_WIDTH && canStartBack) {
|
||||
evt.preventDefault();
|
||||
gestureRef.current = {
|
||||
phase: 'back',
|
||||
touchId: touch.identifier,
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
messageEl: null,
|
||||
messageId: null,
|
||||
edgeBack: true,
|
||||
offset: 0,
|
||||
};
|
||||
document.documentElement.classList.add('mobile-gesture-lock');
|
||||
return;
|
||||
}
|
||||
|
||||
gestureRef.current = {
|
||||
phase: 'pending',
|
||||
touchId: touch.identifier,
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
messageEl: canStartReply ? messageEl : null,
|
||||
messageId: canStartReply ? messageId : null,
|
||||
edgeBack: false,
|
||||
offset: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const handleTouchMove = (evt: TouchEvent) => {
|
||||
const gesture = gestureRef.current;
|
||||
if (gesture.phase === 'idle') return;
|
||||
|
||||
const touch = getActiveTouch(evt, gesture.touchId);
|
||||
if (!touch) return;
|
||||
|
||||
const deltaX = touch.clientX - gesture.startX;
|
||||
const deltaY = touch.clientY - gesture.startY;
|
||||
|
||||
if (gesture.phase === 'pending') {
|
||||
if (Math.abs(deltaX) < AXIS_LOCK_PX && Math.abs(deltaY) < AXIS_LOCK_PX) return;
|
||||
|
||||
const horizontal = Math.abs(deltaX) >= Math.abs(deltaY) * AXIS_DOMINANCE;
|
||||
const vertical = Math.abs(deltaY) >= Math.abs(deltaX) * AXIS_DOMINANCE;
|
||||
|
||||
if (vertical) {
|
||||
resetGesture();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!horizontal) return;
|
||||
|
||||
if (deltaX > 0 && backEnabled) {
|
||||
lockGesture('back', contentRef.current);
|
||||
} else if (deltaX < 0 && gesture.messageEl && gesture.messageId) {
|
||||
lockGesture('reply', gesture.messageEl);
|
||||
} else {
|
||||
resetGesture();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (gesture.phase === 'back') {
|
||||
if (!evt.cancelable) return;
|
||||
evt.preventDefault();
|
||||
|
||||
const width = rootRef.current?.clientWidth ?? window.innerWidth;
|
||||
const offset = applyBackOffset(deltaX, width);
|
||||
gesture.offset = offset;
|
||||
setElementTransform(contentRef.current, offset);
|
||||
return;
|
||||
}
|
||||
|
||||
if (gesture.phase === 'reply' && gesture.messageEl) {
|
||||
if (!evt.cancelable) return;
|
||||
evt.preventDefault();
|
||||
|
||||
const offset = Math.max(deltaX, -REPLY_MAX);
|
||||
gesture.offset = offset;
|
||||
setElementTransform(gesture.messageEl, offset);
|
||||
updateReplyIndicator(gesture.messageEl, offset);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = (evt: TouchEvent) => {
|
||||
const gesture = gestureRef.current;
|
||||
if (gesture.phase === 'idle') return;
|
||||
|
||||
const touch = getEndedTouch(evt, gesture.touchId);
|
||||
if (!touch) return;
|
||||
|
||||
finishGesture();
|
||||
};
|
||||
|
||||
document.addEventListener('touchstart', handleTouchStart, { capture: true, passive: false });
|
||||
document.addEventListener('touchmove', handleTouchMove, { capture: true, passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd, { capture: true });
|
||||
document.addEventListener('touchcancel', handleTouchEnd, { capture: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('touchstart', handleTouchStart, { capture: true });
|
||||
document.removeEventListener('touchmove', handleTouchMove, { capture: true });
|
||||
document.removeEventListener('touchend', handleTouchEnd, { capture: true });
|
||||
document.removeEventListener('touchcancel', handleTouchEnd, { capture: true });
|
||||
resetGesture();
|
||||
};
|
||||
}, [backEnabled, compact, finishGesture, lockGesture, replyEnabled, resetGesture, updateReplyIndicator]);
|
||||
|
||||
useEffect(() => {
|
||||
resetGesture();
|
||||
}, [canGoBack, resetGesture]);
|
||||
|
||||
if (!compact) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const showBackChrome = backEnabled;
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={css.SwipeBackRoot}>
|
||||
{showBackChrome && (
|
||||
<div className={css.SwipeBackUnderlay} aria-hidden>
|
||||
<div className={css.SwipeBackSidebarPeek} />
|
||||
<div className={css.SwipeBackChannelPeek} />
|
||||
</div>
|
||||
)}
|
||||
<div ref={contentRef} className={css.SwipeBackContent}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { ReactNode, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { Editor } from 'slate';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { Icon, Icons } from '../icons';
|
||||
import { useCompactNav } from '../../hooks/useCompactNav';
|
||||
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||
import { mobileSwipeReplyBridgeRef } from './mobileSwipeReplyBridge';
|
||||
import * as css from './mobile-gestures.css';
|
||||
|
||||
type MobileSwipeToReplyLayerProps = {
|
||||
room: Room;
|
||||
editor: Editor;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function MobileSwipeToReplyLayer({ room, editor, children }: MobileSwipeToReplyLayerProps) {
|
||||
const compact = useCompactNav();
|
||||
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
|
||||
const layerRef = useRef<HTMLDivElement>(null);
|
||||
const [indicatorTop, setIndicatorTop] = useState<number | null>(null);
|
||||
const [indicatorActive, setIndicatorActive] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!compact) return;
|
||||
|
||||
mobileSwipeReplyBridgeRef.current = {
|
||||
room,
|
||||
editor,
|
||||
setReplyDraft,
|
||||
layerEl: layerRef.current,
|
||||
setIndicator: (top, active) => {
|
||||
setIndicatorTop(top);
|
||||
setIndicatorActive(active);
|
||||
},
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (mobileSwipeReplyBridgeRef.current?.room.roomId === room.roomId) {
|
||||
mobileSwipeReplyBridgeRef.current = null;
|
||||
}
|
||||
setIndicatorTop(null);
|
||||
setIndicatorActive(false);
|
||||
};
|
||||
}, [compact, editor, room, setReplyDraft]);
|
||||
|
||||
if (!compact) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={layerRef} className={css.SwipeToReplyLayer}>
|
||||
{children}
|
||||
{indicatorTop !== null && (
|
||||
<div
|
||||
className={`${css.SwipeToReplyIndicator} ${
|
||||
indicatorActive ? css.SwipeToReplyIndicatorActive : ''
|
||||
}`}
|
||||
style={{ top: indicatorTop }}
|
||||
aria-hidden
|
||||
>
|
||||
<Icon src={Icons.ReplyArrow} size="200" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
overlay/src/app/components/mobile/mobile-gestures.css.ts
Normal file
78
overlay/src/app/components/mobile/mobile-gestures.css.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
export const SwipeBackRoot = style({
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export const SwipeBackContent = style({
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: color.Background.Container,
|
||||
boxShadow: '0 0 24px rgba(0, 0, 0, 0.35)',
|
||||
willChange: 'transform',
|
||||
touchAction: 'pan-y',
|
||||
});
|
||||
|
||||
export const SwipeBackUnderlay = style({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
});
|
||||
|
||||
export const SwipeBackSidebarPeek = style({
|
||||
width: toRem(66),
|
||||
flexShrink: 0,
|
||||
backgroundColor: color.Background.Container,
|
||||
borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
|
||||
});
|
||||
|
||||
export const SwipeBackChannelPeek = style({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
backgroundColor: color.Background.Container,
|
||||
});
|
||||
|
||||
export const SwipeToReplyLayer = style({
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export const SwipeToReplyIndicator = style({
|
||||
position: 'absolute',
|
||||
right: config.space.S300,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: toRem(36),
|
||||
height: toRem(36),
|
||||
borderRadius: config.radii.Pill,
|
||||
backgroundColor: color.Primary.Container,
|
||||
color: color.Primary.OnContainer,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 2,
|
||||
opacity: 0,
|
||||
transform: 'scale(0.85)',
|
||||
transition: 'opacity 0.12s ease, transform 0.12s ease',
|
||||
});
|
||||
|
||||
export const SwipeToReplyIndicatorActive = style({
|
||||
opacity: 1,
|
||||
transform: 'scale(1)',
|
||||
});
|
||||
15
overlay/src/app/components/mobile/mobileSwipeReplyBridge.ts
Normal file
15
overlay/src/app/components/mobile/mobileSwipeReplyBridge.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { Editor } from 'slate';
|
||||
import { IReplyDraft } from '../../state/room/roomInputDrafts';
|
||||
|
||||
export type MobileSwipeReplyBridge = {
|
||||
room: Room;
|
||||
editor: Editor;
|
||||
setReplyDraft: (draft: IReplyDraft | undefined) => void;
|
||||
layerEl: HTMLElement | null;
|
||||
setIndicator: (top: number | null, active: boolean) => void;
|
||||
};
|
||||
|
||||
export const mobileSwipeReplyBridgeRef: { current: MobileSwipeReplyBridge | null } = {
|
||||
current: null,
|
||||
};
|
||||
316
overlay/src/app/features/ShareRoomPicker.tsx
Normal file
316
overlay/src/app/features/ShareRoomPicker.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import React, { ChangeEventHandler, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Input,
|
||||
MenuItem,
|
||||
Modal,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Scroll,
|
||||
Text,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
|
||||
import { allRoomsAtom } from '../state/room-list/roomList';
|
||||
import { mDirectAtom } from '../state/mDirectList';
|
||||
import { useDirects, useRooms } from '../state/hooks/roomList';
|
||||
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../utils/room';
|
||||
import { RoomAvatar, RoomIcon } from '../components/room-avatar';
|
||||
import { nameInitials } from '../utils/common';
|
||||
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
|
||||
import { factoryRoomIdByActivity } from '../utils/sort';
|
||||
import {
|
||||
SearchItemStrGetter,
|
||||
useAsyncSearch,
|
||||
UseAsyncSearchOptions,
|
||||
} from '../hooks/useAsyncSearch';
|
||||
import { VirtualTile } from '../components/virtualizer';
|
||||
import { AndroidSharePayload } from '../utils/androidShare';
|
||||
import { stopPropagation } from '../utils/keyboard';
|
||||
|
||||
const SEARCH_OPTS: UseAsyncSearchOptions = {
|
||||
limit: 200,
|
||||
matchOptions: {
|
||||
contain: true,
|
||||
},
|
||||
};
|
||||
|
||||
type ShareRoomPickerProps = {
|
||||
/** The pending share payload to display a preview of. */
|
||||
share: AndroidSharePayload;
|
||||
/** Called when the user picks a room. */
|
||||
onPick: (roomId: string) => void;
|
||||
/** Called when the user dismisses without picking. */
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Full-screen modal that lets the user pick a room to send
|
||||
* an incoming Android share intent into.
|
||||
*/
|
||||
export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [confirmRoomId, setConfirmRoomId] = useState<string | null>(null);
|
||||
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const allRoomsSet = useAllJoinedRoomsSet();
|
||||
const getRoom = useGetRoom(allRoomsSet);
|
||||
const rooms = useRooms(mx, allRoomsAtom, mDirects);
|
||||
const directs = useDirects(mx, allRoomsAtom, mDirects);
|
||||
|
||||
const allItems = useMemo(
|
||||
() => [...rooms, ...directs].sort(factoryRoomIdByActivity(mx)),
|
||||
[mx, rooms, directs]
|
||||
);
|
||||
|
||||
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
|
||||
(rId) => getRoom(rId)?.name ?? rId,
|
||||
[getRoom]
|
||||
);
|
||||
|
||||
const [searchResult, searchRoom, resetSearch] = useAsyncSearch(
|
||||
allItems,
|
||||
getRoomNameStr,
|
||||
SEARCH_OPTS
|
||||
);
|
||||
|
||||
const items = searchResult ? searchResult.items : allItems;
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 48,
|
||||
overscan: 8,
|
||||
});
|
||||
const vItems = virtualizer.getVirtualItems();
|
||||
|
||||
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||
const value = evt.currentTarget.value.trim();
|
||||
if (!value) {
|
||||
resetSearch();
|
||||
return;
|
||||
}
|
||||
searchRoom(value);
|
||||
};
|
||||
|
||||
const handleRoomClick: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
const roomId = evt.currentTarget.getAttribute('data-room-id');
|
||||
if (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 fileCount = share.files.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Overlay open>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
clickOutsideDeactivates: true,
|
||||
onDeactivate: onDismiss,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 999,
|
||||
backgroundColor: '#1a1d23',
|
||||
paddingTop: '60px',
|
||||
}}
|
||||
>
|
||||
<Header
|
||||
size="500"
|
||||
style={{ padding: config.space.S400 }}
|
||||
>
|
||||
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0, maxWidth: '80%' }}>
|
||||
<Text size="H4">Share to Room</Text>
|
||||
{(previewText || fileCount > 0) && (
|
||||
<Text size="T200" priority="300" style={{ opacity: 0.7, wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
||||
{previewText
|
||||
? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
|
||||
: `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton size="300" radii="300" onClick={onDismiss}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Header>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
padding: config.space.S200,
|
||||
paddingTop: config.space.S300,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
onChange={handleSearchChange}
|
||||
before={<Icon size="200" src={Icons.Search} />}
|
||||
placeholder="Search rooms…"
|
||||
size="400"
|
||||
variant="Background"
|
||||
outlined
|
||||
autoFocus
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Scroll ref={scrollRef} size="300" hideTrack style={{ flex: 1, minHeight: 0 }}>
|
||||
<Box style={{ padding: config.space.S300 }} direction="Column">
|
||||
{vItems.length === 0 && (
|
||||
<Box
|
||||
style={{ padding: `${config.space.S700} 0` }}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
direction="Column"
|
||||
gap="100"
|
||||
>
|
||||
<Text size="H6" align="Center">
|
||||
{searchResult ? 'No Match Found' : 'No Rooms'}
|
||||
</Text>
|
||||
<Text size="T200" align="Center" priority="300">
|
||||
{searchResult
|
||||
? `No rooms found for "${searchResult.query}".`
|
||||
: 'You have no rooms to share into.'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
|
||||
{vItems.map((vItem) => {
|
||||
const roomId = items[vItem.index];
|
||||
const room = getRoom(roomId);
|
||||
if (!room) return null;
|
||||
const isDm = mDirects.has(roomId);
|
||||
|
||||
const avatarSrc = isDm
|
||||
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
style={{ paddingBottom: config.space.S100 }}
|
||||
ref={virtualizer.measureElement}
|
||||
key={roomId}
|
||||
>
|
||||
<MenuItem
|
||||
data-room-id={roomId}
|
||||
onClick={handleRoomClick}
|
||||
variant="Surface"
|
||||
size="400"
|
||||
radii="400"
|
||||
before={
|
||||
<Avatar size="300" radii={isDm ? '400' : '300'}>
|
||||
{avatarSrc ? (
|
||||
<RoomAvatar
|
||||
roomId={roomId}
|
||||
src={avatarSrc}
|
||||
alt={room.name}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="H6">
|
||||
{nameInitials(room.name)}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<RoomIcon room={room} size="200" filled />
|
||||
)}
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="B400" truncate>
|
||||
{room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Scroll>
|
||||
</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>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
160
overlay/src/app/features/room/RoomView.tsx
Normal file
160
overlay/src/app/features/room/RoomView.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { Box, Text, config } from 'folds';
|
||||
import { EventType, Room } from 'matrix-js-sdk';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useEditor } from '../../components/editor';
|
||||
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
|
||||
import { RoomTimeline } from './RoomTimeline';
|
||||
import { RoomViewTyping } from './RoomViewTyping';
|
||||
import { RoomTombstone } from './RoomTombstone';
|
||||
import { RoomInput } from './RoomInput';
|
||||
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
|
||||
import { Page } from '../../components/page';
|
||||
import { RoomViewHeader } from './RoomViewHeader';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { editableActiveElement } from '../../utils/dom';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { activeThreadIdAtomFamily } from '../../state/activeThread';
|
||||
import { ThreadView } from './ThreadView';
|
||||
import { MobileSwipeToReplyLayer } from '../../components/mobile/MobileSwipeToReplyLayer';
|
||||
|
||||
const FN_KEYS_REGEX = /^F\d+$/;
|
||||
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||
const { code } = evt;
|
||||
if (evt.metaKey || evt.altKey || evt.ctrlKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (FN_KEYS_REGEX.test(code)) return false;
|
||||
|
||||
if (
|
||||
code.startsWith('OS') ||
|
||||
code.startsWith('Meta') ||
|
||||
code.startsWith('Shift') ||
|
||||
code.startsWith('Alt') ||
|
||||
code.startsWith('Control') ||
|
||||
code.startsWith('Arrow') ||
|
||||
code.startsWith('Page') ||
|
||||
code.startsWith('End') ||
|
||||
code.startsWith('Home') ||
|
||||
code === 'Tab' ||
|
||||
code === 'Space' ||
|
||||
code === 'Enter' ||
|
||||
code === 'NumLock' ||
|
||||
code === 'ScrollLock'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
|
||||
const roomInputRef = useRef<HTMLDivElement>(null);
|
||||
const roomViewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
|
||||
const { roomId } = room;
|
||||
const editor = useEditor();
|
||||
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const creators = useRoomCreators(room);
|
||||
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
|
||||
|
||||
const activeThreadId = useAtomValue(activeThreadIdAtomFamily(roomId));
|
||||
|
||||
useKeyDown(
|
||||
window,
|
||||
useCallback(
|
||||
(evt) => {
|
||||
if (editableActiveElement()) return;
|
||||
const portalContainer = document.getElementById('portalContainer');
|
||||
if (portalContainer && portalContainer.children.length > 0) {
|
||||
return;
|
||||
}
|
||||
if (shouldFocusMessageField(evt)) {
|
||||
evt.preventDefault();
|
||||
ReactEditor.focus(editor);
|
||||
if (evt.key.length === 1) {
|
||||
editor.insertText(evt.key);
|
||||
}
|
||||
} else if (isKeyHotkey('mod+v', evt)) {
|
||||
ReactEditor.focus(editor);
|
||||
}
|
||||
},
|
||||
[editor]
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<Page ref={roomViewRef}>
|
||||
<RoomViewHeader />
|
||||
{activeThreadId ? (
|
||||
<ThreadView room={room} threadRootId={activeThreadId} />
|
||||
) : (
|
||||
<>
|
||||
<MobileSwipeToReplyLayer room={room} editor={editor}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomTimeline
|
||||
key={roomId}
|
||||
room={room}
|
||||
eventId={eventId}
|
||||
roomInputRef={roomInputRef}
|
||||
editor={editor}
|
||||
/>
|
||||
<RoomViewTyping room={room} />
|
||||
</Box>
|
||||
</MobileSwipeToReplyLayer>
|
||||
<Box shrink="No" direction="Column" data-disable-swipe-back="true">
|
||||
<div style={{ padding: `0 ${config.space.S400}` }}>
|
||||
{tombstoneEvent ? (
|
||||
<RoomTombstone
|
||||
roomId={roomId}
|
||||
body={tombstoneEvent.getContent().body}
|
||||
replacementRoomId={tombstoneEvent.getContent().replacement_room}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{canMessage && (
|
||||
<RoomInput
|
||||
room={room}
|
||||
editor={editor}
|
||||
roomId={roomId}
|
||||
fileDropContainerRef={roomViewRef}
|
||||
ref={roomInputRef}
|
||||
/>
|
||||
)}
|
||||
{!canMessage && (
|
||||
<RoomInputPlaceholder
|
||||
style={{ padding: config.space.S200 }}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Text align="Center">You do not have permission to post in this room</Text>
|
||||
</RoomInputPlaceholder>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
41
overlay/src/app/features/room/replyToMessage.ts
Normal file
41
overlay/src/app/features/room/replyToMessage.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { RelationType, Room } from 'matrix-js-sdk';
|
||||
import { Editor } from 'slate';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { getEditedEvent } from '../../utils/room';
|
||||
import { IReplyDraft } from '../../state/room/roomInputDrafts';
|
||||
|
||||
export const startReplyToEvent = (
|
||||
room: Room,
|
||||
eventId: string,
|
||||
setReplyDraft: (draft: IReplyDraft | undefined) => void,
|
||||
editor?: Editor,
|
||||
startThread = false
|
||||
): boolean => {
|
||||
const replyEvt = room.findEventById(eventId);
|
||||
if (!replyEvt || replyEvt.isRedacted()) return false;
|
||||
|
||||
const editedReply = getEditedEvent(eventId, replyEvt, room.getUnfilteredTimelineSet());
|
||||
const content = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
||||
const { body, formatted_body: formattedBody } = content;
|
||||
const senderId = replyEvt.getSender();
|
||||
|
||||
if (!senderId || typeof body !== 'string') return false;
|
||||
|
||||
const relation = startThread
|
||||
? { rel_type: RelationType.Thread, event_id: eventId }
|
||||
: replyEvt.getWireContent()['m.relates_to'];
|
||||
|
||||
setReplyDraft({
|
||||
userId: senderId,
|
||||
eventId,
|
||||
body,
|
||||
formattedBody,
|
||||
relation,
|
||||
});
|
||||
|
||||
if (editor) {
|
||||
setTimeout(() => ReactEditor.focus(editor), 100);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Box, Text, Switch, Button, color, Spinner } from 'folds';
|
||||
import { IPusherRequest } from 'matrix-js-sdk';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
@@ -11,6 +11,12 @@ import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { isCapacitorNative, requestSystemNotificationPermission } from '../../../utils/tauri';
|
||||
import {
|
||||
isBackgroundSyncSupported,
|
||||
getBackgroundSyncStatus,
|
||||
requestResetPushRegistration,
|
||||
triggerBackgroundSyncPing,
|
||||
} from '../../../utils/backgroundSync';
|
||||
|
||||
function EmailNotification() {
|
||||
const mx = useMatrixClient();
|
||||
@@ -147,6 +153,7 @@ export function SystemNotification() {
|
||||
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
{isBackgroundSyncSupported() && <AndroidPushNotifications />}
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
@@ -158,3 +165,126 @@ export function SystemNotification() {
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type PushStatus = {
|
||||
registered: boolean;
|
||||
distributor: string;
|
||||
endpoint: string;
|
||||
};
|
||||
|
||||
/** Android-only section showing UnifiedPush registration status and controls. */
|
||||
function AndroidPushNotifications() {
|
||||
const [status, setStatus] = useState<PushStatus | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const s = await getBackgroundSyncStatus();
|
||||
setStatus(
|
||||
s
|
||||
? {
|
||||
registered: s.registered,
|
||||
distributor: s.distributor || '',
|
||||
endpoint: s.endpoint || '',
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const [resetState, reset] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
await requestResetPushRegistration();
|
||||
await refresh();
|
||||
}, [refresh])
|
||||
);
|
||||
|
||||
const [pingState, ping] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
await triggerBackgroundSyncPing('manual-test');
|
||||
}, [])
|
||||
);
|
||||
|
||||
const isBusy =
|
||||
loading ||
|
||||
resetState.status === AsyncStatus.Loading ||
|
||||
pingState.status === AsyncStatus.Loading;
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Android Push (UnifiedPush)</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Background Notifications"
|
||||
description={
|
||||
loading ? (
|
||||
'Loading status…'
|
||||
) : status === undefined ? (
|
||||
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||
Failed to read push status.
|
||||
</Text>
|
||||
) : status.registered ? (
|
||||
<>
|
||||
<Text as="span" size="T200">
|
||||
{`Distributor: ${status.distributor || 'unknown'}`}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text as="span" style={{ color: color.Warning?.Main ?? color.Critical.Main }} size="T200">
|
||||
Not registered. Tap "Reset" to choose a distributor app.
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
after={loading ? <Spinner variant="Secondary" /> : undefined}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Change Distributor"
|
||||
description="Re-open the UnifiedPush distributor selection dialog."
|
||||
after={
|
||||
<Button
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
disabled={isBusy}
|
||||
onClick={() => void reset()}
|
||||
>
|
||||
{resetState.status === AsyncStatus.Loading ? (
|
||||
<Spinner variant="Secondary" size="200" />
|
||||
) : (
|
||||
<Text size="B300">Reset</Text>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Test Notification"
|
||||
description="Trigger a test push ping to verify the pipeline works."
|
||||
after={
|
||||
<Button
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
disabled={isBusy}
|
||||
onClick={() => void ping()}
|
||||
>
|
||||
{pingState.status === AsyncStatus.Loading ? (
|
||||
<Spinner variant="Secondary" size="200" />
|
||||
) : (
|
||||
<Text size="B300">Send Test</Text>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||
import { useAtom, useSetAtom } from 'jotai';
|
||||
import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||
@@ -28,6 +29,8 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { plainToEditorInput, toPlainText } from '../../components/editor';
|
||||
import { ShareRoomPicker } from '../../features/ShareRoomPicker';
|
||||
import {
|
||||
isTauri,
|
||||
isElectron,
|
||||
@@ -41,6 +44,20 @@ import {
|
||||
stopBackgroundSync,
|
||||
setAppForegroundState,
|
||||
} from '../../utils/backgroundSync';
|
||||
import {
|
||||
TUploadItem,
|
||||
roomIdToMsgDraftAtomFamily,
|
||||
roomIdToUploadItemsAtomFamily,
|
||||
} from '../../state/room/roomInputDrafts';
|
||||
import { encryptFile } from '../../utils/matrix';
|
||||
import {
|
||||
AndroidSharePayload,
|
||||
clearPendingAndroidShare,
|
||||
getPendingAndroidShare,
|
||||
isAndroidShareSupported,
|
||||
listenForAndroidShares,
|
||||
materializeSharedFile,
|
||||
} from '../../utils/androidShare';
|
||||
|
||||
/**
|
||||
* Applies the selected emoji style font to the document.
|
||||
@@ -419,15 +436,20 @@ function MessageNotifications() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the native Android background sync service on login, keeps it
|
||||
* Configures native Android push-ping background sync on login, keeps it
|
||||
* informed of foreground state so it doesn't double-fire notifications,
|
||||
* and stops it cleanly on unmount (logout).
|
||||
* and clears it cleanly on unmount (logout).
|
||||
* Only active on Android Capacitor builds.
|
||||
*/
|
||||
function BackgroundSyncSetup() {
|
||||
const mx = useMatrixClient();
|
||||
// Try to make a simple fetch to verify component is rendering
|
||||
try {
|
||||
fetch('/_matrix/client/v3/sync', { method: 'HEAD' }).catch(() => {});
|
||||
} catch {}
|
||||
|
||||
useEffect(() => {
|
||||
console.log('BackgroundSyncSetup: Starting background sync for', mx.getUserId());
|
||||
startBackgroundSync(mx);
|
||||
|
||||
const onVisibility = () => setAppForegroundState(!document.hidden);
|
||||
@@ -493,6 +515,167 @@ function TaskbarFlashStopper() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function AndroidShareIntentHandler() {
|
||||
const mx = useMatrixClient();
|
||||
const navigate = useNavigate();
|
||||
const [pendingShare, setPendingShare] = useState<AndroidSharePayload | null>(null);
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
|
||||
const applyPendingShare = useCallback(
|
||||
async (share: AndroidSharePayload, roomId: string) => {
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return false;
|
||||
|
||||
// Send text message if present
|
||||
const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
|
||||
(value): value is string => !!value
|
||||
);
|
||||
if (nextParts.length > 0) {
|
||||
const textContent = {
|
||||
msgtype: 'm.text' as const,
|
||||
body: nextParts.join('\n'),
|
||||
};
|
||||
await mx.sendMessage(roomId, textContent);
|
||||
}
|
||||
|
||||
// Upload and send files if present
|
||||
if (share.files.length > 0) {
|
||||
for (const sharedFile of share.files) {
|
||||
const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
|
||||
|
||||
let fileToUpload = originalFile;
|
||||
let encInfo: any = undefined;
|
||||
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
const encrypted = await encryptFile(originalFile);
|
||||
fileToUpload = encrypted.file;
|
||||
encInfo = encrypted.encInfo;
|
||||
}
|
||||
|
||||
// Upload file
|
||||
const uploadResult = await mx.uploadContent(fileToUpload);
|
||||
const mxc = uploadResult?.content_uri;
|
||||
if (!mxc) continue;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
await mx.sendMessage(roomId, fileContent);
|
||||
}
|
||||
}
|
||||
|
||||
await clearPendingAndroidShare();
|
||||
return true;
|
||||
},
|
||||
[mx]
|
||||
);
|
||||
|
||||
const handlePickRoom = useCallback(
|
||||
(roomId: string) => {
|
||||
if (!pendingShare) return;
|
||||
|
||||
applyPendingShare(pendingShare, roomId)
|
||||
.then((applied) => {
|
||||
if (applied) {
|
||||
setPendingShare(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) => {
|
||||
console.error('[AndroidShare] Failed to apply share after pick:', err);
|
||||
setPendingShare(null);
|
||||
});
|
||||
},
|
||||
[applyPendingShare, pendingShare, navigate, mDirects, roomToParents]
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
clearPendingAndroidShare().catch(() => {});
|
||||
setPendingShare(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAndroidShareSupported()) return;
|
||||
|
||||
let mounted = true;
|
||||
let listenerHandle: { remove: () => Promise<void> } | undefined;
|
||||
|
||||
getPendingAndroidShare()
|
||||
.then((share) => {
|
||||
if (mounted && share) {
|
||||
setPendingShare(share);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[AndroidShare] Failed to get pending share:', err);
|
||||
});
|
||||
|
||||
listenForAndroidShares((share) => {
|
||||
if (mounted) {
|
||||
setPendingShare(share);
|
||||
}
|
||||
})
|
||||
.then((handle) => {
|
||||
listenerHandle = handle;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[AndroidShare] Failed to listen for shares:', err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
void listenerHandle?.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!pendingShare) return null;
|
||||
|
||||
return (
|
||||
<ShareRoomPicker
|
||||
share={pendingShare}
|
||||
onPick={handlePickRoom}
|
||||
onDismiss={handleDismiss}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type ClientNonUIFeaturesProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
@@ -508,6 +691,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
<BackgroundSyncSetup />
|
||||
<PaarrotAPIInitializer />
|
||||
<TaskbarFlashStopper />
|
||||
<AndroidShareIntentHandler />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
73
overlay/src/app/utils/androidShare.ts
Normal file
73
overlay/src/app/utils/androidShare.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
|
||||
|
||||
/** Metadata for a file received from an Android share intent. */
|
||||
export type AndroidSharedFile = {
|
||||
path: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
/** Payload persisted by the native Android share handler. */
|
||||
export type AndroidSharePayload = {
|
||||
text?: string;
|
||||
subject?: string;
|
||||
files: AndroidSharedFile[];
|
||||
receivedAt: number;
|
||||
};
|
||||
|
||||
interface AndroidShareHandlerPlugin {
|
||||
/** Returns the last pending share payload, if one exists. */
|
||||
getPendingShare(): Promise<{ share: AndroidSharePayload | null }>;
|
||||
/** Clears the last pending share payload after it has been consumed. */
|
||||
clearPendingShare(): Promise<void>;
|
||||
addListener(
|
||||
eventName: 'shareReceived',
|
||||
listenerFunc: (payload: AndroidSharePayload) => void
|
||||
): Promise<PluginListenerHandle>;
|
||||
}
|
||||
|
||||
const AndroidShareHandler = registerPlugin<AndroidShareHandlerPlugin>('AndroidShareHandler');
|
||||
|
||||
/** Returns true when the Android share bridge is available. */
|
||||
export const isAndroidShareSupported = (): boolean =>
|
||||
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
|
||||
|
||||
/** Fetches the current pending native share payload. */
|
||||
export const getPendingAndroidShare = async (): Promise<AndroidSharePayload | null> => {
|
||||
if (!isAndroidShareSupported()) return null;
|
||||
const result = await AndroidShareHandler.getPendingShare();
|
||||
return result.share;
|
||||
};
|
||||
|
||||
/** Clears the native pending share payload. */
|
||||
export const clearPendingAndroidShare = async (): Promise<void> => {
|
||||
if (!isAndroidShareSupported()) return;
|
||||
await AndroidShareHandler.clearPendingShare();
|
||||
};
|
||||
|
||||
/** Subscribes to new incoming native share payloads. */
|
||||
export const listenForAndroidShares = async (
|
||||
listener: (payload: AndroidSharePayload) => void
|
||||
): Promise<PluginListenerHandle | undefined> => {
|
||||
if (!isAndroidShareSupported()) return undefined;
|
||||
return AndroidShareHandler.addListener('shareReceived', listener);
|
||||
};
|
||||
|
||||
/** Converts a cached native shared file into a browser File for upload. */
|
||||
export const materializeSharedFile = async (
|
||||
sharedFile: AndroidSharedFile,
|
||||
receivedAt: number
|
||||
): Promise<File> => {
|
||||
const fileUrl = Capacitor.convertFileSrc(sharedFile.path);
|
||||
const response = await fetch(fileUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to read shared file: ${sharedFile.name}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
return new File([blob], sharedFile.name, {
|
||||
type: sharedFile.mimeType || blob.type,
|
||||
lastModified: receivedAt,
|
||||
});
|
||||
};
|
||||
@@ -1,15 +1,44 @@
|
||||
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||
import type { MatrixClient } from 'matrix-js-sdk';
|
||||
|
||||
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
|
||||
import type { IPusherRequest, MatrixClient } from 'matrix-js-sdk';
|
||||
|
||||
type UnifiedPushStatus = {
|
||||
running: boolean;
|
||||
endpoint: string;
|
||||
instance: string;
|
||||
registered: boolean;
|
||||
distributor: string;
|
||||
distributors: string[] | string;
|
||||
};
|
||||
|
||||
type UnifiedPushEndpointEvent = {
|
||||
endpoint: string;
|
||||
previousEndpoint: string;
|
||||
instance: string;
|
||||
};
|
||||
|
||||
type UnifiedPushUnregisteredEvent = {
|
||||
previousEndpoint: string;
|
||||
instance: string;
|
||||
};
|
||||
|
||||
type UnifiedPushRegistrationFailedEvent = {
|
||||
reason: string;
|
||||
instance: string;
|
||||
};
|
||||
|
||||
interface MatrixBackgroundSyncPlugin {
|
||||
/** Start the background sync service with the given Matrix credentials. */
|
||||
/** Persist credentials and request UnifiedPush registration. */
|
||||
start(options: {
|
||||
homeserverUrl: string;
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
}): Promise<void>;
|
||||
/** Stop the background sync service and clear persisted credentials. */
|
||||
/** Trigger a one-shot fetch as if a push ping arrived. */
|
||||
triggerPing(options: { reason?: string }): Promise<void>;
|
||||
/** Re-open distributor setup flow and retry registration. */
|
||||
requestDistributorSetup(): Promise<{ success: boolean }>;
|
||||
/** Stop any in-flight fetch, clear credentials, and unregister UnifiedPush. */
|
||||
stop(): Promise<void>;
|
||||
/**
|
||||
* Notify the service whether the app UI is visible.
|
||||
@@ -17,63 +46,429 @@ interface MatrixBackgroundSyncPlugin {
|
||||
* because the JS layer handles them via LocalNotifications.
|
||||
*/
|
||||
setAppForeground(options: { foreground: boolean }): Promise<void>;
|
||||
/** Returns whether the sync service is currently running. */
|
||||
getStatus(): Promise<{ running: boolean }>;
|
||||
/** Returns fetch state and current UnifiedPush registration details. */
|
||||
getStatus(): Promise<UnifiedPushStatus>;
|
||||
addListener(
|
||||
eventName: 'unifiedPushNewEndpoint',
|
||||
listenerFunc: (event: UnifiedPushEndpointEvent) => void
|
||||
): Promise<PluginListenerHandle>;
|
||||
addListener(
|
||||
eventName: 'unifiedPushUnregistered',
|
||||
listenerFunc: (event: UnifiedPushUnregisteredEvent) => void
|
||||
): Promise<PluginListenerHandle>;
|
||||
addListener(
|
||||
eventName: 'unifiedPushRegistrationFailed',
|
||||
listenerFunc: (event: UnifiedPushRegistrationFailedEvent) => void
|
||||
): Promise<PluginListenerHandle>;
|
||||
}
|
||||
|
||||
type StoredPusherState = {
|
||||
endpoint: string;
|
||||
appId: string;
|
||||
};
|
||||
|
||||
const MatrixBackgroundSync = registerPlugin<MatrixBackgroundSyncPlugin>('MatrixBackgroundSync');
|
||||
const DEFAULT_UNIFIED_PUSH_GATEWAY = 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify';
|
||||
const PUSHER_APP_ID_BASE = 'com.paarrot.app.android';
|
||||
const PUSHER_STORAGE_PREFIX = 'paarrot.unifiedpush';
|
||||
|
||||
/** Returns true when the current platform is Android Capacitor. */
|
||||
export const isBackgroundSyncSupported = (): boolean =>
|
||||
Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
|
||||
|
||||
/**
|
||||
* Starts the native Matrix background sync service.
|
||||
* Reads credentials directly from the active MatrixClient.
|
||||
* Safe to call on every login — the service is idempotent.
|
||||
* @param mx Authenticated Matrix client
|
||||
*/
|
||||
export const startBackgroundSync = async (mx: MatrixClient): Promise<void> => {
|
||||
if (!isBackgroundSyncSupported()) return;
|
||||
const getStoredPusherKey = (userId: string | null, deviceId: string | null): string =>
|
||||
`${PUSHER_STORAGE_PREFIX}:${userId ?? 'unknown'}:${deviceId ?? 'unknown'}`;
|
||||
|
||||
const homeserverUrl = mx.getHomeserverUrl();
|
||||
const accessToken = mx.getAccessToken();
|
||||
const userId = mx.getUserId();
|
||||
const deviceId = mx.getDeviceId();
|
||||
const buildPusherAppId = (deviceId: string | null): string => {
|
||||
const raw = deviceId ? `${PUSHER_APP_ID_BASE}.${deviceId}` : PUSHER_APP_ID_BASE;
|
||||
return raw.length > 64 ? raw.slice(0, 64) : raw;
|
||||
};
|
||||
|
||||
if (!homeserverUrl || !accessToken || !userId) {
|
||||
console.warn('[BackgroundSync] Missing credentials, not starting');
|
||||
return;
|
||||
}
|
||||
const loadStoredPusherState = (
|
||||
userId: string | null,
|
||||
deviceId: string | null
|
||||
): StoredPusherState | undefined => {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return undefined;
|
||||
|
||||
const raw = window.localStorage.getItem(getStoredPusherKey(userId, deviceId));
|
||||
if (!raw) return undefined;
|
||||
|
||||
try {
|
||||
await MatrixBackgroundSync.start({
|
||||
homeserverUrl,
|
||||
accessToken,
|
||||
userId,
|
||||
deviceId: deviceId ?? '',
|
||||
});
|
||||
console.log('[BackgroundSync] Service started');
|
||||
} catch (err) {
|
||||
console.warn('[BackgroundSync] Failed to start:', err);
|
||||
return JSON.parse(raw) as StoredPusherState;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const saveStoredPusherState = (
|
||||
userId: string | null,
|
||||
deviceId: string | null,
|
||||
state: StoredPusherState
|
||||
): void => {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return;
|
||||
window.localStorage.setItem(getStoredPusherKey(userId, deviceId), JSON.stringify(state));
|
||||
};
|
||||
|
||||
const clearStoredPusherState = (userId: string | null, deviceId: string | null): void => {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return;
|
||||
window.localStorage.removeItem(getStoredPusherKey(userId, deviceId));
|
||||
};
|
||||
|
||||
const normalizeDistributors = (raw: UnifiedPushStatus['distributors']): string[] => {
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (typeof raw !== 'string') return [];
|
||||
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed || trimmed === '[]') return [];
|
||||
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
return trimmed
|
||||
.slice(1, -1)
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return [trimmed];
|
||||
};
|
||||
|
||||
const resolveUnifiedPushGateway = async (endpoint: string): Promise<string> => {
|
||||
try {
|
||||
const discoveryUrl = new URL(endpoint);
|
||||
discoveryUrl.pathname = '/_matrix/push/v1/notify';
|
||||
discoveryUrl.search = '';
|
||||
|
||||
const response = await fetch(discoveryUrl.toString(), { method: 'GET' });
|
||||
if (!response.ok) return DEFAULT_UNIFIED_PUSH_GATEWAY;
|
||||
|
||||
const body = (await response.json()) as {
|
||||
gateway?: string;
|
||||
unifiedpush?: { gateway?: string };
|
||||
};
|
||||
|
||||
if (body.gateway === 'matrix' || body.unifiedpush?.gateway === 'matrix') {
|
||||
return discoveryUrl.toString();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[BackgroundSync] UnifiedPush gateway discovery failed:', err);
|
||||
}
|
||||
|
||||
return DEFAULT_UNIFIED_PUSH_GATEWAY;
|
||||
};
|
||||
|
||||
class AndroidUnifiedPushManager {
|
||||
private client: MatrixClient | undefined;
|
||||
|
||||
private listenerHandles: PluginListenerHandle[] = [];
|
||||
|
||||
private listenersReady = false;
|
||||
|
||||
private distributorSetupAttempted = false;
|
||||
|
||||
private distributorPromptShown = false;
|
||||
|
||||
/** Start native UnifiedPush registration and synchronize the Matrix pusher. */
|
||||
async start(mx: MatrixClient): Promise<void> {
|
||||
console.log('[BackgroundSync] start() called, platform:', Capacitor.getPlatform(), 'isNative:', Capacitor.isNativePlatform());
|
||||
|
||||
if (!isBackgroundSyncSupported()) {
|
||||
console.log('[BackgroundSync] Background sync not supported on this platform');
|
||||
return;
|
||||
}
|
||||
|
||||
const homeserverUrl = mx.getHomeserverUrl();
|
||||
const accessToken = mx.getAccessToken();
|
||||
const userId = mx.getUserId();
|
||||
const deviceId = mx.getDeviceId();
|
||||
|
||||
console.log('[BackgroundSync] Credentials check:', { userId, deviceId, hasToken: !!accessToken, hasUrl: !!homeserverUrl });
|
||||
|
||||
if (!homeserverUrl || !accessToken || !userId) {
|
||||
console.warn('[BackgroundSync] Missing credentials, not starting');
|
||||
return;
|
||||
}
|
||||
|
||||
this.client = mx;
|
||||
this.distributorSetupAttempted = false;
|
||||
this.distributorPromptShown = false;
|
||||
await this.ensureListeners();
|
||||
|
||||
try {
|
||||
console.log('[BackgroundSync] Calling plugin.start()...');
|
||||
await MatrixBackgroundSync.start({
|
||||
homeserverUrl,
|
||||
accessToken,
|
||||
userId,
|
||||
deviceId: deviceId ?? '',
|
||||
});
|
||||
console.log('[BackgroundSync] plugin.start() completed');
|
||||
} catch (err) {
|
||||
console.error('[BackgroundSync] plugin.start() failed:', err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
await this.syncExistingEndpoint();
|
||||
await this.ensureDistributorPromptFromStatus();
|
||||
console.log('[BackgroundSync] UnifiedPush registration requested');
|
||||
}
|
||||
|
||||
/** Stop native UnifiedPush integration and remove the Matrix pusher for this device. */
|
||||
async stop(): Promise<void> {
|
||||
if (!isBackgroundSyncSupported()) return;
|
||||
|
||||
const client = this.client;
|
||||
if (client) {
|
||||
const deviceId = client.getDeviceId();
|
||||
const userId = client.getUserId();
|
||||
const status = await this.safeGetStatus();
|
||||
const stored = loadStoredPusherState(userId, deviceId);
|
||||
const endpoint = status?.endpoint || stored?.endpoint;
|
||||
const appId = stored?.appId ?? buildPusherAppId(deviceId);
|
||||
|
||||
if (endpoint) {
|
||||
await this.removePusher(client, endpoint, appId);
|
||||
}
|
||||
clearStoredPusherState(userId, deviceId);
|
||||
}
|
||||
|
||||
await MatrixBackgroundSync.stop();
|
||||
await this.disposeListeners();
|
||||
this.client = undefined;
|
||||
console.log('[BackgroundSync] UnifiedPush stopped');
|
||||
}
|
||||
|
||||
/** Update the Matrix pusher when a new native endpoint is published. */
|
||||
private async handleNewEndpoint(event: UnifiedPushEndpointEvent): Promise<void> {
|
||||
const client = this.client;
|
||||
if (!client) return;
|
||||
|
||||
if (event.previousEndpoint) {
|
||||
await this.removePusher(client, event.previousEndpoint);
|
||||
}
|
||||
|
||||
await this.upsertPusher(client, event.endpoint);
|
||||
}
|
||||
|
||||
/** Remove the Matrix pusher when UnifiedPush unregisters this instance. */
|
||||
private async handleUnregistered(event: UnifiedPushUnregisteredEvent): Promise<void> {
|
||||
const client = this.client;
|
||||
if (!client) return;
|
||||
|
||||
const stored = loadStoredPusherState(client.getUserId(), client.getDeviceId());
|
||||
const endpoint = event.previousEndpoint || stored?.endpoint;
|
||||
const appId = stored?.appId ?? buildPusherAppId(client.getDeviceId());
|
||||
|
||||
if (endpoint) {
|
||||
await this.removePusher(client, endpoint, appId);
|
||||
}
|
||||
|
||||
clearStoredPusherState(client.getUserId(), client.getDeviceId());
|
||||
}
|
||||
|
||||
/** Log native registration failures so the missing distributor path is visible. */
|
||||
private handleRegistrationFailed(event: UnifiedPushRegistrationFailedEvent): void {
|
||||
console.warn('[BackgroundSync] UnifiedPush registration failed:', event.reason);
|
||||
|
||||
if (event.reason !== 'ACTION_REQUIRED' || this.distributorSetupAttempted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.distributorSetupAttempted = true;
|
||||
void this.tryRequestDistributorSetup();
|
||||
}
|
||||
|
||||
/** Re-opens distributor selection once after ACTION_REQUIRED and logs actionable status. */
|
||||
private async tryRequestDistributorSetup(): Promise<void> {
|
||||
try {
|
||||
const setupResult = await MatrixBackgroundSync.requestDistributorSetup();
|
||||
console.warn('[BackgroundSync] requestDistributorSetup result:', setupResult);
|
||||
const status = await this.safeGetStatus();
|
||||
console.warn('[BackgroundSync] UnifiedPush status after setup attempt:', status);
|
||||
const distributors = normalizeDistributors(status?.distributors ?? []);
|
||||
if (distributors.length === 0) {
|
||||
console.error(
|
||||
'[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.'
|
||||
);
|
||||
this.showNoDistributorPrompt();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[BackgroundSync] requestDistributorSetup failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fallback check so users still get prompted even when ACTION_REQUIRED event is missed. */
|
||||
private async ensureDistributorPromptFromStatus(): Promise<void> {
|
||||
const status = await this.safeGetStatus();
|
||||
if (!status) return;
|
||||
|
||||
const distributors = normalizeDistributors(status.distributors);
|
||||
if (!status.registered && distributors.length === 0) {
|
||||
console.error(
|
||||
'[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.'
|
||||
);
|
||||
this.showNoDistributorPrompt();
|
||||
}
|
||||
}
|
||||
|
||||
/** Show a one-time actionable prompt when no distributor app is installed. */
|
||||
private showNoDistributorPrompt(): void {
|
||||
if (this.distributorPromptShown) return;
|
||||
this.distributorPromptShown = true;
|
||||
|
||||
const message =
|
||||
'Android background notifications need a UnifiedPush distributor app. Install one (for example ntfy), then reopen Paarrot.';
|
||||
const docsUrl = 'https://unifiedpush.org/users/distributors/';
|
||||
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const openDocs = window.confirm(`${message}\n\nOpen distributor list now?`);
|
||||
if (openDocs) {
|
||||
window.open(docsUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
} catch {
|
||||
window.alert(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Install plugin listeners once for the active Matrix client. */
|
||||
private async ensureListeners(): Promise<void> {
|
||||
if (this.listenersReady) return;
|
||||
|
||||
this.listenerHandles = [
|
||||
await MatrixBackgroundSync.addListener('unifiedPushNewEndpoint', (event) => {
|
||||
void this.handleNewEndpoint(event).catch((err) => {
|
||||
console.error('[BackgroundSync] handleNewEndpoint failed:', err);
|
||||
});
|
||||
}),
|
||||
await MatrixBackgroundSync.addListener('unifiedPushUnregistered', (event) => {
|
||||
void this.handleUnregistered(event);
|
||||
}),
|
||||
await MatrixBackgroundSync.addListener('unifiedPushRegistrationFailed', (event) => {
|
||||
this.handleRegistrationFailed(event);
|
||||
}),
|
||||
];
|
||||
this.listenersReady = true;
|
||||
}
|
||||
|
||||
/** Remove all plugin listeners when the manager stops. */
|
||||
private async disposeListeners(): Promise<void> {
|
||||
await Promise.all(this.listenerHandles.map((handle) => handle.remove()));
|
||||
this.listenerHandles = [];
|
||||
this.listenersReady = false;
|
||||
}
|
||||
|
||||
/** Reconcile an already-persisted native endpoint after app startup. */
|
||||
private async syncExistingEndpoint(): Promise<void> {
|
||||
const client = this.client;
|
||||
if (!client) return;
|
||||
|
||||
const status = await this.safeGetStatus();
|
||||
console.log('[BackgroundSync] Native status before pusher sync:', status);
|
||||
if (status?.registered && status.endpoint) {
|
||||
await this.upsertPusher(client, status.endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
/** Create or refresh the Matrix HTTP pusher for the current device. */
|
||||
private async upsertPusher(mx: MatrixClient, endpoint: string): Promise<void> {
|
||||
const deviceId = mx.getDeviceId();
|
||||
const userId = mx.getUserId();
|
||||
const appId = buildPusherAppId(deviceId);
|
||||
const gatewayUrl = await resolveUnifiedPushGateway(endpoint);
|
||||
|
||||
console.log('[BackgroundSync] Upserting Matrix pusher', {
|
||||
appId,
|
||||
endpoint,
|
||||
gatewayUrl,
|
||||
deviceId,
|
||||
userId,
|
||||
});
|
||||
|
||||
try {
|
||||
await mx.setPusher({
|
||||
kind: 'http',
|
||||
app_id: appId,
|
||||
pushkey: endpoint,
|
||||
app_display_name: 'Paarrot',
|
||||
device_display_name: deviceId ?? 'Android',
|
||||
lang: 'en',
|
||||
data: {
|
||||
url: gatewayUrl,
|
||||
format: 'event_id_only',
|
||||
},
|
||||
append: false,
|
||||
device_id: deviceId ?? undefined,
|
||||
} as unknown as IPusherRequest);
|
||||
|
||||
const pushers = (await mx.getPushers())?.pushers ?? [];
|
||||
const thisPusher = pushers.find((p) => p.pushkey === endpoint && p.app_id === appId);
|
||||
console.log('[BackgroundSync] Matrix pusher upserted successfully', {
|
||||
totalPushers: pushers.length,
|
||||
foundThisPusher: Boolean(thisPusher),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[BackgroundSync] Matrix pusher upsert failed:', err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
saveStoredPusherState(userId, deviceId, { endpoint, appId });
|
||||
}
|
||||
|
||||
/** Remove a previously-registered Matrix HTTP pusher for this device. */
|
||||
private async removePusher(
|
||||
mx: MatrixClient,
|
||||
endpoint: string,
|
||||
appId = buildPusherAppId(mx.getDeviceId())
|
||||
): Promise<void> {
|
||||
try {
|
||||
await mx.setPusher({
|
||||
pushkey: endpoint,
|
||||
app_id: appId,
|
||||
kind: null,
|
||||
} as unknown as IPusherRequest);
|
||||
} catch (err) {
|
||||
console.warn('[BackgroundSync] Failed to remove UnifiedPush pusher:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read native registration state without failing the caller. */
|
||||
private async safeGetStatus(): Promise<UnifiedPushStatus | undefined> {
|
||||
try {
|
||||
return await MatrixBackgroundSync.getStatus();
|
||||
} catch (err) {
|
||||
console.warn('[BackgroundSync] Failed to read UnifiedPush status:', err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unifiedPushManager = new AndroidUnifiedPushManager();
|
||||
|
||||
/** Start native UnifiedPush registration and sync the Matrix pusher. */
|
||||
export const startBackgroundSync = async (mx: MatrixClient): Promise<void> => {
|
||||
await unifiedPushManager.start(mx);
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops the native Matrix background sync service.
|
||||
* Call on logout so the service stops and credentials are wiped.
|
||||
* Manually triggers a one-shot native fetch.
|
||||
* Useful for diagnostics and bridge testing.
|
||||
*/
|
||||
export const stopBackgroundSync = async (): Promise<void> => {
|
||||
export const triggerBackgroundSyncPing = async (reason?: string): Promise<void> => {
|
||||
if (!isBackgroundSyncSupported()) return;
|
||||
|
||||
try {
|
||||
await MatrixBackgroundSync.stop();
|
||||
console.log('[BackgroundSync] Service stopped');
|
||||
await MatrixBackgroundSync.triggerPing({ reason });
|
||||
} catch (err) {
|
||||
console.warn('[BackgroundSync] Failed to stop:', err);
|
||||
console.warn('[BackgroundSync] triggerPing failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
/** Stop UnifiedPush integration and remove the Matrix pusher for this session. */
|
||||
export const stopBackgroundSync = async (): Promise<void> => {
|
||||
await unifiedPushManager.stop();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tells the native service whether the app UI is in the foreground.
|
||||
* Call when document visibility changes so the service can suppress
|
||||
@@ -89,3 +484,35 @@ export const setAppForegroundState = async (foreground: boolean): Promise<void>
|
||||
console.warn('[BackgroundSync] setAppForeground failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-opens the UnifiedPush distributor selection dialog.
|
||||
* Allows users to re-select or change their push notification distributor without terminal access.
|
||||
* Useful when the current endpoint becomes unavailable.
|
||||
*/
|
||||
export const requestResetPushRegistration = async (): Promise<{ success: boolean }> => {
|
||||
if (!isBackgroundSyncSupported()) {
|
||||
console.warn('[BackgroundSync] Background sync not supported on this platform');
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await MatrixBackgroundSync.requestDistributorSetup();
|
||||
console.log('[BackgroundSync] requestDistributorSetup completed:', result);
|
||||
return result ?? { success: false };
|
||||
} catch (err) {
|
||||
console.error('[BackgroundSync] requestDistributorSetup failed:', err);
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
|
||||
/** Returns the current native UnifiedPush status, or undefined if unavailable. */
|
||||
export const getBackgroundSyncStatus = async (): Promise<UnifiedPushStatus | undefined> => {
|
||||
if (!isBackgroundSyncSupported()) return undefined;
|
||||
try {
|
||||
return await MatrixBackgroundSync.getStatus();
|
||||
} catch (err) {
|
||||
console.warn('[BackgroundSync] getStatus failed:', err);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -248,6 +248,24 @@ export const isAndroid = (): boolean => {
|
||||
return ua.includes('android');
|
||||
};
|
||||
|
||||
/** Returns true when running as a Capacitor app on Android. */
|
||||
export const isCapacitorAndroid = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const cap = (window as { Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string } }).Capacitor;
|
||||
return Boolean(cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android');
|
||||
};
|
||||
|
||||
/**
|
||||
* Android WebView: keep emoji fonts out of the body text stack and use system UI fonts.
|
||||
*/
|
||||
export const applyAndroidTypographyFix = (): void => {
|
||||
if (!isCapacitorAndroid()) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
root.classList.add('android-capacitor');
|
||||
root.style.setProperty('--font-secondary', 'system-ui, Roboto, "Noto Sans", sans-serif');
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply safe area insets for mobile devices
|
||||
* On Android, CSS env() may not work, so we apply fallback padding
|
||||
|
||||
18
overlay/src/font-setup.ts
Normal file
18
overlay/src/font-setup.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
const cap =
|
||||
typeof window !== 'undefined'
|
||||
? (window as { Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string } }).Capacitor
|
||||
: undefined;
|
||||
|
||||
export const isCapacitorAndroid = Boolean(
|
||||
cap?.isNativePlatform?.() && cap?.getPlatform?.() === 'android'
|
||||
);
|
||||
|
||||
if (isCapacitorAndroid) {
|
||||
document.documentElement.classList.add('android-capacitor');
|
||||
document.documentElement.style.setProperty(
|
||||
'--font-secondary',
|
||||
'system-ui, Roboto, "Noto Sans", sans-serif'
|
||||
);
|
||||
} else {
|
||||
await import('@fontsource-variable/inter');
|
||||
}
|
||||
523
overlay/src/index.css
Normal file
523
overlay/src/index.css
Normal file
@@ -0,0 +1,523 @@
|
||||
/* Twemoji font (Twitter emoji) */
|
||||
@font-face {
|
||||
font-family: Twemoji;
|
||||
src: url('../public/font/Twemoji.Mozilla.v15.1.0.woff2'),
|
||||
url('../public/font/Twemoji.Mozilla.v15.1.0.ttf');
|
||||
font-display: swap;
|
||||
unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
|
||||
}
|
||||
|
||||
/* Apple Color Emoji - uses local Apple Color Emoji if available (macOS/iOS),
|
||||
falls back to Segoe UI Emoji (Windows) or Noto (Linux/Android) */
|
||||
@font-face {
|
||||
font-family: AppleColorEmoji;
|
||||
src: local('Apple Color Emoji'),
|
||||
local('Segoe UI Emoji'),
|
||||
local('Noto Color Emoji');
|
||||
font-display: swap;
|
||||
unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
|
||||
}
|
||||
|
||||
/* System emoji - uses native OS emoji */
|
||||
@font-face {
|
||||
font-family: SystemEmoji;
|
||||
src: local('Apple Color Emoji'),
|
||||
local('Segoe UI Emoji'),
|
||||
local('Segoe UI Symbol'),
|
||||
local('Noto Color Emoji'),
|
||||
local('Android Emoji'),
|
||||
local('EmojiOne Color');
|
||||
font-display: swap;
|
||||
unicode-range: U+1F300-1F9FF, U+2600-26FF, U+2700-27BF, U+FE0F, U+200D, U+1F1E6-1F1FF;
|
||||
}
|
||||
|
||||
:root {
|
||||
--tc-link: hsl(213deg 100% 45%);
|
||||
|
||||
/* user mxid colors */
|
||||
--mx-uc-1: hsl(208, 100%, 45%);
|
||||
--mx-uc-2: hsl(302, 100%, 30%);
|
||||
--mx-uc-3: hsl(163, 100%, 30%);
|
||||
--mx-uc-4: hsl(343, 100%, 45%);
|
||||
--mx-uc-5: hsl(24, 100%, 45%);
|
||||
--mx-uc-6: hsl(181, 100%, 30%);
|
||||
--mx-uc-7: hsl(242, 100%, 45%);
|
||||
--mx-uc-8: hsl(94, 100%, 35%);
|
||||
|
||||
--font-emoji: 'AppleColorEmoji';
|
||||
--font-secondary: 'InterVariable', sans-serif;
|
||||
}
|
||||
|
||||
.dark-theme,
|
||||
.butter-theme,
|
||||
.discord-theme,
|
||||
.discord-darker-theme,
|
||||
.twilight-theme,
|
||||
.mocha-theme {
|
||||
--tc-link: hsl(213deg 100% 80%);
|
||||
|
||||
--mx-uc-1: hsl(208, 100%, 75%);
|
||||
--mx-uc-2: hsl(301, 100%, 80%);
|
||||
--mx-uc-3: hsl(163, 100%, 70%);
|
||||
--mx-uc-4: hsl(343, 100%, 75%);
|
||||
--mx-uc-5: hsl(24, 100%, 70%);
|
||||
--mx-uc-6: hsl(181, 100%, 60%);
|
||||
--mx-uc-7: hsl(243, 100%, 80%);
|
||||
--mx-uc-8: hsl(94, 100%, 80%);
|
||||
|
||||
--font-secondary: 'InterVariable', sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
/* Enable safe area variables */
|
||||
--safe-area-inset-top: env(safe-area-inset-top, 0px);
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-area-inset-left: env(safe-area-inset-left, 0px);
|
||||
--safe-area-inset-right: env(safe-area-inset-right, 0px);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* Safe area insets for mobile devices (notch, navigation bar) */
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
height: 100%;
|
||||
font-family: var(--font-secondary);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
/* Default to dark theme background for safe areas */
|
||||
background-color: #262626;
|
||||
|
||||
/*Why font-variant-ligatures => https://github.com/rsms/inter/issues/222 */
|
||||
font-variant-ligatures: no-contextual;
|
||||
}
|
||||
|
||||
/* Prevent zalgo text and combining diacritics from overflowing line boundaries */
|
||||
p, span, div, a, button, li, td, th, h1, h2, h3, h4, h5, h6 {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Theme-specific body backgrounds for safe area padding */
|
||||
body.light-theme {
|
||||
background-color: #F0F0F0;
|
||||
}
|
||||
body.silver-theme {
|
||||
background-color: #DEDEDE;
|
||||
}
|
||||
body.dark-theme {
|
||||
background-color: #262626;
|
||||
}
|
||||
body.butter-theme {
|
||||
background-color: #1A1916;
|
||||
}
|
||||
body.discord-theme {
|
||||
background-color: #2B2D31;
|
||||
}
|
||||
body.discord-darker-theme {
|
||||
background-color: #1E1F22;
|
||||
}
|
||||
body.twilight-theme {
|
||||
background: linear-gradient(135deg, #14121F 0%, #1C1A2E 50%, #24223D 100%);
|
||||
}
|
||||
body.mocha-theme {
|
||||
background: linear-gradient(135deg, #1A1614 0%, #242019 50%, #2D2721 100%);
|
||||
}
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #262626;
|
||||
}
|
||||
|
||||
.twilight-theme #root {
|
||||
background: linear-gradient(180deg, rgba(28, 26, 46, 0.5) 0%, rgba(36, 34, 61, 0.3) 100%);
|
||||
}
|
||||
|
||||
.mocha-theme #root {
|
||||
background: linear-gradient(180deg, rgba(36, 32, 25, 0.5) 0%, rgba(45, 39, 33, 0.3) 100%);
|
||||
}
|
||||
|
||||
/* Twilight theme gradient overlays */
|
||||
.twilight-theme [role="navigation"],
|
||||
.twilight-theme aside {
|
||||
background: linear-gradient(180deg, rgba(20, 18, 31, 0.8) 0%, rgba(28, 26, 46, 0.9) 100%);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.twilight-theme main {
|
||||
background: linear-gradient(135deg, rgba(24, 22, 45, 0.4) 0%, rgba(36, 34, 61, 0.3) 100%);
|
||||
}
|
||||
|
||||
.twilight-theme header {
|
||||
background: linear-gradient(90deg, rgba(20, 18, 31, 0.9) 0%, rgba(28, 26, 46, 0.8) 100%);
|
||||
}
|
||||
|
||||
/* CSS animations for route transitions */
|
||||
@keyframes fadeSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Apply transition to route changes - works on ALL themes */
|
||||
[data-route-transition="true"] {
|
||||
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Disable route slide animation on compact/mobile — conflicts with swipe gestures */
|
||||
@media (max-width: 750px) {
|
||||
[data-route-transition='true'] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
html.mobile-gesture-lock,
|
||||
html.mobile-gesture-lock body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
html.mobile-gesture-lock [data-timeline-scroll] {
|
||||
overflow: hidden !important;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* Twilight theme enhanced transitions */
|
||||
.twilight-theme [data-route-transition="true"] {
|
||||
animation: fadeSlideIn 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Also apply to main content areas for double coverage */
|
||||
.twilight-theme [data-room-view],
|
||||
.twilight-theme [data-space-view],
|
||||
.twilight-theme [data-inbox-view],
|
||||
.twilight-theme [data-explore-view] {
|
||||
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Smooth transitions for interactive elements */
|
||||
.twilight-theme button,
|
||||
.twilight-theme [role="button"],
|
||||
.twilight-theme a,
|
||||
.twilight-theme input,
|
||||
.twilight-theme textarea {
|
||||
transition: background-color 0.2s ease, color 0.2s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.twilight-theme button:hover,
|
||||
.twilight-theme [role="button"]:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.twilight-theme button:active,
|
||||
.twilight-theme [role="button"]:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Message transitions */
|
||||
.twilight-theme [data-message-item] {
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.twilight-theme [data-message-item]:hover {
|
||||
background: linear-gradient(90deg, rgba(62, 58, 106, 0.15) 0%, rgba(86, 80, 150, 0.1) 100%);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
/* Selected item indicator */
|
||||
.twilight-theme [data-selected="true"],
|
||||
.twilight-theme [aria-selected="true"],
|
||||
.twilight-theme [aria-current="true"] {
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.twilight-theme [data-selected="true"]::before,
|
||||
.twilight-theme [aria-selected="true"]::before,
|
||||
.twilight-theme [aria-current="true"]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: linear-gradient(180deg, #8B7FFF 0%, #6E62E8 100%);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.twilight-theme ::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.twilight-theme ::-webkit-scrollbar-track {
|
||||
background: rgba(20, 18, 31, 0.5);
|
||||
}
|
||||
|
||||
.twilight-theme ::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, #3E3A6A 0%, #565096 100%);
|
||||
border-radius: 5px;
|
||||
border: 2px solid rgba(20, 18, 31, 0.5);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.twilight-theme ::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, #4A4580 0%, #625BAC 100%);
|
||||
}
|
||||
|
||||
/* Mocha theme gradient overlays */
|
||||
.mocha-theme [role="navigation"],
|
||||
.mocha-theme aside {
|
||||
background: linear-gradient(180deg, rgba(26, 22, 20, 0.8) 0%, rgba(36, 32, 25, 0.9) 100%);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.mocha-theme main {
|
||||
background: linear-gradient(135deg, rgba(36, 32, 25, 0.4) 0%, rgba(45, 39, 33, 0.3) 100%);
|
||||
}
|
||||
|
||||
.mocha-theme header {
|
||||
background: linear-gradient(90deg, rgba(26, 22, 20, 0.9) 0%, rgba(36, 32, 25, 0.8) 100%);
|
||||
}
|
||||
|
||||
/* Mocha theme enhanced transitions */
|
||||
.mocha-theme [data-route-transition="true"] {
|
||||
animation: fadeSlideIn 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.mocha-theme [data-room-view],
|
||||
.mocha-theme [data-space-view],
|
||||
.mocha-theme [data-inbox-view],
|
||||
.mocha-theme [data-explore-view] {
|
||||
animation: fadeSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Smooth transitions for interactive elements */
|
||||
.mocha-theme button,
|
||||
.mocha-theme [role="button"],
|
||||
.mocha-theme a,
|
||||
.mocha-theme input,
|
||||
.mocha-theme textarea {
|
||||
transition: background-color 0.2s ease, color 0.2s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.mocha-theme button:hover,
|
||||
.mocha-theme [role="button"]:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mocha-theme button:active,
|
||||
.mocha-theme [role="button"]:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Message transitions */
|
||||
.mocha-theme [data-message-item] {
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.mocha-theme [data-message-item]:hover {
|
||||
background: linear-gradient(90deg, rgba(84, 73, 63, 0.15) 0%, rgba(112, 97, 81, 0.1) 100%);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
/* Selected item indicator */
|
||||
.mocha-theme [data-selected="true"],
|
||||
.mocha-theme [aria-selected="true"],
|
||||
.mocha-theme [aria-current="true"] {
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mocha-theme [data-selected="true"]::before,
|
||||
.mocha-theme [aria-selected="true"]::before,
|
||||
.mocha-theme [aria-current="true"]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: linear-gradient(180deg, #D4A574 0%, #B48660 100%);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.mocha-theme ::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.mocha-theme ::-webkit-scrollbar-track {
|
||||
background: rgba(26, 22, 20, 0.5);
|
||||
}
|
||||
|
||||
.mocha-theme ::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, #54493F 0%, #706151 100%);
|
||||
border-radius: 5px;
|
||||
border: 2px solid rgba(26, 22, 20, 0.5);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.mocha-theme ::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, #625548 0%, #7E6D5A 100%);
|
||||
}
|
||||
|
||||
/* Subtle dot pattern overlay */
|
||||
.mocha-theme::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(rgba(212, 165, 116, 0.03) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.mocha-theme #root {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Focus states */
|
||||
.mocha-theme input:focus,
|
||||
.mocha-theme textarea:focus,
|
||||
.mocha-theme button:focus-visible {
|
||||
outline: 2px solid rgba(212, 165, 116, 0.5);
|
||||
outline-offset: 2px;
|
||||
transition: outline 0.2s ease;
|
||||
}
|
||||
|
||||
/* Subtle dot pattern overlay */
|
||||
.twilight-theme::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(rgba(139, 127, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.twilight-theme #root {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Focus states */
|
||||
.twilight-theme input:focus,
|
||||
.twilight-theme textarea:focus,
|
||||
.twilight-theme button:focus-visible {
|
||||
outline: 2px solid rgba(139, 127, 255, 0.5);
|
||||
outline-offset: 2px;
|
||||
transition: outline 0.2s ease;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
a {
|
||||
color: var(--tc-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
[data-mx-spoiler][aria-pressed='true'] a {
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
b {
|
||||
font-weight: 500;
|
||||
}
|
||||
label {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
button,
|
||||
textarea {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
letter-spacing: inherit;
|
||||
border: none;
|
||||
}
|
||||
button {
|
||||
max-width: 100%;
|
||||
text-transform: none;
|
||||
text-align: inherit;
|
||||
overflow: visible;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
textarea,
|
||||
input,
|
||||
input[type],
|
||||
input[type='text'],
|
||||
input[type='username'],
|
||||
input[type='password'],
|
||||
input[type='email'],
|
||||
input[type='checkbox'] {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
color: inherit;
|
||||
word-spacing: inherit;
|
||||
}
|
||||
|
||||
audio:not([controls]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Android WebView: Inter Variable + emoji fonts in the text stack break space metrics */
|
||||
html.android-capacitor,
|
||||
html.android-capacitor.dark-theme,
|
||||
html.android-capacitor.butter-theme,
|
||||
html.android-capacitor.discord-theme,
|
||||
html.android-capacitor.discord-darker-theme,
|
||||
html.android-capacitor.twilight-theme,
|
||||
html.android-capacitor.mocha-theme,
|
||||
html.android-capacitor.light-theme,
|
||||
html.android-capacitor.silver-theme {
|
||||
--font-secondary: system-ui, Roboto, 'Noto Sans', sans-serif;
|
||||
}
|
||||
|
||||
html.android-capacitor body {
|
||||
font-family: system-ui, Roboto, 'Noto Sans', sans-serif;
|
||||
letter-spacing: normal;
|
||||
word-spacing: normal;
|
||||
font-variant-ligatures: normal;
|
||||
}
|
||||
112
overlay/src/index.tsx
Normal file
112
overlay/src/index.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/* eslint-disable import/first */
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { enableMapSet } from 'immer';
|
||||
|
||||
await import('./font-setup');
|
||||
|
||||
import 'folds/dist/style.css';
|
||||
import { configClass, varsClass } from 'folds';
|
||||
|
||||
enableMapSet();
|
||||
|
||||
import './index.css';
|
||||
|
||||
import { trimTrailingSlash } from './app/utils/common';
|
||||
import App from './app/pages/App';
|
||||
import { applySafeAreaInsets, isTauri } from './app/utils/tauri';
|
||||
import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions';
|
||||
|
||||
// import i18n (needs to be bundled ;))
|
||||
import './app/i18n';
|
||||
|
||||
document.body.classList.add(configClass, varsClass);
|
||||
|
||||
// Apply safe area insets for mobile devices
|
||||
applySafeAreaInsets();
|
||||
|
||||
// Enable View Transitions API for smooth navigation
|
||||
enableViewTransitionsForNavigation();
|
||||
|
||||
// Register Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
const swUrl =
|
||||
import.meta.env.MODE === 'production'
|
||||
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
|
||||
: `/dev-sw.js?dev-sw`;
|
||||
|
||||
navigator.serviceWorker.register(swUrl);
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'token' && event.data?.responseKey) {
|
||||
const getCurrentAccessToken = async () => {
|
||||
const { getCurrentAccessToken: getToken } = await import('./app/utils/auth');
|
||||
return getToken();
|
||||
};
|
||||
|
||||
getCurrentAccessToken().then((token) => {
|
||||
event.source!.postMessage({
|
||||
responseKey: event.data.responseKey,
|
||||
token,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const isElectron = (): boolean => 'electron' in window;
|
||||
|
||||
async function checkForUpdates() {
|
||||
if (isElectron()) {
|
||||
console.log('Update check skipped - Electron handles updates natively');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTauri() || isElectron()) {
|
||||
console.log('Update check skipped - not running in Tauri');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Checking for updates...');
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater');
|
||||
const { ask } = await import('@tauri-apps/plugin-dialog');
|
||||
const { relaunch } = await import('@tauri-apps/plugin-process');
|
||||
|
||||
const update = await check();
|
||||
console.log('Update check result:', update);
|
||||
if (update) {
|
||||
console.log(`Update available: ${update.version}`);
|
||||
const shouldUpdate = await ask(
|
||||
`A new version (${update.version}) is available. Would you like to update now?`,
|
||||
{ title: 'Update Available', kind: 'info' }
|
||||
);
|
||||
if (shouldUpdate) {
|
||||
console.log('User chose to update, downloading...');
|
||||
await update.downloadAndInstall();
|
||||
await relaunch();
|
||||
}
|
||||
} else {
|
||||
console.log('App is up to date');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const mountApp = () => {
|
||||
const rootContainer = document.getElementById('root');
|
||||
|
||||
if (rootContainer === null) {
|
||||
console.error('Root container element not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
const root = createRoot(rootContainer);
|
||||
root.render(<App />);
|
||||
};
|
||||
|
||||
mountApp();
|
||||
|
||||
setTimeout(() => {
|
||||
checkForUpdates();
|
||||
}, 3000);
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paarrot",
|
||||
"version": "4.11.79",
|
||||
"version": "4.11.116",
|
||||
"description": "Paarrot - A Matrix client based on Cinny",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
@@ -30,6 +30,14 @@ const STASH_FLAG = join(ROOT, '.cinny-stash-pending');
|
||||
const cinnyStatus = execSync('git status --porcelain', { cwd: CINNY, encoding: 'utf8' });
|
||||
if (cinnyStatus.trim()) {
|
||||
console.log('[apply-overlay] Stashing pre-existing cinny changes...');
|
||||
// Ensure android/ junction is excluded from the cinny git view — otherwise
|
||||
// `git stash -u` would traverse the junction into the root android/ directory
|
||||
// and delete files there when the stash is created.
|
||||
const cinnyExclude = join(CINNY, '.git', 'info', 'exclude');
|
||||
const excludeContent = existsSync(cinnyExclude) ? readFileSync(cinnyExclude, 'utf8') : '';
|
||||
if (!excludeContent.includes('android/')) {
|
||||
writeFileSync(cinnyExclude, excludeContent + (excludeContent.endsWith('\n') || !excludeContent ? '' : '\n') + 'android/\n', 'utf8');
|
||||
}
|
||||
execSync('git stash push -u -m "pre-overlay stash"', { cwd: CINNY, stdio: 'inherit' });
|
||||
writeFileSync(STASH_FLAG, '1', 'utf8');
|
||||
console.log('[apply-overlay] Stash saved.');
|
||||
@@ -120,21 +128,19 @@ if (!existsSync(androidJunction)) {
|
||||
console.log(` android/ link already exists, skipping`);
|
||||
}
|
||||
|
||||
// 8. Sync version from root package.json into android/app/build.gradle
|
||||
console.log('[apply-overlay] Syncing version into build.gradle...');
|
||||
const rootPkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
|
||||
const version = rootPkg.version ?? '1.0.0';
|
||||
const parts = version.split('.').map(Number);
|
||||
// versionCode: major*10000 + minor*100 + patch (e.g. 4.11.78 -> 41178)
|
||||
const versionCode = (parts[0] ?? 0) * 10000 + (parts[1] ?? 0) * 100 + (parts[2] ?? 0);
|
||||
// 8. Sync time-based version into android/app/build.gradle
|
||||
console.log('[apply-overlay] Syncing time-based version into build.gradle...');
|
||||
const buildTimestampMs = Date.now();
|
||||
const versionCode = Math.floor(buildTimestampMs / 1000);
|
||||
const versionName = new Date(buildTimestampMs).toISOString().replace('T', '.').replaceAll(':', '').replace('Z', '');
|
||||
|
||||
const buildGradlePath = join(androidTarget, 'app', 'build.gradle');
|
||||
let buildGradle = readFileSync(buildGradlePath, 'utf8');
|
||||
buildGradle = buildGradle
|
||||
.replace(/versionCode\s+\d+/, `versionCode ${versionCode}`)
|
||||
.replace(/versionName\s+"[^"]+"/, `versionName "${version}"`);
|
||||
.replace(/versionName\s+"[^"]+"/, `versionName "${versionName}"`);
|
||||
writeFileSync(buildGradlePath, buildGradle, 'utf8');
|
||||
console.log(` versionName "${version}", versionCode ${versionCode}`);
|
||||
console.log(` versionName "${versionName}", versionCode ${versionCode}`);
|
||||
|
||||
// 9. Write android/local.properties with the current machine's Android SDK path
|
||||
const androidHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
||||
|
||||
Reference in New Issue
Block a user