chore: merge 3af516f into vojo/dev, keeping its edge-to-edge --vojo-safe-top safe-area and superseding the abandoned ca34e026 approach
|
|
@ -60,10 +60,12 @@ module.exports = {
|
|||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-shadow': 'error',
|
||||
|
||||
// Policy: kept as warnings, not errors. The codebase has ~70 long-standing
|
||||
// `any` casts and ~15 non-null assertions in matrix-js-sdk interop code.
|
||||
// Promoting to error would block builds on existing usage; turning off
|
||||
// would lose signal on new code. Warnings are visible without blocking.
|
||||
// Policy: kept as `warn` at the rule level so editors / `eslint --fix` /
|
||||
// ad-hoc runs surface them as warnings, but `npm run check:eslint` and
|
||||
// `lint-staged` BOTH pass `--max-warnings 0`, so new occurrences block
|
||||
// commit. When unavoidable (matrix-js-sdk boundary, generic helpers,
|
||||
// third-party callback shapes), suppress on the line with
|
||||
// `// eslint-disable-next-line` and a one-line justification.
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||
},
|
||||
|
|
@ -86,6 +88,11 @@ module.exports = {
|
|||
'no-plusplus': 'off',
|
||||
'prefer-template': 'off',
|
||||
'no-param-reassign': 'off',
|
||||
// `for (;;)` form upstream uses for the iter-loops trips eslint
|
||||
// even though it's intentional — keep upstream control flow.
|
||||
'no-constant-condition': 'off',
|
||||
// Diagnostic `console.log` left as-is in vendor copy.
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
20
.gitignore
vendored
|
|
@ -4,12 +4,24 @@ node_modules
|
|||
devAssets
|
||||
config.local.json
|
||||
|
||||
electron/dist-electron
|
||||
release
|
||||
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode
|
||||
.vscode/*
|
||||
!.vscode/tasks.json
|
||||
.codex
|
||||
.claude
|
||||
docs/ai/desired_features.md
|
||||
docs/ai/bugs.md
|
||||
docs/plans
|
||||
docs
|
||||
docs/design
|
||||
docs/ai/*
|
||||
!docs/ai/README.md
|
||||
!docs/ai/android.md
|
||||
!docs/ai/architecture.md
|
||||
!docs/ai/electron.md
|
||||
!docs/ai/i18n.md
|
||||
!docs/ai/overview.md
|
||||
!docs/ai/server-side.md
|
||||
|
||||
vite.config.*.timestamp-*.mjs
|
||||
|
|
|
|||
5
.husky/pre-commit
Normal file → Executable file
|
|
@ -1,3 +1,2 @@
|
|||
# These are commented until we enable lint and typecheck
|
||||
# npx tsc -p tsconfig.json --noEmit
|
||||
# npx lint-staged
|
||||
npx tsc -p tsconfig.json --noEmit
|
||||
npx lint-staged
|
||||
|
|
|
|||
104
.vscode/tasks.json
vendored
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Deploy to vojo.chat",
|
||||
"type": "shell",
|
||||
"command": "npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/cinny/",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared",
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Deploy widgets",
|
||||
"type": "shell",
|
||||
"command": "(cd apps/widget-telegram && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/telegram/) & PID1=$!; (cd apps/widget-discord && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/discord/) & PID2=$!; (cd apps/widget-whatsapp && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/whatsapp/) & PID3=$!; FAIL=0; wait $PID1 || FAIL=1; wait $PID2 || FAIL=1; wait $PID3 || FAIL=1; exit $FAIL",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared",
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Build Android APK",
|
||||
"type": "shell",
|
||||
"command": "npm run build:android:debug",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared",
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Deploy to Android (ADB)",
|
||||
"type": "shell",
|
||||
"command": "npm run build:android:debug && adb install -r android/app/build/outputs/apk/debug/app-debug.apk",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared",
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Connect to Android device (ADB)",
|
||||
"type": "shell",
|
||||
"command": "adb connect 192.168.1.204:5555",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared",
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Start Electron (dev)",
|
||||
"type": "shell",
|
||||
"command": "npm run electron:dev",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared",
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Build Electron Windows",
|
||||
"type": "shell",
|
||||
"command": "npm run build:electron:win",
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared",
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Deploy Discord bridge",
|
||||
"type": "shell",
|
||||
"command": "docker build -t vojo-mautrix-discord:custom . && docker save vojo-mautrix-discord:custom | gzip | ssh vojo-superuser@187.127.77.124 'gunzip | docker load'",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/../vojo-mautrix-discord"
|
||||
},
|
||||
"group": "none",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared",
|
||||
"showReuseMessage": false
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,8 +1,29 @@
|
|||
apply plugin: 'com.android.application'
|
||||
|
||||
def packageJson = new groovy.json.JsonSlurper().parseText(file('../../package.json').text)
|
||||
def semver = packageJson.version.split('\\.')
|
||||
def computedVersionCode = semver[0].toInteger() * 1000000 + semver[1].toInteger() * 1000 + semver[2].toInteger()
|
||||
// Mirror of resolveAppVersion() in ../../vite.config.js so the APK's
|
||||
// versionName matches __APP_VERSION__ rendered in the About screen.
|
||||
// `git describe --tags --match 'v*'` against tag v0.2.0 yields
|
||||
// `v0.2.0-<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 {
|
||||
namespace = "chat.vojo.app"
|
||||
|
|
@ -12,7 +33,7 @@ android {
|
|||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode computedVersionCode
|
||||
versionName packageJson.version
|
||||
versionName appVersion.name
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
|
@ -20,12 +41,6 @@ android {
|
|||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
// AGP 8+ requires explicit opt-in for BuildConfig generation. We rely on
|
||||
// BuildConfig.DEBUG to gate Log.d calls that dump privacy-sensitive
|
||||
// identifiers (roomId, eventId) so release builds don't leak them through
|
||||
|
|
@ -33,6 +48,26 @@ android {
|
|||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
if (project.hasProperty('VOJO_RELEASE_STORE_FILE')) {
|
||||
storeFile file(VOJO_RELEASE_STORE_FILE)
|
||||
storePassword VOJO_RELEASE_STORE_PASSWORD
|
||||
keyAlias VOJO_RELEASE_KEY_ALIAS
|
||||
keyPassword VOJO_RELEASE_KEY_PASSWORD
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
|
|
@ -52,6 +87,11 @@ dependencies {
|
|||
// already depends on firebase-messaging but declares it `implementation`
|
||||
// so classes aren't exposed at app-module compile time.
|
||||
implementation "com.google.firebase:firebase-messaging:25.0.1"
|
||||
// WorkManager hosts VojoPollWorker — periodic /notifications poll that
|
||||
// delivers messages and missed-call surfaces on networks where FCM
|
||||
// (mtalk.google.com:5228) is blocked. Library self-registers its scheduler
|
||||
// in the merged manifest; we declare no permission for it.
|
||||
implementation "androidx.work:work-runtime:2.10.0"
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
|
|
|
|||
24
android/app/proguard-rules.pro
vendored
|
|
@ -19,3 +19,27 @@
|
|||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
# Keep custom app classes — entry points invoked by Android system (Intents,
|
||||
# FCM, AndroidManifest references) or by JS bridge via reflection.
|
||||
-keep class chat.vojo.app.MainActivity { *; }
|
||||
-keep class chat.vojo.app.VojoFirebaseMessagingService { *; }
|
||||
-keep class chat.vojo.app.CallForegroundPlugin { *; }
|
||||
-keep class chat.vojo.app.CallForegroundService { *; }
|
||||
-keep class chat.vojo.app.CallDeclineReceiver { *; }
|
||||
-keep class chat.vojo.app.CallCancelReceiver { *; }
|
||||
-keep class chat.vojo.app.FullScreenIntentPlugin { *; }
|
||||
-keep class chat.vojo.app.LaunchSplashPlugin { *; }
|
||||
|
||||
# Firebase Messaging — receivers/services resolved by Android via manifest.
|
||||
-keep public class * extends com.google.firebase.messaging.FirebaseMessagingService
|
||||
-keep class com.google.firebase.iid.** { *; }
|
||||
-keep class com.google.firebase.messaging.** { *; }
|
||||
|
||||
# Capacitor — plugins discovered by annotation/reflection.
|
||||
-keep @com.getcapacitor.annotation.CapacitorPlugin class * { *; }
|
||||
-keep class com.getcapacitor.** { *; }
|
||||
-keep class com.getcapacitor.plugin.** { *; }
|
||||
|
||||
# AndroidX splashscreen — reflection paths.
|
||||
-keep class androidx.core.splashscreen.** { *; }
|
||||
|
|
|
|||
|
|
@ -46,6 +46,30 @@
|
|||
android:pathPrefix="/u/" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- System share-sheet target. Three filters because Android's
|
||||
sheet UI dedupes by activity but resolves by MIME match:
|
||||
text/* gets its own filter so the Vojo icon shows up
|
||||
alongside WhatsApp/Telegram for «share link/selection»; */*
|
||||
covers single-file (image/video/audio/pdf/…) and
|
||||
SEND_MULTIPLE picks up gallery multi-select.
|
||||
Payload extraction lives in ShareTargetPlugin — MainActivity
|
||||
only routes the Intent to the plugin via onNewIntent. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
|
|
@ -85,6 +109,18 @@
|
|||
<receiver
|
||||
android:name=".CallDeclineReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".MarkAsReadReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".NotificationDismissReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".ReplyReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.util.LruCache;
|
||||
|
||||
/**
|
||||
* In-memory LRU cache of decoded avatar bitmaps keyed by MXC URL string.
|
||||
*
|
||||
* Sized as a process-singleton (~4 MB) so the FCM service, polling Worker
|
||||
* and ReplyReceiver all share one pool. 96×96 ARGB_8888 bitmap is about
|
||||
* 36 KB, so a 4 MB cache holds ~110 avatars — enough for the active
|
||||
* conversation set on a typical user. LruCache evicts the least-recently-
|
||||
* read entry when full; this is the right shape for "rooms the user is
|
||||
* actively talking in stay warm, dormant rooms reload on demand".
|
||||
*
|
||||
* Thread-safety: LruCache itself is synchronized internally on every
|
||||
* get/put/remove. We don't need an outer lock for normal operation. The
|
||||
* AvatarLoader funnels all puts through this class.
|
||||
*
|
||||
* Process death: cache is in-memory only. After a kill, the first push
|
||||
* to any room cold-renders without avatars and re-renders once the
|
||||
* loader populates the cache (see AvatarLoader.loadAllWithTimeout).
|
||||
*/
|
||||
final class AvatarBitmapCache {
|
||||
|
||||
// Heap budget: bytes. 4 MB is generous against ARGB_8888 96×96 bitmaps
|
||||
// (~36 KB each) and stays comfortably under the 1/8-of-heap Android
|
||||
// recommendation on every device we ship to (minSdk 24 → at least
|
||||
// 96 MB heap on a low-end phone).
|
||||
private static final int MAX_SIZE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
private static final LruCache<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);
|
||||
}
|
||||
}
|
||||
368
android/app/src/main/java/chat/vojo/app/AvatarLoader.java
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.graphics.BitmapShader;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Shader;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Fetches and decodes avatar bitmaps from MXC URLs, populating
|
||||
* {@link AvatarBitmapCache}.
|
||||
*
|
||||
* URL resolution mirrors matrix-js-sdk's auth-media v1.11+ pattern:
|
||||
* mxc://server/mediaId
|
||||
* → <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,7 +121,14 @@ public class CallForegroundPlugin extends Plugin {
|
|||
// extras — Capacitor PushNotificationsPlugin gates pushNotificationActionPerformed
|
||||
// on containsKey. Empty string also satisfies the gate; we pass the
|
||||
// caller's value through verbatim.
|
||||
VojoFirebaseMessagingService.upsertIncomingRing(data, messageId);
|
||||
boolean seeded = VojoFirebaseMessagingService.upsertIncomingRing(data, messageId);
|
||||
// Mark in NotificationDedup so a polling fire 15 minutes later
|
||||
// doesn't post a "Missed call" notification for a ring the user
|
||||
// already saw live via the in-app strip. Mirrors the FCM-arrival
|
||||
// path in VojoFirebaseMessagingService.onMessageReceived.
|
||||
if (seeded) {
|
||||
NotificationDedup.markNotified(getContext(), eventId);
|
||||
}
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.core.content.LocusIdCompat;
|
||||
import androidx.core.content.pm.ShortcutInfoCompat;
|
||||
import androidx.core.content.pm.ShortcutManagerCompat;
|
||||
import androidx.core.graphics.drawable.IconCompat;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Publish a long-lived sharing shortcut for a Matrix room so the system
|
||||
* treats per-room MessagingStyle notifications as conversations on
|
||||
* Android 11+ (API 30+).
|
||||
*
|
||||
* Without a published shortcut whose id matches the notification's
|
||||
* setShortcutId(), Android falls back to the app icon for the collapsed-
|
||||
* preview avatar regardless of Person.setIcon / Builder.setLargeIcon —
|
||||
* Person icons are only consulted by the Conversation styling layer,
|
||||
* which activates exclusively for notifications backed by a real
|
||||
* ShortcutInfoCompat marked Long Lived + the SHORTCUT_CATEGORY_CONVERSATION
|
||||
* sharing category.
|
||||
*
|
||||
* Idempotent: republishing the same shortcut id is the documented "update"
|
||||
* path; ShortcutManagerCompat handles dedup internally. Cheap to call
|
||||
* from the render hot path (~ms on warm system, indistinguishable from a
|
||||
* SharedPreferences write at our scale).
|
||||
*/
|
||||
final class ConversationShortcuts {
|
||||
|
||||
private static final String TAG = "ConvShortcuts";
|
||||
|
||||
private ConversationShortcuts() {}
|
||||
|
||||
/**
|
||||
* Publish or refresh the shortcut backing a room's conversation
|
||||
* notification. No-op on API < 30 — Conversation styling is an
|
||||
* Android 11+ feature; older OS versions render the notification
|
||||
* fine without the shortcut, and the largeIcon/Person.setIcon
|
||||
* pipeline is the primary avatar source on them.
|
||||
*
|
||||
* @param ctx Context for the shortcut manager binding.
|
||||
* @param roomId Matrix room id, used as the shortcut id so it
|
||||
* matches NotificationCompat.Builder.setShortcutId.
|
||||
* @param isDirect Whether the room is a DM; flips the shortcut
|
||||
* category so launchers can group DMs separately.
|
||||
* @param label Short visible label, typically the room name (or
|
||||
* the peer's display name for a DM).
|
||||
* @param avatar Optional cached avatar bitmap. Null falls through
|
||||
* to the app launcher icon — still publishes the
|
||||
* shortcut so the conversation styling activates.
|
||||
*/
|
||||
/**
|
||||
* Returns the published ShortcutInfoCompat so the caller can attach
|
||||
* it directly to the notification via setShortcutInfo() — this is
|
||||
* the documented "atomic publish + bind" path that avoids the race
|
||||
* where the notification posts before the shortcut publish has
|
||||
* settled and Android sees an orphan shortcut id. Null on API < 30,
|
||||
* null on failure (notification still posts cleanly).
|
||||
*/
|
||||
static ShortcutInfoCompat publishForRoom(
|
||||
Context ctx,
|
||||
String roomId,
|
||||
boolean isDirect,
|
||||
String label,
|
||||
Bitmap avatar
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
return null;
|
||||
}
|
||||
if (roomId == null || roomId.isEmpty()) return null;
|
||||
try {
|
||||
// Conversation shortcut icon MUST be adaptive — official docs:
|
||||
// "To avoid unintentional clipping of your shortcut avatar,
|
||||
// provide an AdaptiveIconDrawable for the shortcut's icon."
|
||||
// Without this, Android silently falls back to the app's
|
||||
// launcher icon for the collapsed-shade conversation avatar
|
||||
// slot, even though shortcut publish + bind succeed.
|
||||
// Resource icons (mipmap.ic_launcher) already ship with
|
||||
// adaptive layers in the manifest; bitmap avatars need padding
|
||||
// so the safe zone doesn't crop them.
|
||||
IconCompat icon;
|
||||
if (avatar != null) {
|
||||
Bitmap padded = AvatarLoader.toAdaptivePaddedBitmap(avatar);
|
||||
icon = IconCompat.createWithAdaptiveBitmap(padded);
|
||||
} else {
|
||||
icon = IconCompat.createWithResource(ctx, R.mipmap.ic_launcher);
|
||||
}
|
||||
|
||||
// Intent the shortcut launches when tapped from the launcher
|
||||
// long-press menu or share sheet — opens MainActivity and
|
||||
// delivers the same `room_id` extra the notification tap
|
||||
// path uses, so the existing pushNotificationActionPerformed
|
||||
// listener navigates correctly.
|
||||
Intent launchIntent = new Intent(ctx, MainActivity.class)
|
||||
.setAction(Intent.ACTION_VIEW)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
.putExtra("room_id", roomId)
|
||||
// Capacitor PushNotificationsPlugin gates its action
|
||||
// delivery on bundle.containsKey("google.message_id"); we
|
||||
// attach an empty value so a launcher-initiated open
|
||||
// takes the same path as a push-tap.
|
||||
.putExtra("google.message_id", "");
|
||||
|
||||
// Constant value of androidx.core's
|
||||
// ShortcutInfoCompat.SHORTCUT_CATEGORY_CONVERSATION. Hardcoded
|
||||
// verbatim because older androidx.core in our dependency
|
||||
// graph doesn't export the constant; the string itself is
|
||||
// platform-stable per the Android shortcut category contract.
|
||||
Set<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,16 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.view.View;
|
||||
import androidx.activity.EdgeToEdge;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.splashscreen.SplashScreen;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.core.view.WindowInsetsControllerCompat;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
|
|
@ -63,6 +68,8 @@ public class MainActivity extends BridgeActivity {
|
|||
registerPlugin(FullScreenIntentPlugin.class);
|
||||
registerPlugin(CallForegroundPlugin.class);
|
||||
registerPlugin(LaunchSplashPlugin.class);
|
||||
registerPlugin(ShareTargetPlugin.class);
|
||||
registerPlugin(PollingPlugin.class);
|
||||
|
||||
// AndroidX SplashScreen must be installed before super.onCreate().
|
||||
// Keep it until the web splash confirms its first visible frame is
|
||||
|
|
@ -84,6 +91,60 @@ public class MainActivity extends BridgeActivity {
|
|||
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
|
||||
controller.setAppearanceLightStatusBars(false);
|
||||
controller.setAppearanceLightNavigationBars(false);
|
||||
|
||||
// 3-button nav clearance. Reads `tappableElement` (= 0 in gesture
|
||||
// mode, = nav-bar height in 3-button mode) and applies it as
|
||||
// padding on the activity's content root — NOT on the WebView
|
||||
// itself. The WebView is a child of the content root, so root
|
||||
// padding shrinks the WebView's layout area; in 3-button mode
|
||||
// the WebView ends above the nav bar and the activity
|
||||
// windowBackground strip behind it paints `splash_bg` (#0d0e11)
|
||||
// which matches the dark body bg, so system icons read as
|
||||
// continuous with the chat surface. Gesture mode stays
|
||||
// edge-to-edge (padding = 0). Left/right are included for
|
||||
// landscape 3-button mode where the nav bar rotates to a side.
|
||||
//
|
||||
// CRITICAL: the listener MUST live on the content root, not on
|
||||
// the WebView. Attaching it to the WebView replaces WebView's
|
||||
// internal `OnApplyWindowInsetsListener` (the one Chromium uses
|
||||
// to feed CSS `env(safe-area-inset-*)`), which silently breaks
|
||||
// `env(safe-area-inset-top)` → `--vojo-safe-top` → every top-
|
||||
// anchored UI clearance for the status bar. Padding the parent
|
||||
// leaves WebView's window-insets pipeline untouched while still
|
||||
// shrinking its visual area.
|
||||
//
|
||||
// Why `tappableElement` and not `systemBars()` / `navigationBars()`:
|
||||
// both report ~24-32 dp in gesture mode too (the pill area),
|
||||
// which would lift UI in fullscreen — exactly the regression
|
||||
// commit 443213b4 had. `tappableElement` is the type defined as
|
||||
// «system UI regions where the user can tap», which means 0 in
|
||||
// gesture mode and the nav-bar in 3-button mode.
|
||||
//
|
||||
// Capacitor 8.3's bundled `SystemBars` plugin attaches its own
|
||||
// inset listener on `webView.getParent()` (CoordinatorLayout one
|
||||
// level deeper). Since `index.html` declares `viewport-fit=cover`,
|
||||
// Capacitor takes the passthrough branch and doesn't pad — no
|
||||
// compound-padding with our listener. If `viewport-fit=cover` is
|
||||
// ever removed, set `plugins.SystemBars.insetsHandling = "disable"`
|
||||
// in `capacitor.config.ts` to avoid double-lift.
|
||||
//
|
||||
// Fallback for API < 29 (Android 7-9): `tappableElement` did
|
||||
// not exist before Q. AndroidX backports the call to API 24+ as
|
||||
// `getSystemWindowInsets()` (≈ `navigationBars()`); the explicit
|
||||
// gate makes the intent visible at the call site.
|
||||
final View contentRoot = findViewById(android.R.id.content);
|
||||
ViewCompat.setOnApplyWindowInsetsListener(contentRoot, (v, windowInsets) -> {
|
||||
final int typeMask = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
? WindowInsetsCompat.Type.tappableElement()
|
||||
: WindowInsetsCompat.Type.navigationBars();
|
||||
Insets ins = windowInsets.getInsets(typeMask);
|
||||
v.setPadding(ins.left, 0, ins.right, ins.bottom);
|
||||
// Do NOT consume — propagate to WebView (CSS env() pipeline)
|
||||
// and Capacitor's SystemBars listener so they still see
|
||||
// unmodified insets.
|
||||
return windowInsets;
|
||||
});
|
||||
ViewCompat.requestApplyInsets(contentRoot);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
|||
147
android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* Handles the per-notification "Mark as read" action.
|
||||
*
|
||||
* Posts {@code POST /_matrix/client/v3/rooms/{roomId}/receipt/m.read/{eventId}}
|
||||
* using the access token saved by the polling lifecycle in
|
||||
* {@code vojo_poll_state} SharedPreferences (same storage VojoPollWorker uses;
|
||||
* keeps the credential lifecycle single-sourced). After a successful 2xx the
|
||||
* per-room MessagingStyle notification is dismissed and the
|
||||
* {@link RoomMessageCache} is cleared so the next push to that room starts a
|
||||
* fresh conversation rather than re-appending to the prior history.
|
||||
*
|
||||
* Dismiss policy: OPTIMISTIC. The per-room notification is dismissed
|
||||
* synchronously in onReceive — before the HTTP receipt PUT is even
|
||||
* attempted — so the user sees instant feedback. The async receipt POST
|
||||
* happens on a worker thread afterwards. This mirrors element-android's
|
||||
* NotificationBroadcastReceiver pattern and matches the user's mental
|
||||
* model ("I tapped, it should disappear immediately").
|
||||
*
|
||||
* Failure mode: on any non-2xx or thrown exception we accept that the
|
||||
* server-side read receipt did not land. We do NOT re-post the
|
||||
* notification or implement a flusher because:
|
||||
* - the next room open from the JS app issues a fresh read-receipt
|
||||
* for the latest visible event, catching up the server state
|
||||
* - the in-app read-marker logic is the authoritative path; this
|
||||
* receiver is a convenience for the shade-tap shortcut
|
||||
* - accumulating tombstones in prefs (the CallDeclineReceiver pattern)
|
||||
* would risk leaking historical eventIds the JS side would re-issue
|
||||
* on app resume anyway
|
||||
*
|
||||
* Null-credential edge case (fresh install + first push before any
|
||||
* saveSession bridge): no token to use, we still dismiss the notification
|
||||
* locally so the user isn't stuck looking at a "stuck" Mark-as-read
|
||||
* button. The next room open from JS covers the server view.
|
||||
*/
|
||||
public class MarkAsReadReceiver extends BroadcastReceiver {
|
||||
|
||||
public static final String ACTION_MARK_AS_READ = "chat.vojo.app.MARK_AS_READ";
|
||||
public static final String EXTRA_ROOM_ID = "room_id";
|
||||
public static final String EXTRA_EVENT_ID = "event_id";
|
||||
|
||||
private static final int CONNECT_TIMEOUT_MS = 8_000;
|
||||
private static final int READ_TIMEOUT_MS = 8_000;
|
||||
private static final String TAG = "MarkAsReadRcvr";
|
||||
|
||||
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (intent == null) return;
|
||||
final String roomId = intent.getStringExtra(EXTRA_ROOM_ID);
|
||||
final String eventId = intent.getStringExtra(EXTRA_EVENT_ID);
|
||||
if (roomId == null || roomId.isEmpty()) {
|
||||
Log.w(TAG, "onReceive: missing room_id, abort");
|
||||
return;
|
||||
}
|
||||
|
||||
final Context appContext = context.getApplicationContext();
|
||||
// Dismiss first for instant UX feedback — HTTP latency is irrelevant
|
||||
// to the perceived "marked as read" action.
|
||||
VojoFirebaseMessagingService.dismissRoomNotification(appContext, roomId);
|
||||
|
||||
final SharedPreferences prefs = appContext.getSharedPreferences(
|
||||
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||
final String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null);
|
||||
final String homeserver = prefs.getString(VojoPollWorker.KEY_HOMESERVER_URL, null);
|
||||
if (token == null || token.isEmpty() || homeserver == null || homeserver.isEmpty()) {
|
||||
Log.w(TAG, "onReceive: no credentials in prefs, local dismiss only");
|
||||
return;
|
||||
}
|
||||
if (eventId == null || eventId.isEmpty()) {
|
||||
// Without an eventId we cannot issue a receipt PUT — the JS-side
|
||||
// read-marker handler will catch this up on the next room open.
|
||||
Log.w(TAG, "onReceive: no event_id, local dismiss only");
|
||||
return;
|
||||
}
|
||||
|
||||
final PendingResult pendingResult = goAsync();
|
||||
EXECUTOR.execute(() -> {
|
||||
try {
|
||||
int status = sendReceipt(homeserver, token, roomId, eventId);
|
||||
if (status >= 200 && status < 300) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(TAG, "receipt ok status=" + status + " room=" + roomId);
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "receipt non-2xx status=" + status + " room=" + roomId);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "receipt threw room=" + roomId, t);
|
||||
} finally {
|
||||
pendingResult.finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private int sendReceipt(
|
||||
String baseUrl,
|
||||
String accessToken,
|
||||
String roomId,
|
||||
String eventId
|
||||
) throws IOException {
|
||||
String url = trimTrailingSlash(baseUrl)
|
||||
+ "/_matrix/client/v3/rooms/"
|
||||
+ URLEncoder.encode(roomId, "UTF-8")
|
||||
+ "/receipt/m.read/"
|
||||
+ URLEncoder.encode(eventId, "UTF-8");
|
||||
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
||||
try {
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
conn.setReadTimeout(READ_TIMEOUT_MS);
|
||||
conn.setDoOutput(true);
|
||||
// Empty JSON body per spec; setFixedLengthStreamingMode keeps the
|
||||
// connection on the cached path instead of chunked-transfer fallback.
|
||||
byte[] payload = "{}".getBytes("UTF-8");
|
||||
conn.setFixedLengthStreamingMode(payload.length);
|
||||
try (java.io.OutputStream os = conn.getOutputStream()) {
|
||||
os.write(payload);
|
||||
}
|
||||
return conn.getResponseCode();
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimTrailingSlash(String s) {
|
||||
return (s != null && s.endsWith("/")) ? s.substring(0, s.length() - 1) : s;
|
||||
}
|
||||
}
|
||||
104
android/app/src/main/java/chat/vojo/app/NotificationDedup.java
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Cross-source LRU dedup for rendered push event_ids.
|
||||
*
|
||||
* Both the FCM service (after a successful nm.notify) and the polling Worker
|
||||
* write into the same bounded SharedPreferences-backed set. The Worker reads
|
||||
* it to skip events FCM already delivered — which fixes the regression where
|
||||
* a user who dismissed an FCM notification before polling fired would see
|
||||
* the same event resurface up to 15 minutes later via the polling fallback.
|
||||
*
|
||||
* The native `eventId.hashCode()` notification-id slot is still the primary
|
||||
* dedup for *concurrent* render (Android NotificationManager replace), but
|
||||
* that only collapses surfaces while both notifications are still visible;
|
||||
* once the user dismisses, the slot is empty and the second render would
|
||||
* post fresh. This shared set covers that gap.
|
||||
*
|
||||
* Synchronisation: SharedPreferences read-modify-write is not atomic across
|
||||
* threads/processes, and FCM service runs on a Firebase-managed background
|
||||
* thread while the Worker runs on WorkManager's executor. We serialise all
|
||||
* mutations through a static lock. Critical sections are short (string split
|
||||
* + LinkedHashSet trim + putString) — no Binder calls.
|
||||
*/
|
||||
final class NotificationDedup {
|
||||
|
||||
// Capacity is intentionally larger than VojoPollWorker's worst-case per-run
|
||||
// event count (MAX_PAGES_PER_RUN × PAGE_LIMIT = 250). If a single fire
|
||||
// marks 250 events and the cap were 200, the 50 oldest of those would
|
||||
// already be evicted by the time we finish writing — so a sibling poll
|
||||
// resuming the same window would re-render them. 500 gives 2× headroom
|
||||
// while staying ~12 KB in SharedPreferences (negligible).
|
||||
private static final int MAX_TRACKED = 500;
|
||||
private static final Object lock = new Object();
|
||||
|
||||
private NotificationDedup() {}
|
||||
|
||||
/** Returns true iff the given event_id has been notified in a recent cycle. */
|
||||
static boolean wasNotified(Context ctx, String eventId) {
|
||||
if (eventId == null || eventId.isEmpty()) return false;
|
||||
synchronized (lock) {
|
||||
return readSet(ctx).contains(eventId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Append the event_id to the LRU set, trimming the oldest when full. */
|
||||
static void markNotified(Context ctx, String eventId) {
|
||||
if (eventId == null || eventId.isEmpty()) return;
|
||||
synchronized (lock) {
|
||||
Set<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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.util.Log;
|
||||
|
||||
/**
|
||||
* Fires when the user swipes a per-room MessagingStyle notification away.
|
||||
*
|
||||
* Without this hook, RoomMessageCache would still hold the prior messages
|
||||
* for that room — and the next push would append onto that history and
|
||||
* re-surface the messages the user just dismissed. With it, swipe clears
|
||||
* the cache so the next push starts a fresh conversation for the room.
|
||||
*
|
||||
* NOTE: this only fires for user-driven dismissals — programmatic
|
||||
* nm.cancel calls (mark-as-read, receipt-driven dismiss, channel migration)
|
||||
* already call RoomMessageCache.clear themselves and do NOT fire the
|
||||
* delete intent. There's no double-clear risk.
|
||||
*/
|
||||
public class NotificationDismissReceiver extends BroadcastReceiver {
|
||||
|
||||
public static final String ACTION_NOTIFICATION_DISMISSED =
|
||||
"chat.vojo.app.NOTIFICATION_DISMISSED";
|
||||
public static final String EXTRA_ROOM_ID = "room_id";
|
||||
|
||||
private static final String TAG = "DismissRcvr";
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (intent == null) return;
|
||||
String roomId = intent.getStringExtra(EXTRA_ROOM_ID);
|
||||
if (roomId == null || roomId.isEmpty()) return;
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "swipe clear cache room=" + roomId);
|
||||
RoomMessageCache.clear(roomId);
|
||||
}
|
||||
}
|
||||
236
android/app/src/main/java/chat/vojo/app/PollingPlugin.java
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.work.Constraints;
|
||||
import androidx.work.ExistingPeriodicWorkPolicy;
|
||||
import androidx.work.NetworkType;
|
||||
import androidx.work.PeriodicWorkRequest;
|
||||
import androidx.work.WorkManager;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* JS ↔ Android bridge for the WorkManager-based polling fallback.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - JS calls saveSession({accessToken, homeserverUrl, userId}) on login,
|
||||
* on push (re)enable, and on visibilitychange → visible (to recover a
|
||||
* 401-cleared credentials slot without a full remount).
|
||||
* - JS calls schedule({intervalMinutes}) once push is enabled. Idempotent:
|
||||
* KEEP policy means a second schedule() call against an already-enqueued
|
||||
* worker is a no-op (the running period continues unchanged).
|
||||
* - JS calls saveRoomNames({names}) on mount + visibilitychange → visible
|
||||
* so VojoPollWorker has a local cache to resolve room_id → display name
|
||||
* without making N extra GET /rooms/{id}/state/m.room.name requests.
|
||||
* Brand-new rooms created between visibility events fall back to
|
||||
* sender_display_name in the renderer.
|
||||
* - JS calls cancel() + clearSession() on logout / push disable.
|
||||
*
|
||||
* Worker tag: a single unique periodic worker named UNIQUE_WORK_NAME — KEEP
|
||||
* policy prevents schedule churn from re-creating it. Cancel() removes it
|
||||
* by the same name.
|
||||
*/
|
||||
@CapacitorPlugin(name = "Polling")
|
||||
public class PollingPlugin extends Plugin {
|
||||
|
||||
private static final String TAG = "PollingPlugin";
|
||||
private static final String UNIQUE_WORK_NAME = "vojo_push_poll";
|
||||
|
||||
// Android's hard floor for PeriodicWorkRequest. Requests with shorter
|
||||
// intervals are silently clamped to 15 minutes. We accept the requested
|
||||
// value from JS but enforce the floor here so misuse from JS doesn't
|
||||
// produce a silently-different behavior.
|
||||
private static final long MIN_INTERVAL_MINUTES = 15;
|
||||
|
||||
@PluginMethod
|
||||
public void saveSession(PluginCall call) {
|
||||
String accessToken = call.getString("accessToken");
|
||||
String homeserverUrl = call.getString("homeserverUrl");
|
||||
if (accessToken == null || accessToken.isEmpty()
|
||||
|| homeserverUrl == null || homeserverUrl.isEmpty()) {
|
||||
call.reject("missing_accessToken_or_homeserverUrl");
|
||||
return;
|
||||
}
|
||||
String userId = call.getString("userId");
|
||||
SharedPreferences prefs = getContext()
|
||||
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||
SharedPreferences.Editor editor = prefs.edit()
|
||||
.putString(VojoPollWorker.KEY_ACCESS_TOKEN, accessToken)
|
||||
.putString(VojoPollWorker.KEY_HOMESERVER_URL, homeserverUrl);
|
||||
if (userId != null && !userId.isEmpty()) {
|
||||
editor.putString(VojoPollWorker.KEY_USER_ID, userId);
|
||||
}
|
||||
// Seed the watermark to "now minus a small clock-skew buffer" on the
|
||||
// first saveSession after install / logout. Without seeding the
|
||||
// Worker's first fire sees watermark=0 and renders every historical
|
||||
// unread /notifications entry as a fresh push. The buffer covers the
|
||||
// case where the device clock runs ahead of the homeserver's clock —
|
||||
// event ts is server-side, so a too-fresh local seed would silently
|
||||
// skip recently-arrived events as "older than watermark" forever.
|
||||
// 60s tolerates typical NTP drift while still suppressing days-old
|
||||
// backlog on first enable. We seed only when the key is absent so
|
||||
// subsequent saveSession calls (token rotation, visibilitychange
|
||||
// re-bridge) don't reset live state.
|
||||
if (!prefs.contains(VojoPollWorker.KEY_LAST_SEEN_TS)) {
|
||||
editor.putLong(
|
||||
VojoPollWorker.KEY_LAST_SEEN_TS,
|
||||
System.currentTimeMillis() - SEED_CLOCK_SKEW_BUFFER_MS
|
||||
);
|
||||
}
|
||||
editor.apply();
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
private static final long SEED_CLOCK_SKEW_BUFFER_MS = 60_000L;
|
||||
|
||||
@PluginMethod
|
||||
public void clearSession(PluginCall call) {
|
||||
getContext()
|
||||
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.remove(VojoPollWorker.KEY_ACCESS_TOKEN)
|
||||
.remove(VojoPollWorker.KEY_HOMESERVER_URL)
|
||||
.remove(VojoPollWorker.KEY_USER_ID)
|
||||
.remove(VojoPollWorker.KEY_LAST_SEEN_TS)
|
||||
.remove(VojoPollWorker.KEY_DRAIN_CURSOR)
|
||||
.remove(VojoPollWorker.KEY_DRAIN_TARGET_TS)
|
||||
.remove(VojoPollWorker.KEY_NOTIFIED_IDS)
|
||||
.remove(VojoPollWorker.KEY_ROOM_NAMES)
|
||||
.remove(VojoPollWorker.KEY_USER_AVATARS)
|
||||
.apply();
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* user_id → MXC avatar URL snapshot. Mirrors {@link #saveRoomNames} —
|
||||
* stored as a JSON blob in vojo_poll_state for the FCM service /
|
||||
* polling Worker / ReplyReceiver to consult via
|
||||
* VojoFirebaseMessagingService.lookupUserAvatarMxc. JS dumps on the
|
||||
* same lifecycle triggers as room names (mount, visibility resume,
|
||||
* m.direct change, m.room.encryption flip).
|
||||
*/
|
||||
@PluginMethod
|
||||
public void saveUserAvatars(PluginCall call) {
|
||||
JSObject avatars = call.getObject("avatars");
|
||||
if (avatars == null) {
|
||||
call.reject("missing_avatars");
|
||||
return;
|
||||
}
|
||||
String serialized = avatars.toString();
|
||||
getContext()
|
||||
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(VojoPollWorker.KEY_USER_AVATARS, serialized)
|
||||
.apply();
|
||||
Log.i(TAG, "saveUserAvatars: " + avatars.length() + " entries, "
|
||||
+ serialized.length() + " bytes");
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void saveRoomNames(PluginCall call) {
|
||||
JSObject names = call.getObject("names");
|
||||
if (names == null) {
|
||||
// Empty map is also valid (user cleared all rooms) — JS passes
|
||||
// {} explicitly in that case; missing key is a contract bug.
|
||||
call.reject("missing_names");
|
||||
return;
|
||||
}
|
||||
// `JSObject extends JSONObject`, so names.toString() is already a
|
||||
// valid JSON serialisation of validated values — no need to re-parse
|
||||
// it through `new JSONObject(...)` just to re-serialise. Persist
|
||||
// verbatim.
|
||||
String serialized = names.toString();
|
||||
getContext()
|
||||
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(VojoPollWorker.KEY_ROOM_NAMES, serialized)
|
||||
.apply();
|
||||
Log.i(TAG, "saveRoomNames: " + names.length() + " entries, "
|
||||
+ serialized.length() + " bytes");
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void schedule(PluginCall call) {
|
||||
Integer intervalMinutes = call.getInt("intervalMinutes", 15);
|
||||
long interval = Math.max(MIN_INTERVAL_MINUTES, intervalMinutes != null ? intervalMinutes : 15);
|
||||
|
||||
Constraints constraints = new Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build();
|
||||
|
||||
PeriodicWorkRequest req = new PeriodicWorkRequest.Builder(
|
||||
VojoPollWorker.class, interval, TimeUnit.MINUTES
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
.addTag("vojo_push_poll")
|
||||
.build();
|
||||
|
||||
try {
|
||||
WorkManager.getInstance(getContext())
|
||||
.enqueueUniquePeriodicWork(
|
||||
UNIQUE_WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
req
|
||||
);
|
||||
Log.d(TAG, "scheduled periodic poll every " + interval + " minutes");
|
||||
call.resolve();
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "schedule failed", t);
|
||||
call.reject("schedule_failed: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the per-room MessagingStyle notification + clear the in-memory
|
||||
* RoomMessageCache for the room. Called from the JS receipt listener when
|
||||
* a server-side read receipt zeroes the unread count (the user read on
|
||||
* another device / tab). No-op if the notification was never posted or
|
||||
* has already been swiped away.
|
||||
*/
|
||||
@PluginMethod
|
||||
public void dismissRoom(PluginCall call) {
|
||||
String roomId = call.getString("roomId");
|
||||
if (roomId == null || roomId.isEmpty()) {
|
||||
call.reject("missing_roomId");
|
||||
return;
|
||||
}
|
||||
VojoFirebaseMessagingService.dismissRoomNotification(getContext(), roomId);
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void cancel(PluginCall call) {
|
||||
try {
|
||||
// Block on the Operation so callers awaiting cancel() see the
|
||||
// cancel committed to WorkManager's database before we resolve.
|
||||
// (NOTE: this does NOT interrupt a Worker that's already mid
|
||||
// doWork(); cooperative cancellation via isStopped() is owned
|
||||
// by VojoPollWorker itself.) Without this wait a fast
|
||||
// disable→reenable sequence races with ExistingPeriodicWorkPolicy.KEEP
|
||||
// — the second enqueueUniquePeriodicWork can land before the
|
||||
// cancel is committed and become a no-op. We're already off
|
||||
// the main thread (Capacitor dispatches plugin calls on its
|
||||
// own executor), so the blocking get() is safe here.
|
||||
WorkManager.getInstance(getContext())
|
||||
.cancelUniqueWork(UNIQUE_WORK_NAME)
|
||||
.getResult()
|
||||
.get();
|
||||
Log.d(TAG, "cancelled periodic poll");
|
||||
call.resolve();
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "cancel failed", t);
|
||||
call.reject("cancel_failed: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +45,55 @@ final class PushStrings {
|
|||
return forAppLocale(ctx).getString(R.string.push_invitation);
|
||||
}
|
||||
|
||||
static String missedCallTitle(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_missed_call);
|
||||
}
|
||||
|
||||
static String missedCallBody(Context ctx, String caller) {
|
||||
String safeCaller = caller == null ? "" : caller;
|
||||
return forAppLocale(ctx).getString(R.string.push_missed_call_body, safeCaller);
|
||||
}
|
||||
|
||||
static String channelGroup(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_channel_group);
|
||||
}
|
||||
|
||||
static String channelDm(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_channel_dm);
|
||||
}
|
||||
|
||||
static String channelDmDescription(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_channel_dm_description);
|
||||
}
|
||||
|
||||
static String channelGroupRoom(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_channel_group_room);
|
||||
}
|
||||
|
||||
static String channelGroupRoomDescription(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_channel_group_room_description);
|
||||
}
|
||||
|
||||
static String selfName(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_self_name);
|
||||
}
|
||||
|
||||
static String markAsReadAction(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_action_mark_as_read);
|
||||
}
|
||||
|
||||
static String replyAction(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_action_reply);
|
||||
}
|
||||
|
||||
static String replyHint(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_reply_hint);
|
||||
}
|
||||
|
||||
static String replyFailed(Context ctx) {
|
||||
return forAppLocale(ctx).getString(R.string.push_reply_failed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the invite-notification body from inviter + room name, falling
|
||||
* back through four variants when one or both are absent. The res IDs
|
||||
|
|
|
|||
248
android/app/src/main/java/chat/vojo/app/ReplyReceiver.java
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.app.NotificationManager;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.RemoteInput;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.json.JSONException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* Handles the inline-reply RemoteInput action on a per-room MessagingStyle
|
||||
* notification.
|
||||
*
|
||||
* Flow:
|
||||
* 1. User taps reply, types text, presses send → broadcast fires here.
|
||||
* 2. We immediately append the outgoing message to RoomMessageCache and
|
||||
* re-post the notification (instant UX feedback — the message appears
|
||||
* as a self-Person bubble in the conversation while the HTTP is in
|
||||
* flight).
|
||||
* 3. PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}
|
||||
* with {msgtype: "m.text", body}. Uses the vojo_poll_state token (same
|
||||
* storage as Worker / MarkAsReadReceiver — single credential lifecycle).
|
||||
* 4. On 2xx: nothing further; the JS sync echo will eventually replace
|
||||
* the local-echo bubble in-app.
|
||||
* 5. On non-2xx or thrown: post a small error notification "Could not
|
||||
* send your reply" so the user knows to retry from in-app — better
|
||||
* than silently swallowing the message.
|
||||
*
|
||||
* E2EE rooms are guarded UP-STREAM in VojoFirebaseMessagingService.
|
||||
* renderMessageNotification: we don't even attach the reply action when
|
||||
* RoomMetadata.isEncrypted is true. So this receiver never has to encrypt.
|
||||
* Defense in depth: if a stale notification with the action ever survives
|
||||
* an encryption flip we still detect the failure as a non-2xx HTTP and
|
||||
* surface the error notification rather than sending cleartext (which
|
||||
* Synapse would in any case reject for an encrypted room).
|
||||
*
|
||||
* Null-credential edge case: post the error notification so the user
|
||||
* notices and retries in-app. Same logic as a network failure.
|
||||
*/
|
||||
public class ReplyReceiver extends BroadcastReceiver {
|
||||
|
||||
public static final String ACTION_REPLY = "chat.vojo.app.REPLY";
|
||||
public static final String EXTRA_ROOM_ID = "room_id";
|
||||
public static final String KEY_TEXT_REPLY = "vojo.text_reply";
|
||||
|
||||
private static final int CONNECT_TIMEOUT_MS = 8_000;
|
||||
private static final int READ_TIMEOUT_MS = 8_000;
|
||||
private static final String TAG = "ReplyRcvr";
|
||||
|
||||
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (intent == null) return;
|
||||
final String roomId = intent.getStringExtra(EXTRA_ROOM_ID);
|
||||
if (roomId == null || roomId.isEmpty()) {
|
||||
Log.w(TAG, "onReceive: missing room_id, abort");
|
||||
return;
|
||||
}
|
||||
|
||||
Bundle remote = RemoteInput.getResultsFromIntent(intent);
|
||||
if (remote == null) {
|
||||
Log.w(TAG, "onReceive: no RemoteInput results");
|
||||
return;
|
||||
}
|
||||
CharSequence reply = remote.getCharSequence(KEY_TEXT_REPLY);
|
||||
if (reply == null) {
|
||||
Log.w(TAG, "onReceive: RemoteInput missing text");
|
||||
return;
|
||||
}
|
||||
final String text = reply.toString().trim();
|
||||
if (text.isEmpty()) return;
|
||||
|
||||
final Context appContext = context.getApplicationContext();
|
||||
|
||||
// Pre-flight validation BEFORE the optimistic echo. Posting a self
|
||||
// bubble first and then immediately stacking an error notif on top
|
||||
// is jarring UX; for predictable failures (logged out, freshly
|
||||
// encrypted room) we'd rather skip the echo and only surface the
|
||||
// error.
|
||||
final SharedPreferences prefs = appContext.getSharedPreferences(
|
||||
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
|
||||
final String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null);
|
||||
final String homeserver = prefs.getString(VojoPollWorker.KEY_HOMESERVER_URL, null);
|
||||
if (token == null || token.isEmpty() || homeserver == null || homeserver.isEmpty()) {
|
||||
Log.w(TAG, "onReceive: no credentials in prefs, surfacing error notif");
|
||||
postReplyError(appContext, roomId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Race guard for E2EE flip: the per-room metadata snapshot is
|
||||
// refreshed by JS on m.room.encryption Timeline events, but a push
|
||||
// delivered in the narrow window between the encryption state
|
||||
// landing and the dump completing could still expose the reply
|
||||
// action on a freshly-encrypted room. Re-read the snapshot
|
||||
// synchronously here — Synapse does NOT enforce "no cleartext in
|
||||
// encrypted rooms" at the spec level, so without this guard we'd
|
||||
// leak the user's reply into an E2EE timeline as plaintext.
|
||||
if (isRoomEncryptedAtSendTime(prefs, roomId)) {
|
||||
Log.w(TAG, "onReceive: room flipped to encrypted between render and send, abort");
|
||||
postReplyError(appContext, roomId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistic local echo — appends a self-Person message to the
|
||||
// conversation and re-posts, so the user sees their reply in the
|
||||
// shade before the HTTP completes. Only happens after pre-flight
|
||||
// checks pass so the user doesn't see an echo for a reply we know
|
||||
// will fail.
|
||||
long now = System.currentTimeMillis();
|
||||
VojoFirebaseMessagingService.appendOutgoingMessage(appContext, roomId, text, now);
|
||||
|
||||
final PendingResult pendingResult = goAsync();
|
||||
final String txnId = "vojo-reply-" + UUID.randomUUID();
|
||||
EXECUTOR.execute(() -> {
|
||||
try {
|
||||
int status = sendReply(homeserver, token, roomId, txnId, text);
|
||||
if (status >= 200 && status < 300) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(TAG, "reply ok status=" + status + " room=" + roomId);
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "reply non-2xx status=" + status + " room=" + roomId);
|
||||
postReplyError(appContext, roomId);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "reply threw room=" + roomId, t);
|
||||
postReplyError(appContext, roomId);
|
||||
} finally {
|
||||
pendingResult.finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private int sendReply(
|
||||
String baseUrl,
|
||||
String accessToken,
|
||||
String roomId,
|
||||
String txnId,
|
||||
String text
|
||||
) throws IOException {
|
||||
String url = trimTrailingSlash(baseUrl)
|
||||
+ "/_matrix/client/v3/rooms/"
|
||||
+ URLEncoder.encode(roomId, "UTF-8")
|
||||
+ "/send/m.room.message/"
|
||||
+ URLEncoder.encode(txnId, "UTF-8");
|
||||
|
||||
JSONObject body;
|
||||
try {
|
||||
body = new JSONObject();
|
||||
body.put("msgtype", "m.text");
|
||||
body.put("body", text);
|
||||
} catch (org.json.JSONException je) {
|
||||
// JSONObject.put only throws on NaN/Inf doubles, neither of
|
||||
// which we use — but keep the type contract honest.
|
||||
throw new IOException("payload encode failed", je);
|
||||
}
|
||||
byte[] payload = body.toString().getBytes("UTF-8");
|
||||
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
||||
try {
|
||||
conn.setRequestMethod("PUT");
|
||||
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
conn.setReadTimeout(READ_TIMEOUT_MS);
|
||||
conn.setDoOutput(true);
|
||||
conn.setFixedLengthStreamingMode(payload.length);
|
||||
try (OutputStream os = conn.getOutputStream()) {
|
||||
os.write(payload);
|
||||
}
|
||||
return conn.getResponseCode();
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface a short error notification when the reply HTTP fails so the
|
||||
* user knows the message did NOT land server-side and can retry from
|
||||
* within the app. Posted on the DM channel as a one-shot. Unique notif
|
||||
* id per room so it can't clobber the room's conversation slot.
|
||||
*/
|
||||
private static void postReplyError(Context ctx, String roomId) {
|
||||
NotificationManager nm = (NotificationManager)
|
||||
ctx.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
if (nm == null) return;
|
||||
try {
|
||||
String channel = VojoFirebaseMessagingService.CHANNEL_ID_DM;
|
||||
NotificationCompat.Builder b = new NotificationCompat.Builder(ctx, channel)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(PushStrings.replyFailed(ctx))
|
||||
.setContentText(PushStrings.replyFailed(ctx))
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
|
||||
int errId = ("replyErr_" + roomId).hashCode();
|
||||
nm.notify(errId, b.build());
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "reply error notif failed", t);
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimTrailingSlash(String s) {
|
||||
return (s != null && s.endsWith("/")) ? s.substring(0, s.length() - 1) : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous re-check of the room's encryption flag at send time.
|
||||
* Mirrors VojoFirebaseMessagingService.loadRoomMetadata's tolerant
|
||||
* parse: legacy string-shape entries and missing flags both default
|
||||
* to encrypted=true (privacy-first — refusing a reply on a falsely-
|
||||
* flagged room is harmless; sending cleartext into a truly encrypted
|
||||
* room is a privacy leak).
|
||||
*/
|
||||
private static boolean isRoomEncryptedAtSendTime(SharedPreferences prefs, String roomId) {
|
||||
String raw = prefs.getString(VojoPollWorker.KEY_ROOM_NAMES, null);
|
||||
if (raw == null || raw.isEmpty()) return true;
|
||||
try {
|
||||
JSONObject map = new JSONObject(raw);
|
||||
if (!map.has(roomId) || map.isNull(roomId)) return true;
|
||||
JSONObject obj = map.optJSONObject(roomId);
|
||||
if (obj == null) {
|
||||
// Legacy string-shape predates the encryption flag —
|
||||
// assume encrypted to err on the side of privacy.
|
||||
return true;
|
||||
}
|
||||
return obj.optBoolean("isEncrypted", true);
|
||||
} catch (JSONException je) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
176
android/app/src/main/java/chat/vojo/app/RoomMessageCache.java
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.Person;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Per-room MessagingStyle history cache.
|
||||
*
|
||||
* Stores the last N messages observed for each room so renderMessageNotification
|
||||
* can rebuild a NotificationCompat.MessagingStyle with conversation context on
|
||||
* every new event instead of posting a fresh single-message notification per
|
||||
* event. Without this every 5-message DM produced 5 distinct entries in the
|
||||
* shade; with it the user sees one expandable conversation per room — the
|
||||
* WhatsApp/Telegram convention.
|
||||
*
|
||||
* Thread-safety: ConcurrentHashMap + per-key synchronized mutation via the
|
||||
* compute() / get() pattern. Both VojoFirebaseMessagingService.onMessageReceived
|
||||
* (Firebase-managed thread) and VojoPollWorker.doWork (WorkManager executor)
|
||||
* mutate the cache; without serialization a same-room FCM + polling race could
|
||||
* lose a message. Mutations are short — only deque append + bounded trim.
|
||||
*
|
||||
* Persistence: in-memory only. After process kill the cache is empty, and
|
||||
* renderMessageNotification falls back to extractMessagingStyleFromNotification
|
||||
* to recover history from the live system shade. If the user dismissed the
|
||||
* notification too, the conversation legitimately starts fresh — no signal we
|
||||
* could recover from there anyway.
|
||||
*
|
||||
* Eviction: bounded at MAX_MESSAGES_PER_ROOM per room, with FIFO eviction
|
||||
* (oldest message at the head of the deque is dropped via pollFirst when the
|
||||
* append would exceed the cap). Map itself is unbounded; in practice the
|
||||
* dump from dismissRoom (when a server-side read receipt clears unread) keeps
|
||||
* the room count proportional to active conversations. For safety against
|
||||
* runaway growth from rooms the user never reads, we cap the map at MAX_ROOMS.
|
||||
*/
|
||||
final class RoomMessageCache {
|
||||
|
||||
// Element-android keeps a similar in-memory queue (NotificationEventQueue);
|
||||
// 20 messages per room is generous enough for an active group chat while
|
||||
// staying well under Android's MessagingStyle render budget — Android only
|
||||
// shows the last ~7 messages in the shade anyway.
|
||||
private static final int MAX_MESSAGES_PER_ROOM = 20;
|
||||
|
||||
// Hard cap on the map size so a long-running session that touches many
|
||||
// rooms without ever clearing receipts can't slowly leak memory.
|
||||
// Eviction is approximate (oldest-touched first via insertion order from
|
||||
// ConcurrentHashMap is NOT guaranteed, so we just clear the oldest by
|
||||
// arbitrary entry on overflow — acceptable for an LRU at this scale).
|
||||
private static final int MAX_ROOMS = 200;
|
||||
|
||||
private static final ConcurrentHashMap<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);
|
||||
}
|
||||
}
|
||||
273
android/app/src/main/java/chat/vojo/app/ShareTargetPlugin.java
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.util.Log;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import com.getcapacitor.JSArray;
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Receives ACTION_SEND / ACTION_SEND_MULTIPLE intents from the system share-
|
||||
* sheet and surfaces them to the WebView as a pending share that JS consumes
|
||||
* via {@code pickPendingShare()} (or reacts to via the {@code shareReceived}
|
||||
* event when the app was already in the foreground).
|
||||
*
|
||||
* Cold-start flow:
|
||||
* 1. Share-sheet → Vojo → MainActivity.onCreate → super.onCreate runs
|
||||
* BridgeActivity.load(), which itself calls bridge.onNewIntent(getIntent())
|
||||
* and fans the intent out to every plugin's handleOnNewIntent. So
|
||||
* cold-start and warm-start share the SAME entry point — we don't
|
||||
* double-process via handleOnStart.
|
||||
* 2. captureFromIntent copies payload bytes into the app cache and stashes
|
||||
* the result in {@link #pendingShare}.
|
||||
* 3. JS booting up (Matrix client ready, user logged in) calls
|
||||
* pickPendingShare(); receives the JSON; opens the room-picker UI. The
|
||||
* shareReceived event fired here is dropped silently because no JS
|
||||
* listener is attached yet — that's fine, pickPendingShare drains the
|
||||
* slot regardless.
|
||||
*
|
||||
* Warm flow (app already running):
|
||||
* 1. Share-sheet → MainActivity.onNewIntent → BridgeActivity forwards to
|
||||
* plugin.handleOnNewIntent(intent).
|
||||
* 2. We re-capture the payload AND emit {@code shareReceived} so JS can
|
||||
* open the picker without polling.
|
||||
*
|
||||
* Why we copy to cache instead of handing JS a content:// URI:
|
||||
* - WebView fetch() rejects content:// schemes outright, and
|
||||
* `Capacitor.convertFileSrc()` only works on file paths.
|
||||
* - The originating app holds the read-grant only for the lifetime of the
|
||||
* launching task; routing the URI through JS+picker+RoomInput would race
|
||||
* that grant on Android 14+.
|
||||
* - Copying into our own cache means the share is self-contained: even if
|
||||
* the user backgrounds Vojo for hours before picking a chat, the bytes
|
||||
* are still there. We schedule no cleanup of our own — Android's cache
|
||||
* eviction handles long-tail garbage.
|
||||
*/
|
||||
@CapacitorPlugin(name = "ShareTarget")
|
||||
public class ShareTargetPlugin extends Plugin {
|
||||
|
||||
private static final String TAG = "ShareTargetPlugin";
|
||||
private static final String SHARE_CACHE_SUBDIR = "shared";
|
||||
|
||||
// Single-slot pending share. Multiple share-sheet invocations before JS
|
||||
// drains the slot collapse — the latest wins. JS contract is "consume
|
||||
// once, then it's gone" via pickPendingShare(consume=true). This matches
|
||||
// user intent: tapping share twice on different photos clearly means
|
||||
// "share THIS one now".
|
||||
private volatile JSObject pendingShare = null;
|
||||
|
||||
@Override
|
||||
public void handleOnNewIntent(Intent intent) {
|
||||
super.handleOnNewIntent(intent);
|
||||
captureFromIntent(intent, /* notifyJs */ true);
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void pickPendingShare(PluginCall call) {
|
||||
JSObject ret = new JSObject();
|
||||
JSObject snapshot = pendingShare;
|
||||
if (snapshot == null) {
|
||||
ret.put("empty", true);
|
||||
} else {
|
||||
// Default: consume on read. Lets us treat the slot like a one-shot
|
||||
// mailbox without an extra round-trip. Caller can pass consume=false
|
||||
// to peek (not used today, but cheap to keep).
|
||||
Boolean consume = call.getBoolean("consume", Boolean.TRUE);
|
||||
ret = snapshot;
|
||||
if (Boolean.TRUE.equals(consume)) {
|
||||
pendingShare = null;
|
||||
}
|
||||
}
|
||||
call.resolve(ret);
|
||||
}
|
||||
|
||||
private void captureFromIntent(Intent intent, boolean notifyJs) {
|
||||
if (intent == null) return;
|
||||
String action = intent.getAction();
|
||||
if (action == null) return;
|
||||
|
||||
// Capacitor's JSObject.put() silently swallows JSONException internally
|
||||
// (it wraps org.json.JSONObject and returns `this` on failure) so no
|
||||
// checked exception is thrown here — unlike the raw org.json API.
|
||||
JSObject share = new JSObject();
|
||||
share.put("empty", false);
|
||||
|
||||
String text = intent.getStringExtra(Intent.EXTRA_TEXT);
|
||||
String subject = intent.getStringExtra(Intent.EXTRA_SUBJECT);
|
||||
if (text != null && !text.isEmpty()) share.put("text", text);
|
||||
if (subject != null && !subject.isEmpty()) share.put("subject", subject);
|
||||
|
||||
JSArray items = new JSArray();
|
||||
List<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;
|
||||
}
|
||||
}
|
||||
675
android/app/src/main/java/chat/vojo/app/VojoPollWorker.java
Normal file
|
|
@ -0,0 +1,675 @@
|
|||
package chat.vojo.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.work.Worker;
|
||||
import androidx.work.WorkerParameters;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Periodic poll of `/_matrix/client/v3/notifications` as a fallback delivery
|
||||
* channel for users whose network blocks FCM (mtalk.google.com:5228) — the
|
||||
* ~5% slice on whitelist intranets (corporate / school / government) that
|
||||
* otherwise receive zero pushes.
|
||||
*
|
||||
* Scheduling: enqueued from PollingPlugin.schedule() with a 15-minute period
|
||||
* (Android's minimum for PeriodicWorkRequest) and CONNECTED network constraint.
|
||||
* Cancelled via PollingPlugin.cancel() on logout / push disable.
|
||||
*
|
||||
* Credentials: read from SharedPreferences (saved by the JS side through
|
||||
* PollingPlugin.saveSession). Vanilla Synapse (no MAS/OIDC) issues
|
||||
* non-expiring access tokens; we do not implement refresh-token flow here.
|
||||
* If a 401 ever occurs, doWork returns Result.success() — the next foreground
|
||||
* launch re-saves the credentials and polling resumes. Retrying with a stale
|
||||
* token would just waste battery and amplify rate limits.
|
||||
*
|
||||
* Output: messages and invites route through VojoFirebaseMessagingService
|
||||
* .renderMessageNotification (shared with FCM, same notif-id slots →
|
||||
* Android dedupes by replace). RTC ring events route through
|
||||
* .renderMissedCallNotification (always stale by the time we poll — 15-min
|
||||
* cadence vs 30-second ring lifetime), so the user sees "Missed call" instead
|
||||
* of a phantom incoming-call CallStyle for a long-dead ring.
|
||||
*
|
||||
* E2EE caveat: Synapse cannot decrypt event content, so for end-to-end
|
||||
* encrypted rooms the response carries `content.algorithm`+`ciphertext`
|
||||
* with no `body`. The renderer falls through to PushStrings.messageFallback
|
||||
* (i18n "New message") with the room name as title — same UX as the web
|
||||
* Service Worker on encrypted pushes. By design — no key access from the
|
||||
* Worker.
|
||||
*
|
||||
* Dedup is two complementary mechanisms:
|
||||
* 1) A per-poll high-watermark on the latest event ts we've notified.
|
||||
* Stored as KEY_LAST_SEEN_TS; advances only after a successful render
|
||||
* (or a foreground-skipped event the user already saw in-app). Worker
|
||||
* stops walking within a run as soon as it hits ts strictly less than
|
||||
* watermark — newest-first ordering guarantees the rest are also
|
||||
* older. Same-ts events fall through to the secondary filters because
|
||||
* multiple events can share a millisecond.
|
||||
* 2) NotificationDedup — a shared cross-source bounded LRU written by
|
||||
* every renderer (FCM service after successful nm.notify, this Worker
|
||||
* after successful render, and the ring-upsert paths at seed time).
|
||||
* Lets the Worker skip events FCM already delivered even after the
|
||||
* user dismissed the FCM notification.
|
||||
*
|
||||
* Each fire starts from the HEAD of /notifications (no persistent
|
||||
* pagination cursor — the spec's `next_token` walks BACKWARDS into
|
||||
* history, so a persisted cursor silently drifts off the new events the
|
||||
* next poll should see; see matrix-js-sdk client.ts:5040 for the
|
||||
* reference traversal pattern). When a single fire's backlog exceeds
|
||||
* MAX_PAGES_PER_RUN pages the leftover next_token is saved as
|
||||
* KEY_DRAIN_CURSOR (with the head ts snapshotted in KEY_DRAIN_TARGET_TS)
|
||||
* and resumed on the next run, so big backlogs (>250 events) drain over
|
||||
* consecutive polls without being clipped.
|
||||
*/
|
||||
public class VojoPollWorker extends Worker {
|
||||
|
||||
private static final String TAG = "VojoPoll";
|
||||
|
||||
static final String PREFS = "vojo_poll_state";
|
||||
static final String KEY_ACCESS_TOKEN = "access_token";
|
||||
static final String KEY_HOMESERVER_URL = "homeserver_url";
|
||||
static final String KEY_USER_ID = "user_id";
|
||||
// High-watermark on the latest event ts we've already notified about.
|
||||
// Stored as a long-millis string. Replaces an earlier `last_from` cursor
|
||||
// experiment that misunderstood /notifications pagination direction.
|
||||
static final String KEY_LAST_SEEN_TS = "last_seen_ts";
|
||||
// Continuation cursor used when a single run hits MAX_PAGES_PER_RUN before
|
||||
// reaching the watermark. Persists the next_token across runs so a >250
|
||||
// event backlog drains over consecutive polls instead of being clipped
|
||||
// forever by the page cap. Cleared once we either reach the watermark or
|
||||
// exhaust pagination on a single run.
|
||||
static final String KEY_DRAIN_CURSOR = "drain_cursor";
|
||||
// The "head ts" we recorded when entering drain mode. After drain
|
||||
// completes the watermark is jumped to THIS value rather than the
|
||||
// (older) max ts seen during drain — otherwise the bounded LRU could
|
||||
// evict events from the original head and let the next normal run
|
||||
// re-render them. Set once on entering drain mode, untouched while
|
||||
// draining, cleared when drain completes.
|
||||
static final String KEY_DRAIN_TARGET_TS = "drain_target_ts";
|
||||
static final String KEY_NOTIFIED_IDS = "notified_ids";
|
||||
static final String KEY_ROOM_NAMES = "room_names";
|
||||
// user_id → MXC avatar URL, JSON-encoded, bridged from JS via
|
||||
// PollingPlugin.saveUserAvatars. Consumed by
|
||||
// VojoFirebaseMessagingService.lookupUserAvatarMxc for per-sender
|
||||
// Person.setIcon in MessagingStyle conversations. Bounded at 500
|
||||
// entries on the JS side; read tolerantly here.
|
||||
static final String KEY_USER_AVATARS = "user_avatars";
|
||||
|
||||
private static final int HTTP_TIMEOUT_MS = 30_000;
|
||||
// Cap pages-per-fire so an unexpectedly large backlog (server-side bug,
|
||||
// first run after a long offline window) cannot loop until Android's
|
||||
// 10-minute Worker kill timer fires. 5 pages × 50 events = up to 250
|
||||
// events per cycle — well above realistic 15-minute backlog for a single
|
||||
// user. We also break as soon as we hit ts ≤ watermark, so most polls
|
||||
// touch only a single page.
|
||||
private static final int MAX_PAGES_PER_RUN = 5;
|
||||
private static final int PAGE_LIMIT = 50;
|
||||
|
||||
private static final String RTC_NOTIFICATION_TYPE = "org.matrix.msc4075.rtc.notification";
|
||||
private static final String RTC_NOTIFICATION_TYPE_STABLE = "m.rtc.notification";
|
||||
|
||||
public VojoPollWorker(@NonNull Context context, @NonNull WorkerParameters params) {
|
||||
super(context, params);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Result doWork() {
|
||||
Context ctx = getApplicationContext();
|
||||
SharedPreferences prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
|
||||
String token = prefs.getString(KEY_ACCESS_TOKEN, null);
|
||||
String homeserver = prefs.getString(KEY_HOMESERVER_URL, null);
|
||||
if (token == null || homeserver == null) {
|
||||
// Not logged in (or JS hasn't bridged credentials yet). Return
|
||||
// success so WorkManager keeps the periodic schedule alive —
|
||||
// we'll pick up the credentials on the next fire.
|
||||
Log.i(TAG, "poll: no credentials, bail");
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
// If POST_NOTIFICATIONS was revoked we'd fetch + parse + try to
|
||||
// render and then watch every nm.notify fail with SecurityException
|
||||
// — which leaves the LRU/watermark unadvanced (correctly so for a
|
||||
// transient failure) and re-runs the same loop every 15 minutes
|
||||
// forever. Bail early to avoid burning battery on a permanent
|
||||
// user choice. The next visibility re-bridge inside the JS app
|
||||
// will pick up a re-granted permission.
|
||||
if (!NotificationManagerCompat.from(ctx).areNotificationsEnabled()) {
|
||||
Log.i(TAG, "poll: notifications disabled, bail");
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
long watermark = prefs.getLong(KEY_LAST_SEEN_TS, 0L);
|
||||
String drainCursor = prefs.getString(KEY_DRAIN_CURSOR, null);
|
||||
long drainTargetTs = prefs.getLong(KEY_DRAIN_TARGET_TS, 0L);
|
||||
boolean wasDraining = drainCursor != null;
|
||||
Map<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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
60
apps/.eslintrc.cjs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// Per-package ESLint config for the Preact widget apps under `apps/`.
|
||||
//
|
||||
// `root: true` stops ESLint from walking up to the host's
|
||||
// `cinny/.eslintrc.cjs`, which extends airbnb + the React plugin. Those
|
||||
// rule sets are tuned for the React host and flag legitimate Preact /
|
||||
// small-widget patterns as errors (`class=` attributes, arrow-fn
|
||||
// components, inline icon sub-components, for-of loops, etc.). Keeping
|
||||
// the hierarchy open would force every widget file to fight host style
|
||||
// for no real win.
|
||||
//
|
||||
// Widgets keep a minimal but real lint pass via the rule sets below:
|
||||
//
|
||||
// * `eslint:recommended` — catches genuine bugs (no-undef, no-dupe-*,
|
||||
// no-redeclare, no-unused-vars, …) without enforcing style.
|
||||
// * `@typescript-eslint/recommended` — TS-aware variants of the above
|
||||
// plus type-level checks the recommended set ships.
|
||||
//
|
||||
// We deliberately DON'T extend `plugin:react/recommended` —
|
||||
// `react/react-in-jsx-scope` and `react/no-unknown-property` both flag
|
||||
// Preact-correct code as errors, and disabling them one by one creates
|
||||
// a long suppression list. Widget JSX is type-checked by each app's
|
||||
// `tsc --noEmit` (run by `vite build`), which is the better signal for
|
||||
// JSX correctness anyway.
|
||||
module.exports = {
|
||||
root: true,
|
||||
// `node` covers `module.exports` in this very file (CommonJS config);
|
||||
// `browser` is the runtime widget code itself sees.
|
||||
env: { browser: true, es2021: true, node: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
// preact/hooks has the same dep-array semantics as react/hooks, and
|
||||
// the widget code already carries `// eslint-disable-next-line
|
||||
// react-hooks/exhaustive-deps` directives at the relevant sites;
|
||||
// loading the plugin (a) keeps those directives meaningful (without
|
||||
// it ESLint errors on the «unknown rule» referenced by the comment)
|
||||
// and (b) catches the real exhaustive-deps mistakes in widget hooks
|
||||
// for free.
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'react-hooks'],
|
||||
rules: {
|
||||
// Underscore-prefixed args are intentionally unused (Preact event
|
||||
// handlers receive args the body doesn't need); match the host's
|
||||
// convention so lint reads consistently across both trees.
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
// Widget bridge-protocol regexes occasionally escape `-` inside
|
||||
// character classes for visual clarity (e.g. `[0-9\-]`). The escape
|
||||
// is harmless and pre-existing across all three widgets — keeping
|
||||
// the rule on would force a churn-y diff in code that's been stable
|
||||
// since the v0.7.6 bridge dialect work.
|
||||
'no-useless-escape': 'off',
|
||||
},
|
||||
};
|
||||
|
|
@ -95,6 +95,18 @@ const LinkIcon = () => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
// 2×2 grid of rounded squares — leads the OpenSpaceCard. Reads as
|
||||
// «space with channels inside»; consistent visual vocabulary with the
|
||||
// channels-tab workspace grid affordances on the host side.
|
||||
const SpaceGridIcon = () => (
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<rect x="3.5" y="3.5" width="5.5" height="5.5" rx="1.4" />
|
||||
<rect x="11" y="3.5" width="5.5" height="5.5" rx="1.4" />
|
||||
<rect x="3.5" y="11" width="5.5" height="5.5" rx="1.4" />
|
||||
<rect x="11" y="11" width="5.5" height="5.5" rx="1.4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Linkifier — same heuristic as TG widget.
|
||||
const URL_RE = /https?:\/\/[^\s)]+/g;
|
||||
|
||||
|
|
@ -388,6 +400,13 @@ const loadHCaptcha = (): Promise<HCaptchaApi> => {
|
|||
`script[src^="https://js.hcaptcha.com/1/api.js"]`
|
||||
) as HTMLScriptElement | null;
|
||||
|
||||
// `timeoutHandle` is read in the `settle` closure declared below
|
||||
// BEFORE the assignment at the bottom of this function. ESLint's
|
||||
// flow analysis can't see the deferred assignment through the
|
||||
// closure and flags this as never-reassigned; in practice the value
|
||||
// IS reassigned and using `const` here would break the hcaptcha
|
||||
// script-load timeout path.
|
||||
// eslint-disable-next-line prefer-const
|
||||
let timeoutHandle: number | undefined;
|
||||
let settled = false;
|
||||
const settle = (action: () => void) => {
|
||||
|
|
@ -599,9 +618,7 @@ const CaptchaPanel = ({ state, t, onSolved, onCancel, onExpired }: CaptchaPanelP
|
|||
<div class="auth-card-hint">{t('auth-card.captcha.hint')}</div>
|
||||
<div class="auth-card-captcha-frame">
|
||||
<div ref={containerRef} class="auth-card-captcha-host" />
|
||||
{loadError ? (
|
||||
<div class="auth-card-error">{t('auth-card.captcha.load-error')}</div>
|
||||
) : null}
|
||||
{loadError ? <div class="auth-card-error">{t('auth-card.captcha.load-error')}</div> : null}
|
||||
</div>
|
||||
<div class="auth-card-row">
|
||||
<button type="button" class="btn-text" onClick={onCancel}>
|
||||
|
|
@ -764,6 +781,40 @@ 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
|
||||
// --------------------------------------------------------------------------
|
||||
|
|
@ -927,6 +978,15 @@ export function App({ bootstrap, api }: Props) {
|
|||
// hydrate too; the live path treats it identically.
|
||||
append({ kind: 'diag', text: t('diag.captcha-issued') });
|
||||
appendedAnyHistory = true;
|
||||
} else if (parsed.kind === 'space_ready') {
|
||||
// VOJO-LOGIN-SPACE-V1 sentinel body is a JSON blob —
|
||||
// machine-readable, never user-readable. Suppress the raw
|
||||
// body from the transcript and emit a diag breadcrumb
|
||||
// instead so a reload-replay shows «space ready» rather
|
||||
// than `VOJO-LOGIN-SPACE-V1 {"matrix_to_url":"…"}` ugly
|
||||
// verbatim. Same discipline as the captcha branch above.
|
||||
append({ kind: 'diag', text: t('diag.space-ready') });
|
||||
appendedAnyHistory = true;
|
||||
} else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') {
|
||||
// m.text / m.notice — body is safe to replay verbatim,
|
||||
// BUT we still scrub any login-URL-shaped substring as
|
||||
|
|
@ -989,10 +1049,7 @@ export function App({ bootstrap, api }: Props) {
|
|||
append({ kind: 'diag', text: t('diag.qr-issued') });
|
||||
} else if (event.kind === 'qr_redacted') {
|
||||
const liveState = stateRef.current;
|
||||
if (
|
||||
liveState.kind === 'awaiting_qr_scan' &&
|
||||
liveState.qrEventId === event.redactsEventId
|
||||
) {
|
||||
if (liveState.kind === 'awaiting_qr_scan' && liveState.qrEventId === event.redactsEventId) {
|
||||
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
||||
}
|
||||
} else if (event.kind === 'captcha_challenge') {
|
||||
|
|
@ -1001,6 +1058,12 @@ export function App({ bootstrap, api }: Props) {
|
|||
// transcript DOM (where screenshots / accessibility tools could
|
||||
// leak them). Diag-only display.
|
||||
append({ kind: 'diag', text: t('diag.captcha-issued') });
|
||||
} else if (event.kind === 'space_ready') {
|
||||
// Sentinel body is the JSON `{"matrix_to_url":"…"}` — not human-
|
||||
// readable and pointless to show verbatim. Emit a diag breadcrumb;
|
||||
// the actual «Open in Channels» card is rendered by the reducer
|
||||
// attaching `spaceMatrixToUrl` to the connected state.
|
||||
append({ kind: 'diag', text: t('diag.space-ready') });
|
||||
} else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') {
|
||||
const body = ev.content.body ?? '';
|
||||
append({ kind: 'from-bot', text: `← ${scrubLoginSecret(body)}` });
|
||||
|
|
@ -1185,9 +1248,7 @@ export function App({ bootstrap, api }: Props) {
|
|||
// entry, but a manual disconnect path could leave us in connected
|
||||
// and trigger reconnect from there).
|
||||
const handle =
|
||||
state.kind === 'connected_dead' || state.kind === 'connected'
|
||||
? state.handle
|
||||
: undefined;
|
||||
state.kind === 'connected_dead' || state.kind === 'connected' ? state.handle : undefined;
|
||||
dispatch({ kind: 'request_reconnect', handle });
|
||||
try {
|
||||
await sendBare('reconnect');
|
||||
|
|
@ -1353,6 +1414,17 @@ export function App({ bootstrap, api }: Props) {
|
|||
}
|
||||
/>
|
||||
<div class="command-grid">
|
||||
{/* Open-space CTA — only against a Vojo-patched bridge that
|
||||
* emitted the VOJO-LOGIN-SPACE-V1 sentinel. Listed first so a
|
||||
* fresh post-login user sees «next step» before the Logout
|
||||
* destructive action. */}
|
||||
{state.spaceMatrixToUrl ? (
|
||||
<OpenSpaceCard
|
||||
t={t}
|
||||
matrixToUrl={state.spaceMatrixToUrl}
|
||||
onOpen={(url) => api.openMatrixToUrl(url)}
|
||||
/>
|
||||
) : null}
|
||||
<LogoutCard t={t} onConfirm={onConfirmLogout} />
|
||||
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,15 @@ const LOGIN_SUCCESS_RE = /^successfully logged in as\s+@?(.+?)\.?$/i;
|
|||
const CAPTCHA_CHALLENGE_PREFIX = 'VOJO-CAPTCHA-CHALLENGE-V1';
|
||||
const CAPTCHA_CHALLENGE_RE = /^VOJO-CAPTCHA-CHALLENGE-V1\s+(\{[\s\S]*\})\s*$/;
|
||||
|
||||
// Vojo-patched bridge emits this sentinel right after «Successfully logged
|
||||
// in as @user» (commands_login_space.go::sendLoginSpaceNotice). Carries the
|
||||
// matrix.to URL of the user's personal Discord space so the widget can
|
||||
// render a CTA. Same markdown-inert + structured-JSON discipline as the
|
||||
// captcha sentinel above; the bridge sends this via SendMessageEvent to
|
||||
// bypass goldmark round-trip.
|
||||
const LOGIN_SPACE_SENTINEL_PREFIX = 'VOJO-LOGIN-SPACE-V1';
|
||||
const LOGIN_SPACE_SENTINEL_RE = /^VOJO-LOGIN-SPACE-V1\s+(\{[\s\S]*\})\s*$/;
|
||||
|
||||
// Legacy CAPTCHA fallback — commands.go:fnLoginQR (l.207-209) on UNPATCHED
|
||||
// upstream v0.7.6: «CAPTCHAs are currently not supported - use token login
|
||||
// instead». Kept so a deployment running unpatched bridge still produces a
|
||||
|
|
@ -160,6 +169,28 @@ export const parseLegacyV076Body = (rawBody: string): LoginEvent => {
|
|||
}
|
||||
if (CAPTCHA_REQUIRED_RE.test(body)) return { kind: 'captcha_required' };
|
||||
|
||||
// Vojo login-space sentinel: structured JSON with the personal Discord
|
||||
// space's matrix.to URL. Checked alongside the captcha sentinel —
|
||||
// markdown-inert prefix means it lands verbatim from the bridge, parsed
|
||||
// into a `space_ready` event for the reducer to attach to connected state.
|
||||
// Malformed payload (missing/empty `matrix_to_url`, JSON parse failure) is
|
||||
// silently dropped as `unknown` rather than surfacing a stale CTA.
|
||||
if (body.startsWith(LOGIN_SPACE_SENTINEL_PREFIX)) {
|
||||
const match = LOGIN_SPACE_SENTINEL_RE.exec(body);
|
||||
if (match) {
|
||||
try {
|
||||
const payload = JSON.parse(match[1]) as Record<string, unknown>;
|
||||
const matrixToUrl = typeof payload.matrix_to_url === 'string' ? payload.matrix_to_url : '';
|
||||
if (matrixToUrl) {
|
||||
return { kind: 'space_ready', matrixToUrl };
|
||||
}
|
||||
} catch {
|
||||
// fall through — malformed payload is treated as unknown
|
||||
}
|
||||
}
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
|
||||
const loginWsMatch = LOGIN_WEBSOCKET_FAILED_RE.exec(body);
|
||||
if (loginWsMatch) return { kind: 'login_websocket_failed', reason: loginWsMatch[1].trim() };
|
||||
|
||||
|
|
@ -247,8 +278,8 @@ export const parseEventLegacyV076 = (event: ParsableEvent): LoginEvent => {
|
|||
typeof event.redacts === 'string'
|
||||
? event.redacts
|
||||
: isObject(event.content) && typeof event.content.redacts === 'string'
|
||||
? event.content.redacts
|
||||
: undefined;
|
||||
? event.content.redacts
|
||||
: undefined;
|
||||
if (!target) return { kind: 'unknown' };
|
||||
return { kind: 'qr_redacted', redactsEventId: target };
|
||||
}
|
||||
|
|
@ -330,20 +361,11 @@ function runSanityChecks(): void {
|
|||
|
||||
// Login success (post-QR scan). No snowflake in this line; App fires
|
||||
// `ping` afterwards to pick up the discordId.
|
||||
[
|
||||
'Successfully logged in as @example',
|
||||
{ kind: 'login_success', handle: 'example' },
|
||||
],
|
||||
[
|
||||
'Successfully logged in as @user.name',
|
||||
{ kind: 'login_success', handle: 'user.name' },
|
||||
],
|
||||
['Successfully logged in as @example', { kind: 'login_success', handle: 'example' }],
|
||||
['Successfully logged in as @user.name', { kind: 'login_success', handle: 'user.name' }],
|
||||
|
||||
// Login failure paths.
|
||||
[
|
||||
'Error logging in: rate limited 429',
|
||||
{ kind: 'login_failed', reason: 'rate limited 429' },
|
||||
],
|
||||
['Error logging in: rate limited 429', { kind: 'login_failed', reason: 'rate limited 429' }],
|
||||
// CAPTCHA legacy fallback — pre-empts LOGIN_FAILED_RE. Fires only on
|
||||
// unpatched upstream v0.7.6.
|
||||
[
|
||||
|
|
@ -387,10 +409,7 @@ function runSanityChecks(): void {
|
|||
|
||||
// Logout.
|
||||
['Logged out successfully.', { kind: 'logout_ok' }],
|
||||
[
|
||||
"You weren't logged in, but data was re-cleared just to be safe.",
|
||||
{ kind: 'logout_no_op' },
|
||||
],
|
||||
["You weren't logged in, but data was re-cleared just to be safe.", { kind: 'logout_no_op' }],
|
||||
|
||||
// Disconnect / reconnect.
|
||||
['Successfully disconnected', { kind: 'disconnect_ok' }],
|
||||
|
|
@ -521,7 +540,9 @@ function runSanityChecks(): void {
|
|||
// eslint-disable-next-line no-console
|
||||
console.error('[legacy_v076 event sanity] mismatch', { event, actual, expected });
|
||||
throw new Error(
|
||||
`legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${event.content?.msgtype ?? '<none>'}`
|
||||
`legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${
|
||||
event.content?.msgtype ?? '<none>'
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,6 +113,15 @@ export type LoginEvent =
|
|||
| { kind: 'reconnect_no_op' }
|
||||
| { kind: 'reconnect_failed'; reason?: string }
|
||||
|
||||
// --- Vojo: bridge-managed personal space ---------------------------------
|
||||
// Vojo-patched bridge emits the sentinel `VOJO-LOGIN-SPACE-V1 {...}` as a
|
||||
// separate m.notice right after the «Successfully logged in» line. Carries
|
||||
// a `matrix.to` URL pointing at the user's auto-created Discord space
|
||||
// (user.go::GetSpaceRoom on the bridge side). The widget surfaces this as
|
||||
// an «Open in Channels» card; click → host navigates cinny to the space.
|
||||
// See vojo-mautrix-discord/commands_login_space.go for the wire format.
|
||||
| { kind: 'space_ready'; matrixToUrl: string }
|
||||
|
||||
// --- bridge-side errors --------------------------------------------------
|
||||
// Generic «I don't know that command» — should not happen since we only
|
||||
// ship known commands, but visible if the bridge image is misconfigured
|
||||
|
|
|
|||
|
|
@ -55,13 +55,11 @@ export const EN: Record<StringKey, string> = {
|
|||
'Discord requested a CAPTCHA — QR sign-in is temporarily unavailable. Try again later, or sign in with a token via the bot’s chat.',
|
||||
'auth-error.captcha-send-failed':
|
||||
'Could not deliver your CAPTCHA solution. Check your network and try signing in again.',
|
||||
'auth-error.captcha-expired':
|
||||
'CAPTCHA expired — tap «Sign in with QR code» and solve it again.',
|
||||
'auth-error.captcha-expired': 'CAPTCHA expired — tap «Sign in with QR code» and solve it again.',
|
||||
'auth-error.login-failed': 'Sign-in failed: {reason}',
|
||||
'auth-error.prepare-failed': 'Failed to prepare sign-in: {reason}',
|
||||
'auth-error.websocket-failed': 'Could not connect to the sign-in server: {reason}',
|
||||
'auth-error.connect-after-login-failed':
|
||||
'Signed in, but could not connect to Discord: {reason}',
|
||||
'auth-error.connect-after-login-failed': 'Signed in, but could not connect to Discord: {reason}',
|
||||
'auth-error.already-logged-in': 'You are already signed in to Discord — refresh status.',
|
||||
'auth-error.unknown-command':
|
||||
'The bot does not recognise this command — check the prefix in config.json.',
|
||||
|
|
@ -73,6 +71,9 @@ export const EN: Record<StringKey, string> = {
|
|||
'card.logout.confirm-prompt': 'Sign out for real?',
|
||||
'card.logout.confirm-yes': 'Sign out',
|
||||
'card.logout.confirm-no': 'Cancel',
|
||||
'card.open-space.name': 'Open in Channels',
|
||||
'card.open-space.desc': 'Jump to your Discord space with all chats and servers',
|
||||
'diag.space-ready': 'Discord space ready to open.',
|
||||
'diag.connecting': 'Connecting to Vojo… awaiting capability handshake.',
|
||||
'diag.ready': 'Ready to send commands.',
|
||||
'diag.checking-status': 'Checking connection status…',
|
||||
|
|
|
|||
|
|
@ -86,8 +86,7 @@ export const RU = {
|
|||
'Discord потребовал CAPTCHA — вход через QR временно недоступен. Попробуйте позже или войдите через токен в чате с ботом.',
|
||||
'auth-error.captcha-send-failed':
|
||||
'Не удалось отправить ответ на CAPTCHA. Проверьте сеть и попробуйте войти заново.',
|
||||
'auth-error.captcha-expired':
|
||||
'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.',
|
||||
'auth-error.captcha-expired': 'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.',
|
||||
'auth-error.login-failed': 'Не удалось войти: {reason}',
|
||||
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}',
|
||||
'auth-error.websocket-failed': 'Не удалось подключиться к серверу входа: {reason}',
|
||||
|
|
@ -106,7 +105,11 @@ export const RU = {
|
|||
'card.logout.confirm-prompt': 'Точно выйти?',
|
||||
'card.logout.confirm-yes': 'Выйти',
|
||||
'card.logout.confirm-no': 'Отмена',
|
||||
// --- Open Discord space (Vojo bridge sentinel) ------------------------
|
||||
'card.open-space.name': 'Открыть в Каналах',
|
||||
'card.open-space.desc': 'Перейти в спейс Discord со списком чатов и серверов',
|
||||
// --- Diagnostics in transcript ----------------------------------------
|
||||
'diag.space-ready': 'Discord-спейс готов к открытию.',
|
||||
'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.',
|
||||
'diag.ready': 'Готов отправлять команды.',
|
||||
'diag.checking-status': 'Проверяю статус подключения…',
|
||||
|
|
|
|||
|
|
@ -104,8 +104,13 @@ export type LoginState =
|
|||
| { kind: 'reconnecting'; handle?: string }
|
||||
// Live session — ping or login_success confirmed. Discord legacy bridge
|
||||
// doesn't have a per-account loginId concept (single Discord account
|
||||
// per Matrix user), so logout doesn't need an id.
|
||||
| { kind: 'connected'; handle: string; discordId?: string }
|
||||
// per Matrix user), so logout doesn't need an id. `spaceMatrixToUrl`
|
||||
// is populated from the Vojo `VOJO-LOGIN-SPACE-V1` sentinel that lands
|
||||
// right after login_success; it survives the post-login re-ping and the
|
||||
// reconnect-ok transitions so the «Open in Channels» card stays visible
|
||||
// until logout. Absent until the sentinel arrives (and absent forever
|
||||
// against an UNPATCHED bridge — the card simply never appears).
|
||||
| { kind: 'connected'; handle: string; discordId?: string; spaceMatrixToUrl?: string }
|
||||
// ping says we have a token but the connection's down. Status pill:
|
||||
// green-ish but with a Reconnect recovery action exposed. The reducer
|
||||
// distinguishes `connection_dead` (Discord WS dropped) from `token_stored`
|
||||
|
|
@ -120,10 +125,7 @@ export type LoginState =
|
|||
// staring at an hCaptcha challenge (rqdata/rqtoken are short-lived but
|
||||
// often valid for a couple of minutes — fresh enough to reuse). Other
|
||||
// transient states (logging_out, reconnecting) deliberately don't survive.
|
||||
export type HydrateRestoredState =
|
||||
| PendingFormState
|
||||
| CaptchaSolveState
|
||||
| { kind: 'qr_verifying' };
|
||||
export type HydrateRestoredState = PendingFormState | CaptchaSolveState | { kind: 'qr_verifying' };
|
||||
|
||||
// Outbound user actions the App dispatches. Form-submit actions clear any
|
||||
// pending lastError; structural transitions optimistically advance state —
|
||||
|
|
@ -169,9 +171,7 @@ const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiti
|
|||
const isCaptchaAcceptingState = (
|
||||
s: LoginState
|
||||
): s is PendingFormState | { kind: 'qr_verifying' } | CaptchaSolveState =>
|
||||
s.kind === 'awaiting_qr_scan' ||
|
||||
s.kind === 'qr_verifying' ||
|
||||
s.kind === 'awaiting_captcha_solve';
|
||||
s.kind === 'awaiting_qr_scan' || s.kind === 'qr_verifying' || s.kind === 'awaiting_captcha_solve';
|
||||
|
||||
export const loginReducer = (state: LoginState, action: LoginAction): LoginState => {
|
||||
if (action.kind === 'hydrate') {
|
||||
|
|
@ -266,11 +266,14 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
|
|||
case 'logged_in':
|
||||
// Authoritative source — accept from any state. Used by both the
|
||||
// initial ping AND the post-`login_success` re-ping that picks up
|
||||
// the discordId snowflake.
|
||||
// the discordId snowflake. Preserve `spaceMatrixToUrl` from a prior
|
||||
// `connected` so the post-login_success re-ping doesn't blank the
|
||||
// CTA before the user gets a chance to click it.
|
||||
return {
|
||||
kind: 'connected',
|
||||
handle: event.handle,
|
||||
discordId: event.discordId,
|
||||
spaceMatrixToUrl: state.kind === 'connected' ? state.spaceMatrixToUrl : undefined,
|
||||
};
|
||||
|
||||
case 'connection_dead':
|
||||
|
|
@ -492,12 +495,28 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
|
|||
// green with an empty handle, which the UI's
|
||||
// `state.handle ? connected-as : connected` ternary tolerates.
|
||||
// This avoids the `unknown` flap that the previous draft would
|
||||
// produce when no handle was stashed.
|
||||
// produce when no handle was stashed. spaceMatrixToUrl is not
|
||||
// restorable from connected_dead (the dead state never carried it),
|
||||
// so the CTA stays hidden until a fresh sentinel arrives — bridge
|
||||
// does NOT re-emit on reconnect, but the card returns once the user
|
||||
// explicitly re-logs in.
|
||||
if (state.kind === 'reconnecting' || state.kind === 'connected_dead') {
|
||||
return { kind: 'connected', handle: state.handle ?? '' };
|
||||
}
|
||||
return state;
|
||||
|
||||
case 'space_ready':
|
||||
// Vojo-patched bridge surfaced the personal Discord space — attach
|
||||
// its matrix.to URL to the connected state so the «Open in Channels»
|
||||
// card renders. Late-arriving sentinels from an abandoned flow drop
|
||||
// here silently (e.g. a sentinel that lands during `logging_out`
|
||||
// mustn't resurrect a connected state). Honour only from the
|
||||
// canonical alive states.
|
||||
if (state.kind === 'connected') {
|
||||
return { ...state, spaceMatrixToUrl: event.matrixToUrl };
|
||||
}
|
||||
return state;
|
||||
|
||||
case 'reconnect_failed':
|
||||
if (state.kind !== 'reconnecting') return state;
|
||||
// Roll back to connected_dead carrying the previous handle. The
|
||||
|
|
@ -565,10 +584,7 @@ type HydrateAccumulator = {
|
|||
terminated: boolean;
|
||||
};
|
||||
|
||||
const stepHydrate = (
|
||||
prevAcc: HydrateAccumulator,
|
||||
input: HydrateInput
|
||||
): HydrateAccumulator => {
|
||||
const stepHydrate = (prevAcc: HydrateAccumulator, input: HydrateInput): HydrateAccumulator => {
|
||||
const { ev, ts } = input;
|
||||
|
||||
// After a terminal event we normally stop — except if a fresh
|
||||
|
|
@ -693,9 +709,12 @@ const stepHydrate = (
|
|||
|
||||
case 'already_logged_in':
|
||||
case 'unknown':
|
||||
case 'space_ready':
|
||||
// Soft no-op for hydrate. already_logged_in is a live-flow warning
|
||||
// that doesn't reflect persistent state; unknown is a wording-drift
|
||||
// catch-all.
|
||||
// catch-all; space_ready is a post-terminal sentinel — hydrate
|
||||
// terminates on login_success and lets live ping reconcile, so
|
||||
// the URL gets attached on the live path, not here.
|
||||
return acc;
|
||||
|
||||
default: {
|
||||
|
|
|
|||
|
|
@ -125,6 +125,27 @@ export class WidgetApi {
|
|||
);
|
||||
}
|
||||
|
||||
// Ask the host to navigate to a matrix.to URL inside the cinny app
|
||||
// (room or space). Same side-channel pattern as `openExternalUrl` —
|
||||
// distinct from matrix-widget-api's `fromWidget` so the SDK stays
|
||||
// ignorant of this Vojo extension. The host validates the URL via
|
||||
// `parseMatrixToRoom` (rejecting non-room URLs, javascript:/data:, etc.)
|
||||
// BEFORE routing into the react-router; sending anything that isn't a
|
||||
// matrix.to/#/!roomId or matrix.to/#/#alias URL silently no-ops on the
|
||||
// host side. The widget is responsible for only invoking this when it
|
||||
// genuinely has a matrix.to room URL (e.g. parsed from a bridge
|
||||
// sentinel).
|
||||
public openMatrixToUrl(url: string): void {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
api: 'io.vojo.bot-widget',
|
||||
action: 'open-matrix-to',
|
||||
data: { url },
|
||||
},
|
||||
this.bootstrap.parentOrigin
|
||||
);
|
||||
}
|
||||
|
||||
// Always prefix outbound commands with `<commandPrefix> ` (trailing space).
|
||||
// Legacy mautrix-discord routes management-room commands through the
|
||||
// bridge.commands.Processor in mautrix/go bridge/commands; outside the
|
||||
|
|
|
|||
7
apps/widget-telegram/package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
|||
"name": "@vojo/widget-telegram",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"libphonenumber-js": "^1.11.7",
|
||||
"preact": "10.22.1",
|
||||
"qrcode-generator": "^1.4.4"
|
||||
},
|
||||
|
|
@ -1611,6 +1612,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libphonenumber-js": {
|
||||
"version": "1.11.7",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.7.tgz",
|
||||
"integrity": "sha512-x2xON4/Qg2bRIS11KIN9yCNYUjhtiEjNyptjX0mX+pyKHecxuJVLIpfX1lq9ZD6CrC/rB+y4GBi18c6CEcUR+A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"libphonenumber-js": "^1.11.7",
|
||||
"preact": "10.22.1",
|
||||
"qrcode-generator": "^1.4.4"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'p
|
|||
import type { Dispatch } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import qrcodeGenerator from 'qrcode-generator';
|
||||
// `/min` metadata (~15 KB gzip) covers all country calling codes + length
|
||||
// validation. Sufficient for «is this a plausible phone number?» — the
|
||||
// bridge does the authoritative validation server-side. Avoid `/max`
|
||||
// (~60 KB) since the widget is a separate Preact bundle and ships into
|
||||
// the bot iframe on cold start.
|
||||
import { AsYouType, isValidPhoneNumber } from 'libphonenumber-js/min';
|
||||
import type { WidgetBootstrap } from './bootstrap';
|
||||
import { WidgetApi, type RoomEvent } from './widget-api';
|
||||
import { createT, type T } from './i18n';
|
||||
|
|
@ -104,6 +110,34 @@ const QrIcon = () => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
// Eye + eye-with-slash for the password reveal toggle. SVG paths
|
||||
// copied verbatim from folds `Icons.Eye(false)` / `Icons.EyeBlind(false)`
|
||||
// — the unfilled variants Vojo's main auth uses via
|
||||
// `src/app/components/password-input/PasswordInput.tsx`. Importing folds
|
||||
// into the widget bundle would pull the whole component library, so we
|
||||
// inline the geometry. ViewBox 24×24 + `fill="currentColor"` matches
|
||||
// the folds Icon component output bit-for-bit; the only divergence vs
|
||||
// the host is the wrapper (folds `IconButton` vs our plain `<button>`).
|
||||
const EyeIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M15 12C15 13.6569 13.6569 15 12 15C10.3431 15 9 13.6569 9 12C9 10.3431 10.3431 9 12 9C13.6569 9 15 10.3431 15 12Z" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M1 12C1 12 5.92487 19 12 19C18.0751 19 23 12 23 12C23 12 18.0751 5 12 5C5.92487 5 1 12 1 12ZM2.90443 12C2.93793 12.0401 2.97258 12.0813 3.00836 12.1235C3.53083 12.7395 4.28523 13.5585 5.21221 14.3734C7.11461 16.0459 9.51515 17.5 12 17.5C14.4849 17.5 16.8854 16.0459 18.7878 14.3734C19.7148 13.5585 20.4692 12.7395 20.9916 12.1235C21.0274 12.0813 21.0621 12.0401 21.0956 12C21.0621 11.9599 21.0274 11.9187 20.9916 11.8765C20.4692 11.2605 19.7148 10.4415 18.7878 9.62656C16.8854 7.9541 14.4849 6.5 12 6.5C9.51515 6.5 7.11461 7.9541 5.21221 9.62656C4.28523 10.4415 3.53083 11.2605 3.00836 11.8765C2.97258 11.9187 2.93793 11.9599 2.90443 12Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
const EyeBlindIcon = () => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M4.75213 3.69141L3.69147 4.75207L6.02989 7.09049C3.00297 9.15318 1 12.0001 1 12.0001C1 12.0001 5.92487 19.0001 12 19.0001C13.663 19.0001 15.2399 18.4756 16.6531 17.7137L19.2478 20.3084L20.3085 19.2478L4.75213 3.69141ZM15.5394 16.6L13.5242 14.5848C13.0775 14.8488 12.5565 15.0003 12 15.0003C10.3431 15.0003 9 13.6572 9 12.0003C9 11.4439 9.1515 10.9228 9.4155 10.4761L7.11135 8.17195C6.4387 8.61141 5.80156 9.10856 5.21221 9.62667C4.28523 10.4416 3.53083 11.2607 3.00836 11.8766C2.97258 11.9188 2.93793 11.96 2.90443 12.0001C2.93793 12.0402 2.97258 12.0814 3.00836 12.1236C3.53083 12.7396 4.28523 13.5586 5.21221 14.3736C7.11461 16.046 9.51515 17.5001 12 17.5001C13.2162 17.5001 14.4122 17.1518 15.5394 16.6ZM18.5058 14.6167C18.6009 14.5363 18.6949 14.4552 18.7878 14.3736C19.7148 13.5586 20.4692 12.7396 20.9916 12.1236C21.0274 12.0814 21.0621 12.0402 21.0956 12.0001C21.0621 11.96 21.0274 11.9188 20.9916 11.8766C20.4692 11.2607 19.7148 10.4416 18.7878 9.62667C16.8854 7.95422 14.4849 6.50011 12 6.50011C11.5118 6.50011 11.0268 6.55625 10.5482 6.65915L9.32458 5.43554C10.181 5.16161 11.0772 5.00011 12 5.00011C18.0751 5.00011 23 12.0001 23 12.0001C23 12.0001 21.6825 13.8727 19.5699 15.6808L18.5058 14.6167Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Sign-out arrow leaving an open box — leads the destructive logout
|
||||
// card. Open right side conveys «out of the session». Stays muted
|
||||
// inside `.command-card.danger` so the rose accent is reserved for
|
||||
|
|
@ -222,6 +256,49 @@ type FormProps = {
|
|||
// 60 s matches Telegram Desktop's own "Resend code" lockout.
|
||||
const PHONE_COOLDOWN_MS = 60_000;
|
||||
|
||||
// Minimum digit count before we'd dare call a number «invalid». Below
|
||||
// this threshold the user is still typing the country prefix and the
|
||||
// formatter has nothing to validate against — showing red here would
|
||||
// blink at every keystroke. 7 covers single-digit calling codes (US/RU
|
||||
// = 1 + 6 digits is the shortest reasonable subscriber number).
|
||||
const PHONE_MIN_DIGITS_FOR_VALIDATION = 7;
|
||||
|
||||
// Strip every character that isn't `+` or a digit, then guarantee a
|
||||
// single leading `+` (bridgev2's E.164 validator rejects anything
|
||||
// without it). Used both as the AsYouType input AND as the wire-format
|
||||
// stripped value sent to the bridge — so paste-friendly cleanup
|
||||
// («+1 (213) 373-4253», «+7-905-…») falls out for free.
|
||||
const phoneToE164 = (raw: string): string => {
|
||||
const cleaned = raw.replace(/[^\d+]/g, '');
|
||||
if (cleaned.length === 0) return '';
|
||||
return cleaned.startsWith('+') ? cleaned : `+${cleaned}`;
|
||||
};
|
||||
|
||||
// AsYouType is stateful — calling `.input()` repeatedly with a growing
|
||||
// string mutates the internal char buffer. Use a fresh instance per
|
||||
// call so editing in the middle of the string (paste, backspace) can't
|
||||
// desync the formatter state from the input value.
|
||||
type PhoneFormat = { formatted: string; country: string | undefined };
|
||||
const formatPhoneInput = (raw: string): PhoneFormat => {
|
||||
const e164 = phoneToE164(raw);
|
||||
if (!e164) return { formatted: '', country: undefined };
|
||||
const formatter = new AsYouType();
|
||||
const formatted = formatter.input(e164);
|
||||
return { formatted, country: formatter.getCountry() };
|
||||
};
|
||||
|
||||
// ISO 3166-1 alpha-2 → regional-indicator-symbol emoji. 'RU' → 🇷🇺.
|
||||
// Browsers without flag-emoji fonts (Windows Chrome) fall back to the
|
||||
// two-letter code rendered as letter glyphs, which is still readable.
|
||||
const countryToFlagEmoji = (cc: string | undefined): string => {
|
||||
if (!cc || cc.length !== 2) return '';
|
||||
const codePoints = cc
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map((c) => 127397 + c.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
};
|
||||
|
||||
// Tick once per second while a future timestamp is still in the future.
|
||||
// Returns the seconds remaining (0 once expired). When `until` is null
|
||||
// the hook is idle.
|
||||
|
|
@ -271,6 +348,11 @@ const PhoneForm = ({
|
|||
setPhoneCooldownEnd,
|
||||
}: FormProps) => {
|
||||
const [value, setValue] = useState('');
|
||||
// Country is captured directly from the AsYouType formatter in
|
||||
// `onInput` so we don't run AsYouType a second time per keystroke
|
||||
// just to read `getCountry()` — keeps the formatter call count at
|
||||
// one per actual user input event instead of one per render.
|
||||
const [country, setCountry] = useState<string | undefined>(undefined);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const stillWaiting = useStillWaitingHint([submitting]);
|
||||
|
|
@ -282,17 +364,35 @@ const PhoneForm = ({
|
|||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Wire-format value (no spaces, single leading `+`) — what we send to
|
||||
// the bridge, and what libphonenumber-js validates. `phoneToE164` is
|
||||
// a regex on a 10-char string and `isValidPhoneNumber` (`/min`
|
||||
// metadata) is a length-table lookup — both safe to recompute every
|
||||
// render without memoisation.
|
||||
const e164 = phoneToE164(value);
|
||||
const digitsCount = e164.replace('+', '').length;
|
||||
const hasEnoughDigits = digitsCount >= PHONE_MIN_DIGITS_FOR_VALIDATION;
|
||||
// `isValidPhoneNumber` from `/min` metadata is intentionally treated as
|
||||
// a soft hint, not a hard gate: the libphonenumber-js README itself
|
||||
// warns that strict validation can reject newly-allocated mobile pools
|
||||
// until the package is bumped, and bridgev2 has the authoritative word
|
||||
// (it replies `invalid_value` + the App-level effect clears the
|
||||
// cooldown). Matches Stripe / Auth0 / WhatsApp Web's warn-don't-block
|
||||
// pattern.
|
||||
const showInvalidHint = hasEnoughDigits && !isValidPhoneNumber(e164);
|
||||
|
||||
const onSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || submitting || inCooldown) return;
|
||||
if (!e164 || submitting || inCooldown || !hasEnoughDigits) return;
|
||||
setSubmitting(true);
|
||||
// Clear any stale error optimistically so the form looks ready for the
|
||||
// next attempt; a fresh error will re-arrive from the bot if the
|
||||
// submit fails server-side.
|
||||
dispatch({ kind: 'submit_phone' });
|
||||
try {
|
||||
await send(trimmed, 'phone');
|
||||
// Strip the visual spaces / dashes AsYouType inserted before sending
|
||||
// — bridgev2 normalises but server-side validation expects raw E.164.
|
||||
await send(e164, 'phone');
|
||||
// Cooldown locks retries ONLY after the Matrix transport accepted
|
||||
// the message. If `await send` threw (network down, capability
|
||||
// race, etc.), no SMS was attempted at the Telegram side — locking
|
||||
|
|
@ -309,10 +409,11 @@ const PhoneForm = ({
|
|||
};
|
||||
|
||||
const tone = error ? errorTone(error) : undefined;
|
||||
const submitDisabled = submitting || inCooldown || value.trim() === '';
|
||||
const submitDisabled = submitting || inCooldown || !hasEnoughDigits;
|
||||
const submitLabel = inCooldown
|
||||
? t('auth-card.phone.cooldown', { seconds: String(cooldownSeconds) })
|
||||
: t('auth-card.phone.submit');
|
||||
const flagEmoji = countryToFlagEmoji(country);
|
||||
|
||||
return (
|
||||
<form class={`auth-card${tone === 'error' ? ' error' : ''}`} onSubmit={onSubmit}>
|
||||
|
|
@ -321,27 +422,42 @@ const PhoneForm = ({
|
|||
{t('auth-card.phone.label')}
|
||||
</label>
|
||||
<div class="auth-card-row">
|
||||
<input
|
||||
id="auth-phone-input"
|
||||
ref={inputRef}
|
||||
class="auth-input"
|
||||
type="tel"
|
||||
autocomplete="tel"
|
||||
inputmode="tel"
|
||||
placeholder={t('auth-card.phone.placeholder')}
|
||||
value={value}
|
||||
onInput={(e) => {
|
||||
// Auto-prepend `+` so the user never has to remember to type
|
||||
// it — bridgev2 rejects anything without a leading `+` per
|
||||
// its E.164 input validator. Skipping the special-case
|
||||
// formatting (8→+7 etc.) on purpose: keeping the rule at one
|
||||
// line of logic means there's nothing to misinterpret a
|
||||
// pasted international number as a Russian trunk number.
|
||||
const raw = (e.currentTarget as HTMLInputElement).value;
|
||||
setValue(raw.length > 0 && !raw.startsWith('+') ? `+${raw}` : raw);
|
||||
}}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<div class={`auth-phone-shell${flagEmoji ? ' with-flag' : ''}`}>
|
||||
{flagEmoji ? (
|
||||
<span class="auth-phone-flag" aria-hidden="true">
|
||||
{flagEmoji}
|
||||
</span>
|
||||
) : null}
|
||||
<input
|
||||
id="auth-phone-input"
|
||||
ref={inputRef}
|
||||
class={`auth-input${showInvalidHint ? ' warn' : ''}`}
|
||||
type="tel"
|
||||
autocomplete="tel"
|
||||
inputmode="tel"
|
||||
placeholder={t('auth-card.phone.placeholder')}
|
||||
value={value}
|
||||
onInput={(e) => {
|
||||
// Re-format on every keystroke via a fresh AsYouType. The
|
||||
// formatter strips non-digit/non-`+` chars (so pastes like
|
||||
// «+1 (213) 373-4253» normalise), auto-prepends `+` if
|
||||
// missing (bridgev2 rejects without it), and groups digits
|
||||
// by country convention. Caret jumps to end — acceptable
|
||||
// for left-to-right phone entry; mid-string edits remain
|
||||
// possible but the caret resets. Avoiding 8→+7 special-
|
||||
// casing on purpose so a paste of an international number
|
||||
// can't be misread as a Russian trunk dial. Country is
|
||||
// captured here (instead of recomputed via useMemo) so the
|
||||
// single AsYouType call covers both formatting and flag
|
||||
// detection.
|
||||
const raw = (e.currentTarget as HTMLInputElement).value;
|
||||
const next = formatPhoneInput(raw);
|
||||
setValue(next.formatted);
|
||||
setCountry(next.country);
|
||||
}}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" disabled={submitDisabled}>
|
||||
{submitLabel}
|
||||
</button>
|
||||
|
|
@ -350,6 +466,9 @@ const PhoneForm = ({
|
|||
</button>
|
||||
</div>
|
||||
<div class="auth-card-hint">{t('auth-card.phone.hint')}</div>
|
||||
{showInvalidHint && !error ? (
|
||||
<div class="auth-card-warn">{t('auth-card.phone.invalid')}</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div class={tone === 'warn' ? 'auth-card-warn' : 'auth-card-error'}>
|
||||
{localizeError(error, t)}
|
||||
|
|
@ -508,24 +627,37 @@ const PasswordForm = ({ state, t, dispatch, send, sendCancel }: FormProps) => {
|
|||
{t('auth-card.password.label')}
|
||||
</label>
|
||||
<div class="auth-card-row">
|
||||
<div class="password-row">
|
||||
<div class="auth-password-shell">
|
||||
<input
|
||||
id="auth-password-input"
|
||||
ref={inputRef}
|
||||
class="auth-input password"
|
||||
type={reveal ? 'text' : 'password'}
|
||||
autocomplete="current-password"
|
||||
// `size={1}` kills the HTML default `size=20` which, combined
|
||||
// with `.auth-input.password`'s 20 px font + 4 px
|
||||
// letter-spacing + 44 px right-padding, gives the input an
|
||||
// intrinsic min-content width near 460 px. Chromium does not
|
||||
// honour `min-width: 0` on a flex-item `<input>` against that
|
||||
// size-derived minimum, so the input refused to shrink and
|
||||
// pushed past the `.auth-card` border on narrow viewports.
|
||||
// Setting `size=1` drops the intrinsic floor so `flex: 1`
|
||||
// + `min-width: 0` actually shrink the box to the slot.
|
||||
size={1}
|
||||
value={value}
|
||||
onInput={(e) => setValue((e.currentTarget as HTMLInputElement).value)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-icon"
|
||||
class="auth-password-eye"
|
||||
onClick={() => setReveal((v) => !v)}
|
||||
aria-label={reveal ? t('auth-card.password.hide') : t('auth-card.password.show')}
|
||||
aria-pressed={reveal}
|
||||
aria-controls="auth-password-input"
|
||||
disabled={submitting}
|
||||
>
|
||||
{reveal ? t('auth-card.password.hide') : t('auth-card.password.show')}
|
||||
{reveal ? <EyeIcon /> : <EyeBlindIcon />}
|
||||
</button>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" disabled={submitting || value === ''}>
|
||||
|
|
@ -1182,10 +1314,7 @@ export function App({ bootstrap, api }: Props) {
|
|||
append({ kind: 'diag', text: t('diag.qr-issued') });
|
||||
} else if (event.kind === 'qr_redacted') {
|
||||
const liveState = stateRef.current;
|
||||
if (
|
||||
liveState.kind === 'awaiting_qr_scan' &&
|
||||
liveState.qrEventId === event.redactsEventId
|
||||
) {
|
||||
if (liveState.kind === 'awaiting_qr_scan' && liveState.qrEventId === event.redactsEventId) {
|
||||
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
||||
}
|
||||
} else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') {
|
||||
|
|
@ -1273,7 +1402,6 @@ export function App({ bootstrap, api }: Props) {
|
|||
}
|
||||
}, [sendBare]);
|
||||
|
||||
|
||||
// In-flight guard against double-tap. The button is on the disconnected
|
||||
// screen which unmounts as soon as state advances, BUT a rapid second
|
||||
// click can fire in the microtask window between dispatch and the next
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export const EN: Record<StringKey, string> = {
|
|||
'auth-card.phone.hint': 'SMS may take up to 30 seconds.',
|
||||
'auth-card.phone.submit': 'Send code',
|
||||
'auth-card.phone.cooldown': 'Retry in {seconds}s',
|
||||
'auth-card.phone.invalid': "This doesn't look like a complete international phone number.",
|
||||
'auth-card.code.title': 'Verification code',
|
||||
'auth-card.code.label': 'SMS code',
|
||||
'auth-card.code.placeholder': '123456',
|
||||
|
|
@ -65,7 +66,8 @@ export const EN: Record<StringKey, string> = {
|
|||
'auth-card.qr.expired': 'Sign-in window expired. Tap Cancel and try again.',
|
||||
'auth-card.qr.step-1': 'Open Settings → Devices in the Telegram app.',
|
||||
'auth-card.qr.step-2': 'Tap “Link Device” and scan this QR code.',
|
||||
'auth-card.qr.step-3': 'If two-step verification is on, enter your cloud password on the next step.',
|
||||
'auth-card.qr.step-3':
|
||||
'If two-step verification is on, enter your cloud password on the next step.',
|
||||
'auth-error.invalid-code': 'Code is invalid. Please try again.',
|
||||
'auth-error.wrong-password': 'Password is incorrect. Please try again.',
|
||||
'auth-error.invalid-value': 'Value not accepted: {reason}',
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export const RU = {
|
|||
'auth-card.phone.hint': 'SMS может идти до 30 секунд.',
|
||||
'auth-card.phone.submit': 'Отправить код',
|
||||
'auth-card.phone.cooldown': 'Повтор через {seconds} сек',
|
||||
'auth-card.phone.invalid': 'Похоже, номер ещё не полный или введён с ошибкой.',
|
||||
// --- Code form ---------------------------------------------------------
|
||||
'auth-card.code.title': 'Код подтверждения',
|
||||
'auth-card.code.label': 'Код из SMS',
|
||||
|
|
|
|||
|
|
@ -469,8 +469,7 @@ body {
|
|||
.command-card-confirm-yes,
|
||||
.command-card-confirm-no,
|
||||
.btn-primary,
|
||||
.btn-text,
|
||||
.btn-icon {
|
||||
.btn-text {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
@ -573,6 +572,46 @@ body {
|
|||
box-shadow: 0 0 0 3px rgba(192, 142, 123, 0.22);
|
||||
}
|
||||
|
||||
/* Soft-warn variant for client-side phone validation. The bridge still
|
||||
* has the authoritative word (and the cooldown clears itself on
|
||||
* `invalid_value`), so we use amber rather than the harder rose tone
|
||||
* reserved for server-confirmed errors. */
|
||||
.auth-input.warn {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
.auth-input.warn:focus {
|
||||
box-shadow: 0 0 0 3px rgba(231, 178, 90, 0.22);
|
||||
}
|
||||
|
||||
/* Phone-input shell: lets us position a country-flag emoji over the
|
||||
* input's left padding without splitting the input's own background /
|
||||
* border / focus ring. The shell IS the layout flex child; the input
|
||||
* fills it. `with-flag` bumps text padding-left so the digits clear
|
||||
* the flag glyph. */
|
||||
.auth-phone-shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.auth-phone-shell .auth-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.auth-phone-shell.with-flag .auth-input {
|
||||
padding-left: 44px;
|
||||
}
|
||||
.auth-phone-flag {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.auth-input.code,
|
||||
.auth-input.password {
|
||||
font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace;
|
||||
|
|
@ -580,26 +619,73 @@ body {
|
|||
font-size: 20px;
|
||||
}
|
||||
|
||||
.password-row {
|
||||
/* Password-input shell — host for the reveal eye-button positioned over
|
||||
* the input's right padding. Mirrors `.auth-phone-shell` (left flag) and
|
||||
* follows the same UX as Vojo's main auth `PasswordInput` (eye toggle
|
||||
* embedded in the input chrome, no sibling pill that can overflow off
|
||||
* the right edge on narrow viewports). Replaces the previous
|
||||
* `.password-row` flex-pair that stacked into a full-width button on
|
||||
* mobile, see commit 8d8b39e8. */
|
||||
.auth-password-shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
.auth-password-shell .auth-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* Reserve room for the eye button so the password bullets don't run
|
||||
* under the glyph. Eye button ≈ 36 px wide + 6 px breathing room. */
|
||||
padding-right: 44px;
|
||||
}
|
||||
.btn-icon:hover {
|
||||
/* Suppress the legacy Edge / IE11 native reveal glyph (`::-ms-reveal`)
|
||||
* so it doesn't render on top of our own eye button. Chromium / WebKit
|
||||
* / Firefox ignore this pseudo — no-op on the platforms we actually
|
||||
* ship to, cheap defence for users who arrive on legacy Edge. */
|
||||
.auth-password-shell .auth-input::-ms-reveal {
|
||||
display: none;
|
||||
}
|
||||
.auth-password-eye {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.auth-password-eye:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* Hover / focus rings gated on `[data-input='mouse']` (set by main.tsx
|
||||
* from `pointerdown.pointerType`) because Capacitor Android WebView lies
|
||||
* about `(hover: hover)` on pure-touch devices — a media-query gate
|
||||
* would still let the WebView paint a stuck `:hover` on the tapped
|
||||
* button. Same reason `.command-card:hover` upstream is gated this way. */
|
||||
:root[data-input='mouse'] .auth-password-eye:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: var(--hairline);
|
||||
background: var(--hairline);
|
||||
}
|
||||
:root[data-input='mouse'] .auth-password-eye:focus-visible {
|
||||
outline: 2px solid var(--fleet);
|
||||
outline-offset: 1px;
|
||||
color: var(--text);
|
||||
}
|
||||
.auth-password-eye svg {
|
||||
/* 16 px matches folds `<Icon size="100">` used by the canonical
|
||||
* password input in `src/app/components/password-input`. */
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
|
|
|
|||
7
apps/widget-whatsapp/package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
|||
"name": "@vojo/widget-whatsapp",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"libphonenumber-js": "^1.11.7",
|
||||
"preact": "10.22.1",
|
||||
"qrcode-generator": "1.4.4"
|
||||
},
|
||||
|
|
@ -1611,6 +1612,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libphonenumber-js": {
|
||||
"version": "1.11.7",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.7.tgz",
|
||||
"integrity": "sha512-x2xON4/Qg2bRIS11KIN9yCNYUjhtiEjNyptjX0mX+pyKHecxuJVLIpfX1lq9ZD6CrC/rB+y4GBi18c6CEcUR+A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"libphonenumber-js": "^1.11.7",
|
||||
"preact": "10.22.1",
|
||||
"qrcode-generator": "1.4.4"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'p
|
|||
import type { Dispatch } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import qrcodeGenerator from 'qrcode-generator';
|
||||
// `/min` metadata (~15 KB gzip) covers all country calling codes + length
|
||||
// validation. Sufficient for «is this a plausible phone number?» — the
|
||||
// bridge does the authoritative validation server-side.
|
||||
import { AsYouType, isValidPhoneNumber } from 'libphonenumber-js/min';
|
||||
import type { WidgetBootstrap } from './bootstrap';
|
||||
import { WidgetApi, type RoomEvent } from './widget-api';
|
||||
import { createT, type T, type StringKey } from './i18n';
|
||||
|
|
@ -98,11 +102,7 @@ const LogoutIcon = () => (
|
|||
// picks up the amber tint via `currentColor` in either context.
|
||||
const WarningIcon = () => (
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<path
|
||||
d="M10 3.2 L17.5 16.5 L2.5 16.5 Z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M10 3.2 L17.5 16.5 L2.5 16.5 Z" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M10 8.5 L10 12" stroke-linecap="round" />
|
||||
<circle cx="10" cy="14.2" r="0.7" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
|
|
@ -136,8 +136,7 @@ const URL_RE = /https?:\/\/[^\s)]+/g;
|
|||
// version-bumps from `2@` to e.g. `3@`. Reject patterns are e.g.
|
||||
// «error: a,b,c,d in field» — without the digit prefix and the segment
|
||||
// length floor, the old regex would clobber that.
|
||||
const WA_QR_PAYLOAD_GLOBAL_RE =
|
||||
/\d@[A-Za-z0-9+/=@:_.\-]{7,}(?:,[A-Za-z0-9+/=@:_.\-]{8,}){3}/g;
|
||||
const WA_QR_PAYLOAD_GLOBAL_RE = /\d@[A-Za-z0-9+/=@:_.\-]{7,}(?:,[A-Za-z0-9+/=@:_.\-]{8,}){3}/g;
|
||||
const scrubLoginSecret = (body: string): string =>
|
||||
body.replace(WA_QR_PAYLOAD_GLOBAL_RE, '[redacted QR payload]');
|
||||
|
||||
|
|
@ -199,8 +198,8 @@ const localizeError = (err: LoginErrorFlag, t: T): string => {
|
|||
err.reason === 'another_device'
|
||||
? 'auth-error.external-logout.another-device'
|
||||
: err.reason === 'phone_logged_out'
|
||||
? 'auth-error.external-logout.phone-logged-out'
|
||||
: 'auth-error.external-logout.unknown';
|
||||
? 'auth-error.external-logout.phone-logged-out'
|
||||
: 'auth-error.external-logout.unknown';
|
||||
return t(subKey);
|
||||
}
|
||||
default: {
|
||||
|
|
@ -243,6 +242,44 @@ type FormProps = {
|
|||
// stop firing after.
|
||||
const PHONE_COOLDOWN_MS = 60_000;
|
||||
|
||||
// Minimum digits required before we surface an «invalid number» hint.
|
||||
// Below this the user is still typing the country prefix and the
|
||||
// formatter has nothing useful to validate.
|
||||
const PHONE_MIN_DIGITS_FOR_VALIDATION = 7;
|
||||
|
||||
// Strip every character that isn't `+` or a digit, then guarantee a
|
||||
// single leading `+` — whatsmeow's PairPhone validator fires
|
||||
// `PHONE_NUMBER_NOT_INTERNATIONAL` without it.
|
||||
const phoneToE164 = (raw: string): string => {
|
||||
const cleaned = raw.replace(/[^\d+]/g, '');
|
||||
if (cleaned.length === 0) return '';
|
||||
return cleaned.startsWith('+') ? cleaned : `+${cleaned}`;
|
||||
};
|
||||
|
||||
// AsYouType is stateful — call `.input()` on a fresh instance per render
|
||||
// so paste / mid-string edits don't desync the formatter buffer from the
|
||||
// React state.
|
||||
type PhoneFormat = { formatted: string; country: string | undefined };
|
||||
const formatPhoneInput = (raw: string): PhoneFormat => {
|
||||
const e164 = phoneToE164(raw);
|
||||
if (!e164) return { formatted: '', country: undefined };
|
||||
const formatter = new AsYouType();
|
||||
const formatted = formatter.input(e164);
|
||||
return { formatted, country: formatter.getCountry() };
|
||||
};
|
||||
|
||||
// ISO 3166-1 alpha-2 → regional-indicator-symbol emoji («RU» → 🇷🇺).
|
||||
// Windows Chrome doesn't ship the flag glyphs and falls back to the
|
||||
// two-letter code rendered as plain letters — still readable.
|
||||
const countryToFlagEmoji = (cc: string | undefined): string => {
|
||||
if (!cc || cc.length !== 2) return '';
|
||||
const codePoints = cc
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map((c) => 127397 + c.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
};
|
||||
|
||||
const useCooldownSeconds = (until: number | null): number => {
|
||||
const compute = () => (until ? Math.max(0, Math.ceil((until - Date.now()) / 1000)) : 0);
|
||||
const [seconds, setSeconds] = useState(compute);
|
||||
|
|
@ -287,6 +324,10 @@ const PhoneForm = ({
|
|||
setPhoneCooldownEnd,
|
||||
}: FormProps) => {
|
||||
const [value, setValue] = useState('');
|
||||
// Country comes straight from the AsYouType call inside `onInput` so
|
||||
// the formatter runs once per keystroke (instead of once for
|
||||
// formatting and once more in a useMemo for the flag).
|
||||
const [country, setCountry] = useState<string | undefined>(undefined);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const stillWaiting = useStillWaitingHint([submitting]);
|
||||
|
|
@ -298,14 +339,28 @@ const PhoneForm = ({
|
|||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Wire-format value (no spaces, single leading `+`) — what we send to
|
||||
// the bridge, and what libphonenumber-js validates. Both helpers are
|
||||
// cheap (regex on a 10-char string, length-table lookup) and safe to
|
||||
// recompute every render without memoisation.
|
||||
const e164 = phoneToE164(value);
|
||||
const digitsCount = e164.replace('+', '').length;
|
||||
const hasEnoughDigits = digitsCount >= PHONE_MIN_DIGITS_FOR_VALIDATION;
|
||||
// `isValidPhoneNumber` is a soft hint, not a hard gate: stale `/min`
|
||||
// metadata can reject freshly-allocated mobile pools, and whatsmeow's
|
||||
// own validator on the bridge side is authoritative. Match the
|
||||
// Stripe / Auth0 / WhatsApp Web warn-don't-block pattern.
|
||||
const showInvalidHint = hasEnoughDigits && !isValidPhoneNumber(e164);
|
||||
|
||||
const onSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || submitting || inCooldown) return;
|
||||
if (!e164 || submitting || inCooldown || !hasEnoughDigits) return;
|
||||
setSubmitting(true);
|
||||
dispatch({ kind: 'submit_phone' });
|
||||
try {
|
||||
await send(trimmed);
|
||||
// Strip visual spaces AsYouType inserted before sending — whatsmeow
|
||||
// PairPhone wants raw E.164.
|
||||
await send(e164);
|
||||
// Cooldown locks retries ONLY after the Matrix transport accepted
|
||||
// the message. If `await send` threw (network down, capability
|
||||
// race), no pairing-code request was attempted at the WhatsApp
|
||||
|
|
@ -322,10 +377,11 @@ const PhoneForm = ({
|
|||
};
|
||||
|
||||
const tone = error ? errorTone(error) : undefined;
|
||||
const submitDisabled = submitting || inCooldown || value.trim() === '';
|
||||
const submitDisabled = submitting || inCooldown || !hasEnoughDigits;
|
||||
const submitLabel = inCooldown
|
||||
? t('auth-card.phone.cooldown', { seconds: String(cooldownSeconds) })
|
||||
: t('auth-card.phone.submit');
|
||||
const flagEmoji = countryToFlagEmoji(country);
|
||||
|
||||
return (
|
||||
<form class={`auth-card${tone === 'error' ? ' error' : ''}`} onSubmit={onSubmit}>
|
||||
|
|
@ -334,31 +390,38 @@ const PhoneForm = ({
|
|||
{t('auth-card.phone.label')}
|
||||
</label>
|
||||
<div class="auth-card-row">
|
||||
<input
|
||||
id="auth-phone-input"
|
||||
ref={inputRef}
|
||||
class="auth-input"
|
||||
type="tel"
|
||||
autocomplete="tel"
|
||||
inputmode="tel"
|
||||
placeholder={t('auth-card.phone.placeholder')}
|
||||
value={value}
|
||||
onInput={(e) => {
|
||||
// Auto-prepend `+` so the user never has to remember to type
|
||||
// it — the connector's PHONE_NUMBER_NOT_INTERNATIONAL error
|
||||
// fires for anything without a leading `+` (whatsmeow
|
||||
// PairPhone's validator). Skipping locale-specific
|
||||
// formatting (8→+7 etc.) keeps the rule single-line.
|
||||
//
|
||||
// trimStart on the raw input so that a paste of « +12345…»
|
||||
// (some clipboard sources include a leading space) still
|
||||
// resolves to a single `+`, instead of producing the
|
||||
// double-prefix `+ +12345…` bridgev2 then rejects.
|
||||
const raw = (e.currentTarget as HTMLInputElement).value.trimStart();
|
||||
setValue(raw.length > 0 && !raw.startsWith('+') ? `+${raw}` : raw);
|
||||
}}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<div class={`auth-phone-shell${flagEmoji ? ' with-flag' : ''}`}>
|
||||
{flagEmoji ? (
|
||||
<span class="auth-phone-flag" aria-hidden="true">
|
||||
{flagEmoji}
|
||||
</span>
|
||||
) : null}
|
||||
<input
|
||||
id="auth-phone-input"
|
||||
ref={inputRef}
|
||||
class={`auth-input${showInvalidHint ? ' warn' : ''}`}
|
||||
type="tel"
|
||||
autocomplete="tel"
|
||||
inputmode="tel"
|
||||
placeholder={t('auth-card.phone.placeholder')}
|
||||
value={value}
|
||||
onInput={(e) => {
|
||||
// Re-format on every keystroke via a fresh AsYouType. Strips
|
||||
// non-digit / non-`+` chars (so a paste of «+1 (213) 373-4253»
|
||||
// or « +1…» normalises), auto-prepends `+` if missing
|
||||
// (whatsmeow PairPhone rejects otherwise), and groups digits
|
||||
// per country convention. Caret jumps to end on re-format —
|
||||
// acceptable for left-to-right phone entry. Country is read
|
||||
// from the same formatter call so the flag updates without
|
||||
// a second AsYouType pass.
|
||||
const raw = (e.currentTarget as HTMLInputElement).value;
|
||||
const next = formatPhoneInput(raw);
|
||||
setValue(next.formatted);
|
||||
setCountry(next.country);
|
||||
}}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" disabled={submitDisabled}>
|
||||
{submitLabel}
|
||||
</button>
|
||||
|
|
@ -367,6 +430,9 @@ const PhoneForm = ({
|
|||
</button>
|
||||
</div>
|
||||
<div class="auth-card-hint">{t('auth-card.phone.hint')}</div>
|
||||
{showInvalidHint && !error ? (
|
||||
<div class="auth-card-warn">{t('auth-card.phone.invalid')}</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div class={tone === 'warn' ? 'auth-card-warn' : 'auth-card-error'}>
|
||||
{localizeError(error, t)}
|
||||
|
|
@ -556,10 +622,7 @@ const PairingCodePanel = ({ state, t, sendCancel }: PairingCodePanelProps) => {
|
|||
}, []);
|
||||
|
||||
const elapsed = state.firstShownAt > 0 ? now - state.firstShownAt : 0;
|
||||
const remainingSeconds = Math.max(
|
||||
0,
|
||||
Math.ceil((PAIRING_CODE_TIMEOUT_MS - elapsed) / 1000)
|
||||
);
|
||||
const remainingSeconds = Math.max(0, Math.ceil((PAIRING_CODE_TIMEOUT_MS - elapsed) / 1000));
|
||||
const expired = elapsed >= PAIRING_CODE_TIMEOUT_MS && state.firstShownAt > 0;
|
||||
|
||||
return (
|
||||
|
|
@ -578,10 +641,7 @@ const PairingCodePanel = ({ state, t, sendCancel }: PairingCodePanelProps) => {
|
|||
// user-select: all on the text element keeps one-tap copy
|
||||
// working on touch devices.
|
||||
<>
|
||||
<output
|
||||
class="auth-card-pairing-code-text"
|
||||
aria-describedby="auth-pairing-code-desc"
|
||||
>
|
||||
<output class="auth-card-pairing-code-text" aria-describedby="auth-pairing-code-desc">
|
||||
{state.code}
|
||||
</output>
|
||||
<span id="auth-pairing-code-desc" class="visually-hidden">
|
||||
|
|
@ -603,9 +663,7 @@ const PairingCodePanel = ({ state, t, sendCancel }: PairingCodePanelProps) => {
|
|||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div class="auth-card-countdown expired">
|
||||
{t('auth-card.pairing-code.expired')}
|
||||
</div>
|
||||
<div class="auth-card-countdown expired">{t('auth-card.pairing-code.expired')}</div>
|
||||
)}
|
||||
<ol class="auth-card-pairing-steps">
|
||||
<li>{t('auth-card.pairing-code.step-1')}</li>
|
||||
|
|
@ -1058,10 +1116,7 @@ export function App({ bootstrap, api }: Props) {
|
|||
append({ kind: 'diag', text: t('diag.qr-issued') });
|
||||
} else if (event.kind === 'qr_redacted') {
|
||||
const liveState = stateRef.current;
|
||||
if (
|
||||
liveState.kind === 'awaiting_qr_scan' &&
|
||||
liveState.qrEventId === event.redactsEventId
|
||||
) {
|
||||
if (liveState.kind === 'awaiting_qr_scan' && liveState.qrEventId === event.redactsEventId) {
|
||||
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
||||
}
|
||||
} else if (event.kind === 'pairing_code_displayed') {
|
||||
|
|
|
|||
|
|
@ -47,11 +47,13 @@ export const EN: Record<StringKey, string> = {
|
|||
'Enter your phone number including the country code. WhatsApp will then generate an 8-character pairing code that you enter in the WhatsApp app.',
|
||||
'auth-card.phone.submit': 'Get code',
|
||||
'auth-card.phone.cooldown': 'Retry in {seconds}s',
|
||||
'auth-card.phone.invalid': "This doesn't look like a complete international phone number.",
|
||||
'auth-card.pairing-code.title': 'Enter this code in WhatsApp',
|
||||
'auth-card.pairing-code.hint':
|
||||
'Open WhatsApp on your phone and enter this code under Linked devices → Link with phone number.',
|
||||
'auth-card.pairing-code.preparing': 'Preparing the code…',
|
||||
'auth-card.pairing-code.aria': 'Pairing code for WhatsApp sign-in. Enter it in the app on your phone.',
|
||||
'auth-card.pairing-code.aria':
|
||||
'Pairing code for WhatsApp sign-in. Enter it in the app on your phone.',
|
||||
'auth-card.pairing-code.countdown': 'Time left to enter: {minutes}:{seconds}',
|
||||
'auth-card.pairing-code.expired': 'Sign-in window expired. Tap Cancel and try again.',
|
||||
'auth-card.pairing-code.step-1': 'Open WhatsApp on your phone.',
|
||||
|
|
@ -83,8 +85,7 @@ export const EN: Record<StringKey, string> = {
|
|||
'WhatsApp unlinked this device from another device. Sign in again.',
|
||||
'auth-error.external-logout.phone-logged-out':
|
||||
'You signed out of WhatsApp on the phone — all linked devices were unlinked. Sign in again.',
|
||||
'auth-error.external-logout.unknown':
|
||||
'WhatsApp dropped the session. Sign in again.',
|
||||
'auth-error.external-logout.unknown': 'WhatsApp dropped the session. Sign in again.',
|
||||
'card.logout.name': 'Sign out of WhatsApp',
|
||||
'card.logout.desc': 'End the session for this account',
|
||||
'card.logout.confirm-prompt': 'Sign out for real?',
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ export const RU = {
|
|||
'Введите номер с кодом страны. После этого WhatsApp создаст 8-символьный код — его нужно будет ввести в приложении.',
|
||||
'auth-card.phone.submit': 'Получить код',
|
||||
'auth-card.phone.cooldown': 'Повтор через {seconds} сек',
|
||||
'auth-card.phone.invalid': 'Похоже, номер ещё не полный или введён с ошибкой.',
|
||||
// --- Pairing-code form -------------------------------------------------
|
||||
'auth-card.pairing-code.title': 'Введите этот код в WhatsApp',
|
||||
'auth-card.pairing-code.hint':
|
||||
|
|
@ -104,7 +105,8 @@ export const RU = {
|
|||
'auth-card.pairing-code.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.',
|
||||
'auth-card.pairing-code.step-1': 'Откройте WhatsApp на телефоне.',
|
||||
'auth-card.pairing-code.step-2': 'Перейдите в «Настройки → Связанные устройства».',
|
||||
'auth-card.pairing-code.step-3': 'Нажмите «Привязать устройство → Привязать с помощью номера телефона».',
|
||||
'auth-card.pairing-code.step-3':
|
||||
'Нажмите «Привязать устройство → Привязать с помощью номера телефона».',
|
||||
'auth-card.pairing-code.step-4': 'Введите этот код и подтвердите вход на телефоне.',
|
||||
// --- QR form -----------------------------------------------------------
|
||||
'auth-card.qr.title': 'Вход по QR-коду',
|
||||
|
|
@ -147,8 +149,7 @@ export const RU = {
|
|||
'WhatsApp отвязал это устройство с другого устройства. Войдите снова.',
|
||||
'auth-error.external-logout.phone-logged-out':
|
||||
'Вы вышли из WhatsApp на телефоне — все связанные устройства отвязаны. Войдите снова.',
|
||||
'auth-error.external-logout.unknown':
|
||||
'WhatsApp разорвал сессию. Войдите снова.',
|
||||
'auth-error.external-logout.unknown': 'WhatsApp разорвал сессию. Войдите снова.',
|
||||
// --- Logout ------------------------------------------------------------
|
||||
'card.logout.name': 'Выйти из WhatsApp',
|
||||
'card.logout.desc': 'Завершить сеанс на этом аккаунте',
|
||||
|
|
|
|||
|
|
@ -592,6 +592,44 @@ body {
|
|||
box-shadow: 0 0 0 3px rgba(192, 142, 123, 0.22);
|
||||
}
|
||||
|
||||
/* Soft-warn for client-side phone validation. The bridge still has the
|
||||
* final say (and the cooldown self-clears on `invalid_value`), so amber
|
||||
* is the right register — server-confirmed errors keep rose. */
|
||||
.auth-input.warn {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
.auth-input.warn:focus {
|
||||
box-shadow: 0 0 0 3px rgba(231, 178, 90, 0.22);
|
||||
}
|
||||
|
||||
/* Phone-input shell — host for the country-flag emoji positioned over
|
||||
* the input's left padding (no need to split the input's background /
|
||||
* border / focus ring across two siblings). `with-flag` bumps
|
||||
* padding-left so digits clear the glyph. */
|
||||
.auth-phone-shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.auth-phone-shell .auth-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.auth-phone-shell.with-flag .auth-input {
|
||||
padding-left: 44px;
|
||||
}
|
||||
.auth-phone-flag {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Note: TG-style `.auth-input.code` / `.auth-input.password` /
|
||||
* `.password-row` / `.btn-icon` selectors were intentionally NOT
|
||||
* carried over — WhatsApp has no SMS-code form (pairing-code is
|
||||
|
|
@ -835,7 +873,7 @@ body {
|
|||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
background: rgba(212, 184, 138, 0.10);
|
||||
background: rgba(212, 184, 138, 0.1);
|
||||
border: 1px solid var(--amber);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ Any agent (Claude Code, Cursor, Codex, Windsurf, Cline, Copilot, Aider, …) wor
|
|||
| [architecture.md](architecture.md) | Stack, source layout, routing, features, state management, Matrix SDK patterns, git workflow |
|
||||
| [i18n.md](i18n.md) | i18next setup, translation patterns, Russian-language quality standards, localization progress |
|
||||
| [android.md](android.md) | Capacitor wrapper, Android build chain, edge-to-edge, Service Worker invariants, ADB workflow |
|
||||
| [electron.md](electron.md) | Electron desktop wrapper, privileged `vojo://` scheme for SW, build chain, IPC security, Windows distribution |
|
||||
| [bugs.md](bugs.md) | Known bugs & regressions |
|
||||
| [server-side.md](server-side.md) | Some configs that deployd on server |
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ npm run android:apk:debug # gradle debug build only
|
|||
|
||||
## Versioning
|
||||
|
||||
`versionCode` and `versionName` auto-derived from `package.json` version:
|
||||
`versionCode` and `versionName` are derived from `git describe --tags --match 'v*'` in [`android/app/build.gradle`](../../android/app/build.gradle), mirroring `resolveAppVersion()` in [`vite.config.js`](../../vite.config.js) so the APK's `versionName` matches `__APP_VERSION__` shown in About. Tag is `v0.2.0`; `patch` is the commit count since that tag (e.g. `v0.2.0-87-g…` → versionName `0.2.87`). When git is unavailable, falls back to `package.json` `version`.
|
||||
|
||||
```
|
||||
versionCode = major * 1_000_000 + minor * 1_000 + patch
|
||||
|
|
@ -38,8 +38,8 @@ versionCode = major * 1_000_000 + minor * 1_000 + patch
|
|||
- **Service Worker stays active.** Critical for authenticated Matrix media (MSC3916 / Matrix spec v1.11+). DO NOT disable. `resolveServiceWorkerRequests` default `true`.
|
||||
- **Edge-to-edge.** `EdgeToEdge.enable()` in `MainActivity.java` + `windowLayoutInDisplayCutoutMode: shortEdges`.
|
||||
- **External links.** Opened via `@capacitor/browser` plugin — see [`src/app/utils/capacitor.ts`](../../src/app/utils/capacitor.ts).
|
||||
- **Safe-area coloring.** `body` background-color reads `--vojo-safe-area-bg` (set on `:root` in [`src/app/styles/global.css.ts`](../../src/app/styles/global.css.ts), default `#0d0e11` = chat-list tone). [`Room.tsx`](../../src/app/features/room/Room.tsx) retunes the var to `#181a20` (chat-surface tone) while a chat is mounted so the status-bar / gesture-bar zones never show a seam against the active surface.
|
||||
- **Safe-area insets — top / left / right only on `#root`.** Bottom inset is intentionally **not** applied at `#root` so the app renders edge-to-edge under the Android gesture pill / 3-button bar / iOS home indicator (mirrors WhatsApp / Telegram). Components that anchor interactive UI at the screen bottom MUST add `padding-bottom: var(--vojo-safe-bottom)` themselves — covered: chat composer ([`RoomView.css.ts`](../../src/app/features/room/RoomView.css.ts)), PageNav inner column ([`Page.tsx`](../../src/app/components/page/Page.tsx) → catches SelfRow / WorkspaceFooter / etc.), bottom call rail ([`HorseshoeContainer.css.ts`](../../src/app/pages/HorseshoeContainer.css.ts)), AuthFooter ([`auth/styles.css.ts`](../../src/app/pages/auth/styles.css.ts)). New screens with a bottom CTA must follow this rule or the button lands behind a system 3-button nav bar.
|
||||
- **Safe-area coloring.** `body` background-color is bound to the folds theme variable `var(--oq6d070)` for consistent safe-area coloring.
|
||||
- **Safe-area insets.** Applied on `#root` (not `body`) so the theme background extends behind the system bars.
|
||||
|
||||
## VSCode tasks
|
||||
|
||||
|
|
@ -54,7 +54,188 @@ Push notification text for Android is generated from `public/locales/{en,ru}.jso
|
|||
|
||||
The task requires `node` in `PATH`. Terminal builds and CI inherit it from the shell. **macOS Android Studio with nvm/fnm:** the GUI app may not see nvm-managed node. Workaround: set `NODE_BIN=/path/to/node` in `android/gradle.properties` (the task reads it via `project.findProperty('NODE_BIN')`) or launch AS from a shell that sources your node manager (`open -a "Android Studio"`).
|
||||
|
||||
## ADB wireless workflow
|
||||
## Push polling fallback (WorkManager)
|
||||
|
||||
Users on networks that block FCM (`mtalk.google.com:5228` — corporate, school
|
||||
and government whitelist intranets, ~5% of our audience) get zero pushes from
|
||||
the primary channel. To cover them we run a WorkManager periodic poll of
|
||||
`/_matrix/client/v3/notifications` as a parallel best-effort delivery channel.
|
||||
Always on whenever push is enabled — there's no smart-detect-and-switch (FCM
|
||||
gives no client-visible delivery receipts; see
|
||||
[push_unifiedpush_phase1.md §11](../plans/push_unifiedpush_phase1.md) for the
|
||||
full rationale of why this is the only viable shape).
|
||||
|
||||
Components:
|
||||
|
||||
| Layer | File | Role |
|
||||
|---|---|---|
|
||||
| Worker | [`VojoPollWorker.java`](../../android/app/src/main/java/chat/vojo/app/VojoPollWorker.java) | Periodic fetch of `/notifications`, flattens response into Sygnal-shape `Map<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.
|
||||
2. `adb pair <ip>:<pair-port> <code>`
|
||||
|
|
|
|||
|
|
@ -11,14 +11,14 @@ npm run typecheck # tsc --noEmit
|
|||
|
||||
Build: **Vite 5.4** with vanilla-extract, WASM, PWA plugins.
|
||||
|
||||
> **Note:** `.husky/pre-commit` is currently commented out. `npm run check:eslint` is **green** (0 errors, 116 warnings — kept as warn for `no-explicit-any`/`no-non-null-assertion` policy). `npm run typecheck` still has ~32 known errors (residual project bugs after the TS 5.4 + Bundler migration that cleared ~800 module-resolution errors — see `docs/known-tech-debt-lint/`). Use `bash docs/known-tech-debt-lint/diff.sh` to verify your changes added no new typecheck errors, then `npm run build` for the green build check.
|
||||
> **Note:** `.husky/pre-commit` is enabled and runs `tsc --noEmit` + `lint-staged` (which calls `eslint --max-warnings 0` on staged JS/TS files). Both gates are zero: `npm run typecheck` and `npm run check:eslint` are green (0 errors, 0 warnings). Custom Matrix event-types (`AccountDataEvent.Vojo*`, `PoniesRoomEmotes`, `m.bridge`, `m.call.member` etc.) live in [`src/types/matrix/sdkAugmentation.d.ts`](../../src/types/matrix/sdkAugmentation.d.ts) — add new custom types there to keep `mx.getAccountData` / `mx.getStateEvent` calls type-safe.
|
||||
|
||||
## Source Layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.tsx # Entry point
|
||||
├── colors.css.ts # Custom dark-theme via createTheme(color, …); no light override (uses folds.lightTheme as-is)
|
||||
├── colors.css.ts # Vojo dark + light themes via createTheme(color, …) — both palettes are Vojo-owned, folds defaults are not used
|
||||
├── config.css.ts # fontWeight overrides
|
||||
├── client/
|
||||
│ ├── initMatrix.ts # Matrix SDK init (createClient, startClient, logout)
|
||||
|
|
@ -66,10 +66,11 @@ Router in `Router.tsx`. Each top-level tab (`/direct/`, `/space/...`, `/explore/
|
|||
→ RoomTimeline + RoomViewTyping + RoomInput
|
||||
```
|
||||
|
||||
After P3c the Stream layout is the only timeline layout. There is no DM-vs-non-DM render gate. The classification that used to drive Stream now collapses into a single **member-count** check, mirroring Element-Web's tier-2 pattern (`room.getInvitedAndJoinedMemberCount() === 2`):
|
||||
After P3c the timeline picks between two layouts via a single **member-count** check, mirroring Element-Web's tier-2 pattern (`room.getInvitedAndJoinedMemberCount() === 2`). There is no DM-vs-non-DM render gate — the classification is purely participant-count + channels-route:
|
||||
|
||||
- 1:1 rooms (member-count = 2) get peer-style header chrome (peer avatar fallback in `useRoomAvatar(room, isOneOnOne)`), the `DmCallButton`, and unconditionally hide membership/nick/avatar syslines.
|
||||
- Group rooms (member-count > 2) get the room-style header (no peer fallback), no `DmCallButton`, and respect the `hideMembershipEvents` / `hideNickAvatarEvents` user settings for syslines.
|
||||
- 1:1 rooms (member-count = 2) get peer-style header chrome (peer avatar fallback in `useRoomAvatar(room, isOneOnOne)`), the `DmCallButton`, the **Stream** timeline layout (rail + dot + bubble), and unconditionally hide membership/nick/avatar syslines.
|
||||
- Group rooms (member-count > 2) get the room-style header (no peer fallback), no `DmCallButton`, the **Channel** timeline layout (avatar + in-bubble header + bubble — same silhouette as channels), and respect the `hideMembershipEvents` / `hideNickAvatarEvents` user settings for syslines.
|
||||
- Rooms under `/channels/` always get the Channel layout regardless of member count — `channelsMode` short-circuits the 1:1 check and additionally enables channels-only filtering (thread surfacing, RTC/edit hiding). See `RoomTimeline.tsx::channelStyleLayout`.
|
||||
- Bridged Telegram puppet rooms automatically classify correctly because the gate is server-side authoritative.
|
||||
|
||||
`useAutoDirectSync` (commit 84eeac9) still round-trips `m.direct` on join — **interop only**, so other Matrix clients (Element, FluffyChat) still categorize the same room as a DM. Vojo no longer reads `m.direct` for UI classification; the `mDirectAtom` is kept alive for `useDirectRooms` ordering and other read-only consumers but its truth is no longer load-bearing for the layout.
|
||||
|
|
@ -81,12 +82,12 @@ Use `useIsOneOnOne()` from `hooks/useRoom.ts` whenever you need the 1:1 vs group
|
|||
| Dir | Purpose |
|
||||
|-----|---------|
|
||||
| `room/` | Core room view — **RoomTimeline.tsx** (~1700 LOC after P3c collapse), **RoomInput.tsx** (~691 LOC), **RoomViewHeader.tsx** (thin wrapper after P4) → **RoomViewHeaderDm.tsx** (Dawn header for every room class; 1:1 chrome via avatar fallback + peer-profile-sheet, group chrome via `N members` line; phone button three-gated per §6.8b; search/pinned/invite/leave moved into the `…` menu), MembersDrawer (suppressed for 1:1 in `Room.tsx`), MessageEditor, RoomTombstone, RoomViewTyping, CallChatView, CommandAutocomplete |
|
||||
| `room/message/` | `Message.tsx` (~1170 LOC after P3c) — renders Stream layout unconditionally for every room (1:1 DM, group DM, non-DM, bridged). No layout switch, no `isStream` gate. Renders edit/delete/react menu, mention/hashtag links, reactions viewer. The legacy `Compact`/`Bubble` layouts and `MessageLayout` enum are gone; `Modern.tsx` survives only as a card-preview layout for pin-menu / message-search / inbox. |
|
||||
| `room/message/` | `Message.tsx` (~1170 LOC after P3c) — branches between **Stream** (1:1 rooms) and **Channel** (groups + channels) layouts on the `layout` prop driven by `RoomTimeline.tsx::channelStyleLayout`. Renders edit/delete/react menu, mention/hashtag links, reactions viewer. The legacy `Compact`/`Bubble` layouts and `MessageLayout` enum are gone; `Modern.tsx` survives only as a card-preview layout for pin-menu / message-search / inbox. |
|
||||
| `room-nav/` | `RoomNavItem.tsx` (~435 LOC) — list-row component, used by Home/Direct/Spaces. Carries call-room behaviour (`useCallSession`, `useCallMembers`, `useCallStart`) |
|
||||
| `room-settings/` | Room-specific settings page |
|
||||
| `common-settings/` | Shared settings: general, members, permissions, emojis-stickers, developer-tools |
|
||||
| `space-settings/` | Space-specific settings |
|
||||
| `settings/` | User settings (general, account, notifications, devices, emojis, about, dev-tools). `MessageLayout` / `messageSpacing` / `legacyUsernameColor` were removed in P3c — Stream is now the only layout, and user-settings cleanup migration drops orphan persisted fields on first load. `hideMembershipEvents` / `hideNickAvatarEvents` survive — they still gate the group-room syslines. **Logout lives here only.** |
|
||||
| `settings/` | User settings (general, account, notifications, devices, emojis, about, dev-tools). `MessageLayout` / `messageSpacing` / `legacyUsernameColor` were removed in P3c — layout is no longer user-configurable (Stream/Channel pick automatically on member count), and the cleanup migration drops orphan persisted fields on first load. `hideMembershipEvents` / `hideNickAvatarEvents` survive — they still gate the group-room syslines. **Logout lives here only.** |
|
||||
| `lobby/` | Space/room lobby view |
|
||||
| `search/` | Global search |
|
||||
| `message-search/` | In-room message search |
|
||||
|
|
@ -120,7 +121,6 @@ Use `useIsOneOnOne()` from `hooks/useRoom.ts` whenever you need the 1:1 vs group
|
|||
- `setting-tile/` — Settings list item pattern
|
||||
- `sequence-card/`, `cutout-card/` — Card layouts
|
||||
- `uia-stages/` — User-interactive auth stages (email, captcha, token)
|
||||
- `room-intro/` — Room introduction card
|
||||
- `invite-user-prompt/`, `join-address-prompt/`, `leave-room-prompt/` — Dialogs
|
||||
- `BackRouteHandler.tsx` — Web back-button → back-stack collapse via `replace` (commit dce6be9)
|
||||
|
||||
|
|
@ -162,10 +162,69 @@ Some atoms persist to localStorage (e.g. `settings.ts`, `navToActivePath.ts`), o
|
|||
|
||||
Stock Cinny had multiple themes; vojo simplified to System / Light / Dark (commit 00935ae).
|
||||
|
||||
- `src/colors.css.ts` defines **only** `darkTheme` via `createTheme(color, darkThemeData)`. There is no separate light-theme override — light = stock `folds.lightTheme` imported as-is.
|
||||
- `src/app/hooks/useTheme.ts` selects `LightTheme` or `DarkTheme` based on `useSystemTheme` + `themeId` settings. Class-name-based application — **runtime theme switch requires page reload** (vanilla-extract is compile-time).
|
||||
- `src/colors.css.ts` defines both `darkTheme` (Dawn palette) and `lightTheme` (Vojo light palette) via `createTheme(color, …)`. The folds default `lightTheme` is no longer imported — both Vojo themes own their full token table.
|
||||
- `src/app/hooks/useTheme.ts` selects `LightTheme` or `DarkTheme` based on `useSystemTheme` + `themeId` settings. Class-name-based application — `ThemeManager` swaps the body class on `useActiveTheme` change, so runtime switching is live (no reload needed, but vanilla-extract still requires a rebuild to change the token tables themselves).
|
||||
- Folds tokens (`color.*`, `config.space`, `config.radii`, `config.borderWidth`) are read-only inside folds compiled CSS. Re-skinning colours via `createTheme()` works; re-skinning radii or spacing requires CSS overrides outside folds.
|
||||
- Brand accent in v4.11.x: `Primary.Main = #BDB6EC` (lavender) — referenced in unread-badge, focus-ring, NavLink active state, MessageBase highlight keyframe.
|
||||
- Brand accent: dark `Primary.Main = #9580ff` (Dawn lavender), light `Primary.Main = #5b6aff` (indigo) — referenced in unread-badge, focus-ring, NavLink active state, MessageBase highlight keyframe.
|
||||
- The default theme picker (Settings → General → Appearance) offers System / Light / Dark. The `dawn-redesign-v1` one-shot migration in `state/settings.ts` pins **existing** users (with a stored settings JSON) to dark on first load post-migration; brand-new users skip the migration and keep `useSystemTheme: true` so they follow the OS preference out of the box.
|
||||
|
||||
### Known follow-ups for light theme
|
||||
|
||||
The web theme switch is wired end-to-end (palette, picker, runtime body-class swap, mxid colours, prism syntax highlighting, `--vojo-safe-area-bg`, cold-start `prefers-color-scheme` fallback in `src/index.css`, dual `<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
|
||||
|
||||
|
|
@ -212,7 +271,7 @@ i18next + `react-i18next`. Translations in `public/locales/{en,ru}/*.json`, orga
|
|||
- **matrix-js-sdk 41.4** — Matrix protocol (exact pin, see `docs/plans/matrix_js_sdk_upgrade.md` for the M0..M4 bump trail)
|
||||
- **folds 2.6** — UI component library
|
||||
- **jotai 2.6** — State management
|
||||
- **vanilla-extract** — Type-safe CSS (compile-time → no runtime theme switching without reload)
|
||||
- **vanilla-extract** — Type-safe CSS. Tokens are compile-time, but theme switching is live at runtime: `ThemeManager` swaps the body class on `useActiveTheme` change and every `color.*` var reshades through the cascade. Adding new tokens still requires a rebuild.
|
||||
- **slate 0.123** — Rich text editor
|
||||
- **@tanstack/react-query 5** — Data fetching
|
||||
- **@tanstack/react-virtual 3** — Virtual scrolling — used for **list panels** (`Direct.tsx`, space lists, etc.). Note: `RoomTimeline.tsx` does NOT use this; it uses an in-house `useVirtualPaginator` + `IntersectionObserver`.
|
||||
|
|
@ -228,7 +287,7 @@ i18next + `react-i18next`. Translations in `public/locales/{en,ru}/*.json`, orga
|
|||
- Current vojo work branch: `vojo/dev`
|
||||
- Semantic-release on `dev` branch
|
||||
- CI: GitHub Actions (build, deploy, docker, netlify)
|
||||
- **Husky pre-commit is currently disabled** — `npm run typecheck` and `npm run check:eslint` do not run automatically. `check:eslint` is green; `typecheck` still has ~32 known errors. Use `bash docs/known-tech-debt-lint/diff.sh` to check your changes don't add new typecheck errors. Re-enable husky once typecheck residual is cleared.
|
||||
- **Husky pre-commit runs `tsc --noEmit` + `lint-staged` (`eslint --max-warnings 0`)** — both must be green to commit. `no-explicit-any` and `no-non-null-assertion` policy: kept as `'warn'` in `.eslintrc.cjs` but blocked by `--max-warnings 0`. When introducing one is unavoidable (matrix-js-sdk boundary, generic helper, third-party callback shape), add an inline `// eslint-disable-next-line` with a one-line justification rather than relaxing the rule.
|
||||
- **Android `versionCode` is monotonic** (commit 8064760, derived from commit count). Don't squash or rebase across release boundaries — Play store rejects downgrades
|
||||
- **Commit message style** (vojo memory): one sentence ≤25 words; no body; no Co-Authored-By trailer
|
||||
|
||||
|
|
@ -287,8 +346,8 @@ P3c examples missed initially:
|
|||
- `useDirectRooms` was used in `UserChips::MutualRoomsChip` for «split
|
||||
mutual DMs vs mutual rooms» — needed m.direct semantic, not universal-
|
||||
Direct. Mechanical rename broke the split.
|
||||
- `mDirects.has(room.roomId)` in `RoomIntro`, `RoomSettings`, `RoomProfile`,
|
||||
`SpaceSettings` for peer-avatar fallback — needed member-count semantic
|
||||
- `mDirects.has(room.roomId)` in `RoomIntro` (since removed), `RoomSettings`,
|
||||
`RoomProfile`, `SpaceSettings` for peer-avatar fallback — needed member-count semantic
|
||||
consistent with `RoomViewHeader`. Mechanical preservation of m.direct
|
||||
diverged the chrome.
|
||||
|
||||
|
|
|
|||
235
docs/ai/electron.md
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
# Electron Desktop
|
||||
|
||||
Vojo as a native desktop app (Windows .exe first, macOS/Linux later) via
|
||||
**Electron** wrapping the same Vite `dist/` that web/Capacitor consume.
|
||||
|
||||
## Why not Tauri
|
||||
|
||||
Tauri 2 uses the system WebView (WebView2 on Windows). Service Worker
|
||||
registration on custom schemes is **«won't fix»** per Tauri's own maintainer
|
||||
([tauri#13031](https://github.com/tauri-apps/tauri/issues/13031), Aug 2025).
|
||||
Vojo's SW is load-bearing for authenticated Matrix media (MSC3916). The
|
||||
official Tauri workaround (`tauri-plugin-localhost`) is itself flagged in
|
||||
Tauri's docs as «considerable security risks» — exposes a local HTTP port,
|
||||
any process on the user's machine can hit it. Unacceptable for a Matrix
|
||||
client storing E2EE keys.
|
||||
|
||||
Electron bundles its own Chromium, so SW works as in Chrome after
|
||||
`protocol.registerSchemesAsPrivileged({ allowServiceWorkers: true, ... })`.
|
||||
|
||||
Element Desktop uses the same **privileged-scheme** mechanism but with a
|
||||
different media-auth strategy: their scheme privileges set is just
|
||||
`{ standard, secure, supportFetchAPI }` (no `allowServiceWorkers`), and they
|
||||
inject the `Authorization` header for Matrix media via
|
||||
`session.defaultSession.webRequest.onBeforeSendHeaders` — Service Workers
|
||||
aren't load-bearing for them. Vojo keeps the SW because that's how the web
|
||||
build authenticates media; re-implementing the auth in a main-process hook
|
||||
just for desktop would diverge renderer code paths. Our privilege set is a
|
||||
superset of Element's by design (also Matrix, also AGPL — still our
|
||||
architectural reference for the wider Electron shell).
|
||||
|
||||
## Source layout
|
||||
|
||||
```
|
||||
electron/
|
||||
├── main.ts # main process — window, privileged scheme, IPC
|
||||
├── preload.ts # contextBridge: window.vojoElectron API
|
||||
├── tsconfig.json # CJS output, Node target — separate from src/
|
||||
└── dist-electron/ # tsc output (gitignored)
|
||||
└── main.js, preload.js # generated
|
||||
|
||||
src/app/utils/electron.ts # renderer-side: isElectron(), openExternalUrl(), setupExternalLinkHandler()
|
||||
electron-builder.json # packaging config (NSIS for Windows)
|
||||
release/ # electron-builder output (gitignored)
|
||||
```
|
||||
|
||||
## Build chain
|
||||
|
||||
```bash
|
||||
npm run electron:typecheck # tsc --noEmit -p electron/tsconfig.json
|
||||
npm run electron:build # tsc → electron/dist-electron/*.js (+ package.json override)
|
||||
npm run electron:dev # vite + electron in parallel (concurrently + wait-on)
|
||||
npm run electron:start # electron only — DEV mode (loads localhost:8080)
|
||||
npm run electron:start:prod # electron only — PROD mode (loads vojo://, requires npm run build first)
|
||||
npm run build:electron:win # native build: vite build → electron:build → electron-builder --win
|
||||
# ONLY works on Windows host (or WSL with Wine installed)
|
||||
npm run build:electron:win:docker # cross-build from Linux/WSL via electronuserland/builder:wine
|
||||
# Docker image ~3GB on first run; output in release/
|
||||
```
|
||||
|
||||
### M1 vs M2 mode toggle
|
||||
|
||||
`isDev` in [`electron/main.ts`](../../electron/main.ts) is:
|
||||
|
||||
```ts
|
||||
const isDev = !app.isPackaged && process.env.VOJO_ELECTRON_PROD !== '1';
|
||||
```
|
||||
|
||||
- **Packaged binary** (`.exe`/`.dmg`/`.AppImage`) → `isDev = false` always
|
||||
- **Unpackaged, dev**: `electron:dev` / `electron:start` → loads `http://localhost:8080`
|
||||
- **Unpackaged, prod-mode test** (`electron:start:prod`) → loads `vojo://app/index.html`
|
||||
|
||||
The prod-mode env override exists so M2 (verifying the privileged scheme + service worker actually register) can be tested locally **without** running `electron-builder` for every change. The packaged binary uses the same code path.
|
||||
|
||||
### Cross-building Windows .exe from Linux/WSL
|
||||
|
||||
`build:electron:win:docker` runs the build inside
|
||||
`electronuserland/builder:wine-mono` — the official Wine-based image.
|
||||
|
||||
**`electron-builder.json::win.signAndEditExecutable = false` is required**
|
||||
for this cross-build to finish. Without it, electron-builder invokes
|
||||
`rcedit.exe` through Wine to stamp `FileDescription`/`ProductName`/version
|
||||
metadata onto the bundled `Vojo.exe`. The Wine docker images
|
||||
(`:wine`, `:wine-mono`) ship **without Xvfb**, so rcedit hangs forever
|
||||
trying to create a Win32 window for COM apartment init — see
|
||||
[electron-userland/electron-builder#6191](https://github.com/electron-userland/electron-builder/issues/6191).
|
||||
Cost of the workaround: the `.exe` Properties dialog on Windows shows
|
||||
generic «Electron 42.1.0» metadata instead of «Vojo». Cosmetic only;
|
||||
the binary itself runs correctly. Revisit when CI moves to a real
|
||||
Windows runner (M3 GitHub Actions), where `signAndEditExecutable` can
|
||||
flip back to `true`. Three host caches are mounted in to speed up subsequent builds:
|
||||
|
||||
- `~/.cache/electron` — Electron runtime download cache (~150MB)
|
||||
- `~/.cache/electron-builder` — NSIS / app-update binaries
|
||||
- `${PWD}` — project source (read-write, output goes to `release/`)
|
||||
|
||||
This is the workflow we use locally on WSL because Wine isn't installed natively. The same artifact is produced as a native Windows build. CI (M3, future) will run `electron-builder --win` directly on `windows-latest`.
|
||||
|
||||
`electron:build` writes `electron/dist-electron/package.json` with
|
||||
`{"type":"commonjs"}` to override the root `"type":"module"` for the
|
||||
compiled `.js` files. Required because Electron's main process loader
|
||||
expects CJS unless you opt into ESM (which has separate pitfalls).
|
||||
|
||||
## Custom protocol — load-bearing
|
||||
|
||||
In production, the renderer is loaded from `vojo://app/` (trailing slash,
|
||||
NOT `vojo://app/index.html` — that was the original choice and produced a
|
||||
«Join index.html» screen because React Router parsed the `index.html`
|
||||
segment as a space alias). The `vojo` scheme is registered as privileged
|
||||
BEFORE `app.whenReady()` with `allowServiceWorkers: true`, `secure: true`,
|
||||
`standard: true`, `supportFetchAPI: true`, `corsEnabled: true`,
|
||||
`stream: true`, `codeCache: true`. This is the **one** thing that makes
|
||||
the Vojo SW work in the packaged build. **Do not change
|
||||
`loadURL(vojo://...)` to `loadFile(...)`** — SW will silently fail to
|
||||
register.
|
||||
|
||||
`protocol.handle('vojo', ...)` maps `vojo://app/<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.
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
# Известный техдолг по линтеру
|
||||
|
||||
Эта папка фиксирует **известное состояние** `npm run typecheck` в репозитории. Build при этом зелёный, prod задеплоен — это исторический технический долг, не блокер. Папка нужна чтобы при любых изменениях кода сравнивать **delta** (только то что мы добавили), не путаясь в предсуществующих ошибках.
|
||||
|
||||
После апгрейда TypeScript 4.9 → 5.4 + `moduleResolution: "Bundler"` (см. историю коммита) основная масса (~803 из 835 предыдущих ошибок) исчезла. Осталось ~32 ошибки уже про реальные баги/несоответствия типов в нашем коде, не про модульное резолвинг. `npm run check:eslint` теперь — обычный зелёный чек (0 ошибок, 116 warnings), отдельный snapshot не нужен.
|
||||
|
||||
## Состав
|
||||
|
||||
| Файл | Что |
|
||||
|---|---|
|
||||
| `typecheck.snapshot.txt` | Полный stdout `npm run typecheck`. **~61 строка, ~32 ошибки.** |
|
||||
| `diff.sh` | Скрипт сравнения: запускает текущий typecheck, сравнивает с snapshot-ом, выдаёт **только delta**. |
|
||||
|
||||
## Как пользоваться
|
||||
|
||||
```bash
|
||||
bash docs/known-tech-debt-lint/diff.sh
|
||||
```
|
||||
|
||||
На чистой ветке выводит:
|
||||
```
|
||||
=== typecheck diff vs known-tech-debt snapshot ===
|
||||
no new typecheck errors
|
||||
```
|
||||
|
||||
Если что-то сломал — выводит конкретные новые ошибки в формате `file.tsx(_,_): error TS...` (line/col маска чтобы pure-line-shift не давал phantom NEW + fixed). Реальные позиции — `npm run typecheck` напрямую. Если случайно починил предсуществующий долг — отчитается «(incidentally fixed: N)» к сведению.
|
||||
|
||||
Скрипт смотрит **working tree**, не staged-состояние. Для строгого pre-commit gate сначала apply'нуть свой stage в чистый worktree (`git stash --keep-index` + `bash diff.sh` + `git stash pop`).
|
||||
|
||||
`npm run check:eslint` запускайте напрямую — он зелёный.
|
||||
|
||||
## Что в долге (TL;DR)
|
||||
|
||||
**Typecheck (~32 ошибок):** реальные несоответствия типов. Категории:
|
||||
|
||||
- TS2345 keyof literal-union mismatch (~14): `mx.getAccountData(string)` / `mx.getStateEvent(...)` ждёт `keyof AccountDataEvents` (узкие литеральные типы), у нас передаются `AccountDataEvent.PoniesEmoteRooms`, `'m.call.member'`, `'in.cinny.spaces'` и т.п. — валидные Matrix event-types, но не в SDK-юнионе.
|
||||
- TS2345 i18next signature (~3): `t('Room.members_count', { count: millify(...) })` — `count` хочет `number`, а `millify()` возвращает `string`. На рантайме отображается корректно (в локалях нет plural-вариантов).
|
||||
- TS2345 / TS18048 `Room | undefined` / `Room | null` после `.filter((r) => !!r)` (~6): TS не пропускает truthy-фильтр без type predicate. UserChips.tsx, AddExisting.tsx, Invites.tsx, GlobalPacks.tsx. Runtime безопасно.
|
||||
- TS2345 `IContent` → `RoomMessageEventContent` (1): MessageEditor.tsx — typing gap между общим content и room-message variant.
|
||||
- TS7006 implicit `any` (6): event-handler params (`evt`, `event`, `ev`) в Message.tsx, EventReaders.tsx, UrlPreviewCard.tsx, LiveChip.tsx, MemberGlance.tsx, ReactionViewer.tsx.
|
||||
- TS2540 read-only `sandbox` (1): CallEmbed.ts — `iframe.sandbox = "..."`. Современные DOM types сделали его `DOMTokenList` read-only, но браузеры всё ещё принимают строку.
|
||||
- TS2353 unknown property `endpoint` (1): push.ts — лишнее поле в `setPusher.data`. SDK типы неполные, sygnal/UnifiedPush его читает.
|
||||
- TS2322 `(number | undefined)[]` → `number[]` (1): usePowerLevelTags.ts — то же truthy-filter narrowing.
|
||||
|
||||
Build зелёный, ESLint зелёный. Все 32 оставшихся ошибки — type-strictness без runtime-импакта (truthy-filter narrowing, узкие SDK literal-union'ы, под-типированные event-handler params, слишком строгий DOM types). Это **известный долг**, не блокер. Будущая чистка — отдельный план (создать `docs/plans/typecheck_residual_cleanup.md` когда возьмёмся).
|
||||
|
||||
## Когда обновлять snapshot
|
||||
|
||||
Когда долг будет частично разруливаться отдельной задачей — после её мерджа пересоздать snapshot:
|
||||
|
||||
```bash
|
||||
npm run typecheck > docs/known-tech-debt-lint/typecheck.snapshot.txt 2>&1
|
||||
```
|
||||
|
||||
И обновить TL;DR в этом README.
|
||||
|
||||
Когда typecheck станет зелёным — удалить эту папку целиком и включить husky pre-commit hook.
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Compare current `npm run typecheck` output to the known tech-debt snapshot.
|
||||
# Emits ONLY new errors introduced relative to the snapshot — does not dump
|
||||
# the full output, so agents can read the result without burning context.
|
||||
#
|
||||
# Comparison is line/col-insensitive: each error's `(L,C):` location is masked
|
||||
# to `(_,_):` before sorting + comm, so a pure line shift (e.g. one added/
|
||||
# removed line above the error) doesn't trigger a phantom NEW + fixed pair.
|
||||
# Re-run `npm run typecheck` to see real positions for any errors flagged here.
|
||||
#
|
||||
# Caveat: this checks the working tree, not the staged worktree. If you stage
|
||||
# a fix but leave it unstaged, or vice versa, the diff reports the working-tree
|
||||
# state. For a strict pre-commit gate, run from a clean stash-apply state.
|
||||
#
|
||||
# `npm run check:eslint` is now a normal green check (0 errors); no snapshot
|
||||
# needed there. Run it directly if you want to see warnings.
|
||||
#
|
||||
# Usage:
|
||||
# bash docs/known-tech-debt-lint/diff.sh
|
||||
|
||||
set -u
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
BASELINE_DIR="$ROOT/docs/known-tech-debt-lint"
|
||||
TC_BASE="$BASELINE_DIR/typecheck.snapshot.txt"
|
||||
|
||||
# Temp files registered for cleanup on any exit path (success, error, ^C).
|
||||
TMP_FILES=()
|
||||
cleanup() { [ "${#TMP_FILES[@]}" -gt 0 ] && rm -f "${TMP_FILES[@]}"; }
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Tracks whether any new errors were introduced. Set to 1 by run_typecheck_diff
|
||||
# when delta > 0; script exits with this code at end so CI / runbooks can use
|
||||
# the script as a gate (e.g. `bash diff.sh && echo OK`).
|
||||
NEW_ERRORS=0
|
||||
|
||||
run_typecheck_diff() {
|
||||
echo "=== typecheck diff vs known-tech-debt snapshot ==="
|
||||
local now tc_rc
|
||||
now="$(mktemp)"
|
||||
TMP_FILES+=("$now")
|
||||
( cd "$ROOT" && npm run typecheck ) >"$now" 2>&1
|
||||
tc_rc=$?
|
||||
# Sanity-check: distinguish "tsc ran and reported errors" (rc=2, expected on
|
||||
# this baseline) from "tsc/npm/node failed to run at all" (rc=other, broken
|
||||
# toolchain). Without this guard a rc=127 "tsc: not found" or rc=1 npm error
|
||||
# could produce stdout with no "error TS" lines and the diff would falsely
|
||||
# report "no new errors" / "incidentally fixed: 33".
|
||||
if [ "$tc_rc" -ne 0 ] && [ "$tc_rc" -ne 2 ] && ! grep -q "error TS" "$now"; then
|
||||
echo " ERROR: 'npm run typecheck' did not run cleanly (exit=$tc_rc):"
|
||||
sed 's/^/ /' "$now"
|
||||
NEW_ERRORS=2
|
||||
return
|
||||
fi
|
||||
# Mask `(line,col):` so a pure line shift doesn't change an error's identity.
|
||||
# We compare on the masked form; for NEW lines we display the masked form (the
|
||||
# real position is reproducible by running `npm run typecheck` directly).
|
||||
# `sort` (NOT `sort -u`): we want to preserve cardinality so that two identical
|
||||
# masked errors in the same file aren't collapsed to one — a regression that
|
||||
# adds a duplicate error would otherwise be hidden by the first occurrence.
|
||||
local mask_re='s/\([0-9]+,[0-9]+\):/(_,_):/'
|
||||
local now_masked base_masked
|
||||
now_masked="$(mktemp)"
|
||||
base_masked="$(mktemp)"
|
||||
TMP_FILES+=("$now_masked" "$base_masked")
|
||||
sed -E "$mask_re" "$now" | grep -E "error TS" | sort > "$now_masked"
|
||||
sed -E "$mask_re" "$TC_BASE" | grep -E "error TS" | sort > "$base_masked"
|
||||
|
||||
local new
|
||||
new="$(comm -23 "$now_masked" "$base_masked")"
|
||||
if [ -z "$new" ]; then
|
||||
echo " no new typecheck errors"
|
||||
else
|
||||
local count
|
||||
count="$(printf '%s\n' "$new" | wc -l)"
|
||||
echo " NEW errors: $count (line/col masked — run \`npm run typecheck\` for real positions)"
|
||||
printf '%s\n' "$new" | sed 's/^/ /'
|
||||
NEW_ERRORS=1
|
||||
fi
|
||||
local fixed
|
||||
fixed="$(comm -13 "$now_masked" "$base_masked")"
|
||||
if [ -n "$fixed" ]; then
|
||||
local fcount
|
||||
fcount="$(printf '%s\n' "$fixed" | wc -l)"
|
||||
echo " (incidentally fixed: $fcount)"
|
||||
fi
|
||||
}
|
||||
|
||||
run_typecheck_diff
|
||||
|
||||
exit "$NEW_ERRORS"
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
|
||||
> vojo@4.11.1 typecheck
|
||||
> tsc --noEmit
|
||||
|
||||
src/app/components/event-readers/EventReaders.tsx(82,31): error TS7006: Parameter 'event' implicitly has an 'any' type.
|
||||
src/app/components/image-pack-view/RoomImagePack.tsx(47,9): error TS2345: Argument of type 'StateEvent.PoniesRoomEmotes' is not assignable to parameter of type 'keyof StateEvents'.
|
||||
src/app/components/image-pack-view/UserImagePack.tsx(16,31): error TS2345: Argument of type 'AccountDataEvent.PoniesUserEmotes' is not assignable to parameter of type 'keyof AccountDataEvents'.
|
||||
src/app/components/room-card/RoomCard.tsx(259,18): error TS2345: Argument of type '["Explore.members_count", { count: string; }]' is not assignable to parameter of type '[key: string | string[], options: TOptionsBase & $Dictionary & { defaultValue: string; }] | [key: string | string[], defaultValue: string, options?: (TOptionsBase & $Dictionary) | undefined] | [key: ...]'.
|
||||
Type '["Explore.members_count", { count: string; }]' is not assignable to type '[key: "Explore.members_count" | "Explore.members_count"[], options?: (TOptionsBase & $Dictionary) | undefined]'.
|
||||
Type at position 1 in source is not compatible with type at position 1 in target.
|
||||
Type '{ count: string; }' is not assignable to type 'TOptionsBase & $Dictionary'.
|
||||
Type '{ count: string; }' is not assignable to type 'TOptionsBase'.
|
||||
Types of property 'count' are incompatible.
|
||||
Type 'string' is not assignable to type 'number'.
|
||||
src/app/components/url-preview/UrlPreviewCard.tsx(57,27): error TS7006: Parameter 'evt' implicitly has an 'any' type.
|
||||
src/app/components/user-profile/UserChips.tsx(271,13): error TS18048: 'room' is possibly 'undefined'.
|
||||
src/app/components/user-profile/UserChips.tsx(272,28): error TS2345: Argument of type 'Room | undefined' is not assignable to parameter of type 'Room'.
|
||||
Type 'undefined' is not assignable to type 'Room'.
|
||||
src/app/components/user-profile/UserChips.tsx(275,26): error TS18048: 'room' is possibly 'undefined'.
|
||||
src/app/components/user-profile/UserChips.tsx(276,29): error TS2345: Argument of type 'Room | undefined' is not assignable to parameter of type 'Room'.
|
||||
Type 'undefined' is not assignable to type 'Room'.
|
||||
src/app/components/user-profile/UserChips.tsx(279,25): error TS2345: Argument of type 'Room | undefined' is not assignable to parameter of type 'Room'.
|
||||
Type 'undefined' is not assignable to type 'Room'.
|
||||
src/app/features/add-existing/AddExisting.tsx(168,18): error TS2345: Argument of type '(Room | undefined)[]' is not assignable to parameter of type 'Room[]'.
|
||||
Type 'Room | undefined' is not assignable to type 'Room'.
|
||||
Type 'undefined' is not assignable to type 'Room'.
|
||||
src/app/features/call-status/LiveChip.tsx(90,35): error TS7006: Parameter 'evt' implicitly has an 'any' type.
|
||||
src/app/features/call-status/MemberGlance.tsx(49,23): error TS7006: Parameter 'evt' implicitly has an 'any' type.
|
||||
src/app/features/common-settings/general/RoomJoinRules.tsx(92,52): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
|
||||
Type 'undefined' is not assignable to type 'string'.
|
||||
src/app/features/room/message/Message.tsx(860,31): error TS7006: Parameter 'ev' implicitly has an 'any' type.
|
||||
src/app/features/room/message/MessageEditor.tsx(156,39): error TS2345: Argument of type 'IContent' is not assignable to parameter of type 'RoomMessageEventContent'.
|
||||
Type 'IContent' is not assignable to type 'BaseTimelineEvent & Without<(Without<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; }'.
|
||||
25
electron-builder.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"appId": "chat.vojo.desktop",
|
||||
"productName": "Vojo",
|
||||
"asar": true,
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": ["dist/**/*", "electron/dist-electron/**/*", "package.json"],
|
||||
"extraMetadata": {
|
||||
"main": "electron/dist-electron/main.js"
|
||||
},
|
||||
"win": {
|
||||
"target": ["zip"],
|
||||
"artifactName": "Vojo-${version}-win-${arch}.${ext}",
|
||||
"signAndEditExecutable": false
|
||||
},
|
||||
"mac": {
|
||||
"target": ["dmg"],
|
||||
"category": "public.app-category.social-networking"
|
||||
},
|
||||
"linux": {
|
||||
"target": ["AppImage", "deb"],
|
||||
"category": "Network"
|
||||
}
|
||||
}
|
||||
264
electron/main.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import { app, BrowserWindow, protocol, net, shell, ipcMain } from 'electron';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { existsSync, promises as fsp } from 'node:fs';
|
||||
|
||||
// Dev-mode loads from Vite dev-server (http://localhost:8080) so HMR works.
|
||||
// Prod-mode loads from in-process custom scheme `vojo://app/index.html`.
|
||||
// `VOJO_ELECTRON_PROD=1` forces prod-mode in an un-packaged binary so the
|
||||
// scheme + service-worker path can be validated without re-running
|
||||
// electron-builder. The packaged app sets `app.isPackaged === true`.
|
||||
const isDev = !app.isPackaged && process.env.VOJO_ELECTRON_PROD !== '1';
|
||||
const DEV_URL = 'http://localhost:8080';
|
||||
const APP_SCHEME = 'vojo';
|
||||
const APP_HOST = 'app';
|
||||
|
||||
// Extensions that look like real web assets; for these, a missing file is a
|
||||
// genuine 404. Anything else (including Matrix-flavoured `roomId/userId`
|
||||
// segments like `!foo:vojo.chat` whose `path.extname` returns `.chat`) is
|
||||
// treated as a SPA route and falls back to `index.html`. The allowlist is
|
||||
// intentionally narrow — extend only when adding a new bundled asset kind.
|
||||
const WEB_ASSET_EXTENSIONS = new Set([
|
||||
'.js',
|
||||
'.mjs',
|
||||
'.cjs',
|
||||
'.css',
|
||||
'.html',
|
||||
'.htm',
|
||||
'.map',
|
||||
'.json',
|
||||
'.txt',
|
||||
'.xml',
|
||||
'.svg',
|
||||
'.ico',
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
'.webp',
|
||||
'.avif',
|
||||
'.woff',
|
||||
'.woff2',
|
||||
'.ttf',
|
||||
'.otf',
|
||||
'.wasm',
|
||||
]);
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: APP_SCHEME,
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
allowServiceWorkers: true,
|
||||
corsEnabled: true,
|
||||
stream: true,
|
||||
codeCache: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const ALLOWED_EXTERNAL_SCHEMES = new Set(['http:', 'https:', 'mailto:']);
|
||||
|
||||
const isSafeExternal = (raw: unknown): raw is string => {
|
||||
if (typeof raw !== 'string' || raw.length > 8 * 1024) return false;
|
||||
try {
|
||||
return ALLOWED_EXTERNAL_SCHEMES.has(new URL(raw).protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const distDir = path.resolve(__dirname, '..', '..', 'dist');
|
||||
|
||||
// React Router defaults to BrowserRouter against `window.location.pathname`,
|
||||
// which for `vojo://app/...` would treat URL segments as routes (e.g.
|
||||
// `vojo://app/index.html` resolved as a space alias `index.html`). Vojo
|
||||
// already supports HashRouter via `clientConfig.hashRouter.enabled` in
|
||||
// `config.json` — we override that to `true` for the Electron renderer so
|
||||
// every route lives in `window.location.hash`, leaving the pathname stable
|
||||
// for the protocol handler. The web/Android bundles see the unmodified
|
||||
// config (hash router off).
|
||||
const patchConfigForElectron = (raw: string): string => {
|
||||
try {
|
||||
const config: Record<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();
|
||||
});
|
||||
6
electron/preload.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
contextBridge.exposeInMainWorld('vojoElectron', {
|
||||
platform: process.platform,
|
||||
openExternal: (url: string): Promise<void> => ipcRenderer.invoke('vojo:open-external', url),
|
||||
});
|
||||
18
electron/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist-electron",
|
||||
"rootDir": ".",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"sourceMap": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
|
|
@ -23,7 +23,8 @@
|
|||
property="og:description"
|
||||
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="theme-color" content="#0d0e11" media="(prefers-color-scheme: dark)" />
|
||||
<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" />
|
||||
|
||||
|
|
|
|||
2497
package-lock.json
generated
34
package.json
|
|
@ -1,35 +1,45 @@
|
|||
{
|
||||
"name": "vojo",
|
||||
"version": "4.11.1",
|
||||
"description": "Yet another matrix client",
|
||||
"version": "0.2.0",
|
||||
"description": "Vojo client for matrix server",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "npm run check:eslint && npm run check:prettier",
|
||||
"check:eslint": "eslint src",
|
||||
"check:eslint": "eslint --max-warnings 0 src",
|
||||
"check:prettier": "prettier --check .",
|
||||
"fix:prettier": "prettier --write .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"gen:push-strings": "node scripts/gen-push-strings.mjs",
|
||||
"android:sync": "npx cap sync android",
|
||||
"android:open": "npx cap open android",
|
||||
"android:strip-sourcemaps": "find dist -name '*.map' -delete",
|
||||
"android:apk:debug": "cd android && ./gradlew assembleDebug",
|
||||
"android:apk:release": "cd android && ./gradlew assembleRelease",
|
||||
"android:aab:release": "cd android && ./gradlew bundleRelease",
|
||||
"build:android:debug": "npm run build && npm run android:sync && npm run android:apk:debug",
|
||||
"build:android:release": "npm run build && npm run android:sync && npm run android:apk:release",
|
||||
"build:android:aab": "npm run build && npm run android:sync && npm run android:aab:release",
|
||||
"build:android:debug": "npm run build && npm run android:strip-sourcemaps && npm run android:sync && npm run android:apk:debug",
|
||||
"build:android:release": "npm run build && npm run android:strip-sourcemaps && npm run android:sync && npm run android:apk:release",
|
||||
"build:android:aab": "npm run build && npm run android:strip-sourcemaps && npm run android:sync && npm run android:aab:release",
|
||||
"electron:typecheck": "tsc --noEmit -p electron/tsconfig.json",
|
||||
"electron:build": "tsc -p electron/tsconfig.json && node -e \"require('fs').writeFileSync('electron/dist-electron/package.json', JSON.stringify({type:'commonjs'}))\"",
|
||||
"electron:dev": "concurrently -k -n vite,electron -c blue,green \"npm:start\" \"wait-on tcp:8080 && npm run electron:build && electron electron/dist-electron/main.js\"",
|
||||
"electron:start": "electron electron/dist-electron/main.js",
|
||||
"electron:start:prod": "cross-env VOJO_ELECTRON_PROD=1 electron electron/dist-electron/main.js",
|
||||
"build:electron:win": "npm run build && npm run electron:build && electron-builder --win",
|
||||
"build:electron:win:docker": "docker run --rm -v ${PWD}:/project -v ~/.cache/electron:/root/.cache/electron -v ~/.cache/electron-builder:/root/.cache/electron-builder -w /project electronuserland/builder:wine-mono /bin/bash -c \"trap 'chown -R 1000:1000 /project/dist /project/release /project/electron/dist-electron 2>/dev/null || true' EXIT; npm run build && npm run electron:build && npx electron-builder --win\"",
|
||||
"build:electron:mac": "npm run build && npm run electron:build && electron-builder --mac",
|
||||
"build:electron:linux": "npm run build && npm run electron:build && electron-builder --linux",
|
||||
"prepare": "husky install",
|
||||
"commit": "git-cz"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,jsx,mjs,cjs}": "eslint",
|
||||
"*.{ts,tsx,js,jsx,mjs,cjs}": "eslint --max-warnings 0",
|
||||
"*": "prettier --ignore-unknown --write"
|
||||
},
|
||||
"config": {
|
||||
|
|
@ -66,7 +76,6 @@
|
|||
"browser-encrypt-attachment": "0.3.0",
|
||||
"chroma-js": "3.1.2",
|
||||
"classnames": "2.3.2",
|
||||
"dateformat": "5.0.3",
|
||||
"dayjs": "1.11.10",
|
||||
"domhandler": "5.0.3",
|
||||
"emojibase": "15.3.1",
|
||||
|
|
@ -125,7 +134,11 @@
|
|||
"@typescript-eslint/parser": "7.18.0",
|
||||
"@vitejs/plugin-react": "4.2.0",
|
||||
"buffer": "6.0.3",
|
||||
"concurrently": "9.2.1",
|
||||
"cross-env": "7.0.3",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
"electron": "42.1.0",
|
||||
"electron-builder": "26.8.1",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-airbnb": "19.0.4",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
|
|
@ -140,6 +153,7 @@
|
|||
"vite": "5.4.19",
|
||||
"vite-plugin-pwa": "0.20.5",
|
||||
"vite-plugin-static-copy": "1.0.4",
|
||||
"vite-plugin-top-level-await": "1.4.4"
|
||||
"vite-plugin-top-level-await": "1.4.4",
|
||||
"wait-on": "9.0.10"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
388
public/delete-account.html
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
<!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>
|
||||
|
|
@ -90,6 +90,8 @@
|
|||
"menu_emojis_stickers": "Emojis & Stickers",
|
||||
"menu_developer_tools": "Developer Tools",
|
||||
"menu_about": "About",
|
||||
"drag_to_close": "Drag down to close",
|
||||
"close": "Close",
|
||||
"logout": "Logout",
|
||||
"logout_confirm": "You're about to log out. Are you sure?",
|
||||
"logout_failed": "Failed to logout! {{message}}",
|
||||
|
|
@ -97,7 +99,6 @@
|
|||
"logout_unverified_desc": "Verify your device before logging out to save your encrypted messages.",
|
||||
"logout_alert_title": "Alert",
|
||||
"logout_alert_desc": "Enable device verification or export your encrypted data from settings to avoid losing access to your messages.",
|
||||
|
||||
"general_title": "General",
|
||||
"appearance": "Appearance",
|
||||
"system_theme": "System",
|
||||
|
|
@ -121,7 +122,6 @@
|
|||
"url_preview": "Url Preview",
|
||||
"url_preview_encrypted": "Url Preview in Encrypted Room",
|
||||
"show_hidden_events": "Show Hidden Events",
|
||||
|
||||
"account_title": "Account",
|
||||
"profile": "Profile",
|
||||
"avatar": "Avatar",
|
||||
|
|
@ -140,7 +140,6 @@
|
|||
"select_user_desc": "Prevent receiving messages or invites from user by adding their userId.",
|
||||
"block": "Block",
|
||||
"users": "Users",
|
||||
|
||||
"notifications_title": "Notifications",
|
||||
"block_messages": "Block Messages",
|
||||
"block_messages_moved": "This option has been moved to \"Account > Block Users\" section.",
|
||||
|
|
@ -185,7 +184,6 @@
|
|||
"notif_disable": "Disable",
|
||||
"notif_silent": "Notify Silent",
|
||||
"notif_loud": "Notify Loud",
|
||||
|
||||
"devices_title": "Devices",
|
||||
"security": "Security",
|
||||
"device_verification": "Device Verification",
|
||||
|
|
@ -228,7 +226,6 @@
|
|||
"verify_other_desc": "Verify device identity and grant access to encrypted messages.",
|
||||
"verify": "Verify",
|
||||
"reset": "Reset",
|
||||
|
||||
"local_backup": "Local Backup",
|
||||
"new_password": "New Password",
|
||||
"confirm_password": "Confirm Password",
|
||||
|
|
@ -242,7 +239,6 @@
|
|||
"import_desc": "Load password protected copy of encryption data from device to decrypt your messages.",
|
||||
"import": "Import",
|
||||
"decrypt": "Decrypt",
|
||||
|
||||
"emojis_stickers_title": "Emojis & Stickers",
|
||||
"default_pack": "Default Pack",
|
||||
"unknown": "Unknown",
|
||||
|
|
@ -252,7 +248,6 @@
|
|||
"select_pack_desc": "Pick emoji and sticker packs from rooms to use globally.",
|
||||
"select": "Select",
|
||||
"room_packs": "Room Packs",
|
||||
"close": "Close",
|
||||
"select_all": "Select All",
|
||||
"unselect_all": "Unselect All",
|
||||
"no_packs": "No Packs",
|
||||
|
|
@ -260,15 +255,17 @@
|
|||
"apply_error": "Failed to apply changes! Please try again.",
|
||||
"apply_ready": "Changes saved! Apply when ready.",
|
||||
"apply_changes": "Apply Changes",
|
||||
|
||||
"about_title": "About",
|
||||
"about_tagline": "Yet another matrix client.",
|
||||
"options": "Options",
|
||||
"clear_cache_title": "Clear Cache & Reload",
|
||||
"clear_cache_desc": "Clear all your locally stored data and reload from server.",
|
||||
"clear_cache": "Clear Cache",
|
||||
"legal": "Legal",
|
||||
"privacy_policy_title": "Privacy Policy",
|
||||
"privacy_policy_desc": "How your data is handled.",
|
||||
"privacy_policy_open": "Open",
|
||||
"credits": "Credits",
|
||||
|
||||
"devtools_title": "Developer Tools",
|
||||
"enable_devtools": "Enable Developer Tools",
|
||||
"access_token": "Access Token",
|
||||
|
|
@ -372,7 +369,7 @@
|
|||
"create_chat": "Create Chat",
|
||||
"create_chat_subtitle": "Start a private, encrypted chat by entering a username.",
|
||||
"start_first_chat": "Start a chat",
|
||||
"segment_dm": "DM",
|
||||
"segment_dm": "Direct",
|
||||
"segment_channels": "Channels",
|
||||
"segment_bots": "Robots",
|
||||
"self_row_label": "You",
|
||||
|
|
@ -386,7 +383,8 @@
|
|||
"e2e_encryption": "End-to-End Encryption",
|
||||
"e2e_encryption_desc": "Once this feature is enabled, it can't be disabled after the room is created.",
|
||||
"rate_limited": "Server rate-limited your request for {{minutes}} minutes!",
|
||||
"create": "Create"
|
||||
"create": "Create",
|
||||
"close": "Close"
|
||||
},
|
||||
"Channels": {
|
||||
"no_spaces_title": "No communities yet",
|
||||
|
|
@ -396,7 +394,12 @@
|
|||
"pick_channel_desc": "Choose a channel from the list on the left to start reading.",
|
||||
"root_category": "Channels",
|
||||
"workspace_switcher_aria": "Switch community",
|
||||
"workspace_switcher_active_marker": "Current"
|
||||
"workspace_switcher_create_space": "Create community",
|
||||
"workspace_switcher_drag_to_close": "Drag down to close",
|
||||
"workspace_switcher_member_count_one": "{{count}} member",
|
||||
"workspace_switcher_member_count_other": "{{count}} members",
|
||||
"workspace_footer_subtitle": "Community",
|
||||
"create_channel": "Create channel"
|
||||
},
|
||||
"Call": {
|
||||
"start": "Start call",
|
||||
|
|
@ -421,7 +424,19 @@
|
|||
"in_call": "In call",
|
||||
"in_call_count": "{{count}} in call",
|
||||
"connecting": "Connecting…",
|
||||
"open_call_room": "Open call room"
|
||||
"open_call_room": "Open call room",
|
||||
"bubble_outgoing": "Outgoing call",
|
||||
"bubble_incoming": "Incoming call",
|
||||
"bubble_missed": "Missed call",
|
||||
"bubble_cancelled": "Cancelled call",
|
||||
"bubble_ongoing": "Ongoing call",
|
||||
"bubble_in_progress": "In progress…",
|
||||
"bubble_missed_count_one": "{{count}} missed call",
|
||||
"bubble_missed_count_other": "{{count}} missed calls",
|
||||
"bubble_cancelled_count_one": "{{count}} cancelled call",
|
||||
"bubble_cancelled_count_other": "{{count}} cancelled calls",
|
||||
"duration_minutes_seconds": "{{minutes}} min {{seconds}} sec",
|
||||
"duration_seconds": "{{seconds}} sec"
|
||||
},
|
||||
"Room": {
|
||||
"drag_to_close": "Drag up to close",
|
||||
|
|
@ -433,7 +448,6 @@
|
|||
"jump_to_latest": "Jump to Latest",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
|
||||
"view_reactions": "View Reactions",
|
||||
"read_receipts": "Read Receipts",
|
||||
"view_source": "View Source",
|
||||
|
|
@ -445,7 +459,6 @@
|
|||
"reply": "Reply",
|
||||
"reply_in_thread": "Reply in Thread",
|
||||
"edit_message": "Edit Message",
|
||||
|
||||
"delete_message": "Delete Message",
|
||||
"delete_confirm": "This action is irreversible! Are you sure that you want to delete this message?",
|
||||
"reason": "Reason",
|
||||
|
|
@ -453,7 +466,6 @@
|
|||
"delete_error": "Failed to delete message! Please try again.",
|
||||
"deleting": "Deleting...",
|
||||
"delete": "Delete",
|
||||
|
||||
"report_message": "Report Message",
|
||||
"report_desc": "Report this message to server, which may then notify the appropriate people to take action.",
|
||||
"report_reason": "Reason",
|
||||
|
|
@ -462,18 +474,20 @@
|
|||
"reporting": "Reporting...",
|
||||
"report": "Report",
|
||||
"no_reason": "No reason provided",
|
||||
|
||||
"is_typing": " is typing...",
|
||||
"and": " and ",
|
||||
"are_typing": " are typing...",
|
||||
"others_count": "{{count}} others",
|
||||
"drop_typing": "Dismiss typing indicator",
|
||||
|
||||
"members": "Members",
|
||||
"members_count_one": "{{formattedCount}} Member",
|
||||
"members_count_other": "{{formattedCount}} Members",
|
||||
"hide_members": "Hide Members",
|
||||
"show_members": "Show Members",
|
||||
"members_pane_title": "Members",
|
||||
"members_sheet_title_one": "{{formattedCount}} member",
|
||||
"members_sheet_title_other": "{{formattedCount}} members",
|
||||
"open_members_of": "Open members of {{name}}",
|
||||
"more_options": "More Options",
|
||||
"close": "Close",
|
||||
"search": "Search",
|
||||
|
|
@ -485,23 +499,30 @@
|
|||
"room_settings": "Room Settings",
|
||||
"jump_to_time": "Jump to Time",
|
||||
"leave_room": "Leave Room",
|
||||
|
||||
"send_message": "Send a message...",
|
||||
"send_message_alt_1": "One line or many...",
|
||||
"send_message_alt_2": "Write something right now...",
|
||||
"send_message_alt_3": "Don't keep me waiting, type...",
|
||||
"send_message_alt_4": "This line won't fill itself...",
|
||||
"send_message_alt_5": "So... what's it gonna be?..",
|
||||
"send_message_alt_6": "Nobody reads placeholders. But you did...",
|
||||
"send_message_alt_7": "Letters here, please...",
|
||||
"send_message_alt_8": "You stare at the placeholder. The placeholder stares back...",
|
||||
"send_message_alt_9": "Congrats, you're in the 3% who read placeholders...",
|
||||
"send_message_alt_10": "Fine, I'll wait... and wait...",
|
||||
"send_message_alt_11": "After you...",
|
||||
"drop_files": "Drop Files in \"{{name}}\"",
|
||||
"drag_drop_desc": "Drag and drop files here or click for selection dialog",
|
||||
|
||||
"pinned_messages": "Pinned Messages",
|
||||
"no_pinned_messages": "No Pinned Messages",
|
||||
"no_pinned_messages_desc": "Users with sufficient permissions can pin messages from the message context menu.",
|
||||
"open": "Open",
|
||||
"failed_to_load": "Failed to load message!",
|
||||
|
||||
"time_label": "Time",
|
||||
"date_label": "Date",
|
||||
"preset": "Preset",
|
||||
"beginning": "Beginning",
|
||||
"open_timeline": "Open Timeline",
|
||||
|
||||
"message_deleted": "This message has been deleted",
|
||||
"message_deleted_reason": "This message has been deleted. {{reason}}",
|
||||
"unsupported_message": "Unsupported message",
|
||||
|
|
@ -511,7 +532,6 @@
|
|||
"broken_message": "Broken message",
|
||||
"empty_message": "Empty message",
|
||||
"edited": " (edited)",
|
||||
|
||||
"thread_caption": "Thread",
|
||||
"thread_in_channel_subtitle": "in #{{channel}}",
|
||||
"thread_close": "Close thread",
|
||||
|
|
@ -528,18 +548,24 @@
|
|||
"thread_summary_highlight_one": "{{count}} mention",
|
||||
"thread_summary_highlight_other": "{{count}} mentions",
|
||||
"no_post_permission": "You do not have permission to post in this room",
|
||||
|
||||
"conversation_beginning": "This is the beginning of conversation.",
|
||||
"created_by": "Created by <bold>@{{creator}}</bold> on {{date}} {{time}}",
|
||||
"invite_member": "Invite Member",
|
||||
"open_old_room": "Open Old Room",
|
||||
"join_old_room": "Join Old Room",
|
||||
"empty_dm": "The hardest part is the first message.",
|
||||
"empty_dm_alt_1": "You have to start somewhere.",
|
||||
"empty_dm_alt_2": "Someone has to go first.",
|
||||
"empty_dm_alt_3": "A blank canvas. Not a single typo — yet.",
|
||||
"empty_group": "The group is set up. Who goes first?",
|
||||
"empty_group_alt_1": "No one has said anything here yet.",
|
||||
"empty_group_alt_2": "The calm before the first message.",
|
||||
"empty_group_alt_3": "Everyone's here — go ahead.",
|
||||
"empty_bridge": "Messages here travel through the {{network}} bridge.",
|
||||
"empty_bridge_alt_1": "This chat is linked to {{network}}.",
|
||||
"empty_bridge_alt_2": "Your contact is writing from {{network}}.",
|
||||
"empty_bridge_generic": "Messages here travel through a bridge.",
|
||||
"empty_encrypted": "Messages are protected with end-to-end encryption.",
|
||||
"leave_room_title": "Leave Room",
|
||||
"leave_room_confirm": "Are you sure you want to leave this room?",
|
||||
"leave_room_error": "Failed to leave room! {{error}}",
|
||||
"leaving": "Leaving...",
|
||||
"leave": "Leave",
|
||||
|
||||
"member_broken": "Broken membership event",
|
||||
"member_accepted_knock": "<bold>{{sender}}</bold> accepted <bold>{{user}}</bold>'s join request",
|
||||
"member_invited": "<bold>{{sender}}</bold> invited <bold>{{user}}</bold>",
|
||||
|
|
@ -557,10 +583,7 @@
|
|||
"member_name_removed": "<bold>{{user}}</bold> removed their display name",
|
||||
"member_avatar_changed": "<bold>{{user}}</bold> changed their avatar",
|
||||
"member_avatar_removed": "<bold>{{user}}</bold> removed their avatar",
|
||||
"member_no_change": "Membership event with no changes",
|
||||
|
||||
"member_ended_call": "<bold>{{user}}</bold> ended the call",
|
||||
"member_joined_call": "<bold>{{user}}</bold> joined the call"
|
||||
"member_no_change": "Membership event with no changes"
|
||||
},
|
||||
"Inbox": {
|
||||
"invite_title": "Invite",
|
||||
|
|
@ -568,19 +591,15 @@
|
|||
"user_id_placeholder": "@username:server",
|
||||
"reason_optional": "Reason (Optional)",
|
||||
"invite_button": "Invite",
|
||||
|
||||
"notif_default": "Default",
|
||||
"notif_all_messages": "All Messages",
|
||||
"notif_mentions_keywords": "Mention & Keywords",
|
||||
"notif_mute": "Mute",
|
||||
|
||||
"unverified_device": "Unverified Device",
|
||||
"unverified_devices": "Unverified Devices"
|
||||
},
|
||||
|
||||
"Explore": {
|
||||
"explore_community": "Explore Community",
|
||||
|
||||
"add_server": "Add Server",
|
||||
"add_server_desc": "Add server name to explore public communities.",
|
||||
"server_name": "Server Name",
|
||||
|
|
@ -588,13 +607,11 @@
|
|||
"view": "View",
|
||||
"featured": "Featured",
|
||||
"servers": "Servers",
|
||||
|
||||
"featured_by_client": "Featured by Client",
|
||||
"featured_by_client_desc": "Public rooms and spaces hand-picked by this client.",
|
||||
"featured_spaces": "Featured Spaces",
|
||||
"featured_rooms": "Featured Rooms",
|
||||
"no_featured": "No featured rooms or spaces yet.",
|
||||
|
||||
"search": "Search",
|
||||
"search_placeholder": "Search for keyword",
|
||||
"clear": "Clear",
|
||||
|
|
@ -613,9 +630,9 @@
|
|||
"previous_page": "Previous Page",
|
||||
"next_page": "Next Page",
|
||||
"no_communities": "No communities found!",
|
||||
|
||||
"space_badge": "Space",
|
||||
"members_count": "{{count}} Members",
|
||||
"members_count_one": "{{formattedCount}} Member",
|
||||
"members_count_other": "{{formattedCount}} Members",
|
||||
"join": "Join",
|
||||
"joining": "Joining",
|
||||
"retry": "Retry",
|
||||
|
|
@ -624,7 +641,6 @@
|
|||
"view_error": "View Error",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
|
||||
"Create": {
|
||||
"add_space": "Add Space",
|
||||
"create_space": "Create Space",
|
||||
|
|
@ -632,7 +648,6 @@
|
|||
"join_with_address": "Join with Address",
|
||||
"join_with_address_desc": "Join an existing community.",
|
||||
"new_space": "New Space",
|
||||
|
||||
"access": "Access",
|
||||
"name": "Name",
|
||||
"topic_optional": "Topic (Optional)",
|
||||
|
|
@ -644,47 +659,38 @@
|
|||
"allow_federation_desc": "Users from other servers can join.",
|
||||
"create": "Create",
|
||||
"rate_limited": "Server rate-limited your request for {{minutes}} minutes!",
|
||||
|
||||
"access_restricted": "Restricted",
|
||||
"access_restricted_desc": "Only members of the parent space can join.",
|
||||
"access_private": "Private",
|
||||
"access_private_desc": "Only people with an invite can join.",
|
||||
"access_public": "Public",
|
||||
"access_public_desc": "Anyone with the address can join.",
|
||||
|
||||
"address_optional": "Address (Optional)",
|
||||
"address_hint": "Pick a unique address to make it discoverable.",
|
||||
"address_taken": "This address is already taken. Please choose a different one.",
|
||||
|
||||
"founders": "Founders",
|
||||
"founders_desc": "Privileged users assigned during creation. They have elevated control and can only be changed during an upgrade.",
|
||||
"enter": "Enter",
|
||||
"no_suggestions": "No Suggestions",
|
||||
"no_suggestions_desc": "Enter a user ID and press Enter.",
|
||||
|
||||
"version": "Version",
|
||||
"versions": "Versions",
|
||||
|
||||
"chat_room": "Chat Room",
|
||||
"chat_room_desc": "Messages, photos, and videos.",
|
||||
"voice_room": "Voice Room",
|
||||
"voice_room_desc": "Live audio and video conversations.",
|
||||
|
||||
"new_chat_room": "New Chat Room",
|
||||
"new_voice_room": "New Voice Room",
|
||||
|
||||
"existing_space": "Existing Space",
|
||||
"add_room": "Add Room",
|
||||
"existing_room": "Existing Room"
|
||||
},
|
||||
|
||||
"RoomSettings": {
|
||||
"general": "General",
|
||||
"members": "Members",
|
||||
"permissions": "Permissions",
|
||||
"emojis_stickers": "Emojis & Stickers",
|
||||
"developer_tools": "Developer Tools",
|
||||
|
||||
"profile": "Profile",
|
||||
"edit": "Edit",
|
||||
"unknown": "Unknown",
|
||||
|
|
@ -696,30 +702,25 @@
|
|||
"topic": "Topic",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
|
||||
"options": "Options",
|
||||
"addresses": "Addresses",
|
||||
"advanced_options": "Advanced Options",
|
||||
|
||||
"space_access": "Space Access",
|
||||
"room_access": "Room Access",
|
||||
"space_access_desc": "Change how people can join the space.",
|
||||
"room_access_desc": "Change how people can join the room.",
|
||||
|
||||
"join_invite_only": "Invite Only",
|
||||
"join_knock_invite": "Knock & Invite",
|
||||
"join_space_members_or_knock": "Space Members or Knock",
|
||||
"join_space_members": "Space Members",
|
||||
"join_public": "Public",
|
||||
"join_unsupported": "Unsupported",
|
||||
|
||||
"history_visibility": "Message History Visibility",
|
||||
"history_visibility_desc": "Changes to history visibility will only apply to future messages and will not affect existing history.",
|
||||
"visibility_after_invite": "After Invite",
|
||||
"visibility_after_join": "After Join",
|
||||
"visibility_all_messages": "All Messages",
|
||||
"visibility_all_messages_guests": "All Messages (Guests)",
|
||||
|
||||
"room_encryption": "Room Encryption",
|
||||
"encryption_enabled_desc": "Messages in this room are protected by end-to-end encryption.",
|
||||
"encryption_disabled_desc": "Once enabled, encryption cannot be disabled!",
|
||||
|
|
@ -728,11 +729,9 @@
|
|||
"enable_encryption": "Enable Encryption",
|
||||
"enable_encryption_confirm": "Are you sure? Once enabled, encryption cannot be disabled!",
|
||||
"enable_e2e_encryption": "Enable E2E Encryption",
|
||||
|
||||
"publish_to_directory": "Publish to Directory",
|
||||
"publish_space_desc": "List the space in the public directory to make it discoverable by others.",
|
||||
"publish_room_desc": "List the room in the public directory to make it discoverable by others.",
|
||||
|
||||
"published_addresses": "Published Addresses",
|
||||
"published_addresses_desc": "If access is <b>Public</b>, Published addresses will be used to join by anyone.",
|
||||
"no_addresses": "No Addresses",
|
||||
|
|
@ -746,13 +745,11 @@
|
|||
"publish": "Publish",
|
||||
"delete": "Delete",
|
||||
"selected_count": "{{count}} Selected",
|
||||
|
||||
"local_addresses": "Local Addresses",
|
||||
"local_addresses_desc": "Set local address so users can join through your homeserver.",
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"loading": "Loading...",
|
||||
|
||||
"space_upgrade": "Space Upgrade",
|
||||
"room_upgrade": "Room Upgrade",
|
||||
"upgrade": "Upgrade",
|
||||
|
|
@ -766,25 +763,21 @@
|
|||
"old_room": "Old Room",
|
||||
"open_new_space": "Open New Space",
|
||||
"open_new_room": "Open New Room",
|
||||
|
||||
"members_count": "{{count}} Members",
|
||||
"search": "Search",
|
||||
"no_results": "No Results",
|
||||
"results_count": "{{count}} Results",
|
||||
"scroll_to_top": "Scroll to Top",
|
||||
"no_membership_members": "No \"{{filter}}\" Members",
|
||||
|
||||
"filter_joined": "Joined",
|
||||
"filter_invited": "Invited",
|
||||
"filter_left": "Left",
|
||||
"filter_kicked": "Kicked",
|
||||
"filter_banned": "Banned",
|
||||
|
||||
"sort_a_to_z": "A to Z",
|
||||
"sort_z_to_a": "Z to A",
|
||||
"sort_newest": "Newest",
|
||||
"sort_oldest": "Oldest",
|
||||
|
||||
"perm_messages": "Messages",
|
||||
"perm_send_messages": "Send Messages",
|
||||
"perm_send_stickers": "Send Stickers",
|
||||
|
|
@ -817,12 +810,10 @@
|
|||
"perm_manage_emojis_stickers": "Manage Emojis & Stickers",
|
||||
"perm_change_server_acls": "Change Server ACLs",
|
||||
"perm_modify_widgets": "Modify Widgets",
|
||||
|
||||
"founders": "Founders",
|
||||
"founders_desc": "Founding members have all permissions and can only be changed during a room upgrade.",
|
||||
"power_levels": "Power Levels",
|
||||
"power_levels_desc": "Manage and customize incremental power levels for users.",
|
||||
|
||||
"new_power_level": "New Power Level",
|
||||
"new_power_level_desc": "Create a new power level.",
|
||||
"power_level_placeholder": "Bot",
|
||||
|
|
@ -839,11 +830,9 @@
|
|||
"failed_to_apply": "Failed to apply changes! Please try again.",
|
||||
"apply_changes": "Apply Changes",
|
||||
"and_above": "& Above",
|
||||
|
||||
"users": "Users",
|
||||
"default_power": "Default Power",
|
||||
"default_power_desc": "Default power level for all users.",
|
||||
|
||||
"packs": "Packs",
|
||||
"new_pack": "New Pack",
|
||||
"new_pack_desc": "Add your own emoji and sticker pack to use in room.",
|
||||
|
|
@ -852,7 +841,6 @@
|
|||
"view": "View",
|
||||
"failed_to_remove_packs": "Failed to remove packs! Please try again.",
|
||||
"delete_selected_packs": "Delete selected packs. ({{count}} selected)",
|
||||
|
||||
"enable_developer_tools": "Enable Developer Tools",
|
||||
"room_id": "Room ID",
|
||||
"room_id_desc": "Copy room ID to clipboard.",
|
||||
|
|
@ -875,7 +863,6 @@
|
|||
"message_event_type": "Message Event Type",
|
||||
"send": "Send",
|
||||
"state_key_optional": "State Key (Optional)",
|
||||
|
||||
"pack": "Pack",
|
||||
"images_usage": "Images Usage",
|
||||
"images_usage_desc": "Select how the images are being used: as emojis, as stickers, or as both.",
|
||||
|
|
@ -890,7 +877,6 @@
|
|||
"usage_both": "Both",
|
||||
"usage_sticker": "Sticker",
|
||||
"usage_emoji": "Emoji",
|
||||
|
||||
"power_goku": "Goku",
|
||||
"power_manager": "Manager",
|
||||
"power_founder": "Founder",
|
||||
|
|
@ -900,7 +886,6 @@
|
|||
"power_muted": "Muted",
|
||||
"power_team": "Team"
|
||||
},
|
||||
|
||||
"Push": {
|
||||
"new_message": "New message",
|
||||
"new_messages": "New messages",
|
||||
|
|
@ -911,7 +896,19 @@
|
|||
"invite_body": "{{inviter}} invited you to {{roomName}}",
|
||||
"invite_body_no_room": "{{inviter}} invited you to a room",
|
||||
"invite_body_no_inviter": "Invited you to {{roomName}}",
|
||||
"invite_body_generic": "New invitation"
|
||||
"invite_body_generic": "New invitation",
|
||||
"missed_call": "Missed call",
|
||||
"missed_call_body": "{{caller}} tried to call you",
|
||||
"channel_group": "Chats",
|
||||
"channel_dm": "Direct messages",
|
||||
"channel_dm_description": "New messages from direct chats",
|
||||
"channel_group_room": "Group chats",
|
||||
"channel_group_room_description": "New messages from group chats and channels",
|
||||
"self_name": "You",
|
||||
"action_mark_as_read": "Mark as read",
|
||||
"action_reply": "Reply",
|
||||
"reply_hint": "Reply…",
|
||||
"reply_failed": "Could not send your reply"
|
||||
},
|
||||
"Bots": {
|
||||
"not_connected_title": "{{name}} is not connected",
|
||||
|
|
@ -983,5 +980,15 @@
|
|||
"copy_server": "Copy server",
|
||||
"explore_community": "Explore community",
|
||||
"open_in_browser": "Open in browser"
|
||||
},
|
||||
"Share": {
|
||||
"share_text": "Ready to share text",
|
||||
"share_image": "Ready to share image",
|
||||
"share_video": "Ready to share video",
|
||||
"share_audio": "Ready to share audio",
|
||||
"share_file": "Ready to share: {{name}}",
|
||||
"share_files": "Ready to share {{count}} files",
|
||||
"tap_chat_to_send": "Open a chat to drop it in",
|
||||
"cancel": "Cancel share"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,6 +90,8 @@
|
|||
"menu_emojis_stickers": "Эмодзи и стикеры",
|
||||
"menu_developer_tools": "Инструменты разработчика",
|
||||
"menu_about": "О приложении",
|
||||
"drag_to_close": "Потянуть вниз чтобы закрыть",
|
||||
"close": "Закрыть",
|
||||
"logout": "Выйти",
|
||||
"logout_confirm": "Вы собираетесь выйти из аккаунта. Вы уверены?",
|
||||
"logout_failed": "Не удалось выйти! {{message}}",
|
||||
|
|
@ -97,7 +99,6 @@
|
|||
"logout_unverified_desc": "Верифицируйте устройство перед выходом, чтобы сохранить зашифрованные сообщения.",
|
||||
"logout_alert_title": "Внимание",
|
||||
"logout_alert_desc": "Включите верификацию устройства или экспортируйте зашифрованные данные в настройках, чтобы не потерять доступ к сообщениям.",
|
||||
|
||||
"general_title": "Общие",
|
||||
"appearance": "Внешний вид",
|
||||
"system_theme": "Системная",
|
||||
|
|
@ -121,7 +122,6 @@
|
|||
"url_preview": "Предпросмотр ссылок",
|
||||
"url_preview_encrypted": "Предпросмотр ссылок в зашифрованных комнатах",
|
||||
"show_hidden_events": "Показывать скрытые события",
|
||||
|
||||
"account_title": "Аккаунт",
|
||||
"profile": "Профиль",
|
||||
"avatar": "Аватар",
|
||||
|
|
@ -140,7 +140,6 @@
|
|||
"select_user_desc": "Заблокируйте получение сообщений и приглашений от пользователя, добавив его идентификатор.",
|
||||
"block": "Заблокировать",
|
||||
"users": "Пользователи",
|
||||
|
||||
"notifications_title": "Уведомления",
|
||||
"block_messages": "Блокировка сообщений",
|
||||
"block_messages_moved": "Эта опция перенесена в раздел «Аккаунт > Заблокированные пользователи».",
|
||||
|
|
@ -185,7 +184,6 @@
|
|||
"notif_disable": "Отключить",
|
||||
"notif_silent": "Тихое уведомление",
|
||||
"notif_loud": "Громкое уведомление",
|
||||
|
||||
"devices_title": "Устройства",
|
||||
"security": "Безопасность",
|
||||
"device_verification": "Верификация устройства",
|
||||
|
|
@ -228,7 +226,6 @@
|
|||
"verify_other_desc": "Подтвердите идентичность устройства и получите доступ к зашифрованным сообщениям.",
|
||||
"verify": "Верифицировать",
|
||||
"reset": "Сбросить",
|
||||
|
||||
"local_backup": "Локальная копия",
|
||||
"new_password": "Новый пароль",
|
||||
"confirm_password": "Подтвердите пароль",
|
||||
|
|
@ -242,7 +239,6 @@
|
|||
"import_desc": "Загрузите защищённую паролем копию ключей шифрования с устройства для расшифровки сообщений.",
|
||||
"import": "Импорт",
|
||||
"decrypt": "Расшифровать",
|
||||
|
||||
"emojis_stickers_title": "Эмодзи и стикеры",
|
||||
"default_pack": "Пакет по умолчанию",
|
||||
"unknown": "Неизвестно",
|
||||
|
|
@ -252,7 +248,6 @@
|
|||
"select_pack_desc": "Выберите пакеты эмодзи и стикеров из комнат для использования во всех комнатах.",
|
||||
"select": "Выбрать",
|
||||
"room_packs": "Пакеты комнат",
|
||||
"close": "Закрыть",
|
||||
"select_all": "Выбрать все",
|
||||
"unselect_all": "Снять выделение",
|
||||
"no_packs": "Нет пакетов",
|
||||
|
|
@ -260,15 +255,17 @@
|
|||
"apply_error": "Не удалось применить изменения! Попробуйте снова.",
|
||||
"apply_ready": "Изменения сохранены! Примените, когда будете готовы.",
|
||||
"apply_changes": "Применить изменения",
|
||||
|
||||
"about_title": "О приложении",
|
||||
"about_tagline": "Ещё один клиент для Matrix.",
|
||||
"options": "Параметры",
|
||||
"clear_cache_title": "Очистить кэш и перезагрузить",
|
||||
"clear_cache_desc": "Удалить все локально сохранённые данные и загрузить заново с сервера.",
|
||||
"clear_cache": "Очистить кэш",
|
||||
"legal": "Юридическое",
|
||||
"privacy_policy_title": "Политика конфиденциальности",
|
||||
"privacy_policy_desc": "Как обрабатываются ваши данные.",
|
||||
"privacy_policy_open": "Открыть",
|
||||
"credits": "Благодарности",
|
||||
|
||||
"devtools_title": "Инструменты разработчика",
|
||||
"enable_devtools": "Включить инструменты разработчика",
|
||||
"access_token": "Токен доступа",
|
||||
|
|
@ -388,7 +385,8 @@
|
|||
"e2e_encryption": "Сквозное шифрование",
|
||||
"e2e_encryption_desc": "После включения эту функцию нельзя отключить после создания комнаты.",
|
||||
"rate_limited": "Сервер ограничил частоту запросов на {{minutes}} мин.!",
|
||||
"create": "Создать"
|
||||
"create": "Создать",
|
||||
"close": "Закрыть"
|
||||
},
|
||||
"Channels": {
|
||||
"no_spaces_title": "Пока нет сообществ",
|
||||
|
|
@ -398,7 +396,14 @@
|
|||
"pick_channel_desc": "Откройте канал из списка слева, чтобы начать читать.",
|
||||
"root_category": "Каналы",
|
||||
"workspace_switcher_aria": "Сменить сообщество",
|
||||
"workspace_switcher_active_marker": "Текущее"
|
||||
"workspace_switcher_create_space": "Создать сообщество",
|
||||
"workspace_switcher_drag_to_close": "Потяните вниз, чтобы закрыть",
|
||||
"workspace_switcher_member_count_one": "{{count}} участник",
|
||||
"workspace_switcher_member_count_few": "{{count}} участника",
|
||||
"workspace_switcher_member_count_many": "{{count}} участников",
|
||||
"workspace_switcher_member_count_other": "{{count}} участника",
|
||||
"workspace_footer_subtitle": "Сообщество",
|
||||
"create_channel": "Создать канал"
|
||||
},
|
||||
"Call": {
|
||||
"start": "Позвонить",
|
||||
|
|
@ -423,7 +428,21 @@
|
|||
"in_call": "В звонке",
|
||||
"in_call_count": "{{count}} в звонке",
|
||||
"connecting": "Соединение…",
|
||||
"open_call_room": "Открыть чат звонка"
|
||||
"open_call_room": "Открыть чат звонка",
|
||||
"bubble_outgoing": "Исходящий звонок",
|
||||
"bubble_incoming": "Входящий звонок",
|
||||
"bubble_missed": "Пропущенный звонок",
|
||||
"bubble_cancelled": "Отменённый звонок",
|
||||
"bubble_ongoing": "Идёт звонок",
|
||||
"bubble_in_progress": "Идёт сейчас…",
|
||||
"bubble_missed_count_one": "{{count}} пропущенный звонок",
|
||||
"bubble_missed_count_few": "{{count}} пропущенных звонка",
|
||||
"bubble_missed_count_many": "{{count}} пропущенных звонков",
|
||||
"bubble_cancelled_count_one": "{{count}} отменённый звонок",
|
||||
"bubble_cancelled_count_few": "{{count}} отменённых звонка",
|
||||
"bubble_cancelled_count_many": "{{count}} отменённых звонков",
|
||||
"duration_minutes_seconds": "{{minutes}} мин {{seconds}} сек",
|
||||
"duration_seconds": "{{seconds}} сек"
|
||||
},
|
||||
"Room": {
|
||||
"drag_to_close": "Потянуть вверх чтобы закрыть",
|
||||
|
|
@ -435,7 +454,6 @@
|
|||
"jump_to_latest": "К последним",
|
||||
"today": "Сегодня",
|
||||
"yesterday": "Вчера",
|
||||
|
||||
"view_reactions": "Реакции",
|
||||
"read_receipts": "Подтверждения прочтения",
|
||||
"view_source": "Исходный код",
|
||||
|
|
@ -447,7 +465,6 @@
|
|||
"reply": "Ответить",
|
||||
"reply_in_thread": "Ответить в треде",
|
||||
"edit_message": "Редактировать",
|
||||
|
||||
"delete_message": "Удалить сообщение",
|
||||
"delete_confirm": "Это действие необратимо! Вы уверены, что хотите удалить это сообщение?",
|
||||
"reason": "Причина",
|
||||
|
|
@ -455,7 +472,6 @@
|
|||
"delete_error": "Не удалось удалить сообщение! Попробуйте снова.",
|
||||
"deleting": "Удаление...",
|
||||
"delete": "Удалить",
|
||||
|
||||
"report_message": "Пожаловаться",
|
||||
"report_desc": "Сообщить о нарушении на сервер, который может уведомить ответственных лиц для принятия мер.",
|
||||
"report_reason": "Причина",
|
||||
|
|
@ -464,13 +480,11 @@
|
|||
"reporting": "Отправка...",
|
||||
"report": "Пожаловаться",
|
||||
"no_reason": "Причина не указана",
|
||||
|
||||
"is_typing": " печатает...",
|
||||
"and": " и ",
|
||||
"are_typing": " печатают...",
|
||||
"others_count": "ещё {{count}}",
|
||||
"drop_typing": "Скрыть индикатор набора",
|
||||
|
||||
"members": "Участники",
|
||||
"members_count_one": "{{formattedCount}} участник",
|
||||
"members_count_few": "{{formattedCount}} участника",
|
||||
|
|
@ -478,6 +492,12 @@
|
|||
"members_count_other": "{{formattedCount}} участника",
|
||||
"hide_members": "Скрыть участников",
|
||||
"show_members": "Показать участников",
|
||||
"members_pane_title": "Участники",
|
||||
"members_sheet_title_one": "{{formattedCount}} участник",
|
||||
"members_sheet_title_few": "{{formattedCount}} участника",
|
||||
"members_sheet_title_many": "{{formattedCount}} участников",
|
||||
"members_sheet_title_other": "{{formattedCount}} участника",
|
||||
"open_members_of": "Открыть участников: {{name}}",
|
||||
"more_options": "Ещё",
|
||||
"close": "Закрыть",
|
||||
"search": "Поиск",
|
||||
|
|
@ -489,23 +509,30 @@
|
|||
"room_settings": "Настройки комнаты",
|
||||
"jump_to_time": "Перейти к дате",
|
||||
"leave_room": "Покинуть комнату",
|
||||
|
||||
"send_message": "Написать сообщение...",
|
||||
"send_message_alt_1": "В одну строку или несколько...",
|
||||
"send_message_alt_2": "Написать в эту минуту...",
|
||||
"send_message_alt_3": "Не томи, пиши...",
|
||||
"send_message_alt_4": "Эта строка сама себя не заполнит...",
|
||||
"send_message_alt_5": "Ну так что?..",
|
||||
"send_message_alt_6": "Никто не читает плейсхолдеры. Но вы прочитали...",
|
||||
"send_message_alt_7": "Сюда буквы, пожалуйста...",
|
||||
"send_message_alt_8": "Вы смотрите на плейсхолдер. Плейсхолдер смотрит на вас...",
|
||||
"send_message_alt_9": "Поздравляю, вы в 3% людей, читающих плейсхолдеры...",
|
||||
"send_message_alt_10": "Ну я подожду, подожду...",
|
||||
"send_message_alt_11": "Только после вас...",
|
||||
"drop_files": "Перетащите файлы в \"{{name}}\"",
|
||||
"drag_drop_desc": "Перетащите файлы сюда или нажмите для выбора",
|
||||
|
||||
"pinned_messages": "Закреплённые сообщения",
|
||||
"no_pinned_messages": "Нет закреплённых сообщений",
|
||||
"no_pinned_messages_desc": "Пользователи с достаточным уровнем прав могут закреплять сообщения через контекстное меню.",
|
||||
"open": "Открыть",
|
||||
"failed_to_load": "Не удалось загрузить сообщение!",
|
||||
|
||||
"time_label": "Время",
|
||||
"date_label": "Дата",
|
||||
"preset": "Пресет",
|
||||
"beginning": "Начало",
|
||||
"open_timeline": "Открыть ленту",
|
||||
|
||||
"message_deleted": "Сообщение было удалено",
|
||||
"message_deleted_reason": "Сообщение было удалено. {{reason}}",
|
||||
"unsupported_message": "Неподдерживаемое сообщение",
|
||||
|
|
@ -515,7 +542,6 @@
|
|||
"broken_message": "Повреждённое сообщение",
|
||||
"empty_message": "Пустое сообщение",
|
||||
"edited": " (изменено)",
|
||||
|
||||
"thread_caption": "Тред",
|
||||
"thread_in_channel_subtitle": "в #{{channel}}",
|
||||
"thread_close": "Закрыть тред",
|
||||
|
|
@ -538,18 +564,24 @@
|
|||
"thread_summary_highlight_many": "{{count}} упоминаний",
|
||||
"thread_summary_highlight_other": "{{count}} упоминания",
|
||||
"no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате",
|
||||
|
||||
"conversation_beginning": "Начало переписки.",
|
||||
"created_by": "Комната создана <bold>@{{creator}}</bold> {{date}} {{time}}",
|
||||
"invite_member": "Пригласить",
|
||||
"open_old_room": "Открыть старую комнату",
|
||||
"join_old_room": "Войти в старую комнату",
|
||||
"empty_dm": "Самое сложное — первое сообщение.",
|
||||
"empty_dm_alt_1": "С чего-то надо начать.",
|
||||
"empty_dm_alt_2": "Кто-то должен написать первым.",
|
||||
"empty_dm_alt_3": "Чистый лист. Ни одной опечатки — пока.",
|
||||
"empty_group": "Группа создана. Кто первый?",
|
||||
"empty_group_alt_1": "Здесь пока никто ничего не сказал.",
|
||||
"empty_group_alt_2": "Тишина перед первым сообщением.",
|
||||
"empty_group_alt_3": "Все в сборе — можно начинать.",
|
||||
"empty_bridge": "Сообщения идут через мост с {{network}}.",
|
||||
"empty_bridge_alt_1": "Этот чат соединён с {{network}}.",
|
||||
"empty_bridge_alt_2": "Собеседник пишет из {{network}}.",
|
||||
"empty_bridge_generic": "Сообщения идут через мост.",
|
||||
"empty_encrypted": "Сообщения защищены сквозным шифрованием.",
|
||||
"leave_room_title": "Покинуть комнату",
|
||||
"leave_room_confirm": "Покинуть эту комнату?",
|
||||
"leave_room_error": "Не удалось покинуть комнату! {{error}}",
|
||||
"leaving": "Выход...",
|
||||
"leave": "Покинуть",
|
||||
|
||||
"member_broken": "Некорректное событие участия",
|
||||
"member_accepted_knock": "<bold>{{sender}}</bold> одобряет вступление <bold>{{user}}</bold>",
|
||||
"member_invited": "<bold>{{sender}}</bold> приглашает <bold>{{user}}</bold>",
|
||||
|
|
@ -567,10 +599,7 @@
|
|||
"member_name_removed": "<bold>{{user}}</bold> убирает отображаемое имя",
|
||||
"member_avatar_changed": "<bold>{{user}}</bold> меняет аватар",
|
||||
"member_avatar_removed": "<bold>{{user}}</bold> убирает аватар",
|
||||
"member_no_change": "Событие участия без изменений",
|
||||
|
||||
"member_ended_call": "<bold>{{user}}</bold> больше не в звонке",
|
||||
"member_joined_call": "<bold>{{user}}</bold> теперь в звонке"
|
||||
"member_no_change": "Событие участия без изменений"
|
||||
},
|
||||
"Inbox": {
|
||||
"invite_title": "Пригласить",
|
||||
|
|
@ -578,19 +607,15 @@
|
|||
"user_id_placeholder": "@username:server",
|
||||
"reason_optional": "Причина (необязательно)",
|
||||
"invite_button": "Пригласить",
|
||||
|
||||
"notif_default": "По умолчанию",
|
||||
"notif_all_messages": "Все сообщения",
|
||||
"notif_mentions_keywords": "Упоминания и ключевые слова",
|
||||
"notif_mute": "Без уведомлений",
|
||||
|
||||
"unverified_device": "Неподтверждённое устройство",
|
||||
"unverified_devices": "Неподтверждённые устройства"
|
||||
},
|
||||
|
||||
"Explore": {
|
||||
"explore_community": "Обзор сообществ",
|
||||
|
||||
"add_server": "Добавить сервер",
|
||||
"add_server_desc": "Укажите имя сервера для обзора публичных сообществ.",
|
||||
"server_name": "Имя сервера",
|
||||
|
|
@ -598,13 +623,11 @@
|
|||
"view": "Открыть",
|
||||
"featured": "Рекомендуемые",
|
||||
"servers": "Серверы",
|
||||
|
||||
"featured_by_client": "Рекомендации клиента",
|
||||
"featured_by_client_desc": "Подборка публичных комнат и пространств от этого клиента.",
|
||||
"featured_spaces": "Рекомендуемые пространства",
|
||||
"featured_rooms": "Рекомендуемые комнаты",
|
||||
"no_featured": "Рекомендуемых комнат и пространств пока нет.",
|
||||
|
||||
"search": "Поиск",
|
||||
"search_placeholder": "Поиск по ключевому слову",
|
||||
"clear": "Очистить",
|
||||
|
|
@ -623,9 +646,11 @@
|
|||
"previous_page": "Предыдущая",
|
||||
"next_page": "Следующая",
|
||||
"no_communities": "Сообщества не найдены!",
|
||||
|
||||
"space_badge": "Пространство",
|
||||
"members_count": "{{count}} участников",
|
||||
"members_count_one": "{{formattedCount}} участник",
|
||||
"members_count_few": "{{formattedCount}} участника",
|
||||
"members_count_many": "{{formattedCount}} участников",
|
||||
"members_count_other": "{{formattedCount}} участника",
|
||||
"join": "Присоединиться",
|
||||
"joining": "Вступление…",
|
||||
"retry": "Повторить",
|
||||
|
|
@ -634,7 +659,6 @@
|
|||
"view_error": "Подробности",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
|
||||
"Create": {
|
||||
"add_space": "Добавить пространство",
|
||||
"create_space": "Создать пространство",
|
||||
|
|
@ -642,7 +666,6 @@
|
|||
"join_with_address": "Присоединиться по адресу",
|
||||
"join_with_address_desc": "Присоединиться к существующему сообществу.",
|
||||
"new_space": "Новое пространство",
|
||||
|
||||
"access": "Доступ",
|
||||
"name": "Название",
|
||||
"topic_optional": "Тема (необязательно)",
|
||||
|
|
@ -654,47 +677,38 @@
|
|||
"allow_federation_desc": "Пользователи с других серверов смогут присоединиться.",
|
||||
"create": "Создать",
|
||||
"rate_limited": "Сервер ограничил ваш запрос на {{minutes}} мин.!",
|
||||
|
||||
"access_restricted": "Ограниченный",
|
||||
"access_restricted_desc": "Могут присоединиться только участники родительского пространства.",
|
||||
"access_private": "Приватный",
|
||||
"access_private_desc": "Могут присоединиться только приглашённые.",
|
||||
"access_public": "Публичный",
|
||||
"access_public_desc": "Любой, у кого есть адрес, может присоединиться.",
|
||||
|
||||
"address_optional": "Адрес (необязательно)",
|
||||
"address_hint": "Выберите уникальный адрес, чтобы пространство можно было найти.",
|
||||
"address_taken": "Этот адрес уже занят. Выберите другой.",
|
||||
|
||||
"founders": "Основатели",
|
||||
"founders_desc": "Привилегированные пользователи, назначенные при создании. Они имеют расширенные полномочия; изменить их можно только при обновлении пространства.",
|
||||
"enter": "Добавить",
|
||||
"no_suggestions": "Нет предложений",
|
||||
"no_suggestions_desc": "Введите ID пользователя и нажмите Добавить.",
|
||||
|
||||
"version": "Версия",
|
||||
"versions": "Версии",
|
||||
|
||||
"chat_room": "Чат-комната",
|
||||
"chat_room_desc": "Сообщения, фото и видео.",
|
||||
"voice_room": "Голосовая комната",
|
||||
"voice_room_desc": "Голосовые и видеозвонки в реальном времени.",
|
||||
|
||||
"new_chat_room": "Новая чат-комната",
|
||||
"new_voice_room": "Новая голосовая комната",
|
||||
|
||||
"existing_space": "Существующее пространство",
|
||||
"add_room": "Добавить комнату",
|
||||
"existing_room": "Существующая комната"
|
||||
},
|
||||
|
||||
"RoomSettings": {
|
||||
"general": "Основные",
|
||||
"members": "Участники",
|
||||
"permissions": "Права доступа",
|
||||
"emojis_stickers": "Эмодзи и стикеры",
|
||||
"developer_tools": "Инструменты разработчика",
|
||||
|
||||
"profile": "Профиль",
|
||||
"edit": "Редактировать",
|
||||
"unknown": "Неизвестно",
|
||||
|
|
@ -706,30 +720,25 @@
|
|||
"topic": "Тема",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
|
||||
"options": "Настройки",
|
||||
"addresses": "Адреса",
|
||||
"advanced_options": "Дополнительные настройки",
|
||||
|
||||
"space_access": "Доступ к пространству",
|
||||
"room_access": "Доступ к комнате",
|
||||
"space_access_desc": "Изменить способ вступления в пространство.",
|
||||
"room_access_desc": "Изменить способ вступления в комнату.",
|
||||
|
||||
"join_invite_only": "Только по приглашению",
|
||||
"join_knock_invite": "Запрос и приглашение",
|
||||
"join_space_members_or_knock": "Участники пространства или запрос",
|
||||
"join_space_members": "Участники пространства",
|
||||
"join_public": "Публичный",
|
||||
"join_unsupported": "Не поддерживается",
|
||||
|
||||
"history_visibility": "Видимость истории сообщений",
|
||||
"history_visibility_desc": "Изменения видимости истории применяются только к новым сообщениям и не затрагивают существующую историю.",
|
||||
"visibility_after_invite": "После приглашения",
|
||||
"visibility_after_join": "После вступления",
|
||||
"visibility_all_messages": "Все сообщения",
|
||||
"visibility_all_messages_guests": "Все сообщения (гости)",
|
||||
|
||||
"room_encryption": "Шифрование комнаты",
|
||||
"encryption_enabled_desc": "Сообщения в этой комнате защищены сквозным шифрованием.",
|
||||
"encryption_disabled_desc": "После включения шифрование невозможно отключить!",
|
||||
|
|
@ -738,11 +747,9 @@
|
|||
"enable_encryption": "Включить шифрование",
|
||||
"enable_encryption_confirm": "Вы уверены? После включения шифрование невозможно отключить!",
|
||||
"enable_e2e_encryption": "Включить E2E-шифрование",
|
||||
|
||||
"publish_to_directory": "Показывать в поиске",
|
||||
"publish_space_desc": "Сделать пространство видимым в общем списке, чтобы другие пользователи могли его найти.",
|
||||
"publish_room_desc": "Сделать комнату видимой в общем списке, чтобы другие пользователи могли её найти.",
|
||||
|
||||
"published_addresses": "Опубликованные адреса",
|
||||
"published_addresses_desc": "Если доступ <b>публичный</b>, опубликованные адреса будут использоваться для присоединения.",
|
||||
"no_addresses": "Нет адресов",
|
||||
|
|
@ -756,13 +763,11 @@
|
|||
"publish": "Опубликовать",
|
||||
"delete": "Удалить",
|
||||
"selected_count": "Выбрано: {{count}}",
|
||||
|
||||
"local_addresses": "Локальные адреса",
|
||||
"local_addresses_desc": "Задайте локальный адрес, чтобы пользователи могли присоединиться через ваш сервер.",
|
||||
"collapse": "Свернуть",
|
||||
"expand": "Развернуть",
|
||||
"loading": "Загрузка...",
|
||||
|
||||
"space_upgrade": "Обновление пространства",
|
||||
"room_upgrade": "Обновление комнаты",
|
||||
"upgrade": "Обновить",
|
||||
|
|
@ -776,25 +781,21 @@
|
|||
"old_room": "Старая комната",
|
||||
"open_new_space": "Открыть новое пространство",
|
||||
"open_new_room": "Открыть новую комнату",
|
||||
|
||||
"members_count": "{{count}} участников",
|
||||
"search": "Поиск",
|
||||
"no_results": "Ничего не найдено",
|
||||
"results_count": "{{count}} результатов",
|
||||
"scroll_to_top": "Наверх",
|
||||
"no_membership_members": "Нет участников «{{filter}}»",
|
||||
|
||||
"filter_joined": "Вступившие",
|
||||
"filter_invited": "Приглашённые",
|
||||
"filter_left": "Вышедшие",
|
||||
"filter_kicked": "Исключённые",
|
||||
"filter_banned": "Забаненные",
|
||||
|
||||
"sort_a_to_z": "А — Я",
|
||||
"sort_z_to_a": "Я — А",
|
||||
"sort_newest": "Новые",
|
||||
"sort_oldest": "Старые",
|
||||
|
||||
"perm_messages": "Сообщения",
|
||||
"perm_send_messages": "Отправка сообщений",
|
||||
"perm_send_stickers": "Отправка стикеров",
|
||||
|
|
@ -827,12 +828,10 @@
|
|||
"perm_manage_emojis_stickers": "Управление эмодзи и стикерами",
|
||||
"perm_change_server_acls": "Изменение ACL серверов",
|
||||
"perm_modify_widgets": "Изменение виджетов",
|
||||
|
||||
"founders": "Основатели",
|
||||
"founders_desc": "Основатели имеют все права. Изменить их состав можно только при обновлении комнаты.",
|
||||
"power_levels": "Уровни власти",
|
||||
"power_levels_desc": "Управление и настройка уровней власти для пользователей.",
|
||||
|
||||
"new_power_level": "Новый уровень власти",
|
||||
"power_level_placeholder": "Бот",
|
||||
"new_power_level_desc": "Создать новый уровень власти.",
|
||||
|
|
@ -849,11 +848,9 @@
|
|||
"failed_to_apply": "Не удалось применить изменения! Попробуйте ещё раз.",
|
||||
"apply_changes": "Применить изменения",
|
||||
"and_above": "и выше",
|
||||
|
||||
"users": "Пользователи",
|
||||
"default_power": "Уровень по умолчанию",
|
||||
"default_power_desc": "Уровень власти по умолчанию для всех пользователей.",
|
||||
|
||||
"packs": "Паки",
|
||||
"new_pack": "Новый пак",
|
||||
"new_pack_desc": "Добавьте свой пак эмодзи и стикеров для использования в комнате.",
|
||||
|
|
@ -862,7 +859,6 @@
|
|||
"view": "Открыть",
|
||||
"failed_to_remove_packs": "Не удалось удалить паки! Попробуйте ещё раз.",
|
||||
"delete_selected_packs": "Удалить выбранные паки. (Выбрано: {{count}})",
|
||||
|
||||
"enable_developer_tools": "Включить инструменты разработчика",
|
||||
"room_id": "ID комнаты",
|
||||
"room_id_desc": "Скопировать ID комнаты в буфер обмена.",
|
||||
|
|
@ -885,7 +881,6 @@
|
|||
"message_event_type": "Тип события сообщения",
|
||||
"send": "Отправить",
|
||||
"state_key_optional": "State Key (необязательно)",
|
||||
|
||||
"pack": "Пак",
|
||||
"images_usage": "Использование изображений",
|
||||
"images_usage_desc": "Выберите, как используются изображения: как эмодзи, как стикеры или как и то, и другое.",
|
||||
|
|
@ -900,7 +895,6 @@
|
|||
"usage_both": "Оба",
|
||||
"usage_sticker": "Стикер",
|
||||
"usage_emoji": "Эмодзи",
|
||||
|
||||
"power_goku": "Гоку",
|
||||
"power_manager": "Менеджер",
|
||||
"power_founder": "Основатель",
|
||||
|
|
@ -910,7 +904,6 @@
|
|||
"power_muted": "Без голоса",
|
||||
"power_team": "Команда"
|
||||
},
|
||||
|
||||
"Push": {
|
||||
"new_message": "Новое сообщение",
|
||||
"new_messages": "Новые сообщения",
|
||||
|
|
@ -921,7 +914,19 @@
|
|||
"invite_body": "{{inviter}} приглашает вас в {{roomName}}",
|
||||
"invite_body_no_room": "{{inviter}} приглашает вас в комнату",
|
||||
"invite_body_no_inviter": "Приглашение в {{roomName}}",
|
||||
"invite_body_generic": "Новое приглашение"
|
||||
"invite_body_generic": "Новое приглашение",
|
||||
"missed_call": "Пропущенный звонок",
|
||||
"missed_call_body": "{{caller}} пытался вам дозвониться",
|
||||
"channel_group": "Чаты",
|
||||
"channel_dm": "Личные сообщения",
|
||||
"channel_dm_description": "Новые сообщения из личных переписок",
|
||||
"channel_group_room": "Групповые чаты",
|
||||
"channel_group_room_description": "Новые сообщения из групповых чатов и каналов",
|
||||
"self_name": "Я",
|
||||
"action_mark_as_read": "Прочитано",
|
||||
"action_reply": "Ответить",
|
||||
"reply_hint": "Ответ…",
|
||||
"reply_failed": "Не удалось отправить ответ"
|
||||
},
|
||||
"Bots": {
|
||||
"not_connected_title": "{{name}} не подключён",
|
||||
|
|
@ -996,5 +1001,15 @@
|
|||
"copy_server": "Скопировать сервер",
|
||||
"explore_community": "Открыть сервер",
|
||||
"open_in_browser": "Открыть в браузере"
|
||||
},
|
||||
"Share": {
|
||||
"share_text": "Готово к пересылке: текст",
|
||||
"share_image": "Готово к пересылке: изображение",
|
||||
"share_video": "Готово к пересылке: видео",
|
||||
"share_audio": "Готово к пересылке: аудио",
|
||||
"share_file": "Готово к пересылке: {{name}}",
|
||||
"share_files": "Готово к пересылке: {{count}} файлов",
|
||||
"tap_chat_to_send": "Откройте чат, чтобы отправить",
|
||||
"cancel": "Отменить"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"start_url": "./",
|
||||
"background_color": "#000",
|
||||
"theme_color": "#000",
|
||||
"background_color": "#0d0e11",
|
||||
"theme_color": "#0d0e11",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./public/android/vojo.svg",
|
||||
|
|
|
|||
400
public/privacy.html
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
<!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>
|
||||
BIN
public/res/store/feature-graphic.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 100 KiB |
|
|
@ -51,6 +51,18 @@ const ANDROID_KEYS = [
|
|||
'invite_body_no_room',
|
||||
'invite_body_no_inviter',
|
||||
'invite_body_generic',
|
||||
'missed_call',
|
||||
'missed_call_body',
|
||||
'channel_group',
|
||||
'channel_dm',
|
||||
'channel_dm_description',
|
||||
'channel_group_room',
|
||||
'channel_group_room_description',
|
||||
'self_name',
|
||||
'action_mark_as_read',
|
||||
'action_reply',
|
||||
'reply_hint',
|
||||
'reply_failed',
|
||||
];
|
||||
|
||||
// i18next uses named placeholders ({{inviter}}); Android string resources
|
||||
|
|
@ -59,9 +71,13 @@ const ANDROID_KEYS = [
|
|||
// inviter, roomName) always passes inviter in position 1, roomName in
|
||||
// position 2, regardless of how the translators order them in the JSON.
|
||||
// Adding a new placeholder: add it here AND update PushStrings accordingly.
|
||||
// `caller` reuses position 1: it only appears in missed_call_body, which
|
||||
// has no other placeholders, so the position assignment is keyed per-key
|
||||
// in practice — the table just enumerates every placeholder name we accept.
|
||||
const PLACEHOLDER_POSITIONS = {
|
||||
inviter: 1,
|
||||
roomName: 2,
|
||||
caller: 1,
|
||||
};
|
||||
|
||||
const LANGS = {
|
||||
|
|
@ -115,7 +131,7 @@ function verifyParity(bundles) {
|
|||
const locales = Object.keys(bundles);
|
||||
const [first, ...rest] = locales;
|
||||
const firstKeys = new Set(Object.keys(bundles[first]));
|
||||
for (const locale of rest) {
|
||||
rest.forEach((locale) => {
|
||||
const keys = new Set(Object.keys(bundles[locale]));
|
||||
const missingInOther = [...firstKeys].filter((k) => !keys.has(k));
|
||||
const extraInOther = [...keys].filter((k) => !firstKeys.has(k));
|
||||
|
|
@ -126,13 +142,13 @@ function verifyParity(bundles) {
|
|||
` Extra in ${locale}: ${JSON.stringify(extraInOther)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const key of ANDROID_KEYS) {
|
||||
for (const locale of locales) {
|
||||
});
|
||||
ANDROID_KEYS.forEach((key) => {
|
||||
locales.forEach((locale) => {
|
||||
if (typeof bundles[locale][key] !== 'string') {
|
||||
throw new Error(`Push.${key} missing or non-string in ${locale}.json`);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Placeholder tokens must match across locales for any given key —
|
||||
// a translator adding {{user}} on one side silently produces
|
||||
// literal-curly-brace output on the other surface.
|
||||
|
|
@ -146,7 +162,7 @@ function verifyParity(bundles) {
|
|||
return { locale, tokens };
|
||||
});
|
||||
const baseline = tokenSets[0];
|
||||
for (const entry of tokenSets.slice(1)) {
|
||||
tokenSets.slice(1).forEach((entry) => {
|
||||
const baselineArr = [...baseline.tokens].sort();
|
||||
const entryArr = [...entry.tokens].sort();
|
||||
if (baselineArr.length !== entryArr.length || baselineArr.some((t, i) => t !== entryArr[i])) {
|
||||
|
|
@ -156,8 +172,8 @@ function verifyParity(bundles) {
|
|||
`${entry.locale}=${JSON.stringify(entryArr)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function emitResource(locale, bundle, resDir) {
|
||||
|
|
@ -170,12 +186,12 @@ function emitResource(locale, bundle, resDir) {
|
|||
'-->',
|
||||
'<resources>',
|
||||
];
|
||||
for (const key of ANDROID_KEYS) {
|
||||
ANDROID_KEYS.forEach((key) => {
|
||||
const raw = bundle[key];
|
||||
const { text, placeholders } = convertPlaceholders(raw, locale, key);
|
||||
const formattedAttr = placeholders.size > 0 ? ' formatted="true"' : '';
|
||||
lines.push(` <string name="push_${key}"${formattedAttr}>${xmlEscape(text)}</string>`);
|
||||
}
|
||||
});
|
||||
lines.push('</resources>');
|
||||
lines.push('');
|
||||
const outPath = path.join(resDir, LANGS[locale], 'push_strings.xml');
|
||||
|
|
@ -191,15 +207,15 @@ function main() {
|
|||
}
|
||||
const resDir = outIdx !== -1 ? path.resolve(process.argv[outIdx + 1]) : DEFAULT_OUT;
|
||||
|
||||
const bundles = {};
|
||||
for (const locale of Object.keys(LANGS)) {
|
||||
bundles[locale] = readBundle(locale);
|
||||
}
|
||||
const bundles = Object.keys(LANGS).reduce((acc, locale) => {
|
||||
acc[locale] = readBundle(locale);
|
||||
return acc;
|
||||
}, {});
|
||||
verifyParity(bundles);
|
||||
for (const locale of Object.keys(LANGS)) {
|
||||
Object.keys(LANGS).forEach((locale) => {
|
||||
const outPath = emitResource(locale, bundles[locale], resDir);
|
||||
process.stdout.write(` wrote ${path.relative(ROOT, outPath)}\n`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
|
|||
>
|
||||
{stageToComplete.type === AuthType.Password && (
|
||||
<PasswordStage
|
||||
userId={mx.getUserId()!}
|
||||
userId={mx.getSafeUserId()}
|
||||
stageData={stageToComplete}
|
||||
onCancel={onCancel}
|
||||
submitAuthDict={action}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ function makeUIAAction<T>(
|
|||
authData: IAuthData,
|
||||
performAction: PerformAction<T>,
|
||||
resolve: (data: T) => void,
|
||||
reject: (error?: any) => void
|
||||
reject: (error?: unknown) => void
|
||||
): UIAAction<T> {
|
||||
const action: UIAAction<T> = {
|
||||
authData,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export const ImageOverlay = as<'div', ImageOverlayProps>(
|
|||
<Modal
|
||||
className={ModalWide}
|
||||
size="500"
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
|
||||
>
|
||||
{renderViewer({
|
||||
src,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,17 @@ export function Modal500({ requestClose, children }: Modal500Props) {
|
|||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal size="500" variant="Background">
|
||||
<Modal
|
||||
size="500"
|
||||
variant="Background"
|
||||
// Reset `--vojo-safe-top` for everything mounted inside the
|
||||
// dialog. The Android status-bar inset is reserved by each
|
||||
// page header's `padding-top: var(--vojo-safe-top)` for
|
||||
// top-of-screen surfaces — but a centred 500px modal sits
|
||||
// away from the screen edge, and the same padding inside it
|
||||
// just adds dead space above its header.
|
||||
style={{ ['--vojo-safe-top' as string]: '0px' }}
|
||||
>
|
||||
{/* PageRoot rendered inside the dialog (Settings,
|
||||
SpaceSettings, RoomSettings) would otherwise pick up
|
||||
the web horseshoe layout — void column + rounded
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { MouseEventHandler, createContext, useContext } from 'react';
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import { MsgType } from 'matrix-js-sdk';
|
||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||
import { Opts } from 'linkifyjs';
|
||||
|
|
@ -43,11 +43,10 @@ import { logMedia } from './message/attachment/streamMediaDebug';
|
|||
// in the timeline; pin-menu / message-search leave it null and fall back
|
||||
// to the legacy MImage / MVideo Attachment chrome.
|
||||
export type StreamMediaContextValue = {
|
||||
// Only `own` survives — it drives the bubble's asymmetric notch corner. The
|
||||
// sender nick used to be overlaid on the media via this context, but it's
|
||||
// now rendered ABOVE the media by the Stream name header (like text).
|
||||
own: boolean;
|
||||
username: string;
|
||||
senderId: string;
|
||||
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
|
||||
onUsernameContextMenu: MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
export const StreamMediaContext = createContext<StreamMediaContextValue | null>(null);
|
||||
export const useStreamMediaContext = (): StreamMediaContextValue | null =>
|
||||
|
|
@ -65,6 +64,12 @@ type RenderMessageContentProps = {
|
|||
htmlReactParserOptions: HTMLReactParserOptions;
|
||||
linkifyOpts: Opts;
|
||||
outlineAttachment?: boolean;
|
||||
// Threaded into `ImageContent` so its onClick can open the new
|
||||
// atom-driven horseshoe media viewer instead of the legacy
|
||||
// `<Overlay>` modal. Set by `RoomTimeline`; non-Room callers
|
||||
// (pin-menu, message search) leave it undefined and stay on the
|
||||
// legacy modal until they're migrated.
|
||||
eventId?: string;
|
||||
};
|
||||
export function RenderMessageContent({
|
||||
displayName,
|
||||
|
|
@ -78,6 +83,7 @@ export function RenderMessageContent({
|
|||
htmlReactParserOptions,
|
||||
linkifyOpts,
|
||||
outlineAttachment,
|
||||
eventId,
|
||||
}: RenderMessageContentProps) {
|
||||
const streamMedia = useStreamMediaContext();
|
||||
const renderUrlsPreview = (urls: string[]) => {
|
||||
|
|
@ -219,6 +225,7 @@ export function RenderMessageContent({
|
|||
<ImageContent
|
||||
{...props}
|
||||
autoPlay={mediaAutoLoad}
|
||||
eventId={eventId}
|
||||
renderImage={(p) => <Image {...p} loading="lazy" decoding="async" />}
|
||||
renderViewer={(p) => <ImageViewer {...p} />}
|
||||
/>
|
||||
|
|
@ -229,10 +236,6 @@ export function RenderMessageContent({
|
|||
<StreamMediaImage
|
||||
content={getContent()}
|
||||
own={streamMedia.own}
|
||||
overlay={streamMedia.username}
|
||||
senderId={streamMedia.senderId}
|
||||
onUsernameClick={streamMedia.onUsernameClick}
|
||||
onUsernameContextMenu={streamMedia.onUsernameContextMenu}
|
||||
renderImageContent={renderImageInside}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -258,6 +261,7 @@ export function RenderMessageContent({
|
|||
body={body}
|
||||
info={info}
|
||||
{...props}
|
||||
eventId={eventId}
|
||||
renderThumbnail={
|
||||
mediaAutoLoad
|
||||
? () => (
|
||||
|
|
@ -279,10 +283,6 @@ export function RenderMessageContent({
|
|||
<StreamMediaVideo
|
||||
content={getContent()}
|
||||
own={streamMedia.own}
|
||||
overlay={streamMedia.username}
|
||||
senderId={streamMedia.senderId}
|
||||
onUsernameClick={streamMedia.onUsernameClick}
|
||||
onUsernameContextMenu={streamMedia.onUsernameContextMenu}
|
||||
renderAsFile={renderFile}
|
||||
renderVideoContent={renderVideoInside}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export function SecretStorageRecoveryPassphrase({
|
|||
bits
|
||||
);
|
||||
|
||||
// matrix-js-sdk wants SecretStorageKeyDescriptionAesV1; our local type is structurally compatible but distinct.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
|
||||
|
||||
if (!match) {
|
||||
|
|
@ -131,6 +133,8 @@ export function SecretStorageRecoveryKey({
|
|||
async (recoveryKey) => {
|
||||
const decodedRecoveryKey = decodeRecoveryKey(recoveryKey);
|
||||
|
||||
// matrix-js-sdk wants SecretStorageKeyDescriptionAesV1; our local type is structurally compatible but distinct.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
|
||||
|
||||
if (!match) {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
|
|||
try {
|
||||
validatedAuthMetadata = validateAuthMetadata(authMetadata);
|
||||
} catch (e) {
|
||||
// Auth-metadata parsing failure is non-fatal; the client falls
|
||||
// back to legacy `.well-known` discovery. Surface to dev console.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
RestrictedAllowType,
|
||||
Room,
|
||||
} from 'matrix-js-sdk';
|
||||
import type { StateEvents } from 'matrix-js-sdk';
|
||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import { RoomType, StateEvent } from '../../../types/matrix/room';
|
||||
import { getViaServers } from '../../plugins/via-servers';
|
||||
|
|
@ -17,7 +18,7 @@ export const createRoomCreationContent = (
|
|||
allowFederation: boolean,
|
||||
additionalCreators: string[] | undefined
|
||||
): object => {
|
||||
const content: Record<string, any> = {};
|
||||
const content: Record<string, unknown> = {};
|
||||
if (typeof type === 'string') {
|
||||
content.type = type;
|
||||
}
|
||||
|
|
@ -152,11 +153,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
|||
if (data.parent) {
|
||||
await mx.sendStateEvent(
|
||||
data.parent.roomId,
|
||||
StateEvent.SpaceChild as any,
|
||||
StateEvent.SpaceChild as keyof StateEvents,
|
||||
{
|
||||
auto_join: false,
|
||||
suggested: false,
|
||||
via: [getMxIdServer(mx.getUserId() ?? '') ?? ''],
|
||||
via: [getMxIdServer(mx.getSafeUserId()) ?? ''],
|
||||
},
|
||||
result.room_id
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ import { onTabPress } from '../../../utils/keyboard';
|
|||
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
|
||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
|
||||
import { IEmoji, emojis } from '../../../plugins/emoji';
|
||||
import { IEmoji } from '../../../plugins/emoji';
|
||||
import { emojis } from '../../../plugins/emoji-data';
|
||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
|
|||
const roomAliasFromQueryText = (mx: MatrixClient, text: string) =>
|
||||
isRoomAlias(`#${text}`)
|
||||
? `#${text}`
|
||||
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
||||
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getSafeUserId())}`;
|
||||
|
||||
function UnknownRoomMentionItem({
|
||||
query,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ type MentionAutoCompleteHandler = (userId: string, name: string) => void;
|
|||
const userIdFromQueryText = (mx: MatrixClient, text: string) =>
|
||||
isUserId(`@${text}`)
|
||||
? `@${text}`
|
||||
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
||||
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getSafeUserId())}`;
|
||||
|
||||
function UnknownMentionItem({
|
||||
userId,
|
||||
|
|
@ -92,7 +92,7 @@ export function UserMentionAutocomplete({
|
|||
}: UserMentionAutocompleteProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const roomId: string = room.roomId!;
|
||||
const { roomId } = room;
|
||||
const roomAliasOrId = room.getCanonicalAlias() || roomId;
|
||||
const members = useRoomMembers(mx, roomId);
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ import { isKeyHotkey } from 'is-hotkey';
|
|||
import { Room } from 'matrix-js-sdk';
|
||||
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { IEmoji, emojiGroups, emojis } from '../../plugins/emoji';
|
||||
import { IEmoji } from '../../plugins/emoji';
|
||||
import { emojiGroups, emojis } from '../../plugins/emoji-data';
|
||||
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
||||
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
||||
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||
key={readerId}
|
||||
style={{ padding: `0 ${config.space.S200}` }}
|
||||
radii="400"
|
||||
onClick={(event) => {
|
||||
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
openProfile(
|
||||
room.roomId,
|
||||
space?.roomId,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ type RoomImagePackProps = {
|
|||
|
||||
export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
const userId = mx.getSafeUserId();
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const creators = useRoomCreators(room);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { useUserImagePack } from '../../hooks/useImagePacks';
|
|||
export function UserImagePack() {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const defaultPack = useMemo(() => new ImagePack(mx.getUserId() ?? '', {}, undefined), [mx]);
|
||||
const defaultPack = useMemo(() => new ImagePack(mx.getSafeUserId(), {}, undefined), [mx]);
|
||||
const imagePack = useUserImagePack();
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
|
|
|
|||
|
|
@ -1,133 +0,0 @@
|
|||
import React, { FormEventHandler, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Dialog,
|
||||
Overlay,
|
||||
OverlayCenter,
|
||||
OverlayBackdrop,
|
||||
Header,
|
||||
config,
|
||||
Box,
|
||||
Text,
|
||||
IconButton,
|
||||
Icon,
|
||||
Icons,
|
||||
Button,
|
||||
Input,
|
||||
color,
|
||||
} from 'folds';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { isRoomAlias, isRoomId } from '../../utils/matrix';
|
||||
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to';
|
||||
import { tryDecodeURIComponent } from '../../utils/dom';
|
||||
|
||||
type JoinAddressProps = {
|
||||
onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
|
||||
const { t } = useTranslation();
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
|
||||
const handleSubmit: FormEventHandler<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 +0,0 @@
|
|||
export * from './JoinAddressPrompt';
|
||||
199
src/app/components/members-list/MembersList.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
// Dawn-styled members sheet body. Renders a centred room hero
|
||||
// (avatar + name + count + e2ee + optional topic) and a flat
|
||||
// power-tag-grouped list of joined members. Mirrors the visual
|
||||
// language of the 1:1 peer-profile sheet (`UserHero` + info table)
|
||||
// so the two sheets read as one design system.
|
||||
//
|
||||
// No internal scroll container — the host (mobile horseshoe or
|
||||
// desktop side pane) wraps this in its own scroll surface. That
|
||||
// lets the host measure the natural content height for content-fit
|
||||
// rail sizing.
|
||||
//
|
||||
// Tap on a row opens the per-user profile via `useOpenUserRoomProfile`.
|
||||
// Atom mutual-exclusion (`useOpenUserRoomProfile` clears the members
|
||||
// atom) routes the transition cleanly: on desktop the right-pane
|
||||
// content swaps; on mobile group the same horseshoe silhouette
|
||||
// switches body (handled in `RoomViewMembersPanel`).
|
||||
//
|
||||
// Search / filter / sort were intentionally not ported — product asked
|
||||
// for a clean grouped list first. The legacy
|
||||
// `features/room/MembersDrawer.tsx` keeps those affordances and is
|
||||
// still used by `features/lobby/Lobby.tsx` for space lobbies.
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Avatar, Icon, Icons, Text } from 'folds';
|
||||
import { Room, RoomMember } from 'matrix-js-sdk';
|
||||
import classNames from 'classnames';
|
||||
import { TypingIndicator } from '../typing-indicator';
|
||||
import { UserAvatar } from '../user-avatar';
|
||||
import { Membership, MemberPowerTag } from '../../../types/matrix/room';
|
||||
import { GetMemberPowerTag, useFlattenPowerTagMembers } from '../../hooks/useMemberPowerTag';
|
||||
import { useMemberPowerSort } from '../../hooks/useMemberSort';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { useGetMemberPowerLevel, usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { RoomMembersHero } from './RoomMembersHero';
|
||||
import * as css from './styles.css';
|
||||
|
||||
type MembersListProps = {
|
||||
room: Room;
|
||||
members: RoomMember[];
|
||||
getPowerTag: GetMemberPowerTag;
|
||||
// Forwarded to `RoomMembersHero` — tap on hero avatar opens
|
||||
// full-view. Only the mobile horseshoe wires this; the desktop
|
||||
// side pane doesn't get an avatar-zoom path (the right-pane chrome
|
||||
// is fixed-width so there's no «expand to silhouette»).
|
||||
onHeroAvatarClick?: () => void;
|
||||
};
|
||||
|
||||
type MemberRowProps = {
|
||||
room: Room;
|
||||
member: RoomMember;
|
||||
typing: boolean;
|
||||
onOpenProfile: (userId: string, anchor: HTMLButtonElement) => void;
|
||||
};
|
||||
function MemberRow({ room, member, typing, onOpenProfile }: MemberRowProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const name =
|
||||
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
|
||||
// First-name display per design: only what fits before the first space
|
||||
// (or the whole string if no space). Keeps each row narrow against the
|
||||
// ~240 px panel without horizontal ellipsis kicking in immediately.
|
||||
const shortName = name.split(/\s+/)[0] ?? name;
|
||||
const avatarMxcUrl = member.getMxcAvatarUrl();
|
||||
const avatarUrl = avatarMxcUrl
|
||||
? mx.mxcUrlToHttp(avatarMxcUrl, 64, 64, 'crop', undefined, false, useAuthentication)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.MemberRow}
|
||||
onClick={(evt) => onOpenProfile(member.userId, evt.currentTarget)}
|
||||
>
|
||||
<Avatar size="200" className={css.MemberAvatar}>
|
||||
<UserAvatar
|
||||
userId={member.userId}
|
||||
src={avatarUrl ?? undefined}
|
||||
alt={name}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
<div className={css.MemberMainCol}>
|
||||
<div className={css.MemberNameRow}>
|
||||
<Text as="span" size="T300" truncate className={css.MemberName}>
|
||||
{shortName}
|
||||
</Text>
|
||||
</div>
|
||||
{typing && (
|
||||
<span className={css.MemberTyping}>
|
||||
<TypingIndicator size="300" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MembersList({ room, members, getPowerTag, onHeroAvatarClick }: MembersListProps) {
|
||||
const openUserRoomProfile = useOpenUserRoomProfile();
|
||||
const space = useSpaceOptionally();
|
||||
const typingMembers = useRoomTypingMember(room.roomId);
|
||||
|
||||
// Filter + sort BEFORE flatten — `useFlattenPowerTagMembers` emits a
|
||||
// new group header whenever the current member's tag differs from the
|
||||
// previous one's tag. Feeding it an unsorted list produces duplicate
|
||||
// role headers ("admin · 1, member · 5, admin · 1") and the row
|
||||
// counts go wrong. Mirrors the legacy `MembersDrawer.tsx::filteredMembers`
|
||||
// pipeline (filter joined-only → sort by power).
|
||||
//
|
||||
// Joined-only matches the design canon's "Участники · 28" — banned /
|
||||
// kicked / left memberships have their own surfaces in moderation
|
||||
// settings, not in the members sheet.
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const creators = useRoomCreators(room);
|
||||
const getPowerLevel = useGetMemberPowerLevel(powerLevels);
|
||||
const memberPowerSort = useMemberPowerSort(creators, getPowerLevel);
|
||||
const processedMembers = useMemo(
|
||||
() => members.filter((m) => m.membership === Membership.Join).sort(memberPowerSort),
|
||||
[members, memberPowerSort]
|
||||
);
|
||||
const flat = useFlattenPowerTagMembers(processedMembers, getPowerTag);
|
||||
|
||||
// Index of the first tag entry in the flat list — used to drop the
|
||||
// top padding on the first section header so it lines up tight with
|
||||
// the rule above (the List's top border).
|
||||
const firstTagIndex = flat.findIndex((entry) => !('userId' in entry));
|
||||
|
||||
// Per-tag member count for the section header — cheap (single pass)
|
||||
// and lets the design's "admin · 1 / mod · 1 / участники · 22" labels
|
||||
// read truthfully.
|
||||
const tagCounts = useMemo(() => {
|
||||
const counts = new Map<MemberPowerTag, number>();
|
||||
flat.forEach((entry) => {
|
||||
if ('userId' in entry) return;
|
||||
counts.set(entry, 0);
|
||||
});
|
||||
let currentTag: MemberPowerTag | undefined;
|
||||
flat.forEach((entry) => {
|
||||
if (!('userId' in entry)) {
|
||||
currentTag = entry;
|
||||
return;
|
||||
}
|
||||
if (currentTag) counts.set(currentTag, (counts.get(currentTag) ?? 0) + 1);
|
||||
});
|
||||
return counts;
|
||||
}, [flat]);
|
||||
|
||||
const typingByUser = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
typingMembers.forEach((receipt) => set.add(receipt.userId));
|
||||
return set;
|
||||
}, [typingMembers]);
|
||||
|
||||
const handleOpenProfile = (userId: string, anchor: HTMLButtonElement) => {
|
||||
openUserRoomProfile(room.roomId, space?.roomId, userId, anchor.getBoundingClientRect(), 'Left');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={css.Root}>
|
||||
<RoomMembersHero room={room} onAvatarClick={onHeroAvatarClick} />
|
||||
<div className={css.List}>
|
||||
{flat.map((entry, idx) => {
|
||||
if (!('userId' in entry)) {
|
||||
const count = tagCounts.get(entry) ?? 0;
|
||||
return (
|
||||
<div
|
||||
key={`tag-${entry.name}`}
|
||||
className={classNames(css.GroupLabel, idx === firstTagIndex && css.GroupLabelFirst)}
|
||||
>
|
||||
<Text as="span" size="T200" className={css.GroupLabelText}>
|
||||
{entry.name}
|
||||
</Text>
|
||||
<Text as="span" size="T200" className={css.GroupLabelCount}>
|
||||
{count}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MemberRow
|
||||
key={`user-${entry.userId}`}
|
||||
room={room}
|
||||
member={entry}
|
||||
typing={typingByUser.has(entry.userId)}
|
||||
onOpenProfile={handleOpenProfile}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/app/components/members-list/RoomMembersHero.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// Centred hero block on top of the group-room members sheet —
|
||||
// counterpart of `UserHero` from the 1:1 peer-profile sheet.
|
||||
// Renders: large gradient room avatar, room name, subline («N
|
||||
// members · e2ee») and optional topic clamped to a few lines.
|
||||
// Consumed by both the mobile horseshoe (`RoomViewMembersPanel`)
|
||||
// and the desktop right-side pane (`RoomViewMembersSidePanel`)
|
||||
// via `MembersList`.
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Icon, Icons, Text } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { RoomAvatar, RoomIcon } from '../room-avatar';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
||||
import { useRoomMemberCount } from '../../hooks/useRoomMemberCount';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { millify } from '../../plugins/millify';
|
||||
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
|
||||
import * as css from './styles.css';
|
||||
|
||||
type RoomMembersHeroProps = {
|
||||
room: Room;
|
||||
// Optional avatar-tap handler. When provided the avatar surface
|
||||
// becomes a button (one tab stop, focus ring). The host decides
|
||||
// what tap does — typically swap the panel body to a full-view of
|
||||
// the avatar, mirroring the 1:1 profile-horseshoe behaviour.
|
||||
onAvatarClick?: () => void;
|
||||
};
|
||||
|
||||
export function RoomMembersHero({ room, onAvatarClick }: RoomMembersHeroProps) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
// Group sheet: use the room's own avatar, NOT the peer fallback —
|
||||
// the 1:1 peer-fallback path lives in `RoomViewHeaderDm` for the
|
||||
// chat header, and here we're showing the room itself.
|
||||
//
|
||||
// No width/height passed to `mxcUrlToHttp` — same as `UserRoomProfile`
|
||||
// does for the 1:1 hero. Synapse returns the original upload, the
|
||||
// browser scales it via CSS without resampling artefacts. Asking for
|
||||
// a small thumbnail (e.g. 192x192) routes through Synapse's JPEG
|
||||
// recompression pipeline and ships visibly «pixelated» avatars on
|
||||
// high-DPR phones (96 CSS px = 192–288 native px on 2x/3x screens,
|
||||
// so the thumb is right at the edge before the recompression hits).
|
||||
const avatarMxc = useRoomAvatar(room, false);
|
||||
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
|
||||
const name = useRoomName(room);
|
||||
const topic = useRoomTopic(room);
|
||||
const memberCount = useRoomMemberCount(room);
|
||||
const encrypted = !!useStateEvent(room, StateEvent.RoomEncryption);
|
||||
|
||||
// Drop the folds `<Avatar size="…">` wrapper here on purpose —
|
||||
// `RoomAvatar` already renders the folds `AvatarImage` / `AvatarFallback`
|
||||
// primitives with our `.RoomAvatar` class that fills the parent
|
||||
// (`width: 100% / height: 100%`). Wrapping it in another sized
|
||||
// `<Avatar>` double-wraps the surface and the inner avatar ends up
|
||||
// smaller than its container, drifting off-centre. Mirrors the
|
||||
// pattern `UserHero` uses for the 1:1 profile sheet.
|
||||
const avatarNode = (
|
||||
<span className={css.HeroAvatar} aria-hidden={onAvatarClick ? undefined : true}>
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon size="600" joinRule={room.getJoinRule()} roomType={room.getType()} />
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box direction="Column" alignItems="Center" gap="200" className={css.HeroRoot}>
|
||||
{onAvatarClick ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAvatarClick}
|
||||
className={css.HeroAvatarButton}
|
||||
aria-label={t('Room.expand_avatar', { defaultValue: 'Open avatar' })}
|
||||
>
|
||||
{avatarNode}
|
||||
</button>
|
||||
) : (
|
||||
avatarNode
|
||||
)}
|
||||
|
||||
<Text size="H4" align="Center" className={classNames(BreakWord, LineClamp3)} title={name}>
|
||||
{name}
|
||||
</Text>
|
||||
|
||||
<Box alignItems="Center" gap="200" className={css.HeroSubline}>
|
||||
<Text as="span" size="T200" className={css.HeroMembersCount}>
|
||||
{t('Room.members_sheet_title', {
|
||||
count: memberCount,
|
||||
formattedCount: millify(memberCount),
|
||||
})}
|
||||
</Text>
|
||||
{encrypted && (
|
||||
<>
|
||||
<span className={css.HeroBullet} aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span className={css.HeroE2ee}>
|
||||
<Icon size="50" src={Icons.Lock} filled />
|
||||
<Text as="span" size="T200">
|
||||
{t('Room.encrypted_short')}
|
||||
</Text>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{topic && (
|
||||
<Text
|
||||
as="p"
|
||||
size="T200"
|
||||
align="Center"
|
||||
className={classNames(BreakWord, LineClamp3, css.HeroTopic)}
|
||||
>
|
||||
{topic}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
187
src/app/components/members-list/styles.css.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
// Outer column that owns the sheet body — hero + group list. The host
|
||||
// (mobile horseshoe / desktop side pane) decides the surrounding
|
||||
// scroll strategy; this component just renders content flat so the
|
||||
// host can measure its `scrollHeight` for content-fit rail sizing.
|
||||
export const Root = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
// ── Hero ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Centred avatar + name + subline + topic. Visual rhyme with
|
||||
// `UserHero` from the 1:1 peer profile sheet — same gap rhythm and
|
||||
// padding so the two sheets read as one design system.
|
||||
export const HeroRoot = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: `${config.space.S500} ${config.space.S400} ${config.space.S300}`,
|
||||
width: '100%',
|
||||
gap: toRem(8),
|
||||
});
|
||||
|
||||
// Avatar wrapper — circular 96 px to mirror the 1:1 `UserHero`. The
|
||||
// `RoomAvatar` primitive renders as an absolutely-filling image /
|
||||
// fallback inside this fixed-size container, so the avatar is its
|
||||
// own boundary and stays exactly centred under the column. Using
|
||||
// `display: block` (not inline-flex) avoids inline-baseline padding
|
||||
// that can offset the visual centre by 1-2 px on some browsers.
|
||||
// `boxShadow` ring matches the Dawn outline rhythm used by
|
||||
// `UserHero` (chat-list rows, avatar surface plate).
|
||||
export const HeroAvatar = style({
|
||||
position: 'relative',
|
||||
display: 'block',
|
||||
width: toRem(96),
|
||||
height: toRem(96),
|
||||
borderRadius: '50%',
|
||||
flexShrink: 0,
|
||||
boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Background.Container}`,
|
||||
});
|
||||
|
||||
// Native button chrome reset for the tap-to-zoom avatar wrapper.
|
||||
// Mirrors `UserHero.HeroAvatarButton`. Keeps the visible avatar
|
||||
// pixel-identical to the non-clickable path so the user can't tell
|
||||
// «is this tappable?» from look alone — the focus ring on
|
||||
// keyboard-tab is the affordance.
|
||||
export const HeroAvatarButton = style({
|
||||
display: 'inline-flex',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
// Subline row beneath the name — «N members · e2ee» layout mirrors
|
||||
// the user-profile hero's presence + e2ee row.
|
||||
export const HeroSubline = style({
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
});
|
||||
|
||||
export const HeroMembersCount = style({
|
||||
color: color.Surface.OnContainer,
|
||||
opacity: 0.7,
|
||||
});
|
||||
|
||||
export const HeroBullet = style({
|
||||
color: color.Surface.ContainerLine,
|
||||
});
|
||||
|
||||
export const HeroE2ee = style({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: toRem(4),
|
||||
color: color.Success.Main,
|
||||
});
|
||||
|
||||
// Optional topic line — muted secondary text, clamped to keep the
|
||||
// hero compact. Long topics overflow into the inner list scroll
|
||||
// only if the user keeps reading via the host's scroll handle.
|
||||
export const HeroTopic = style({
|
||||
marginTop: toRem(4),
|
||||
color: color.Surface.OnContainer,
|
||||
opacity: 0.65,
|
||||
maxWidth: toRem(360),
|
||||
});
|
||||
|
||||
// ── List ────────────────────────────────────────────────────────
|
||||
|
||||
// Spans the full width below the hero. Top border separates the
|
||||
// hero block from the role-grouped list — same divider rhythm as
|
||||
// the user-profile `InfoSection` rule above the chips.
|
||||
export const List = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: `0 0 ${config.space.S200}`,
|
||||
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
});
|
||||
|
||||
// Group divider — uppercase muted label + numeric count. Spaced apart
|
||||
// to match `stream-v2-dawn.jsx` ("ADMIN · 1", "MOD · 1", "BOTS · 1",
|
||||
// "УЧАСТНИКИ · 22", "ГОСТИ · 4").
|
||||
export const GroupLabel = style({
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
padding: `${config.space.S400} ${config.space.S400} ${config.space.S200}`,
|
||||
});
|
||||
|
||||
export const GroupLabelFirst = style({
|
||||
paddingTop: config.space.S300,
|
||||
});
|
||||
|
||||
export const GroupLabelText = style({
|
||||
color: color.Surface.OnContainer,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: toRem(1),
|
||||
fontWeight: 600,
|
||||
opacity: 0.7,
|
||||
});
|
||||
|
||||
export const GroupLabelCount = style({
|
||||
color: color.Surface.OnContainer,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
opacity: 0.5,
|
||||
});
|
||||
|
||||
// Member row — full-width button so the entire surface is tappable
|
||||
// for opening the per-user profile. `text-align: left` because the
|
||||
// `<button>` resets it to `center` by default. Hover surface raises
|
||||
// to `Surface.ContainerHover`, matching the chat-list rows.
|
||||
export const MemberRow = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S200,
|
||||
width: '100%',
|
||||
padding: `${config.space.S200} ${config.space.S400}`,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: color.Surface.OnContainer,
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
minHeight: toRem(40),
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
backgroundColor: color.Surface.ContainerHover,
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: `${config.borderWidth.B400} solid ${color.Primary.Main}`,
|
||||
outlineOffset: `-${config.borderWidth.B400}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const MemberAvatar = style({
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const MemberMainCol = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
export const MemberNameRow = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S200,
|
||||
minWidth: 0,
|
||||
});
|
||||
|
||||
export const MemberName = style({
|
||||
minWidth: 0,
|
||||
flex: '0 1 auto',
|
||||
});
|
||||
|
||||
export const MemberTyping = style({
|
||||
display: 'inline-flex',
|
||||
marginTop: toRem(2),
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import parse, { HTMLReactParserOptions } from 'html-react-parser';
|
||||
import Linkify from 'linkify-react';
|
||||
import { Opts } from 'linkifyjs';
|
||||
|
|
@ -21,16 +21,30 @@ export function RenderBody({
|
|||
htmlReactParserOptions,
|
||||
linkifyOpts,
|
||||
}: RenderBodyProps) {
|
||||
// `sanitizeCustomHtml` (a full sanitize-html DOM pass) + `parse` are the
|
||||
// hottest per-render cost on the timeline — RoomTimeline re-renders every
|
||||
// visible Message on each live event. Both `customBody` and the memoized
|
||||
// `htmlReactParserOptions` (RoomTimeline.tsx:654) are stable across those
|
||||
// renders, so caching the parsed tree skips the work for unchanged rows.
|
||||
const parsedCustomBody = useMemo(
|
||||
() => (customBody ? parse(sanitizeCustomHtml(customBody), htmlReactParserOptions) : null),
|
||||
[customBody, htmlReactParserOptions]
|
||||
);
|
||||
// Plaintext path: scaleSystemEmoji walks the string for unicode emoji and
|
||||
// highlightText re-splits on the search regex — both pure functions of
|
||||
// (body, highlightRegex), so memoize to match the custom-body fast path.
|
||||
const plainBody = useMemo(
|
||||
() =>
|
||||
highlightRegex
|
||||
? highlightText(highlightRegex, scaleSystemEmoji(body))
|
||||
: scaleSystemEmoji(body),
|
||||
[body, highlightRegex]
|
||||
);
|
||||
|
||||
if (body === '') <MessageEmptyContent />;
|
||||
if (customBody) {
|
||||
if (customBody === '') <MessageEmptyContent />;
|
||||
return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
|
||||
return parsedCustomBody;
|
||||
}
|
||||
return (
|
||||
<Linkify options={linkifyOpts}>
|
||||
{highlightRegex
|
||||
? highlightText(highlightRegex, scaleSystemEmoji(body))
|
||||
: scaleSystemEmoji(body)}
|
||||
</Linkify>
|
||||
);
|
||||
return <Linkify options={linkifyOpts}>{plainBody}</Linkify>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,50 +45,6 @@ export const StreamMediaBubble = recipe({
|
|||
},
|
||||
});
|
||||
|
||||
// Username chip — anchored top-left so its text baseline lands on the
|
||||
// rail-dot baseline, matching the Username header in text bubbles.
|
||||
// StreamMediaBubble has no real border (frame is a pseudo-element above
|
||||
// the image), so the chip's coordinate space is flush with the bubble's
|
||||
// outer edge — no off-by-one compensation needed.
|
||||
export const StreamMediaUsernameOverlay = style({
|
||||
position: 'absolute',
|
||||
top: config.space.S200,
|
||||
left: config.space.S200,
|
||||
maxWidth: `calc(100% - ${config.space.S400})`,
|
||||
zIndex: 2,
|
||||
// Wrapper is decorative — clicks pass through to the image. The
|
||||
// <button> child opts back in via pointer-events: auto.
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
export const StreamMediaUsernameLabel = style({
|
||||
display: 'inline-block',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
pointerEvents: 'auto',
|
||||
cursor: 'pointer',
|
||||
// Plain text on the image — no fill / border / radius. text-shadow keeps
|
||||
// the lavender legible against bright photo regions; saturated photos
|
||||
// already contrast against Primary.OnContainer on average.
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
color: color.Primary.OnContainer,
|
||||
fontSize: toRem(11),
|
||||
lineHeight: config.lineHeight.T200,
|
||||
fontWeight: 600,
|
||||
fontFamily: 'inherit',
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.7)',
|
||||
selectors: {
|
||||
'&:hover, &:focus-visible': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Caption mini-bubble under the image. Uniform R500 corners — the asymmetric
|
||||
// notch lives on the image-bubble itself; stacking two notch'd rectangles
|
||||
// reads worse than one notched + one rounded.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { MouseEventHandler, ReactNode } from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import {
|
||||
IImageContent,
|
||||
MATRIX_SPOILER_PROPERTY_NAME,
|
||||
|
|
@ -11,42 +11,23 @@ import { StreamMediaShell } from './StreamMediaShell';
|
|||
// extensions) are useless as alt-text — bridged photos from
|
||||
// mautrix-telegram set body to the source filename. Treat those as
|
||||
// decorative; users with a real caption keep theirs.
|
||||
const FILENAME_ALT_RE = /^(image|img[_-].*|screenshot[_-].*|.+\.(?:jpe?g|png|webp|gif|bmp|svg|heic|avif|tiff?))$/i;
|
||||
const FILENAME_ALT_RE =
|
||||
/^(image|img[_-].*|screenshot[_-].*|.+\.(?:jpe?g|png|webp|gif|bmp|svg|heic|avif|tiff?))$/i;
|
||||
const altFor = (body?: string): string => (body && !FILENAME_ALT_RE.test(body) ? body : '');
|
||||
|
||||
export type StreamMediaImageProps = {
|
||||
content: IImageContent;
|
||||
own: boolean;
|
||||
overlay?: ReactNode;
|
||||
onUsernameClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
onUsernameContextMenu?: MouseEventHandler<HTMLButtonElement>;
|
||||
senderId?: string;
|
||||
renderImageContent: (props: RenderImageContentProps) => ReactNode;
|
||||
};
|
||||
|
||||
export function StreamMediaImage({
|
||||
content,
|
||||
own,
|
||||
overlay,
|
||||
onUsernameClick,
|
||||
onUsernameContextMenu,
|
||||
senderId,
|
||||
renderImageContent,
|
||||
}: StreamMediaImageProps) {
|
||||
export function StreamMediaImage({ content, own, renderImageContent }: StreamMediaImageProps) {
|
||||
const imgInfo = content.info;
|
||||
const mxcUrl = content.file?.url ?? content.url;
|
||||
if (typeof mxcUrl !== 'string') return null;
|
||||
|
||||
return (
|
||||
<StreamMediaShell
|
||||
naturalW={imgInfo?.w}
|
||||
naturalH={imgInfo?.h}
|
||||
own={own}
|
||||
overlay={overlay}
|
||||
senderId={senderId}
|
||||
onUsernameClick={onUsernameClick}
|
||||
onUsernameContextMenu={onUsernameContextMenu}
|
||||
>
|
||||
<StreamMediaShell naturalW={imgInfo?.w} naturalH={imgInfo?.h} own={own}>
|
||||
{renderImageContent({
|
||||
body: altFor(content.body),
|
||||
info: imgInfo,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import React, { MouseEventHandler, ReactNode, useRef } from 'react';
|
||||
import React, { ReactNode, useRef } from 'react';
|
||||
import { toRem } from 'folds';
|
||||
import {
|
||||
StreamMediaBubble,
|
||||
StreamMediaUsernameLabel,
|
||||
StreamMediaUsernameOverlay,
|
||||
} from './StreamMedia.css';
|
||||
import { StreamMediaBubble } from './StreamMedia.css';
|
||||
import { logMedia, useMediaMeasureDebug } from './streamMediaDebug';
|
||||
|
||||
const STREAM_MEDIA_MAX_DIM = 320;
|
||||
|
|
@ -15,10 +11,6 @@ export type StreamMediaShellProps = {
|
|||
naturalW?: number;
|
||||
naturalH?: number;
|
||||
own: boolean;
|
||||
overlay?: ReactNode;
|
||||
senderId?: string;
|
||||
onUsernameClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
onUsernameContextMenu?: MouseEventHandler<HTMLButtonElement>;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
|
|
@ -32,6 +24,8 @@ function computeBoxStyle(naturalW?: number, naturalH?: number): React.CSSPropert
|
|||
// `object-fit: cover` (image) / `contain` (video) on the inner element.
|
||||
const naturalAspect = naturalW && naturalH ? naturalW / naturalH : NaN;
|
||||
if (
|
||||
!naturalW ||
|
||||
!naturalH ||
|
||||
!Number.isFinite(naturalAspect) ||
|
||||
naturalAspect < STREAM_MEDIA_MIN_ASPECT ||
|
||||
naturalAspect > STREAM_MEDIA_MAX_ASPECT
|
||||
|
|
@ -39,31 +33,18 @@ function computeBoxStyle(naturalW?: number, naturalH?: number): React.CSSPropert
|
|||
return { width: toRem(STREAM_MEDIA_MAX_DIM), height: toRem(STREAM_MEDIA_MAX_DIM) };
|
||||
}
|
||||
if (naturalAspect >= 1) {
|
||||
const w = Math.min(STREAM_MEDIA_MAX_DIM, naturalW!);
|
||||
const w = Math.min(STREAM_MEDIA_MAX_DIM, naturalW);
|
||||
return { width: toRem(w), height: toRem(w / naturalAspect) };
|
||||
}
|
||||
const h = Math.min(STREAM_MEDIA_MAX_DIM, naturalH!);
|
||||
const h = Math.min(STREAM_MEDIA_MAX_DIM, naturalH);
|
||||
return { width: toRem(h * naturalAspect), height: toRem(h) };
|
||||
}
|
||||
|
||||
// Shared chrome for image / video timeline bubbles: square-ish bubble with
|
||||
// asymmetric notch + 1px pseudo-frame, and the sender username overlaid
|
||||
// top-left as a text chip. Caller plugs in the actual media renderer as
|
||||
// `children`.
|
||||
//
|
||||
// Tab order: chip rendered BEFORE children so keyboard focus visits the
|
||||
// username (top-left visual) before the media tap target — matches reading
|
||||
// order.
|
||||
export function StreamMediaShell({
|
||||
naturalW,
|
||||
naturalH,
|
||||
own,
|
||||
overlay,
|
||||
senderId,
|
||||
onUsernameClick,
|
||||
onUsernameContextMenu,
|
||||
children,
|
||||
}: StreamMediaShellProps) {
|
||||
// asymmetric notch + 1px pseudo-frame. Caller plugs in the actual media
|
||||
// renderer as `children`. The sender nick is rendered ABOVE the media by the
|
||||
// Stream layout's name header (like text messages), not overlaid on the image.
|
||||
export function StreamMediaShell({ naturalW, naturalH, own, children }: StreamMediaShellProps) {
|
||||
const bubbleRef = useRef<HTMLDivElement>(null);
|
||||
const computedStyle = computeBoxStyle(naturalW, naturalH);
|
||||
|
||||
|
|
@ -71,8 +52,6 @@ export function StreamMediaShell({
|
|||
own,
|
||||
naturalW,
|
||||
naturalH,
|
||||
overlayPresent: !!overlay,
|
||||
senderId,
|
||||
computedStyle: { ...computedStyle },
|
||||
});
|
||||
|
||||
|
|
@ -80,25 +59,6 @@ export function StreamMediaShell({
|
|||
|
||||
return (
|
||||
<div ref={bubbleRef} className={StreamMediaBubble({ own })} style={computedStyle}>
|
||||
{overlay && (
|
||||
<div className={StreamMediaUsernameOverlay}>
|
||||
<button
|
||||
type="button"
|
||||
className={StreamMediaUsernameLabel}
|
||||
data-user-id={senderId}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUsernameClick?.(e);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.stopPropagation();
|
||||
onUsernameContextMenu?.(e);
|
||||
}}
|
||||
>
|
||||
{overlay}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { MouseEventHandler, ReactNode } from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import {
|
||||
IVideoContent,
|
||||
MATRIX_SPOILER_PROPERTY_NAME,
|
||||
|
|
@ -11,10 +11,6 @@ import { StreamMediaShell } from './StreamMediaShell';
|
|||
export type StreamMediaVideoProps = {
|
||||
content: IVideoContent;
|
||||
own: boolean;
|
||||
overlay?: ReactNode;
|
||||
onUsernameClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
onUsernameContextMenu?: MouseEventHandler<HTMLButtonElement>;
|
||||
senderId?: string;
|
||||
renderAsFile: () => ReactNode;
|
||||
renderVideoContent: (props: RenderVideoContentProps) => ReactNode;
|
||||
};
|
||||
|
|
@ -22,10 +18,6 @@ export type StreamMediaVideoProps = {
|
|||
export function StreamMediaVideo({
|
||||
content,
|
||||
own,
|
||||
overlay,
|
||||
onUsernameClick,
|
||||
onUsernameContextMenu,
|
||||
senderId,
|
||||
renderAsFile,
|
||||
renderVideoContent,
|
||||
}: StreamMediaVideoProps) {
|
||||
|
|
@ -42,15 +34,7 @@ export function StreamMediaVideo({
|
|||
}
|
||||
|
||||
return (
|
||||
<StreamMediaShell
|
||||
naturalW={videoInfo.w}
|
||||
naturalH={videoInfo.h}
|
||||
own={own}
|
||||
overlay={overlay}
|
||||
senderId={senderId}
|
||||
onUsernameClick={onUsernameClick}
|
||||
onUsernameContextMenu={onUsernameContextMenu}
|
||||
>
|
||||
<StreamMediaShell naturalW={videoInfo.w} naturalH={videoInfo.h} own={own}>
|
||||
{renderVideoContent({
|
||||
body: content.body || 'Video',
|
||||
info: videoInfo,
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
|
|||
<Modal
|
||||
className={ModalWide}
|
||||
size="500"
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
|
||||
>
|
||||
{renderViewer({
|
||||
name: body,
|
||||
|
|
@ -203,7 +203,7 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
|
|||
<Modal
|
||||
className={ModalWide}
|
||||
size="500"
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
|
||||
>
|
||||
{renderViewer({
|
||||
name: body,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../util
|
|||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { ModalWide } from '../../../styles/Modal.css';
|
||||
import { validBlurHash } from '../../../utils/blurHash';
|
||||
import { useMediaViewerHost } from '../../../features/room/mediaViewerHostContext';
|
||||
import { useOpenMediaViewer } from '../../../state/hooks/mediaViewer';
|
||||
|
||||
type RenderViewerProps = {
|
||||
src: string;
|
||||
|
|
@ -44,7 +46,17 @@ type RenderImageProps = {
|
|||
onLoad: () => void;
|
||||
onError: () => void;
|
||||
onClick: () => void;
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLImageElement>) => void;
|
||||
tabIndex: number;
|
||||
// `role="button"` so assistive tech announces the clickable
|
||||
// image as a button rather than a plain image. Paired with
|
||||
// `aria-label` and an Enter/Space `onKeyDown` to make the
|
||||
// affordance keyboard-activatable per WAI-ARIA. Element-Web
|
||||
// wraps in `<AccessibleButton>` — we keep the bare `<img>` to
|
||||
// avoid relayout, which works because folds' `Image` is
|
||||
// `as<'img'>` and just spreads these props onto the DOM node.
|
||||
role: 'button';
|
||||
'aria-label': string;
|
||||
};
|
||||
export type ImageContentProps = {
|
||||
body: string;
|
||||
|
|
@ -55,6 +67,13 @@ export type ImageContentProps = {
|
|||
autoPlay?: boolean;
|
||||
markedAsSpoiler?: boolean;
|
||||
spoilerReason?: string;
|
||||
// When provided AND the `MediaViewerHostContext` is non-null,
|
||||
// clicking the thumbnail opens the atom-driven horseshoe viewer
|
||||
// (mobile bottom-up sheet / desktop right pane) instead of the
|
||||
// legacy full-screen `<Overlay>` viewer. Non-Room surfaces
|
||||
// (pin-menu, message search) leave the host context as `null` and
|
||||
// therefore keep the legacy modal even if they pass `eventId`.
|
||||
eventId?: string;
|
||||
renderViewer: (props: RenderViewerProps) => ReactNode;
|
||||
renderImage: (props: RenderImageProps) => ReactNode;
|
||||
};
|
||||
|
|
@ -70,6 +89,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||
autoPlay,
|
||||
markedAsSpoiler,
|
||||
spoilerReason,
|
||||
eventId,
|
||||
renderViewer,
|
||||
renderImage,
|
||||
...props
|
||||
|
|
@ -79,12 +99,37 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
||||
const host = useMediaViewerHost();
|
||||
const openMediaViewer = useOpenMediaViewer();
|
||||
const useAtomViewer = !!(host && eventId);
|
||||
|
||||
const [load, setLoad] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [viewer, setViewer] = useState(false);
|
||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||
|
||||
const handleOpen = () => {
|
||||
if (useAtomViewer && host && eventId) {
|
||||
// The viewer body re-resolves + decrypts the media itself,
|
||||
// owning the blob-URL lifecycle so it can revoke on close.
|
||||
// We deliberately don't pass `srcState.data` here even when
|
||||
// it's available — pinning a blob URL into the atom would
|
||||
// leak it (the atom outlives the timeline thumbnail).
|
||||
openMediaViewer({
|
||||
roomId: host.roomId,
|
||||
eventId,
|
||||
kind: 'image',
|
||||
url,
|
||||
body,
|
||||
info,
|
||||
encInfo,
|
||||
mimeType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setViewer(true);
|
||||
};
|
||||
|
||||
const [srcState, loadSrc] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
||||
|
|
@ -118,7 +163,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||
|
||||
return (
|
||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
{!useAtomViewer && srcState.status === AsyncStatus.Success && (
|
||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
|
|
@ -132,7 +177,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||
<Modal
|
||||
className={ModalWide}
|
||||
size="500"
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
|
||||
>
|
||||
{renderViewer({
|
||||
src: srcState.data,
|
||||
|
|
@ -168,15 +213,25 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||
</Box>
|
||||
)}
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||
<Box
|
||||
className={classNames(css.AbsoluteContainer, blurred ? css.Blur : css.ImageClickable)}
|
||||
>
|
||||
{renderImage({
|
||||
alt: body,
|
||||
title: body,
|
||||
src: srcState.data,
|
||||
onLoad: handleLoad,
|
||||
onError: handleError,
|
||||
onClick: () => setViewer(true),
|
||||
onClick: handleOpen,
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleOpen();
|
||||
}
|
||||
},
|
||||
tabIndex: 0,
|
||||
role: 'button',
|
||||
'aria-label': body || 'Open media',
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ import {
|
|||
} from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { validBlurHash } from '../../../utils/blurHash';
|
||||
import { useMediaViewerHost } from '../../../features/room/mediaViewerHostContext';
|
||||
import { useOpenMediaViewer } from '../../../state/hooks/mediaViewer';
|
||||
|
||||
type RenderVideoProps = {
|
||||
title: string;
|
||||
|
|
@ -50,6 +52,14 @@ type VideoContentProps = {
|
|||
autoPlay?: boolean;
|
||||
markedAsSpoiler?: boolean;
|
||||
spoilerReason?: string;
|
||||
// When provided AND `MediaViewerHostContext` is non-null, tapping
|
||||
// the thumbnail opens the atom-driven horseshoe viewer for video
|
||||
// playback instead of loading + playing inline (which hands off to
|
||||
// the browser's native video-element fullscreen when the user hits
|
||||
// the controls' expand button — that's why the user used to see
|
||||
// Chrome's default video viewer). Non-Room surfaces leave the
|
||||
// host context as `null` and keep the inline player.
|
||||
eventId?: string;
|
||||
renderThumbnail?: () => ReactNode;
|
||||
renderVideo: (props: RenderVideoProps) => ReactNode;
|
||||
};
|
||||
|
|
@ -65,6 +75,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||
autoPlay,
|
||||
markedAsSpoiler,
|
||||
spoilerReason,
|
||||
eventId,
|
||||
renderThumbnail,
|
||||
renderVideo,
|
||||
...props
|
||||
|
|
@ -74,6 +85,9 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
||||
const host = useMediaViewerHost();
|
||||
const openMediaViewer = useOpenMediaViewer();
|
||||
const useAtomViewer = !!(host && eventId);
|
||||
|
||||
const [load, setLoad] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
|
@ -106,8 +120,29 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Skip inline preload in atom-viewer mode — the user gets the
|
||||
// viewer's own resolve path on tap; preloading every visible
|
||||
// video in the timeline would burn bandwidth and decrypt CPU
|
||||
// for videos the user never opens.
|
||||
if (useAtomViewer) return;
|
||||
if (autoPlay) loadSrc();
|
||||
}, [autoPlay, loadSrc]);
|
||||
}, [autoPlay, loadSrc, useAtomViewer]);
|
||||
|
||||
const openAtomViewer = useCallback(() => {
|
||||
if (!host || !eventId) return;
|
||||
// No `resolvedSrc` — viewer body owns blob-URL lifecycle; see
|
||||
// the rationale in `ImageContent.handleOpen`.
|
||||
openMediaViewer({
|
||||
roomId: host.roomId,
|
||||
eventId,
|
||||
kind: 'video',
|
||||
url,
|
||||
body,
|
||||
info,
|
||||
encInfo,
|
||||
mimeType,
|
||||
});
|
||||
}, [host, eventId, openMediaViewer, url, body, info, encInfo, mimeType]);
|
||||
|
||||
return (
|
||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||
|
|
@ -129,7 +164,21 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||
{renderThumbnail()}
|
||||
</Box>
|
||||
)}
|
||||
{!autoPlay && !blurred && srcState.status === AsyncStatus.Idle && (
|
||||
{useAtomViewer && !blurred && (
|
||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
size="300"
|
||||
onClick={openAtomViewer}
|
||||
before={<Icon size="Inherit" src={Icons.Play} filled />}
|
||||
>
|
||||
<Text size="B300">Watch</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
{!useAtomViewer && !autoPlay && !blurred && srcState.status === AsyncStatus.Idle && (
|
||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||
<Button
|
||||
variant="Secondary"
|
||||
|
|
@ -143,7 +192,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
{!useAtomViewer && srcState.status === AsyncStatus.Success && (
|
||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||
{renderVideo({
|
||||
title: body,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,36 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, config } from 'folds';
|
||||
|
||||
// Click affordance for the timeline image thumbnail. Without this
|
||||
// the `<img>` looked decorative on web (the default cursor stays
|
||||
// as `default`) even though it's clickable to open the media
|
||||
// viewer. The subtle 0.92 brightness on hover doubles as the
|
||||
// "this is interactive" signal — same idiom as how the rest of
|
||||
// the app's clickable surfaces shift tone on hover.
|
||||
//
|
||||
// `will-change: filter` hints the compositor so the brightness
|
||||
// transition runs on the GPU instead of repainting on the CPU —
|
||||
// matters for large hi-DPI thumbnails on slower phones.
|
||||
export const ImageClickable = style({
|
||||
cursor: 'pointer',
|
||||
transition: 'filter 120ms ease',
|
||||
willChange: 'filter',
|
||||
selectors: {
|
||||
'&:hover': { filter: 'brightness(0.92)' },
|
||||
},
|
||||
});
|
||||
|
||||
// `:focus-visible` outline on the inner `<img>` (which carries
|
||||
// `tabIndex` + `role="button"`, not the wrapper Box). Lives as a
|
||||
// `globalStyle` because vanilla-extract's `style({...})` only
|
||||
// permits selectors that target the class itself (`&...`) — a
|
||||
// descendant selector like `& img:focus-visible` errors at build
|
||||
// time. `globalStyle` is the documented escape valve for that.
|
||||
globalStyle(`${ImageClickable} img:focus-visible`, {
|
||||
outline: `2px solid currentColor`,
|
||||
outlineOffset: '-2px',
|
||||
});
|
||||
|
||||
export const RelativeBase = style([
|
||||
DefaultReset,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,25 +1,36 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
// 36px circular avatar — a notch above folds `Avatar size="200"` (32px)
|
||||
// for visual weight matching the channels mockup. Consumers override
|
||||
// the folds preset via inline style; the shared `CHANNEL_AVATAR_PX`
|
||||
// constant keeps the CSS slot width and the inline override in sync.
|
||||
export const CHANNEL_AVATAR_PX = 36;
|
||||
// 40px circular avatar — Discord's cozy-mode avatar size. Consumers override
|
||||
// the folds preset via inline style; the shared `CHANNEL_AVATAR_PX` constant
|
||||
// keeps the CSS slot width and the inline override in sync.
|
||||
export const CHANNEL_AVATAR_PX = 40;
|
||||
const ChannelAvatarWidth = toRem(CHANNEL_AVATAR_PX);
|
||||
|
||||
// Discord cozy-mode geometry: avatar 16px from the list edge, 16px gap to the
|
||||
// content, so the message column starts at 16 + 40 + 16 = 72px.
|
||||
const ChannelEdgePad = toRem(16);
|
||||
const ChannelAvatarGap = toRem(16);
|
||||
|
||||
export const ChannelRow = style({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: config.space.S300,
|
||||
paddingLeft: config.space.S400,
|
||||
paddingRight: config.space.S400,
|
||||
paddingTop: config.space.S100,
|
||||
paddingBottom: config.space.S100,
|
||||
gap: ChannelAvatarGap,
|
||||
// Span the full pane edge-to-edge so the hover highlight runs the whole
|
||||
// width like Discord: cancel MessageBase's S400/S200 horizontal padding with
|
||||
// negative margins, then re-add the 16px avatar gutter as paddingLeft (so the
|
||||
// avatar's left edge lands exactly 16px from the screen edge — Discord cozy).
|
||||
marginLeft: `calc(-1 * ${config.space.S400})`,
|
||||
marginRight: `calc(-1 * ${config.space.S200})`,
|
||||
paddingLeft: ChannelEdgePad,
|
||||
paddingRight: ChannelEdgePad,
|
||||
// Tight vertical rhythm (Discord stacks lines on line-height); group
|
||||
// separation comes from MessageBase's `space` marginTop on the run head.
|
||||
paddingTop: toRem(2),
|
||||
paddingBottom: toRem(2),
|
||||
minWidth: 0,
|
||||
// Hover bg subtle so adjacent rows still read as distinct units even
|
||||
// without bubble borders. `@media (hover: hover)` keeps this inert on
|
||||
// touch where there's no pointer to follow.
|
||||
// Hover bg subtle so adjacent rows still read as distinct units. `@media
|
||||
// (hover: hover)` keeps this inert on touch where there's no pointer.
|
||||
'@media': {
|
||||
'(hover: hover) and (pointer: fine)': {
|
||||
selectors: {
|
||||
|
|
@ -105,8 +116,12 @@ export const ChannelDayDividerLabel = style({
|
|||
// body would, indented past the avatar slot, so the column reads
|
||||
// continuous.
|
||||
export const ChannelSysline = style({
|
||||
paddingLeft: `calc(${ChannelAvatarWidth} + ${config.space.S300} + ${config.space.S400})`,
|
||||
paddingRight: config.space.S400,
|
||||
// Indent past the avatar gutter so the sysline body aligns with the message
|
||||
// body column (72px). The sysline sits inside MessageBase's S400 (16px) left
|
||||
// pad (it has no edge-to-edge negative margin), so paddingLeft = avatar (40)
|
||||
// + gap (16) = 56 lands the content at 16 + 56 = 72px.
|
||||
paddingLeft: `calc(${ChannelAvatarWidth} + ${ChannelAvatarGap})`,
|
||||
paddingRight: config.space.S200,
|
||||
paddingTop: config.space.S100,
|
||||
paddingBottom: config.space.S100,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
|
|
@ -121,3 +136,107 @@ export const ChannelSyslineBody = style({
|
|||
minWidth: 0,
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
// Bubble chrome applied when `ChannelLayout` is invoked with
|
||||
// `headerInBubble` (thread drawer and channels main timeline pass it).
|
||||
// Mirrors `StreamBubble` from the DM timeline so a channel row reads
|
||||
// like a chat-bubble cluster: dark `Surface.Container` card with an
|
||||
// asymmetric notch corner per `data-own`, sized `fit-content` so short
|
||||
// bubbles shrink-wrap instead of stretching across the column.
|
||||
// Reactions and the thread-summary card live as siblings of the body
|
||||
// in `ChannelLayout`, so they stay OUTSIDE the bubble — identical
|
||||
// composition to Stream. The `[data-bubble="true"]` row marker keeps
|
||||
// the un-bubbled channel/sysline layout (pre-redesign callers) opt-in
|
||||
// rather than forcing the look on every consumer.
|
||||
globalStyle(`${ChannelRow}[data-bubble="true"] ${ChannelMessageBody}`, {
|
||||
backgroundColor: color.Surface.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||
paddingTop: config.space.S200,
|
||||
paddingBottom: config.space.S200,
|
||||
paddingLeft: toRem(15),
|
||||
paddingRight: toRem(15),
|
||||
display: 'inline-block',
|
||||
width: 'fit-content',
|
||||
maxWidth: '100%',
|
||||
minWidth: 0,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
// Clips the thread-summary footer's hover bg against the bubble's
|
||||
// rounded BR/BL corners — without it the rectangular hover paint
|
||||
// punches past the curve. No outflow content lives inside the bubble
|
||||
// (option bar, reactions are siblings on the row) so clipping is
|
||||
// safe.
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
// Asymmetric corner per `data-own` — own messages flatten BOTTOM-LEFT
|
||||
// (4px), incoming messages flatten TOP-LEFT. Same pattern as
|
||||
// `StreamBubble.own`/`StreamBubble.others`.
|
||||
globalStyle(`${ChannelRow}[data-bubble="true"][data-own="true"] ${ChannelMessageBody}`, {
|
||||
borderRadius: `${toRem(16)} ${toRem(16)} ${toRem(16)} ${toRem(4)}`,
|
||||
});
|
||||
|
||||
globalStyle(`${ChannelRow}[data-bubble="true"][data-own="false"] ${ChannelMessageBody}`, {
|
||||
borderRadius: `${toRem(4)} ${toRem(16)} ${toRem(16)} ${toRem(16)}`,
|
||||
// Peer (not-own) bubble bg for the channel layout — its own var. (The 1-1
|
||||
// Stream layout's incoming bubble instead binds to color.Surface.Container,
|
||||
// the composer surface.) Covers channels main timeline AND thread drawer
|
||||
// (both pass `headerInBubble`, so `data-bubble="true"` fires).
|
||||
backgroundColor: 'var(--vojo-peer-bubble-bg)',
|
||||
});
|
||||
|
||||
// Small gap so the in-bubble header (username + time) doesn't sit flush
|
||||
// against the first line of message text. Matches the Stream layout's
|
||||
// `StreamName` 2px marginBottom.
|
||||
globalStyle(`${ChannelRow}[data-bubble="true"] ${ChannelHeader}[data-in-bubble="true"]`, {
|
||||
marginBottom: toRem(2),
|
||||
});
|
||||
|
||||
// Thread-summary footer rendered INSIDE the bubble (rather than as a
|
||||
// separate pill below). Negative L/R margin (matches the bubble's
|
||||
// `paddingLeft/Right: 15px`) stretches the wrapper to the bubble's
|
||||
// inner border edge so the 1px top rule reads as a section divider
|
||||
// spanning the whole bubble. Negative `marginBottom` cancels the
|
||||
// bubble's S200 bottom pad so the footer flushes against the bubble's
|
||||
// rounded bottom edge — bubble + summary read as one card with a
|
||||
// horizontal rule splitting them.
|
||||
//
|
||||
// The footer body keeps no own border/radius — it inherits the bubble's
|
||||
// bottom corners via clipping (`ChannelMessageBody` itself doesn't
|
||||
// `overflow: hidden`, but the rounded bottom of the bubble visually
|
||||
// caps the footer anyway because the divider line never reaches the
|
||||
// curved corner pixel).
|
||||
export const ChannelBubbleThreadSummary = style({
|
||||
marginTop: config.space.S200,
|
||||
marginLeft: toRem(-15),
|
||||
marginRight: toRem(-15),
|
||||
marginBottom: `calc(-1 * ${config.space.S200})`,
|
||||
borderTop: `1px solid ${color.Surface.ContainerLine}`,
|
||||
});
|
||||
|
||||
// Footer button — strip the original ThreadSummaryCard pill chrome
|
||||
// (own bg, radius, padding, max-width) so it reads as a flush bubble
|
||||
// footer. Click target expands to the full footer width. Hover paints
|
||||
// a subtle `SurfaceVariant.Container` shade that contrasts against
|
||||
// the bubble's `Surface.Container` bg, signalling tappable footer
|
||||
// without the pill silhouette returning.
|
||||
globalStyle(`${ChannelBubbleThreadSummary} > button`, {
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
maxWidth: 'none',
|
||||
borderRadius: 0,
|
||||
backgroundColor: 'transparent',
|
||||
padding: `${config.space.S200} ${toRem(15)}`,
|
||||
});
|
||||
|
||||
globalStyle(`${ChannelBubbleThreadSummary} > button:hover`, {
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
});
|
||||
|
||||
globalStyle(`${ChannelBubbleThreadSummary} > button:focus-visible`, {
|
||||
// Inset the focus ring slightly so it doesn't punch through the
|
||||
// bubble's rounded bottom corners on the BR/BL when the row is
|
||||
// either own or incoming.
|
||||
outlineOffset: toRem(-2),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,18 @@ export type ChannelLayoutProps = {
|
|||
header?: ReactNode;
|
||||
reactions?: ReactNode;
|
||||
threadSummary?: ReactNode;
|
||||
// Forwarded onto the row root as `data-own="true"|"false"`. Channels
|
||||
// main timeline doesn't style off it; the thread-drawer bubble CSS
|
||||
// reads it to mirror `StreamBubble`'s own-vs-incoming notch corner.
|
||||
isOwn?: boolean;
|
||||
// `true` (thread drawer): the header (name + time) renders INSIDE the body
|
||||
// slot above the content, and the body is a chat bubble — the compact
|
||||
// in-bubble look.
|
||||
// `false` (Discord-style main timeline): the header renders as a sibling row
|
||||
// ABOVE the body (next to the avatar) and the body is plain text (no bubble)
|
||||
// for everyone — Discord groups don't bubble messages. See `data-bubble`
|
||||
// below and `Channel.css.ts`.
|
||||
headerInBubble?: boolean;
|
||||
onContextMenu?: MouseEventHandler<HTMLDivElement>;
|
||||
};
|
||||
|
||||
|
|
@ -33,20 +45,53 @@ export type ChannelLayoutProps = {
|
|||
// thread-summary, reactions in vertical flow.
|
||||
export const ChannelLayout = as<'div', ChannelLayoutProps>(
|
||||
(
|
||||
{ className, avatar, header, reactions, threadSummary, onContextMenu, children, ...props },
|
||||
{
|
||||
className,
|
||||
avatar,
|
||||
header,
|
||||
reactions,
|
||||
threadSummary,
|
||||
isOwn,
|
||||
headerInBubble,
|
||||
onContextMenu,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => (
|
||||
<div
|
||||
className={classNames(css.ChannelRow, className)}
|
||||
onContextMenu={onContextMenu}
|
||||
data-own={isOwn ? 'true' : 'false'}
|
||||
// Bubbles only in the compact in-bubble mode (thread drawer). The Discord
|
||||
// header-above main timeline renders ALL messages as plain text (no
|
||||
// bubbles) — own and peer alike.
|
||||
data-bubble={headerInBubble ? 'true' : undefined}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<div className={css.ChannelAvatarSlot}>{avatar}</div>
|
||||
<div className={css.ChannelBody}>
|
||||
{header && <div className={css.ChannelHeader}>{header}</div>}
|
||||
<div className={css.ChannelMessageBody}>{children}</div>
|
||||
{threadSummary && <div className={css.ChannelThreadSummary}>{threadSummary}</div>}
|
||||
{!headerInBubble && header && <div className={css.ChannelHeader}>{header}</div>}
|
||||
<div className={css.ChannelMessageBody}>
|
||||
{headerInBubble && header && (
|
||||
<div className={css.ChannelHeader} data-in-bubble="true">
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
{headerInBubble && threadSummary && (
|
||||
// Inside-bubble footer: stretches via negative margins to the
|
||||
// bubble's inner border edge, paints a 1px top divider, and
|
||||
// hosts the existing thread-summary button as a flush
|
||||
// full-width chip. Reads as one continuous card with a
|
||||
// section break instead of two stacked pills.
|
||||
<div className={css.ChannelBubbleThreadSummary}>{threadSummary}</div>
|
||||
)}
|
||||
</div>
|
||||
{!headerInBubble && threadSummary && (
|
||||
<div className={css.ChannelThreadSummary}>{threadSummary}</div>
|
||||
)}
|
||||
{reactions && <div className={css.ChannelReactions}>{reactions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -103,7 +148,11 @@ export type ChannelMessageAvatarProps = {
|
|||
// folds `<Avatar>` + `<UserAvatar>` combination. Lifted out of `Message`
|
||||
// so the `useMediaAuthentication` / `useMatrixClient` hook calls only run
|
||||
// when channel layout is selected (Stream rows don't need an avatar).
|
||||
export function ChannelMessageAvatar({ room, senderId, senderDisplayName }: ChannelMessageAvatarProps) {
|
||||
export function ChannelMessageAvatar({
|
||||
room,
|
||||
senderId,
|
||||
senderDisplayName,
|
||||
}: ChannelMessageAvatarProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const avatarMxc = getMemberAvatarMxc(room, senderId);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import React, { ReactNode, useImperativeHandle, useRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { as } from 'folds';
|
||||
import { as, toRem } from 'folds';
|
||||
import * as css from './layout.css';
|
||||
import { useStreamLayoutDebug } from './streamDebug';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { Time } from '../Time';
|
||||
|
||||
// Stream rows use a fixed `S400` gap so the rail-bridge offsets in
|
||||
// layout.css.ts (StreamRailBridgeY = S400) match the gap between rows.
|
||||
// Day-divider rows fall back to this `S400` MessageBase spacing variant
|
||||
// (see RoomTimeline.renderDayDivider). Message rows force collapse=true
|
||||
// in Message.tsx so their marginTop drops to 0 — the rail-bridge offsets
|
||||
// still resolve against `StreamRailBridgeY = S400` and the small overlap
|
||||
// over the tighter gap stays hidden by the dot halo.
|
||||
export const STREAM_MESSAGE_SPACING = '400' as const;
|
||||
|
||||
// Sample timestamp used by the day-divider's invisible track-1 placeholder.
|
||||
|
|
@ -16,7 +19,18 @@ export const STREAM_MESSAGE_SPACING = '400' as const;
|
|||
// chat. The auto-sized grid column then matches the surrounding message rows.
|
||||
const DAY_DIVIDER_PLACEHOLDER_TS = 0;
|
||||
|
||||
// Stream layout — DM redesign (docs/plans/dm_1x1_redesign.md §6.5b).
|
||||
// Rail-dot diameters. The base dot is 9px (see `StreamDotSize` /
|
||||
// `StreamDotColumn` in layout.css.ts, which keep the rail X consistent). The
|
||||
// neutral gray dot is 0.95× that; the «state» dots (green = read, gold =
|
||||
// mention, red = failed — `dotProminent`) are 1.1× the neutral so they read as
|
||||
// slightly larger on the rail. The dot stays in-flow, so a prominent dot just
|
||||
// overflows the 9px column by ~0.4px into the gap (centred enough to read on
|
||||
// the rail) — same harmless trick the larger day-dot already uses.
|
||||
const STREAM_DOT_NEUTRAL = toRem(8.55);
|
||||
const STREAM_DOT_PROMINENT = toRem(9.405);
|
||||
|
||||
// Stream layout — DM «VS Code chat» redesign
|
||||
// (docs/plans/dm_stream_vscode_redesign.md).
|
||||
//
|
||||
// Visual structure (3-track CSS grid, see StreamRoot in layout.css.ts):
|
||||
// ┌─G─┬─time─┬─G─┬─dot─┬─G─┬───── bubble (1fr) ─────────┐
|
||||
|
|
@ -31,11 +45,30 @@ export type StreamLayoutProps = {
|
|||
time?: ReactNode;
|
||||
dotColor: string;
|
||||
dotOpacity: number;
|
||||
// `true` → green/gold/red «state» dot drawn 1.1× the neutral gray size.
|
||||
dotProminent?: boolean;
|
||||
// Drives the bubble chrome: own → plain text on the chat background (no
|
||||
// bubble); incoming → filled bubble (composer-matched surface). See
|
||||
// layout.css.ts `StreamBubble`.
|
||||
isOwn?: boolean;
|
||||
compact?: boolean;
|
||||
// Same-sender continuation row (the whole run after the first message, any
|
||||
// minute): drop the rail dot + timestamp + nick and stack the body tight
|
||||
// under the previous one. The timestamp is kept in the DOM (invisible) only
|
||||
// to reserve the time-track width. The caller also passes `header={undefined}`
|
||||
// for collapsed rows. See RoomTimeline `collapsed`.
|
||||
collapsed?: boolean;
|
||||
// Author name — rendered as a bold label ABOVE the bubble, on the
|
||||
// dot/timestamp baseline (DM «VS Code chat» redesign). `undefined` for
|
||||
// media rows (the name is overlaid on the media instead) and for collapsed
|
||||
// continuation rows.
|
||||
header?: ReactNode;
|
||||
railStart?: boolean;
|
||||
railEnd?: boolean;
|
||||
// Suppress the rail segment entirely on this row. Set for trailing
|
||||
// continuation rows that sit AFTER the last dot (the rail must stop at the
|
||||
// last dot, not bleed down through the dot-less tail of a run).
|
||||
railHidden?: boolean;
|
||||
// Image messages: bubble bg/border/padding collapse so the
|
||||
// StreamMediaImage child supplies the visible chrome.
|
||||
mediaMode?: boolean;
|
||||
|
|
@ -99,11 +132,14 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
|
|||
time,
|
||||
dotColor,
|
||||
dotOpacity,
|
||||
dotProminent,
|
||||
isOwn,
|
||||
compact,
|
||||
collapsed,
|
||||
header,
|
||||
railStart,
|
||||
railEnd,
|
||||
railHidden,
|
||||
mediaMode,
|
||||
reactions,
|
||||
threadSummary,
|
||||
|
|
@ -137,34 +173,62 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
|
|||
|
||||
return (
|
||||
<div
|
||||
className={classNames(css.StreamRoot({ compact: !!compact }), className)}
|
||||
className={classNames(
|
||||
css.StreamRoot({ compact: !!compact, collapsed: !!collapsed }),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={rootRef}
|
||||
>
|
||||
<span className={css.StreamHeaderTime} ref={timeRef}>
|
||||
{/* Collapsed rows keep the timestamp in the DOM (so the auto-sized
|
||||
time track stays the same width and the body column doesn't shift)
|
||||
but hide it — only the first message of the minute shows its time. */}
|
||||
<span
|
||||
className={classNames(css.StreamHeaderTime, collapsed && css.StreamHeaderTimeHidden)}
|
||||
aria-hidden={collapsed || undefined}
|
||||
ref={timeRef}
|
||||
>
|
||||
{time}
|
||||
</span>
|
||||
<span className={css.StreamDotColumn} aria-hidden>
|
||||
<span
|
||||
className={classNames(
|
||||
css.StreamRail,
|
||||
railStart && railEnd && css.StreamRailSingle,
|
||||
railStart && !railEnd && css.StreamRailStart,
|
||||
railEnd && !railStart && css.StreamRailEnd
|
||||
)}
|
||||
ref={railRef}
|
||||
/>
|
||||
<span
|
||||
className={classNames(css.StreamDotHalo, css.StreamHeaderDotHalo)}
|
||||
ref={dotRef}
|
||||
>
|
||||
{/* The rail is suppressed entirely on trailing continuation rows
|
||||
(after the last dot) so the line stops at the last dot instead of
|
||||
bleeding down through the dot-less tail of a run. */}
|
||||
{!railHidden && (
|
||||
<span
|
||||
className={css.StreamDotFill}
|
||||
style={{ backgroundColor: dotColor, opacity: dotOpacity }}
|
||||
className={classNames(
|
||||
css.StreamRail,
|
||||
railStart && railEnd && css.StreamRailSingle,
|
||||
railStart && !railEnd && css.StreamRailStart,
|
||||
railEnd && !railStart && css.StreamRailEnd
|
||||
)}
|
||||
ref={railRef}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{/* No dot on collapsed continuation rows — the rail passes straight
|
||||
through, anchored by the first message's dot above. */}
|
||||
{!collapsed && (
|
||||
<span
|
||||
className={classNames(css.StreamDotHalo, css.StreamHeaderDotHalo)}
|
||||
style={{
|
||||
width: dotProminent ? STREAM_DOT_PROMINENT : STREAM_DOT_NEUTRAL,
|
||||
height: dotProminent ? STREAM_DOT_PROMINENT : STREAM_DOT_NEUTRAL,
|
||||
}}
|
||||
ref={dotRef}
|
||||
>
|
||||
<span
|
||||
className={css.StreamDotFill}
|
||||
style={{ backgroundColor: dotColor, opacity: dotOpacity }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className={css.StreamColumn}>
|
||||
{header && (
|
||||
<div className={css.StreamName} ref={headerRef}>
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={css.StreamBubble({
|
||||
own: !!isOwn,
|
||||
|
|
@ -173,11 +237,6 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
|
|||
})}
|
||||
ref={bubbleRef}
|
||||
>
|
||||
{header && (
|
||||
<div className={css.StreamBubbleHeader} ref={headerRef}>
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
{threadSummary && <div className={css.StreamThreadSummary}>{threadSummary}</div>}
|
||||
|
|
|
|||