Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
| ca34e026fb |
276 changed files with 3276 additions and 21868 deletions
|
|
@ -60,12 +60,10 @@ module.exports = {
|
|||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-shadow': 'error',
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||
},
|
||||
|
|
@ -88,11 +86,6 @@ 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',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
20
.gitignore
vendored
20
.gitignore
vendored
|
|
@ -4,24 +4,12 @@ node_modules
|
|||
devAssets
|
||||
config.local.json
|
||||
|
||||
electron/dist-electron
|
||||
release
|
||||
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode/*
|
||||
!.vscode/tasks.json
|
||||
.vscode
|
||||
.codex
|
||||
.claude
|
||||
docs/ai/desired_features.md
|
||||
docs/ai/bugs.md
|
||||
docs/plans
|
||||
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
|
||||
docs
|
||||
5
.husky/pre-commit
Executable file → Normal file
5
.husky/pre-commit
Executable file → Normal file
|
|
@ -1,2 +1,3 @@
|
|||
npx tsc -p tsconfig.json --noEmit
|
||||
npx lint-staged
|
||||
# These are commented until we enable lint and typecheck
|
||||
# npx tsc -p tsconfig.json --noEmit
|
||||
# npx lint-staged
|
||||
104
.vscode/tasks.json
vendored
104
.vscode/tasks.json
vendored
|
|
@ -1,104 +0,0 @@
|
|||
{
|
||||
"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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,29 +1,8 @@
|
|||
apply plugin: 'com.android.application'
|
||||
|
||||
// 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-<commits>-g<hash>`; 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
|
||||
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()
|
||||
|
||||
android {
|
||||
namespace = "chat.vojo.app"
|
||||
|
|
@ -33,7 +12,7 @@ android {
|
|||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode computedVersionCode
|
||||
versionName appVersion.name
|
||||
versionName packageJson.version
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
|
@ -41,6 +20,12 @@ 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
|
||||
|
|
@ -48,26 +33,6 @@ 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 {
|
||||
|
|
@ -87,11 +52,6 @@ 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"
|
||||
|
|
|
|||
24
android/app/proguard-rules.pro
vendored
24
android/app/proguard-rules.pro
vendored
|
|
@ -19,27 +19,3 @@
|
|||
# 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.** { *; }
|
||||
|
|
|
|||
|
|
@ -46,30 +46,6 @@
|
|||
android:pathPrefix="/u/" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- System share-sheet target. Three filters because Android's
|
||||
sheet UI dedupes by activity but resolves by MIME match:
|
||||
text/* gets its own filter so the Vojo icon shows up
|
||||
alongside WhatsApp/Telegram for «share link/selection»; */*
|
||||
covers single-file (image/video/audio/pdf/…) and
|
||||
SEND_MULTIPLE picks up gallery multi-select.
|
||||
Payload extraction lives in ShareTargetPlugin — MainActivity
|
||||
only routes the Intent to the plugin via onNewIntent. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
|
|
@ -109,18 +85,6 @@
|
|||
<receiver
|
||||
android:name=".CallDeclineReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".MarkAsReadReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".NotificationDismissReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".ReplyReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
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<String, Bitmap> CACHE =
|
||||
new LruCache<String, Bitmap>(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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,368 +0,0 @@
|
|||
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
|
||||
* → <homeserver>/_matrix/client/v1/media/thumbnail/<server>/<mediaId>
|
||||
* ?width=96&height=96&method=crop
|
||||
* + Authorization: Bearer <accessToken>
|
||||
*
|
||||
* 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<String, CountDownLatch> 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<String> 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<String> 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<CountDownLatch> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -121,14 +121,7 @@ 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.
|
||||
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);
|
||||
}
|
||||
VojoFirebaseMessagingService.upsertIncomingRing(data, messageId);
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,163 +0,0 @@
|
|||
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<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -63,8 +63,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,147 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
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<String> 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<String> 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<String> readSet(Context ctx) {
|
||||
SharedPreferences prefs = ctx.getSharedPreferences(
|
||||
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||
String raw = prefs.getString(VojoPollWorker.KEY_NOTIFIED_IDS, "");
|
||||
Set<String> 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<String> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,236 +0,0 @@
|
|||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -45,55 +45,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,248 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
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<String, Deque<Entry>> 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<Entry> append(String roomId, Entry entry) {
|
||||
if (roomId == null || roomId.isEmpty() || entry == null) {
|
||||
return java.util.Collections.emptyList();
|
||||
}
|
||||
final List<Entry> snapshot = new ArrayList<>();
|
||||
store.compute(roomId, (key, existing) -> {
|
||||
Deque<Entry> 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<String> 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<Entry> entries) {
|
||||
if (roomId == null || roomId.isEmpty() || entries == null || entries.isEmpty()) return;
|
||||
store.computeIfAbsent(roomId, key -> {
|
||||
Deque<Entry> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
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<Uri> 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<Uri> 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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,675 +0,0 @@
|
|||
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<String, String> 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<String, String> 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/<version>" 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<String,
|
||||
// String> 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<String, String> flattenNotification(
|
||||
JSONObject entry, Map<String, String> roomNames
|
||||
) {
|
||||
Map<String, String> 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<String, String> loadRoomNamesMap(SharedPreferences prefs) {
|
||||
Map<String, String> 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<String> 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<String, String> 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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
// 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',
|
||||
},
|
||||
};
|
||||
|
|
@ -95,18 +95,6 @@ const LinkIcon = () => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
// 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 = () => (
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<rect x="3.5" y="3.5" width="5.5" height="5.5" rx="1.4" />
|
||||
<rect x="11" y="3.5" width="5.5" height="5.5" rx="1.4" />
|
||||
<rect x="3.5" y="11" width="5.5" height="5.5" rx="1.4" />
|
||||
<rect x="11" y="11" width="5.5" height="5.5" rx="1.4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Linkifier — same heuristic as TG widget.
|
||||
const URL_RE = /https?:\/\/[^\s)]+/g;
|
||||
|
||||
|
|
@ -400,13 +388,6 @@ const loadHCaptcha = (): Promise<HCaptchaApi> => {
|
|||
`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) => {
|
||||
|
|
@ -618,7 +599,9 @@ const CaptchaPanel = ({ state, t, onSolved, onCancel, onExpired }: CaptchaPanelP
|
|||
<div class="auth-card-hint">{t('auth-card.captcha.hint')}</div>
|
||||
<div class="auth-card-captcha-frame">
|
||||
<div ref={containerRef} class="auth-card-captcha-host" />
|
||||
{loadError ? <div class="auth-card-error">{t('auth-card.captcha.load-error')}</div> : null}
|
||||
{loadError ? (
|
||||
<div class="auth-card-error">{t('auth-card.captcha.load-error')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div class="auth-card-row">
|
||||
<button type="button" class="btn-text" onClick={onCancel}>
|
||||
|
|
@ -781,40 +764,6 @@ const LogoutCard = ({ t, onConfirm }: LogoutCardProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Open-space card — Vojo extension
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
type OpenSpaceCardProps = {
|
||||
t: T;
|
||||
matrixToUrl: string;
|
||||
onOpen: (url: string) => void;
|
||||
};
|
||||
|
||||
// Surfaces the personal Discord space the bridge auto-created at login.
|
||||
// Renders only when `state.spaceMatrixToUrl` is populated — i.e. against a
|
||||
// Vojo-patched bridge that emitted `VOJO-LOGIN-SPACE-V1`. Against an
|
||||
// upstream/unpatched bridge the card is absent (no sentinel, no URL, the
|
||||
// `space_ready` reducer case never fires).
|
||||
//
|
||||
// Click hands the URL to the host via the `io.vojo.bot-widget`
|
||||
// side-channel (api.openMatrixToUrl) — the widget is sandboxed and
|
||||
// can't navigate cinny itself. Host validates and routes.
|
||||
const OpenSpaceCard = ({ t, matrixToUrl, onOpen }: OpenSpaceCardProps) => (
|
||||
<button class="command-card" type="button" onClick={() => onOpen(matrixToUrl)}>
|
||||
<span class="command-card-lead-icon" aria-hidden="true">
|
||||
<SpaceGridIcon />
|
||||
</span>
|
||||
<div class="command-card-body">
|
||||
<div class="command-card-name">{t('card.open-space.name')}</div>
|
||||
<div class="command-card-desc">{t('card.open-space.desc')}</div>
|
||||
</div>
|
||||
<span class="command-card-chevron" aria-hidden="true">
|
||||
›
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Main App
|
||||
// --------------------------------------------------------------------------
|
||||
|
|
@ -978,15 +927,6 @@ 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
|
||||
|
|
@ -1049,7 +989,10 @@ 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') {
|
||||
|
|
@ -1058,12 +1001,6 @@ 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)}` });
|
||||
|
|
@ -1248,7 +1185,9 @@ 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');
|
||||
|
|
@ -1414,17 +1353,6 @@ export function App({ bootstrap, api }: Props) {
|
|||
}
|
||||
/>
|
||||
<div class="command-grid">
|
||||
{/* 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 ? (
|
||||
<OpenSpaceCard
|
||||
t={t}
|
||||
matrixToUrl={state.spaceMatrixToUrl}
|
||||
onOpen={(url) => api.openMatrixToUrl(url)}
|
||||
/>
|
||||
) : null}
|
||||
<LogoutCard t={t} onConfirm={onConfirmLogout} />
|
||||
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -56,15 +56,6 @@ 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
|
||||
|
|
@ -169,28 +160,6 @@ 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<string, unknown>;
|
||||
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() };
|
||||
|
||||
|
|
@ -361,11 +330,20 @@ 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.
|
||||
[
|
||||
|
|
@ -409,7 +387,10 @@ 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' }],
|
||||
|
|
@ -540,9 +521,7 @@ 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 ?? '<none>'
|
||||
}`
|
||||
`legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${event.content?.msgtype ?? '<none>'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,15 +113,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -55,11 +55,13 @@ export const EN: Record<StringKey, string> = {
|
|||
'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.',
|
||||
|
|
@ -71,9 +73,6 @@ export const EN: Record<StringKey, string> = {
|
|||
'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…',
|
||||
|
|
|
|||
|
|
@ -86,7 +86,8 @@ 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}',
|
||||
|
|
@ -105,11 +106,7 @@ 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': 'Проверяю статус подключения…',
|
||||
|
|
|
|||
|
|
@ -104,13 +104,8 @@ 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. `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 }
|
||||
// per Matrix user), so logout doesn't need an id.
|
||||
| { kind: 'connected'; handle: string; discordId?: 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`
|
||||
|
|
@ -125,7 +120,10 @@ 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 —
|
||||
|
|
@ -171,7 +169,9 @@ 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,14 +266,11 @@ 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. 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.
|
||||
// the discordId snowflake.
|
||||
return {
|
||||
kind: 'connected',
|
||||
handle: event.handle,
|
||||
discordId: event.discordId,
|
||||
spaceMatrixToUrl: state.kind === 'connected' ? state.spaceMatrixToUrl : undefined,
|
||||
};
|
||||
|
||||
case 'connection_dead':
|
||||
|
|
@ -495,28 +492,12 @@ 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. 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.
|
||||
// produce when no handle was stashed.
|
||||
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
|
||||
|
|
@ -584,7 +565,10 @@ 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
|
||||
|
|
@ -709,12 +693,9 @@ const stepHydrate = (prevAcc: HydrateAccumulator, input: HydrateInput): HydrateA
|
|||
|
||||
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; 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.
|
||||
// catch-all.
|
||||
return acc;
|
||||
|
||||
default: {
|
||||
|
|
|
|||
|
|
@ -125,27 +125,6 @@ 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 `<commandPrefix> ` (trailing space).
|
||||
// Legacy mautrix-discord routes management-room commands through the
|
||||
// bridge.commands.Processor in mautrix/go bridge/commands; outside the
|
||||
|
|
|
|||
|
|
@ -742,21 +742,6 @@ body {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
/* PasswordForm wraps its input + show/hide toggle in `.password-row`
|
||||
* so the toggle pill sits next to the input on desktop. On narrow
|
||||
* viewports that nested row stays row-direction with `flex-shrink: 0`
|
||||
* on `.btn-icon`, and the input's monospace `font-size: 20px` +
|
||||
* `letter-spacing: 4px` (see `.auth-input.password`) pushes the toggle
|
||||
* off-screen. Continue the same column-stack pattern the outer
|
||||
* `.auth-card-row` already uses so the toggle drops below the input
|
||||
* full-width — visually consistent with btn-primary / btn-text. */
|
||||
.password-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
.password-row .btn-icon {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Compact .command-card on mobile — preserves the «two-row title +
|
||||
* chevron» structure but trims padding so a single login/logout card
|
||||
* doesn't dominate a phone-height viewport. */
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ 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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ npm run android:apk:debug # gradle debug build only
|
|||
|
||||
## Versioning
|
||||
|
||||
`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` and `versionName` auto-derived from `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 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.
|
||||
- **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.
|
||||
|
||||
## VSCode tasks
|
||||
|
||||
|
|
@ -54,188 +54,7 @@ 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"`).
|
||||
|
||||
## 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<String,String>`, 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<PollingPluginIface>('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<String, ArrayDeque<Entry>>` 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.
|
||||
|
||||
|
||||
## ADB wireless workflow
|
||||
|
||||
1. On the phone, enable Wireless debugging, tap "Pair device with pairing code" — note IP, port, 6-digit code.
|
||||
2. `adb pair <ip>:<pair-port> <code>`
|
||||
|
|
|
|||
|
|
@ -11,14 +11,14 @@ npm run typecheck # tsc --noEmit
|
|||
|
||||
Build: **Vite 5.4** with vanilla-extract, WASM, PWA plugins.
|
||||
|
||||
> **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.
|
||||
> **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.
|
||||
|
||||
## Source Layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.tsx # Entry point
|
||||
├── colors.css.ts # Vojo dark + light themes via createTheme(color, …) — both palettes are Vojo-owned, folds defaults are not used
|
||||
├── colors.css.ts # Custom dark-theme via createTheme(color, …); no light override (uses folds.lightTheme as-is)
|
||||
├── config.css.ts # fontWeight overrides
|
||||
├── client/
|
||||
│ ├── initMatrix.ts # Matrix SDK init (createClient, startClient, logout)
|
||||
|
|
@ -162,69 +162,10 @@ 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 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).
|
||||
- `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).
|
||||
- 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: 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 `<meta theme-color>` 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.
|
||||
- Brand accent in v4.11.x: `Primary.Main = #BDB6EC` (lavender) — referenced in unread-badge, focus-ring, NavLink active state, MessageBase highlight keyframe.
|
||||
|
||||
## Responsive design
|
||||
|
||||
|
|
@ -271,7 +212,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. 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.
|
||||
- **vanilla-extract** — Type-safe CSS (compile-time → no runtime theme switching without reload)
|
||||
- **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`.
|
||||
|
|
@ -287,7 +228,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 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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,235 +0,0 @@
|
|||
# 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/<path>` → 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.
|
||||
57
docs/known-tech-debt-lint/README.md
Normal file
57
docs/known-tech-debt-lint/README.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Известный техдолг по линтеру
|
||||
|
||||
Эта папка фиксирует **известное состояние** `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.
|
||||
91
docs/known-tech-debt-lint/diff.sh
Executable file
91
docs/known-tech-debt-lint/diff.sh
Executable file
|
|
@ -0,0 +1,91 @@
|
|||
#!/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"
|
||||
54
docs/known-tech-debt-lint/typecheck.snapshot.txt
Normal file
54
docs/known-tech-debt-lint/typecheck.snapshot.txt
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
|
||||
> 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<ReplyEvent, NoRelationEvent> & NoRelationEvent) | (Without<NoRelationEvent, ReplyEvent> & 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; }'.
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
264
electron/main.ts
264
electron/main.ts
|
|
@ -1,264 +0,0 @@
|
|||
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<string, unknown> = 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();
|
||||
});
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
contextBridge.exposeInMainWorld('vojoElectron', {
|
||||
platform: process.platform,
|
||||
openExternal: (url: string): Promise<void> => ipcRenderer.invoke('vojo:open-external', url),
|
||||
});
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"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"]
|
||||
}
|
||||
|
|
@ -23,8 +23,7 @@
|
|||
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."
|
||||
/>
|
||||
<meta name="theme-color" content="#0d0e11" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#f2f2f7" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<link id="favicon" rel="shortcut icon" type="image/svg+xml" href="./public/res/svg/vojo.svg" />
|
||||
|
||||
|
|
|
|||
2486
package-lock.json
generated
2486
package-lock.json
generated
File diff suppressed because it is too large
Load diff
33
package.json
33
package.json
|
|
@ -1,45 +1,35 @@
|
|||
{
|
||||
"name": "vojo",
|
||||
"version": "0.2.0",
|
||||
"description": "Vojo client for matrix server",
|
||||
"version": "4.11.1",
|
||||
"description": "Yet another matrix client",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "npm run check:eslint && npm run check:prettier",
|
||||
"check:eslint": "eslint --max-warnings 0 src",
|
||||
"check:eslint": "eslint 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: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",
|
||||
"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",
|
||||
"prepare": "husky install",
|
||||
"commit": "git-cz"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,jsx,mjs,cjs}": "eslint --max-warnings 0",
|
||||
"*.{ts,tsx,js,jsx,mjs,cjs}": "eslint",
|
||||
"*": "prettier --ignore-unknown --write"
|
||||
},
|
||||
"config": {
|
||||
|
|
@ -135,11 +125,7 @@
|
|||
"@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",
|
||||
|
|
@ -154,7 +140,6 @@
|
|||
"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",
|
||||
"wait-on": "9.0.10"
|
||||
"vite-plugin-top-level-await": "1.4.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,388 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0d0e11" />
|
||||
<meta name="robots" content="index,follow" />
|
||||
<title>Vojo — Account deletion</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0e11;
|
||||
--panel: #181a20;
|
||||
--surface: #21232b;
|
||||
--text: #e6e6e9;
|
||||
--text-strong: #f4f4f6;
|
||||
--muted: rgba(230, 230, 233, 0.62);
|
||||
--faint: rgba(230, 230, 233, 0.38);
|
||||
--divider: rgba(255, 255, 255, 0.08);
|
||||
--fleet: #9580ff;
|
||||
--fleet-soft: #a59cff;
|
||||
color-scheme: dark;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, "SF Pro Text", "Inter", system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
body {
|
||||
min-height: 100vh;
|
||||
line-height: 1.7;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.frame {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 56px 28px 120px;
|
||||
}
|
||||
|
||||
header.doc {
|
||||
padding-bottom: 28px;
|
||||
margin-bottom: 40px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-bottom: 22px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.brand-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 99px;
|
||||
background: var(--fleet);
|
||||
}
|
||||
.brand-name { color: var(--text-strong); font-weight: 600; }
|
||||
.brand-sep { color: var(--faint); }
|
||||
|
||||
h1 {
|
||||
font-size: 34px;
|
||||
line-height: 1.15;
|
||||
font-weight: 600;
|
||||
margin: 0 0 10px;
|
||||
letter-spacing: -0.6px;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.effective { color: var(--muted); font-size: 14px; margin: 0; }
|
||||
|
||||
.lang-switch {
|
||||
display: inline-flex;
|
||||
margin-top: 24px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.lang-switch button {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 4px 0;
|
||||
font: inherit;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: color .15s ease;
|
||||
}
|
||||
.lang-switch button:hover { color: var(--text); }
|
||||
.lang-switch button[aria-pressed="true"] {
|
||||
color: var(--text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
.lang-switch .sep {
|
||||
padding: 0 10px;
|
||||
color: var(--faint);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 44px 0 12px;
|
||||
letter-spacing: -0.2px;
|
||||
color: var(--text-strong);
|
||||
scroll-margin-top: 24px;
|
||||
}
|
||||
|
||||
p { margin: 0 0 14px; color: var(--text); }
|
||||
ul, ol {
|
||||
margin: 0 0 18px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
ul li, ol li { margin: 8px 0; padding-left: 4px; }
|
||||
ul li::marker, ol li::marker { color: var(--faint); }
|
||||
ul li b, ol li b, p b { color: var(--text-strong); font-weight: 600; }
|
||||
|
||||
a {
|
||||
color: var(--fleet-soft);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid rgba(165, 156, 255, 0.35);
|
||||
transition: color .15s ease, border-color .15s ease;
|
||||
}
|
||||
a:hover {
|
||||
color: #c0b9ff;
|
||||
border-bottom-color: rgba(192, 185, 255, 0.7);
|
||||
}
|
||||
|
||||
.callout {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
margin: 14px 0 20px;
|
||||
}
|
||||
.callout p:last-child { margin-bottom: 0; }
|
||||
.callout a.email {
|
||||
font-family: ui-monospace, "JetBrains Mono", monospace;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
section[hidden] { display: none; }
|
||||
section > h2:first-of-type { margin-top: 0; }
|
||||
|
||||
footer.doc {
|
||||
margin-top: 64px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--divider);
|
||||
font-size: 13px;
|
||||
color: var(--faint);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
footer.doc .copy {
|
||||
font-family: ui-monospace, "JetBrains Mono", monospace;
|
||||
}
|
||||
footer.doc a { color: var(--muted); border-bottom-color: transparent; }
|
||||
footer.doc a:hover { color: var(--text); border-bottom-color: var(--divider); }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.frame { padding: 36px 20px 96px; }
|
||||
h1 { font-size: 28px; }
|
||||
h2 { font-size: 18px; margin: 36px 0 10px; }
|
||||
body { font-size: 15.5px; line-height: 1.65; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="frame">
|
||||
|
||||
<header class="doc">
|
||||
<div class="brand">
|
||||
<span class="brand-dot" aria-hidden="true"></span>
|
||||
<span class="brand-name">Vojo</span>
|
||||
<span class="brand-sep">·</span>
|
||||
<span>Account deletion</span>
|
||||
</div>
|
||||
|
||||
<h1 data-i18n-h1>Delete your account</h1>
|
||||
<p class="effective" data-i18n-effective>Vojo Project · vojo.chat</p>
|
||||
|
||||
<div class="lang-switch" role="group" aria-label="Language">
|
||||
<button type="button" data-lang="en" aria-pressed="true">English</button>
|
||||
<span class="sep" aria-hidden="true">/</span>
|
||||
<button type="button" data-lang="ru" aria-pressed="false">Русский</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section lang="en" data-lang="en">
|
||||
<p>This page explains how to request deletion of your <b>Vojo</b> account and the
|
||||
data associated with it on the <code>vojo.chat</code> homeserver.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h2>How to request deletion</h2>
|
||||
<ol>
|
||||
<li>Send an email from any address to
|
||||
<a class="email" href="mailto:vojochatdev@gmail.com?subject=Delete%20account">vojochatdev@gmail.com</a>
|
||||
with the subject <b>“Delete account”</b>.</li>
|
||||
<li>In the body of the email, include your full Matrix user ID — the
|
||||
<code>@username:vojo.chat</code> 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.</li>
|
||||
<li>We acknowledge the request within a few business days and complete deletion
|
||||
within <b>thirty days</b> of the original request.</li>
|
||||
</ol>
|
||||
|
||||
<div class="callout">
|
||||
<p><b>Contact:</b>
|
||||
<a class="email" href="mailto:vojochatdev@gmail.com">vojochatdev@gmail.com</a></p>
|
||||
<p style="margin-top: 6px;"><b>Subject line:</b> Delete account</p>
|
||||
</div>
|
||||
|
||||
<h2>What gets deleted</h2>
|
||||
<ul>
|
||||
<li>Your account record on the <code>vojo.chat</code> homeserver (user profile,
|
||||
display name, avatar).</li>
|
||||
<li>Your active sessions and authentication tokens.</li>
|
||||
<li>Your encryption keys held server-side.</li>
|
||||
<li>Media files you uploaded to the <code>vojo.chat</code> media storage.</li>
|
||||
<li>The push-notification registration (Firebase Cloud Messaging token) bound
|
||||
to the account.</li>
|
||||
</ul>
|
||||
|
||||
<h2>What we cannot delete on your behalf</h2>
|
||||
<ul>
|
||||
<li><b>Messages already delivered to other servers.</b> 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.</li>
|
||||
<li><b>Copies held by other participants.</b> Anything you sent into a
|
||||
conversation has been received by the people you sent it to. We cannot
|
||||
reach into their devices or accounts.</li>
|
||||
<li><b>Messages in rooms you no longer participate in.</b> 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.</li>
|
||||
<li><b>Bridged third-party networks.</b> 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.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Data retained after deletion</h2>
|
||||
<p>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.</p>
|
||||
|
||||
<h2>If you'd prefer to stay but stop receiving notifications</h2>
|
||||
<p>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.</p>
|
||||
|
||||
<h2>Privacy Policy</h2>
|
||||
<p>For a fuller description of what we hold and why, see our
|
||||
<a href="https://vojo.chat/privacy">Privacy Policy</a>.</p>
|
||||
</section>
|
||||
|
||||
<section lang="ru" data-lang="ru" hidden>
|
||||
<p>На этой странице описано, как запросить удаление вашей учётной записи <b>Vojo</b>
|
||||
и связанных с ней данных на homeserver-е <code>vojo.chat</code>.</p>
|
||||
|
||||
<p>Vojo поддерживается проектом Vojo, независимым разработчиком. В приложении пока
|
||||
нет кнопки «Удалить аккаунт»; до её появления удаление выполняется по запросу на
|
||||
адрес ниже. Мы отвечаем на каждое обращение.</p>
|
||||
|
||||
<h2>Как запросить удаление</h2>
|
||||
<ol>
|
||||
<li>Напишите письмо с любого адреса на
|
||||
<a class="email" href="mailto:vojochatdev@gmail.com?subject=Delete%20account">vojochatdev@gmail.com</a>
|
||||
с темой <b>«Delete account»</b>.</li>
|
||||
<li>В тексте письма укажите полный Matrix user ID — идентификатор вида
|
||||
<code>@username:vojo.chat</code>, который виден в Настройки → Аккаунт.
|
||||
Если доступа к аккаунту больше нет, опишите достаточно деталей
|
||||
(примерная дата создания, email или recovery-информация, которую помните),
|
||||
чтобы мы могли надёжно его опознать.</li>
|
||||
<li>Мы подтверждаем получение запроса в течение нескольких рабочих дней и
|
||||
завершаем удаление в течение <b>тридцати дней</b> с момента
|
||||
первоначального обращения.</li>
|
||||
</ol>
|
||||
|
||||
<div class="callout">
|
||||
<p><b>Контакт:</b>
|
||||
<a class="email" href="mailto:vojochatdev@gmail.com">vojochatdev@gmail.com</a></p>
|
||||
<p style="margin-top: 6px;"><b>Тема письма:</b> Delete account</p>
|
||||
</div>
|
||||
|
||||
<h2>Что будет удалено</h2>
|
||||
<ul>
|
||||
<li>Запись вашего аккаунта на homeserver-е <code>vojo.chat</code> (профиль,
|
||||
отображаемое имя, аватар).</li>
|
||||
<li>Активные сессии и токены аутентификации.</li>
|
||||
<li>Ключи шифрования, хранящиеся на сервере.</li>
|
||||
<li>Медиа-файлы, которые вы загружали в медиа-хранилище
|
||||
<code>vojo.chat</code>.</li>
|
||||
<li>Регистрация push-уведомлений (Firebase Cloud Messaging токен),
|
||||
привязанная к аккаунту.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Что мы не можем удалить за вас</h2>
|
||||
<ul>
|
||||
<li><b>Сообщения, уже доставленные на другие серверы.</b> Matrix — федеративная
|
||||
сеть: когда вы отправляли сообщение в комнату, где были участники с других
|
||||
homeserver-ов, эти сообщения реплицировались на их серверы и больше не
|
||||
находятся под нашим контролем.</li>
|
||||
<li><b>Копии у других участников.</b> Всё, что вы отправили в переписку, уже
|
||||
получили те, кому вы это отправляли. Мы не можем добраться до их устройств
|
||||
и учётных записей.</li>
|
||||
<li><b>Сообщения в комнатах, где вас больше нет.</b> Несколько остаточных
|
||||
событий (записи о членстве, состояние комнаты) могут оставаться на
|
||||
homeserver-е для целостности комнаты, но они уже не связаны с вашим
|
||||
удалённым аккаунтом.</li>
|
||||
<li><b>Подключённые сторонние сети.</b> Если вы пользовались мостами в
|
||||
Telegram, Discord или WhatsApp, эти сети хранят собственные копии ваших
|
||||
сообщений по своим политикам; деактивация аккаунта Vojo не удаляет данные
|
||||
на этих сервисах.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Данные, остающиеся после удаления</h2>
|
||||
<p>После деактивации аккаунта серверные журналы доступа могут содержать ваш IP-
|
||||
адрес и время запросов ещё до тридцати дней в рамках обычной ротации. Резервные
|
||||
копии, охватывающие период до удаления, ротируются и пропадают в течение
|
||||
тридцати дней. По истечении этого срока никакие персональные данные, относящиеся
|
||||
к вашему аккаунту, на нашей инфраструктуре не остаются.</p>
|
||||
|
||||
<h2>Если хотите остаться, но прекратить уведомления</h2>
|
||||
<p>Если вы хотите только перестать получать уведомления, не теряя аккаунт целиком —
|
||||
просто выйдите из приложения на своём устройстве. Это снимет привязку к push-
|
||||
сервису без деактивации учётной записи.</p>
|
||||
|
||||
<h2>Политика конфиденциальности</h2>
|
||||
<p>Более полное описание того, что у нас хранится и зачем — в
|
||||
<a href="https://vojo.chat/privacy">Политике конфиденциальности</a>.</p>
|
||||
</section>
|
||||
|
||||
<footer class="doc">
|
||||
<span class="copy">© Vojo Project · 2026</span>
|
||||
<a href="https://vojo.chat">vojo.chat</a>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var buttons = document.querySelectorAll('.lang-switch button');
|
||||
var sections = document.querySelectorAll('section[data-lang]');
|
||||
var h1 = document.querySelector('[data-i18n-h1]');
|
||||
var eff = document.querySelector('[data-i18n-effective]');
|
||||
var H1 = { en: 'Delete your account', ru: 'Удалить аккаунт' };
|
||||
var EFF = { en: 'Vojo Project · vojo.chat', ru: 'Проект Vojo · vojo.chat' };
|
||||
function setLang(lang) {
|
||||
buttons.forEach(function (b) {
|
||||
b.setAttribute('aria-pressed', String(b.dataset.lang === lang));
|
||||
});
|
||||
sections.forEach(function (s) {
|
||||
s.hidden = s.dataset.lang !== lang;
|
||||
});
|
||||
if (h1 && H1[lang]) h1.textContent = H1[lang];
|
||||
if (eff && EFF[lang]) eff.textContent = EFF[lang];
|
||||
document.documentElement.lang = lang;
|
||||
document.title = (lang === 'ru' ? 'Vojo — Удаление аккаунта' : 'Vojo — Account deletion');
|
||||
try { localStorage.setItem('vojo-delete-lang', lang); } catch (e) {}
|
||||
}
|
||||
buttons.forEach(function (b) {
|
||||
b.addEventListener('click', function () { setLang(b.dataset.lang); });
|
||||
});
|
||||
var stored = null;
|
||||
try { stored = localStorage.getItem('vojo-delete-lang'); } catch (e) {}
|
||||
setLang(stored || 'en');
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
BIN
public/font/Twemoji.Mozilla.v15.1.0.ttf
Normal file
BIN
public/font/Twemoji.Mozilla.v15.1.0.ttf
Normal file
Binary file not shown.
|
|
@ -90,8 +90,6 @@
|
|||
"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}}",
|
||||
|
|
@ -99,6 +97,7 @@
|
|||
"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",
|
||||
|
|
@ -122,6 +121,7 @@
|
|||
"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,6 +140,7 @@
|
|||
"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.",
|
||||
|
|
@ -184,6 +185,7 @@
|
|||
"notif_disable": "Disable",
|
||||
"notif_silent": "Notify Silent",
|
||||
"notif_loud": "Notify Loud",
|
||||
|
||||
"devices_title": "Devices",
|
||||
"security": "Security",
|
||||
"device_verification": "Device Verification",
|
||||
|
|
@ -226,6 +228,7 @@
|
|||
"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",
|
||||
|
|
@ -239,6 +242,7 @@
|
|||
"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",
|
||||
|
|
@ -248,6 +252,7 @@
|
|||
"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",
|
||||
|
|
@ -255,17 +260,15 @@
|
|||
"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",
|
||||
|
|
@ -369,7 +372,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": "Direct",
|
||||
"segment_dm": "DM",
|
||||
"segment_channels": "Channels",
|
||||
"segment_bots": "Robots",
|
||||
"self_row_label": "You",
|
||||
|
|
@ -383,8 +386,7 @@
|
|||
"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",
|
||||
"close": "Close"
|
||||
"create": "Create"
|
||||
},
|
||||
"Channels": {
|
||||
"no_spaces_title": "No communities yet",
|
||||
|
|
@ -394,12 +396,7 @@
|
|||
"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_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"
|
||||
"workspace_switcher_active_marker": "Current"
|
||||
},
|
||||
"Call": {
|
||||
"start": "Start call",
|
||||
|
|
@ -424,19 +421,7 @@
|
|||
"in_call": "In call",
|
||||
"in_call_count": "{{count}} in call",
|
||||
"connecting": "Connecting…",
|
||||
"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"
|
||||
"open_call_room": "Open call room"
|
||||
},
|
||||
"Room": {
|
||||
"drag_to_close": "Drag up to close",
|
||||
|
|
@ -448,6 +433,7 @@
|
|||
"jump_to_latest": "Jump to Latest",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
|
||||
"view_reactions": "View Reactions",
|
||||
"read_receipts": "Read Receipts",
|
||||
"view_source": "View Source",
|
||||
|
|
@ -459,6 +445,7 @@
|
|||
"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",
|
||||
|
|
@ -466,6 +453,7 @@
|
|||
"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",
|
||||
|
|
@ -474,11 +462,13 @@
|
|||
"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",
|
||||
|
|
@ -495,30 +485,23 @@
|
|||
"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",
|
||||
|
|
@ -528,6 +511,7 @@
|
|||
"broken_message": "Broken message",
|
||||
"empty_message": "Empty message",
|
||||
"edited": " (edited)",
|
||||
|
||||
"thread_caption": "Thread",
|
||||
"thread_in_channel_subtitle": "in #{{channel}}",
|
||||
"thread_close": "Close thread",
|
||||
|
|
@ -544,6 +528,7 @@
|
|||
"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 <bold>@{{creator}}</bold> on {{date}} {{time}}",
|
||||
"invite_member": "Invite Member",
|
||||
|
|
@ -554,6 +539,7 @@
|
|||
"leave_room_error": "Failed to leave room! {{error}}",
|
||||
"leaving": "Leaving...",
|
||||
"leave": "Leave",
|
||||
|
||||
"member_broken": "Broken membership event",
|
||||
"member_accepted_knock": "<bold>{{sender}}</bold> accepted <bold>{{user}}</bold>'s join request",
|
||||
"member_invited": "<bold>{{sender}}</bold> invited <bold>{{user}}</bold>",
|
||||
|
|
@ -571,7 +557,10 @@
|
|||
"member_name_removed": "<bold>{{user}}</bold> removed their display name",
|
||||
"member_avatar_changed": "<bold>{{user}}</bold> changed their avatar",
|
||||
"member_avatar_removed": "<bold>{{user}}</bold> removed their avatar",
|
||||
"member_no_change": "Membership event with no changes"
|
||||
"member_no_change": "Membership event with no changes",
|
||||
|
||||
"member_ended_call": "<bold>{{user}}</bold> ended the call",
|
||||
"member_joined_call": "<bold>{{user}}</bold> joined the call"
|
||||
},
|
||||
"Inbox": {
|
||||
"invite_title": "Invite",
|
||||
|
|
@ -579,15 +568,19 @@
|
|||
"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",
|
||||
|
|
@ -595,11 +588,13 @@
|
|||
"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",
|
||||
|
|
@ -618,9 +613,9 @@
|
|||
"previous_page": "Previous Page",
|
||||
"next_page": "Next Page",
|
||||
"no_communities": "No communities found!",
|
||||
|
||||
"space_badge": "Space",
|
||||
"members_count_one": "{{formattedCount}} Member",
|
||||
"members_count_other": "{{formattedCount}} Members",
|
||||
"members_count": "{{count}} Members",
|
||||
"join": "Join",
|
||||
"joining": "Joining",
|
||||
"retry": "Retry",
|
||||
|
|
@ -629,6 +624,7 @@
|
|||
"view_error": "View Error",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
|
||||
"Create": {
|
||||
"add_space": "Add Space",
|
||||
"create_space": "Create Space",
|
||||
|
|
@ -636,6 +632,7 @@
|
|||
"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)",
|
||||
|
|
@ -647,38 +644,47 @@
|
|||
"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",
|
||||
|
|
@ -690,25 +696,30 @@
|
|||
"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!",
|
||||
|
|
@ -717,9 +728,11 @@
|
|||
"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 <b>Public</b>, Published addresses will be used to join by anyone.",
|
||||
"no_addresses": "No Addresses",
|
||||
|
|
@ -733,11 +746,13 @@
|
|||
"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",
|
||||
|
|
@ -751,21 +766,25 @@
|
|||
"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",
|
||||
|
|
@ -798,10 +817,12 @@
|
|||
"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",
|
||||
|
|
@ -818,9 +839,11 @@
|
|||
"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.",
|
||||
|
|
@ -829,6 +852,7 @@
|
|||
"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.",
|
||||
|
|
@ -851,6 +875,7 @@
|
|||
"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.",
|
||||
|
|
@ -865,6 +890,7 @@
|
|||
"usage_both": "Both",
|
||||
"usage_sticker": "Sticker",
|
||||
"usage_emoji": "Emoji",
|
||||
|
||||
"power_goku": "Goku",
|
||||
"power_manager": "Manager",
|
||||
"power_founder": "Founder",
|
||||
|
|
@ -874,6 +900,7 @@
|
|||
"power_muted": "Muted",
|
||||
"power_team": "Team"
|
||||
},
|
||||
|
||||
"Push": {
|
||||
"new_message": "New message",
|
||||
"new_messages": "New messages",
|
||||
|
|
@ -884,19 +911,7 @@
|
|||
"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",
|
||||
"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"
|
||||
"invite_body_generic": "New invitation"
|
||||
},
|
||||
"Bots": {
|
||||
"not_connected_title": "{{name}} is not connected",
|
||||
|
|
@ -968,15 +983,5 @@
|
|||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,8 +90,6 @@
|
|||
"menu_emojis_stickers": "Эмодзи и стикеры",
|
||||
"menu_developer_tools": "Инструменты разработчика",
|
||||
"menu_about": "О приложении",
|
||||
"drag_to_close": "Потянуть вниз чтобы закрыть",
|
||||
"close": "Закрыть",
|
||||
"logout": "Выйти",
|
||||
"logout_confirm": "Вы собираетесь выйти из аккаунта. Вы уверены?",
|
||||
"logout_failed": "Не удалось выйти! {{message}}",
|
||||
|
|
@ -99,6 +97,7 @@
|
|||
"logout_unverified_desc": "Верифицируйте устройство перед выходом, чтобы сохранить зашифрованные сообщения.",
|
||||
"logout_alert_title": "Внимание",
|
||||
"logout_alert_desc": "Включите верификацию устройства или экспортируйте зашифрованные данные в настройках, чтобы не потерять доступ к сообщениям.",
|
||||
|
||||
"general_title": "Общие",
|
||||
"appearance": "Внешний вид",
|
||||
"system_theme": "Системная",
|
||||
|
|
@ -122,6 +121,7 @@
|
|||
"url_preview": "Предпросмотр ссылок",
|
||||
"url_preview_encrypted": "Предпросмотр ссылок в зашифрованных комнатах",
|
||||
"show_hidden_events": "Показывать скрытые события",
|
||||
|
||||
"account_title": "Аккаунт",
|
||||
"profile": "Профиль",
|
||||
"avatar": "Аватар",
|
||||
|
|
@ -140,6 +140,7 @@
|
|||
"select_user_desc": "Заблокируйте получение сообщений и приглашений от пользователя, добавив его идентификатор.",
|
||||
"block": "Заблокировать",
|
||||
"users": "Пользователи",
|
||||
|
||||
"notifications_title": "Уведомления",
|
||||
"block_messages": "Блокировка сообщений",
|
||||
"block_messages_moved": "Эта опция перенесена в раздел «Аккаунт > Заблокированные пользователи».",
|
||||
|
|
@ -184,6 +185,7 @@
|
|||
"notif_disable": "Отключить",
|
||||
"notif_silent": "Тихое уведомление",
|
||||
"notif_loud": "Громкое уведомление",
|
||||
|
||||
"devices_title": "Устройства",
|
||||
"security": "Безопасность",
|
||||
"device_verification": "Верификация устройства",
|
||||
|
|
@ -226,6 +228,7 @@
|
|||
"verify_other_desc": "Подтвердите идентичность устройства и получите доступ к зашифрованным сообщениям.",
|
||||
"verify": "Верифицировать",
|
||||
"reset": "Сбросить",
|
||||
|
||||
"local_backup": "Локальная копия",
|
||||
"new_password": "Новый пароль",
|
||||
"confirm_password": "Подтвердите пароль",
|
||||
|
|
@ -239,6 +242,7 @@
|
|||
"import_desc": "Загрузите защищённую паролем копию ключей шифрования с устройства для расшифровки сообщений.",
|
||||
"import": "Импорт",
|
||||
"decrypt": "Расшифровать",
|
||||
|
||||
"emojis_stickers_title": "Эмодзи и стикеры",
|
||||
"default_pack": "Пакет по умолчанию",
|
||||
"unknown": "Неизвестно",
|
||||
|
|
@ -248,6 +252,7 @@
|
|||
"select_pack_desc": "Выберите пакеты эмодзи и стикеров из комнат для использования во всех комнатах.",
|
||||
"select": "Выбрать",
|
||||
"room_packs": "Пакеты комнат",
|
||||
"close": "Закрыть",
|
||||
"select_all": "Выбрать все",
|
||||
"unselect_all": "Снять выделение",
|
||||
"no_packs": "Нет пакетов",
|
||||
|
|
@ -255,17 +260,15 @@
|
|||
"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": "Токен доступа",
|
||||
|
|
@ -385,8 +388,7 @@
|
|||
"e2e_encryption": "Сквозное шифрование",
|
||||
"e2e_encryption_desc": "После включения эту функцию нельзя отключить после создания комнаты.",
|
||||
"rate_limited": "Сервер ограничил частоту запросов на {{minutes}} мин.!",
|
||||
"create": "Создать",
|
||||
"close": "Закрыть"
|
||||
"create": "Создать"
|
||||
},
|
||||
"Channels": {
|
||||
"no_spaces_title": "Пока нет сообществ",
|
||||
|
|
@ -396,14 +398,7 @@
|
|||
"pick_channel_desc": "Откройте канал из списка слева, чтобы начать читать.",
|
||||
"root_category": "Каналы",
|
||||
"workspace_switcher_aria": "Сменить сообщество",
|
||||
"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": "Создать канал"
|
||||
"workspace_switcher_active_marker": "Текущее"
|
||||
},
|
||||
"Call": {
|
||||
"start": "Позвонить",
|
||||
|
|
@ -428,21 +423,7 @@
|
|||
"in_call": "В звонке",
|
||||
"in_call_count": "{{count}} в звонке",
|
||||
"connecting": "Соединение…",
|
||||
"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}} сек"
|
||||
"open_call_room": "Открыть чат звонка"
|
||||
},
|
||||
"Room": {
|
||||
"drag_to_close": "Потянуть вверх чтобы закрыть",
|
||||
|
|
@ -454,6 +435,7 @@
|
|||
"jump_to_latest": "К последним",
|
||||
"today": "Сегодня",
|
||||
"yesterday": "Вчера",
|
||||
|
||||
"view_reactions": "Реакции",
|
||||
"read_receipts": "Подтверждения прочтения",
|
||||
"view_source": "Исходный код",
|
||||
|
|
@ -465,6 +447,7 @@
|
|||
"reply": "Ответить",
|
||||
"reply_in_thread": "Ответить в треде",
|
||||
"edit_message": "Редактировать",
|
||||
|
||||
"delete_message": "Удалить сообщение",
|
||||
"delete_confirm": "Это действие необратимо! Вы уверены, что хотите удалить это сообщение?",
|
||||
"reason": "Причина",
|
||||
|
|
@ -472,6 +455,7 @@
|
|||
"delete_error": "Не удалось удалить сообщение! Попробуйте снова.",
|
||||
"deleting": "Удаление...",
|
||||
"delete": "Удалить",
|
||||
|
||||
"report_message": "Пожаловаться",
|
||||
"report_desc": "Сообщить о нарушении на сервер, который может уведомить ответственных лиц для принятия мер.",
|
||||
"report_reason": "Причина",
|
||||
|
|
@ -480,11 +464,13 @@
|
|||
"reporting": "Отправка...",
|
||||
"report": "Пожаловаться",
|
||||
"no_reason": "Причина не указана",
|
||||
|
||||
"is_typing": " печатает...",
|
||||
"and": " и ",
|
||||
"are_typing": " печатают...",
|
||||
"others_count": "ещё {{count}}",
|
||||
"drop_typing": "Скрыть индикатор набора",
|
||||
|
||||
"members": "Участники",
|
||||
"members_count_one": "{{formattedCount}} участник",
|
||||
"members_count_few": "{{formattedCount}} участника",
|
||||
|
|
@ -503,30 +489,23 @@
|
|||
"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": "Неподдерживаемое сообщение",
|
||||
|
|
@ -536,6 +515,7 @@
|
|||
"broken_message": "Повреждённое сообщение",
|
||||
"empty_message": "Пустое сообщение",
|
||||
"edited": " (изменено)",
|
||||
|
||||
"thread_caption": "Тред",
|
||||
"thread_in_channel_subtitle": "в #{{channel}}",
|
||||
"thread_close": "Закрыть тред",
|
||||
|
|
@ -558,6 +538,7 @@
|
|||
"thread_summary_highlight_many": "{{count}} упоминаний",
|
||||
"thread_summary_highlight_other": "{{count}} упоминания",
|
||||
"no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате",
|
||||
|
||||
"conversation_beginning": "Начало переписки.",
|
||||
"created_by": "Комната создана <bold>@{{creator}}</bold> {{date}} {{time}}",
|
||||
"invite_member": "Пригласить",
|
||||
|
|
@ -568,6 +549,7 @@
|
|||
"leave_room_error": "Не удалось покинуть комнату! {{error}}",
|
||||
"leaving": "Выход...",
|
||||
"leave": "Покинуть",
|
||||
|
||||
"member_broken": "Некорректное событие участия",
|
||||
"member_accepted_knock": "<bold>{{sender}}</bold> одобряет вступление <bold>{{user}}</bold>",
|
||||
"member_invited": "<bold>{{sender}}</bold> приглашает <bold>{{user}}</bold>",
|
||||
|
|
@ -585,7 +567,10 @@
|
|||
"member_name_removed": "<bold>{{user}}</bold> убирает отображаемое имя",
|
||||
"member_avatar_changed": "<bold>{{user}}</bold> меняет аватар",
|
||||
"member_avatar_removed": "<bold>{{user}}</bold> убирает аватар",
|
||||
"member_no_change": "Событие участия без изменений"
|
||||
"member_no_change": "Событие участия без изменений",
|
||||
|
||||
"member_ended_call": "<bold>{{user}}</bold> больше не в звонке",
|
||||
"member_joined_call": "<bold>{{user}}</bold> теперь в звонке"
|
||||
},
|
||||
"Inbox": {
|
||||
"invite_title": "Пригласить",
|
||||
|
|
@ -593,15 +578,19 @@
|
|||
"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": "Имя сервера",
|
||||
|
|
@ -609,11 +598,13 @@
|
|||
"view": "Открыть",
|
||||
"featured": "Рекомендуемые",
|
||||
"servers": "Серверы",
|
||||
|
||||
"featured_by_client": "Рекомендации клиента",
|
||||
"featured_by_client_desc": "Подборка публичных комнат и пространств от этого клиента.",
|
||||
"featured_spaces": "Рекомендуемые пространства",
|
||||
"featured_rooms": "Рекомендуемые комнаты",
|
||||
"no_featured": "Рекомендуемых комнат и пространств пока нет.",
|
||||
|
||||
"search": "Поиск",
|
||||
"search_placeholder": "Поиск по ключевому слову",
|
||||
"clear": "Очистить",
|
||||
|
|
@ -632,11 +623,9 @@
|
|||
"previous_page": "Предыдущая",
|
||||
"next_page": "Следующая",
|
||||
"no_communities": "Сообщества не найдены!",
|
||||
|
||||
"space_badge": "Пространство",
|
||||
"members_count_one": "{{formattedCount}} участник",
|
||||
"members_count_few": "{{formattedCount}} участника",
|
||||
"members_count_many": "{{formattedCount}} участников",
|
||||
"members_count_other": "{{formattedCount}} участника",
|
||||
"members_count": "{{count}} участников",
|
||||
"join": "Присоединиться",
|
||||
"joining": "Вступление…",
|
||||
"retry": "Повторить",
|
||||
|
|
@ -645,6 +634,7 @@
|
|||
"view_error": "Подробности",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
|
||||
"Create": {
|
||||
"add_space": "Добавить пространство",
|
||||
"create_space": "Создать пространство",
|
||||
|
|
@ -652,6 +642,7 @@
|
|||
"join_with_address": "Присоединиться по адресу",
|
||||
"join_with_address_desc": "Присоединиться к существующему сообществу.",
|
||||
"new_space": "Новое пространство",
|
||||
|
||||
"access": "Доступ",
|
||||
"name": "Название",
|
||||
"topic_optional": "Тема (необязательно)",
|
||||
|
|
@ -663,38 +654,47 @@
|
|||
"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,25 +706,30 @@
|
|||
"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": "После включения шифрование невозможно отключить!",
|
||||
|
|
@ -733,9 +738,11 @@
|
|||
"enable_encryption": "Включить шифрование",
|
||||
"enable_encryption_confirm": "Вы уверены? После включения шифрование невозможно отключить!",
|
||||
"enable_e2e_encryption": "Включить E2E-шифрование",
|
||||
|
||||
"publish_to_directory": "Показывать в поиске",
|
||||
"publish_space_desc": "Сделать пространство видимым в общем списке, чтобы другие пользователи могли его найти.",
|
||||
"publish_room_desc": "Сделать комнату видимой в общем списке, чтобы другие пользователи могли её найти.",
|
||||
|
||||
"published_addresses": "Опубликованные адреса",
|
||||
"published_addresses_desc": "Если доступ <b>публичный</b>, опубликованные адреса будут использоваться для присоединения.",
|
||||
"no_addresses": "Нет адресов",
|
||||
|
|
@ -749,11 +756,13 @@
|
|||
"publish": "Опубликовать",
|
||||
"delete": "Удалить",
|
||||
"selected_count": "Выбрано: {{count}}",
|
||||
|
||||
"local_addresses": "Локальные адреса",
|
||||
"local_addresses_desc": "Задайте локальный адрес, чтобы пользователи могли присоединиться через ваш сервер.",
|
||||
"collapse": "Свернуть",
|
||||
"expand": "Развернуть",
|
||||
"loading": "Загрузка...",
|
||||
|
||||
"space_upgrade": "Обновление пространства",
|
||||
"room_upgrade": "Обновление комнаты",
|
||||
"upgrade": "Обновить",
|
||||
|
|
@ -767,21 +776,25 @@
|
|||
"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": "Отправка стикеров",
|
||||
|
|
@ -814,10 +827,12 @@
|
|||
"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": "Создать новый уровень власти.",
|
||||
|
|
@ -834,9 +849,11 @@
|
|||
"failed_to_apply": "Не удалось применить изменения! Попробуйте ещё раз.",
|
||||
"apply_changes": "Применить изменения",
|
||||
"and_above": "и выше",
|
||||
|
||||
"users": "Пользователи",
|
||||
"default_power": "Уровень по умолчанию",
|
||||
"default_power_desc": "Уровень власти по умолчанию для всех пользователей.",
|
||||
|
||||
"packs": "Паки",
|
||||
"new_pack": "Новый пак",
|
||||
"new_pack_desc": "Добавьте свой пак эмодзи и стикеров для использования в комнате.",
|
||||
|
|
@ -845,6 +862,7 @@
|
|||
"view": "Открыть",
|
||||
"failed_to_remove_packs": "Не удалось удалить паки! Попробуйте ещё раз.",
|
||||
"delete_selected_packs": "Удалить выбранные паки. (Выбрано: {{count}})",
|
||||
|
||||
"enable_developer_tools": "Включить инструменты разработчика",
|
||||
"room_id": "ID комнаты",
|
||||
"room_id_desc": "Скопировать ID комнаты в буфер обмена.",
|
||||
|
|
@ -867,6 +885,7 @@
|
|||
"message_event_type": "Тип события сообщения",
|
||||
"send": "Отправить",
|
||||
"state_key_optional": "State Key (необязательно)",
|
||||
|
||||
"pack": "Пак",
|
||||
"images_usage": "Использование изображений",
|
||||
"images_usage_desc": "Выберите, как используются изображения: как эмодзи, как стикеры или как и то, и другое.",
|
||||
|
|
@ -881,6 +900,7 @@
|
|||
"usage_both": "Оба",
|
||||
"usage_sticker": "Стикер",
|
||||
"usage_emoji": "Эмодзи",
|
||||
|
||||
"power_goku": "Гоку",
|
||||
"power_manager": "Менеджер",
|
||||
"power_founder": "Основатель",
|
||||
|
|
@ -890,6 +910,7 @@
|
|||
"power_muted": "Без голоса",
|
||||
"power_team": "Команда"
|
||||
},
|
||||
|
||||
"Push": {
|
||||
"new_message": "Новое сообщение",
|
||||
"new_messages": "Новые сообщения",
|
||||
|
|
@ -900,19 +921,7 @@
|
|||
"invite_body": "{{inviter}} приглашает вас в {{roomName}}",
|
||||
"invite_body_no_room": "{{inviter}} приглашает вас в комнату",
|
||||
"invite_body_no_inviter": "Приглашение в {{roomName}}",
|
||||
"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": "Не удалось отправить ответ"
|
||||
"invite_body_generic": "Новое приглашение"
|
||||
},
|
||||
"Bots": {
|
||||
"not_connected_title": "{{name}} не подключён",
|
||||
|
|
@ -987,15 +996,5 @@
|
|||
"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": "Отменить"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"start_url": "./",
|
||||
"background_color": "#0d0e11",
|
||||
"theme_color": "#0d0e11",
|
||||
"background_color": "#000",
|
||||
"theme_color": "#000",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./public/android/vojo.svg",
|
||||
|
|
|
|||
|
|
@ -1,400 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0d0e11" />
|
||||
<meta name="robots" content="index,follow" />
|
||||
<title>Vojo — Privacy Policy</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0e11;
|
||||
--panel: #181a20;
|
||||
--surface: #21232b;
|
||||
--text: #e6e6e9;
|
||||
--text-strong: #f4f4f6;
|
||||
--muted: rgba(230, 230, 233, 0.62);
|
||||
--faint: rgba(230, 230, 233, 0.38);
|
||||
--divider: rgba(255, 255, 255, 0.08);
|
||||
--fleet: #9580ff;
|
||||
--fleet-soft: #a59cff;
|
||||
color-scheme: dark;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, "SF Pro Text", "Inter", system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
body {
|
||||
min-height: 100vh;
|
||||
line-height: 1.7;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.frame {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 56px 28px 120px;
|
||||
}
|
||||
|
||||
header.doc {
|
||||
padding-bottom: 28px;
|
||||
margin-bottom: 40px;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-bottom: 22px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.brand-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 99px;
|
||||
background: var(--fleet);
|
||||
}
|
||||
.brand-name { color: var(--text-strong); font-weight: 600; }
|
||||
.brand-sep { color: var(--faint); }
|
||||
|
||||
h1 {
|
||||
font-size: 34px;
|
||||
line-height: 1.15;
|
||||
font-weight: 600;
|
||||
margin: 0 0 10px;
|
||||
letter-spacing: -0.6px;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.effective { color: var(--muted); font-size: 14px; margin: 0; }
|
||||
|
||||
.lang-switch {
|
||||
display: inline-flex;
|
||||
margin-top: 24px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.lang-switch button {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 4px 0;
|
||||
font: inherit;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: color .15s ease;
|
||||
}
|
||||
.lang-switch button:hover { color: var(--text); }
|
||||
.lang-switch button[aria-pressed="true"] {
|
||||
color: var(--text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
.lang-switch .sep {
|
||||
padding: 0 10px;
|
||||
color: var(--faint);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 44px 0 12px;
|
||||
letter-spacing: -0.2px;
|
||||
color: var(--text-strong);
|
||||
scroll-margin-top: 24px;
|
||||
}
|
||||
|
||||
p { margin: 0 0 14px; color: var(--text); }
|
||||
ul {
|
||||
margin: 0 0 18px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
ul li { margin: 8px 0; padding-left: 4px; }
|
||||
ul li::marker { color: var(--faint); }
|
||||
ul li b, p b { color: var(--text-strong); font-weight: 600; }
|
||||
|
||||
a {
|
||||
color: var(--fleet-soft);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid rgba(165, 156, 255, 0.35);
|
||||
transition: color .15s ease, border-color .15s ease;
|
||||
}
|
||||
a:hover {
|
||||
color: #c0b9ff;
|
||||
border-bottom-color: rgba(192, 185, 255, 0.7);
|
||||
}
|
||||
|
||||
section[hidden] { display: none; }
|
||||
section > h2:first-of-type { margin-top: 0; }
|
||||
|
||||
footer.doc {
|
||||
margin-top: 64px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--divider);
|
||||
font-size: 13px;
|
||||
color: var(--faint);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
footer.doc .copy {
|
||||
font-family: ui-monospace, "JetBrains Mono", monospace;
|
||||
}
|
||||
footer.doc a { color: var(--muted); border-bottom-color: transparent; }
|
||||
footer.doc a:hover { color: var(--text); border-bottom-color: var(--divider); }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.frame { padding: 36px 20px 96px; }
|
||||
h1 { font-size: 28px; }
|
||||
h2 { font-size: 18px; margin: 36px 0 10px; }
|
||||
body { font-size: 15.5px; line-height: 1.65; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="frame">
|
||||
|
||||
<header class="doc">
|
||||
<div class="brand">
|
||||
<span class="brand-dot" aria-hidden="true"></span>
|
||||
<span class="brand-name">Vojo</span>
|
||||
<span class="brand-sep">·</span>
|
||||
<span>Legal</span>
|
||||
</div>
|
||||
|
||||
<h1 data-i18n-h1>Privacy Policy</h1>
|
||||
<p class="effective" data-i18n-effective>Effective 13 May 2026</p>
|
||||
|
||||
<div class="lang-switch" role="group" aria-label="Language">
|
||||
<button type="button" data-lang="en" aria-pressed="true">English</button>
|
||||
<span class="sep" aria-hidden="true">/</span>
|
||||
<button type="button" data-lang="ru" aria-pressed="false">Русский</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section lang="en" data-lang="en">
|
||||
<p>This is the privacy policy for <b>Vojo</b>, a chat app built on the open
|
||||
<a href="https://matrix.org" rel="noopener">Matrix</a> protocol. It's maintained by
|
||||
the Vojo Project, an independent developer. If you have questions about anything
|
||||
here, write to <a href="mailto:vojochatdev@gmail.com">vojochatdev@gmail.com</a>.</p>
|
||||
|
||||
<p>We try to keep this short and readable. If something is unclear, ask.</p>
|
||||
|
||||
<h2>How Vojo works, briefly</h2>
|
||||
<p>Your messages, profile and rooms live on a Matrix server. By default that's
|
||||
<code>vojo.chat</code>, 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.</p>
|
||||
|
||||
<h2>What we hold and what we use it for</h2>
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h2>Who else is involved</h2>
|
||||
<ul>
|
||||
<li><b>Our hosting provider.</b> The
|
||||
<a href="https://www.hostinger.com" rel="noopener">Hostinger</a>
|
||||
infrastructure carrying <code>vojo.chat</code> sits in the European Union.</li>
|
||||
<li><b>Google's push service.</b> 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.</li>
|
||||
<li><b>Bot checks.</b> 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.</li>
|
||||
<li><b>Optional bridges.</b> 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.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Permissions on your phone</h2>
|
||||
<p>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.</p>
|
||||
|
||||
<h2>How long we keep things</h2>
|
||||
<p>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.</p>
|
||||
|
||||
<p>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.</p>
|
||||
|
||||
<h2>Your rights</h2>
|
||||
<p>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.</p>
|
||||
|
||||
<h2>Kids, changes, contact</h2>
|
||||
<p>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 <a href="https://vojo.chat/privacy">vojo.chat/privacy</a>. For
|
||||
anything else: <a href="mailto:vojochatdev@gmail.com">vojochatdev@gmail.com</a>.</p>
|
||||
</section>
|
||||
|
||||
<section lang="ru" data-lang="ru" hidden>
|
||||
<p>Это политика конфиденциальности <b>Vojo</b> — чат-приложения на открытом
|
||||
протоколе <a href="https://matrix.org" rel="noopener">Matrix</a>. Его поддерживает
|
||||
проект Vojo, независимый разработчик. Если по тексту возникают вопросы — пишите
|
||||
на <a href="mailto:vojochatdev@gmail.com">vojochatdev@gmail.com</a>.</p>
|
||||
|
||||
<p>Постарались уложиться в нормальный читаемый объём. Если что-то непонятно —
|
||||
спрашивайте.</p>
|
||||
|
||||
<h2>Как устроено</h2>
|
||||
<p>Ваши сообщения, профиль и список комнат живут на Matrix-сервере. По умолчанию
|
||||
это <code>vojo.chat</code>, который держим мы. Вы можете войти на любой другой
|
||||
Matrix-сервер, которому доверяете — если так, оператором ваших данных будет тот
|
||||
сервер, не мы.</p>
|
||||
|
||||
<h2>Что у нас лежит и зачем</h2>
|
||||
<p>Чтобы приложение работало, у нас лежат предсказуемые вещи: ваш аккаунт, ваши
|
||||
сообщения и комнаты, медиа, и базовые технические данные (IP, время запросов),
|
||||
которые возникают, когда устройство разговаривает с нашими серверами. На самом
|
||||
устройстве лежит локальный кэш сообщений и ключей — чтобы можно было читать
|
||||
офлайн и не входить заново каждый раз.</p>
|
||||
|
||||
<p>Личные переписки по умолчанию защищены end-to-end шифрованием. В зашифрованной
|
||||
комнате мы видим, кто кому пишет и когда, но не видим, что именно. В
|
||||
незашифрованных комнатах мы видим и содержимое.</p>
|
||||
|
||||
<p>Голосовые звонки шифруются между участниками. Если устройство не может
|
||||
дотянуться до собеседника напрямую, аудио ретранслируется через нашу
|
||||
инфраструктуру по пути — мы его не записываем и не храним.</p>
|
||||
|
||||
<p>Все эти данные мы используем для того, чтобы сервис работал: доставка
|
||||
сообщений, синхронизация устройств, входящие звонки, ограниченные логи для борьбы
|
||||
со спамом и злоупотреблениями. Это весь список. Никакой рекламы, аналитики,
|
||||
перепродажи или профилирования.</p>
|
||||
|
||||
<h2>Кто ещё в этом участвует</h2>
|
||||
<ul>
|
||||
<li><b>Наш хостинг.</b>
|
||||
<a href="https://www.hostinger.com" rel="noopener">Hostinger</a> держит
|
||||
инфраструктуру <code>vojo.chat</code> в Европейском союзе.</li>
|
||||
<li><b>Push-сервис Google.</b> Push-уведомления идут через Google, чтобы
|
||||
телефон проснулся и зазвонил. Для зашифрованных переписок уведомление
|
||||
несёт только маршрутную информацию, нужную для того чтобы подгрузить
|
||||
сообщение локально — содержимое остаётся на Matrix-сервере. Для
|
||||
незашифрованных Google может видеть короткий предпросмотр (кто, где,
|
||||
фрагмент). Это единственный регулярный случай, когда данные выходят за
|
||||
пределы ЕС; передача идёт по Стандартным договорным условиям Европейской
|
||||
комиссии.</li>
|
||||
<li><b>Капча.</b> При регистрации и в паре дополнительных функций ненадолго
|
||||
подгружается сторонняя проверка «вы не робот». Этот провайдер видит ваше
|
||||
взаимодействие с капчей и регулируется собственной политикой.</li>
|
||||
<li><b>Опциональные мосты.</b> Если вы решите подключить Telegram, Discord
|
||||
или WhatsApp через Vojo, сообщения с этими сетями неизбежно проходят
|
||||
через мостовую инфраструктуру, которую держим мы, и сама сеть тоже их
|
||||
видит. Без вашего явного действия это не включается.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Разрешения на телефоне</h2>
|
||||
<p>На Android приложение просит: микрофон (используется только во время звонка);
|
||||
уведомления (чтобы показывать сообщения и звонки); право показывать звонок поверх
|
||||
локскрина и держать его при выключенном экране; доступ к сети. И всё. Мы не
|
||||
трогаем адресную книгу, фотогалерею, SMS, точную геолокацию и журнал вызовов.</p>
|
||||
|
||||
<h2>Сколько мы это храним</h2>
|
||||
<p>Сообщения и аккаунт лежат на Matrix-сервере до тех пор, пока вы их не
|
||||
удалите или не попросите деактивировать аккаунт. Удаление обрабатывается в
|
||||
течение тридцати дней. Журналы доступа на сервере хранятся не более тридцати
|
||||
дней, затем уходят на ротацию.</p>
|
||||
|
||||
<p>Кэш на устройстве пропадает, когда вы удаляете Vojo или очищаете его данные в
|
||||
настройках телефона. Выход из аккаунта прекращает сессию, но не всегда подчищает
|
||||
весь кэш сразу — самый чистый способ обнулиться — это переустановка.</p>
|
||||
|
||||
<h2>Ваши права</h2>
|
||||
<p>Если вы живёте в ЕС/ЕЭЗ (а во многих других местах закон работает похоже), вы
|
||||
можете попросить нас показать, что у нас лежит, поправить неверное, удалить,
|
||||
отдать в переносимом виде, остановить конкретное использование. Можно отозвать
|
||||
согласие на дополнительные функции и пожаловаться в местный надзорный орган, если
|
||||
кажется, что мы что-то делаем не так. Напишите на адрес сверху, и пойдём
|
||||
разбираться.</p>
|
||||
|
||||
<h2>Дети, изменения, контакты</h2>
|
||||
<p>Vojo не рассчитан на людей младше 16 лет, и мы сознательно не собираем данные
|
||||
детей. Если что-то поменяется так, что это вас реально касается, обновим дату в
|
||||
начале и постараемся отметить это в самом приложении. Текущая версия всегда
|
||||
лежит по адресу <a href="https://vojo.chat/privacy">vojo.chat/privacy</a>. По
|
||||
любым другим вопросам:
|
||||
<a href="mailto:vojochatdev@gmail.com">vojochatdev@gmail.com</a>.</p>
|
||||
</section>
|
||||
|
||||
<footer class="doc">
|
||||
<span class="copy">© Vojo Project · 2026</span>
|
||||
<a href="https://vojo.chat">vojo.chat</a>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var buttons = document.querySelectorAll('.lang-switch button');
|
||||
var sections = document.querySelectorAll('section[data-lang]');
|
||||
var h1 = document.querySelector('[data-i18n-h1]');
|
||||
var eff = document.querySelector('[data-i18n-effective]');
|
||||
var H1 = { en: 'Privacy Policy', ru: 'Политика конфиденциальности' };
|
||||
var EFF = { en: 'Effective 13 May 2026', ru: 'Действует с 13 мая 2026 г.' };
|
||||
function setLang(lang) {
|
||||
buttons.forEach(function (b) {
|
||||
b.setAttribute('aria-pressed', String(b.dataset.lang === lang));
|
||||
});
|
||||
sections.forEach(function (s) {
|
||||
s.hidden = s.dataset.lang !== lang;
|
||||
});
|
||||
if (h1 && H1[lang]) h1.textContent = H1[lang];
|
||||
if (eff && EFF[lang]) eff.textContent = EFF[lang];
|
||||
document.documentElement.lang = lang;
|
||||
document.title = (lang === 'ru' ? 'Vojo — Политика конфиденциальности' : 'Vojo — Privacy Policy');
|
||||
try { localStorage.setItem('vojo-privacy-lang', lang); } catch (e) {}
|
||||
}
|
||||
buttons.forEach(function (b) {
|
||||
b.addEventListener('click', function () { setLang(b.dataset.lang); });
|
||||
});
|
||||
var stored = null;
|
||||
try { stored = localStorage.getItem('vojo-privacy-lang'); } catch (e) {}
|
||||
setLang(stored || 'en');
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 160 KiB |
|
|
@ -51,18 +51,6 @@ 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
|
||||
|
|
@ -71,13 +59,9 @@ 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 = {
|
||||
|
|
@ -131,7 +115,7 @@ function verifyParity(bundles) {
|
|||
const locales = Object.keys(bundles);
|
||||
const [first, ...rest] = locales;
|
||||
const firstKeys = new Set(Object.keys(bundles[first]));
|
||||
rest.forEach((locale) => {
|
||||
for (const locale of rest) {
|
||||
const keys = new Set(Object.keys(bundles[locale]));
|
||||
const missingInOther = [...firstKeys].filter((k) => !keys.has(k));
|
||||
const extraInOther = [...keys].filter((k) => !firstKeys.has(k));
|
||||
|
|
@ -142,13 +126,13 @@ function verifyParity(bundles) {
|
|||
` Extra in ${locale}: ${JSON.stringify(extraInOther)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
ANDROID_KEYS.forEach((key) => {
|
||||
locales.forEach((locale) => {
|
||||
}
|
||||
for (const key of ANDROID_KEYS) {
|
||||
for (const locale of locales) {
|
||||
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.
|
||||
|
|
@ -162,7 +146,7 @@ function verifyParity(bundles) {
|
|||
return { locale, tokens };
|
||||
});
|
||||
const baseline = tokenSets[0];
|
||||
tokenSets.slice(1).forEach((entry) => {
|
||||
for (const entry of tokenSets.slice(1)) {
|
||||
const baselineArr = [...baseline.tokens].sort();
|
||||
const entryArr = [...entry.tokens].sort();
|
||||
if (baselineArr.length !== entryArr.length || baselineArr.some((t, i) => t !== entryArr[i])) {
|
||||
|
|
@ -172,8 +156,8 @@ function verifyParity(bundles) {
|
|||
`${entry.locale}=${JSON.stringify(entryArr)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emitResource(locale, bundle, resDir) {
|
||||
|
|
@ -186,12 +170,12 @@ function emitResource(locale, bundle, resDir) {
|
|||
'-->',
|
||||
'<resources>',
|
||||
];
|
||||
ANDROID_KEYS.forEach((key) => {
|
||||
for (const key of ANDROID_KEYS) {
|
||||
const raw = bundle[key];
|
||||
const { text, placeholders } = convertPlaceholders(raw, locale, key);
|
||||
const formattedAttr = placeholders.size > 0 ? ' formatted="true"' : '';
|
||||
lines.push(` <string name="push_${key}"${formattedAttr}>${xmlEscape(text)}</string>`);
|
||||
});
|
||||
}
|
||||
lines.push('</resources>');
|
||||
lines.push('');
|
||||
const outPath = path.join(resDir, LANGS[locale], 'push_strings.xml');
|
||||
|
|
@ -207,15 +191,15 @@ function main() {
|
|||
}
|
||||
const resDir = outIdx !== -1 ? path.resolve(process.argv[outIdx + 1]) : DEFAULT_OUT;
|
||||
|
||||
const bundles = Object.keys(LANGS).reduce((acc, locale) => {
|
||||
acc[locale] = readBundle(locale);
|
||||
return acc;
|
||||
}, {});
|
||||
const bundles = {};
|
||||
for (const locale of Object.keys(LANGS)) {
|
||||
bundles[locale] = readBundle(locale);
|
||||
}
|
||||
verifyParity(bundles);
|
||||
Object.keys(LANGS).forEach((locale) => {
|
||||
for (const locale of Object.keys(LANGS)) {
|
||||
const outPath = emitResource(locale, bundles[locale], resDir);
|
||||
process.stdout.write(` wrote ${path.relative(ROOT, outPath)}\n`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
|
|||
>
|
||||
{stageToComplete.type === AuthType.Password && (
|
||||
<PasswordStage
|
||||
userId={mx.getSafeUserId()}
|
||||
userId={mx.getUserId()!}
|
||||
stageData={stageToComplete}
|
||||
onCancel={onCancel}
|
||||
submitAuthDict={action}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ function makeUIAAction<T>(
|
|||
authData: IAuthData,
|
||||
performAction: PerformAction<T>,
|
||||
resolve: (data: T) => void,
|
||||
reject: (error?: unknown) => void
|
||||
reject: (error?: any) => void
|
||||
): UIAAction<T> {
|
||||
const action: UIAAction<T> = {
|
||||
authData,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export const ImageOverlay = as<'div', ImageOverlayProps>(
|
|||
<Modal
|
||||
className={ModalWide}
|
||||
size="500"
|
||||
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
>
|
||||
{renderViewer({
|
||||
src,
|
||||
|
|
|
|||
|
|
@ -20,17 +20,7 @@ export function Modal500({ requestClose, children }: Modal500Props) {
|
|||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal
|
||||
size="500"
|
||||
variant="Background"
|
||||
// Reset `--vojo-safe-top` for everything mounted inside the
|
||||
// dialog. The Android status-bar inset is reserved by each
|
||||
// page header's `padding-top: var(--vojo-safe-top)` for
|
||||
// top-of-screen surfaces — but a centred 500px modal sits
|
||||
// away from the screen edge, and the same padding inside it
|
||||
// just adds dead space above its header.
|
||||
style={{ ['--vojo-safe-top' as string]: '0px' }}
|
||||
>
|
||||
<Modal size="500" variant="Background">
|
||||
{/* PageRoot rendered inside the dialog (Settings,
|
||||
SpaceSettings, RoomSettings) would otherwise pick up
|
||||
the web horseshoe layout — void column + rounded
|
||||
|
|
|
|||
|
|
@ -65,12 +65,6 @@ 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
|
||||
// `<Overlay>` 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,
|
||||
|
|
@ -84,7 +78,6 @@ export function RenderMessageContent({
|
|||
htmlReactParserOptions,
|
||||
linkifyOpts,
|
||||
outlineAttachment,
|
||||
eventId,
|
||||
}: RenderMessageContentProps) {
|
||||
const streamMedia = useStreamMediaContext();
|
||||
const renderUrlsPreview = (urls: string[]) => {
|
||||
|
|
@ -226,7 +219,6 @@ export function RenderMessageContent({
|
|||
<ImageContent
|
||||
{...props}
|
||||
autoPlay={mediaAutoLoad}
|
||||
eventId={eventId}
|
||||
renderImage={(p) => <Image {...p} loading="lazy" decoding="async" />}
|
||||
renderViewer={(p) => <ImageViewer {...p} />}
|
||||
/>
|
||||
|
|
@ -266,7 +258,6 @@ export function RenderMessageContent({
|
|||
body={body}
|
||||
info={info}
|
||||
{...props}
|
||||
eventId={eventId}
|
||||
renderThumbnail={
|
||||
mediaAutoLoad
|
||||
? () => (
|
||||
|
|
|
|||
|
|
@ -39,8 +39,6 @@ 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) {
|
||||
|
|
@ -133,8 +131,6 @@ 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) {
|
||||
|
|
|
|||
|
|
@ -34,9 +34,6 @@ 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ 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';
|
||||
|
|
@ -18,7 +17,7 @@ export const createRoomCreationContent = (
|
|||
allowFederation: boolean,
|
||||
additionalCreators: string[] | undefined
|
||||
): object => {
|
||||
const content: Record<string, unknown> = {};
|
||||
const content: Record<string, any> = {};
|
||||
if (typeof type === 'string') {
|
||||
content.type = type;
|
||||
}
|
||||
|
|
@ -153,11 +152,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
|||
if (data.parent) {
|
||||
await mx.sendStateEvent(
|
||||
data.parent.roomId,
|
||||
StateEvent.SpaceChild as keyof StateEvents,
|
||||
StateEvent.SpaceChild as any,
|
||||
{
|
||||
auto_join: false,
|
||||
suggested: false,
|
||||
via: [getMxIdServer(mx.getSafeUserId()) ?? ''],
|
||||
via: [getMxIdServer(mx.getUserId() ?? '') ?? ''],
|
||||
},
|
||||
result.room_id
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.getSafeUserId())}`;
|
||||
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
||||
|
||||
function UnknownRoomMentionItem({
|
||||
query,
|
||||
|
|
|
|||
|
|
@ -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.getSafeUserId())}`;
|
||||
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
||||
|
||||
function UnknownMentionItem({
|
||||
userId,
|
||||
|
|
@ -92,7 +92,7 @@ export function UserMentionAutocomplete({
|
|||
}: UserMentionAutocompleteProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const { roomId } = room;
|
||||
const roomId: string = room.roomId!;
|
||||
const roomAliasOrId = room.getCanonicalAlias() || roomId;
|
||||
const members = useRoomMembers(mx, roomId);
|
||||
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||
key={readerId}
|
||||
style={{ padding: `0 ${config.space.S200}` }}
|
||||
radii="400"
|
||||
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick={(event) => {
|
||||
openProfile(
|
||||
room.roomId,
|
||||
space?.roomId,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ type RoomImagePackProps = {
|
|||
|
||||
export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getSafeUserId();
|
||||
const userId = mx.getUserId()!;
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const creators = useRoomCreators(room);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { useUserImagePack } from '../../hooks/useImagePacks';
|
|||
export function UserImagePack() {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const defaultPack = useMemo(() => new ImagePack(mx.getSafeUserId(), {}, undefined), [mx]);
|
||||
const defaultPack = useMemo(() => new ImagePack(mx.getUserId() ?? '', {}, undefined), [mx]);
|
||||
const imagePack = useUserImagePack();
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
|
|
|
|||
133
src/app/components/join-address-prompt/JoinAddressPrompt.tsx
Normal file
133
src/app/components/join-address-prompt/JoinAddressPrompt.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
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<HTMLFormElement> = (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 (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: onCancel,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Dialog variant="Surface">
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
}}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="H4">{t('Home.join_with_address')}</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={onCancel} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box
|
||||
as="form"
|
||||
onSubmit={handleSubmit}
|
||||
style={{ padding: config.space.S400, paddingTop: 0 }}
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<Box direction="Column" gap="200">
|
||||
<Text priority="400" size="T300">
|
||||
{t('Home.join_address_desc')}
|
||||
</Text>
|
||||
<Text as="ul" size="T200" priority="300" style={{ paddingLeft: config.space.S400 }}>
|
||||
<li>#community:server</li>
|
||||
<li>https://matrix.to/#/#community:server</li>
|
||||
<li>https://matrix.to/#/!xYzAj?via=server</li>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">{t('Home.address')}</Text>
|
||||
<Input
|
||||
size="500"
|
||||
autoFocus
|
||||
name="addressInput"
|
||||
variant="Background"
|
||||
placeholder="#community:server"
|
||||
required
|
||||
/>
|
||||
{invalid && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
<b>{t('Home.invalid_address')}</b>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Button type="submit" variant="Primary">
|
||||
<Text size="B400">{t('Home.open')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
1
src/app/components/join-address-prompt/index.ts
Normal file
1
src/app/components/join-address-prompt/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './JoinAddressPrompt';
|
||||
|
|
@ -32,8 +32,6 @@ function computeBoxStyle(naturalW?: number, naturalH?: number): React.CSSPropert
|
|||
// `object-fit: cover` (image) / `contain` (video) on the inner element.
|
||||
const naturalAspect = naturalW && naturalH ? naturalW / naturalH : NaN;
|
||||
if (
|
||||
!naturalW ||
|
||||
!naturalH ||
|
||||
!Number.isFinite(naturalAspect) ||
|
||||
naturalAspect < STREAM_MEDIA_MIN_ASPECT ||
|
||||
naturalAspect > STREAM_MEDIA_MAX_ASPECT
|
||||
|
|
@ -41,10 +39,10 @@ function computeBoxStyle(naturalW?: number, naturalH?: number): React.CSSPropert
|
|||
return { width: toRem(STREAM_MEDIA_MAX_DIM), height: toRem(STREAM_MEDIA_MAX_DIM) };
|
||||
}
|
||||
if (naturalAspect >= 1) {
|
||||
const w = Math.min(STREAM_MEDIA_MAX_DIM, naturalW);
|
||||
const w = Math.min(STREAM_MEDIA_MAX_DIM, naturalW!);
|
||||
return { width: toRem(w), height: toRem(w / naturalAspect) };
|
||||
}
|
||||
const h = Math.min(STREAM_MEDIA_MAX_DIM, naturalH);
|
||||
const h = Math.min(STREAM_MEDIA_MAX_DIM, naturalH!);
|
||||
return { width: toRem(h * naturalAspect), height: toRem(h) };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
|
|||
<Modal
|
||||
className={ModalWide}
|
||||
size="500"
|
||||
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
>
|
||||
{renderViewer({
|
||||
name: body,
|
||||
|
|
@ -203,7 +203,7 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
|
|||
<Modal
|
||||
className={ModalWide}
|
||||
size="500"
|
||||
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
>
|
||||
{renderViewer({
|
||||
name: body,
|
||||
|
|
|
|||
|
|
@ -31,8 +31,6 @@ 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;
|
||||
|
|
@ -46,17 +44,7 @@ type RenderImageProps = {
|
|||
onLoad: () => void;
|
||||
onError: () => void;
|
||||
onClick: () => void;
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLImageElement>) => 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 `<AccessibleButton>` — we keep the bare `<img>` 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;
|
||||
|
|
@ -67,13 +55,6 @@ 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 `<Overlay>` 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;
|
||||
};
|
||||
|
|
@ -89,7 +70,6 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||
autoPlay,
|
||||
markedAsSpoiler,
|
||||
spoilerReason,
|
||||
eventId,
|
||||
renderViewer,
|
||||
renderImage,
|
||||
...props
|
||||
|
|
@ -99,37 +79,12 @@ 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);
|
||||
|
|
@ -163,7 +118,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||
|
||||
return (
|
||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||
{!useAtomViewer && srcState.status === AsyncStatus.Success && (
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
|
|
@ -177,7 +132,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||
<Modal
|
||||
className={ModalWide}
|
||||
size="500"
|
||||
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
>
|
||||
{renderViewer({
|
||||
src: srcState.data,
|
||||
|
|
@ -213,25 +168,15 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||
</Box>
|
||||
)}
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Box
|
||||
className={classNames(css.AbsoluteContainer, blurred ? css.Blur : css.ImageClickable)}
|
||||
>
|
||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||
{renderImage({
|
||||
alt: body,
|
||||
title: body,
|
||||
src: srcState.data,
|
||||
onLoad: handleLoad,
|
||||
onError: handleError,
|
||||
onClick: handleOpen,
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleOpen();
|
||||
}
|
||||
},
|
||||
onClick: () => setViewer(true),
|
||||
tabIndex: 0,
|
||||
role: 'button',
|
||||
'aria-label': body || 'Open media',
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -32,8 +32,6 @@ 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;
|
||||
|
|
@ -52,14 +50,6 @@ 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;
|
||||
};
|
||||
|
|
@ -75,7 +65,6 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||
autoPlay,
|
||||
markedAsSpoiler,
|
||||
spoilerReason,
|
||||
eventId,
|
||||
renderThumbnail,
|
||||
renderVideo,
|
||||
...props
|
||||
|
|
@ -85,9 +74,6 @@ 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);
|
||||
|
|
@ -120,29 +106,8 @@ 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, 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]);
|
||||
}, [autoPlay, loadSrc]);
|
||||
|
||||
return (
|
||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||
|
|
@ -164,21 +129,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||
{renderThumbnail()}
|
||||
</Box>
|
||||
)}
|
||||
{useAtomViewer && !blurred && (
|
||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
size="300"
|
||||
onClick={openAtomViewer}
|
||||
before={<Icon size="Inherit" src={Icons.Play} filled />}
|
||||
>
|
||||
<Text size="B300">Watch</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
{!useAtomViewer && !autoPlay && !blurred && srcState.status === AsyncStatus.Idle && (
|
||||
{!autoPlay && !blurred && srcState.status === AsyncStatus.Idle && (
|
||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||
<Button
|
||||
variant="Secondary"
|
||||
|
|
@ -192,7 +143,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
{!useAtomViewer && srcState.status === AsyncStatus.Success && (
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||
{renderVideo({
|
||||
title: body,
|
||||
|
|
|
|||
|
|
@ -1,36 +1,6 @@
|
|||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, config } from 'folds';
|
||||
|
||||
// Click affordance for the timeline image thumbnail. Without this
|
||||
// the `<img>` 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 `<img>` (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,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
// 36px circular avatar — a notch above folds `Avatar size="200"` (32px)
|
||||
|
|
@ -121,106 +121,3 @@ 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 — matches Stream layout's `peerBg`
|
||||
// variant. 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 `StreamBubbleHeader`'s
|
||||
// 2px gap.
|
||||
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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,16 +25,6 @@ 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;
|
||||
// When true, the header is rendered INSIDE the message-body slot
|
||||
// (above content) instead of as a sibling row above the body. Thread
|
||||
// drawer flips this on so the bubble wraps the username + time the
|
||||
// same way `StreamBubble` does in DM chat. Channels main timeline
|
||||
// keeps this false — name + time stay above an unbordered body.
|
||||
headerInBubble?: boolean;
|
||||
onContextMenu?: MouseEventHandler<HTMLDivElement>;
|
||||
};
|
||||
|
||||
|
|
@ -43,50 +33,20 @@ export type ChannelLayoutProps = {
|
|||
// thread-summary, reactions in vertical flow.
|
||||
export const ChannelLayout = as<'div', ChannelLayoutProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
avatar,
|
||||
header,
|
||||
reactions,
|
||||
threadSummary,
|
||||
isOwn,
|
||||
headerInBubble,
|
||||
onContextMenu,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
{ className, avatar, header, reactions, threadSummary, onContextMenu, children, ...props },
|
||||
ref
|
||||
) => (
|
||||
<div
|
||||
className={classNames(css.ChannelRow, className)}
|
||||
onContextMenu={onContextMenu}
|
||||
data-own={isOwn ? 'true' : 'false'}
|
||||
data-bubble={headerInBubble ? 'true' : undefined}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<div className={css.ChannelAvatarSlot}>{avatar}</div>
|
||||
<div className={css.ChannelBody}>
|
||||
{!headerInBubble && header && <div className={css.ChannelHeader}>{header}</div>}
|
||||
<div className={css.ChannelMessageBody}>
|
||||
{headerInBubble && header && (
|
||||
<div className={css.ChannelHeader} data-in-bubble="true">
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
{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.
|
||||
<div className={css.ChannelBubbleThreadSummary}>{threadSummary}</div>
|
||||
)}
|
||||
</div>
|
||||
{!headerInBubble && threadSummary && (
|
||||
<div className={css.ChannelThreadSummary}>{threadSummary}</div>
|
||||
)}
|
||||
{header && <div className={css.ChannelHeader}>{header}</div>}
|
||||
<div className={css.ChannelMessageBody}>{children}</div>
|
||||
{threadSummary && <div className={css.ChannelThreadSummary}>{threadSummary}</div>}
|
||||
{reactions && <div className={css.ChannelReactions}>{reactions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -32,10 +32,6 @@ export type StreamLayoutProps = {
|
|||
dotColor: string;
|
||||
dotOpacity: number;
|
||||
isOwn?: boolean;
|
||||
// Peer (not-own) bubble bg — caller passes `!isOwn` so every
|
||||
// «чужое» сообщение reshades to `--vojo-peer-bubble-bg`. Applies
|
||||
// in 1-1 DMs, groups, channels alike. No effect for own messages.
|
||||
peerBg?: boolean;
|
||||
compact?: boolean;
|
||||
header?: ReactNode;
|
||||
railStart?: boolean;
|
||||
|
|
@ -104,7 +100,6 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
|
|||
dotColor,
|
||||
dotOpacity,
|
||||
isOwn,
|
||||
peerBg,
|
||||
compact,
|
||||
header,
|
||||
railStart,
|
||||
|
|
@ -174,7 +169,6 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
|
|||
className={css.StreamBubble({
|
||||
own: !!isOwn,
|
||||
compact: !!compact,
|
||||
peerBg: !!peerBg,
|
||||
mediaMode: !!mediaMode,
|
||||
})}
|
||||
ref={bubbleRef}
|
||||
|
|
|
|||
|
|
@ -283,7 +283,7 @@ export const StreamRail = style({
|
|||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: StreamRailLineWidth,
|
||||
background: 'var(--vojo-timeline-rail)',
|
||||
background: color.Surface.Container,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
});
|
||||
|
|
@ -424,15 +424,14 @@ export const StreamBubble = recipe({
|
|||
zIndex: 1,
|
||||
},
|
||||
variants: {
|
||||
// Asymmetric notch — own: bottom-left flat, three corners R500+.
|
||||
// Incoming: top-left flat, three corners R500+. Mirrored on the
|
||||
// vertical axis so own/peer read as opposing silhouettes.
|
||||
// Asymmetric notch — own: top-left flat, three corners R500.
|
||||
// Incoming: mirrored.
|
||||
own: {
|
||||
true: {
|
||||
borderRadius: `${toRem(16)} ${toRem(16)} ${toRem(16)} ${toRem(4)}`,
|
||||
borderRadius: `${toRem(4)} ${config.radii.R500} ${config.radii.R500} ${config.radii.R500}`,
|
||||
},
|
||||
false: {
|
||||
borderRadius: `${toRem(4)} ${toRem(16)} ${toRem(16)} ${toRem(16)}`,
|
||||
borderRadius: `${config.radii.R500} ${config.radii.R500} ${config.radii.R500} ${toRem(4)}`,
|
||||
},
|
||||
},
|
||||
// Mobile fills the message column (block 100%); desktop fits content
|
||||
|
|
@ -453,14 +452,6 @@ export const StreamBubble = recipe({
|
|||
paddingRight: toRem(15),
|
||||
},
|
||||
},
|
||||
// Peer (not-own) bubble bg — differentiation between «я» and
|
||||
// «не я» across every room class. Media rows neutralize this via
|
||||
// the `peerBg + mediaMode` compound below (order-independent).
|
||||
peerBg: {
|
||||
true: {
|
||||
backgroundColor: 'var(--vojo-peer-bubble-bg)',
|
||||
},
|
||||
},
|
||||
// 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
|
||||
|
|
@ -480,21 +471,9 @@ export const StreamBubble = recipe({
|
|||
},
|
||||
},
|
||||
},
|
||||
// Compound overrides — emitted after all variant classes, so they
|
||||
// win the cascade regardless of variant declaration order. Keeps
|
||||
// peer image / video bubbles transparent (the StreamMediaImage
|
||||
// child supplies the chrome) even though `peerBg` would otherwise
|
||||
// paint `--vojo-peer-bubble-bg` underneath.
|
||||
compoundVariants: [
|
||||
{
|
||||
variants: { peerBg: true, mediaMode: true },
|
||||
style: { backgroundColor: 'transparent' },
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
own: false,
|
||||
compact: false,
|
||||
peerBg: false,
|
||||
mediaMode: false,
|
||||
},
|
||||
});
|
||||
|
|
@ -545,6 +524,7 @@ export const StreamSysline = style({
|
|||
paddingBottom: toRem(2),
|
||||
});
|
||||
|
||||
|
||||
export const StreamSyslineBody = style({
|
||||
fontSize: toRem(11.5),
|
||||
color: color.Surface.OnContainer,
|
||||
|
|
@ -651,7 +631,7 @@ export const StreamDayLineWrap = style({
|
|||
export const StreamDayLineSegment = style({
|
||||
flex: 1,
|
||||
height: 1,
|
||||
background: 'var(--vojo-timeline-rail)',
|
||||
background: color.Surface.Container,
|
||||
minWidth: toRem(8),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
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<MobilePagerPaneInfo | null>(null);
|
||||
|
||||
export const MobilePagerPaneProvider = MobilePagerPaneContext.Provider;
|
||||
|
||||
export function useMobilePagerPane(): MobilePagerPaneInfo | null {
|
||||
return useContext(MobilePagerPaneContext);
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import React 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';
|
||||
import { MobileTabsPager } from './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 `<Outlet/>` 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` <Navigate> 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 <Outlet/> — 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 <Outlet />;
|
||||
}
|
||||
return <MobileTabsPager />;
|
||||
}
|
||||
|
|
@ -1,444 +0,0 @@
|
|||
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<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.inert = !isActive;
|
||||
}, [isActive]);
|
||||
return (
|
||||
<div ref={ref} className={css.pane} aria-hidden={!isActive || undefined}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
// `<Channels>` (workspace listing) keyed to the active space; if no,
|
||||
// `<ChannelsRootNav>` 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
|
||||
// `<Navigate>` 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 `<Outlet/>` 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
|
||||
// `<SpaceProvider value={...}>` 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<Tab[]>(() => {
|
||||
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<Tab | null>(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<HTMLDivElement>(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<React.CSSProperties>(
|
||||
() => ({
|
||||
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 <Outlet />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={css.pagerRoot}>
|
||||
<MobileTabsPagerHeader
|
||||
showBots={showBots}
|
||||
activeTab={activeTab}
|
||||
onSelectDirect={onSelectDirect}
|
||||
onSelectChannels={onSelectChannels}
|
||||
onSelectBots={onSelectBots}
|
||||
/>
|
||||
{/* `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. */}
|
||||
<div className={css.strip} style={stripStyle} data-pager-pane="true">
|
||||
<MobilePagerPaneProvider value={directPaneInfo}>
|
||||
<PaneSlot isActive={directPaneInfo.isActive}>
|
||||
<Direct />
|
||||
</PaneSlot>
|
||||
</MobilePagerPaneProvider>
|
||||
<MobilePagerPaneProvider value={channelsPaneInfo}>
|
||||
<PaneSlot isActive={channelsPaneInfo.isActive}>
|
||||
<ChannelsModeProvider value>
|
||||
{activeSpace ? (
|
||||
<SpaceProvider key={activeSpace.roomId} value={activeSpace}>
|
||||
<Channels />
|
||||
</SpaceProvider>
|
||||
) : (
|
||||
<ChannelsRootNav />
|
||||
)}
|
||||
</ChannelsModeProvider>
|
||||
</PaneSlot>
|
||||
</MobilePagerPaneProvider>
|
||||
{showBots && (
|
||||
<MobilePagerPaneProvider value={botsPaneInfo}>
|
||||
<PaneSlot isActive={botsPaneInfo.isActive}>
|
||||
<Bots />
|
||||
</PaneSlot>
|
||||
</MobilePagerPaneProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
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 (
|
||||
<div
|
||||
className={css.pagerStaticHeader}
|
||||
style={elevated ? { zIndex: PAGER_HEADER_ELEVATED_Z } : undefined}
|
||||
>
|
||||
<div className={streamHeaderCss.tabsRow}>
|
||||
<div className={streamHeaderCss.tabsCluster}>
|
||||
<Segment
|
||||
active={activeTab === 'direct'}
|
||||
label={t('Direct.segment_dm')}
|
||||
onClick={onSelectDirect}
|
||||
/>
|
||||
<Segment
|
||||
active={activeTab === 'channels'}
|
||||
label={t('Direct.segment_channels')}
|
||||
onClick={onSelectChannels}
|
||||
/>
|
||||
{showBots && (
|
||||
<Segment
|
||||
active={activeTab === 'bots'}
|
||||
label={t('Direct.segment_bots')}
|
||||
onClick={onSelectBots}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Box grow="Yes" />
|
||||
{isFormActive ? (
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
size="400"
|
||||
radii="Pill"
|
||||
onClick={closeForm}
|
||||
aria-label={t('Direct.close')}
|
||||
aria-controls={INLINE_FORM_ID}
|
||||
aria-expanded
|
||||
disabled={iconsDisabled}
|
||||
>
|
||||
<Icon size="100" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
) : (
|
||||
<div className={streamHeaderCss.iconsCluster}>
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
size="400"
|
||||
radii="Pill"
|
||||
onClick={primaryAction ? primaryAction.onClick : openChat}
|
||||
aria-label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
|
||||
// See StreamHeader's matching IconButton: drop only
|
||||
// `aria-controls` when the override opens a portal
|
||||
// Modal (no in-subtree form to point at). The
|
||||
// override IS a dialog opener, so `aria-haspopup` +
|
||||
// `aria-expanded={false}` stay accurate either way.
|
||||
aria-controls={primaryAction ? undefined : INLINE_FORM_ID}
|
||||
aria-expanded={false}
|
||||
aria-haspopup="dialog"
|
||||
disabled={iconsDisabled}
|
||||
>
|
||||
<Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
size="400"
|
||||
radii="Pill"
|
||||
onClick={openSearch}
|
||||
aria-label={t('Search.search')}
|
||||
aria-controls={INLINE_FORM_ID}
|
||||
aria-expanded={false}
|
||||
aria-haspopup="dialog"
|
||||
disabled={iconsDisabled}
|
||||
>
|
||||
<Icon size="100" src={Icons.Search} />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
// 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 <Outlet/> 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;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
// `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';
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
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
|
||||
// `<PageRoot>` 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 (`<Text size="L400">` 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,
|
||||
});
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
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<HTMLDivElement | null>;
|
||||
// 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]);
|
||||
}
|
||||
|
|
@ -9,13 +9,17 @@ import React, {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Box, Header, Line, Scroll, Text, as, color, toRem } from 'folds';
|
||||
import { Box, Header, Line, Scroll, Text, as, 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,
|
||||
|
|
@ -78,9 +82,12 @@ 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 — without it the outer void would
|
||||
bleed through. */}
|
||||
<Box grow="Yes" style={{ minWidth: 0, backgroundColor: VOJO_HORSESHOE_VOID_COLOR }}>
|
||||
opaque bg of its own (e.g. ChannelsLanding) — without it
|
||||
the outer void would bleed through. */}
|
||||
<Box
|
||||
grow="Yes"
|
||||
style={{ minWidth: 0, backgroundColor: VOJO_HORSESHOE_VOID_COLOR }}
|
||||
>
|
||||
<Box
|
||||
grow="Yes"
|
||||
className={ContainerColor({ variant: 'Background' })}
|
||||
|
|
@ -110,29 +117,10 @@ export function PageRoot({ nav, children }: PageRootProps) {
|
|||
type ClientDrawerLayoutProps = {
|
||||
children: ReactNode;
|
||||
resizable?: boolean;
|
||||
// Round the inner column's right-side corners (TR + BR). Off by
|
||||
// default — the page-nav right rounding was intentionally dropped
|
||||
// from the standard pattern in commit 74d32eb. Re-enabled only at
|
||||
// callsites that explicitly opt in. Currently unused in the tree
|
||||
// (the Settings nav tried it and was reverted on product feedback),
|
||||
// kept as a primitive for future nested-horseshoe surfaces that
|
||||
// want a fully-rounded "island" nav between two voids.
|
||||
roundedRight?: boolean;
|
||||
// Background surface tone for the inner column. Default
|
||||
// `'background'` reads the standard `Background.Container` (Dawn
|
||||
// bg2 = #0d0e11) — the deepest surface, what every tab's nav uses.
|
||||
// `'surfaceVariant'` swaps to `color.SurfaceVariant.Container`
|
||||
// (Dawn bg = #181a20) — the «raised» chat-pane tone used by 1-1
|
||||
// chats and the composer; visually a step lighter than the DM
|
||||
// list, marking the Settings nav as a distinct surface without
|
||||
// jumping outside the Dawn palette.
|
||||
surface?: 'background' | 'surfaceVariant';
|
||||
};
|
||||
export function PageNav({
|
||||
size,
|
||||
resizable,
|
||||
roundedRight,
|
||||
surface,
|
||||
children,
|
||||
}: ClientDrawerLayoutProps & css.PageNavVariants) {
|
||||
const screenSize = useScreenSizeContext();
|
||||
|
|
@ -140,24 +128,9 @@ export function PageNav({
|
|||
const horseshoe = useHorseshoeEnabled();
|
||||
|
||||
if (resizable && !isMobile) {
|
||||
// `ResizablePageNav` is a function declaration (hoisted) below — the
|
||||
// forward reference is safe at runtime.
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
return <ResizablePageNav>{children}</ResizablePageNav>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box
|
||||
grow={isMobile ? 'Yes' : undefined}
|
||||
|
|
@ -168,19 +141,11 @@ export function PageNav({
|
|||
grow="Yes"
|
||||
direction="Column"
|
||||
className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined}
|
||||
// Top inset for native: `#root` no longer reserves the status-bar
|
||||
// height (src/index.css), so the page-nav extends to the screen
|
||||
// top. The padding here pushes the page-nav header (workspace
|
||||
// tabs, etc.) below the status-bar icons. Applied at the inner
|
||||
// column rather than at the `PageNavHeader` recipe because the
|
||||
// recipe uses a Folds `<Header size="...">` 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,
|
||||
}}
|
||||
// Bottom inset for native: keeps any nav-footer row (SelfRow,
|
||||
// WorkspaceFooter, …) clear of the Android gesture pill / 3-button
|
||||
// bar / iOS home indicator after `#root` stopped reserving the
|
||||
// inset itself. `var(--vojo-safe-bottom)` resolves to 0 on web.
|
||||
style={{ paddingBottom: 'var(--vojo-safe-bottom)' }}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
|
|
@ -193,7 +158,9 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
|
|||
const handleRef = useRef<HTMLDivElement>(null);
|
||||
const horseshoe = useHorseshoeEnabled();
|
||||
const [savedWidth, setSavedWidth] = useAtom(sidebarWidthAtom);
|
||||
const [vw, setVw] = useState<number>(typeof window !== 'undefined' ? window.innerWidth : 1280);
|
||||
const [vw, setVw] = useState<number>(
|
||||
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
|
||||
|
|
@ -306,19 +273,14 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
|
|||
grow="Yes"
|
||||
direction="Column"
|
||||
className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined}
|
||||
// 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)' }}
|
||||
// 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)' }}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
{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
|
||||
<div
|
||||
ref={handleRef}
|
||||
role="separator"
|
||||
|
|
@ -327,7 +289,6 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
|
|||
aria-valuemin={SIDEBAR_WIDTH_MIN}
|
||||
aria-valuemax={maxW}
|
||||
aria-label="Resize sidebar"
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
className={css.PageNavResizeHandle}
|
||||
// On web the page-nav is followed by the horseshoe void gap
|
||||
|
|
@ -382,12 +343,7 @@ export function PageNavContent({
|
|||
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
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.
|
||||
<Box grow="Yes" direction="Column" style={{ minHeight: 0 }}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Scroll
|
||||
ref={scrollRef}
|
||||
variant="Background"
|
||||
|
|
@ -402,29 +358,15 @@ export function PageNavContent({
|
|||
);
|
||||
}
|
||||
|
||||
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 Page = as<'div'>(({ className, ...props }, ref) => (
|
||||
<Box
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
className={classNames(ContainerColor({ variant }), className)}
|
||||
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
);
|
||||
));
|
||||
|
||||
export const PageHeader = as<'div', css.PageHeaderVariants>(
|
||||
({ className, outlined, balance, ...props }, ref) => (
|
||||
|
|
|
|||
|
|
@ -79,14 +79,6 @@ 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: {
|
||||
|
|
|
|||
|
|
@ -256,10 +256,7 @@ export const RoomCard = as<'div', RoomCardProps>(
|
|||
<Box gap="100">
|
||||
<Icon size="50" src={Icons.User} />
|
||||
<Text size="T200">
|
||||
{t('Explore.members_count', {
|
||||
count: joinedMemberCount,
|
||||
formattedCount: millify(joinedMemberCount),
|
||||
})}
|
||||
{t('Explore.members_count', { count: millify(joinedMemberCount) })}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { getMemberDisplayName, getStateEvent } from '../../utils/room';
|
|||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { timeDayMonYear, timeHourMinute } from '../../utils/time';
|
||||
import { timeDayMonthYear, timeHourMinute } from '../../utils/time';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { RoomAvatar } from '../room-avatar';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
|
|
@ -75,7 +75,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
|||
i18nKey="Room.created_by"
|
||||
values={{
|
||||
creator: creatorName,
|
||||
date: timeDayMonYear(ts),
|
||||
date: timeDayMonthYear(ts),
|
||||
time: timeHourMinute(ts),
|
||||
}}
|
||||
components={{ bold: <b /> }}
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={css.chip}
|
||||
tabIndex={hidden ? -1 : 0}
|
||||
aria-hidden={hidden || undefined}
|
||||
>
|
||||
<Icon size="50" src={iconSrc} />
|
||||
<span className={css.chipPlaceholder}>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
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<HTMLButtonElement, SegmentProps>(
|
||||
({ active, disabled, label, onClick }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
onClick={disabled ? undefined : onClick}
|
||||
aria-pressed={active}
|
||||
aria-disabled={disabled || undefined}
|
||||
className={css.segment({ active, disabled })}
|
||||
>
|
||||
<span aria-hidden className={css.segmentDot({ active })} />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
Segment.displayName = 'Segment';
|
||||
|
|
@ -1,349 +0,0 @@
|
|||
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',
|
||||
padding: `0 ${toRem(8)}`,
|
||||
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',
|
||||
gap: toRem(4),
|
||||
alignSelf: 'stretch',
|
||||
});
|
||||
|
||||
export const iconsCluster = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: toRem(4),
|
||||
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).
|
||||
export const segment = recipe({
|
||||
base: {
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: color.Background.OnContainer,
|
||||
cursor: 'pointer',
|
||||
padding: `${toRem(8)} ${toRem(10)}`,
|
||||
borderRadius: toRem(8),
|
||||
font: 'inherit',
|
||||
fontSize: toRem(14),
|
||||
lineHeight: 1.2,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: toRem(8),
|
||||
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)}`,
|
||||
});
|
||||
|
|
@ -1,551 +0,0 @@
|
|||
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<HTMLDivElement | null>;
|
||||
// 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<HTMLDivElement>(null);
|
||||
const curtainRef = useRef<HTMLDivElement>(null);
|
||||
const bottomPinnedRef = useRef<HTMLDivElement>(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,
|
||||
setPinned: curtain.setPinned,
|
||||
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<MobilePagerCurtainControls>(
|
||||
() => ({
|
||||
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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<div className={css.stage} data-platform={isNativePlatform() ? undefined : 'web'}>
|
||||
<header className={css.header}>
|
||||
{/* ── 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. */}
|
||||
<div
|
||||
className={css.tabsRow}
|
||||
style={inPagerMode ? { opacity: 0 } : undefined}
|
||||
aria-hidden={inPagerMode || undefined}
|
||||
>
|
||||
<div className={css.tabsCluster}>
|
||||
<Segment
|
||||
active={!!directMatch}
|
||||
label={t('Direct.segment_dm')}
|
||||
onClick={onSegmentDirect}
|
||||
/>
|
||||
<Segment
|
||||
active={!!channelsMatch}
|
||||
label={t('Direct.segment_channels')}
|
||||
onClick={onSegmentChannels}
|
||||
/>
|
||||
{showBotsSegment && (
|
||||
<Segment
|
||||
active={!!botsMatch}
|
||||
label={t('Direct.segment_bots')}
|
||||
onClick={onSegmentBots}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Box grow="Yes" />
|
||||
{isActive ? (
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
size="400"
|
||||
radii="Pill"
|
||||
onClick={close}
|
||||
aria-label={t('Direct.close')}
|
||||
aria-controls={INLINE_FORM_ID}
|
||||
aria-expanded
|
||||
>
|
||||
<Icon size="100" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
) : (
|
||||
<div className={css.iconsCluster}>
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
size="400"
|
||||
radii="Pill"
|
||||
onClick={primaryAction ? primaryAction.onClick : openChat}
|
||||
aria-label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
|
||||
// `aria-controls` points at the curtain-mounted form
|
||||
// region — drop it when `primaryAction` opens a portal
|
||||
// dialog (`Modal` lives outside this subtree, so there
|
||||
// is nothing to control here). `aria-haspopup="dialog"`
|
||||
// + `aria-expanded={false}` stay accurate for both
|
||||
// branches: the override opens a true Modal dialog.
|
||||
aria-controls={primaryAction ? undefined : INLINE_FORM_ID}
|
||||
aria-expanded={false}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
size="400"
|
||||
radii="Pill"
|
||||
onClick={openSearch}
|
||||
aria-label={t('Search.search')}
|
||||
aria-controls={INLINE_FORM_ID}
|
||||
aria-expanded={false}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<Icon size="100" src={Icons.Search} />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 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 ? (
|
||||
<div
|
||||
id={INLINE_FORM_ID}
|
||||
role="region"
|
||||
aria-label={
|
||||
curtain.activeForm === 'search'
|
||||
? t('Search.search')
|
||||
: t('Direct.create_chat_subtitle')
|
||||
}
|
||||
className={css.formArea}
|
||||
style={{
|
||||
height: toRem(curtain.formHeightPx ?? 0),
|
||||
}}
|
||||
>
|
||||
<div ref={curtain.formMeasureRef} className={css.formInner}>
|
||||
{curtain.activeForm === 'search' && <InlineRoomSearch onClose={close} />}
|
||||
{curtain.activeForm === 'chat' && <InlineNewChatForm onClose={close} />}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={css.chipRow}>
|
||||
<Chip
|
||||
iconSrc={Icons.Search}
|
||||
label={t('Search.search')}
|
||||
onClick={openSearch}
|
||||
hidden={curtain.snap !== 'peek'}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.chipRow}>
|
||||
<Chip
|
||||
iconSrc={primaryAction ? primaryAction.iconSrc : Icons.Plus}
|
||||
label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
|
||||
onClick={primaryAction ? primaryAction.onClick : openChat}
|
||||
hidden={curtain.snap !== 'peek'}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* ── 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. */}
|
||||
<div
|
||||
ref={curtainRef}
|
||||
className={css.curtain}
|
||||
style={{
|
||||
top: toRem(curtainTop),
|
||||
transition: curtain.isDragging ? 'none' : undefined,
|
||||
}}
|
||||
onTransitionEnd={onCurtainTransitionEnd}
|
||||
>
|
||||
{/* 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() && (
|
||||
<div
|
||||
ref={handleRef}
|
||||
className={css.handle}
|
||||
data-dragging={handleVisual.dragging || undefined}
|
||||
data-at-commit={handleVisual.atCommit || undefined}
|
||||
aria-hidden
|
||||
>
|
||||
<div className={css.handleBar} />
|
||||
</div>
|
||||
)}
|
||||
{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 && (
|
||||
<div
|
||||
ref={bottomPinnedRef}
|
||||
className={css.bottomPinnedSlot}
|
||||
style={keyboardOpen ? { height: 0, overflow: 'hidden' } : undefined}
|
||||
>
|
||||
{bottomPinned}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
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 <CreateChat gap="400" onCreated={onClose} />;
|
||||
}
|
||||
|
|
@ -1,244 +0,0 @@
|
|||
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 (
|
||||
<Box direction="Column" gap="200" style={{ height: toRem(SEARCH_FORM_BASE_PX - 40) }}>
|
||||
{/* ── Input bar (matches chip geometry: h=48 / r=20 / pad 8/14)
|
||||
so the chip → input morph reads as a content crossfade. */}
|
||||
<Box
|
||||
alignItems="Center"
|
||||
style={{
|
||||
backgroundColor: color.Background.Container,
|
||||
borderRadius: toRem(20),
|
||||
padding: `${toRem(8)} ${toRem(14)}`,
|
||||
height: toRem(48),
|
||||
gap: toRem(10),
|
||||
}}
|
||||
>
|
||||
<Icon size="50" src={Icons.Search} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={t('Search.search')}
|
||||
autoComplete="off"
|
||||
style={{
|
||||
flex: 1,
|
||||
appearance: 'none',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
background: 'transparent',
|
||||
font: 'inherit',
|
||||
fontSize: toRem(14),
|
||||
color: color.Background.OnContainer,
|
||||
minWidth: 0,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* ── Result list ────────────────────────────────────── */}
|
||||
<Box grow="Yes" style={{ minHeight: 0 }}>
|
||||
<Scroll ref={scrollRef} size="300" hideTrack visibility="Hover">
|
||||
{roomsToRender.length === 0 && (
|
||||
<Box
|
||||
grow="Yes"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{ paddingTop: toRem(40) }}
|
||||
>
|
||||
<Text size="H6" align="Center">
|
||||
{result ? t('Search.no_match_found') : t('Search.no_rooms')}
|
||||
</Text>
|
||||
<Text size="T200" align="Center" priority="300">
|
||||
{result
|
||||
? t('Search.no_match_for_query', { query: result.query })
|
||||
: t('Search.no_rooms_to_display')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{roomsToRender.length > 0 && (
|
||||
<Box direction="Column" gap="100" style={{ padding: `${toRem(4)} 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 (
|
||||
<button
|
||||
key={roomId}
|
||||
type="button"
|
||||
data-focus-index={index}
|
||||
data-room-id={roomId}
|
||||
data-space={room.isSpaceRoom()}
|
||||
onClick={handleRoomClick}
|
||||
aria-pressed={focused}
|
||||
style={{
|
||||
appearance: 'none',
|
||||
WebkitAppearance: 'none',
|
||||
border: 'none',
|
||||
backgroundColor: focused ? color.Primary.Main : 'transparent',
|
||||
color: focused ? color.Primary.OnMain : color.Background.OnContainer,
|
||||
borderRadius: toRem(12),
|
||||
padding: `${toRem(8)} ${toRem(10)}`,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: toRem(10),
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Avatar size="200" radii={dm ? '400' : '300'}>
|
||||
{dm || room.isSpaceRoom() ? (
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={
|
||||
dm
|
||||
? getDirectRoomAvatarUrl(mx, room, 32, useAuthentication)
|
||||
: getRoomAvatarUrl(mx, room, 32, useAuthentication)
|
||||
}
|
||||
alt={room.name}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="H6">
|
||||
{nameInitials(room.name)}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<RoomIcon
|
||||
size="100"
|
||||
joinRule={room.getJoinRule()}
|
||||
roomType={room.getType()}
|
||||
/>
|
||||
)}
|
||||
</Avatar>
|
||||
<Box grow="Yes" alignItems="Center" gap="100" style={{ minWidth: 0 }}>
|
||||
<Text size="T400" truncate>
|
||||
{queryHighlightRegex
|
||||
? highlightText(queryHighlightRegex, [room.name])
|
||||
: room.name}
|
||||
</Text>
|
||||
{dmUsername && (
|
||||
<Text as="span" size="T200" priority="300" truncate>
|
||||
@
|
||||
{queryHighlightRegex
|
||||
? highlightText(queryHighlightRegex, [dmUsername])
|
||||
: dmUsername}
|
||||
</Text>
|
||||
)}
|
||||
{!dm && perfectParent && perfectParent !== perfectOrphanParent && (
|
||||
<Text size="T200" priority="300" truncate>
|
||||
— {getRoom(perfectParent)?.name ?? perfectParent}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box gap="100" alignItems="Center" shrink="No">
|
||||
{dmUserServer && (
|
||||
<Text size="T200" priority="300" truncate>
|
||||
<b>{dmUserServer}</b>
|
||||
</Text>
|
||||
)}
|
||||
{!dm && perfectOrphanParent && (
|
||||
<Text size="T200" priority="300" truncate>
|
||||
<b>{getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent}</b>
|
||||
</Text>
|
||||
)}
|
||||
{unread && (
|
||||
<UnreadBadgeCenter>
|
||||
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
|
||||
</UnreadBadgeCenter>
|
||||
)}
|
||||
</Box>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
// ────────────────────────────────────────────────────────────────────
|
||||
// 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 `<Header size="600">`
|
||||
// = 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;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { StreamHeader } from './StreamHeader';
|
||||
export { TABS_ROW_PX, CHIP_ROW_PX, CURTAIN_SNAP_MS, CURTAIN_SNAP_EASING } from './geometry';
|
||||
|
|
@ -1,380 +0,0 @@
|
|||
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,
|
||||
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<HTMLDivElement | null>;
|
||||
// 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<HTMLDivElement | null>;
|
||||
// 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<HTMLDivElement | null>;
|
||||
// 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<HTMLDivElement | null>;
|
||||
// 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.
|
||||
pinned: boolean;
|
||||
setPinned: (next: boolean) => void;
|
||||
// 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 / close-peek / form-close). Narrowed to the two
|
||||
// non-form destinations the hook ever reaches. pin/unpin flips
|
||||
// `pinned` instead.
|
||||
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» / «push the curtain up to pin» gestures happen 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.
|
||||
//
|
||||
// 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 PIN_COMMIT_THRESHOLD ×
|
||||
// PIN_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,
|
||||
setPinned,
|
||||
setLiveDrag,
|
||||
commit,
|
||||
disabled,
|
||||
setHandleState,
|
||||
}: Args): void {
|
||||
const snapRef = useRef<CurtainSnap>(snap);
|
||||
snapRef.current = snap;
|
||||
const pinnedRef = useRef<boolean>(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 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 — pinned+up,
|
||||
// peek+down, form+down. Bail without preventDefault so any
|
||||
// native default (overscroll bounce, etc.) can still play.
|
||||
startX = null;
|
||||
startY = null;
|
||||
direction = 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':
|
||||
// Rubber-banded free-range drag spanning pin↔closed↔peek
|
||||
// in one motion. NO clamps either side — the curtain
|
||||
// follows the finger off-screen upward and continuously
|
||||
// into peek territory downward. Direction-aware atCommit
|
||||
// shows the right commit feedback for whichever side the
|
||||
// user is leaning into. Mirrors the handle's `closed-free`
|
||||
// but with 0.65× displacement so the body drag reads as
|
||||
// physically «heavier».
|
||||
lastDelta = delta * RUBBER_BAND;
|
||||
atCommit =
|
||||
lastDelta <= 0
|
||||
? -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD
|
||||
: 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.
|
||||
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.
|
||||
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':
|
||||
// Direction-aware commit, sign-exclusive: pin wins UP-side,
|
||||
// peek wins DOWN-side, below both thresholds 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 '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; 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`,
|
||||
// `setPinned` 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]);
|
||||
}
|
||||
|
|
@ -1,483 +0,0 @@
|
|||
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<HTMLDivElement | null>;
|
||||
// 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<CurtainSnap>(snap);
|
||||
snapRef.current = snap;
|
||||
const pinnedRef = useRef<boolean>(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]);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue