diff --git a/.eslintrc.cjs b/.eslintrc.cjs index a30cb4f0..bcfccb0d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -60,10 +60,12 @@ module.exports = { '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], '@typescript-eslint/no-shadow': 'error', - // Policy: kept as warnings, not errors. The codebase has ~70 long-standing - // `any` casts and ~15 non-null assertions in matrix-js-sdk interop code. - // Promoting to error would block builds on existing usage; turning off - // would lose signal on new code. Warnings are visible without blocking. + // Policy: kept as `warn` at the rule level so editors / `eslint --fix` / + // ad-hoc runs surface them as warnings, but `npm run check:eslint` and + // `lint-staged` BOTH pass `--max-warnings 0`, so new occurrences block + // commit. When unavoidable (matrix-js-sdk boundary, generic helpers, + // third-party callback shapes), suppress on the line with + // `// eslint-disable-next-line` and a one-line justification. '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-non-null-assertion': 'warn', }, @@ -86,6 +88,11 @@ module.exports = { 'no-plusplus': 'off', 'prefer-template': 'off', 'no-param-reassign': 'off', + // `for (;;)` form upstream uses for the iter-loops trips eslint + // even though it's intentional — keep upstream control flow. + 'no-constant-condition': 'off', + // Diagnostic `console.log` left as-is in vendor copy. + 'no-console': 'off', }, }, ], diff --git a/.gitignore b/.gitignore index f23ffa9f..44b8a204 100644 --- a/.gitignore +++ b/.gitignore @@ -4,12 +4,24 @@ node_modules devAssets config.local.json +electron/dist-electron +release + .DS_Store .idea -.vscode +.vscode/* +!.vscode/tasks.json .codex .claude -docs/ai/desired_features.md -docs/ai/bugs.md docs/plans -docs \ No newline at end of file +docs/design +docs/ai/* +!docs/ai/README.md +!docs/ai/android.md +!docs/ai/architecture.md +!docs/ai/electron.md +!docs/ai/i18n.md +!docs/ai/overview.md +!docs/ai/server-side.md + +vite.config.*.timestamp-*.mjs diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100644 new mode 100755 index 7aea7a8d..e55b74d6 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,2 @@ -# These are commented until we enable lint and typecheck -# npx tsc -p tsconfig.json --noEmit -# npx lint-staged \ No newline at end of file +npx tsc -p tsconfig.json --noEmit +npx lint-staged diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..94120e62 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,104 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Deploy to vojo.chat", + "type": "shell", + "command": "npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/cinny/", + "group": "none", + "presentation": { + "reveal": "always", + "panel": "shared", + "showReuseMessage": false + }, + "problemMatcher": [] + }, + { + "label": "Deploy widgets", + "type": "shell", + "command": "(cd apps/widget-telegram && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/telegram/) & PID1=$!; (cd apps/widget-discord && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/discord/) & PID2=$!; (cd apps/widget-whatsapp && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/whatsapp/) & PID3=$!; FAIL=0; wait $PID1 || FAIL=1; wait $PID2 || FAIL=1; wait $PID3 || FAIL=1; exit $FAIL", + "group": "none", + "presentation": { + "reveal": "always", + "panel": "shared", + "showReuseMessage": false + }, + "problemMatcher": [] + }, + { + "label": "Build Android APK", + "type": "shell", + "command": "npm run build:android:debug", + "group": "none", + "presentation": { + "reveal": "always", + "panel": "shared", + "showReuseMessage": false + }, + "problemMatcher": [] + }, + { + "label": "Deploy to Android (ADB)", + "type": "shell", + "command": "npm run build:android:debug && adb install -r android/app/build/outputs/apk/debug/app-debug.apk", + "group": "none", + "presentation": { + "reveal": "always", + "panel": "shared", + "showReuseMessage": false + }, + "problemMatcher": [] + }, + { + "label": "Connect to Android device (ADB)", + "type": "shell", + "command": "adb connect 192.168.1.204:5555", + "group": "none", + "presentation": { + "reveal": "always", + "panel": "shared", + "showReuseMessage": false + }, + "problemMatcher": [] + }, + { + "label": "Start Electron (dev)", + "type": "shell", + "command": "npm run electron:dev", + "group": "none", + "presentation": { + "reveal": "always", + "panel": "shared", + "showReuseMessage": false + }, + "problemMatcher": [] + }, + { + "label": "Build Electron Windows", + "type": "shell", + "command": "npm run build:electron:win", + "group": "none", + "presentation": { + "reveal": "always", + "panel": "shared", + "showReuseMessage": false + }, + "problemMatcher": [] + }, + { + "label": "Deploy Discord bridge", + "type": "shell", + "command": "docker build -t vojo-mautrix-discord:custom . && docker save vojo-mautrix-discord:custom | gzip | ssh vojo-superuser@187.127.77.124 'gunzip | docker load'", + "options": { + "cwd": "${workspaceFolder}/../vojo-mautrix-discord" + }, + "group": "none", + "presentation": { + "reveal": "always", + "panel": "shared", + "showReuseMessage": false + }, + "problemMatcher": [] + } + ] +} diff --git a/android/app/build.gradle b/android/app/build.gradle index 7fca7976..989cbaa0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,8 +1,29 @@ apply plugin: 'com.android.application' -def packageJson = new groovy.json.JsonSlurper().parseText(file('../../package.json').text) -def semver = packageJson.version.split('\\.') -def computedVersionCode = semver[0].toInteger() * 1000000 + semver[1].toInteger() * 1000 + semver[2].toInteger() +// Mirror of resolveAppVersion() in ../../vite.config.js so the APK's +// versionName matches __APP_VERSION__ rendered in the About screen. +// `git describe --tags --match 'v*'` against tag v0.2.0 yields +// `v0.2.0--g`; patch = commit count since the tag. +// Falls back to package.json only when git is unavailable. +def gitDescribe = providers.exec { + it.commandLine 'git', 'describe', '--tags', '--match', 'v*', '--always' + it.workingDir rootDir.parentFile + it.ignoreExitValue = true +} +def appVersion = { + def fromGit = gitDescribe.result.get().exitValue == 0 ? gitDescribe.standardOutput.asText.get().trim() : null + def m = fromGit =~ /^v?(\d+)\.(\d+)\.(\d+)(?:-(\d+)-g[0-9a-f]+)?$/ + if (fromGit && m.matches()) { + def major = m[0][1].toInteger() + def minor = m[0][2].toInteger() + def patch = (m[0][4] ?: m[0][3]).toInteger() + return [name: "${major}.${minor}.${patch}", major: major, minor: minor, patch: patch] + } + def pkg = new groovy.json.JsonSlurper().parseText(file('../../package.json').text) + def parts = pkg.version.split('\\.') + return [name: pkg.version, major: parts[0].toInteger(), minor: parts[1].toInteger(), patch: parts[2].toInteger()] +}() +def computedVersionCode = appVersion.major * 1000000 + appVersion.minor * 1000 + appVersion.patch android { namespace = "chat.vojo.app" @@ -12,7 +33,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode computedVersionCode - versionName packageJson.version + versionName appVersion.name testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. @@ -20,12 +41,6 @@ android { ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' } } - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - } - } // AGP 8+ requires explicit opt-in for BuildConfig generation. We rely on // BuildConfig.DEBUG to gate Log.d calls that dump privacy-sensitive // identifiers (roomId, eventId) so release builds don't leak them through @@ -33,6 +48,26 @@ android { buildFeatures { buildConfig = true } + + signingConfigs { + release { + if (project.hasProperty('VOJO_RELEASE_STORE_FILE')) { + storeFile file(VOJO_RELEASE_STORE_FILE) + storePassword VOJO_RELEASE_STORE_PASSWORD + keyAlias VOJO_RELEASE_KEY_ALIAS + keyPassword VOJO_RELEASE_KEY_PASSWORD + } + } + } + + buildTypes { + release { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + signingConfig signingConfigs.release + } + } } repositories { @@ -52,6 +87,11 @@ dependencies { // already depends on firebase-messaging but declares it `implementation` // so classes aren't exposed at app-module compile time. implementation "com.google.firebase:firebase-messaging:25.0.1" + // WorkManager hosts VojoPollWorker — periodic /notifications poll that + // delivers messages and missed-call surfaces on networks where FCM + // (mtalk.google.com:5228) is blocked. Library self-registers its scheduler + // in the merged manifest; we declare no permission for it. + implementation "androidx.work:work-runtime:2.10.0" testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index f1b42451..2f8647a9 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -19,3 +19,27 @@ # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile + +# Keep custom app classes — entry points invoked by Android system (Intents, +# FCM, AndroidManifest references) or by JS bridge via reflection. +-keep class chat.vojo.app.MainActivity { *; } +-keep class chat.vojo.app.VojoFirebaseMessagingService { *; } +-keep class chat.vojo.app.CallForegroundPlugin { *; } +-keep class chat.vojo.app.CallForegroundService { *; } +-keep class chat.vojo.app.CallDeclineReceiver { *; } +-keep class chat.vojo.app.CallCancelReceiver { *; } +-keep class chat.vojo.app.FullScreenIntentPlugin { *; } +-keep class chat.vojo.app.LaunchSplashPlugin { *; } + +# Firebase Messaging — receivers/services resolved by Android via manifest. +-keep public class * extends com.google.firebase.messaging.FirebaseMessagingService +-keep class com.google.firebase.iid.** { *; } +-keep class com.google.firebase.messaging.** { *; } + +# Capacitor — plugins discovered by annotation/reflection. +-keep @com.getcapacitor.annotation.CapacitorPlugin class * { *; } +-keep class com.getcapacitor.** { *; } +-keep class com.getcapacitor.plugin.** { *; } + +# AndroidX splashscreen — reflection paths. +-keep class androidx.core.splashscreen.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5bcf9e68..3deb0cf9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -46,6 +46,30 @@ android:pathPrefix="/u/" /> + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/chat/vojo/app/AvatarBitmapCache.java b/android/app/src/main/java/chat/vojo/app/AvatarBitmapCache.java new file mode 100644 index 00000000..8aaa15db --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/AvatarBitmapCache.java @@ -0,0 +1,65 @@ +package chat.vojo.app; + +import android.graphics.Bitmap; +import android.util.LruCache; + +/** + * In-memory LRU cache of decoded avatar bitmaps keyed by MXC URL string. + * + * Sized as a process-singleton (~4 MB) so the FCM service, polling Worker + * and ReplyReceiver all share one pool. 96×96 ARGB_8888 bitmap is about + * 36 KB, so a 4 MB cache holds ~110 avatars — enough for the active + * conversation set on a typical user. LruCache evicts the least-recently- + * read entry when full; this is the right shape for "rooms the user is + * actively talking in stay warm, dormant rooms reload on demand". + * + * Thread-safety: LruCache itself is synchronized internally on every + * get/put/remove. We don't need an outer lock for normal operation. The + * AvatarLoader funnels all puts through this class. + * + * Process death: cache is in-memory only. After a kill, the first push + * to any room cold-renders without avatars and re-renders once the + * loader populates the cache (see AvatarLoader.loadAllWithTimeout). + */ +final class AvatarBitmapCache { + + // Heap budget: bytes. 4 MB is generous against ARGB_8888 96×96 bitmaps + // (~36 KB each) and stays comfortably under the 1/8-of-heap Android + // recommendation on every device we ship to (minSdk 24 → at least + // 96 MB heap on a low-end phone). + private static final int MAX_SIZE_BYTES = 4 * 1024 * 1024; + + private static final LruCache CACHE = + new LruCache(MAX_SIZE_BYTES) { + @Override + protected int sizeOf(String key, Bitmap value) { + return value.getByteCount(); + } + }; + + private AvatarBitmapCache() {} + + /** + * Returns the cached bitmap for an MXC URL, or null on miss. + * + * Bitmap references are NOT defensively copied — the cache hands out + * the same reference to every caller. This is safe because no code + * path in the app calls Bitmap.recycle() on a cached bitmap (the + * intermediate square / source bitmaps inside AvatarLoader. + * toCircularBitmap ARE recycled, but the circular output that lands + * here is held until LRU evicts it). LRU eviction simply drops the + * cache's reference, and the GC reclaims memory only after every + * Notification that referenced the bitmap is also released by the + * system. Adding a defensive copy here would halve the effective + * cache size for no real-world benefit. + */ + static Bitmap get(String mxc) { + if (mxc == null || mxc.isEmpty()) return null; + return CACHE.get(mxc); + } + + static void put(String mxc, Bitmap bitmap) { + if (mxc == null || mxc.isEmpty() || bitmap == null) return; + CACHE.put(mxc, bitmap); + } +} diff --git a/android/app/src/main/java/chat/vojo/app/AvatarLoader.java b/android/app/src/main/java/chat/vojo/app/AvatarLoader.java new file mode 100644 index 00000000..ca3c57c2 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/AvatarLoader.java @@ -0,0 +1,368 @@ +package chat.vojo.app; + +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.BitmapShader; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Shader; +import android.util.Log; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +/** + * Fetches and decodes avatar bitmaps from MXC URLs, populating + * {@link AvatarBitmapCache}. + * + * URL resolution mirrors matrix-js-sdk's auth-media v1.11+ pattern: + * mxc://server/mediaId + * → /_matrix/client/v1/media/thumbnail// + * ?width=96&height=96&method=crop + * + Authorization: Bearer + * + * The legacy unauthenticated `/_matrix/media/v3/thumbnail/...` endpoint is + * NOT used — every Synapse the Vojo audience runs against (vanilla, v1.11+ + * by deployment policy, see docs/ai/server-side.md) speaks auth media. + * Removing the legacy fallback keeps the loader off the deprecated path + * and avoids leaking the access token to a server route that doesn't + * require it. + * + * Concurrency: each MXC URL is fetched at most once concurrently — the + * `inFlight` set short-circuits duplicate requests from rapid + * append-rebuild cycles on the same conversation. Loads happen on a + * shared 4-thread pool; bigger than 1 so 5 senders in a group chat can + * load in parallel, capped to keep socket pressure under the typical + * mobile network budget. + * + * Two entry points: + * - {@link #loadAllWithTimeout}: synchronous wait, used by the render + * path to populate the cache before building the MessagingStyle so the + * first post already has avatars. Timeout-bounded to keep FCM thread + * responsive (Android budgets ~10s; we use 800 ms). + * - {@link #prefetch}: fire-and-forget, used for warm-up scenarios. + * Not currently called but kept for the room-metadata bridge to + * eventually warm the cache on visibility resume. + */ +final class AvatarLoader { + + private static final String TAG = "AvatarLoader"; + + private static final int AVATAR_SIZE_PX = 96; + private static final int CONNECT_TIMEOUT_MS = 5_000; + private static final int READ_TIMEOUT_MS = 5_000; + private static final int RENDER_BLOCK_TIMEOUT_MS = 800; + // Cap decoded bitmap byte count — a malicious / huge avatar shouldn't + // OOM the FCM service. 96×96 ARGB_8888 is ~36 KB; we accept up to + // 4× that (~140 KB) to allow some downscaling slack on servers that + // return slightly oversized thumbnails. + private static final int MAX_DECODED_BYTES = 144 * 1024; + + private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(4); + + // MXC URL → CountDownLatch that fires when the in-flight download + // completes (success or failure). A second caller observing an + // already-pending mxc waits on the SAME latch instead of either + // returning empty-handed or kicking off a duplicate fetch. Latches + // are removed by the worker task in its finally block; the same task + // that put the entry is the only one allowed to remove it, so a slow + // remove() race is harmless. + private static final ConcurrentHashMap inFlight = + new ConcurrentHashMap<>(); + + private AvatarLoader() {} + + /** + * Block the caller for up to {@link #RENDER_BLOCK_TIMEOUT_MS} while + * fetching any of the given MXC URLs that are not yet in + * {@link AvatarBitmapCache}. Cache hits are no-ops. Already-in-flight + * URLs are awaited via the shared latch — duplicate concurrent + * fetches do not happen. + * + * Designed to be called inline from the render path: after this + * returns, {@link AvatarBitmapCache#get} will be non-null for every + * MXC that loaded successfully within the budget. Failures are + * silent — the render then falls back to a Person without icon + * (Android renders initials/blank). + * + * Returns the count of avatars that landed in the cache during this + * call (purely informational — useful for logs). + */ + static int loadAllWithTimeout(Context ctx, Collection mxcs) { + if (mxcs == null || mxcs.isEmpty()) { + Log.i(TAG, "loadAll: empty input, skip"); + return 0; + } + SharedPreferences prefs = ctx.getSharedPreferences( + VojoPollWorker.PREFS, Context.MODE_PRIVATE); + String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null); + String homeserver = prefs.getString(VojoPollWorker.KEY_HOMESERVER_URL, null); + if (token == null || token.isEmpty() || homeserver == null || homeserver.isEmpty()) { + // No credentials yet (fresh install + first push). We can't + // resolve MXC URLs without an access token. Falling back to + // no-icon Person renderer is the correct behaviour here. + Log.i(TAG, "loadAll: no credentials in prefs, skip" + + " hasToken=" + (token != null && !token.isEmpty()) + + " hasHs=" + (homeserver != null && !homeserver.isEmpty())); + return 0; + } + // De-duplicate and filter to misses only; if the cache already has + // an entry, no work is needed. + Set toLoad = new LinkedHashSet<>(); + for (String mxc : mxcs) { + if (mxc == null || mxc.isEmpty()) continue; + if (!mxc.startsWith("mxc://")) continue; + if (AvatarBitmapCache.get(mxc) != null) continue; + toLoad.add(mxc); + } + if (toLoad.isEmpty()) return 0; + + // Per-mxc latches shared across concurrent callers — a second + // caller arriving while we're already mid-fetch waits on the + // SAME latch instead of forcing a duplicate HTTP or returning + // immediately empty-handed (which was the previous bug — see + // git blame for the race description). + java.util.List waits = new java.util.ArrayList<>(toLoad.size()); + for (String mxc : toLoad) { + CountDownLatch myLatch = new CountDownLatch(1); + CountDownLatch existing = inFlight.putIfAbsent(mxc, myLatch); + if (existing != null) { + // Already in flight — share the original latch. + waits.add(existing); + continue; + } + // We own this fetch; kick off the worker that will fire + // myLatch when done. + waits.add(myLatch); + final String capturedMxc = mxc; + final String capturedHomeserver = homeserver; + final String capturedToken = token; + EXECUTOR.execute(() -> { + try { + Bitmap bmp = fetchAndDecode(capturedMxc, capturedHomeserver, capturedToken); + if (bmp != null) AvatarBitmapCache.put(capturedMxc, bmp); + } catch (Throwable t) { + Log.w(TAG, "fetch threw mxc=" + capturedMxc, t); + } finally { + // Remove BEFORE countDown so a freshly-arriving caller + // doesn't observe a stale latch for an already-loaded + // mxc (would block until the next call with no fetch + // actually pending). Cache.get() on the post-await + // side covers the race where remove+put-cache happens + // between two latch waits. + inFlight.remove(capturedMxc); + myLatch.countDown(); + } + }); + } + // Single budget for the whole batch — wait for all latches OR + // hit the timeout. Latches that fire early just return await() + // immediately; the slowest one consumes the remainder of the + // budget. + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(RENDER_BLOCK_TIMEOUT_MS); + try { + for (CountDownLatch latch : waits) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) break; + latch.await(remaining, TimeUnit.NANOSECONDS); + } + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + // Count how many actually landed in the cache during this call — + // includes both items we fetched and items that finished after our + // timeout (which won't be reflected in this count but are still + // usable on the next render). + int hits = 0; + for (String mxc : toLoad) { + if (AvatarBitmapCache.get(mxc) != null) hits += 1; + } + Log.i(TAG, "loadAll: requested=" + mxcs.size() + + " toLoad=" + toLoad.size() + " hits=" + hits); + return hits; + } + + /** + * Resolve an `mxc://server/mediaId` URL to a 96×96 thumbnail via the + * authenticated v1.11+ media endpoint and decode the response into a + * Bitmap. Returns null on any non-2xx, decode failure, or oversized + * payload (see {@link #MAX_DECODED_BYTES}). + */ + private static Bitmap fetchAndDecode(String mxc, String homeserver, String token) + throws IOException { + Parsed parsed = parseMxc(mxc); + if (parsed == null) { + Log.w(TAG, "fetch: malformed mxc=" + mxc); + return null; + } + + // Server + mediaId are NOT URL-encoded — matches matrix-js-sdk's + // content-repo.ts (it concatenates verbatim via `new URL()`). + // URLEncoder would turn `example.com:8448` into `example.com%3A8448`, + // which Synapse's media router rejects as an unknown server. + // mediaId is base64-ish per spec (URL-safe alphabet) so no + // encoding is needed there either. + StringBuilder url = new StringBuilder(homeserver); + if (!homeserver.endsWith("/")) url.append('/'); + url.append("_matrix/client/v1/media/thumbnail/") + .append(parsed.server) + .append('/') + .append(parsed.mediaId) + .append("?width=").append(AVATAR_SIZE_PX) + .append("&height=").append(AVATAR_SIZE_PX) + .append("&method=crop"); + + HttpURLConnection conn = (HttpURLConnection) new URL(url.toString()).openConnection(); + try { + conn.setRequestMethod("GET"); + conn.setRequestProperty("Authorization", "Bearer " + token); + conn.setRequestProperty("Accept", "image/*"); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + int code = conn.getResponseCode(); + Log.i(TAG, "fetch: mxc=" + mxc + " status=" + code); + if (code < 200 || code >= 300) return null; + int contentLength = conn.getContentLength(); + if (contentLength > MAX_DECODED_BYTES) { + Log.w(TAG, "fetch: oversized contentLength=" + contentLength + " mxc=" + mxc); + return null; + } + try (InputStream in = conn.getInputStream()) { + BitmapFactory.Options opts = new BitmapFactory.Options(); + // Stick with ARGB_8888 even on low-mem devices — RGB_565 + // would lose alpha (group avatars often have a + // transparent corner) and the cache cap (4 MB) already + // bounds total memory. inJustDecodeBounds + sample-size + // dance is overkill at 96×96. + opts.inPreferredConfig = Bitmap.Config.ARGB_8888; + Bitmap bmp = BitmapFactory.decodeStream(in, null, opts); + if (bmp == null) { + Log.w(TAG, "fetch: decodeStream returned null mxc=" + mxc); + return null; + } + if (bmp.getByteCount() > MAX_DECODED_BYTES) { + Log.w(TAG, "fetch: decoded oversized " + + bmp.getByteCount() + " bytes mxc=" + mxc); + bmp.recycle(); + return null; + } + // Crop into a circle BEFORE caching — IconCompat.createWithBitmap + // renders the bitmap verbatim, with no shape mask, so a + // square thumbnail from the homeserver lands as a square + // tile in the shade (visible on Android 12+ where + // conversation Person icons used to be auto-rounded by the + // OS — this changed). Pre-cropping guarantees a round + // visual on every API level instead of relying on the + // SystemUI of the day. The original square bitmap is + // recycled once the circular copy is in hand. + return toCircularBitmap(bmp); + } + } finally { + conn.disconnect(); + } + } + + /** + * Re-encode a circular avatar as an adaptive-icon-shaped bitmap: + * embeds the avatar inside a transparent canvas whose total size is + * 1.5× the avatar so Android's adaptive-icon safe zone (66% of total) + * covers the entire avatar without clipping. + * + * Required for conversation-shortcut icons per docs at + * developer.android.com/develop/ui/views/notifications/conversations: + * *"To avoid unintentional clipping of your shortcut avatar, provide + * an AdaptiveIconDrawable for the shortcut's icon."* + * + * Without this padding, IconCompat.createWithAdaptiveBitmap would + * crop ~17% off every edge of the avatar to fit the safe zone — a + * visible mutilation. With it, the shortcut icon renders pixel- + * identical to the circular avatar inside the system shade's + * conversation slot. + */ + static Bitmap toAdaptivePaddedBitmap(Bitmap circularAvatar) { + int avatarSize = Math.min(circularAvatar.getWidth(), circularAvatar.getHeight()); + // Pad to 150% so the adaptive safe-zone (66% of canvas = avatarSize) + // covers the full avatar. Rounded up to keep the canvas even. + int canvasSize = (int) Math.ceil(avatarSize / 0.66f); + if (canvasSize % 2 != 0) canvasSize += 1; + Bitmap output = Bitmap.createBitmap(canvasSize, canvasSize, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(output); + int offset = (canvasSize - avatarSize) / 2; + canvas.drawBitmap(circularAvatar, offset, offset, null); + return output; + } + + /** + * Return a circular ARGB_8888 bitmap of the source — centre-cropped to + * a square if non-square, then masked with a circular path so the + * corners are transparent. The source bitmap is recycled. + * + * Anti-aliased edges via Paint.setAntiAlias on the circle draw — the + * BitmapShader copies the source's pixels into the circular region in + * a single drawCircle call, which keeps allocation to one output + * bitmap (vs the naive "decode → square crop → mask compose" path + * that touches three intermediate bitmaps). + */ + private static Bitmap toCircularBitmap(Bitmap source) { + int size = Math.min(source.getWidth(), source.getHeight()); + Bitmap squareSource; + if (source.getWidth() == size && source.getHeight() == size) { + squareSource = source; + } else { + int x = (source.getWidth() - size) / 2; + int y = (source.getHeight() - size) / 2; + squareSource = Bitmap.createBitmap(source, x, y, size, size); + source.recycle(); + } + Bitmap output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(output); + Paint paint = new Paint(); + paint.setAntiAlias(true); + paint.setShader(new BitmapShader( + squareSource, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)); + float radius = size / 2f; + canvas.drawCircle(radius, radius, radius, paint); + if (squareSource != source) { + squareSource.recycle(); + } + return output; + } + + private static final class Parsed { + final String server; + final String mediaId; + + Parsed(String server, String mediaId) { + this.server = server; + this.mediaId = mediaId; + } + } + + /** + * Split an `mxc://server/mediaId` URL into its two components. Returns + * null on any malformed input — caller drops the avatar silently. + */ + private static Parsed parseMxc(String mxc) { + if (mxc == null) return null; + final String prefix = "mxc://"; + if (!mxc.startsWith(prefix)) return null; + int slash = mxc.indexOf('/', prefix.length()); + if (slash < 0 || slash == prefix.length()) return null; + String server = mxc.substring(prefix.length(), slash); + String mediaId = mxc.substring(slash + 1); + if (server.isEmpty() || mediaId.isEmpty()) return null; + return new Parsed(server, mediaId); + } +} diff --git a/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java b/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java index c2d87264..7339e82d 100644 --- a/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java +++ b/android/app/src/main/java/chat/vojo/app/CallForegroundPlugin.java @@ -121,7 +121,14 @@ public class CallForegroundPlugin extends Plugin { // extras — Capacitor PushNotificationsPlugin gates pushNotificationActionPerformed // on containsKey. Empty string also satisfies the gate; we pass the // caller's value through verbatim. - VojoFirebaseMessagingService.upsertIncomingRing(data, messageId); + boolean seeded = VojoFirebaseMessagingService.upsertIncomingRing(data, messageId); + // Mark in NotificationDedup so a polling fire 15 minutes later + // doesn't post a "Missed call" notification for a ring the user + // already saw live via the in-app strip. Mirrors the FCM-arrival + // path in VojoFirebaseMessagingService.onMessageReceived. + if (seeded) { + NotificationDedup.markNotified(getContext(), eventId); + } call.resolve(); } diff --git a/android/app/src/main/java/chat/vojo/app/ConversationShortcuts.java b/android/app/src/main/java/chat/vojo/app/ConversationShortcuts.java new file mode 100644 index 00000000..147faba3 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/ConversationShortcuts.java @@ -0,0 +1,163 @@ +package chat.vojo.app; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.os.Build; +import android.util.Log; + +import androidx.core.content.LocusIdCompat; +import androidx.core.content.pm.ShortcutInfoCompat; +import androidx.core.content.pm.ShortcutManagerCompat; +import androidx.core.graphics.drawable.IconCompat; + +import java.util.Collections; +import java.util.Set; + +/** + * Publish a long-lived sharing shortcut for a Matrix room so the system + * treats per-room MessagingStyle notifications as conversations on + * Android 11+ (API 30+). + * + * Without a published shortcut whose id matches the notification's + * setShortcutId(), Android falls back to the app icon for the collapsed- + * preview avatar regardless of Person.setIcon / Builder.setLargeIcon — + * Person icons are only consulted by the Conversation styling layer, + * which activates exclusively for notifications backed by a real + * ShortcutInfoCompat marked Long Lived + the SHORTCUT_CATEGORY_CONVERSATION + * sharing category. + * + * Idempotent: republishing the same shortcut id is the documented "update" + * path; ShortcutManagerCompat handles dedup internally. Cheap to call + * from the render hot path (~ms on warm system, indistinguishable from a + * SharedPreferences write at our scale). + */ +final class ConversationShortcuts { + + private static final String TAG = "ConvShortcuts"; + + private ConversationShortcuts() {} + + /** + * Publish or refresh the shortcut backing a room's conversation + * notification. No-op on API < 30 — Conversation styling is an + * Android 11+ feature; older OS versions render the notification + * fine without the shortcut, and the largeIcon/Person.setIcon + * pipeline is the primary avatar source on them. + * + * @param ctx Context for the shortcut manager binding. + * @param roomId Matrix room id, used as the shortcut id so it + * matches NotificationCompat.Builder.setShortcutId. + * @param isDirect Whether the room is a DM; flips the shortcut + * category so launchers can group DMs separately. + * @param label Short visible label, typically the room name (or + * the peer's display name for a DM). + * @param avatar Optional cached avatar bitmap. Null falls through + * to the app launcher icon — still publishes the + * shortcut so the conversation styling activates. + */ + /** + * Returns the published ShortcutInfoCompat so the caller can attach + * it directly to the notification via setShortcutInfo() — this is + * the documented "atomic publish + bind" path that avoids the race + * where the notification posts before the shortcut publish has + * settled and Android sees an orphan shortcut id. Null on API < 30, + * null on failure (notification still posts cleanly). + */ + static ShortcutInfoCompat publishForRoom( + Context ctx, + String roomId, + boolean isDirect, + String label, + Bitmap avatar + ) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return null; + } + if (roomId == null || roomId.isEmpty()) return null; + try { + // Conversation shortcut icon MUST be adaptive — official docs: + // "To avoid unintentional clipping of your shortcut avatar, + // provide an AdaptiveIconDrawable for the shortcut's icon." + // Without this, Android silently falls back to the app's + // launcher icon for the collapsed-shade conversation avatar + // slot, even though shortcut publish + bind succeed. + // Resource icons (mipmap.ic_launcher) already ship with + // adaptive layers in the manifest; bitmap avatars need padding + // so the safe zone doesn't crop them. + IconCompat icon; + if (avatar != null) { + Bitmap padded = AvatarLoader.toAdaptivePaddedBitmap(avatar); + icon = IconCompat.createWithAdaptiveBitmap(padded); + } else { + icon = IconCompat.createWithResource(ctx, R.mipmap.ic_launcher); + } + + // Intent the shortcut launches when tapped from the launcher + // long-press menu or share sheet — opens MainActivity and + // delivers the same `room_id` extra the notification tap + // path uses, so the existing pushNotificationActionPerformed + // listener navigates correctly. + Intent launchIntent = new Intent(ctx, MainActivity.class) + .setAction(Intent.ACTION_VIEW) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP) + .putExtra("room_id", roomId) + // Capacitor PushNotificationsPlugin gates its action + // delivery on bundle.containsKey("google.message_id"); we + // attach an empty value so a launcher-initiated open + // takes the same path as a push-tap. + .putExtra("google.message_id", ""); + + // Constant value of androidx.core's + // ShortcutInfoCompat.SHORTCUT_CATEGORY_CONVERSATION. Hardcoded + // verbatim because older androidx.core in our dependency + // graph doesn't export the constant; the string itself is + // platform-stable per the Android shortcut category contract. + Set categories = + Collections.singleton("android.shortcut.conversation"); + + ShortcutInfoCompat.Builder b = new ShortcutInfoCompat.Builder(ctx, roomId) + .setShortLabel(label != null && !label.isEmpty() ? label : "Vojo") + .setLongLabel(label != null && !label.isEmpty() ? label : "Vojo") + .setIntent(launchIntent) + .setIcon(icon) + .setLongLived(true) + .setCategories(categories) + // LocusId mirrors the shortcut id; the OS uses it to + // attribute the notification to a specific conversation + // for digital-wellbeing dashboards and bubble grouping. + .setLocusId(new LocusIdCompat(roomId)) + // Marks isDirect so launchers / share sheet can present + // person-style affordances on DMs. + .setIsConversation(); + // setPerson is only needed for one-on-one conversations to + // unlock direct-share suggestions, but for a DM we also want + // it to anchor the shortcut on the peer's identity. Skipped + // for groups (single Person doesn't represent the room). + if (isDirect) { + b.setPerson(new androidx.core.app.Person.Builder() + // setKey must match the Person.key used in the + // MessagingStyle so Android's conversation + // attribution matches the shortcut to the + // notification on the same identity. + .setKey(roomId) + .setName(label != null ? label : "") + .setIcon(icon) + .build()); + } + + ShortcutInfoCompat shortcut = b.build(); + boolean ok = ShortcutManagerCompat.pushDynamicShortcut(ctx, shortcut); + Log.i(TAG, "publish room=" + roomId + " label=" + label + + " hasAvatar=" + (avatar != null) + " ok=" + ok); + return shortcut; + } catch (Throwable t) { + // Shortcut publish is best-effort UX — a failure must not + // sink the notification. Worst case: collapsed preview + // falls back to app icon (same as before the shortcut path + // existed at all). + Log.w(TAG, "publish failed room=" + roomId, t); + return null; + } + } +} diff --git a/android/app/src/main/java/chat/vojo/app/MainActivity.java b/android/app/src/main/java/chat/vojo/app/MainActivity.java index f3734bb9..a6e36d50 100644 --- a/android/app/src/main/java/chat/vojo/app/MainActivity.java +++ b/android/app/src/main/java/chat/vojo/app/MainActivity.java @@ -1,11 +1,16 @@ package chat.vojo.app; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.view.View; import androidx.activity.EdgeToEdge; +import androidx.core.graphics.Insets; import androidx.core.splashscreen.SplashScreen; +import androidx.core.view.ViewCompat; import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; import androidx.core.view.WindowInsetsControllerCompat; import com.getcapacitor.BridgeActivity; @@ -63,6 +68,8 @@ public class MainActivity extends BridgeActivity { registerPlugin(FullScreenIntentPlugin.class); registerPlugin(CallForegroundPlugin.class); registerPlugin(LaunchSplashPlugin.class); + registerPlugin(ShareTargetPlugin.class); + registerPlugin(PollingPlugin.class); // AndroidX SplashScreen must be installed before super.onCreate(). // Keep it until the web splash confirms its first visible frame is @@ -84,6 +91,60 @@ public class MainActivity extends BridgeActivity { WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView()); controller.setAppearanceLightStatusBars(false); controller.setAppearanceLightNavigationBars(false); + + // 3-button nav clearance. Reads `tappableElement` (= 0 in gesture + // mode, = nav-bar height in 3-button mode) and applies it as + // padding on the activity's content root — NOT on the WebView + // itself. The WebView is a child of the content root, so root + // padding shrinks the WebView's layout area; in 3-button mode + // the WebView ends above the nav bar and the activity + // windowBackground strip behind it paints `splash_bg` (#0d0e11) + // which matches the dark body bg, so system icons read as + // continuous with the chat surface. Gesture mode stays + // edge-to-edge (padding = 0). Left/right are included for + // landscape 3-button mode where the nav bar rotates to a side. + // + // CRITICAL: the listener MUST live on the content root, not on + // the WebView. Attaching it to the WebView replaces WebView's + // internal `OnApplyWindowInsetsListener` (the one Chromium uses + // to feed CSS `env(safe-area-inset-*)`), which silently breaks + // `env(safe-area-inset-top)` → `--vojo-safe-top` → every top- + // anchored UI clearance for the status bar. Padding the parent + // leaves WebView's window-insets pipeline untouched while still + // shrinking its visual area. + // + // Why `tappableElement` and not `systemBars()` / `navigationBars()`: + // both report ~24-32 dp in gesture mode too (the pill area), + // which would lift UI in fullscreen — exactly the regression + // commit 443213b4 had. `tappableElement` is the type defined as + // «system UI regions where the user can tap», which means 0 in + // gesture mode and the nav-bar in 3-button mode. + // + // Capacitor 8.3's bundled `SystemBars` plugin attaches its own + // inset listener on `webView.getParent()` (CoordinatorLayout one + // level deeper). Since `index.html` declares `viewport-fit=cover`, + // Capacitor takes the passthrough branch and doesn't pad — no + // compound-padding with our listener. If `viewport-fit=cover` is + // ever removed, set `plugins.SystemBars.insetsHandling = "disable"` + // in `capacitor.config.ts` to avoid double-lift. + // + // Fallback for API < 29 (Android 7-9): `tappableElement` did + // not exist before Q. AndroidX backports the call to API 24+ as + // `getSystemWindowInsets()` (≈ `navigationBars()`); the explicit + // gate makes the intent visible at the call site. + final View contentRoot = findViewById(android.R.id.content); + ViewCompat.setOnApplyWindowInsetsListener(contentRoot, (v, windowInsets) -> { + final int typeMask = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ? WindowInsetsCompat.Type.tappableElement() + : WindowInsetsCompat.Type.navigationBars(); + Insets ins = windowInsets.getInsets(typeMask); + v.setPadding(ins.left, 0, ins.right, ins.bottom); + // Do NOT consume — propagate to WebView (CSS env() pipeline) + // and Capacitor's SystemBars listener so they still see + // unmodified insets. + return windowInsets; + }); + ViewCompat.requestApplyInsets(contentRoot); } @Override diff --git a/android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java b/android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java new file mode 100644 index 00000000..e46b0e85 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java @@ -0,0 +1,147 @@ +package chat.vojo.app; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.util.Log; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Handles the per-notification "Mark as read" action. + * + * Posts {@code POST /_matrix/client/v3/rooms/{roomId}/receipt/m.read/{eventId}} + * using the access token saved by the polling lifecycle in + * {@code vojo_poll_state} SharedPreferences (same storage VojoPollWorker uses; + * keeps the credential lifecycle single-sourced). After a successful 2xx the + * per-room MessagingStyle notification is dismissed and the + * {@link RoomMessageCache} is cleared so the next push to that room starts a + * fresh conversation rather than re-appending to the prior history. + * + * Dismiss policy: OPTIMISTIC. The per-room notification is dismissed + * synchronously in onReceive — before the HTTP receipt PUT is even + * attempted — so the user sees instant feedback. The async receipt POST + * happens on a worker thread afterwards. This mirrors element-android's + * NotificationBroadcastReceiver pattern and matches the user's mental + * model ("I tapped, it should disappear immediately"). + * + * Failure mode: on any non-2xx or thrown exception we accept that the + * server-side read receipt did not land. We do NOT re-post the + * notification or implement a flusher because: + * - the next room open from the JS app issues a fresh read-receipt + * for the latest visible event, catching up the server state + * - the in-app read-marker logic is the authoritative path; this + * receiver is a convenience for the shade-tap shortcut + * - accumulating tombstones in prefs (the CallDeclineReceiver pattern) + * would risk leaking historical eventIds the JS side would re-issue + * on app resume anyway + * + * Null-credential edge case (fresh install + first push before any + * saveSession bridge): no token to use, we still dismiss the notification + * locally so the user isn't stuck looking at a "stuck" Mark-as-read + * button. The next room open from JS covers the server view. + */ +public class MarkAsReadReceiver extends BroadcastReceiver { + + public static final String ACTION_MARK_AS_READ = "chat.vojo.app.MARK_AS_READ"; + public static final String EXTRA_ROOM_ID = "room_id"; + public static final String EXTRA_EVENT_ID = "event_id"; + + private static final int CONNECT_TIMEOUT_MS = 8_000; + private static final int READ_TIMEOUT_MS = 8_000; + private static final String TAG = "MarkAsReadRcvr"; + + private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(); + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null) return; + final String roomId = intent.getStringExtra(EXTRA_ROOM_ID); + final String eventId = intent.getStringExtra(EXTRA_EVENT_ID); + if (roomId == null || roomId.isEmpty()) { + Log.w(TAG, "onReceive: missing room_id, abort"); + return; + } + + final Context appContext = context.getApplicationContext(); + // Dismiss first for instant UX feedback — HTTP latency is irrelevant + // to the perceived "marked as read" action. + VojoFirebaseMessagingService.dismissRoomNotification(appContext, roomId); + + final SharedPreferences prefs = appContext.getSharedPreferences( + VojoPollWorker.PREFS, Context.MODE_PRIVATE); + final String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null); + final String homeserver = prefs.getString(VojoPollWorker.KEY_HOMESERVER_URL, null); + if (token == null || token.isEmpty() || homeserver == null || homeserver.isEmpty()) { + Log.w(TAG, "onReceive: no credentials in prefs, local dismiss only"); + return; + } + if (eventId == null || eventId.isEmpty()) { + // Without an eventId we cannot issue a receipt PUT — the JS-side + // read-marker handler will catch this up on the next room open. + Log.w(TAG, "onReceive: no event_id, local dismiss only"); + return; + } + + final PendingResult pendingResult = goAsync(); + EXECUTOR.execute(() -> { + try { + int status = sendReceipt(homeserver, token, roomId, eventId); + if (status >= 200 && status < 300) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "receipt ok status=" + status + " room=" + roomId); + } + } else { + Log.w(TAG, "receipt non-2xx status=" + status + " room=" + roomId); + } + } catch (Throwable t) { + Log.w(TAG, "receipt threw room=" + roomId, t); + } finally { + pendingResult.finish(); + } + }); + } + + private int sendReceipt( + String baseUrl, + String accessToken, + String roomId, + String eventId + ) throws IOException { + String url = trimTrailingSlash(baseUrl) + + "/_matrix/client/v3/rooms/" + + URLEncoder.encode(roomId, "UTF-8") + + "/receipt/m.read/" + + URLEncoder.encode(eventId, "UTF-8"); + + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + try { + conn.setRequestMethod("POST"); + conn.setRequestProperty("Authorization", "Bearer " + accessToken); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setDoOutput(true); + // Empty JSON body per spec; setFixedLengthStreamingMode keeps the + // connection on the cached path instead of chunked-transfer fallback. + byte[] payload = "{}".getBytes("UTF-8"); + conn.setFixedLengthStreamingMode(payload.length); + try (java.io.OutputStream os = conn.getOutputStream()) { + os.write(payload); + } + return conn.getResponseCode(); + } finally { + conn.disconnect(); + } + } + + private static String trimTrailingSlash(String s) { + return (s != null && s.endsWith("/")) ? s.substring(0, s.length() - 1) : s; + } +} diff --git a/android/app/src/main/java/chat/vojo/app/NotificationDedup.java b/android/app/src/main/java/chat/vojo/app/NotificationDedup.java new file mode 100644 index 00000000..4a3d422a --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/NotificationDedup.java @@ -0,0 +1,104 @@ +package chat.vojo.app; + +import android.content.Context; +import android.content.SharedPreferences; + +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Cross-source LRU dedup for rendered push event_ids. + * + * Both the FCM service (after a successful nm.notify) and the polling Worker + * write into the same bounded SharedPreferences-backed set. The Worker reads + * it to skip events FCM already delivered — which fixes the regression where + * a user who dismissed an FCM notification before polling fired would see + * the same event resurface up to 15 minutes later via the polling fallback. + * + * The native `eventId.hashCode()` notification-id slot is still the primary + * dedup for *concurrent* render (Android NotificationManager replace), but + * that only collapses surfaces while both notifications are still visible; + * once the user dismisses, the slot is empty and the second render would + * post fresh. This shared set covers that gap. + * + * Synchronisation: SharedPreferences read-modify-write is not atomic across + * threads/processes, and FCM service runs on a Firebase-managed background + * thread while the Worker runs on WorkManager's executor. We serialise all + * mutations through a static lock. Critical sections are short (string split + * + LinkedHashSet trim + putString) — no Binder calls. + */ +final class NotificationDedup { + + // Capacity is intentionally larger than VojoPollWorker's worst-case per-run + // event count (MAX_PAGES_PER_RUN × PAGE_LIMIT = 250). If a single fire + // marks 250 events and the cap were 200, the 50 oldest of those would + // already be evicted by the time we finish writing — so a sibling poll + // resuming the same window would re-render them. 500 gives 2× headroom + // while staying ~12 KB in SharedPreferences (negligible). + private static final int MAX_TRACKED = 500; + private static final Object lock = new Object(); + + private NotificationDedup() {} + + /** Returns true iff the given event_id has been notified in a recent cycle. */ + static boolean wasNotified(Context ctx, String eventId) { + if (eventId == null || eventId.isEmpty()) return false; + synchronized (lock) { + return readSet(ctx).contains(eventId); + } + } + + /** Append the event_id to the LRU set, trimming the oldest when full. */ + static void markNotified(Context ctx, String eventId) { + if (eventId == null || eventId.isEmpty()) return; + synchronized (lock) { + Set set = readSet(ctx); + // LinkedHashSet preserves insertion order — re-adding moves to tail + // only if we remove-then-add. The Set#add no-op on a present entry + // does NOT refresh position, but the simple "drop oldest" trim + // below is adequate for our scale and matches the Worker's + // existing semantics. Skip the disk write entirely when add() + // returned false — the event was already in the set, persistence + // would just churn SharedPreferences for no state change. + if (!set.add(eventId)) return; + if (set.size() > MAX_TRACKED) { + Iterator it = set.iterator(); + int drop = set.size() - MAX_TRACKED; + while (it.hasNext() && drop > 0) { + it.next(); + it.remove(); + drop -= 1; + } + } + writeSet(ctx, set); + } + } + + /** Caller must hold {@link #lock}. */ + private static Set readSet(Context ctx) { + SharedPreferences prefs = ctx.getSharedPreferences( + VojoPollWorker.PREFS, Context.MODE_PRIVATE); + String raw = prefs.getString(VojoPollWorker.KEY_NOTIFIED_IDS, ""); + Set out = new LinkedHashSet<>(); + if (raw.isEmpty()) return out; + for (String id : raw.split(",")) { + if (!id.isEmpty()) out.add(id); + } + return out; + } + + /** Caller must hold {@link #lock}. */ + private static void writeSet(Context ctx, Set set) { + SharedPreferences prefs = ctx.getSharedPreferences( + VojoPollWorker.PREFS, Context.MODE_PRIVATE); + StringBuilder sb = new StringBuilder(set.size() * 25); + boolean first = true; + for (String id : set) { + if (!first) sb.append(','); + sb.append(id); + first = false; + } + prefs.edit().putString(VojoPollWorker.KEY_NOTIFIED_IDS, sb.toString()).apply(); + } +} diff --git a/android/app/src/main/java/chat/vojo/app/NotificationDismissReceiver.java b/android/app/src/main/java/chat/vojo/app/NotificationDismissReceiver.java new file mode 100644 index 00000000..4689a46c --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/NotificationDismissReceiver.java @@ -0,0 +1,37 @@ +package chat.vojo.app; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +/** + * Fires when the user swipes a per-room MessagingStyle notification away. + * + * Without this hook, RoomMessageCache would still hold the prior messages + * for that room — and the next push would append onto that history and + * re-surface the messages the user just dismissed. With it, swipe clears + * the cache so the next push starts a fresh conversation for the room. + * + * NOTE: this only fires for user-driven dismissals — programmatic + * nm.cancel calls (mark-as-read, receipt-driven dismiss, channel migration) + * already call RoomMessageCache.clear themselves and do NOT fire the + * delete intent. There's no double-clear risk. + */ +public class NotificationDismissReceiver extends BroadcastReceiver { + + public static final String ACTION_NOTIFICATION_DISMISSED = + "chat.vojo.app.NOTIFICATION_DISMISSED"; + public static final String EXTRA_ROOM_ID = "room_id"; + + private static final String TAG = "DismissRcvr"; + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null) return; + String roomId = intent.getStringExtra(EXTRA_ROOM_ID); + if (roomId == null || roomId.isEmpty()) return; + if (BuildConfig.DEBUG) Log.d(TAG, "swipe clear cache room=" + roomId); + RoomMessageCache.clear(roomId); + } +} diff --git a/android/app/src/main/java/chat/vojo/app/PollingPlugin.java b/android/app/src/main/java/chat/vojo/app/PollingPlugin.java new file mode 100644 index 00000000..ee998b95 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/PollingPlugin.java @@ -0,0 +1,236 @@ +package chat.vojo.app; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import androidx.work.Constraints; +import androidx.work.ExistingPeriodicWorkPolicy; +import androidx.work.NetworkType; +import androidx.work.PeriodicWorkRequest; +import androidx.work.WorkManager; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +import java.util.concurrent.TimeUnit; + +/** + * JS ↔ Android bridge for the WorkManager-based polling fallback. + * + * Lifecycle: + * - JS calls saveSession({accessToken, homeserverUrl, userId}) on login, + * on push (re)enable, and on visibilitychange → visible (to recover a + * 401-cleared credentials slot without a full remount). + * - JS calls schedule({intervalMinutes}) once push is enabled. Idempotent: + * KEEP policy means a second schedule() call against an already-enqueued + * worker is a no-op (the running period continues unchanged). + * - JS calls saveRoomNames({names}) on mount + visibilitychange → visible + * so VojoPollWorker has a local cache to resolve room_id → display name + * without making N extra GET /rooms/{id}/state/m.room.name requests. + * Brand-new rooms created between visibility events fall back to + * sender_display_name in the renderer. + * - JS calls cancel() + clearSession() on logout / push disable. + * + * Worker tag: a single unique periodic worker named UNIQUE_WORK_NAME — KEEP + * policy prevents schedule churn from re-creating it. Cancel() removes it + * by the same name. + */ +@CapacitorPlugin(name = "Polling") +public class PollingPlugin extends Plugin { + + private static final String TAG = "PollingPlugin"; + private static final String UNIQUE_WORK_NAME = "vojo_push_poll"; + + // Android's hard floor for PeriodicWorkRequest. Requests with shorter + // intervals are silently clamped to 15 minutes. We accept the requested + // value from JS but enforce the floor here so misuse from JS doesn't + // produce a silently-different behavior. + private static final long MIN_INTERVAL_MINUTES = 15; + + @PluginMethod + public void saveSession(PluginCall call) { + String accessToken = call.getString("accessToken"); + String homeserverUrl = call.getString("homeserverUrl"); + if (accessToken == null || accessToken.isEmpty() + || homeserverUrl == null || homeserverUrl.isEmpty()) { + call.reject("missing_accessToken_or_homeserverUrl"); + return; + } + String userId = call.getString("userId"); + SharedPreferences prefs = getContext() + .getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE); + SharedPreferences.Editor editor = prefs.edit() + .putString(VojoPollWorker.KEY_ACCESS_TOKEN, accessToken) + .putString(VojoPollWorker.KEY_HOMESERVER_URL, homeserverUrl); + if (userId != null && !userId.isEmpty()) { + editor.putString(VojoPollWorker.KEY_USER_ID, userId); + } + // Seed the watermark to "now minus a small clock-skew buffer" on the + // first saveSession after install / logout. Without seeding the + // Worker's first fire sees watermark=0 and renders every historical + // unread /notifications entry as a fresh push. The buffer covers the + // case where the device clock runs ahead of the homeserver's clock — + // event ts is server-side, so a too-fresh local seed would silently + // skip recently-arrived events as "older than watermark" forever. + // 60s tolerates typical NTP drift while still suppressing days-old + // backlog on first enable. We seed only when the key is absent so + // subsequent saveSession calls (token rotation, visibilitychange + // re-bridge) don't reset live state. + if (!prefs.contains(VojoPollWorker.KEY_LAST_SEEN_TS)) { + editor.putLong( + VojoPollWorker.KEY_LAST_SEEN_TS, + System.currentTimeMillis() - SEED_CLOCK_SKEW_BUFFER_MS + ); + } + editor.apply(); + call.resolve(); + } + + private static final long SEED_CLOCK_SKEW_BUFFER_MS = 60_000L; + + @PluginMethod + public void clearSession(PluginCall call) { + getContext() + .getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE) + .edit() + .remove(VojoPollWorker.KEY_ACCESS_TOKEN) + .remove(VojoPollWorker.KEY_HOMESERVER_URL) + .remove(VojoPollWorker.KEY_USER_ID) + .remove(VojoPollWorker.KEY_LAST_SEEN_TS) + .remove(VojoPollWorker.KEY_DRAIN_CURSOR) + .remove(VojoPollWorker.KEY_DRAIN_TARGET_TS) + .remove(VojoPollWorker.KEY_NOTIFIED_IDS) + .remove(VojoPollWorker.KEY_ROOM_NAMES) + .remove(VojoPollWorker.KEY_USER_AVATARS) + .apply(); + call.resolve(); + } + + /** + * user_id → MXC avatar URL snapshot. Mirrors {@link #saveRoomNames} — + * stored as a JSON blob in vojo_poll_state for the FCM service / + * polling Worker / ReplyReceiver to consult via + * VojoFirebaseMessagingService.lookupUserAvatarMxc. JS dumps on the + * same lifecycle triggers as room names (mount, visibility resume, + * m.direct change, m.room.encryption flip). + */ + @PluginMethod + public void saveUserAvatars(PluginCall call) { + JSObject avatars = call.getObject("avatars"); + if (avatars == null) { + call.reject("missing_avatars"); + return; + } + String serialized = avatars.toString(); + getContext() + .getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE) + .edit() + .putString(VojoPollWorker.KEY_USER_AVATARS, serialized) + .apply(); + Log.i(TAG, "saveUserAvatars: " + avatars.length() + " entries, " + + serialized.length() + " bytes"); + call.resolve(); + } + + @PluginMethod + public void saveRoomNames(PluginCall call) { + JSObject names = call.getObject("names"); + if (names == null) { + // Empty map is also valid (user cleared all rooms) — JS passes + // {} explicitly in that case; missing key is a contract bug. + call.reject("missing_names"); + return; + } + // `JSObject extends JSONObject`, so names.toString() is already a + // valid JSON serialisation of validated values — no need to re-parse + // it through `new JSONObject(...)` just to re-serialise. Persist + // verbatim. + String serialized = names.toString(); + getContext() + .getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE) + .edit() + .putString(VojoPollWorker.KEY_ROOM_NAMES, serialized) + .apply(); + Log.i(TAG, "saveRoomNames: " + names.length() + " entries, " + + serialized.length() + " bytes"); + call.resolve(); + } + + @PluginMethod + public void schedule(PluginCall call) { + Integer intervalMinutes = call.getInt("intervalMinutes", 15); + long interval = Math.max(MIN_INTERVAL_MINUTES, intervalMinutes != null ? intervalMinutes : 15); + + Constraints constraints = new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(); + + PeriodicWorkRequest req = new PeriodicWorkRequest.Builder( + VojoPollWorker.class, interval, TimeUnit.MINUTES + ) + .setConstraints(constraints) + .addTag("vojo_push_poll") + .build(); + + try { + WorkManager.getInstance(getContext()) + .enqueueUniquePeriodicWork( + UNIQUE_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + req + ); + Log.d(TAG, "scheduled periodic poll every " + interval + " minutes"); + call.resolve(); + } catch (Throwable t) { + Log.w(TAG, "schedule failed", t); + call.reject("schedule_failed: " + t.getMessage()); + } + } + + /** + * Dismiss the per-room MessagingStyle notification + clear the in-memory + * RoomMessageCache for the room. Called from the JS receipt listener when + * a server-side read receipt zeroes the unread count (the user read on + * another device / tab). No-op if the notification was never posted or + * has already been swiped away. + */ + @PluginMethod + public void dismissRoom(PluginCall call) { + String roomId = call.getString("roomId"); + if (roomId == null || roomId.isEmpty()) { + call.reject("missing_roomId"); + return; + } + VojoFirebaseMessagingService.dismissRoomNotification(getContext(), roomId); + call.resolve(); + } + + @PluginMethod + public void cancel(PluginCall call) { + try { + // Block on the Operation so callers awaiting cancel() see the + // cancel committed to WorkManager's database before we resolve. + // (NOTE: this does NOT interrupt a Worker that's already mid + // doWork(); cooperative cancellation via isStopped() is owned + // by VojoPollWorker itself.) Without this wait a fast + // disable→reenable sequence races with ExistingPeriodicWorkPolicy.KEEP + // — the second enqueueUniquePeriodicWork can land before the + // cancel is committed and become a no-op. We're already off + // the main thread (Capacitor dispatches plugin calls on its + // own executor), so the blocking get() is safe here. + WorkManager.getInstance(getContext()) + .cancelUniqueWork(UNIQUE_WORK_NAME) + .getResult() + .get(); + Log.d(TAG, "cancelled periodic poll"); + call.resolve(); + } catch (Throwable t) { + Log.w(TAG, "cancel failed", t); + call.reject("cancel_failed: " + t.getMessage()); + } + } +} diff --git a/android/app/src/main/java/chat/vojo/app/PushStrings.java b/android/app/src/main/java/chat/vojo/app/PushStrings.java index ff0437f0..08ed2955 100644 --- a/android/app/src/main/java/chat/vojo/app/PushStrings.java +++ b/android/app/src/main/java/chat/vojo/app/PushStrings.java @@ -45,6 +45,55 @@ final class PushStrings { return forAppLocale(ctx).getString(R.string.push_invitation); } + static String missedCallTitle(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_missed_call); + } + + static String missedCallBody(Context ctx, String caller) { + String safeCaller = caller == null ? "" : caller; + return forAppLocale(ctx).getString(R.string.push_missed_call_body, safeCaller); + } + + static String channelGroup(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_channel_group); + } + + static String channelDm(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_channel_dm); + } + + static String channelDmDescription(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_channel_dm_description); + } + + static String channelGroupRoom(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_channel_group_room); + } + + static String channelGroupRoomDescription(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_channel_group_room_description); + } + + static String selfName(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_self_name); + } + + static String markAsReadAction(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_action_mark_as_read); + } + + static String replyAction(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_action_reply); + } + + static String replyHint(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_reply_hint); + } + + static String replyFailed(Context ctx) { + return forAppLocale(ctx).getString(R.string.push_reply_failed); + } + /** * Build the invite-notification body from inviter + room name, falling * back through four variants when one or both are absent. The res IDs diff --git a/android/app/src/main/java/chat/vojo/app/ReplyReceiver.java b/android/app/src/main/java/chat/vojo/app/ReplyReceiver.java new file mode 100644 index 00000000..ecac6b2e --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/ReplyReceiver.java @@ -0,0 +1,248 @@ +package chat.vojo.app; + +import android.app.NotificationManager; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.util.Log; + +import androidx.core.app.NotificationCompat; +import androidx.core.app.RemoteInput; + +import org.json.JSONObject; +import org.json.JSONException; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Handles the inline-reply RemoteInput action on a per-room MessagingStyle + * notification. + * + * Flow: + * 1. User taps reply, types text, presses send → broadcast fires here. + * 2. We immediately append the outgoing message to RoomMessageCache and + * re-post the notification (instant UX feedback — the message appears + * as a self-Person bubble in the conversation while the HTTP is in + * flight). + * 3. PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId} + * with {msgtype: "m.text", body}. Uses the vojo_poll_state token (same + * storage as Worker / MarkAsReadReceiver — single credential lifecycle). + * 4. On 2xx: nothing further; the JS sync echo will eventually replace + * the local-echo bubble in-app. + * 5. On non-2xx or thrown: post a small error notification "Could not + * send your reply" so the user knows to retry from in-app — better + * than silently swallowing the message. + * + * E2EE rooms are guarded UP-STREAM in VojoFirebaseMessagingService. + * renderMessageNotification: we don't even attach the reply action when + * RoomMetadata.isEncrypted is true. So this receiver never has to encrypt. + * Defense in depth: if a stale notification with the action ever survives + * an encryption flip we still detect the failure as a non-2xx HTTP and + * surface the error notification rather than sending cleartext (which + * Synapse would in any case reject for an encrypted room). + * + * Null-credential edge case: post the error notification so the user + * notices and retries in-app. Same logic as a network failure. + */ +public class ReplyReceiver extends BroadcastReceiver { + + public static final String ACTION_REPLY = "chat.vojo.app.REPLY"; + public static final String EXTRA_ROOM_ID = "room_id"; + public static final String KEY_TEXT_REPLY = "vojo.text_reply"; + + private static final int CONNECT_TIMEOUT_MS = 8_000; + private static final int READ_TIMEOUT_MS = 8_000; + private static final String TAG = "ReplyRcvr"; + + private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(); + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null) return; + final String roomId = intent.getStringExtra(EXTRA_ROOM_ID); + if (roomId == null || roomId.isEmpty()) { + Log.w(TAG, "onReceive: missing room_id, abort"); + return; + } + + Bundle remote = RemoteInput.getResultsFromIntent(intent); + if (remote == null) { + Log.w(TAG, "onReceive: no RemoteInput results"); + return; + } + CharSequence reply = remote.getCharSequence(KEY_TEXT_REPLY); + if (reply == null) { + Log.w(TAG, "onReceive: RemoteInput missing text"); + return; + } + final String text = reply.toString().trim(); + if (text.isEmpty()) return; + + final Context appContext = context.getApplicationContext(); + + // Pre-flight validation BEFORE the optimistic echo. Posting a self + // bubble first and then immediately stacking an error notif on top + // is jarring UX; for predictable failures (logged out, freshly + // encrypted room) we'd rather skip the echo and only surface the + // error. + final SharedPreferences prefs = appContext.getSharedPreferences( + VojoPollWorker.PREFS, Context.MODE_PRIVATE); + final String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null); + final String homeserver = prefs.getString(VojoPollWorker.KEY_HOMESERVER_URL, null); + if (token == null || token.isEmpty() || homeserver == null || homeserver.isEmpty()) { + Log.w(TAG, "onReceive: no credentials in prefs, surfacing error notif"); + postReplyError(appContext, roomId); + return; + } + + // Race guard for E2EE flip: the per-room metadata snapshot is + // refreshed by JS on m.room.encryption Timeline events, but a push + // delivered in the narrow window between the encryption state + // landing and the dump completing could still expose the reply + // action on a freshly-encrypted room. Re-read the snapshot + // synchronously here — Synapse does NOT enforce "no cleartext in + // encrypted rooms" at the spec level, so without this guard we'd + // leak the user's reply into an E2EE timeline as plaintext. + if (isRoomEncryptedAtSendTime(prefs, roomId)) { + Log.w(TAG, "onReceive: room flipped to encrypted between render and send, abort"); + postReplyError(appContext, roomId); + return; + } + + // Optimistic local echo — appends a self-Person message to the + // conversation and re-posts, so the user sees their reply in the + // shade before the HTTP completes. Only happens after pre-flight + // checks pass so the user doesn't see an echo for a reply we know + // will fail. + long now = System.currentTimeMillis(); + VojoFirebaseMessagingService.appendOutgoingMessage(appContext, roomId, text, now); + + final PendingResult pendingResult = goAsync(); + final String txnId = "vojo-reply-" + UUID.randomUUID(); + EXECUTOR.execute(() -> { + try { + int status = sendReply(homeserver, token, roomId, txnId, text); + if (status >= 200 && status < 300) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "reply ok status=" + status + " room=" + roomId); + } + } else { + Log.w(TAG, "reply non-2xx status=" + status + " room=" + roomId); + postReplyError(appContext, roomId); + } + } catch (Throwable t) { + Log.w(TAG, "reply threw room=" + roomId, t); + postReplyError(appContext, roomId); + } finally { + pendingResult.finish(); + } + }); + } + + private int sendReply( + String baseUrl, + String accessToken, + String roomId, + String txnId, + String text + ) throws IOException { + String url = trimTrailingSlash(baseUrl) + + "/_matrix/client/v3/rooms/" + + URLEncoder.encode(roomId, "UTF-8") + + "/send/m.room.message/" + + URLEncoder.encode(txnId, "UTF-8"); + + JSONObject body; + try { + body = new JSONObject(); + body.put("msgtype", "m.text"); + body.put("body", text); + } catch (org.json.JSONException je) { + // JSONObject.put only throws on NaN/Inf doubles, neither of + // which we use — but keep the type contract honest. + throw new IOException("payload encode failed", je); + } + byte[] payload = body.toString().getBytes("UTF-8"); + + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + try { + conn.setRequestMethod("PUT"); + conn.setRequestProperty("Authorization", "Bearer " + accessToken); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setDoOutput(true); + conn.setFixedLengthStreamingMode(payload.length); + try (OutputStream os = conn.getOutputStream()) { + os.write(payload); + } + return conn.getResponseCode(); + } finally { + conn.disconnect(); + } + } + + /** + * Surface a short error notification when the reply HTTP fails so the + * user knows the message did NOT land server-side and can retry from + * within the app. Posted on the DM channel as a one-shot. Unique notif + * id per room so it can't clobber the room's conversation slot. + */ + private static void postReplyError(Context ctx, String roomId) { + NotificationManager nm = (NotificationManager) + ctx.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm == null) return; + try { + String channel = VojoFirebaseMessagingService.CHANNEL_ID_DM; + NotificationCompat.Builder b = new NotificationCompat.Builder(ctx, channel) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(PushStrings.replyFailed(ctx)) + .setContentText(PushStrings.replyFailed(ctx)) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT); + int errId = ("replyErr_" + roomId).hashCode(); + nm.notify(errId, b.build()); + } catch (Throwable t) { + Log.w(TAG, "reply error notif failed", t); + } + } + + private static String trimTrailingSlash(String s) { + return (s != null && s.endsWith("/")) ? s.substring(0, s.length() - 1) : s; + } + + /** + * Synchronous re-check of the room's encryption flag at send time. + * Mirrors VojoFirebaseMessagingService.loadRoomMetadata's tolerant + * parse: legacy string-shape entries and missing flags both default + * to encrypted=true (privacy-first — refusing a reply on a falsely- + * flagged room is harmless; sending cleartext into a truly encrypted + * room is a privacy leak). + */ + private static boolean isRoomEncryptedAtSendTime(SharedPreferences prefs, String roomId) { + String raw = prefs.getString(VojoPollWorker.KEY_ROOM_NAMES, null); + if (raw == null || raw.isEmpty()) return true; + try { + JSONObject map = new JSONObject(raw); + if (!map.has(roomId) || map.isNull(roomId)) return true; + JSONObject obj = map.optJSONObject(roomId); + if (obj == null) { + // Legacy string-shape predates the encryption flag — + // assume encrypted to err on the side of privacy. + return true; + } + return obj.optBoolean("isEncrypted", true); + } catch (JSONException je) { + return true; + } + } +} diff --git a/android/app/src/main/java/chat/vojo/app/RoomMessageCache.java b/android/app/src/main/java/chat/vojo/app/RoomMessageCache.java new file mode 100644 index 00000000..7fe1b9a6 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/RoomMessageCache.java @@ -0,0 +1,176 @@ +package chat.vojo.app; + +import androidx.core.app.NotificationCompat; +import androidx.core.app.Person; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Per-room MessagingStyle history cache. + * + * Stores the last N messages observed for each room so renderMessageNotification + * can rebuild a NotificationCompat.MessagingStyle with conversation context on + * every new event instead of posting a fresh single-message notification per + * event. Without this every 5-message DM produced 5 distinct entries in the + * shade; with it the user sees one expandable conversation per room — the + * WhatsApp/Telegram convention. + * + * Thread-safety: ConcurrentHashMap + per-key synchronized mutation via the + * compute() / get() pattern. Both VojoFirebaseMessagingService.onMessageReceived + * (Firebase-managed thread) and VojoPollWorker.doWork (WorkManager executor) + * mutate the cache; without serialization a same-room FCM + polling race could + * lose a message. Mutations are short — only deque append + bounded trim. + * + * Persistence: in-memory only. After process kill the cache is empty, and + * renderMessageNotification falls back to extractMessagingStyleFromNotification + * to recover history from the live system shade. If the user dismissed the + * notification too, the conversation legitimately starts fresh — no signal we + * could recover from there anyway. + * + * Eviction: bounded at MAX_MESSAGES_PER_ROOM per room, with FIFO eviction + * (oldest message at the head of the deque is dropped via pollFirst when the + * append would exceed the cap). Map itself is unbounded; in practice the + * dump from dismissRoom (when a server-side read receipt clears unread) keeps + * the room count proportional to active conversations. For safety against + * runaway growth from rooms the user never reads, we cap the map at MAX_ROOMS. + */ +final class RoomMessageCache { + + // Element-android keeps a similar in-memory queue (NotificationEventQueue); + // 20 messages per room is generous enough for an active group chat while + // staying well under Android's MessagingStyle render budget — Android only + // shows the last ~7 messages in the shade anyway. + private static final int MAX_MESSAGES_PER_ROOM = 20; + + // Hard cap on the map size so a long-running session that touches many + // rooms without ever clearing receipts can't slowly leak memory. + // Eviction is approximate (oldest-touched first via insertion order from + // ConcurrentHashMap is NOT guaranteed, so we just clear the oldest by + // arbitrary entry on overflow — acceptable for an LRU at this scale). + private static final int MAX_ROOMS = 200; + + private static final ConcurrentHashMap> store = + new ConcurrentHashMap<>(); + + private RoomMessageCache() {} + + /** + * Snapshot of a single rendered message. We can't store + * NotificationCompat.MessagingStyle.Message directly because Person's + * Icon field is not safely shareable across threads / not cheap to + * rebuild on every poll. Building the Message at render time from this + * record matches element-android's RoomGroupMessageCreator pattern. + */ + static final class Entry { + // Matrix event_id when known (incoming pushes always carry one; + // outgoing optimistic-echo entries pass null). Used by append() to + // suppress duplicate appends when FCM retries / cross-source + // delivery hands the same event in twice — without this the + // MessagingStyle conversation would render the same message N + // times in the shade. + final String eventId; + final String body; + final long timestamp; + final String senderKey; + final String senderName; + final boolean fromSelf; + + Entry( + String eventId, + String body, + long timestamp, + String senderKey, + String senderName, + boolean fromSelf + ) { + this.eventId = eventId; + this.body = body; + this.timestamp = timestamp; + this.senderKey = senderKey; + this.senderName = senderName; + this.fromSelf = fromSelf; + } + } + + /** + * Append a message to the room's history and return an ordered snapshot + * including the newly-added entry. Snapshot is taken INSIDE the atomic + * compute() so a concurrent append for the same room can't mutate the + * deque between our addLast and our copy. Returning the deque reference + * and copying outside is unsafe — ConcurrentHashMap.compute serialises + * only the lambda body per key, not subsequent reads of the value. + */ + static List append(String roomId, Entry entry) { + if (roomId == null || roomId.isEmpty() || entry == null) { + return java.util.Collections.emptyList(); + } + final List snapshot = new ArrayList<>(); + store.compute(roomId, (key, existing) -> { + Deque d = (existing != null) ? existing : new ArrayDeque<>(); + // Dedup by eventId — protects against FCM retry / cross-source + // (FCM + polling Worker) double-delivery that would otherwise + // append the same event twice. Only applies when both the new + // entry and a prior one carry a non-empty eventId; outgoing + // self-echo entries have null eventId by design and never + // collide. + boolean isDup = false; + if (entry.eventId != null && !entry.eventId.isEmpty()) { + for (Entry prior : d) { + if (entry.eventId.equals(prior.eventId)) { + isDup = true; + break; + } + } + } + if (!isDup) { + d.addLast(entry); + while (d.size() > MAX_MESSAGES_PER_ROOM) { + d.pollFirst(); + } + } + snapshot.addAll(d); + return d; + }); + // Bound the map. Iteration order of ConcurrentHashMap is unspecified + // and the size() check is racy with concurrent puts; we accept ±1 + // eviction precision at the 200-room cap as an acceptable approximation + // of LRU (the alternative is a global lock on every append which is + // far more expensive than letting the cache drift by one). + if (store.size() > MAX_ROOMS) { + java.util.Iterator it = store.keySet().iterator(); + while (it.hasNext() && store.size() > MAX_ROOMS) { + String key = it.next(); + if (!key.equals(roomId)) it.remove(); + } + } + return snapshot; + } + + /** + * Seed the room's history from an already-posted MessagingStyle (recovered + * via NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification + * after process kill). Idempotent: if the room already has cached entries + * we leave them alone — they are by construction at least as recent. + */ + static void seedIfAbsent(String roomId, List entries) { + if (roomId == null || roomId.isEmpty() || entries == null || entries.isEmpty()) return; + store.computeIfAbsent(roomId, key -> { + Deque d = new ArrayDeque<>(); + for (Entry e : entries) { + d.addLast(e); + while (d.size() > MAX_MESSAGES_PER_ROOM) d.pollFirst(); + } + return d; + }); + } + + /** Drop all cached messages for a room (e.g. on receipt-driven dismiss). */ + static void clear(String roomId) { + if (roomId == null || roomId.isEmpty()) return; + store.remove(roomId); + } +} diff --git a/android/app/src/main/java/chat/vojo/app/ShareTargetPlugin.java b/android/app/src/main/java/chat/vojo/app/ShareTargetPlugin.java new file mode 100644 index 00000000..752f8f02 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/ShareTargetPlugin.java @@ -0,0 +1,273 @@ +package chat.vojo.app; + +import android.content.ContentResolver; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.provider.OpenableColumns; +import android.util.Log; +import android.webkit.MimeTypeMap; + +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; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Receives ACTION_SEND / ACTION_SEND_MULTIPLE intents from the system share- + * sheet and surfaces them to the WebView as a pending share that JS consumes + * via {@code pickPendingShare()} (or reacts to via the {@code shareReceived} + * event when the app was already in the foreground). + * + * Cold-start flow: + * 1. Share-sheet → Vojo → MainActivity.onCreate → super.onCreate runs + * BridgeActivity.load(), which itself calls bridge.onNewIntent(getIntent()) + * and fans the intent out to every plugin's handleOnNewIntent. So + * cold-start and warm-start share the SAME entry point — we don't + * double-process via handleOnStart. + * 2. captureFromIntent copies payload bytes into the app cache and stashes + * the result in {@link #pendingShare}. + * 3. JS booting up (Matrix client ready, user logged in) calls + * pickPendingShare(); receives the JSON; opens the room-picker UI. The + * shareReceived event fired here is dropped silently because no JS + * listener is attached yet — that's fine, pickPendingShare drains the + * slot regardless. + * + * Warm flow (app already running): + * 1. Share-sheet → MainActivity.onNewIntent → BridgeActivity forwards to + * plugin.handleOnNewIntent(intent). + * 2. We re-capture the payload AND emit {@code shareReceived} so JS can + * open the picker without polling. + * + * Why we copy to cache instead of handing JS a content:// URI: + * - WebView fetch() rejects content:// schemes outright, and + * `Capacitor.convertFileSrc()` only works on file paths. + * - The originating app holds the read-grant only for the lifetime of the + * launching task; routing the URI through JS+picker+RoomInput would race + * that grant on Android 14+. + * - Copying into our own cache means the share is self-contained: even if + * the user backgrounds Vojo for hours before picking a chat, the bytes + * are still there. We schedule no cleanup of our own — Android's cache + * eviction handles long-tail garbage. + */ +@CapacitorPlugin(name = "ShareTarget") +public class ShareTargetPlugin extends Plugin { + + private static final String TAG = "ShareTargetPlugin"; + private static final String SHARE_CACHE_SUBDIR = "shared"; + + // Single-slot pending share. Multiple share-sheet invocations before JS + // drains the slot collapse — the latest wins. JS contract is "consume + // once, then it's gone" via pickPendingShare(consume=true). This matches + // user intent: tapping share twice on different photos clearly means + // "share THIS one now". + private volatile JSObject pendingShare = null; + + @Override + public void handleOnNewIntent(Intent intent) { + super.handleOnNewIntent(intent); + captureFromIntent(intent, /* notifyJs */ true); + } + + @PluginMethod + public void pickPendingShare(PluginCall call) { + JSObject ret = new JSObject(); + JSObject snapshot = pendingShare; + if (snapshot == null) { + ret.put("empty", true); + } else { + // Default: consume on read. Lets us treat the slot like a one-shot + // mailbox without an extra round-trip. Caller can pass consume=false + // to peek (not used today, but cheap to keep). + Boolean consume = call.getBoolean("consume", Boolean.TRUE); + ret = snapshot; + if (Boolean.TRUE.equals(consume)) { + pendingShare = null; + } + } + call.resolve(ret); + } + + private void captureFromIntent(Intent intent, boolean notifyJs) { + if (intent == null) return; + String action = intent.getAction(); + if (action == null) return; + + // Capacitor's JSObject.put() silently swallows JSONException internally + // (it wraps org.json.JSONObject and returns `this` on failure) so no + // checked exception is thrown here — unlike the raw org.json API. + JSObject share = new JSObject(); + share.put("empty", false); + + String text = intent.getStringExtra(Intent.EXTRA_TEXT); + String subject = intent.getStringExtra(Intent.EXTRA_SUBJECT); + if (text != null && !text.isEmpty()) share.put("text", text); + if (subject != null && !subject.isEmpty()) share.put("subject", subject); + + JSArray items = new JSArray(); + List uris = new ArrayList<>(); + if (Intent.ACTION_SEND.equals(action)) { + Uri uri; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + uri = intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri.class); + } else { + // Deprecated overload — required to read EXTRA_STREAM on + // API ≤32, where the typed variant doesn't exist. + //noinspection deprecation + uri = intent.getParcelableExtra(Intent.EXTRA_STREAM); + } + if (uri != null) uris.add(uri); + } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { + List multi; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + multi = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri.class); + } else { + //noinspection deprecation + multi = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); + } + if (multi != null) uris.addAll(multi); + } + + String intentMime = intent.getType(); + for (Uri uri : uris) { + JSObject item = copyUriToCache(uri, intentMime); + if (item != null) items.put(item); + } + share.put("items", items); + + // Drop pure-noise intents — neither text nor a successfully + // copied file. Possible if a sender app handed us only a content:// + // URI we can't read (permission revoked) or an EXTRA_STREAM with a + // null Uri. Keeps JS from showing an empty picker. + if (text == null && subject == null && items.length() == 0) { + Log.w(TAG, "Dropping share intent with no usable payload"); + return; + } + + pendingShare = share; + if (notifyJs) { + notifyListeners("shareReceived", new JSObject()); + } + } + + /** + * Stream the content of {@code uri} into a fresh file under + * cacheDir/shared/, then return {name, mimeType, size, path}. The path is + * an absolute filesystem path — JS wraps it with + * {@code Capacitor.convertFileSrc} before fetch(). + */ + private JSObject copyUriToCache(Uri uri, String fallbackMime) { + if (uri == null) return null; + ContentResolver resolver = getContext().getContentResolver(); + + String name = queryDisplayName(resolver, uri); + String mimeType = resolver.getType(uri); + if (mimeType == null) mimeType = fallbackMime; + if (mimeType == null) mimeType = "application/octet-stream"; + + if (name == null || name.isEmpty()) { + String ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); + name = "share-" + UUID.randomUUID() + (ext != null ? "." + ext : ""); + } + + File dir = new File(getContext().getCacheDir(), SHARE_CACHE_SUBDIR); + // mkdirs returns false if the directory already exists — not an error. + // The real failure mode is the I/O exception below on FileOutputStream + // construction, which we surface. + if (!dir.exists() && !dir.mkdirs()) { + Log.e(TAG, "Could not create share cache dir: " + dir); + return null; + } + // Prefix with UUID so a repeated share of "IMG_1234.jpg" doesn't + // overwrite the previous payload while the user is still picking a + // chat for the older one (e.g. Gallery → Vojo, see room-picker open, + // background → Gallery → re-share same file → foreground Vojo). Both + // payloads stay independently addressable. + File out = new File(dir, UUID.randomUUID() + "_" + safeFileName(name)); + + // Open the input first; if the sender's provider hands us back + // null (revoked grant, gone-away ContentProvider, …) bail before + // creating any on-disk file — otherwise the FileOutputStream + // initializer below would create a zero-byte orphan we'd never + // clean up (catch arm doesn't fire when we early-return). + long size; + try (InputStream in = resolver.openInputStream(uri)) { + if (in == null) { + Log.w(TAG, "openInputStream returned null for " + uri); + return null; + } + try (FileOutputStream fos = new FileOutputStream(out)) { + byte[] buf = new byte[64 * 1024]; + int n; + long total = 0; + while ((n = in.read(buf)) > 0) { + fos.write(buf, 0, n); + total += n; + } + size = total; + } + } catch (IOException e) { + Log.e(TAG, "Failed to copy " + uri, e); + // Drop the partial file so we don't surface a truncated + // payload to JS as if it were valid. + //noinspection ResultOfMethodCallIgnored + out.delete(); + return null; + } + + JSObject item = new JSObject(); + item.put("name", name); + item.put("mimeType", mimeType); + item.put("size", size); + item.put("path", out.getAbsolutePath()); + return item; + } + + private String queryDisplayName(ContentResolver resolver, Uri uri) { + // ContentResolver.query throws if the provider rejects the URI scheme + // (e.g. some senders pass a file:// directly — no provider involved). + // Wrap in try/catch and fall back to the URI's last path segment. + try (Cursor c = resolver.query(uri, new String[]{ OpenableColumns.DISPLAY_NAME }, null, null, null)) { + if (c != null && c.moveToFirst()) { + int idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (idx >= 0) { + String name = c.getString(idx); + if (name != null && !name.isEmpty()) return name; + } + } + } catch (Throwable t) { + Log.d(TAG, "queryDisplayName failed for " + uri + ": " + t.getMessage()); + } + String last = uri.getLastPathSegment(); + if (last != null && !last.isEmpty()) { + // Strip any directory traversal a malicious sender might encode. + int slash = last.lastIndexOf('/'); + return slash >= 0 ? last.substring(slash + 1) : last; + } + return null; + } + + private static String safeFileName(String name) { + // Strip path separators and trim length — the on-disk name is just an + // identifier; the display name we return to JS preserves the user's + // original filename verbatim. Trim from the tail so the recognisable + // head ("IMG_2025_05_16…") survives and the extension is the part + // that gets clipped on absurdly long names; the on-disk extension + // doesn't matter because nothing inside Vojo dispatches on it (the + // display name carries the real extension into JS). + String stripped = name.replaceAll("[/\\\\]", "_"); + if (stripped.length() > 120) stripped = stripped.substring(0, 120); + return stripped; + } +} diff --git a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java index e41d10c7..db962ff7 100644 --- a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java +++ b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java @@ -1,11 +1,15 @@ package chat.vojo.app; import android.app.AlarmManager; +import android.app.Notification; import android.app.NotificationChannel; +import android.app.NotificationChannelGroup; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; +import android.graphics.Bitmap; import android.media.AudioAttributes; import android.media.RingtoneManager; import android.net.Uri; @@ -16,12 +20,19 @@ import android.util.Log; import androidx.core.app.NotificationCompat; import androidx.core.app.Person; +import androidx.core.app.RemoteInput; +import androidx.core.content.LocusIdCompat; +import androidx.core.content.pm.ShortcutInfoCompat; +import androidx.core.graphics.drawable.IconCompat; import com.capacitorjs.plugins.pushnotifications.MessagingService; import com.google.firebase.messaging.RemoteMessage; +import org.json.JSONObject; + import java.util.HashMap; import java.util.Iterator; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -32,7 +43,11 @@ import java.util.concurrent.ConcurrentHashMap; * but JS listeners detach when the WebView is paused/backgrounded. * * Message branch: builds a system notification when the activity is NOT in - * the foreground — covering both "backgrounded" and "killed" cases. + * the foreground — covering both "backgrounded" and "killed" cases. The + * actual render is delegated to the static `renderMessageNotification` + * helper below, which is also called from VojoPollWorker on the FCM-blocked + * fallback path. Successful renders are recorded in NotificationDedup so + * the polling Worker doesn't re-surface them on a later cycle. * * Call branch: funnels every observed DM ring through the native ring * registry (see below). FCM arrival either seeds the registry (foreground, @@ -43,7 +58,24 @@ import java.util.concurrent.ConcurrentHashMap; */ public class VojoFirebaseMessagingService extends MessagingService { - private static final String CHANNEL_ID = "vojo_messages"; + // Legacy channel ID (single bucket for everything). Kept as a constant so + // the v1 channel creation path can delete it cleanly on first run after + // upgrade. Do not post into this channel any more. + private static final String LEGACY_MESSAGE_CHANNEL_ID = "vojo_messages"; + + // Channel-group + per-bucket channels. Group lets the OS Settings UI + // collapse both message channels under one "Chats" header so the user can + // toggle DM vs group rooms independently without spelunking. v1 suffix + // mirrors the vojo_calls_v2 strategy — channel settings are immutable + // after creation on API 26+, so any future config change (sound, vibration + // pattern) bumps the version. + private static final String MESSAGE_CHANNEL_GROUP_ID = "vojo_messages_v1"; + // Package-visible: ReplyReceiver reuses CHANNEL_ID_DM for its "reply + // failed" one-shot notification. Was previously a separate _PUBLIC + // alias; collapsed to a single package-private constant. + static final String CHANNEL_ID_DM = "vojo_messages_dm_v1"; + private static final String CHANNEL_ID_GROUP = "vojo_messages_group_v1"; + private static final String GROUP_KEY = "vojo_messages"; // NotificationChannel settings (vibration pattern, sound, importance) are // immutable after creation on API 26+. Bump this ID whenever the channel @@ -68,6 +100,52 @@ public class VojoFirebaseMessagingService extends MessagingService { return RTC_NOTIFICATION_TYPE.equals(type) || RTC_NOTIFICATION_TYPE_STABLE.equals(type); } + /** + * Composite dedup key for a single call session (a ring + its retries). + * Element-web uses the same shape (`call_{callId}_{roomId}` in + * `getIncomingCallToastKey`). We mark this key in NotificationDedup the + * moment we post the first CallStyle for the session; later + * m.rtc.notification events that share the parent — re-ring for + * participant rejoin, FCM retry-with-fresh-event-id — see the mark and + * silently skip the post. + * + * Package-private so VojoPollWorker can build the same key shape from + * its flatten path without duplicating the literal — keeps the FCM and + * polling paths trivially in sync. + */ + static String compositeCallDedupKey(String roomId, String callSessionId) { + return "call:" + roomId + ":" + callSessionId; + } + + /** + * Pull the parent-call event_id (the "call session" identifier) out of + * a flattened Sygnal payload. The standard MSC4075 m.rtc.notification + * references the parent via `content.m.relates_to.event_id`; Sygnal + * flattens nested content with `_` separator but keeps `m.relates_to` + * literal, so the observed FCM keys land as either + * `content_m.relates_to_event_id` (literal dot) or + * `content_m_relates_to_event_id` (escaped to underscore) depending on + * deployment config. We probe both shapes — and `content_call_id` as + * a third candidate for legacy MSC2746 calls — so the dedup works + * regardless of which encoding the homeserver's Sygnal happens to use. + * + * Package-private: VojoPollWorker's flatten path writes the + * `content_m.relates_to_event_id` form, so this same helper resolves + * both FCM and polling payloads. + */ + static String extractCallSessionId(Map data) { + String[] candidates = new String[] { + "content_m.relates_to_event_id", + "content_m_relates_to_event_id", + "content_call_id", + }; + for (String key : candidates) { + String v = data.get(key); + if (v != null && !v.isEmpty()) return v; + } + return null; + } + private static final long RTC_DEFAULT_LIFETIME_MS = 30_000L; private static final long RTC_LIFETIME_GRACE_MS = 2_000L; @@ -180,6 +258,19 @@ public class VojoFirebaseMessagingService extends MessagingService { Log.w(TAG, "route: call missing eventId/roomId, drop"); return; } + // Composite session dedup — silently drop re-rings (different + // event_id, same parent call session) that would otherwise + // re-alert the user for a call they already saw the first + // ring for. The legacy per-eventId dedup misses this because + // each re-ring has a fresh m.rtc.notification event_id. + String callSessionId = extractCallSessionId(data); + if (callSessionId != null) { + String compositeKey = compositeCallDedupKey(roomId, callSessionId); + if (NotificationDedup.wasNotified(this, compositeKey)) { + dlog("route: call re-ring suppressed session=" + callSessionId); + return; + } + } // Snapshot the payload — FCM internals may recycle the map reference. Map snapshot = new HashMap<>(data); boolean seeded = upsertIncomingRing(snapshot, remoteMessage.getMessageId()); @@ -187,6 +278,23 @@ public class VojoFirebaseMessagingService extends MessagingService { dlog("route: call tombstoned, skipping native (event=" + eventId + ")"); return; } + // Cross-source dedup at seed time, regardless of fg/bg branch. + // The bg path also marks again via postIncomingCallNotification + // after a successful nm.notify (defense in depth — markNotified + // is idempotent). The fg path otherwise wouldn't mark at all, + // and a polling fire 15 minutes later would resurface the + // event as a "Missed call" notification even though the user + // already saw the live JS strip and chose to ignore it. + NotificationDedup.markNotified(this, eventId); + // Mark the session composite so a re-ring of the same call + // session (different event_id) doesn't re-alert. Marked + // unconditionally — fg path's "JS strip owns UX" means the + // user has already been alerted in-app even though no native + // notification was posted. + if (callSessionId != null) { + NotificationDedup.markNotified(this, + compositeCallDedupKey(roomId, callSessionId)); + } if (MainActivity.isInForeground) { dlog("route: call seeded (foreground, JS strip owns UX) event=" + eventId); // Race guard: MainActivity.onPause may have run its render @@ -212,11 +320,41 @@ public class VojoFirebaseMessagingService extends MessagingService { } return; } + String eventId = data.get("event_id"); if (!MainActivity.isInForeground) { + // Cross-source dedup gate — mirrors VojoPollWorker.doWork: + // if NotificationDedup already saw this eventId (FCM + // retry, or the polling Worker rendered it first), skip + // re-rendering. Without this gate an FCM retry would + // re-append the same message into the per-room + // MessagingStyle conversation (RoomMessageCache.append + // now also dedupes by eventId as belt-and-suspenders, but + // skipping render entirely is cheaper). + if (eventId != null && !eventId.isEmpty() + && NotificationDedup.wasNotified(this, eventId)) { + dlog("route: message-branch dedup skip event=" + eventId); + return; + } dlog("route: message-branch (background)"); - showSystemNotification(remoteMessage); + boolean posted = renderMessageNotification( + this, data, remoteMessage.getMessageId()); + // Cross-source dedup: only mark on a successful nm.notify so + // a permission-revoked SecurityException doesn't silently + // hide the event from VojoPollWorker's retry path. The + // polling Worker writes to the same store from doWork(). + if (posted && eventId != null && !eventId.isEmpty()) { + NotificationDedup.markNotified(this, eventId); + } } else { dlog("route: skip (foreground, non-call)"); + // Even though we didn't render, the JS timeline already + // surfaced the event live. Mark it in NotificationDedup so a + // later poll cycle — fired after the user backgrounds the + // app but before the server marks the event read — does not + // resurface it in the shade as a stale "missed" notification. + if (eventId != null && !eventId.isEmpty()) { + NotificationDedup.markNotified(this, eventId); + } } } catch (Throwable t) { // Don't let any notification-construction bug crash the FCM service — if we @@ -227,57 +365,357 @@ public class VojoFirebaseMessagingService extends MessagingService { } } - private void showSystemNotification(RemoteMessage message) { - Map data = message.getData(); + /** + * Shared message/invite renderer. Called by the FCM service (instance path, + * background only) and by VojoPollWorker (background polling path, used as + * the FCM-blocked fallback delivery channel). + * + * Static + Context-parameterised so the Worker — which has no Service + * lifetime — can post into the same notification id space. Identity is + * derived as `roomId.hashCode()` — one MessagingStyle conversation per + * room, appended on each new event. WhatsApp/Telegram convention: five + * messages in one DM coalesce into one expandable entry instead of five + * stacked banners. NotificationDedup is the cross-source LRU that prevents + * FCM and polling from double-counting the same event into the conversation. + * + * History source: in-memory RoomMessageCache (bounded per-room deque). + * Process-kill recovery: extractMessagingStyleFromNotification reads the + * existing on-shade notification and seeds the cache, so a re-render after + * cold-start preserves prior messages instead of starting empty. + * + * Both call sites pre-gate the "should I render" decision: FCM gates on + * `!isInForeground`, polling gates on its own watermark + NotificationDedup. + * This method just renders. + */ + static boolean renderMessageNotification( + Context ctx, + Map data, + String messageId + ) { String roomId = data.get("room_id"); String eventId = data.get("event_id"); // Sygnal flattens nested notification fields with `_` separator: - // sender_display_name, content_body, content_msgtype, etc. + // sender_display_name, content_body, content_msgtype, etc. The polling + // fallback (VojoPollWorker) builds the same flattened shape when it + // parses /_matrix/client/v3/notifications responses, so the rest of + // this method is source-agnostic. boolean isInvite = "m.room.member".equals(data.get("type")) && "invite".equals(data.get("content_membership")); - String title; - String body; + // Invites pre-date conversation context and don't benefit from + // MessagingStyle (no body, no sender thread). Render them with the + // original BigTextStyle path so the invite-tap navigation still lands + // on the /direct/ panel via the existing JS listener. if (isInvite) { - // m.room.member invites carry no content.body — the generic - // path would show "New message" and drop the invite semantic. - // Title marks the category ("Invitation"); inviter + room land - // in body (WhatsApp/Telegram convention; keeps shade scannable). - title = PushStrings.inviteTitle(this); - body = PushStrings.inviteBody(this, humanInviter(data), data.get("room_name")); - } else { - title = firstNonEmpty( - data.get("room_name"), - data.get("sender_display_name"), - data.get("sender"), - "Vojo" - ); - body = firstNonEmpty( - data.get("content_body"), - PushStrings.messageFallback(this) - ); + return renderInviteNotification(ctx, data, messageId); } - // Reuse Capacitor plugin's intent shape so its handleOnNewIntent() fires - // `pushNotificationActionPerformed` and the existing JS listener navigates. - Intent launchIntent = new Intent(this, MainActivity.class); + if (roomId == null || roomId.isEmpty()) { + Log.w(TAG, "msg: missing room_id, drop"); + return false; + } + + NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm == null) { + Log.w(TAG, "msg: NotificationManager is null, abort"); + return false; + } + + RoomMetadata meta = loadRoomMetadata(ctx, roomId); + String channelId = meta.isDirect ? CHANNEL_ID_DM : CHANNEL_ID_GROUP; + ensureMessageChannels(ctx, nm); + + String senderName = firstNonEmpty( + data.get("sender_display_name"), + mxidLocalPart(data.get("sender")), + "Vojo" + ); + String senderKey = firstNonEmpty(data.get("sender"), senderName); + String body = firstNonEmpty( + data.get("content_body"), + PushStrings.messageFallback(ctx) + ); + long timestamp = parseLong(data.get("content_sender_ts"), System.currentTimeMillis()); + if (timestamp <= 0L) timestamp = System.currentTimeMillis(); + + // Process-kill recovery: if our in-memory cache is empty for this + // room, try to recover the conversation history from the on-shade + // notification posted by a prior process. Without this a cold-start + // after kill would shrink the conversation back to a single message. + seedCacheFromActiveNotification(ctx, nm, roomId); + + RoomMessageCache.Entry entry = new RoomMessageCache.Entry( + eventId, body, timestamp, senderKey, senderName, /* fromSelf */ false + ); + List history = RoomMessageCache.append(roomId, entry); + + // Pre-warm AvatarBitmapCache before building Person objects so the + // first nm.notify already includes icons rather than re-rendering + // after async loads. Blocks up to 800ms on cold cache; cache hits + // are no-ops. Failures land as no-icon Persons (Android fallback). + SharedPreferences avatarPrefs = ctx.getSharedPreferences( + VojoPollWorker.PREFS, Context.MODE_PRIVATE); + java.util.List avatarMxcs = collectAvatarMxcs(avatarPrefs, history, meta); + Log.i(TAG, "msg: collected " + avatarMxcs.size() + " avatar mxcs" + + " (history=" + history.size() + " roomMxc=" + (meta.avatarMxc != null) + ")"); + AvatarLoader.loadAllWithTimeout(ctx, avatarMxcs); + + // Self Person anchors the MessagingStyle constructor. Real user_id + + // localised "You" label come from prefs that JS bridged via + // PollingPlugin.saveSession. On a fresh install with a push arriving + // before the JS bridge has ever run we fall through to a generic key + // — MessagingStyle still renders correctly, just without an account- + // specific self anchor. + Person self = buildSelfPerson(ctx); + + NotificationCompat.MessagingStyle style = new NotificationCompat.MessagingStyle(self); + String roomName = firstNonEmpty(data.get("room_name"), meta.name); + boolean isGroup = !meta.isDirect; + if (isGroup && !roomName.isEmpty()) { + style.setConversationTitle(roomName); + } + style.setGroupConversation(isGroup); + + // Track the most-recent non-self sender so we can attach it via + // builder.addPerson() below — Android attributes the conversation + // preview using addPerson() Persons, not the MessagingStyle ones + // (the two have to agree, but the avatar source for the + // collapsed-shade preview is the addPerson set). + Person lastNonSelfSender = null; + for (RoomMessageCache.Entry e : history) { + Person sender; + if (e.fromSelf) { + sender = null; + } else { + Person.Builder sb = new Person.Builder() + .setName(e.senderName != null ? e.senderName : "") + .setKey(e.senderKey != null ? e.senderKey : ""); + String senderMxc = lookupUserAvatarMxc(avatarPrefs, e.senderKey); + IconCompat senderIcon = iconFromCachedMxc(senderMxc); + if (senderIcon != null) sb.setIcon(senderIcon); + sender = sb.build(); + lastNonSelfSender = sender; + Log.i(TAG, "msg: sender Person key=" + e.senderKey + + " mxc=" + senderMxc + + " iconAttached=" + (senderIcon != null)); + } + style.addMessage(new NotificationCompat.MessagingStyle.Message( + e.body != null ? e.body : "", e.timestamp, sender + )); + } + + // Per-room id slot — every event in this room replaces in place. + int notifId = roomNotifId(roomId); + + // PendingIntent must be distinct per re-render so FLAG_UPDATE_CURRENT + // doesn't smash the prior intent's extras — but the request code is + // stable per room so we don't leak intents. + // + // Mutability: conversation notifications on Android 11+ that carry + // both a setShortcutInfo and an addPerson trigger the system's + // "may bubble" path, and the NMS disqualifying-features check + // (NotificationManagerService.checkDisqualifyingFeatures) rejects + // any such notification whose contentIntent is FLAG_IMMUTABLE + // with "PendingIntents attached to bubbles must be mutable" — even + // if we never explicitly set a BubbleMetadata. The notification + // gets DROPPED entirely. So this PI must be MUTABLE on API 31+. + // The intent only carries roomId/eventId/google.message_id (no + // secrets), so granting the system the ability to fill in extras + // is acceptable. + Intent launchIntent = new Intent(ctx, MainActivity.class); + launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); + launchIntent.putExtra("google.message_id", messageId != null ? messageId : ""); + for (Map.Entry en : data.entrySet()) { + launchIntent.putExtra(en.getKey(), en.getValue()); + } + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + flags |= PendingIntent.FLAG_MUTABLE; + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + flags |= PendingIntent.FLAG_IMMUTABLE; + } + PendingIntent contentIntent = PendingIntent.getActivity( + ctx, ("open_" + roomId).hashCode(), launchIntent, flags + ); + + // Resolve room avatar (used both for largeIcon AND as the shortcut + // icon — keeping them in sync gives the conversation avatar a + // single source of truth). + android.graphics.Bitmap roomBitmap = meta.avatarMxc != null + ? AvatarBitmapCache.get(meta.avatarMxc) : null; + + // Publish the conversation shortcut BEFORE building the + // notification so we can attach the returned ShortcutInfoCompat + // via setShortcutInfo() — that's the documented atomic-bind path + // and is what flips Android's shade into conversation styling + // (shows Person/large icon in collapsed preview instead of app + // icon). + ShortcutInfoCompat shortcut = ConversationShortcuts.publishForRoom( + ctx, + roomId, + meta.isDirect, + firstNonEmpty(data.get("room_name"), meta.name, "Vojo"), + roomBitmap + ); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, channelId) + .setSmallIcon(R.mipmap.ic_launcher) + .setStyle(style) + .setAutoCancel(true) + .setContentIntent(contentIntent) + // Hook user-swipe so RoomMessageCache.clear runs and the next + // push to this room starts a fresh conversation — without this, + // the dismissed messages would re-surface inside the next + // MessagingStyle re-render. + .setDeleteIntent(buildDismissPendingIntent(ctx, roomId)) + .setGroup(GROUP_KEY) + // Suppress re-alerting on every appended message — Android would + // otherwise vibrate + sound + heads-up for each new event in an + // active conversation, which is the exact UX regression + // per-room MessagingStyle was supposed to fix. The first + // notification in a room still alerts; subsequent updates are + // visual only. + .setOnlyAlertOnce(history.size() > 1) + // LocusId is the second half of conversation attribution — + // setShortcutId alone tells the system "this notification + // belongs to this shortcut", but LocusId is what flips on + // the conversation-grouping / bubbles / wellbeing integration. + // Element-android attaches both. + .setLocusId(new LocusIdCompat(roomId)) + .setWhen(timestamp) + .setShowWhen(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + // GROUP_ALERT_ALL (default — not explicitly set) lets the + // per-room notification raise its own heads-up/sound the + // first time it appears, while setOnlyAlertOnce above + // suppresses re-alert on subsequent updates within the same + // room. The companion group summary is built with + // setOnlyAlertOnce(true) so it never doubles up the alert. + // Previously this was GROUP_ALERT_SUMMARY which silenced the + // child entirely, so DM messages with rich sender info never + // produced a heads-up — only a generic "Vojo / New messages" + // summary banner did. + .addAction(buildMarkAsReadAction(ctx, roomId, eventId)); + + // Bind notification to shortcut: prefer setShortcutInfo (full + // ShortcutInfoCompat) when we successfully published, else fall + // back to setShortcutId (id-only — still useful on older OS that + // ignore Conversation styling anyway). + if (shortcut != null) { + builder.setShortcutInfo(shortcut); + } else { + builder.setShortcutId(roomId); + } + + // Attribution Person on the builder itself — Android's + // conversation-styling layer uses addPerson() to pick the avatar + // for the collapsed shade preview, NOT the Person objects inside + // MessagingStyle messages (those drive the per-message bubble + // avatars in the expanded view). Without addPerson the preview + // falls back to the small icon (= app icon) even when MessagingStyle + // has rich Person.setIcon entries on every message. + if (lastNonSelfSender != null) { + builder.addPerson(lastNonSelfSender); + } + + if (roomBitmap != null) { + builder.setLargeIcon(roomBitmap); + } + + Log.i(TAG, "msg: largeIcon attached=" + (roomBitmap != null) + + " shortcutBound=" + (shortcut != null) + + " roomMxc=" + meta.avatarMxc); + + // Inline reply is only safe in cleartext rooms — the receiver + // builds a vanilla `m.room.message`, and we have no key material + // on the Java side to encrypt. RoomMetadata.isEncrypted defaults + // to true on missing/legacy snapshots (privacy-first), so reply + // is opt-in via a confirmed cleartext flag rather than opt-out. + if (!meta.isEncrypted) { + builder.addAction(buildReplyAction(ctx, roomId)); + } + + dlog("msg: posting notif id=" + notifId + " channel=" + channelId + + " historySize=" + history.size() + + " notifsEnabled=" + nm.areNotificationsEnabled()); + boolean posted = false; + try { + nm.notify(notifId, builder.build()); + posted = true; + } catch (SecurityException e) { + Log.e(TAG, "msg: nm.notify threw SecurityException", e); + } + + // Group summary keeps the per-room notifications collapsing under + // a single shade entry on Android pre-N + the launcher unread dot + // works off the summary on every Android. Posted on the DM channel + // so it inherits the lighter-weight settings; the per-room + // notification can still alert independently from its own channel. + try { + NotificationCompat.Builder summary = new NotificationCompat.Builder(ctx, channelId) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle("Vojo") + .setContentText(PushStrings.messagesFallback(ctx)) + .setGroup(GROUP_KEY) + .setGroupSummary(true) + // The per-room child already alerts on first post (default + // GROUP_ALERT_ALL); this summary must stay silent on every + // post or every additional room would re-alert through the + // generic "New messages" banner. setOnlyAlertOnce is per + // notification id (here SUMMARY_NOTIFICATION_ID is + // constant), so the first post still alerts once if the + // child is suppressed — which never happens in our setup. + .setOnlyAlertOnce(true) + .setAutoCancel(true); + nm.notify(SUMMARY_NOTIFICATION_ID, summary.build()); + } catch (SecurityException e) { + Log.e(TAG, "msg: summary notify threw SecurityException", e); + } + return posted; + } + + /** + * Pre-MessagingStyle render path for m.room.member invites. They predate + * the room they would belong to (the conversation doesn't exist yet, no + * sender thread to anchor), so the BigTextStyle invite-card is still the + * right shape. The per-event id slot is preserved so multiple invites + * stack as separate entries. + */ + private static boolean renderInviteNotification( + Context ctx, + Map data, + String messageId + ) { + String roomId = data.get("room_id"); + String eventId = data.get("event_id"); + + String title = PushStrings.inviteTitle(ctx); + String body = PushStrings.inviteBody(ctx, humanInviter(data), data.get("room_name")); + + Intent launchIntent = new Intent(ctx, MainActivity.class); launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); - String messageId = message.getMessageId(); launchIntent.putExtra("google.message_id", messageId != null ? messageId : ""); for (Map.Entry e : data.entrySet()) { launchIntent.putExtra(e.getKey(), e.getValue()); } - - // Unique requestCode per event so each notification's PendingIntent is distinct String uniqueKey = eventId != null ? eventId : (roomId != null ? roomId : "vojo"); int requestCode = uniqueKey.hashCode(); - int flags = PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); - PendingIntent pendingIntent = PendingIntent.getActivity(this, requestCode, launchIntent, flags); + PendingIntent pendingIntent = PendingIntent.getActivity(ctx, requestCode, launchIntent, flags); - NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) + NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm == null) { + Log.w(TAG, "invite: NotificationManager is null, abort"); + return false; + } + // Invites route to the DM channel — they almost always graduate into + // a DM and the lighter settings match their relatively quiet UX. + ensureMessageChannels(ctx, nm); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, CHANNEL_ID_DM) .setSmallIcon(R.mipmap.ic_launcher) .setContentTitle(title) .setContentText(body) @@ -288,35 +726,596 @@ public class VojoFirebaseMessagingService extends MessagingService { .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(NotificationCompat.CATEGORY_MESSAGE); - NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - if (nm == null) { - Log.w(TAG, "msg: NotificationManager is null, abort"); - return; - } - - ensureMessageChannel(nm); - - // Unique notification id per event — each message shows separately in the shade. - // Guard against the (rare) hashCode collision with the reserved summary id. int notifId = uniqueKey.hashCode(); if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1; - dlog("msg: posting notif id=" + notifId + " channel=" + CHANNEL_ID - + " notifsEnabled=" + nm.areNotificationsEnabled()); try { nm.notify(notifId, builder.build()); } catch (SecurityException e) { - Log.e(TAG, "msg: nm.notify threw SecurityException", e); + Log.e(TAG, "invite: nm.notify threw SecurityException", e); + return false; + } + try { + NotificationCompat.Builder summary = new NotificationCompat.Builder(ctx, CHANNEL_ID_DM) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle("Vojo") + .setContentText(PushStrings.messagesFallback(ctx)) + .setGroup(GROUP_KEY) + .setGroupSummary(true) + // The per-room child already alerts on first post (default + // GROUP_ALERT_ALL); this summary must stay silent on every + // post or every additional room would re-alert through the + // generic "New messages" banner. setOnlyAlertOnce is per + // notification id (here SUMMARY_NOTIFICATION_ID is + // constant), so the first post still alerts once if the + // child is suppressed — which never happens in our setup. + .setOnlyAlertOnce(true) + .setAutoCancel(true); + nm.notify(SUMMARY_NOTIFICATION_ID, summary.build()); + } catch (SecurityException e) { + Log.e(TAG, "invite: summary notify threw SecurityException", e); + } + return true; + } + + /** + * Dismiss the per-room MessagingStyle notification and drop the in-memory + * cache. Called from MarkAsReadReceiver after a successful read receipt + * PUT and from PollingPlugin.dismissRoom when JS observes a server-side + * receipt taking unread count to zero on another device. + */ + static void dismissRoomNotification(Context ctx, String roomId) { + if (roomId == null || roomId.isEmpty()) return; + RoomMessageCache.clear(roomId); + NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm == null) return; + try { + nm.cancel(roomNotifId(roomId)); + } catch (Throwable t) { + Log.w(TAG, "dismiss: nm.cancel threw room=" + roomId, t); + } + } + + static int roomNotifId(String roomId) { + int id = roomId.hashCode(); + // SUMMARY_NOTIFICATION_ID is reserved (Integer.MIN_VALUE); empty + // String hashCode() = 0 also leaves no native ambiguity but we + // bump it anyway so the slot is deterministic. + if (id == SUMMARY_NOTIFICATION_ID) id += 1; + return id; + } + + private static PendingIntent buildDismissPendingIntent(Context ctx, String roomId) { + Intent intent = new Intent(ctx, NotificationDismissReceiver.class) + .setAction(NotificationDismissReceiver.ACTION_NOTIFICATION_DISMISSED) + .putExtra(NotificationDismissReceiver.EXTRA_ROOM_ID, roomId); + int requestCode = ("dismiss_" + roomId).hashCode(); + int flags = PendingIntent.FLAG_UPDATE_CURRENT + | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); + return PendingIntent.getBroadcast(ctx, requestCode, intent, flags); + } + + private static NotificationCompat.Action buildMarkAsReadAction( + Context ctx, String roomId, String eventId + ) { + Intent intent = new Intent(ctx, MarkAsReadReceiver.class) + .setAction(MarkAsReadReceiver.ACTION_MARK_AS_READ) + .putExtra(MarkAsReadReceiver.EXTRA_ROOM_ID, roomId); + if (eventId != null && !eventId.isEmpty()) { + intent.putExtra(MarkAsReadReceiver.EXTRA_EVENT_ID, eventId); + } + int requestCode = ("read_" + roomId).hashCode(); + int flags = PendingIntent.FLAG_UPDATE_CURRENT + | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); + PendingIntent pi = PendingIntent.getBroadcast(ctx, requestCode, intent, flags); + return new NotificationCompat.Action.Builder( + R.mipmap.ic_launcher, + PushStrings.markAsReadAction(ctx), + pi + ) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) + .setShowsUserInterface(false) + .build(); + } + + /** + * Inline reply action with RemoteInput. Tapping the action opens an + * in-shade text field; submit fires the receiver and PUTs the message + * as `m.room.message` from the cleartext path. Only attached for + * non-encrypted rooms (see caller gate); E2EE replies need keys the + * Java side does not have. + * + * MutabilityCompat: RemoteInput requires the PendingIntent to be + * MUTABLE so the input bundle can be attached at submit time. We OR + * FLAG_MUTABLE on API 31+ where the immutable default would otherwise + * cause the receiver to fire without any RemoteInput payload. + */ + private static NotificationCompat.Action buildReplyAction(Context ctx, String roomId) { + Intent intent = new Intent(ctx, ReplyReceiver.class) + .setAction(ReplyReceiver.ACTION_REPLY) + .putExtra(ReplyReceiver.EXTRA_ROOM_ID, roomId); + int requestCode = ("reply_" + roomId).hashCode(); + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + flags |= PendingIntent.FLAG_MUTABLE; + } + PendingIntent pi = PendingIntent.getBroadcast(ctx, requestCode, intent, flags); + RemoteInput remoteInput = new RemoteInput.Builder(ReplyReceiver.KEY_TEXT_REPLY) + .setLabel(PushStrings.replyHint(ctx)) + .build(); + return new NotificationCompat.Action.Builder( + R.mipmap.ic_launcher, + PushStrings.replyAction(ctx), + pi + ) + .addRemoteInput(remoteInput) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) + .setShowsUserInterface(false) + // Allow Wear / Android Auto auto-replies for accessibility. + .setAllowGeneratedReplies(true) + .build(); + } + + /** + * Append a self-Person message to the room's MessagingStyle cache and + * re-post the notification with the new entry visible. Used by + * ReplyReceiver for instant optimistic feedback before the HTTP PUT + * completes — the user sees their text in the conversation bubble + * immediately, the live sync echo eventually replaces it with the + * server-authoritative version when they next open the app. + * + * Returns true iff the notification re-render succeeded. + */ + static boolean appendOutgoingMessage( + Context ctx, String roomId, String body, long timestamp + ) { + if (roomId == null || roomId.isEmpty() || body == null || body.isEmpty()) { + return false; + } + NotificationManager nm = (NotificationManager) + ctx.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm == null) return false; + RoomMetadata meta = loadRoomMetadata(ctx, roomId); + String channelId = meta.isDirect ? CHANNEL_ID_DM : CHANNEL_ID_GROUP; + ensureMessageChannels(ctx, nm); + + // Re-seed from active notification before append so the new self + // message lands at the tail of any in-flight conversation history + // (covers the cold-render-after-kill case for outgoing replies). + seedCacheFromActiveNotification(ctx, nm, roomId); + + RoomMessageCache.Entry self = new RoomMessageCache.Entry( + /*eventId*/ null, body, timestamp, + /*senderKey*/ null, /*senderName*/ "", /*fromSelf*/ true + ); + java.util.List history = RoomMessageCache.append(roomId, self); + + // Pre-warm avatars for the same reason as renderMessageNotification: + // the optimistic-echo re-render should already include sender icons + // rather than flashing without and re-posting. The cache is shared + // across both paths so a freshly-received message warming the + // cache primes a later reply re-render. + SharedPreferences avatarPrefs = ctx.getSharedPreferences( + VojoPollWorker.PREFS, Context.MODE_PRIVATE); + java.util.List avatarMxcs = collectAvatarMxcs(avatarPrefs, history, meta); + AvatarLoader.loadAllWithTimeout(ctx, avatarMxcs); + + Person selfPerson = buildSelfPerson(ctx); + NotificationCompat.MessagingStyle style = new NotificationCompat.MessagingStyle(selfPerson); + boolean isGroup = !meta.isDirect; + if (isGroup && !meta.name.isEmpty()) { + style.setConversationTitle(meta.name); + } + style.setGroupConversation(isGroup); + Person lastNonSelfSender = null; + for (RoomMessageCache.Entry e : history) { + Person sender; + if (e.fromSelf) { + sender = null; + } else { + Person.Builder sb = new Person.Builder() + .setName(e.senderName != null ? e.senderName : "") + .setKey(e.senderKey != null ? e.senderKey : ""); + String senderMxc = lookupUserAvatarMxc(avatarPrefs, e.senderKey); + IconCompat senderIcon = iconFromCachedMxc(senderMxc); + if (senderIcon != null) sb.setIcon(senderIcon); + sender = sb.build(); + lastNonSelfSender = sender; + Log.i(TAG, "msg: sender Person key=" + e.senderKey + + " mxc=" + senderMxc + + " iconAttached=" + (senderIcon != null)); + } + style.addMessage(new NotificationCompat.MessagingStyle.Message( + e.body != null ? e.body : "", e.timestamp, sender + )); } - // Summary notification for the group (Android shows this when 4+ notifications stack) - NotificationCompat.Builder summary = new NotificationCompat.Builder(this, CHANNEL_ID) + Intent launchIntent = new Intent(ctx, MainActivity.class); + launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); + launchIntent.putExtra("google.message_id", ""); + launchIntent.putExtra("room_id", roomId); + // MUTABLE for conversation-notification bubble eligibility on + // API 31+ — same rationale as in renderMessageNotification. + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + flags |= PendingIntent.FLAG_MUTABLE; + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + flags |= PendingIntent.FLAG_IMMUTABLE; + } + PendingIntent contentIntent = PendingIntent.getActivity( + ctx, ("open_" + roomId).hashCode(), launchIntent, flags + ); + + android.graphics.Bitmap roomBitmap = meta.avatarMxc != null + ? AvatarBitmapCache.get(meta.avatarMxc) : null; + + // Mirror renderMessageNotification: publish shortcut FIRST, + // attach via setShortcutInfo, set LocusId. Same conversation + // styling on the reply-echo re-render as on the receive render. + ShortcutInfoCompat shortcut = ConversationShortcuts.publishForRoom( + ctx, roomId, meta.isDirect, + meta.name.isEmpty() ? "Vojo" : meta.name, + roomBitmap + ); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, channelId) .setSmallIcon(R.mipmap.ic_launcher) - .setContentTitle("Vojo") - .setContentText(PushStrings.messagesFallback(this)) + .setStyle(style) + .setAutoCancel(true) + .setContentIntent(contentIntent) + .setDeleteIntent(buildDismissPendingIntent(ctx, roomId)) .setGroup(GROUP_KEY) - .setGroupSummary(true) - .setAutoCancel(true); - nm.notify(SUMMARY_NOTIFICATION_ID, summary.build()); + // Always silent — sending a reply must not re-alert the user. + .setOnlyAlertOnce(true) + .setLocusId(new LocusIdCompat(roomId)) + .setWhen(timestamp) + .setShowWhen(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + // setOnlyAlertOnce(true) above already keeps the outgoing + // re-render silent; default GROUP_ALERT_ALL applies. Previously + // GROUP_ALERT_SUMMARY was set here for symmetry with the + // receive path, but that also silenced the child outside of + // the alert-once window — removed for the same reason as in + // renderMessageNotification. + // Re-attach mark-as-read so the action set stays consistent on + // re-render; eventId is unknown for an outgoing-only update so + // it falls through to the local-dismiss-only branch in the + // receiver — acceptable for an optimistic echo. + .addAction(buildMarkAsReadAction(ctx, roomId, null)); + if (shortcut != null) { + builder.setShortcutInfo(shortcut); + } else { + builder.setShortcutId(roomId); + } + if (roomBitmap != null) { + builder.setLargeIcon(roomBitmap); + } + if (lastNonSelfSender != null) { + builder.addPerson(lastNonSelfSender); + } + if (!meta.isEncrypted) { + builder.addAction(buildReplyAction(ctx, roomId)); + } + + try { + nm.notify(roomNotifId(roomId), builder.build()); + return true; + } catch (SecurityException e) { + Log.e(TAG, "outgoing: nm.notify threw SecurityException", e); + return false; + } + } + + private static Person buildSelfPerson(Context ctx) { + SharedPreferences prefs = ctx.getSharedPreferences( + VojoPollWorker.PREFS, Context.MODE_PRIVATE); + String userId = prefs.getString(VojoPollWorker.KEY_USER_ID, null); + Person.Builder b = new Person.Builder() + .setName(PushStrings.selfName(ctx)); + if (userId != null && !userId.isEmpty()) { + b.setKey(userId); + String mxc = lookupUserAvatarMxc(prefs, userId); + if (mxc != null) { + IconCompat icon = iconFromCachedMxc(mxc); + if (icon != null) b.setIcon(icon); + } + } + return b.build(); + } + + /** + * Lookup avatar MXC for a user_id from the JS-bridged userAvatars + * snapshot in vojo_poll_state. Returns null when the user is not in + * the snapshot (cap of 500 entries) or when prefs are empty (cold + * start before any bridge has run). + */ + private static String lookupUserAvatarMxc(SharedPreferences prefs, String userId) { + if (userId == null || userId.isEmpty()) return null; + String raw = prefs.getString(VojoPollWorker.KEY_USER_AVATARS, null); + if (raw == null || raw.isEmpty()) { + Log.i(TAG, "avatar lookup: prefs empty for userId=" + userId); + return null; + } + try { + JSONObject map = new JSONObject(raw); + if (!map.has(userId) || map.isNull(userId)) { + Log.i(TAG, "avatar lookup: miss for userId=" + userId + + " (snapshot has " + map.length() + " entries)"); + return null; + } + String mxc = map.optString(userId, null); + if (mxc == null || mxc.isEmpty()) return null; + return mxc; + } catch (Throwable t) { + Log.w(TAG, "avatar lookup: JSON parse failed", t); + return null; + } + } + + /** + * Wrap a cache-hit bitmap into an IconCompat for Person.setIcon / + * NotificationCompat.setLargeIcon. Returns null on cache miss — the + * caller renders the Person without an icon and Android falls back to + * its monogram / blank circle. + */ + private static IconCompat iconFromCachedMxc(String mxc) { + if (mxc == null || mxc.isEmpty()) return null; + android.graphics.Bitmap bmp = AvatarBitmapCache.get(mxc); + if (bmp == null) return null; + return IconCompat.createWithBitmap(bmp); + } + + /** + * Collect every MXC URL needed to render a per-room MessagingStyle + * conversation: self avatar (if known), each historical sender's + * avatar, plus the room avatar for the largeIcon. Used to pre-warm + * AvatarBitmapCache via AvatarLoader.loadAllWithTimeout BEFORE the + * Person / largeIcon construction. Skips entries already in cache so + * the loader can short-circuit. + */ + private static java.util.List collectAvatarMxcs( + SharedPreferences prefs, + java.util.List history, + RoomMetadata meta + ) { + java.util.LinkedHashSet out = new java.util.LinkedHashSet<>(); + String selfUserId = prefs.getString(VojoPollWorker.KEY_USER_ID, null); + if (selfUserId != null && !selfUserId.isEmpty()) { + String selfMxc = lookupUserAvatarMxc(prefs, selfUserId); + if (selfMxc != null) out.add(selfMxc); + } + if (history != null) { + for (RoomMessageCache.Entry e : history) { + if (e.fromSelf) continue; + if (e.senderKey == null || e.senderKey.isEmpty()) continue; + String mxc = lookupUserAvatarMxc(prefs, e.senderKey); + if (mxc != null) out.add(mxc); + } + } + if (meta != null && meta.avatarMxc != null && !meta.avatarMxc.isEmpty()) { + out.add(meta.avatarMxc); + } + return new java.util.ArrayList<>(out); + } + + /** + * Try to populate RoomMessageCache from an already-posted MessagingStyle + * notification so a re-render after process kill preserves conversation + * history instead of collapsing back to a single message. No-op when the + * cache already has entries for this room (the in-memory store is the + * source of truth in the normal hot path). + */ + private static void seedCacheFromActiveNotification( + Context ctx, NotificationManager nm, String roomId + ) { + try { + StatusBarNotification[] active = nm.getActiveNotifications(); + int slot = roomNotifId(roomId); + for (StatusBarNotification sbn : active) { + if (sbn.getTag() != null) continue; + if (sbn.getId() != slot) continue; + Notification posted = sbn.getNotification(); + if (posted == null) continue; + NotificationCompat.MessagingStyle existing = + NotificationCompat.MessagingStyle + .extractMessagingStyleFromNotification(posted); + if (existing == null) return; + List entries = new java.util.ArrayList<>(); + for (NotificationCompat.MessagingStyle.Message m : existing.getMessages()) { + Person p = m.getPerson(); + String name = p != null && p.getName() != null + ? p.getName().toString() : ""; + String key = p != null ? p.getKey() : null; + String body = m.getText() != null ? m.getText().toString() : ""; + // eventId is not preserved through MessagingStyle's + // extras-based round-trip — recovered entries seed + // with null. The append-dedup is then no-op for these, + // which is acceptable: process-kill recovery is rare, + // and the very next push that arrives will compare + // against the seeded entry by sender+timestamp+body + // mostly fortuitously (no risk of OUR own re-render + // doubling). The only theoretical regression is a + // single FCM duplicate retry landing right after the + // recovery — uncommon enough to accept. + entries.add(new RoomMessageCache.Entry( + /*eventId*/ null, + body, m.getTimestamp(), key, name, + /* fromSelf */ p == null + )); + } + RoomMessageCache.seedIfAbsent(roomId, entries); + return; + } + } catch (Throwable t) { + Log.w(TAG, "seed: extractMessagingStyleFromNotification failed", t); + } + } + + /** + * Snapshot of per-room metadata bridged from JS via + * PollingPlugin.saveRoomNames (which now accepts both a legacy + * room_id → name string map AND a structured shape including isDirect + * and isEncrypted). isEncrypted controls whether the inline reply + * action is offered — we can't sign + encrypt without keys on the Java + * side, so encrypted rooms get a read-only notification. + */ + private static final class RoomMetadata { + final String name; + final boolean isDirect; + final boolean isEncrypted; + final String avatarMxc; + + RoomMetadata(String name, boolean isDirect, boolean isEncrypted, String avatarMxc) { + this.name = name == null ? "" : name; + this.isDirect = isDirect; + this.isEncrypted = isEncrypted; + this.avatarMxc = avatarMxc; + } + } + + private static RoomMetadata loadRoomMetadata(Context ctx, String roomId) { + // Privacy-first defaults on every "no information" path: isEncrypted + // = true so the inline reply action is NOT offered when we can't + // confirm the room is cleartext. Reviewer correctly flagged that + // previously these defaults were `false`, which means an unknown + // room rendered the reply affordance — and the receiver's + // server-side guard would silently reject the send, leaving the + // user with a dead button. With isEncrypted=true the action is + // simply absent until JS bridges fresh metadata. + if (roomId == null || roomId.isEmpty()) { + return new RoomMetadata("", true, true, null); + } + SharedPreferences prefs = ctx.getSharedPreferences( + VojoPollWorker.PREFS, Context.MODE_PRIVATE); + String raw = prefs.getString(VojoPollWorker.KEY_ROOM_NAMES, null); + if (raw == null || raw.isEmpty()) { + return new RoomMetadata("", true, true, null); + } + try { + JSONObject map = new JSONObject(raw); + if (!map.has(roomId) || map.isNull(roomId)) { + return new RoomMetadata("", true, true, null); + } + // Legacy shape: { roomId: "Display name" }. New shape: + // { roomId: { name: "Display name", isDirect: bool, + // isEncrypted: bool } }. Parse tolerantly so an APK + // that still has the old map written to prefs from a previous + // boot doesn't lose channel routing. + JSONObject obj = map.optJSONObject(roomId); + if (obj != null) { + String name = obj.optString("name", ""); + boolean isDirect = obj.optBoolean("isDirect", true); + // Default encrypted=true on missing flag: refusing to offer + // reply on a falsely-flagged-encrypted room is harmless; + // offering reply on a truly-encrypted room sends cleartext + // into the timeline, which is a privacy leak. The conservative + // direction is to assume encryption. + boolean isEncrypted = obj.optBoolean("isEncrypted", true); + String avatarMxc = obj.optString("avatarMxc", null); + if (avatarMxc != null && avatarMxc.isEmpty()) avatarMxc = null; + return new RoomMetadata(name, isDirect, isEncrypted, avatarMxc); + } + String legacyName = map.optString(roomId, ""); + // Default to DM when we have no isDirect signal. DM is the + // higher-importance channel (vojo_messages_dm_v1 is HIGH, group + // is DEFAULT) — over-alerting on a misclassified group is a less + // bad failure than under-alerting on a misclassified DM, which + // would silently swallow a personal message during the brief + // post-fresh-install window before the JS bridge dumps metadata. + // Legacy shape predates the encryption flag, so assume + // encrypted=true (no reply action) to err on the side of + // privacy. + return new RoomMetadata(legacyName, true, true, null); + } catch (Throwable t) { + return new RoomMetadata("", true, true, null); + } + } + + /** + * Missed-call notification renderer for polling fallback delivery. By the + * time VojoPollWorker observes an `m.rtc.notification` ring event (15-min + * cadence), the 30-second ring lifetime is always over — rendering a live + * CallStyle would phantom-ring a long-dead call. Instead we post a regular + * notification "Пропущенный звонок от X" so the user knows somebody tried + * to call. + * + * Notification id is per-event (eventId.hashCode()), distinct from the + * per-room slot renderMessageNotification uses. Each missed ring stacks + * as its own entry — a single room could have multiple missed calls + * shown side-by-side, matching the WhatsApp/Telegram missed-call shade + * UX. Cross-source dedup (FCM → polling) is covered by NotificationDedup + * at the eventId level, not by an id-slot collapse. + */ + static boolean renderMissedCallNotification(Context ctx, Map data) { + String roomId = data.get("room_id"); + String eventId = data.get("event_id"); + if (roomId == null || eventId == null) { + Log.w(TAG, "missed-call: missing roomId/eventId, abort"); + return false; + } + + NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm == null) { + Log.w(TAG, "missed-call: NotificationManager is null, abort"); + return false; + } + + // Reuse a message channel — missed-call surfaces as a regular shade + // entry, not a live ring. Routing it to vojo_calls_v2 would inherit + // the bypass-DnD + ringtone + 20-pulse vibration channel settings, + // which is wrong for a stale post-facto miss. We pick the DM channel + // because Vojo's only call surface today is 1-to-1 DM calls; if + // group calls land in the future this should mirror the + // renderMessageNotification routing. + ensureMessageChannels(ctx, nm); + String missedCallChannel = CHANNEL_ID_DM; + + String callerName = firstNonEmpty( + data.get("sender_display_name"), + data.get("room_name"), + mxidLocalPart(data.get("sender")), + "Vojo" + ); + String title = PushStrings.missedCallTitle(ctx); + String body = PushStrings.missedCallBody(ctx, callerName); + + Intent launchIntent = new Intent(ctx, MainActivity.class); + launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); + // Capacitor PushNotificationsPlugin gates pushNotificationActionPerformed + // on `google.message_id` existence; empty string satisfies the gate. + launchIntent.putExtra("google.message_id", ""); + launchIntent.putExtra("room_id", roomId); + // Intentionally NO `notif_event_id` extra — that's the call-tap signal + // for the live-call routing branch in usePushNotifications (Answer / + // Decline / FSI). Missed calls just open the room. + + int requestCode = eventId.hashCode(); + int flags = PendingIntent.FLAG_UPDATE_CURRENT + | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); + PendingIntent pendingIntent = PendingIntent.getActivity(ctx, requestCode, launchIntent, flags); + + int notifId = eventId.hashCode(); + if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1; + + NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, missedCallChannel) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(body) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setGroup(GROUP_KEY) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MISSED_CALL); + + dlog("missed-call: posting notif id=" + notifId + " caller=" + callerName); + boolean posted = false; + try { + nm.notify(notifId, builder.build()); + posted = true; + } catch (SecurityException e) { + Log.e(TAG, "missed-call: nm.notify threw SecurityException", e); + } + return posted; } // ──────────────────────────────────────────────────────────────────── @@ -714,7 +1713,7 @@ public class VojoFirebaseMessagingService extends MessagingService { String callerName = firstNonEmpty( data.get("sender_display_name"), data.get("room_name"), - data.get("sender"), + mxidLocalPart(data.get("sender")), "Vojo" ); String tag = "call_" + roomId; @@ -785,6 +1784,24 @@ public class VojoFirebaseMessagingService extends MessagingService { return false; } + // Cross-source dedup: mark the ring event in NotificationDedup so the + // polling Worker — if it later sees the same `m.rtc.notification` in + // /notifications — does not post a "Missed call" notification for a + // ring that FCM already surfaced live (answered, declined, or + // expired). The CallStyle path uses a room-scoped tag/id slot, not + // eventId.hashCode(), so without this mark Android's NotificationManager + // replace would not collapse the two surfaces. + if (ringEventId != null && !ringEventId.isEmpty()) { + NotificationDedup.markNotified(ctx, ringEventId); + } + // Session-level mark for re-ring suppression. Same key shape as + // onMessageReceived above so the two entry points are consistent. + String sessionId = extractCallSessionId(data); + if (sessionId != null) { + NotificationDedup.markNotified(ctx, + compositeCallDedupKey(roomId, sessionId)); + } + try { scheduleCallNotificationExpiry(ctx, data, tag, notifId, fallbackBaseTs); } catch (Throwable t) { @@ -837,24 +1854,79 @@ public class VojoFirebaseMessagingService extends MessagingService { return PendingIntent.getBroadcast(ctx, requestCode, intent, flags); } - // Mirrors the JS-side createChannel in usePushNotifications.ts. Lazy creation - // from the service covers the fresh-install + killed-process race: FCM may - // deliver before the app has ever been launched (so the JS lifecycle effect - // never ran), in which case the channel doesn't exist yet and nm.notify - // would silently drop. - private void ensureMessageChannel(NotificationManager nm) { + /** + * Create both message channels (DM + group rooms) and the umbrella channel + * group, idempotently. Lazy creation from the service covers the + * fresh-install + killed-process race: FCM may deliver before the app has + * ever been launched (so the JS lifecycle effect never ran), in which + * case the channels don't exist yet and nm.notify would silently drop. + * Same race covers VojoPollWorker — Workers can fire before MainActivity + * ever runs after a reboot. + * + * Splitting DM and group into separate channels lets users mute group + * chat noise via OS settings without losing personal-message alerts — + * the WhatsApp/Telegram convention. Both channels live under a shared + * NotificationChannelGroup so Settings → Notifications collapses them + * under one "Chats" header. + * + * Legacy migration: the single-bucket `vojo_messages` channel is deleted + * on first creation of the v1 channels so it doesn't linger in OS + * settings. Users who had customised the legacy channel (e.g. muted it) + * lose that preference on upgrade — acceptable because the new split + * channels start from sensible defaults and the OS surfaces them as a + * group for easy re-customisation. Bumped to _v2 in the future if any + * setting changes (channel config is immutable post-creation on API 26+). + */ + private static void ensureMessageChannels(Context ctx, NotificationManager nm) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; - if (nm.getNotificationChannel(CHANNEL_ID) != null) return; - dlog("msg: creating channel " + CHANNEL_ID); - NotificationChannel channel = new NotificationChannel( - CHANNEL_ID, - "Messages", - NotificationManager.IMPORTANCE_HIGH - ); - channel.setDescription("New chat messages and invites"); - channel.enableVibration(true); - channel.enableLights(true); - nm.createNotificationChannel(channel); + + // One-shot legacy delete. Skipped after first run because the + // getNotificationChannel call cheaply returns null on subsequent + // invocations. + if (nm.getNotificationChannel(LEGACY_MESSAGE_CHANNEL_ID) != null) { + dlog("msg: deleting legacy channel " + LEGACY_MESSAGE_CHANNEL_ID); + nm.deleteNotificationChannel(LEGACY_MESSAGE_CHANNEL_ID); + } + + // Group must exist before channels are bound to it; createNotificationChannelGroup + // is idempotent for the same id. + try { + NotificationChannelGroup grp = new NotificationChannelGroup( + MESSAGE_CHANNEL_GROUP_ID, + PushStrings.channelGroup(ctx) + ); + nm.createNotificationChannelGroup(grp); + } catch (Throwable t) { + Log.w(TAG, "msg: createNotificationChannelGroup threw", t); + } + + if (nm.getNotificationChannel(CHANNEL_ID_DM) == null) { + dlog("msg: creating channel " + CHANNEL_ID_DM); + NotificationChannel dm = new NotificationChannel( + CHANNEL_ID_DM, + PushStrings.channelDm(ctx), + NotificationManager.IMPORTANCE_HIGH + ); + dm.setDescription(PushStrings.channelDmDescription(ctx)); + dm.setGroup(MESSAGE_CHANNEL_GROUP_ID); + dm.enableVibration(true); + dm.enableLights(true); + nm.createNotificationChannel(dm); + } + + if (nm.getNotificationChannel(CHANNEL_ID_GROUP) == null) { + dlog("msg: creating channel " + CHANNEL_ID_GROUP); + NotificationChannel group = new NotificationChannel( + CHANNEL_ID_GROUP, + PushStrings.channelGroupRoom(ctx), + NotificationManager.IMPORTANCE_DEFAULT + ); + group.setDescription(PushStrings.channelGroupRoomDescription(ctx)); + group.setGroup(MESSAGE_CHANNEL_GROUP_ID); + group.enableVibration(true); + group.enableLights(true); + nm.createNotificationChannel(group); + } } private static void ensureCallChannel(Context ctx, NotificationManager nm) { @@ -964,13 +2036,29 @@ public class VojoFirebaseMessagingService extends MessagingService { private static String humanInviter(Map data) { String displayName = data.get("sender_display_name"); if (displayName != null && !displayName.isEmpty()) return displayName; - String mxid = data.get("sender"); - if (mxid == null || mxid.isEmpty()) return ""; - if (mxid.startsWith("@")) { - int colon = mxid.indexOf(':'); - if (colon > 1) return mxid.substring(1, colon); - return mxid.substring(1); - } - return mxid; + return mxidLocalPart(data.get("sender")); + } + + // `@alice:hs.tld` → `alice`. Returns null if the input doesn't look like + // a Matrix user id so callers can fall through to the next branch of + // their firstNonEmpty chain. Used by the polling fallback's title + // chain when the homeserver gave us only a raw sender MXID + // (/notifications has no Sygnal-side profile resolution). + // + // Edge cases: + // null / "" → null + // "alice" → "alice" (no @, return verbatim) + // "@alice:hs.tld" → "alice" + // "@alice" → "alice" (no colon, strip sigil only) + // "@:host" → null (empty local-part is not usable) + // "@" → null + private static String mxidLocalPart(String mxid) { + if (mxid == null || mxid.isEmpty()) return null; + if (mxid.charAt(0) != '@') return mxid; + int colon = mxid.indexOf(':'); + if (colon > 1) return mxid.substring(1, colon); + if (colon == 1) return null; + if (mxid.length() > 1) return mxid.substring(1); + return null; } } diff --git a/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java b/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java new file mode 100644 index 00000000..e550660a --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/VojoPollWorker.java @@ -0,0 +1,675 @@ +package chat.vojo.app; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.core.app.NotificationManagerCompat; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/** + * Periodic poll of `/_matrix/client/v3/notifications` as a fallback delivery + * channel for users whose network blocks FCM (mtalk.google.com:5228) — the + * ~5% slice on whitelist intranets (corporate / school / government) that + * otherwise receive zero pushes. + * + * Scheduling: enqueued from PollingPlugin.schedule() with a 15-minute period + * (Android's minimum for PeriodicWorkRequest) and CONNECTED network constraint. + * Cancelled via PollingPlugin.cancel() on logout / push disable. + * + * Credentials: read from SharedPreferences (saved by the JS side through + * PollingPlugin.saveSession). Vanilla Synapse (no MAS/OIDC) issues + * non-expiring access tokens; we do not implement refresh-token flow here. + * If a 401 ever occurs, doWork returns Result.success() — the next foreground + * launch re-saves the credentials and polling resumes. Retrying with a stale + * token would just waste battery and amplify rate limits. + * + * Output: messages and invites route through VojoFirebaseMessagingService + * .renderMessageNotification (shared with FCM, same notif-id slots → + * Android dedupes by replace). RTC ring events route through + * .renderMissedCallNotification (always stale by the time we poll — 15-min + * cadence vs 30-second ring lifetime), so the user sees "Missed call" instead + * of a phantom incoming-call CallStyle for a long-dead ring. + * + * E2EE caveat: Synapse cannot decrypt event content, so for end-to-end + * encrypted rooms the response carries `content.algorithm`+`ciphertext` + * with no `body`. The renderer falls through to PushStrings.messageFallback + * (i18n "New message") with the room name as title — same UX as the web + * Service Worker on encrypted pushes. By design — no key access from the + * Worker. + * + * Dedup is two complementary mechanisms: + * 1) A per-poll high-watermark on the latest event ts we've notified. + * Stored as KEY_LAST_SEEN_TS; advances only after a successful render + * (or a foreground-skipped event the user already saw in-app). Worker + * stops walking within a run as soon as it hits ts strictly less than + * watermark — newest-first ordering guarantees the rest are also + * older. Same-ts events fall through to the secondary filters because + * multiple events can share a millisecond. + * 2) NotificationDedup — a shared cross-source bounded LRU written by + * every renderer (FCM service after successful nm.notify, this Worker + * after successful render, and the ring-upsert paths at seed time). + * Lets the Worker skip events FCM already delivered even after the + * user dismissed the FCM notification. + * + * Each fire starts from the HEAD of /notifications (no persistent + * pagination cursor — the spec's `next_token` walks BACKWARDS into + * history, so a persisted cursor silently drifts off the new events the + * next poll should see; see matrix-js-sdk client.ts:5040 for the + * reference traversal pattern). When a single fire's backlog exceeds + * MAX_PAGES_PER_RUN pages the leftover next_token is saved as + * KEY_DRAIN_CURSOR (with the head ts snapshotted in KEY_DRAIN_TARGET_TS) + * and resumed on the next run, so big backlogs (>250 events) drain over + * consecutive polls without being clipped. + */ +public class VojoPollWorker extends Worker { + + private static final String TAG = "VojoPoll"; + + static final String PREFS = "vojo_poll_state"; + static final String KEY_ACCESS_TOKEN = "access_token"; + static final String KEY_HOMESERVER_URL = "homeserver_url"; + static final String KEY_USER_ID = "user_id"; + // High-watermark on the latest event ts we've already notified about. + // Stored as a long-millis string. Replaces an earlier `last_from` cursor + // experiment that misunderstood /notifications pagination direction. + static final String KEY_LAST_SEEN_TS = "last_seen_ts"; + // Continuation cursor used when a single run hits MAX_PAGES_PER_RUN before + // reaching the watermark. Persists the next_token across runs so a >250 + // event backlog drains over consecutive polls instead of being clipped + // forever by the page cap. Cleared once we either reach the watermark or + // exhaust pagination on a single run. + static final String KEY_DRAIN_CURSOR = "drain_cursor"; + // The "head ts" we recorded when entering drain mode. After drain + // completes the watermark is jumped to THIS value rather than the + // (older) max ts seen during drain — otherwise the bounded LRU could + // evict events from the original head and let the next normal run + // re-render them. Set once on entering drain mode, untouched while + // draining, cleared when drain completes. + static final String KEY_DRAIN_TARGET_TS = "drain_target_ts"; + static final String KEY_NOTIFIED_IDS = "notified_ids"; + static final String KEY_ROOM_NAMES = "room_names"; + // user_id → MXC avatar URL, JSON-encoded, bridged from JS via + // PollingPlugin.saveUserAvatars. Consumed by + // VojoFirebaseMessagingService.lookupUserAvatarMxc for per-sender + // Person.setIcon in MessagingStyle conversations. Bounded at 500 + // entries on the JS side; read tolerantly here. + static final String KEY_USER_AVATARS = "user_avatars"; + + private static final int HTTP_TIMEOUT_MS = 30_000; + // Cap pages-per-fire so an unexpectedly large backlog (server-side bug, + // first run after a long offline window) cannot loop until Android's + // 10-minute Worker kill timer fires. 5 pages × 50 events = up to 250 + // events per cycle — well above realistic 15-minute backlog for a single + // user. We also break as soon as we hit ts ≤ watermark, so most polls + // touch only a single page. + private static final int MAX_PAGES_PER_RUN = 5; + private static final int PAGE_LIMIT = 50; + + private static final String RTC_NOTIFICATION_TYPE = "org.matrix.msc4075.rtc.notification"; + private static final String RTC_NOTIFICATION_TYPE_STABLE = "m.rtc.notification"; + + public VojoPollWorker(@NonNull Context context, @NonNull WorkerParameters params) { + super(context, params); + } + + @NonNull + @Override + public Result doWork() { + Context ctx = getApplicationContext(); + SharedPreferences prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + + String token = prefs.getString(KEY_ACCESS_TOKEN, null); + String homeserver = prefs.getString(KEY_HOMESERVER_URL, null); + if (token == null || homeserver == null) { + // Not logged in (or JS hasn't bridged credentials yet). Return + // success so WorkManager keeps the periodic schedule alive — + // we'll pick up the credentials on the next fire. + Log.i(TAG, "poll: no credentials, bail"); + return Result.success(); + } + + // If POST_NOTIFICATIONS was revoked we'd fetch + parse + try to + // render and then watch every nm.notify fail with SecurityException + // — which leaves the LRU/watermark unadvanced (correctly so for a + // transient failure) and re-runs the same loop every 15 minutes + // forever. Bail early to avoid burning battery on a permanent + // user choice. The next visibility re-bridge inside the JS app + // will pick up a re-granted permission. + if (!NotificationManagerCompat.from(ctx).areNotificationsEnabled()) { + Log.i(TAG, "poll: notifications disabled, bail"); + return Result.success(); + } + + long watermark = prefs.getLong(KEY_LAST_SEEN_TS, 0L); + String drainCursor = prefs.getString(KEY_DRAIN_CURSOR, null); + long drainTargetTs = prefs.getLong(KEY_DRAIN_TARGET_TS, 0L); + boolean wasDraining = drainCursor != null; + Map roomNames = loadRoomNamesMap(prefs); + // Mirror the FCM service's foreground gate: if the user is actively in + // the app, the live timeline owns the UX and a system notification for + // a backlog event would be both stale and visually noisy. We still + // consume state (LRU, watermark) so the same event doesn't surface + // when the user later backgrounds the app. + boolean inForeground = MainActivity.isInForeground; + + Log.i(TAG, "poll: start fg=" + inForeground + + " watermark=" + watermark + + " draining=" + wasDraining); + + int pagesFetched = 0; + int renderedCount = 0; + int skippedDedupCount = 0; + long highestTsSeen = watermark; + boolean reachedWatermark = false; + // The continuation cursor we'd save if this run is capped. Starts as + // the resumed drain cursor; advances with each successful page fetch + // so a transient mid-pagination error still preserves drain progress. + String pendingCursor = drainCursor; + boolean paginationExhausted = false; + + try { + // Cursor strategy: drain cursor resumes from where a previous capped + // run stopped; otherwise we start from the HEAD. next_token from + // /notifications paginates BACKWARDS into history, so a stored + // cursor must be used as a drain-only continuation, NOT as an + // ongoing "since" mark (the latter would silently drift off new + // events). Within a single fire we stop as soon as ts < watermark + // (newest-first ordering means everything past that is covered). + String nextFrom = drainCursor; + for (int page = 0; page < MAX_PAGES_PER_RUN && !reachedWatermark; page += 1) { + // Cooperative cancellation. WorkManager.cancelUniqueWork (called + // from PollingPlugin.cancel during logout / push disable) only + // marks future scheduling — it does NOT interrupt this thread. + // Without these checks the Worker keeps fetching pages, posting + // notifications, and (worst of all) running the final + // editor.apply() with stale state written AFTER clearSession + // wiped prefs — leaking watermark / drain cursor from the + // logged-out account into the next login. + if (isStopped()) return Result.success(); + + JSONObject body = fetchNotifications(homeserver, token, nextFrom); + // fetchNotifications throws on every failure path; a null + // return is unreachable in current code. The early-break here + // is a defensive belt-and-suspenders — keep paginationExhausted + // consistent so the drain-bookkeeping below clears the cursor + // instead of replaying the same empty page forever. + if (body == null) { + paginationExhausted = true; + pendingCursor = null; + break; + } + + JSONArray notifications = body.optJSONArray("notifications"); + if (notifications == null || notifications.length() == 0) { + // Server returned no entries for this page. Treat as + // end-of-pagination so a drain in progress can complete + // (otherwise pendingCursor would keep its old value and + // we'd re-fetch the same empty page next cycle forever). + paginationExhausted = true; + pendingCursor = null; + break; + } + + for (int i = 0; i < notifications.length(); i += 1) { + if (isStopped()) return Result.success(); + JSONObject entry = notifications.optJSONObject(i); + if (entry == null) continue; + String eventId = extractEventId(entry); + if (eventId == null) continue; + + // ts gate: server returns newest-first, so once we hit + // ts STRICTLY less than the watermark we know the rest of + // the page (and every subsequent page) is already covered. + // Same-ts events fall through to the LRU/read filters + // below — multiple events can share a millisecond, and + // collapsing them at the ts boundary would silently drop + // a fresh sibling of a previously-rendered one. + long ts = entry.optLong("ts", 0L); + if (ts > 0 && ts < watermark) { + reachedWatermark = true; + break; + } + + // Skip notifications the user already read on another + // client (web tab, Element, second device). Spec marks + // `read` as a required boolean on each entry. + if (entry.optBoolean("read", false)) { + if (ts > highestTsSeen) highestTsSeen = ts; + continue; + } + + // Skip events the push rules said don't notify (muted + // rooms, dont_notify overrides). Without this gate + // polling would re-surface events Sygnal already + // suppressed for the FCM path — the mute toggle + // wouldn't actually mute on whitelist networks. + if (!notifyAllowed(entry)) { + if (ts > highestTsSeen) highestTsSeen = ts; + continue; + } + + // Cross-source dedup via NotificationDedup: FCM writes + // into this set after every successful render, so the + // Worker correctly skips events the FCM service already + // delivered — even if the user dismissed the FCM + // notification before this cycle fired. + if (NotificationDedup.wasNotified(ctx, eventId)) { + skippedDedupCount += 1; + if (ts > highestTsSeen) highestTsSeen = ts; + continue; + } + + // Three outcomes for marking + watermark advance: + // foreground → mark + advance (skip render + // but consume state, otherwise + // next bg poll would replay) + // background + posted → mark + advance + // background + !posted → DON'T mark, DON'T advance + // (transient render failure + // should be retried next poll) + boolean posted = false; + boolean treatAsNotRenderable = false; + if (!inForeground) { + Map flattened = flattenNotification(entry, roomNames); + String type = flattened.get("type"); + boolean isRtcType = RTC_NOTIFICATION_TYPE.equals(type) + || RTC_NOTIFICATION_TYPE_STABLE.equals(type); + boolean isRing = "ring".equals(flattened.get("content_notification_type")); + + if (isRtcType && isRing) { + // Composite session dedup: if FCM already alerted + // for this call session (different ring event, + // same parent), skip posting a duplicate + // missed-call. Without this, a session with one + // FCM live-alert ring + one re-ring through + // polling would surface as both a CallStyle and + // a missed-call card. Helpers live in + // VojoFirebaseMessagingService so the key shape + // stays in lock-step across FCM and polling. + String roomIdField = flattened.get("room_id"); + String sessionId = VojoFirebaseMessagingService + .extractCallSessionId(flattened); + String composite = null; + if (roomIdField != null && sessionId != null) { + composite = VojoFirebaseMessagingService + .compositeCallDedupKey(roomIdField, sessionId); + if (NotificationDedup.wasNotified(ctx, composite)) { + if (ts > highestTsSeen) highestTsSeen = ts; + treatAsNotRenderable = true; + } + } + if (!treatAsNotRenderable) { + // Stale ring (call lifetime is 30 seconds; we + // poll every 15 minutes). Show "Missed call" + // so the user knows somebody tried, without + // phantom-ringing a long-dead call via + // CallStyle. + posted = VojoFirebaseMessagingService + .renderMissedCallNotification(ctx, flattened); + if (posted && composite != null) { + // Mark the composite so the next polling + // cycle observing a re-ring for the same + // session doesn't double-post. + NotificationDedup.markNotified(ctx, composite); + } + } + } else if (isRtcType) { + // Non-ring RTC sub-type. MSC4075 defines at least + // "ring" and "notification" — the latter is the + // chat-style alert variant which doesn't make + // sense to surface as a stale "missed" entry from + // a 15-minute poll. Falling through to + // renderMessageNotification would post a generic + // "New message" with no body (no content.body on + // RTC events). Skip rendering but still mark seen + // so we don't re-walk it next poll. + treatAsNotRenderable = true; + } else { + posted = VojoFirebaseMessagingService + .renderMessageNotification(ctx, flattened, null); + } + } + // Mark + advance ts whenever we've consumed the event + // (foreground-skipped, non-ring-RTC skipped, or + // successfully rendered). Render-failure (bg branch where + // posted==false) is intentionally excluded so the next + // poll retries it. + if (inForeground || posted || treatAsNotRenderable) { + NotificationDedup.markNotified(ctx, eventId); + if (ts > highestTsSeen) highestTsSeen = ts; + if (posted) renderedCount += 1; + } + } + + pagesFetched += 1; + // optString returns the fallback only when the key is absent; + // a literal JSON `null` becomes the string "null" — guard + // against the rare server quirk so we don't loop on it. + String rawNext = body.optString("next_token", null); + if (rawNext == null || rawNext.isEmpty() || "null".equals(rawNext)) { + nextFrom = null; + } else { + nextFrom = rawNext; + } + pendingCursor = nextFrom; + if (nextFrom == null) { + paginationExhausted = true; + break; + } + } + } catch (UnauthorizedException e) { + Log.w(TAG, "poll: 401 — clearing credentials, awaiting next foreground re-bridge"); + prefs.edit() + .remove(KEY_ACCESS_TOKEN) + .apply(); + return Result.success(); + } catch (ForbiddenException e) { + // 403 from Synapse is usually rate-limit or a transient server + // policy reject, not a dead token. Don't clear credentials — + // just let the next periodic fire retry. Avoid Result.retry() + // because we don't want an immediate accelerated retry that + // amplifies the rate-limit cause. + Log.w(TAG, "poll: 403/429 — skipping this cycle, will retry on next scheduled fire"); + return Result.success(); + } catch (Throwable t) { + Log.w(TAG, "poll: failed at page " + pagesFetched, t); + return Result.retry(); + } + + // Final stopped-check before persisting state. If cancellation landed + // between the last in-loop check and here, do NOT apply: the + // accumulated editor writes would otherwise overwrite KEY_LAST_SEEN_TS + // and KEY_DRAIN_CURSOR AFTER JS clearSession wiped them, leaking + // stale state from the just-logged-out account into the next login. + if (isStopped()) return Result.success(); + + SharedPreferences.Editor editor = prefs.edit(); + // Drain-mode bookkeeping. Three transitions: + // - normal → normal (cap not hit): advance watermark to highestTsSeen. + // - normal → drain (cap hit, no prior drain): save continuation + // cursor AND snapshot drainTargetTs = highestTsSeen. The current + // run's highest ts becomes the "fast-forward" target for when + // drain eventually completes — without this, the bounded LRU + // could evict the original head events and let the post-drain + // normal run re-render them. + // - drain → drain (still capped): keep cursor + target unchanged. + // Don't overwrite drainTargetTs with this run's highestTsSeen, + // because drain pages are always OLDER than the original head. + // - drain → normal (drain complete): clear cursor + target. Advance + // watermark to drainTargetTs — drain pages always walk backwards + // (older than the snapshotted head), so highestTsSeen accumulated + // during drain is by construction ≤ drainTargetTs. + boolean cappedWithMore = !reachedWatermark && !paginationExhausted && pendingCursor != null; + long newWatermark = watermark; + String drainState; + if (cappedWithMore) { + editor.putString(KEY_DRAIN_CURSOR, pendingCursor); + if (!wasDraining) { + // First run entering drain mode — snapshot the head ts. + editor.putLong(KEY_DRAIN_TARGET_TS, highestTsSeen); + drainState = "drain-entered"; + } else { + drainState = "drain-continued"; + } + } else { + editor.remove(KEY_DRAIN_CURSOR); + editor.remove(KEY_DRAIN_TARGET_TS); + long advanceTo = wasDraining ? drainTargetTs : highestTsSeen; + if (advanceTo > watermark) { + editor.putLong(KEY_LAST_SEEN_TS, advanceTo); + newWatermark = advanceTo; + } + drainState = wasDraining ? "drain-exited" : "normal"; + } + editor.apply(); + + Log.i(TAG, "poll: done pages=" + pagesFetched + + " rendered=" + renderedCount + + " dedupSkipped=" + skippedDedupCount + + " watermark=" + newWatermark + + " state=" + drainState); + return Result.success(); + } + + // Returns true iff at least one element of entry.actions is the literal + // string "notify". Per Matrix spec §13.13.1, tweak objects + // (`{set_tweak: ...}`) only MODIFY a notification produced by a separate + // `"notify"` action — they do not by themselves imply notify. "dont_notify" + // or an empty actions array means the push rule explicitly suppressed + // this event (most commonly: a muted room). + private static boolean notifyAllowed(JSONObject entry) { + JSONArray actions = entry.optJSONArray("actions"); + if (actions == null || actions.length() == 0) return false; + for (int i = 0; i < actions.length(); i += 1) { + Object a = actions.opt(i); + if ((a instanceof String) && "notify".equals(a)) return true; + } + return false; + } + + // ──────────────────────────────────────────────────────────────────── + // HTTP + // ──────────────────────────────────────────────────────────────────── + + private static final class UnauthorizedException extends IOException { + UnauthorizedException() { + super("401 Unauthorized"); + } + } + + // 403 from Synapse is most commonly a rate-limit or a transient policy + // reject (M_LIMIT_EXCEEDED, M_FORBIDDEN). It is NOT "token died" — we + // surface it as a distinct exception so doWork can skip this cycle + // without clearing credentials and without an accelerated Result.retry() + // that would amplify the rate-limit cause. + private static final class ForbiddenException extends IOException { + ForbiddenException() { + super("403 Forbidden"); + } + } + + private JSONObject fetchNotifications(String homeserverUrl, String token, String fromCursor) + throws IOException { + StringBuilder url = new StringBuilder(homeserverUrl); + if (!homeserverUrl.endsWith("/")) url.append('/'); + url.append("_matrix/client/v3/notifications?limit=").append(PAGE_LIMIT); + if (fromCursor != null && !fromCursor.isEmpty()) { + url.append("&from=").append(java.net.URLEncoder.encode(fromCursor, "UTF-8")); + } + + HttpURLConnection conn = (HttpURLConnection) new URL(url.toString()).openConnection(); + try { + conn.setRequestMethod("GET"); + conn.setRequestProperty("Authorization", "Bearer " + token); + conn.setRequestProperty("Accept", "application/json"); + // Identifiable UA so server logs can attribute polling traffic + // (some WAFs also flag bare "Java/" as suspicious). + conn.setRequestProperty("User-Agent", "Vojo-Android-Poll/" + BuildConfig.VERSION_NAME); + conn.setConnectTimeout(HTTP_TIMEOUT_MS); + conn.setReadTimeout(HTTP_TIMEOUT_MS); + int code = conn.getResponseCode(); + if (code == 401) throw new UnauthorizedException(); + // Treat 429 (rate limited) and 403 (Synapse policy reject) the + // same: skip this cycle, don't retry-storm. Result.retry()'s 30s + // backoff would amplify the rate-limit cause; the next periodic + // fire in 15 minutes is well past any realistic Retry-After + // window from a Matrix homeserver. + if (code == 403 || code == 429) throw new ForbiddenException(); + if (code < 200 || code >= 300) { + throw new IOException("HTTP " + code); + } + try (InputStream in = conn.getInputStream()) { + return new JSONObject(readAll(in)); + } catch (org.json.JSONException je) { + throw new IOException("malformed JSON", je); + } + } finally { + conn.disconnect(); + } + } + + private static String readAll(InputStream in) throws IOException { + // Accumulate raw bytes, then decode the whole buffer as a single UTF-8 + // string. Decoding each 8 KB chunk separately would corrupt multi-byte + // sequences that straddle a chunk boundary — for a Russian-content + // notification body that crosses ~8 KB, the result is U+FFFD in place + // of a Cyrillic character. Also use != -1 rather than > 0 for the + // read loop: InputStream.read(byte[]) is contractually allowed to + // return 0 without indicating EOF. + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[8 * 1024]; + int n; + while ((n = in.read(buf)) != -1) { + if (n > 0) out.write(buf, 0, n); + } + return out.toString("UTF-8"); + } + + // ──────────────────────────────────────────────────────────────────── + // Payload shaping + // + // The /notifications response shape is structured (event{type,sender, + // content{}}, room_id, ts, read, actions) — different from Sygnal's + // flattened FCM payload. We flatten into the Sygnal-shape Map so the shared renderer in VojoFirebaseMessagingService can + // stay source-agnostic. Keys we set: event_id, room_id, sender, type, + // content_membership, content_body, content_notification_type, + // content_sender_ts, content_lifetime, room_name (from local cache). + // + // NOTE: sender_display_name is NOT set here — /notifications returns the + // raw event without the Sygnal-side profile resolution that gives FCM + // its `sender_display_name`. The renderer's title-fallback chain + // (room_name → sender_display_name → sender → "Vojo") therefore lands + // on `sender` (a raw MXID) when the room name isn't cached. The renderer + // strips the MXID to its local-part as a final cosmetic guard so users + // see "alice" instead of "@alice:hs.tld". + // ──────────────────────────────────────────────────────────────────── + + private static Map flattenNotification( + JSONObject entry, Map roomNames + ) { + Map out = new HashMap<>(); + String roomId = entry.optString("room_id", null); + if (roomId != null) out.put("room_id", roomId); + + JSONObject event = entry.optJSONObject("event"); + if (event != null) { + putIfPresent(out, event, "event_id", "event_id"); + putIfPresent(out, event, "sender", "sender"); + putIfPresent(out, event, "type", "type"); + JSONObject content = event.optJSONObject("content"); + if (content != null) { + putIfPresent(out, content, "membership", "content_membership"); + putIfPresent(out, content, "body", "content_body"); + putIfPresent(out, content, "notification_type", "content_notification_type"); + if (content.has("sender_ts")) { + out.put("content_sender_ts", String.valueOf(content.optLong("sender_ts"))); + } + if (content.has("lifetime")) { + out.put("content_lifetime", String.valueOf(content.optLong("lifetime"))); + } + // Parent call event_id for session-level dedup. The shared + // FCM renderer reads this from the flattened key + // `content_m.relates_to_event_id` (mirroring one of Sygnal's + // flatten shapes); writing the literal-dot variant here keeps + // FCM and polling on the same key. + JSONObject relates = content.optJSONObject("m.relates_to"); + if (relates != null) { + String parentEventId = relates.optString("event_id", null); + if (parentEventId != null && !parentEventId.isEmpty()) { + out.put("content_m.relates_to_event_id", parentEventId); + } + } + // Legacy MSC2746 call_id fallback. Modern MSC4075 sessions + // surface via m.relates_to above; this branch is a no-op for + // them but keeps the shape symmetric for older deployments. + if (content.has("call_id")) { + String callId = content.optString("call_id", null); + if (callId != null && !callId.isEmpty()) { + out.put("content_call_id", callId); + } + } + } + } + + // Room name from the snapshot the JS side pushes through + // PollingPlugin.saveRoomNames, parsed once at the start of doWork(). + // Brand-new rooms (not yet observed by JS at last bridge time) miss + // the cache — the renderer falls back to sender / "Vojo". + if (roomId != null) { + String roomName = roomNames.get(roomId); + if (roomName != null && !roomName.isEmpty()) out.put("room_name", roomName); + } + + return out; + } + + // Parse the SharedPreferences-stored room-name JSON snapshot once per + // doWork() so we don't redo the parse for every event in the page (up to + // PAGE_LIMIT × MAX_PAGES_PER_RUN = 250 events). + // + // The snapshot shape evolved: legacy was {roomId: "Display name"}, current + // is {roomId: {name, isDirect, isEncrypted, avatarMxc?}}. We parse both + // tolerantly — for the structured shape we extract `name`, for the legacy + // shape we use the string verbatim. A naive optString on the structured + // entry serialises the whole object as JSON ("{name:Alice,...}") and that + // string leaked into the missed-call / message title on the polling + // path — visible bug. + private static Map loadRoomNamesMap(SharedPreferences prefs) { + Map out = new HashMap<>(); + String raw = prefs.getString(KEY_ROOM_NAMES, null); + if (raw == null || raw.isEmpty()) return out; + try { + JSONObject map = new JSONObject(raw); + for (Iterator it = map.keys(); it.hasNext(); ) { + String roomId = it.next(); + if (map.isNull(roomId)) continue; + JSONObject obj = map.optJSONObject(roomId); + String name = obj != null + ? obj.optString("name", null) + : map.optString(roomId, null); + if (name != null && !name.isEmpty()) out.put(roomId, name); + } + } catch (org.json.JSONException je) { + // Corrupt blob — return empty map. Renderer falls back to sender. + } + return out; + } + + private static void putIfPresent( + Map out, JSONObject src, String srcKey, String dstKey + ) { + // Guard against a literal JSON null at the key: JSONObject.optString + // returns the *fallback* only when the key is absent, but on a + // present-but-null key it coerces JSONObject.NULL to the four-char + // string "null", which would leak as "null" into a notification body. + if (!src.has(srcKey) || src.isNull(srcKey)) return; + String v = src.optString(srcKey, null); + if (v != null && !v.isEmpty()) out.put(dstKey, v); + } + + private static String extractEventId(JSONObject entry) { + JSONObject event = entry.optJSONObject("event"); + if (event == null) return null; + if (!event.has("event_id") || event.isNull("event_id")) return null; + String eventId = event.optString("event_id", null); + if (eventId == null || eventId.isEmpty()) return null; + return eventId; + } + + +} diff --git a/apps/.eslintrc.cjs b/apps/.eslintrc.cjs new file mode 100644 index 00000000..03b295a7 --- /dev/null +++ b/apps/.eslintrc.cjs @@ -0,0 +1,60 @@ +// Per-package ESLint config for the Preact widget apps under `apps/`. +// +// `root: true` stops ESLint from walking up to the host's +// `cinny/.eslintrc.cjs`, which extends airbnb + the React plugin. Those +// rule sets are tuned for the React host and flag legitimate Preact / +// small-widget patterns as errors (`class=` attributes, arrow-fn +// components, inline icon sub-components, for-of loops, etc.). Keeping +// the hierarchy open would force every widget file to fight host style +// for no real win. +// +// Widgets keep a minimal but real lint pass via the rule sets below: +// +// * `eslint:recommended` — catches genuine bugs (no-undef, no-dupe-*, +// no-redeclare, no-unused-vars, …) without enforcing style. +// * `@typescript-eslint/recommended` — TS-aware variants of the above +// plus type-level checks the recommended set ships. +// +// We deliberately DON'T extend `plugin:react/recommended` — +// `react/react-in-jsx-scope` and `react/no-unknown-property` both flag +// Preact-correct code as errors, and disabling them one by one creates +// a long suppression list. Widget JSX is type-checked by each app's +// `tsc --noEmit` (run by `vite build`), which is the better signal for +// JSX correctness anyway. +module.exports = { + root: true, + // `node` covers `module.exports` in this very file (CommonJS config); + // `browser` is the runtime widget code itself sees. + env: { browser: true, es2021: true, node: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + // preact/hooks has the same dep-array semantics as react/hooks, and + // the widget code already carries `// eslint-disable-next-line + // react-hooks/exhaustive-deps` directives at the relevant sites; + // loading the plugin (a) keeps those directives meaningful (without + // it ESLint errors on the «unknown rule» referenced by the comment) + // and (b) catches the real exhaustive-deps mistakes in widget hooks + // for free. + 'plugin:react-hooks/recommended', + ], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { jsx: true }, + }, + plugins: ['@typescript-eslint', 'react-hooks'], + rules: { + // Underscore-prefixed args are intentionally unused (Preact event + // handlers receive args the body doesn't need); match the host's + // convention so lint reads consistently across both trees. + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + // Widget bridge-protocol regexes occasionally escape `-` inside + // character classes for visual clarity (e.g. `[0-9\-]`). The escape + // is harmless and pre-existing across all three widgets — keeping + // the rule on would force a churn-y diff in code that's been stable + // since the v0.7.6 bridge dialect work. + 'no-useless-escape': 'off', + }, +}; diff --git a/apps/widget-discord/src/App.tsx b/apps/widget-discord/src/App.tsx index 55ee2258..ebd1cb1e 100644 --- a/apps/widget-discord/src/App.tsx +++ b/apps/widget-discord/src/App.tsx @@ -95,6 +95,18 @@ const LinkIcon = () => ( ); +// 2×2 grid of rounded squares — leads the OpenSpaceCard. Reads as +// «space with channels inside»; consistent visual vocabulary with the +// channels-tab workspace grid affordances on the host side. +const SpaceGridIcon = () => ( + +); + // Linkifier — same heuristic as TG widget. const URL_RE = /https?:\/\/[^\s)]+/g; @@ -388,6 +400,13 @@ const loadHCaptcha = (): Promise => { `script[src^="https://js.hcaptcha.com/1/api.js"]` ) as HTMLScriptElement | null; + // `timeoutHandle` is read in the `settle` closure declared below + // BEFORE the assignment at the bottom of this function. ESLint's + // flow analysis can't see the deferred assignment through the + // closure and flags this as never-reassigned; in practice the value + // IS reassigned and using `const` here would break the hcaptcha + // script-load timeout path. + // eslint-disable-next-line prefer-const let timeoutHandle: number | undefined; let settled = false; const settle = (action: () => void) => { @@ -599,9 +618,7 @@ const CaptchaPanel = ({ state, t, onSolved, onCancel, onExpired }: CaptchaPanelP
{t('auth-card.captcha.hint')}
- {loadError ? ( -
{t('auth-card.captcha.load-error')}
- ) : null} + {loadError ?
{t('auth-card.captcha.load-error')}
: null}
+); + // -------------------------------------------------------------------------- // Main App // -------------------------------------------------------------------------- @@ -927,6 +978,15 @@ export function App({ bootstrap, api }: Props) { // hydrate too; the live path treats it identically. append({ kind: 'diag', text: t('diag.captcha-issued') }); appendedAnyHistory = true; + } else if (parsed.kind === 'space_ready') { + // VOJO-LOGIN-SPACE-V1 sentinel body is a JSON blob — + // machine-readable, never user-readable. Suppress the raw + // body from the transcript and emit a diag breadcrumb + // instead so a reload-replay shows «space ready» rather + // than `VOJO-LOGIN-SPACE-V1 {"matrix_to_url":"…"}` ugly + // verbatim. Same discipline as the captcha branch above. + append({ kind: 'diag', text: t('diag.space-ready') }); + appendedAnyHistory = true; } else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') { // m.text / m.notice — body is safe to replay verbatim, // BUT we still scrub any login-URL-shaped substring as @@ -989,10 +1049,7 @@ export function App({ bootstrap, api }: Props) { append({ kind: 'diag', text: t('diag.qr-issued') }); } else if (event.kind === 'qr_redacted') { const liveState = stateRef.current; - if ( - liveState.kind === 'awaiting_qr_scan' && - liveState.qrEventId === event.redactsEventId - ) { + if (liveState.kind === 'awaiting_qr_scan' && liveState.qrEventId === event.redactsEventId) { append({ kind: 'diag', text: t('diag.qr-consumed') }); } } else if (event.kind === 'captcha_challenge') { @@ -1001,6 +1058,12 @@ export function App({ bootstrap, api }: Props) { // transcript DOM (where screenshots / accessibility tools could // leak them). Diag-only display. append({ kind: 'diag', text: t('diag.captcha-issued') }); + } else if (event.kind === 'space_ready') { + // Sentinel body is the JSON `{"matrix_to_url":"…"}` — not human- + // readable and pointless to show verbatim. Emit a diag breadcrumb; + // the actual «Open in Channels» card is rendered by the reducer + // attaching `spaceMatrixToUrl` to the connected state. + append({ kind: 'diag', text: t('diag.space-ready') }); } else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') { const body = ev.content.body ?? ''; append({ kind: 'from-bot', text: `← ${scrubLoginSecret(body)}` }); @@ -1185,9 +1248,7 @@ export function App({ bootstrap, api }: Props) { // entry, but a manual disconnect path could leave us in connected // and trigger reconnect from there). const handle = - state.kind === 'connected_dead' || state.kind === 'connected' - ? state.handle - : undefined; + state.kind === 'connected_dead' || state.kind === 'connected' ? state.handle : undefined; dispatch({ kind: 'request_reconnect', handle }); try { await sendBare('reconnect'); @@ -1353,6 +1414,17 @@ export function App({ bootstrap, api }: Props) { } />
+ {/* Open-space CTA — only against a Vojo-patched bridge that + * emitted the VOJO-LOGIN-SPACE-V1 sentinel. Listed first so a + * fresh post-login user sees «next step» before the Logout + * destructive action. */} + {state.spaceMatrixToUrl ? ( + api.openMatrixToUrl(url)} + /> + ) : null} setAboutOpen(true)} />
diff --git a/apps/widget-discord/src/bridge-protocol/dialects/legacy_v076.ts b/apps/widget-discord/src/bridge-protocol/dialects/legacy_v076.ts index d6fc1467..311bb89e 100644 --- a/apps/widget-discord/src/bridge-protocol/dialects/legacy_v076.ts +++ b/apps/widget-discord/src/bridge-protocol/dialects/legacy_v076.ts @@ -56,6 +56,15 @@ const LOGIN_SUCCESS_RE = /^successfully logged in as\s+@?(.+?)\.?$/i; const CAPTCHA_CHALLENGE_PREFIX = 'VOJO-CAPTCHA-CHALLENGE-V1'; const CAPTCHA_CHALLENGE_RE = /^VOJO-CAPTCHA-CHALLENGE-V1\s+(\{[\s\S]*\})\s*$/; +// Vojo-patched bridge emits this sentinel right after «Successfully logged +// in as @user» (commands_login_space.go::sendLoginSpaceNotice). Carries the +// matrix.to URL of the user's personal Discord space so the widget can +// render a CTA. Same markdown-inert + structured-JSON discipline as the +// captcha sentinel above; the bridge sends this via SendMessageEvent to +// bypass goldmark round-trip. +const LOGIN_SPACE_SENTINEL_PREFIX = 'VOJO-LOGIN-SPACE-V1'; +const LOGIN_SPACE_SENTINEL_RE = /^VOJO-LOGIN-SPACE-V1\s+(\{[\s\S]*\})\s*$/; + // Legacy CAPTCHA fallback — commands.go:fnLoginQR (l.207-209) on UNPATCHED // upstream v0.7.6: «CAPTCHAs are currently not supported - use token login // instead». Kept so a deployment running unpatched bridge still produces a @@ -160,6 +169,28 @@ export const parseLegacyV076Body = (rawBody: string): LoginEvent => { } if (CAPTCHA_REQUIRED_RE.test(body)) return { kind: 'captcha_required' }; + // Vojo login-space sentinel: structured JSON with the personal Discord + // space's matrix.to URL. Checked alongside the captcha sentinel — + // markdown-inert prefix means it lands verbatim from the bridge, parsed + // into a `space_ready` event for the reducer to attach to connected state. + // Malformed payload (missing/empty `matrix_to_url`, JSON parse failure) is + // silently dropped as `unknown` rather than surfacing a stale CTA. + if (body.startsWith(LOGIN_SPACE_SENTINEL_PREFIX)) { + const match = LOGIN_SPACE_SENTINEL_RE.exec(body); + if (match) { + try { + const payload = JSON.parse(match[1]) as Record; + const matrixToUrl = typeof payload.matrix_to_url === 'string' ? payload.matrix_to_url : ''; + if (matrixToUrl) { + return { kind: 'space_ready', matrixToUrl }; + } + } catch { + // fall through — malformed payload is treated as unknown + } + } + return { kind: 'unknown' }; + } + const loginWsMatch = LOGIN_WEBSOCKET_FAILED_RE.exec(body); if (loginWsMatch) return { kind: 'login_websocket_failed', reason: loginWsMatch[1].trim() }; @@ -247,8 +278,8 @@ export const parseEventLegacyV076 = (event: ParsableEvent): LoginEvent => { typeof event.redacts === 'string' ? event.redacts : isObject(event.content) && typeof event.content.redacts === 'string' - ? event.content.redacts - : undefined; + ? event.content.redacts + : undefined; if (!target) return { kind: 'unknown' }; return { kind: 'qr_redacted', redactsEventId: target }; } @@ -330,20 +361,11 @@ function runSanityChecks(): void { // Login success (post-QR scan). No snowflake in this line; App fires // `ping` afterwards to pick up the discordId. - [ - 'Successfully logged in as @example', - { kind: 'login_success', handle: 'example' }, - ], - [ - 'Successfully logged in as @user.name', - { kind: 'login_success', handle: 'user.name' }, - ], + ['Successfully logged in as @example', { kind: 'login_success', handle: 'example' }], + ['Successfully logged in as @user.name', { kind: 'login_success', handle: 'user.name' }], // Login failure paths. - [ - 'Error logging in: rate limited 429', - { kind: 'login_failed', reason: 'rate limited 429' }, - ], + ['Error logging in: rate limited 429', { kind: 'login_failed', reason: 'rate limited 429' }], // CAPTCHA legacy fallback — pre-empts LOGIN_FAILED_RE. Fires only on // unpatched upstream v0.7.6. [ @@ -387,10 +409,7 @@ function runSanityChecks(): void { // Logout. ['Logged out successfully.', { kind: 'logout_ok' }], - [ - "You weren't logged in, but data was re-cleared just to be safe.", - { kind: 'logout_no_op' }, - ], + ["You weren't logged in, but data was re-cleared just to be safe.", { kind: 'logout_no_op' }], // Disconnect / reconnect. ['Successfully disconnected', { kind: 'disconnect_ok' }], @@ -521,7 +540,9 @@ function runSanityChecks(): void { // eslint-disable-next-line no-console console.error('[legacy_v076 event sanity] mismatch', { event, actual, expected }); throw new Error( - `legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${event.content?.msgtype ?? ''}` + `legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${ + event.content?.msgtype ?? '' + }` ); } } diff --git a/apps/widget-discord/src/bridge-protocol/types.ts b/apps/widget-discord/src/bridge-protocol/types.ts index afa780a9..128ac540 100644 --- a/apps/widget-discord/src/bridge-protocol/types.ts +++ b/apps/widget-discord/src/bridge-protocol/types.ts @@ -113,6 +113,15 @@ export type LoginEvent = | { kind: 'reconnect_no_op' } | { kind: 'reconnect_failed'; reason?: string } + // --- Vojo: bridge-managed personal space --------------------------------- + // Vojo-patched bridge emits the sentinel `VOJO-LOGIN-SPACE-V1 {...}` as a + // separate m.notice right after the «Successfully logged in» line. Carries + // a `matrix.to` URL pointing at the user's auto-created Discord space + // (user.go::GetSpaceRoom on the bridge side). The widget surfaces this as + // an «Open in Channels» card; click → host navigates cinny to the space. + // See vojo-mautrix-discord/commands_login_space.go for the wire format. + | { kind: 'space_ready'; matrixToUrl: string } + // --- bridge-side errors -------------------------------------------------- // Generic «I don't know that command» — should not happen since we only // ship known commands, but visible if the bridge image is misconfigured diff --git a/apps/widget-discord/src/i18n/en.ts b/apps/widget-discord/src/i18n/en.ts index 5e742925..42182974 100644 --- a/apps/widget-discord/src/i18n/en.ts +++ b/apps/widget-discord/src/i18n/en.ts @@ -55,13 +55,11 @@ export const EN: Record = { 'Discord requested a CAPTCHA — QR sign-in is temporarily unavailable. Try again later, or sign in with a token via the bot’s chat.', 'auth-error.captcha-send-failed': 'Could not deliver your CAPTCHA solution. Check your network and try signing in again.', - 'auth-error.captcha-expired': - 'CAPTCHA expired — tap «Sign in with QR code» and solve it again.', + 'auth-error.captcha-expired': 'CAPTCHA expired — tap «Sign in with QR code» and solve it again.', 'auth-error.login-failed': 'Sign-in failed: {reason}', 'auth-error.prepare-failed': 'Failed to prepare sign-in: {reason}', 'auth-error.websocket-failed': 'Could not connect to the sign-in server: {reason}', - 'auth-error.connect-after-login-failed': - 'Signed in, but could not connect to Discord: {reason}', + 'auth-error.connect-after-login-failed': 'Signed in, but could not connect to Discord: {reason}', 'auth-error.already-logged-in': 'You are already signed in to Discord — refresh status.', 'auth-error.unknown-command': 'The bot does not recognise this command — check the prefix in config.json.', @@ -73,6 +71,9 @@ export const EN: Record = { 'card.logout.confirm-prompt': 'Sign out for real?', 'card.logout.confirm-yes': 'Sign out', 'card.logout.confirm-no': 'Cancel', + 'card.open-space.name': 'Open in Channels', + 'card.open-space.desc': 'Jump to your Discord space with all chats and servers', + 'diag.space-ready': 'Discord space ready to open.', 'diag.connecting': 'Connecting to Vojo… awaiting capability handshake.', 'diag.ready': 'Ready to send commands.', 'diag.checking-status': 'Checking connection status…', diff --git a/apps/widget-discord/src/i18n/ru.ts b/apps/widget-discord/src/i18n/ru.ts index 7e770c21..3549215c 100644 --- a/apps/widget-discord/src/i18n/ru.ts +++ b/apps/widget-discord/src/i18n/ru.ts @@ -86,8 +86,7 @@ export const RU = { 'Discord потребовал CAPTCHA — вход через QR временно недоступен. Попробуйте позже или войдите через токен в чате с ботом.', 'auth-error.captcha-send-failed': 'Не удалось отправить ответ на CAPTCHA. Проверьте сеть и попробуйте войти заново.', - 'auth-error.captcha-expired': - 'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.', + 'auth-error.captcha-expired': 'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.', 'auth-error.login-failed': 'Не удалось войти: {reason}', 'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}', 'auth-error.websocket-failed': 'Не удалось подключиться к серверу входа: {reason}', @@ -106,7 +105,11 @@ export const RU = { 'card.logout.confirm-prompt': 'Точно выйти?', 'card.logout.confirm-yes': 'Выйти', 'card.logout.confirm-no': 'Отмена', + // --- Open Discord space (Vojo bridge sentinel) ------------------------ + 'card.open-space.name': 'Открыть в Каналах', + 'card.open-space.desc': 'Перейти в спейс Discord со списком чатов и серверов', // --- Diagnostics in transcript ---------------------------------------- + 'diag.space-ready': 'Discord-спейс готов к открытию.', 'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.', 'diag.ready': 'Готов отправлять команды.', 'diag.checking-status': 'Проверяю статус подключения…', diff --git a/apps/widget-discord/src/state.ts b/apps/widget-discord/src/state.ts index a1c6344d..f2a02ab0 100644 --- a/apps/widget-discord/src/state.ts +++ b/apps/widget-discord/src/state.ts @@ -104,8 +104,13 @@ export type LoginState = | { kind: 'reconnecting'; handle?: string } // Live session — ping or login_success confirmed. Discord legacy bridge // doesn't have a per-account loginId concept (single Discord account - // per Matrix user), so logout doesn't need an id. - | { kind: 'connected'; handle: string; discordId?: string } + // per Matrix user), so logout doesn't need an id. `spaceMatrixToUrl` + // is populated from the Vojo `VOJO-LOGIN-SPACE-V1` sentinel that lands + // right after login_success; it survives the post-login re-ping and the + // reconnect-ok transitions so the «Open in Channels» card stays visible + // until logout. Absent until the sentinel arrives (and absent forever + // against an UNPATCHED bridge — the card simply never appears). + | { kind: 'connected'; handle: string; discordId?: string; spaceMatrixToUrl?: string } // ping says we have a token but the connection's down. Status pill: // green-ish but with a Reconnect recovery action exposed. The reducer // distinguishes `connection_dead` (Discord WS dropped) from `token_stored` @@ -120,10 +125,7 @@ export type LoginState = // staring at an hCaptcha challenge (rqdata/rqtoken are short-lived but // often valid for a couple of minutes — fresh enough to reuse). Other // transient states (logging_out, reconnecting) deliberately don't survive. -export type HydrateRestoredState = - | PendingFormState - | CaptchaSolveState - | { kind: 'qr_verifying' }; +export type HydrateRestoredState = PendingFormState | CaptchaSolveState | { kind: 'qr_verifying' }; // Outbound user actions the App dispatches. Form-submit actions clear any // pending lastError; structural transitions optimistically advance state — @@ -169,9 +171,7 @@ const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiti const isCaptchaAcceptingState = ( s: LoginState ): s is PendingFormState | { kind: 'qr_verifying' } | CaptchaSolveState => - s.kind === 'awaiting_qr_scan' || - s.kind === 'qr_verifying' || - s.kind === 'awaiting_captcha_solve'; + s.kind === 'awaiting_qr_scan' || s.kind === 'qr_verifying' || s.kind === 'awaiting_captcha_solve'; export const loginReducer = (state: LoginState, action: LoginAction): LoginState => { if (action.kind === 'hydrate') { @@ -266,11 +266,14 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState case 'logged_in': // Authoritative source — accept from any state. Used by both the // initial ping AND the post-`login_success` re-ping that picks up - // the discordId snowflake. + // the discordId snowflake. Preserve `spaceMatrixToUrl` from a prior + // `connected` so the post-login_success re-ping doesn't blank the + // CTA before the user gets a chance to click it. return { kind: 'connected', handle: event.handle, discordId: event.discordId, + spaceMatrixToUrl: state.kind === 'connected' ? state.spaceMatrixToUrl : undefined, }; case 'connection_dead': @@ -492,12 +495,28 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState // green with an empty handle, which the UI's // `state.handle ? connected-as : connected` ternary tolerates. // This avoids the `unknown` flap that the previous draft would - // produce when no handle was stashed. + // produce when no handle was stashed. spaceMatrixToUrl is not + // restorable from connected_dead (the dead state never carried it), + // so the CTA stays hidden until a fresh sentinel arrives — bridge + // does NOT re-emit on reconnect, but the card returns once the user + // explicitly re-logs in. if (state.kind === 'reconnecting' || state.kind === 'connected_dead') { return { kind: 'connected', handle: state.handle ?? '' }; } return state; + case 'space_ready': + // Vojo-patched bridge surfaced the personal Discord space — attach + // its matrix.to URL to the connected state so the «Open in Channels» + // card renders. Late-arriving sentinels from an abandoned flow drop + // here silently (e.g. a sentinel that lands during `logging_out` + // mustn't resurrect a connected state). Honour only from the + // canonical alive states. + if (state.kind === 'connected') { + return { ...state, spaceMatrixToUrl: event.matrixToUrl }; + } + return state; + case 'reconnect_failed': if (state.kind !== 'reconnecting') return state; // Roll back to connected_dead carrying the previous handle. The @@ -565,10 +584,7 @@ type HydrateAccumulator = { terminated: boolean; }; -const stepHydrate = ( - prevAcc: HydrateAccumulator, - input: HydrateInput -): HydrateAccumulator => { +const stepHydrate = (prevAcc: HydrateAccumulator, input: HydrateInput): HydrateAccumulator => { const { ev, ts } = input; // After a terminal event we normally stop — except if a fresh @@ -693,9 +709,12 @@ const stepHydrate = ( case 'already_logged_in': case 'unknown': + case 'space_ready': // Soft no-op for hydrate. already_logged_in is a live-flow warning // that doesn't reflect persistent state; unknown is a wording-drift - // catch-all. + // catch-all; space_ready is a post-terminal sentinel — hydrate + // terminates on login_success and lets live ping reconcile, so + // the URL gets attached on the live path, not here. return acc; default: { diff --git a/apps/widget-discord/src/widget-api.ts b/apps/widget-discord/src/widget-api.ts index c88e1469..fbed93e9 100644 --- a/apps/widget-discord/src/widget-api.ts +++ b/apps/widget-discord/src/widget-api.ts @@ -125,6 +125,27 @@ export class WidgetApi { ); } + // Ask the host to navigate to a matrix.to URL inside the cinny app + // (room or space). Same side-channel pattern as `openExternalUrl` — + // distinct from matrix-widget-api's `fromWidget` so the SDK stays + // ignorant of this Vojo extension. The host validates the URL via + // `parseMatrixToRoom` (rejecting non-room URLs, javascript:/data:, etc.) + // BEFORE routing into the react-router; sending anything that isn't a + // matrix.to/#/!roomId or matrix.to/#/#alias URL silently no-ops on the + // host side. The widget is responsible for only invoking this when it + // genuinely has a matrix.to room URL (e.g. parsed from a bridge + // sentinel). + public openMatrixToUrl(url: string): void { + window.parent.postMessage( + { + api: 'io.vojo.bot-widget', + action: 'open-matrix-to', + data: { url }, + }, + this.bootstrap.parentOrigin + ); + } + // Always prefix outbound commands with ` ` (trailing space). // Legacy mautrix-discord routes management-room commands through the // bridge.commands.Processor in mautrix/go bridge/commands; outside the diff --git a/apps/widget-telegram/package-lock.json b/apps/widget-telegram/package-lock.json index 13c15bec..eb55825f 100644 --- a/apps/widget-telegram/package-lock.json +++ b/apps/widget-telegram/package-lock.json @@ -8,6 +8,7 @@ "name": "@vojo/widget-telegram", "version": "0.0.1", "dependencies": { + "libphonenumber-js": "^1.11.7", "preact": "10.22.1", "qrcode-generator": "^1.4.4" }, @@ -1611,6 +1612,12 @@ "dev": true, "license": "MIT" }, + "node_modules/libphonenumber-js": { + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.7.tgz", + "integrity": "sha512-x2xON4/Qg2bRIS11KIN9yCNYUjhtiEjNyptjX0mX+pyKHecxuJVLIpfX1lq9ZD6CrC/rB+y4GBi18c6CEcUR+A==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", diff --git a/apps/widget-telegram/package.json b/apps/widget-telegram/package.json index 503f0703..4ba17d56 100644 --- a/apps/widget-telegram/package.json +++ b/apps/widget-telegram/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "libphonenumber-js": "^1.11.7", "preact": "10.22.1", "qrcode-generator": "^1.4.4" }, diff --git a/apps/widget-telegram/src/App.tsx b/apps/widget-telegram/src/App.tsx index 29c31ce1..7a8e6a27 100644 --- a/apps/widget-telegram/src/App.tsx +++ b/apps/widget-telegram/src/App.tsx @@ -2,6 +2,12 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'p import type { Dispatch } from 'preact/hooks'; import type { ComponentChildren } from 'preact'; import qrcodeGenerator from 'qrcode-generator'; +// `/min` metadata (~15 KB gzip) covers all country calling codes + length +// validation. Sufficient for «is this a plausible phone number?» — the +// bridge does the authoritative validation server-side. Avoid `/max` +// (~60 KB) since the widget is a separate Preact bundle and ships into +// the bot iframe on cold start. +import { AsYouType, isValidPhoneNumber } from 'libphonenumber-js/min'; import type { WidgetBootstrap } from './bootstrap'; import { WidgetApi, type RoomEvent } from './widget-api'; import { createT, type T } from './i18n'; @@ -104,6 +110,34 @@ const QrIcon = () => ( ); +// Eye + eye-with-slash for the password reveal toggle. SVG paths +// copied verbatim from folds `Icons.Eye(false)` / `Icons.EyeBlind(false)` +// — the unfilled variants Vojo's main auth uses via +// `src/app/components/password-input/PasswordInput.tsx`. Importing folds +// into the widget bundle would pull the whole component library, so we +// inline the geometry. ViewBox 24×24 + `fill="currentColor"` matches +// the folds Icon component output bit-for-bit; the only divergence vs +// the host is the wrapper (folds `IconButton` vs our plain ` @@ -350,6 +466,9 @@ const PhoneForm = ({
{t('auth-card.phone.hint')}
+ {showInvalidHint && !error ? ( +
{t('auth-card.phone.invalid')}
+ ) : null} {error ? (
{localizeError(error, t)} @@ -508,24 +627,37 @@ const PasswordForm = ({ state, t, dispatch, send, sendCancel }: FormProps) => { {t('auth-card.password.label')}
-
+
` against that + // size-derived minimum, so the input refused to shrink and + // pushed past the `.auth-card` border on narrow viewports. + // Setting `size=1` drops the intrinsic floor so `flex: 1` + // + `min-width: 0` actually shrink the box to the slot. + size={1} value={value} onInput={(e) => setValue((e.currentTarget as HTMLInputElement).value)} disabled={submitting} />
@@ -367,6 +430,9 @@ const PhoneForm = ({
{t('auth-card.phone.hint')}
+ {showInvalidHint && !error ? ( +
{t('auth-card.phone.invalid')}
+ ) : null} {error ? (
{localizeError(error, t)} @@ -556,10 +622,7 @@ const PairingCodePanel = ({ state, t, sendCancel }: PairingCodePanelProps) => { }, []); const elapsed = state.firstShownAt > 0 ? now - state.firstShownAt : 0; - const remainingSeconds = Math.max( - 0, - Math.ceil((PAIRING_CODE_TIMEOUT_MS - elapsed) / 1000) - ); + const remainingSeconds = Math.max(0, Math.ceil((PAIRING_CODE_TIMEOUT_MS - elapsed) / 1000)); const expired = elapsed >= PAIRING_CODE_TIMEOUT_MS && state.firstShownAt > 0; return ( @@ -578,10 +641,7 @@ const PairingCodePanel = ({ state, t, sendCancel }: PairingCodePanelProps) => { // user-select: all on the text element keeps one-tap copy // working on touch devices. <> - + {state.code} @@ -603,9 +663,7 @@ const PairingCodePanel = ({ state, t, sendCancel }: PairingCodePanelProps) => { })}
) : ( -
- {t('auth-card.pairing-code.expired')} -
+
{t('auth-card.pairing-code.expired')}
)}
  1. {t('auth-card.pairing-code.step-1')}
  2. @@ -1058,10 +1116,7 @@ export function App({ bootstrap, api }: Props) { append({ kind: 'diag', text: t('diag.qr-issued') }); } else if (event.kind === 'qr_redacted') { const liveState = stateRef.current; - if ( - liveState.kind === 'awaiting_qr_scan' && - liveState.qrEventId === event.redactsEventId - ) { + if (liveState.kind === 'awaiting_qr_scan' && liveState.qrEventId === event.redactsEventId) { append({ kind: 'diag', text: t('diag.qr-consumed') }); } } else if (event.kind === 'pairing_code_displayed') { diff --git a/apps/widget-whatsapp/src/i18n/en.ts b/apps/widget-whatsapp/src/i18n/en.ts index 7de15030..2285b9e3 100644 --- a/apps/widget-whatsapp/src/i18n/en.ts +++ b/apps/widget-whatsapp/src/i18n/en.ts @@ -47,11 +47,13 @@ export const EN: Record = { 'Enter your phone number including the country code. WhatsApp will then generate an 8-character pairing code that you enter in the WhatsApp app.', 'auth-card.phone.submit': 'Get code', 'auth-card.phone.cooldown': 'Retry in {seconds}s', + 'auth-card.phone.invalid': "This doesn't look like a complete international phone number.", 'auth-card.pairing-code.title': 'Enter this code in WhatsApp', 'auth-card.pairing-code.hint': 'Open WhatsApp on your phone and enter this code under Linked devices → Link with phone number.', 'auth-card.pairing-code.preparing': 'Preparing the code…', - 'auth-card.pairing-code.aria': 'Pairing code for WhatsApp sign-in. Enter it in the app on your phone.', + 'auth-card.pairing-code.aria': + 'Pairing code for WhatsApp sign-in. Enter it in the app on your phone.', 'auth-card.pairing-code.countdown': 'Time left to enter: {minutes}:{seconds}', 'auth-card.pairing-code.expired': 'Sign-in window expired. Tap Cancel and try again.', 'auth-card.pairing-code.step-1': 'Open WhatsApp on your phone.', @@ -83,8 +85,7 @@ export const EN: Record = { 'WhatsApp unlinked this device from another device. Sign in again.', 'auth-error.external-logout.phone-logged-out': 'You signed out of WhatsApp on the phone — all linked devices were unlinked. Sign in again.', - 'auth-error.external-logout.unknown': - 'WhatsApp dropped the session. Sign in again.', + 'auth-error.external-logout.unknown': 'WhatsApp dropped the session. Sign in again.', 'card.logout.name': 'Sign out of WhatsApp', 'card.logout.desc': 'End the session for this account', 'card.logout.confirm-prompt': 'Sign out for real?', diff --git a/apps/widget-whatsapp/src/i18n/ru.ts b/apps/widget-whatsapp/src/i18n/ru.ts index 0e5fb214..7239b2de 100644 --- a/apps/widget-whatsapp/src/i18n/ru.ts +++ b/apps/widget-whatsapp/src/i18n/ru.ts @@ -94,6 +94,7 @@ export const RU = { 'Введите номер с кодом страны. После этого WhatsApp создаст 8-символьный код — его нужно будет ввести в приложении.', 'auth-card.phone.submit': 'Получить код', 'auth-card.phone.cooldown': 'Повтор через {seconds} сек', + 'auth-card.phone.invalid': 'Похоже, номер ещё не полный или введён с ошибкой.', // --- Pairing-code form ------------------------------------------------- 'auth-card.pairing-code.title': 'Введите этот код в WhatsApp', 'auth-card.pairing-code.hint': @@ -104,7 +105,8 @@ export const RU = { 'auth-card.pairing-code.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.', 'auth-card.pairing-code.step-1': 'Откройте WhatsApp на телефоне.', 'auth-card.pairing-code.step-2': 'Перейдите в «Настройки → Связанные устройства».', - 'auth-card.pairing-code.step-3': 'Нажмите «Привязать устройство → Привязать с помощью номера телефона».', + 'auth-card.pairing-code.step-3': + 'Нажмите «Привязать устройство → Привязать с помощью номера телефона».', 'auth-card.pairing-code.step-4': 'Введите этот код и подтвердите вход на телефоне.', // --- QR form ----------------------------------------------------------- 'auth-card.qr.title': 'Вход по QR-коду', @@ -147,8 +149,7 @@ export const RU = { 'WhatsApp отвязал это устройство с другого устройства. Войдите снова.', 'auth-error.external-logout.phone-logged-out': 'Вы вышли из WhatsApp на телефоне — все связанные устройства отвязаны. Войдите снова.', - 'auth-error.external-logout.unknown': - 'WhatsApp разорвал сессию. Войдите снова.', + 'auth-error.external-logout.unknown': 'WhatsApp разорвал сессию. Войдите снова.', // --- Logout ------------------------------------------------------------ 'card.logout.name': 'Выйти из WhatsApp', 'card.logout.desc': 'Завершить сеанс на этом аккаунте', diff --git a/apps/widget-whatsapp/src/styles.css b/apps/widget-whatsapp/src/styles.css index d5d54b86..3ee7dce8 100644 --- a/apps/widget-whatsapp/src/styles.css +++ b/apps/widget-whatsapp/src/styles.css @@ -592,6 +592,44 @@ body { box-shadow: 0 0 0 3px rgba(192, 142, 123, 0.22); } +/* Soft-warn for client-side phone validation. The bridge still has the + * final say (and the cooldown self-clears on `invalid_value`), so amber + * is the right register — server-confirmed errors keep rose. */ +.auth-input.warn { + border-color: var(--amber); +} +.auth-input.warn:focus { + box-shadow: 0 0 0 3px rgba(231, 178, 90, 0.22); +} + +/* Phone-input shell — host for the country-flag emoji positioned over + * the input's left padding (no need to split the input's background / + * border / focus ring across two siblings). `with-flag` bumps + * padding-left so digits clear the glyph. */ +.auth-phone-shell { + position: relative; + display: flex; + flex: 1; + min-width: 0; +} +.auth-phone-shell .auth-input { + flex: 1; + min-width: 0; +} +.auth-phone-shell.with-flag .auth-input { + padding-left: 44px; +} +.auth-phone-flag { + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); + font-size: 20px; + line-height: 1; + pointer-events: none; + user-select: none; +} + /* Note: TG-style `.auth-input.code` / `.auth-input.password` / * `.password-row` / `.btn-icon` selectors were intentionally NOT * carried over — WhatsApp has no SMS-code form (pairing-code is @@ -835,7 +873,7 @@ body { display: flex; align-items: flex-start; gap: 10px; - background: rgba(212, 184, 138, 0.10); + background: rgba(212, 184, 138, 0.1); border: 1px solid var(--amber); border-radius: 10px; padding: 12px 14px; diff --git a/docs/ai/README.md b/docs/ai/README.md index ea365fb8..3bec6564 100644 --- a/docs/ai/README.md +++ b/docs/ai/README.md @@ -19,6 +19,7 @@ Any agent (Claude Code, Cursor, Codex, Windsurf, Cline, Copilot, Aider, …) wor | [architecture.md](architecture.md) | Stack, source layout, routing, features, state management, Matrix SDK patterns, git workflow | | [i18n.md](i18n.md) | i18next setup, translation patterns, Russian-language quality standards, localization progress | | [android.md](android.md) | Capacitor wrapper, Android build chain, edge-to-edge, Service Worker invariants, ADB workflow | +| [electron.md](electron.md) | Electron desktop wrapper, privileged `vojo://` scheme for SW, build chain, IPC security, Windows distribution | | [bugs.md](bugs.md) | Known bugs & regressions | | [server-side.md](server-side.md) | Some configs that deployd on server | diff --git a/docs/ai/android.md b/docs/ai/android.md index 0b7c77cf..5c4f75c6 100644 --- a/docs/ai/android.md +++ b/docs/ai/android.md @@ -26,7 +26,7 @@ npm run android:apk:debug # gradle debug build only ## Versioning -`versionCode` and `versionName` auto-derived from `package.json` version: +`versionCode` and `versionName` are derived from `git describe --tags --match 'v*'` in [`android/app/build.gradle`](../../android/app/build.gradle), mirroring `resolveAppVersion()` in [`vite.config.js`](../../vite.config.js) so the APK's `versionName` matches `__APP_VERSION__` shown in About. Tag is `v0.2.0`; `patch` is the commit count since that tag (e.g. `v0.2.0-87-g…` → versionName `0.2.87`). When git is unavailable, falls back to `package.json` `version`. ``` versionCode = major * 1_000_000 + minor * 1_000 + patch @@ -38,8 +38,8 @@ versionCode = major * 1_000_000 + minor * 1_000 + patch - **Service Worker stays active.** Critical for authenticated Matrix media (MSC3916 / Matrix spec v1.11+). DO NOT disable. `resolveServiceWorkerRequests` default `true`. - **Edge-to-edge.** `EdgeToEdge.enable()` in `MainActivity.java` + `windowLayoutInDisplayCutoutMode: shortEdges`. - **External links.** Opened via `@capacitor/browser` plugin — see [`src/app/utils/capacitor.ts`](../../src/app/utils/capacitor.ts). -- **Safe-area coloring.** `body` background-color reads `--vojo-safe-area-bg` (set on `:root` in [`src/app/styles/global.css.ts`](../../src/app/styles/global.css.ts), default `#0d0e11` = chat-list tone). [`Room.tsx`](../../src/app/features/room/Room.tsx) retunes the var to `#181a20` (chat-surface tone) while a chat is mounted so the status-bar / gesture-bar zones never show a seam against the active surface. -- **Safe-area insets — top / left / right only on `#root`.** Bottom inset is intentionally **not** applied at `#root` so the app renders edge-to-edge under the Android gesture pill / 3-button bar / iOS home indicator (mirrors WhatsApp / Telegram). Components that anchor interactive UI at the screen bottom MUST add `padding-bottom: var(--vojo-safe-bottom)` themselves — covered: chat composer ([`RoomView.css.ts`](../../src/app/features/room/RoomView.css.ts)), PageNav inner column ([`Page.tsx`](../../src/app/components/page/Page.tsx) → catches SelfRow / WorkspaceFooter / etc.), bottom call rail ([`HorseshoeContainer.css.ts`](../../src/app/pages/HorseshoeContainer.css.ts)), AuthFooter ([`auth/styles.css.ts`](../../src/app/pages/auth/styles.css.ts)). New screens with a bottom CTA must follow this rule or the button lands behind a system 3-button nav bar. +- **Safe-area coloring.** `body` background-color is bound to the folds theme variable `var(--oq6d070)` for consistent safe-area coloring. +- **Safe-area insets.** Applied on `#root` (not `body`) so the theme background extends behind the system bars. ## VSCode tasks @@ -54,7 +54,188 @@ Push notification text for Android is generated from `public/locales/{en,ru}.jso The task requires `node` in `PATH`. Terminal builds and CI inherit it from the shell. **macOS Android Studio with nvm/fnm:** the GUI app may not see nvm-managed node. Workaround: set `NODE_BIN=/path/to/node` in `android/gradle.properties` (the task reads it via `project.findProperty('NODE_BIN')`) or launch AS from a shell that sources your node manager (`open -a "Android Studio"`). -## ADB wireless workflow +## Push polling fallback (WorkManager) + +Users on networks that block FCM (`mtalk.google.com:5228` — corporate, school +and government whitelist intranets, ~5% of our audience) get zero pushes from +the primary channel. To cover them we run a WorkManager periodic poll of +`/_matrix/client/v3/notifications` as a parallel best-effort delivery channel. +Always on whenever push is enabled — there's no smart-detect-and-switch (FCM +gives no client-visible delivery receipts; see +[push_unifiedpush_phase1.md §11](../plans/push_unifiedpush_phase1.md) for the +full rationale of why this is the only viable shape). + +Components: + +| Layer | File | Role | +|---|---|---| +| Worker | [`VojoPollWorker.java`](../../android/app/src/main/java/chat/vojo/app/VojoPollWorker.java) | Periodic fetch of `/notifications`, flattens response into Sygnal-shape `Map`, routes message/invite → `renderMessageNotification`, RTC ring → `renderMissedCallNotification`. Skips events that are `read=true`, push-rule-suppressed (`actions` lacks `notify`), in NotificationDedup, or with `ts < watermark`. Foreground-gated: doesn't render system notifications while `MainActivity.isInForeground` (still consumes state). Saves a drain cursor when capped at `MAX_PAGES_PER_RUN`. | +| Bridge | [`PollingPlugin.java`](../../android/app/src/main/java/chat/vojo/app/PollingPlugin.java) | Capacitor plugin. JS calls `saveSession` (token + homeserver, seeds watermark on first use to skip historical backlog), `schedule(15)` (unique periodic worker), `saveRoomNames` (room-id → name cache), `cancel` (awaits WorkManager Operation completion) + `clearSession` on disable/logout. | +| Renderers | [`VojoFirebaseMessagingService.java::renderMessageNotification`, `::renderMissedCallNotification`](../../android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java) | Static, Context-parameterised so the Worker can post into the same notification id space as FCM. Message path uses a **per-room** `roomId.hashCode()` slot — every new event in a room appends to a MessagingStyle conversation rather than stacking as a separate card (see [MessagingStyle pipeline](#messagingstyle-pipeline) below). Missed-call path uses per-event slots so multiple missed rings stack. After successful `nm.notify`, mark the event in NotificationDedup so the polling Worker doesn't re-surface it after the user dismisses an FCM-delivered one. | +| Dedup | [`NotificationDedup.java`](../../android/app/src/main/java/chat/vojo/app/NotificationDedup.java) | Thread-safe shared LRU set of rendered event_ids. Written by both FCM service (background renders AND foreground-skipped events) and Worker (after successful render or foreground-skip). Bounded at 500 entries to comfortably exceed a single Worker run's worst case (`MAX_PAGES_PER_RUN × PAGE_LIMIT = 250`), persisted in `vojo_poll_state` SharedPreferences. | +| JS plugin | [`src/app/plugins/polling.ts`](../../src/app/plugins/polling.ts) | `registerPlugin('Polling', { web: noop })`. Web has no analogue (SW already wakes for push) — fallback is a no-op. | +| Lifecycle | [`src/app/hooks/usePushNotifications.ts::usePushNotificationsLifecycle`](../../src/app/hooks/usePushNotifications.ts) | Reactive to `usePushEnabled()`. On mount with push enabled: `saveSession` + `schedule` + initial room-name dump. On `visibilitychange → visible`: re-`saveSession` (recovers a 401-cleared credentials slot without remount) + re-dump room names. On unmount or push disable: `cancel` + `clearSession`. | + +Why polling is rendered as **missed call** (not CallStyle) for ring events: the +`m.rtc.notification` lifetime is 30 seconds; polling runs at the 15-minute +floor of `PeriodicWorkRequest`. Every ring observed by the Worker is already +stale and the live call long over — rendering CallStyle with ringtone would +phantom-ring a dead call. Missed-call style preserves the "you missed a call +from X" signal without the wrong UX. Live-call delivery for whitelist users +remains a gap; closing it requires a non-FCM live channel (UnifiedPush, see +the stale plan above). + +Why we do not need a refresh-token flow: Vojo's homeserver is vanilla Synapse +without MAS/OIDC (see [server-side.md](server-side.md)), so access tokens are +long-lived. A 401 from the Worker logs out the credentials slot and waits for +the next foreground app launch to re-bridge — no native refresh-token logic +required. If we ever migrate to MAS, the Worker needs a refresh path. + +Why our source manifest does not declare `RECEIVE_BOOT_COMPLETED`: WorkManager's +library manifest already declares the permission and the `RescheduleReceiver`, +which the manifest merger folds into the merged manifest. Reboot persistence +works end-to-end without our app re-declaring anything. Apps only need to add +the permission themselves when they listen for `BOOT_COMPLETED` for their own +purposes. + +Edge cases handled: +- Token rotation (post-MAS migration): currently not bridged from JS to native + on token-rotate events. JS re-saves credentials on every lifecycle re-mount + AND on visibilitychange → visible, so user-driven re-open recovers within + seconds. After a 401 the Worker clears its credentials slot; after a 403 + it leaves credentials alone and just skips the cycle (403 is most often a + transient rate-limit, not a dead token). +- First fire after install / re-login: `saveSession` seeds + `KEY_LAST_SEEN_TS` to `System.currentTimeMillis() - 60s` on first write, + so the Worker doesn't render every historical unread `/notifications` + entry as a fresh push. The 60s buffer tolerates device-clock drift ahead + of the homeserver (event `ts` is server-side); without it a fast-clock + device would silently skip fresh events as "older than watermark". +- POST_NOTIFICATIONS revoked at runtime: Worker bails early on + `NotificationManagerCompat.areNotificationsEnabled() == false`. Without + this guard `nm.notify` would throw `SecurityException` per event, leave + the LRU and watermark unadvanced, and re-walk the same backlog every 15 + minutes until the user re-grants permission. +- Worker > 10 minutes (Android kill timer): bounded by `MAX_PAGES_PER_RUN=5` + × `PAGE_LIMIT=50` + 30s HTTP timeout per call. Cannot exceed ~3 minutes + in normal operation. Most polls touch only a single page because the ts + watermark short-circuits the loop. +- Large backlog (>250 events accumulated while offline): when a single fire + hits `MAX_PAGES_PER_RUN` before reaching the watermark, the Worker saves + the leftover `next_token` as `KEY_DRAIN_CURSOR` AND snapshots the head ts + of the first run as `KEY_DRAIN_TARGET_TS`. Subsequent fires resume from + that cursor instead of head; the target ts is the fast-forward + destination for the watermark when drain finally completes — without it, + the bounded LRU could evict head events and let the post-drain normal + run re-render them. +- Network unavailable: `NetworkType.CONNECTED` constraint skips the run; next + cycle retries. +- Doze: WorkManager honours maintenance windows. No catch-up — only the next + scheduled fire delivers the accumulated backlog. The Worker walks from the + head of `/notifications` and stops as soon as it reaches the watermark, so a + Doze-extended gap just produces a larger first-page walk. +- Pagination assumes newest-first ordering (Vojo runs vanilla Synapse, whose + `get_push_actions_for_user` issues `ORDER BY stream_ordering DESC`). The + Matrix spec for `/notifications` does not formally mandate this ordering, so + if Vojo ever migrates to a homeserver implementation that paginates oldest- + first (Conduit, Dendrite, …) the `ts < watermark` break would clip new + events. Revisit the Worker before any such migration. +- Already-read events (user read on another client) are skipped via the `read` + field on each `/notifications` entry; their ts still advances the watermark + so they don't get re-walked next poll. +- Muted rooms: `actions` array on each `/notifications` entry is consulted; + events without `notify` (i.e. `dont_notify` from a mute push rule) are + skipped. Without this, the mute toggle wouldn't actually mute polling- + delivered notifications even though Sygnal honours it for FCM. +- User in foreground: Worker doesn't render system notifications while + `MainActivity.isInForeground` (live timeline owns UX). State still + advances so events don't replay on the next backgrounded poll. +- FCM + polling double delivery: NotificationDedup is the single source of + truth — FCM service and Worker both write to it after successful render, + both read it before posting. Even if the user dismisses an FCM-delivered + notification before polling fires, the Worker skips it. +- UTF-8 multi-byte boundaries: `readAll` accumulates raw bytes and decodes + the full buffer once, never per-chunk; otherwise a Cyrillic character + straddling an 8 KB read boundary would become U+FFFD. +- Logout race: `initMatrix.ts::logoutClient`, `clearLocalSessionAndReload`, + and the `SessionLoggedOut` listener in `ClientRoot.tsx` all call + `polling.cancel()` + `polling.clearSession()` synchronously before + `window.location.replace`, so the Worker can't fire one more time with + the stale access_token. `cancel()` awaits the WorkManager `Operation` so + a fast disable → re-enable cycle doesn't race the `KEEP` policy. The + lifecycle effect's unmount cleanup repeats the same calls as + belt-and-suspenders. + +Cleanups invoked symmetrically across every logout path: +`useDisablePushNotifications`, `logoutClient`, `clearLocalSessionAndReload`, +the `SessionLoggedOut` listener, and the lifecycle effect's unmount all +call `polling.cancel()` + `polling.clearSession()`. + +## MessagingStyle pipeline + +Background-rendered message notifications use +`NotificationCompat.MessagingStyle` so multiple events in one room collapse +into an expandable conversation card (WhatsApp / Telegram convention) +rather than each event posting a separate banner. Notification id is +**per-room** (`roomId.hashCode()`), not per-event. + +Components: + +| Layer | File | Role | +|---|---|---| +| Cache | [`RoomMessageCache.java`](../../android/app/src/main/java/chat/vojo/app/RoomMessageCache.java) | Thread-safe `ConcurrentHashMap>` bounded at 20 messages × 200 rooms. Snapshot is taken INSIDE `compute()` so a concurrent FCM + Worker append on the same room can't race the copy. Mutated by both `VojoFirebaseMessagingService.renderMessageNotification` (FCM service path AND Worker path through the same static helper) and `appendOutgoingMessage` (ReplyReceiver echo). | +| Channels | `vojo_messages_dm_v1` (IMPORTANCE_HIGH) + `vojo_messages_group_v1` (IMPORTANCE_DEFAULT) under `NotificationChannelGroup("vojo_messages_v1")`. Legacy `vojo_messages` is deleted on first creation of v1. Channel split lets users mute group-room noise in OS settings without losing DM alerts. | +| Metadata snapshot | JS bridges `{roomId: {name, isDirect, isEncrypted}}` via `polling.saveRoomNames` → `KEY_ROOM_NAMES` in `vojo_poll_state`. `loadRoomMetadata` parses tolerantly (legacy `roomId: "name"` falls back to `isDirect=true, isEncrypted=true` for safety). Re-dump triggers: mount, visibility-change, `ClientEvent.AccountData` for `m.direct`, `RoomEvent.Timeline` filtered to `m.room.encryption`. | +| Process-kill recovery | On cache miss, `seedCacheFromActiveNotification` calls `NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification` on the on-shade `StatusBarNotification` to rebuild prior history. Survives process kill; fails gracefully to single-message conversation if the notification was also dismissed. | +| Receivers | [`MarkAsReadReceiver`](../../android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java) (POST `/_matrix/client/v3/rooms/{roomId}/receipt/m.read/{eventId}` + dismiss), [`NotificationDismissReceiver`](../../android/app/src/main/java/chat/vojo/app/NotificationDismissReceiver.java) (swipe → clear cache so the next push starts fresh), [`ReplyReceiver`](../../android/app/src/main/java/chat/vojo/app/ReplyReceiver.java) (RemoteInput → PUT `m.room.message` with `m.text` body + optimistic local echo). All read credentials from `vojo_poll_state` SharedPreferences (same lifecycle as `VojoPollWorker`). | +| Receipt-driven dismiss | JS `mx.on(RoomEvent.Receipt)` filters own-user receipts, checks `room.getUnreadNotificationCount(Total) === 0`, calls `polling.dismissRoom(roomId)` → native `nm.cancel + RoomMessageCache.clear`. Mirrors element-web's `Notifier.onRoomReceipt`. Killed-process dismiss is not covered (no JS context to observe the receipt) — acceptable: the next FCM push to that room renders a fresh conversation from cache-empty state. | + +Why MessagingStyle vs the old per-event flow: 5 messages in one DM previously +produced 5 separate cards in the shade with redundant title/avatar. The +MessagingStyle conversation matches WhatsApp/Telegram UX and is the documented +Android pattern for messaging apps. See element-android's +`RoomGroupMessageCreator` for the canonical reference. + +Why two channels (DM + group) and not per-conversation channels (the +fluffychat approach): per-conversation works for low-room-count clients but +proliferates user-visible settings entries on a Matrix client with dozens of +active rooms. Element-android sidesteps the question with a NOISY/SILENT +split based on push rules; we picked a middle ground — bucketed by DM vs +group room — which mirrors fluffychat's `directChats`/`groupChats` +NotificationChannelGroup setup. + +Why reply action is gated on `!isEncrypted`: the Java path has no key +material to sign + encrypt outgoing replies with, so an inline reply in an +E2EE room would send cleartext (Synapse does not enforce the +"encrypted-only" rule, so the leak is real). The snapshot defaults to +`isEncrypted=true` on cache miss and the JS side re-dumps on +`m.room.encryption` state events so the action is dropped within seconds of +a room being switched to E2EE. + +Why call-session composite dedup +(`compositeCallDedupKey(roomId, sessionId)`): the legacy per-eventId dedup +misses re-rings of the same call session because each ring is a fresh +`m.rtc.notification` event with a new event_id. We extract the parent call +event_id from `content.m.relates_to.event_id` (Worker JSON parse) / +`content_m.relates_to_event_id` (FCM Sygnal-flatten) and mark the composite +in NotificationDedup the moment we post the first CallStyle. Subsequent +ring events for the same session see the mark and skip silently. Mirrors +element-web's `getIncomingCallToastKey` pattern. + +Why edit-collapse (`m.replace`) is **NOT implemented**: requires parsing +`content.m.relates_to.rel_type == "m.replace"` + finding the original event +in the per-room cache and replacing in place. The complication: FCM +payloads (Sygnal-flattened) encode nested keys inconsistently across +deployments (`content_m.relates_to_rel_type` vs +`content_m_relates_to_rel_type` vs dot-preserved variants), and the Worker +parses raw JSON cleanly while FCM hits one of the flattened shapes. +Asymmetric handling (Worker only) creates user-visible drift between +delivery paths. Real-world impact is low — users rarely edit +notification-flagged messages in the seconds-long window before they're +read — so the feature is deferred until we have a uniform key shape from +Sygnal config or until a real-world report justifies the parser complexity. + + 1. On the phone, enable Wireless debugging, tap "Pair device with pairing code" — note IP, port, 6-digit code. 2. `adb pair : ` diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md index 1ccc7337..78ee29e3 100644 --- a/docs/ai/architecture.md +++ b/docs/ai/architecture.md @@ -11,14 +11,14 @@ npm run typecheck # tsc --noEmit Build: **Vite 5.4** with vanilla-extract, WASM, PWA plugins. -> **Note:** `.husky/pre-commit` is currently commented out. `npm run check:eslint` is **green** (0 errors, 116 warnings — kept as warn for `no-explicit-any`/`no-non-null-assertion` policy). `npm run typecheck` still has ~32 known errors (residual project bugs after the TS 5.4 + Bundler migration that cleared ~800 module-resolution errors — see `docs/known-tech-debt-lint/`). Use `bash docs/known-tech-debt-lint/diff.sh` to verify your changes added no new typecheck errors, then `npm run build` for the green build check. +> **Note:** `.husky/pre-commit` is enabled and runs `tsc --noEmit` + `lint-staged` (which calls `eslint --max-warnings 0` on staged JS/TS files). Both gates are zero: `npm run typecheck` and `npm run check:eslint` are green (0 errors, 0 warnings). Custom Matrix event-types (`AccountDataEvent.Vojo*`, `PoniesRoomEmotes`, `m.bridge`, `m.call.member` etc.) live in [`src/types/matrix/sdkAugmentation.d.ts`](../../src/types/matrix/sdkAugmentation.d.ts) — add new custom types there to keep `mx.getAccountData` / `mx.getStateEvent` calls type-safe. ## Source Layout ``` src/ ├── index.tsx # Entry point -├── colors.css.ts # Custom dark-theme via createTheme(color, …); no light override (uses folds.lightTheme as-is) +├── colors.css.ts # Vojo dark + light themes via createTheme(color, …) — both palettes are Vojo-owned, folds defaults are not used ├── config.css.ts # fontWeight overrides ├── client/ │ ├── initMatrix.ts # Matrix SDK init (createClient, startClient, logout) @@ -66,10 +66,11 @@ Router in `Router.tsx`. Each top-level tab (`/direct/`, `/space/...`, `/explore/ → RoomTimeline + RoomViewTyping + RoomInput ``` -After P3c the Stream layout is the only timeline layout. There is no DM-vs-non-DM render gate. The classification that used to drive Stream now collapses into a single **member-count** check, mirroring Element-Web's tier-2 pattern (`room.getInvitedAndJoinedMemberCount() === 2`): +After P3c the timeline picks between two layouts via a single **member-count** check, mirroring Element-Web's tier-2 pattern (`room.getInvitedAndJoinedMemberCount() === 2`). There is no DM-vs-non-DM render gate — the classification is purely participant-count + channels-route: -- 1:1 rooms (member-count = 2) get peer-style header chrome (peer avatar fallback in `useRoomAvatar(room, isOneOnOne)`), the `DmCallButton`, and unconditionally hide membership/nick/avatar syslines. -- Group rooms (member-count > 2) get the room-style header (no peer fallback), no `DmCallButton`, and respect the `hideMembershipEvents` / `hideNickAvatarEvents` user settings for syslines. +- 1:1 rooms (member-count = 2) get peer-style header chrome (peer avatar fallback in `useRoomAvatar(room, isOneOnOne)`), the `DmCallButton`, the **Stream** timeline layout (rail + dot + bubble), and unconditionally hide membership/nick/avatar syslines. +- Group rooms (member-count > 2) get the room-style header (no peer fallback), no `DmCallButton`, the **Channel** timeline layout (avatar + in-bubble header + bubble — same silhouette as channels), and respect the `hideMembershipEvents` / `hideNickAvatarEvents` user settings for syslines. +- Rooms under `/channels/` always get the Channel layout regardless of member count — `channelsMode` short-circuits the 1:1 check and additionally enables channels-only filtering (thread surfacing, RTC/edit hiding). See `RoomTimeline.tsx::channelStyleLayout`. - Bridged Telegram puppet rooms automatically classify correctly because the gate is server-side authoritative. `useAutoDirectSync` (commit 84eeac9) still round-trips `m.direct` on join — **interop only**, so other Matrix clients (Element, FluffyChat) still categorize the same room as a DM. Vojo no longer reads `m.direct` for UI classification; the `mDirectAtom` is kept alive for `useDirectRooms` ordering and other read-only consumers but its truth is no longer load-bearing for the layout. @@ -81,12 +82,12 @@ Use `useIsOneOnOne()` from `hooks/useRoom.ts` whenever you need the 1:1 vs group | Dir | Purpose | |-----|---------| | `room/` | Core room view — **RoomTimeline.tsx** (~1700 LOC after P3c collapse), **RoomInput.tsx** (~691 LOC), **RoomViewHeader.tsx** (thin wrapper after P4) → **RoomViewHeaderDm.tsx** (Dawn header for every room class; 1:1 chrome via avatar fallback + peer-profile-sheet, group chrome via `N members` line; phone button three-gated per §6.8b; search/pinned/invite/leave moved into the `…` menu), MembersDrawer (suppressed for 1:1 in `Room.tsx`), MessageEditor, RoomTombstone, RoomViewTyping, CallChatView, CommandAutocomplete | -| `room/message/` | `Message.tsx` (~1170 LOC after P3c) — renders Stream layout unconditionally for every room (1:1 DM, group DM, non-DM, bridged). No layout switch, no `isStream` gate. Renders edit/delete/react menu, mention/hashtag links, reactions viewer. The legacy `Compact`/`Bubble` layouts and `MessageLayout` enum are gone; `Modern.tsx` survives only as a card-preview layout for pin-menu / message-search / inbox. | +| `room/message/` | `Message.tsx` (~1170 LOC after P3c) — branches between **Stream** (1:1 rooms) and **Channel** (groups + channels) layouts on the `layout` prop driven by `RoomTimeline.tsx::channelStyleLayout`. Renders edit/delete/react menu, mention/hashtag links, reactions viewer. The legacy `Compact`/`Bubble` layouts and `MessageLayout` enum are gone; `Modern.tsx` survives only as a card-preview layout for pin-menu / message-search / inbox. | | `room-nav/` | `RoomNavItem.tsx` (~435 LOC) — list-row component, used by Home/Direct/Spaces. Carries call-room behaviour (`useCallSession`, `useCallMembers`, `useCallStart`) | | `room-settings/` | Room-specific settings page | | `common-settings/` | Shared settings: general, members, permissions, emojis-stickers, developer-tools | | `space-settings/` | Space-specific settings | -| `settings/` | User settings (general, account, notifications, devices, emojis, about, dev-tools). `MessageLayout` / `messageSpacing` / `legacyUsernameColor` were removed in P3c — Stream is now the only layout, and user-settings cleanup migration drops orphan persisted fields on first load. `hideMembershipEvents` / `hideNickAvatarEvents` survive — they still gate the group-room syslines. **Logout lives here only.** | +| `settings/` | User settings (general, account, notifications, devices, emojis, about, dev-tools). `MessageLayout` / `messageSpacing` / `legacyUsernameColor` were removed in P3c — layout is no longer user-configurable (Stream/Channel pick automatically on member count), and the cleanup migration drops orphan persisted fields on first load. `hideMembershipEvents` / `hideNickAvatarEvents` survive — they still gate the group-room syslines. **Logout lives here only.** | | `lobby/` | Space/room lobby view | | `search/` | Global search | | `message-search/` | In-room message search | @@ -120,7 +121,6 @@ Use `useIsOneOnOne()` from `hooks/useRoom.ts` whenever you need the 1:1 vs group - `setting-tile/` — Settings list item pattern - `sequence-card/`, `cutout-card/` — Card layouts - `uia-stages/` — User-interactive auth stages (email, captcha, token) -- `room-intro/` — Room introduction card - `invite-user-prompt/`, `join-address-prompt/`, `leave-room-prompt/` — Dialogs - `BackRouteHandler.tsx` — Web back-button → back-stack collapse via `replace` (commit dce6be9) @@ -162,10 +162,69 @@ Some atoms persist to localStorage (e.g. `settings.ts`, `navToActivePath.ts`), o Stock Cinny had multiple themes; vojo simplified to System / Light / Dark (commit 00935ae). -- `src/colors.css.ts` defines **only** `darkTheme` via `createTheme(color, darkThemeData)`. There is no separate light-theme override — light = stock `folds.lightTheme` imported as-is. -- `src/app/hooks/useTheme.ts` selects `LightTheme` or `DarkTheme` based on `useSystemTheme` + `themeId` settings. Class-name-based application — **runtime theme switch requires page reload** (vanilla-extract is compile-time). +- `src/colors.css.ts` defines both `darkTheme` (Dawn palette) and `lightTheme` (Vojo light palette) via `createTheme(color, …)`. The folds default `lightTheme` is no longer imported — both Vojo themes own their full token table. +- `src/app/hooks/useTheme.ts` selects `LightTheme` or `DarkTheme` based on `useSystemTheme` + `themeId` settings. Class-name-based application — `ThemeManager` swaps the body class on `useActiveTheme` change, so runtime switching is live (no reload needed, but vanilla-extract still requires a rebuild to change the token tables themselves). - Folds tokens (`color.*`, `config.space`, `config.radii`, `config.borderWidth`) are read-only inside folds compiled CSS. Re-skinning colours via `createTheme()` works; re-skinning radii or spacing requires CSS overrides outside folds. -- Brand accent in v4.11.x: `Primary.Main = #BDB6EC` (lavender) — referenced in unread-badge, focus-ring, NavLink active state, MessageBase highlight keyframe. +- Brand accent: dark `Primary.Main = #9580ff` (Dawn lavender), light `Primary.Main = #5b6aff` (indigo) — referenced in unread-badge, focus-ring, NavLink active state, MessageBase highlight keyframe. +- The default theme picker (Settings → General → Appearance) offers System / Light / Dark. The `dawn-redesign-v1` one-shot migration in `state/settings.ts` pins **existing** users (with a stored settings JSON) to dark on first load post-migration; brand-new users skip the migration and keep `useSystemTheme: true` so they follow the OS preference out of the box. + +### Known follow-ups for light theme + +The web theme switch is wired end-to-end (palette, picker, runtime body-class swap, mxid colours, prism syntax highlighting, `--vojo-safe-area-bg`, cold-start `prefers-color-scheme` fallback in `src/index.css`, dual `` in `index.html`). Native and PWA chrome are NOT yet bound to the active theme — track these as separate tasks: + +- **Android system bars** — `MainActivity.java::onCreate` hardcodes `controller.setAppearanceLight{Status,Navigation}Bars(false)`. On light theme the icons are white over a light bar → invisible. Fix is a small JS↔Java bridge (custom Capacitor plugin, or `@capacitor/status-bar` for status-bar tint + custom plugin for nav-bar) driven from `ThemeManager`'s `useEffect`. +- **Android native splash** — `android/app/src/main/res/values/colors.xml::splash_bg = #0d0e11` and `styles.xml::windowBackground` are dark. Light users see a dark splash → fade to white. Add `values-night/` variants or read the stored `themeId` from a SharedPreferences shim before paint. +- **Capacitor WebView paint color** — `capacitor.config.ts::backgroundColor = '#0d0e11'` (mirrored in the built `capacitor.config.json`). Set at WebView init, cannot be re-themed at runtime via JS — needs the splash-fix above to land first. +- **PWA manifest** — `public/manifest.json` `theme_color`/`background_color` are pinned to dark (`#0d0e11`). Manifest format does not support media queries, so the choice is one default; we keep dark because the migration also pins existing users to dark. +- **AuthLayout** — `src/app/pages/auth/styles.css.ts` hardcodes dark backgrounds (`#0d0e11` etc.) for the bistable auth scaffold (see `bugs.md` for why the auth layout cannot be naively re-skinned). Light-theme users see a dark login/register/reset-password screen. Tied to the auth bistable-layout refactor. +- **Bot widgets** — `BotShell.css.ts`, `BotWidgetMount.css.ts`, `BotCard.tsx` hardcode `#9580ff` / `#7ab6d9` / `#0c0c0e` accent + ink colors. Each bot widget is a separate Preact app so it doesn't share Vojo's folds tokens — needs its own theme passing through `apps/widget-*` or a CSS-var bridge from the parent. + +The horseshoe void seam reshades via the `--vojo-horseshoe-void` CSS variable: dark `#090909` (deep void against `#0d0e11` panel) and light `#d6d6e3` (soft lavender-grey against `#f2f2f7` panel). See `src/app/styles/horseshoe.ts` + `src/index.css`. + +## Composer card geometry + +Load-bearing pixel values for the main chat composer + thread-drawer composer (both wrap `RoomInput` with the `ChatComposer` class). The composer is a floating rounded card with **32px corner radius** (`VOJO_HORSESHOE_RADIUS_PX`); all paddings are tuned so the visible glyphs (text, IconButton icons) stay outside the curve clip. Source of truth: [`src/app/features/room/RoomView.css.ts`](../../src/app/features/room/RoomView.css.ts), [`src/app/features/room/RoomInput.tsx`](../../src/app/features/room/RoomInput.tsx) (action-row padding). + +| Element | Value | Where | +|---|---|---| +| Card corner radius | 32px | `VOJO_HORSESHOE_RADIUS_PX` | +| Card outer padding | `6px / 16px` (vertical / horizontal) | `RoomView.css.ts` → `.ChatComposer .Editor` | +| Textarea vertical padding | 13px (folds default — do NOT override) | `Editor.css.ts` → `EditorTextarea` | +| Textarea horizontal padding | 12px left, 12px right | `RoomView.css.ts` → `:first-child` / `:last-child` rules | +| Placeholder paddingTop | 13px (folds default — must match textarea padding) | `Editor.css.ts` → `EditorPlaceholderTextVisual` | +| Action-row padding | `2px / 8px / 4px` (top / sides / bottom) | `RoomInput.tsx` `bottom` slot | +| IconButton size | 32×32 (folds `size="300"`, `fill="None"`) | `RoomInput.tsx` | +| IconButton internal padding | 4px (SVG 24×24 centered) | folds default | +| Empty-state composer height (single-line, no reply) | ~93px | derived | + +**Don't override the textarea's vertical padding (13px) without also retuning `EditorPlaceholderTextVisual.paddingTop` in lockstep**: folds tuned the pair so Slate's placeholder span and the typed-text caret land on the same y inside the contenteditable content-box. Diverging the two breaks vertical alignment — typed text and the «Send a message…» placeholder appear at different baselines. + +**Visual alignment goal** — text glyph and Plus icon-glyph sit on the same vertical column at 28px from the card edge (mirrored on the right for Send): +- `text-glyph-x = outer (16) + textarea paddingLeft (12) = 28` +- `icon-glyph-x = outer (16) + row paddingLeft (8) + button-internal-pad (4) = 28` + +**Bottom-left curve clearance** (Plus IconButton container vs the 32px corner): +- `button-bottom y = 6 (outer) + 4 (row pad-bot) = 10` +- `curve-x at y=10 = 32 − √(32² − 22²) ≈ 8.76px` +- `button-left = 16 (outer) + 8 (row pad-left) = 24` +- **clearance ≈ 15.24px** — comfortable for the hit-box; the visible glyph clears the curve by ~23px + +**Top-left curve clearance** (placeholder text glyph): +- `text-glyph-y = 6 (outer) + 13 (textarea pad-top) = 19` +- `curve-x at y=19 = 32 − √(32² − 13²) ≈ 2.76px` +- `text-glyph-x = 28` +- **clearance ≈ 25.24px** — very generous; supports multi-line growth + +**Future compactness levers** (if needed without breaking alignment): +- Outer card vertical padding (currently 6px) — drop to 4px saves 4px +- Action-row padding (currently 2/4) — drop to 0/2 saves 4px +- IconButton size (currently 300 / 32px) — already smallest in folds; no further reduction available + +Avoid touching textarea or placeholder vertical padding unless you re-tune both in matched pairs and visually verify glyph alignment. + +**Don't apply these to other composers**: the textarea-padding compact override is scoped to `.ChatComposer`. The message-edit overlay, `Editor.preview.tsx`, and any future `CustomEditor` consumer outside the chat composer keep the folds-default `padding: 13px 1px` (`Editor.css.ts:24-42`). + +If you re-tune any number here, update both the CSS comments in `RoomView.css.ts` and this table — they're cross-referenced. ## Responsive design @@ -212,7 +271,7 @@ i18next + `react-i18next`. Translations in `public/locales/{en,ru}/*.json`, orga - **matrix-js-sdk 41.4** — Matrix protocol (exact pin, see `docs/plans/matrix_js_sdk_upgrade.md` for the M0..M4 bump trail) - **folds 2.6** — UI component library - **jotai 2.6** — State management -- **vanilla-extract** — Type-safe CSS (compile-time → no runtime theme switching without reload) +- **vanilla-extract** — Type-safe CSS. Tokens are compile-time, but theme switching is live at runtime: `ThemeManager` swaps the body class on `useActiveTheme` change and every `color.*` var reshades through the cascade. Adding new tokens still requires a rebuild. - **slate 0.123** — Rich text editor - **@tanstack/react-query 5** — Data fetching - **@tanstack/react-virtual 3** — Virtual scrolling — used for **list panels** (`Direct.tsx`, space lists, etc.). Note: `RoomTimeline.tsx` does NOT use this; it uses an in-house `useVirtualPaginator` + `IntersectionObserver`. @@ -228,7 +287,7 @@ i18next + `react-i18next`. Translations in `public/locales/{en,ru}/*.json`, orga - Current vojo work branch: `vojo/dev` - Semantic-release on `dev` branch - CI: GitHub Actions (build, deploy, docker, netlify) -- **Husky pre-commit is currently disabled** — `npm run typecheck` and `npm run check:eslint` do not run automatically. `check:eslint` is green; `typecheck` still has ~32 known errors. Use `bash docs/known-tech-debt-lint/diff.sh` to check your changes don't add new typecheck errors. Re-enable husky once typecheck residual is cleared. +- **Husky pre-commit runs `tsc --noEmit` + `lint-staged` (`eslint --max-warnings 0`)** — both must be green to commit. `no-explicit-any` and `no-non-null-assertion` policy: kept as `'warn'` in `.eslintrc.cjs` but blocked by `--max-warnings 0`. When introducing one is unavoidable (matrix-js-sdk boundary, generic helper, third-party callback shape), add an inline `// eslint-disable-next-line` with a one-line justification rather than relaxing the rule. - **Android `versionCode` is monotonic** (commit 8064760, derived from commit count). Don't squash or rebase across release boundaries — Play store rejects downgrades - **Commit message style** (vojo memory): one sentence ≤25 words; no body; no Co-Authored-By trailer @@ -287,8 +346,8 @@ P3c examples missed initially: - `useDirectRooms` was used in `UserChips::MutualRoomsChip` for «split mutual DMs vs mutual rooms» — needed m.direct semantic, not universal- Direct. Mechanical rename broke the split. -- `mDirects.has(room.roomId)` in `RoomIntro`, `RoomSettings`, `RoomProfile`, - `SpaceSettings` for peer-avatar fallback — needed member-count semantic +- `mDirects.has(room.roomId)` in `RoomIntro` (since removed), `RoomSettings`, + `RoomProfile`, `SpaceSettings` for peer-avatar fallback — needed member-count semantic consistent with `RoomViewHeader`. Mechanical preservation of m.direct diverged the chrome. diff --git a/docs/ai/electron.md b/docs/ai/electron.md new file mode 100644 index 00000000..66b7302a --- /dev/null +++ b/docs/ai/electron.md @@ -0,0 +1,235 @@ +# Electron Desktop + +Vojo as a native desktop app (Windows .exe first, macOS/Linux later) via +**Electron** wrapping the same Vite `dist/` that web/Capacitor consume. + +## Why not Tauri + +Tauri 2 uses the system WebView (WebView2 on Windows). Service Worker +registration on custom schemes is **«won't fix»** per Tauri's own maintainer +([tauri#13031](https://github.com/tauri-apps/tauri/issues/13031), Aug 2025). +Vojo's SW is load-bearing for authenticated Matrix media (MSC3916). The +official Tauri workaround (`tauri-plugin-localhost`) is itself flagged in +Tauri's docs as «considerable security risks» — exposes a local HTTP port, +any process on the user's machine can hit it. Unacceptable for a Matrix +client storing E2EE keys. + +Electron bundles its own Chromium, so SW works as in Chrome after +`protocol.registerSchemesAsPrivileged({ allowServiceWorkers: true, ... })`. + +Element Desktop uses the same **privileged-scheme** mechanism but with a +different media-auth strategy: their scheme privileges set is just +`{ standard, secure, supportFetchAPI }` (no `allowServiceWorkers`), and they +inject the `Authorization` header for Matrix media via +`session.defaultSession.webRequest.onBeforeSendHeaders` — Service Workers +aren't load-bearing for them. Vojo keeps the SW because that's how the web +build authenticates media; re-implementing the auth in a main-process hook +just for desktop would diverge renderer code paths. Our privilege set is a +superset of Element's by design (also Matrix, also AGPL — still our +architectural reference for the wider Electron shell). + +## Source layout + +``` +electron/ +├── main.ts # main process — window, privileged scheme, IPC +├── preload.ts # contextBridge: window.vojoElectron API +├── tsconfig.json # CJS output, Node target — separate from src/ +└── dist-electron/ # tsc output (gitignored) + └── main.js, preload.js # generated + +src/app/utils/electron.ts # renderer-side: isElectron(), openExternalUrl(), setupExternalLinkHandler() +electron-builder.json # packaging config (NSIS for Windows) +release/ # electron-builder output (gitignored) +``` + +## Build chain + +```bash +npm run electron:typecheck # tsc --noEmit -p electron/tsconfig.json +npm run electron:build # tsc → electron/dist-electron/*.js (+ package.json override) +npm run electron:dev # vite + electron in parallel (concurrently + wait-on) +npm run electron:start # electron only — DEV mode (loads localhost:8080) +npm run electron:start:prod # electron only — PROD mode (loads vojo://, requires npm run build first) +npm run build:electron:win # native build: vite build → electron:build → electron-builder --win + # ONLY works on Windows host (or WSL with Wine installed) +npm run build:electron:win:docker # cross-build from Linux/WSL via electronuserland/builder:wine + # Docker image ~3GB on first run; output in release/ +``` + +### M1 vs M2 mode toggle + +`isDev` in [`electron/main.ts`](../../electron/main.ts) is: + +```ts +const isDev = !app.isPackaged && process.env.VOJO_ELECTRON_PROD !== '1'; +``` + +- **Packaged binary** (`.exe`/`.dmg`/`.AppImage`) → `isDev = false` always +- **Unpackaged, dev**: `electron:dev` / `electron:start` → loads `http://localhost:8080` +- **Unpackaged, prod-mode test** (`electron:start:prod`) → loads `vojo://app/index.html` + +The prod-mode env override exists so M2 (verifying the privileged scheme + service worker actually register) can be tested locally **without** running `electron-builder` for every change. The packaged binary uses the same code path. + +### Cross-building Windows .exe from Linux/WSL + +`build:electron:win:docker` runs the build inside +`electronuserland/builder:wine-mono` — the official Wine-based image. + +**`electron-builder.json::win.signAndEditExecutable = false` is required** +for this cross-build to finish. Without it, electron-builder invokes +`rcedit.exe` through Wine to stamp `FileDescription`/`ProductName`/version +metadata onto the bundled `Vojo.exe`. The Wine docker images +(`:wine`, `:wine-mono`) ship **without Xvfb**, so rcedit hangs forever +trying to create a Win32 window for COM apartment init — see +[electron-userland/electron-builder#6191](https://github.com/electron-userland/electron-builder/issues/6191). +Cost of the workaround: the `.exe` Properties dialog on Windows shows +generic «Electron 42.1.0» metadata instead of «Vojo». Cosmetic only; +the binary itself runs correctly. Revisit when CI moves to a real +Windows runner (M3 GitHub Actions), where `signAndEditExecutable` can +flip back to `true`. Three host caches are mounted in to speed up subsequent builds: + +- `~/.cache/electron` — Electron runtime download cache (~150MB) +- `~/.cache/electron-builder` — NSIS / app-update binaries +- `${PWD}` — project source (read-write, output goes to `release/`) + +This is the workflow we use locally on WSL because Wine isn't installed natively. The same artifact is produced as a native Windows build. CI (M3, future) will run `electron-builder --win` directly on `windows-latest`. + +`electron:build` writes `electron/dist-electron/package.json` with +`{"type":"commonjs"}` to override the root `"type":"module"` for the +compiled `.js` files. Required because Electron's main process loader +expects CJS unless you opt into ESM (which has separate pitfalls). + +## Custom protocol — load-bearing + +In production, the renderer is loaded from `vojo://app/` (trailing slash, +NOT `vojo://app/index.html` — that was the original choice and produced a +«Join index.html» screen because React Router parsed the `index.html` +segment as a space alias). The `vojo` scheme is registered as privileged +BEFORE `app.whenReady()` with `allowServiceWorkers: true`, `secure: true`, +`standard: true`, `supportFetchAPI: true`, `corsEnabled: true`, +`stream: true`, `codeCache: true`. This is the **one** thing that makes +the Vojo SW work in the packaged build. **Do not change +`loadURL(vojo://...)` to `loadFile(...)`** — SW will silently fail to +register. + +`protocol.handle('vojo', ...)` maps `vojo://app/` → file lookup +inside the packaged `dist/`. Two guards: + +1. **Host check**: only `vojo://app` is accepted; any other host returns + 403. Otherwise `vojo://evil/...` would resolve into a separate + (cookie/SW/IndexedDB-isolated) copy of the bundle with detached + storage — same content but split-brain state. +2. **Path-traversal guard**: `path.relative(distDir, filePath)` is checked + for `..`-prefix or absolute output — the canonical Node check. + `filePath.startsWith(distDir)` is **insufficient** (`/a/dist_evil` is a + prefix-match for `/a/dist`). + +## IPC — security stance + +- `contextIsolation: true`, `nodeIntegration: false` — Electron defaults. +- `preload.ts` exposes a minimal API: `platform` (string), `openExternal(url)`. +- `ipcMain.handle('vojo:open-external')` validates url scheme against an + allowlist (`http:`, `https:`, `mailto:`) and length (≤ 8KB) before calling + `shell.openExternal`. Don't widen the allowlist without thinking — e.g. + `file:` would let the renderer ask the OS to open arbitrary files. +- `setWindowOpenHandler` denies all `window.open()` and routes safe URLs + through `shell.openExternal`. +- `will-navigate` intercepts top-frame navigation: only `vojo://` (prod) and + `http://localhost:8080` (dev) are internal; everything else opens + externally. + +## Push notifications — different model from Android + +Android uses FCM + foreground service + Sygnal. **Electron does NOT use this +path.** Desktop model = «always running app»: matrix-js-sdk sync stays open +while the app is launched (Discord/Slack/Element Desktop pattern), and timeline +events fan out to `new Notification(...)` directly. Closed app = no +notifications. This is intentional and matches user expectations for desktop +Matrix clients. + +The existing [`usePushNotifications.ts`](../../src/app/hooks/usePushNotifications.ts) +VAPID/Web-Push flow is web/PWA-only on this branch — Electron path is to +be added in **M4** of [`docs/plans/electron_desktop.md`](../plans/electron_desktop.md). + +## Window chrome — Windows Controls Overlay + +On Windows the `BrowserWindow` uses `titleBarStyle: 'hidden'` plus +`titleBarOverlay` to remove the native title bar but **keep real OS +min/max/close buttons** (drawn by Windows itself in the top-right +corner, with their native hover/snap/accessibility behavior). The +remaining bar area takes our Dawn-dark palette (`#0d0e11` / +`#e0e0e8` symbol color) so it blends with the renderer rather than +clashing with the system accent. Same pattern as Discord, Slack, +VS Code, Element Desktop. + +macOS gets `titleBarStyle: 'hiddenInset'` (traffic lights inset +over a clean dark area). Linux keeps the system frame to avoid +breaking GTK/KWin window decorations. + +**Caveat**: the 32px overlay area floats ON TOP of the renderer's +pixels (the renderer's content extends to `y=0`). If you put critical +UI in the top-right 100px, it visually overlaps the buttons. The +buttons remain clickable regardless, but content may look obscured. +Vojo's current SidebarNav (66px wide on the left) and PageRoot +content don't have anything load-bearing in the top-right strip, so +this is fine — verify before adding new top-bar widgets there. + +**Drag region** — Electron does NOT make the hidden-titlebar area +draggable automatically. `main.ts` injects CSS via +`webContents.insertCSS` on `dom-ready` that adds a `body::before` +overlay with `-webkit-app-region: drag` sized by the Window Controls +Overlay API `env(titlebar-area-*)` variables. Body gets matching +`padding-top: env(titlebar-area-height, 32px)` so renderer content +shifts down instead of sitting under the drag strip. If new Vojo UI +ever puts elements at `top: 0` with `position: fixed`, they'll need +explicit `top: env(titlebar-area-height, 32px)` to clear the drag +region — body padding doesn't shift fixed children. + +**Theme switching**: `titleBarOverlay.color` is currently hardcoded +dark. When light theme is fully wired up (see `architecture.md` +«Known follow-ups for light theme»), call +`win.setTitleBarOverlay({color, symbolColor})` from a theme-change +IPC to keep the bar in sync. Tracked as a follow-up, not implemented +in M2. + +## Renderer-side platform detection + +[`src/app/utils/electron.ts`](../../src/app/utils/electron.ts) mirrors the +shape of [`capacitor.ts`](../../src/app/utils/capacitor.ts): `isElectron()`, +`openExternalUrl(url)`, `setupExternalLinkHandler()`. The handlers from both +files are called in [`src/index.tsx`](../../src/index.tsx) at boot — each is +a no-op on the other platform. + +Do **not** unify them into a single `platform.ts` — Capacitor's wrapper +imports `@capacitor/browser` at top level and runs in WebView even in dev; +Electron's wrapper has no peer dependency and detects via +`window.vojoElectron`. + +## Build doesn't ship Android stuff + +`electron-builder.json` `files` field includes ONLY `dist/**`, +`electron/dist-electron/**`, and `package.json`. The `android/` directory +is NOT in the asar — keeps the .exe lean and avoids accidentally shipping +keystore paths or build artifacts. + +## Known caveats + +- **`__dirname` in CJS output points to `electron/dist-electron/`** — that's + why `distDir = path.resolve(__dirname, '..', '..', 'dist')` walks up two + levels. If you change `outDir` in `electron/tsconfig.json`, retune this + path. +- **`sandbox: true` is on.** Preload uses only `contextBridge` and + `ipcRenderer`, both of which are sandbox-compatible per + [Electron sandbox docs](https://www.electronjs.org/docs/latest/tutorial/sandbox). + Don't add `fs`/`path`/`child_process` to preload — that requires + `sandbox: false` and weakens isolation. If you ever do, document why. +- **No code-signing in M0..M3.** SmartScreen on Windows shows «Windows + protected your PC» dialog; user clicks «More info → Run anyway». Drop-off + exists but acceptable for alpha. **OV cert (~$65/yr) does NOT bypass + SmartScreen instantly** — reputation accrues over ~thousands of installs + (weeks–months). **EV cert (~$250-400/yr + hardware token / cloud HSM) + gives instant SmartScreen pass.** Plan accordingly: OV is a trap for + app-launch UX; either accept unsigned + click-through, or budget EV. +- **No auto-updater in M0..M3.** `electron-updater` requires a signed + build for differential updates; revisit when signing is in place. diff --git a/docs/known-tech-debt-lint/README.md b/docs/known-tech-debt-lint/README.md deleted file mode 100644 index 30ab6199..00000000 --- a/docs/known-tech-debt-lint/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# Известный техдолг по линтеру - -Эта папка фиксирует **известное состояние** `npm run typecheck` в репозитории. Build при этом зелёный, prod задеплоен — это исторический технический долг, не блокер. Папка нужна чтобы при любых изменениях кода сравнивать **delta** (только то что мы добавили), не путаясь в предсуществующих ошибках. - -После апгрейда TypeScript 4.9 → 5.4 + `moduleResolution: "Bundler"` (см. историю коммита) основная масса (~803 из 835 предыдущих ошибок) исчезла. Осталось ~32 ошибки уже про реальные баги/несоответствия типов в нашем коде, не про модульное резолвинг. `npm run check:eslint` теперь — обычный зелёный чек (0 ошибок, 116 warnings), отдельный snapshot не нужен. - -## Состав - -| Файл | Что | -|---|---| -| `typecheck.snapshot.txt` | Полный stdout `npm run typecheck`. **~61 строка, ~32 ошибки.** | -| `diff.sh` | Скрипт сравнения: запускает текущий typecheck, сравнивает с snapshot-ом, выдаёт **только delta**. | - -## Как пользоваться - -```bash -bash docs/known-tech-debt-lint/diff.sh -``` - -На чистой ветке выводит: -``` -=== typecheck diff vs known-tech-debt snapshot === - no new typecheck errors -``` - -Если что-то сломал — выводит конкретные новые ошибки в формате `file.tsx(_,_): error TS...` (line/col маска чтобы pure-line-shift не давал phantom NEW + fixed). Реальные позиции — `npm run typecheck` напрямую. Если случайно починил предсуществующий долг — отчитается «(incidentally fixed: N)» к сведению. - -Скрипт смотрит **working tree**, не staged-состояние. Для строгого pre-commit gate сначала apply'нуть свой stage в чистый worktree (`git stash --keep-index` + `bash diff.sh` + `git stash pop`). - -`npm run check:eslint` запускайте напрямую — он зелёный. - -## Что в долге (TL;DR) - -**Typecheck (~32 ошибок):** реальные несоответствия типов. Категории: - -- TS2345 keyof literal-union mismatch (~14): `mx.getAccountData(string)` / `mx.getStateEvent(...)` ждёт `keyof AccountDataEvents` (узкие литеральные типы), у нас передаются `AccountDataEvent.PoniesEmoteRooms`, `'m.call.member'`, `'in.cinny.spaces'` и т.п. — валидные Matrix event-types, но не в SDK-юнионе. -- TS2345 i18next signature (~3): `t('Room.members_count', { count: millify(...) })` — `count` хочет `number`, а `millify()` возвращает `string`. На рантайме отображается корректно (в локалях нет plural-вариантов). -- TS2345 / TS18048 `Room | undefined` / `Room | null` после `.filter((r) => !!r)` (~6): TS не пропускает truthy-фильтр без type predicate. UserChips.tsx, AddExisting.tsx, Invites.tsx, GlobalPacks.tsx. Runtime безопасно. -- TS2345 `IContent` → `RoomMessageEventContent` (1): MessageEditor.tsx — typing gap между общим content и room-message variant. -- TS7006 implicit `any` (6): event-handler params (`evt`, `event`, `ev`) в Message.tsx, EventReaders.tsx, UrlPreviewCard.tsx, LiveChip.tsx, MemberGlance.tsx, ReactionViewer.tsx. -- TS2540 read-only `sandbox` (1): CallEmbed.ts — `iframe.sandbox = "..."`. Современные DOM types сделали его `DOMTokenList` read-only, но браузеры всё ещё принимают строку. -- TS2353 unknown property `endpoint` (1): push.ts — лишнее поле в `setPusher.data`. SDK типы неполные, sygnal/UnifiedPush его читает. -- TS2322 `(number | undefined)[]` → `number[]` (1): usePowerLevelTags.ts — то же truthy-filter narrowing. - -Build зелёный, ESLint зелёный. Все 32 оставшихся ошибки — type-strictness без runtime-импакта (truthy-filter narrowing, узкие SDK literal-union'ы, под-типированные event-handler params, слишком строгий DOM types). Это **известный долг**, не блокер. Будущая чистка — отдельный план (создать `docs/plans/typecheck_residual_cleanup.md` когда возьмёмся). - -## Когда обновлять snapshot - -Когда долг будет частично разруливаться отдельной задачей — после её мерджа пересоздать snapshot: - -```bash -npm run typecheck > docs/known-tech-debt-lint/typecheck.snapshot.txt 2>&1 -``` - -И обновить TL;DR в этом README. - -Когда typecheck станет зелёным — удалить эту папку целиком и включить husky pre-commit hook. diff --git a/docs/known-tech-debt-lint/diff.sh b/docs/known-tech-debt-lint/diff.sh deleted file mode 100755 index fa8383bc..00000000 --- a/docs/known-tech-debt-lint/diff.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env bash -# Compare current `npm run typecheck` output to the known tech-debt snapshot. -# Emits ONLY new errors introduced relative to the snapshot — does not dump -# the full output, so agents can read the result without burning context. -# -# Comparison is line/col-insensitive: each error's `(L,C):` location is masked -# to `(_,_):` before sorting + comm, so a pure line shift (e.g. one added/ -# removed line above the error) doesn't trigger a phantom NEW + fixed pair. -# Re-run `npm run typecheck` to see real positions for any errors flagged here. -# -# Caveat: this checks the working tree, not the staged worktree. If you stage -# a fix but leave it unstaged, or vice versa, the diff reports the working-tree -# state. For a strict pre-commit gate, run from a clean stash-apply state. -# -# `npm run check:eslint` is now a normal green check (0 errors); no snapshot -# needed there. Run it directly if you want to see warnings. -# -# Usage: -# bash docs/known-tech-debt-lint/diff.sh - -set -u - -ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -BASELINE_DIR="$ROOT/docs/known-tech-debt-lint" -TC_BASE="$BASELINE_DIR/typecheck.snapshot.txt" - -# Temp files registered for cleanup on any exit path (success, error, ^C). -TMP_FILES=() -cleanup() { [ "${#TMP_FILES[@]}" -gt 0 ] && rm -f "${TMP_FILES[@]}"; } -trap cleanup EXIT INT TERM - -# Tracks whether any new errors were introduced. Set to 1 by run_typecheck_diff -# when delta > 0; script exits with this code at end so CI / runbooks can use -# the script as a gate (e.g. `bash diff.sh && echo OK`). -NEW_ERRORS=0 - -run_typecheck_diff() { - echo "=== typecheck diff vs known-tech-debt snapshot ===" - local now tc_rc - now="$(mktemp)" - TMP_FILES+=("$now") - ( cd "$ROOT" && npm run typecheck ) >"$now" 2>&1 - tc_rc=$? - # Sanity-check: distinguish "tsc ran and reported errors" (rc=2, expected on - # this baseline) from "tsc/npm/node failed to run at all" (rc=other, broken - # toolchain). Without this guard a rc=127 "tsc: not found" or rc=1 npm error - # could produce stdout with no "error TS" lines and the diff would falsely - # report "no new errors" / "incidentally fixed: 33". - if [ "$tc_rc" -ne 0 ] && [ "$tc_rc" -ne 2 ] && ! grep -q "error TS" "$now"; then - echo " ERROR: 'npm run typecheck' did not run cleanly (exit=$tc_rc):" - sed 's/^/ /' "$now" - NEW_ERRORS=2 - return - fi - # Mask `(line,col):` so a pure line shift doesn't change an error's identity. - # We compare on the masked form; for NEW lines we display the masked form (the - # real position is reproducible by running `npm run typecheck` directly). - # `sort` (NOT `sort -u`): we want to preserve cardinality so that two identical - # masked errors in the same file aren't collapsed to one — a regression that - # adds a duplicate error would otherwise be hidden by the first occurrence. - local mask_re='s/\([0-9]+,[0-9]+\):/(_,_):/' - local now_masked base_masked - now_masked="$(mktemp)" - base_masked="$(mktemp)" - TMP_FILES+=("$now_masked" "$base_masked") - sed -E "$mask_re" "$now" | grep -E "error TS" | sort > "$now_masked" - sed -E "$mask_re" "$TC_BASE" | grep -E "error TS" | sort > "$base_masked" - - local new - new="$(comm -23 "$now_masked" "$base_masked")" - if [ -z "$new" ]; then - echo " no new typecheck errors" - else - local count - count="$(printf '%s\n' "$new" | wc -l)" - echo " NEW errors: $count (line/col masked — run \`npm run typecheck\` for real positions)" - printf '%s\n' "$new" | sed 's/^/ /' - NEW_ERRORS=1 - fi - local fixed - fixed="$(comm -13 "$now_masked" "$base_masked")" - if [ -n "$fixed" ]; then - local fcount - fcount="$(printf '%s\n' "$fixed" | wc -l)" - echo " (incidentally fixed: $fcount)" - fi -} - -run_typecheck_diff - -exit "$NEW_ERRORS" diff --git a/docs/known-tech-debt-lint/typecheck.snapshot.txt b/docs/known-tech-debt-lint/typecheck.snapshot.txt deleted file mode 100644 index 2b2a66ea..00000000 --- a/docs/known-tech-debt-lint/typecheck.snapshot.txt +++ /dev/null @@ -1,54 +0,0 @@ - -> vojo@4.11.1 typecheck -> tsc --noEmit - -src/app/components/event-readers/EventReaders.tsx(82,31): error TS7006: Parameter 'event' implicitly has an 'any' type. -src/app/components/image-pack-view/RoomImagePack.tsx(47,9): error TS2345: Argument of type 'StateEvent.PoniesRoomEmotes' is not assignable to parameter of type 'keyof StateEvents'. -src/app/components/image-pack-view/UserImagePack.tsx(16,31): error TS2345: Argument of type 'AccountDataEvent.PoniesUserEmotes' is not assignable to parameter of type 'keyof AccountDataEvents'. -src/app/components/room-card/RoomCard.tsx(259,18): error TS2345: Argument of type '["Explore.members_count", { count: string; }]' is not assignable to parameter of type '[key: string | string[], options: TOptionsBase & $Dictionary & { defaultValue: string; }] | [key: string | string[], defaultValue: string, options?: (TOptionsBase & $Dictionary) | undefined] | [key: ...]'. - Type '["Explore.members_count", { count: string; }]' is not assignable to type '[key: "Explore.members_count" | "Explore.members_count"[], options?: (TOptionsBase & $Dictionary) | undefined]'. - Type at position 1 in source is not compatible with type at position 1 in target. - Type '{ count: string; }' is not assignable to type 'TOptionsBase & $Dictionary'. - Type '{ count: string; }' is not assignable to type 'TOptionsBase'. - Types of property 'count' are incompatible. - Type 'string' is not assignable to type 'number'. -src/app/components/url-preview/UrlPreviewCard.tsx(57,27): error TS7006: Parameter 'evt' implicitly has an 'any' type. -src/app/components/user-profile/UserChips.tsx(271,13): error TS18048: 'room' is possibly 'undefined'. -src/app/components/user-profile/UserChips.tsx(272,28): error TS2345: Argument of type 'Room | undefined' is not assignable to parameter of type 'Room'. - Type 'undefined' is not assignable to type 'Room'. -src/app/components/user-profile/UserChips.tsx(275,26): error TS18048: 'room' is possibly 'undefined'. -src/app/components/user-profile/UserChips.tsx(276,29): error TS2345: Argument of type 'Room | undefined' is not assignable to parameter of type 'Room'. - Type 'undefined' is not assignable to type 'Room'. -src/app/components/user-profile/UserChips.tsx(279,25): error TS2345: Argument of type 'Room | undefined' is not assignable to parameter of type 'Room'. - Type 'undefined' is not assignable to type 'Room'. -src/app/features/add-existing/AddExisting.tsx(168,18): error TS2345: Argument of type '(Room | undefined)[]' is not assignable to parameter of type 'Room[]'. - Type 'Room | undefined' is not assignable to type 'Room'. - Type 'undefined' is not assignable to type 'Room'. -src/app/features/call-status/LiveChip.tsx(90,35): error TS7006: Parameter 'evt' implicitly has an 'any' type. -src/app/features/call-status/MemberGlance.tsx(49,23): error TS7006: Parameter 'evt' implicitly has an 'any' type. -src/app/features/common-settings/general/RoomJoinRules.tsx(92,52): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'. - Type 'undefined' is not assignable to type 'string'. -src/app/features/room/message/Message.tsx(860,31): error TS7006: Parameter 'ev' implicitly has an 'any' type. -src/app/features/room/message/MessageEditor.tsx(156,39): error TS2345: Argument of type 'IContent' is not assignable to parameter of type 'RoomMessageEventContent'. - Type 'IContent' is not assignable to type 'BaseTimelineEvent & Without<(Without & NoRelationEvent) | (Without & ReplyEvent), (Without<...> & RelationEvent) | (Without<...> & ReplacementEvent<...>)> & Without<...> & ReplacementEvent<...> & FileContent'. - Property '"body"' is missing in type 'IContent' but required in type 'BaseTimelineEvent'. -src/app/features/room/reaction-viewer/ReactionViewer.tsx(135,33): error TS7006: Parameter 'event' implicitly has an 'any' type. -src/app/features/settings/developer-tools/DevelopTools.tsx(30,31): error TS2345: Argument of type 'string' is not assignable to parameter of type 'keyof AccountDataEvents'. -src/app/features/settings/developer-tools/DevelopTools.tsx(39,54): error TS2345: Argument of type 'string' is not assignable to parameter of type 'keyof AccountDataEvents'. -src/app/features/settings/emojis-stickers/GlobalPacks.tsx(161,44): error TS2345: Argument of type '(PackAddress | undefined)[]' is not assignable to parameter of type 'PackAddress[]'. - Type 'PackAddress | undefined' is not assignable to type 'PackAddress'. - Type 'undefined' is not assignable to type 'PackAddress'. -src/app/features/settings/emojis-stickers/GlobalPacks.tsx(164,39): error TS2345: Argument of type '(PackAddress | undefined)[]' is not assignable to parameter of type 'PackAddress[]'. -src/app/features/settings/emojis-stickers/GlobalPacks.tsx(311,27): error TS2345: Argument of type 'AccountDataEvent.PoniesEmoteRooms' is not assignable to parameter of type 'keyof AccountDataEvents'. -src/app/features/settings/emojis-stickers/GlobalPacks.tsx(328,31): error TS2345: Argument of type 'AccountDataEvent.PoniesEmoteRooms' is not assignable to parameter of type 'keyof AccountDataEvents'. -src/app/hooks/useAccountData.ts(7,62): error TS2345: Argument of type 'string' is not assignable to parameter of type 'keyof AccountDataEvents'. -src/app/hooks/usePowerLevelTags.ts(14,9): error TS2322: Type '(number | undefined)[]' is not assignable to type 'number[]'. - Type 'number | undefined' is not assignable to type 'number'. - Type 'undefined' is not assignable to type 'number'. -src/app/pages/client/inbox/Invites.tsx(722,45): error TS2345: Argument of type 'Room | null' is not assignable to parameter of type 'Room'. - Type 'null' is not assignable to type 'Room'. -src/app/pages/client/sidebar/SpaceTabs.tsx(750,27): error TS2345: Argument of type 'AccountDataEvent.VojoSpaces' is not assignable to parameter of type 'keyof AccountDataEvents'. -src/app/pages/client/sidebar/SpaceTabs.tsx(797,25): error TS2345: Argument of type 'AccountDataEvent.VojoSpaces' is not assignable to parameter of type 'keyof AccountDataEvents'. -src/app/plugins/call/CallEmbed.ts(129,12): error TS2540: Cannot assign to 'sandbox' because it is a read-only property. -src/app/plugins/recent-emoji.ts(45,21): error TS2345: Argument of type 'AccountDataEvent.ElementRecentEmoji' is not assignable to parameter of type 'keyof AccountDataEvents'. -src/app/utils/push.ts(160,9): error TS2353: Object literal may only specify known properties, and 'endpoint' does not exist in type '{ format?: string | undefined; url?: string | undefined; brand?: string | undefined; }'. diff --git a/electron-builder.json b/electron-builder.json new file mode 100644 index 00000000..e0f41849 --- /dev/null +++ b/electron-builder.json @@ -0,0 +1,25 @@ +{ + "appId": "chat.vojo.desktop", + "productName": "Vojo", + "asar": true, + "directories": { + "output": "release" + }, + "files": ["dist/**/*", "electron/dist-electron/**/*", "package.json"], + "extraMetadata": { + "main": "electron/dist-electron/main.js" + }, + "win": { + "target": ["zip"], + "artifactName": "Vojo-${version}-win-${arch}.${ext}", + "signAndEditExecutable": false + }, + "mac": { + "target": ["dmg"], + "category": "public.app-category.social-networking" + }, + "linux": { + "target": ["AppImage", "deb"], + "category": "Network" + } +} diff --git a/electron/main.ts b/electron/main.ts new file mode 100644 index 00000000..1f8e0e43 --- /dev/null +++ b/electron/main.ts @@ -0,0 +1,264 @@ +import { app, BrowserWindow, protocol, net, shell, ipcMain } from 'electron'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { existsSync, promises as fsp } from 'node:fs'; + +// Dev-mode loads from Vite dev-server (http://localhost:8080) so HMR works. +// Prod-mode loads from in-process custom scheme `vojo://app/index.html`. +// `VOJO_ELECTRON_PROD=1` forces prod-mode in an un-packaged binary so the +// scheme + service-worker path can be validated without re-running +// electron-builder. The packaged app sets `app.isPackaged === true`. +const isDev = !app.isPackaged && process.env.VOJO_ELECTRON_PROD !== '1'; +const DEV_URL = 'http://localhost:8080'; +const APP_SCHEME = 'vojo'; +const APP_HOST = 'app'; + +// Extensions that look like real web assets; for these, a missing file is a +// genuine 404. Anything else (including Matrix-flavoured `roomId/userId` +// segments like `!foo:vojo.chat` whose `path.extname` returns `.chat`) is +// treated as a SPA route and falls back to `index.html`. The allowlist is +// intentionally narrow — extend only when adding a new bundled asset kind. +const WEB_ASSET_EXTENSIONS = new Set([ + '.js', + '.mjs', + '.cjs', + '.css', + '.html', + '.htm', + '.map', + '.json', + '.txt', + '.xml', + '.svg', + '.ico', + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.avif', + '.woff', + '.woff2', + '.ttf', + '.otf', + '.wasm', +]); + +protocol.registerSchemesAsPrivileged([ + { + scheme: APP_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + allowServiceWorkers: true, + corsEnabled: true, + stream: true, + codeCache: true, + }, + }, +]); + +const ALLOWED_EXTERNAL_SCHEMES = new Set(['http:', 'https:', 'mailto:']); + +const isSafeExternal = (raw: unknown): raw is string => { + if (typeof raw !== 'string' || raw.length > 8 * 1024) return false; + try { + return ALLOWED_EXTERNAL_SCHEMES.has(new URL(raw).protocol); + } catch { + return false; + } +}; + +const distDir = path.resolve(__dirname, '..', '..', 'dist'); + +// React Router defaults to BrowserRouter against `window.location.pathname`, +// which for `vojo://app/...` would treat URL segments as routes (e.g. +// `vojo://app/index.html` resolved as a space alias `index.html`). Vojo +// already supports HashRouter via `clientConfig.hashRouter.enabled` in +// `config.json` — we override that to `true` for the Electron renderer so +// every route lives in `window.location.hash`, leaving the pathname stable +// for the protocol handler. The web/Android bundles see the unmodified +// config (hash router off). +const patchConfigForElectron = (raw: string): string => { + try { + const config: Record = JSON.parse(raw); + const existing = (config.hashRouter as { basename?: string } | undefined) ?? {}; + config.hashRouter = { + enabled: true, + basename: typeof existing.basename === 'string' ? existing.basename : '/', + }; + return JSON.stringify(config); + } catch { + return raw; + } +}; + +const registerAppProtocol = () => { + protocol.handle(APP_SCHEME, async (request) => { + let url: URL; + try { + url = new URL(request.url); + } catch { + return new Response(null, { status: 400 }); + } + // Reject foreign origins under the same scheme. `protocol.handle` does + // not validate the URL host, so without this check a renderer-side + // `window.location = 'vojo://evil/...'` would resolve into a separate + // (cookie/SW/IndexedDB-isolated) copy of the bundle — same content but + // detached storage. Only `vojo://app` is accepted. + if (url.hostname !== APP_HOST) { + return new Response(null, { status: 403 }); + } + const rel = (url.pathname || '/').replace(/^\/+/, '') || 'index.html'; + let filePath = path.normalize(path.join(distDir, rel)); + + // Path-traversal guard. `filePath.startsWith(distDir)` is unsafe — a + // sibling directory `/a/dist_evil` passes the prefix check against + // `/a/dist`. `path.relative` yields `..`-leading or absolute output + // for paths escaping `distDir`, which is the canonical Node check. + let relFromDist = path.relative(distDir, filePath); + if (relFromDist.startsWith('..') || path.isAbsolute(relFromDist)) { + return new Response(null, { status: 403 }); + } + + // SPA fallback for paths without a real file on disk. Under HashRouter + // (the Electron default — see `patchConfigForElectron`) reloads arrive + // with pathname `/`, so this rarely fires; kept as a safety net for + // manual URL edits and if someone ever reverts the HashRouter patch. + // We MUST NOT gate via `path.extname() !== ''` — Matrix room/user IDs + // like `!foo:vojo.chat` parse as having extension `.chat` and would + // wrongly 404. Use a narrow allowlist of real web-asset extensions. + if (!existsSync(filePath)) { + const ext = path.extname(rel).toLowerCase(); + if (ext !== '' && WEB_ASSET_EXTENSIONS.has(ext)) { + return new Response('Not Found', { status: 404 }); + } + filePath = path.join(distDir, 'index.html'); + relFromDist = 'index.html'; + } + + // Override top-level `config.json` to force HashRouter on in Electron. + // Exact-match via `path.relative` is Windows-safe (case + separator + // normalization), unlike `path.dirname(filePath) === distDir`. + if (relFromDist === 'config.json') { + const raw = await fsp.readFile(filePath, 'utf-8'); + return new Response(patchConfigForElectron(raw), { + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }); + } + + return net.fetch(pathToFileURL(filePath).toString()); + }); +}; + +const createWindow = async () => { + const win = new BrowserWindow({ + width: 1280, + height: 800, + minWidth: 640, + minHeight: 480, + backgroundColor: '#0d0e11', + autoHideMenuBar: true, + // Native-looking chrome: Windows draws real min/max/close buttons via + // Window Controls Overlay, the rest of the bar matches our Dawn palette. + // Same pattern as Discord/Slack/VS Code/Element Desktop. On macOS + // `hiddenInset` keeps traffic lights but inset over a clean dark area. + // Linux keeps the system frame to avoid breaking GTK-decoration UX. + ...(process.platform === 'win32' && { + titleBarStyle: 'hidden' as const, + titleBarOverlay: { + color: '#0d0e11', + symbolColor: '#e0e0e8', + height: 32, + }, + }), + ...(process.platform === 'darwin' && { + titleBarStyle: 'hiddenInset' as const, + }), + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + // sandbox: true is safe here — preload uses only `contextBridge` and + // `ipcRenderer`, both of which are exposed inside the Electron sandbox + // per https://www.electronjs.org/docs/latest/tutorial/sandbox. + sandbox: true, + }, + }); + + // With `titleBarStyle: 'hidden'` Windows draws the min/max/close buttons + // but the rest of the bar is the renderer's pixel area — and Electron does + // NOT mark it draggable automatically. Inject a CSS drag-region overlay + // sized by the Window Controls Overlay API env() variables (the canonical + // way Microsoft / Chromium expose the safe drag area excluding the OS + // button strip). Body gets matching padding-top so content shifts down + // by 32px instead of sitting under the drag region. + if (process.platform === 'win32') { + win.webContents.on('dom-ready', () => { + win.webContents.insertCSS(` + body { padding-top: env(titlebar-area-height, 32px) !important; } + body::before { + content: ""; + position: fixed; + top: env(titlebar-area-y, 0); + left: env(titlebar-area-x, 0); + width: env(titlebar-area-width, calc(100vw - 138px)); + height: env(titlebar-area-height, 32px); + -webkit-app-region: drag; + z-index: 2147483647; + } + `); + }); + } + + win.webContents.setWindowOpenHandler(({ url }) => { + if (isSafeExternal(url)) shell.openExternal(url); + return { action: 'deny' }; + }); + + win.webContents.on('will-navigate', (event, url) => { + let target: URL; + try { + target = new URL(url); + } catch { + event.preventDefault(); + return; + } + // Strict match for the in-app origin. `target.protocol === 'vojo:'` alone + // would treat `vojo://evil/...` as internal even though the protocol + // handler now rejects it; preventing navigation upstream avoids the + // round-trip and keeps the renderer pinned to the canonical origin. + const isInternal = + (target.protocol === `${APP_SCHEME}:` && target.hostname === APP_HOST) || + (isDev && target.origin === DEV_URL); + if (isInternal) return; + event.preventDefault(); + if (isSafeExternal(url)) shell.openExternal(url); + }); + + if (isDev) { + await win.loadURL(DEV_URL); + win.webContents.openDevTools({ mode: 'detach' }); + } else { + await win.loadURL(`${APP_SCHEME}://${APP_HOST}/`); + } +}; + +app.whenReady().then(() => { + if (!isDev) registerAppProtocol(); + + ipcMain.handle('vojo:open-external', async (_event, url: unknown) => { + if (isSafeExternal(url)) await shell.openExternal(url); + }); + + createWindow(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit(); +}); diff --git a/electron/preload.ts b/electron/preload.ts new file mode 100644 index 00000000..e35b8035 --- /dev/null +++ b/electron/preload.ts @@ -0,0 +1,6 @@ +import { contextBridge, ipcRenderer } from 'electron'; + +contextBridge.exposeInMainWorld('vojoElectron', { + platform: process.platform, + openExternal: (url: string): Promise => ipcRenderer.invoke('vojo:open-external', url), +}); diff --git a/electron/tsconfig.json b/electron/tsconfig.json new file mode 100644 index 00000000..95e414e3 --- /dev/null +++ b/electron/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "lib": ["ES2022", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist-electron", + "rootDir": ".", + "resolveJsonModule": true, + "isolatedModules": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["*.ts"] +} diff --git a/index.html b/index.html index e0d86df3..5e32702d 100644 --- a/index.html +++ b/index.html @@ -23,7 +23,8 @@ property="og:description" content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source." /> - + + diff --git a/package-lock.json b/package-lock.json index 3e11c1fb..8aa6a34c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vojo", - "version": "4.11.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vojo", - "version": "4.11.1", + "version": "0.2.0", "license": "AGPL-3.0-only", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "1.1.6", @@ -34,7 +34,6 @@ "browser-encrypt-attachment": "0.3.0", "chroma-js": "3.1.2", "classnames": "2.3.2", - "dateformat": "5.0.3", "dayjs": "1.11.10", "domhandler": "5.0.3", "emojibase": "15.3.1", @@ -93,7 +92,11 @@ "@typescript-eslint/parser": "7.18.0", "@vitejs/plugin-react": "4.2.0", "buffer": "6.0.3", + "concurrently": "9.2.1", + "cross-env": "7.0.3", "cz-conventional-changelog": "3.3.0", + "electron": "42.1.0", + "electron-builder": "26.8.1", "eslint": "8.57.1", "eslint-config-airbnb": "19.0.4", "eslint-config-prettier": "8.5.0", @@ -108,10 +111,11 @@ "vite": "5.4.19", "vite-plugin-pwa": "0.20.5", "vite-plugin-static-copy": "1.0.4", - "vite-plugin-top-level-await": "1.4.4" + "vite-plugin-top-level-await": "1.4.4", + "wait-on": "9.0.10" }, "engines": { - "node": ">=22.0.0" + "node": ">=22.12.0" } }, "node_modules/@ampproject/remapping": { @@ -2013,6 +2017,264 @@ "node": ">=v18" } }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/get": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", + "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@electron/get/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/rebuild": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.4.tgz", + "integrity": "sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@element-hq/element-call-embedded": { "version": "0.16.3", "resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.16.3.tgz", @@ -2530,6 +2792,60 @@ "tslib": "2" } }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", + "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -2835,6 +3151,61 @@ "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==" }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@mapbox/node-pre-gyp": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", @@ -4800,6 +5171,26 @@ "url": "https://ko-fi.com/dangreen" } }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -5042,6 +5433,19 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@tanstack/query-core": { "version": "5.24.1", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.24.1.tgz", @@ -5157,6 +5561,19 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, "node_modules/@types/chroma-js": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-3.1.1.tgz", @@ -5164,6 +5581,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -5190,6 +5617,13 @@ "@types/node": "*" } }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/is-hotkey": { "version": "0.1.10", "resolved": "https://registry.npmjs.org/@types/is-hotkey/-/is-hotkey-0.1.10.tgz", @@ -5203,11 +5637,40 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "18.11.18", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", "integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==" }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, "node_modules/@types/prismjs": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.0.tgz", @@ -5255,6 +5718,16 @@ "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "dev": true }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/sanitize-html": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.9.0.tgz", @@ -5288,6 +5761,25 @@ "integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==", "dev": true }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", @@ -5650,6 +6142,13 @@ "node": ">=10.0.0" } }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -5681,7 +6180,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "optional": true, + "devOptional": true, "dependencies": { "debug": "4" }, @@ -5711,6 +6210,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, "node_modules/another-json": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz", @@ -5793,6 +6302,311 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/app-builder-bin": { + "version": "5.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", + "integrity": "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.8.1.tgz", + "integrity": "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.3", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.8.1", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.0.3", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.8.1", + "electron-builder-squirrel-windows": "26.8.1" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/app-builder-lib/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/app-builder-lib/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/app-builder-lib/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/app-builder-lib/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/app-builder-lib/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/app-builder-lib/node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/tar": { + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/app-builder-lib/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/app-builder-lib/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", @@ -5971,6 +6785,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, "node_modules/ast-types-flow": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", @@ -5992,6 +6817,23 @@ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "dev": true }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", @@ -6032,6 +6874,19 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/axobject-query": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", @@ -6191,6 +7046,15 @@ "resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.4.tgz", "integrity": "sha512-r/As72u2FbucLoK5NTegM/GucxJc3d8GvHc4ngo13IO/nt2HU4gONxNLq1XPN6EM/V8Y9URIa7PcSz2RZu553A==" }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/bplist-parser": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", @@ -6309,6 +7173,94 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/builder-util": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.8.1.tgz", + "integrity": "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.12", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", + "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util-runtime/node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/builder-util/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -6317,6 +7269,35 @@ "node": ">=8" } }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cachedir": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz", @@ -6490,6 +7471,29 @@ "integrity": "sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg==", "license": "(BSD-3-Clause AND Apache-2.0)" }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/classnames": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", @@ -6617,6 +7621,29 @@ "node": ">=0.8" } }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clone-response/node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -6657,6 +7684,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -6746,6 +7786,16 @@ "node": ">=4.0.0" } }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/compute-scroll-into-view": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", @@ -6758,6 +7808,47 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "devOptional": true }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -6838,6 +7929,14 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/cosmiconfig": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", @@ -6905,6 +8004,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/crc/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -7110,14 +8265,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/dateformat": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-5.0.3.tgz", - "integrity": "sha512-Kvr6HmPXUMerlLcLF+Pwq3K7apHpYmGDVqrxcDasBg86UcKeTSNWbEzU8bwdXnxnR44FtMhJAxI4Bov6Y/KUfA==", - "engines": { - "node": ">=12.20" - } - }, "node_modules/dayjs": { "version": "1.11.10", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", @@ -7201,6 +8348,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -7244,6 +8401,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -7279,6 +8446,25 @@ "node": ">=8" } }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -7304,6 +8490,78 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dmg-builder": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.8.1.tgz", + "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -7367,6 +8625,35 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -7396,11 +8683,123 @@ "node": ">=0.10.0" } }, + "node_modules/electron": { + "version": "42.1.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.1.0.tgz", + "integrity": "sha512-0szNwC/0dWtkvNce5j3ThiuL0TxBNrZN/BZhdOiGwbLreiD/+u3MGpkct4hA5Ycagb8MXjpEr5/oosi+FwuKRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/electron-builder": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.8.1.tgz", + "integrity": "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.8.1", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.8.1", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish": { + "version": "26.8.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz", + "integrity": "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "26.8.1", + "builder-util-runtime": "9.5.1", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.83", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.83.tgz", "integrity": "sha512-LcUDPqSt+V0QmI47XLzZrz5OqILSMGsPFkDYus22rIbgorSvBYEFqq854ltTmUdHkY92FSdAAvsh4jWEULMdfQ==" }, + "node_modules/electron/node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/elementtree": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", @@ -7440,6 +8839,16 @@ "emojibase": "*" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -7473,6 +8882,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -7620,6 +9036,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/esbuild": { "version": "0.19.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", @@ -8179,6 +9603,13 @@ "node": ">=0.10.0" } }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -8194,6 +9625,38 @@ "node": ">=4" } }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8455,6 +9918,27 @@ "react-dom": "17.0.0" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -8464,6 +9948,23 @@ "is-callable": "^1.1.3" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs-extra": { "version": "11.3.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", @@ -8657,6 +10158,22 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -8707,6 +10224,25 @@ "node": ">=10.13.0" } }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, "node_modules/global-directory": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", @@ -8833,6 +10369,61 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/got/node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/got/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -8967,6 +10558,39 @@ "node": ">=0.10.0" } }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, "node_modules/html-dom-parser": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-dom-parser/-/html-dom-parser-4.0.0.tgz", @@ -9034,11 +10658,56 @@ "entities": "^4.4.0" } }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "optional": true, + "devOptional": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -9109,6 +10778,58 @@ "node-fetch": "^2.6.12" } }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-corefoundation/node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/iconv-corefoundation/node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -9837,6 +11558,19 @@ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -9871,11 +11605,29 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", - "optional": true, "bin": { "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/joi": { + "version": "18.2.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz", + "integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/jotai": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.6.0.tgz", @@ -9957,6 +11709,14 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -10048,6 +11808,13 @@ "node": ">=0.10" } }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -10587,6 +12354,16 @@ "loose-envify": "cli.js" } }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -10631,6 +12408,20 @@ "semver": "bin/semver.js" } }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -10768,6 +12559,42 @@ "millify": "bin/millify" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -10976,6 +12803,37 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/node-abi": { + "version": "4.31.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.31.0.tgz", + "integrity": "sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -10995,6 +12853,153 @@ } } }, + "node_modules/node-gyp": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", + "integrity": "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/node-gyp/node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/node-gyp/node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/tar": { + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -11024,6 +13029,19 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -11307,6 +13325,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -11484,6 +13512,21 @@ "path2d": "^0.2.0" } }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -11641,6 +13684,40 @@ "node": ">=6" } }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -11673,6 +13750,39 @@ "react-is": "^16.13.1" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -11702,6 +13812,19 @@ } ] }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/raf-schd": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", @@ -11921,6 +14044,19 @@ "react-dom": ">=16.8" } }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -12104,6 +14240,24 @@ "node": "*" } }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -12124,6 +14278,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve-dir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", @@ -12147,6 +14308,19 @@ "node": ">=4" } }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/restore-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", @@ -12161,6 +14335,16 @@ "node": ">=8" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -12194,6 +14378,25 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/rollup": { "version": "4.30.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.30.1.tgz", @@ -12352,6 +14555,16 @@ "dev": true, "license": "MIT" }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, "node_modules/sanitize-html": { "version": "2.12.1", "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.12.1.tgz", @@ -12408,6 +14621,45 @@ "node": ">=10" } }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -12488,6 +14740,19 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -12596,6 +14861,19 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -12714,6 +14992,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/smob": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", @@ -12792,6 +15082,24 @@ "deprecated": "Please use @jridgewell/sourcemap-codec instead", "dev": true }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -12982,6 +15290,19 @@ "inline-style-parser": "0.1.1" } }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -13042,6 +15363,32 @@ "node": ">=8" } }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/tempy": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", @@ -13091,6 +15438,26 @@ "dev": true, "license": "MIT" }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/tiny-invariant": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", @@ -13137,6 +15504,26 @@ "node": ">=0.6.0" } }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/tmp-promise/node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -13163,6 +15550,16 @@ "tree-kill": "cli.js" } }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -13358,6 +15755,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/unhomoglyph": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz", @@ -13482,6 +15897,13 @@ "punycode": "^2.1.0" } }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -13500,6 +15922,22 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/vite": { "version": "5.4.19", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", @@ -14061,6 +16499,33 @@ "node": ">=0.10.0" } }, + "node_modules/wait-on": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.10.tgz", + "integrity": "sha512-rCoJEhvMr0X6alHmwc9abbrA5ZrLZFKpFQVKPNFwl2h7DapXOGdmimIHDtLOWhT4PjhZhxFEtZoQgEXbkDWdZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.16.0", + "joi": "^18.2.1", + "lodash": "^4.18.1", + "minimist": "^1.2.8", + "rxjs": "^7.8.2" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/wait-on/node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", diff --git a/package.json b/package.json index 45ebf51b..7d7d3552 100644 --- a/package.json +++ b/package.json @@ -1,35 +1,45 @@ { "name": "vojo", - "version": "4.11.1", - "description": "Yet another matrix client", + "version": "0.2.0", + "description": "Vojo client for matrix server", "main": "index.js", "type": "module", "engines": { - "node": ">=22.0.0" + "node": ">=22.12.0" }, "scripts": { "start": "vite", "build": "vite build", "preview": "vite preview", "lint": "npm run check:eslint && npm run check:prettier", - "check:eslint": "eslint src", + "check:eslint": "eslint --max-warnings 0 src", "check:prettier": "prettier --check .", "fix:prettier": "prettier --write .", "typecheck": "tsc --noEmit", "gen:push-strings": "node scripts/gen-push-strings.mjs", "android:sync": "npx cap sync android", "android:open": "npx cap open android", + "android:strip-sourcemaps": "find dist -name '*.map' -delete", "android:apk:debug": "cd android && ./gradlew assembleDebug", "android:apk:release": "cd android && ./gradlew assembleRelease", "android:aab:release": "cd android && ./gradlew bundleRelease", - "build:android:debug": "npm run build && npm run android:sync && npm run android:apk:debug", - "build:android:release": "npm run build && npm run android:sync && npm run android:apk:release", - "build:android:aab": "npm run build && npm run android:sync && npm run android:aab:release", + "build:android:debug": "npm run build && npm run android:strip-sourcemaps && npm run android:sync && npm run android:apk:debug", + "build:android:release": "npm run build && npm run android:strip-sourcemaps && npm run android:sync && npm run android:apk:release", + "build:android:aab": "npm run build && npm run android:strip-sourcemaps && npm run android:sync && npm run android:aab:release", + "electron:typecheck": "tsc --noEmit -p electron/tsconfig.json", + "electron:build": "tsc -p electron/tsconfig.json && node -e \"require('fs').writeFileSync('electron/dist-electron/package.json', JSON.stringify({type:'commonjs'}))\"", + "electron:dev": "concurrently -k -n vite,electron -c blue,green \"npm:start\" \"wait-on tcp:8080 && npm run electron:build && electron electron/dist-electron/main.js\"", + "electron:start": "electron electron/dist-electron/main.js", + "electron:start:prod": "cross-env VOJO_ELECTRON_PROD=1 electron electron/dist-electron/main.js", + "build:electron:win": "npm run build && npm run electron:build && electron-builder --win", + "build:electron:win:docker": "docker run --rm -v ${PWD}:/project -v ~/.cache/electron:/root/.cache/electron -v ~/.cache/electron-builder:/root/.cache/electron-builder -w /project electronuserland/builder:wine-mono /bin/bash -c \"trap 'chown -R 1000:1000 /project/dist /project/release /project/electron/dist-electron 2>/dev/null || true' EXIT; npm run build && npm run electron:build && npx electron-builder --win\"", + "build:electron:mac": "npm run build && npm run electron:build && electron-builder --mac", + "build:electron:linux": "npm run build && npm run electron:build && electron-builder --linux", "prepare": "husky install", "commit": "git-cz" }, "lint-staged": { - "*.{ts,tsx,js,jsx,mjs,cjs}": "eslint", + "*.{ts,tsx,js,jsx,mjs,cjs}": "eslint --max-warnings 0", "*": "prettier --ignore-unknown --write" }, "config": { @@ -66,7 +76,6 @@ "browser-encrypt-attachment": "0.3.0", "chroma-js": "3.1.2", "classnames": "2.3.2", - "dateformat": "5.0.3", "dayjs": "1.11.10", "domhandler": "5.0.3", "emojibase": "15.3.1", @@ -125,7 +134,11 @@ "@typescript-eslint/parser": "7.18.0", "@vitejs/plugin-react": "4.2.0", "buffer": "6.0.3", + "concurrently": "9.2.1", + "cross-env": "7.0.3", "cz-conventional-changelog": "3.3.0", + "electron": "42.1.0", + "electron-builder": "26.8.1", "eslint": "8.57.1", "eslint-config-airbnb": "19.0.4", "eslint-config-prettier": "8.5.0", @@ -140,6 +153,7 @@ "vite": "5.4.19", "vite-plugin-pwa": "0.20.5", "vite-plugin-static-copy": "1.0.4", - "vite-plugin-top-level-await": "1.4.4" + "vite-plugin-top-level-await": "1.4.4", + "wait-on": "9.0.10" } } diff --git a/public/delete-account.html b/public/delete-account.html new file mode 100644 index 00000000..e0dadf2a --- /dev/null +++ b/public/delete-account.html @@ -0,0 +1,388 @@ + + + + + + + + Vojo — Account deletion + + + +
    + +
    +
    + + Vojo + · + Account deletion +
    + +

    Delete your account

    +

    Vojo Project · vojo.chat

    + +
    + + + +
    +
    + +
    +

    This page explains how to request deletion of your Vojo account and the + data associated with it on the vojo.chat homeserver.

    + +

    Vojo is maintained by the Vojo Project, an independent developer. The application + does not yet expose an in-app "Delete account" button; until it does, deletion is + handled by request to the address below. We reply to every request.

    + +

    How to request deletion

    +
      +
    1. Send an email from any address to + + with the subject “Delete account”.
    2. +
    3. In the body of the email, include your full Matrix user ID — the + @username:vojo.chat identifier shown in Settings → Account. + If you have lost access to the account, describe enough detail (approximate + creation date, the email or recovery info you remember) so we can identify it + with reasonable certainty.
    4. +
    5. We acknowledge the request within a few business days and complete deletion + within thirty days of the original request.
    6. +
    + +
    +

    Contact: +

    +

    Subject line: Delete account

    +
    + +

    What gets deleted

    +
      +
    • Your account record on the vojo.chat homeserver (user profile, + display name, avatar).
    • +
    • Your active sessions and authentication tokens.
    • +
    • Your encryption keys held server-side.
    • +
    • Media files you uploaded to the vojo.chat media storage.
    • +
    • The push-notification registration (Firebase Cloud Messaging token) bound + to the account.
    • +
    + +

    What we cannot delete on your behalf

    +
      +
    • Messages already delivered to other servers. Matrix is a federated + network: when you sent a message into a room that included participants on + other homeservers, those messages were replicated to their servers and are + no longer under our control.
    • +
    • Copies held by other participants. Anything you sent into a + conversation has been received by the people you sent it to. We cannot + reach into their devices or accounts.
    • +
    • Messages in rooms you no longer participate in. A few residual + events (membership records, room state) may remain on the homeserver for + room consistency, but they no longer link to your deleted account.
    • +
    • Bridged third-party networks. If you used a Telegram, Discord or + WhatsApp bridge, those networks hold their own copies of your messages + governed by their own retention policies; deactivating your Vojo account + does not delete data on those services.
    • +
    + +

    Data retained after deletion

    +

    After your account is deactivated, server access logs may retain your IP address + and request timestamps for up to thirty additional days as part of normal abuse- + prevention rotation. Backup snapshots covering the period before deletion are + rotated out within thirty days. After that period, no personal data attributable to + your account remains on our infrastructure.

    + +

    If you'd prefer to stay but stop receiving notifications

    +

    If you only want notifications to stop and not to lose your account entirely, + you can simply sign out of the application on your device — this removes the + push-notification binding without deactivating the account.

    + +

    Privacy Policy

    +

    For a fuller description of what we hold and why, see our + Privacy Policy.

    +
    + + + + + +
    + + + + + diff --git a/public/font/Twemoji.Mozilla.v15.1.0.ttf b/public/font/Twemoji.Mozilla.v15.1.0.ttf deleted file mode 100644 index efb3c898..00000000 Binary files a/public/font/Twemoji.Mozilla.v15.1.0.ttf and /dev/null differ diff --git a/public/locales/en.json b/public/locales/en.json index 3f5a2d9a..309329e1 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -90,6 +90,8 @@ "menu_emojis_stickers": "Emojis & Stickers", "menu_developer_tools": "Developer Tools", "menu_about": "About", + "drag_to_close": "Drag down to close", + "close": "Close", "logout": "Logout", "logout_confirm": "You're about to log out. Are you sure?", "logout_failed": "Failed to logout! {{message}}", @@ -97,7 +99,6 @@ "logout_unverified_desc": "Verify your device before logging out to save your encrypted messages.", "logout_alert_title": "Alert", "logout_alert_desc": "Enable device verification or export your encrypted data from settings to avoid losing access to your messages.", - "general_title": "General", "appearance": "Appearance", "system_theme": "System", @@ -121,7 +122,6 @@ "url_preview": "Url Preview", "url_preview_encrypted": "Url Preview in Encrypted Room", "show_hidden_events": "Show Hidden Events", - "account_title": "Account", "profile": "Profile", "avatar": "Avatar", @@ -140,7 +140,6 @@ "select_user_desc": "Prevent receiving messages or invites from user by adding their userId.", "block": "Block", "users": "Users", - "notifications_title": "Notifications", "block_messages": "Block Messages", "block_messages_moved": "This option has been moved to \"Account > Block Users\" section.", @@ -185,7 +184,6 @@ "notif_disable": "Disable", "notif_silent": "Notify Silent", "notif_loud": "Notify Loud", - "devices_title": "Devices", "security": "Security", "device_verification": "Device Verification", @@ -228,7 +226,6 @@ "verify_other_desc": "Verify device identity and grant access to encrypted messages.", "verify": "Verify", "reset": "Reset", - "local_backup": "Local Backup", "new_password": "New Password", "confirm_password": "Confirm Password", @@ -242,7 +239,6 @@ "import_desc": "Load password protected copy of encryption data from device to decrypt your messages.", "import": "Import", "decrypt": "Decrypt", - "emojis_stickers_title": "Emojis & Stickers", "default_pack": "Default Pack", "unknown": "Unknown", @@ -252,7 +248,6 @@ "select_pack_desc": "Pick emoji and sticker packs from rooms to use globally.", "select": "Select", "room_packs": "Room Packs", - "close": "Close", "select_all": "Select All", "unselect_all": "Unselect All", "no_packs": "No Packs", @@ -260,15 +255,17 @@ "apply_error": "Failed to apply changes! Please try again.", "apply_ready": "Changes saved! Apply when ready.", "apply_changes": "Apply Changes", - "about_title": "About", "about_tagline": "Yet another matrix client.", "options": "Options", "clear_cache_title": "Clear Cache & Reload", "clear_cache_desc": "Clear all your locally stored data and reload from server.", "clear_cache": "Clear Cache", + "legal": "Legal", + "privacy_policy_title": "Privacy Policy", + "privacy_policy_desc": "How your data is handled.", + "privacy_policy_open": "Open", "credits": "Credits", - "devtools_title": "Developer Tools", "enable_devtools": "Enable Developer Tools", "access_token": "Access Token", @@ -372,7 +369,7 @@ "create_chat": "Create Chat", "create_chat_subtitle": "Start a private, encrypted chat by entering a username.", "start_first_chat": "Start a chat", - "segment_dm": "DM", + "segment_dm": "Direct", "segment_channels": "Channels", "segment_bots": "Robots", "self_row_label": "You", @@ -386,7 +383,8 @@ "e2e_encryption": "End-to-End Encryption", "e2e_encryption_desc": "Once this feature is enabled, it can't be disabled after the room is created.", "rate_limited": "Server rate-limited your request for {{minutes}} minutes!", - "create": "Create" + "create": "Create", + "close": "Close" }, "Channels": { "no_spaces_title": "No communities yet", @@ -396,7 +394,12 @@ "pick_channel_desc": "Choose a channel from the list on the left to start reading.", "root_category": "Channels", "workspace_switcher_aria": "Switch community", - "workspace_switcher_active_marker": "Current" + "workspace_switcher_create_space": "Create community", + "workspace_switcher_drag_to_close": "Drag down to close", + "workspace_switcher_member_count_one": "{{count}} member", + "workspace_switcher_member_count_other": "{{count}} members", + "workspace_footer_subtitle": "Community", + "create_channel": "Create channel" }, "Call": { "start": "Start call", @@ -421,7 +424,19 @@ "in_call": "In call", "in_call_count": "{{count}} in call", "connecting": "Connecting…", - "open_call_room": "Open call room" + "open_call_room": "Open call room", + "bubble_outgoing": "Outgoing call", + "bubble_incoming": "Incoming call", + "bubble_missed": "Missed call", + "bubble_cancelled": "Cancelled call", + "bubble_ongoing": "Ongoing call", + "bubble_in_progress": "In progress…", + "bubble_missed_count_one": "{{count}} missed call", + "bubble_missed_count_other": "{{count}} missed calls", + "bubble_cancelled_count_one": "{{count}} cancelled call", + "bubble_cancelled_count_other": "{{count}} cancelled calls", + "duration_minutes_seconds": "{{minutes}} min {{seconds}} sec", + "duration_seconds": "{{seconds}} sec" }, "Room": { "drag_to_close": "Drag up to close", @@ -433,7 +448,6 @@ "jump_to_latest": "Jump to Latest", "today": "Today", "yesterday": "Yesterday", - "view_reactions": "View Reactions", "read_receipts": "Read Receipts", "view_source": "View Source", @@ -445,7 +459,6 @@ "reply": "Reply", "reply_in_thread": "Reply in Thread", "edit_message": "Edit Message", - "delete_message": "Delete Message", "delete_confirm": "This action is irreversible! Are you sure that you want to delete this message?", "reason": "Reason", @@ -453,7 +466,6 @@ "delete_error": "Failed to delete message! Please try again.", "deleting": "Deleting...", "delete": "Delete", - "report_message": "Report Message", "report_desc": "Report this message to server, which may then notify the appropriate people to take action.", "report_reason": "Reason", @@ -462,18 +474,20 @@ "reporting": "Reporting...", "report": "Report", "no_reason": "No reason provided", - "is_typing": " is typing...", "and": " and ", "are_typing": " are typing...", "others_count": "{{count}} others", "drop_typing": "Dismiss typing indicator", - "members": "Members", "members_count_one": "{{formattedCount}} Member", "members_count_other": "{{formattedCount}} Members", "hide_members": "Hide Members", "show_members": "Show Members", + "members_pane_title": "Members", + "members_sheet_title_one": "{{formattedCount}} member", + "members_sheet_title_other": "{{formattedCount}} members", + "open_members_of": "Open members of {{name}}", "more_options": "More Options", "close": "Close", "search": "Search", @@ -485,23 +499,30 @@ "room_settings": "Room Settings", "jump_to_time": "Jump to Time", "leave_room": "Leave Room", - "send_message": "Send a message...", + "send_message_alt_1": "One line or many...", + "send_message_alt_2": "Write something right now...", + "send_message_alt_3": "Don't keep me waiting, type...", + "send_message_alt_4": "This line won't fill itself...", + "send_message_alt_5": "So... what's it gonna be?..", + "send_message_alt_6": "Nobody reads placeholders. But you did...", + "send_message_alt_7": "Letters here, please...", + "send_message_alt_8": "You stare at the placeholder. The placeholder stares back...", + "send_message_alt_9": "Congrats, you're in the 3% who read placeholders...", + "send_message_alt_10": "Fine, I'll wait... and wait...", + "send_message_alt_11": "After you...", "drop_files": "Drop Files in \"{{name}}\"", "drag_drop_desc": "Drag and drop files here or click for selection dialog", - "pinned_messages": "Pinned Messages", "no_pinned_messages": "No Pinned Messages", "no_pinned_messages_desc": "Users with sufficient permissions can pin messages from the message context menu.", "open": "Open", "failed_to_load": "Failed to load message!", - "time_label": "Time", "date_label": "Date", "preset": "Preset", "beginning": "Beginning", "open_timeline": "Open Timeline", - "message_deleted": "This message has been deleted", "message_deleted_reason": "This message has been deleted. {{reason}}", "unsupported_message": "Unsupported message", @@ -511,7 +532,6 @@ "broken_message": "Broken message", "empty_message": "Empty message", "edited": " (edited)", - "thread_caption": "Thread", "thread_in_channel_subtitle": "in #{{channel}}", "thread_close": "Close thread", @@ -528,18 +548,24 @@ "thread_summary_highlight_one": "{{count}} mention", "thread_summary_highlight_other": "{{count}} mentions", "no_post_permission": "You do not have permission to post in this room", - - "conversation_beginning": "This is the beginning of conversation.", - "created_by": "Created by @{{creator}} on {{date}} {{time}}", - "invite_member": "Invite Member", - "open_old_room": "Open Old Room", - "join_old_room": "Join Old Room", + "empty_dm": "The hardest part is the first message.", + "empty_dm_alt_1": "You have to start somewhere.", + "empty_dm_alt_2": "Someone has to go first.", + "empty_dm_alt_3": "A blank canvas. Not a single typo — yet.", + "empty_group": "The group is set up. Who goes first?", + "empty_group_alt_1": "No one has said anything here yet.", + "empty_group_alt_2": "The calm before the first message.", + "empty_group_alt_3": "Everyone's here — go ahead.", + "empty_bridge": "Messages here travel through the {{network}} bridge.", + "empty_bridge_alt_1": "This chat is linked to {{network}}.", + "empty_bridge_alt_2": "Your contact is writing from {{network}}.", + "empty_bridge_generic": "Messages here travel through a bridge.", + "empty_encrypted": "Messages are protected with end-to-end encryption.", "leave_room_title": "Leave Room", "leave_room_confirm": "Are you sure you want to leave this room?", "leave_room_error": "Failed to leave room! {{error}}", "leaving": "Leaving...", "leave": "Leave", - "member_broken": "Broken membership event", "member_accepted_knock": "{{sender}} accepted {{user}}'s join request", "member_invited": "{{sender}} invited {{user}}", @@ -557,10 +583,7 @@ "member_name_removed": "{{user}} removed their display name", "member_avatar_changed": "{{user}} changed their avatar", "member_avatar_removed": "{{user}} removed their avatar", - "member_no_change": "Membership event with no changes", - - "member_ended_call": "{{user}} ended the call", - "member_joined_call": "{{user}} joined the call" + "member_no_change": "Membership event with no changes" }, "Inbox": { "invite_title": "Invite", @@ -568,19 +591,15 @@ "user_id_placeholder": "@username:server", "reason_optional": "Reason (Optional)", "invite_button": "Invite", - "notif_default": "Default", "notif_all_messages": "All Messages", "notif_mentions_keywords": "Mention & Keywords", "notif_mute": "Mute", - "unverified_device": "Unverified Device", "unverified_devices": "Unverified Devices" }, - "Explore": { "explore_community": "Explore Community", - "add_server": "Add Server", "add_server_desc": "Add server name to explore public communities.", "server_name": "Server Name", @@ -588,13 +607,11 @@ "view": "View", "featured": "Featured", "servers": "Servers", - "featured_by_client": "Featured by Client", "featured_by_client_desc": "Public rooms and spaces hand-picked by this client.", "featured_spaces": "Featured Spaces", "featured_rooms": "Featured Rooms", "no_featured": "No featured rooms or spaces yet.", - "search": "Search", "search_placeholder": "Search for keyword", "clear": "Clear", @@ -613,9 +630,9 @@ "previous_page": "Previous Page", "next_page": "Next Page", "no_communities": "No communities found!", - "space_badge": "Space", - "members_count": "{{count}} Members", + "members_count_one": "{{formattedCount}} Member", + "members_count_other": "{{formattedCount}} Members", "join": "Join", "joining": "Joining", "retry": "Retry", @@ -624,7 +641,6 @@ "view_error": "View Error", "cancel": "Cancel" }, - "Create": { "add_space": "Add Space", "create_space": "Create Space", @@ -632,7 +648,6 @@ "join_with_address": "Join with Address", "join_with_address_desc": "Join an existing community.", "new_space": "New Space", - "access": "Access", "name": "Name", "topic_optional": "Topic (Optional)", @@ -644,47 +659,38 @@ "allow_federation_desc": "Users from other servers can join.", "create": "Create", "rate_limited": "Server rate-limited your request for {{minutes}} minutes!", - "access_restricted": "Restricted", "access_restricted_desc": "Only members of the parent space can join.", "access_private": "Private", "access_private_desc": "Only people with an invite can join.", "access_public": "Public", "access_public_desc": "Anyone with the address can join.", - "address_optional": "Address (Optional)", "address_hint": "Pick a unique address to make it discoverable.", "address_taken": "This address is already taken. Please choose a different one.", - "founders": "Founders", "founders_desc": "Privileged users assigned during creation. They have elevated control and can only be changed during an upgrade.", "enter": "Enter", "no_suggestions": "No Suggestions", "no_suggestions_desc": "Enter a user ID and press Enter.", - "version": "Version", "versions": "Versions", - "chat_room": "Chat Room", "chat_room_desc": "Messages, photos, and videos.", "voice_room": "Voice Room", "voice_room_desc": "Live audio and video conversations.", - "new_chat_room": "New Chat Room", "new_voice_room": "New Voice Room", - "existing_space": "Existing Space", "add_room": "Add Room", "existing_room": "Existing Room" }, - "RoomSettings": { "general": "General", "members": "Members", "permissions": "Permissions", "emojis_stickers": "Emojis & Stickers", "developer_tools": "Developer Tools", - "profile": "Profile", "edit": "Edit", "unknown": "Unknown", @@ -696,30 +702,25 @@ "topic": "Topic", "save": "Save", "cancel": "Cancel", - "options": "Options", "addresses": "Addresses", "advanced_options": "Advanced Options", - "space_access": "Space Access", "room_access": "Room Access", "space_access_desc": "Change how people can join the space.", "room_access_desc": "Change how people can join the room.", - "join_invite_only": "Invite Only", "join_knock_invite": "Knock & Invite", "join_space_members_or_knock": "Space Members or Knock", "join_space_members": "Space Members", "join_public": "Public", "join_unsupported": "Unsupported", - "history_visibility": "Message History Visibility", "history_visibility_desc": "Changes to history visibility will only apply to future messages and will not affect existing history.", "visibility_after_invite": "After Invite", "visibility_after_join": "After Join", "visibility_all_messages": "All Messages", "visibility_all_messages_guests": "All Messages (Guests)", - "room_encryption": "Room Encryption", "encryption_enabled_desc": "Messages in this room are protected by end-to-end encryption.", "encryption_disabled_desc": "Once enabled, encryption cannot be disabled!", @@ -728,11 +729,9 @@ "enable_encryption": "Enable Encryption", "enable_encryption_confirm": "Are you sure? Once enabled, encryption cannot be disabled!", "enable_e2e_encryption": "Enable E2E Encryption", - "publish_to_directory": "Publish to Directory", "publish_space_desc": "List the space in the public directory to make it discoverable by others.", "publish_room_desc": "List the room in the public directory to make it discoverable by others.", - "published_addresses": "Published Addresses", "published_addresses_desc": "If access is Public, Published addresses will be used to join by anyone.", "no_addresses": "No Addresses", @@ -746,13 +745,11 @@ "publish": "Publish", "delete": "Delete", "selected_count": "{{count}} Selected", - "local_addresses": "Local Addresses", "local_addresses_desc": "Set local address so users can join through your homeserver.", "collapse": "Collapse", "expand": "Expand", "loading": "Loading...", - "space_upgrade": "Space Upgrade", "room_upgrade": "Room Upgrade", "upgrade": "Upgrade", @@ -766,25 +763,21 @@ "old_room": "Old Room", "open_new_space": "Open New Space", "open_new_room": "Open New Room", - "members_count": "{{count}} Members", "search": "Search", "no_results": "No Results", "results_count": "{{count}} Results", "scroll_to_top": "Scroll to Top", "no_membership_members": "No \"{{filter}}\" Members", - "filter_joined": "Joined", "filter_invited": "Invited", "filter_left": "Left", "filter_kicked": "Kicked", "filter_banned": "Banned", - "sort_a_to_z": "A to Z", "sort_z_to_a": "Z to A", "sort_newest": "Newest", "sort_oldest": "Oldest", - "perm_messages": "Messages", "perm_send_messages": "Send Messages", "perm_send_stickers": "Send Stickers", @@ -817,12 +810,10 @@ "perm_manage_emojis_stickers": "Manage Emojis & Stickers", "perm_change_server_acls": "Change Server ACLs", "perm_modify_widgets": "Modify Widgets", - "founders": "Founders", "founders_desc": "Founding members have all permissions and can only be changed during a room upgrade.", "power_levels": "Power Levels", "power_levels_desc": "Manage and customize incremental power levels for users.", - "new_power_level": "New Power Level", "new_power_level_desc": "Create a new power level.", "power_level_placeholder": "Bot", @@ -839,11 +830,9 @@ "failed_to_apply": "Failed to apply changes! Please try again.", "apply_changes": "Apply Changes", "and_above": "& Above", - "users": "Users", "default_power": "Default Power", "default_power_desc": "Default power level for all users.", - "packs": "Packs", "new_pack": "New Pack", "new_pack_desc": "Add your own emoji and sticker pack to use in room.", @@ -852,7 +841,6 @@ "view": "View", "failed_to_remove_packs": "Failed to remove packs! Please try again.", "delete_selected_packs": "Delete selected packs. ({{count}} selected)", - "enable_developer_tools": "Enable Developer Tools", "room_id": "Room ID", "room_id_desc": "Copy room ID to clipboard.", @@ -875,7 +863,6 @@ "message_event_type": "Message Event Type", "send": "Send", "state_key_optional": "State Key (Optional)", - "pack": "Pack", "images_usage": "Images Usage", "images_usage_desc": "Select how the images are being used: as emojis, as stickers, or as both.", @@ -890,7 +877,6 @@ "usage_both": "Both", "usage_sticker": "Sticker", "usage_emoji": "Emoji", - "power_goku": "Goku", "power_manager": "Manager", "power_founder": "Founder", @@ -900,7 +886,6 @@ "power_muted": "Muted", "power_team": "Team" }, - "Push": { "new_message": "New message", "new_messages": "New messages", @@ -911,7 +896,19 @@ "invite_body": "{{inviter}} invited you to {{roomName}}", "invite_body_no_room": "{{inviter}} invited you to a room", "invite_body_no_inviter": "Invited you to {{roomName}}", - "invite_body_generic": "New invitation" + "invite_body_generic": "New invitation", + "missed_call": "Missed call", + "missed_call_body": "{{caller}} tried to call you", + "channel_group": "Chats", + "channel_dm": "Direct messages", + "channel_dm_description": "New messages from direct chats", + "channel_group_room": "Group chats", + "channel_group_room_description": "New messages from group chats and channels", + "self_name": "You", + "action_mark_as_read": "Mark as read", + "action_reply": "Reply", + "reply_hint": "Reply…", + "reply_failed": "Could not send your reply" }, "Bots": { "not_connected_title": "{{name}} is not connected", @@ -983,5 +980,15 @@ "copy_server": "Copy server", "explore_community": "Explore community", "open_in_browser": "Open in browser" + }, + "Share": { + "share_text": "Ready to share text", + "share_image": "Ready to share image", + "share_video": "Ready to share video", + "share_audio": "Ready to share audio", + "share_file": "Ready to share: {{name}}", + "share_files": "Ready to share {{count}} files", + "tap_chat_to_send": "Open a chat to drop it in", + "cancel": "Cancel share" } } diff --git a/public/locales/ru.json b/public/locales/ru.json index 3939bd0b..baa683d4 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -90,6 +90,8 @@ "menu_emojis_stickers": "Эмодзи и стикеры", "menu_developer_tools": "Инструменты разработчика", "menu_about": "О приложении", + "drag_to_close": "Потянуть вниз чтобы закрыть", + "close": "Закрыть", "logout": "Выйти", "logout_confirm": "Вы собираетесь выйти из аккаунта. Вы уверены?", "logout_failed": "Не удалось выйти! {{message}}", @@ -97,7 +99,6 @@ "logout_unverified_desc": "Верифицируйте устройство перед выходом, чтобы сохранить зашифрованные сообщения.", "logout_alert_title": "Внимание", "logout_alert_desc": "Включите верификацию устройства или экспортируйте зашифрованные данные в настройках, чтобы не потерять доступ к сообщениям.", - "general_title": "Общие", "appearance": "Внешний вид", "system_theme": "Системная", @@ -121,7 +122,6 @@ "url_preview": "Предпросмотр ссылок", "url_preview_encrypted": "Предпросмотр ссылок в зашифрованных комнатах", "show_hidden_events": "Показывать скрытые события", - "account_title": "Аккаунт", "profile": "Профиль", "avatar": "Аватар", @@ -140,7 +140,6 @@ "select_user_desc": "Заблокируйте получение сообщений и приглашений от пользователя, добавив его идентификатор.", "block": "Заблокировать", "users": "Пользователи", - "notifications_title": "Уведомления", "block_messages": "Блокировка сообщений", "block_messages_moved": "Эта опция перенесена в раздел «Аккаунт > Заблокированные пользователи».", @@ -185,7 +184,6 @@ "notif_disable": "Отключить", "notif_silent": "Тихое уведомление", "notif_loud": "Громкое уведомление", - "devices_title": "Устройства", "security": "Безопасность", "device_verification": "Верификация устройства", @@ -228,7 +226,6 @@ "verify_other_desc": "Подтвердите идентичность устройства и получите доступ к зашифрованным сообщениям.", "verify": "Верифицировать", "reset": "Сбросить", - "local_backup": "Локальная копия", "new_password": "Новый пароль", "confirm_password": "Подтвердите пароль", @@ -242,7 +239,6 @@ "import_desc": "Загрузите защищённую паролем копию ключей шифрования с устройства для расшифровки сообщений.", "import": "Импорт", "decrypt": "Расшифровать", - "emojis_stickers_title": "Эмодзи и стикеры", "default_pack": "Пакет по умолчанию", "unknown": "Неизвестно", @@ -252,7 +248,6 @@ "select_pack_desc": "Выберите пакеты эмодзи и стикеров из комнат для использования во всех комнатах.", "select": "Выбрать", "room_packs": "Пакеты комнат", - "close": "Закрыть", "select_all": "Выбрать все", "unselect_all": "Снять выделение", "no_packs": "Нет пакетов", @@ -260,15 +255,17 @@ "apply_error": "Не удалось применить изменения! Попробуйте снова.", "apply_ready": "Изменения сохранены! Примените, когда будете готовы.", "apply_changes": "Применить изменения", - "about_title": "О приложении", "about_tagline": "Ещё один клиент для Matrix.", "options": "Параметры", "clear_cache_title": "Очистить кэш и перезагрузить", "clear_cache_desc": "Удалить все локально сохранённые данные и загрузить заново с сервера.", "clear_cache": "Очистить кэш", + "legal": "Юридическое", + "privacy_policy_title": "Политика конфиденциальности", + "privacy_policy_desc": "Как обрабатываются ваши данные.", + "privacy_policy_open": "Открыть", "credits": "Благодарности", - "devtools_title": "Инструменты разработчика", "enable_devtools": "Включить инструменты разработчика", "access_token": "Токен доступа", @@ -388,7 +385,8 @@ "e2e_encryption": "Сквозное шифрование", "e2e_encryption_desc": "После включения эту функцию нельзя отключить после создания комнаты.", "rate_limited": "Сервер ограничил частоту запросов на {{minutes}} мин.!", - "create": "Создать" + "create": "Создать", + "close": "Закрыть" }, "Channels": { "no_spaces_title": "Пока нет сообществ", @@ -398,7 +396,14 @@ "pick_channel_desc": "Откройте канал из списка слева, чтобы начать читать.", "root_category": "Каналы", "workspace_switcher_aria": "Сменить сообщество", - "workspace_switcher_active_marker": "Текущее" + "workspace_switcher_create_space": "Создать сообщество", + "workspace_switcher_drag_to_close": "Потяните вниз, чтобы закрыть", + "workspace_switcher_member_count_one": "{{count}} участник", + "workspace_switcher_member_count_few": "{{count}} участника", + "workspace_switcher_member_count_many": "{{count}} участников", + "workspace_switcher_member_count_other": "{{count}} участника", + "workspace_footer_subtitle": "Сообщество", + "create_channel": "Создать канал" }, "Call": { "start": "Позвонить", @@ -423,7 +428,21 @@ "in_call": "В звонке", "in_call_count": "{{count}} в звонке", "connecting": "Соединение…", - "open_call_room": "Открыть чат звонка" + "open_call_room": "Открыть чат звонка", + "bubble_outgoing": "Исходящий звонок", + "bubble_incoming": "Входящий звонок", + "bubble_missed": "Пропущенный звонок", + "bubble_cancelled": "Отменённый звонок", + "bubble_ongoing": "Идёт звонок", + "bubble_in_progress": "Идёт сейчас…", + "bubble_missed_count_one": "{{count}} пропущенный звонок", + "bubble_missed_count_few": "{{count}} пропущенных звонка", + "bubble_missed_count_many": "{{count}} пропущенных звонков", + "bubble_cancelled_count_one": "{{count}} отменённый звонок", + "bubble_cancelled_count_few": "{{count}} отменённых звонка", + "bubble_cancelled_count_many": "{{count}} отменённых звонков", + "duration_minutes_seconds": "{{minutes}} мин {{seconds}} сек", + "duration_seconds": "{{seconds}} сек" }, "Room": { "drag_to_close": "Потянуть вверх чтобы закрыть", @@ -435,7 +454,6 @@ "jump_to_latest": "К последним", "today": "Сегодня", "yesterday": "Вчера", - "view_reactions": "Реакции", "read_receipts": "Подтверждения прочтения", "view_source": "Исходный код", @@ -447,7 +465,6 @@ "reply": "Ответить", "reply_in_thread": "Ответить в треде", "edit_message": "Редактировать", - "delete_message": "Удалить сообщение", "delete_confirm": "Это действие необратимо! Вы уверены, что хотите удалить это сообщение?", "reason": "Причина", @@ -455,7 +472,6 @@ "delete_error": "Не удалось удалить сообщение! Попробуйте снова.", "deleting": "Удаление...", "delete": "Удалить", - "report_message": "Пожаловаться", "report_desc": "Сообщить о нарушении на сервер, который может уведомить ответственных лиц для принятия мер.", "report_reason": "Причина", @@ -464,13 +480,11 @@ "reporting": "Отправка...", "report": "Пожаловаться", "no_reason": "Причина не указана", - "is_typing": " печатает...", "and": " и ", "are_typing": " печатают...", "others_count": "ещё {{count}}", "drop_typing": "Скрыть индикатор набора", - "members": "Участники", "members_count_one": "{{formattedCount}} участник", "members_count_few": "{{formattedCount}} участника", @@ -478,6 +492,12 @@ "members_count_other": "{{formattedCount}} участника", "hide_members": "Скрыть участников", "show_members": "Показать участников", + "members_pane_title": "Участники", + "members_sheet_title_one": "{{formattedCount}} участник", + "members_sheet_title_few": "{{formattedCount}} участника", + "members_sheet_title_many": "{{formattedCount}} участников", + "members_sheet_title_other": "{{formattedCount}} участника", + "open_members_of": "Открыть участников: {{name}}", "more_options": "Ещё", "close": "Закрыть", "search": "Поиск", @@ -489,23 +509,30 @@ "room_settings": "Настройки комнаты", "jump_to_time": "Перейти к дате", "leave_room": "Покинуть комнату", - "send_message": "Написать сообщение...", + "send_message_alt_1": "В одну строку или несколько...", + "send_message_alt_2": "Написать в эту минуту...", + "send_message_alt_3": "Не томи, пиши...", + "send_message_alt_4": "Эта строка сама себя не заполнит...", + "send_message_alt_5": "Ну так что?..", + "send_message_alt_6": "Никто не читает плейсхолдеры. Но вы прочитали...", + "send_message_alt_7": "Сюда буквы, пожалуйста...", + "send_message_alt_8": "Вы смотрите на плейсхолдер. Плейсхолдер смотрит на вас...", + "send_message_alt_9": "Поздравляю, вы в 3% людей, читающих плейсхолдеры...", + "send_message_alt_10": "Ну я подожду, подожду...", + "send_message_alt_11": "Только после вас...", "drop_files": "Перетащите файлы в \"{{name}}\"", "drag_drop_desc": "Перетащите файлы сюда или нажмите для выбора", - "pinned_messages": "Закреплённые сообщения", "no_pinned_messages": "Нет закреплённых сообщений", "no_pinned_messages_desc": "Пользователи с достаточным уровнем прав могут закреплять сообщения через контекстное меню.", "open": "Открыть", "failed_to_load": "Не удалось загрузить сообщение!", - "time_label": "Время", "date_label": "Дата", "preset": "Пресет", "beginning": "Начало", "open_timeline": "Открыть ленту", - "message_deleted": "Сообщение было удалено", "message_deleted_reason": "Сообщение было удалено. {{reason}}", "unsupported_message": "Неподдерживаемое сообщение", @@ -515,7 +542,6 @@ "broken_message": "Повреждённое сообщение", "empty_message": "Пустое сообщение", "edited": " (изменено)", - "thread_caption": "Тред", "thread_in_channel_subtitle": "в #{{channel}}", "thread_close": "Закрыть тред", @@ -538,18 +564,24 @@ "thread_summary_highlight_many": "{{count}} упоминаний", "thread_summary_highlight_other": "{{count}} упоминания", "no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате", - - "conversation_beginning": "Начало переписки.", - "created_by": "Комната создана @{{creator}} {{date}} {{time}}", - "invite_member": "Пригласить", - "open_old_room": "Открыть старую комнату", - "join_old_room": "Войти в старую комнату", + "empty_dm": "Самое сложное — первое сообщение.", + "empty_dm_alt_1": "С чего-то надо начать.", + "empty_dm_alt_2": "Кто-то должен написать первым.", + "empty_dm_alt_3": "Чистый лист. Ни одной опечатки — пока.", + "empty_group": "Группа создана. Кто первый?", + "empty_group_alt_1": "Здесь пока никто ничего не сказал.", + "empty_group_alt_2": "Тишина перед первым сообщением.", + "empty_group_alt_3": "Все в сборе — можно начинать.", + "empty_bridge": "Сообщения идут через мост с {{network}}.", + "empty_bridge_alt_1": "Этот чат соединён с {{network}}.", + "empty_bridge_alt_2": "Собеседник пишет из {{network}}.", + "empty_bridge_generic": "Сообщения идут через мост.", + "empty_encrypted": "Сообщения защищены сквозным шифрованием.", "leave_room_title": "Покинуть комнату", "leave_room_confirm": "Покинуть эту комнату?", "leave_room_error": "Не удалось покинуть комнату! {{error}}", "leaving": "Выход...", "leave": "Покинуть", - "member_broken": "Некорректное событие участия", "member_accepted_knock": "{{sender}} одобряет вступление {{user}}", "member_invited": "{{sender}} приглашает {{user}}", @@ -567,10 +599,7 @@ "member_name_removed": "{{user}} убирает отображаемое имя", "member_avatar_changed": "{{user}} меняет аватар", "member_avatar_removed": "{{user}} убирает аватар", - "member_no_change": "Событие участия без изменений", - - "member_ended_call": "{{user}} больше не в звонке", - "member_joined_call": "{{user}} теперь в звонке" + "member_no_change": "Событие участия без изменений" }, "Inbox": { "invite_title": "Пригласить", @@ -578,19 +607,15 @@ "user_id_placeholder": "@username:server", "reason_optional": "Причина (необязательно)", "invite_button": "Пригласить", - "notif_default": "По умолчанию", "notif_all_messages": "Все сообщения", "notif_mentions_keywords": "Упоминания и ключевые слова", "notif_mute": "Без уведомлений", - "unverified_device": "Неподтверждённое устройство", "unverified_devices": "Неподтверждённые устройства" }, - "Explore": { "explore_community": "Обзор сообществ", - "add_server": "Добавить сервер", "add_server_desc": "Укажите имя сервера для обзора публичных сообществ.", "server_name": "Имя сервера", @@ -598,13 +623,11 @@ "view": "Открыть", "featured": "Рекомендуемые", "servers": "Серверы", - "featured_by_client": "Рекомендации клиента", "featured_by_client_desc": "Подборка публичных комнат и пространств от этого клиента.", "featured_spaces": "Рекомендуемые пространства", "featured_rooms": "Рекомендуемые комнаты", "no_featured": "Рекомендуемых комнат и пространств пока нет.", - "search": "Поиск", "search_placeholder": "Поиск по ключевому слову", "clear": "Очистить", @@ -623,9 +646,11 @@ "previous_page": "Предыдущая", "next_page": "Следующая", "no_communities": "Сообщества не найдены!", - "space_badge": "Пространство", - "members_count": "{{count}} участников", + "members_count_one": "{{formattedCount}} участник", + "members_count_few": "{{formattedCount}} участника", + "members_count_many": "{{formattedCount}} участников", + "members_count_other": "{{formattedCount}} участника", "join": "Присоединиться", "joining": "Вступление…", "retry": "Повторить", @@ -634,7 +659,6 @@ "view_error": "Подробности", "cancel": "Отмена" }, - "Create": { "add_space": "Добавить пространство", "create_space": "Создать пространство", @@ -642,7 +666,6 @@ "join_with_address": "Присоединиться по адресу", "join_with_address_desc": "Присоединиться к существующему сообществу.", "new_space": "Новое пространство", - "access": "Доступ", "name": "Название", "topic_optional": "Тема (необязательно)", @@ -654,47 +677,38 @@ "allow_federation_desc": "Пользователи с других серверов смогут присоединиться.", "create": "Создать", "rate_limited": "Сервер ограничил ваш запрос на {{minutes}} мин.!", - "access_restricted": "Ограниченный", "access_restricted_desc": "Могут присоединиться только участники родительского пространства.", "access_private": "Приватный", "access_private_desc": "Могут присоединиться только приглашённые.", "access_public": "Публичный", "access_public_desc": "Любой, у кого есть адрес, может присоединиться.", - "address_optional": "Адрес (необязательно)", "address_hint": "Выберите уникальный адрес, чтобы пространство можно было найти.", "address_taken": "Этот адрес уже занят. Выберите другой.", - "founders": "Основатели", "founders_desc": "Привилегированные пользователи, назначенные при создании. Они имеют расширенные полномочия; изменить их можно только при обновлении пространства.", "enter": "Добавить", "no_suggestions": "Нет предложений", "no_suggestions_desc": "Введите ID пользователя и нажмите Добавить.", - "version": "Версия", "versions": "Версии", - "chat_room": "Чат-комната", "chat_room_desc": "Сообщения, фото и видео.", "voice_room": "Голосовая комната", "voice_room_desc": "Голосовые и видеозвонки в реальном времени.", - "new_chat_room": "Новая чат-комната", "new_voice_room": "Новая голосовая комната", - "existing_space": "Существующее пространство", "add_room": "Добавить комнату", "existing_room": "Существующая комната" }, - "RoomSettings": { "general": "Основные", "members": "Участники", "permissions": "Права доступа", "emojis_stickers": "Эмодзи и стикеры", "developer_tools": "Инструменты разработчика", - "profile": "Профиль", "edit": "Редактировать", "unknown": "Неизвестно", @@ -706,30 +720,25 @@ "topic": "Тема", "save": "Сохранить", "cancel": "Отмена", - "options": "Настройки", "addresses": "Адреса", "advanced_options": "Дополнительные настройки", - "space_access": "Доступ к пространству", "room_access": "Доступ к комнате", "space_access_desc": "Изменить способ вступления в пространство.", "room_access_desc": "Изменить способ вступления в комнату.", - "join_invite_only": "Только по приглашению", "join_knock_invite": "Запрос и приглашение", "join_space_members_or_knock": "Участники пространства или запрос", "join_space_members": "Участники пространства", "join_public": "Публичный", "join_unsupported": "Не поддерживается", - "history_visibility": "Видимость истории сообщений", "history_visibility_desc": "Изменения видимости истории применяются только к новым сообщениям и не затрагивают существующую историю.", "visibility_after_invite": "После приглашения", "visibility_after_join": "После вступления", "visibility_all_messages": "Все сообщения", "visibility_all_messages_guests": "Все сообщения (гости)", - "room_encryption": "Шифрование комнаты", "encryption_enabled_desc": "Сообщения в этой комнате защищены сквозным шифрованием.", "encryption_disabled_desc": "После включения шифрование невозможно отключить!", @@ -738,11 +747,9 @@ "enable_encryption": "Включить шифрование", "enable_encryption_confirm": "Вы уверены? После включения шифрование невозможно отключить!", "enable_e2e_encryption": "Включить E2E-шифрование", - "publish_to_directory": "Показывать в поиске", "publish_space_desc": "Сделать пространство видимым в общем списке, чтобы другие пользователи могли его найти.", "publish_room_desc": "Сделать комнату видимой в общем списке, чтобы другие пользователи могли её найти.", - "published_addresses": "Опубликованные адреса", "published_addresses_desc": "Если доступ публичный, опубликованные адреса будут использоваться для присоединения.", "no_addresses": "Нет адресов", @@ -756,13 +763,11 @@ "publish": "Опубликовать", "delete": "Удалить", "selected_count": "Выбрано: {{count}}", - "local_addresses": "Локальные адреса", "local_addresses_desc": "Задайте локальный адрес, чтобы пользователи могли присоединиться через ваш сервер.", "collapse": "Свернуть", "expand": "Развернуть", "loading": "Загрузка...", - "space_upgrade": "Обновление пространства", "room_upgrade": "Обновление комнаты", "upgrade": "Обновить", @@ -776,25 +781,21 @@ "old_room": "Старая комната", "open_new_space": "Открыть новое пространство", "open_new_room": "Открыть новую комнату", - "members_count": "{{count}} участников", "search": "Поиск", "no_results": "Ничего не найдено", "results_count": "{{count}} результатов", "scroll_to_top": "Наверх", "no_membership_members": "Нет участников «{{filter}}»", - "filter_joined": "Вступившие", "filter_invited": "Приглашённые", "filter_left": "Вышедшие", "filter_kicked": "Исключённые", "filter_banned": "Забаненные", - "sort_a_to_z": "А — Я", "sort_z_to_a": "Я — А", "sort_newest": "Новые", "sort_oldest": "Старые", - "perm_messages": "Сообщения", "perm_send_messages": "Отправка сообщений", "perm_send_stickers": "Отправка стикеров", @@ -827,12 +828,10 @@ "perm_manage_emojis_stickers": "Управление эмодзи и стикерами", "perm_change_server_acls": "Изменение ACL серверов", "perm_modify_widgets": "Изменение виджетов", - "founders": "Основатели", "founders_desc": "Основатели имеют все права. Изменить их состав можно только при обновлении комнаты.", "power_levels": "Уровни власти", "power_levels_desc": "Управление и настройка уровней власти для пользователей.", - "new_power_level": "Новый уровень власти", "power_level_placeholder": "Бот", "new_power_level_desc": "Создать новый уровень власти.", @@ -849,11 +848,9 @@ "failed_to_apply": "Не удалось применить изменения! Попробуйте ещё раз.", "apply_changes": "Применить изменения", "and_above": "и выше", - "users": "Пользователи", "default_power": "Уровень по умолчанию", "default_power_desc": "Уровень власти по умолчанию для всех пользователей.", - "packs": "Паки", "new_pack": "Новый пак", "new_pack_desc": "Добавьте свой пак эмодзи и стикеров для использования в комнате.", @@ -862,7 +859,6 @@ "view": "Открыть", "failed_to_remove_packs": "Не удалось удалить паки! Попробуйте ещё раз.", "delete_selected_packs": "Удалить выбранные паки. (Выбрано: {{count}})", - "enable_developer_tools": "Включить инструменты разработчика", "room_id": "ID комнаты", "room_id_desc": "Скопировать ID комнаты в буфер обмена.", @@ -885,7 +881,6 @@ "message_event_type": "Тип события сообщения", "send": "Отправить", "state_key_optional": "State Key (необязательно)", - "pack": "Пак", "images_usage": "Использование изображений", "images_usage_desc": "Выберите, как используются изображения: как эмодзи, как стикеры или как и то, и другое.", @@ -900,7 +895,6 @@ "usage_both": "Оба", "usage_sticker": "Стикер", "usage_emoji": "Эмодзи", - "power_goku": "Гоку", "power_manager": "Менеджер", "power_founder": "Основатель", @@ -910,7 +904,6 @@ "power_muted": "Без голоса", "power_team": "Команда" }, - "Push": { "new_message": "Новое сообщение", "new_messages": "Новые сообщения", @@ -921,7 +914,19 @@ "invite_body": "{{inviter}} приглашает вас в {{roomName}}", "invite_body_no_room": "{{inviter}} приглашает вас в комнату", "invite_body_no_inviter": "Приглашение в {{roomName}}", - "invite_body_generic": "Новое приглашение" + "invite_body_generic": "Новое приглашение", + "missed_call": "Пропущенный звонок", + "missed_call_body": "{{caller}} пытался вам дозвониться", + "channel_group": "Чаты", + "channel_dm": "Личные сообщения", + "channel_dm_description": "Новые сообщения из личных переписок", + "channel_group_room": "Групповые чаты", + "channel_group_room_description": "Новые сообщения из групповых чатов и каналов", + "self_name": "Я", + "action_mark_as_read": "Прочитано", + "action_reply": "Ответить", + "reply_hint": "Ответ…", + "reply_failed": "Не удалось отправить ответ" }, "Bots": { "not_connected_title": "{{name}} не подключён", @@ -996,5 +1001,15 @@ "copy_server": "Скопировать сервер", "explore_community": "Открыть сервер", "open_in_browser": "Открыть в браузере" + }, + "Share": { + "share_text": "Готово к пересылке: текст", + "share_image": "Готово к пересылке: изображение", + "share_video": "Готово к пересылке: видео", + "share_audio": "Готово к пересылке: аудио", + "share_file": "Готово к пересылке: {{name}}", + "share_files": "Готово к пересылке: {{count}} файлов", + "tap_chat_to_send": "Откройте чат, чтобы отправить", + "cancel": "Отменить" } } diff --git a/public/manifest.json b/public/manifest.json index 73399b33..df4f768f 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -7,8 +7,8 @@ "display": "standalone", "orientation": "portrait", "start_url": "./", - "background_color": "#000", - "theme_color": "#000", + "background_color": "#0d0e11", + "theme_color": "#0d0e11", "icons": [ { "src": "./public/android/vojo.svg", diff --git a/public/privacy.html b/public/privacy.html new file mode 100644 index 00000000..51451ab6 --- /dev/null +++ b/public/privacy.html @@ -0,0 +1,400 @@ + + + + + + + + Vojo — Privacy Policy + + + +
    + +
    +
    + + Vojo + · + Legal +
    + +

    Privacy Policy

    +

    Effective 13 May 2026

    + +
    + + + +
    +
    + +
    +

    This is the privacy policy for Vojo, a chat app built on the open + Matrix protocol. It's maintained by + the Vojo Project, an independent developer. If you have questions about anything + here, write to vojochatdev@gmail.com.

    + +

    We try to keep this short and readable. If something is unclear, ask.

    + +

    How Vojo works, briefly

    +

    Your messages, profile and rooms live on a Matrix server. By default that's + vojo.chat, which we run. You can also sign in to any other Matrix + server you trust — if you do, the operator of that server is the one holding your + data, not us.

    + +

    What we hold and what we use it for

    +

    To make the app work we keep the obvious things: your account, the messages and + rooms you send and receive, the media you share, and basic technical data (IP + address, connection times) generated when your device talks to our servers. Your + device also caches messages and keys locally so you can read them offline and stay + signed in.

    + +

    Direct conversations are end-to-end encrypted by default. In an encrypted room + we can see who's talking to whom and when, but not what they're saying. In an + unencrypted room we see the content too.

    + +

    Voice calls are encrypted between participants. When your device can't reach the + other side directly, the audio is relayed through our infrastructure on its way + through — we don't record it and we don't keep it.

    + +

    We use this data to run the service: deliver messages, sync your devices, ring + your phone for incoming calls, keep limited logs to fight abuse and spam. That's + the whole list. No advertising, no analytics, no resale, no profiling.

    + +

    Who else is involved

    +
      +
    • Our hosting provider. The + Hostinger + infrastructure carrying vojo.chat sits in the European Union.
    • +
    • Google's push service. Push notifications go through Google so your + phone can wake up and ring or buzz. For end-to-end encrypted chats the + notification only carries the routing info needed to fetch the message + locally — the content stays on your Matrix server. For unencrypted chats + Google may see a short preview (who, where, snippet). This is the only + routine reason data leaves the EU; we rely on the Standard Contractual + Clauses for that transfer.
    • +
    • Bot checks. Signing up, and a couple of optional features, briefly + load a third-party "are you a human" check. That provider sees your + interaction with the puzzle and is governed by its own privacy policy.
    • +
    • Optional bridges. If you choose to connect Telegram, Discord or + WhatsApp through Vojo, your messages with those networks have to pass + through bridge infrastructure we run, and the network itself sees them + too. None of this turns on unless you opt in.
    • +
    + +

    Permissions on your phone

    +

    On Android we ask for: the microphone (only used during calls); notifications + (so we can show you messages and ring for calls); permission to show calls over + the lock screen and to keep a call running with the screen off; and network access. + That's it. We don't touch your address book, photo library, SMS, precise location + or call log.

    + +

    How long we keep things

    +

    Your messages and account stay on your Matrix server until you delete them or + ask us to deactivate the account. Deletion is processed within about thirty days. + Server access logs are kept for no more than thirty days and then rotated out.

    + +

    Data cached on your device goes away when you uninstall Vojo or clear its data + in your phone's settings. Signing out ends your session but doesn't always scrub + every cached message immediately — the cleanest reset is an uninstall.

    + +

    Your rights

    +

    If you live in the EU/EEA (and in many other places the law works similarly), + you can ask us to show you what we hold, fix something that's wrong, delete your + data, hand it over in a portable form, or stop a particular use. You can also + withdraw any consent you've given for optional features, and complain to your + local data-protection authority if you think we're handling things badly. Email + the address at the top and we'll take it from there.

    + +

    Kids, changes, contact

    +

    Vojo isn't aimed at anyone under 16, and we don't knowingly collect data from + children. If we change this policy in a way that actually affects you, we'll + update the date above and try to flag it inside the app. The current version + always lives at vojo.chat/privacy. For + anything else: vojochatdev@gmail.com.

    +
    + + + + + +
    + + + + + diff --git a/public/res/img/mascot.webm b/public/res/img/mascot.webm index 46eebf1d..f2ae098a 100644 Binary files a/public/res/img/mascot.webm and b/public/res/img/mascot.webm differ diff --git a/public/res/store/feature-graphic.png b/public/res/store/feature-graphic.png new file mode 100644 index 00000000..dfff234c Binary files /dev/null and b/public/res/store/feature-graphic.png differ diff --git a/public/res/svg/vojo-highlight.svg b/public/res/svg/vojo-highlight.svg index 5b173fba..015b5701 100644 --- a/public/res/svg/vojo-highlight.svg +++ b/public/res/svg/vojo-highlight.svg @@ -1,5 +1,5 @@ - + diff --git a/public/res/svg/vojo-unread.svg b/public/res/svg/vojo-unread.svg index 019da1dd..35726143 100644 --- a/public/res/svg/vojo-unread.svg +++ b/public/res/svg/vojo-unread.svg @@ -1,5 +1,5 @@ - + diff --git a/public/res/svg/vojo.svg b/public/res/svg/vojo.svg index 5e999513..5f42d69e 100644 --- a/public/res/svg/vojo.svg +++ b/public/res/svg/vojo.svg @@ -1,4 +1,4 @@ - + diff --git a/scripts/gen-push-strings.mjs b/scripts/gen-push-strings.mjs index 2a1780dc..03d97340 100644 --- a/scripts/gen-push-strings.mjs +++ b/scripts/gen-push-strings.mjs @@ -51,6 +51,18 @@ const ANDROID_KEYS = [ 'invite_body_no_room', 'invite_body_no_inviter', 'invite_body_generic', + 'missed_call', + 'missed_call_body', + 'channel_group', + 'channel_dm', + 'channel_dm_description', + 'channel_group_room', + 'channel_group_room_description', + 'self_name', + 'action_mark_as_read', + 'action_reply', + 'reply_hint', + 'reply_failed', ]; // i18next uses named placeholders ({{inviter}}); Android string resources @@ -59,9 +71,13 @@ const ANDROID_KEYS = [ // inviter, roomName) always passes inviter in position 1, roomName in // position 2, regardless of how the translators order them in the JSON. // Adding a new placeholder: add it here AND update PushStrings accordingly. +// `caller` reuses position 1: it only appears in missed_call_body, which +// has no other placeholders, so the position assignment is keyed per-key +// in practice — the table just enumerates every placeholder name we accept. const PLACEHOLDER_POSITIONS = { inviter: 1, roomName: 2, + caller: 1, }; const LANGS = { @@ -115,7 +131,7 @@ function verifyParity(bundles) { const locales = Object.keys(bundles); const [first, ...rest] = locales; const firstKeys = new Set(Object.keys(bundles[first])); - for (const locale of rest) { + rest.forEach((locale) => { const keys = new Set(Object.keys(bundles[locale])); const missingInOther = [...firstKeys].filter((k) => !keys.has(k)); const extraInOther = [...keys].filter((k) => !firstKeys.has(k)); @@ -126,13 +142,13 @@ function verifyParity(bundles) { ` Extra in ${locale}: ${JSON.stringify(extraInOther)}` ); } - } - for (const key of ANDROID_KEYS) { - for (const locale of locales) { + }); + ANDROID_KEYS.forEach((key) => { + locales.forEach((locale) => { if (typeof bundles[locale][key] !== 'string') { throw new Error(`Push.${key} missing or non-string in ${locale}.json`); } - } + }); // Placeholder tokens must match across locales for any given key — // a translator adding {{user}} on one side silently produces // literal-curly-brace output on the other surface. @@ -146,7 +162,7 @@ function verifyParity(bundles) { return { locale, tokens }; }); const baseline = tokenSets[0]; - for (const entry of tokenSets.slice(1)) { + tokenSets.slice(1).forEach((entry) => { const baselineArr = [...baseline.tokens].sort(); const entryArr = [...entry.tokens].sort(); if (baselineArr.length !== entryArr.length || baselineArr.some((t, i) => t !== entryArr[i])) { @@ -156,8 +172,8 @@ function verifyParity(bundles) { `${entry.locale}=${JSON.stringify(entryArr)}` ); } - } - } + }); + }); } function emitResource(locale, bundle, resDir) { @@ -170,12 +186,12 @@ function emitResource(locale, bundle, resDir) { '-->', '', ]; - for (const key of ANDROID_KEYS) { + ANDROID_KEYS.forEach((key) => { const raw = bundle[key]; const { text, placeholders } = convertPlaceholders(raw, locale, key); const formattedAttr = placeholders.size > 0 ? ' formatted="true"' : ''; lines.push(` ${xmlEscape(text)}`); - } + }); lines.push(''); lines.push(''); const outPath = path.join(resDir, LANGS[locale], 'push_strings.xml'); @@ -191,15 +207,15 @@ function main() { } const resDir = outIdx !== -1 ? path.resolve(process.argv[outIdx + 1]) : DEFAULT_OUT; - const bundles = {}; - for (const locale of Object.keys(LANGS)) { - bundles[locale] = readBundle(locale); - } + const bundles = Object.keys(LANGS).reduce((acc, locale) => { + acc[locale] = readBundle(locale); + return acc; + }, {}); verifyParity(bundles); - for (const locale of Object.keys(LANGS)) { + Object.keys(LANGS).forEach((locale) => { const outPath = emitResource(locale, bundles[locale], resDir); process.stdout.write(` wrote ${path.relative(ROOT, outPath)}\n`); - } + }); } try { diff --git a/src/app/components/ActionUIA.tsx b/src/app/components/ActionUIA.tsx index b9cd122f..608f283e 100644 --- a/src/app/components/ActionUIA.tsx +++ b/src/app/components/ActionUIA.tsx @@ -36,7 +36,7 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA > {stageToComplete.type === AuthType.Password && ( ( authData: IAuthData, performAction: PerformAction, resolve: (data: T) => void, - reject: (error?: any) => void + reject: (error?: unknown) => void ): UIAAction { const action: UIAAction = { authData, diff --git a/src/app/components/ImageOverlay.tsx b/src/app/components/ImageOverlay.tsx index ea690924..d87f934b 100644 --- a/src/app/components/ImageOverlay.tsx +++ b/src/app/components/ImageOverlay.tsx @@ -30,7 +30,7 @@ export const ImageOverlay = as<'div', ImageOverlayProps>( evt.stopPropagation()} + onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()} > {renderViewer({ src, diff --git a/src/app/components/Modal500.tsx b/src/app/components/Modal500.tsx index a4c3128c..43dca162 100644 --- a/src/app/components/Modal500.tsx +++ b/src/app/components/Modal500.tsx @@ -20,7 +20,17 @@ export function Modal500({ requestClose, children }: Modal500Props) { escapeDeactivates: stopPropagation, }} > - + {/* PageRoot rendered inside the dialog (Settings, SpaceSettings, RoomSettings) would otherwise pick up the web horseshoe layout — void column + rounded diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 71700e82..79a756ef 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -1,4 +1,4 @@ -import React, { MouseEventHandler, createContext, useContext } from 'react'; +import React, { createContext, useContext } from 'react'; import { MsgType } from 'matrix-js-sdk'; import { HTMLReactParserOptions } from 'html-react-parser'; import { Opts } from 'linkifyjs'; @@ -43,11 +43,10 @@ import { logMedia } from './message/attachment/streamMediaDebug'; // in the timeline; pin-menu / message-search leave it null and fall back // to the legacy MImage / MVideo Attachment chrome. export type StreamMediaContextValue = { + // Only `own` survives — it drives the bubble's asymmetric notch corner. The + // sender nick used to be overlaid on the media via this context, but it's + // now rendered ABOVE the media by the Stream name header (like text). own: boolean; - username: string; - senderId: string; - onUsernameClick: MouseEventHandler; - onUsernameContextMenu: MouseEventHandler; }; export const StreamMediaContext = createContext(null); export const useStreamMediaContext = (): StreamMediaContextValue | null => @@ -65,6 +64,12 @@ type RenderMessageContentProps = { htmlReactParserOptions: HTMLReactParserOptions; linkifyOpts: Opts; outlineAttachment?: boolean; + // Threaded into `ImageContent` so its onClick can open the new + // atom-driven horseshoe media viewer instead of the legacy + // `` modal. Set by `RoomTimeline`; non-Room callers + // (pin-menu, message search) leave it undefined and stay on the + // legacy modal until they're migrated. + eventId?: string; }; export function RenderMessageContent({ displayName, @@ -78,6 +83,7 @@ export function RenderMessageContent({ htmlReactParserOptions, linkifyOpts, outlineAttachment, + eventId, }: RenderMessageContentProps) { const streamMedia = useStreamMediaContext(); const renderUrlsPreview = (urls: string[]) => { @@ -219,6 +225,7 @@ export function RenderMessageContent({ } renderViewer={(p) => } /> @@ -229,10 +236,6 @@ export function RenderMessageContent({ ) : ( @@ -258,6 +261,7 @@ export function RenderMessageContent({ body={body} info={info} {...props} + eventId={eventId} renderThumbnail={ mediaAutoLoad ? () => ( @@ -279,10 +283,6 @@ export function RenderMessageContent({ diff --git a/src/app/components/SecretStorage.tsx b/src/app/components/SecretStorage.tsx index 9d8628e5..815f5fbc 100644 --- a/src/app/components/SecretStorage.tsx +++ b/src/app/components/SecretStorage.tsx @@ -39,6 +39,8 @@ export function SecretStorageRecoveryPassphrase({ bits ); + // matrix-js-sdk wants SecretStorageKeyDescriptionAesV1; our local type is structurally compatible but distinct. + // eslint-disable-next-line @typescript-eslint/no-explicit-any const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any); if (!match) { @@ -131,6 +133,8 @@ export function SecretStorageRecoveryKey({ async (recoveryKey) => { const decodedRecoveryKey = decodeRecoveryKey(recoveryKey); + // matrix-js-sdk wants SecretStorageKeyDescriptionAesV1; our local type is structurally compatible but distinct. + // eslint-disable-next-line @typescript-eslint/no-explicit-any const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any); if (!match) { diff --git a/src/app/components/ServerConfigsLoader.tsx b/src/app/components/ServerConfigsLoader.tsx index 3c8ce8eb..ddd198bb 100644 --- a/src/app/components/ServerConfigsLoader.tsx +++ b/src/app/components/ServerConfigsLoader.tsx @@ -34,6 +34,9 @@ export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) { try { validatedAuthMetadata = validateAuthMetadata(authMetadata); } catch (e) { + // Auth-metadata parsing failure is non-fatal; the client falls + // back to legacy `.well-known` discovery. Surface to dev console. + // eslint-disable-next-line no-console console.error(e); } diff --git a/src/app/components/create-room/utils.ts b/src/app/components/create-room/utils.ts index f3e699aa..4f7d6be1 100644 --- a/src/app/components/create-room/utils.ts +++ b/src/app/components/create-room/utils.ts @@ -6,6 +6,7 @@ import { RestrictedAllowType, Room, } from 'matrix-js-sdk'; +import type { StateEvents } from 'matrix-js-sdk'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { RoomType, StateEvent } from '../../../types/matrix/room'; import { getViaServers } from '../../plugins/via-servers'; @@ -17,7 +18,7 @@ export const createRoomCreationContent = ( allowFederation: boolean, additionalCreators: string[] | undefined ): object => { - const content: Record = {}; + const content: Record = {}; if (typeof type === 'string') { content.type = type; } @@ -152,11 +153,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis if (data.parent) { await mx.sendStateEvent( data.parent.roomId, - StateEvent.SpaceChild as any, + StateEvent.SpaceChild as keyof StateEvents, { auto_join: false, suggested: false, - via: [getMxIdServer(mx.getUserId() ?? '') ?? ''], + via: [getMxIdServer(mx.getSafeUserId()) ?? ''], }, result.room_id ); diff --git a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx index d358ff7d..a2387eb8 100644 --- a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx @@ -11,7 +11,8 @@ import { onTabPress } from '../../../utils/keyboard'; import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; import { useRelevantImagePacks } from '../../../hooks/useImagePacks'; -import { IEmoji, emojis } from '../../../plugins/emoji'; +import { IEmoji } from '../../../plugins/emoji'; +import { emojis } from '../../../plugins/emoji-data'; import { useKeyDown } from '../../../hooks/useKeyDown'; import { mxcUrlToHttp } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; diff --git a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx index 377cecab..de5b9b10 100644 --- a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx @@ -24,7 +24,7 @@ type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void; const roomAliasFromQueryText = (mx: MatrixClient, text: string) => isRoomAlias(`#${text}`) ? `#${text}` - : `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`; + : `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getSafeUserId())}`; function UnknownRoomMentionItem({ query, diff --git a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx index 7a8012eb..dc446137 100644 --- a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx @@ -26,7 +26,7 @@ type MentionAutoCompleteHandler = (userId: string, name: string) => void; const userIdFromQueryText = (mx: MatrixClient, text: string) => isUserId(`@${text}`) ? `@${text}` - : `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`; + : `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getSafeUserId())}`; function UnknownMentionItem({ userId, @@ -92,7 +92,7 @@ export function UserMentionAutocomplete({ }: UserMentionAutocompleteProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const roomId: string = room.roomId!; + const { roomId } = room; const roomAliasOrId = room.getCanonicalAlias() || roomId; const members = useRoomMembers(mx, roomId); diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index d5a76c71..be10a212 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -15,7 +15,8 @@ import { isKeyHotkey } from 'is-hotkey'; import { Room } from 'matrix-js-sdk'; import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { IEmoji, emojiGroups, emojis } from '../../plugins/emoji'; +import { IEmoji } from '../../plugins/emoji'; +import { emojiGroups, emojis } from '../../plugins/emoji-data'; import { useEmojiGroupLabels } from './useEmojiGroupLabels'; import { useEmojiGroupIcons } from './useEmojiGroupIcons'; import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard'; diff --git a/src/app/components/event-readers/EventReaders.tsx b/src/app/components/event-readers/EventReaders.tsx index c7900237..fbbba81c 100644 --- a/src/app/components/event-readers/EventReaders.tsx +++ b/src/app/components/event-readers/EventReaders.tsx @@ -79,7 +79,7 @@ export const EventReaders = as<'div', EventReadersProps>( key={readerId} style={{ padding: `0 ${config.space.S200}` }} radii="400" - onClick={(event) => { + onClick={(event: React.MouseEvent) => { openProfile( room.roomId, space?.roomId, diff --git a/src/app/components/image-pack-view/RoomImagePack.tsx b/src/app/components/image-pack-view/RoomImagePack.tsx index 92b4ff21..50e32ad7 100644 --- a/src/app/components/image-pack-view/RoomImagePack.tsx +++ b/src/app/components/image-pack-view/RoomImagePack.tsx @@ -17,7 +17,7 @@ type RoomImagePackProps = { export function RoomImagePack({ room, stateKey }: RoomImagePackProps) { const mx = useMatrixClient(); - const userId = mx.getUserId()!; + const userId = mx.getSafeUserId(); const powerLevels = usePowerLevels(room); const creators = useRoomCreators(room); diff --git a/src/app/components/image-pack-view/UserImagePack.tsx b/src/app/components/image-pack-view/UserImagePack.tsx index 4987793d..2c46b07c 100644 --- a/src/app/components/image-pack-view/UserImagePack.tsx +++ b/src/app/components/image-pack-view/UserImagePack.tsx @@ -8,7 +8,7 @@ import { useUserImagePack } from '../../hooks/useImagePacks'; export function UserImagePack() { const mx = useMatrixClient(); - const defaultPack = useMemo(() => new ImagePack(mx.getUserId() ?? '', {}, undefined), [mx]); + const defaultPack = useMemo(() => new ImagePack(mx.getSafeUserId(), {}, undefined), [mx]); const imagePack = useUserImagePack(); const handleUpdate = useCallback( diff --git a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx b/src/app/components/join-address-prompt/JoinAddressPrompt.tsx deleted file mode 100644 index cc243a21..00000000 --- a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import React, { FormEventHandler, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import FocusTrap from 'focus-trap-react'; -import { - Dialog, - Overlay, - OverlayCenter, - OverlayBackdrop, - Header, - config, - Box, - Text, - IconButton, - Icon, - Icons, - Button, - Input, - color, -} from 'folds'; -import { stopPropagation } from '../../utils/keyboard'; -import { isRoomAlias, isRoomId } from '../../utils/matrix'; -import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to'; -import { tryDecodeURIComponent } from '../../utils/dom'; - -type JoinAddressProps = { - onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void; - onCancel: () => void; -}; -export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) { - const { t } = useTranslation(); - const [invalid, setInvalid] = useState(false); - - const handleSubmit: FormEventHandler = (evt) => { - evt.preventDefault(); - setInvalid(false); - - const target = evt.target as HTMLFormElement | undefined; - const addressInput = target?.addressInput as HTMLInputElement | undefined; - const address = addressInput?.value.trim(); - if (!address) return; - - if (isRoomId(address) || isRoomAlias(address)) { - onOpen(address); - return; - } - - if (testMatrixTo(address)) { - const decodedAddress = tryDecodeURIComponent(address); - const toRoom = parseMatrixToRoom(decodedAddress); - if (toRoom) { - onOpen(toRoom.roomIdOrAlias, toRoom.viaServers); - return; - } - - const toEvent = parseMatrixToRoomEvent(decodedAddress); - if (toEvent) { - onOpen(toEvent.roomIdOrAlias, toEvent.viaServers, toEvent.eventId); - return; - } - } - - setInvalid(true); - }; - - return ( - }> - - - -
    - - {t('Home.join_with_address')} - - - - -
    - - - - {t('Home.join_address_desc')} - - -
  3. #community:server
  4. -
  5. https://matrix.to/#/#community:server
  6. -
  7. https://matrix.to/#/!xYzAj?via=server
  8. -
    -
    - - {t('Home.address')} - - {invalid && ( - - {t('Home.invalid_address')} - - )} - - -
    -
    -
    -
    -
    - ); -} diff --git a/src/app/components/join-address-prompt/index.ts b/src/app/components/join-address-prompt/index.ts deleted file mode 100644 index b14b8a61..00000000 --- a/src/app/components/join-address-prompt/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './JoinAddressPrompt'; diff --git a/src/app/components/members-list/MembersList.tsx b/src/app/components/members-list/MembersList.tsx new file mode 100644 index 00000000..91602622 --- /dev/null +++ b/src/app/components/members-list/MembersList.tsx @@ -0,0 +1,199 @@ +// Dawn-styled members sheet body. Renders a centred room hero +// (avatar + name + count + e2ee + optional topic) and a flat +// power-tag-grouped list of joined members. Mirrors the visual +// language of the 1:1 peer-profile sheet (`UserHero` + info table) +// so the two sheets read as one design system. +// +// No internal scroll container — the host (mobile horseshoe or +// desktop side pane) wraps this in its own scroll surface. That +// lets the host measure the natural content height for content-fit +// rail sizing. +// +// Tap on a row opens the per-user profile via `useOpenUserRoomProfile`. +// Atom mutual-exclusion (`useOpenUserRoomProfile` clears the members +// atom) routes the transition cleanly: on desktop the right-pane +// content swaps; on mobile group the same horseshoe silhouette +// switches body (handled in `RoomViewMembersPanel`). +// +// Search / filter / sort were intentionally not ported — product asked +// for a clean grouped list first. The legacy +// `features/room/MembersDrawer.tsx` keeps those affordances and is +// still used by `features/lobby/Lobby.tsx` for space lobbies. + +import React, { useMemo } from 'react'; +import { Avatar, Icon, Icons, Text } from 'folds'; +import { Room, RoomMember } from 'matrix-js-sdk'; +import classNames from 'classnames'; +import { TypingIndicator } from '../typing-indicator'; +import { UserAvatar } from '../user-avatar'; +import { Membership, MemberPowerTag } from '../../../types/matrix/room'; +import { GetMemberPowerTag, useFlattenPowerTagMembers } from '../../hooks/useMemberPowerTag'; +import { useMemberPowerSort } from '../../hooks/useMemberSort'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers'; +import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { useSpaceOptionally } from '../../hooks/useSpace'; +import { useGetMemberPowerLevel, usePowerLevelsContext } from '../../hooks/usePowerLevels'; +import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { getMemberDisplayName } from '../../utils/room'; +import { getMxIdLocalPart } from '../../utils/matrix'; +import { RoomMembersHero } from './RoomMembersHero'; +import * as css from './styles.css'; + +type MembersListProps = { + room: Room; + members: RoomMember[]; + getPowerTag: GetMemberPowerTag; + // Forwarded to `RoomMembersHero` — tap on hero avatar opens + // full-view. Only the mobile horseshoe wires this; the desktop + // side pane doesn't get an avatar-zoom path (the right-pane chrome + // is fixed-width so there's no «expand to silhouette»). + onHeroAvatarClick?: () => void; +}; + +type MemberRowProps = { + room: Room; + member: RoomMember; + typing: boolean; + onOpenProfile: (userId: string, anchor: HTMLButtonElement) => void; +}; +function MemberRow({ room, member, typing, onOpenProfile }: MemberRowProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const name = + getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId; + // First-name display per design: only what fits before the first space + // (or the whole string if no space). Keeps each row narrow against the + // ~240 px panel without horizontal ellipsis kicking in immediately. + const shortName = name.split(/\s+/)[0] ?? name; + const avatarMxcUrl = member.getMxcAvatarUrl(); + const avatarUrl = avatarMxcUrl + ? mx.mxcUrlToHttp(avatarMxcUrl, 64, 64, 'crop', undefined, false, useAuthentication) + : undefined; + + return ( + + ); +} + +export function MembersList({ room, members, getPowerTag, onHeroAvatarClick }: MembersListProps) { + const openUserRoomProfile = useOpenUserRoomProfile(); + const space = useSpaceOptionally(); + const typingMembers = useRoomTypingMember(room.roomId); + + // Filter + sort BEFORE flatten — `useFlattenPowerTagMembers` emits a + // new group header whenever the current member's tag differs from the + // previous one's tag. Feeding it an unsorted list produces duplicate + // role headers ("admin · 1, member · 5, admin · 1") and the row + // counts go wrong. Mirrors the legacy `MembersDrawer.tsx::filteredMembers` + // pipeline (filter joined-only → sort by power). + // + // Joined-only matches the design canon's "Участники · 28" — banned / + // kicked / left memberships have their own surfaces in moderation + // settings, not in the members sheet. + const powerLevels = usePowerLevelsContext(); + const creators = useRoomCreators(room); + const getPowerLevel = useGetMemberPowerLevel(powerLevels); + const memberPowerSort = useMemberPowerSort(creators, getPowerLevel); + const processedMembers = useMemo( + () => members.filter((m) => m.membership === Membership.Join).sort(memberPowerSort), + [members, memberPowerSort] + ); + const flat = useFlattenPowerTagMembers(processedMembers, getPowerTag); + + // Index of the first tag entry in the flat list — used to drop the + // top padding on the first section header so it lines up tight with + // the rule above (the List's top border). + const firstTagIndex = flat.findIndex((entry) => !('userId' in entry)); + + // Per-tag member count for the section header — cheap (single pass) + // and lets the design's "admin · 1 / mod · 1 / участники · 22" labels + // read truthfully. + const tagCounts = useMemo(() => { + const counts = new Map(); + flat.forEach((entry) => { + if ('userId' in entry) return; + counts.set(entry, 0); + }); + let currentTag: MemberPowerTag | undefined; + flat.forEach((entry) => { + if (!('userId' in entry)) { + currentTag = entry; + return; + } + if (currentTag) counts.set(currentTag, (counts.get(currentTag) ?? 0) + 1); + }); + return counts; + }, [flat]); + + const typingByUser = useMemo(() => { + const set = new Set(); + typingMembers.forEach((receipt) => set.add(receipt.userId)); + return set; + }, [typingMembers]); + + const handleOpenProfile = (userId: string, anchor: HTMLButtonElement) => { + openUserRoomProfile(room.roomId, space?.roomId, userId, anchor.getBoundingClientRect(), 'Left'); + }; + + return ( +
    + +
    + {flat.map((entry, idx) => { + if (!('userId' in entry)) { + const count = tagCounts.get(entry) ?? 0; + return ( +
    + + {entry.name} + + + {count} + +
    + ); + } + return ( + + ); + })} +
    +
    + ); +} diff --git a/src/app/components/members-list/RoomMembersHero.tsx b/src/app/components/members-list/RoomMembersHero.tsx new file mode 100644 index 00000000..9e9226d3 --- /dev/null +++ b/src/app/components/members-list/RoomMembersHero.tsx @@ -0,0 +1,131 @@ +// Centred hero block on top of the group-room members sheet — +// counterpart of `UserHero` from the 1:1 peer-profile sheet. +// Renders: large gradient room avatar, room name, subline («N +// members · e2ee») and optional topic clamped to a few lines. +// Consumed by both the mobile horseshoe (`RoomViewMembersPanel`) +// and the desktop right-side pane (`RoomViewMembersSidePanel`) +// via `MembersList`. + +import React from 'react'; +import { Box, Icon, Icons, Text } from 'folds'; +import classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; +import { Room } from 'matrix-js-sdk'; +import { RoomAvatar, RoomIcon } from '../room-avatar'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta'; +import { useRoomMemberCount } from '../../hooks/useRoomMemberCount'; +import { useStateEvent } from '../../hooks/useStateEvent'; +import { StateEvent } from '../../../types/matrix/room'; +import { mxcUrlToHttp } from '../../utils/matrix'; +import { millify } from '../../plugins/millify'; +import { BreakWord, LineClamp3 } from '../../styles/Text.css'; +import * as css from './styles.css'; + +type RoomMembersHeroProps = { + room: Room; + // Optional avatar-tap handler. When provided the avatar surface + // becomes a button (one tab stop, focus ring). The host decides + // what tap does — typically swap the panel body to a full-view of + // the avatar, mirroring the 1:1 profile-horseshoe behaviour. + onAvatarClick?: () => void; +}; + +export function RoomMembersHero({ room, onAvatarClick }: RoomMembersHeroProps) { + const { t } = useTranslation(); + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + + // Group sheet: use the room's own avatar, NOT the peer fallback — + // the 1:1 peer-fallback path lives in `RoomViewHeaderDm` for the + // chat header, and here we're showing the room itself. + // + // No width/height passed to `mxcUrlToHttp` — same as `UserRoomProfile` + // does for the 1:1 hero. Synapse returns the original upload, the + // browser scales it via CSS without resampling artefacts. Asking for + // a small thumbnail (e.g. 192x192) routes through Synapse's JPEG + // recompression pipeline and ships visibly «pixelated» avatars on + // high-DPR phones (96 CSS px = 192–288 native px on 2x/3x screens, + // so the thumb is right at the edge before the recompression hits). + const avatarMxc = useRoomAvatar(room, false); + const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; + const name = useRoomName(room); + const topic = useRoomTopic(room); + const memberCount = useRoomMemberCount(room); + const encrypted = !!useStateEvent(room, StateEvent.RoomEncryption); + + // Drop the folds `` wrapper here on purpose — + // `RoomAvatar` already renders the folds `AvatarImage` / `AvatarFallback` + // primitives with our `.RoomAvatar` class that fills the parent + // (`width: 100% / height: 100%`). Wrapping it in another sized + // `` double-wraps the surface and the inner avatar ends up + // smaller than its container, drifting off-centre. Mirrors the + // pattern `UserHero` uses for the 1:1 profile sheet. + const avatarNode = ( + + ( + + )} + /> + + ); + + return ( + + {onAvatarClick ? ( + + ) : ( + avatarNode + )} + + + {name} + + + + + {t('Room.members_sheet_title', { + count: memberCount, + formattedCount: millify(memberCount), + })} + + {encrypted && ( + <> + + · + + + + + {t('Room.encrypted_short')} + + + + )} + + + {topic && ( + + {topic} + + )} + + ); +} diff --git a/src/app/components/members-list/styles.css.ts b/src/app/components/members-list/styles.css.ts new file mode 100644 index 00000000..c726ad55 --- /dev/null +++ b/src/app/components/members-list/styles.css.ts @@ -0,0 +1,187 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +// Outer column that owns the sheet body — hero + group list. The host +// (mobile horseshoe / desktop side pane) decides the surrounding +// scroll strategy; this component just renders content flat so the +// host can measure its `scrollHeight` for content-fit rail sizing. +export const Root = style({ + display: 'flex', + flexDirection: 'column', + width: '100%', +}); + +// ── Hero ──────────────────────────────────────────────────────── +// +// Centred avatar + name + subline + topic. Visual rhyme with +// `UserHero` from the 1:1 peer profile sheet — same gap rhythm and +// padding so the two sheets read as one design system. +export const HeroRoot = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: `${config.space.S500} ${config.space.S400} ${config.space.S300}`, + width: '100%', + gap: toRem(8), +}); + +// Avatar wrapper — circular 96 px to mirror the 1:1 `UserHero`. The +// `RoomAvatar` primitive renders as an absolutely-filling image / +// fallback inside this fixed-size container, so the avatar is its +// own boundary and stays exactly centred under the column. Using +// `display: block` (not inline-flex) avoids inline-baseline padding +// that can offset the visual centre by 1-2 px on some browsers. +// `boxShadow` ring matches the Dawn outline rhythm used by +// `UserHero` (chat-list rows, avatar surface plate). +export const HeroAvatar = style({ + position: 'relative', + display: 'block', + width: toRem(96), + height: toRem(96), + borderRadius: '50%', + flexShrink: 0, + boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Background.Container}`, +}); + +// Native button chrome reset for the tap-to-zoom avatar wrapper. +// Mirrors `UserHero.HeroAvatarButton`. Keeps the visible avatar +// pixel-identical to the non-clickable path so the user can't tell +// «is this tappable?» from look alone — the focus ring on +// keyboard-tab is the affordance. +export const HeroAvatarButton = style({ + display: 'inline-flex', + background: 'transparent', + border: 'none', + padding: 0, + margin: 0, + cursor: 'pointer', +}); + +// Subline row beneath the name — «N members · e2ee» layout mirrors +// the user-profile hero's presence + e2ee row. +export const HeroSubline = style({ + width: '100%', + justifyContent: 'center', + flexWrap: 'wrap', +}); + +export const HeroMembersCount = style({ + color: color.Surface.OnContainer, + opacity: 0.7, +}); + +export const HeroBullet = style({ + color: color.Surface.ContainerLine, +}); + +export const HeroE2ee = style({ + display: 'inline-flex', + alignItems: 'center', + gap: toRem(4), + color: color.Success.Main, +}); + +// Optional topic line — muted secondary text, clamped to keep the +// hero compact. Long topics overflow into the inner list scroll +// only if the user keeps reading via the host's scroll handle. +export const HeroTopic = style({ + marginTop: toRem(4), + color: color.Surface.OnContainer, + opacity: 0.65, + maxWidth: toRem(360), +}); + +// ── List ──────────────────────────────────────────────────────── + +// Spans the full width below the hero. Top border separates the +// hero block from the role-grouped list — same divider rhythm as +// the user-profile `InfoSection` rule above the chips. +export const List = style({ + display: 'flex', + flexDirection: 'column', + padding: `0 0 ${config.space.S200}`, + borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, +}); + +// Group divider — uppercase muted label + numeric count. Spaced apart +// to match `stream-v2-dawn.jsx` ("ADMIN · 1", "MOD · 1", "BOTS · 1", +// "УЧАСТНИКИ · 22", "ГОСТИ · 4"). +export const GroupLabel = style({ + display: 'flex', + alignItems: 'baseline', + justifyContent: 'space-between', + padding: `${config.space.S400} ${config.space.S400} ${config.space.S200}`, +}); + +export const GroupLabelFirst = style({ + paddingTop: config.space.S300, +}); + +export const GroupLabelText = style({ + color: color.Surface.OnContainer, + textTransform: 'uppercase', + letterSpacing: toRem(1), + fontWeight: 600, + opacity: 0.7, +}); + +export const GroupLabelCount = style({ + color: color.Surface.OnContainer, + fontVariantNumeric: 'tabular-nums', + opacity: 0.5, +}); + +// Member row — full-width button so the entire surface is tappable +// for opening the per-user profile. `text-align: left` because the +// ` -
- )} {children}
); diff --git a/src/app/components/message/attachment/StreamMediaVideo.tsx b/src/app/components/message/attachment/StreamMediaVideo.tsx index 37b5e950..a67419fc 100644 --- a/src/app/components/message/attachment/StreamMediaVideo.tsx +++ b/src/app/components/message/attachment/StreamMediaVideo.tsx @@ -1,4 +1,4 @@ -import React, { MouseEventHandler, ReactNode } from 'react'; +import React, { ReactNode } from 'react'; import { IVideoContent, MATRIX_SPOILER_PROPERTY_NAME, @@ -11,10 +11,6 @@ import { StreamMediaShell } from './StreamMediaShell'; export type StreamMediaVideoProps = { content: IVideoContent; own: boolean; - overlay?: ReactNode; - onUsernameClick?: MouseEventHandler; - onUsernameContextMenu?: MouseEventHandler; - senderId?: string; renderAsFile: () => ReactNode; renderVideoContent: (props: RenderVideoContentProps) => ReactNode; }; @@ -22,10 +18,6 @@ export type StreamMediaVideoProps = { export function StreamMediaVideo({ content, own, - overlay, - onUsernameClick, - onUsernameContextMenu, - senderId, renderAsFile, renderVideoContent, }: StreamMediaVideoProps) { @@ -42,15 +34,7 @@ export function StreamMediaVideo({ } return ( - + {renderVideoContent({ body: content.body || 'Video', info: videoInfo, diff --git a/src/app/components/message/content/FileContent.tsx b/src/app/components/message/content/FileContent.tsx index 7e127f2a..b6ae97e3 100644 --- a/src/app/components/message/content/FileContent.tsx +++ b/src/app/components/message/content/FileContent.tsx @@ -114,7 +114,7 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea evt.stopPropagation()} + onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()} > {renderViewer({ name: body, @@ -203,7 +203,7 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read evt.stopPropagation()} + onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()} > {renderViewer({ name: body, diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 4f1d9c75..bf10c0ad 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -31,6 +31,8 @@ import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../util import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { ModalWide } from '../../../styles/Modal.css'; import { validBlurHash } from '../../../utils/blurHash'; +import { useMediaViewerHost } from '../../../features/room/mediaViewerHostContext'; +import { useOpenMediaViewer } from '../../../state/hooks/mediaViewer'; type RenderViewerProps = { src: string; @@ -44,7 +46,17 @@ type RenderImageProps = { onLoad: () => void; onError: () => void; onClick: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; tabIndex: number; + // `role="button"` so assistive tech announces the clickable + // image as a button rather than a plain image. Paired with + // `aria-label` and an Enter/Space `onKeyDown` to make the + // affordance keyboard-activatable per WAI-ARIA. Element-Web + // wraps in `` — we keep the bare `` to + // avoid relayout, which works because folds' `Image` is + // `as<'img'>` and just spreads these props onto the DOM node. + role: 'button'; + 'aria-label': string; }; export type ImageContentProps = { body: string; @@ -55,6 +67,13 @@ export type ImageContentProps = { autoPlay?: boolean; markedAsSpoiler?: boolean; spoilerReason?: string; + // When provided AND the `MediaViewerHostContext` is non-null, + // clicking the thumbnail opens the atom-driven horseshoe viewer + // (mobile bottom-up sheet / desktop right pane) instead of the + // legacy full-screen `` viewer. Non-Room surfaces + // (pin-menu, message search) leave the host context as `null` and + // therefore keep the legacy modal even if they pass `eventId`. + eventId?: string; renderViewer: (props: RenderViewerProps) => ReactNode; renderImage: (props: RenderImageProps) => ReactNode; }; @@ -70,6 +89,7 @@ export const ImageContent = as<'div', ImageContentProps>( autoPlay, markedAsSpoiler, spoilerReason, + eventId, renderViewer, renderImage, ...props @@ -79,12 +99,37 @@ export const ImageContent = as<'div', ImageContentProps>( const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]); + const host = useMediaViewerHost(); + const openMediaViewer = useOpenMediaViewer(); + const useAtomViewer = !!(host && eventId); const [load, setLoad] = useState(false); const [error, setError] = useState(false); const [viewer, setViewer] = useState(false); const [blurred, setBlurred] = useState(markedAsSpoiler ?? false); + const handleOpen = () => { + if (useAtomViewer && host && eventId) { + // The viewer body re-resolves + decrypts the media itself, + // owning the blob-URL lifecycle so it can revoke on close. + // We deliberately don't pass `srcState.data` here even when + // it's available — pinning a blob URL into the atom would + // leak it (the atom outlives the timeline thumbnail). + openMediaViewer({ + roomId: host.roomId, + eventId, + kind: 'image', + url, + body, + info, + encInfo, + mimeType, + }); + return; + } + setViewer(true); + }; + const [srcState, loadSrc] = useAsyncCallback( useCallback(async () => { const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); @@ -118,7 +163,7 @@ export const ImageContent = as<'div', ImageContentProps>( return ( - {srcState.status === AsyncStatus.Success && ( + {!useAtomViewer && srcState.status === AsyncStatus.Success && ( }> ( evt.stopPropagation()} + onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()} > {renderViewer({ src: srcState.data, @@ -168,15 +213,25 @@ export const ImageContent = as<'div', ImageContentProps>( )} {srcState.status === AsyncStatus.Success && ( - + {renderImage({ alt: body, title: body, src: srcState.data, onLoad: handleLoad, onError: handleError, - onClick: () => setViewer(true), + onClick: handleOpen, + onKeyDown: (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleOpen(); + } + }, tabIndex: 0, + role: 'button', + 'aria-label': body || 'Open media', })} )} diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx index b33ec272..09640f61 100644 --- a/src/app/components/message/content/VideoContent.tsx +++ b/src/app/components/message/content/VideoContent.tsx @@ -32,6 +32,8 @@ import { } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { validBlurHash } from '../../../utils/blurHash'; +import { useMediaViewerHost } from '../../../features/room/mediaViewerHostContext'; +import { useOpenMediaViewer } from '../../../state/hooks/mediaViewer'; type RenderVideoProps = { title: string; @@ -50,6 +52,14 @@ type VideoContentProps = { autoPlay?: boolean; markedAsSpoiler?: boolean; spoilerReason?: string; + // When provided AND `MediaViewerHostContext` is non-null, tapping + // the thumbnail opens the atom-driven horseshoe viewer for video + // playback instead of loading + playing inline (which hands off to + // the browser's native video-element fullscreen when the user hits + // the controls' expand button — that's why the user used to see + // Chrome's default video viewer). Non-Room surfaces leave the + // host context as `null` and keep the inline player. + eventId?: string; renderThumbnail?: () => ReactNode; renderVideo: (props: RenderVideoProps) => ReactNode; }; @@ -65,6 +75,7 @@ export const VideoContent = as<'div', VideoContentProps>( autoPlay, markedAsSpoiler, spoilerReason, + eventId, renderThumbnail, renderVideo, ...props @@ -74,6 +85,9 @@ export const VideoContent = as<'div', VideoContentProps>( const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]); + const host = useMediaViewerHost(); + const openMediaViewer = useOpenMediaViewer(); + const useAtomViewer = !!(host && eventId); const [load, setLoad] = useState(false); const [error, setError] = useState(false); @@ -106,8 +120,29 @@ export const VideoContent = as<'div', VideoContentProps>( }; useEffect(() => { + // Skip inline preload in atom-viewer mode — the user gets the + // viewer's own resolve path on tap; preloading every visible + // video in the timeline would burn bandwidth and decrypt CPU + // for videos the user never opens. + if (useAtomViewer) return; if (autoPlay) loadSrc(); - }, [autoPlay, loadSrc]); + }, [autoPlay, loadSrc, useAtomViewer]); + + const openAtomViewer = useCallback(() => { + if (!host || !eventId) return; + // No `resolvedSrc` — viewer body owns blob-URL lifecycle; see + // the rationale in `ImageContent.handleOpen`. + openMediaViewer({ + roomId: host.roomId, + eventId, + kind: 'video', + url, + body, + info, + encInfo, + mimeType, + }); + }, [host, eventId, openMediaViewer, url, body, info, encInfo, mimeType]); return ( @@ -129,7 +164,21 @@ export const VideoContent = as<'div', VideoContentProps>( {renderThumbnail()} )} - {!autoPlay && !blurred && srcState.status === AsyncStatus.Idle && ( + {useAtomViewer && !blurred && ( + + + + )} + {!useAtomViewer && !autoPlay && !blurred && srcState.status === AsyncStatus.Idle && ( )} - {srcState.status === AsyncStatus.Success && ( + {!useAtomViewer && srcState.status === AsyncStatus.Success && ( {renderVideo({ title: body, diff --git a/src/app/components/message/content/style.css.ts b/src/app/components/message/content/style.css.ts index bb5d8484..dadcbe3a 100644 --- a/src/app/components/message/content/style.css.ts +++ b/src/app/components/message/content/style.css.ts @@ -1,6 +1,36 @@ -import { style } from '@vanilla-extract/css'; +import { globalStyle, style } from '@vanilla-extract/css'; import { DefaultReset, config } from 'folds'; +// Click affordance for the timeline image thumbnail. Without this +// the `` looked decorative on web (the default cursor stays +// as `default`) even though it's clickable to open the media +// viewer. The subtle 0.92 brightness on hover doubles as the +// "this is interactive" signal — same idiom as how the rest of +// the app's clickable surfaces shift tone on hover. +// +// `will-change: filter` hints the compositor so the brightness +// transition runs on the GPU instead of repainting on the CPU — +// matters for large hi-DPI thumbnails on slower phones. +export const ImageClickable = style({ + cursor: 'pointer', + transition: 'filter 120ms ease', + willChange: 'filter', + selectors: { + '&:hover': { filter: 'brightness(0.92)' }, + }, +}); + +// `:focus-visible` outline on the inner `` (which carries +// `tabIndex` + `role="button"`, not the wrapper Box). Lives as a +// `globalStyle` because vanilla-extract's `style({...})` only +// permits selectors that target the class itself (`&...`) — a +// descendant selector like `& img:focus-visible` errors at build +// time. `globalStyle` is the documented escape valve for that. +globalStyle(`${ImageClickable} img:focus-visible`, { + outline: `2px solid currentColor`, + outlineOffset: '-2px', +}); + export const RelativeBase = style([ DefaultReset, { diff --git a/src/app/components/message/layout/Channel.css.ts b/src/app/components/message/layout/Channel.css.ts index 9b23b97c..2958b567 100644 --- a/src/app/components/message/layout/Channel.css.ts +++ b/src/app/components/message/layout/Channel.css.ts @@ -1,25 +1,36 @@ -import { style } from '@vanilla-extract/css'; +import { globalStyle, style } from '@vanilla-extract/css'; import { color, config, toRem } from 'folds'; -// 36px circular avatar — a notch above folds `Avatar size="200"` (32px) -// for visual weight matching the channels mockup. Consumers override -// the folds preset via inline style; the shared `CHANNEL_AVATAR_PX` -// constant keeps the CSS slot width and the inline override in sync. -export const CHANNEL_AVATAR_PX = 36; +// 40px circular avatar — Discord's cozy-mode avatar size. Consumers override +// the folds preset via inline style; the shared `CHANNEL_AVATAR_PX` constant +// keeps the CSS slot width and the inline override in sync. +export const CHANNEL_AVATAR_PX = 40; const ChannelAvatarWidth = toRem(CHANNEL_AVATAR_PX); +// Discord cozy-mode geometry: avatar 16px from the list edge, 16px gap to the +// content, so the message column starts at 16 + 40 + 16 = 72px. +const ChannelEdgePad = toRem(16); +const ChannelAvatarGap = toRem(16); + export const ChannelRow = style({ display: 'flex', alignItems: 'flex-start', - gap: config.space.S300, - paddingLeft: config.space.S400, - paddingRight: config.space.S400, - paddingTop: config.space.S100, - paddingBottom: config.space.S100, + gap: ChannelAvatarGap, + // Span the full pane edge-to-edge so the hover highlight runs the whole + // width like Discord: cancel MessageBase's S400/S200 horizontal padding with + // negative margins, then re-add the 16px avatar gutter as paddingLeft (so the + // avatar's left edge lands exactly 16px from the screen edge — Discord cozy). + marginLeft: `calc(-1 * ${config.space.S400})`, + marginRight: `calc(-1 * ${config.space.S200})`, + paddingLeft: ChannelEdgePad, + paddingRight: ChannelEdgePad, + // Tight vertical rhythm (Discord stacks lines on line-height); group + // separation comes from MessageBase's `space` marginTop on the run head. + paddingTop: toRem(2), + paddingBottom: toRem(2), minWidth: 0, - // Hover bg subtle so adjacent rows still read as distinct units even - // without bubble borders. `@media (hover: hover)` keeps this inert on - // touch where there's no pointer to follow. + // Hover bg subtle so adjacent rows still read as distinct units. `@media + // (hover: hover)` keeps this inert on touch where there's no pointer. '@media': { '(hover: hover) and (pointer: fine)': { selectors: { @@ -105,8 +116,12 @@ export const ChannelDayDividerLabel = style({ // body would, indented past the avatar slot, so the column reads // continuous. export const ChannelSysline = style({ - paddingLeft: `calc(${ChannelAvatarWidth} + ${config.space.S300} + ${config.space.S400})`, - paddingRight: config.space.S400, + // Indent past the avatar gutter so the sysline body aligns with the message + // body column (72px). The sysline sits inside MessageBase's S400 (16px) left + // pad (it has no edge-to-edge negative margin), so paddingLeft = avatar (40) + // + gap (16) = 56 lands the content at 16 + 56 = 72px. + paddingLeft: `calc(${ChannelAvatarWidth} + ${ChannelAvatarGap})`, + paddingRight: config.space.S200, paddingTop: config.space.S100, paddingBottom: config.space.S100, color: color.SurfaceVariant.OnContainer, @@ -121,3 +136,107 @@ export const ChannelSyslineBody = style({ minWidth: 0, flex: 1, }); + +// Bubble chrome applied when `ChannelLayout` is invoked with +// `headerInBubble` (thread drawer and channels main timeline pass it). +// Mirrors `StreamBubble` from the DM timeline so a channel row reads +// like a chat-bubble cluster: dark `Surface.Container` card with an +// asymmetric notch corner per `data-own`, sized `fit-content` so short +// bubbles shrink-wrap instead of stretching across the column. +// Reactions and the thread-summary card live as siblings of the body +// in `ChannelLayout`, so they stay OUTSIDE the bubble — identical +// composition to Stream. The `[data-bubble="true"]` row marker keeps +// the un-bubbled channel/sysline layout (pre-redesign callers) opt-in +// rather than forcing the look on every consumer. +globalStyle(`${ChannelRow}[data-bubble="true"] ${ChannelMessageBody}`, { + backgroundColor: color.Surface.Container, + color: color.SurfaceVariant.OnContainer, + border: `1px solid ${color.Surface.ContainerLine}`, + paddingTop: config.space.S200, + paddingBottom: config.space.S200, + paddingLeft: toRem(15), + paddingRight: toRem(15), + display: 'inline-block', + width: 'fit-content', + maxWidth: '100%', + minWidth: 0, + position: 'relative', + zIndex: 1, + // Clips the thread-summary footer's hover bg against the bubble's + // rounded BR/BL corners — without it the rectangular hover paint + // punches past the curve. No outflow content lives inside the bubble + // (option bar, reactions are siblings on the row) so clipping is + // safe. + overflow: 'hidden', +}); + +// Asymmetric corner per `data-own` — own messages flatten BOTTOM-LEFT +// (4px), incoming messages flatten TOP-LEFT. Same pattern as +// `StreamBubble.own`/`StreamBubble.others`. +globalStyle(`${ChannelRow}[data-bubble="true"][data-own="true"] ${ChannelMessageBody}`, { + borderRadius: `${toRem(16)} ${toRem(16)} ${toRem(16)} ${toRem(4)}`, +}); + +globalStyle(`${ChannelRow}[data-bubble="true"][data-own="false"] ${ChannelMessageBody}`, { + borderRadius: `${toRem(4)} ${toRem(16)} ${toRem(16)} ${toRem(16)}`, + // Peer (not-own) bubble bg for the channel layout — its own var. (The 1-1 + // Stream layout's incoming bubble instead binds to color.Surface.Container, + // the composer surface.) Covers channels main timeline AND thread drawer + // (both pass `headerInBubble`, so `data-bubble="true"` fires). + backgroundColor: 'var(--vojo-peer-bubble-bg)', +}); + +// Small gap so the in-bubble header (username + time) doesn't sit flush +// against the first line of message text. Matches the Stream layout's +// `StreamName` 2px marginBottom. +globalStyle(`${ChannelRow}[data-bubble="true"] ${ChannelHeader}[data-in-bubble="true"]`, { + marginBottom: toRem(2), +}); + +// Thread-summary footer rendered INSIDE the bubble (rather than as a +// separate pill below). Negative L/R margin (matches the bubble's +// `paddingLeft/Right: 15px`) stretches the wrapper to the bubble's +// inner border edge so the 1px top rule reads as a section divider +// spanning the whole bubble. Negative `marginBottom` cancels the +// bubble's S200 bottom pad so the footer flushes against the bubble's +// rounded bottom edge — bubble + summary read as one card with a +// horizontal rule splitting them. +// +// The footer body keeps no own border/radius — it inherits the bubble's +// bottom corners via clipping (`ChannelMessageBody` itself doesn't +// `overflow: hidden`, but the rounded bottom of the bubble visually +// caps the footer anyway because the divider line never reaches the +// curved corner pixel). +export const ChannelBubbleThreadSummary = style({ + marginTop: config.space.S200, + marginLeft: toRem(-15), + marginRight: toRem(-15), + marginBottom: `calc(-1 * ${config.space.S200})`, + borderTop: `1px solid ${color.Surface.ContainerLine}`, +}); + +// Footer button — strip the original ThreadSummaryCard pill chrome +// (own bg, radius, padding, max-width) so it reads as a flush bubble +// footer. Click target expands to the full footer width. Hover paints +// a subtle `SurfaceVariant.Container` shade that contrasts against +// the bubble's `Surface.Container` bg, signalling tappable footer +// without the pill silhouette returning. +globalStyle(`${ChannelBubbleThreadSummary} > button`, { + display: 'flex', + width: '100%', + maxWidth: 'none', + borderRadius: 0, + backgroundColor: 'transparent', + padding: `${config.space.S200} ${toRem(15)}`, +}); + +globalStyle(`${ChannelBubbleThreadSummary} > button:hover`, { + backgroundColor: color.SurfaceVariant.Container, +}); + +globalStyle(`${ChannelBubbleThreadSummary} > button:focus-visible`, { + // Inset the focus ring slightly so it doesn't punch through the + // bubble's rounded bottom corners on the BR/BL when the row is + // either own or incoming. + outlineOffset: toRem(-2), +}); diff --git a/src/app/components/message/layout/Channel.tsx b/src/app/components/message/layout/Channel.tsx index b4fb4c5d..98b17c0a 100644 --- a/src/app/components/message/layout/Channel.tsx +++ b/src/app/components/message/layout/Channel.tsx @@ -25,6 +25,18 @@ export type ChannelLayoutProps = { header?: ReactNode; reactions?: ReactNode; threadSummary?: ReactNode; + // Forwarded onto the row root as `data-own="true"|"false"`. Channels + // main timeline doesn't style off it; the thread-drawer bubble CSS + // reads it to mirror `StreamBubble`'s own-vs-incoming notch corner. + isOwn?: boolean; + // `true` (thread drawer): the header (name + time) renders INSIDE the body + // slot above the content, and the body is a chat bubble — the compact + // in-bubble look. + // `false` (Discord-style main timeline): the header renders as a sibling row + // ABOVE the body (next to the avatar) and the body is plain text (no bubble) + // for everyone — Discord groups don't bubble messages. See `data-bubble` + // below and `Channel.css.ts`. + headerInBubble?: boolean; onContextMenu?: MouseEventHandler; }; @@ -33,20 +45,53 @@ export type ChannelLayoutProps = { // thread-summary, reactions in vertical flow. export const ChannelLayout = as<'div', ChannelLayoutProps>( ( - { className, avatar, header, reactions, threadSummary, onContextMenu, children, ...props }, + { + className, + avatar, + header, + reactions, + threadSummary, + isOwn, + headerInBubble, + onContextMenu, + children, + ...props + }, ref ) => (
{avatar}
- {header &&
{header}
} -
{children}
- {threadSummary &&
{threadSummary}
} + {!headerInBubble && header &&
{header}
} +
+ {headerInBubble && header && ( +
+ {header} +
+ )} + {children} + {headerInBubble && threadSummary && ( + // Inside-bubble footer: stretches via negative margins to the + // bubble's inner border edge, paints a 1px top divider, and + // hosts the existing thread-summary button as a flush + // full-width chip. Reads as one continuous card with a + // section break instead of two stacked pills. +
{threadSummary}
+ )} +
+ {!headerInBubble && threadSummary && ( +
{threadSummary}
+ )} {reactions &&
{reactions}
}
@@ -103,7 +148,11 @@ export type ChannelMessageAvatarProps = { // folds `` + `` combination. Lifted out of `Message` // so the `useMediaAuthentication` / `useMatrixClient` hook calls only run // when channel layout is selected (Stream rows don't need an avatar). -export function ChannelMessageAvatar({ room, senderId, senderDisplayName }: ChannelMessageAvatarProps) { +export function ChannelMessageAvatar({ + room, + senderId, + senderDisplayName, +}: ChannelMessageAvatarProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const avatarMxc = getMemberAvatarMxc(room, senderId); diff --git a/src/app/components/message/layout/Stream.tsx b/src/app/components/message/layout/Stream.tsx index 6da75a66..9a8ec183 100644 --- a/src/app/components/message/layout/Stream.tsx +++ b/src/app/components/message/layout/Stream.tsx @@ -1,13 +1,16 @@ import React, { ReactNode, useImperativeHandle, useRef } from 'react'; import classNames from 'classnames'; -import { as } from 'folds'; +import { as, toRem } from 'folds'; import * as css from './layout.css'; import { useStreamLayoutDebug } from './streamDebug'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { Time } from '../Time'; -// Stream rows use a fixed `S400` gap so the rail-bridge offsets in -// layout.css.ts (StreamRailBridgeY = S400) match the gap between rows. +// Day-divider rows fall back to this `S400` MessageBase spacing variant +// (see RoomTimeline.renderDayDivider). Message rows force collapse=true +// in Message.tsx so their marginTop drops to 0 — the rail-bridge offsets +// still resolve against `StreamRailBridgeY = S400` and the small overlap +// over the tighter gap stays hidden by the dot halo. export const STREAM_MESSAGE_SPACING = '400' as const; // Sample timestamp used by the day-divider's invisible track-1 placeholder. @@ -16,7 +19,18 @@ export const STREAM_MESSAGE_SPACING = '400' as const; // chat. The auto-sized grid column then matches the surrounding message rows. const DAY_DIVIDER_PLACEHOLDER_TS = 0; -// Stream layout — DM redesign (docs/plans/dm_1x1_redesign.md §6.5b). +// Rail-dot diameters. The base dot is 9px (see `StreamDotSize` / +// `StreamDotColumn` in layout.css.ts, which keep the rail X consistent). The +// neutral gray dot is 0.95× that; the «state» dots (green = read, gold = +// mention, red = failed — `dotProminent`) are 1.1× the neutral so they read as +// slightly larger on the rail. The dot stays in-flow, so a prominent dot just +// overflows the 9px column by ~0.4px into the gap (centred enough to read on +// the rail) — same harmless trick the larger day-dot already uses. +const STREAM_DOT_NEUTRAL = toRem(8.55); +const STREAM_DOT_PROMINENT = toRem(9.405); + +// Stream layout — DM «VS Code chat» redesign +// (docs/plans/dm_stream_vscode_redesign.md). // // Visual structure (3-track CSS grid, see StreamRoot in layout.css.ts): // ┌─G─┬─time─┬─G─┬─dot─┬─G─┬───── bubble (1fr) ─────────┐ @@ -31,11 +45,30 @@ export type StreamLayoutProps = { time?: ReactNode; dotColor: string; dotOpacity: number; + // `true` → green/gold/red «state» dot drawn 1.1× the neutral gray size. + dotProminent?: boolean; + // Drives the bubble chrome: own → plain text on the chat background (no + // bubble); incoming → filled bubble (composer-matched surface). See + // layout.css.ts `StreamBubble`. isOwn?: boolean; compact?: boolean; + // Same-sender continuation row (the whole run after the first message, any + // minute): drop the rail dot + timestamp + nick and stack the body tight + // under the previous one. The timestamp is kept in the DOM (invisible) only + // to reserve the time-track width. The caller also passes `header={undefined}` + // for collapsed rows. See RoomTimeline `collapsed`. + collapsed?: boolean; + // Author name — rendered as a bold label ABOVE the bubble, on the + // dot/timestamp baseline (DM «VS Code chat» redesign). `undefined` for + // media rows (the name is overlaid on the media instead) and for collapsed + // continuation rows. header?: ReactNode; railStart?: boolean; railEnd?: boolean; + // Suppress the rail segment entirely on this row. Set for trailing + // continuation rows that sit AFTER the last dot (the rail must stop at the + // last dot, not bleed down through the dot-less tail of a run). + railHidden?: boolean; // Image messages: bubble bg/border/padding collapse so the // StreamMediaImage child supplies the visible chrome. mediaMode?: boolean; @@ -99,11 +132,14 @@ export const StreamLayout = as<'div', StreamLayoutProps>( time, dotColor, dotOpacity, + dotProminent, isOwn, compact, + collapsed, header, railStart, railEnd, + railHidden, mediaMode, reactions, threadSummary, @@ -137,34 +173,62 @@ export const StreamLayout = as<'div', StreamLayoutProps>( return (
- + {/* Collapsed rows keep the timestamp in the DOM (so the auto-sized + time track stays the same width and the body column doesn't shift) + but hide it — only the first message of the minute shows its time. */} + {time} - - + {/* The rail is suppressed entirely on trailing continuation rows + (after the last dot) so the line stops at the last dot instead of + bleeding down through the dot-less tail of a run. */} + {!railHidden && ( - + )} + {/* No dot on collapsed continuation rows — the rail passes straight + through, anchored by the first message's dot above. */} + {!collapsed && ( + + + + )}
+ {header && ( +
+ {header} +
+ )}
( })} ref={bubbleRef} > - {header && ( -
- {header} -
- )} {children}
{threadSummary &&
{threadSummary}
} diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 9f422639..4b8ea5e2 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -138,7 +138,8 @@ export const UsernameBold = style({ fontWeight: 550, }); -// Stream layout (DM redesign — see docs/plans/dm_1x1_redesign.md §6.5b). +// Stream layout (DM «VS Code chat» redesign — see +// docs/plans/dm_stream_vscode_redesign.md). // // Symmetric three-gap layout, expressed as a 3-track CSS grid: // @@ -168,14 +169,17 @@ export const UsernameBold = style({ // loosen on mobile without disturbing the screen-edge anchor (which the // user dialled in earlier and asked to keep). // -// Mobile: pad = S100 (minimal screen-edge anchor); gap = 2 × S100 = S200 -// (the user asked to double the inter-element gap on native). -// Desktop: pad = S500 (≈ 0.5 cm PageNav clearance, unchanged); gap = -// S500 / 1.1 ≈ 18.2 px (the user asked to shrink the desktop inter-element -// gap by 1.1× — keeps the layout tighter without dropping a whole token). +// The whole time→dot→nick block was nudged ~1mm (~4px) to the right per the +// latest request — both pad values stepped up one token. +// Mobile: pad = S200 (8px — was S100; +4px ≈ 1mm off the screen edge); gap = +// S200 (the user asked to double the inter-element gap on native). +// Desktop: pad = S500 (20px — was S400; +4px ≈ 1mm further from the PageNav, +// the column still clears the nav rail); gap = S500 / 1.1 ≈ 18.2 px (the user +// asked to shrink the desktop inter-element gap by 1.1× — keeps the layout +// tighter without dropping a whole token). const StreamRowPadVar = createVar(); const StreamRowGapVar = createVar(); -const StreamRowPadMobile = config.space.S100; +const StreamRowPadMobile = config.space.S200; const StreamRowPadDesktop = config.space.S500; const StreamRowGapMobile = config.space.S200; const StreamRowGapDesktop = `calc(${config.space.S500} / 1.1)`; @@ -187,16 +191,28 @@ const StreamSyslineDotSize = toRem(6); const StreamDayDotSize = toRem(12.1); const StreamRailLineWidth = '1px'; const StreamBubbleBorderWidth = '1px'; +// Minimal, all-corners rounding for the incoming bubble + the peer name chip +// (user request — like the VS Code reference, no sharp/notched corner). +const StreamBubbleRadius = toRem(6); const StreamTimeLineHeight = toRem(13); const StreamRailBridgeY = config.space.S400; -// Vertical centre of the bubble's header text from the row's content-area -// top. = bubble.borderTop (1) + bubble.paddingTop (S200) + line.T200 / 2. -// Used to push the timestamp and the dot down in their grid cells so -// they read on the same baseline as the Username component inside the -// bubble. Rail-end's height also adds `S100` back to account for the -// rail's negative `top` offset (it starts above the row outer edge). -const StreamHeaderInnerCenterY = `calc(${StreamBubbleBorderWidth} + ${config.space.S200} + (${config.lineHeight.T200} / 2))`; +// Author name (header line) — DM «VS Code chat» redesign +// (docs/plans/dm_stream_vscode_redesign.md). Bold, pure white/black, a step +// larger than chat body (T400 = 15px). The dot + timestamp vertically centre +// on this line, so its line-height drives the rail geometry below. +const StreamNameFontSize = toRem(16); +const StreamNameLineHeight = toRem(20); + +// Vertical centre of the author-name line, measured from the row's +// content-area top. The name is now the FIRST child of StreamColumn (track 3) +// with nothing above it, so the centre is simply half its line height — the +// old in-bubble offset (border + padding) no longer applies. The timestamp + +// dot are pushed down by this amount (minus their own half-heights) so all +// three read on one baseline. Rail-end/start heights resolve against it; each +// adds `S100` back to account for the rail's negative `top` offset (it starts +// above the row outer edge). +const StreamHeaderInnerCenterY = `calc(${StreamNameLineHeight} / 2)`; export const StreamRoot = recipe({ base: { @@ -209,8 +225,13 @@ export const StreamRoot = recipe({ columnGap: StreamRowGapVar, paddingLeft: StreamRowPadVar, alignItems: 'flex-start', - paddingTop: config.space.S100, - paddingBottom: config.space.S100, + // Vertical padding tuned to ~3px (down from S100/4px) so the + // bubble-to-bubble gap reads tighter. Per-row contribution to the + // gap drops from 8px to 7px on each side — combined with + // MessageBase's S100 top/bot the total bubble↔bubble gap is + // 4 + 3 + 0 (collapsed marginTop) + 3 + 4 = 14px ≈ S400/1.14. + paddingTop: toRem(3), + paddingBottom: toRem(3), paddingRight: config.space.S400, }, variants: { @@ -234,8 +255,18 @@ export const StreamRoot = recipe({ paddingRight: 0, }, }, + // Same-minute continuation row (dot/name/time hidden): tighten the stack. + // Drop the top padding and pull up by S100, so collapsed bodies sit ~7px + // apart vs the ~14px gap between distinct minute groups. The rail bridge + // (±S400) dwarfs this, so the rail line stays unbroken through the cluster. + collapsed: { + true: { + paddingTop: 0, + marginTop: `calc(-1 * ${config.space.S100})`, + }, + }, }, - defaultVariants: { compact: false }, + defaultVariants: { compact: false, collapsed: false }, }); // Sysline timestamp. Now a regular grid item in track 1 — sized to its @@ -283,7 +314,7 @@ export const StreamRail = style({ left: '50%', transform: 'translateX(-50%)', width: StreamRailLineWidth, - background: color.Surface.Container, + background: 'var(--vojo-timeline-rail)', pointerEvents: 'none', zIndex: 0, }); @@ -413,64 +444,92 @@ export const StreamThreadSummary = style({ export const StreamBubble = recipe({ base: { + // Incoming (peer) bubble — filled with the SAME surface as the composer + // card (`color.Surface.Container`, see RoomView.css.ts) so the bubble and + // the input form read as one material; it sits a step darker than the + // `SurfaceVariant.Container` page background. Minimal rounding on ALL four + // corners (no sharp/notched corner). Own messages override to a no-chrome + // plain block below. backgroundColor: color.Surface.Container, color: color.SurfaceVariant.OnContainer, border: `${StreamBubbleBorderWidth} solid ${color.Surface.ContainerLine}`, - paddingTop: config.space.S200, - paddingBottom: config.space.S200, + borderRadius: StreamBubbleRadius, + // Padding bumped ~1.1× (user request) so the bubble reads a touch larger + // around the text: vertical 8→8.8px, horizontal 15→16.5px. + paddingTop: toRem(8.8), + paddingBottom: toRem(8.8), + paddingLeft: toRem(16.5), + paddingRight: toRem(16.5), minWidth: 0, maxWidth: toRem(720), position: 'relative', zIndex: 1, }, variants: { - // Asymmetric notch — own: top-left flat, three corners R500. - // Incoming: mirrored. + // Own messages render as plain text on the chat background — no fill, + // no border, no rounding, flush-left beneath the author name and + // spanning the message column like a paragraph (user points 2 + 4). own: { true: { - borderRadius: `${toRem(4)} ${config.radii.R500} ${config.radii.R500} ${config.radii.R500}`, - }, - false: { - borderRadius: `${config.radii.R500} ${config.radii.R500} ${config.radii.R500} ${toRem(4)}`, - }, - }, - // Mobile fills the message column (block 100%); desktop fits content - // (inline-block fit-content). Branched via useScreenSizeContext, not - // CSS media queries — see docs/plans/dm_1x1_redesign.md §5.5. - compact: { - true: { + backgroundColor: 'transparent', + border: 'none', + borderRadius: 0, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, display: 'block', width: '100%', - paddingLeft: config.space.S300, - paddingRight: config.space.S300, + maxWidth: '100%', }, - false: { + false: {}, + }, + // Placeholder so the compound variants below can target the breakpoint. + compact: { true: {}, false: {} }, + // Image / video: bubble becomes a transparent shell so the + // StreamMediaImage child supplies the visible chrome. `display: block, + // width: 100%` (NOT fit-content) so the child's `max-width: 100%` has a + // definite width to clamp against — fit-content would make the chain + // circular and overflow on narrow screens. + mediaMode: { true: {} }, + }, + // Compounds are emitted after the variant classes, so they win the cascade. + compoundVariants: [ + // Peer bubble width — fit the text on BOTH web/desktop and native/mobile + // (the user settled on «по размеру текста» everywhere; this supersedes the + // earlier native-stretch tweak). Mobile only tightens the horizontal + // padding for narrow viewports. + { + variants: { own: false, compact: false, mediaMode: false }, + style: { display: 'inline-block', width: 'fit-content', maxWidth: '100%' }, + }, + { + variants: { own: false, compact: true, mediaMode: false }, + style: { display: 'inline-block', width: 'fit-content', maxWidth: '100%', - paddingLeft: toRem(15), - paddingRight: toRem(15), + // Tighter horizontal padding on narrow viewports (12 × 1.1 ≈ 13.2px). + paddingLeft: toRem(13.2), + paddingRight: toRem(13.2), }, }, - // Image messages: bubble becomes a transparent shell so the - // StreamMediaImage child supplies the visible chrome instead. - // `display: block, width: 100%` (NOT fit-content) so the bubble has a - // definite width inherited from StreamColumn — required for the - // child's `max-width: 100%` to clamp the image. With fit-content the - // chain becomes circular (parent shrinks to child, child grows to - // its explicit pixel width), and the image overflows past the - // viewport on narrow screens. - mediaMode: { - true: { + // Media shell wins over both the peer fill and the width rules above + // (incl. own's lifted max-width) — keeps image/video bubbles capped at + // the same 720px as before regardless of own/peer. + { + variants: { mediaMode: true }, + style: { backgroundColor: 'transparent', border: 'none', borderRadius: 0, padding: 0, display: 'block', width: '100%', + maxWidth: toRem(720), }, }, - }, + ], defaultVariants: { own: false, compact: false, @@ -478,17 +537,41 @@ export const StreamBubble = recipe({ }, }); -export const StreamBubbleHeader = style({ +// Author name — sits ABOVE the bubble as the first child of StreamColumn +// (track 3), aligned on the dot/timestamp baseline. Bold, a step larger than +// chat body, pure white (dark) / black (light) via `--vojo-stream-name`. The +// inner Username button inherits colour/size/weight from here, so callers +// pass the name without their own colour/size. +export const StreamName = style({ position: 'relative', - marginBottom: toRem(2), - fontSize: toRem(11), - lineHeight: config.lineHeight.T200, - minHeight: config.lineHeight.T200, - fontWeight: 600, + // Symmetric top-left corner (user request): the vertical gap from the nick + // down to the bubble equals the horizontal gap from the bubble's left edge + // out to the timerail = column-gap + dot radius. Inherits StreamRowGapVar + // from StreamRoot, so it tracks the per-breakpoint gap. Only applies on run + // heads (continuations render no nick), so continuations stay tight. + marginBottom: `calc(${StreamRowGapVar} + (${StreamDotSize} / 2))`, + fontSize: StreamNameFontSize, + lineHeight: StreamNameLineHeight, + minHeight: StreamNameLineHeight, + fontWeight: 700, + color: 'var(--vojo-stream-name)', display: 'flex', alignItems: 'center', gap: toRem(6), flexWrap: 'nowrap', + minWidth: 0, + // Bound the label to the message column so a long display name truncates + // (ellipsis) instead of overflowing the rail. StreamColumn uses + // `align-items: flex-start`, so without this the flex container would + // content-size past the column edge. + maxWidth: '100%', +}); + +// Let the (single) name child shrink so css.Username's ellipsis engages on +// long display names — replaces the old `` wrapper. Must be a +// globalStyle: vanilla-extract `style()` selectors may only target `&`. +globalStyle(`${StreamName} > *`, { + minWidth: 0, }); // Message-row timestamp — grid item in track 1, content-sized. Pushed @@ -513,6 +596,13 @@ globalStyle(`${StreamHeaderTime} time`, { lineHeight: StreamTimeLineHeight, }); +// Collapsed continuation rows keep the timestamp in the DOM so the auto-sized +// time track stays the same width (body column doesn't shift) but render it +// invisible — a same-sender run shows its dot+timestamp only on the first row. +export const StreamHeaderTimeHidden = style({ + visibility: 'hidden', +}); + // Sysline — thin single-line state-event row inside Stream layout. // Composes with StreamRoot so the time / dot / content tracks line up // vertically with message rows above and below. Override align-items to @@ -524,13 +614,15 @@ export const StreamSysline = style({ paddingBottom: toRem(2), }); - +// System lines (room name/topic/avatar changes, hidden-event dev rows) read +// as muted, understated «Thinking»-style notes (user point 10) — soft grey, +// italic, in the regular sans (not the mono timestamp face) so they recede +// behind real messages instead of reading as another bubble. export const StreamSyslineBody = style({ - fontSize: toRem(11.5), + fontSize: toRem(12), color: color.Surface.OnContainer, - opacity: 0.55, - fontFamily: - '"JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + opacity: 0.5, + fontStyle: 'italic', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', @@ -631,7 +723,7 @@ export const StreamDayLineWrap = style({ export const StreamDayLineSegment = style({ flex: 1, height: 1, - background: color.Surface.Container, + background: 'var(--vojo-timeline-rail)', minWidth: toRem(8), }); diff --git a/src/app/components/mobile-tabs-pager/MobilePagerPaneContext.tsx b/src/app/components/mobile-tabs-pager/MobilePagerPaneContext.tsx new file mode 100644 index 00000000..82ae1807 --- /dev/null +++ b/src/app/components/mobile-tabs-pager/MobilePagerPaneContext.tsx @@ -0,0 +1,37 @@ +import { createContext, useContext } from 'react'; + +export type MobilePagerTab = 'direct' | 'channels' | 'bots'; + +// Set by MobileTabsPager around each of its three listing panes. Lets +// the StreamHeader inside the pane discover (a) that it's mounted in +// pager mode at all, (b) whether it's the currently active pane, and +// (c) how to request a tab switch with no animation (used by the +// invisible per-pane Segment buttons that capture taps in the safe- +// top + tabsRow band — see `StreamHeader.tsx` for why the row is +// `opacity:0` instead of `visibility:hidden`). +// +// "Mounted in pager mode" controls whether the per-pane tabs row +// renders visibly — when we're in pager mode the tabs row is painted +// invisible (the shared static header at pager root paints the visible +// tabs + icons), but the row still occupies its TABS_ROW_PX height so +// the curtain's snap geometry is unchanged. +// +// "isActive" controls which pane's curtain is wired to the shared +// static header's action icons via `mobilePagerCurtainAtom`. +// +// `selectTabInstant` is the tap commit path. It snaps the strip to +// the target tab without the swipe-finish CSS transition — taps feel +// snappy where swipes still animate. Swipes still go through the +// pager's gesture hook which uses a separate animated commit. +export type MobilePagerPaneInfo = { + isActive: boolean; + selectTabInstant: (target: MobilePagerTab) => void; +}; + +const MobilePagerPaneContext = createContext(null); + +export const MobilePagerPaneProvider = MobilePagerPaneContext.Provider; + +export function useMobilePagerPane(): MobilePagerPaneInfo | null { + return useContext(MobilePagerPaneContext); +} diff --git a/src/app/components/mobile-tabs-pager/MobileTabsLayout.tsx b/src/app/components/mobile-tabs-pager/MobileTabsLayout.tsx new file mode 100644 index 00000000..d7ed9ccc --- /dev/null +++ b/src/app/components/mobile-tabs-pager/MobileTabsLayout.tsx @@ -0,0 +1,60 @@ +import React, { Suspense } from 'react'; +import { Outlet, useMatch } from 'react-router-dom'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { isNativePlatform } from '../../utils/capacitor'; +import { BOTS_PATH, CHANNELS_PATH, CHANNELS_SPACE_PATH, DIRECT_PATH } from '../../pages/paths'; + +// MobileTabsPager is the ONLY static importer of the Channels and Bots feature +// modules. It renders only on `mobile && native` (below), so lazy-loading it +// here removes the static edge that otherwise pinned Channels + Bots into the +// boot bundle for every target. On native the chunk streams from the local APK +// filesystem (no network), so a null Suspense fallback is imperceptible. +const MobileTabsPager = React.lazy(() => + import('./MobileTabsPager').then((m) => ({ default: m.MobileTabsPager })) +); + +// Router-level wrapper around the three listing tabs (/direct/, +// /channels/, /bots/). When all of (mobile breakpoint, Capacitor +// native runtime, listing-root URL) hold, we hijack rendering and +// mount `MobileTabsPager` directly — the wrapped routes' Outlet is +// never read, so their `element` chains stay unmounted. Anywhere else +// — non-mobile breakpoints (tablet, desktop), non-Capacitor runtimes +// (mobile web, Electron desktop), AND detail URLs nested under any +// listing root (/direct/!roomId, /channels/!space/!roomId, +// /bots/:botId) — we pass through to `` and the existing +// route tree renders unchanged. +// +// Channels has TWO listing-root URLs that both activate the pager on +// the Channels tab: +// +// * `/channels/` — landing (empty-state CTA when the user has no +// orphan spaces; otherwise pager-internal render of the active +// workspace via the persisted active-space resolver). +// * `/channels/!space/` — workspace listing for that specific space. +// This is what `commitTo('channels')` actually navigates to when +// an active space is known, so the user lands directly on the +// workspace view without bouncing through `/channels/` and the +// `ChannelsLanding` redirect (which previously cut the +// pager animation short and made the swipe feel jerky). +// +// Detail URLs nested under either (`/channels/!space/!roomId`, etc.) +// flip both matches to false and fall through to — Room +// renders full-screen on mobile as before. +export function MobileTabsLayout() { + const mobile = useScreenSizeContext() === ScreenSize.Mobile; + const native = isNativePlatform(); + const directRoot = !!useMatch({ path: DIRECT_PATH, end: true }); + const channelsRoot = !!useMatch({ path: CHANNELS_PATH, end: true }); + const channelsSpaceRoot = !!useMatch({ path: CHANNELS_SPACE_PATH, end: true }); + const botsRoot = !!useMatch({ path: BOTS_PATH, end: true }); + const onListingRoot = directRoot || channelsRoot || channelsSpaceRoot || botsRoot; + + if (!(mobile && native) || !onListingRoot) { + return ; + } + return ( + + + + ); +} diff --git a/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx b/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx new file mode 100644 index 00000000..c5d7bde4 --- /dev/null +++ b/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx @@ -0,0 +1,444 @@ +import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Outlet, useMatch, useNavigate } from 'react-router-dom'; +import { useAtomValue } from 'jotai'; +import { BOTS_PATH, CHANNELS_PATH, CHANNELS_SPACE_PATH, DIRECT_PATH } from '../../pages/paths'; +import { useBotPresets } from '../../features/bots/catalog'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { allRoomsAtom } from '../../state/room-list/roomList'; +import { roomToParentsAtom } from '../../state/room/roomToParents'; +import { useOrphanSpaces } from '../../state/hooks/roomList'; +import { + getCanonicalAliasOrRoomId, + getCanonicalAliasRoomId, + isRoomAlias, +} from '../../utils/matrix'; +import { getChannelsSpacePath } from '../../pages/pathUtils'; +import { SpaceProvider } from '../../hooks/useSpace'; +import { Direct } from '../../pages/client/direct'; +import { Channels, ChannelsRootNav, useActiveSpace } from '../../pages/client/channels'; +import { Bots } from '../../pages/client/bots'; +import { ChannelsModeProvider } from '../../hooks/useChannelsMode'; +import { settingsSheetAtom } from '../../state/settingsSheet'; +import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet'; +import { MobilePagerPaneProvider, MobilePagerTab } from './MobilePagerPaneContext'; +import { MobileTabsPagerHeader } from './MobileTabsPagerHeader'; +import { useMobileTabsPagerGesture } from './useMobileTabsPagerGesture'; +import { PAGER_EASING, PAGER_TRANSITION_MS, PANE_GAP_PX } from './geometry'; +import * as css from './style.css'; + +// Aliased to the context's `MobilePagerTab` so the per-pane Segment's +// `selectTabInstant(target)` callback (exposed via MobilePagerPaneInfo) +// stays type-aligned with the pager's own `tabs` array. +type Tab = MobilePagerTab; + +// URL-safe wrapper around decodeURIComponent — matches the same helper +// inside `useActiveSpace`. Used here to validate the URL `:spaceIdOrAlias` +// param before we decide whether to mount the pager or defer to the +// existing route tree's JoinBeforeNavigate fallback. +const safeDecode = (raw: string): string | undefined => { + try { + return decodeURIComponent(raw); + } catch { + return undefined; + } +}; + +type PaneSlotProps = { + isActive: boolean; + children: ReactNode; +}; + +// Wraps a pane's DOM box and toggles the `inert` HTMLElement property +// based on the active flag, plus mirrors it into `aria-hidden`. +// +// `inert` removes the off-screen pane subtree from focus order, click +// handling, and the accessibility tree — important so assistive tech +// (and stray keyboard focus on devices with hardware keyboards) can't +// reach controls that are visually translateX'd out of the viewport. +// `aria-hidden` is the long-supported half of the same intent and +// alone covers most screen readers. Both are applied for full +// portability across AT/browser combinations. +// +// `inert` is assigned via ref (not as a JSX attr) because React 18.2's +// HTMLAttributes typing doesn't include it. The underlying DOM +// property is supported by Chromium 102+ / Safari 15.5+ which covers +// Capacitor's WebView baseline. +function PaneSlot({ isActive, children }: PaneSlotProps) { + const ref = useRef(null); + useEffect(() => { + if (ref.current) ref.current.inert = !isActive; + }, [isActive]); + return ( +
+ {children} +
+ ); +} + +// Mobile + Capacitor horizontal swipe pager. Mounts all three listing +// surfaces once, slides between them via CSS transform. +// +// Visual layout decomposes into a STATIC overlay header at the top +// (segments + action icons, painted by `MobileTabsPagerHeader`) and a +// translating strip below it. Each pane's StreamHeader still renders +// its own tabs row but `visibility: hidden` in pager mode — kept in +// DOM only so the curtain's TABS_ROW_PX-based snap geometry is +// preserved. Action icons in the static header proxy through +// `mobilePagerCurtainAtom`, written by whichever pane is active. +// +// Channels tab specifics: the pane content depends on whether the +// user has at least one joined orphan space. If yes, we render +// `` (workspace listing) keyed to the active space; if no, +// `` paints the empty-state CTA. `commitTo('channels')` +// navigates to /channels/!{spaceId}/ when an active space is known so +// the swipe never bounces through /channels/ + the ChannelsLanding +// `` redirect (which previously cut the slide animation +// short and made the gesture feel jerky). +// +// Inter-pane gap: `PANE_GAP_PX` is inserted between adjacent panes +// via inline `gap` on the strip. The pagerRoot's SurfaceVariant +// backdrop shows through the gap during a swipe, matching the user +// request for a light-blue divider colour identical to the header. +// +// Invalid space URL fall-through: if the URL is `/channels/:alias/` +// but `:alias` doesn't resolve to a joined orphan space (deep-link +// to a workspace the user isn't in, or a typo), the pager bails out +// and renders `` instead. That delegates to the existing +// `/channels/!space/` route element whose `RouteSpaceProvider` shows +// `JoinBeforeNavigate`. Without this guard, `useActiveSpace` would +// silently fall back to the persisted-or-first-orphan space and the +// pager would show a DIFFERENT workspace than the URL claims — +// confusing the user and breaking deep-link semantics. +export function MobileTabsPager() { + const mx = useMatrixClient(); + const navigate = useNavigate(); + const bots = useBotPresets(); + + // `end: true` matches the listing-root URL exactly. Detail URLs + // (/channels/!space/!room/, /direct/!room, /bots/:botId) flip these + // to false — and MobileTabsLayout above us would have rendered + // Outlet instead of the pager in that case, so we never see those + // states. Channels is matched via EITHER /channels/ (landing) OR + // /channels/!space/ (workspace listing) — both keep us on the + // channels tab. + const channelsRoot = !!useMatch({ path: CHANNELS_PATH, end: true }); + const channelsSpaceMatch = useMatch({ path: CHANNELS_SPACE_PATH, end: true }); + const channelsSpaceRoot = !!channelsSpaceMatch; + const channelsActive = channelsRoot || channelsSpaceRoot; + const botsRoot = !!useMatch({ path: BOTS_PATH, end: true }); + + // Active space resolution mirrors ChannelsLanding: URL > localStorage + // > first joined orphan. `useOrphanSpaces` filters `allRoomsAtom` + // through `isSpace(mx.getRoom) && !roomToParents.has(...)`, so every + // entry it returns is BOTH (a) a Space the user has joined and (b) + // an orphan (no parent Space). `useActiveSpace` then constrains its + // result to that orphan set. Net invariant: if `activeSpaceId` is + // defined, `mx.getRoom(activeSpaceId)` is a Space the user is + // currently a member of — which is exactly the precondition + // `RouteSpaceProvider` would otherwise enforce via + // `joinedSpaces.includes(space.roomId)` before mounting Channels. + // That's why the pager can mount Channels with a plain + // `` and skip RouteSpaceProvider's + // JoinBeforeNavigate fallback path safely — for VALID URL spaces. + // Invalid URL spaces hit the early-return below. + const roomToParents = useAtomValue(roomToParentsAtom); + const orphanSpaceIds = useOrphanSpaces(mx, allRoomsAtom, roomToParents); + const activeSpaceId = useActiveSpace(orphanSpaceIds); + const activeSpace = activeSpaceId ? mx.getRoom(activeSpaceId) : null; + + // Validate the URL `:spaceIdOrAlias` param when on the workspace + // route. If the param exists but doesn't resolve to a joined orphan + // (deep-link to an unjoined / unknown space), we'll defer to the + // original route tree below instead of silently substituting another + // workspace. + const urlSpaceParam = channelsSpaceMatch?.params.spaceIdOrAlias; + const urlSpaceIsValid = useMemo(() => { + if (!urlSpaceParam) return true; + const decoded = safeDecode(urlSpaceParam); + if (!decoded) return false; + const resolved = isRoomAlias(decoded) ? getCanonicalAliasRoomId(mx, decoded) : decoded; + return resolved !== undefined && orphanSpaceIds.includes(resolved); + }, [mx, urlSpaceParam, orphanSpaceIds]); + + const showBots = bots.length > 0 || botsRoot; + + const tabs = useMemo(() => { + const list: Tab[] = ['direct', 'channels']; + if (showBots) list.push('bots'); + return list; + }, [showBots]); + + const urlActiveIdx = useMemo(() => { + if (botsRoot) { + const i = tabs.indexOf('bots'); + return i >= 0 ? i : 0; + } + if (channelsActive) { + const i = tabs.indexOf('channels'); + return i >= 0 ? i : 0; + } + const i = tabs.indexOf('direct'); + return i >= 0 ? i : 0; + }, [tabs, channelsActive, botsRoot]); + + const [dragPx, setDragPxState] = useState(0); + const [dragging, setDraggingState] = useState(false); + // Tap-driven commits set this for one frame to suppress the strip's + // CSS transform transition. The strip jumps to the new tab without + // the 280ms slide animation that swipe commits intentionally play. + // Cleared in a rAF effect below so subsequent state changes resume + // with normal transitions. + const [instantSwitch, setInstantSwitch] = useState(false); + // Stored as a Tab NAME, not an index. The `tabs` array's length and + // composition can change at runtime (showBots flipping when the user + // navigates onto/off /bots/, or when a bot-config refresh adds a + // bot) — a stored index could end up pointing at the wrong tab or + // off the end of the array. A stable name lets us re-derive the + // index on every render via `tabs.indexOf(...)`. + // + // Load-bearing: react-router-dom v6's `useNavigate` does NOT auto- + // wrap in `React.startTransition` and its router-state update + // (`useSyncExternalStore`-backed) is asynchronous relative to React + // 18's auto-batching of setState in the same event handler. Without + // this pending lock, the commit path `setDragPx(0); setDragging + // (false); navigate(...)` can land in two renders — first with + // dragPx=0 at the OLD urlActiveIdx (strip snaps back to source tab), + // then with the NEW urlActiveIdx (strip animates to target). That + // two-stage flicker is exactly the "jerk on release" the user + // reported. See + // https://github.com/remix-run/react-router/issues/11003 for the + // upstream non-batching discussion. Do NOT remove without measuring. + // Cleared once the URL catches up (or after a safety timeout — see + // the effect below). + const [pendingTargetTab, setPendingTargetTab] = useState(null); + + const setDragPx = useCallback((px: number, drag: boolean) => { + setDragPxState(px); + setDraggingState(drag); + }, []); + + const destinationFor = useCallback( + (tab: Tab): string => { + if (tab === 'direct') return DIRECT_PATH; + if (tab === 'bots') return BOTS_PATH; + if (activeSpaceId) { + const alias = getCanonicalAliasOrRoomId(mx, activeSpaceId); + return getChannelsSpacePath(alias); + } + return CHANNELS_PATH; + }, + [mx, activeSpaceId] + ); + + // Shared commit core. `instant=true` flips `instantSwitch` for one + // frame so the strip's transform jump from old tab to new lands + // without the swipe-finish CSS transition — used by tap paths + // (static header Segments + per-pane Segments). `instant=false` + // preserves the animation — used by the swipe gesture commit so + // the strip smoothly completes the user's drag. + const commitToInternal = useCallback( + (idx: number, instant: boolean) => { + const target = tabs[idx]; + if (!target) return; + setDragPxState(0); + setDraggingState(false); + if (instant) setInstantSwitch(true); + setPendingTargetTab(target); + navigate(destinationFor(target), { replace: true }); + }, + [tabs, navigate, destinationFor] + ); + + const commitToSwipe = useCallback( + (idx: number) => commitToInternal(idx, false), + [commitToInternal] + ); + + // Tap entry by tab name. Exposed to per-pane Segments via + // `MobilePagerPaneInfo.selectTabInstant` so the invisible per-pane + // segment buttons (which capture taps when the static header isn't + // z-elevated) route through the same instant commit path as the + // static header's own segment buttons. + const selectTabInstant = useCallback( + (target: Tab) => { + const i = tabs.indexOf(target); + if (i >= 0) commitToInternal(i, true); + }, + [tabs, commitToInternal] + ); + + const onSelectDirect = useCallback(() => selectTabInstant('direct'), [selectTabInstant]); + const onSelectChannels = useCallback(() => selectTabInstant('channels'), [selectTabInstant]); + const onSelectBots = useCallback(() => selectTabInstant('bots'), [selectTabInstant]); + + // Clear `instantSwitch` on the next paint frame so subsequent + // transform changes use the normal animated transition again. rAF + // (not setTimeout 0) so we cancel cleanly if a new commit re-arms + // the flag before the frame fires. + useEffect(() => { + if (!instantSwitch) return undefined; + const id = requestAnimationFrame(() => setInstantSwitch(false)); + return () => cancelAnimationFrame(id); + }, [instantSwitch]); + + const pendingTargetIdx = pendingTargetTab !== null ? tabs.indexOf(pendingTargetTab) : -1; + + useEffect(() => { + if (pendingTargetTab === null) return undefined; + // Tab disappeared from the array mid-animation (e.g. /bots/ deep- + // link held the Bots tab visible, the user committed to Direct, + // and during the slide the bot config became empty so showBots + // flipped to false). The stored target no longer maps to any + // index — clear immediately so visualIdx falls back to urlActive. + if (pendingTargetIdx === -1) { + setPendingTargetTab(null); + return undefined; + } + if (pendingTargetIdx === urlActiveIdx) { + setPendingTargetTab(null); + return undefined; + } + const id = window.setTimeout(() => setPendingTargetTab(null), PAGER_TRANSITION_MS + 100); + return () => window.clearTimeout(id); + }, [pendingTargetTab, pendingTargetIdx, urlActiveIdx]); + + const visualIdx = pendingTargetIdx >= 0 ? pendingTargetIdx : urlActiveIdx; + const visualDragPx = pendingTargetTab !== null ? 0 : dragPx; + + // Suppress the pager gesture while ANY of: + // 1. A horseshoe sheet is open (Settings or workspace switcher). + // A horizontal swipe on the sheet body, or on the still- + // visible listing above the sheet, would steer the pager into + // a sibling tab and unmount the sheet's host. + // 2. A commit-slide animation is in flight (pendingTargetTab set). + // Starting a new gesture during the 280ms transition would + // either jump (because visualDragPx is forced to 0) or commit + // relative to a stale urlActiveIdx — same UX hazard React + // Navigation's TabView avoids by locking gestures during + // transitions. + const settingsSheetOpen = !!useAtomValue(settingsSheetAtom); + const workspaceSheetOpen = !!useAtomValue(channelsWorkspaceSheetAtom); + const gestureDisabled = settingsSheetOpen || workspaceSheetOpen || pendingTargetTab !== null; + + const rootRef = useRef(null); + useMobileTabsPagerGesture({ + rootRef, + activeIdx: urlActiveIdx, + tabsCount: tabs.length, + disabled: gestureDisabled, + setDragPx, + // Swipe commits keep the slide animation — the strip glides from + // the drag-released position to the target tab. Tap commits use + // `selectTabInstant` (passed down via per-pane context + the + // static header's `onSelectXxx` props) which sets `instantSwitch` + // for one frame to skip the transition. + commitTo: commitToSwipe, + }); + + // Gap-aware strip transform. Each adjacent pane is offset by an + // extra `PANE_GAP_PX` so a swipe past the gap zone exposes the + // pagerRoot backdrop colour, matching the static header tone. + // Memoised so the inline object identity is stable when dragPx + // doesn't change — avoids extra child re-renders when other state + // updates (e.g. atom subscription) tick the parent. + const stripStyle = useMemo( + () => ({ + width: `calc(${tabs.length * 100}vw + ${(tabs.length - 1) * PANE_GAP_PX}px)`, + transform: `translate3d(calc(${-visualIdx * 100}vw - ${ + visualIdx * PANE_GAP_PX + }px + ${visualDragPx}px), 0, 0)`, + // Transition suppressed while a finger drag is in flight (the + // strip follows the finger 1:1) AND for the single frame after + // a tap-driven commit (`instantSwitch`) so segment taps snap to + // the target tab without the 280ms slide. Swipe commits leave + // `instantSwitch` false so the slide-to-target animation plays. + transition: + dragging || instantSwitch ? 'none' : `transform ${PAGER_TRANSITION_MS}ms ${PAGER_EASING}`, + gap: `${PANE_GAP_PX}px`, + }), + [tabs.length, visualIdx, visualDragPx, dragging, instantSwitch] + ); + + // Per-pane context values memoised separately so each pane's + // `useMobilePagerPane()` consumer (the inner StreamHeader) only + // re-runs when ITS isActive flag toggles, not every time the parent + // re-renders (e.g. on every touchmove during a drag). Without this, + // a fresh `{ isActive: bool }` object per render would tick every + // pane's context subscription at 60Hz during a swipe. + const directIdx = useMemo(() => tabs.indexOf('direct'), [tabs]); + const channelsIdx = useMemo(() => tabs.indexOf('channels'), [tabs]); + const botsIdx = useMemo(() => tabs.indexOf('bots'), [tabs]); + const directPaneInfo = useMemo( + () => ({ isActive: urlActiveIdx === directIdx, selectTabInstant }), + [urlActiveIdx, directIdx, selectTabInstant] + ); + const channelsPaneInfo = useMemo( + () => ({ isActive: urlActiveIdx === channelsIdx, selectTabInstant }), + [urlActiveIdx, channelsIdx, selectTabInstant] + ); + const botsPaneInfo = useMemo( + () => ({ isActive: urlActiveIdx === botsIdx, selectTabInstant }), + [urlActiveIdx, botsIdx, selectTabInstant] + ); + + // The static header doesn't need useMatch of its own — `urlActiveIdx` + // is already the authoritative source of truth for which tab is + // active. Map it back to a Tab name and pass down. + const activeTab: Tab = tabs[urlActiveIdx] ?? 'direct'; + + // Invalid URL space — defer to the existing route tree which + // handles unjoined / unknown spaces via JoinBeforeNavigate. All + // hooks above must run unconditionally for rules-of-hooks + // compliance; this early-return is the first conditional render. + if (channelsSpaceRoot && !urlSpaceIsValid) { + return ; + } + + return ( +
+ + {/* `data-pager-pane="true"` flags everything inside the strip so + per-pane background paints (PageNav-inner surface, + MobileSettings / ChannelsWorkspace appBody, StreamHeader + stage + header) collapse to transparent in pager mode. With + both the strip and the static header at z-auto in pagerRoot, + DOM order puts the strip on top — and with the strip's bg + layers transparent, the static header tabs show through every + pixel the curtain isn't covering. See `pagerStaticHeader` in + style.css.ts for the full overlay contract. */} +
+ + + + + + + + + {activeSpace ? ( + + + + ) : ( + + )} + + + + {showBots && ( + + + + + + )} +
+
+ ); +} diff --git a/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx b/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx new file mode 100644 index 00000000..e41fb1ce --- /dev/null +++ b/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx @@ -0,0 +1,214 @@ +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAtomValue } from 'jotai'; +import { Box, Icon, IconButton, Icons } from 'folds'; +import { + curtainPinnedByTabAtom, + mobileHorseshoeActiveAtom, + mobilePagerCurtainAtom, +} from '../../state/mobilePagerHeader'; +import { Segment } from '../stream-header/Segment'; +import * as streamHeaderCss from '../stream-header/StreamHeader.css'; +import * as css from './style.css'; + +// Positive z-index applied to the static pager header when any +// horseshoe sheet is geometrically active (drag in flight or sheet +// open). Any positive value beats the strip's `z-index: auto` +// stacking context in pagerRoot, so `z: 1` is sufficient; the constant +// just makes the intent explicit at the call site. +const PAGER_HEADER_ELEVATED_Z = 1; + +type Tab = 'direct' | 'channels' | 'bots'; + +// Must match the `INLINE_FORM_ID` local constant in +// `StreamHeader.tsx`. The shared static header's action icons are +// `aria-controls`-linked to the form region that the active pane's +// StreamHeader renders inside its curtain — mirroring the original +// in-pane buttons' ARIA semantics so assistive tech still announces +// the relationship correctly. Keep the two literals in lockstep. +const INLINE_FORM_ID = 'stream-header-inline-form'; + +type MobileTabsPagerHeaderProps = { + showBots: boolean; + // Active tab name resolved by the parent pager from the URL. We + // accept it as a prop rather than re-running `useMatch` here — the + // pager already knows the answer and passing it down keeps the + // segment highlight in lock-step with the strip's visual position + // (one source of truth = `urlActiveIdx`). + activeTab: Tab; + onSelectDirect: () => void; + onSelectChannels: () => void; + onSelectBots: () => void; +}; + +// Static shared tabs row painted at the top of MobileTabsPager. Lives +// outside the swipe strip so it doesn't translate with the panes — +// addresses the user-visible regression where each pane's identical +// tabs row sliding underneath felt like "the header is moving" even +// though the segments matched at every pixel. +// +// Reuses `stream-header/StreamHeader.css.ts` classes (`tabsRow`, +// `tabsCluster`, `iconsCluster`) so the layout, padding, and segment +// styling stay identical to the per-pane tabs row that sits hidden +// underneath. The only structural difference is the surrounding +// `pagerStaticHeader` wrapper which positions this row absolute at +// the top of the pager and reserves the status-bar safe-area inset. +// +// Segment clicks call the pager's commit callbacks (so the swipe +// animation uses the same pendingTargetIdx path as a finger swipe). +// Action icons read `mobilePagerCurtainAtom` — the active pane's +// StreamHeader writes its curtain controls there, so Plus/Search/X +// drive whichever curtain is currently visible. +// +// ARIA: action icons mirror the original in-pane buttons' +// `aria-controls` / `aria-expanded` / `aria-haspopup` relationship to +// the form region (`#stream-header-inline-form`). When `iconsDisabled` +// (atom not yet populated on initial mount) the buttons report +// `aria-disabled` so assistive tech announces the unavailable state +// instead of silently failing on activation. +export function MobileTabsPagerHeader({ + showBots, + activeTab, + onSelectDirect, + onSelectChannels, + onSelectBots, +}: MobileTabsPagerHeaderProps) { + const { t } = useTranslation(); + + const curtainControls = useAtomValue(mobilePagerCurtainAtom); + const isFormActive = curtainControls?.isFormActive ?? false; + const openChat = useCallback(() => curtainControls?.openChat(), [curtainControls]); + const openSearch = useCallback(() => curtainControls?.openSearch(), [curtainControls]); + const closeForm = useCallback(() => curtainControls?.closeForm(), [curtainControls]); + const iconsDisabled = curtainControls === null; + // Tab-specific override for the Plus button (Channels publishes + // «create channel» / «create community»). Falls back to the default + // «new chat» path that opens InlineNewChatForm via the curtain. + // `primaryAction.onClick` is already stable (memoised by the + // publishing pane), so we wire it directly into onClick without + // re-wrapping in another useCallback. + const primaryAction = curtainControls?.primaryAction ?? null; + + // The static header does NOT translate to follow the curtain. It + // stays put; the curtain physically rises ABOVE it via z-stack — see + // the «curtain-overlay invariants» comment in style.css.ts on + // pagerStaticHeader for the bg / z-order contract. + // + // Z-elevation while a horseshoe sheet is GEOMETRICALLY active: the + // MobileSettings / ChannelsWorkspace container paints + // `VOJO_HORSESHOE_VOID_COLOR` (= #000 in dark theme) across the + // entire pane to drive the carve cut-out the moment `expandedPx > 0`. + // Without elevation that void bleeds up through the transparent + // strip-stack into the safe-top + tabsRow zone, turning the system- + // tray strip + tabs black. Bumping the static header into a positive + // z-index puts it ABOVE the strip's stacking context (positive z + // beats z:auto stacking contexts per CSS painting order), covering + // the void in its own y-band with SurfaceVariant bg + visible tabs. + // + // The atom tracks the GEOMETRIC signal (`expandedPx > 0`), not the + // sheet-open atoms, so elevation lands on the FIRST frame of drag — + // not 80 px later when the user crosses the commit threshold. The + // horseshoes' appBody flips to opaque in lockstep (same signal), + // containing the void to the bottom carve everywhere below the + // static header. + // + // Pinned-overrides-elevation: when the active pane's curtain is + // pinned the curtain itself contains the void — it covers everything + // from `y = safe-top` downward inside the strip's stacking context, + // and the opaque appBody (also flipped on `horseshoeActive`) covers + // the safe-top band above the curtain. Re-elevating the static + // header in that state would visibly «slice» the pinned curtain in + // the safe-top + tabsRow band, popping tabs back over what the user + // explicitly pulled up to cover. So we suppress elevation whenever + // the active tab's pin is set — preserves the «pinned hides tabs» + // invariant across sheet open/drag. + // + // The curtain pin gesture is suppressed while either sheet is open + // (see `StreamHeader.gestureDisabled`), so this elevation never + // races with a pin-in-progress drag. + const horseshoeActive = useAtomValue(mobileHorseshoeActiveAtom); + const pinnedByTab = useAtomValue(curtainPinnedByTabAtom); + const activePinned = !!pinnedByTab[activeTab]; + const elevated = horseshoeActive && !activePinned; + + return ( +
+
+
+ + + {showBots && ( + + )} +
+ + {isFormActive ? ( + + + + ) : ( +
+ + + + + + +
+ )} +
+
+ ); +} diff --git a/src/app/components/mobile-tabs-pager/geometry.ts b/src/app/components/mobile-tabs-pager/geometry.ts new file mode 100644 index 00000000..ad7b4b36 --- /dev/null +++ b/src/app/components/mobile-tabs-pager/geometry.ts @@ -0,0 +1,40 @@ +// Mobile horizontal swipe pager — tuning constants. +// +// The pager is only active on Capacitor + mobile + listing-root URLs +// (/direct/, /channels/, /bots/). Everywhere else MobileTabsLayout +// passes through to and these values are inert. + +// Direction-resolve dead-zone (px). The finger must travel at least +// this far on either axis before we resolve the gesture as horizontal +// (engage the pager) or vertical (bail, let curtain / horseshoes / +// scroll take over). +export const DEAD_ZONE_PX = 12; + +// Edge-guard band (px). Touchstart inside this strip from the L or R +// viewport edge is ignored — that zone belongs to the Android system +// back-gesture in edge-to-edge mode, and reacting to it would steal +// the back-swipe. +export const EDGE_GUARD_PX = 24; + +// Rubber-band attenuation factor applied when the user pulls past the +// leftmost or rightmost tab boundary. Soft pull, never commits. +export const RUBBER_BAND_FACTOR = 0.35; + +// Commit threshold = max(MIN_COMMIT_PX, viewport_width × COMMIT_FRACTION). +// Tuned wide on purpose (≈40% of viewport, floor 150px) so accidental +// horizontal jitter on long DM rows doesn't flip tabs. +export const MIN_COMMIT_PX = 150; +export const COMMIT_FRACTION = 0.4; + +// Snap-back / commit-slide animation. Same curve & duration as the +// curtain commit so the two motions feel consistent. +export const PAGER_TRANSITION_MS = 280; +export const PAGER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; + +// Visible gap between adjacent panes inside the strip. Surfaces the +// `SurfaceVariant.Container` (pagerRoot's backdrop) during a swipe, +// matching the design intent of a light-blue divider between screens. +// Roughly 2× the standard horseshoe seam (`VOJO_HORSESHOE_GAP_PX=12`) +// — the inter-pane gap reads as a transitional void rather than a +// horseshoe surface boundary, so it's tuned wider to feel breathing. +export const PANE_GAP_PX = 24; diff --git a/src/app/components/mobile-tabs-pager/index.ts b/src/app/components/mobile-tabs-pager/index.ts new file mode 100644 index 00000000..c10d0a08 --- /dev/null +++ b/src/app/components/mobile-tabs-pager/index.ts @@ -0,0 +1,4 @@ +// `MobileTabsPager` is intentionally NOT exported — it's mounted only +// from `MobileTabsLayout` based on the routed activation conditions, +// never directly by route or app code. +export { MobileTabsLayout } from './MobileTabsLayout'; diff --git a/src/app/components/mobile-tabs-pager/style.css.ts b/src/app/components/mobile-tabs-pager/style.css.ts new file mode 100644 index 00000000..09f5acd2 --- /dev/null +++ b/src/app/components/mobile-tabs-pager/style.css.ts @@ -0,0 +1,161 @@ +import { style } from '@vanilla-extract/css'; +import { color } from 'folds'; + +// Pager root. Sits inside the authed shell's row-flex slot +// (ClientLayout → Box grow=Yes), so `flex: 1 1 0` fills the slot +// horizontally; `align-items: stretch` on the parent fills vertically. +// +// `touch-action: pan-y` lets the browser keep doing native vertical +// scroll (DM list virtualizer, curtain peek pull-down) without us +// having to call preventDefault on every move — only the pager's own +// listener calls preventDefault, and only after axis-resolve commits +// to "horizontal". +// +// `SurfaceVariant.Container` backdrop intentionally shows through +// (a) the inter-pane gap during a swipe — the gap colour the user +// asked for is "light blue same as the header", which IS this +// SurfaceVariant tone — and (b) any sub-pixel rounding seam at rest. +// +// `color: Background.OnContainer` mirrors what the route-level +// `` would have set via its `ContainerColor({ variant: +// 'Background' })` wrapper. We mount Direct/Channels/Bots directly +// here, bypassing PageRoot for the swipe-pager experience, so without +// this declaration the form labels rendered inside the per-pane +// StreamHeader curtain (`` for Username/Server/ +// Options) had `color: inherit` cascading all the way up to `body`, +// which sets no color → browser default black. The labels became +// invisible against the dark form background only on native (where the +// pager activates). Desktop / web go through PageRoot and inherit the +// expected light tone for free. +export const pagerRoot = style({ + position: 'relative', + flex: '1 1 0', + minWidth: 0, + minHeight: 0, + height: '100%', + overflow: 'hidden', + touchAction: 'pan-y', + backgroundColor: color.SurfaceVariant.Container, + color: color.Background.OnContainer, +}); + +// Shared static tabs row painted BEHIND the strip in DOM order. +// Reserves the status-bar safe-area inset via padding-top so the +// segments + icons sit just below the system status bar, and so the +// backdrop colour extends through the inset zone (matching the per-pane +// PageNav's own `paddingTop: var(--vojo-safe-top)` so there's no +// visible band boundary at the inset edge). +// +// Curtain-overlay invariants (why no z-index here at rest): +// +// The chats curtain must visually rise ABOVE this header when the +// user pulls it up to the «pinned» snap — like a real blind sliding +// over the segments rather than the segments moving. The curtain +// lives inside each pane > stage with `z: 2` in stage's local +// stacking context. The stage / pane stack inside the swipe `strip`, +// which creates its own stacking context via `transform`. To let the +// curtain visually surface above this static header we: +// +// (a) leave both elements at `z-index: auto` in pagerRoot's +// stacking context (this block has no `zIndex` AT REST), so +// painting order falls back to DOM order. `pagerStaticHeader` +// is rendered BEFORE `strip` in MobileTabsPager — so the +// strip (and everything inside it that paints opaquely) paints +// on top. +// +// (b) tag the strip with `data-pager-pane="true"`. All per-pane +// background paints (PageNav-inner surface, MobileSettings/ +// ChannelsWorkspace appBody, StreamHeader stage + header) +// become transparent under that selector, so the static +// header tabs show through every transparent layer of the +// strip until the curtain — the only remaining opaque element +// — covers them by being positioned at top: 0 (pinned snap). +// +// Breaking (a) or (b) re-introduces the «paravozik» regression where +// the tabs visually slide with the curtain. See git history for the +// user-feedback trail. +// +// Conditional z-elevation (horseshoe-active override, suppressed by pin): +// +// When a horseshoe sheet (Settings or workspace switcher) is +// geometrically active — i.e. `expandedPx > 0`, which covers both +// the in-flight drag and the committed-open state — the wrapping +// container paints `VOJO_HORSESHOE_VOID_COLOR` (= #000 in dark +// theme) across the entire pane so the carve at the sheet's top +// reads as a dark seam. With the transparent strip stack from (b), +// that void would bleed up through the safe-top + tabsRow zone, +// turning the system-tray strip + tabs solid black. +// +// `MobileTabsPagerHeader.tsx` bumps this element to a positive +// `zIndex` (inline style, driven by `mobileHorseshoeActiveAtom`) +// from the first frame of drag. Positive z beats the strip's +// `z: auto` stacking context, putting the static header back on +// top in the safe-top + tabsRow band — the void is contained to +// the carve area, tabs stay visible. The horseshoe's `appBody` +// flips back to opaque on the same signal so the void doesn't +// bleed into the chip-area band between the static header and +// the curtain top either. The curtain pin gesture is gated off +// in the same state (see `StreamHeader.gestureDisabled`) so no +// pin can race the elevation flip. +// +// Pinned-override: when the active pane's curtain is pinned, the +// curtain itself sits at the top of the stage (z:2 inside the +// strip's stacking ctx) and covers everything from `y = safe-top` +// downward — including the tabsRow band. Above the curtain +// (y=0..safe-top) the opaque appBody contains the void. Elevating +// the static header in that state would visibly slice the pinned +// curtain in the tabsRow band, popping tabs over what the user +// explicitly pulled up to cover. So `MobileTabsPagerHeader` +// suppresses elevation whenever `curtainPinnedByTabAtom[activeTab]` +// is true — preserves the «pinned hides tabs» invariant across +// sheet open/drag without re-introducing the void leak. +export const pagerStaticHeader = style({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + paddingTop: 'var(--vojo-safe-top, 0px)', + // The wrapped tabsRow has its own height of TABS_ROW_PX via the + // stream-header recipe; we don't set a fixed height here so the + // status-bar inset adds on top naturally. + backgroundColor: color.SurfaceVariant.Container, +}); + +// Horizontal strip carrying all three panes side-by-side. Width & +// transform are computed inline in the JSX (they depend on tabs.length +// and visualIdx + visualDragPx, and the gap math couples to them). +// +// `gap: PANE_GAP_PX` is what makes the inter-pane void visible during +// a swipe — the pagerRoot's SurfaceVariant.Container colour shows +// through the gap, matching the static header tone exactly. +export const strip = style({ + display: 'flex', + flexDirection: 'row', + height: '100%', + willChange: 'transform', +}); + +// Each pane is exactly one viewport wide. CRITICALLY `display: flex; +// flex-direction: row` so the nested Folds PageNav (which is a flex +// child with `flex-grow: 1` on mobile to override its 256px recipe +// width) expands to fill the pane. A column-flex parent here would +// leave PageNav at 256px — the bug that ate the previous attempt. +// +// No paddingTop here: the per-pane StreamHeader still renders its +// own tabs row (kept for the curtain's TABS_ROW_PX snap math, just +// painted invisible via `opacity: 0` — load-bearing because +// `visibility: hidden` would remove the row from hit-testing and +// the per-pane Segments need to capture taps at rest, see +// `StreamHeader.tsx` tabsRow rationale), and PageNav's inner column +// reserves the status-bar safe-area inset via its own +// `paddingTop: var(--vojo-safe-top)`. The static header overlay at +// the pager root simply paints OVER the same screen zone, so the +// underlying geometry stays identical to non-pager mode. +export const pane = style({ + display: 'flex', + flexDirection: 'row', + flexShrink: 0, + width: '100vw', + height: '100%', + minWidth: 0, +}); diff --git a/src/app/components/mobile-tabs-pager/useMobileTabsPagerGesture.ts b/src/app/components/mobile-tabs-pager/useMobileTabsPagerGesture.ts new file mode 100644 index 00000000..b2cbe12d --- /dev/null +++ b/src/app/components/mobile-tabs-pager/useMobileTabsPagerGesture.ts @@ -0,0 +1,220 @@ +import { MutableRefObject, useEffect, useRef } from 'react'; +import { + COMMIT_FRACTION, + DEAD_ZONE_PX, + EDGE_GUARD_PX, + MIN_COMMIT_PX, + RUBBER_BAND_FACTOR, +} from './geometry'; + +type Args = { + // Root element the touch listeners attach to. Touches outside this + // element never reach the pager — that's how we keep the gesture + // scoped to the listing surface and out of detail routes. + rootRef: MutableRefObject; + // Index of the currently active pane. Mirrored into a ref so the + // single bound effect reads fresh values without re-attaching. + activeIdx: number; + // Total number of panes. Used to clamp commit + rubber-band edges. + tabsCount: number; + // While true the listeners stay bound but every touchstart bails + // immediately. Used by the parent to suppress the gesture when an + // overlay sheet (settings, workspace switcher) is open — a swipe + // there shouldn't navigate sibling tabs. + disabled: boolean; + // Setter for the live drag delta. The pager component re-renders the + // strip transform on every change. + setDragPx: (px: number, dragging: boolean) => void; + // Commit a tab change. The caller is expected to reset dragPx to 0 + // AND call navigate(replace) in the same React batch so the strip's + // transform jumps from (oldIdx, dragPx) to (newIdx, 0) in one render + // — CSS transition then animates the (small) remaining distance + // smoothly without an intermediate "snap back" flash. + commitTo: (idx: number) => void; +}; + +// Horizontal swipe driver for the mobile listing tab pager. Mirrors +// the shape of `useCurtainHandleGesture`: single listener bound to +// the pager root, refs for the latest snap/index state, axis-resolve +// in the dead-zone, rubber-band at boundaries, threshold-commit on +// release. +// +// Conflict resolution with other gestures sharing the same surface +// (curtain, MobileSettingsHorseshoe, ChannelsWorkspaceHorseshoe) is +// cooperative: every gesture-owner resolves axis at the same dead- +// zone (12px) and bails when its own axis doesn't dominate. The pager +// wins horizontal; the others win vertical. +export function useMobileTabsPagerGesture({ + rootRef, + activeIdx, + tabsCount, + disabled, + setDragPx, + commitTo, +}: Args): void { + const activeRef = useRef(activeIdx); + const countRef = useRef(tabsCount); + const disabledRef = useRef(disabled); + activeRef.current = activeIdx; + countRef.current = tabsCount; + disabledRef.current = disabled; + + useEffect(() => { + const root = rootRef.current; + if (!root) return undefined; + + let startX: number | null = null; + let startY: number | null = null; + let engaged = false; + let bailed = false; + let lastDragPx = 0; + + const reset = () => { + startX = null; + startY = null; + engaged = false; + bailed = false; + lastDragPx = 0; + }; + + const onTouchStart = (e: TouchEvent) => { + if (disabledRef.current) { + reset(); + return; + } + if (e.touches.length !== 1) { + reset(); + return; + } + const t = e.touches[0]; + const vw = window.innerWidth; + // Android system back-gesture lives in the L/R edge strip in + // edge-to-edge mode. Ignore touches there so we don't fight it. + if (t.clientX < EDGE_GUARD_PX || t.clientX > vw - EDGE_GUARD_PX) { + reset(); + return; + } + startX = t.clientX; + startY = t.clientY; + engaged = false; + bailed = false; + lastDragPx = 0; + }; + + const onTouchMove = (e: TouchEvent) => { + if (e.touches.length !== 1) { + // Second finger landed mid-gesture — abort without commit. + if (engaged) setDragPx(0, false); + reset(); + bailed = true; + return; + } + // Defensive symmetry with onTouchStart's disabled check: a sheet + // opening async between touchstart and touchmove (e.g. atom flip + // from a delayed effect) shouldn't let an already-armed pager + // gesture commit through. + if (disabledRef.current) { + if (engaged) setDragPx(0, false); + reset(); + bailed = true; + return; + } + if (startX === null || startY === null || bailed) return; + const t = e.touches[0]; + const dx = t.clientX - startX; + const dy = t.clientY - startY; + + if (!engaged) { + // Wait for the finger to leave the dead-zone before deciding + // who owns the gesture. The pager only engages when |dx| + // strictly dominates |dy|; ties go to vertical (curtain + + // horseshoe pull-down feels more natural than horizontal + // commit for ambiguous gestures). + if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) return; + if (Math.abs(dy) >= Math.abs(dx)) { + bailed = true; + return; + } + engaged = true; + } + + if (e.cancelable) e.preventDefault(); + const vw = window.innerWidth; + const idx = activeRef.current; + const count = countRef.current; + let drag = dx; + // Rubber-band at the leftmost (dx > 0 = trying to go past idx 0) + // and rightmost (dx < 0 = trying to go past last idx) boundary. + // Soft attenuation — commit threshold can never be reached, so + // the spring-back on release lands us back on the current tab. + if (idx === 0 && dx > 0) drag = dx * RUBBER_BAND_FACTOR; + else if (idx === count - 1 && dx < 0) drag = dx * RUBBER_BAND_FACTOR; + // Clamp to ±one viewport so an overshooting swipe doesn't + // translate the strip into nonsense territory. + drag = Math.max(-vw, Math.min(vw, drag)); + lastDragPx = drag; + setDragPx(drag, true); + }; + + const onTouchEnd = () => { + if (!engaged) { + reset(); + return; + } + // Defensive recheck symmetric with onTouchStart / onTouchMove: + // an overlay sheet could have opened between the last touchmove + // and this touchend (atom flip from a delayed effect, a system + // dialog, etc.). Committing under those circumstances would + // navigate sibling tabs from beneath the overlay — same hazard + // the touchstart/move gates exist to prevent. Spring back + // instead. + if (disabledRef.current) { + setDragPx(0, false); + reset(); + return; + } + const vw = window.innerWidth; + const idx = activeRef.current; + const count = countRef.current; + const threshold = Math.max(MIN_COMMIT_PX, vw * COMMIT_FRACTION); + + let nextIdx = idx; + // Negative drag (finger moved left) → next tab to the right. + // Positive drag (finger moved right) → previous tab to the left. + if (lastDragPx <= -threshold && idx < count - 1) nextIdx = idx + 1; + else if (lastDragPx >= threshold && idx > 0) nextIdx = idx - 1; + + if (nextIdx !== idx) { + commitTo(nextIdx); + } else { + // No commit — re-enable transition and animate the strip back + // to its resting position at the current tab. + setDragPx(0, false); + } + + reset(); + }; + + const onTouchCancel = () => { + // System cancel (incoming call, scroll-take-over, etc.) — never + // commit; just spring back if a drag was in flight. + if (engaged) setDragPx(0, false); + reset(); + }; + + root.addEventListener('touchstart', onTouchStart, { passive: true }); + root.addEventListener('touchmove', onTouchMove, { passive: false }); + root.addEventListener('touchend', onTouchEnd, { passive: true }); + root.addEventListener('touchcancel', onTouchCancel, { passive: true }); + return () => { + root.removeEventListener('touchstart', onTouchStart); + root.removeEventListener('touchmove', onTouchMove); + root.removeEventListener('touchend', onTouchEnd); + root.removeEventListener('touchcancel', onTouchCancel); + }; + // setDragPx / commitTo are stable useCallbacks from the parent; + // activeIdx / tabsCount are mirrored via the refs above so the + // listener reads fresh values without re-binding on every nav. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rootRef, setDragPx, commitTo]); +} diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index 2abc8005..f72b924f 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -9,17 +9,13 @@ import React, { useRef, useState, } from 'react'; -import { Box, Header, Line, Scroll, Text, as, toRem } from 'folds'; +import { Box, Header, Line, Scroll, Text, as, color, toRem } from 'folds'; import { useAtom } from 'jotai'; import classNames from 'classnames'; import { ContainerColor } from '../../styles/ContainerColor.css'; import * as css from './style.css'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; -import { - SIDEBAR_WIDTH_MIN, - clampSidebarWidth, - sidebarWidthAtom, -} from '../../state/sidebarWidth'; +import { SIDEBAR_WIDTH_MIN, clampSidebarWidth, sidebarWidthAtom } from '../../state/sidebarWidth'; import { VOJO_HORSESHOE_VOID_COLOR, VOJO_HORSESHOE_GAP_PX, @@ -82,12 +78,9 @@ export function PageRoot({ nav, children }: PageRootProps) { TL/BL carves expose the outer's void. The explicit Background bg on the inner is what keeps the panel's apparent colour unchanged for routes whose content has no - opaque bg of its own (e.g. ChannelsLanding) — without it - the outer void would bleed through. */} - + opaque bg of its own — without it the outer void would + bleed through. */} + {children}; } + const radii = toRem(VOJO_HORSESHOE_RADIUS_PX); + const roundedRightStyle = + roundedRight && horseshoe + ? { borderTopRightRadius: radii, borderBottomRightRadius: radii } + : undefined; + // Inline `backgroundColor` overrides whatever `PageNavInnerWebHorseshoe` + // sets via vanilla-extract — inline style wins on specificity, so + // we can override the default `Background.Container` without + // touching the recipe. + const surfaceStyle = + surface === 'surfaceVariant' ? { backgroundColor: color.SurfaceVariant.Container } : undefined; + return ( ` with a fixed height — + // padding there would clip the header content. `--vojo-safe-top` + // is 0 on web and inside Modal500-hosted dialogs. + style={{ + paddingTop: 'var(--vojo-safe-top, 0px)', + ...roundedRightStyle, + ...surfaceStyle, + }} > {children} @@ -158,9 +193,7 @@ function ResizablePageNav({ children }: { children: ReactNode }) { const handleRef = useRef(null); const horseshoe = useHorseshoeEnabled(); const [savedWidth, setSavedWidth] = useAtom(sidebarWidthAtom); - const [vw, setVw] = useState( - typeof window !== 'undefined' ? window.innerWidth : 1280 - ); + const [vw, setVw] = useState(typeof window !== 'undefined' ? window.innerWidth : 1280); const [dragging, setDragging] = useState(false); // Live width during a drag — kept in component state so we don't write to // the localStorage-backed atom on every pointermove (hundreds of sync disk @@ -273,14 +306,19 @@ function ResizablePageNav({ children }: { children: ReactNode }) { grow="Yes" direction="Column" className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined} - // See twin block in `PageNav` above — same native safe-area - // protection for any footer row mounted inside a resizable - // page-nav. On web `var(--vojo-safe-bottom)` is 0. - style={{ paddingBottom: 'var(--vojo-safe-bottom)' }} + // Same native safe-top inset as the regular PageNav above — + // `var(--vojo-safe-top)` is 0 on web (where resizable is used) + // but kept here for symmetry / future-proofing. + style={{ paddingTop: 'var(--vojo-safe-top, 0px)' }} > {children} {canResize && ( + // Canonical WAI-ARIA window-splitter pattern: focusable separator + // with aria-orientation and current/min/max values. The strict + // role-supports-aria-props lookup table doesn't model the splitter + // sub-pattern, but assistive tech does. + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/role-supports-aria-props, jsx-a11y/no-noninteractive-tabindex
; }) { return ( - + // `minHeight: 0` is the canonical flexbox fix for a scroll child inside + // a flex column: without it the Scroll's intrinsic content height pushes + // the wrapper to that height, the column overflows the viewport, and + // every sibling with default `flex-shrink: 1` (header / footer rows) + // gets squashed below its natural height. + (({ className, ...props }, ref) => ( - -)); +type PageVariantProps = { + // Background surface tone. Default `'Surface'` (Dawn bg2, #0d0e11) + // — the deepest tone used by every sub-page elsewhere in the app. + // `'SurfaceVariant'` (Dawn bg, #181a20) is one notch lighter and + // used by the Settings sub-pages so they read on the same surface + // tone as the Settings menu (which itself uses + // `surface="surfaceVariant"` on its PageNav). Other variants + // (`'Background'`, `'Primary'`, etc.) pass through unchanged in + // case a future surface needs a different tone — see folds tokens + // for the full set. + variant?: 'Background' | 'Surface' | 'SurfaceVariant' | 'Primary' | 'Secondary'; +}; +export const Page = as<'div', PageVariantProps>( + ({ className, variant = 'Surface', ...props }, ref) => ( + + ) +); export const PageHeader = as<'div', css.PageHeaderVariants>( ({ className, outlined, balance, ...props }, ref) => ( diff --git a/src/app/components/page/style.css.ts b/src/app/components/page/style.css.ts index 8a60bedb..7c35d4ab 100644 --- a/src/app/components/page/style.css.ts +++ b/src/app/components/page/style.css.ts @@ -79,6 +79,14 @@ export const PageNav = recipe({ '300': { width: toRem(222), }, + // Used by the Settings nav — ~1.43× the regular 300 (~317px = + // 1.3 × 1.1 over 222px). Settings labels are long + // ("Notifications", "Emojis & Stickers", "Developer Tools") and + // the 222px column truncated them; the wider column also gives + // the nested-horseshoe void gap on the right room to breathe. + '350': { + width: toRem(317), + }, }, }, defaultVariants: { diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index e1d68193..577f5088 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -256,7 +256,10 @@ export const RoomCard = as<'div', RoomCardProps>( - {t('Explore.members_count', { count: millify(joinedMemberCount) })} + {t('Explore.members_count', { + count: joinedMemberCount, + formattedCount: millify(joinedMemberCount), + })} )} diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx deleted file mode 100644 index 4de2a67e..00000000 --- a/src/app/components/room-intro/RoomIntro.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import React, { useCallback, useState } from 'react'; -import { Avatar, Box, Button, Spinner, Text, as } from 'folds'; -import { Room } from 'matrix-js-sdk'; -import { Trans, useTranslation } from 'react-i18next'; -import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room'; -import { getMemberDisplayName, getStateEvent } from '../../utils/room'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; -import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; -import { timeDayMonthYear, timeHourMinute } from '../../utils/time'; -import { useRoomNavigate } from '../../hooks/useRoomNavigate'; -import { RoomAvatar } from '../room-avatar'; -import { nameInitials } from '../../utils/common'; -import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta'; -import { useIsOneOnOne } from '../../hooks/useRoom'; -import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -import { InviteUserPrompt } from '../invite-user-prompt'; - -export type RoomIntroProps = { - room: Room; -}; - -export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => { - const { t } = useTranslation(); - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const { navigateRoom } = useRoomNavigate(); - // Match RoomViewHeader's peer-avatar logic — pull the fallback only when the - // room is strictly 1:1, not when it carries an `m.direct` flag. Bridged - // Telegram 1:1s and just-promoted self-DMs both lack the flag but have - // member-count = 2, so this picks them up correctly. - const isOneOnOne = useIsOneOnOne(); - const [invitePrompt, setInvitePrompt] = useState(false); - - const createEvent = getStateEvent(room, StateEvent.RoomCreate); - const avatarMxc = useRoomAvatar(room, isOneOnOne); - const name = useRoomName(room); - const topic = useRoomTopic(room); - const avatarHttpUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined; - - const createContent = createEvent?.getContent(); - const ts = createEvent?.getTs(); - const creatorId = createEvent?.getSender(); - const creatorName = - creatorId && (getMemberDisplayName(room, creatorId) ?? getMxIdLocalPart(creatorId)); - const prevRoomId = createContent?.predecessor?.room_id; - - const [prevRoomState, joinPrevRoom] = useAsyncCallback( - useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx]) - ); - - return ( - - - - {nameInitials(name)}} - /> - - - - - - {name} - - - {typeof topic === 'string' ? topic : t('Room.conversation_beginning')} - - {creatorName && ts && ( - - }} - /> - - )} - - - - - {invitePrompt && ( - setInvitePrompt(false)} /> - )} - {typeof prevRoomId === 'string' && - (mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? ( - - ) : ( - - ))} - - - - ); -}); diff --git a/src/app/components/room-intro/index.ts b/src/app/components/room-intro/index.ts deleted file mode 100644 index 7250c789..00000000 --- a/src/app/components/room-intro/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './RoomIntro'; diff --git a/src/app/components/stream-header/Chip.tsx b/src/app/components/stream-header/Chip.tsx new file mode 100644 index 00000000..417e4783 --- /dev/null +++ b/src/app/components/stream-header/Chip.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Icon, IconSrc } from 'folds'; +import * as css from './StreamHeader.css'; + +type ChipProps = { + iconSrc: IconSrc; + label: string; + onClick: () => void; + // When the curtain covers the chip its row is `height: 0` / + // `overflow: hidden`. We also flip `tabIndex` so keyboard users + // can't focus the invisible button on desktop (where peek-drag is + // unavailable). Re-enabled when the row is revealed. + hidden: boolean; +}; + +// Pill-shaped reveal button shown when the user drags the curtain down +// to a peek stage. Same geometry as the inline form's input bar so the +// transition chip → input feels like a content swap, not a layout move. +export function Chip({ iconSrc, label, onClick, hidden }: ChipProps) { + return ( + + ); +} diff --git a/src/app/components/stream-header/Segment.tsx b/src/app/components/stream-header/Segment.tsx new file mode 100644 index 00000000..21101ca8 --- /dev/null +++ b/src/app/components/stream-header/Segment.tsx @@ -0,0 +1,28 @@ +import React, { forwardRef } from 'react'; +import * as css from './StreamHeader.css'; + +type SegmentProps = { + active: boolean; + disabled?: boolean; + label: string; + onClick?: () => void; +}; + +// Tab segment for the StreamHeader row. Active state is communicated +// by a violet dot (folds `Primary.Main`) and a heavier font weight. +export const Segment = forwardRef( + ({ active, disabled, label, onClick }, ref) => ( + + ) +); +Segment.displayName = 'Segment'; diff --git a/src/app/components/stream-header/StreamHeader.css.ts b/src/app/components/stream-header/StreamHeader.css.ts new file mode 100644 index 00000000..5ea1efd9 --- /dev/null +++ b/src/app/components/stream-header/StreamHeader.css.ts @@ -0,0 +1,366 @@ +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; +import { color, config, toRem } from 'folds'; +import { + CHIP_GAP_PX, + CURTAIN_BREATHER_PX, + CURTAIN_RADIUS_PX, + CURTAIN_SNAP_EASING, + CURTAIN_SNAP_MS, + HANDLE_HEIGHT_PX, + TABS_ROW_PX, + WEB_TABS_ROW_PX, +} from './geometry'; + +// Stage. Position-relative anchor. The header itself paints the +// light-blue backdrop; the curtain is layered ABOVE it via z-index. +// +// In pager mode the bg collapses to transparent so the pager's static +// header (sitting behind the strip in DOM order) shows through every +// pixel the curtain isn't covering. See +// `mobile-tabs-pager/style.css.ts::pagerStaticHeader` for the full +// curtain-overlay contract. The strip is tagged +// `data-pager-pane="true"` in MobileTabsPager.tsx, which gates this +// selector. +export const stage = style({ + position: 'relative', + flex: 1, + minHeight: 0, + display: 'flex', + flexDirection: 'column', + backgroundColor: color.SurfaceVariant.Container, + selectors: { + '[data-pager-pane="true"] &': { + backgroundColor: 'transparent', + }, + }, +}); + +// Header — always-rendered strip carrying tabs row + (optional) chip +// reveal area + (optional) active form. The curtain slides on top of +// the area BELOW the tabs row to cover/reveal those children. +// +// In pager mode the bg collapses to transparent for the same reason as +// `stage` above — let the static pager header show through where the +// curtain isn't. Chips have their own pill bg and the inline form is +// composed of folds-styled inputs with their own backgrounds, so the +// peek/form snaps stay visually opaque without this layer. +export const header = style({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + display: 'flex', + flexDirection: 'column', + // Higher than `stage`, lower than `curtain` so the curtain occludes + // everything below the tabs row when raised. + zIndex: 1, + backgroundColor: color.SurfaceVariant.Container, + selectors: { + '[data-pager-pane="true"] &': { + backgroundColor: 'transparent', + }, + }, +}); + +// Tabs row. Stays fully visible regardless of curtain position +// because the curtain's `top` floor equals `TABS_ROW_PX` on native +// (`WEB_TABS_ROW_PX` on web — see `geometry.ts::WEB_TABS_ROW_PX`). +// +// Web variant: shrink to `WEB_TABS_ROW_PX` (= 54 px = folds Header +// `size="600"`) so the row reads at the same height as the right-pane +// room `PageHeader`, AND own the 1 px divider rule as a +// `border-bottom`. Putting the rule on `tabsRow` (not on the curtain +// as a `border-top`) is load-bearing for pixel alignment: with the +// global `* { box-sizing: border-box }` reset (`src/index.css`), +// `tabsRow`'s 1 px bottom border lands at y=53→54 inside the 54 px +// box — exactly where PageHeader's outlined border-bottom paints. If +// the rule lived on the curtain's `border-top` at `top: 54`, it would +// paint at y=54→55, off-by-one against the right pane. +export const tabsRow = style({ + flexShrink: 0, + height: toRem(TABS_ROW_PX), + display: 'flex', + alignItems: 'center', + // Horizontal padding is tuned for the narrowest target: Android 360 px + // viewport (Pixel/Galaxy base) must fit 3 RU segments (Личные/Каналы/ + // Роботы) + Plus + Search without iconsCluster being pushed past the + // right edge. Reducing this from 8 → 6 saves 4 px across the row; + // combined with the tightened Segment / cluster gaps below, the floor + // drops from ≈378 px to ≈350 px. See sidebarWidth.ts for the desktop + // resize floor that mirrors this measurement. + padding: `0 ${toRem(6)}`, + selectors: { + '[data-platform="web"] &': { + height: toRem(WEB_TABS_ROW_PX), + borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + }, + }, +}); + +export const tabsCluster = style({ + display: 'flex', + alignItems: 'center', + // Tightened from 4 → 2 px so the 3 RU segments + 2 action icons fit + // inside a 360 px viewport (standard Android base width). See the + // `tabsRow` padding comment for the full row-fit budget. + gap: toRem(2), + alignSelf: 'stretch', +}); + +export const iconsCluster = style({ + display: 'flex', + alignItems: 'center', + gap: toRem(2), + flexShrink: 0, +}); + +// Curtain. Layered above the header (z-index higher). Its top edge +// moves with the snap state (and live finger drag); its bottom edge +// is anchored to the stage bottom so the curtain's `bottomPinned` +// child (DirectSelfRow / WorkspaceFooter) stays glued to the visible +// viewport bottom regardless of where the curtain's top is. +// +// On native, only the TOP corners are rounded: the bottom is meant +// to read as continuous with the always-visible bottomPinned row +// (DirectSelfRow is the curtain's last flex child) — adding +// `borderBottomRadius` would crop the row's corners against the +// curtain's `overflow: hidden`, which visually reads as «a light- +// blue strip cuts into the row». +// +// Live finger tracking and snap commits both flow through React state +// updates to `top` so the transition is always coordinated with the +// rendered position — disabled during drag, restored on commit. +// +// Web variant (`[data-platform="web"]` on `stage`, set by +// StreamHeader.tsx when `!isNativePlatform()`): there is no pin/peek +// gesture, so the curtain is a purely static slab under the tabs row. +// Drop ONLY the «card» rounding (top corners flat). The divider rule +// at the seam is owned by `tabsRow.borderBottom` under the same +// selector — that placement keeps the rule pixel-aligned with the +// right-pane `PageHeader`'s outlined border (see `tabsRow` comment +// above). The curtain bg stays `Background.Container` so the chat- +// row rows (`NavItem variant="Background"`) keep blending into one +// continuous list surface — if we made the curtain transparent the +// rows would paint as dark cards over the lighter +// `SurfaceVariant.Container` stage. +export const curtain = style({ + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + zIndex: 2, + display: 'flex', + flexDirection: 'column', + minHeight: 0, + overflow: 'hidden', + backgroundColor: color.Background.Container, + borderTopLeftRadius: toRem(CURTAIN_RADIUS_PX), + borderTopRightRadius: toRem(CURTAIN_RADIUS_PX), + transition: `top ${CURTAIN_SNAP_MS}ms ${CURTAIN_SNAP_EASING}`, + // Hint the compositor while the curtain is moving. Cheap since the + // curtain is the only element in this stacking context that animates. + willChange: 'top', + selectors: { + '[data-platform="web"] &': { + borderTopLeftRadius: 0, + borderTopRightRadius: 0, + }, + }, +}); + +// Drag handle at the top of the curtain. Dedicated touch surface for +// the pin / unpin gesture so it doesn't compete with the chat list's +// vertical scroll. `touchAction: none` keeps the browser from claiming +// the gesture for native scroll heuristics — our `touchmove` listener +// in `useCurtainHandleGesture` drives every pixel of motion. +// +// Sits as the first flex child of the curtain so the list (or +// DirectEmpty / equivalent placeholder) takes the remaining space +// below it. `flexShrink: 0` locks the height so a long list doesn't +// squash the hit-zone. +export const handle = style({ + flexShrink: 0, + height: toRem(HANDLE_HEIGHT_PX), + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + touchAction: 'none', +}); + +// Visual «grabber» pill centred inside `handle`. Semi-transparent +// foreground so the affordance reads as «draggable» without competing +// with content beneath. Pure decoration — the parent `handle` div +// captures the touch. +// +// State machine mirrors `PageNavResizeHandle` on desktop: a subtle +// resting state, a more prominent «being dragged» state, and an even +// more prominent «threshold reached, release to commit» state. The +// state-driving `data-dragging` / `data-at-commit` attributes live on +// the parent `handle` div (set by StreamHeader.tsx from the gesture +// hook). Transition durations match the desktop handle (140ms ease) +// so the two affordances feel related. +export const handleBar = style({ + width: toRem(40), + height: toRem(4), + borderRadius: toRem(2), + backgroundColor: color.Background.OnContainer, + opacity: 0.25, + pointerEvents: 'none', + transition: + 'opacity 140ms ease, width 140ms ease, height 140ms ease, background-color 140ms ease', + selectors: { + // Dragging but threshold not yet reached: highlight, slight grow. + '[data-dragging="true"] &': { + opacity: 0.55, + width: toRem(48), + backgroundColor: color.Primary.Main, + }, + // Threshold reached during drag: full stretch + opacity. Releasing + // here commits pin (or unpin). Reads as «yes, you're there». + '[data-dragging="true"][data-at-commit="true"] &': { + opacity: 0.9, + width: toRem(64), + height: toRem(5), + backgroundColor: color.Primary.Main, + }, + }, +}); + +// Wrapper around `bottomPinned` inside the curtain. Anchored to the +// curtain's flex-bottom by virtue of being the last child. The TSX +// collapses this slot to `{ height: 0, overflow: hidden }` when the +// on-screen keyboard rises (via `VisualViewport.height` shrink) so +// the row neither paints nor claims flex space above the keyboard. +// Without this compensation, `interactive-widget=resizes-content` +// (global viewport meta — load-bearing for the room composer) +// shrinks the layout viewport, dragging every `bottom: 0` element +// up over the inline form. The DirectSelfRow ending up immediately +// above the keyboard would block the user's view of the form they're +// typing into. +export const bottomPinnedSlot = style({ + flexShrink: 0, +}); + +// Segment button (Direct / Channels / Bots). +// +// Horizontal padding (8 px) and dot-to-label gap (6 px) are the tightest +// values that still keep the tap target comfortable on touch. They're +// load-bearing for the row-fit budget on 360 px Android viewports — see +// `tabsRow` above. Vertical padding (8 px) is kept full for hit-target +// height; the row is height-fixed by `TABS_ROW_PX` so trimming vertical +// padding wouldn't buy any width. +export const segment = recipe({ + base: { + appearance: 'none', + border: 'none', + background: 'transparent', + color: color.Background.OnContainer, + cursor: 'pointer', + padding: toRem(8), + borderRadius: toRem(8), + font: 'inherit', + fontSize: toRem(14), + lineHeight: 1.2, + display: 'inline-flex', + alignItems: 'center', + gap: toRem(6), + whiteSpace: 'nowrap', + fontWeight: 500, + WebkitAppearance: 'none', + }, + variants: { + active: { + true: { fontWeight: 600 }, + }, + disabled: { + true: { opacity: 0.45, cursor: 'default' }, + }, + }, +}); + +// Active-state dot inside each segment. +export const segmentDot = recipe({ + base: { + width: toRem(6), + height: toRem(6), + borderRadius: '50%', + flexShrink: 0, + }, + variants: { + active: { + true: { backgroundColor: color.Primary.Main }, + false: { backgroundColor: 'transparent' }, + }, + }, +}); + +// Chip row — outer clip-strip. Both rows reveal together when the +// user drags the curtain down to the `peek` snap. +// +// The `marginBottom` math is load-bearing for the snap-top +// calculation: the resting `top` of `peek` lands the curtain exactly +// where the next row would have begun, so the breather never "steals" +// pixels from the next chip's paddingTop. Two different values: +// - default (chip-to-chip): `CHIP_GAP_PX` — tighter, so the two +// pills read as a related pair when both are revealed. +// - `:last-child` (chip-to-curtain): `CURTAIN_BREATHER_PX` — wider, +// so the curtain's rounded top has comfortable air above the +// chip pill it lands above. +export const chipRow = style({ + height: toRem(56), + marginBottom: toRem(CHIP_GAP_PX), + paddingLeft: toRem(24), + paddingRight: toRem(24), + paddingTop: toRem(8), + display: 'flex', + alignItems: 'flex-start', + selectors: { + '&:last-child': { + marginBottom: toRem(CURTAIN_BREATHER_PX), + }, + }, +}); + +// The chip pill itself. +export const chip = style({ + appearance: 'none', + border: 'none', + display: 'flex', + alignItems: 'center', + gap: toRem(10), + width: '100%', + height: toRem(48), + padding: `${toRem(8)} ${toRem(14)}`, + borderRadius: toRem(20), + font: 'inherit', + fontSize: toRem(14), + textAlign: 'left', + cursor: 'pointer', + backgroundColor: color.Background.Container, + color: color.Background.OnContainer, + WebkitAppearance: 'none', +}); + +export const chipPlaceholder = style({ + opacity: 0.65, + whiteSpace: 'nowrap', +}); + +// Active form area in the header. Outer is `position: relative`; the +// inner mounted form fills it with `top: 0` so the form's first +// element (input bar) sits flush at the line where chips would +// otherwise live. +export const formArea = style({ + position: 'relative', + flexShrink: 0, + overflow: 'hidden', +}); + +export const formInner = style({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + padding: `${toRem(8)} ${toRem(24)} ${toRem(12)}`, +}); diff --git a/src/app/components/stream-header/StreamHeader.tsx b/src/app/components/stream-header/StreamHeader.tsx new file mode 100644 index 00000000..fda2608d --- /dev/null +++ b/src/app/components/stream-header/StreamHeader.tsx @@ -0,0 +1,550 @@ +import React, { + MutableRefObject, + ReactNode, + TransitionEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMatch, useNavigate } from 'react-router-dom'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { Box, Icon, IconButton, Icons, toRem } from 'folds'; +import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths'; +import { isNativePlatform } from '../../utils/capacitor'; +import { useBotPresets } from '../../features/bots/catalog'; +import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext'; +import { + MobilePagerCurtainControls, + StreamHeaderPrimaryAction, + mobilePagerCurtainAtom, +} from '../../state/mobilePagerHeader'; +import { TABS_ROW_PX, WEB_TABS_ROW_PX } from './geometry'; +import { settingsSheetAtom } from '../../state/settingsSheet'; +import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet'; +import * as css from './StreamHeader.css'; +import { Segment } from './Segment'; +import { Chip } from './Chip'; +import { isFormSnap, snapTopPx, useCurtainState } from './useCurtainState'; +import { useCurtainHandleGesture } from './useCurtainHandleGesture'; +import { useCurtainBodyGesture } from './useCurtainBodyGesture'; +import { InlineNewChatForm } from './forms/InlineNewChatForm'; +import { InlineRoomSearch } from './forms/InlineRoomSearch'; + +const INLINE_FORM_ID = 'stream-header-inline-form'; + +type StreamHeaderProps = { + // Scroll viewport that hosts the chat list under the curtain. The + // curtain BODY gesture (`useCurtainBodyGesture`) reads this ref's + // `scrollHeight`/`clientHeight` to decide whether to engage: long + // lists keep native scroll, short / empty lists drive the curtain + // via body drag. May be a ref whose `.current` is null on listing + // surfaces that render their empty state directly as a curtain + // child without wrapping it in `PageNavContent` (Direct's + // `DirectEmpty`, ChannelsRootNav's `ChannelsLanding`) — the body + // gesture treats null as «not scrollable» and engages. + scrollRef: MutableRefObject; + // Curtain contents — the chat list. The list is rendered inside an + // `overflow: auto` div that the gesture hook listens to. + children: ReactNode; + // Optional row(s) pinned to the bottom of the curtain (DirectSelfRow, + // WorkspaceFooter). Hidden while a form is active so the on-screen + // keyboard's viewport resize doesn't push them up over the form + // (see commit 14ed080). + bottomPinned?: ReactNode; + // Stable identifier used to persist the curtain's pinned overlay + // across listing-pane remounts (the user taps into a Room and back, + // which unmounts the listing pane). Pin state is stored in + // `curtainPinnedByTabAtom[pinKey]` so it outlives any individual + // StreamHeader instance. Each listing tab (Direct/Channels/Bots) + // passes its own key; the Channels landing CTA and workspace + // listing share `"channels"` so pin survives the toggle between + // empty state and a chosen workspace. + pinKey: string; + // Optional override for the Plus button. When omitted the header + // renders the default «new chat» action that opens InlineNewChatForm + // via the curtain. Channels overrides this with «create channel» / + // «create community» so the same Plus slot launches a contextual + // action instead of the DM-creation form. + primaryAction?: StreamHeaderPrimaryAction; +}; + +export function StreamHeader({ + scrollRef, + children, + bottomPinned, + pinKey, + primaryAction, +}: StreamHeaderProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const bots = useBotPresets(); + const navOpts = useMemo(() => ({ replace: isNativePlatform() }), []); + + const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: false }); + const botsMatch = useMatch({ path: BOTS_PATH, caseSensitive: true, end: false }); + const channelsMatch = useMatch({ path: CHANNELS_PATH, caseSensitive: true, end: false }); + const showBotsSegment = bots.length > 0 || !!botsMatch; + + // Pager mode wiring. When this StreamHeader is mounted inside + // MobileTabsPager, the shared static tabs row at the pager root + // owns the visible segments + action icons; our local tabs row is + // kept in DOM (preserving the curtain's TABS_ROW_PX-based snap + // geometry) but rendered with `opacity: 0` (still tap-able). Only + // the currently active pane writes its curtain controls to + // `mobilePagerCurtainAtom` so the shared icons drive THIS curtain. + // + // `selectTabInstant` is the pager's tap-commit entrypoint: when our + // invisible per-pane Segments capture a tap (because the static + // header sits behind the strip in z-stack at rest), routing through + // this callback runs the same commit path as the static header's + // taps and suppresses the swipe-finish slide animation so tab + // switches feel snappy. Falls back to a plain `navigate(...)` on + // surfaces outside pager mode (desktop, non-listing routes). + const pagerPane = useMobilePagerPane(); + const inPagerMode = pagerPane !== null; + const isActivePagerPane = pagerPane?.isActive ?? false; + const selectTabInstant = pagerPane?.selectTabInstant ?? null; + + const onSegmentDirect = useCallback(() => { + if (selectTabInstant) selectTabInstant('direct'); + else navigate(DIRECT_PATH, navOpts); + }, [selectTabInstant, navigate, navOpts]); + const onSegmentChannels = useCallback(() => { + if (selectTabInstant) selectTabInstant('channels'); + else navigate(CHANNELS_PATH, navOpts); + }, [selectTabInstant, navigate, navOpts]); + const onSegmentBots = useCallback(() => { + if (selectTabInstant) selectTabInstant('bots'); + else navigate(BOTS_PATH, navOpts); + }, [selectTabInstant, navigate, navOpts]); + + const curtain = useCurtainState(pinKey); + + // Suppress every curtain gesture whenever the user is interacting + // with something else that would otherwise race the pin path: + // + // * Settings sheet open (DirectSelfRow-originated bottom sheet) — + // a drag-up on the still-visible list above the sheet would + // mutate the pin atom underneath the sheet and the user would + // see an unexpected pinned curtain on dismissal. + // * Workspace switcher sheet open — same shape, on the Channels + // workspace surface. + // * Inactive pager pane — the strip clips offscreen panes so they + // shouldn't receive touches in practice, but bind defense-in- + // depth so a stray pointer event on a translateX'd pane never + // pins someone else's tab. + // + // Mirrors `MobileTabsPager.gestureDisabled` which suppresses the + // pager's OWN horizontal-swipe gesture under the same conditions. + const settingsSheetOpen = !!useAtomValue(settingsSheetAtom); + const workspaceSheetOpen = !!useAtomValue(channelsWorkspaceSheetAtom); + const offscreenPagerPane = inPagerMode && !isActivePagerPane; + const gestureDisabled = settingsSheetOpen || workspaceSheetOpen || offscreenPagerPane; + + // Two parallel curtain-gesture surfaces: + // + // * `useCurtainHandleGesture` — the dedicated 32 px drag-handle + // at the top of the curtain. Crisp 1:1 finger ↔ curtain. From + // closed the gesture is a free-range drag spanning pin↔closed↔ + // peek in one motion (`closed-free`); other snaps drive single- + // destination transitions (unpin / close-peek / form-close). + // Engages regardless of whether the chat list is scrollable — + // the handle is a distinct surface and never competes with list + // scroll. Only rendered on native (`isNativePlatform()`). + // + // * `useCurtainBodyGesture` — anywhere on the curtain body + // OUTSIDE the handle (chat list, empty-state placeholder). + // Rubber-banded (0.65) for all transitions, so the body drag + // reads as physically «heavier» than the handle's crisp pull. + // Engages only when the chat list has no scrollable content; + // additionally bails on touches that start inside the bottom- + // pinned slot (DirectSelfRow / WorkspaceFooter have their own + // drag-to-open bottom sheets) and on touches that start while + // pinned (unpin is HANDLE-only — the user has to grab the + // dedicated affordance to release the lock). + // + // Both hooks share `handleVisual` (mirrors desktop + // `PageNavResizeHandle`: `dragging` lights up the grabber pill; + // `atCommit` stretches + brightens it once the user crosses the + // per-transition commit threshold). The two surfaces are mutually + // exclusive on each touch (handle's listener short-circuits when + // the touch starts on the handle; body's listener does the same + // when it ISN'T on the handle), so they never fight over the + // visual. + const handleRef = useRef(null); + const curtainRef = useRef(null); + const bottomPinnedRef = useRef(null); + const [handleVisual, setHandleVisual] = useState<{ dragging: boolean; atCommit: boolean }>({ + dragging: false, + atCommit: false, + }); + useCurtainHandleGesture({ + handleRef, + snap: curtain.snap, + pinned: curtain.pinned, + setPinned: curtain.setPinned, + setLiveDrag: curtain.setLiveDrag, + commit: curtain.commit, + disabled: gestureDisabled, + setHandleState: setHandleVisual, + }); + useCurtainBodyGesture({ + curtainRef, + handleRef, + bottomPinnedRef, + scrollRef, + snap: curtain.snap, + pinned: curtain.pinned, + setLiveDrag: curtain.setLiveDrag, + commit: curtain.commit, + disabled: gestureDisabled, + setHandleState: setHandleVisual, + }); + + const isActive = isFormSnap(curtain.snap); + const openSearch = useCallback(() => curtain.open('search'), [curtain]); + const openChat = useCallback(() => curtain.open('chat'), [curtain]); + const { close } = curtain; + + // Memoised controls object so the cleanup's identity check (atom + // compare-and-clear) is meaningful — without useMemo a fresh object + // would be created on every render and the cleanup of an earlier + // render would never match the atom's current contents. + const pagerControls = useMemo( + () => ({ + openSearch, + openChat, + closeForm: close, + isFormActive: isActive, + primaryAction: primaryAction ?? null, + }), + [openSearch, openChat, close, isActive, primaryAction] + ); + + const setPagerCurtain = useSetAtom(mobilePagerCurtainAtom); + useEffect(() => { + if (!isActivePagerPane) return undefined; + setPagerCurtain(pagerControls); + // Compare-and-clear cleanup: only wipe the atom if it still holds + // OUR controls. If another pane became active between this render + // and the cleanup (rapid tab switch), it has already overwritten + // the atom with its own controls — we must not clobber that. + return () => { + setPagerCurtain((prev) => (prev === pagerControls ? null : prev)); + }; + }, [isActivePagerPane, pagerControls, setPagerCurtain]); + + // Curtain's `top` is the resting snap position plus the live drag + // delta. React-driven (no inline DOM writes), so finger-tracking and + // commit happen in the same render pipeline and there's no + // intermediate "snap back, then animate" flash on release. + // + // When `pinned` is true the local snap (kept at {closed, peek, + // form-*}) is overridden — the curtain rests at y = 0 inside the + // stage (= y = safe-top in viewport), covering the tabs row. The + // global pinned atom shares this state across every listing tab so + // swiping between Direct / Channels / Bots preserves the lock. + // + // `platformOffset` is the web-only shift that lifts every non-pinned + // snap by the delta between native and web tabs-row heights. Tabs + // row on web is `WEB_TABS_ROW_PX` (= 54px, matching PageHeader on + // the right pane); `snapTopPx` is computed against `TABS_ROW_PX` + // (= 64px) which stays authoritative for native pin/peek geometry. + // Subtracting the delta on web realigns the closed/form snaps with + // the smaller tabs row without touching the snap-state machine. + // Pinned (= 0) doesn't need the offset because the safe-top + native + // contract owns that case and pinned is native-only. + const platformOffset = isNativePlatform() ? 0 : WEB_TABS_ROW_PX - TABS_ROW_PX; + const curtainTop = curtain.pinned + ? 0 + curtain.liveDragPx + : snapTopPx(curtain.snap, curtain.formHeightPx) + platformOffset + curtain.liveDragPx; + + // After the curtain settles at `closed`, unmount any lingering form. + // Guarded so unrelated transitionend events (e.g. children's own + // transitions bubbling up) don't drop the form mid-animation. + const onCurtainTransitionEnd = useCallback( + (evt: TransitionEvent) => { + if (evt.target !== evt.currentTarget) return; + if (evt.propertyName !== 'top') return; + curtain.acknowledgeClosed(); + }, + [curtain] + ); + + // On-screen keyboard detection via VisualViewport API. Global + // viewport-meta is `interactive-widget=resizes-content` (load- + // bearing for the room composer's keyboard-follow behaviour), which + // shrinks the layout viewport when a soft keyboard appears. Any + // `bottom: 0` child — including DirectSelfRow inside the curtain — + // rises with the shrunken viewport and ends up sitting RIGHT ABOVE + // the keyboard, blocking the inline form the user is typing into. + // + // Fix: when the keyboard is up, collapse the `bottomPinned` slot + // to zero height so it neither claims flex space at the curtain + // bottom nor renders above the keyboard. The user perceives the + // keyboard as overlaying everything below the form (matching their + // mental model: "клавиатура рисуется поверх кнопок и чатов, кнопка + // настройки остаётся прибитой снизу"). The row reappears the moment + // the keyboard retracts. + // + // The reference-height tracking mirrors `AuthLayout.tsx`: bump the + // reference upward on every grow (so rotation / keyboard-close + // events stay self-correcting) and treat a meaningful shrink (>= + // KEYBOARD_PROBE_PX) as «keyboard is up». The probe avoids + // spurious flips on small browser-chrome animations. + const [keyboardOpen, setKeyboardOpen] = useState(false); + useEffect(() => { + // Desktop browsers / Electron have no soft keyboard, but their + // VisualViewport DOES shrink on aggressive page zoom (Ctrl+`+`). + // That used to slip past as a hidden quirk while the curtain + // rendered as a rounded card; once the web variant flattened the + // curtain it became a visible regression — the DirectSelfRow at + // the bottom would collapse to height: 0 under zoom and read as + // broken layout. Gate the listener to native so the probe only + // arms where a real soft keyboard can actually appear. + if (!isNativePlatform()) return undefined; + const vv = window.visualViewport; + if (!vv) return undefined; + const KEYBOARD_PROBE_PX = 100; + let referenceH = vv.height; + let rafId: number | null = null; + const apply = () => { + rafId = null; + if (vv.height > referenceH) referenceH = vv.height; + setKeyboardOpen(referenceH - vv.height >= KEYBOARD_PROBE_PX); + }; + const onResize = () => { + if (rafId === null) rafId = requestAnimationFrame(apply); + }; + apply(); + vv.addEventListener('resize', onResize); + return () => { + if (rafId !== null) cancelAnimationFrame(rafId); + vv.removeEventListener('resize', onResize); + }; + }, []); + + return ( +
+
+ {/* ── Tabs row + action icons (always visible) ─────────── + In pager mode the row stays mounted (curtain snap math + depends on its TABS_ROW_PX height) but is painted invisible + via `opacity: 0` because the shared static tabs row at the + pager root owns the visible chrome. Critically opacity (not + `visibility: hidden`) keeps the row HIT-TESTABLE while + invisible — the strip's stacking context paints ON TOP of + the static pager header at rest (the curtain-rises-over- + header contract), so without per-pane taps the segments + would be unreachable. Both rows wire onClick to the same + navigate() destination, so whichever row captures the tap + the user-visible result is identical. The static header z- + elevates only while a horseshoe sheet is active and the + active pane isn't pinned — in that window the static header + sits above the strip and captures taps directly; the rest + of the time taps land on this opacity-0 row and resolve + through its own per-pane onClick handlers. + + `aria-hidden` removes the duplicate (per-pane) segments and + icons from the accessibility tree so screen readers don't + announce three sets of "Direct / Channels / Bots" plus the + single visible set from the shared static header. */} +
+
+ + + {showBotsSegment && ( + + )} +
+ + {isActive ? ( + + + + ) : ( +
+ + + + + + +
+ )} +
+ + {/* ── Chips vs form ────────────────────────────────────── + Mutually exclusive. While a form is mounted (including the + curtain's close-snap window before `acknowledgeClosed`), the + chips stay unrendered so the form doesn't visually jump + from y = TABS_ROW_PX to y = TABS_ROW_PX + 2·CHIP_ROW_PX + mid-animation. + + When chips are rendered: always present in their fixed + header positions; the curtain occludes them by z-stacking. + As the user drags the curtain down, the chips reveal from + underneath naturally. `Chip.hidden` only controls keyboard + focus (the chip paints normally; the curtain's z-index does + the visual hiding). */} + {curtain.activeForm ? ( +
+
+ {curtain.activeForm === 'search' && } + {curtain.activeForm === 'chat' && } +
+
+ ) : ( + <> +
+
+
+
+ + )} +
+ + {/* ── Curtain layer ───────────────────────────────────── + Renders ABOVE the header (z-index higher). `top` combines the + snap-derived resting position with the live finger drag — one + React-controlled inline style, no ref-based DOM writes. The + transition is disabled during the drag and restored on commit + so the snap commit animates smoothly without an intermediate + "snap back then animate forward" flash. */} +
+ {/* Drag handle — native-only. On web (desktop browsers, + Electron) the curtain has no interactive snap states, so + the handle would be pure decoration with no behaviour + behind it; rendering it conditionally drops the 32 px + grabber strip on those surfaces and lets the chat list + sit flush against the curtain's rounded top. + + On native the handle hosts the authoritative curtain + gesture (pin / unpin / peek / close-peek / form-close) + and stays mounted across snap transitions so the gesture + surface is always reachable when there is one to make. + + `data-dragging` / `data-at-commit` mirror the desktop + `PageNavResizeHandle`: CSS selectors on `handleBar` light + the pill up Primary-blue + stretch it when these flip. + Both attrs are emitted/cleared only via React state set by + the gesture hook (dedup'd), so the handle visual updates + without slamming the DOM on every touchmove. */} + {isNativePlatform() && ( +
+
+
+ )} + {children} + {/* `bottomPinned` (DirectSelfRow, WorkspaceFooter) is kept + mounted across snaps so the curtain reads as a self- + contained "screen" with its bottom row always pinned to + the stage bottom. While the on-screen keyboard is up the + slot collapses to `height: 0` so it neither paints nor + claims flex space above the keyboard (see the + `keyboardOpen` effect above for the rationale). */} + {bottomPinned && ( +
+ {bottomPinned} +
+ )} +
+
+ ); +} diff --git a/src/app/components/stream-header/forms/InlineNewChatForm.tsx b/src/app/components/stream-header/forms/InlineNewChatForm.tsx new file mode 100644 index 00000000..80110f6c --- /dev/null +++ b/src/app/components/stream-header/forms/InlineNewChatForm.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { CreateChat } from '../../../features/create-chat'; + +type Props = { + // Called after the form successfully creates or navigates to an + // existing DM. The StreamHeader uses this to close the curtain over + // the form. + onClose: () => void; +}; + +// Thin shell around the shared `CreateChat` form. The legacy +// `/direct/_create` route renders the same component with a Page/ +// PageHero shell; here we only feed it the `onClose` callback and the +// tighter `gap='400'` rhythm so the form fits comfortably under the +// header. +export function InlineNewChatForm({ onClose }: Props) { + return ; +} diff --git a/src/app/components/stream-header/forms/InlineRoomSearch.tsx b/src/app/components/stream-header/forms/InlineRoomSearch.tsx new file mode 100644 index 00000000..40780fda --- /dev/null +++ b/src/app/components/stream-header/forms/InlineRoomSearch.tsx @@ -0,0 +1,244 @@ +import React, { useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Avatar, Box, Icon, Icons, Scroll, Text, color, toRem } from 'folds'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; +import { RoomAvatar, RoomIcon } from '../../../components/room-avatar'; +import { UnreadBadge, UnreadBadgeCenter } from '../../../components/unread-badge'; +import { getAllParents, getDirectRoomAvatarUrl, getRoomAvatarUrl, guessPerfectParent } from '../../../utils/room'; +import { nameInitials } from '../../../utils/common'; +import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix'; +import { highlightText } from '../../../plugins/react-custom-html-parser'; +import { getDmUserId, useRoomSearch } from '../../../features/search/useRoomSearch'; +import { SEARCH_FORM_BASE_PX } from '../geometry'; + +type Props = { + onClose: () => void; +}; + +// Inline search panel mounted in the StreamHeader. Shares all search +// logic with the global Search modal via `useRoomSearch`; only the +// presentation chrome differs (no Modal/Overlay/FocusTrap, a custom +// row layout that matches the inline aesthetic). +export function InlineRoomSearch({ onClose }: Props) { + const { t } = useTranslation(); + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const { navigateRoom, navigateSpace } = useRoomNavigate(); + + const openRoomId = useCallback( + (roomId: string, isSpace: boolean) => { + if (isSpace) navigateSpace(roomId); + else navigateRoom(roomId); + onClose(); + }, + [navigateRoom, navigateSpace, onClose] + ); + + const { + inputRef, + scrollRef, + roomsToRender, + result, + listFocus, + queryHighlightRegex, + handleInputChange, + handleInputKeyDown, + handleRoomClick, + getRoom, + mDirects, + orphanSpaces, + roomToParents, + roomToUnread, + myUserId, + } = useRoomSearch({ onOpenRoomId: openRoomId }); + + // Focus the input on mount. Inline form opens via an explicit user + // action (chip tap or icon click), so this is request-initiated + // focus rather than ambient `autoFocus` — keeps screen readers + // happy. + useEffect(() => { + inputRef.current?.focus(); + }, [inputRef]); + + return ( + + {/* ── Input bar (matches chip geometry: h=48 / r=20 / pad 8/14) + so the chip → input morph reads as a content crossfade. */} + + + + + + {/* ── Result list ────────────────────────────────────── */} + + + {roomsToRender.length === 0 && ( + + + {result ? t('Search.no_match_found') : t('Search.no_rooms')} + + + {result + ? t('Search.no_match_for_query', { query: result.query }) + : t('Search.no_rooms_to_display')} + + + )} + {roomsToRender.length > 0 && ( + + {roomsToRender.map((roomId, index) => { + const room = getRoom(roomId); + if (!room) return null; + + const dm = mDirects.has(roomId); + const dmUserId = dm ? getDmUserId(roomId, getRoom, myUserId) : undefined; + const dmUsername = dmUserId ? getMxIdLocalPart(dmUserId) : undefined; + const dmUserServer = dmUserId ? getMxIdServer(dmUserId) : undefined; + + const allParents = getAllParents(roomToParents, roomId); + const orphanParents = allParents + ? orphanSpaces.filter((o) => allParents.has(o)) + : undefined; + const perfectOrphanParent = + orphanParents && guessPerfectParent(mx, roomId, orphanParents); + + const exactParents = roomToParents.get(roomId); + const perfectParent = + exactParents && guessPerfectParent(mx, roomId, Array.from(exactParents)); + + const unread = roomToUnread.get(roomId); + const focused = listFocus.index === index; + + return ( + + ); + })} + + )} + + + + ); +} diff --git a/src/app/components/stream-header/geometry.ts b/src/app/components/stream-header/geometry.ts new file mode 100644 index 00000000..1d3f2f74 --- /dev/null +++ b/src/app/components/stream-header/geometry.ts @@ -0,0 +1,162 @@ +// ──────────────────────────────────────────────────────────────────── +// StreamHeader geometry — shared constants for the curtain layout. +// +// Mental model: the chats card is a curtain layered ABOVE the header +// (z-index higher). The curtain's `top` is the visible part of the +// header below the always-pinned tabs row. When the curtain is fully +// closed it sits flush under the tabs row (covering chips + form area +// beneath). Dragging it DOWN reveals more of the header from underneath. +// Dragging UP raises the curtain back over the header. +// +// Snap stops (curtain.top, px): +// pinned = 0 (curtain sits flush at top of the stage, tabs row +// covered; the safe-top status-bar strip above the +// stage stays painted by the surrounding context — +// see «pinned visual contract» below) +// closed = TABS_ROW_PX +// peek = TABS_ROW_PX + 2·CHIP_ROW_PX + CHIP_GAP_PX +// + CURTAIN_BREATHER_PX +// form:* = TABS_ROW_PX + formHeight + CURTAIN_BREATHER_PX +// +// Pinned visual contract: at `pinned` the curtain's top edge lands at +// y = safe-top in viewport coords (because the stage starts after the +// PageNav / appBody padding-top: var(--vojo-safe-top)). The system tray +// strip stays painted by appBody / PageNav-inner / MobileTabsPager's +// static header — all of which use `SurfaceVariant.Container` for that +// zone, so the colour is continuous across surfaces. The curtain MUST +// NOT extend into the safe-top zone (otherwise system text is covered) +// and MUST NOT add internal padding-top (otherwise the chat list grows +// visually taller). The clamp on the up-drag (= -TABS_ROW_PX) enforces +// the first invariant; we deliberately do not add any padding inside +// the curtain to enforce the second. +// ──────────────────────────────────────────────────────────────────── + +// Tabs row height. Always visible above the curtain. +export const TABS_ROW_PX = 64; + +// Web-only tabs-row height override. Matches folds `
` +// = 3.375rem = 54 px, which is the height the right-side `PageHeader` +// in the room chat panel (see `components/page/Page.tsx::PageHeader`) +// renders at. The 1 px divider rule on web lives as `tabsRow. +// borderBottom` so — under the global `* { box-sizing: border-box }` +// reset — it lands at y=53→54 inside this 54 px box, exactly matching +// where PageHeader's `outlined: true` border-bottom paints on the +// right pane. The two panes thus share one visible header baseline. +// +// Native keeps `TABS_ROW_PX` because the pin-gesture travel +// (`PIN_TRAVEL_PX`) is anchored to it and shrinking it would change +// the curtain's snap geometry. Web has no pin gesture, so the override +// is applied through two coordinated levers: +// 1. CSS `[data-platform="web"] &` selectors on `tabsRow` (height +// → `WEB_TABS_ROW_PX`, plus the divider as `borderBottom`). +// 2. TSX `platformOffset = WEB_TABS_ROW_PX - TABS_ROW_PX` (= -10) +// added to `curtainTop` so the closed / peek / form snaps all +// ride the reduced tabs row without recomputing `snapTopPx`. +export const WEB_TABS_ROW_PX = 54; + +// Each peek-chip row. Reveals one chip's pill (h=48) + 8px top breather. +export const CHIP_ROW_PX = 56; + +// Vertical gap BETWEEN two consecutive chip rows. Separate from +// `CURTAIN_BREATHER_PX` so the inter-chip spacing can read tighter +// than the breather between the last chip and the curtain's rounded +// top (the curtain's straight edge against a chip pill needs more +// air to avoid feeling «clamped», while two pills sitting in a +// vertical stack want to read as a pair). +export const CHIP_GAP_PX = 14; + +// Initial estimate for the search form's outer height. The actual +// height is measured at runtime via ResizeObserver and adapts to the +// available viewport so the form never overflows the chats card. +export const SEARCH_FORM_BASE_PX = 360; + +// Breathing strip between the bottom of any header content (revealed +// chip pill, form's last actionable element) and the top of the +// curtain. Painted by the header's `SurfaceVariant.Container` (light- +// blue) so the chip / Create button / search results never visually +// touch the curtain's rounded top — the user reads chips that sit +// flush with the curtain as «зажатые» rather than two separate +// affordances. Not applied at `closed` (nothing to breathe to). +export const CURTAIN_BREATHER_PX = 20; + +// Curtain snap transition. Tuned tight for an in-app reveal — +// emphasized-decelerate territory. +export const CURTAIN_SNAP_MS = 280; +export const CURTAIN_SNAP_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; + +// Curtain card top-corner radius. Matches the composer card and the +// horseshoe surfaces elsewhere in the app. +export const CURTAIN_RADIUS_PX = 24; + +// Total vertical travel of the curtain between `closed` and `peek` — +// the resting-top delta between the two snaps. Used as the basis for +// the peek-commit threshold: the user must drag (rubber-banded) at +// least COMMIT_THRESHOLD × PEEK_TRAVEL_PX before release for the snap +// to flip. Anything shorter reads as accidental and springs back. +export const PEEK_TRAVEL_PX = CHIP_ROW_PX + CHIP_GAP_PX + CHIP_ROW_PX + CURTAIN_BREATHER_PX; + +// Touch gesture tuning. RUBBER_BAND dampens finger→curtain motion so +// the chip reveal feels resistive; COMMIT_THRESHOLD is the fraction of +// the full peek travel the user must cross on release for the snap to +// commit. Tuned high (≈90%) so anything below «дотянул почти до конца» +// reads as accidental and snaps back to `closed`. +export const RUBBER_BAND = 0.65; +export const DIRECTION_DEAD_ZONE_PX = 10; +export const COMMIT_THRESHOLD = 0.9; +// Pull-up distance (raw finger px) required to close an active form. +export const ACTIVE_CLOSE_THRESHOLD_PX = 100; + +// Total vertical CURTAIN travel for the closed ↔ pinned gesture. +// Equals the tabs row height because pinning lifts the curtain by +// exactly that distance (from y = TABS_ROW_PX down to y = 0 inside +// the stage). +export const PIN_TRAVEL_PX = TABS_ROW_PX; + +// Commit threshold for pin / unpin. Tuned very high (≈95%) so the +// user must drag the curtain almost-all-the-way to the cap before +// release for the snap to flip. Anything shorter reads as accidental +// and springs back to the previous resting snap. +// +// On the handle the up direction is 1:1 with no upper clamp (the +// «closed-free» transition spans the full pin↔closed↔peek range in +// one gesture and the curtain follows the finger off-screen freely); +// the committing curtain DISPLACEMENT is still +// `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` ≈ 61 px — essentially «drag +// the curtain across the full tabs-row height». On the body the same +// displacement is reached with a longer finger pull because the body +// path is rubber-banded (×0.65). +// +// Unpin's clamp is asymmetric — `pinned-free` lower-bounds the live +// delta at 0 (no destination above pinned) but leaves the upper +// direction unclamped so the same gesture can carry the curtain +// through closed into peek territory in one motion. The handle-only +// contract on unpin means the body never resolves to `pinned-free`, +// so the no-upper-clamp tolerance only applies on the dedicated +// drag-handle. +export const PIN_COMMIT_THRESHOLD = 0.95; + +// Drag-handle hit-zone at the top of the curtain. NATIVE-ONLY: the +// handle is rendered only when `isNativePlatform()` is true (see +// StreamHeader.tsx) — on web (desktop / Electron) the curtain has +// no interactive snap states, so the handle would be pure +// decoration and is omitted entirely. +// +// On native the handle is the AUTHORITATIVE gesture surface — +// closed-free / unpin / close-peek / form-close all bind here with +// 1:1 finger ↔ curtain tracking, no matter whether the chat list +// inside the curtain is scrollable. See `useCurtainHandleGesture` +// for the full state machine. +// +// A parallel `useCurtainBodyGesture` bound to the curtain's body +// handles drag from anywhere on the card, but only when the inner +// chat list has no scrollable content AND the curtain isn't pinned +// (unpin is handle-only). Its dynamics are rubber-banded so the +// body drag reads as physically «heavier» than the handle's crisp +// pull. +// +// Size: 32 px tall — enough touch target to land on comfortably with +// a thumb (the visible grabber pill inside is much smaller, see +// `StreamHeader.css.ts::handleBar`). The list (or DirectEmpty / the +// equivalent placeholder) starts 32 px below the curtain's top edge +// on native; on web the list sits flush at the curtain's top. +export const HANDLE_HEIGHT_PX = 32; diff --git a/src/app/components/stream-header/index.ts b/src/app/components/stream-header/index.ts new file mode 100644 index 00000000..42aeed15 --- /dev/null +++ b/src/app/components/stream-header/index.ts @@ -0,0 +1,2 @@ +export { StreamHeader } from './StreamHeader'; +export { TABS_ROW_PX, CHIP_ROW_PX, CURTAIN_SNAP_MS, CURTAIN_SNAP_EASING } from './geometry'; diff --git a/src/app/components/stream-header/useCurtainBodyGesture.ts b/src/app/components/stream-header/useCurtainBodyGesture.ts new file mode 100644 index 00000000..cc00fef8 --- /dev/null +++ b/src/app/components/stream-header/useCurtainBodyGesture.ts @@ -0,0 +1,421 @@ +import { MutableRefObject, useEffect, useRef } from 'react'; +import { isNativePlatform } from '../../utils/capacitor'; +import { + ACTIVE_CLOSE_THRESHOLD_PX, + COMMIT_THRESHOLD, + DIRECTION_DEAD_ZONE_PX, + PEEK_TRAVEL_PX, + RUBBER_BAND, +} from './geometry'; +import { CurtainSnap, isFormSnap } from './useCurtainState'; +import { + assertNeverCurtainTransition, + CurtainTransition, + resolveCurtainTransition, +} from './useCurtainHandleGesture'; + +type Args = { + // The curtain element. Touch listeners bind here so anywhere on the + // curtain body — the chat list, an empty-state placeholder, the + // DirectSelfRow / WorkspaceFooter at the bottom — can drive a + // gesture. The handle's own listener (`useCurtainHandleGesture`) + // is bound to a child element of this curtain and runs first; we + // explicitly bail on touches that originate inside the handle so + // the two surfaces don't double-engage. + curtainRef: MutableRefObject; + // The handle element. Used solely to short-circuit our listener + // when the touch starts inside the handle's hit-zone (the handle + // hook has already armed for that touch). + handleRef: MutableRefObject; + // The `bottomPinned` slot at the bottom of the curtain (hosts + // DirectSelfRow, WorkspaceFooter). These rows open their own bottom + // sheets via vertical drag, so a touch that starts there must NOT + // engage the curtain body — otherwise the + // user's «pull settings up» gesture would also pin the curtain + // and the two motions would visually fight. `null` is fine (the + // surface has no bottomPinned content); the contains() check is + // optional-chained. + bottomPinnedRef: MutableRefObject; + // Scroll viewport of the chat list inside the curtain. The body + // gesture engages only when this element is NOT scrollable + // (scrollHeight ≤ clientHeight + 1): on long lists the user's + // vertical drag must remain a native scroll gesture, on short / + // empty lists the same drag drives the curtain instead. Treated + // as «not scrollable» when `scrollRef.current` is null (some + // listing surfaces render their empty state DIRECTLY as a curtain + // child, bypassing `PageNavContent` — `Direct.tsx::DirectEmpty`, + // `ChannelsRootNav::ChannelsLanding` — so scrollRef stays null and + // the body gesture must still engage). + scrollRef: MutableRefObject; + // Current snap stop. Mirrored into a ref so the listener — bound + // once per `disabled` flip — reads fresh values without rebinding. + snap: CurtainSnap; + // Per-pane pinned overlay; also ref-mirrored. Only READ here (used + // by the touchstart bail) — pin / unpin commits are the handle's + // exclusive contract, see «Direction asymmetry» on the hook. + pinned: boolean; + // Live drag delta sink — feeds the curtain's `top` via React state, + // no direct DOM writes. + setLiveDrag: (px: number, dragging: boolean) => void; + // Snap commit. `'peek'` fires from closed-free's down-half; + // `'closed'` fires from close-peek and form-close. The pin / unpin + // paths are handle-only and never flip state through this setter + // from the body. + commit: (next: 'peek' | 'closed') => void; + // Suppress gesture binding entirely. Same conditions as the handle + // hook — see StreamHeader's `gestureDisabled`. + disabled?: boolean; + // Shared handle-visual sink. The grabber pill at the top of the + // curtain animates Primary-blue + stretches whenever the user has + // crossed the per-transition commit threshold, on ANY surface — + // handle or body. Dedupe inside the hook keeps consumer re-renders + // bounded to actual state flips. + setHandleState?: (state: { dragging: boolean; atCommit: boolean }) => void; +}; + +// Touch-gesture driver for the curtain BODY (everything outside the +// dedicated drag-handle). Native-only. +// +// Why a second surface? On listing surfaces with content that fits in +// one screen (empty Direct / Bots / Channels states, the ChannelsLanding +// CTA, a workspace with few rooms) the user's natural «pull the curtain +// down to peek» gesture happens anywhere on the visible card. +// Restricting all motion to the 32 px handle on these surfaces felt +// artificial. On the other hand, surfaces with a scrollable list need +// their native vertical scroll preserved — so the body gesture is +// *conditional*: it engages only when the chat list has no scrollable +// content (scrollHeight ≤ clientHeight + 1). Long lists keep using the +// handle for curtain motion. +// +// Direction asymmetry — pinning is handle-only, retracting is shared. +// The body engages on: +// * closed + DOWN → peek (closed-free, down-half only) +// * peek + UP → closed (close-peek) +// * form-* + UP → closed (form-close) +// The body does NOT engage on: +// * closed + UP → would be pin via closed-free's up-half. The +// user reported that arbitrary upward drag on +// the body made it too easy to accidentally +// close the directs/channels/bots header by +// pinning. Pin must be a deliberate gesture on +// the dedicated pin-handle. After close-peek / +// form-close lands at `closed`, the curtain +// can only go further up via the handle. +// * pinned + DOWN → unpin / peek-from-pinned. Same rationale: the +// pin handle owns the unpin contract too, so an +// accidental drag on the visible card can't +// undo it. Bailed at touchstart (see Pinned +// override below). +// The asymmetric block on closed+UP is implemented in onTouchMove +// after the transition resolves — we only bail closed-free's UP half, +// not every upward drag, so close-peek and form-close still engage on +// the body. +// +// Dynamics: all transitions use rubber-band 0.65 (= RUBBER_BAND) so +// the body drag feels physically «heavier» than the handle's crisp +// 1:1 — the user reads the two surfaces as distinct affordances. The +// commit math is expressed in CURTAIN displacement (lastDelta), not +// raw finger pull, so a body «commit at COMMIT_THRESHOLD × +// PEEK_TRAVEL_PX» visually matches a handle commit at the same point — +// only the finger pull needed to get there differs. +// +// Form-snap override: when a form is mounted, the chat list under it +// is mostly hidden but still in DOM with whatever scrollHeight it has. +// Skip the scrollable-bail in that case — the body's visible area is +// the strip BELOW the form, and a drag there is unambiguously a +// form-close intent (the only valid transition from form-* snap). +// +// Pinned override: the body gesture is INERT while the curtain is +// pinned. Unpin is exclusively the handle's contract — the user has +// to grab the dedicated pin-handle to release the lock, so an +// accidental drag anywhere on the visible card doesn't undo it. We +// bail at touchstart so no listener side-effects (preventDefault, +// liveDrag emit, …) can fire either. +export function useCurtainBodyGesture({ + curtainRef, + handleRef, + bottomPinnedRef, + scrollRef, + snap, + pinned, + setLiveDrag, + commit, + disabled, + setHandleState, +}: Args): void { + const snapRef = useRef(snap); + snapRef.current = snap; + const pinnedRef = useRef(pinned); + pinnedRef.current = pinned; + const commitRef = useRef(commit); + commitRef.current = commit; + const setHandleStateRef = useRef(setHandleState); + setHandleStateRef.current = setHandleState; + + useEffect(() => { + if (!isNativePlatform()) return undefined; + if (disabled) return undefined; + const curtain = curtainRef.current; + if (!curtain) return undefined; + + let startX: number | null = null; + let startY: number | null = null; + let direction: 'up' | 'down' | null = null; + let transition: CurtainTransition | null = null; + let engaged = false; + let lastDelta = 0; + // Same dedupe pattern as the handle hook — re-render the consumer + // only on actual visual-state flips. + let emittedDragging = false; + let emittedAtCommit = false; + const emitHandle = (dragging: boolean, atCommit: boolean) => { + if (dragging === emittedDragging && atCommit === emittedAtCommit) return; + emittedDragging = dragging; + emittedAtCommit = atCommit; + setHandleStateRef.current?.({ dragging, atCommit }); + }; + + const onTouchStart = (e: TouchEvent) => { + if (e.touches.length !== 1) return; + // Pinned bail — handle owns unpin exclusively. See the «Pinned + // override» note above the hook for the rationale. + if (pinnedRef.current) return; + // Hand off to the handle hook if the touch starts inside the + // handle's 32 px hit-zone — the handle's own listener has + // already armed for this touch. + const target = e.target as Node | null; + if (target && handleRef.current?.contains(target)) return; + // Hand off to the bottomPinned region (DirectSelfRow, + // WorkspaceFooter). Those rows host their own drag-to-open + // bottom sheets — engaging the curtain gesture here would pin + // the curtain in parallel with the sheet opening, and the two + // motions would visually fight. + if (target && bottomPinnedRef.current?.contains(target)) return; + // Scroll-aware bail: leave a scrollable chat list to its native + // vertical scroll. Skipped in form-* snaps because the visible + // body area there is the strip BELOW the form (where the list + // mostly isn't paintable anyway), and form-close is the only + // valid transition — letting the list scroll instead would + // strand the user in the form. + const list = scrollRef.current; + if (!isFormSnap(snapRef.current) && list && list.scrollHeight > list.clientHeight + 1) { + return; + } + startX = e.touches[0].clientX; + startY = e.touches[0].clientY; + direction = null; + transition = null; + engaged = false; + lastDelta = 0; + }; + + const onTouchMove = (e: TouchEvent) => { + if (e.touches.length !== 1) { + // Second finger landed mid-gesture — abort. + startX = null; + startY = null; + direction = null; + transition = null; + if (engaged) setLiveDrag(0, false); + engaged = false; + lastDelta = 0; + emitHandle(false, false); + return; + } + if (startY === null) return; + + const delta = e.touches[0].clientY - startY; + const deltaX = startX !== null ? e.touches[0].clientX - startX : 0; + + if (direction === null) { + if (Math.abs(delta) < DIRECTION_DEAD_ZONE_PX) return; + // Horizontal-bail: pager horizontal swipe wins ties → we drop. + if (Math.abs(deltaX) > Math.abs(delta)) { + startX = null; + startY = null; + direction = null; + return; + } + direction = delta > 0 ? 'down' : 'up'; + transition = resolveCurtainTransition(snapRef.current, pinnedRef.current, direction); + if (transition === null) { + // (snap, pinned, direction) has no valid motion — peek+down, + // form+down (pinned+up also resolves to null, though the + // touchstart pinned-bail already filters every pinned + // gesture before we reach here). Bail without preventDefault + // so any native default (overscroll bounce, etc.) can still + // play. + startX = null; + startY = null; + direction = null; + return; + } + // Closed-free UP-half bail. closed-free is the only transition + // whose upward direction commits to pin — and pin via body is + // exactly what the user banned (see «Direction asymmetry» on + // the hook). The downward half (closed → peek) stays on body. + // close-peek and form-close are also upward, but their commit + // target is `closed` — they're the «retract» gestures the user + // explicitly wants to keep on the body, so they pass through. + if (transition === 'closed-free' && direction === 'up') { + startX = null; + startY = null; + direction = null; + transition = null; + return; + } + } + + engaged = true; + e.preventDefault(); + + // Per-transition rubber-band dynamics + atCommit semantics. All + // thresholds expressed against CURTAIN displacement (lastDelta) + // so the body and the handle commit at the same visual point, + // only the finger pull needed differs. + let atCommit = false; + switch (transition) { + case 'closed-free': + // Body-side `closed-free` is DOWN-only: the handle owns the + // UP half (pin commit) per «Direction asymmetry» above, and + // the closed-free up-bail in the dead-zone block makes sure + // we only ever engage this branch with direction='down'. + // Clamp at 0 below so a mid-gesture finger-up past the + // start point can't drag the curtain into pin territory + // and offer a pin commit that the user explicitly didn't + // want exposed on the body. Rubber-banded 0.65× + // displacement matches the «physically heavier» body feel. + lastDelta = Math.max(0, delta * RUBBER_BAND); + atCommit = lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD; + break; + case 'close-peek': + // Rubber-banded up. No clamp either side — matches the + // original list-bound peek feel; a downward jitter past the + // peek snap is visually negligible against the rubber-band + // damping. Commit target is `closed`; no path into pin + // territory (the user's hard rule — pin is handle-only). + lastDelta = delta * RUBBER_BAND; + atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD; + break; + case 'form-close': + // Rubber-banded up; capped at 0 so an accidental downward + // jitter doesn't push the curtain below its form-snap top. + // Commit target is `closed` (the form-close drag retracts + // through the form's vertical footprint into the closed + // snap). + lastDelta = Math.min(0, delta * RUBBER_BAND); + atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX; + break; + case 'pinned-free': + // Unreachable on the body — the pinned bail at touchstart + // prevents the hook from ever resolving this transition. + // Kept here so the `never` default below stays exhaustive + // and a future opening of pinned-free on the body would + // need to wire the dispatch explicitly. + break; + case null: + // Unreachable: `engaged` is set only after `transition` is + // resolved non-null in the dead-zone block above. + break; + default: { + assertNeverCurtainTransition(transition); + break; + } + } + setLiveDrag(lastDelta, true); + emitHandle(true, atCommit); + }; + + const onTouchEnd = () => { + if (!engaged) { + startX = null; + startY = null; + direction = null; + transition = null; + return; + } + switch (transition) { + case 'closed-free': + // Body is DOWN-only — peek is the sole commit target. Pin + // commit lives on the handle (see «Direction asymmetry» + // above and the touchmove switch). Below threshold the + // curtain springs back to closed. + if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) { + commitRef.current('peek'); + } else { + setLiveDrag(0, false); + } + break; + case 'close-peek': + if (-lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) { + commitRef.current('closed'); + } else { + setLiveDrag(0, false); + } + break; + case 'form-close': + if (-lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX) { + commitRef.current('closed'); + } else { + setLiveDrag(0, false); + } + break; + case 'pinned-free': + case null: + // Both unreachable per the touchmove switch above (pinned + // bail at touchstart filters pinned-free; `engaged` only + // flips once `transition` is non-null). The setLiveDrag + // fallback preserves spring-back behaviour if a future + // change exposes either path here. + setLiveDrag(0, false); + break; + default: { + assertNeverCurtainTransition(transition); + setLiveDrag(0, false); + break; + } + } + startX = null; + startY = null; + direction = null; + transition = null; + engaged = false; + lastDelta = 0; + emitHandle(false, false); + }; + + const onTouchCancel = () => { + if (engaged) setLiveDrag(0, false); + startX = null; + startY = null; + direction = null; + transition = null; + engaged = false; + lastDelta = 0; + emitHandle(false, false); + }; + + curtain.addEventListener('touchstart', onTouchStart, { passive: true }); + curtain.addEventListener('touchmove', onTouchMove, { passive: false }); + curtain.addEventListener('touchend', onTouchEnd, { passive: true }); + curtain.addEventListener('touchcancel', onTouchCancel, { passive: true }); + return () => { + curtain.removeEventListener('touchstart', onTouchStart); + curtain.removeEventListener('touchmove', onTouchMove); + curtain.removeEventListener('touchend', onTouchEnd); + curtain.removeEventListener('touchcancel', onTouchCancel); + // Same teardown contract as the handle hook — see its cleanup for + // the rationale. If `disabled` flips true while a body drag is in + // flight, the touchend never reaches us and the curtain would stay + // frozen at the finger position until the next touch. + if (engaged) { + setLiveDrag(0, false); + emitHandle(false, false); + } + }; + // setLiveDrag is stable; the ref args are stable. `snap`, `pinned`, + // and `commit` are ref-mirrored. Only `disabled` needs to tear + // listeners down — it's the sole effect dep. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [curtainRef, handleRef, bottomPinnedRef, scrollRef, setLiveDrag, disabled]); +} diff --git a/src/app/components/stream-header/useCurtainHandleGesture.ts b/src/app/components/stream-header/useCurtainHandleGesture.ts new file mode 100644 index 00000000..aa99be04 --- /dev/null +++ b/src/app/components/stream-header/useCurtainHandleGesture.ts @@ -0,0 +1,483 @@ +import { MutableRefObject, useEffect, useRef } from 'react'; +import { isNativePlatform } from '../../utils/capacitor'; +import { + ACTIVE_CLOSE_THRESHOLD_PX, + COMMIT_THRESHOLD, + DIRECTION_DEAD_ZONE_PX, + PEEK_TRAVEL_PX, + PIN_COMMIT_THRESHOLD, + PIN_TRAVEL_PX, +} from './geometry'; +import { CurtainSnap, isFormSnap } from './useCurtainState'; + +type Args = { + // Drag-handle element at the top of the curtain. ALL curtain + // gestures bind here — the chat list's scroll viewport is left to + // native vertical scroll so finger-down inside the list never races + // a pin / peek / form-close path. Mounted as the first flex child + // of the curtain in StreamHeader.tsx. + handleRef: MutableRefObject; + // Current snap stop. Mirrored into a ref so the listener (bound + // once per `disabled` flip) reads fresh values without re-attaching. + // Every snap participates: closed → pin/peek, peek → close-peek, + // form-* → form-close, pinned-overlay → unpin. + snap: CurtainSnap; + // Per-pane pinned overlay. When true the handle's drag-down path + // commits unpin; when false the snap drives which transition arms. + pinned: boolean; + // Setter for the pinned overlay; called on release once the user's + // drag past the commit threshold qualifies the gesture. + setPinned: (next: boolean) => void; + // Setter for the live drag delta during touchmove. The hook reads + // `liveDragPx` from the parent state too, so React drives the + // curtain's `top` re-render — no direct DOM writes. + setLiveDrag: (px: number, dragging: boolean) => void; + // Snap commit. Called on release for peek / close-peek / form-close + // (the pin / unpin paths flip `pinned` instead). Narrowed to the + // two non-form destinations the hook ever reaches. Also resets + // liveDragPx + isDragging atomically inside the parent state. + commit: (next: 'peek' | 'closed') => void; + // Suppress gesture binding entirely. Used to gate motion when a + // bottom sheet is open or when this pane is inactive inside the + // swipe pager. + disabled?: boolean; + // Optional sink for handle-visual state — drives the grabber pill's + // «idle / dragging / threshold reached» appearance via + // `data-dragging` and `data-at-commit` on the handle div. Called + // only when the state actually changes, so the consumer doesn't pay + // a re-render on every touchmove. + setHandleState?: (state: { dragging: boolean; atCommit: boolean }) => void; +}; + +// Curtain transitions either gesture surface can resolve. Each one +// has its own commit threshold and release destination (snap commit +// vs pin flip); per-surface dynamics (1:1 on the handle, rubber-band +// on the curtain body) decide how raw finger displacement translates +// into curtain motion — see `onTouchMove` here for the 1:1 branches +// and `useCurtainBodyGesture` for the rubber-banded equivalents. +// +// `closed-free` is the single free-range transition that spans the +// full pin↔closed↔peek vertical range in one gesture. From the closed +// snap, neither direction is locked at the dead-zone: the user can +// drag up past the safe-top zone OR down through the chip area in +// one motion, and the release decides pin / peek / snap-back based +// on the final position. The earlier pair of one-shot `pin` and +// `peek` transitions used a hard «gate» at the start point (each +// direction was clamped to one side of 0 once the dead-zone resolved +// the direction) and the user reported this as a regression — drag +// up, then back down, ran into an invisible wall at the closed +// position before peek could engage. +// +// `pinned-free` is the symmetric free-range transition for the +// pinned overlay: from pinned + drag DOWN the curtain follows the +// finger all the way through closed into peek territory in one +// motion. On release, peek wins if the finger crossed the absolute +// peek planka (PIN_TRAVEL_PX + COMMIT_THRESHOLD × PEEK_TRAVEL_PX — +// the same visual point peek commits at from closed-free), unpin +// wins if at least the unpin threshold was reached, otherwise snap +// back to pinned. UP is no-op (no destination above pinned). Only +// the handle resolves to `pinned-free` — the body gesture bails at +// touchstart while pinned so unpin remains a deliberate handle pull. +export type CurtainTransition = 'closed-free' | 'pinned-free' | 'close-peek' | 'form-close'; + +// Exhaustive-check helper. Used in the `default` branch of every +// switch over `CurtainTransition | null` so that adding a fifth +// variant to the union fails typecheck at every dispatch site +// rather than silently no-op'ing through default. The argument is +// prefixed with `_` so eslint's `argsIgnorePattern: '^_'` keeps the +// rule happy without us tagging it `// eslint-disable`. +export const assertNeverCurtainTransition = (_value: never): void => {}; + +// Decide which transition the gesture arms based on the snap state +// at direction-resolution time and the finger direction. `null` means +// the (snap, pinned, direction) triple has no valid motion and the +// gesture must bail so native scroll / pager swipe / nothing-at-all +// owns the touch. +// +// Direction guards encoded here: +// * pinned + UP → no-op (would push the curtain past safe-top +// on commit — no destination above pinned). +// * pinned + DOWN → pinned-free (HANDLE-only contract — the body +// hook bails entirely while pinned so unpin / +// peek-from-pinned stays a deliberate handle +// pull. See +// `useCurtainBodyGesture::onTouchStart`). +// * closed (any) → closed-free (single transition spanning the +// whole pin↔closed↔peek range; direction at +// the dead-zone matters only for the +// horizontal-bail check). +// * peek + UP → close-peek (retreat to closed). +// * peek + DOWN → no-op (nothing lower to reveal). +// * form-* + UP → form-close. +// * form-* + DOWN → no-op (form is already the lowest snap). +export function resolveCurtainTransition( + snap: CurtainSnap, + pinned: boolean, + direction: 'up' | 'down' +): CurtainTransition | null { + if (pinned) return direction === 'down' ? 'pinned-free' : null; + if (snap === 'closed') return 'closed-free'; + if (snap === 'peek') return direction === 'up' ? 'close-peek' : null; + if (isFormSnap(snap)) return direction === 'up' ? 'form-close' : null; + return null; +} + +// Touch-gesture driver for the dedicated 32 px drag-handle at the top +// of the curtain. Native-only: listeners aren't attached on web / +// desktop. +// +// The handle is the «authoritative» gesture surface — it owns every +// transition (closed-free, pinned-free, close-peek, form-close) +// with crisp 1:1 finger ↔ curtain tracking regardless of whether +// the chat list inside the curtain is scrollable. The curtain BODY +// has a parallel gesture (`useCurtainBodyGesture`) with rubber- +// banded dynamics that only engages when the body's chat list has +// no scrollable content — so the user can pull the curtain «from +// anywhere» on empty / short lists but a real list-scroll is never +// hijacked under their finger. The body is also fully inert while +// pinned, so unpin (and unpin → peek overshoot) stays a deliberate +// handle pull. +// +// Design rationale: gestures used to bind to the chat list's scroll +// viewport directly, which produced repeating «drag-at-scrollTop=0 +// hijacks for pin/peek» bugs. Moving every transition onto a +// dedicated handle (plus an opt-in body surface that bails on +// scrollable lists) removes the scroll/gesture race entirely. +// +// Per-transition dynamics — all track the finger 1:1, but the clamp +// shapes differ to keep on-screen motion sensible while preserving +// the «drag up off-screen from anywhere» feel the user explicitly +// asked for: +// * closed-free — NO clamps either side. Finger goes off- +// screen up → curtain follows past safe-top; +// finger crosses back below the start point → +// curtain continues into peek territory in +// the same gesture. Direction-aware commit +// on release: pin if pulled UP past +// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX, peek +// if pulled DOWN past COMMIT_THRESHOLD × +// PEEK_TRAVEL_PX, else snap back to closed. +// * pinned-free — DOWN-only free-range drag from pinned. +// Clamped at 0 below (no destination above +// pinned), NO upper clamp — the finger can +// carry the curtain through closed into +// peek territory in one motion. Release +// decides peek (lastDelta ≥ PIN_TRAVEL_PX + +// COMMIT_THRESHOLD × PEEK_TRAVEL_PX), unpin +// (lastDelta ≥ PIN_COMMIT_THRESHOLD × +// PIN_TRAVEL_PX), or snap back to pinned. +// * close-peek — capped at 0 below (no transition lower +// than peek), NO upper clamp (drag past +// closed into safe-top freely). Commit at +// COMMIT_THRESHOLD × PEEK_TRAVEL_PX. +// * form-close — capped at 0 so a downward jitter can't +// push the curtain below its form-snap top, +// NO upper clamp. Commit at +// ACTIVE_CLOSE_THRESHOLD_PX (absolute). +// +// Handle visual: emitHandle(true, atCommit) fires on every transition +// during touchmove so the grabber pill animates Primary-blue + +// stretches as the user crosses the commit threshold, no matter which +// motion is in flight. The dedupe keeps consumer re-renders bounded +// to actual state flips. The body hook shares the same setHandleState +// sink — only one of the two surfaces is engaged at any moment, so +// they never fight over the visual. +export function useCurtainHandleGesture({ + handleRef, + snap, + pinned, + setPinned, + setLiveDrag, + commit, + disabled, + setHandleState, +}: Args): void { + const snapRef = useRef(snap); + snapRef.current = snap; + const pinnedRef = useRef(pinned); + pinnedRef.current = pinned; + const setPinnedRef = useRef(setPinned); + setPinnedRef.current = setPinned; + const commitRef = useRef(commit); + commitRef.current = commit; + const setHandleStateRef = useRef(setHandleState); + setHandleStateRef.current = setHandleState; + + useEffect(() => { + if (!isNativePlatform()) return undefined; + if (disabled) return undefined; + const handle = handleRef.current; + if (!handle) return undefined; + + let startX: number | null = null; + let startY: number | null = null; + let direction: 'up' | 'down' | null = null; + let transition: CurtainTransition | null = null; + let engaged = false; + let lastDelta = 0; + // Last visual state emitted to the consumer. We dedupe here so the + // setter (a React useState) only re-renders when something actually + // changed, not on every 60fps touchmove. + let emittedDragging = false; + let emittedAtCommit = false; + const emitHandle = (dragging: boolean, atCommit: boolean) => { + if (dragging === emittedDragging && atCommit === emittedAtCommit) return; + emittedDragging = dragging; + emittedAtCommit = atCommit; + setHandleStateRef.current?.({ dragging, atCommit }); + }; + + const onTouchStart = (e: TouchEvent) => { + if (e.touches.length !== 1) return; + startX = e.touches[0].clientX; + startY = e.touches[0].clientY; + direction = null; + transition = null; + engaged = false; + lastDelta = 0; + }; + + const onTouchMove = (e: TouchEvent) => { + if (e.touches.length !== 1) { + // Second finger landed mid-gesture — abort. + startX = null; + startY = null; + direction = null; + transition = null; + if (engaged) setLiveDrag(0, false); + engaged = false; + lastDelta = 0; + emitHandle(false, false); + return; + } + if (startY === null) return; + + const delta = e.touches[0].clientY - startY; + const deltaX = startX !== null ? e.touches[0].clientX - startX : 0; + + // Resolve a direction once the finger crosses the dead-zone. + if (direction === null) { + if (Math.abs(delta) < DIRECTION_DEAD_ZONE_PX) return; + // Horizontal-bail: if |dx| strictly exceeds |dy|, the user is + // swiping the mobile tab pager, not pulling the curtain. Drop + // tracking so the pager owns the gesture. + if (Math.abs(deltaX) > Math.abs(delta)) { + startX = null; + startY = null; + direction = null; + return; + } + direction = delta > 0 ? 'down' : 'up'; + transition = resolveCurtainTransition(snapRef.current, pinnedRef.current, direction); + // (snap, pinned, direction) has no valid motion — pinned+up, + // peek+down, form+down. Bail so the gesture can be re-armed on + // the next touch sequence; no preventDefault is fired so the + // browser keeps any default behaviour (it would be a no-op + // here anyway — the handle has touchAction:none in CSS). + if (transition === null) { + startX = null; + startY = null; + direction = null; + return; + } + } + + engaged = true; + e.preventDefault(); + + // Clamp the raw finger delta into the live curtain displacement + // (`lastDelta`). Stored separately because the commit math on + // release needs the same value the curtain was visually showing. + let atCommit = false; + switch (transition) { + case 'closed-free': + // Single free-range drag spanning pin↔closed↔peek. 1:1 with + // NO clamps either side: the curtain follows the finger off- + // screen upward (past safe-top) and continuously into peek + // territory downward in the same gesture. The release decides + // pin / peek / snap-back from the final lastDelta. + lastDelta = delta; + // Direction-aware atCommit so the grabber pill stretches + // whichever way the user is committing. Pin and peek are + // sign-exclusive (one branch can't fire simultaneously with + // the other) so a simple ternary on `lastDelta` suffices. + atCommit = + lastDelta <= 0 + ? -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD + : lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD; + break; + case 'pinned-free': + // 1:1 down from pinned. Clamped at 0 below (a downward + // jitter past the start mustn't push the curtain into + // safe-top — there's no destination above pinned), NO + // upper clamp — the curtain follows the finger through + // closed into peek territory in one motion. + lastDelta = Math.max(0, delta); + // atCommit fires as soon as ANY commit qualifies (the + // grabber pill stretches to signal «release works here»); + // it stays true past the unpin threshold all the way + // through peek, since both are valid landing zones. + atCommit = lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD; + break; + case 'close-peek': + // 1:1 up; delta is negative. Lower-capped at 0 (a downward + // jitter shouldn't push past the peek snap), NO upper clamp + // — the curtain follows the finger off-screen freely in the + // safe-top direction, matching the «drag up off-screen from + // anywhere» expectation. + lastDelta = Math.min(0, delta); + atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD; + break; + case 'form-close': + // Form close: finger moves UP (delta < 0). Track 1:1, capped + // at 0 so an accidental downward jitter doesn't push the + // curtain below its resting form-snap position. + lastDelta = Math.min(0, delta); + atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX; + break; + case null: + // Unreachable: `engaged` is set only after `transition` is + // resolved non-null in the dead-zone block above; reaching + // this case would imply the gesture engaged without a + // transition, which the control flow above forbids. + break; + default: { + // Exhaustive guard. The `never` cast turns a future addition + // to `CurtainTransition` into a compile error here — adding + // a fifth member without wiring its dispatch fails typecheck. + assertNeverCurtainTransition(transition); + break; + } + } + setLiveDrag(lastDelta, true); + emitHandle(true, atCommit); + }; + + const onTouchEnd = () => { + if (!engaged) { + startX = null; + startY = null; + direction = null; + transition = null; + return; + } + // Commit decision per transition. setPinned() and commit() each + // reset liveDragPx + isDragging in the same batched update — + // React renders the curtain at the new resting top with the snap + // transition re-enabled. Non-commit paths drop the live drag back + // to 0 with transition active so the curtain springs back. + switch (transition) { + case 'closed-free': + // Direction-aware commit from the free-range drag. Pin + // wins over peek if both somehow qualified (sign-exclusive + // in practice — lastDelta can't be simultaneously <0 and + // >0). Below either threshold, spring back to closed. + if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) { + setPinnedRef.current(true); + } else if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) { + commitRef.current('peek'); + } else { + setLiveDrag(0, false); + } + break; + case 'pinned-free': + // Two-tier commit: peek wins if the finger crossed the + // absolute peek planka (matches the visual point peek + // commits at from closed-free — PIN_TRAVEL_PX to get to + // closed + COMMIT_THRESHOLD × PEEK_TRAVEL_PX through the + // chip area); otherwise unpin if at least the unpin + // threshold was reached; else snap back to pinned. + // + // The peek branch MUST clear `pinned` before committing + // the snap. The curtain's resting top is + // `pinned ? 0 : snapTopPx(snap)` — so commit('peek') + // alone would set snap='peek' yet leave the curtain + // visually at top=0 (the pin overlay wins). Both updates + // batch into one render inside this touchend handler. + if (lastDelta >= PIN_TRAVEL_PX + COMMIT_THRESHOLD * PEEK_TRAVEL_PX) { + setPinnedRef.current(false); + commitRef.current('peek'); + } else if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) { + setPinnedRef.current(false); + } else { + setLiveDrag(0, false); + } + break; + case 'close-peek': + if (-lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) { + commitRef.current('closed'); + } else { + setLiveDrag(0, false); + } + break; + case 'form-close': + if (-lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX) { + commitRef.current('closed'); + } else { + setLiveDrag(0, false); + } + break; + case null: + // Unreachable: `engaged` is set only after `transition` is + // resolved non-null. Mirrors the touchmove switch. + setLiveDrag(0, false); + break; + default: { + // Exhaustive guard — see the touchmove switch for the same + // pattern. setLiveDrag fallback preserves spring-back if a + // future transition lands here unhandled at runtime. + assertNeverCurtainTransition(transition); + setLiveDrag(0, false); + break; + } + } + startX = null; + startY = null; + direction = null; + transition = null; + engaged = false; + lastDelta = 0; + emitHandle(false, false); + }; + + const onTouchCancel = () => { + // System cancel never commits — always snap back to current snap. + if (engaged) setLiveDrag(0, false); + startX = null; + startY = null; + direction = null; + transition = null; + engaged = false; + lastDelta = 0; + emitHandle(false, false); + }; + + handle.addEventListener('touchstart', onTouchStart, { passive: true }); + handle.addEventListener('touchmove', onTouchMove, { passive: false }); + handle.addEventListener('touchend', onTouchEnd, { passive: true }); + handle.addEventListener('touchcancel', onTouchCancel, { passive: true }); + return () => { + handle.removeEventListener('touchstart', onTouchStart); + handle.removeEventListener('touchmove', onTouchMove); + handle.removeEventListener('touchend', onTouchEnd); + handle.removeEventListener('touchcancel', onTouchCancel); + // If `disabled` flips true while a drag is in flight, the touchend + // we'd normally rely on for snap-back never reaches us (the listener + // is gone). Without an explicit reset the curtain stays frozen at + // the finger position with `transition: none` and the grabber pill + // stuck Primary-blue until the user starts a new touch — visible as + // a half-open curtain after, say, a sheet opens mid-drag. + if (engaged) { + setLiveDrag(0, false); + emitHandle(false, false); + } + }; + // setLiveDrag is a stable useCallback; handleRef is stable. `snap`, + // `pinned`, `setPinned` and `commit` are mirrored via the refs + // above so the listener (bound once per `disabled` flip) reads + // fresh values without re-attaching every render. `disabled` is + // the only signal that needs to tear the listeners down — it goes + // into the deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [handleRef, setLiveDrag, disabled]); +} diff --git a/src/app/components/stream-header/useCurtainState.ts b/src/app/components/stream-header/useCurtainState.ts new file mode 100644 index 00000000..8e9b8711 --- /dev/null +++ b/src/app/components/stream-header/useCurtainState.ts @@ -0,0 +1,249 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useAtom } from 'jotai'; +import { curtainPinnedByTabAtom } from '../../state/mobilePagerHeader'; +import { + CHIP_GAP_PX, + CHIP_ROW_PX, + CURTAIN_BREATHER_PX, + CURTAIN_SNAP_MS, + SEARCH_FORM_BASE_PX, + TABS_ROW_PX, +} from './geometry'; + +// Discrete snap stops for the curtain. The curtain's resting `top` +// is derived from this value plus the live finger drag delta. There +// is no separate «active vs peek» mode flag — the snap encodes both. +export type CurtainSnap = + | 'closed' // curtain flush under tabs row; nothing peeking + | 'peek' // both action chips (search + new chat) revealed + | 'form-search' // full search form revealed + | 'form-chat'; // full new-chat form revealed + +export const isFormSnap = (snap: CurtainSnap): snap is 'form-search' | 'form-chat' => + snap === 'form-search' || snap === 'form-chat'; + +export const isPeekSnap = (snap: CurtainSnap): snap is 'peek' => snap === 'peek'; + +// Form kind currently rendered in the header. Stays set during the +// curtain's close transition so the form has content to slide behind; +// cleared by `acknowledgeClosed` after the snap settles at `closed`. +export type ActiveForm = 'search' | 'chat' | null; + +export type CurtainState = { + snap: CurtainSnap; + // Per-tab pin overlay. Stored in `curtainPinnedByTabAtom` keyed by + // the consumer-supplied `pinKey` so the lock survives the route- + // driven listing-pane unmount when the user taps into a Room and + // back. Each tab keeps its own pin (Direct/Channels/Bots are + // independent). + pinned: boolean; + // Setter for the pinned overlay. Called by the gesture hook on + // commit (drag-up-from-closed past threshold sets true; drag-down- + // while-pinned past threshold sets false). + setPinned: (next: boolean) => void; + activeForm: ActiveForm; + // Live finger delta in px. Added to the snap-derived resting top to + // compute the curtain's visible top. Stays at 0 when no gesture is + // in flight. Positive = finger pulled down (peek reveal); negative = + // finger pulled up (close gesture). + liveDragPx: number; + // True while a touch gesture is in flight. Controls the curtain's + // `top` transition: disabled while dragging (curtain tracks finger + // 1:1), restored on release so the snap commit animates smoothly. + isDragging: boolean; + // Live measured height of the active form's outer; used to compute + // the curtain's resting `top` when `snap === 'form-*'`. `null` while + // no form is mounted. + formHeightPx: number | null; + // Ref pointing at the rendered form's outer — a ResizeObserver + // watches this to feed `formHeightPx`. Consumer attaches it to the + // form's wrapping element. + formMeasureRef: React.RefObject; + // Open a form. Sets `snap` and `activeForm` synchronously. + open: (form: 'search' | 'chat') => void; + // Close the curtain (raise it back to `closed`). Keeps `activeForm` + // set until the snap transition lands so the form stays mounted + // during the slide-up. + close: () => void; + // Commit a snap stop directly. Used by the touch gesture on release. + // Also resets `liveDragPx` and `isDragging` in one batched update. + // Narrowed to the two non-form destinations the gesture hooks ever + // reach — peek-reveal and close. Form snaps are entered through + // `open()` which sets `activeForm` synchronously alongside the snap. + commit: (next: 'peek' | 'closed') => void; + // Setter for the live drag delta — called from the touch gesture on + // every touchmove. Updates are batched by React inside event handlers. + setLiveDrag: (px: number, dragging: boolean) => void; + // Notify the hook that the curtain reached `closed` so any lingering + // form can be unmounted (called from the curtain element's + // `onTransitionEnd`). + acknowledgeClosed: () => void; +}; + +// Resting `top` (px) of the curtain for a given snap stop and the +// currently measured form height (null falls back to the base). +// Snap-top math mirrors the DOM layout of the chip stack so the +// curtain's resting edge always lands on the boundary of the next +// row (no «next chip pill peeking through» bug). Chip rows are 56px +// each; between them is `CHIP_GAP_PX` (chip-to-chip, tighter); after +// the last revealed chip is `CURTAIN_BREATHER_PX` (chip-to-curtain, +// wider). Forms use just the breather. +export function snapTopPx(snap: CurtainSnap, formH: number | null): number { + switch (snap) { + case 'closed': + return TABS_ROW_PX; + case 'peek': + return TABS_ROW_PX + CHIP_ROW_PX + CHIP_GAP_PX + CHIP_ROW_PX + CURTAIN_BREATHER_PX; + case 'form-search': + case 'form-chat': + return TABS_ROW_PX + (formH ?? SEARCH_FORM_BASE_PX) + CURTAIN_BREATHER_PX; + default: + return TABS_ROW_PX; + } +} + +export function useCurtainState(pinKey: string): CurtainState { + const [snap, setSnap] = useState('closed'); + const [activeForm, setActiveForm] = useState(null); + const [formHeightPx, setFormHeightPx] = useState(null); + const [liveDragPx, setLiveDragPx] = useState(0); + const [isDragging, setIsDragging] = useState(false); + // Per-tab pin lives in `curtainPinnedByTabAtom` so the lock survives + // the route-driven listing-pane unmount that happens when the user + // taps into a Room and back. The atom outlives any individual + // StreamHeader instance. + const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom); + const pinned = !!pinnedMap[pinKey]; + + const formMeasureRef = useRef(null); + + const setPinned = useCallback( + (next: boolean) => { + setPinnedMap((prev) => { + // Compare-and-skip so we don't allocate a fresh object (and + // re-render every other subscriber of the atom) when nothing + // actually changes. + if (!!prev[pinKey] === next) return prev; + return { ...prev, [pinKey]: next }; + }); + // Drop any in-flight live drag on commit so the curtain renders + // at the new pinned-derived top without a residual finger offset. + setLiveDragPx(0); + setIsDragging(false); + }, + [pinKey, setPinnedMap] + ); + + const open = useCallback( + (form: 'search' | 'chat') => { + setActiveForm(form); + setSnap(form === 'search' ? 'form-search' : 'form-chat'); + setLiveDragPx(0); + setIsDragging(false); + // Safety net: clear pin so the form is visible. In practice the + // visible openers (static pager header icons, in-pane chips on + // non-pager surfaces) are all covered by the curtain when pinned, + // so the user can't trigger this directly — but a future + // programmatic open() would otherwise mount the form behind the + // still-pinned curtain at curtainTop=0 and present an invisible + // form. + setPinned(false); + }, + [setPinned] + ); + + const close = useCallback(() => { + setSnap('closed'); + setLiveDragPx(0); + setIsDragging(false); + }, []); + + const commit = useCallback((next: 'peek' | 'closed') => { + setSnap(next); + setLiveDragPx(0); + setIsDragging(false); + // `activeForm` is intentionally NOT cleared here — it stays set + // so the closing transition has form content beneath the curtain + // as it slides up. `acknowledgeClosed` clears it once the snap + // settles at `closed`. + }, []); + + const setLiveDrag = useCallback((px: number, dragging: boolean) => { + setLiveDragPx(px); + setIsDragging(dragging); + }, []); + + const acknowledgeClosed = useCallback(() => { + if (snap === 'closed') setActiveForm(null); + }, [snap]); + + // Measure the form's outer height while it's mounted. We do NOT + // overwrite the measured height to null on unmount — keeping the + // last-known height stable lets the close transition target the + // same `top` value as the open transition (no jump at SNAP_MS-end). + useLayoutEffect(() => { + if (!activeForm) return undefined; + const el = formMeasureRef.current; + if (!el) return undefined; + const measure = () => setFormHeightPx(el.offsetHeight); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, [activeForm]); + + // Safety-net for missed `transitionend` (route unmount mid-anim, + // browser quirks). Once snap settles at `closed`, force-drop the + // form after a generous window past the snap duration. + const timerRef = useRef(null); + useEffect(() => { + if (snap !== 'closed') { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + return undefined; + } + timerRef.current = window.setTimeout(() => { + setActiveForm(null); + }, CURTAIN_SNAP_MS + 200); + return () => { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [snap]); + + return useMemo( + () => ({ + snap, + pinned, + setPinned, + activeForm, + liveDragPx, + isDragging, + formHeightPx, + formMeasureRef, + open, + close, + commit, + setLiveDrag, + acknowledgeClosed, + }), + [ + snap, + pinned, + setPinned, + activeForm, + liveDragPx, + isDragging, + formHeightPx, + open, + close, + commit, + setLiveDrag, + acknowledgeClosed, + ] + ); +} diff --git a/src/app/components/uia-stages/ReCaptchaStage.tsx b/src/app/components/uia-stages/ReCaptchaStage.tsx index 68b3fcf4..78dcc98f 100644 --- a/src/app/components/uia-stages/ReCaptchaStage.tsx +++ b/src/app/components/uia-stages/ReCaptchaStage.tsx @@ -1,9 +1,13 @@ -import React from 'react'; +import React, { Suspense } from 'react'; import { Dialog, Text, Box, Button, config } from 'folds'; import { AuthType } from 'matrix-js-sdk'; -import ReCAPTCHA from 'react-google-recaptcha'; import { StageComponentProps } from './types'; +// react-google-recaptcha (+ its grecaptcha loader) is only ever rendered in +// the registration UIA captcha stage — a cold, rare path. Lazy-loading it +// keeps it out of the boot bundle. +const ReCAPTCHA = React.lazy(() => import('react-google-recaptcha')); + function ReCaptchaErrorDialog({ title, message, @@ -57,7 +61,9 @@ export function ReCaptchaStageDialog({ stageData, submitAuthDict, onCancel }: St Please check the box below to proceed. - + Loading…}> + + ); diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index ee8967af..bc53c71f 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -54,7 +54,7 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>( alt={prev['og:title']} title={prev['og:title']} tabIndex={0} - onKeyDown={(evt) => onEnterOrSpace(() => setViewer(true))(evt)} + onKeyDown={(evt: React.KeyboardEvent) => onEnterOrSpace(() => setViewer(true))(evt)} onClick={() => setViewer(true)} /> )} diff --git a/src/app/components/user-avatar/UserAvatar.css.ts b/src/app/components/user-avatar/UserAvatar.css.ts index 34283d73..f93983cb 100644 --- a/src/app/components/user-avatar/UserAvatar.css.ts +++ b/src/app/components/user-avatar/UserAvatar.css.ts @@ -5,6 +5,12 @@ export const UserAvatar = style({ backgroundColor: color.Secondary.Container, color: color.Secondary.OnContainer, textTransform: 'capitalize', + // Centre-crop to fill the (always-circular) avatar box, so a `scale` + // thumbnail (aspect-preserved — e.g. the 320 scale the profile hero requests + // because Synapse caps `crop` thumbnails at 96px) covers the circle exactly + // like a server-side crop would. No-op for the square crop thumbnails used + // by the small avatars elsewhere. + objectFit: 'cover', selectors: { '&[data-image-loaded="true"]': { diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 899ecd1e..12b6c700 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -49,10 +49,7 @@ const shadeHex = (hex: string, amt: number): string => { // `Intl.RelativeTimeFormat` here because it follows the *browser* // locale, not the i18next-selected language; mixing those gives us // English «5 minutes ago» under a Russian UI. -const formatLastSeen = ( - ts: number, - t: ReturnType['t'] -): string => { +const formatLastSeen = (ts: number, t: ReturnType['t']): string => { const now = Date.now(); const diffMs = Math.max(0, now - ts); const mins = Math.floor(diffMs / 60_000); @@ -71,7 +68,13 @@ const formatLastSeen = ( type UserHeroProps = { userId: string; displayName?: string; + // Small (96px) cropped thumbnail for the collapsed hero — fast to fetch. avatarUrl?: string; + // Full-resolution avatar used ONLY in the expanded (~340px) state below, so + // the blown-up tile stays sharp instead of upscaling the 96px thumbnail. + // Mirrors RoomViewProfilePanel's full-res mobile fullview. Falls back to + // `avatarUrl` when absent. + avatarUrlExpanded?: string; presence?: UserPresence; encrypted?: boolean; onAvatarClick?: () => void; @@ -87,6 +90,7 @@ export function UserHero({ userId, displayName, avatarUrl, + avatarUrlExpanded, presence, encrypted, onAvatarClick, @@ -137,7 +141,7 @@ export function UserHero({ > ( @@ -159,10 +163,9 @@ export function UserHero({ css.HeroAvatarButton, avatarExpanded && css.HeroAvatarButtonExpanded )} - aria-label={t( - avatarExpanded ? 'Room.collapse_avatar' : 'Room.expand_avatar', - { defaultValue: avatarExpanded ? 'Close avatar' : 'Open avatar' } - )} + aria-label={t(avatarExpanded ? 'Room.collapse_avatar' : 'Room.expand_avatar', { + defaultValue: avatarExpanded ? 'Close avatar' : 'Open avatar', + })} > {avatarNode} diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index 02a95ca0..ff301764 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -53,11 +53,7 @@ type UserRoomProfileProps = { avatarExpanded?: boolean; }; -export function UserRoomProfile({ - userId, - onAvatarClick, - avatarExpanded, -}: UserRoomProfileProps) { +export function UserRoomProfile({ userId, onAvatarClick, avatarExpanded }: UserRoomProfileProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const navigate = useNavigate(); @@ -86,7 +82,23 @@ export function UserRoomProfile({ const displayName = getMemberDisplayName(room, userId); const avatarMxc = getMemberAvatarMxc(room, userId); - const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; + // The collapsed HeroAvatar renders at 96px CSS (user-profile/styles.css.ts). + // A `crop` thumbnail can't fix retina sharpness here: Synapse only + // pre-generates 32px and 96px CROP thumbnails by default, so any `crop` + // request > 96 just returns the 96px one — upscaled and pixelated on a 2–3× + // display. `scale` thumbnails go up to 320/640/800, so we request a 320 scale: + // sharp on retina yet ~10× smaller than the full-resolution original (which + // the SDK returns when no dimensions are passed, and which made the hero + // visibly progressive-load on open). `UserAvatar` forces `object-fit: cover`, + // so a non-square scale thumbnail still fills the circle. The full-res + // original is reserved for the expanded tile + tap-to-zoom fullview below. + const avatarUrl = + (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication, 320, 320, 'scale')) ?? undefined; + // Full-resolution original for the desktop side-pane expanded (~340px) tile — + // a 96px thumbnail would upscale and pixelate when blown up. Only fetched + // when the avatar is expanded (UserHero swaps to this src then). + const avatarUrlExpanded = + (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; // Pass the raw SDK presence through. `UserHero` already guards on // `lastActiveTs > 0` before formatting the last-seen line, so the @@ -144,6 +156,7 @@ export function UserRoomProfile({ userId={userId} displayName={displayName} avatarUrl={avatarUrl} + avatarUrlExpanded={avatarUrlExpanded} presence={presence} encrypted={encrypted} onAvatarClick={onAvatarClick} diff --git a/src/app/features/add-existing/AddExisting.tsx b/src/app/features/add-existing/AddExisting.tsx index d100096b..6b4aecc2 100644 --- a/src/app/features/add-existing/AddExisting.tsx +++ b/src/app/features/add-existing/AddExisting.tsx @@ -30,6 +30,7 @@ import React, { import { useAtomValue } from 'jotai'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Room } from 'matrix-js-sdk'; +import type { StateEvents } from 'matrix-js-sdk'; import { stopPropagation } from '../../utils/keyboard'; import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -136,7 +137,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM await mx.sendStateEvent( parentId, - StateEvent.SpaceChild as any, + StateEvent.SpaceChild as keyof StateEvents, { auto_join: false, suggested: false, @@ -164,7 +165,9 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM }; const handleApplyChanges = () => { - const selectedRooms = selected.map((rId) => getRoom(rId)).filter((room) => room !== undefined); + const selectedRooms = selected + .map((rId) => getRoom(rId)) + .filter((room): room is Room => room !== undefined); applyChanges(selectedRooms).then(() => { if (alive()) { setSelected([]); diff --git a/src/app/features/bots/BotShell.css.ts b/src/app/features/bots/BotShell.css.ts index 723cc9cf..dfdeb291 100644 --- a/src/app/features/bots/BotShell.css.ts +++ b/src/app/features/bots/BotShell.css.ts @@ -21,6 +21,20 @@ export const Shell = style([ flexDirection: 'column', backgroundColor: color.SurfaceVariant.Container, overflow: 'hidden', + // Native safe-top: `#root` no longer reserves the status-bar inset + // (src/index.css), so BotShell extends to the screen top. The + // padding keeps the Hero (avatar + title + actions) clear of the + // system icons. Shell bg already matches the widget body tone, so + // the padding zone reads as a continuation of the bot surface. On + // web `--vojo-safe-top` is 0. + // + // Bottom inset is intentionally NOT added here: the iframe inside + // `Frame` paints its own body bg (#181a20, see widget-telegram + // styles.css) and the widget is responsible for the gesture-pill + // clearance of its own action rows. Padding Shell here exposed a + // visible seam between the iframe area and the env-bottom-tall + // strip below it on Android. + paddingTop: 'var(--vojo-safe-top, 0px)', }, ]); diff --git a/src/app/features/bots/BotWidgetEmbed.ts b/src/app/features/bots/BotWidgetEmbed.ts index 7b3ed8c2..e59e3ae5 100644 --- a/src/app/features/bots/BotWidgetEmbed.ts +++ b/src/app/features/bots/BotWidgetEmbed.ts @@ -18,6 +18,7 @@ import { } from 'matrix-widget-api'; import { Theme } from '../../hooks/useTheme'; import { openExternalUrl } from '../../utils/capacitor'; +import { parseMatrixToRoom, type MatrixToRoom } from '../../plugins/matrix-to'; import type { BotPreset } from './catalog'; import { BotWidgetDriver, @@ -34,6 +35,14 @@ export type BotWidgetEmbedOptions = { language: string; onError: (error: Error) => void; onReady?: () => void; + // Optional generic «navigate cinny to a matrix.to room/alias» callback. + // Plumbed from `BotWidgetMount` where react-router's `useNavigate` is + // available. The embed validates the URL via `parseMatrixToRoom` BEFORE + // calling — handler receives an already-parsed `{roomIdOrAlias, viaServers}` + // and is free to assume the inputs are well-formed Matrix references. Not + // bot-aware: any widget that delivers a matrix.to URL via the side-channel + // (`open-matrix-to` action) reaches the same handler. + onOpenMatrixToRoom?: (target: MatrixToRoom) => void; }; const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`; @@ -214,22 +223,30 @@ export class BotWidgetEmbed { this.feedStateUpdate(ev); }; - // Side-channel postMessage handler for the widget's `openExternalUrl` - // call. Distinct from matrix-widget-api's `fromWidget` channel + // Side-channel postMessage handler for the widget's Vojo-extension + // actions. Distinct from matrix-widget-api's `fromWidget` channel // (`api: io.vojo.bot-widget` instead of `api: fromWidget`) so it // doesn't go through ClientWidgetApi at all — keeps the SDK ignorant // of our extension and avoids the «unknown action» reply path. // - // Why this exists: the host's global `setupExternalLinkHandler` - // (utils/capacitor.ts) intercepts `` clicks at - // the host document level and routes them via Capacitor's Browser - // plugin. But cross-origin iframes don't bubble click events into - // the parent document, so widget-side links are invisible to it — - // on Capacitor's Android WebView those clicks silently disappear. - // The widget posts this message; we validate the URL and forward - // to the same `openExternalUrl` helper the host uses elsewhere. + // Two actions today: // - // Security gates (defence in depth): + // * `open-external-url` — forwards an https:// URL to the host's + // `openExternalUrl` (utils/capacitor.ts), which routes through + // Capacitor's Browser plugin on native and `window.open` on web. + // Exists because cross-origin iframes don't bubble click events + // to the host document, so the global `setupExternalLinkHandler` + // never sees widget-side `` clicks — on + // Capacitor's Android WebView those would silently disappear. + // + // * `open-matrix-to` — generic «navigate cinny to a matrix.to room + // or alias». Validates the URL through the same `parseMatrixToRoom` + // cinny uses for in-app mention rendering, then hands the parsed + // `MatrixToRoom` to `options.onOpenMatrixToRoom` (composed by + // BotWidgetMount with `useNavigate` + `getChannelsSpacePath`). The + // widget never sees a route — it only knows matrix.to URLs. + // + // Security gates (defence in depth, apply to BOTH actions): // 1. `ev.origin` must equal the widget's pinned origin. WITHOUT this // check, a compromised widget bundle could `window.location.href // = 'https://attacker.example/'` — the browser keeps the same @@ -242,11 +259,17 @@ export class BotWidgetEmbed { // iframe of the SAME origin — e.g. an ad embed loaded into a // sibling frame on the same origin in a future deployment — // could otherwise pass the origin check). - // 3. Only https URLs are honoured. We tightened from http+https to - // https-only because no shipped widget content links over plain - // http; rejecting http closes a cleartext-redirect vector via - // Capacitor `Browser.open` on Android. - // 4. javascript:, data:, file:, etc. are implicitly rejected by (3). + // + // Per-action URL validation (NOT shared, but each branch enforces): + // * `open-external-url` — requires `https:` protocol, rejecting plain + // http, javascript:, data:, file:, etc. We tightened from http+https + // to https-only because no shipped widget content links over plain + // http; rejecting http closes a cleartext-redirect vector via + // Capacitor `Browser.open` on Android. + // * `open-matrix-to` — requires the URL to parse as a matrix.to room + // or alias via `parseMatrixToRoom`. Anything else (matrix.to user + // links, event links, arbitrary https URLs, javascript:/data:/file: + // pseudo-schemes) returns undefined and silently no-ops. private readonly onWidgetMessage = (ev: MessageEvent) => { if (ev.origin !== this.widgetOrigin) return; if (ev.source !== this.iframe.contentWindow) return; @@ -255,16 +278,38 @@ export class BotWidgetEmbed { | undefined; if (!msg || typeof msg !== 'object') return; if (msg.api !== 'io.vojo.bot-widget') return; - if (msg.action !== 'open-external-url') return; const url = msg.data?.url; if (typeof url !== 'string') return; - try { - const parsed = new URL(url); - if (parsed.protocol !== 'https:') return; - } catch { + + if (msg.action === 'open-external-url') { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:') return; + } catch { + return; + } + openExternalUrl(url).catch(() => { + /* fire-and-forget: log handled inside openExternalUrl */ + }); return; } - void openExternalUrl(url); + + if (msg.action === 'open-matrix-to') { + // Generic «navigate cinny to a matrix.to room/alias». Not bot-aware — + // the widget hands over a matrix.to URL it obtained however (parsed + // from a bridge sentinel, scraped from chat, whatever), and we + // validate via the same `parseMatrixToRoom` cinny uses for in-app + // mention rendering (plugins/react-custom-html-parser.tsx). Only the + // matrix.to/#/!roomId and matrix.to/#/#alias shapes pass — user + // links, event links, non-matrix.to URLs, javascript:/data:/etc. all + // return undefined and silently no-op here. The host-side router + // hop (`onOpenMatrixToRoom`) is the optional caller — embedded code + // paths that don't provide a callback (e.g. future test harness) get + // a silent drop, not a crash. + const parsed = parseMatrixToRoom(url); + if (!parsed) return; + this.options.onOpenMatrixToRoom?.(parsed); + } }; public constructor(private readonly options: BotWidgetEmbedOptions) { diff --git a/src/app/features/bots/BotWidgetMount.tsx b/src/app/features/bots/BotWidgetMount.tsx index 78ff0fa9..fe2e50ef 100644 --- a/src/app/features/bots/BotWidgetMount.tsx +++ b/src/app/features/bots/BotWidgetMount.tsx @@ -1,8 +1,16 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Room, SyncState } from 'matrix-js-sdk'; import type { BotPreset } from './catalog'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useSyncState } from '../../hooks/useSyncState'; +import { + getCanonicalAliasOrRoomId, + getCanonicalAliasRoomId, + isRoomAlias, +} from '../../utils/matrix'; +import { getChannelsSpacePath } from '../../pages/pathUtils'; +import type { MatrixToRoom } from '../../plugins/matrix-to'; import { useBotWidgetEmbed } from './useBotWidgetEmbed'; import * as css from './BotWidgetMount.css'; @@ -34,15 +42,46 @@ type BotWidgetMountProps = { export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) { const containerRef = useRef(null); - const { ready } = useBotWidgetEmbed({ containerRef, preset, room, onError }); + const navigate = useNavigate(); + const mx = useMatrixClient(); + + // Generic «navigate cinny to a matrix.to room/alias». Bot-agnostic: any + // widget that posts `{action: 'open-matrix-to', data: {url}}` on the + // `io.vojo.bot-widget` side-channel reaches this. The embed has already + // validated the URL via `parseMatrixToRoom` so `target` is well-formed. + // For an alias we resolve to the canonical room id first — the channels + // path expects an id-or-alias either way, but joined-room lookup needs + // the id form for the via-server hint to be effective. `viaServers` are + // currently dropped (the channels view doesn't propagate them); add a + // dedicated «join-via» path if a future widget needs to surface a room + // the user hasn't joined yet. + const handleOpenMatrixToRoom = useCallback( + (target: MatrixToRoom) => { + const { roomIdOrAlias } = target; + const idOrAlias = isRoomAlias(roomIdOrAlias) + ? getCanonicalAliasRoomId(mx, roomIdOrAlias) ?? roomIdOrAlias + : roomIdOrAlias; + const canonical = getCanonicalAliasOrRoomId(mx, idOrAlias); + navigate(getChannelsSpacePath(canonical)); + }, + [mx, navigate] + ); + + const { ready } = useBotWidgetEmbed({ + containerRef, + preset, + room, + onError, + onOpenMatrixToRoom: handleOpenMatrixToRoom, + }); // Track Matrix sync state so the bot loading bar yields to the global // SyncIndicator when the connection is unhealthy. Without this, on a // dropped network the user would see TWO sweeping bars at once — the // bot bar at top stuck in «still loading» plus the SyncIndicator at // bottom in transient/error state. The bottom bar is the canonical - // connection-state surface; the top one defers. - const mx = useMatrixClient(); + // connection-state surface; the top one defers. Reuses `mx` from the + // navigate-callback block above — single hook call per render. const [syncState, setSyncState] = useState(() => mx.getSyncState()); useSyncState( mx, @@ -106,10 +145,7 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) { // SyncIndicator can take over without two bars overlapping. // Reduced-motion: animation is off (no iterations ever land), so // parking a static stripe for ~2s isn't graceful, just stuck. - if ( - hideReason === 'sync' || - window.matchMedia('(prefers-reduced-motion: reduce)').matches - ) { + if (hideReason === 'sync' || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { setVisible(false); setPendingHide(false); return undefined; diff --git a/src/app/features/bots/useBotWidgetEmbed.ts b/src/app/features/bots/useBotWidgetEmbed.ts index 0dba6b3d..e61ffb63 100644 --- a/src/app/features/bots/useBotWidgetEmbed.ts +++ b/src/app/features/bots/useBotWidgetEmbed.ts @@ -3,6 +3,7 @@ import { Room } from 'matrix-js-sdk'; import { useTranslation } from 'react-i18next'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { Theme, useTheme } from '../../hooks/useTheme'; +import type { MatrixToRoom } from '../../plugins/matrix-to'; import type { BotPreset } from './catalog'; import { BotWidgetEmbed } from './BotWidgetEmbed'; @@ -11,6 +12,9 @@ type UseBotWidgetEmbedOptions = { preset: BotPreset; room: Room; onError: () => void; + // Forwarded into the embed. Plumbed from `BotWidgetMount` where the + // react-router context is available — the hook stays unaware of routing. + onOpenMatrixToRoom?: (target: MatrixToRoom) => void; }; type UseBotWidgetEmbedResult = { @@ -30,6 +34,7 @@ export const useBotWidgetEmbed = ({ preset, room, onError, + onOpenMatrixToRoom, }: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => { const { i18n } = useTranslation(); const mx = useMatrixClient(); @@ -43,6 +48,12 @@ export const useBotWidgetEmbed = ({ themeRef.current = theme; const languageRef = useRef(i18n.language); languageRef.current = i18n.language; + // Same indirection for `onOpenMatrixToRoom`: the callback identity + // typically changes per render (closes over `navigate`/`mx`), and we do + // NOT want that to remount the embed. The ref carries the latest fn; the + // embed only sees a stable shim that re-reads it. + const onOpenMatrixToRoomRef = useRef(onOpenMatrixToRoom); + onOpenMatrixToRoomRef.current = onOpenMatrixToRoom; // Depend on primitive identity for the embed lifecycle — using `preset` // directly would remount the iframe (and re-handshake with the widget) @@ -72,6 +83,9 @@ export const useBotWidgetEmbed = ({ language: languageRef.current, onError, onReady: () => setReady(true), + // Indirection so the embed lifecycle doesn't reset when the + // navigate-callback closes over a new render's `mx`/`navigate`. + onOpenMatrixToRoom: (target) => onOpenMatrixToRoomRef.current?.(target), }); embedRef.current = embed; } catch (error) { diff --git a/src/app/features/call/CallMemberCard.tsx b/src/app/features/call/CallMemberCard.tsx index 46903f80..1c3680f5 100644 --- a/src/app/features/call/CallMemberCard.tsx +++ b/src/app/features/call/CallMemberCard.tsx @@ -47,7 +47,7 @@ export function CallMemberCard({ member }: CallMemberCardProps) { className={css.CallMemberCard} variant="SurfaceVariant" radii="500" - onClick={(evt: any) => + onClick={(evt: React.MouseEvent) => openUserProfile( room.roomId, undefined, diff --git a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx index 729690ec..d86224c6 100644 --- a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx +++ b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { MatrixError } from 'matrix-js-sdk'; +import type { StateEvents, TimelineEvents } from 'matrix-js-sdk'; import { Box, Chip, @@ -53,9 +54,18 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro useCallback( (evtType, evtStateKey, evtContent) => { if (typeof evtStateKey === 'string') { - return mx.sendStateEvent(room.roomId, evtType as any, evtContent, evtStateKey); + return mx.sendStateEvent( + room.roomId, + evtType as keyof StateEvents, + evtContent as StateEvents[keyof StateEvents], + evtStateKey + ); } - return mx.sendEvent(room.roomId, evtType as any, evtContent); + return mx.sendEvent( + room.roomId, + evtType as keyof TimelineEvents, + evtContent as TimelineEvents[keyof TimelineEvents] + ); }, [mx, room] ) diff --git a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx index 5ce9341c..5bdeb970 100644 --- a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx +++ b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx @@ -15,6 +15,7 @@ import { } from 'folds'; import { useTranslation } from 'react-i18next'; import { MatrixError } from 'matrix-js-sdk'; +import type { StateEvents } from 'matrix-js-sdk'; import { Page, PageHeader } from '../../../components/page'; import { SequenceCard } from '../../../components/sequence-card'; import { TextViewerContent } from '../../../components/text-viewer'; @@ -61,7 +62,13 @@ function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEdi const [submitState, submit] = useAsyncCallback( useCallback( - (c) => mx.sendStateEvent(room.roomId, type as any, c, stateKey), + (c) => + mx.sendStateEvent( + room.roomId, + type as keyof StateEvents, + c as StateEvents[keyof StateEvents], + stateKey + ), [mx, room, type, stateKey] ) ); diff --git a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx index 0979cc1a..e36d3484 100644 --- a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx +++ b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx @@ -18,6 +18,7 @@ import { } from 'folds'; import { useTranslation } from 'react-i18next'; import { MatrixError } from 'matrix-js-sdk'; +import type { StateEvents } from 'matrix-js-sdk'; import { SequenceCard } from '../../../components/sequence-card'; import { ImagePack, @@ -59,7 +60,12 @@ function CreatePackTile({ packs, roomId }: CreatePackTileProps) { display_name: name, }, }; - await mx.sendStateEvent(roomId, StateEvent.PoniesRoomEmotes as any, content, stateKey); + await mx.sendStateEvent( + roomId, + StateEvent.PoniesRoomEmotes as keyof StateEvents, + content as StateEvents[keyof StateEvents], + stateKey + ); }, [mx, roomId] ) @@ -164,7 +170,12 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) { for (let i = 0; i < removedPacks.length; i += 1) { const addr = removedPacks[i]; // eslint-disable-next-line no-await-in-loop - await mx.sendStateEvent(room.roomId, StateEvent.PoniesRoomEmotes as any, {}, addr.stateKey); + await mx.sendStateEvent( + room.roomId, + StateEvent.PoniesRoomEmotes as keyof StateEvents, + {} as StateEvents[keyof StateEvents], + addr.stateKey + ); } }, [mx, room, removedPacks]) ); diff --git a/src/app/features/common-settings/general/RoomAddress.tsx b/src/app/features/common-settings/general/RoomAddress.tsx index 275a6ff2..bcae00c3 100644 --- a/src/app/features/common-settings/general/RoomAddress.tsx +++ b/src/app/features/common-settings/general/RoomAddress.tsx @@ -65,6 +65,9 @@ export function RoomPublishedAddresses({ permissions }: RoomPublishedAddressesPr ` + // markup, no user-controlled input. Safe. + // eslint-disable-next-line react/no-danger } /> diff --git a/src/app/features/common-settings/general/RoomEncryption.tsx b/src/app/features/common-settings/general/RoomEncryption.tsx index 425a47d7..fde639b7 100644 --- a/src/app/features/common-settings/general/RoomEncryption.tsx +++ b/src/app/features/common-settings/general/RoomEncryption.tsx @@ -17,6 +17,7 @@ import { } from 'folds'; import React, { useCallback, useState } from 'react'; import { MatrixError } from 'matrix-js-sdk'; +import type { StateEvents } from 'matrix-js-sdk'; import FocusTrap from 'focus-trap-react'; import { useTranslation } from 'react-i18next'; import { SequenceCard } from '../../../components/sequence-card'; @@ -48,7 +49,7 @@ export function RoomEncryption({ permissions }: RoomEncryptionProps) { const [enableState, enable] = useAsyncCallback( useCallback(async () => { - await mx.sendStateEvent(room.roomId, StateEvent.RoomEncryption as any, { + await mx.sendStateEvent(room.roomId, StateEvent.RoomEncryption as keyof StateEvents, { algorithm: ROOM_ENC_ALGO, }); }, [mx, room.roomId]) diff --git a/src/app/features/common-settings/general/RoomHistoryVisibility.tsx b/src/app/features/common-settings/general/RoomHistoryVisibility.tsx index 4c9ccf9b..4af93e4e 100644 --- a/src/app/features/common-settings/general/RoomHistoryVisibility.tsx +++ b/src/app/features/common-settings/general/RoomHistoryVisibility.tsx @@ -13,6 +13,7 @@ import { Text, } from 'folds'; import { HistoryVisibility, MatrixError } from 'matrix-js-sdk'; +import type { StateEvents } from 'matrix-js-sdk'; import { RoomHistoryVisibilityEventContent } from 'matrix-js-sdk/lib/types'; import FocusTrap from 'focus-trap-react'; import { useTranslation } from 'react-i18next'; @@ -80,7 +81,11 @@ export function RoomHistoryVisibility({ permissions }: RoomHistoryVisibilityProp const content: RoomHistoryVisibilityEventContent = { history_visibility: visibility, }; - await mx.sendStateEvent(room.roomId, StateEvent.RoomHistoryVisibility as any, content); + await mx.sendStateEvent( + room.roomId, + StateEvent.RoomHistoryVisibility as keyof StateEvents, + content + ); }, [mx, room.roomId] ) diff --git a/src/app/features/common-settings/general/RoomJoinRules.tsx b/src/app/features/common-settings/general/RoomJoinRules.tsx index 842e7570..6279822e 100644 --- a/src/app/features/common-settings/general/RoomJoinRules.tsx +++ b/src/app/features/common-settings/general/RoomJoinRules.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useMemo } from 'react'; import { color, Text } from 'folds'; import { useTranslation } from 'react-i18next'; import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk'; +import type { StateEvents } from 'matrix-js-sdk'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { useAtomValue } from 'jotai'; import { @@ -88,7 +89,7 @@ export function RoomJoinRules({ permissions }: RoomJoinRulesProps) { const parents = getStateEvents(room, StateEvent.SpaceParent) .map((event) => event.getStateKey()) - .filter((parentId) => typeof parentId === 'string') + .filter((parentId): parentId is string => typeof parentId === 'string') .filter((parentId) => roomParents?.has(parentId)); if (parents.length === 0 && space && roomParents) { @@ -113,7 +114,7 @@ export function RoomJoinRules({ permissions }: RoomJoinRulesProps) { join_rule: joinRule as JoinRule, }; if (allow.length > 0) c.allow = allow; - await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c); + await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as keyof StateEvents, c); }, [mx, room, space, subspaces, roomIdToParents] ) diff --git a/src/app/features/common-settings/general/RoomProfile.tsx b/src/app/features/common-settings/general/RoomProfile.tsx index 5382218d..bade282a 100644 --- a/src/app/features/common-settings/general/RoomProfile.tsx +++ b/src/app/features/common-settings/general/RoomProfile.tsx @@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next'; import Linkify from 'linkify-react'; import classNames from 'classnames'; import { JoinRule, MatrixError } from 'matrix-js-sdk'; +import type { StateEvents } from 'matrix-js-sdk'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../../room-settings/styles.css'; import { useRoom } from '../../../hooks/useRoom'; @@ -94,15 +95,19 @@ export function RoomProfileEdit({ useCallback( async (roomAvatarMxc?: string | null, roomName?: string, roomTopic?: string) => { if (roomAvatarMxc !== undefined) { - await mx.sendStateEvent(room.roomId, StateEvent.RoomAvatar as any, { + await mx.sendStateEvent(room.roomId, StateEvent.RoomAvatar as keyof StateEvents, { url: roomAvatarMxc, }); } if (roomName !== undefined) { - await mx.sendStateEvent(room.roomId, StateEvent.RoomName as any, { name: roomName }); + await mx.sendStateEvent(room.roomId, StateEvent.RoomName as keyof StateEvents, { + name: roomName, + }); } if (roomTopic !== undefined) { - await mx.sendStateEvent(room.roomId, StateEvent.RoomTopic as any, { topic: roomTopic }); + await mx.sendStateEvent(room.roomId, StateEvent.RoomTopic as keyof StateEvents, { + topic: roomTopic, + }); } }, [mx, room.roomId] diff --git a/src/app/features/common-settings/permissions/PermissionGroups.tsx b/src/app/features/common-settings/permissions/PermissionGroups.tsx index daa3c41d..f173b595 100644 --- a/src/app/features/common-settings/permissions/PermissionGroups.tsx +++ b/src/app/features/common-settings/permissions/PermissionGroups.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Badge, Box, Button, Chip, config, Icon, Icons, Menu, Spinner, Text } from 'folds'; import { useTranslation } from 'react-i18next'; import produce from 'immer'; +import type { StateEvents } from 'matrix-js-sdk'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; @@ -87,7 +88,11 @@ export function PermissionGroups({ return draftPowerLevels; }); - await mx.sendStateEvent(room.roomId, StateEvent.RoomPowerLevels as any, editedPowerLevels); + await mx.sendStateEvent( + room.roomId, + StateEvent.RoomPowerLevels as keyof StateEvents, + editedPowerLevels + ); }, [mx, room, powerLevels, permissionUpdate, permissionGroups]) ); diff --git a/src/app/features/common-settings/permissions/PowersEditor.tsx b/src/app/features/common-settings/permissions/PowersEditor.tsx index b45da7d1..c3fc3c7b 100644 --- a/src/app/features/common-settings/permissions/PowersEditor.tsx +++ b/src/app/features/common-settings/permissions/PowersEditor.tsx @@ -1,4 +1,11 @@ -import React, { FormEventHandler, MouseEventHandler, useCallback, useMemo, useState } from 'react'; +import React, { + FormEventHandler, + MouseEventHandler, + Suspense, + useCallback, + useMemo, + useState, +} from 'react'; import { Box, Text, @@ -21,6 +28,7 @@ import { import { HexColorPicker } from 'react-colorful'; import { useTranslation } from 'react-i18next'; import { useAtomValue } from 'jotai'; +import type { StateEvents } from 'matrix-js-sdk'; import { Page, PageContent, PageHeader } from '../../../components/page'; import { IPowerLevels } from '../../../hooks/usePowerLevels'; import { SequenceCard } from '../../../components/sequence-card'; @@ -36,7 +44,6 @@ import { useRoom } from '../../../hooks/useRoom'; import { HexColorPickerPopOut } from '../../../components/HexColorPickerPopOut'; import { PowerColorBadge, PowerIcon } from '../../../components/power'; import { UseStateProvider } from '../../../components/UseStateProvider'; -import { EmojiBoard } from '../../../components/emoji-board'; import { useImagePackRooms } from '../../../hooks/useImagePackRooms'; import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; @@ -51,6 +58,17 @@ import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge'; import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag'; import { creatorsSupported } from '../../../utils/matrix'; +// Lazy-loaded: EmojiBoard pulls the heavy emoji picker dataset (`emoji-data`, +// ~506 KB compact.json). PowersEditor is reachable from the boot graph +// (RoomSettingsRenderer / SpaceSettingsRenderer are mounted at the app root), +// so a static import here would drag `emoji-data` into the initial bundle. The +// board only mounts inside the PopOut below when the user opens the icon +// picker, so deferring it keeps `emoji-data` off boot (the chat composer's own +// static EmojiBoard import keeps the chunk in the lazy Room bundle). +const EmojiBoard = React.lazy(() => + import('../../../components/emoji-board').then((m) => ({ default: m.EmojiBoard })) +); + type EditPowerProps = { maxPower: number; power?: number; @@ -207,23 +225,25 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) { position="Bottom" anchor={cords} content={ - { - setTagIcon({ key }); - setCords(undefined); - }} - onCustomEmojiSelect={(mxc) => { - setTagIcon({ key: mxc }); - setCords(undefined); - }} - requestClose={() => { - setCords(undefined); - }} - /> + + { + setTagIcon({ key }); + setCords(undefined); + }} + onCustomEmojiSelect={(mxc) => { + setTagIcon({ key: mxc }); + setCords(undefined); + }} + requestClose={() => { + setCords(undefined); + }} + /> + } > + + + )} +
+
+ ); +} diff --git a/src/app/features/room/MembersDrawer.css.ts b/src/app/features/room/MembersDrawer.css.ts index 860ceda0..e59ef7b9 100644 --- a/src/app/features/room/MembersDrawer.css.ts +++ b/src/app/features/room/MembersDrawer.css.ts @@ -1,8 +1,18 @@ import { keyframes, style } from '@vanilla-extract/css'; import { config, toRem } from 'folds'; +import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe'; +// Left edge carves TL + BL the same way `RoomViewProfileSidePanel` does +// across the 12px horseshoe void gap rendered by Room.tsx — same design +// language as the page-nav <-> chat split. `overflow: hidden` keeps the +// rounded corners clean against header / scroll content; the void +// colour beneath is painted by the parent flex row, not by the panel +// itself. export const MembersDrawer = style({ width: toRem(266), + overflow: 'hidden', + borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX), + borderBottomLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX), }); export const MembersDrawerHeader = style({ diff --git a/src/app/features/room/MobileMediaViewerHorseshoe.css.ts b/src/app/features/room/MobileMediaViewerHorseshoe.css.ts new file mode 100644 index 00000000..cc554a48 --- /dev/null +++ b/src/app/features/room/MobileMediaViewerHorseshoe.css.ts @@ -0,0 +1,109 @@ +import { style } from '@vanilla-extract/css'; +import { color, toRem } from 'folds'; +import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe'; + +// Re-exported so the TSX can pick up the constants without crossing +// the vanilla-extract / runtime boundary twice. +export const HORSESHOE_RADIUS_PX = VOJO_HORSESHOE_RADIUS_PX; +export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX; + +// Outer container — anchor for the two absolutely-positioned panes +// (`appBody` and `silhouette`). Same shape as the settings-sheet +// container, see that file for the full rationale. +export const container = style({ + position: 'relative', + display: 'flex', + flex: 1, + flexDirection: 'column', + minWidth: 0, + minHeight: 0, + overflow: 'hidden', +}); + +// Holds the wrapped chat column. Stays put — clip-path carves the +// bottom edge so virtualized timeline rows aren't re-measured +// mid-gesture. `backgroundColor` opaque so the void colour painted +// on the container doesn't bleed through any transparent slivers +// in the wrapped tree (e.g. between bubble rows). +export const appBody = style({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + display: 'flex', + flexDirection: 'column', + minWidth: 0, + minHeight: 0, + backgroundColor: color.Background.Container, + willChange: 'clip-path', +}); + +// The viewer sheet's surface. `Background.Container` (deepest Dawn +// tone) so the image floats on a near-black backdrop — closest to +// the legacy `` viewer's full-screen dark scrim. +export const silhouette = style({ + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + backgroundColor: color.Background.Container, + willChange: 'height, border-top-left-radius, border-top-right-radius', +}); + +// Top-anchored — handle + viewer body reveal top-down as silhouette +// grows. Android nav-bar clearance is owned by `MainActivity.java`'s +// WindowInsets listener (the WebView is already sized above the nav +// bar), so no `padding-bottom: env(...)` here — that would stack on +// top of the native padding and lift the download / share row above +// its visible host. +export const panelContent = style({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', +}); + +// 20px drag-to-close band. The ONLY drag-to-close origin — touches +// on the viewer body below this strip drive zoom / pan / swipe and +// MUST NOT initiate a close gesture. `touchAction: none` blocks +// browser-native scroll on this strip. +export const panelHandle = style({ + flexShrink: 0, + height: toRem(20), + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'grab', + touchAction: 'none', + userSelect: 'none', + selectors: { + '&:active': { cursor: 'grabbing' }, + }, +}); + +// Darker than `Background.Container` would make the grabber +// disappear — use `SurfaceVariant.Container` so the bar reads +// against the dark viewer bg. Mirror of the settings handle's +// step-up against `SurfaceVariant.Container` (where it uses the +// deeper `Background.Container`). +export const panelHandleBar = style({ + width: toRem(36), + height: toRem(4), + borderRadius: toRem(4), + backgroundColor: color.SurfaceVariant.Container, +}); + +export const panelBody = style({ + flex: 1, + display: 'flex', + flexDirection: 'column', + minHeight: 0, + minWidth: 0, +}); diff --git a/src/app/features/room/MobileMediaViewerHorseshoe.tsx b/src/app/features/room/MobileMediaViewerHorseshoe.tsx new file mode 100644 index 00000000..a54fe5b9 --- /dev/null +++ b/src/app/features/room/MobileMediaViewerHorseshoe.tsx @@ -0,0 +1,419 @@ +// Bottom-up «horseshoe» sheet that wraps the mobile chat column for +// the media viewer. Sister of `MobileSettingsHorseshoe` (which wraps +// the DM list with the same idiom for the Settings tree) — same +// clip-path carve, same VAUL easing, same Strict-Mode-safe entry +// animation, same `keepMounted` delayed unmount. +// +// Differences from the settings sheet: +// • Tap-to-open only. No drag-up origin — opening is driven from +// `useOpenMediaViewer` called by `ImageContent.onClick`. The +// 20px handle band is the ONLY drag-sensitive area; touches on +// the viewer body below run zoom / pan / horizontal swipe +// instead. +// • `RAIL_FRACTION = 3 / 4` (75% of viewport) vs settings's 2/3. +// • Silhouette bg = `Background.Container` (deepest Dawn tone) for +// a dark image backdrop, vs settings's `SurfaceVariant.Container`. +// • Wrapped content is the chat column, not the DM list. +// • No `--vojo-safe-top` reset — there's no PageNav inside the +// viewer body that would inherit it; the viewer body has its own +// padding policy. + +import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useAtomValue } from 'jotai'; +import { useTranslation } from 'react-i18next'; +import { mediaViewerAtom } from '../../state/mediaViewer'; +import { useCloseMediaViewer } from '../../state/hooks/mediaViewer'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe'; +import { MediaViewerBody } from './MediaViewerBody'; +import * as css from './MobileMediaViewerHorseshoe.css'; + +const VAUL_EASING = 'cubic-bezier(0.32, 0.72, 0, 1)'; +const ANIMATION_MS = 250; +// Drag distance past which release commits the close. Matches the +// settings sheet's 80px (the only commit gesture there too). +const COMMIT_THRESHOLD_PX = 80; +// Sheet height as a fraction of the viewport — product spec. +// Capped at runtime by the wrapper container's actual height +// (see `railHeightPx` calc below) so it can't spill past the +// chat header when chrome outside the chat column (e.g. +// `HorseshoeContainer.bottomRail` during a call) shrinks the +// wrapper. +const RAIL_VIEWPORT_FRACTION = 0.8; +// Absolute floor for tiny / split-screen viewports — the sheet +// becomes useless below this. NB: floor cannot exceed the +// container's ceiling (`Math.min` is applied last), so in extreme +// squeeze cases (split-screen, oversized call rail) the sheet +// silently shrinks below the floor rather than clipping the chat +// header. +const RAIL_MIN_PX = 200; +// Headroom reserved at the TOP of the container for the chat +// header (`PageHeader` = folds `Header size="600"`, ~64px) plus +// the 12px horseshoe void gap above the silhouette's rounded +// TL/TR carves. With this in place the sheet's top edge stops +// just below the chat header even when the wrapper container +// shrinks (e.g. `HorseshoeContainer.bottomRail` is on during a +// call). Previously the headroom was 8px which let the sheet +// climb over the header in tight viewports. +const CHAT_HEADER_RESERVED_PX = 76; +// Same emerge window as the settings sheet so the silhouette's +// rounding + void gap finish exactly when the gesture qualifies to +// commit. +const HORSESHOE_EMERGE_PX = 80; + +// Symmetric cubic in-out — slow start, fast middle, slow finish. +// Same curve the settings / profile horseshoes use for their emerge +// (rationale at the top of `MobileSettingsHorseshoe.tsx`). +const easeInOutCubic = (t: number): number => + t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2; + +type DragState = { + inputType: 'touch' | 'pointer'; + startY: number; + deltaY: number; +}; + +type MobileMediaViewerHorseshoeProps = { + children: ReactNode; +}; + +function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoeProps) { + const { t } = useTranslation(); + const entry = useAtomValue(mediaViewerAtom); + const closeSheet = useCloseMediaViewer(); + + const [drag, setDrag] = useState(null); + const [viewportHeight, setViewportHeight] = useState(() => + typeof window === 'undefined' ? 800 : window.innerHeight + ); + + useEffect(() => { + const onResize = () => setViewportHeight(window.innerHeight); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + // Measure the wrapper container directly via `ResizeObserver` so + // the rail height tracks whatever vertical space is actually + // available to the chat column. The viewport-based fraction is + // the *intent* (90% of the screen — product spec), but + // `HorseshoeContainer.bottomRail` (the call rail) sits OUTSIDE + // the chat column inside the same viewport and shrinks our + // wrapper when active. Without the container clamp, 90% of the + // viewport could exceed the wrapper's height, and the sheet + // would render up to / past the chat header which sits inside + // the wrapper's appBody. + const containerRef = useRef(null); + const [containerHeight, setContainerHeight] = useState(() => + typeof window === 'undefined' ? 800 : window.innerHeight + ); + + useLayoutEffect(() => { + const el = containerRef.current; + if (!el) return undefined; + setContainerHeight(el.clientHeight); + const ro = new ResizeObserver((entries) => { + const cr = entries[0]?.contentRect; + if (cr) setContainerHeight(cr.height); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Final rail = viewport × fraction, then floor-then-ceiling + // clamped. Container (minus chat-header reserve) is the HARD + // ceiling: when the wrapper shrinks (call rail / split-screen) + // the sheet shrinks below `RAIL_MIN_PX` rather than spilling + // over the chat header. Order matters — `Math.min` last makes + // container win against floor. + const idealRailPx = Math.round(viewportHeight * RAIL_VIEWPORT_FRACTION); + const railHeightPx = Math.min( + Math.max(0, containerHeight - CHAT_HEADER_RESERVED_PX), + Math.max(RAIL_MIN_PX, idealRailPx) + ); + const open = !!entry; + + // Entry-animation gate — see `MobileSettingsHorseshoe` for the + // full rationale. `hasEnteredRef` mirror guards the atom-clearing + // unmount cleanup against React 18 strict-mode dev rehearsal. + const [hasEntered, setHasEntered] = useState(false); + const hasEnteredRef = useRef(false); + useLayoutEffect(() => { + const id = requestAnimationFrame(() => { + hasEnteredRef.current = true; + setHasEntered(true); + }); + return () => cancelAnimationFrame(id); + }, []); + + // Keep the viewer body mounted through the 250ms exit slide so + // the user sees the image slide down with the sheet instead of + // an empty panel collapsing. + const [keepMounted, setKeepMounted] = useState(open); + useEffect(() => { + if (open) { + setKeepMounted(true); + return undefined; + } + const id = window.setTimeout(() => setKeepMounted(false), ANIMATION_MS); + return () => window.clearTimeout(id); + }, [open]); + + // Persist the last opened entry through the exit animation so the + // body keeps rendering the image as it slides down. + const lastEntryRef = useRef(entry); + if (entry) lastEntryRef.current = entry; + + const baseExpanded = open && hasEntered ? railHeightPx : 0; + // Drag is close-only (positive deltaY); clamp negative deltas to + // 0 so an upward reversal cancels rather than expanding past the + // open height. + const expandedPx = drag + ? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY)) + : baseExpanded; + const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0; + const isDragging = drag !== null; + const horseshoeActive = expandedPx > 0; + + const handleRef = useRef(null); + + const dragRef = useRef(null); + dragRef.current = drag; + const entryRef = useRef(entry); + entryRef.current = entry; + const closeSheetRef = useRef(closeSheet); + closeSheetRef.current = closeSheet; + + // Clear the atom if the wrapper unmounts while the sheet is open + // (route change). Strict-mode rehearsal guard via `hasEnteredRef` + // — without it, the cleanup fires before the entry rAF and a + // deep-link cold-start would clear the atom mid-rehearsal. + useEffect( + () => () => { + if (!hasEnteredRef.current) return; + if (entryRef.current) closeSheetRef.current(); + }, + [] + ); + + // Hardware Escape (also dispatched by the global Android-back + // handler via the portal-marker pattern below). Skip inputs / + // contenteditable so typing inside a future caption field doesn't + // dismiss the sheet. + useEffect(() => { + if (!open) return undefined; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + const target = e.target as HTMLElement | null; + if ( + target && + (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable) + ) { + return; + } + closeSheetRef.current(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [open]); + + // Drag-to-close only — handle band at the top of the silhouette. + // No document-level open-drag listener; opening is tap-only via + // `useOpenMediaViewer`. + // + // CLAMP, not early-return — see `MobileSettingsHorseshoe` for + // the bug-fix rationale. Reversing the gesture must drag deltaY + // back toward 0, not leave the stale value. + useEffect(() => { + const handleEl = handleRef.current; + if (!handleEl) return undefined; + + const applyMove = (clientY: number, e: TouchEvent | PointerEvent) => { + const d = dragRef.current; + if (!d) return; + const rawDelta = clientY - d.startY; + const nextDelta = Math.max(0, rawDelta); + if (e.cancelable) e.preventDefault(); + setDrag({ ...d, deltaY: nextDelta }); + }; + + const applyEnd = () => { + const d = dragRef.current; + if (!d) return; + if (d.deltaY > COMMIT_THRESHOLD_PX) { + closeSheetRef.current(); + } + setDrag(null); + }; + + const onHandleTouchStart = (e: TouchEvent) => { + if (dragRef.current) return; + if (!entryRef.current) return; + const touch = e.touches[0]; + setDrag({ inputType: 'touch', startY: touch.clientY, deltaY: 0 }); + }; + const onTouchMove = (e: TouchEvent) => { + const d = dragRef.current; + if (!d || d.inputType !== 'touch') return; + applyMove(e.touches[0].clientY, e); + }; + const onTouchEnd = () => { + const d = dragRef.current; + if (!d || d.inputType !== 'touch') return; + applyEnd(); + }; + + const onHandlePointerDown = (e: PointerEvent) => { + if (e.pointerType === 'touch') return; + if (dragRef.current) return; + if (!entryRef.current) return; + if (e.button !== 0) return; + setDrag({ inputType: 'pointer', startY: e.clientY, deltaY: 0 }); + }; + const onPointerMove = (e: PointerEvent) => { + if (e.pointerType === 'touch') return; + const d = dragRef.current; + if (!d || d.inputType !== 'pointer') return; + applyMove(e.clientY, e); + }; + const onPointerEnd = (e: PointerEvent) => { + if (e.pointerType === 'touch') return; + const d = dragRef.current; + if (!d || d.inputType !== 'pointer') return; + applyEnd(); + }; + + handleEl.addEventListener('touchstart', onHandleTouchStart, { passive: true }); + document.addEventListener('touchmove', onTouchMove, { passive: false }); + document.addEventListener('touchend', onTouchEnd, { passive: true }); + document.addEventListener('touchcancel', onTouchEnd, { passive: true }); + handleEl.addEventListener('pointerdown', onHandlePointerDown); + document.addEventListener('pointermove', onPointerMove, { passive: false }); + document.addEventListener('pointerup', onPointerEnd, { passive: true }); + document.addEventListener('pointercancel', onPointerEnd, { passive: true }); + + return () => { + handleEl.removeEventListener('touchstart', onHandleTouchStart); + document.removeEventListener('touchmove', onTouchMove); + document.removeEventListener('touchend', onTouchEnd); + document.removeEventListener('touchcancel', onTouchEnd); + handleEl.removeEventListener('pointerdown', onHandlePointerDown); + document.removeEventListener('pointermove', onPointerMove); + document.removeEventListener('pointerup', onPointerEnd); + document.removeEventListener('pointercancel', onPointerEnd); + }; + }, []); + + // Geometry — same emerge ramp as the settings / profile horseshoes + // (rationale at the top of `MobileSettingsHorseshoe`). + let horseshoeRamp: number; + if (isDragging) { + horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX)); + } else { + horseshoeRamp = expandedFraction > 0 ? 1 : 0; + } + const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX; + const appBodyRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX; + const appBodyGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX; + const appBodyMaskBottomPx = expandedPx + appBodyGapPx; + + const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px round 0px 0px ${appBodyRadiusPx}px ${appBodyRadiusPx}px)`; + + const silhouetteTransition = isDragging + ? 'none' + : `height ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`; + const appBodyTransition = isDragging ? 'none' : `clip-path ${ANIMATION_MS}ms ${VAUL_EASING}`; + + const containerStyle: React.CSSProperties = { + backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined, + }; + + const renderEntry = entry ?? lastEntryRef.current; + const renderBody = !!renderEntry && (keepMounted || isDragging); + + // Marker portal for the global Android-back handler — when + // `portalContainer.firstChild` matches our `data-vojo-…-active` + // attribute, the back-button dispatches an Escape keydown that + // the `keydown` effect above catches. + const portalTarget = + typeof document !== 'undefined' + ? document.getElementById('portalContainer') ?? document.body + : null; + + return ( +
+ {open && portalTarget + ? createPortal( +