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-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
'@typescript-eslint/no-shadow': 'error',
|
'@typescript-eslint/no-shadow': 'error',
|
||||||
|
|
||||||
// Policy: kept as `warn` at the rule level so editors / `eslint --fix` /
|
// Policy: kept as warnings, not errors. The codebase has ~70 long-standing
|
||||||
// ad-hoc runs surface them as warnings, but `npm run check:eslint` and
|
// `any` casts and ~15 non-null assertions in matrix-js-sdk interop code.
|
||||||
// `lint-staged` BOTH pass `--max-warnings 0`, so new occurrences block
|
// Promoting to error would block builds on existing usage; turning off
|
||||||
// commit. When unavoidable (matrix-js-sdk boundary, generic helpers,
|
// would lose signal on new code. Warnings are visible without blocking.
|
||||||
// third-party callback shapes), suppress on the line with
|
|
||||||
// `// eslint-disable-next-line` and a one-line justification.
|
|
||||||
'@typescript-eslint/no-explicit-any': 'warn',
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
'@typescript-eslint/no-non-null-assertion': 'warn',
|
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||||
},
|
},
|
||||||
|
|
@ -88,11 +86,6 @@ module.exports = {
|
||||||
'no-plusplus': 'off',
|
'no-plusplus': 'off',
|
||||||
'prefer-template': 'off',
|
'prefer-template': 'off',
|
||||||
'no-param-reassign': '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
|
devAssets
|
||||||
config.local.json
|
config.local.json
|
||||||
|
|
||||||
electron/dist-electron
|
|
||||||
release
|
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea
|
.idea
|
||||||
.vscode/*
|
.vscode
|
||||||
!.vscode/tasks.json
|
|
||||||
.codex
|
.codex
|
||||||
.claude
|
.claude
|
||||||
|
docs/ai/desired_features.md
|
||||||
|
docs/ai/bugs.md
|
||||||
docs/plans
|
docs/plans
|
||||||
docs/design
|
docs
|
||||||
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
|
|
||||||
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
|
# These are commented until we enable lint and typecheck
|
||||||
npx lint-staged
|
# 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'
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
// Mirror of resolveAppVersion() in ../../vite.config.js so the APK's
|
def packageJson = new groovy.json.JsonSlurper().parseText(file('../../package.json').text)
|
||||||
// versionName matches __APP_VERSION__ rendered in the About screen.
|
def semver = packageJson.version.split('\\.')
|
||||||
// `git describe --tags --match 'v*'` against tag v0.2.0 yields
|
def computedVersionCode = semver[0].toInteger() * 1000000 + semver[1].toInteger() * 1000 + semver[2].toInteger()
|
||||||
// `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
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "chat.vojo.app"
|
namespace = "chat.vojo.app"
|
||||||
|
|
@ -33,7 +12,7 @@ android {
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode computedVersionCode
|
versionCode computedVersionCode
|
||||||
versionName appVersion.name
|
versionName packageJson.version
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|
@ -41,6 +20,12 @@ android {
|
||||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
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
|
// AGP 8+ requires explicit opt-in for BuildConfig generation. We rely on
|
||||||
// BuildConfig.DEBUG to gate Log.d calls that dump privacy-sensitive
|
// BuildConfig.DEBUG to gate Log.d calls that dump privacy-sensitive
|
||||||
// identifiers (roomId, eventId) so release builds don't leak them through
|
// identifiers (roomId, eventId) so release builds don't leak them through
|
||||||
|
|
@ -48,26 +33,6 @@ android {
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig = true
|
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 {
|
repositories {
|
||||||
|
|
@ -87,11 +52,6 @@ dependencies {
|
||||||
// already depends on firebase-messaging but declares it `implementation`
|
// already depends on firebase-messaging but declares it `implementation`
|
||||||
// so classes aren't exposed at app-module compile time.
|
// so classes aren't exposed at app-module compile time.
|
||||||
implementation "com.google.firebase:firebase-messaging:25.0.1"
|
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"
|
testImplementation "junit:junit:$junitVersion"
|
||||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
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
|
# If you keep the line number information, uncomment this to
|
||||||
# hide the original source file name.
|
# hide the original source file name.
|
||||||
#-renamesourcefileattribute SourceFile
|
#-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/" />
|
android:pathPrefix="/u/" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
|
|
@ -109,18 +85,6 @@
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".CallDeclineReceiver"
|
android:name=".CallDeclineReceiver"
|
||||||
android:exported="false" />
|
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>
|
</application>
|
||||||
|
|
||||||
<!-- Permissions -->
|
<!-- 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
|
// extras — Capacitor PushNotificationsPlugin gates pushNotificationActionPerformed
|
||||||
// on containsKey. Empty string also satisfies the gate; we pass the
|
// on containsKey. Empty string also satisfies the gate; we pass the
|
||||||
// caller's value through verbatim.
|
// caller's value through verbatim.
|
||||||
boolean seeded = VojoFirebaseMessagingService.upsertIncomingRing(data, messageId);
|
VojoFirebaseMessagingService.upsertIncomingRing(data, messageId);
|
||||||
// Mark in NotificationDedup so a polling fire 15 minutes later
|
|
||||||
// doesn't post a "Missed call" notification for a ring the user
|
|
||||||
// already saw live via the in-app strip. Mirrors the FCM-arrival
|
|
||||||
// path in VojoFirebaseMessagingService.onMessageReceived.
|
|
||||||
if (seeded) {
|
|
||||||
NotificationDedup.markNotified(getContext(), eventId);
|
|
||||||
}
|
|
||||||
call.resolve();
|
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(FullScreenIntentPlugin.class);
|
||||||
registerPlugin(CallForegroundPlugin.class);
|
registerPlugin(CallForegroundPlugin.class);
|
||||||
registerPlugin(LaunchSplashPlugin.class);
|
registerPlugin(LaunchSplashPlugin.class);
|
||||||
registerPlugin(ShareTargetPlugin.class);
|
|
||||||
registerPlugin(PollingPlugin.class);
|
|
||||||
|
|
||||||
// AndroidX SplashScreen must be installed before super.onCreate().
|
// AndroidX SplashScreen must be installed before super.onCreate().
|
||||||
// Keep it until the web splash confirms its first visible frame is
|
// 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);
|
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
|
* Build the invite-notification body from inviter + room name, falling
|
||||||
* back through four variants when one or both are absent. The res IDs
|
* 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>
|
</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.
|
// Linkifier — same heuristic as TG widget.
|
||||||
const URL_RE = /https?:\/\/[^\s)]+/g;
|
const URL_RE = /https?:\/\/[^\s)]+/g;
|
||||||
|
|
||||||
|
|
@ -400,13 +388,6 @@ const loadHCaptcha = (): Promise<HCaptchaApi> => {
|
||||||
`script[src^="https://js.hcaptcha.com/1/api.js"]`
|
`script[src^="https://js.hcaptcha.com/1/api.js"]`
|
||||||
) as HTMLScriptElement | null;
|
) 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 timeoutHandle: number | undefined;
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const settle = (action: () => void) => {
|
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-hint">{t('auth-card.captcha.hint')}</div>
|
||||||
<div class="auth-card-captcha-frame">
|
<div class="auth-card-captcha-frame">
|
||||||
<div ref={containerRef} class="auth-card-captcha-host" />
|
<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>
|
||||||
<div class="auth-card-row">
|
<div class="auth-card-row">
|
||||||
<button type="button" class="btn-text" onClick={onCancel}>
|
<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
|
// Main App
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|
@ -978,15 +927,6 @@ export function App({ bootstrap, api }: Props) {
|
||||||
// hydrate too; the live path treats it identically.
|
// hydrate too; the live path treats it identically.
|
||||||
append({ kind: 'diag', text: t('diag.captcha-issued') });
|
append({ kind: 'diag', text: t('diag.captcha-issued') });
|
||||||
appendedAnyHistory = true;
|
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') {
|
} else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') {
|
||||||
// m.text / m.notice — body is safe to replay verbatim,
|
// m.text / m.notice — body is safe to replay verbatim,
|
||||||
// BUT we still scrub any login-URL-shaped substring as
|
// 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') });
|
append({ kind: 'diag', text: t('diag.qr-issued') });
|
||||||
} else if (event.kind === 'qr_redacted') {
|
} else if (event.kind === 'qr_redacted') {
|
||||||
const liveState = stateRef.current;
|
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') });
|
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
||||||
}
|
}
|
||||||
} else if (event.kind === 'captcha_challenge') {
|
} else if (event.kind === 'captcha_challenge') {
|
||||||
|
|
@ -1058,12 +1001,6 @@ export function App({ bootstrap, api }: Props) {
|
||||||
// transcript DOM (where screenshots / accessibility tools could
|
// transcript DOM (where screenshots / accessibility tools could
|
||||||
// leak them). Diag-only display.
|
// leak them). Diag-only display.
|
||||||
append({ kind: 'diag', text: t('diag.captcha-issued') });
|
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') {
|
} else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') {
|
||||||
const body = ev.content.body ?? '';
|
const body = ev.content.body ?? '';
|
||||||
append({ kind: 'from-bot', text: `← ${scrubLoginSecret(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
|
// entry, but a manual disconnect path could leave us in connected
|
||||||
// and trigger reconnect from there).
|
// and trigger reconnect from there).
|
||||||
const handle =
|
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 });
|
dispatch({ kind: 'request_reconnect', handle });
|
||||||
try {
|
try {
|
||||||
await sendBare('reconnect');
|
await sendBare('reconnect');
|
||||||
|
|
@ -1414,17 +1353,6 @@ export function App({ bootstrap, api }: Props) {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div class="command-grid">
|
<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} />
|
<LogoutCard t={t} onConfirm={onConfirmLogout} />
|
||||||
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
|
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
|
||||||
</div>
|
</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_PREFIX = 'VOJO-CAPTCHA-CHALLENGE-V1';
|
||||||
const CAPTCHA_CHALLENGE_RE = /^VOJO-CAPTCHA-CHALLENGE-V1\s+(\{[\s\S]*\})\s*$/;
|
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
|
// Legacy CAPTCHA fallback — commands.go:fnLoginQR (l.207-209) on UNPATCHED
|
||||||
// upstream v0.7.6: «CAPTCHAs are currently not supported - use token login
|
// upstream v0.7.6: «CAPTCHAs are currently not supported - use token login
|
||||||
// instead». Kept so a deployment running unpatched bridge still produces a
|
// 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' };
|
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);
|
const loginWsMatch = LOGIN_WEBSOCKET_FAILED_RE.exec(body);
|
||||||
if (loginWsMatch) return { kind: 'login_websocket_failed', reason: loginWsMatch[1].trim() };
|
if (loginWsMatch) return { kind: 'login_websocket_failed', reason: loginWsMatch[1].trim() };
|
||||||
|
|
||||||
|
|
@ -278,8 +247,8 @@ export const parseEventLegacyV076 = (event: ParsableEvent): LoginEvent => {
|
||||||
typeof event.redacts === 'string'
|
typeof event.redacts === 'string'
|
||||||
? event.redacts
|
? event.redacts
|
||||||
: isObject(event.content) && typeof event.content.redacts === 'string'
|
: isObject(event.content) && typeof event.content.redacts === 'string'
|
||||||
? event.content.redacts
|
? event.content.redacts
|
||||||
: undefined;
|
: undefined;
|
||||||
if (!target) return { kind: 'unknown' };
|
if (!target) return { kind: 'unknown' };
|
||||||
return { kind: 'qr_redacted', redactsEventId: target };
|
return { kind: 'qr_redacted', redactsEventId: target };
|
||||||
}
|
}
|
||||||
|
|
@ -361,11 +330,20 @@ function runSanityChecks(): void {
|
||||||
|
|
||||||
// Login success (post-QR scan). No snowflake in this line; App fires
|
// Login success (post-QR scan). No snowflake in this line; App fires
|
||||||
// `ping` afterwards to pick up the discordId.
|
// `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.
|
// 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
|
// CAPTCHA legacy fallback — pre-empts LOGIN_FAILED_RE. Fires only on
|
||||||
// unpatched upstream v0.7.6.
|
// unpatched upstream v0.7.6.
|
||||||
[
|
[
|
||||||
|
|
@ -409,7 +387,10 @@ function runSanityChecks(): void {
|
||||||
|
|
||||||
// Logout.
|
// Logout.
|
||||||
['Logged out successfully.', { kind: 'logout_ok' }],
|
['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.
|
// Disconnect / reconnect.
|
||||||
['Successfully disconnected', { kind: 'disconnect_ok' }],
|
['Successfully disconnected', { kind: 'disconnect_ok' }],
|
||||||
|
|
@ -540,9 +521,7 @@ function runSanityChecks(): void {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error('[legacy_v076 event sanity] mismatch', { event, actual, expected });
|
console.error('[legacy_v076 event sanity] mismatch', { event, actual, expected });
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${
|
`legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${event.content?.msgtype ?? '<none>'}`
|
||||||
event.content?.msgtype ?? '<none>'
|
|
||||||
}`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,15 +113,6 @@ export type LoginEvent =
|
||||||
| { kind: 'reconnect_no_op' }
|
| { kind: 'reconnect_no_op' }
|
||||||
| { kind: 'reconnect_failed'; reason?: string }
|
| { 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 --------------------------------------------------
|
// --- bridge-side errors --------------------------------------------------
|
||||||
// Generic «I don't know that command» — should not happen since we only
|
// Generic «I don't know that command» — should not happen since we only
|
||||||
// ship known commands, but visible if the bridge image is misconfigured
|
// 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.',
|
'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':
|
'auth-error.captcha-send-failed':
|
||||||
'Could not deliver your CAPTCHA solution. Check your network and try signing in again.',
|
'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.login-failed': 'Sign-in failed: {reason}',
|
||||||
'auth-error.prepare-failed': 'Failed to prepare sign-in: {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.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.already-logged-in': 'You are already signed in to Discord — refresh status.',
|
||||||
'auth-error.unknown-command':
|
'auth-error.unknown-command':
|
||||||
'The bot does not recognise this command — check the prefix in config.json.',
|
'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-prompt': 'Sign out for real?',
|
||||||
'card.logout.confirm-yes': 'Sign out',
|
'card.logout.confirm-yes': 'Sign out',
|
||||||
'card.logout.confirm-no': 'Cancel',
|
'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.connecting': 'Connecting to Vojo… awaiting capability handshake.',
|
||||||
'diag.ready': 'Ready to send commands.',
|
'diag.ready': 'Ready to send commands.',
|
||||||
'diag.checking-status': 'Checking connection status…',
|
'diag.checking-status': 'Checking connection status…',
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,8 @@ export const RU = {
|
||||||
'Discord потребовал CAPTCHA — вход через QR временно недоступен. Попробуйте позже или войдите через токен в чате с ботом.',
|
'Discord потребовал CAPTCHA — вход через QR временно недоступен. Попробуйте позже или войдите через токен в чате с ботом.',
|
||||||
'auth-error.captcha-send-failed':
|
'auth-error.captcha-send-failed':
|
||||||
'Не удалось отправить ответ на CAPTCHA. Проверьте сеть и попробуйте войти заново.',
|
'Не удалось отправить ответ на CAPTCHA. Проверьте сеть и попробуйте войти заново.',
|
||||||
'auth-error.captcha-expired': 'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.',
|
'auth-error.captcha-expired':
|
||||||
|
'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.',
|
||||||
'auth-error.login-failed': 'Не удалось войти: {reason}',
|
'auth-error.login-failed': 'Не удалось войти: {reason}',
|
||||||
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}',
|
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}',
|
||||||
'auth-error.websocket-failed': 'Не удалось подключиться к серверу входа: {reason}',
|
'auth-error.websocket-failed': 'Не удалось подключиться к серверу входа: {reason}',
|
||||||
|
|
@ -105,11 +106,7 @@ export const RU = {
|
||||||
'card.logout.confirm-prompt': 'Точно выйти?',
|
'card.logout.confirm-prompt': 'Точно выйти?',
|
||||||
'card.logout.confirm-yes': 'Выйти',
|
'card.logout.confirm-yes': 'Выйти',
|
||||||
'card.logout.confirm-no': 'Отмена',
|
'card.logout.confirm-no': 'Отмена',
|
||||||
// --- Open Discord space (Vojo bridge sentinel) ------------------------
|
|
||||||
'card.open-space.name': 'Открыть в Каналах',
|
|
||||||
'card.open-space.desc': 'Перейти в спейс Discord со списком чатов и серверов',
|
|
||||||
// --- Diagnostics in transcript ----------------------------------------
|
// --- Diagnostics in transcript ----------------------------------------
|
||||||
'diag.space-ready': 'Discord-спейс готов к открытию.',
|
|
||||||
'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.',
|
'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.',
|
||||||
'diag.ready': 'Готов отправлять команды.',
|
'diag.ready': 'Готов отправлять команды.',
|
||||||
'diag.checking-status': 'Проверяю статус подключения…',
|
'diag.checking-status': 'Проверяю статус подключения…',
|
||||||
|
|
|
||||||
|
|
@ -104,13 +104,8 @@ export type LoginState =
|
||||||
| { kind: 'reconnecting'; handle?: string }
|
| { kind: 'reconnecting'; handle?: string }
|
||||||
// Live session — ping or login_success confirmed. Discord legacy bridge
|
// Live session — ping or login_success confirmed. Discord legacy bridge
|
||||||
// doesn't have a per-account loginId concept (single Discord account
|
// doesn't have a per-account loginId concept (single Discord account
|
||||||
// per Matrix user), so logout doesn't need an id. `spaceMatrixToUrl`
|
// per Matrix user), so logout doesn't need an id.
|
||||||
// is populated from the Vojo `VOJO-LOGIN-SPACE-V1` sentinel that lands
|
| { kind: 'connected'; handle: string; discordId?: string }
|
||||||
// right after login_success; it survives the post-login re-ping and the
|
|
||||||
// reconnect-ok transitions so the «Open in Channels» card stays visible
|
|
||||||
// until logout. Absent until the sentinel arrives (and absent forever
|
|
||||||
// against an UNPATCHED bridge — the card simply never appears).
|
|
||||||
| { kind: 'connected'; handle: string; discordId?: string; spaceMatrixToUrl?: string }
|
|
||||||
// ping says we have a token but the connection's down. Status pill:
|
// ping says we have a token but the connection's down. Status pill:
|
||||||
// green-ish but with a Reconnect recovery action exposed. The reducer
|
// green-ish but with a Reconnect recovery action exposed. The reducer
|
||||||
// distinguishes `connection_dead` (Discord WS dropped) from `token_stored`
|
// 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
|
// staring at an hCaptcha challenge (rqdata/rqtoken are short-lived but
|
||||||
// often valid for a couple of minutes — fresh enough to reuse). Other
|
// often valid for a couple of minutes — fresh enough to reuse). Other
|
||||||
// transient states (logging_out, reconnecting) deliberately don't survive.
|
// 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
|
// Outbound user actions the App dispatches. Form-submit actions clear any
|
||||||
// pending lastError; structural transitions optimistically advance state —
|
// pending lastError; structural transitions optimistically advance state —
|
||||||
|
|
@ -171,7 +169,9 @@ const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiti
|
||||||
const isCaptchaAcceptingState = (
|
const isCaptchaAcceptingState = (
|
||||||
s: LoginState
|
s: LoginState
|
||||||
): s is PendingFormState | { kind: 'qr_verifying' } | CaptchaSolveState =>
|
): 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 => {
|
export const loginReducer = (state: LoginState, action: LoginAction): LoginState => {
|
||||||
if (action.kind === 'hydrate') {
|
if (action.kind === 'hydrate') {
|
||||||
|
|
@ -266,14 +266,11 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
|
||||||
case 'logged_in':
|
case 'logged_in':
|
||||||
// Authoritative source — accept from any state. Used by both the
|
// Authoritative source — accept from any state. Used by both the
|
||||||
// initial ping AND the post-`login_success` re-ping that picks up
|
// initial ping AND the post-`login_success` re-ping that picks up
|
||||||
// the discordId snowflake. Preserve `spaceMatrixToUrl` from a prior
|
// the discordId snowflake.
|
||||||
// `connected` so the post-login_success re-ping doesn't blank the
|
|
||||||
// CTA before the user gets a chance to click it.
|
|
||||||
return {
|
return {
|
||||||
kind: 'connected',
|
kind: 'connected',
|
||||||
handle: event.handle,
|
handle: event.handle,
|
||||||
discordId: event.discordId,
|
discordId: event.discordId,
|
||||||
spaceMatrixToUrl: state.kind === 'connected' ? state.spaceMatrixToUrl : undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'connection_dead':
|
case 'connection_dead':
|
||||||
|
|
@ -495,28 +492,12 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
|
||||||
// green with an empty handle, which the UI's
|
// green with an empty handle, which the UI's
|
||||||
// `state.handle ? connected-as : connected` ternary tolerates.
|
// `state.handle ? connected-as : connected` ternary tolerates.
|
||||||
// This avoids the `unknown` flap that the previous draft would
|
// This avoids the `unknown` flap that the previous draft would
|
||||||
// produce when no handle was stashed. spaceMatrixToUrl is not
|
// produce when no handle was stashed.
|
||||||
// restorable from connected_dead (the dead state never carried it),
|
|
||||||
// so the CTA stays hidden until a fresh sentinel arrives — bridge
|
|
||||||
// does NOT re-emit on reconnect, but the card returns once the user
|
|
||||||
// explicitly re-logs in.
|
|
||||||
if (state.kind === 'reconnecting' || state.kind === 'connected_dead') {
|
if (state.kind === 'reconnecting' || state.kind === 'connected_dead') {
|
||||||
return { kind: 'connected', handle: state.handle ?? '' };
|
return { kind: 'connected', handle: state.handle ?? '' };
|
||||||
}
|
}
|
||||||
return state;
|
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':
|
case 'reconnect_failed':
|
||||||
if (state.kind !== 'reconnecting') return state;
|
if (state.kind !== 'reconnecting') return state;
|
||||||
// Roll back to connected_dead carrying the previous handle. The
|
// Roll back to connected_dead carrying the previous handle. The
|
||||||
|
|
@ -584,7 +565,10 @@ type HydrateAccumulator = {
|
||||||
terminated: boolean;
|
terminated: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const stepHydrate = (prevAcc: HydrateAccumulator, input: HydrateInput): HydrateAccumulator => {
|
const stepHydrate = (
|
||||||
|
prevAcc: HydrateAccumulator,
|
||||||
|
input: HydrateInput
|
||||||
|
): HydrateAccumulator => {
|
||||||
const { ev, ts } = input;
|
const { ev, ts } = input;
|
||||||
|
|
||||||
// After a terminal event we normally stop — except if a fresh
|
// 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 'already_logged_in':
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
case 'space_ready':
|
|
||||||
// Soft no-op for hydrate. already_logged_in is a live-flow warning
|
// Soft no-op for hydrate. already_logged_in is a live-flow warning
|
||||||
// that doesn't reflect persistent state; unknown is a wording-drift
|
// that doesn't reflect persistent state; unknown is a wording-drift
|
||||||
// catch-all; space_ready is a post-terminal sentinel — hydrate
|
// catch-all.
|
||||||
// terminates on login_success and lets live ping reconcile, so
|
|
||||||
// the URL gets attached on the live path, not here.
|
|
||||||
return acc;
|
return acc;
|
||||||
|
|
||||||
default: {
|
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).
|
// Always prefix outbound commands with `<commandPrefix> ` (trailing space).
|
||||||
// Legacy mautrix-discord routes management-room commands through the
|
// Legacy mautrix-discord routes management-room commands through the
|
||||||
// bridge.commands.Processor in mautrix/go bridge/commands; outside the
|
// bridge.commands.Processor in mautrix/go bridge/commands; outside the
|
||||||
|
|
|
||||||
|
|
@ -742,21 +742,6 @@ body {
|
||||||
width: 100%;
|
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 +
|
/* Compact .command-card on mobile — preserves the «two-row title +
|
||||||
* chevron» structure but trims padding so a single login/logout card
|
* chevron» structure but trims padding so a single login/logout card
|
||||||
* doesn't dominate a phone-height viewport. */
|
* 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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [bugs.md](bugs.md) | Known bugs & regressions |
|
||||||
| [server-side.md](server-side.md) | Some configs that deployd on server |
|
| [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
|
## 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
|
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`.
|
- **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`.
|
- **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).
|
- **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 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.** Applied on `#root` (not `body`) so the theme background extends behind the system bars.
|
- **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
|
## 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"`).
|
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)
|
## ADB wireless workflow
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
1. On the phone, enable Wireless debugging, tap "Pair device with pairing code" — note IP, port, 6-digit code.
|
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>`
|
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.
|
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
|
## Source Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── index.tsx # Entry point
|
├── 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
|
├── config.css.ts # fontWeight overrides
|
||||||
├── client/
|
├── client/
|
||||||
│ ├── initMatrix.ts # Matrix SDK init (createClient, startClient, logout)
|
│ ├── 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).
|
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/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 — `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/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.
|
- 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.
|
- Brand accent in v4.11.x: `Primary.Main = #BDB6EC` (lavender) — 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.
|
|
||||||
|
|
||||||
## Responsive design
|
## 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)
|
- **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
|
- **folds 2.6** — UI component library
|
||||||
- **jotai 2.6** — State management
|
- **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
|
- **slate 0.123** — Rich text editor
|
||||||
- **@tanstack/react-query 5** — Data fetching
|
- **@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`.
|
- **@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`
|
- Current vojo work branch: `vojo/dev`
|
||||||
- Semantic-release on `dev` branch
|
- Semantic-release on `dev` branch
|
||||||
- CI: GitHub Actions (build, deploy, docker, netlify)
|
- 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
|
- **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
|
- **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"
|
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."
|
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="#000000" />
|
||||||
<meta name="theme-color" content="#f2f2f7" media="(prefers-color-scheme: light)" />
|
|
||||||
|
|
||||||
<link id="favicon" rel="shortcut icon" type="image/svg+xml" href="./public/res/svg/vojo.svg" />
|
<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",
|
"name": "vojo",
|
||||||
"version": "0.2.0",
|
"version": "4.11.1",
|
||||||
"description": "Vojo client for matrix server",
|
"description": "Yet another matrix client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.12.0"
|
"node": ">=22.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "npm run check:eslint && npm run check:prettier",
|
"lint": "npm run check:eslint && npm run check:prettier",
|
||||||
"check:eslint": "eslint --max-warnings 0 src",
|
"check:eslint": "eslint src",
|
||||||
"check:prettier": "prettier --check .",
|
"check:prettier": "prettier --check .",
|
||||||
"fix:prettier": "prettier --write .",
|
"fix:prettier": "prettier --write .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"gen:push-strings": "node scripts/gen-push-strings.mjs",
|
"gen:push-strings": "node scripts/gen-push-strings.mjs",
|
||||||
"android:sync": "npx cap sync android",
|
"android:sync": "npx cap sync android",
|
||||||
"android:open": "npx cap open android",
|
"android:open": "npx cap open android",
|
||||||
"android:strip-sourcemaps": "find dist -name '*.map' -delete",
|
|
||||||
"android:apk:debug": "cd android && ./gradlew assembleDebug",
|
"android:apk:debug": "cd android && ./gradlew assembleDebug",
|
||||||
"android:apk:release": "cd android && ./gradlew assembleRelease",
|
"android:apk:release": "cd android && ./gradlew assembleRelease",
|
||||||
"android:aab:release": "cd android && ./gradlew bundleRelease",
|
"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:debug": "npm run build && 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:release": "npm run build && 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",
|
"build:android:aab": "npm run build && npm run android:sync && npm run android:aab:release",
|
||||||
"electron:typecheck": "tsc --noEmit -p electron/tsconfig.json",
|
|
||||||
"electron:build": "tsc -p electron/tsconfig.json && node -e \"require('fs').writeFileSync('electron/dist-electron/package.json', JSON.stringify({type:'commonjs'}))\"",
|
|
||||||
"electron:dev": "concurrently -k -n vite,electron -c blue,green \"npm:start\" \"wait-on tcp:8080 && npm run electron:build && electron electron/dist-electron/main.js\"",
|
|
||||||
"electron:start": "electron electron/dist-electron/main.js",
|
|
||||||
"electron:start:prod": "cross-env VOJO_ELECTRON_PROD=1 electron electron/dist-electron/main.js",
|
|
||||||
"build:electron:win": "npm run build && npm run electron:build && electron-builder --win",
|
|
||||||
"build:electron:win:docker": "docker run --rm -v ${PWD}:/project -v ~/.cache/electron:/root/.cache/electron -v ~/.cache/electron-builder:/root/.cache/electron-builder -w /project electronuserland/builder:wine-mono /bin/bash -c \"trap 'chown -R 1000:1000 /project/dist /project/release /project/electron/dist-electron 2>/dev/null || true' EXIT; npm run build && npm run electron:build && npx electron-builder --win\"",
|
|
||||||
"build:electron:mac": "npm run build && npm run electron:build && electron-builder --mac",
|
|
||||||
"build:electron:linux": "npm run build && npm run electron:build && electron-builder --linux",
|
|
||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
"commit": "git-cz"
|
"commit": "git-cz"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,tsx,js,jsx,mjs,cjs}": "eslint --max-warnings 0",
|
"*.{ts,tsx,js,jsx,mjs,cjs}": "eslint",
|
||||||
"*": "prettier --ignore-unknown --write"
|
"*": "prettier --ignore-unknown --write"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
|
|
@ -135,11 +125,7 @@
|
||||||
"@typescript-eslint/parser": "7.18.0",
|
"@typescript-eslint/parser": "7.18.0",
|
||||||
"@vitejs/plugin-react": "4.2.0",
|
"@vitejs/plugin-react": "4.2.0",
|
||||||
"buffer": "6.0.3",
|
"buffer": "6.0.3",
|
||||||
"concurrently": "9.2.1",
|
|
||||||
"cross-env": "7.0.3",
|
|
||||||
"cz-conventional-changelog": "3.3.0",
|
"cz-conventional-changelog": "3.3.0",
|
||||||
"electron": "42.1.0",
|
|
||||||
"electron-builder": "26.8.1",
|
|
||||||
"eslint": "8.57.1",
|
"eslint": "8.57.1",
|
||||||
"eslint-config-airbnb": "19.0.4",
|
"eslint-config-airbnb": "19.0.4",
|
||||||
"eslint-config-prettier": "8.5.0",
|
"eslint-config-prettier": "8.5.0",
|
||||||
|
|
@ -154,7 +140,6 @@
|
||||||
"vite": "5.4.19",
|
"vite": "5.4.19",
|
||||||
"vite-plugin-pwa": "0.20.5",
|
"vite-plugin-pwa": "0.20.5",
|
||||||
"vite-plugin-static-copy": "1.0.4",
|
"vite-plugin-static-copy": "1.0.4",
|
||||||
"vite-plugin-top-level-await": "1.4.4",
|
"vite-plugin-top-level-await": "1.4.4"
|
||||||
"wait-on": "9.0.10"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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_emojis_stickers": "Emojis & Stickers",
|
||||||
"menu_developer_tools": "Developer Tools",
|
"menu_developer_tools": "Developer Tools",
|
||||||
"menu_about": "About",
|
"menu_about": "About",
|
||||||
"drag_to_close": "Drag down to close",
|
|
||||||
"close": "Close",
|
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"logout_confirm": "You're about to log out. Are you sure?",
|
"logout_confirm": "You're about to log out. Are you sure?",
|
||||||
"logout_failed": "Failed to logout! {{message}}",
|
"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_unverified_desc": "Verify your device before logging out to save your encrypted messages.",
|
||||||
"logout_alert_title": "Alert",
|
"logout_alert_title": "Alert",
|
||||||
"logout_alert_desc": "Enable device verification or export your encrypted data from settings to avoid losing access to your messages.",
|
"logout_alert_desc": "Enable device verification or export your encrypted data from settings to avoid losing access to your messages.",
|
||||||
|
|
||||||
"general_title": "General",
|
"general_title": "General",
|
||||||
"appearance": "Appearance",
|
"appearance": "Appearance",
|
||||||
"system_theme": "System",
|
"system_theme": "System",
|
||||||
|
|
@ -122,6 +121,7 @@
|
||||||
"url_preview": "Url Preview",
|
"url_preview": "Url Preview",
|
||||||
"url_preview_encrypted": "Url Preview in Encrypted Room",
|
"url_preview_encrypted": "Url Preview in Encrypted Room",
|
||||||
"show_hidden_events": "Show Hidden Events",
|
"show_hidden_events": "Show Hidden Events",
|
||||||
|
|
||||||
"account_title": "Account",
|
"account_title": "Account",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"avatar": "Avatar",
|
"avatar": "Avatar",
|
||||||
|
|
@ -140,6 +140,7 @@
|
||||||
"select_user_desc": "Prevent receiving messages or invites from user by adding their userId.",
|
"select_user_desc": "Prevent receiving messages or invites from user by adding their userId.",
|
||||||
"block": "Block",
|
"block": "Block",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
|
|
||||||
"notifications_title": "Notifications",
|
"notifications_title": "Notifications",
|
||||||
"block_messages": "Block Messages",
|
"block_messages": "Block Messages",
|
||||||
"block_messages_moved": "This option has been moved to \"Account > Block Users\" section.",
|
"block_messages_moved": "This option has been moved to \"Account > Block Users\" section.",
|
||||||
|
|
@ -184,6 +185,7 @@
|
||||||
"notif_disable": "Disable",
|
"notif_disable": "Disable",
|
||||||
"notif_silent": "Notify Silent",
|
"notif_silent": "Notify Silent",
|
||||||
"notif_loud": "Notify Loud",
|
"notif_loud": "Notify Loud",
|
||||||
|
|
||||||
"devices_title": "Devices",
|
"devices_title": "Devices",
|
||||||
"security": "Security",
|
"security": "Security",
|
||||||
"device_verification": "Device Verification",
|
"device_verification": "Device Verification",
|
||||||
|
|
@ -226,6 +228,7 @@
|
||||||
"verify_other_desc": "Verify device identity and grant access to encrypted messages.",
|
"verify_other_desc": "Verify device identity and grant access to encrypted messages.",
|
||||||
"verify": "Verify",
|
"verify": "Verify",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
|
|
||||||
"local_backup": "Local Backup",
|
"local_backup": "Local Backup",
|
||||||
"new_password": "New Password",
|
"new_password": "New Password",
|
||||||
"confirm_password": "Confirm 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_desc": "Load password protected copy of encryption data from device to decrypt your messages.",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
"decrypt": "Decrypt",
|
"decrypt": "Decrypt",
|
||||||
|
|
||||||
"emojis_stickers_title": "Emojis & Stickers",
|
"emojis_stickers_title": "Emojis & Stickers",
|
||||||
"default_pack": "Default Pack",
|
"default_pack": "Default Pack",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
|
|
@ -248,6 +252,7 @@
|
||||||
"select_pack_desc": "Pick emoji and sticker packs from rooms to use globally.",
|
"select_pack_desc": "Pick emoji and sticker packs from rooms to use globally.",
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
"room_packs": "Room Packs",
|
"room_packs": "Room Packs",
|
||||||
|
"close": "Close",
|
||||||
"select_all": "Select All",
|
"select_all": "Select All",
|
||||||
"unselect_all": "Unselect All",
|
"unselect_all": "Unselect All",
|
||||||
"no_packs": "No Packs",
|
"no_packs": "No Packs",
|
||||||
|
|
@ -255,17 +260,15 @@
|
||||||
"apply_error": "Failed to apply changes! Please try again.",
|
"apply_error": "Failed to apply changes! Please try again.",
|
||||||
"apply_ready": "Changes saved! Apply when ready.",
|
"apply_ready": "Changes saved! Apply when ready.",
|
||||||
"apply_changes": "Apply Changes",
|
"apply_changes": "Apply Changes",
|
||||||
|
|
||||||
"about_title": "About",
|
"about_title": "About",
|
||||||
"about_tagline": "Yet another matrix client.",
|
"about_tagline": "Yet another matrix client.",
|
||||||
"options": "Options",
|
"options": "Options",
|
||||||
"clear_cache_title": "Clear Cache & Reload",
|
"clear_cache_title": "Clear Cache & Reload",
|
||||||
"clear_cache_desc": "Clear all your locally stored data and reload from server.",
|
"clear_cache_desc": "Clear all your locally stored data and reload from server.",
|
||||||
"clear_cache": "Clear Cache",
|
"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",
|
"credits": "Credits",
|
||||||
|
|
||||||
"devtools_title": "Developer Tools",
|
"devtools_title": "Developer Tools",
|
||||||
"enable_devtools": "Enable Developer Tools",
|
"enable_devtools": "Enable Developer Tools",
|
||||||
"access_token": "Access Token",
|
"access_token": "Access Token",
|
||||||
|
|
@ -369,7 +372,7 @@
|
||||||
"create_chat": "Create Chat",
|
"create_chat": "Create Chat",
|
||||||
"create_chat_subtitle": "Start a private, encrypted chat by entering a username.",
|
"create_chat_subtitle": "Start a private, encrypted chat by entering a username.",
|
||||||
"start_first_chat": "Start a chat",
|
"start_first_chat": "Start a chat",
|
||||||
"segment_dm": "Direct",
|
"segment_dm": "DM",
|
||||||
"segment_channels": "Channels",
|
"segment_channels": "Channels",
|
||||||
"segment_bots": "Robots",
|
"segment_bots": "Robots",
|
||||||
"self_row_label": "You",
|
"self_row_label": "You",
|
||||||
|
|
@ -383,8 +386,7 @@
|
||||||
"e2e_encryption": "End-to-End Encryption",
|
"e2e_encryption": "End-to-End Encryption",
|
||||||
"e2e_encryption_desc": "Once this feature is enabled, it can't be disabled after the room is created.",
|
"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!",
|
"rate_limited": "Server rate-limited your request for {{minutes}} minutes!",
|
||||||
"create": "Create",
|
"create": "Create"
|
||||||
"close": "Close"
|
|
||||||
},
|
},
|
||||||
"Channels": {
|
"Channels": {
|
||||||
"no_spaces_title": "No communities yet",
|
"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.",
|
"pick_channel_desc": "Choose a channel from the list on the left to start reading.",
|
||||||
"root_category": "Channels",
|
"root_category": "Channels",
|
||||||
"workspace_switcher_aria": "Switch community",
|
"workspace_switcher_aria": "Switch community",
|
||||||
"workspace_switcher_create_space": "Create community",
|
"workspace_switcher_active_marker": "Current"
|
||||||
"workspace_switcher_drag_to_close": "Drag down to close",
|
|
||||||
"workspace_switcher_member_count_one": "{{count}} member",
|
|
||||||
"workspace_switcher_member_count_other": "{{count}} members",
|
|
||||||
"workspace_footer_subtitle": "Community",
|
|
||||||
"create_channel": "Create channel"
|
|
||||||
},
|
},
|
||||||
"Call": {
|
"Call": {
|
||||||
"start": "Start call",
|
"start": "Start call",
|
||||||
|
|
@ -424,19 +421,7 @@
|
||||||
"in_call": "In call",
|
"in_call": "In call",
|
||||||
"in_call_count": "{{count}} in call",
|
"in_call_count": "{{count}} in call",
|
||||||
"connecting": "Connecting…",
|
"connecting": "Connecting…",
|
||||||
"open_call_room": "Open call room",
|
"open_call_room": "Open call room"
|
||||||
"bubble_outgoing": "Outgoing call",
|
|
||||||
"bubble_incoming": "Incoming call",
|
|
||||||
"bubble_missed": "Missed call",
|
|
||||||
"bubble_cancelled": "Cancelled call",
|
|
||||||
"bubble_ongoing": "Ongoing call",
|
|
||||||
"bubble_in_progress": "In progress…",
|
|
||||||
"bubble_missed_count_one": "{{count}} missed call",
|
|
||||||
"bubble_missed_count_other": "{{count}} missed calls",
|
|
||||||
"bubble_cancelled_count_one": "{{count}} cancelled call",
|
|
||||||
"bubble_cancelled_count_other": "{{count}} cancelled calls",
|
|
||||||
"duration_minutes_seconds": "{{minutes}} min {{seconds}} sec",
|
|
||||||
"duration_seconds": "{{seconds}} sec"
|
|
||||||
},
|
},
|
||||||
"Room": {
|
"Room": {
|
||||||
"drag_to_close": "Drag up to close",
|
"drag_to_close": "Drag up to close",
|
||||||
|
|
@ -448,6 +433,7 @@
|
||||||
"jump_to_latest": "Jump to Latest",
|
"jump_to_latest": "Jump to Latest",
|
||||||
"today": "Today",
|
"today": "Today",
|
||||||
"yesterday": "Yesterday",
|
"yesterday": "Yesterday",
|
||||||
|
|
||||||
"view_reactions": "View Reactions",
|
"view_reactions": "View Reactions",
|
||||||
"read_receipts": "Read Receipts",
|
"read_receipts": "Read Receipts",
|
||||||
"view_source": "View Source",
|
"view_source": "View Source",
|
||||||
|
|
@ -459,6 +445,7 @@
|
||||||
"reply": "Reply",
|
"reply": "Reply",
|
||||||
"reply_in_thread": "Reply in Thread",
|
"reply_in_thread": "Reply in Thread",
|
||||||
"edit_message": "Edit Message",
|
"edit_message": "Edit Message",
|
||||||
|
|
||||||
"delete_message": "Delete Message",
|
"delete_message": "Delete Message",
|
||||||
"delete_confirm": "This action is irreversible! Are you sure that you want to delete this message?",
|
"delete_confirm": "This action is irreversible! Are you sure that you want to delete this message?",
|
||||||
"reason": "Reason",
|
"reason": "Reason",
|
||||||
|
|
@ -466,6 +453,7 @@
|
||||||
"delete_error": "Failed to delete message! Please try again.",
|
"delete_error": "Failed to delete message! Please try again.",
|
||||||
"deleting": "Deleting...",
|
"deleting": "Deleting...",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
|
||||||
"report_message": "Report Message",
|
"report_message": "Report Message",
|
||||||
"report_desc": "Report this message to server, which may then notify the appropriate people to take action.",
|
"report_desc": "Report this message to server, which may then notify the appropriate people to take action.",
|
||||||
"report_reason": "Reason",
|
"report_reason": "Reason",
|
||||||
|
|
@ -474,11 +462,13 @@
|
||||||
"reporting": "Reporting...",
|
"reporting": "Reporting...",
|
||||||
"report": "Report",
|
"report": "Report",
|
||||||
"no_reason": "No reason provided",
|
"no_reason": "No reason provided",
|
||||||
|
|
||||||
"is_typing": " is typing...",
|
"is_typing": " is typing...",
|
||||||
"and": " and ",
|
"and": " and ",
|
||||||
"are_typing": " are typing...",
|
"are_typing": " are typing...",
|
||||||
"others_count": "{{count}} others",
|
"others_count": "{{count}} others",
|
||||||
"drop_typing": "Dismiss typing indicator",
|
"drop_typing": "Dismiss typing indicator",
|
||||||
|
|
||||||
"members": "Members",
|
"members": "Members",
|
||||||
"members_count_one": "{{formattedCount}} Member",
|
"members_count_one": "{{formattedCount}} Member",
|
||||||
"members_count_other": "{{formattedCount}} Members",
|
"members_count_other": "{{formattedCount}} Members",
|
||||||
|
|
@ -495,30 +485,23 @@
|
||||||
"room_settings": "Room Settings",
|
"room_settings": "Room Settings",
|
||||||
"jump_to_time": "Jump to Time",
|
"jump_to_time": "Jump to Time",
|
||||||
"leave_room": "Leave Room",
|
"leave_room": "Leave Room",
|
||||||
|
|
||||||
"send_message": "Send a message...",
|
"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}}\"",
|
"drop_files": "Drop Files in \"{{name}}\"",
|
||||||
"drag_drop_desc": "Drag and drop files here or click for selection dialog",
|
"drag_drop_desc": "Drag and drop files here or click for selection dialog",
|
||||||
|
|
||||||
"pinned_messages": "Pinned Messages",
|
"pinned_messages": "Pinned Messages",
|
||||||
"no_pinned_messages": "No Pinned Messages",
|
"no_pinned_messages": "No Pinned Messages",
|
||||||
"no_pinned_messages_desc": "Users with sufficient permissions can pin messages from the message context menu.",
|
"no_pinned_messages_desc": "Users with sufficient permissions can pin messages from the message context menu.",
|
||||||
"open": "Open",
|
"open": "Open",
|
||||||
"failed_to_load": "Failed to load message!",
|
"failed_to_load": "Failed to load message!",
|
||||||
|
|
||||||
"time_label": "Time",
|
"time_label": "Time",
|
||||||
"date_label": "Date",
|
"date_label": "Date",
|
||||||
"preset": "Preset",
|
"preset": "Preset",
|
||||||
"beginning": "Beginning",
|
"beginning": "Beginning",
|
||||||
"open_timeline": "Open Timeline",
|
"open_timeline": "Open Timeline",
|
||||||
|
|
||||||
"message_deleted": "This message has been deleted",
|
"message_deleted": "This message has been deleted",
|
||||||
"message_deleted_reason": "This message has been deleted. {{reason}}",
|
"message_deleted_reason": "This message has been deleted. {{reason}}",
|
||||||
"unsupported_message": "Unsupported message",
|
"unsupported_message": "Unsupported message",
|
||||||
|
|
@ -528,6 +511,7 @@
|
||||||
"broken_message": "Broken message",
|
"broken_message": "Broken message",
|
||||||
"empty_message": "Empty message",
|
"empty_message": "Empty message",
|
||||||
"edited": " (edited)",
|
"edited": " (edited)",
|
||||||
|
|
||||||
"thread_caption": "Thread",
|
"thread_caption": "Thread",
|
||||||
"thread_in_channel_subtitle": "in #{{channel}}",
|
"thread_in_channel_subtitle": "in #{{channel}}",
|
||||||
"thread_close": "Close thread",
|
"thread_close": "Close thread",
|
||||||
|
|
@ -544,6 +528,7 @@
|
||||||
"thread_summary_highlight_one": "{{count}} mention",
|
"thread_summary_highlight_one": "{{count}} mention",
|
||||||
"thread_summary_highlight_other": "{{count}} mentions",
|
"thread_summary_highlight_other": "{{count}} mentions",
|
||||||
"no_post_permission": "You do not have permission to post in this room",
|
"no_post_permission": "You do not have permission to post in this room",
|
||||||
|
|
||||||
"conversation_beginning": "This is the beginning of conversation.",
|
"conversation_beginning": "This is the beginning of conversation.",
|
||||||
"created_by": "Created by <bold>@{{creator}}</bold> on {{date}} {{time}}",
|
"created_by": "Created by <bold>@{{creator}}</bold> on {{date}} {{time}}",
|
||||||
"invite_member": "Invite Member",
|
"invite_member": "Invite Member",
|
||||||
|
|
@ -554,6 +539,7 @@
|
||||||
"leave_room_error": "Failed to leave room! {{error}}",
|
"leave_room_error": "Failed to leave room! {{error}}",
|
||||||
"leaving": "Leaving...",
|
"leaving": "Leaving...",
|
||||||
"leave": "Leave",
|
"leave": "Leave",
|
||||||
|
|
||||||
"member_broken": "Broken membership event",
|
"member_broken": "Broken membership event",
|
||||||
"member_accepted_knock": "<bold>{{sender}}</bold> accepted <bold>{{user}}</bold>'s join request",
|
"member_accepted_knock": "<bold>{{sender}}</bold> accepted <bold>{{user}}</bold>'s join request",
|
||||||
"member_invited": "<bold>{{sender}}</bold> invited <bold>{{user}}</bold>",
|
"member_invited": "<bold>{{sender}}</bold> invited <bold>{{user}}</bold>",
|
||||||
|
|
@ -571,7 +557,10 @@
|
||||||
"member_name_removed": "<bold>{{user}}</bold> removed their display name",
|
"member_name_removed": "<bold>{{user}}</bold> removed their display name",
|
||||||
"member_avatar_changed": "<bold>{{user}}</bold> changed their avatar",
|
"member_avatar_changed": "<bold>{{user}}</bold> changed their avatar",
|
||||||
"member_avatar_removed": "<bold>{{user}}</bold> removed 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": {
|
"Inbox": {
|
||||||
"invite_title": "Invite",
|
"invite_title": "Invite",
|
||||||
|
|
@ -579,15 +568,19 @@
|
||||||
"user_id_placeholder": "@username:server",
|
"user_id_placeholder": "@username:server",
|
||||||
"reason_optional": "Reason (Optional)",
|
"reason_optional": "Reason (Optional)",
|
||||||
"invite_button": "Invite",
|
"invite_button": "Invite",
|
||||||
|
|
||||||
"notif_default": "Default",
|
"notif_default": "Default",
|
||||||
"notif_all_messages": "All Messages",
|
"notif_all_messages": "All Messages",
|
||||||
"notif_mentions_keywords": "Mention & Keywords",
|
"notif_mentions_keywords": "Mention & Keywords",
|
||||||
"notif_mute": "Mute",
|
"notif_mute": "Mute",
|
||||||
|
|
||||||
"unverified_device": "Unverified Device",
|
"unverified_device": "Unverified Device",
|
||||||
"unverified_devices": "Unverified Devices"
|
"unverified_devices": "Unverified Devices"
|
||||||
},
|
},
|
||||||
|
|
||||||
"Explore": {
|
"Explore": {
|
||||||
"explore_community": "Explore Community",
|
"explore_community": "Explore Community",
|
||||||
|
|
||||||
"add_server": "Add Server",
|
"add_server": "Add Server",
|
||||||
"add_server_desc": "Add server name to explore public communities.",
|
"add_server_desc": "Add server name to explore public communities.",
|
||||||
"server_name": "Server Name",
|
"server_name": "Server Name",
|
||||||
|
|
@ -595,11 +588,13 @@
|
||||||
"view": "View",
|
"view": "View",
|
||||||
"featured": "Featured",
|
"featured": "Featured",
|
||||||
"servers": "Servers",
|
"servers": "Servers",
|
||||||
|
|
||||||
"featured_by_client": "Featured by Client",
|
"featured_by_client": "Featured by Client",
|
||||||
"featured_by_client_desc": "Public rooms and spaces hand-picked by this client.",
|
"featured_by_client_desc": "Public rooms and spaces hand-picked by this client.",
|
||||||
"featured_spaces": "Featured Spaces",
|
"featured_spaces": "Featured Spaces",
|
||||||
"featured_rooms": "Featured Rooms",
|
"featured_rooms": "Featured Rooms",
|
||||||
"no_featured": "No featured rooms or spaces yet.",
|
"no_featured": "No featured rooms or spaces yet.",
|
||||||
|
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"search_placeholder": "Search for keyword",
|
"search_placeholder": "Search for keyword",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
|
|
@ -618,9 +613,9 @@
|
||||||
"previous_page": "Previous Page",
|
"previous_page": "Previous Page",
|
||||||
"next_page": "Next Page",
|
"next_page": "Next Page",
|
||||||
"no_communities": "No communities found!",
|
"no_communities": "No communities found!",
|
||||||
|
|
||||||
"space_badge": "Space",
|
"space_badge": "Space",
|
||||||
"members_count_one": "{{formattedCount}} Member",
|
"members_count": "{{count}} Members",
|
||||||
"members_count_other": "{{formattedCount}} Members",
|
|
||||||
"join": "Join",
|
"join": "Join",
|
||||||
"joining": "Joining",
|
"joining": "Joining",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
|
|
@ -629,6 +624,7 @@
|
||||||
"view_error": "View Error",
|
"view_error": "View Error",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel"
|
||||||
},
|
},
|
||||||
|
|
||||||
"Create": {
|
"Create": {
|
||||||
"add_space": "Add Space",
|
"add_space": "Add Space",
|
||||||
"create_space": "Create Space",
|
"create_space": "Create Space",
|
||||||
|
|
@ -636,6 +632,7 @@
|
||||||
"join_with_address": "Join with Address",
|
"join_with_address": "Join with Address",
|
||||||
"join_with_address_desc": "Join an existing community.",
|
"join_with_address_desc": "Join an existing community.",
|
||||||
"new_space": "New Space",
|
"new_space": "New Space",
|
||||||
|
|
||||||
"access": "Access",
|
"access": "Access",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"topic_optional": "Topic (Optional)",
|
"topic_optional": "Topic (Optional)",
|
||||||
|
|
@ -647,38 +644,47 @@
|
||||||
"allow_federation_desc": "Users from other servers can join.",
|
"allow_federation_desc": "Users from other servers can join.",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"rate_limited": "Server rate-limited your request for {{minutes}} minutes!",
|
"rate_limited": "Server rate-limited your request for {{minutes}} minutes!",
|
||||||
|
|
||||||
"access_restricted": "Restricted",
|
"access_restricted": "Restricted",
|
||||||
"access_restricted_desc": "Only members of the parent space can join.",
|
"access_restricted_desc": "Only members of the parent space can join.",
|
||||||
"access_private": "Private",
|
"access_private": "Private",
|
||||||
"access_private_desc": "Only people with an invite can join.",
|
"access_private_desc": "Only people with an invite can join.",
|
||||||
"access_public": "Public",
|
"access_public": "Public",
|
||||||
"access_public_desc": "Anyone with the address can join.",
|
"access_public_desc": "Anyone with the address can join.",
|
||||||
|
|
||||||
"address_optional": "Address (Optional)",
|
"address_optional": "Address (Optional)",
|
||||||
"address_hint": "Pick a unique address to make it discoverable.",
|
"address_hint": "Pick a unique address to make it discoverable.",
|
||||||
"address_taken": "This address is already taken. Please choose a different one.",
|
"address_taken": "This address is already taken. Please choose a different one.",
|
||||||
|
|
||||||
"founders": "Founders",
|
"founders": "Founders",
|
||||||
"founders_desc": "Privileged users assigned during creation. They have elevated control and can only be changed during an upgrade.",
|
"founders_desc": "Privileged users assigned during creation. They have elevated control and can only be changed during an upgrade.",
|
||||||
"enter": "Enter",
|
"enter": "Enter",
|
||||||
"no_suggestions": "No Suggestions",
|
"no_suggestions": "No Suggestions",
|
||||||
"no_suggestions_desc": "Enter a user ID and press Enter.",
|
"no_suggestions_desc": "Enter a user ID and press Enter.",
|
||||||
|
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"versions": "Versions",
|
"versions": "Versions",
|
||||||
|
|
||||||
"chat_room": "Chat Room",
|
"chat_room": "Chat Room",
|
||||||
"chat_room_desc": "Messages, photos, and videos.",
|
"chat_room_desc": "Messages, photos, and videos.",
|
||||||
"voice_room": "Voice Room",
|
"voice_room": "Voice Room",
|
||||||
"voice_room_desc": "Live audio and video conversations.",
|
"voice_room_desc": "Live audio and video conversations.",
|
||||||
|
|
||||||
"new_chat_room": "New Chat Room",
|
"new_chat_room": "New Chat Room",
|
||||||
"new_voice_room": "New Voice Room",
|
"new_voice_room": "New Voice Room",
|
||||||
|
|
||||||
"existing_space": "Existing Space",
|
"existing_space": "Existing Space",
|
||||||
"add_room": "Add Room",
|
"add_room": "Add Room",
|
||||||
"existing_room": "Existing Room"
|
"existing_room": "Existing Room"
|
||||||
},
|
},
|
||||||
|
|
||||||
"RoomSettings": {
|
"RoomSettings": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"members": "Members",
|
"members": "Members",
|
||||||
"permissions": "Permissions",
|
"permissions": "Permissions",
|
||||||
"emojis_stickers": "Emojis & Stickers",
|
"emojis_stickers": "Emojis & Stickers",
|
||||||
"developer_tools": "Developer Tools",
|
"developer_tools": "Developer Tools",
|
||||||
|
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
|
|
@ -690,25 +696,30 @@
|
||||||
"topic": "Topic",
|
"topic": "Topic",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|
||||||
"options": "Options",
|
"options": "Options",
|
||||||
"addresses": "Addresses",
|
"addresses": "Addresses",
|
||||||
"advanced_options": "Advanced Options",
|
"advanced_options": "Advanced Options",
|
||||||
|
|
||||||
"space_access": "Space Access",
|
"space_access": "Space Access",
|
||||||
"room_access": "Room Access",
|
"room_access": "Room Access",
|
||||||
"space_access_desc": "Change how people can join the space.",
|
"space_access_desc": "Change how people can join the space.",
|
||||||
"room_access_desc": "Change how people can join the room.",
|
"room_access_desc": "Change how people can join the room.",
|
||||||
|
|
||||||
"join_invite_only": "Invite Only",
|
"join_invite_only": "Invite Only",
|
||||||
"join_knock_invite": "Knock & Invite",
|
"join_knock_invite": "Knock & Invite",
|
||||||
"join_space_members_or_knock": "Space Members or Knock",
|
"join_space_members_or_knock": "Space Members or Knock",
|
||||||
"join_space_members": "Space Members",
|
"join_space_members": "Space Members",
|
||||||
"join_public": "Public",
|
"join_public": "Public",
|
||||||
"join_unsupported": "Unsupported",
|
"join_unsupported": "Unsupported",
|
||||||
|
|
||||||
"history_visibility": "Message History Visibility",
|
"history_visibility": "Message History Visibility",
|
||||||
"history_visibility_desc": "Changes to history visibility will only apply to future messages and will not affect existing history.",
|
"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_invite": "After Invite",
|
||||||
"visibility_after_join": "After Join",
|
"visibility_after_join": "After Join",
|
||||||
"visibility_all_messages": "All Messages",
|
"visibility_all_messages": "All Messages",
|
||||||
"visibility_all_messages_guests": "All Messages (Guests)",
|
"visibility_all_messages_guests": "All Messages (Guests)",
|
||||||
|
|
||||||
"room_encryption": "Room Encryption",
|
"room_encryption": "Room Encryption",
|
||||||
"encryption_enabled_desc": "Messages in this room are protected by end-to-end encryption.",
|
"encryption_enabled_desc": "Messages in this room are protected by end-to-end encryption.",
|
||||||
"encryption_disabled_desc": "Once enabled, encryption cannot be disabled!",
|
"encryption_disabled_desc": "Once enabled, encryption cannot be disabled!",
|
||||||
|
|
@ -717,9 +728,11 @@
|
||||||
"enable_encryption": "Enable Encryption",
|
"enable_encryption": "Enable Encryption",
|
||||||
"enable_encryption_confirm": "Are you sure? Once enabled, encryption cannot be disabled!",
|
"enable_encryption_confirm": "Are you sure? Once enabled, encryption cannot be disabled!",
|
||||||
"enable_e2e_encryption": "Enable E2E Encryption",
|
"enable_e2e_encryption": "Enable E2E Encryption",
|
||||||
|
|
||||||
"publish_to_directory": "Publish to Directory",
|
"publish_to_directory": "Publish to Directory",
|
||||||
"publish_space_desc": "List the space in the public directory to make it discoverable by others.",
|
"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.",
|
"publish_room_desc": "List the room in the public directory to make it discoverable by others.",
|
||||||
|
|
||||||
"published_addresses": "Published Addresses",
|
"published_addresses": "Published Addresses",
|
||||||
"published_addresses_desc": "If access is <b>Public</b>, Published addresses will be used to join by anyone.",
|
"published_addresses_desc": "If access is <b>Public</b>, Published addresses will be used to join by anyone.",
|
||||||
"no_addresses": "No Addresses",
|
"no_addresses": "No Addresses",
|
||||||
|
|
@ -733,11 +746,13 @@
|
||||||
"publish": "Publish",
|
"publish": "Publish",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"selected_count": "{{count}} Selected",
|
"selected_count": "{{count}} Selected",
|
||||||
|
|
||||||
"local_addresses": "Local Addresses",
|
"local_addresses": "Local Addresses",
|
||||||
"local_addresses_desc": "Set local address so users can join through your homeserver.",
|
"local_addresses_desc": "Set local address so users can join through your homeserver.",
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
|
|
||||||
"space_upgrade": "Space Upgrade",
|
"space_upgrade": "Space Upgrade",
|
||||||
"room_upgrade": "Room Upgrade",
|
"room_upgrade": "Room Upgrade",
|
||||||
"upgrade": "Upgrade",
|
"upgrade": "Upgrade",
|
||||||
|
|
@ -751,21 +766,25 @@
|
||||||
"old_room": "Old Room",
|
"old_room": "Old Room",
|
||||||
"open_new_space": "Open New Space",
|
"open_new_space": "Open New Space",
|
||||||
"open_new_room": "Open New Room",
|
"open_new_room": "Open New Room",
|
||||||
|
|
||||||
"members_count": "{{count}} Members",
|
"members_count": "{{count}} Members",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"no_results": "No Results",
|
"no_results": "No Results",
|
||||||
"results_count": "{{count}} Results",
|
"results_count": "{{count}} Results",
|
||||||
"scroll_to_top": "Scroll to Top",
|
"scroll_to_top": "Scroll to Top",
|
||||||
"no_membership_members": "No \"{{filter}}\" Members",
|
"no_membership_members": "No \"{{filter}}\" Members",
|
||||||
|
|
||||||
"filter_joined": "Joined",
|
"filter_joined": "Joined",
|
||||||
"filter_invited": "Invited",
|
"filter_invited": "Invited",
|
||||||
"filter_left": "Left",
|
"filter_left": "Left",
|
||||||
"filter_kicked": "Kicked",
|
"filter_kicked": "Kicked",
|
||||||
"filter_banned": "Banned",
|
"filter_banned": "Banned",
|
||||||
|
|
||||||
"sort_a_to_z": "A to Z",
|
"sort_a_to_z": "A to Z",
|
||||||
"sort_z_to_a": "Z to A",
|
"sort_z_to_a": "Z to A",
|
||||||
"sort_newest": "Newest",
|
"sort_newest": "Newest",
|
||||||
"sort_oldest": "Oldest",
|
"sort_oldest": "Oldest",
|
||||||
|
|
||||||
"perm_messages": "Messages",
|
"perm_messages": "Messages",
|
||||||
"perm_send_messages": "Send Messages",
|
"perm_send_messages": "Send Messages",
|
||||||
"perm_send_stickers": "Send Stickers",
|
"perm_send_stickers": "Send Stickers",
|
||||||
|
|
@ -798,10 +817,12 @@
|
||||||
"perm_manage_emojis_stickers": "Manage Emojis & Stickers",
|
"perm_manage_emojis_stickers": "Manage Emojis & Stickers",
|
||||||
"perm_change_server_acls": "Change Server ACLs",
|
"perm_change_server_acls": "Change Server ACLs",
|
||||||
"perm_modify_widgets": "Modify Widgets",
|
"perm_modify_widgets": "Modify Widgets",
|
||||||
|
|
||||||
"founders": "Founders",
|
"founders": "Founders",
|
||||||
"founders_desc": "Founding members have all permissions and can only be changed during a room upgrade.",
|
"founders_desc": "Founding members have all permissions and can only be changed during a room upgrade.",
|
||||||
"power_levels": "Power Levels",
|
"power_levels": "Power Levels",
|
||||||
"power_levels_desc": "Manage and customize incremental power levels for users.",
|
"power_levels_desc": "Manage and customize incremental power levels for users.",
|
||||||
|
|
||||||
"new_power_level": "New Power Level",
|
"new_power_level": "New Power Level",
|
||||||
"new_power_level_desc": "Create a new power level.",
|
"new_power_level_desc": "Create a new power level.",
|
||||||
"power_level_placeholder": "Bot",
|
"power_level_placeholder": "Bot",
|
||||||
|
|
@ -818,9 +839,11 @@
|
||||||
"failed_to_apply": "Failed to apply changes! Please try again.",
|
"failed_to_apply": "Failed to apply changes! Please try again.",
|
||||||
"apply_changes": "Apply Changes",
|
"apply_changes": "Apply Changes",
|
||||||
"and_above": "& Above",
|
"and_above": "& Above",
|
||||||
|
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"default_power": "Default Power",
|
"default_power": "Default Power",
|
||||||
"default_power_desc": "Default power level for all users.",
|
"default_power_desc": "Default power level for all users.",
|
||||||
|
|
||||||
"packs": "Packs",
|
"packs": "Packs",
|
||||||
"new_pack": "New Pack",
|
"new_pack": "New Pack",
|
||||||
"new_pack_desc": "Add your own emoji and sticker pack to use in room.",
|
"new_pack_desc": "Add your own emoji and sticker pack to use in room.",
|
||||||
|
|
@ -829,6 +852,7 @@
|
||||||
"view": "View",
|
"view": "View",
|
||||||
"failed_to_remove_packs": "Failed to remove packs! Please try again.",
|
"failed_to_remove_packs": "Failed to remove packs! Please try again.",
|
||||||
"delete_selected_packs": "Delete selected packs. ({{count}} selected)",
|
"delete_selected_packs": "Delete selected packs. ({{count}} selected)",
|
||||||
|
|
||||||
"enable_developer_tools": "Enable Developer Tools",
|
"enable_developer_tools": "Enable Developer Tools",
|
||||||
"room_id": "Room ID",
|
"room_id": "Room ID",
|
||||||
"room_id_desc": "Copy room ID to clipboard.",
|
"room_id_desc": "Copy room ID to clipboard.",
|
||||||
|
|
@ -851,6 +875,7 @@
|
||||||
"message_event_type": "Message Event Type",
|
"message_event_type": "Message Event Type",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"state_key_optional": "State Key (Optional)",
|
"state_key_optional": "State Key (Optional)",
|
||||||
|
|
||||||
"pack": "Pack",
|
"pack": "Pack",
|
||||||
"images_usage": "Images Usage",
|
"images_usage": "Images Usage",
|
||||||
"images_usage_desc": "Select how the images are being used: as emojis, as stickers, or as both.",
|
"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_both": "Both",
|
||||||
"usage_sticker": "Sticker",
|
"usage_sticker": "Sticker",
|
||||||
"usage_emoji": "Emoji",
|
"usage_emoji": "Emoji",
|
||||||
|
|
||||||
"power_goku": "Goku",
|
"power_goku": "Goku",
|
||||||
"power_manager": "Manager",
|
"power_manager": "Manager",
|
||||||
"power_founder": "Founder",
|
"power_founder": "Founder",
|
||||||
|
|
@ -874,6 +900,7 @@
|
||||||
"power_muted": "Muted",
|
"power_muted": "Muted",
|
||||||
"power_team": "Team"
|
"power_team": "Team"
|
||||||
},
|
},
|
||||||
|
|
||||||
"Push": {
|
"Push": {
|
||||||
"new_message": "New message",
|
"new_message": "New message",
|
||||||
"new_messages": "New messages",
|
"new_messages": "New messages",
|
||||||
|
|
@ -884,19 +911,7 @@
|
||||||
"invite_body": "{{inviter}} invited you to {{roomName}}",
|
"invite_body": "{{inviter}} invited you to {{roomName}}",
|
||||||
"invite_body_no_room": "{{inviter}} invited you to a room",
|
"invite_body_no_room": "{{inviter}} invited you to a room",
|
||||||
"invite_body_no_inviter": "Invited you to {{roomName}}",
|
"invite_body_no_inviter": "Invited you to {{roomName}}",
|
||||||
"invite_body_generic": "New invitation",
|
"invite_body_generic": "New invitation"
|
||||||
"missed_call": "Missed call",
|
|
||||||
"missed_call_body": "{{caller}} tried to call you",
|
|
||||||
"channel_group": "Chats",
|
|
||||||
"channel_dm": "Direct messages",
|
|
||||||
"channel_dm_description": "New messages from direct chats",
|
|
||||||
"channel_group_room": "Group chats",
|
|
||||||
"channel_group_room_description": "New messages from group chats and channels",
|
|
||||||
"self_name": "You",
|
|
||||||
"action_mark_as_read": "Mark as read",
|
|
||||||
"action_reply": "Reply",
|
|
||||||
"reply_hint": "Reply…",
|
|
||||||
"reply_failed": "Could not send your reply"
|
|
||||||
},
|
},
|
||||||
"Bots": {
|
"Bots": {
|
||||||
"not_connected_title": "{{name}} is not connected",
|
"not_connected_title": "{{name}} is not connected",
|
||||||
|
|
@ -968,15 +983,5 @@
|
||||||
"copy_server": "Copy server",
|
"copy_server": "Copy server",
|
||||||
"explore_community": "Explore community",
|
"explore_community": "Explore community",
|
||||||
"open_in_browser": "Open in browser"
|
"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_emojis_stickers": "Эмодзи и стикеры",
|
||||||
"menu_developer_tools": "Инструменты разработчика",
|
"menu_developer_tools": "Инструменты разработчика",
|
||||||
"menu_about": "О приложении",
|
"menu_about": "О приложении",
|
||||||
"drag_to_close": "Потянуть вниз чтобы закрыть",
|
|
||||||
"close": "Закрыть",
|
|
||||||
"logout": "Выйти",
|
"logout": "Выйти",
|
||||||
"logout_confirm": "Вы собираетесь выйти из аккаунта. Вы уверены?",
|
"logout_confirm": "Вы собираетесь выйти из аккаунта. Вы уверены?",
|
||||||
"logout_failed": "Не удалось выйти! {{message}}",
|
"logout_failed": "Не удалось выйти! {{message}}",
|
||||||
|
|
@ -99,6 +97,7 @@
|
||||||
"logout_unverified_desc": "Верифицируйте устройство перед выходом, чтобы сохранить зашифрованные сообщения.",
|
"logout_unverified_desc": "Верифицируйте устройство перед выходом, чтобы сохранить зашифрованные сообщения.",
|
||||||
"logout_alert_title": "Внимание",
|
"logout_alert_title": "Внимание",
|
||||||
"logout_alert_desc": "Включите верификацию устройства или экспортируйте зашифрованные данные в настройках, чтобы не потерять доступ к сообщениям.",
|
"logout_alert_desc": "Включите верификацию устройства или экспортируйте зашифрованные данные в настройках, чтобы не потерять доступ к сообщениям.",
|
||||||
|
|
||||||
"general_title": "Общие",
|
"general_title": "Общие",
|
||||||
"appearance": "Внешний вид",
|
"appearance": "Внешний вид",
|
||||||
"system_theme": "Системная",
|
"system_theme": "Системная",
|
||||||
|
|
@ -122,6 +121,7 @@
|
||||||
"url_preview": "Предпросмотр ссылок",
|
"url_preview": "Предпросмотр ссылок",
|
||||||
"url_preview_encrypted": "Предпросмотр ссылок в зашифрованных комнатах",
|
"url_preview_encrypted": "Предпросмотр ссылок в зашифрованных комнатах",
|
||||||
"show_hidden_events": "Показывать скрытые события",
|
"show_hidden_events": "Показывать скрытые события",
|
||||||
|
|
||||||
"account_title": "Аккаунт",
|
"account_title": "Аккаунт",
|
||||||
"profile": "Профиль",
|
"profile": "Профиль",
|
||||||
"avatar": "Аватар",
|
"avatar": "Аватар",
|
||||||
|
|
@ -140,6 +140,7 @@
|
||||||
"select_user_desc": "Заблокируйте получение сообщений и приглашений от пользователя, добавив его идентификатор.",
|
"select_user_desc": "Заблокируйте получение сообщений и приглашений от пользователя, добавив его идентификатор.",
|
||||||
"block": "Заблокировать",
|
"block": "Заблокировать",
|
||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
|
|
||||||
"notifications_title": "Уведомления",
|
"notifications_title": "Уведомления",
|
||||||
"block_messages": "Блокировка сообщений",
|
"block_messages": "Блокировка сообщений",
|
||||||
"block_messages_moved": "Эта опция перенесена в раздел «Аккаунт > Заблокированные пользователи».",
|
"block_messages_moved": "Эта опция перенесена в раздел «Аккаунт > Заблокированные пользователи».",
|
||||||
|
|
@ -184,6 +185,7 @@
|
||||||
"notif_disable": "Отключить",
|
"notif_disable": "Отключить",
|
||||||
"notif_silent": "Тихое уведомление",
|
"notif_silent": "Тихое уведомление",
|
||||||
"notif_loud": "Громкое уведомление",
|
"notif_loud": "Громкое уведомление",
|
||||||
|
|
||||||
"devices_title": "Устройства",
|
"devices_title": "Устройства",
|
||||||
"security": "Безопасность",
|
"security": "Безопасность",
|
||||||
"device_verification": "Верификация устройства",
|
"device_verification": "Верификация устройства",
|
||||||
|
|
@ -226,6 +228,7 @@
|
||||||
"verify_other_desc": "Подтвердите идентичность устройства и получите доступ к зашифрованным сообщениям.",
|
"verify_other_desc": "Подтвердите идентичность устройства и получите доступ к зашифрованным сообщениям.",
|
||||||
"verify": "Верифицировать",
|
"verify": "Верифицировать",
|
||||||
"reset": "Сбросить",
|
"reset": "Сбросить",
|
||||||
|
|
||||||
"local_backup": "Локальная копия",
|
"local_backup": "Локальная копия",
|
||||||
"new_password": "Новый пароль",
|
"new_password": "Новый пароль",
|
||||||
"confirm_password": "Подтвердите пароль",
|
"confirm_password": "Подтвердите пароль",
|
||||||
|
|
@ -239,6 +242,7 @@
|
||||||
"import_desc": "Загрузите защищённую паролем копию ключей шифрования с устройства для расшифровки сообщений.",
|
"import_desc": "Загрузите защищённую паролем копию ключей шифрования с устройства для расшифровки сообщений.",
|
||||||
"import": "Импорт",
|
"import": "Импорт",
|
||||||
"decrypt": "Расшифровать",
|
"decrypt": "Расшифровать",
|
||||||
|
|
||||||
"emojis_stickers_title": "Эмодзи и стикеры",
|
"emojis_stickers_title": "Эмодзи и стикеры",
|
||||||
"default_pack": "Пакет по умолчанию",
|
"default_pack": "Пакет по умолчанию",
|
||||||
"unknown": "Неизвестно",
|
"unknown": "Неизвестно",
|
||||||
|
|
@ -248,6 +252,7 @@
|
||||||
"select_pack_desc": "Выберите пакеты эмодзи и стикеров из комнат для использования во всех комнатах.",
|
"select_pack_desc": "Выберите пакеты эмодзи и стикеров из комнат для использования во всех комнатах.",
|
||||||
"select": "Выбрать",
|
"select": "Выбрать",
|
||||||
"room_packs": "Пакеты комнат",
|
"room_packs": "Пакеты комнат",
|
||||||
|
"close": "Закрыть",
|
||||||
"select_all": "Выбрать все",
|
"select_all": "Выбрать все",
|
||||||
"unselect_all": "Снять выделение",
|
"unselect_all": "Снять выделение",
|
||||||
"no_packs": "Нет пакетов",
|
"no_packs": "Нет пакетов",
|
||||||
|
|
@ -255,17 +260,15 @@
|
||||||
"apply_error": "Не удалось применить изменения! Попробуйте снова.",
|
"apply_error": "Не удалось применить изменения! Попробуйте снова.",
|
||||||
"apply_ready": "Изменения сохранены! Примените, когда будете готовы.",
|
"apply_ready": "Изменения сохранены! Примените, когда будете готовы.",
|
||||||
"apply_changes": "Применить изменения",
|
"apply_changes": "Применить изменения",
|
||||||
|
|
||||||
"about_title": "О приложении",
|
"about_title": "О приложении",
|
||||||
"about_tagline": "Ещё один клиент для Matrix.",
|
"about_tagline": "Ещё один клиент для Matrix.",
|
||||||
"options": "Параметры",
|
"options": "Параметры",
|
||||||
"clear_cache_title": "Очистить кэш и перезагрузить",
|
"clear_cache_title": "Очистить кэш и перезагрузить",
|
||||||
"clear_cache_desc": "Удалить все локально сохранённые данные и загрузить заново с сервера.",
|
"clear_cache_desc": "Удалить все локально сохранённые данные и загрузить заново с сервера.",
|
||||||
"clear_cache": "Очистить кэш",
|
"clear_cache": "Очистить кэш",
|
||||||
"legal": "Юридическое",
|
|
||||||
"privacy_policy_title": "Политика конфиденциальности",
|
|
||||||
"privacy_policy_desc": "Как обрабатываются ваши данные.",
|
|
||||||
"privacy_policy_open": "Открыть",
|
|
||||||
"credits": "Благодарности",
|
"credits": "Благодарности",
|
||||||
|
|
||||||
"devtools_title": "Инструменты разработчика",
|
"devtools_title": "Инструменты разработчика",
|
||||||
"enable_devtools": "Включить инструменты разработчика",
|
"enable_devtools": "Включить инструменты разработчика",
|
||||||
"access_token": "Токен доступа",
|
"access_token": "Токен доступа",
|
||||||
|
|
@ -385,8 +388,7 @@
|
||||||
"e2e_encryption": "Сквозное шифрование",
|
"e2e_encryption": "Сквозное шифрование",
|
||||||
"e2e_encryption_desc": "После включения эту функцию нельзя отключить после создания комнаты.",
|
"e2e_encryption_desc": "После включения эту функцию нельзя отключить после создания комнаты.",
|
||||||
"rate_limited": "Сервер ограничил частоту запросов на {{minutes}} мин.!",
|
"rate_limited": "Сервер ограничил частоту запросов на {{minutes}} мин.!",
|
||||||
"create": "Создать",
|
"create": "Создать"
|
||||||
"close": "Закрыть"
|
|
||||||
},
|
},
|
||||||
"Channels": {
|
"Channels": {
|
||||||
"no_spaces_title": "Пока нет сообществ",
|
"no_spaces_title": "Пока нет сообществ",
|
||||||
|
|
@ -396,14 +398,7 @@
|
||||||
"pick_channel_desc": "Откройте канал из списка слева, чтобы начать читать.",
|
"pick_channel_desc": "Откройте канал из списка слева, чтобы начать читать.",
|
||||||
"root_category": "Каналы",
|
"root_category": "Каналы",
|
||||||
"workspace_switcher_aria": "Сменить сообщество",
|
"workspace_switcher_aria": "Сменить сообщество",
|
||||||
"workspace_switcher_create_space": "Создать сообщество",
|
"workspace_switcher_active_marker": "Текущее"
|
||||||
"workspace_switcher_drag_to_close": "Потяните вниз, чтобы закрыть",
|
|
||||||
"workspace_switcher_member_count_one": "{{count}} участник",
|
|
||||||
"workspace_switcher_member_count_few": "{{count}} участника",
|
|
||||||
"workspace_switcher_member_count_many": "{{count}} участников",
|
|
||||||
"workspace_switcher_member_count_other": "{{count}} участника",
|
|
||||||
"workspace_footer_subtitle": "Сообщество",
|
|
||||||
"create_channel": "Создать канал"
|
|
||||||
},
|
},
|
||||||
"Call": {
|
"Call": {
|
||||||
"start": "Позвонить",
|
"start": "Позвонить",
|
||||||
|
|
@ -428,21 +423,7 @@
|
||||||
"in_call": "В звонке",
|
"in_call": "В звонке",
|
||||||
"in_call_count": "{{count}} в звонке",
|
"in_call_count": "{{count}} в звонке",
|
||||||
"connecting": "Соединение…",
|
"connecting": "Соединение…",
|
||||||
"open_call_room": "Открыть чат звонка",
|
"open_call_room": "Открыть чат звонка"
|
||||||
"bubble_outgoing": "Исходящий звонок",
|
|
||||||
"bubble_incoming": "Входящий звонок",
|
|
||||||
"bubble_missed": "Пропущенный звонок",
|
|
||||||
"bubble_cancelled": "Отменённый звонок",
|
|
||||||
"bubble_ongoing": "Идёт звонок",
|
|
||||||
"bubble_in_progress": "Идёт сейчас…",
|
|
||||||
"bubble_missed_count_one": "{{count}} пропущенный звонок",
|
|
||||||
"bubble_missed_count_few": "{{count}} пропущенных звонка",
|
|
||||||
"bubble_missed_count_many": "{{count}} пропущенных звонков",
|
|
||||||
"bubble_cancelled_count_one": "{{count}} отменённый звонок",
|
|
||||||
"bubble_cancelled_count_few": "{{count}} отменённых звонка",
|
|
||||||
"bubble_cancelled_count_many": "{{count}} отменённых звонков",
|
|
||||||
"duration_minutes_seconds": "{{minutes}} мин {{seconds}} сек",
|
|
||||||
"duration_seconds": "{{seconds}} сек"
|
|
||||||
},
|
},
|
||||||
"Room": {
|
"Room": {
|
||||||
"drag_to_close": "Потянуть вверх чтобы закрыть",
|
"drag_to_close": "Потянуть вверх чтобы закрыть",
|
||||||
|
|
@ -454,6 +435,7 @@
|
||||||
"jump_to_latest": "К последним",
|
"jump_to_latest": "К последним",
|
||||||
"today": "Сегодня",
|
"today": "Сегодня",
|
||||||
"yesterday": "Вчера",
|
"yesterday": "Вчера",
|
||||||
|
|
||||||
"view_reactions": "Реакции",
|
"view_reactions": "Реакции",
|
||||||
"read_receipts": "Подтверждения прочтения",
|
"read_receipts": "Подтверждения прочтения",
|
||||||
"view_source": "Исходный код",
|
"view_source": "Исходный код",
|
||||||
|
|
@ -465,6 +447,7 @@
|
||||||
"reply": "Ответить",
|
"reply": "Ответить",
|
||||||
"reply_in_thread": "Ответить в треде",
|
"reply_in_thread": "Ответить в треде",
|
||||||
"edit_message": "Редактировать",
|
"edit_message": "Редактировать",
|
||||||
|
|
||||||
"delete_message": "Удалить сообщение",
|
"delete_message": "Удалить сообщение",
|
||||||
"delete_confirm": "Это действие необратимо! Вы уверены, что хотите удалить это сообщение?",
|
"delete_confirm": "Это действие необратимо! Вы уверены, что хотите удалить это сообщение?",
|
||||||
"reason": "Причина",
|
"reason": "Причина",
|
||||||
|
|
@ -472,6 +455,7 @@
|
||||||
"delete_error": "Не удалось удалить сообщение! Попробуйте снова.",
|
"delete_error": "Не удалось удалить сообщение! Попробуйте снова.",
|
||||||
"deleting": "Удаление...",
|
"deleting": "Удаление...",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
|
|
||||||
"report_message": "Пожаловаться",
|
"report_message": "Пожаловаться",
|
||||||
"report_desc": "Сообщить о нарушении на сервер, который может уведомить ответственных лиц для принятия мер.",
|
"report_desc": "Сообщить о нарушении на сервер, который может уведомить ответственных лиц для принятия мер.",
|
||||||
"report_reason": "Причина",
|
"report_reason": "Причина",
|
||||||
|
|
@ -480,11 +464,13 @@
|
||||||
"reporting": "Отправка...",
|
"reporting": "Отправка...",
|
||||||
"report": "Пожаловаться",
|
"report": "Пожаловаться",
|
||||||
"no_reason": "Причина не указана",
|
"no_reason": "Причина не указана",
|
||||||
|
|
||||||
"is_typing": " печатает...",
|
"is_typing": " печатает...",
|
||||||
"and": " и ",
|
"and": " и ",
|
||||||
"are_typing": " печатают...",
|
"are_typing": " печатают...",
|
||||||
"others_count": "ещё {{count}}",
|
"others_count": "ещё {{count}}",
|
||||||
"drop_typing": "Скрыть индикатор набора",
|
"drop_typing": "Скрыть индикатор набора",
|
||||||
|
|
||||||
"members": "Участники",
|
"members": "Участники",
|
||||||
"members_count_one": "{{formattedCount}} участник",
|
"members_count_one": "{{formattedCount}} участник",
|
||||||
"members_count_few": "{{formattedCount}} участника",
|
"members_count_few": "{{formattedCount}} участника",
|
||||||
|
|
@ -503,30 +489,23 @@
|
||||||
"room_settings": "Настройки комнаты",
|
"room_settings": "Настройки комнаты",
|
||||||
"jump_to_time": "Перейти к дате",
|
"jump_to_time": "Перейти к дате",
|
||||||
"leave_room": "Покинуть комнату",
|
"leave_room": "Покинуть комнату",
|
||||||
|
|
||||||
"send_message": "Написать сообщение...",
|
"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}}\"",
|
"drop_files": "Перетащите файлы в \"{{name}}\"",
|
||||||
"drag_drop_desc": "Перетащите файлы сюда или нажмите для выбора",
|
"drag_drop_desc": "Перетащите файлы сюда или нажмите для выбора",
|
||||||
|
|
||||||
"pinned_messages": "Закреплённые сообщения",
|
"pinned_messages": "Закреплённые сообщения",
|
||||||
"no_pinned_messages": "Нет закреплённых сообщений",
|
"no_pinned_messages": "Нет закреплённых сообщений",
|
||||||
"no_pinned_messages_desc": "Пользователи с достаточным уровнем прав могут закреплять сообщения через контекстное меню.",
|
"no_pinned_messages_desc": "Пользователи с достаточным уровнем прав могут закреплять сообщения через контекстное меню.",
|
||||||
"open": "Открыть",
|
"open": "Открыть",
|
||||||
"failed_to_load": "Не удалось загрузить сообщение!",
|
"failed_to_load": "Не удалось загрузить сообщение!",
|
||||||
|
|
||||||
"time_label": "Время",
|
"time_label": "Время",
|
||||||
"date_label": "Дата",
|
"date_label": "Дата",
|
||||||
"preset": "Пресет",
|
"preset": "Пресет",
|
||||||
"beginning": "Начало",
|
"beginning": "Начало",
|
||||||
"open_timeline": "Открыть ленту",
|
"open_timeline": "Открыть ленту",
|
||||||
|
|
||||||
"message_deleted": "Сообщение было удалено",
|
"message_deleted": "Сообщение было удалено",
|
||||||
"message_deleted_reason": "Сообщение было удалено. {{reason}}",
|
"message_deleted_reason": "Сообщение было удалено. {{reason}}",
|
||||||
"unsupported_message": "Неподдерживаемое сообщение",
|
"unsupported_message": "Неподдерживаемое сообщение",
|
||||||
|
|
@ -536,6 +515,7 @@
|
||||||
"broken_message": "Повреждённое сообщение",
|
"broken_message": "Повреждённое сообщение",
|
||||||
"empty_message": "Пустое сообщение",
|
"empty_message": "Пустое сообщение",
|
||||||
"edited": " (изменено)",
|
"edited": " (изменено)",
|
||||||
|
|
||||||
"thread_caption": "Тред",
|
"thread_caption": "Тред",
|
||||||
"thread_in_channel_subtitle": "в #{{channel}}",
|
"thread_in_channel_subtitle": "в #{{channel}}",
|
||||||
"thread_close": "Закрыть тред",
|
"thread_close": "Закрыть тред",
|
||||||
|
|
@ -558,6 +538,7 @@
|
||||||
"thread_summary_highlight_many": "{{count}} упоминаний",
|
"thread_summary_highlight_many": "{{count}} упоминаний",
|
||||||
"thread_summary_highlight_other": "{{count}} упоминания",
|
"thread_summary_highlight_other": "{{count}} упоминания",
|
||||||
"no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате",
|
"no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате",
|
||||||
|
|
||||||
"conversation_beginning": "Начало переписки.",
|
"conversation_beginning": "Начало переписки.",
|
||||||
"created_by": "Комната создана <bold>@{{creator}}</bold> {{date}} {{time}}",
|
"created_by": "Комната создана <bold>@{{creator}}</bold> {{date}} {{time}}",
|
||||||
"invite_member": "Пригласить",
|
"invite_member": "Пригласить",
|
||||||
|
|
@ -568,6 +549,7 @@
|
||||||
"leave_room_error": "Не удалось покинуть комнату! {{error}}",
|
"leave_room_error": "Не удалось покинуть комнату! {{error}}",
|
||||||
"leaving": "Выход...",
|
"leaving": "Выход...",
|
||||||
"leave": "Покинуть",
|
"leave": "Покинуть",
|
||||||
|
|
||||||
"member_broken": "Некорректное событие участия",
|
"member_broken": "Некорректное событие участия",
|
||||||
"member_accepted_knock": "<bold>{{sender}}</bold> одобряет вступление <bold>{{user}}</bold>",
|
"member_accepted_knock": "<bold>{{sender}}</bold> одобряет вступление <bold>{{user}}</bold>",
|
||||||
"member_invited": "<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_name_removed": "<bold>{{user}}</bold> убирает отображаемое имя",
|
||||||
"member_avatar_changed": "<bold>{{user}}</bold> меняет аватар",
|
"member_avatar_changed": "<bold>{{user}}</bold> меняет аватар",
|
||||||
"member_avatar_removed": "<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": {
|
"Inbox": {
|
||||||
"invite_title": "Пригласить",
|
"invite_title": "Пригласить",
|
||||||
|
|
@ -593,15 +578,19 @@
|
||||||
"user_id_placeholder": "@username:server",
|
"user_id_placeholder": "@username:server",
|
||||||
"reason_optional": "Причина (необязательно)",
|
"reason_optional": "Причина (необязательно)",
|
||||||
"invite_button": "Пригласить",
|
"invite_button": "Пригласить",
|
||||||
|
|
||||||
"notif_default": "По умолчанию",
|
"notif_default": "По умолчанию",
|
||||||
"notif_all_messages": "Все сообщения",
|
"notif_all_messages": "Все сообщения",
|
||||||
"notif_mentions_keywords": "Упоминания и ключевые слова",
|
"notif_mentions_keywords": "Упоминания и ключевые слова",
|
||||||
"notif_mute": "Без уведомлений",
|
"notif_mute": "Без уведомлений",
|
||||||
|
|
||||||
"unverified_device": "Неподтверждённое устройство",
|
"unverified_device": "Неподтверждённое устройство",
|
||||||
"unverified_devices": "Неподтверждённые устройства"
|
"unverified_devices": "Неподтверждённые устройства"
|
||||||
},
|
},
|
||||||
|
|
||||||
"Explore": {
|
"Explore": {
|
||||||
"explore_community": "Обзор сообществ",
|
"explore_community": "Обзор сообществ",
|
||||||
|
|
||||||
"add_server": "Добавить сервер",
|
"add_server": "Добавить сервер",
|
||||||
"add_server_desc": "Укажите имя сервера для обзора публичных сообществ.",
|
"add_server_desc": "Укажите имя сервера для обзора публичных сообществ.",
|
||||||
"server_name": "Имя сервера",
|
"server_name": "Имя сервера",
|
||||||
|
|
@ -609,11 +598,13 @@
|
||||||
"view": "Открыть",
|
"view": "Открыть",
|
||||||
"featured": "Рекомендуемые",
|
"featured": "Рекомендуемые",
|
||||||
"servers": "Серверы",
|
"servers": "Серверы",
|
||||||
|
|
||||||
"featured_by_client": "Рекомендации клиента",
|
"featured_by_client": "Рекомендации клиента",
|
||||||
"featured_by_client_desc": "Подборка публичных комнат и пространств от этого клиента.",
|
"featured_by_client_desc": "Подборка публичных комнат и пространств от этого клиента.",
|
||||||
"featured_spaces": "Рекомендуемые пространства",
|
"featured_spaces": "Рекомендуемые пространства",
|
||||||
"featured_rooms": "Рекомендуемые комнаты",
|
"featured_rooms": "Рекомендуемые комнаты",
|
||||||
"no_featured": "Рекомендуемых комнат и пространств пока нет.",
|
"no_featured": "Рекомендуемых комнат и пространств пока нет.",
|
||||||
|
|
||||||
"search": "Поиск",
|
"search": "Поиск",
|
||||||
"search_placeholder": "Поиск по ключевому слову",
|
"search_placeholder": "Поиск по ключевому слову",
|
||||||
"clear": "Очистить",
|
"clear": "Очистить",
|
||||||
|
|
@ -632,11 +623,9 @@
|
||||||
"previous_page": "Предыдущая",
|
"previous_page": "Предыдущая",
|
||||||
"next_page": "Следующая",
|
"next_page": "Следующая",
|
||||||
"no_communities": "Сообщества не найдены!",
|
"no_communities": "Сообщества не найдены!",
|
||||||
|
|
||||||
"space_badge": "Пространство",
|
"space_badge": "Пространство",
|
||||||
"members_count_one": "{{formattedCount}} участник",
|
"members_count": "{{count}} участников",
|
||||||
"members_count_few": "{{formattedCount}} участника",
|
|
||||||
"members_count_many": "{{formattedCount}} участников",
|
|
||||||
"members_count_other": "{{formattedCount}} участника",
|
|
||||||
"join": "Присоединиться",
|
"join": "Присоединиться",
|
||||||
"joining": "Вступление…",
|
"joining": "Вступление…",
|
||||||
"retry": "Повторить",
|
"retry": "Повторить",
|
||||||
|
|
@ -645,6 +634,7 @@
|
||||||
"view_error": "Подробности",
|
"view_error": "Подробности",
|
||||||
"cancel": "Отмена"
|
"cancel": "Отмена"
|
||||||
},
|
},
|
||||||
|
|
||||||
"Create": {
|
"Create": {
|
||||||
"add_space": "Добавить пространство",
|
"add_space": "Добавить пространство",
|
||||||
"create_space": "Создать пространство",
|
"create_space": "Создать пространство",
|
||||||
|
|
@ -652,6 +642,7 @@
|
||||||
"join_with_address": "Присоединиться по адресу",
|
"join_with_address": "Присоединиться по адресу",
|
||||||
"join_with_address_desc": "Присоединиться к существующему сообществу.",
|
"join_with_address_desc": "Присоединиться к существующему сообществу.",
|
||||||
"new_space": "Новое пространство",
|
"new_space": "Новое пространство",
|
||||||
|
|
||||||
"access": "Доступ",
|
"access": "Доступ",
|
||||||
"name": "Название",
|
"name": "Название",
|
||||||
"topic_optional": "Тема (необязательно)",
|
"topic_optional": "Тема (необязательно)",
|
||||||
|
|
@ -663,38 +654,47 @@
|
||||||
"allow_federation_desc": "Пользователи с других серверов смогут присоединиться.",
|
"allow_federation_desc": "Пользователи с других серверов смогут присоединиться.",
|
||||||
"create": "Создать",
|
"create": "Создать",
|
||||||
"rate_limited": "Сервер ограничил ваш запрос на {{minutes}} мин.!",
|
"rate_limited": "Сервер ограничил ваш запрос на {{minutes}} мин.!",
|
||||||
|
|
||||||
"access_restricted": "Ограниченный",
|
"access_restricted": "Ограниченный",
|
||||||
"access_restricted_desc": "Могут присоединиться только участники родительского пространства.",
|
"access_restricted_desc": "Могут присоединиться только участники родительского пространства.",
|
||||||
"access_private": "Приватный",
|
"access_private": "Приватный",
|
||||||
"access_private_desc": "Могут присоединиться только приглашённые.",
|
"access_private_desc": "Могут присоединиться только приглашённые.",
|
||||||
"access_public": "Публичный",
|
"access_public": "Публичный",
|
||||||
"access_public_desc": "Любой, у кого есть адрес, может присоединиться.",
|
"access_public_desc": "Любой, у кого есть адрес, может присоединиться.",
|
||||||
|
|
||||||
"address_optional": "Адрес (необязательно)",
|
"address_optional": "Адрес (необязательно)",
|
||||||
"address_hint": "Выберите уникальный адрес, чтобы пространство можно было найти.",
|
"address_hint": "Выберите уникальный адрес, чтобы пространство можно было найти.",
|
||||||
"address_taken": "Этот адрес уже занят. Выберите другой.",
|
"address_taken": "Этот адрес уже занят. Выберите другой.",
|
||||||
|
|
||||||
"founders": "Основатели",
|
"founders": "Основатели",
|
||||||
"founders_desc": "Привилегированные пользователи, назначенные при создании. Они имеют расширенные полномочия; изменить их можно только при обновлении пространства.",
|
"founders_desc": "Привилегированные пользователи, назначенные при создании. Они имеют расширенные полномочия; изменить их можно только при обновлении пространства.",
|
||||||
"enter": "Добавить",
|
"enter": "Добавить",
|
||||||
"no_suggestions": "Нет предложений",
|
"no_suggestions": "Нет предложений",
|
||||||
"no_suggestions_desc": "Введите ID пользователя и нажмите Добавить.",
|
"no_suggestions_desc": "Введите ID пользователя и нажмите Добавить.",
|
||||||
|
|
||||||
"version": "Версия",
|
"version": "Версия",
|
||||||
"versions": "Версии",
|
"versions": "Версии",
|
||||||
|
|
||||||
"chat_room": "Чат-комната",
|
"chat_room": "Чат-комната",
|
||||||
"chat_room_desc": "Сообщения, фото и видео.",
|
"chat_room_desc": "Сообщения, фото и видео.",
|
||||||
"voice_room": "Голосовая комната",
|
"voice_room": "Голосовая комната",
|
||||||
"voice_room_desc": "Голосовые и видеозвонки в реальном времени.",
|
"voice_room_desc": "Голосовые и видеозвонки в реальном времени.",
|
||||||
|
|
||||||
"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": {
|
"RoomSettings": {
|
||||||
"general": "Основные",
|
"general": "Основные",
|
||||||
"members": "Участники",
|
"members": "Участники",
|
||||||
"permissions": "Права доступа",
|
"permissions": "Права доступа",
|
||||||
"emojis_stickers": "Эмодзи и стикеры",
|
"emojis_stickers": "Эмодзи и стикеры",
|
||||||
"developer_tools": "Инструменты разработчика",
|
"developer_tools": "Инструменты разработчика",
|
||||||
|
|
||||||
"profile": "Профиль",
|
"profile": "Профиль",
|
||||||
"edit": "Редактировать",
|
"edit": "Редактировать",
|
||||||
"unknown": "Неизвестно",
|
"unknown": "Неизвестно",
|
||||||
|
|
@ -706,25 +706,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": "Изменить способ вступления в пространство.",
|
"space_access_desc": "Изменить способ вступления в пространство.",
|
||||||
"room_access_desc": "Изменить способ вступления в комнату.",
|
"room_access_desc": "Изменить способ вступления в комнату.",
|
||||||
|
|
||||||
"join_invite_only": "Только по приглашению",
|
"join_invite_only": "Только по приглашению",
|
||||||
"join_knock_invite": "Запрос и приглашение",
|
"join_knock_invite": "Запрос и приглашение",
|
||||||
"join_space_members_or_knock": "Участники пространства или запрос",
|
"join_space_members_or_knock": "Участники пространства или запрос",
|
||||||
"join_space_members": "Участники пространства",
|
"join_space_members": "Участники пространства",
|
||||||
"join_public": "Публичный",
|
"join_public": "Публичный",
|
||||||
"join_unsupported": "Не поддерживается",
|
"join_unsupported": "Не поддерживается",
|
||||||
|
|
||||||
"history_visibility": "Видимость истории сообщений",
|
"history_visibility": "Видимость истории сообщений",
|
||||||
"history_visibility_desc": "Изменения видимости истории применяются только к новым сообщениям и не затрагивают существующую историю.",
|
"history_visibility_desc": "Изменения видимости истории применяются только к новым сообщениям и не затрагивают существующую историю.",
|
||||||
"visibility_after_invite": "После приглашения",
|
"visibility_after_invite": "После приглашения",
|
||||||
"visibility_after_join": "После вступления",
|
"visibility_after_join": "После вступления",
|
||||||
"visibility_all_messages": "Все сообщения",
|
"visibility_all_messages": "Все сообщения",
|
||||||
"visibility_all_messages_guests": "Все сообщения (гости)",
|
"visibility_all_messages_guests": "Все сообщения (гости)",
|
||||||
|
|
||||||
"room_encryption": "Шифрование комнаты",
|
"room_encryption": "Шифрование комнаты",
|
||||||
"encryption_enabled_desc": "Сообщения в этой комнате защищены сквозным шифрованием.",
|
"encryption_enabled_desc": "Сообщения в этой комнате защищены сквозным шифрованием.",
|
||||||
"encryption_disabled_desc": "После включения шифрование невозможно отключить!",
|
"encryption_disabled_desc": "После включения шифрование невозможно отключить!",
|
||||||
|
|
@ -733,9 +738,11 @@
|
||||||
"enable_encryption": "Включить шифрование",
|
"enable_encryption": "Включить шифрование",
|
||||||
"enable_encryption_confirm": "Вы уверены? После включения шифрование невозможно отключить!",
|
"enable_encryption_confirm": "Вы уверены? После включения шифрование невозможно отключить!",
|
||||||
"enable_e2e_encryption": "Включить E2E-шифрование",
|
"enable_e2e_encryption": "Включить E2E-шифрование",
|
||||||
|
|
||||||
"publish_to_directory": "Показывать в поиске",
|
"publish_to_directory": "Показывать в поиске",
|
||||||
"publish_space_desc": "Сделать пространство видимым в общем списке, чтобы другие пользователи могли его найти.",
|
"publish_space_desc": "Сделать пространство видимым в общем списке, чтобы другие пользователи могли его найти.",
|
||||||
"publish_room_desc": "Сделать комнату видимой в общем списке, чтобы другие пользователи могли её найти.",
|
"publish_room_desc": "Сделать комнату видимой в общем списке, чтобы другие пользователи могли её найти.",
|
||||||
|
|
||||||
"published_addresses": "Опубликованные адреса",
|
"published_addresses": "Опубликованные адреса",
|
||||||
"published_addresses_desc": "Если доступ <b>публичный</b>, опубликованные адреса будут использоваться для присоединения.",
|
"published_addresses_desc": "Если доступ <b>публичный</b>, опубликованные адреса будут использоваться для присоединения.",
|
||||||
"no_addresses": "Нет адресов",
|
"no_addresses": "Нет адресов",
|
||||||
|
|
@ -749,11 +756,13 @@
|
||||||
"publish": "Опубликовать",
|
"publish": "Опубликовать",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
"selected_count": "Выбрано: {{count}}",
|
"selected_count": "Выбрано: {{count}}",
|
||||||
|
|
||||||
"local_addresses": "Локальные адреса",
|
"local_addresses": "Локальные адреса",
|
||||||
"local_addresses_desc": "Задайте локальный адрес, чтобы пользователи могли присоединиться через ваш сервер.",
|
"local_addresses_desc": "Задайте локальный адрес, чтобы пользователи могли присоединиться через ваш сервер.",
|
||||||
"collapse": "Свернуть",
|
"collapse": "Свернуть",
|
||||||
"expand": "Развернуть",
|
"expand": "Развернуть",
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
|
|
||||||
"space_upgrade": "Обновление пространства",
|
"space_upgrade": "Обновление пространства",
|
||||||
"room_upgrade": "Обновление комнаты",
|
"room_upgrade": "Обновление комнаты",
|
||||||
"upgrade": "Обновить",
|
"upgrade": "Обновить",
|
||||||
|
|
@ -767,21 +776,25 @@
|
||||||
"old_room": "Старая комната",
|
"old_room": "Старая комната",
|
||||||
"open_new_space": "Открыть новое пространство",
|
"open_new_space": "Открыть новое пространство",
|
||||||
"open_new_room": "Открыть новую комнату",
|
"open_new_room": "Открыть новую комнату",
|
||||||
|
|
||||||
"members_count": "{{count}} участников",
|
"members_count": "{{count}} участников",
|
||||||
"search": "Поиск",
|
"search": "Поиск",
|
||||||
"no_results": "Ничего не найдено",
|
"no_results": "Ничего не найдено",
|
||||||
"results_count": "{{count}} результатов",
|
"results_count": "{{count}} результатов",
|
||||||
"scroll_to_top": "Наверх",
|
"scroll_to_top": "Наверх",
|
||||||
"no_membership_members": "Нет участников «{{filter}}»",
|
"no_membership_members": "Нет участников «{{filter}}»",
|
||||||
|
|
||||||
"filter_joined": "Вступившие",
|
"filter_joined": "Вступившие",
|
||||||
"filter_invited": "Приглашённые",
|
"filter_invited": "Приглашённые",
|
||||||
"filter_left": "Вышедшие",
|
"filter_left": "Вышедшие",
|
||||||
"filter_kicked": "Исключённые",
|
"filter_kicked": "Исключённые",
|
||||||
"filter_banned": "Забаненные",
|
"filter_banned": "Забаненные",
|
||||||
|
|
||||||
"sort_a_to_z": "А — Я",
|
"sort_a_to_z": "А — Я",
|
||||||
"sort_z_to_a": "Я — А",
|
"sort_z_to_a": "Я — А",
|
||||||
"sort_newest": "Новые",
|
"sort_newest": "Новые",
|
||||||
"sort_oldest": "Старые",
|
"sort_oldest": "Старые",
|
||||||
|
|
||||||
"perm_messages": "Сообщения",
|
"perm_messages": "Сообщения",
|
||||||
"perm_send_messages": "Отправка сообщений",
|
"perm_send_messages": "Отправка сообщений",
|
||||||
"perm_send_stickers": "Отправка стикеров",
|
"perm_send_stickers": "Отправка стикеров",
|
||||||
|
|
@ -814,10 +827,12 @@
|
||||||
"perm_manage_emojis_stickers": "Управление эмодзи и стикерами",
|
"perm_manage_emojis_stickers": "Управление эмодзи и стикерами",
|
||||||
"perm_change_server_acls": "Изменение ACL серверов",
|
"perm_change_server_acls": "Изменение ACL серверов",
|
||||||
"perm_modify_widgets": "Изменение виджетов",
|
"perm_modify_widgets": "Изменение виджетов",
|
||||||
|
|
||||||
"founders": "Основатели",
|
"founders": "Основатели",
|
||||||
"founders_desc": "Основатели имеют все права. Изменить их состав можно только при обновлении комнаты.",
|
"founders_desc": "Основатели имеют все права. Изменить их состав можно только при обновлении комнаты.",
|
||||||
"power_levels": "Уровни власти",
|
"power_levels": "Уровни власти",
|
||||||
"power_levels_desc": "Управление и настройка уровней власти для пользователей.",
|
"power_levels_desc": "Управление и настройка уровней власти для пользователей.",
|
||||||
|
|
||||||
"new_power_level": "Новый уровень власти",
|
"new_power_level": "Новый уровень власти",
|
||||||
"power_level_placeholder": "Бот",
|
"power_level_placeholder": "Бот",
|
||||||
"new_power_level_desc": "Создать новый уровень власти.",
|
"new_power_level_desc": "Создать новый уровень власти.",
|
||||||
|
|
@ -834,9 +849,11 @@
|
||||||
"failed_to_apply": "Не удалось применить изменения! Попробуйте ещё раз.",
|
"failed_to_apply": "Не удалось применить изменения! Попробуйте ещё раз.",
|
||||||
"apply_changes": "Применить изменения",
|
"apply_changes": "Применить изменения",
|
||||||
"and_above": "и выше",
|
"and_above": "и выше",
|
||||||
|
|
||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
"default_power": "Уровень по умолчанию",
|
"default_power": "Уровень по умолчанию",
|
||||||
"default_power_desc": "Уровень власти по умолчанию для всех пользователей.",
|
"default_power_desc": "Уровень власти по умолчанию для всех пользователей.",
|
||||||
|
|
||||||
"packs": "Паки",
|
"packs": "Паки",
|
||||||
"new_pack": "Новый пак",
|
"new_pack": "Новый пак",
|
||||||
"new_pack_desc": "Добавьте свой пак эмодзи и стикеров для использования в комнате.",
|
"new_pack_desc": "Добавьте свой пак эмодзи и стикеров для использования в комнате.",
|
||||||
|
|
@ -845,6 +862,7 @@
|
||||||
"view": "Открыть",
|
"view": "Открыть",
|
||||||
"failed_to_remove_packs": "Не удалось удалить паки! Попробуйте ещё раз.",
|
"failed_to_remove_packs": "Не удалось удалить паки! Попробуйте ещё раз.",
|
||||||
"delete_selected_packs": "Удалить выбранные паки. (Выбрано: {{count}})",
|
"delete_selected_packs": "Удалить выбранные паки. (Выбрано: {{count}})",
|
||||||
|
|
||||||
"enable_developer_tools": "Включить инструменты разработчика",
|
"enable_developer_tools": "Включить инструменты разработчика",
|
||||||
"room_id": "ID комнаты",
|
"room_id": "ID комнаты",
|
||||||
"room_id_desc": "Скопировать ID комнаты в буфер обмена.",
|
"room_id_desc": "Скопировать ID комнаты в буфер обмена.",
|
||||||
|
|
@ -867,6 +885,7 @@
|
||||||
"message_event_type": "Тип события сообщения",
|
"message_event_type": "Тип события сообщения",
|
||||||
"send": "Отправить",
|
"send": "Отправить",
|
||||||
"state_key_optional": "State Key (необязательно)",
|
"state_key_optional": "State Key (необязательно)",
|
||||||
|
|
||||||
"pack": "Пак",
|
"pack": "Пак",
|
||||||
"images_usage": "Использование изображений",
|
"images_usage": "Использование изображений",
|
||||||
"images_usage_desc": "Выберите, как используются изображения: как эмодзи, как стикеры или как и то, и другое.",
|
"images_usage_desc": "Выберите, как используются изображения: как эмодзи, как стикеры или как и то, и другое.",
|
||||||
|
|
@ -881,6 +900,7 @@
|
||||||
"usage_both": "Оба",
|
"usage_both": "Оба",
|
||||||
"usage_sticker": "Стикер",
|
"usage_sticker": "Стикер",
|
||||||
"usage_emoji": "Эмодзи",
|
"usage_emoji": "Эмодзи",
|
||||||
|
|
||||||
"power_goku": "Гоку",
|
"power_goku": "Гоку",
|
||||||
"power_manager": "Менеджер",
|
"power_manager": "Менеджер",
|
||||||
"power_founder": "Основатель",
|
"power_founder": "Основатель",
|
||||||
|
|
@ -890,6 +910,7 @@
|
||||||
"power_muted": "Без голоса",
|
"power_muted": "Без голоса",
|
||||||
"power_team": "Команда"
|
"power_team": "Команда"
|
||||||
},
|
},
|
||||||
|
|
||||||
"Push": {
|
"Push": {
|
||||||
"new_message": "Новое сообщение",
|
"new_message": "Новое сообщение",
|
||||||
"new_messages": "Новые сообщения",
|
"new_messages": "Новые сообщения",
|
||||||
|
|
@ -900,19 +921,7 @@
|
||||||
"invite_body": "{{inviter}} приглашает вас в {{roomName}}",
|
"invite_body": "{{inviter}} приглашает вас в {{roomName}}",
|
||||||
"invite_body_no_room": "{{inviter}} приглашает вас в комнату",
|
"invite_body_no_room": "{{inviter}} приглашает вас в комнату",
|
||||||
"invite_body_no_inviter": "Приглашение в {{roomName}}",
|
"invite_body_no_inviter": "Приглашение в {{roomName}}",
|
||||||
"invite_body_generic": "Новое приглашение",
|
"invite_body_generic": "Новое приглашение"
|
||||||
"missed_call": "Пропущенный звонок",
|
|
||||||
"missed_call_body": "{{caller}} пытался вам дозвониться",
|
|
||||||
"channel_group": "Чаты",
|
|
||||||
"channel_dm": "Личные сообщения",
|
|
||||||
"channel_dm_description": "Новые сообщения из личных переписок",
|
|
||||||
"channel_group_room": "Групповые чаты",
|
|
||||||
"channel_group_room_description": "Новые сообщения из групповых чатов и каналов",
|
|
||||||
"self_name": "Я",
|
|
||||||
"action_mark_as_read": "Прочитано",
|
|
||||||
"action_reply": "Ответить",
|
|
||||||
"reply_hint": "Ответ…",
|
|
||||||
"reply_failed": "Не удалось отправить ответ"
|
|
||||||
},
|
},
|
||||||
"Bots": {
|
"Bots": {
|
||||||
"not_connected_title": "{{name}} не подключён",
|
"not_connected_title": "{{name}} не подключён",
|
||||||
|
|
@ -987,15 +996,5 @@
|
||||||
"copy_server": "Скопировать сервер",
|
"copy_server": "Скопировать сервер",
|
||||||
"explore_community": "Открыть сервер",
|
"explore_community": "Открыть сервер",
|
||||||
"open_in_browser": "Открыть в браузере"
|
"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",
|
"display": "standalone",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"start_url": "./",
|
"start_url": "./",
|
||||||
"background_color": "#0d0e11",
|
"background_color": "#000",
|
||||||
"theme_color": "#0d0e11",
|
"theme_color": "#000",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "./public/android/vojo.svg",
|
"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_room',
|
||||||
'invite_body_no_inviter',
|
'invite_body_no_inviter',
|
||||||
'invite_body_generic',
|
'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
|
// 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
|
// inviter, roomName) always passes inviter in position 1, roomName in
|
||||||
// position 2, regardless of how the translators order them in the JSON.
|
// position 2, regardless of how the translators order them in the JSON.
|
||||||
// Adding a new placeholder: add it here AND update PushStrings accordingly.
|
// 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 = {
|
const PLACEHOLDER_POSITIONS = {
|
||||||
inviter: 1,
|
inviter: 1,
|
||||||
roomName: 2,
|
roomName: 2,
|
||||||
caller: 1,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const LANGS = {
|
const LANGS = {
|
||||||
|
|
@ -131,7 +115,7 @@ function verifyParity(bundles) {
|
||||||
const locales = Object.keys(bundles);
|
const locales = Object.keys(bundles);
|
||||||
const [first, ...rest] = locales;
|
const [first, ...rest] = locales;
|
||||||
const firstKeys = new Set(Object.keys(bundles[first]));
|
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 keys = new Set(Object.keys(bundles[locale]));
|
||||||
const missingInOther = [...firstKeys].filter((k) => !keys.has(k));
|
const missingInOther = [...firstKeys].filter((k) => !keys.has(k));
|
||||||
const extraInOther = [...keys].filter((k) => !firstKeys.has(k));
|
const extraInOther = [...keys].filter((k) => !firstKeys.has(k));
|
||||||
|
|
@ -142,13 +126,13 @@ function verifyParity(bundles) {
|
||||||
` Extra in ${locale}: ${JSON.stringify(extraInOther)}`
|
` Extra in ${locale}: ${JSON.stringify(extraInOther)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
ANDROID_KEYS.forEach((key) => {
|
for (const key of ANDROID_KEYS) {
|
||||||
locales.forEach((locale) => {
|
for (const locale of locales) {
|
||||||
if (typeof bundles[locale][key] !== 'string') {
|
if (typeof bundles[locale][key] !== 'string') {
|
||||||
throw new Error(`Push.${key} missing or non-string in ${locale}.json`);
|
throw new Error(`Push.${key} missing or non-string in ${locale}.json`);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
// Placeholder tokens must match across locales for any given key —
|
// Placeholder tokens must match across locales for any given key —
|
||||||
// a translator adding {{user}} on one side silently produces
|
// a translator adding {{user}} on one side silently produces
|
||||||
// literal-curly-brace output on the other surface.
|
// literal-curly-brace output on the other surface.
|
||||||
|
|
@ -162,7 +146,7 @@ function verifyParity(bundles) {
|
||||||
return { locale, tokens };
|
return { locale, tokens };
|
||||||
});
|
});
|
||||||
const baseline = tokenSets[0];
|
const baseline = tokenSets[0];
|
||||||
tokenSets.slice(1).forEach((entry) => {
|
for (const entry of tokenSets.slice(1)) {
|
||||||
const baselineArr = [...baseline.tokens].sort();
|
const baselineArr = [...baseline.tokens].sort();
|
||||||
const entryArr = [...entry.tokens].sort();
|
const entryArr = [...entry.tokens].sort();
|
||||||
if (baselineArr.length !== entryArr.length || baselineArr.some((t, i) => t !== entryArr[i])) {
|
if (baselineArr.length !== entryArr.length || baselineArr.some((t, i) => t !== entryArr[i])) {
|
||||||
|
|
@ -172,8 +156,8 @@ function verifyParity(bundles) {
|
||||||
`${entry.locale}=${JSON.stringify(entryArr)}`
|
`${entry.locale}=${JSON.stringify(entryArr)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitResource(locale, bundle, resDir) {
|
function emitResource(locale, bundle, resDir) {
|
||||||
|
|
@ -186,12 +170,12 @@ function emitResource(locale, bundle, resDir) {
|
||||||
'-->',
|
'-->',
|
||||||
'<resources>',
|
'<resources>',
|
||||||
];
|
];
|
||||||
ANDROID_KEYS.forEach((key) => {
|
for (const key of ANDROID_KEYS) {
|
||||||
const raw = bundle[key];
|
const raw = bundle[key];
|
||||||
const { text, placeholders } = convertPlaceholders(raw, locale, key);
|
const { text, placeholders } = convertPlaceholders(raw, locale, key);
|
||||||
const formattedAttr = placeholders.size > 0 ? ' formatted="true"' : '';
|
const formattedAttr = placeholders.size > 0 ? ' formatted="true"' : '';
|
||||||
lines.push(` <string name="push_${key}"${formattedAttr}>${xmlEscape(text)}</string>`);
|
lines.push(` <string name="push_${key}"${formattedAttr}>${xmlEscape(text)}</string>`);
|
||||||
});
|
}
|
||||||
lines.push('</resources>');
|
lines.push('</resources>');
|
||||||
lines.push('');
|
lines.push('');
|
||||||
const outPath = path.join(resDir, LANGS[locale], 'push_strings.xml');
|
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 resDir = outIdx !== -1 ? path.resolve(process.argv[outIdx + 1]) : DEFAULT_OUT;
|
||||||
|
|
||||||
const bundles = Object.keys(LANGS).reduce((acc, locale) => {
|
const bundles = {};
|
||||||
acc[locale] = readBundle(locale);
|
for (const locale of Object.keys(LANGS)) {
|
||||||
return acc;
|
bundles[locale] = readBundle(locale);
|
||||||
}, {});
|
}
|
||||||
verifyParity(bundles);
|
verifyParity(bundles);
|
||||||
Object.keys(LANGS).forEach((locale) => {
|
for (const locale of Object.keys(LANGS)) {
|
||||||
const outPath = emitResource(locale, bundles[locale], resDir);
|
const outPath = emitResource(locale, bundles[locale], resDir);
|
||||||
process.stdout.write(` wrote ${path.relative(ROOT, outPath)}\n`);
|
process.stdout.write(` wrote ${path.relative(ROOT, outPath)}\n`);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
|
||||||
>
|
>
|
||||||
{stageToComplete.type === AuthType.Password && (
|
{stageToComplete.type === AuthType.Password && (
|
||||||
<PasswordStage
|
<PasswordStage
|
||||||
userId={mx.getSafeUserId()}
|
userId={mx.getUserId()!}
|
||||||
stageData={stageToComplete}
|
stageData={stageToComplete}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
submitAuthDict={action}
|
submitAuthDict={action}
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ function makeUIAAction<T>(
|
||||||
authData: IAuthData,
|
authData: IAuthData,
|
||||||
performAction: PerformAction<T>,
|
performAction: PerformAction<T>,
|
||||||
resolve: (data: T) => void,
|
resolve: (data: T) => void,
|
||||||
reject: (error?: unknown) => void
|
reject: (error?: any) => void
|
||||||
): UIAAction<T> {
|
): UIAAction<T> {
|
||||||
const action: UIAAction<T> = {
|
const action: UIAAction<T> = {
|
||||||
authData,
|
authData,
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export const ImageOverlay = as<'div', ImageOverlayProps>(
|
||||||
<Modal
|
<Modal
|
||||||
className={ModalWide}
|
className={ModalWide}
|
||||||
size="500"
|
size="500"
|
||||||
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
|
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||||
>
|
>
|
||||||
{renderViewer({
|
{renderViewer({
|
||||||
src,
|
src,
|
||||||
|
|
|
||||||
|
|
@ -20,17 +20,7 @@ export function Modal500({ requestClose, children }: Modal500Props) {
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal
|
<Modal size="500" variant="Background">
|
||||||
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' }}
|
|
||||||
>
|
|
||||||
{/* PageRoot rendered inside the dialog (Settings,
|
{/* PageRoot rendered inside the dialog (Settings,
|
||||||
SpaceSettings, RoomSettings) would otherwise pick up
|
SpaceSettings, RoomSettings) would otherwise pick up
|
||||||
the web horseshoe layout — void column + rounded
|
the web horseshoe layout — void column + rounded
|
||||||
|
|
|
||||||
|
|
@ -65,12 +65,6 @@ type RenderMessageContentProps = {
|
||||||
htmlReactParserOptions: HTMLReactParserOptions;
|
htmlReactParserOptions: HTMLReactParserOptions;
|
||||||
linkifyOpts: Opts;
|
linkifyOpts: Opts;
|
||||||
outlineAttachment?: boolean;
|
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({
|
export function RenderMessageContent({
|
||||||
displayName,
|
displayName,
|
||||||
|
|
@ -84,7 +78,6 @@ export function RenderMessageContent({
|
||||||
htmlReactParserOptions,
|
htmlReactParserOptions,
|
||||||
linkifyOpts,
|
linkifyOpts,
|
||||||
outlineAttachment,
|
outlineAttachment,
|
||||||
eventId,
|
|
||||||
}: RenderMessageContentProps) {
|
}: RenderMessageContentProps) {
|
||||||
const streamMedia = useStreamMediaContext();
|
const streamMedia = useStreamMediaContext();
|
||||||
const renderUrlsPreview = (urls: string[]) => {
|
const renderUrlsPreview = (urls: string[]) => {
|
||||||
|
|
@ -226,7 +219,6 @@ export function RenderMessageContent({
|
||||||
<ImageContent
|
<ImageContent
|
||||||
{...props}
|
{...props}
|
||||||
autoPlay={mediaAutoLoad}
|
autoPlay={mediaAutoLoad}
|
||||||
eventId={eventId}
|
|
||||||
renderImage={(p) => <Image {...p} loading="lazy" decoding="async" />}
|
renderImage={(p) => <Image {...p} loading="lazy" decoding="async" />}
|
||||||
renderViewer={(p) => <ImageViewer {...p} />}
|
renderViewer={(p) => <ImageViewer {...p} />}
|
||||||
/>
|
/>
|
||||||
|
|
@ -266,7 +258,6 @@ export function RenderMessageContent({
|
||||||
body={body}
|
body={body}
|
||||||
info={info}
|
info={info}
|
||||||
{...props}
|
{...props}
|
||||||
eventId={eventId}
|
|
||||||
renderThumbnail={
|
renderThumbnail={
|
||||||
mediaAutoLoad
|
mediaAutoLoad
|
||||||
? () => (
|
? () => (
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,6 @@ export function SecretStorageRecoveryPassphrase({
|
||||||
bits
|
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);
|
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
|
|
@ -133,8 +131,6 @@ export function SecretStorageRecoveryKey({
|
||||||
async (recoveryKey) => {
|
async (recoveryKey) => {
|
||||||
const decodedRecoveryKey = decodeRecoveryKey(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);
|
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,6 @@ export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
|
||||||
try {
|
try {
|
||||||
validatedAuthMetadata = validateAuthMetadata(authMetadata);
|
validatedAuthMetadata = validateAuthMetadata(authMetadata);
|
||||||
} catch (e) {
|
} 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);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
RestrictedAllowType,
|
RestrictedAllowType,
|
||||||
Room,
|
Room,
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import type { StateEvents } from 'matrix-js-sdk';
|
|
||||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||||
import { RoomType, StateEvent } from '../../../types/matrix/room';
|
import { RoomType, StateEvent } from '../../../types/matrix/room';
|
||||||
import { getViaServers } from '../../plugins/via-servers';
|
import { getViaServers } from '../../plugins/via-servers';
|
||||||
|
|
@ -18,7 +17,7 @@ export const createRoomCreationContent = (
|
||||||
allowFederation: boolean,
|
allowFederation: boolean,
|
||||||
additionalCreators: string[] | undefined
|
additionalCreators: string[] | undefined
|
||||||
): object => {
|
): object => {
|
||||||
const content: Record<string, unknown> = {};
|
const content: Record<string, any> = {};
|
||||||
if (typeof type === 'string') {
|
if (typeof type === 'string') {
|
||||||
content.type = type;
|
content.type = type;
|
||||||
}
|
}
|
||||||
|
|
@ -153,11 +152,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
||||||
if (data.parent) {
|
if (data.parent) {
|
||||||
await mx.sendStateEvent(
|
await mx.sendStateEvent(
|
||||||
data.parent.roomId,
|
data.parent.roomId,
|
||||||
StateEvent.SpaceChild as keyof StateEvents,
|
StateEvent.SpaceChild as any,
|
||||||
{
|
{
|
||||||
auto_join: false,
|
auto_join: false,
|
||||||
suggested: false,
|
suggested: false,
|
||||||
via: [getMxIdServer(mx.getSafeUserId()) ?? ''],
|
via: [getMxIdServer(mx.getUserId() ?? '') ?? ''],
|
||||||
},
|
},
|
||||||
result.room_id
|
result.room_id
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
|
||||||
const roomAliasFromQueryText = (mx: MatrixClient, text: string) =>
|
const roomAliasFromQueryText = (mx: MatrixClient, text: string) =>
|
||||||
isRoomAlias(`#${text}`)
|
isRoomAlias(`#${text}`)
|
||||||
? `#${text}`
|
? `#${text}`
|
||||||
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getSafeUserId())}`;
|
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
||||||
|
|
||||||
function UnknownRoomMentionItem({
|
function UnknownRoomMentionItem({
|
||||||
query,
|
query,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ type MentionAutoCompleteHandler = (userId: string, name: string) => void;
|
||||||
const userIdFromQueryText = (mx: MatrixClient, text: string) =>
|
const userIdFromQueryText = (mx: MatrixClient, text: string) =>
|
||||||
isUserId(`@${text}`)
|
isUserId(`@${text}`)
|
||||||
? `@${text}`
|
? `@${text}`
|
||||||
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getSafeUserId())}`;
|
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
||||||
|
|
||||||
function UnknownMentionItem({
|
function UnknownMentionItem({
|
||||||
userId,
|
userId,
|
||||||
|
|
@ -92,7 +92,7 @@ export function UserMentionAutocomplete({
|
||||||
}: UserMentionAutocompleteProps) {
|
}: UserMentionAutocompleteProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const { roomId } = room;
|
const roomId: string = room.roomId!;
|
||||||
const roomAliasOrId = room.getCanonicalAlias() || roomId;
|
const roomAliasOrId = room.getCanonicalAlias() || roomId;
|
||||||
const members = useRoomMembers(mx, roomId);
|
const members = useRoomMembers(mx, roomId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ export const EventReaders = as<'div', EventReadersProps>(
|
||||||
key={readerId}
|
key={readerId}
|
||||||
style={{ padding: `0 ${config.space.S200}` }}
|
style={{ padding: `0 ${config.space.S200}` }}
|
||||||
radii="400"
|
radii="400"
|
||||||
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
|
onClick={(event) => {
|
||||||
openProfile(
|
openProfile(
|
||||||
room.roomId,
|
room.roomId,
|
||||||
space?.roomId,
|
space?.roomId,
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ type RoomImagePackProps = {
|
||||||
|
|
||||||
export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
|
export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const userId = mx.getSafeUserId();
|
const userId = mx.getUserId()!;
|
||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
const creators = useRoomCreators(room);
|
const creators = useRoomCreators(room);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { useUserImagePack } from '../../hooks/useImagePacks';
|
||||||
export function UserImagePack() {
|
export function UserImagePack() {
|
||||||
const mx = useMatrixClient();
|
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 imagePack = useUserImagePack();
|
||||||
|
|
||||||
const handleUpdate = useCallback(
|
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.
|
// `object-fit: cover` (image) / `contain` (video) on the inner element.
|
||||||
const naturalAspect = naturalW && naturalH ? naturalW / naturalH : NaN;
|
const naturalAspect = naturalW && naturalH ? naturalW / naturalH : NaN;
|
||||||
if (
|
if (
|
||||||
!naturalW ||
|
|
||||||
!naturalH ||
|
|
||||||
!Number.isFinite(naturalAspect) ||
|
!Number.isFinite(naturalAspect) ||
|
||||||
naturalAspect < STREAM_MEDIA_MIN_ASPECT ||
|
naturalAspect < STREAM_MEDIA_MIN_ASPECT ||
|
||||||
naturalAspect > STREAM_MEDIA_MAX_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) };
|
return { width: toRem(STREAM_MEDIA_MAX_DIM), height: toRem(STREAM_MEDIA_MAX_DIM) };
|
||||||
}
|
}
|
||||||
if (naturalAspect >= 1) {
|
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) };
|
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) };
|
return { width: toRem(h * naturalAspect), height: toRem(h) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
|
||||||
<Modal
|
<Modal
|
||||||
className={ModalWide}
|
className={ModalWide}
|
||||||
size="500"
|
size="500"
|
||||||
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
|
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||||
>
|
>
|
||||||
{renderViewer({
|
{renderViewer({
|
||||||
name: body,
|
name: body,
|
||||||
|
|
@ -203,7 +203,7 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
|
||||||
<Modal
|
<Modal
|
||||||
className={ModalWide}
|
className={ModalWide}
|
||||||
size="500"
|
size="500"
|
||||||
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
|
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||||
>
|
>
|
||||||
{renderViewer({
|
{renderViewer({
|
||||||
name: body,
|
name: body,
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,6 @@ import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../util
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { ModalWide } from '../../../styles/Modal.css';
|
import { ModalWide } from '../../../styles/Modal.css';
|
||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
import { useMediaViewerHost } from '../../../features/room/mediaViewerHostContext';
|
|
||||||
import { useOpenMediaViewer } from '../../../state/hooks/mediaViewer';
|
|
||||||
|
|
||||||
type RenderViewerProps = {
|
type RenderViewerProps = {
|
||||||
src: string;
|
src: string;
|
||||||
|
|
@ -46,17 +44,7 @@ type RenderImageProps = {
|
||||||
onLoad: () => void;
|
onLoad: () => void;
|
||||||
onError: () => void;
|
onError: () => void;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
onKeyDown: (e: React.KeyboardEvent<HTMLImageElement>) => void;
|
|
||||||
tabIndex: number;
|
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 = {
|
export type ImageContentProps = {
|
||||||
body: string;
|
body: string;
|
||||||
|
|
@ -67,13 +55,6 @@ export type ImageContentProps = {
|
||||||
autoPlay?: boolean;
|
autoPlay?: boolean;
|
||||||
markedAsSpoiler?: boolean;
|
markedAsSpoiler?: boolean;
|
||||||
spoilerReason?: string;
|
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;
|
renderViewer: (props: RenderViewerProps) => ReactNode;
|
||||||
renderImage: (props: RenderImageProps) => ReactNode;
|
renderImage: (props: RenderImageProps) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
@ -89,7 +70,6 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||||
autoPlay,
|
autoPlay,
|
||||||
markedAsSpoiler,
|
markedAsSpoiler,
|
||||||
spoilerReason,
|
spoilerReason,
|
||||||
eventId,
|
|
||||||
renderViewer,
|
renderViewer,
|
||||||
renderImage,
|
renderImage,
|
||||||
...props
|
...props
|
||||||
|
|
@ -99,37 +79,12 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
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 [load, setLoad] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [viewer, setViewer] = useState(false);
|
const [viewer, setViewer] = useState(false);
|
||||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? 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(
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
||||||
|
|
@ -163,7 +118,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||||
{!useAtomViewer && srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
|
|
@ -177,7 +132,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||||
<Modal
|
<Modal
|
||||||
className={ModalWide}
|
className={ModalWide}
|
||||||
size="500"
|
size="500"
|
||||||
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
|
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||||
>
|
>
|
||||||
{renderViewer({
|
{renderViewer({
|
||||||
src: srcState.data,
|
src: srcState.data,
|
||||||
|
|
@ -213,25 +168,15 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Box
|
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||||
className={classNames(css.AbsoluteContainer, blurred ? css.Blur : css.ImageClickable)}
|
|
||||||
>
|
|
||||||
{renderImage({
|
{renderImage({
|
||||||
alt: body,
|
alt: body,
|
||||||
title: body,
|
title: body,
|
||||||
src: srcState.data,
|
src: srcState.data,
|
||||||
onLoad: handleLoad,
|
onLoad: handleLoad,
|
||||||
onError: handleError,
|
onError: handleError,
|
||||||
onClick: handleOpen,
|
onClick: () => setViewer(true),
|
||||||
onKeyDown: (e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleOpen();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tabIndex: 0,
|
tabIndex: 0,
|
||||||
role: 'button',
|
|
||||||
'aria-label': body || 'Open media',
|
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,6 @@ import {
|
||||||
} from '../../../utils/matrix';
|
} from '../../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
import { useMediaViewerHost } from '../../../features/room/mediaViewerHostContext';
|
|
||||||
import { useOpenMediaViewer } from '../../../state/hooks/mediaViewer';
|
|
||||||
|
|
||||||
type RenderVideoProps = {
|
type RenderVideoProps = {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -52,14 +50,6 @@ type VideoContentProps = {
|
||||||
autoPlay?: boolean;
|
autoPlay?: boolean;
|
||||||
markedAsSpoiler?: boolean;
|
markedAsSpoiler?: boolean;
|
||||||
spoilerReason?: string;
|
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;
|
renderThumbnail?: () => ReactNode;
|
||||||
renderVideo: (props: RenderVideoProps) => ReactNode;
|
renderVideo: (props: RenderVideoProps) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
@ -75,7 +65,6 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||||
autoPlay,
|
autoPlay,
|
||||||
markedAsSpoiler,
|
markedAsSpoiler,
|
||||||
spoilerReason,
|
spoilerReason,
|
||||||
eventId,
|
|
||||||
renderThumbnail,
|
renderThumbnail,
|
||||||
renderVideo,
|
renderVideo,
|
||||||
...props
|
...props
|
||||||
|
|
@ -85,9 +74,6 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
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 [load, setLoad] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
|
@ -120,29 +106,8 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
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();
|
if (autoPlay) loadSrc();
|
||||||
}, [autoPlay, loadSrc, useAtomViewer]);
|
}, [autoPlay, loadSrc]);
|
||||||
|
|
||||||
const openAtomViewer = useCallback(() => {
|
|
||||||
if (!host || !eventId) return;
|
|
||||||
// No `resolvedSrc` — viewer body owns blob-URL lifecycle; see
|
|
||||||
// the rationale in `ImageContent.handleOpen`.
|
|
||||||
openMediaViewer({
|
|
||||||
roomId: host.roomId,
|
|
||||||
eventId,
|
|
||||||
kind: 'video',
|
|
||||||
url,
|
|
||||||
body,
|
|
||||||
info,
|
|
||||||
encInfo,
|
|
||||||
mimeType,
|
|
||||||
});
|
|
||||||
}, [host, eventId, openMediaViewer, url, body, info, encInfo, mimeType]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||||
|
|
@ -164,21 +129,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||||
{renderThumbnail()}
|
{renderThumbnail()}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{useAtomViewer && !blurred && (
|
{!autoPlay && !blurred && srcState.status === AsyncStatus.Idle && (
|
||||||
<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 && (
|
|
||||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||||
<Button
|
<Button
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
|
|
@ -192,7 +143,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{!useAtomViewer && srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||||
{renderVideo({
|
{renderVideo({
|
||||||
title: body,
|
title: body,
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,6 @@
|
||||||
import { globalStyle, style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { DefaultReset, config } from 'folds';
|
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([
|
export const RelativeBase = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { globalStyle, style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { color, config, toRem } from 'folds';
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
// 36px circular avatar — a notch above folds `Avatar size="200"` (32px)
|
// 36px circular avatar — a notch above folds `Avatar size="200"` (32px)
|
||||||
|
|
@ -121,106 +121,3 @@ export const ChannelSyslineBody = style({
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
flex: 1,
|
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;
|
header?: ReactNode;
|
||||||
reactions?: ReactNode;
|
reactions?: ReactNode;
|
||||||
threadSummary?: 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>;
|
onContextMenu?: MouseEventHandler<HTMLDivElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -43,50 +33,20 @@ export type ChannelLayoutProps = {
|
||||||
// thread-summary, reactions in vertical flow.
|
// thread-summary, reactions in vertical flow.
|
||||||
export const ChannelLayout = as<'div', ChannelLayoutProps>(
|
export const ChannelLayout = as<'div', ChannelLayoutProps>(
|
||||||
(
|
(
|
||||||
{
|
{ className, avatar, header, reactions, threadSummary, onContextMenu, children, ...props },
|
||||||
className,
|
|
||||||
avatar,
|
|
||||||
header,
|
|
||||||
reactions,
|
|
||||||
threadSummary,
|
|
||||||
isOwn,
|
|
||||||
headerInBubble,
|
|
||||||
onContextMenu,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref
|
ref
|
||||||
) => (
|
) => (
|
||||||
<div
|
<div
|
||||||
className={classNames(css.ChannelRow, className)}
|
className={classNames(css.ChannelRow, className)}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
data-own={isOwn ? 'true' : 'false'}
|
|
||||||
data-bubble={headerInBubble ? 'true' : undefined}
|
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
<div className={css.ChannelAvatarSlot}>{avatar}</div>
|
<div className={css.ChannelAvatarSlot}>{avatar}</div>
|
||||||
<div className={css.ChannelBody}>
|
<div className={css.ChannelBody}>
|
||||||
{!headerInBubble && header && <div className={css.ChannelHeader}>{header}</div>}
|
{header && <div className={css.ChannelHeader}>{header}</div>}
|
||||||
<div className={css.ChannelMessageBody}>
|
<div className={css.ChannelMessageBody}>{children}</div>
|
||||||
{headerInBubble && header && (
|
{threadSummary && <div className={css.ChannelThreadSummary}>{threadSummary}</div>}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{reactions && <div className={css.ChannelReactions}>{reactions}</div>}
|
{reactions && <div className={css.ChannelReactions}>{reactions}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,6 @@ export type StreamLayoutProps = {
|
||||||
dotColor: string;
|
dotColor: string;
|
||||||
dotOpacity: number;
|
dotOpacity: number;
|
||||||
isOwn?: boolean;
|
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;
|
compact?: boolean;
|
||||||
header?: ReactNode;
|
header?: ReactNode;
|
||||||
railStart?: boolean;
|
railStart?: boolean;
|
||||||
|
|
@ -104,7 +100,6 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
|
||||||
dotColor,
|
dotColor,
|
||||||
dotOpacity,
|
dotOpacity,
|
||||||
isOwn,
|
isOwn,
|
||||||
peerBg,
|
|
||||||
compact,
|
compact,
|
||||||
header,
|
header,
|
||||||
railStart,
|
railStart,
|
||||||
|
|
@ -174,7 +169,6 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
|
||||||
className={css.StreamBubble({
|
className={css.StreamBubble({
|
||||||
own: !!isOwn,
|
own: !!isOwn,
|
||||||
compact: !!compact,
|
compact: !!compact,
|
||||||
peerBg: !!peerBg,
|
|
||||||
mediaMode: !!mediaMode,
|
mediaMode: !!mediaMode,
|
||||||
})}
|
})}
|
||||||
ref={bubbleRef}
|
ref={bubbleRef}
|
||||||
|
|
|
||||||
|
|
@ -283,7 +283,7 @@ export const StreamRail = style({
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
width: StreamRailLineWidth,
|
width: StreamRailLineWidth,
|
||||||
background: 'var(--vojo-timeline-rail)',
|
background: color.Surface.Container,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
zIndex: 0,
|
zIndex: 0,
|
||||||
});
|
});
|
||||||
|
|
@ -424,15 +424,14 @@ export const StreamBubble = recipe({
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
// Asymmetric notch — own: bottom-left flat, three corners R500+.
|
// Asymmetric notch — own: top-left flat, three corners R500.
|
||||||
// Incoming: top-left flat, three corners R500+. Mirrored on the
|
// Incoming: mirrored.
|
||||||
// vertical axis so own/peer read as opposing silhouettes.
|
|
||||||
own: {
|
own: {
|
||||||
true: {
|
true: {
|
||||||
borderRadius: `${toRem(16)} ${toRem(16)} ${toRem(16)} ${toRem(4)}`,
|
borderRadius: `${toRem(4)} ${config.radii.R500} ${config.radii.R500} ${config.radii.R500}`,
|
||||||
},
|
},
|
||||||
false: {
|
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
|
// Mobile fills the message column (block 100%); desktop fits content
|
||||||
|
|
@ -453,14 +452,6 @@ export const StreamBubble = recipe({
|
||||||
paddingRight: toRem(15),
|
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
|
// Image messages: bubble becomes a transparent shell so the
|
||||||
// StreamMediaImage child supplies the visible chrome instead.
|
// StreamMediaImage child supplies the visible chrome instead.
|
||||||
// `display: block, width: 100%` (NOT fit-content) so the bubble has a
|
// `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: {
|
defaultVariants: {
|
||||||
own: false,
|
own: false,
|
||||||
compact: false,
|
compact: false,
|
||||||
peerBg: false,
|
|
||||||
mediaMode: false,
|
mediaMode: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -545,6 +524,7 @@ export const StreamSysline = style({
|
||||||
paddingBottom: toRem(2),
|
paddingBottom: toRem(2),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
export const StreamSyslineBody = style({
|
export const StreamSyslineBody = style({
|
||||||
fontSize: toRem(11.5),
|
fontSize: toRem(11.5),
|
||||||
color: color.Surface.OnContainer,
|
color: color.Surface.OnContainer,
|
||||||
|
|
@ -651,7 +631,7 @@ export const StreamDayLineWrap = style({
|
||||||
export const StreamDayLineSegment = style({
|
export const StreamDayLineSegment = style({
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: 1,
|
height: 1,
|
||||||
background: 'var(--vojo-timeline-rail)',
|
background: color.Surface.Container,
|
||||||
minWidth: toRem(8),
|
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,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} 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 { useAtom } from 'jotai';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import * as css from './style.css';
|
import * as css from './style.css';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
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 {
|
import {
|
||||||
VOJO_HORSESHOE_VOID_COLOR,
|
VOJO_HORSESHOE_VOID_COLOR,
|
||||||
VOJO_HORSESHOE_GAP_PX,
|
VOJO_HORSESHOE_GAP_PX,
|
||||||
|
|
@ -78,9 +82,12 @@ export function PageRoot({ nav, children }: PageRootProps) {
|
||||||
TL/BL carves expose the outer's void. The explicit
|
TL/BL carves expose the outer's void. The explicit
|
||||||
Background bg on the inner is what keeps the panel's
|
Background bg on the inner is what keeps the panel's
|
||||||
apparent colour unchanged for routes whose content has no
|
apparent colour unchanged for routes whose content has no
|
||||||
opaque bg of its own — without it the outer void would
|
opaque bg of its own (e.g. ChannelsLanding) — without it
|
||||||
bleed through. */}
|
the outer void would bleed through. */}
|
||||||
<Box grow="Yes" style={{ minWidth: 0, backgroundColor: VOJO_HORSESHOE_VOID_COLOR }}>
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
style={{ minWidth: 0, backgroundColor: VOJO_HORSESHOE_VOID_COLOR }}
|
||||||
|
>
|
||||||
<Box
|
<Box
|
||||||
grow="Yes"
|
grow="Yes"
|
||||||
className={ContainerColor({ variant: 'Background' })}
|
className={ContainerColor({ variant: 'Background' })}
|
||||||
|
|
@ -110,29 +117,10 @@ export function PageRoot({ nav, children }: PageRootProps) {
|
||||||
type ClientDrawerLayoutProps = {
|
type ClientDrawerLayoutProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
resizable?: boolean;
|
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({
|
export function PageNav({
|
||||||
size,
|
size,
|
||||||
resizable,
|
resizable,
|
||||||
roundedRight,
|
|
||||||
surface,
|
|
||||||
children,
|
children,
|
||||||
}: ClientDrawerLayoutProps & css.PageNavVariants) {
|
}: ClientDrawerLayoutProps & css.PageNavVariants) {
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
|
|
@ -140,24 +128,9 @@ export function PageNav({
|
||||||
const horseshoe = useHorseshoeEnabled();
|
const horseshoe = useHorseshoeEnabled();
|
||||||
|
|
||||||
if (resizable && !isMobile) {
|
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>;
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
grow={isMobile ? 'Yes' : undefined}
|
grow={isMobile ? 'Yes' : undefined}
|
||||||
|
|
@ -168,19 +141,11 @@ export function PageNav({
|
||||||
grow="Yes"
|
grow="Yes"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined}
|
className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined}
|
||||||
// Top inset for native: `#root` no longer reserves the status-bar
|
// Bottom inset for native: keeps any nav-footer row (SelfRow,
|
||||||
// height (src/index.css), so the page-nav extends to the screen
|
// WorkspaceFooter, …) clear of the Android gesture pill / 3-button
|
||||||
// top. The padding here pushes the page-nav header (workspace
|
// bar / iOS home indicator after `#root` stopped reserving the
|
||||||
// tabs, etc.) below the status-bar icons. Applied at the inner
|
// inset itself. `var(--vojo-safe-bottom)` resolves to 0 on web.
|
||||||
// column rather than at the `PageNavHeader` recipe because the
|
style={{ paddingBottom: 'var(--vojo-safe-bottom)' }}
|
||||||
// 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,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -193,7 +158,9 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
|
||||||
const handleRef = useRef<HTMLDivElement>(null);
|
const handleRef = useRef<HTMLDivElement>(null);
|
||||||
const horseshoe = useHorseshoeEnabled();
|
const horseshoe = useHorseshoeEnabled();
|
||||||
const [savedWidth, setSavedWidth] = useAtom(sidebarWidthAtom);
|
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);
|
const [dragging, setDragging] = useState(false);
|
||||||
// Live width during a drag — kept in component state so we don't write to
|
// 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
|
// the localStorage-backed atom on every pointermove (hundreds of sync disk
|
||||||
|
|
@ -306,19 +273,14 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
|
||||||
grow="Yes"
|
grow="Yes"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined}
|
className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined}
|
||||||
// Same native safe-top inset as the regular PageNav above —
|
// See twin block in `PageNav` above — same native safe-area
|
||||||
// `var(--vojo-safe-top)` is 0 on web (where resizable is used)
|
// protection for any footer row mounted inside a resizable
|
||||||
// but kept here for symmetry / future-proofing.
|
// page-nav. On web `var(--vojo-safe-bottom)` is 0.
|
||||||
style={{ paddingTop: 'var(--vojo-safe-top, 0px)' }}
|
style={{ paddingBottom: 'var(--vojo-safe-bottom)' }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
{canResize && (
|
{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
|
<div
|
||||||
ref={handleRef}
|
ref={handleRef}
|
||||||
role="separator"
|
role="separator"
|
||||||
|
|
@ -327,7 +289,6 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
|
||||||
aria-valuemin={SIDEBAR_WIDTH_MIN}
|
aria-valuemin={SIDEBAR_WIDTH_MIN}
|
||||||
aria-valuemax={maxW}
|
aria-valuemax={maxW}
|
||||||
aria-label="Resize sidebar"
|
aria-label="Resize sidebar"
|
||||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className={css.PageNavResizeHandle}
|
className={css.PageNavResizeHandle}
|
||||||
// On web the page-nav is followed by the horseshoe void gap
|
// On web the page-nav is followed by the horseshoe void gap
|
||||||
|
|
@ -382,12 +343,7 @@ export function PageNavContent({
|
||||||
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
scrollRef?: MutableRefObject<HTMLDivElement | null>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
// `minHeight: 0` is the canonical flexbox fix for a scroll child inside
|
<Box grow="Yes" direction="Column">
|
||||||
// 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 }}>
|
|
||||||
<Scroll
|
<Scroll
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
variant="Background"
|
variant="Background"
|
||||||
|
|
@ -402,29 +358,15 @@ export function PageNavContent({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type PageVariantProps = {
|
export const Page = as<'div'>(({ className, ...props }, ref) => (
|
||||||
// Background surface tone. Default `'Surface'` (Dawn bg2, #0d0e11)
|
<Box
|
||||||
// — the deepest tone used by every sub-page elsewhere in the app.
|
grow="Yes"
|
||||||
// `'SurfaceVariant'` (Dawn bg, #181a20) is one notch lighter and
|
direction="Column"
|
||||||
// used by the Settings sub-pages so they read on the same surface
|
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
|
||||||
// tone as the Settings menu (which itself uses
|
{...props}
|
||||||
// `surface="surfaceVariant"` on its PageNav). Other variants
|
ref={ref}
|
||||||
// (`'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) => (
|
|
||||||
<Box
|
|
||||||
grow="Yes"
|
|
||||||
direction="Column"
|
|
||||||
className={classNames(ContainerColor({ variant }), className)}
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
export const PageHeader = as<'div', css.PageHeaderVariants>(
|
export const PageHeader = as<'div', css.PageHeaderVariants>(
|
||||||
({ className, outlined, balance, ...props }, ref) => (
|
({ className, outlined, balance, ...props }, ref) => (
|
||||||
|
|
|
||||||
|
|
@ -79,14 +79,6 @@ export const PageNav = recipe({
|
||||||
'300': {
|
'300': {
|
||||||
width: toRem(222),
|
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: {
|
defaultVariants: {
|
||||||
|
|
|
||||||
|
|
@ -256,10 +256,7 @@ export const RoomCard = as<'div', RoomCardProps>(
|
||||||
<Box gap="100">
|
<Box gap="100">
|
||||||
<Icon size="50" src={Icons.User} />
|
<Icon size="50" src={Icons.User} />
|
||||||
<Text size="T200">
|
<Text size="T200">
|
||||||
{t('Explore.members_count', {
|
{t('Explore.members_count', { count: millify(joinedMemberCount) })}
|
||||||
count: joinedMemberCount,
|
|
||||||
formattedCount: millify(joinedMemberCount),
|
|
||||||
})}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { getMemberDisplayName, getStateEvent } from '../../utils/room';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { timeDayMonYear, timeHourMinute } from '../../utils/time';
|
import { timeDayMonthYear, timeHourMinute } from '../../utils/time';
|
||||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
import { RoomAvatar } from '../room-avatar';
|
import { RoomAvatar } from '../room-avatar';
|
||||||
import { nameInitials } from '../../utils/common';
|
import { nameInitials } from '../../utils/common';
|
||||||
|
|
@ -75,7 +75,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
||||||
i18nKey="Room.created_by"
|
i18nKey="Room.created_by"
|
||||||
values={{
|
values={{
|
||||||
creator: creatorName,
|
creator: creatorName,
|
||||||
date: timeDayMonYear(ts),
|
date: timeDayMonthYear(ts),
|
||||||
time: timeHourMinute(ts),
|
time: timeHourMinute(ts),
|
||||||
}}
|
}}
|
||||||
components={{ bold: <b /> }}
|
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