Compare commits

..

1 commit

276 changed files with 3276 additions and 21868 deletions

View file

@ -60,12 +60,10 @@ module.exports = {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-shadow': 'error',
// Policy: kept as `warn` at the rule level so editors / `eslint --fix` /
// ad-hoc runs surface them as warnings, but `npm run check:eslint` and
// `lint-staged` BOTH pass `--max-warnings 0`, so new occurrences block
// commit. When unavoidable (matrix-js-sdk boundary, generic helpers,
// third-party callback shapes), suppress on the line with
// `// eslint-disable-next-line` and a one-line justification.
// Policy: kept as warnings, not errors. The codebase has ~70 long-standing
// `any` casts and ~15 non-null assertions in matrix-js-sdk interop code.
// Promoting to error would block builds on existing usage; turning off
// would lose signal on new code. Warnings are visible without blocking.
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-non-null-assertion': 'warn',
},
@ -88,11 +86,6 @@ module.exports = {
'no-plusplus': 'off',
'prefer-template': 'off',
'no-param-reassign': 'off',
// `for (;;)` form upstream uses for the iter-loops trips eslint
// even though it's intentional — keep upstream control flow.
'no-constant-condition': 'off',
// Diagnostic `console.log` left as-is in vendor copy.
'no-console': 'off',
},
},
],

20
.gitignore vendored
View file

@ -4,24 +4,12 @@ node_modules
devAssets
config.local.json
electron/dist-electron
release
.DS_Store
.idea
.vscode/*
!.vscode/tasks.json
.vscode
.codex
.claude
docs/ai/desired_features.md
docs/ai/bugs.md
docs/plans
docs/design
docs/ai/*
!docs/ai/README.md
!docs/ai/android.md
!docs/ai/architecture.md
!docs/ai/electron.md
!docs/ai/i18n.md
!docs/ai/overview.md
!docs/ai/server-side.md
vite.config.*.timestamp-*.mjs
docs

5
.husky/pre-commit Executable file → Normal file
View file

@ -1,2 +1,3 @@
npx tsc -p tsconfig.json --noEmit
npx lint-staged
# These are commented until we enable lint and typecheck
# npx tsc -p tsconfig.json --noEmit
# npx lint-staged

104
.vscode/tasks.json vendored
View file

@ -1,104 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Deploy to vojo.chat",
"type": "shell",
"command": "npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/cinny/",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"showReuseMessage": false
},
"problemMatcher": []
},
{
"label": "Deploy widgets",
"type": "shell",
"command": "(cd apps/widget-telegram && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/telegram/) & PID1=$!; (cd apps/widget-discord && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/discord/) & PID2=$!; (cd apps/widget-whatsapp && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/whatsapp/) & PID3=$!; FAIL=0; wait $PID1 || FAIL=1; wait $PID2 || FAIL=1; wait $PID3 || FAIL=1; exit $FAIL",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"showReuseMessage": false
},
"problemMatcher": []
},
{
"label": "Build Android APK",
"type": "shell",
"command": "npm run build:android:debug",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"showReuseMessage": false
},
"problemMatcher": []
},
{
"label": "Deploy to Android (ADB)",
"type": "shell",
"command": "npm run build:android:debug && adb install -r android/app/build/outputs/apk/debug/app-debug.apk",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"showReuseMessage": false
},
"problemMatcher": []
},
{
"label": "Connect to Android device (ADB)",
"type": "shell",
"command": "adb connect 192.168.1.204:5555",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"showReuseMessage": false
},
"problemMatcher": []
},
{
"label": "Start Electron (dev)",
"type": "shell",
"command": "npm run electron:dev",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"showReuseMessage": false
},
"problemMatcher": []
},
{
"label": "Build Electron Windows",
"type": "shell",
"command": "npm run build:electron:win",
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"showReuseMessage": false
},
"problemMatcher": []
},
{
"label": "Deploy Discord bridge",
"type": "shell",
"command": "docker build -t vojo-mautrix-discord:custom . && docker save vojo-mautrix-discord:custom | gzip | ssh vojo-superuser@187.127.77.124 'gunzip | docker load'",
"options": {
"cwd": "${workspaceFolder}/../vojo-mautrix-discord"
},
"group": "none",
"presentation": {
"reveal": "always",
"panel": "shared",
"showReuseMessage": false
},
"problemMatcher": []
}
]
}

View file

@ -1,29 +1,8 @@
apply plugin: 'com.android.application'
// Mirror of resolveAppVersion() in ../../vite.config.js so the APK's
// versionName matches __APP_VERSION__ rendered in the About screen.
// `git describe --tags --match 'v*'` against tag v0.2.0 yields
// `v0.2.0-<commits>-g<hash>`; patch = commit count since the tag.
// Falls back to package.json only when git is unavailable.
def gitDescribe = providers.exec {
it.commandLine 'git', 'describe', '--tags', '--match', 'v*', '--always'
it.workingDir rootDir.parentFile
it.ignoreExitValue = true
}
def appVersion = {
def fromGit = gitDescribe.result.get().exitValue == 0 ? gitDescribe.standardOutput.asText.get().trim() : null
def m = fromGit =~ /^v?(\d+)\.(\d+)\.(\d+)(?:-(\d+)-g[0-9a-f]+)?$/
if (fromGit && m.matches()) {
def major = m[0][1].toInteger()
def minor = m[0][2].toInteger()
def patch = (m[0][4] ?: m[0][3]).toInteger()
return [name: "${major}.${minor}.${patch}", major: major, minor: minor, patch: patch]
}
def pkg = new groovy.json.JsonSlurper().parseText(file('../../package.json').text)
def parts = pkg.version.split('\\.')
return [name: pkg.version, major: parts[0].toInteger(), minor: parts[1].toInteger(), patch: parts[2].toInteger()]
}()
def computedVersionCode = appVersion.major * 1000000 + appVersion.minor * 1000 + appVersion.patch
def packageJson = new groovy.json.JsonSlurper().parseText(file('../../package.json').text)
def semver = packageJson.version.split('\\.')
def computedVersionCode = semver[0].toInteger() * 1000000 + semver[1].toInteger() * 1000 + semver[2].toInteger()
android {
namespace = "chat.vojo.app"
@ -33,7 +12,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode computedVersionCode
versionName appVersion.name
versionName packageJson.version
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@ -41,6 +20,12 @@ android {
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
// AGP 8+ requires explicit opt-in for BuildConfig generation. We rely on
// BuildConfig.DEBUG to gate Log.d calls that dump privacy-sensitive
// identifiers (roomId, eventId) so release builds don't leak them through
@ -48,26 +33,6 @@ android {
buildFeatures {
buildConfig = true
}
signingConfigs {
release {
if (project.hasProperty('VOJO_RELEASE_STORE_FILE')) {
storeFile file(VOJO_RELEASE_STORE_FILE)
storePassword VOJO_RELEASE_STORE_PASSWORD
keyAlias VOJO_RELEASE_KEY_ALIAS
keyPassword VOJO_RELEASE_KEY_PASSWORD
}
}
}
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
}
}
repositories {
@ -87,11 +52,6 @@ dependencies {
// already depends on firebase-messaging but declares it `implementation`
// so classes aren't exposed at app-module compile time.
implementation "com.google.firebase:firebase-messaging:25.0.1"
// WorkManager hosts VojoPollWorker periodic /notifications poll that
// delivers messages and missed-call surfaces on networks where FCM
// (mtalk.google.com:5228) is blocked. Library self-registers its scheduler
// in the merged manifest; we declare no permission for it.
implementation "androidx.work:work-runtime:2.10.0"
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"

View file

@ -19,27 +19,3 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# Keep custom app classes entry points invoked by Android system (Intents,
# FCM, AndroidManifest references) or by JS bridge via reflection.
-keep class chat.vojo.app.MainActivity { *; }
-keep class chat.vojo.app.VojoFirebaseMessagingService { *; }
-keep class chat.vojo.app.CallForegroundPlugin { *; }
-keep class chat.vojo.app.CallForegroundService { *; }
-keep class chat.vojo.app.CallDeclineReceiver { *; }
-keep class chat.vojo.app.CallCancelReceiver { *; }
-keep class chat.vojo.app.FullScreenIntentPlugin { *; }
-keep class chat.vojo.app.LaunchSplashPlugin { *; }
# Firebase Messaging receivers/services resolved by Android via manifest.
-keep public class * extends com.google.firebase.messaging.FirebaseMessagingService
-keep class com.google.firebase.iid.** { *; }
-keep class com.google.firebase.messaging.** { *; }
# Capacitor plugins discovered by annotation/reflection.
-keep @com.getcapacitor.annotation.CapacitorPlugin class * { *; }
-keep class com.getcapacitor.** { *; }
-keep class com.getcapacitor.plugin.** { *; }
# AndroidX splashscreen reflection paths.
-keep class androidx.core.splashscreen.** { *; }

View file

@ -46,30 +46,6 @@
android:pathPrefix="/u/" />
</intent-filter>
<!-- System share-sheet target. Three filters because Android's
sheet UI dedupes by activity but resolves by MIME match:
text/* gets its own filter so the Vojo icon shows up
alongside WhatsApp/Telegram for «share link/selection»; */*
covers single-file (image/video/audio/pdf/…) and
SEND_MULTIPLE picks up gallery multi-select.
Payload extraction lives in ShareTargetPlugin — MainActivity
only routes the Intent to the plugin via onNewIntent. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<provider
@ -109,18 +85,6 @@
<receiver
android:name=".CallDeclineReceiver"
android:exported="false" />
<receiver
android:name=".MarkAsReadReceiver"
android:exported="false" />
<receiver
android:name=".NotificationDismissReceiver"
android:exported="false" />
<receiver
android:name=".ReplyReceiver"
android:exported="false" />
</application>
<!-- Permissions -->

View file

@ -1,65 +0,0 @@
package chat.vojo.app;
import android.graphics.Bitmap;
import android.util.LruCache;
/**
* In-memory LRU cache of decoded avatar bitmaps keyed by MXC URL string.
*
* Sized as a process-singleton (~4 MB) so the FCM service, polling Worker
* and ReplyReceiver all share one pool. 96×96 ARGB_8888 bitmap is about
* 36 KB, so a 4 MB cache holds ~110 avatars enough for the active
* conversation set on a typical user. LruCache evicts the least-recently-
* read entry when full; this is the right shape for "rooms the user is
* actively talking in stay warm, dormant rooms reload on demand".
*
* Thread-safety: LruCache itself is synchronized internally on every
* get/put/remove. We don't need an outer lock for normal operation. The
* AvatarLoader funnels all puts through this class.
*
* Process death: cache is in-memory only. After a kill, the first push
* to any room cold-renders without avatars and re-renders once the
* loader populates the cache (see AvatarLoader.loadAllWithTimeout).
*/
final class AvatarBitmapCache {
// Heap budget: bytes. 4 MB is generous against ARGB_8888 96×96 bitmaps
// (~36 KB each) and stays comfortably under the 1/8-of-heap Android
// recommendation on every device we ship to (minSdk 24 at least
// 96 MB heap on a low-end phone).
private static final int MAX_SIZE_BYTES = 4 * 1024 * 1024;
private static final LruCache<String, Bitmap> CACHE =
new LruCache<String, Bitmap>(MAX_SIZE_BYTES) {
@Override
protected int sizeOf(String key, Bitmap value) {
return value.getByteCount();
}
};
private AvatarBitmapCache() {}
/**
* Returns the cached bitmap for an MXC URL, or null on miss.
*
* Bitmap references are NOT defensively copied the cache hands out
* the same reference to every caller. This is safe because no code
* path in the app calls Bitmap.recycle() on a cached bitmap (the
* intermediate square / source bitmaps inside AvatarLoader.
* toCircularBitmap ARE recycled, but the circular output that lands
* here is held until LRU evicts it). LRU eviction simply drops the
* cache's reference, and the GC reclaims memory only after every
* Notification that referenced the bitmap is also released by the
* system. Adding a defensive copy here would halve the effective
* cache size for no real-world benefit.
*/
static Bitmap get(String mxc) {
if (mxc == null || mxc.isEmpty()) return null;
return CACHE.get(mxc);
}
static void put(String mxc, Bitmap bitmap) {
if (mxc == null || mxc.isEmpty() || bitmap == null) return;
CACHE.put(mxc, bitmap);
}
}

View file

@ -1,368 +0,0 @@
package chat.vojo.app;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.BitmapShader;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Shader;
import android.util.Log;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* Fetches and decodes avatar bitmaps from MXC URLs, populating
* {@link AvatarBitmapCache}.
*
* URL resolution mirrors matrix-js-sdk's auth-media v1.11+ pattern:
* mxc://server/mediaId
* <homeserver>/_matrix/client/v1/media/thumbnail/<server>/<mediaId>
* ?width=96&height=96&method=crop
* + Authorization: Bearer <accessToken>
*
* The legacy unauthenticated `/_matrix/media/v3/thumbnail/...` endpoint is
* NOT used every Synapse the Vojo audience runs against (vanilla, v1.11+
* by deployment policy, see docs/ai/server-side.md) speaks auth media.
* Removing the legacy fallback keeps the loader off the deprecated path
* and avoids leaking the access token to a server route that doesn't
* require it.
*
* Concurrency: each MXC URL is fetched at most once concurrently the
* `inFlight` set short-circuits duplicate requests from rapid
* append-rebuild cycles on the same conversation. Loads happen on a
* shared 4-thread pool; bigger than 1 so 5 senders in a group chat can
* load in parallel, capped to keep socket pressure under the typical
* mobile network budget.
*
* Two entry points:
* - {@link #loadAllWithTimeout}: synchronous wait, used by the render
* path to populate the cache before building the MessagingStyle so the
* first post already has avatars. Timeout-bounded to keep FCM thread
* responsive (Android budgets ~10s; we use 800 ms).
* - {@link #prefetch}: fire-and-forget, used for warm-up scenarios.
* Not currently called but kept for the room-metadata bridge to
* eventually warm the cache on visibility resume.
*/
final class AvatarLoader {
private static final String TAG = "AvatarLoader";
private static final int AVATAR_SIZE_PX = 96;
private static final int CONNECT_TIMEOUT_MS = 5_000;
private static final int READ_TIMEOUT_MS = 5_000;
private static final int RENDER_BLOCK_TIMEOUT_MS = 800;
// Cap decoded bitmap byte count a malicious / huge avatar shouldn't
// OOM the FCM service. 96×96 ARGB_8888 is ~36 KB; we accept up to
// 4× that (~140 KB) to allow some downscaling slack on servers that
// return slightly oversized thumbnails.
private static final int MAX_DECODED_BYTES = 144 * 1024;
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(4);
// MXC URL CountDownLatch that fires when the in-flight download
// completes (success or failure). A second caller observing an
// already-pending mxc waits on the SAME latch instead of either
// returning empty-handed or kicking off a duplicate fetch. Latches
// are removed by the worker task in its finally block; the same task
// that put the entry is the only one allowed to remove it, so a slow
// remove() race is harmless.
private static final ConcurrentHashMap<String, CountDownLatch> inFlight =
new ConcurrentHashMap<>();
private AvatarLoader() {}
/**
* Block the caller for up to {@link #RENDER_BLOCK_TIMEOUT_MS} while
* fetching any of the given MXC URLs that are not yet in
* {@link AvatarBitmapCache}. Cache hits are no-ops. Already-in-flight
* URLs are awaited via the shared latch duplicate concurrent
* fetches do not happen.
*
* Designed to be called inline from the render path: after this
* returns, {@link AvatarBitmapCache#get} will be non-null for every
* MXC that loaded successfully within the budget. Failures are
* silent the render then falls back to a Person without icon
* (Android renders initials/blank).
*
* Returns the count of avatars that landed in the cache during this
* call (purely informational useful for logs).
*/
static int loadAllWithTimeout(Context ctx, Collection<String> mxcs) {
if (mxcs == null || mxcs.isEmpty()) {
Log.i(TAG, "loadAll: empty input, skip");
return 0;
}
SharedPreferences prefs = ctx.getSharedPreferences(
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null);
String homeserver = prefs.getString(VojoPollWorker.KEY_HOMESERVER_URL, null);
if (token == null || token.isEmpty() || homeserver == null || homeserver.isEmpty()) {
// No credentials yet (fresh install + first push). We can't
// resolve MXC URLs without an access token. Falling back to
// no-icon Person renderer is the correct behaviour here.
Log.i(TAG, "loadAll: no credentials in prefs, skip"
+ " hasToken=" + (token != null && !token.isEmpty())
+ " hasHs=" + (homeserver != null && !homeserver.isEmpty()));
return 0;
}
// De-duplicate and filter to misses only; if the cache already has
// an entry, no work is needed.
Set<String> toLoad = new LinkedHashSet<>();
for (String mxc : mxcs) {
if (mxc == null || mxc.isEmpty()) continue;
if (!mxc.startsWith("mxc://")) continue;
if (AvatarBitmapCache.get(mxc) != null) continue;
toLoad.add(mxc);
}
if (toLoad.isEmpty()) return 0;
// Per-mxc latches shared across concurrent callers a second
// caller arriving while we're already mid-fetch waits on the
// SAME latch instead of forcing a duplicate HTTP or returning
// immediately empty-handed (which was the previous bug see
// git blame for the race description).
java.util.List<CountDownLatch> waits = new java.util.ArrayList<>(toLoad.size());
for (String mxc : toLoad) {
CountDownLatch myLatch = new CountDownLatch(1);
CountDownLatch existing = inFlight.putIfAbsent(mxc, myLatch);
if (existing != null) {
// Already in flight share the original latch.
waits.add(existing);
continue;
}
// We own this fetch; kick off the worker that will fire
// myLatch when done.
waits.add(myLatch);
final String capturedMxc = mxc;
final String capturedHomeserver = homeserver;
final String capturedToken = token;
EXECUTOR.execute(() -> {
try {
Bitmap bmp = fetchAndDecode(capturedMxc, capturedHomeserver, capturedToken);
if (bmp != null) AvatarBitmapCache.put(capturedMxc, bmp);
} catch (Throwable t) {
Log.w(TAG, "fetch threw mxc=" + capturedMxc, t);
} finally {
// Remove BEFORE countDown so a freshly-arriving caller
// doesn't observe a stale latch for an already-loaded
// mxc (would block until the next call with no fetch
// actually pending). Cache.get() on the post-await
// side covers the race where remove+put-cache happens
// between two latch waits.
inFlight.remove(capturedMxc);
myLatch.countDown();
}
});
}
// Single budget for the whole batch wait for all latches OR
// hit the timeout. Latches that fire early just return await()
// immediately; the slowest one consumes the remainder of the
// budget.
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(RENDER_BLOCK_TIMEOUT_MS);
try {
for (CountDownLatch latch : waits) {
long remaining = deadline - System.nanoTime();
if (remaining <= 0) break;
latch.await(remaining, TimeUnit.NANOSECONDS);
}
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
// Count how many actually landed in the cache during this call
// includes both items we fetched and items that finished after our
// timeout (which won't be reflected in this count but are still
// usable on the next render).
int hits = 0;
for (String mxc : toLoad) {
if (AvatarBitmapCache.get(mxc) != null) hits += 1;
}
Log.i(TAG, "loadAll: requested=" + mxcs.size()
+ " toLoad=" + toLoad.size() + " hits=" + hits);
return hits;
}
/**
* Resolve an `mxc://server/mediaId` URL to a 96×96 thumbnail via the
* authenticated v1.11+ media endpoint and decode the response into a
* Bitmap. Returns null on any non-2xx, decode failure, or oversized
* payload (see {@link #MAX_DECODED_BYTES}).
*/
private static Bitmap fetchAndDecode(String mxc, String homeserver, String token)
throws IOException {
Parsed parsed = parseMxc(mxc);
if (parsed == null) {
Log.w(TAG, "fetch: malformed mxc=" + mxc);
return null;
}
// Server + mediaId are NOT URL-encoded matches matrix-js-sdk's
// content-repo.ts (it concatenates verbatim via `new URL()`).
// URLEncoder would turn `example.com:8448` into `example.com%3A8448`,
// which Synapse's media router rejects as an unknown server.
// mediaId is base64-ish per spec (URL-safe alphabet) so no
// encoding is needed there either.
StringBuilder url = new StringBuilder(homeserver);
if (!homeserver.endsWith("/")) url.append('/');
url.append("_matrix/client/v1/media/thumbnail/")
.append(parsed.server)
.append('/')
.append(parsed.mediaId)
.append("?width=").append(AVATAR_SIZE_PX)
.append("&height=").append(AVATAR_SIZE_PX)
.append("&method=crop");
HttpURLConnection conn = (HttpURLConnection) new URL(url.toString()).openConnection();
try {
conn.setRequestMethod("GET");
conn.setRequestProperty("Authorization", "Bearer " + token);
conn.setRequestProperty("Accept", "image/*");
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
conn.setReadTimeout(READ_TIMEOUT_MS);
int code = conn.getResponseCode();
Log.i(TAG, "fetch: mxc=" + mxc + " status=" + code);
if (code < 200 || code >= 300) return null;
int contentLength = conn.getContentLength();
if (contentLength > MAX_DECODED_BYTES) {
Log.w(TAG, "fetch: oversized contentLength=" + contentLength + " mxc=" + mxc);
return null;
}
try (InputStream in = conn.getInputStream()) {
BitmapFactory.Options opts = new BitmapFactory.Options();
// Stick with ARGB_8888 even on low-mem devices RGB_565
// would lose alpha (group avatars often have a
// transparent corner) and the cache cap (4 MB) already
// bounds total memory. inJustDecodeBounds + sample-size
// dance is overkill at 96×96.
opts.inPreferredConfig = Bitmap.Config.ARGB_8888;
Bitmap bmp = BitmapFactory.decodeStream(in, null, opts);
if (bmp == null) {
Log.w(TAG, "fetch: decodeStream returned null mxc=" + mxc);
return null;
}
if (bmp.getByteCount() > MAX_DECODED_BYTES) {
Log.w(TAG, "fetch: decoded oversized "
+ bmp.getByteCount() + " bytes mxc=" + mxc);
bmp.recycle();
return null;
}
// Crop into a circle BEFORE caching IconCompat.createWithBitmap
// renders the bitmap verbatim, with no shape mask, so a
// square thumbnail from the homeserver lands as a square
// tile in the shade (visible on Android 12+ where
// conversation Person icons used to be auto-rounded by the
// OS this changed). Pre-cropping guarantees a round
// visual on every API level instead of relying on the
// SystemUI of the day. The original square bitmap is
// recycled once the circular copy is in hand.
return toCircularBitmap(bmp);
}
} finally {
conn.disconnect();
}
}
/**
* Re-encode a circular avatar as an adaptive-icon-shaped bitmap:
* embeds the avatar inside a transparent canvas whose total size is
* 1.5× the avatar so Android's adaptive-icon safe zone (66% of total)
* covers the entire avatar without clipping.
*
* Required for conversation-shortcut icons per docs at
* developer.android.com/develop/ui/views/notifications/conversations:
* *"To avoid unintentional clipping of your shortcut avatar, provide
* an AdaptiveIconDrawable for the shortcut's icon."*
*
* Without this padding, IconCompat.createWithAdaptiveBitmap would
* crop ~17% off every edge of the avatar to fit the safe zone a
* visible mutilation. With it, the shortcut icon renders pixel-
* identical to the circular avatar inside the system shade's
* conversation slot.
*/
static Bitmap toAdaptivePaddedBitmap(Bitmap circularAvatar) {
int avatarSize = Math.min(circularAvatar.getWidth(), circularAvatar.getHeight());
// Pad to 150% so the adaptive safe-zone (66% of canvas = avatarSize)
// covers the full avatar. Rounded up to keep the canvas even.
int canvasSize = (int) Math.ceil(avatarSize / 0.66f);
if (canvasSize % 2 != 0) canvasSize += 1;
Bitmap output = Bitmap.createBitmap(canvasSize, canvasSize, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(output);
int offset = (canvasSize - avatarSize) / 2;
canvas.drawBitmap(circularAvatar, offset, offset, null);
return output;
}
/**
* Return a circular ARGB_8888 bitmap of the source centre-cropped to
* a square if non-square, then masked with a circular path so the
* corners are transparent. The source bitmap is recycled.
*
* Anti-aliased edges via Paint.setAntiAlias on the circle draw the
* BitmapShader copies the source's pixels into the circular region in
* a single drawCircle call, which keeps allocation to one output
* bitmap (vs the naive "decode → square crop → mask compose" path
* that touches three intermediate bitmaps).
*/
private static Bitmap toCircularBitmap(Bitmap source) {
int size = Math.min(source.getWidth(), source.getHeight());
Bitmap squareSource;
if (source.getWidth() == size && source.getHeight() == size) {
squareSource = source;
} else {
int x = (source.getWidth() - size) / 2;
int y = (source.getHeight() - size) / 2;
squareSource = Bitmap.createBitmap(source, x, y, size, size);
source.recycle();
}
Bitmap output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(output);
Paint paint = new Paint();
paint.setAntiAlias(true);
paint.setShader(new BitmapShader(
squareSource, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP));
float radius = size / 2f;
canvas.drawCircle(radius, radius, radius, paint);
if (squareSource != source) {
squareSource.recycle();
}
return output;
}
private static final class Parsed {
final String server;
final String mediaId;
Parsed(String server, String mediaId) {
this.server = server;
this.mediaId = mediaId;
}
}
/**
* Split an `mxc://server/mediaId` URL into its two components. Returns
* null on any malformed input caller drops the avatar silently.
*/
private static Parsed parseMxc(String mxc) {
if (mxc == null) return null;
final String prefix = "mxc://";
if (!mxc.startsWith(prefix)) return null;
int slash = mxc.indexOf('/', prefix.length());
if (slash < 0 || slash == prefix.length()) return null;
String server = mxc.substring(prefix.length(), slash);
String mediaId = mxc.substring(slash + 1);
if (server.isEmpty() || mediaId.isEmpty()) return null;
return new Parsed(server, mediaId);
}
}

View file

@ -121,14 +121,7 @@ public class CallForegroundPlugin extends Plugin {
// extras Capacitor PushNotificationsPlugin gates pushNotificationActionPerformed
// on containsKey. Empty string also satisfies the gate; we pass the
// caller's value through verbatim.
boolean seeded = VojoFirebaseMessagingService.upsertIncomingRing(data, messageId);
// Mark in NotificationDedup so a polling fire 15 minutes later
// doesn't post a "Missed call" notification for a ring the user
// already saw live via the in-app strip. Mirrors the FCM-arrival
// path in VojoFirebaseMessagingService.onMessageReceived.
if (seeded) {
NotificationDedup.markNotified(getContext(), eventId);
}
VojoFirebaseMessagingService.upsertIncomingRing(data, messageId);
call.resolve();
}

View file

@ -1,163 +0,0 @@
package chat.vojo.app;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.os.Build;
import android.util.Log;
import androidx.core.content.LocusIdCompat;
import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.IconCompat;
import java.util.Collections;
import java.util.Set;
/**
* Publish a long-lived sharing shortcut for a Matrix room so the system
* treats per-room MessagingStyle notifications as conversations on
* Android 11+ (API 30+).
*
* Without a published shortcut whose id matches the notification's
* setShortcutId(), Android falls back to the app icon for the collapsed-
* preview avatar regardless of Person.setIcon / Builder.setLargeIcon
* Person icons are only consulted by the Conversation styling layer,
* which activates exclusively for notifications backed by a real
* ShortcutInfoCompat marked Long Lived + the SHORTCUT_CATEGORY_CONVERSATION
* sharing category.
*
* Idempotent: republishing the same shortcut id is the documented "update"
* path; ShortcutManagerCompat handles dedup internally. Cheap to call
* from the render hot path (~ms on warm system, indistinguishable from a
* SharedPreferences write at our scale).
*/
final class ConversationShortcuts {
private static final String TAG = "ConvShortcuts";
private ConversationShortcuts() {}
/**
* Publish or refresh the shortcut backing a room's conversation
* notification. No-op on API < 30 Conversation styling is an
* Android 11+ feature; older OS versions render the notification
* fine without the shortcut, and the largeIcon/Person.setIcon
* pipeline is the primary avatar source on them.
*
* @param ctx Context for the shortcut manager binding.
* @param roomId Matrix room id, used as the shortcut id so it
* matches NotificationCompat.Builder.setShortcutId.
* @param isDirect Whether the room is a DM; flips the shortcut
* category so launchers can group DMs separately.
* @param label Short visible label, typically the room name (or
* the peer's display name for a DM).
* @param avatar Optional cached avatar bitmap. Null falls through
* to the app launcher icon still publishes the
* shortcut so the conversation styling activates.
*/
/**
* Returns the published ShortcutInfoCompat so the caller can attach
* it directly to the notification via setShortcutInfo() this is
* the documented "atomic publish + bind" path that avoids the race
* where the notification posts before the shortcut publish has
* settled and Android sees an orphan shortcut id. Null on API < 30,
* null on failure (notification still posts cleanly).
*/
static ShortcutInfoCompat publishForRoom(
Context ctx,
String roomId,
boolean isDirect,
String label,
Bitmap avatar
) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return null;
}
if (roomId == null || roomId.isEmpty()) return null;
try {
// Conversation shortcut icon MUST be adaptive official docs:
// "To avoid unintentional clipping of your shortcut avatar,
// provide an AdaptiveIconDrawable for the shortcut's icon."
// Without this, Android silently falls back to the app's
// launcher icon for the collapsed-shade conversation avatar
// slot, even though shortcut publish + bind succeed.
// Resource icons (mipmap.ic_launcher) already ship with
// adaptive layers in the manifest; bitmap avatars need padding
// so the safe zone doesn't crop them.
IconCompat icon;
if (avatar != null) {
Bitmap padded = AvatarLoader.toAdaptivePaddedBitmap(avatar);
icon = IconCompat.createWithAdaptiveBitmap(padded);
} else {
icon = IconCompat.createWithResource(ctx, R.mipmap.ic_launcher);
}
// Intent the shortcut launches when tapped from the launcher
// long-press menu or share sheet opens MainActivity and
// delivers the same `room_id` extra the notification tap
// path uses, so the existing pushNotificationActionPerformed
// listener navigates correctly.
Intent launchIntent = new Intent(ctx, MainActivity.class)
.setAction(Intent.ACTION_VIEW)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP)
.putExtra("room_id", roomId)
// Capacitor PushNotificationsPlugin gates its action
// delivery on bundle.containsKey("google.message_id"); we
// attach an empty value so a launcher-initiated open
// takes the same path as a push-tap.
.putExtra("google.message_id", "");
// Constant value of androidx.core's
// ShortcutInfoCompat.SHORTCUT_CATEGORY_CONVERSATION. Hardcoded
// verbatim because older androidx.core in our dependency
// graph doesn't export the constant; the string itself is
// platform-stable per the Android shortcut category contract.
Set<String> categories =
Collections.singleton("android.shortcut.conversation");
ShortcutInfoCompat.Builder b = new ShortcutInfoCompat.Builder(ctx, roomId)
.setShortLabel(label != null && !label.isEmpty() ? label : "Vojo")
.setLongLabel(label != null && !label.isEmpty() ? label : "Vojo")
.setIntent(launchIntent)
.setIcon(icon)
.setLongLived(true)
.setCategories(categories)
// LocusId mirrors the shortcut id; the OS uses it to
// attribute the notification to a specific conversation
// for digital-wellbeing dashboards and bubble grouping.
.setLocusId(new LocusIdCompat(roomId))
// Marks isDirect so launchers / share sheet can present
// person-style affordances on DMs.
.setIsConversation();
// setPerson is only needed for one-on-one conversations to
// unlock direct-share suggestions, but for a DM we also want
// it to anchor the shortcut on the peer's identity. Skipped
// for groups (single Person doesn't represent the room).
if (isDirect) {
b.setPerson(new androidx.core.app.Person.Builder()
// setKey must match the Person.key used in the
// MessagingStyle so Android's conversation
// attribution matches the shortcut to the
// notification on the same identity.
.setKey(roomId)
.setName(label != null ? label : "")
.setIcon(icon)
.build());
}
ShortcutInfoCompat shortcut = b.build();
boolean ok = ShortcutManagerCompat.pushDynamicShortcut(ctx, shortcut);
Log.i(TAG, "publish room=" + roomId + " label=" + label
+ " hasAvatar=" + (avatar != null) + " ok=" + ok);
return shortcut;
} catch (Throwable t) {
// Shortcut publish is best-effort UX a failure must not
// sink the notification. Worst case: collapsed preview
// falls back to app icon (same as before the shortcut path
// existed at all).
Log.w(TAG, "publish failed room=" + roomId, t);
return null;
}
}
}

View file

@ -63,8 +63,6 @@ public class MainActivity extends BridgeActivity {
registerPlugin(FullScreenIntentPlugin.class);
registerPlugin(CallForegroundPlugin.class);
registerPlugin(LaunchSplashPlugin.class);
registerPlugin(ShareTargetPlugin.class);
registerPlugin(PollingPlugin.class);
// AndroidX SplashScreen must be installed before super.onCreate().
// Keep it until the web splash confirms its first visible frame is

View file

@ -1,147 +0,0 @@
package chat.vojo.app;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.util.Log;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Handles the per-notification "Mark as read" action.
*
* Posts {@code POST /_matrix/client/v3/rooms/{roomId}/receipt/m.read/{eventId}}
* using the access token saved by the polling lifecycle in
* {@code vojo_poll_state} SharedPreferences (same storage VojoPollWorker uses;
* keeps the credential lifecycle single-sourced). After a successful 2xx the
* per-room MessagingStyle notification is dismissed and the
* {@link RoomMessageCache} is cleared so the next push to that room starts a
* fresh conversation rather than re-appending to the prior history.
*
* Dismiss policy: OPTIMISTIC. The per-room notification is dismissed
* synchronously in onReceive before the HTTP receipt PUT is even
* attempted so the user sees instant feedback. The async receipt POST
* happens on a worker thread afterwards. This mirrors element-android's
* NotificationBroadcastReceiver pattern and matches the user's mental
* model ("I tapped, it should disappear immediately").
*
* Failure mode: on any non-2xx or thrown exception we accept that the
* server-side read receipt did not land. We do NOT re-post the
* notification or implement a flusher because:
* - the next room open from the JS app issues a fresh read-receipt
* for the latest visible event, catching up the server state
* - the in-app read-marker logic is the authoritative path; this
* receiver is a convenience for the shade-tap shortcut
* - accumulating tombstones in prefs (the CallDeclineReceiver pattern)
* would risk leaking historical eventIds the JS side would re-issue
* on app resume anyway
*
* Null-credential edge case (fresh install + first push before any
* saveSession bridge): no token to use, we still dismiss the notification
* locally so the user isn't stuck looking at a "stuck" Mark-as-read
* button. The next room open from JS covers the server view.
*/
public class MarkAsReadReceiver extends BroadcastReceiver {
public static final String ACTION_MARK_AS_READ = "chat.vojo.app.MARK_AS_READ";
public static final String EXTRA_ROOM_ID = "room_id";
public static final String EXTRA_EVENT_ID = "event_id";
private static final int CONNECT_TIMEOUT_MS = 8_000;
private static final int READ_TIMEOUT_MS = 8_000;
private static final String TAG = "MarkAsReadRcvr";
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
@Override
public void onReceive(Context context, Intent intent) {
if (intent == null) return;
final String roomId = intent.getStringExtra(EXTRA_ROOM_ID);
final String eventId = intent.getStringExtra(EXTRA_EVENT_ID);
if (roomId == null || roomId.isEmpty()) {
Log.w(TAG, "onReceive: missing room_id, abort");
return;
}
final Context appContext = context.getApplicationContext();
// Dismiss first for instant UX feedback HTTP latency is irrelevant
// to the perceived "marked as read" action.
VojoFirebaseMessagingService.dismissRoomNotification(appContext, roomId);
final SharedPreferences prefs = appContext.getSharedPreferences(
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
final String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null);
final String homeserver = prefs.getString(VojoPollWorker.KEY_HOMESERVER_URL, null);
if (token == null || token.isEmpty() || homeserver == null || homeserver.isEmpty()) {
Log.w(TAG, "onReceive: no credentials in prefs, local dismiss only");
return;
}
if (eventId == null || eventId.isEmpty()) {
// Without an eventId we cannot issue a receipt PUT the JS-side
// read-marker handler will catch this up on the next room open.
Log.w(TAG, "onReceive: no event_id, local dismiss only");
return;
}
final PendingResult pendingResult = goAsync();
EXECUTOR.execute(() -> {
try {
int status = sendReceipt(homeserver, token, roomId, eventId);
if (status >= 200 && status < 300) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "receipt ok status=" + status + " room=" + roomId);
}
} else {
Log.w(TAG, "receipt non-2xx status=" + status + " room=" + roomId);
}
} catch (Throwable t) {
Log.w(TAG, "receipt threw room=" + roomId, t);
} finally {
pendingResult.finish();
}
});
}
private int sendReceipt(
String baseUrl,
String accessToken,
String roomId,
String eventId
) throws IOException {
String url = trimTrailingSlash(baseUrl)
+ "/_matrix/client/v3/rooms/"
+ URLEncoder.encode(roomId, "UTF-8")
+ "/receipt/m.read/"
+ URLEncoder.encode(eventId, "UTF-8");
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
try {
conn.setRequestMethod("POST");
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
conn.setRequestProperty("Content-Type", "application/json");
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
conn.setReadTimeout(READ_TIMEOUT_MS);
conn.setDoOutput(true);
// Empty JSON body per spec; setFixedLengthStreamingMode keeps the
// connection on the cached path instead of chunked-transfer fallback.
byte[] payload = "{}".getBytes("UTF-8");
conn.setFixedLengthStreamingMode(payload.length);
try (java.io.OutputStream os = conn.getOutputStream()) {
os.write(payload);
}
return conn.getResponseCode();
} finally {
conn.disconnect();
}
}
private static String trimTrailingSlash(String s) {
return (s != null && s.endsWith("/")) ? s.substring(0, s.length() - 1) : s;
}
}

View file

@ -1,104 +0,0 @@
package chat.vojo.app;
import android.content.Context;
import android.content.SharedPreferences;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* Cross-source LRU dedup for rendered push event_ids.
*
* Both the FCM service (after a successful nm.notify) and the polling Worker
* write into the same bounded SharedPreferences-backed set. The Worker reads
* it to skip events FCM already delivered which fixes the regression where
* a user who dismissed an FCM notification before polling fired would see
* the same event resurface up to 15 minutes later via the polling fallback.
*
* The native `eventId.hashCode()` notification-id slot is still the primary
* dedup for *concurrent* render (Android NotificationManager replace), but
* that only collapses surfaces while both notifications are still visible;
* once the user dismisses, the slot is empty and the second render would
* post fresh. This shared set covers that gap.
*
* Synchronisation: SharedPreferences read-modify-write is not atomic across
* threads/processes, and FCM service runs on a Firebase-managed background
* thread while the Worker runs on WorkManager's executor. We serialise all
* mutations through a static lock. Critical sections are short (string split
* + LinkedHashSet trim + putString) no Binder calls.
*/
final class NotificationDedup {
// Capacity is intentionally larger than VojoPollWorker's worst-case per-run
// event count (MAX_PAGES_PER_RUN × PAGE_LIMIT = 250). If a single fire
// marks 250 events and the cap were 200, the 50 oldest of those would
// already be evicted by the time we finish writing so a sibling poll
// resuming the same window would re-render them. 500 gives 2× headroom
// while staying ~12 KB in SharedPreferences (negligible).
private static final int MAX_TRACKED = 500;
private static final Object lock = new Object();
private NotificationDedup() {}
/** Returns true iff the given event_id has been notified in a recent cycle. */
static boolean wasNotified(Context ctx, String eventId) {
if (eventId == null || eventId.isEmpty()) return false;
synchronized (lock) {
return readSet(ctx).contains(eventId);
}
}
/** Append the event_id to the LRU set, trimming the oldest when full. */
static void markNotified(Context ctx, String eventId) {
if (eventId == null || eventId.isEmpty()) return;
synchronized (lock) {
Set<String> set = readSet(ctx);
// LinkedHashSet preserves insertion order re-adding moves to tail
// only if we remove-then-add. The Set#add no-op on a present entry
// does NOT refresh position, but the simple "drop oldest" trim
// below is adequate for our scale and matches the Worker's
// existing semantics. Skip the disk write entirely when add()
// returned false the event was already in the set, persistence
// would just churn SharedPreferences for no state change.
if (!set.add(eventId)) return;
if (set.size() > MAX_TRACKED) {
Iterator<String> it = set.iterator();
int drop = set.size() - MAX_TRACKED;
while (it.hasNext() && drop > 0) {
it.next();
it.remove();
drop -= 1;
}
}
writeSet(ctx, set);
}
}
/** Caller must hold {@link #lock}. */
private static Set<String> readSet(Context ctx) {
SharedPreferences prefs = ctx.getSharedPreferences(
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
String raw = prefs.getString(VojoPollWorker.KEY_NOTIFIED_IDS, "");
Set<String> out = new LinkedHashSet<>();
if (raw.isEmpty()) return out;
for (String id : raw.split(",")) {
if (!id.isEmpty()) out.add(id);
}
return out;
}
/** Caller must hold {@link #lock}. */
private static void writeSet(Context ctx, Set<String> set) {
SharedPreferences prefs = ctx.getSharedPreferences(
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
StringBuilder sb = new StringBuilder(set.size() * 25);
boolean first = true;
for (String id : set) {
if (!first) sb.append(',');
sb.append(id);
first = false;
}
prefs.edit().putString(VojoPollWorker.KEY_NOTIFIED_IDS, sb.toString()).apply();
}
}

View file

@ -1,37 +0,0 @@
package chat.vojo.app;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.util.Log;
/**
* Fires when the user swipes a per-room MessagingStyle notification away.
*
* Without this hook, RoomMessageCache would still hold the prior messages
* for that room and the next push would append onto that history and
* re-surface the messages the user just dismissed. With it, swipe clears
* the cache so the next push starts a fresh conversation for the room.
*
* NOTE: this only fires for user-driven dismissals programmatic
* nm.cancel calls (mark-as-read, receipt-driven dismiss, channel migration)
* already call RoomMessageCache.clear themselves and do NOT fire the
* delete intent. There's no double-clear risk.
*/
public class NotificationDismissReceiver extends BroadcastReceiver {
public static final String ACTION_NOTIFICATION_DISMISSED =
"chat.vojo.app.NOTIFICATION_DISMISSED";
public static final String EXTRA_ROOM_ID = "room_id";
private static final String TAG = "DismissRcvr";
@Override
public void onReceive(Context context, Intent intent) {
if (intent == null) return;
String roomId = intent.getStringExtra(EXTRA_ROOM_ID);
if (roomId == null || roomId.isEmpty()) return;
if (BuildConfig.DEBUG) Log.d(TAG, "swipe clear cache room=" + roomId);
RoomMessageCache.clear(roomId);
}
}

View file

@ -1,236 +0,0 @@
package chat.vojo.app;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.work.Constraints;
import androidx.work.ExistingPeriodicWorkPolicy;
import androidx.work.NetworkType;
import androidx.work.PeriodicWorkRequest;
import androidx.work.WorkManager;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.util.concurrent.TimeUnit;
/**
* JS Android bridge for the WorkManager-based polling fallback.
*
* Lifecycle:
* - JS calls saveSession({accessToken, homeserverUrl, userId}) on login,
* on push (re)enable, and on visibilitychange visible (to recover a
* 401-cleared credentials slot without a full remount).
* - JS calls schedule({intervalMinutes}) once push is enabled. Idempotent:
* KEEP policy means a second schedule() call against an already-enqueued
* worker is a no-op (the running period continues unchanged).
* - JS calls saveRoomNames({names}) on mount + visibilitychange visible
* so VojoPollWorker has a local cache to resolve room_id display name
* without making N extra GET /rooms/{id}/state/m.room.name requests.
* Brand-new rooms created between visibility events fall back to
* sender_display_name in the renderer.
* - JS calls cancel() + clearSession() on logout / push disable.
*
* Worker tag: a single unique periodic worker named UNIQUE_WORK_NAME KEEP
* policy prevents schedule churn from re-creating it. Cancel() removes it
* by the same name.
*/
@CapacitorPlugin(name = "Polling")
public class PollingPlugin extends Plugin {
private static final String TAG = "PollingPlugin";
private static final String UNIQUE_WORK_NAME = "vojo_push_poll";
// Android's hard floor for PeriodicWorkRequest. Requests with shorter
// intervals are silently clamped to 15 minutes. We accept the requested
// value from JS but enforce the floor here so misuse from JS doesn't
// produce a silently-different behavior.
private static final long MIN_INTERVAL_MINUTES = 15;
@PluginMethod
public void saveSession(PluginCall call) {
String accessToken = call.getString("accessToken");
String homeserverUrl = call.getString("homeserverUrl");
if (accessToken == null || accessToken.isEmpty()
|| homeserverUrl == null || homeserverUrl.isEmpty()) {
call.reject("missing_accessToken_or_homeserverUrl");
return;
}
String userId = call.getString("userId");
SharedPreferences prefs = getContext()
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE);
SharedPreferences.Editor editor = prefs.edit()
.putString(VojoPollWorker.KEY_ACCESS_TOKEN, accessToken)
.putString(VojoPollWorker.KEY_HOMESERVER_URL, homeserverUrl);
if (userId != null && !userId.isEmpty()) {
editor.putString(VojoPollWorker.KEY_USER_ID, userId);
}
// Seed the watermark to "now minus a small clock-skew buffer" on the
// first saveSession after install / logout. Without seeding the
// Worker's first fire sees watermark=0 and renders every historical
// unread /notifications entry as a fresh push. The buffer covers the
// case where the device clock runs ahead of the homeserver's clock
// event ts is server-side, so a too-fresh local seed would silently
// skip recently-arrived events as "older than watermark" forever.
// 60s tolerates typical NTP drift while still suppressing days-old
// backlog on first enable. We seed only when the key is absent so
// subsequent saveSession calls (token rotation, visibilitychange
// re-bridge) don't reset live state.
if (!prefs.contains(VojoPollWorker.KEY_LAST_SEEN_TS)) {
editor.putLong(
VojoPollWorker.KEY_LAST_SEEN_TS,
System.currentTimeMillis() - SEED_CLOCK_SKEW_BUFFER_MS
);
}
editor.apply();
call.resolve();
}
private static final long SEED_CLOCK_SKEW_BUFFER_MS = 60_000L;
@PluginMethod
public void clearSession(PluginCall call) {
getContext()
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE)
.edit()
.remove(VojoPollWorker.KEY_ACCESS_TOKEN)
.remove(VojoPollWorker.KEY_HOMESERVER_URL)
.remove(VojoPollWorker.KEY_USER_ID)
.remove(VojoPollWorker.KEY_LAST_SEEN_TS)
.remove(VojoPollWorker.KEY_DRAIN_CURSOR)
.remove(VojoPollWorker.KEY_DRAIN_TARGET_TS)
.remove(VojoPollWorker.KEY_NOTIFIED_IDS)
.remove(VojoPollWorker.KEY_ROOM_NAMES)
.remove(VojoPollWorker.KEY_USER_AVATARS)
.apply();
call.resolve();
}
/**
* user_id MXC avatar URL snapshot. Mirrors {@link #saveRoomNames}
* stored as a JSON blob in vojo_poll_state for the FCM service /
* polling Worker / ReplyReceiver to consult via
* VojoFirebaseMessagingService.lookupUserAvatarMxc. JS dumps on the
* same lifecycle triggers as room names (mount, visibility resume,
* m.direct change, m.room.encryption flip).
*/
@PluginMethod
public void saveUserAvatars(PluginCall call) {
JSObject avatars = call.getObject("avatars");
if (avatars == null) {
call.reject("missing_avatars");
return;
}
String serialized = avatars.toString();
getContext()
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE)
.edit()
.putString(VojoPollWorker.KEY_USER_AVATARS, serialized)
.apply();
Log.i(TAG, "saveUserAvatars: " + avatars.length() + " entries, "
+ serialized.length() + " bytes");
call.resolve();
}
@PluginMethod
public void saveRoomNames(PluginCall call) {
JSObject names = call.getObject("names");
if (names == null) {
// Empty map is also valid (user cleared all rooms) JS passes
// {} explicitly in that case; missing key is a contract bug.
call.reject("missing_names");
return;
}
// `JSObject extends JSONObject`, so names.toString() is already a
// valid JSON serialisation of validated values no need to re-parse
// it through `new JSONObject(...)` just to re-serialise. Persist
// verbatim.
String serialized = names.toString();
getContext()
.getSharedPreferences(VojoPollWorker.PREFS, Context.MODE_PRIVATE)
.edit()
.putString(VojoPollWorker.KEY_ROOM_NAMES, serialized)
.apply();
Log.i(TAG, "saveRoomNames: " + names.length() + " entries, "
+ serialized.length() + " bytes");
call.resolve();
}
@PluginMethod
public void schedule(PluginCall call) {
Integer intervalMinutes = call.getInt("intervalMinutes", 15);
long interval = Math.max(MIN_INTERVAL_MINUTES, intervalMinutes != null ? intervalMinutes : 15);
Constraints constraints = new Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build();
PeriodicWorkRequest req = new PeriodicWorkRequest.Builder(
VojoPollWorker.class, interval, TimeUnit.MINUTES
)
.setConstraints(constraints)
.addTag("vojo_push_poll")
.build();
try {
WorkManager.getInstance(getContext())
.enqueueUniquePeriodicWork(
UNIQUE_WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
req
);
Log.d(TAG, "scheduled periodic poll every " + interval + " minutes");
call.resolve();
} catch (Throwable t) {
Log.w(TAG, "schedule failed", t);
call.reject("schedule_failed: " + t.getMessage());
}
}
/**
* Dismiss the per-room MessagingStyle notification + clear the in-memory
* RoomMessageCache for the room. Called from the JS receipt listener when
* a server-side read receipt zeroes the unread count (the user read on
* another device / tab). No-op if the notification was never posted or
* has already been swiped away.
*/
@PluginMethod
public void dismissRoom(PluginCall call) {
String roomId = call.getString("roomId");
if (roomId == null || roomId.isEmpty()) {
call.reject("missing_roomId");
return;
}
VojoFirebaseMessagingService.dismissRoomNotification(getContext(), roomId);
call.resolve();
}
@PluginMethod
public void cancel(PluginCall call) {
try {
// Block on the Operation so callers awaiting cancel() see the
// cancel committed to WorkManager's database before we resolve.
// (NOTE: this does NOT interrupt a Worker that's already mid
// doWork(); cooperative cancellation via isStopped() is owned
// by VojoPollWorker itself.) Without this wait a fast
// disablereenable 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());
}
}
}

View file

@ -45,55 +45,6 @@ final class PushStrings {
return forAppLocale(ctx).getString(R.string.push_invitation);
}
static String missedCallTitle(Context ctx) {
return forAppLocale(ctx).getString(R.string.push_missed_call);
}
static String missedCallBody(Context ctx, String caller) {
String safeCaller = caller == null ? "" : caller;
return forAppLocale(ctx).getString(R.string.push_missed_call_body, safeCaller);
}
static String channelGroup(Context ctx) {
return forAppLocale(ctx).getString(R.string.push_channel_group);
}
static String channelDm(Context ctx) {
return forAppLocale(ctx).getString(R.string.push_channel_dm);
}
static String channelDmDescription(Context ctx) {
return forAppLocale(ctx).getString(R.string.push_channel_dm_description);
}
static String channelGroupRoom(Context ctx) {
return forAppLocale(ctx).getString(R.string.push_channel_group_room);
}
static String channelGroupRoomDescription(Context ctx) {
return forAppLocale(ctx).getString(R.string.push_channel_group_room_description);
}
static String selfName(Context ctx) {
return forAppLocale(ctx).getString(R.string.push_self_name);
}
static String markAsReadAction(Context ctx) {
return forAppLocale(ctx).getString(R.string.push_action_mark_as_read);
}
static String replyAction(Context ctx) {
return forAppLocale(ctx).getString(R.string.push_action_reply);
}
static String replyHint(Context ctx) {
return forAppLocale(ctx).getString(R.string.push_reply_hint);
}
static String replyFailed(Context ctx) {
return forAppLocale(ctx).getString(R.string.push_reply_failed);
}
/**
* Build the invite-notification body from inviter + room name, falling
* back through four variants when one or both are absent. The res IDs

View file

@ -1,248 +0,0 @@
package chat.vojo.app;
import android.app.NotificationManager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import androidx.core.app.RemoteInput;
import org.json.JSONObject;
import org.json.JSONException;
import java.io.IOException;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Handles the inline-reply RemoteInput action on a per-room MessagingStyle
* notification.
*
* Flow:
* 1. User taps reply, types text, presses send broadcast fires here.
* 2. We immediately append the outgoing message to RoomMessageCache and
* re-post the notification (instant UX feedback the message appears
* as a self-Person bubble in the conversation while the HTTP is in
* flight).
* 3. PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}
* with {msgtype: "m.text", body}. Uses the vojo_poll_state token (same
* storage as Worker / MarkAsReadReceiver single credential lifecycle).
* 4. On 2xx: nothing further; the JS sync echo will eventually replace
* the local-echo bubble in-app.
* 5. On non-2xx or thrown: post a small error notification "Could not
* send your reply" so the user knows to retry from in-app — better
* than silently swallowing the message.
*
* E2EE rooms are guarded UP-STREAM in VojoFirebaseMessagingService.
* renderMessageNotification: we don't even attach the reply action when
* RoomMetadata.isEncrypted is true. So this receiver never has to encrypt.
* Defense in depth: if a stale notification with the action ever survives
* an encryption flip we still detect the failure as a non-2xx HTTP and
* surface the error notification rather than sending cleartext (which
* Synapse would in any case reject for an encrypted room).
*
* Null-credential edge case: post the error notification so the user
* notices and retries in-app. Same logic as a network failure.
*/
public class ReplyReceiver extends BroadcastReceiver {
public static final String ACTION_REPLY = "chat.vojo.app.REPLY";
public static final String EXTRA_ROOM_ID = "room_id";
public static final String KEY_TEXT_REPLY = "vojo.text_reply";
private static final int CONNECT_TIMEOUT_MS = 8_000;
private static final int READ_TIMEOUT_MS = 8_000;
private static final String TAG = "ReplyRcvr";
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
@Override
public void onReceive(Context context, Intent intent) {
if (intent == null) return;
final String roomId = intent.getStringExtra(EXTRA_ROOM_ID);
if (roomId == null || roomId.isEmpty()) {
Log.w(TAG, "onReceive: missing room_id, abort");
return;
}
Bundle remote = RemoteInput.getResultsFromIntent(intent);
if (remote == null) {
Log.w(TAG, "onReceive: no RemoteInput results");
return;
}
CharSequence reply = remote.getCharSequence(KEY_TEXT_REPLY);
if (reply == null) {
Log.w(TAG, "onReceive: RemoteInput missing text");
return;
}
final String text = reply.toString().trim();
if (text.isEmpty()) return;
final Context appContext = context.getApplicationContext();
// Pre-flight validation BEFORE the optimistic echo. Posting a self
// bubble first and then immediately stacking an error notif on top
// is jarring UX; for predictable failures (logged out, freshly
// encrypted room) we'd rather skip the echo and only surface the
// error.
final SharedPreferences prefs = appContext.getSharedPreferences(
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
final String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null);
final String homeserver = prefs.getString(VojoPollWorker.KEY_HOMESERVER_URL, null);
if (token == null || token.isEmpty() || homeserver == null || homeserver.isEmpty()) {
Log.w(TAG, "onReceive: no credentials in prefs, surfacing error notif");
postReplyError(appContext, roomId);
return;
}
// Race guard for E2EE flip: the per-room metadata snapshot is
// refreshed by JS on m.room.encryption Timeline events, but a push
// delivered in the narrow window between the encryption state
// landing and the dump completing could still expose the reply
// action on a freshly-encrypted room. Re-read the snapshot
// synchronously here Synapse does NOT enforce "no cleartext in
// encrypted rooms" at the spec level, so without this guard we'd
// leak the user's reply into an E2EE timeline as plaintext.
if (isRoomEncryptedAtSendTime(prefs, roomId)) {
Log.w(TAG, "onReceive: room flipped to encrypted between render and send, abort");
postReplyError(appContext, roomId);
return;
}
// Optimistic local echo appends a self-Person message to the
// conversation and re-posts, so the user sees their reply in the
// shade before the HTTP completes. Only happens after pre-flight
// checks pass so the user doesn't see an echo for a reply we know
// will fail.
long now = System.currentTimeMillis();
VojoFirebaseMessagingService.appendOutgoingMessage(appContext, roomId, text, now);
final PendingResult pendingResult = goAsync();
final String txnId = "vojo-reply-" + UUID.randomUUID();
EXECUTOR.execute(() -> {
try {
int status = sendReply(homeserver, token, roomId, txnId, text);
if (status >= 200 && status < 300) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "reply ok status=" + status + " room=" + roomId);
}
} else {
Log.w(TAG, "reply non-2xx status=" + status + " room=" + roomId);
postReplyError(appContext, roomId);
}
} catch (Throwable t) {
Log.w(TAG, "reply threw room=" + roomId, t);
postReplyError(appContext, roomId);
} finally {
pendingResult.finish();
}
});
}
private int sendReply(
String baseUrl,
String accessToken,
String roomId,
String txnId,
String text
) throws IOException {
String url = trimTrailingSlash(baseUrl)
+ "/_matrix/client/v3/rooms/"
+ URLEncoder.encode(roomId, "UTF-8")
+ "/send/m.room.message/"
+ URLEncoder.encode(txnId, "UTF-8");
JSONObject body;
try {
body = new JSONObject();
body.put("msgtype", "m.text");
body.put("body", text);
} catch (org.json.JSONException je) {
// JSONObject.put only throws on NaN/Inf doubles, neither of
// which we use but keep the type contract honest.
throw new IOException("payload encode failed", je);
}
byte[] payload = body.toString().getBytes("UTF-8");
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
try {
conn.setRequestMethod("PUT");
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
conn.setRequestProperty("Content-Type", "application/json");
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
conn.setReadTimeout(READ_TIMEOUT_MS);
conn.setDoOutput(true);
conn.setFixedLengthStreamingMode(payload.length);
try (OutputStream os = conn.getOutputStream()) {
os.write(payload);
}
return conn.getResponseCode();
} finally {
conn.disconnect();
}
}
/**
* Surface a short error notification when the reply HTTP fails so the
* user knows the message did NOT land server-side and can retry from
* within the app. Posted on the DM channel as a one-shot. Unique notif
* id per room so it can't clobber the room's conversation slot.
*/
private static void postReplyError(Context ctx, String roomId) {
NotificationManager nm = (NotificationManager)
ctx.getSystemService(Context.NOTIFICATION_SERVICE);
if (nm == null) return;
try {
String channel = VojoFirebaseMessagingService.CHANNEL_ID_DM;
NotificationCompat.Builder b = new NotificationCompat.Builder(ctx, channel)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(PushStrings.replyFailed(ctx))
.setContentText(PushStrings.replyFailed(ctx))
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT);
int errId = ("replyErr_" + roomId).hashCode();
nm.notify(errId, b.build());
} catch (Throwable t) {
Log.w(TAG, "reply error notif failed", t);
}
}
private static String trimTrailingSlash(String s) {
return (s != null && s.endsWith("/")) ? s.substring(0, s.length() - 1) : s;
}
/**
* Synchronous re-check of the room's encryption flag at send time.
* Mirrors VojoFirebaseMessagingService.loadRoomMetadata's tolerant
* parse: legacy string-shape entries and missing flags both default
* to encrypted=true (privacy-first refusing a reply on a falsely-
* flagged room is harmless; sending cleartext into a truly encrypted
* room is a privacy leak).
*/
private static boolean isRoomEncryptedAtSendTime(SharedPreferences prefs, String roomId) {
String raw = prefs.getString(VojoPollWorker.KEY_ROOM_NAMES, null);
if (raw == null || raw.isEmpty()) return true;
try {
JSONObject map = new JSONObject(raw);
if (!map.has(roomId) || map.isNull(roomId)) return true;
JSONObject obj = map.optJSONObject(roomId);
if (obj == null) {
// Legacy string-shape predates the encryption flag
// assume encrypted to err on the side of privacy.
return true;
}
return obj.optBoolean("isEncrypted", true);
} catch (JSONException je) {
return true;
}
}
}

View file

@ -1,176 +0,0 @@
package chat.vojo.app;
import androidx.core.app.NotificationCompat;
import androidx.core.app.Person;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
/**
* Per-room MessagingStyle history cache.
*
* Stores the last N messages observed for each room so renderMessageNotification
* can rebuild a NotificationCompat.MessagingStyle with conversation context on
* every new event instead of posting a fresh single-message notification per
* event. Without this every 5-message DM produced 5 distinct entries in the
* shade; with it the user sees one expandable conversation per room the
* WhatsApp/Telegram convention.
*
* Thread-safety: ConcurrentHashMap + per-key synchronized mutation via the
* compute() / get() pattern. Both VojoFirebaseMessagingService.onMessageReceived
* (Firebase-managed thread) and VojoPollWorker.doWork (WorkManager executor)
* mutate the cache; without serialization a same-room FCM + polling race could
* lose a message. Mutations are short only deque append + bounded trim.
*
* Persistence: in-memory only. After process kill the cache is empty, and
* renderMessageNotification falls back to extractMessagingStyleFromNotification
* to recover history from the live system shade. If the user dismissed the
* notification too, the conversation legitimately starts fresh no signal we
* could recover from there anyway.
*
* Eviction: bounded at MAX_MESSAGES_PER_ROOM per room, with FIFO eviction
* (oldest message at the head of the deque is dropped via pollFirst when the
* append would exceed the cap). Map itself is unbounded; in practice the
* dump from dismissRoom (when a server-side read receipt clears unread) keeps
* the room count proportional to active conversations. For safety against
* runaway growth from rooms the user never reads, we cap the map at MAX_ROOMS.
*/
final class RoomMessageCache {
// Element-android keeps a similar in-memory queue (NotificationEventQueue);
// 20 messages per room is generous enough for an active group chat while
// staying well under Android's MessagingStyle render budget Android only
// shows the last ~7 messages in the shade anyway.
private static final int MAX_MESSAGES_PER_ROOM = 20;
// Hard cap on the map size so a long-running session that touches many
// rooms without ever clearing receipts can't slowly leak memory.
// Eviction is approximate (oldest-touched first via insertion order from
// ConcurrentHashMap is NOT guaranteed, so we just clear the oldest by
// arbitrary entry on overflow acceptable for an LRU at this scale).
private static final int MAX_ROOMS = 200;
private static final ConcurrentHashMap<String, Deque<Entry>> store =
new ConcurrentHashMap<>();
private RoomMessageCache() {}
/**
* Snapshot of a single rendered message. We can't store
* NotificationCompat.MessagingStyle.Message directly because Person's
* Icon field is not safely shareable across threads / not cheap to
* rebuild on every poll. Building the Message at render time from this
* record matches element-android's RoomGroupMessageCreator pattern.
*/
static final class Entry {
// Matrix event_id when known (incoming pushes always carry one;
// outgoing optimistic-echo entries pass null). Used by append() to
// suppress duplicate appends when FCM retries / cross-source
// delivery hands the same event in twice without this the
// MessagingStyle conversation would render the same message N
// times in the shade.
final String eventId;
final String body;
final long timestamp;
final String senderKey;
final String senderName;
final boolean fromSelf;
Entry(
String eventId,
String body,
long timestamp,
String senderKey,
String senderName,
boolean fromSelf
) {
this.eventId = eventId;
this.body = body;
this.timestamp = timestamp;
this.senderKey = senderKey;
this.senderName = senderName;
this.fromSelf = fromSelf;
}
}
/**
* Append a message to the room's history and return an ordered snapshot
* including the newly-added entry. Snapshot is taken INSIDE the atomic
* compute() so a concurrent append for the same room can't mutate the
* deque between our addLast and our copy. Returning the deque reference
* and copying outside is unsafe ConcurrentHashMap.compute serialises
* only the lambda body per key, not subsequent reads of the value.
*/
static List<Entry> append(String roomId, Entry entry) {
if (roomId == null || roomId.isEmpty() || entry == null) {
return java.util.Collections.emptyList();
}
final List<Entry> snapshot = new ArrayList<>();
store.compute(roomId, (key, existing) -> {
Deque<Entry> d = (existing != null) ? existing : new ArrayDeque<>();
// Dedup by eventId protects against FCM retry / cross-source
// (FCM + polling Worker) double-delivery that would otherwise
// append the same event twice. Only applies when both the new
// entry and a prior one carry a non-empty eventId; outgoing
// self-echo entries have null eventId by design and never
// collide.
boolean isDup = false;
if (entry.eventId != null && !entry.eventId.isEmpty()) {
for (Entry prior : d) {
if (entry.eventId.equals(prior.eventId)) {
isDup = true;
break;
}
}
}
if (!isDup) {
d.addLast(entry);
while (d.size() > MAX_MESSAGES_PER_ROOM) {
d.pollFirst();
}
}
snapshot.addAll(d);
return d;
});
// Bound the map. Iteration order of ConcurrentHashMap is unspecified
// and the size() check is racy with concurrent puts; we accept ±1
// eviction precision at the 200-room cap as an acceptable approximation
// of LRU (the alternative is a global lock on every append which is
// far more expensive than letting the cache drift by one).
if (store.size() > MAX_ROOMS) {
java.util.Iterator<String> it = store.keySet().iterator();
while (it.hasNext() && store.size() > MAX_ROOMS) {
String key = it.next();
if (!key.equals(roomId)) it.remove();
}
}
return snapshot;
}
/**
* Seed the room's history from an already-posted MessagingStyle (recovered
* via NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification
* after process kill). Idempotent: if the room already has cached entries
* we leave them alone they are by construction at least as recent.
*/
static void seedIfAbsent(String roomId, List<Entry> entries) {
if (roomId == null || roomId.isEmpty() || entries == null || entries.isEmpty()) return;
store.computeIfAbsent(roomId, key -> {
Deque<Entry> d = new ArrayDeque<>();
for (Entry e : entries) {
d.addLast(e);
while (d.size() > MAX_MESSAGES_PER_ROOM) d.pollFirst();
}
return d;
});
}
/** Drop all cached messages for a room (e.g. on receipt-driven dismiss). */
static void clear(String roomId) {
if (roomId == null || roomId.isEmpty()) return;
store.remove(roomId);
}
}

View file

@ -1,273 +0,0 @@
package chat.vojo.app;
import android.content.ContentResolver;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.provider.OpenableColumns;
import android.util.Log;
import android.webkit.MimeTypeMap;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Receives ACTION_SEND / ACTION_SEND_MULTIPLE intents from the system share-
* sheet and surfaces them to the WebView as a pending share that JS consumes
* via {@code pickPendingShare()} (or reacts to via the {@code shareReceived}
* event when the app was already in the foreground).
*
* Cold-start flow:
* 1. Share-sheet Vojo MainActivity.onCreate super.onCreate runs
* BridgeActivity.load(), which itself calls bridge.onNewIntent(getIntent())
* and fans the intent out to every plugin's handleOnNewIntent. So
* cold-start and warm-start share the SAME entry point we don't
* double-process via handleOnStart.
* 2. captureFromIntent copies payload bytes into the app cache and stashes
* the result in {@link #pendingShare}.
* 3. JS booting up (Matrix client ready, user logged in) calls
* pickPendingShare(); receives the JSON; opens the room-picker UI. The
* shareReceived event fired here is dropped silently because no JS
* listener is attached yet that's fine, pickPendingShare drains the
* slot regardless.
*
* Warm flow (app already running):
* 1. Share-sheet MainActivity.onNewIntent BridgeActivity forwards to
* plugin.handleOnNewIntent(intent).
* 2. We re-capture the payload AND emit {@code shareReceived} so JS can
* open the picker without polling.
*
* Why we copy to cache instead of handing JS a content:// URI:
* - WebView fetch() rejects content:// schemes outright, and
* `Capacitor.convertFileSrc()` only works on file paths.
* - The originating app holds the read-grant only for the lifetime of the
* launching task; routing the URI through JS+picker+RoomInput would race
* that grant on Android 14+.
* - Copying into our own cache means the share is self-contained: even if
* the user backgrounds Vojo for hours before picking a chat, the bytes
* are still there. We schedule no cleanup of our own Android's cache
* eviction handles long-tail garbage.
*/
@CapacitorPlugin(name = "ShareTarget")
public class ShareTargetPlugin extends Plugin {
private static final String TAG = "ShareTargetPlugin";
private static final String SHARE_CACHE_SUBDIR = "shared";
// Single-slot pending share. Multiple share-sheet invocations before JS
// drains the slot collapse the latest wins. JS contract is "consume
// once, then it's gone" via pickPendingShare(consume=true). This matches
// user intent: tapping share twice on different photos clearly means
// "share THIS one now".
private volatile JSObject pendingShare = null;
@Override
public void handleOnNewIntent(Intent intent) {
super.handleOnNewIntent(intent);
captureFromIntent(intent, /* notifyJs */ true);
}
@PluginMethod
public void pickPendingShare(PluginCall call) {
JSObject ret = new JSObject();
JSObject snapshot = pendingShare;
if (snapshot == null) {
ret.put("empty", true);
} else {
// Default: consume on read. Lets us treat the slot like a one-shot
// mailbox without an extra round-trip. Caller can pass consume=false
// to peek (not used today, but cheap to keep).
Boolean consume = call.getBoolean("consume", Boolean.TRUE);
ret = snapshot;
if (Boolean.TRUE.equals(consume)) {
pendingShare = null;
}
}
call.resolve(ret);
}
private void captureFromIntent(Intent intent, boolean notifyJs) {
if (intent == null) return;
String action = intent.getAction();
if (action == null) return;
// Capacitor's JSObject.put() silently swallows JSONException internally
// (it wraps org.json.JSONObject and returns `this` on failure) so no
// checked exception is thrown here unlike the raw org.json API.
JSObject share = new JSObject();
share.put("empty", false);
String text = intent.getStringExtra(Intent.EXTRA_TEXT);
String subject = intent.getStringExtra(Intent.EXTRA_SUBJECT);
if (text != null && !text.isEmpty()) share.put("text", text);
if (subject != null && !subject.isEmpty()) share.put("subject", subject);
JSArray items = new JSArray();
List<Uri> uris = new ArrayList<>();
if (Intent.ACTION_SEND.equals(action)) {
Uri uri;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
uri = intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri.class);
} else {
// Deprecated overload required to read EXTRA_STREAM on
// API 32, where the typed variant doesn't exist.
//noinspection deprecation
uri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
}
if (uri != null) uris.add(uri);
} else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
List<Uri> multi;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
multi = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri.class);
} else {
//noinspection deprecation
multi = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
}
if (multi != null) uris.addAll(multi);
}
String intentMime = intent.getType();
for (Uri uri : uris) {
JSObject item = copyUriToCache(uri, intentMime);
if (item != null) items.put(item);
}
share.put("items", items);
// Drop pure-noise intents neither text nor a successfully
// copied file. Possible if a sender app handed us only a content://
// URI we can't read (permission revoked) or an EXTRA_STREAM with a
// null Uri. Keeps JS from showing an empty picker.
if (text == null && subject == null && items.length() == 0) {
Log.w(TAG, "Dropping share intent with no usable payload");
return;
}
pendingShare = share;
if (notifyJs) {
notifyListeners("shareReceived", new JSObject());
}
}
/**
* Stream the content of {@code uri} into a fresh file under
* cacheDir/shared/, then return {name, mimeType, size, path}. The path is
* an absolute filesystem path JS wraps it with
* {@code Capacitor.convertFileSrc} before fetch().
*/
private JSObject copyUriToCache(Uri uri, String fallbackMime) {
if (uri == null) return null;
ContentResolver resolver = getContext().getContentResolver();
String name = queryDisplayName(resolver, uri);
String mimeType = resolver.getType(uri);
if (mimeType == null) mimeType = fallbackMime;
if (mimeType == null) mimeType = "application/octet-stream";
if (name == null || name.isEmpty()) {
String ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
name = "share-" + UUID.randomUUID() + (ext != null ? "." + ext : "");
}
File dir = new File(getContext().getCacheDir(), SHARE_CACHE_SUBDIR);
// mkdirs returns false if the directory already exists not an error.
// The real failure mode is the I/O exception below on FileOutputStream
// construction, which we surface.
if (!dir.exists() && !dir.mkdirs()) {
Log.e(TAG, "Could not create share cache dir: " + dir);
return null;
}
// Prefix with UUID so a repeated share of "IMG_1234.jpg" doesn't
// overwrite the previous payload while the user is still picking a
// chat for the older one (e.g. Gallery Vojo, see room-picker open,
// background Gallery re-share same file foreground Vojo). Both
// payloads stay independently addressable.
File out = new File(dir, UUID.randomUUID() + "_" + safeFileName(name));
// Open the input first; if the sender's provider hands us back
// null (revoked grant, gone-away ContentProvider, ) bail before
// creating any on-disk file otherwise the FileOutputStream
// initializer below would create a zero-byte orphan we'd never
// clean up (catch arm doesn't fire when we early-return).
long size;
try (InputStream in = resolver.openInputStream(uri)) {
if (in == null) {
Log.w(TAG, "openInputStream returned null for " + uri);
return null;
}
try (FileOutputStream fos = new FileOutputStream(out)) {
byte[] buf = new byte[64 * 1024];
int n;
long total = 0;
while ((n = in.read(buf)) > 0) {
fos.write(buf, 0, n);
total += n;
}
size = total;
}
} catch (IOException e) {
Log.e(TAG, "Failed to copy " + uri, e);
// Drop the partial file so we don't surface a truncated
// payload to JS as if it were valid.
//noinspection ResultOfMethodCallIgnored
out.delete();
return null;
}
JSObject item = new JSObject();
item.put("name", name);
item.put("mimeType", mimeType);
item.put("size", size);
item.put("path", out.getAbsolutePath());
return item;
}
private String queryDisplayName(ContentResolver resolver, Uri uri) {
// ContentResolver.query throws if the provider rejects the URI scheme
// (e.g. some senders pass a file:// directly no provider involved).
// Wrap in try/catch and fall back to the URI's last path segment.
try (Cursor c = resolver.query(uri, new String[]{ OpenableColumns.DISPLAY_NAME }, null, null, null)) {
if (c != null && c.moveToFirst()) {
int idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (idx >= 0) {
String name = c.getString(idx);
if (name != null && !name.isEmpty()) return name;
}
}
} catch (Throwable t) {
Log.d(TAG, "queryDisplayName failed for " + uri + ": " + t.getMessage());
}
String last = uri.getLastPathSegment();
if (last != null && !last.isEmpty()) {
// Strip any directory traversal a malicious sender might encode.
int slash = last.lastIndexOf('/');
return slash >= 0 ? last.substring(slash + 1) : last;
}
return null;
}
private static String safeFileName(String name) {
// Strip path separators and trim length the on-disk name is just an
// identifier; the display name we return to JS preserves the user's
// original filename verbatim. Trim from the tail so the recognisable
// head ("IMG_2025_05_16…") survives and the extension is the part
// that gets clipped on absurdly long names; the on-disk extension
// doesn't matter because nothing inside Vojo dispatches on it (the
// display name carries the real extension into JS).
String stripped = name.replaceAll("[/\\\\]", "_");
if (stripped.length() > 120) stripped = stripped.substring(0, 120);
return stripped;
}
}

View file

@ -1,675 +0,0 @@
package chat.vojo.app;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationManagerCompat;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
/**
* Periodic poll of `/_matrix/client/v3/notifications` as a fallback delivery
* channel for users whose network blocks FCM (mtalk.google.com:5228) the
* ~5% slice on whitelist intranets (corporate / school / government) that
* otherwise receive zero pushes.
*
* Scheduling: enqueued from PollingPlugin.schedule() with a 15-minute period
* (Android's minimum for PeriodicWorkRequest) and CONNECTED network constraint.
* Cancelled via PollingPlugin.cancel() on logout / push disable.
*
* Credentials: read from SharedPreferences (saved by the JS side through
* PollingPlugin.saveSession). Vanilla Synapse (no MAS/OIDC) issues
* non-expiring access tokens; we do not implement refresh-token flow here.
* If a 401 ever occurs, doWork returns Result.success() the next foreground
* launch re-saves the credentials and polling resumes. Retrying with a stale
* token would just waste battery and amplify rate limits.
*
* Output: messages and invites route through VojoFirebaseMessagingService
* .renderMessageNotification (shared with FCM, same notif-id slots
* Android dedupes by replace). RTC ring events route through
* .renderMissedCallNotification (always stale by the time we poll 15-min
* cadence vs 30-second ring lifetime), so the user sees "Missed call" instead
* of a phantom incoming-call CallStyle for a long-dead ring.
*
* E2EE caveat: Synapse cannot decrypt event content, so for end-to-end
* encrypted rooms the response carries `content.algorithm`+`ciphertext`
* with no `body`. The renderer falls through to PushStrings.messageFallback
* (i18n "New message") with the room name as title same UX as the web
* Service Worker on encrypted pushes. By design no key access from the
* Worker.
*
* Dedup is two complementary mechanisms:
* 1) A per-poll high-watermark on the latest event ts we've notified.
* Stored as KEY_LAST_SEEN_TS; advances only after a successful render
* (or a foreground-skipped event the user already saw in-app). Worker
* stops walking within a run as soon as it hits ts strictly less than
* watermark newest-first ordering guarantees the rest are also
* older. Same-ts events fall through to the secondary filters because
* multiple events can share a millisecond.
* 2) NotificationDedup a shared cross-source bounded LRU written by
* every renderer (FCM service after successful nm.notify, this Worker
* after successful render, and the ring-upsert paths at seed time).
* Lets the Worker skip events FCM already delivered even after the
* user dismissed the FCM notification.
*
* Each fire starts from the HEAD of /notifications (no persistent
* pagination cursor the spec's `next_token` walks BACKWARDS into
* history, so a persisted cursor silently drifts off the new events the
* next poll should see; see matrix-js-sdk client.ts:5040 for the
* reference traversal pattern). When a single fire's backlog exceeds
* MAX_PAGES_PER_RUN pages the leftover next_token is saved as
* KEY_DRAIN_CURSOR (with the head ts snapshotted in KEY_DRAIN_TARGET_TS)
* and resumed on the next run, so big backlogs (>250 events) drain over
* consecutive polls without being clipped.
*/
public class VojoPollWorker extends Worker {
private static final String TAG = "VojoPoll";
static final String PREFS = "vojo_poll_state";
static final String KEY_ACCESS_TOKEN = "access_token";
static final String KEY_HOMESERVER_URL = "homeserver_url";
static final String KEY_USER_ID = "user_id";
// High-watermark on the latest event ts we've already notified about.
// Stored as a long-millis string. Replaces an earlier `last_from` cursor
// experiment that misunderstood /notifications pagination direction.
static final String KEY_LAST_SEEN_TS = "last_seen_ts";
// Continuation cursor used when a single run hits MAX_PAGES_PER_RUN before
// reaching the watermark. Persists the next_token across runs so a >250
// event backlog drains over consecutive polls instead of being clipped
// forever by the page cap. Cleared once we either reach the watermark or
// exhaust pagination on a single run.
static final String KEY_DRAIN_CURSOR = "drain_cursor";
// The "head ts" we recorded when entering drain mode. After drain
// completes the watermark is jumped to THIS value rather than the
// (older) max ts seen during drain otherwise the bounded LRU could
// evict events from the original head and let the next normal run
// re-render them. Set once on entering drain mode, untouched while
// draining, cleared when drain completes.
static final String KEY_DRAIN_TARGET_TS = "drain_target_ts";
static final String KEY_NOTIFIED_IDS = "notified_ids";
static final String KEY_ROOM_NAMES = "room_names";
// user_id MXC avatar URL, JSON-encoded, bridged from JS via
// PollingPlugin.saveUserAvatars. Consumed by
// VojoFirebaseMessagingService.lookupUserAvatarMxc for per-sender
// Person.setIcon in MessagingStyle conversations. Bounded at 500
// entries on the JS side; read tolerantly here.
static final String KEY_USER_AVATARS = "user_avatars";
private static final int HTTP_TIMEOUT_MS = 30_000;
// Cap pages-per-fire so an unexpectedly large backlog (server-side bug,
// first run after a long offline window) cannot loop until Android's
// 10-minute Worker kill timer fires. 5 pages × 50 events = up to 250
// events per cycle well above realistic 15-minute backlog for a single
// user. We also break as soon as we hit ts watermark, so most polls
// touch only a single page.
private static final int MAX_PAGES_PER_RUN = 5;
private static final int PAGE_LIMIT = 50;
private static final String RTC_NOTIFICATION_TYPE = "org.matrix.msc4075.rtc.notification";
private static final String RTC_NOTIFICATION_TYPE_STABLE = "m.rtc.notification";
public VojoPollWorker(@NonNull Context context, @NonNull WorkerParameters params) {
super(context, params);
}
@NonNull
@Override
public Result doWork() {
Context ctx = getApplicationContext();
SharedPreferences prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
String token = prefs.getString(KEY_ACCESS_TOKEN, null);
String homeserver = prefs.getString(KEY_HOMESERVER_URL, null);
if (token == null || homeserver == null) {
// Not logged in (or JS hasn't bridged credentials yet). Return
// success so WorkManager keeps the periodic schedule alive
// we'll pick up the credentials on the next fire.
Log.i(TAG, "poll: no credentials, bail");
return Result.success();
}
// If POST_NOTIFICATIONS was revoked we'd fetch + parse + try to
// render and then watch every nm.notify fail with SecurityException
// which leaves the LRU/watermark unadvanced (correctly so for a
// transient failure) and re-runs the same loop every 15 minutes
// forever. Bail early to avoid burning battery on a permanent
// user choice. The next visibility re-bridge inside the JS app
// will pick up a re-granted permission.
if (!NotificationManagerCompat.from(ctx).areNotificationsEnabled()) {
Log.i(TAG, "poll: notifications disabled, bail");
return Result.success();
}
long watermark = prefs.getLong(KEY_LAST_SEEN_TS, 0L);
String drainCursor = prefs.getString(KEY_DRAIN_CURSOR, null);
long drainTargetTs = prefs.getLong(KEY_DRAIN_TARGET_TS, 0L);
boolean wasDraining = drainCursor != null;
Map<String, String> roomNames = loadRoomNamesMap(prefs);
// Mirror the FCM service's foreground gate: if the user is actively in
// the app, the live timeline owns the UX and a system notification for
// a backlog event would be both stale and visually noisy. We still
// consume state (LRU, watermark) so the same event doesn't surface
// when the user later backgrounds the app.
boolean inForeground = MainActivity.isInForeground;
Log.i(TAG, "poll: start fg=" + inForeground
+ " watermark=" + watermark
+ " draining=" + wasDraining);
int pagesFetched = 0;
int renderedCount = 0;
int skippedDedupCount = 0;
long highestTsSeen = watermark;
boolean reachedWatermark = false;
// The continuation cursor we'd save if this run is capped. Starts as
// the resumed drain cursor; advances with each successful page fetch
// so a transient mid-pagination error still preserves drain progress.
String pendingCursor = drainCursor;
boolean paginationExhausted = false;
try {
// Cursor strategy: drain cursor resumes from where a previous capped
// run stopped; otherwise we start from the HEAD. next_token from
// /notifications paginates BACKWARDS into history, so a stored
// cursor must be used as a drain-only continuation, NOT as an
// ongoing "since" mark (the latter would silently drift off new
// events). Within a single fire we stop as soon as ts < watermark
// (newest-first ordering means everything past that is covered).
String nextFrom = drainCursor;
for (int page = 0; page < MAX_PAGES_PER_RUN && !reachedWatermark; page += 1) {
// Cooperative cancellation. WorkManager.cancelUniqueWork (called
// from PollingPlugin.cancel during logout / push disable) only
// marks future scheduling it does NOT interrupt this thread.
// Without these checks the Worker keeps fetching pages, posting
// notifications, and (worst of all) running the final
// editor.apply() with stale state written AFTER clearSession
// wiped prefs leaking watermark / drain cursor from the
// logged-out account into the next login.
if (isStopped()) return Result.success();
JSONObject body = fetchNotifications(homeserver, token, nextFrom);
// fetchNotifications throws on every failure path; a null
// return is unreachable in current code. The early-break here
// is a defensive belt-and-suspenders keep paginationExhausted
// consistent so the drain-bookkeeping below clears the cursor
// instead of replaying the same empty page forever.
if (body == null) {
paginationExhausted = true;
pendingCursor = null;
break;
}
JSONArray notifications = body.optJSONArray("notifications");
if (notifications == null || notifications.length() == 0) {
// Server returned no entries for this page. Treat as
// end-of-pagination so a drain in progress can complete
// (otherwise pendingCursor would keep its old value and
// we'd re-fetch the same empty page next cycle forever).
paginationExhausted = true;
pendingCursor = null;
break;
}
for (int i = 0; i < notifications.length(); i += 1) {
if (isStopped()) return Result.success();
JSONObject entry = notifications.optJSONObject(i);
if (entry == null) continue;
String eventId = extractEventId(entry);
if (eventId == null) continue;
// ts gate: server returns newest-first, so once we hit
// ts STRICTLY less than the watermark we know the rest of
// the page (and every subsequent page) is already covered.
// Same-ts events fall through to the LRU/read filters
// below multiple events can share a millisecond, and
// collapsing them at the ts boundary would silently drop
// a fresh sibling of a previously-rendered one.
long ts = entry.optLong("ts", 0L);
if (ts > 0 && ts < watermark) {
reachedWatermark = true;
break;
}
// Skip notifications the user already read on another
// client (web tab, Element, second device). Spec marks
// `read` as a required boolean on each entry.
if (entry.optBoolean("read", false)) {
if (ts > highestTsSeen) highestTsSeen = ts;
continue;
}
// Skip events the push rules said don't notify (muted
// rooms, dont_notify overrides). Without this gate
// polling would re-surface events Sygnal already
// suppressed for the FCM path the mute toggle
// wouldn't actually mute on whitelist networks.
if (!notifyAllowed(entry)) {
if (ts > highestTsSeen) highestTsSeen = ts;
continue;
}
// Cross-source dedup via NotificationDedup: FCM writes
// into this set after every successful render, so the
// Worker correctly skips events the FCM service already
// delivered even if the user dismissed the FCM
// notification before this cycle fired.
if (NotificationDedup.wasNotified(ctx, eventId)) {
skippedDedupCount += 1;
if (ts > highestTsSeen) highestTsSeen = ts;
continue;
}
// Three outcomes for marking + watermark advance:
// foreground mark + advance (skip render
// but consume state, otherwise
// next bg poll would replay)
// background + posted mark + advance
// background + !posted DON'T mark, DON'T advance
// (transient render failure
// should be retried next poll)
boolean posted = false;
boolean treatAsNotRenderable = false;
if (!inForeground) {
Map<String, String> flattened = flattenNotification(entry, roomNames);
String type = flattened.get("type");
boolean isRtcType = RTC_NOTIFICATION_TYPE.equals(type)
|| RTC_NOTIFICATION_TYPE_STABLE.equals(type);
boolean isRing = "ring".equals(flattened.get("content_notification_type"));
if (isRtcType && isRing) {
// Composite session dedup: if FCM already alerted
// for this call session (different ring event,
// same parent), skip posting a duplicate
// missed-call. Without this, a session with one
// FCM live-alert ring + one re-ring through
// polling would surface as both a CallStyle and
// a missed-call card. Helpers live in
// VojoFirebaseMessagingService so the key shape
// stays in lock-step across FCM and polling.
String roomIdField = flattened.get("room_id");
String sessionId = VojoFirebaseMessagingService
.extractCallSessionId(flattened);
String composite = null;
if (roomIdField != null && sessionId != null) {
composite = VojoFirebaseMessagingService
.compositeCallDedupKey(roomIdField, sessionId);
if (NotificationDedup.wasNotified(ctx, composite)) {
if (ts > highestTsSeen) highestTsSeen = ts;
treatAsNotRenderable = true;
}
}
if (!treatAsNotRenderable) {
// Stale ring (call lifetime is 30 seconds; we
// poll every 15 minutes). Show "Missed call"
// so the user knows somebody tried, without
// phantom-ringing a long-dead call via
// CallStyle.
posted = VojoFirebaseMessagingService
.renderMissedCallNotification(ctx, flattened);
if (posted && composite != null) {
// Mark the composite so the next polling
// cycle observing a re-ring for the same
// session doesn't double-post.
NotificationDedup.markNotified(ctx, composite);
}
}
} else if (isRtcType) {
// Non-ring RTC sub-type. MSC4075 defines at least
// "ring" and "notification" the latter is the
// chat-style alert variant which doesn't make
// sense to surface as a stale "missed" entry from
// a 15-minute poll. Falling through to
// renderMessageNotification would post a generic
// "New message" with no body (no content.body on
// RTC events). Skip rendering but still mark seen
// so we don't re-walk it next poll.
treatAsNotRenderable = true;
} else {
posted = VojoFirebaseMessagingService
.renderMessageNotification(ctx, flattened, null);
}
}
// Mark + advance ts whenever we've consumed the event
// (foreground-skipped, non-ring-RTC skipped, or
// successfully rendered). Render-failure (bg branch where
// posted==false) is intentionally excluded so the next
// poll retries it.
if (inForeground || posted || treatAsNotRenderable) {
NotificationDedup.markNotified(ctx, eventId);
if (ts > highestTsSeen) highestTsSeen = ts;
if (posted) renderedCount += 1;
}
}
pagesFetched += 1;
// optString returns the fallback only when the key is absent;
// a literal JSON `null` becomes the string "null" guard
// against the rare server quirk so we don't loop on it.
String rawNext = body.optString("next_token", null);
if (rawNext == null || rawNext.isEmpty() || "null".equals(rawNext)) {
nextFrom = null;
} else {
nextFrom = rawNext;
}
pendingCursor = nextFrom;
if (nextFrom == null) {
paginationExhausted = true;
break;
}
}
} catch (UnauthorizedException e) {
Log.w(TAG, "poll: 401 — clearing credentials, awaiting next foreground re-bridge");
prefs.edit()
.remove(KEY_ACCESS_TOKEN)
.apply();
return Result.success();
} catch (ForbiddenException e) {
// 403 from Synapse is usually rate-limit or a transient server
// policy reject, not a dead token. Don't clear credentials
// just let the next periodic fire retry. Avoid Result.retry()
// because we don't want an immediate accelerated retry that
// amplifies the rate-limit cause.
Log.w(TAG, "poll: 403/429 — skipping this cycle, will retry on next scheduled fire");
return Result.success();
} catch (Throwable t) {
Log.w(TAG, "poll: failed at page " + pagesFetched, t);
return Result.retry();
}
// Final stopped-check before persisting state. If cancellation landed
// between the last in-loop check and here, do NOT apply: the
// accumulated editor writes would otherwise overwrite KEY_LAST_SEEN_TS
// and KEY_DRAIN_CURSOR AFTER JS clearSession wiped them, leaking
// stale state from the just-logged-out account into the next login.
if (isStopped()) return Result.success();
SharedPreferences.Editor editor = prefs.edit();
// Drain-mode bookkeeping. Three transitions:
// - normal normal (cap not hit): advance watermark to highestTsSeen.
// - normal drain (cap hit, no prior drain): save continuation
// cursor AND snapshot drainTargetTs = highestTsSeen. The current
// run's highest ts becomes the "fast-forward" target for when
// drain eventually completes without this, the bounded LRU
// could evict the original head events and let the post-drain
// normal run re-render them.
// - drain drain (still capped): keep cursor + target unchanged.
// Don't overwrite drainTargetTs with this run's highestTsSeen,
// because drain pages are always OLDER than the original head.
// - drain normal (drain complete): clear cursor + target. Advance
// watermark to drainTargetTs drain pages always walk backwards
// (older than the snapshotted head), so highestTsSeen accumulated
// during drain is by construction drainTargetTs.
boolean cappedWithMore = !reachedWatermark && !paginationExhausted && pendingCursor != null;
long newWatermark = watermark;
String drainState;
if (cappedWithMore) {
editor.putString(KEY_DRAIN_CURSOR, pendingCursor);
if (!wasDraining) {
// First run entering drain mode snapshot the head ts.
editor.putLong(KEY_DRAIN_TARGET_TS, highestTsSeen);
drainState = "drain-entered";
} else {
drainState = "drain-continued";
}
} else {
editor.remove(KEY_DRAIN_CURSOR);
editor.remove(KEY_DRAIN_TARGET_TS);
long advanceTo = wasDraining ? drainTargetTs : highestTsSeen;
if (advanceTo > watermark) {
editor.putLong(KEY_LAST_SEEN_TS, advanceTo);
newWatermark = advanceTo;
}
drainState = wasDraining ? "drain-exited" : "normal";
}
editor.apply();
Log.i(TAG, "poll: done pages=" + pagesFetched
+ " rendered=" + renderedCount
+ " dedupSkipped=" + skippedDedupCount
+ " watermark=" + newWatermark
+ " state=" + drainState);
return Result.success();
}
// Returns true iff at least one element of entry.actions is the literal
// string "notify". Per Matrix spec §13.13.1, tweak objects
// (`{set_tweak: ...}`) only MODIFY a notification produced by a separate
// `"notify"` action they do not by themselves imply notify. "dont_notify"
// or an empty actions array means the push rule explicitly suppressed
// this event (most commonly: a muted room).
private static boolean notifyAllowed(JSONObject entry) {
JSONArray actions = entry.optJSONArray("actions");
if (actions == null || actions.length() == 0) return false;
for (int i = 0; i < actions.length(); i += 1) {
Object a = actions.opt(i);
if ((a instanceof String) && "notify".equals(a)) return true;
}
return false;
}
//
// HTTP
//
private static final class UnauthorizedException extends IOException {
UnauthorizedException() {
super("401 Unauthorized");
}
}
// 403 from Synapse is most commonly a rate-limit or a transient policy
// reject (M_LIMIT_EXCEEDED, M_FORBIDDEN). It is NOT "token died" we
// surface it as a distinct exception so doWork can skip this cycle
// without clearing credentials and without an accelerated Result.retry()
// that would amplify the rate-limit cause.
private static final class ForbiddenException extends IOException {
ForbiddenException() {
super("403 Forbidden");
}
}
private JSONObject fetchNotifications(String homeserverUrl, String token, String fromCursor)
throws IOException {
StringBuilder url = new StringBuilder(homeserverUrl);
if (!homeserverUrl.endsWith("/")) url.append('/');
url.append("_matrix/client/v3/notifications?limit=").append(PAGE_LIMIT);
if (fromCursor != null && !fromCursor.isEmpty()) {
url.append("&from=").append(java.net.URLEncoder.encode(fromCursor, "UTF-8"));
}
HttpURLConnection conn = (HttpURLConnection) new URL(url.toString()).openConnection();
try {
conn.setRequestMethod("GET");
conn.setRequestProperty("Authorization", "Bearer " + token);
conn.setRequestProperty("Accept", "application/json");
// Identifiable UA so server logs can attribute polling traffic
// (some WAFs also flag bare "Java/<version>" as suspicious).
conn.setRequestProperty("User-Agent", "Vojo-Android-Poll/" + BuildConfig.VERSION_NAME);
conn.setConnectTimeout(HTTP_TIMEOUT_MS);
conn.setReadTimeout(HTTP_TIMEOUT_MS);
int code = conn.getResponseCode();
if (code == 401) throw new UnauthorizedException();
// Treat 429 (rate limited) and 403 (Synapse policy reject) the
// same: skip this cycle, don't retry-storm. Result.retry()'s 30s
// backoff would amplify the rate-limit cause; the next periodic
// fire in 15 minutes is well past any realistic Retry-After
// window from a Matrix homeserver.
if (code == 403 || code == 429) throw new ForbiddenException();
if (code < 200 || code >= 300) {
throw new IOException("HTTP " + code);
}
try (InputStream in = conn.getInputStream()) {
return new JSONObject(readAll(in));
} catch (org.json.JSONException je) {
throw new IOException("malformed JSON", je);
}
} finally {
conn.disconnect();
}
}
private static String readAll(InputStream in) throws IOException {
// Accumulate raw bytes, then decode the whole buffer as a single UTF-8
// string. Decoding each 8 KB chunk separately would corrupt multi-byte
// sequences that straddle a chunk boundary for a Russian-content
// notification body that crosses ~8 KB, the result is U+FFFD in place
// of a Cyrillic character. Also use != -1 rather than > 0 for the
// read loop: InputStream.read(byte[]) is contractually allowed to
// return 0 without indicating EOF.
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
byte[] buf = new byte[8 * 1024];
int n;
while ((n = in.read(buf)) != -1) {
if (n > 0) out.write(buf, 0, n);
}
return out.toString("UTF-8");
}
//
// Payload shaping
//
// The /notifications response shape is structured (event{type,sender,
// content{}}, room_id, ts, read, actions) different from Sygnal's
// flattened FCM payload. We flatten into the Sygnal-shape Map<String,
// String> so the shared renderer in VojoFirebaseMessagingService can
// stay source-agnostic. Keys we set: event_id, room_id, sender, type,
// content_membership, content_body, content_notification_type,
// content_sender_ts, content_lifetime, room_name (from local cache).
//
// NOTE: sender_display_name is NOT set here /notifications returns the
// raw event without the Sygnal-side profile resolution that gives FCM
// its `sender_display_name`. The renderer's title-fallback chain
// (room_name sender_display_name sender "Vojo") therefore lands
// on `sender` (a raw MXID) when the room name isn't cached. The renderer
// strips the MXID to its local-part as a final cosmetic guard so users
// see "alice" instead of "@alice:hs.tld".
//
private static Map<String, String> flattenNotification(
JSONObject entry, Map<String, String> roomNames
) {
Map<String, String> out = new HashMap<>();
String roomId = entry.optString("room_id", null);
if (roomId != null) out.put("room_id", roomId);
JSONObject event = entry.optJSONObject("event");
if (event != null) {
putIfPresent(out, event, "event_id", "event_id");
putIfPresent(out, event, "sender", "sender");
putIfPresent(out, event, "type", "type");
JSONObject content = event.optJSONObject("content");
if (content != null) {
putIfPresent(out, content, "membership", "content_membership");
putIfPresent(out, content, "body", "content_body");
putIfPresent(out, content, "notification_type", "content_notification_type");
if (content.has("sender_ts")) {
out.put("content_sender_ts", String.valueOf(content.optLong("sender_ts")));
}
if (content.has("lifetime")) {
out.put("content_lifetime", String.valueOf(content.optLong("lifetime")));
}
// Parent call event_id for session-level dedup. The shared
// FCM renderer reads this from the flattened key
// `content_m.relates_to_event_id` (mirroring one of Sygnal's
// flatten shapes); writing the literal-dot variant here keeps
// FCM and polling on the same key.
JSONObject relates = content.optJSONObject("m.relates_to");
if (relates != null) {
String parentEventId = relates.optString("event_id", null);
if (parentEventId != null && !parentEventId.isEmpty()) {
out.put("content_m.relates_to_event_id", parentEventId);
}
}
// Legacy MSC2746 call_id fallback. Modern MSC4075 sessions
// surface via m.relates_to above; this branch is a no-op for
// them but keeps the shape symmetric for older deployments.
if (content.has("call_id")) {
String callId = content.optString("call_id", null);
if (callId != null && !callId.isEmpty()) {
out.put("content_call_id", callId);
}
}
}
}
// Room name from the snapshot the JS side pushes through
// PollingPlugin.saveRoomNames, parsed once at the start of doWork().
// Brand-new rooms (not yet observed by JS at last bridge time) miss
// the cache the renderer falls back to sender / "Vojo".
if (roomId != null) {
String roomName = roomNames.get(roomId);
if (roomName != null && !roomName.isEmpty()) out.put("room_name", roomName);
}
return out;
}
// Parse the SharedPreferences-stored room-name JSON snapshot once per
// doWork() so we don't redo the parse for every event in the page (up to
// PAGE_LIMIT × MAX_PAGES_PER_RUN = 250 events).
//
// The snapshot shape evolved: legacy was {roomId: "Display name"}, current
// is {roomId: {name, isDirect, isEncrypted, avatarMxc?}}. We parse both
// tolerantly for the structured shape we extract `name`, for the legacy
// shape we use the string verbatim. A naive optString on the structured
// entry serialises the whole object as JSON ("{name:Alice,...}") and that
// string leaked into the missed-call / message title on the polling
// path visible bug.
private static Map<String, String> loadRoomNamesMap(SharedPreferences prefs) {
Map<String, String> out = new HashMap<>();
String raw = prefs.getString(KEY_ROOM_NAMES, null);
if (raw == null || raw.isEmpty()) return out;
try {
JSONObject map = new JSONObject(raw);
for (Iterator<String> it = map.keys(); it.hasNext(); ) {
String roomId = it.next();
if (map.isNull(roomId)) continue;
JSONObject obj = map.optJSONObject(roomId);
String name = obj != null
? obj.optString("name", null)
: map.optString(roomId, null);
if (name != null && !name.isEmpty()) out.put(roomId, name);
}
} catch (org.json.JSONException je) {
// Corrupt blob return empty map. Renderer falls back to sender.
}
return out;
}
private static void putIfPresent(
Map<String, String> out, JSONObject src, String srcKey, String dstKey
) {
// Guard against a literal JSON null at the key: JSONObject.optString
// returns the *fallback* only when the key is absent, but on a
// present-but-null key it coerces JSONObject.NULL to the four-char
// string "null", which would leak as "null" into a notification body.
if (!src.has(srcKey) || src.isNull(srcKey)) return;
String v = src.optString(srcKey, null);
if (v != null && !v.isEmpty()) out.put(dstKey, v);
}
private static String extractEventId(JSONObject entry) {
JSONObject event = entry.optJSONObject("event");
if (event == null) return null;
if (!event.has("event_id") || event.isNull("event_id")) return null;
String eventId = event.optString("event_id", null);
if (eventId == null || eventId.isEmpty()) return null;
return eventId;
}
}

View file

@ -1,60 +0,0 @@
// Per-package ESLint config for the Preact widget apps under `apps/`.
//
// `root: true` stops ESLint from walking up to the host's
// `cinny/.eslintrc.cjs`, which extends airbnb + the React plugin. Those
// rule sets are tuned for the React host and flag legitimate Preact /
// small-widget patterns as errors (`class=` attributes, arrow-fn
// components, inline icon sub-components, for-of loops, etc.). Keeping
// the hierarchy open would force every widget file to fight host style
// for no real win.
//
// Widgets keep a minimal but real lint pass via the rule sets below:
//
// * `eslint:recommended` — catches genuine bugs (no-undef, no-dupe-*,
// no-redeclare, no-unused-vars, …) without enforcing style.
// * `@typescript-eslint/recommended` — TS-aware variants of the above
// plus type-level checks the recommended set ships.
//
// We deliberately DON'T extend `plugin:react/recommended` —
// `react/react-in-jsx-scope` and `react/no-unknown-property` both flag
// Preact-correct code as errors, and disabling them one by one creates
// a long suppression list. Widget JSX is type-checked by each app's
// `tsc --noEmit` (run by `vite build`), which is the better signal for
// JSX correctness anyway.
module.exports = {
root: true,
// `node` covers `module.exports` in this very file (CommonJS config);
// `browser` is the runtime widget code itself sees.
env: { browser: true, es2021: true, node: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
// preact/hooks has the same dep-array semantics as react/hooks, and
// the widget code already carries `// eslint-disable-next-line
// react-hooks/exhaustive-deps` directives at the relevant sites;
// loading the plugin (a) keeps those directives meaningful (without
// it ESLint errors on the «unknown rule» referenced by the comment)
// and (b) catches the real exhaustive-deps mistakes in widget hooks
// for free.
'plugin:react-hooks/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
plugins: ['@typescript-eslint', 'react-hooks'],
rules: {
// Underscore-prefixed args are intentionally unused (Preact event
// handlers receive args the body doesn't need); match the host's
// convention so lint reads consistently across both trees.
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
// Widget bridge-protocol regexes occasionally escape `-` inside
// character classes for visual clarity (e.g. `[0-9\-]`). The escape
// is harmless and pre-existing across all three widgets — keeping
// the rule on would force a churn-y diff in code that's been stable
// since the v0.7.6 bridge dialect work.
'no-useless-escape': 'off',
},
};

View file

@ -95,18 +95,6 @@ const LinkIcon = () => (
</svg>
);
// 2×2 grid of rounded squares — leads the OpenSpaceCard. Reads as
// «space with channels inside»; consistent visual vocabulary with the
// channels-tab workspace grid affordances on the host side.
const SpaceGridIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="3.5" y="3.5" width="5.5" height="5.5" rx="1.4" />
<rect x="11" y="3.5" width="5.5" height="5.5" rx="1.4" />
<rect x="3.5" y="11" width="5.5" height="5.5" rx="1.4" />
<rect x="11" y="11" width="5.5" height="5.5" rx="1.4" />
</svg>
);
// Linkifier — same heuristic as TG widget.
const URL_RE = /https?:\/\/[^\s)]+/g;
@ -400,13 +388,6 @@ const loadHCaptcha = (): Promise<HCaptchaApi> => {
`script[src^="https://js.hcaptcha.com/1/api.js"]`
) as HTMLScriptElement | null;
// `timeoutHandle` is read in the `settle` closure declared below
// BEFORE the assignment at the bottom of this function. ESLint's
// flow analysis can't see the deferred assignment through the
// closure and flags this as never-reassigned; in practice the value
// IS reassigned and using `const` here would break the hcaptcha
// script-load timeout path.
// eslint-disable-next-line prefer-const
let timeoutHandle: number | undefined;
let settled = false;
const settle = (action: () => void) => {
@ -618,7 +599,9 @@ const CaptchaPanel = ({ state, t, onSolved, onCancel, onExpired }: CaptchaPanelP
<div class="auth-card-hint">{t('auth-card.captcha.hint')}</div>
<div class="auth-card-captcha-frame">
<div ref={containerRef} class="auth-card-captcha-host" />
{loadError ? <div class="auth-card-error">{t('auth-card.captcha.load-error')}</div> : null}
{loadError ? (
<div class="auth-card-error">{t('auth-card.captcha.load-error')}</div>
) : null}
</div>
<div class="auth-card-row">
<button type="button" class="btn-text" onClick={onCancel}>
@ -781,40 +764,6 @@ const LogoutCard = ({ t, onConfirm }: LogoutCardProps) => {
);
};
// --------------------------------------------------------------------------
// Open-space card — Vojo extension
// --------------------------------------------------------------------------
type OpenSpaceCardProps = {
t: T;
matrixToUrl: string;
onOpen: (url: string) => void;
};
// Surfaces the personal Discord space the bridge auto-created at login.
// Renders only when `state.spaceMatrixToUrl` is populated — i.e. against a
// Vojo-patched bridge that emitted `VOJO-LOGIN-SPACE-V1`. Against an
// upstream/unpatched bridge the card is absent (no sentinel, no URL, the
// `space_ready` reducer case never fires).
//
// Click hands the URL to the host via the `io.vojo.bot-widget`
// side-channel (api.openMatrixToUrl) — the widget is sandboxed and
// can't navigate cinny itself. Host validates and routes.
const OpenSpaceCard = ({ t, matrixToUrl, onOpen }: OpenSpaceCardProps) => (
<button class="command-card" type="button" onClick={() => onOpen(matrixToUrl)}>
<span class="command-card-lead-icon" aria-hidden="true">
<SpaceGridIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.open-space.name')}</div>
<div class="command-card-desc">{t('card.open-space.desc')}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
);
// --------------------------------------------------------------------------
// Main App
// --------------------------------------------------------------------------
@ -978,15 +927,6 @@ export function App({ bootstrap, api }: Props) {
// hydrate too; the live path treats it identically.
append({ kind: 'diag', text: t('diag.captcha-issued') });
appendedAnyHistory = true;
} else if (parsed.kind === 'space_ready') {
// VOJO-LOGIN-SPACE-V1 sentinel body is a JSON blob —
// machine-readable, never user-readable. Suppress the raw
// body from the transcript and emit a diag breadcrumb
// instead so a reload-replay shows «space ready» rather
// than `VOJO-LOGIN-SPACE-V1 {"matrix_to_url":"…"}` ugly
// verbatim. Same discipline as the captcha branch above.
append({ kind: 'diag', text: t('diag.space-ready') });
appendedAnyHistory = true;
} else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') {
// m.text / m.notice — body is safe to replay verbatim,
// BUT we still scrub any login-URL-shaped substring as
@ -1049,7 +989,10 @@ export function App({ bootstrap, api }: Props) {
append({ kind: 'diag', text: t('diag.qr-issued') });
} else if (event.kind === 'qr_redacted') {
const liveState = stateRef.current;
if (liveState.kind === 'awaiting_qr_scan' && liveState.qrEventId === event.redactsEventId) {
if (
liveState.kind === 'awaiting_qr_scan' &&
liveState.qrEventId === event.redactsEventId
) {
append({ kind: 'diag', text: t('diag.qr-consumed') });
}
} else if (event.kind === 'captcha_challenge') {
@ -1058,12 +1001,6 @@ export function App({ bootstrap, api }: Props) {
// transcript DOM (where screenshots / accessibility tools could
// leak them). Diag-only display.
append({ kind: 'diag', text: t('diag.captcha-issued') });
} else if (event.kind === 'space_ready') {
// Sentinel body is the JSON `{"matrix_to_url":"…"}` — not human-
// readable and pointless to show verbatim. Emit a diag breadcrumb;
// the actual «Open in Channels» card is rendered by the reducer
// attaching `spaceMatrixToUrl` to the connected state.
append({ kind: 'diag', text: t('diag.space-ready') });
} else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') {
const body = ev.content.body ?? '';
append({ kind: 'from-bot', text: `${scrubLoginSecret(body)}` });
@ -1248,7 +1185,9 @@ export function App({ bootstrap, api }: Props) {
// entry, but a manual disconnect path could leave us in connected
// and trigger reconnect from there).
const handle =
state.kind === 'connected_dead' || state.kind === 'connected' ? state.handle : undefined;
state.kind === 'connected_dead' || state.kind === 'connected'
? state.handle
: undefined;
dispatch({ kind: 'request_reconnect', handle });
try {
await sendBare('reconnect');
@ -1414,17 +1353,6 @@ export function App({ bootstrap, api }: Props) {
}
/>
<div class="command-grid">
{/* Open-space CTA only against a Vojo-patched bridge that
* emitted the VOJO-LOGIN-SPACE-V1 sentinel. Listed first so a
* fresh post-login user sees «next step» before the Logout
* destructive action. */}
{state.spaceMatrixToUrl ? (
<OpenSpaceCard
t={t}
matrixToUrl={state.spaceMatrixToUrl}
onOpen={(url) => api.openMatrixToUrl(url)}
/>
) : null}
<LogoutCard t={t} onConfirm={onConfirmLogout} />
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
</div>

View file

@ -56,15 +56,6 @@ const LOGIN_SUCCESS_RE = /^successfully logged in as\s+@?(.+?)\.?$/i;
const CAPTCHA_CHALLENGE_PREFIX = 'VOJO-CAPTCHA-CHALLENGE-V1';
const CAPTCHA_CHALLENGE_RE = /^VOJO-CAPTCHA-CHALLENGE-V1\s+(\{[\s\S]*\})\s*$/;
// Vojo-patched bridge emits this sentinel right after «Successfully logged
// in as @user» (commands_login_space.go::sendLoginSpaceNotice). Carries the
// matrix.to URL of the user's personal Discord space so the widget can
// render a CTA. Same markdown-inert + structured-JSON discipline as the
// captcha sentinel above; the bridge sends this via SendMessageEvent to
// bypass goldmark round-trip.
const LOGIN_SPACE_SENTINEL_PREFIX = 'VOJO-LOGIN-SPACE-V1';
const LOGIN_SPACE_SENTINEL_RE = /^VOJO-LOGIN-SPACE-V1\s+(\{[\s\S]*\})\s*$/;
// Legacy CAPTCHA fallback — commands.go:fnLoginQR (l.207-209) on UNPATCHED
// upstream v0.7.6: «CAPTCHAs are currently not supported - use token login
// instead». Kept so a deployment running unpatched bridge still produces a
@ -169,28 +160,6 @@ export const parseLegacyV076Body = (rawBody: string): LoginEvent => {
}
if (CAPTCHA_REQUIRED_RE.test(body)) return { kind: 'captcha_required' };
// Vojo login-space sentinel: structured JSON with the personal Discord
// space's matrix.to URL. Checked alongside the captcha sentinel —
// markdown-inert prefix means it lands verbatim from the bridge, parsed
// into a `space_ready` event for the reducer to attach to connected state.
// Malformed payload (missing/empty `matrix_to_url`, JSON parse failure) is
// silently dropped as `unknown` rather than surfacing a stale CTA.
if (body.startsWith(LOGIN_SPACE_SENTINEL_PREFIX)) {
const match = LOGIN_SPACE_SENTINEL_RE.exec(body);
if (match) {
try {
const payload = JSON.parse(match[1]) as Record<string, unknown>;
const matrixToUrl = typeof payload.matrix_to_url === 'string' ? payload.matrix_to_url : '';
if (matrixToUrl) {
return { kind: 'space_ready', matrixToUrl };
}
} catch {
// fall through — malformed payload is treated as unknown
}
}
return { kind: 'unknown' };
}
const loginWsMatch = LOGIN_WEBSOCKET_FAILED_RE.exec(body);
if (loginWsMatch) return { kind: 'login_websocket_failed', reason: loginWsMatch[1].trim() };
@ -361,11 +330,20 @@ function runSanityChecks(): void {
// Login success (post-QR scan). No snowflake in this line; App fires
// `ping` afterwards to pick up the discordId.
['Successfully logged in as @example', { kind: 'login_success', handle: 'example' }],
['Successfully logged in as @user.name', { kind: 'login_success', handle: 'user.name' }],
[
'Successfully logged in as @example',
{ kind: 'login_success', handle: 'example' },
],
[
'Successfully logged in as @user.name',
{ kind: 'login_success', handle: 'user.name' },
],
// Login failure paths.
['Error logging in: rate limited 429', { kind: 'login_failed', reason: 'rate limited 429' }],
[
'Error logging in: rate limited 429',
{ kind: 'login_failed', reason: 'rate limited 429' },
],
// CAPTCHA legacy fallback — pre-empts LOGIN_FAILED_RE. Fires only on
// unpatched upstream v0.7.6.
[
@ -409,7 +387,10 @@ function runSanityChecks(): void {
// Logout.
['Logged out successfully.', { kind: 'logout_ok' }],
["You weren't logged in, but data was re-cleared just to be safe.", { kind: 'logout_no_op' }],
[
"You weren't logged in, but data was re-cleared just to be safe.",
{ kind: 'logout_no_op' },
],
// Disconnect / reconnect.
['Successfully disconnected', { kind: 'disconnect_ok' }],
@ -540,9 +521,7 @@ function runSanityChecks(): void {
// eslint-disable-next-line no-console
console.error('[legacy_v076 event sanity] mismatch', { event, actual, expected });
throw new Error(
`legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${
event.content?.msgtype ?? '<none>'
}`
`legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${event.content?.msgtype ?? '<none>'}`
);
}
}

View file

@ -113,15 +113,6 @@ export type LoginEvent =
| { kind: 'reconnect_no_op' }
| { kind: 'reconnect_failed'; reason?: string }
// --- Vojo: bridge-managed personal space ---------------------------------
// Vojo-patched bridge emits the sentinel `VOJO-LOGIN-SPACE-V1 {...}` as a
// separate m.notice right after the «Successfully logged in» line. Carries
// a `matrix.to` URL pointing at the user's auto-created Discord space
// (user.go::GetSpaceRoom on the bridge side). The widget surfaces this as
// an «Open in Channels» card; click → host navigates cinny to the space.
// See vojo-mautrix-discord/commands_login_space.go for the wire format.
| { kind: 'space_ready'; matrixToUrl: string }
// --- bridge-side errors --------------------------------------------------
// Generic «I don't know that command» — should not happen since we only
// ship known commands, but visible if the bridge image is misconfigured

View file

@ -55,11 +55,13 @@ export const EN: Record<StringKey, string> = {
'Discord requested a CAPTCHA — QR sign-in is temporarily unavailable. Try again later, or sign in with a token via the bots chat.',
'auth-error.captcha-send-failed':
'Could not deliver your CAPTCHA solution. Check your network and try signing in again.',
'auth-error.captcha-expired': 'CAPTCHA expired — tap «Sign in with QR code» and solve it again.',
'auth-error.captcha-expired':
'CAPTCHA expired — tap «Sign in with QR code» and solve it again.',
'auth-error.login-failed': 'Sign-in failed: {reason}',
'auth-error.prepare-failed': 'Failed to prepare sign-in: {reason}',
'auth-error.websocket-failed': 'Could not connect to the sign-in server: {reason}',
'auth-error.connect-after-login-failed': 'Signed in, but could not connect to Discord: {reason}',
'auth-error.connect-after-login-failed':
'Signed in, but could not connect to Discord: {reason}',
'auth-error.already-logged-in': 'You are already signed in to Discord — refresh status.',
'auth-error.unknown-command':
'The bot does not recognise this command — check the prefix in config.json.',
@ -71,9 +73,6 @@ export const EN: Record<StringKey, string> = {
'card.logout.confirm-prompt': 'Sign out for real?',
'card.logout.confirm-yes': 'Sign out',
'card.logout.confirm-no': 'Cancel',
'card.open-space.name': 'Open in Channels',
'card.open-space.desc': 'Jump to your Discord space with all chats and servers',
'diag.space-ready': 'Discord space ready to open.',
'diag.connecting': 'Connecting to Vojo… awaiting capability handshake.',
'diag.ready': 'Ready to send commands.',
'diag.checking-status': 'Checking connection status…',

View file

@ -86,7 +86,8 @@ export const RU = {
'Discord потребовал CAPTCHA — вход через QR временно недоступен. Попробуйте позже или войдите через токен в чате с ботом.',
'auth-error.captcha-send-failed':
'Не удалось отправить ответ на CAPTCHA. Проверьте сеть и попробуйте войти заново.',
'auth-error.captcha-expired': 'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.',
'auth-error.captcha-expired':
'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.',
'auth-error.login-failed': 'Не удалось войти: {reason}',
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}',
'auth-error.websocket-failed': 'Не удалось подключиться к серверу входа: {reason}',
@ -105,11 +106,7 @@ export const RU = {
'card.logout.confirm-prompt': 'Точно выйти?',
'card.logout.confirm-yes': 'Выйти',
'card.logout.confirm-no': 'Отмена',
// --- Open Discord space (Vojo bridge sentinel) ------------------------
'card.open-space.name': 'Открыть в Каналах',
'card.open-space.desc': 'Перейти в спейс Discord со списком чатов и серверов',
// --- Diagnostics in transcript ----------------------------------------
'diag.space-ready': 'Discord-спейс готов к открытию.',
'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.',
'diag.ready': 'Готов отправлять команды.',
'diag.checking-status': 'Проверяю статус подключения…',

View file

@ -104,13 +104,8 @@ export type LoginState =
| { kind: 'reconnecting'; handle?: string }
// Live session — ping or login_success confirmed. Discord legacy bridge
// doesn't have a per-account loginId concept (single Discord account
// per Matrix user), so logout doesn't need an id. `spaceMatrixToUrl`
// is populated from the Vojo `VOJO-LOGIN-SPACE-V1` sentinel that lands
// right after login_success; it survives the post-login re-ping and the
// reconnect-ok transitions so the «Open in Channels» card stays visible
// until logout. Absent until the sentinel arrives (and absent forever
// against an UNPATCHED bridge — the card simply never appears).
| { kind: 'connected'; handle: string; discordId?: string; spaceMatrixToUrl?: string }
// per Matrix user), so logout doesn't need an id.
| { kind: 'connected'; handle: string; discordId?: string }
// ping says we have a token but the connection's down. Status pill:
// green-ish but with a Reconnect recovery action exposed. The reducer
// distinguishes `connection_dead` (Discord WS dropped) from `token_stored`
@ -125,7 +120,10 @@ export type LoginState =
// staring at an hCaptcha challenge (rqdata/rqtoken are short-lived but
// often valid for a couple of minutes — fresh enough to reuse). Other
// transient states (logging_out, reconnecting) deliberately don't survive.
export type HydrateRestoredState = PendingFormState | CaptchaSolveState | { kind: 'qr_verifying' };
export type HydrateRestoredState =
| PendingFormState
| CaptchaSolveState
| { kind: 'qr_verifying' };
// Outbound user actions the App dispatches. Form-submit actions clear any
// pending lastError; structural transitions optimistically advance state —
@ -171,7 +169,9 @@ const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiti
const isCaptchaAcceptingState = (
s: LoginState
): s is PendingFormState | { kind: 'qr_verifying' } | CaptchaSolveState =>
s.kind === 'awaiting_qr_scan' || s.kind === 'qr_verifying' || s.kind === 'awaiting_captcha_solve';
s.kind === 'awaiting_qr_scan' ||
s.kind === 'qr_verifying' ||
s.kind === 'awaiting_captcha_solve';
export const loginReducer = (state: LoginState, action: LoginAction): LoginState => {
if (action.kind === 'hydrate') {
@ -266,14 +266,11 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
case 'logged_in':
// Authoritative source — accept from any state. Used by both the
// initial ping AND the post-`login_success` re-ping that picks up
// the discordId snowflake. Preserve `spaceMatrixToUrl` from a prior
// `connected` so the post-login_success re-ping doesn't blank the
// CTA before the user gets a chance to click it.
// the discordId snowflake.
return {
kind: 'connected',
handle: event.handle,
discordId: event.discordId,
spaceMatrixToUrl: state.kind === 'connected' ? state.spaceMatrixToUrl : undefined,
};
case 'connection_dead':
@ -495,28 +492,12 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState
// green with an empty handle, which the UI's
// `state.handle ? connected-as : connected` ternary tolerates.
// This avoids the `unknown` flap that the previous draft would
// produce when no handle was stashed. spaceMatrixToUrl is not
// restorable from connected_dead (the dead state never carried it),
// so the CTA stays hidden until a fresh sentinel arrives — bridge
// does NOT re-emit on reconnect, but the card returns once the user
// explicitly re-logs in.
// produce when no handle was stashed.
if (state.kind === 'reconnecting' || state.kind === 'connected_dead') {
return { kind: 'connected', handle: state.handle ?? '' };
}
return state;
case 'space_ready':
// Vojo-patched bridge surfaced the personal Discord space — attach
// its matrix.to URL to the connected state so the «Open in Channels»
// card renders. Late-arriving sentinels from an abandoned flow drop
// here silently (e.g. a sentinel that lands during `logging_out`
// mustn't resurrect a connected state). Honour only from the
// canonical alive states.
if (state.kind === 'connected') {
return { ...state, spaceMatrixToUrl: event.matrixToUrl };
}
return state;
case 'reconnect_failed':
if (state.kind !== 'reconnecting') return state;
// Roll back to connected_dead carrying the previous handle. The
@ -584,7 +565,10 @@ type HydrateAccumulator = {
terminated: boolean;
};
const stepHydrate = (prevAcc: HydrateAccumulator, input: HydrateInput): HydrateAccumulator => {
const stepHydrate = (
prevAcc: HydrateAccumulator,
input: HydrateInput
): HydrateAccumulator => {
const { ev, ts } = input;
// After a terminal event we normally stop — except if a fresh
@ -709,12 +693,9 @@ const stepHydrate = (prevAcc: HydrateAccumulator, input: HydrateInput): HydrateA
case 'already_logged_in':
case 'unknown':
case 'space_ready':
// Soft no-op for hydrate. already_logged_in is a live-flow warning
// that doesn't reflect persistent state; unknown is a wording-drift
// catch-all; space_ready is a post-terminal sentinel — hydrate
// terminates on login_success and lets live ping reconcile, so
// the URL gets attached on the live path, not here.
// catch-all.
return acc;
default: {

View file

@ -125,27 +125,6 @@ export class WidgetApi {
);
}
// Ask the host to navigate to a matrix.to URL inside the cinny app
// (room or space). Same side-channel pattern as `openExternalUrl` —
// distinct from matrix-widget-api's `fromWidget` so the SDK stays
// ignorant of this Vojo extension. The host validates the URL via
// `parseMatrixToRoom` (rejecting non-room URLs, javascript:/data:, etc.)
// BEFORE routing into the react-router; sending anything that isn't a
// matrix.to/#/!roomId or matrix.to/#/#alias URL silently no-ops on the
// host side. The widget is responsible for only invoking this when it
// genuinely has a matrix.to room URL (e.g. parsed from a bridge
// sentinel).
public openMatrixToUrl(url: string): void {
window.parent.postMessage(
{
api: 'io.vojo.bot-widget',
action: 'open-matrix-to',
data: { url },
},
this.bootstrap.parentOrigin
);
}
// Always prefix outbound commands with `<commandPrefix> ` (trailing space).
// Legacy mautrix-discord routes management-room commands through the
// bridge.commands.Processor in mautrix/go bridge/commands; outside the

View file

@ -742,21 +742,6 @@ body {
width: 100%;
}
/* PasswordForm wraps its input + show/hide toggle in `.password-row`
* so the toggle pill sits next to the input on desktop. On narrow
* viewports that nested row stays row-direction with `flex-shrink: 0`
* on `.btn-icon`, and the input's monospace `font-size: 20px` +
* `letter-spacing: 4px` (see `.auth-input.password`) pushes the toggle
* off-screen. Continue the same column-stack pattern the outer
* `.auth-card-row` already uses so the toggle drops below the input
* full-width visually consistent with btn-primary / btn-text. */
.password-row {
flex-direction: column;
}
.password-row .btn-icon {
width: 100%;
}
/* Compact .command-card on mobile preserves the «two-row title +
* chevron» structure but trims padding so a single login/logout card
* doesn't dominate a phone-height viewport. */

View file

@ -19,7 +19,6 @@ Any agent (Claude Code, Cursor, Codex, Windsurf, Cline, Copilot, Aider, …) wor
| [architecture.md](architecture.md) | Stack, source layout, routing, features, state management, Matrix SDK patterns, git workflow |
| [i18n.md](i18n.md) | i18next setup, translation patterns, Russian-language quality standards, localization progress |
| [android.md](android.md) | Capacitor wrapper, Android build chain, edge-to-edge, Service Worker invariants, ADB workflow |
| [electron.md](electron.md) | Electron desktop wrapper, privileged `vojo://` scheme for SW, build chain, IPC security, Windows distribution |
| [bugs.md](bugs.md) | Known bugs & regressions |
| [server-side.md](server-side.md) | Some configs that deployd on server |

View file

@ -26,7 +26,7 @@ npm run android:apk:debug # gradle debug build only
## Versioning
`versionCode` and `versionName` are derived from `git describe --tags --match 'v*'` in [`android/app/build.gradle`](../../android/app/build.gradle), mirroring `resolveAppVersion()` in [`vite.config.js`](../../vite.config.js) so the APK's `versionName` matches `__APP_VERSION__` shown in About. Tag is `v0.2.0`; `patch` is the commit count since that tag (e.g. `v0.2.0-87-g…` → versionName `0.2.87`). When git is unavailable, falls back to `package.json` `version`.
`versionCode` and `versionName` auto-derived from `package.json` version:
```
versionCode = major * 1_000_000 + minor * 1_000 + patch
@ -38,8 +38,8 @@ versionCode = major * 1_000_000 + minor * 1_000 + patch
- **Service Worker stays active.** Critical for authenticated Matrix media (MSC3916 / Matrix spec v1.11+). DO NOT disable. `resolveServiceWorkerRequests` default `true`.
- **Edge-to-edge.** `EdgeToEdge.enable()` in `MainActivity.java` + `windowLayoutInDisplayCutoutMode: shortEdges`.
- **External links.** Opened via `@capacitor/browser` plugin — see [`src/app/utils/capacitor.ts`](../../src/app/utils/capacitor.ts).
- **Safe-area coloring.** `body` background-color is bound to the folds theme variable `var(--oq6d070)` for consistent safe-area coloring.
- **Safe-area insets.** Applied on `#root` (not `body`) so the theme background extends behind the system bars.
- **Safe-area coloring.** `body` background-color reads `--vojo-safe-area-bg` (set on `:root` in [`src/app/styles/global.css.ts`](../../src/app/styles/global.css.ts), default `#0d0e11` = chat-list tone). [`Room.tsx`](../../src/app/features/room/Room.tsx) retunes the var to `#181a20` (chat-surface tone) while a chat is mounted so the status-bar / gesture-bar zones never show a seam against the active surface.
- **Safe-area insets — top / left / right only on `#root`.** Bottom inset is intentionally **not** applied at `#root` so the app renders edge-to-edge under the Android gesture pill / 3-button bar / iOS home indicator (mirrors WhatsApp / Telegram). Components that anchor interactive UI at the screen bottom MUST add `padding-bottom: var(--vojo-safe-bottom)` themselves — covered: chat composer ([`RoomView.css.ts`](../../src/app/features/room/RoomView.css.ts)), PageNav inner column ([`Page.tsx`](../../src/app/components/page/Page.tsx) → catches SelfRow / WorkspaceFooter / etc.), bottom call rail ([`HorseshoeContainer.css.ts`](../../src/app/pages/HorseshoeContainer.css.ts)), AuthFooter ([`auth/styles.css.ts`](../../src/app/pages/auth/styles.css.ts)). New screens with a bottom CTA must follow this rule or the button lands behind a system 3-button nav bar.
## VSCode tasks
@ -54,188 +54,7 @@ Push notification text for Android is generated from `public/locales/{en,ru}.jso
The task requires `node` in `PATH`. Terminal builds and CI inherit it from the shell. **macOS Android Studio with nvm/fnm:** the GUI app may not see nvm-managed node. Workaround: set `NODE_BIN=/path/to/node` in `android/gradle.properties` (the task reads it via `project.findProperty('NODE_BIN')`) or launch AS from a shell that sources your node manager (`open -a "Android Studio"`).
## Push polling fallback (WorkManager)
Users on networks that block FCM (`mtalk.google.com:5228` — corporate, school
and government whitelist intranets, ~5% of our audience) get zero pushes from
the primary channel. To cover them we run a WorkManager periodic poll of
`/_matrix/client/v3/notifications` as a parallel best-effort delivery channel.
Always on whenever push is enabled — there's no smart-detect-and-switch (FCM
gives no client-visible delivery receipts; see
[push_unifiedpush_phase1.md §11](../plans/push_unifiedpush_phase1.md) for the
full rationale of why this is the only viable shape).
Components:
| Layer | File | Role |
|---|---|---|
| Worker | [`VojoPollWorker.java`](../../android/app/src/main/java/chat/vojo/app/VojoPollWorker.java) | Periodic fetch of `/notifications`, flattens response into Sygnal-shape `Map<String,String>`, routes message/invite → `renderMessageNotification`, RTC ring → `renderMissedCallNotification`. Skips events that are `read=true`, push-rule-suppressed (`actions` lacks `notify`), in NotificationDedup, or with `ts < watermark`. Foreground-gated: doesn't render system notifications while `MainActivity.isInForeground` (still consumes state). Saves a drain cursor when capped at `MAX_PAGES_PER_RUN`. |
| Bridge | [`PollingPlugin.java`](../../android/app/src/main/java/chat/vojo/app/PollingPlugin.java) | Capacitor plugin. JS calls `saveSession` (token + homeserver, seeds watermark on first use to skip historical backlog), `schedule(15)` (unique periodic worker), `saveRoomNames` (room-id → name cache), `cancel` (awaits WorkManager Operation completion) + `clearSession` on disable/logout. |
| Renderers | [`VojoFirebaseMessagingService.java::renderMessageNotification`, `::renderMissedCallNotification`](../../android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java) | Static, Context-parameterised so the Worker can post into the same notification id space as FCM. Message path uses a **per-room** `roomId.hashCode()` slot — every new event in a room appends to a MessagingStyle conversation rather than stacking as a separate card (see [MessagingStyle pipeline](#messagingstyle-pipeline) below). Missed-call path uses per-event slots so multiple missed rings stack. After successful `nm.notify`, mark the event in NotificationDedup so the polling Worker doesn't re-surface it after the user dismisses an FCM-delivered one. |
| Dedup | [`NotificationDedup.java`](../../android/app/src/main/java/chat/vojo/app/NotificationDedup.java) | Thread-safe shared LRU set of rendered event_ids. Written by both FCM service (background renders AND foreground-skipped events) and Worker (after successful render or foreground-skip). Bounded at 500 entries to comfortably exceed a single Worker run's worst case (`MAX_PAGES_PER_RUN × PAGE_LIMIT = 250`), persisted in `vojo_poll_state` SharedPreferences. |
| JS plugin | [`src/app/plugins/polling.ts`](../../src/app/plugins/polling.ts) | `registerPlugin<PollingPluginIface>('Polling', { web: noop })`. Web has no analogue (SW already wakes for push) — fallback is a no-op. |
| Lifecycle | [`src/app/hooks/usePushNotifications.ts::usePushNotificationsLifecycle`](../../src/app/hooks/usePushNotifications.ts) | Reactive to `usePushEnabled()`. On mount with push enabled: `saveSession` + `schedule` + initial room-name dump. On `visibilitychange → visible`: re-`saveSession` (recovers a 401-cleared credentials slot without remount) + re-dump room names. On unmount or push disable: `cancel` + `clearSession`. |
Why polling is rendered as **missed call** (not CallStyle) for ring events: the
`m.rtc.notification` lifetime is 30 seconds; polling runs at the 15-minute
floor of `PeriodicWorkRequest`. Every ring observed by the Worker is already
stale and the live call long over — rendering CallStyle with ringtone would
phantom-ring a dead call. Missed-call style preserves the "you missed a call
from X" signal without the wrong UX. Live-call delivery for whitelist users
remains a gap; closing it requires a non-FCM live channel (UnifiedPush, see
the stale plan above).
Why we do not need a refresh-token flow: Vojo's homeserver is vanilla Synapse
without MAS/OIDC (see [server-side.md](server-side.md)), so access tokens are
long-lived. A 401 from the Worker logs out the credentials slot and waits for
the next foreground app launch to re-bridge — no native refresh-token logic
required. If we ever migrate to MAS, the Worker needs a refresh path.
Why our source manifest does not declare `RECEIVE_BOOT_COMPLETED`: WorkManager's
library manifest already declares the permission and the `RescheduleReceiver`,
which the manifest merger folds into the merged manifest. Reboot persistence
works end-to-end without our app re-declaring anything. Apps only need to add
the permission themselves when they listen for `BOOT_COMPLETED` for their own
purposes.
Edge cases handled:
- Token rotation (post-MAS migration): currently not bridged from JS to native
on token-rotate events. JS re-saves credentials on every lifecycle re-mount
AND on visibilitychange → visible, so user-driven re-open recovers within
seconds. After a 401 the Worker clears its credentials slot; after a 403
it leaves credentials alone and just skips the cycle (403 is most often a
transient rate-limit, not a dead token).
- First fire after install / re-login: `saveSession` seeds
`KEY_LAST_SEEN_TS` to `System.currentTimeMillis() - 60s` on first write,
so the Worker doesn't render every historical unread `/notifications`
entry as a fresh push. The 60s buffer tolerates device-clock drift ahead
of the homeserver (event `ts` is server-side); without it a fast-clock
device would silently skip fresh events as "older than watermark".
- POST_NOTIFICATIONS revoked at runtime: Worker bails early on
`NotificationManagerCompat.areNotificationsEnabled() == false`. Without
this guard `nm.notify` would throw `SecurityException` per event, leave
the LRU and watermark unadvanced, and re-walk the same backlog every 15
minutes until the user re-grants permission.
- Worker > 10 minutes (Android kill timer): bounded by `MAX_PAGES_PER_RUN=5`
× `PAGE_LIMIT=50` + 30s HTTP timeout per call. Cannot exceed ~3 minutes
in normal operation. Most polls touch only a single page because the ts
watermark short-circuits the loop.
- Large backlog (>250 events accumulated while offline): when a single fire
hits `MAX_PAGES_PER_RUN` before reaching the watermark, the Worker saves
the leftover `next_token` as `KEY_DRAIN_CURSOR` AND snapshots the head ts
of the first run as `KEY_DRAIN_TARGET_TS`. Subsequent fires resume from
that cursor instead of head; the target ts is the fast-forward
destination for the watermark when drain finally completes — without it,
the bounded LRU could evict head events and let the post-drain normal
run re-render them.
- Network unavailable: `NetworkType.CONNECTED` constraint skips the run; next
cycle retries.
- Doze: WorkManager honours maintenance windows. No catch-up — only the next
scheduled fire delivers the accumulated backlog. The Worker walks from the
head of `/notifications` and stops as soon as it reaches the watermark, so a
Doze-extended gap just produces a larger first-page walk.
- Pagination assumes newest-first ordering (Vojo runs vanilla Synapse, whose
`get_push_actions_for_user` issues `ORDER BY stream_ordering DESC`). The
Matrix spec for `/notifications` does not formally mandate this ordering, so
if Vojo ever migrates to a homeserver implementation that paginates oldest-
first (Conduit, Dendrite, …) the `ts < watermark` break would clip new
events. Revisit the Worker before any such migration.
- Already-read events (user read on another client) are skipped via the `read`
field on each `/notifications` entry; their ts still advances the watermark
so they don't get re-walked next poll.
- Muted rooms: `actions` array on each `/notifications` entry is consulted;
events without `notify` (i.e. `dont_notify` from a mute push rule) are
skipped. Without this, the mute toggle wouldn't actually mute polling-
delivered notifications even though Sygnal honours it for FCM.
- User in foreground: Worker doesn't render system notifications while
`MainActivity.isInForeground` (live timeline owns UX). State still
advances so events don't replay on the next backgrounded poll.
- FCM + polling double delivery: NotificationDedup is the single source of
truth — FCM service and Worker both write to it after successful render,
both read it before posting. Even if the user dismisses an FCM-delivered
notification before polling fires, the Worker skips it.
- UTF-8 multi-byte boundaries: `readAll` accumulates raw bytes and decodes
the full buffer once, never per-chunk; otherwise a Cyrillic character
straddling an 8 KB read boundary would become U+FFFD.
- Logout race: `initMatrix.ts::logoutClient`, `clearLocalSessionAndReload`,
and the `SessionLoggedOut` listener in `ClientRoot.tsx` all call
`polling.cancel()` + `polling.clearSession()` synchronously before
`window.location.replace`, so the Worker can't fire one more time with
the stale access_token. `cancel()` awaits the WorkManager `Operation` so
a fast disable → re-enable cycle doesn't race the `KEEP` policy. The
lifecycle effect's unmount cleanup repeats the same calls as
belt-and-suspenders.
Cleanups invoked symmetrically across every logout path:
`useDisablePushNotifications`, `logoutClient`, `clearLocalSessionAndReload`,
the `SessionLoggedOut` listener, and the lifecycle effect's unmount all
call `polling.cancel()` + `polling.clearSession()`.
## MessagingStyle pipeline
Background-rendered message notifications use
`NotificationCompat.MessagingStyle` so multiple events in one room collapse
into an expandable conversation card (WhatsApp / Telegram convention)
rather than each event posting a separate banner. Notification id is
**per-room** (`roomId.hashCode()`), not per-event.
Components:
| Layer | File | Role |
|---|---|---|
| Cache | [`RoomMessageCache.java`](../../android/app/src/main/java/chat/vojo/app/RoomMessageCache.java) | Thread-safe `ConcurrentHashMap<String, ArrayDeque<Entry>>` bounded at 20 messages × 200 rooms. Snapshot is taken INSIDE `compute()` so a concurrent FCM + Worker append on the same room can't race the copy. Mutated by both `VojoFirebaseMessagingService.renderMessageNotification` (FCM service path AND Worker path through the same static helper) and `appendOutgoingMessage` (ReplyReceiver echo). |
| Channels | `vojo_messages_dm_v1` (IMPORTANCE_HIGH) + `vojo_messages_group_v1` (IMPORTANCE_DEFAULT) under `NotificationChannelGroup("vojo_messages_v1")`. Legacy `vojo_messages` is deleted on first creation of v1. Channel split lets users mute group-room noise in OS settings without losing DM alerts. |
| Metadata snapshot | JS bridges `{roomId: {name, isDirect, isEncrypted}}` via `polling.saveRoomNames``KEY_ROOM_NAMES` in `vojo_poll_state`. `loadRoomMetadata` parses tolerantly (legacy `roomId: "name"` falls back to `isDirect=true, isEncrypted=true` for safety). Re-dump triggers: mount, visibility-change, `ClientEvent.AccountData` for `m.direct`, `RoomEvent.Timeline` filtered to `m.room.encryption`. |
| Process-kill recovery | On cache miss, `seedCacheFromActiveNotification` calls `NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification` on the on-shade `StatusBarNotification` to rebuild prior history. Survives process kill; fails gracefully to single-message conversation if the notification was also dismissed. |
| Receivers | [`MarkAsReadReceiver`](../../android/app/src/main/java/chat/vojo/app/MarkAsReadReceiver.java) (POST `/_matrix/client/v3/rooms/{roomId}/receipt/m.read/{eventId}` + dismiss), [`NotificationDismissReceiver`](../../android/app/src/main/java/chat/vojo/app/NotificationDismissReceiver.java) (swipe → clear cache so the next push starts fresh), [`ReplyReceiver`](../../android/app/src/main/java/chat/vojo/app/ReplyReceiver.java) (RemoteInput → PUT `m.room.message` with `m.text` body + optimistic local echo). All read credentials from `vojo_poll_state` SharedPreferences (same lifecycle as `VojoPollWorker`). |
| Receipt-driven dismiss | JS `mx.on(RoomEvent.Receipt)` filters own-user receipts, checks `room.getUnreadNotificationCount(Total) === 0`, calls `polling.dismissRoom(roomId)` → native `nm.cancel + RoomMessageCache.clear`. Mirrors element-web's `Notifier.onRoomReceipt`. Killed-process dismiss is not covered (no JS context to observe the receipt) — acceptable: the next FCM push to that room renders a fresh conversation from cache-empty state. |
Why MessagingStyle vs the old per-event flow: 5 messages in one DM previously
produced 5 separate cards in the shade with redundant title/avatar. The
MessagingStyle conversation matches WhatsApp/Telegram UX and is the documented
Android pattern for messaging apps. See element-android's
`RoomGroupMessageCreator` for the canonical reference.
Why two channels (DM + group) and not per-conversation channels (the
fluffychat approach): per-conversation works for low-room-count clients but
proliferates user-visible settings entries on a Matrix client with dozens of
active rooms. Element-android sidesteps the question with a NOISY/SILENT
split based on push rules; we picked a middle ground — bucketed by DM vs
group room — which mirrors fluffychat's `directChats`/`groupChats`
NotificationChannelGroup setup.
Why reply action is gated on `!isEncrypted`: the Java path has no key
material to sign + encrypt outgoing replies with, so an inline reply in an
E2EE room would send cleartext (Synapse does not enforce the
"encrypted-only" rule, so the leak is real). The snapshot defaults to
`isEncrypted=true` on cache miss and the JS side re-dumps on
`m.room.encryption` state events so the action is dropped within seconds of
a room being switched to E2EE.
Why call-session composite dedup
(`compositeCallDedupKey(roomId, sessionId)`): the legacy per-eventId dedup
misses re-rings of the same call session because each ring is a fresh
`m.rtc.notification` event with a new event_id. We extract the parent call
event_id from `content.m.relates_to.event_id` (Worker JSON parse) /
`content_m.relates_to_event_id` (FCM Sygnal-flatten) and mark the composite
in NotificationDedup the moment we post the first CallStyle. Subsequent
ring events for the same session see the mark and skip silently. Mirrors
element-web's `getIncomingCallToastKey` pattern.
Why edit-collapse (`m.replace`) is **NOT implemented**: requires parsing
`content.m.relates_to.rel_type == "m.replace"` + finding the original event
in the per-room cache and replacing in place. The complication: FCM
payloads (Sygnal-flattened) encode nested keys inconsistently across
deployments (`content_m.relates_to_rel_type` vs
`content_m_relates_to_rel_type` vs dot-preserved variants), and the Worker
parses raw JSON cleanly while FCM hits one of the flattened shapes.
Asymmetric handling (Worker only) creates user-visible drift between
delivery paths. Real-world impact is low — users rarely edit
notification-flagged messages in the seconds-long window before they're
read — so the feature is deferred until we have a uniform key shape from
Sygnal config or until a real-world report justifies the parser complexity.
## ADB wireless workflow
1. On the phone, enable Wireless debugging, tap "Pair device with pairing code" — note IP, port, 6-digit code.
2. `adb pair <ip>:<pair-port> <code>`

View file

@ -11,14 +11,14 @@ npm run typecheck # tsc --noEmit
Build: **Vite 5.4** with vanilla-extract, WASM, PWA plugins.
> **Note:** `.husky/pre-commit` is enabled and runs `tsc --noEmit` + `lint-staged` (which calls `eslint --max-warnings 0` on staged JS/TS files). Both gates are zero: `npm run typecheck` and `npm run check:eslint` are green (0 errors, 0 warnings). Custom Matrix event-types (`AccountDataEvent.Vojo*`, `PoniesRoomEmotes`, `m.bridge`, `m.call.member` etc.) live in [`src/types/matrix/sdkAugmentation.d.ts`](../../src/types/matrix/sdkAugmentation.d.ts) — add new custom types there to keep `mx.getAccountData` / `mx.getStateEvent` calls type-safe.
> **Note:** `.husky/pre-commit` is currently commented out. `npm run check:eslint` is **green** (0 errors, 116 warnings — kept as warn for `no-explicit-any`/`no-non-null-assertion` policy). `npm run typecheck` still has ~32 known errors (residual project bugs after the TS 5.4 + Bundler migration that cleared ~800 module-resolution errors — see `docs/known-tech-debt-lint/`). Use `bash docs/known-tech-debt-lint/diff.sh` to verify your changes added no new typecheck errors, then `npm run build` for the green build check.
## Source Layout
```
src/
├── index.tsx # Entry point
├── colors.css.ts # Vojo dark + light themes via createTheme(color, …) — both palettes are Vojo-owned, folds defaults are not used
├── colors.css.ts # Custom dark-theme via createTheme(color, …); no light override (uses folds.lightTheme as-is)
├── config.css.ts # fontWeight overrides
├── client/
│ ├── initMatrix.ts # Matrix SDK init (createClient, startClient, logout)
@ -162,69 +162,10 @@ Some atoms persist to localStorage (e.g. `settings.ts`, `navToActivePath.ts`), o
Stock Cinny had multiple themes; vojo simplified to System / Light / Dark (commit 00935ae).
- `src/colors.css.ts` defines both `darkTheme` (Dawn palette) and `lightTheme` (Vojo light palette) via `createTheme(color, …)`. The folds default `lightTheme` is no longer imported — both Vojo themes own their full token table.
- `src/app/hooks/useTheme.ts` selects `LightTheme` or `DarkTheme` based on `useSystemTheme` + `themeId` settings. Class-name-based application — `ThemeManager` swaps the body class on `useActiveTheme` change, so runtime switching is live (no reload needed, but vanilla-extract still requires a rebuild to change the token tables themselves).
- `src/colors.css.ts` defines **only** `darkTheme` via `createTheme(color, darkThemeData)`. There is no separate light-theme override — light = stock `folds.lightTheme` imported as-is.
- `src/app/hooks/useTheme.ts` selects `LightTheme` or `DarkTheme` based on `useSystemTheme` + `themeId` settings. Class-name-based application — **runtime theme switch requires page reload** (vanilla-extract is compile-time).
- Folds tokens (`color.*`, `config.space`, `config.radii`, `config.borderWidth`) are read-only inside folds compiled CSS. Re-skinning colours via `createTheme()` works; re-skinning radii or spacing requires CSS overrides outside folds.
- Brand accent: dark `Primary.Main = #9580ff` (Dawn lavender), light `Primary.Main = #5b6aff` (indigo) — referenced in unread-badge, focus-ring, NavLink active state, MessageBase highlight keyframe.
- The default theme picker (Settings → General → Appearance) offers System / Light / Dark. The `dawn-redesign-v1` one-shot migration in `state/settings.ts` pins **existing** users (with a stored settings JSON) to dark on first load post-migration; brand-new users skip the migration and keep `useSystemTheme: true` so they follow the OS preference out of the box.
### Known follow-ups for light theme
The web theme switch is wired end-to-end (palette, picker, runtime body-class swap, mxid colours, prism syntax highlighting, `--vojo-safe-area-bg`, cold-start `prefers-color-scheme` fallback in `src/index.css`, dual `<meta theme-color>` in `index.html`). Native and PWA chrome are NOT yet bound to the active theme — track these as separate tasks:
- **Android system bars**`MainActivity.java::onCreate` hardcodes `controller.setAppearanceLight{Status,Navigation}Bars(false)`. On light theme the icons are white over a light bar → invisible. Fix is a small JS↔Java bridge (custom Capacitor plugin, or `@capacitor/status-bar` for status-bar tint + custom plugin for nav-bar) driven from `ThemeManager`'s `useEffect`.
- **Android native splash**`android/app/src/main/res/values/colors.xml::splash_bg = #0d0e11` and `styles.xml::windowBackground` are dark. Light users see a dark splash → fade to white. Add `values-night/` variants or read the stored `themeId` from a SharedPreferences shim before paint.
- **Capacitor WebView paint color**`capacitor.config.ts::backgroundColor = '#0d0e11'` (mirrored in the built `capacitor.config.json`). Set at WebView init, cannot be re-themed at runtime via JS — needs the splash-fix above to land first.
- **PWA manifest**`public/manifest.json` `theme_color`/`background_color` are pinned to dark (`#0d0e11`). Manifest format does not support media queries, so the choice is one default; we keep dark because the migration also pins existing users to dark.
- **AuthLayout**`src/app/pages/auth/styles.css.ts` hardcodes dark backgrounds (`#0d0e11` etc.) for the bistable auth scaffold (see `bugs.md` for why the auth layout cannot be naively re-skinned). Light-theme users see a dark login/register/reset-password screen. Tied to the auth bistable-layout refactor.
- **Bot widgets**`BotShell.css.ts`, `BotWidgetMount.css.ts`, `BotCard.tsx` hardcode `#9580ff` / `#7ab6d9` / `#0c0c0e` accent + ink colors. Each bot widget is a separate Preact app so it doesn't share Vojo's folds tokens — needs its own theme passing through `apps/widget-*` or a CSS-var bridge from the parent.
The horseshoe void seam reshades via the `--vojo-horseshoe-void` CSS variable: dark `#090909` (deep void against `#0d0e11` panel) and light `#d6d6e3` (soft lavender-grey against `#f2f2f7` panel). See `src/app/styles/horseshoe.ts` + `src/index.css`.
## Composer card geometry
Load-bearing pixel values for the main chat composer + thread-drawer composer (both wrap `RoomInput` with the `ChatComposer` class). The composer is a floating rounded card with **32px corner radius** (`VOJO_HORSESHOE_RADIUS_PX`); all paddings are tuned so the visible glyphs (text, IconButton icons) stay outside the curve clip. Source of truth: [`src/app/features/room/RoomView.css.ts`](../../src/app/features/room/RoomView.css.ts), [`src/app/features/room/RoomInput.tsx`](../../src/app/features/room/RoomInput.tsx) (action-row padding).
| Element | Value | Where |
|---|---|---|
| Card corner radius | 32px | `VOJO_HORSESHOE_RADIUS_PX` |
| Card outer padding | `6px / 16px` (vertical / horizontal) | `RoomView.css.ts``.ChatComposer .Editor` |
| Textarea vertical padding | 13px (folds default — do NOT override) | `Editor.css.ts``EditorTextarea` |
| Textarea horizontal padding | 12px left, 12px right | `RoomView.css.ts``:first-child` / `:last-child` rules |
| Placeholder paddingTop | 13px (folds default — must match textarea padding) | `Editor.css.ts``EditorPlaceholderTextVisual` |
| Action-row padding | `2px / 8px / 4px` (top / sides / bottom) | `RoomInput.tsx` `bottom` slot |
| IconButton size | 32×32 (folds `size="300"`, `fill="None"`) | `RoomInput.tsx` |
| IconButton internal padding | 4px (SVG 24×24 centered) | folds default |
| Empty-state composer height (single-line, no reply) | ~93px | derived |
**Don't override the textarea's vertical padding (13px) without also retuning `EditorPlaceholderTextVisual.paddingTop` in lockstep**: folds tuned the pair so Slate's placeholder span and the typed-text caret land on the same y inside the contenteditable content-box. Diverging the two breaks vertical alignment — typed text and the «Send a message…» placeholder appear at different baselines.
**Visual alignment goal** — text glyph and Plus icon-glyph sit on the same vertical column at 28px from the card edge (mirrored on the right for Send):
- `text-glyph-x = outer (16) + textarea paddingLeft (12) = 28`
- `icon-glyph-x = outer (16) + row paddingLeft (8) + button-internal-pad (4) = 28`
**Bottom-left curve clearance** (Plus IconButton container vs the 32px corner):
- `button-bottom y = 6 (outer) + 4 (row pad-bot) = 10`
- `curve-x at y=10 = 32 √(32² 22²) ≈ 8.76px`
- `button-left = 16 (outer) + 8 (row pad-left) = 24`
- **clearance ≈ 15.24px** — comfortable for the hit-box; the visible glyph clears the curve by ~23px
**Top-left curve clearance** (placeholder text glyph):
- `text-glyph-y = 6 (outer) + 13 (textarea pad-top) = 19`
- `curve-x at y=19 = 32 √(32² 13²) ≈ 2.76px`
- `text-glyph-x = 28`
- **clearance ≈ 25.24px** — very generous; supports multi-line growth
**Future compactness levers** (if needed without breaking alignment):
- Outer card vertical padding (currently 6px) — drop to 4px saves 4px
- Action-row padding (currently 2/4) — drop to 0/2 saves 4px
- IconButton size (currently 300 / 32px) — already smallest in folds; no further reduction available
Avoid touching textarea or placeholder vertical padding unless you re-tune both in matched pairs and visually verify glyph alignment.
**Don't apply these to other composers**: the textarea-padding compact override is scoped to `.ChatComposer`. The message-edit overlay, `Editor.preview.tsx`, and any future `CustomEditor` consumer outside the chat composer keep the folds-default `padding: 13px 1px` (`Editor.css.ts:24-42`).
If you re-tune any number here, update both the CSS comments in `RoomView.css.ts` and this table — they're cross-referenced.
- Brand accent in v4.11.x: `Primary.Main = #BDB6EC` (lavender) — referenced in unread-badge, focus-ring, NavLink active state, MessageBase highlight keyframe.
## Responsive design
@ -271,7 +212,7 @@ i18next + `react-i18next`. Translations in `public/locales/{en,ru}/*.json`, orga
- **matrix-js-sdk 41.4** — Matrix protocol (exact pin, see `docs/plans/matrix_js_sdk_upgrade.md` for the M0..M4 bump trail)
- **folds 2.6** — UI component library
- **jotai 2.6** — State management
- **vanilla-extract** — Type-safe CSS. Tokens are compile-time, but theme switching is live at runtime: `ThemeManager` swaps the body class on `useActiveTheme` change and every `color.*` var reshades through the cascade. Adding new tokens still requires a rebuild.
- **vanilla-extract** — Type-safe CSS (compile-time → no runtime theme switching without reload)
- **slate 0.123** — Rich text editor
- **@tanstack/react-query 5** — Data fetching
- **@tanstack/react-virtual 3** — Virtual scrolling — used for **list panels** (`Direct.tsx`, space lists, etc.). Note: `RoomTimeline.tsx` does NOT use this; it uses an in-house `useVirtualPaginator` + `IntersectionObserver`.
@ -287,7 +228,7 @@ i18next + `react-i18next`. Translations in `public/locales/{en,ru}/*.json`, orga
- Current vojo work branch: `vojo/dev`
- Semantic-release on `dev` branch
- CI: GitHub Actions (build, deploy, docker, netlify)
- **Husky pre-commit runs `tsc --noEmit` + `lint-staged` (`eslint --max-warnings 0`)** — both must be green to commit. `no-explicit-any` and `no-non-null-assertion` policy: kept as `'warn'` in `.eslintrc.cjs` but blocked by `--max-warnings 0`. When introducing one is unavoidable (matrix-js-sdk boundary, generic helper, third-party callback shape), add an inline `// eslint-disable-next-line` with a one-line justification rather than relaxing the rule.
- **Husky pre-commit is currently disabled** — `npm run typecheck` and `npm run check:eslint` do not run automatically. `check:eslint` is green; `typecheck` still has ~32 known errors. Use `bash docs/known-tech-debt-lint/diff.sh` to check your changes don't add new typecheck errors. Re-enable husky once typecheck residual is cleared.
- **Android `versionCode` is monotonic** (commit 8064760, derived from commit count). Don't squash or rebase across release boundaries — Play store rejects downgrades
- **Commit message style** (vojo memory): one sentence ≤25 words; no body; no Co-Authored-By trailer

View file

@ -1,235 +0,0 @@
# Electron Desktop
Vojo as a native desktop app (Windows .exe first, macOS/Linux later) via
**Electron** wrapping the same Vite `dist/` that web/Capacitor consume.
## Why not Tauri
Tauri 2 uses the system WebView (WebView2 on Windows). Service Worker
registration on custom schemes is **«won't fix»** per Tauri's own maintainer
([tauri#13031](https://github.com/tauri-apps/tauri/issues/13031), Aug 2025).
Vojo's SW is load-bearing for authenticated Matrix media (MSC3916). The
official Tauri workaround (`tauri-plugin-localhost`) is itself flagged in
Tauri's docs as «considerable security risks» — exposes a local HTTP port,
any process on the user's machine can hit it. Unacceptable for a Matrix
client storing E2EE keys.
Electron bundles its own Chromium, so SW works as in Chrome after
`protocol.registerSchemesAsPrivileged({ allowServiceWorkers: true, ... })`.
Element Desktop uses the same **privileged-scheme** mechanism but with a
different media-auth strategy: their scheme privileges set is just
`{ standard, secure, supportFetchAPI }` (no `allowServiceWorkers`), and they
inject the `Authorization` header for Matrix media via
`session.defaultSession.webRequest.onBeforeSendHeaders` — Service Workers
aren't load-bearing for them. Vojo keeps the SW because that's how the web
build authenticates media; re-implementing the auth in a main-process hook
just for desktop would diverge renderer code paths. Our privilege set is a
superset of Element's by design (also Matrix, also AGPL — still our
architectural reference for the wider Electron shell).
## Source layout
```
electron/
├── main.ts # main process — window, privileged scheme, IPC
├── preload.ts # contextBridge: window.vojoElectron API
├── tsconfig.json # CJS output, Node target — separate from src/
└── dist-electron/ # tsc output (gitignored)
└── main.js, preload.js # generated
src/app/utils/electron.ts # renderer-side: isElectron(), openExternalUrl(), setupExternalLinkHandler()
electron-builder.json # packaging config (NSIS for Windows)
release/ # electron-builder output (gitignored)
```
## Build chain
```bash
npm run electron:typecheck # tsc --noEmit -p electron/tsconfig.json
npm run electron:build # tsc → electron/dist-electron/*.js (+ package.json override)
npm run electron:dev # vite + electron in parallel (concurrently + wait-on)
npm run electron:start # electron only — DEV mode (loads localhost:8080)
npm run electron:start:prod # electron only — PROD mode (loads vojo://, requires npm run build first)
npm run build:electron:win # native build: vite build → electron:build → electron-builder --win
# ONLY works on Windows host (or WSL with Wine installed)
npm run build:electron:win:docker # cross-build from Linux/WSL via electronuserland/builder:wine
# Docker image ~3GB on first run; output in release/
```
### M1 vs M2 mode toggle
`isDev` in [`electron/main.ts`](../../electron/main.ts) is:
```ts
const isDev = !app.isPackaged && process.env.VOJO_ELECTRON_PROD !== '1';
```
- **Packaged binary** (`.exe`/`.dmg`/`.AppImage`) → `isDev = false` always
- **Unpackaged, dev**: `electron:dev` / `electron:start` → loads `http://localhost:8080`
- **Unpackaged, prod-mode test** (`electron:start:prod`) → loads `vojo://app/index.html`
The prod-mode env override exists so M2 (verifying the privileged scheme + service worker actually register) can be tested locally **without** running `electron-builder` for every change. The packaged binary uses the same code path.
### Cross-building Windows .exe from Linux/WSL
`build:electron:win:docker` runs the build inside
`electronuserland/builder:wine-mono` — the official Wine-based image.
**`electron-builder.json::win.signAndEditExecutable = false` is required**
for this cross-build to finish. Without it, electron-builder invokes
`rcedit.exe` through Wine to stamp `FileDescription`/`ProductName`/version
metadata onto the bundled `Vojo.exe`. The Wine docker images
(`:wine`, `:wine-mono`) ship **without Xvfb**, so rcedit hangs forever
trying to create a Win32 window for COM apartment init — see
[electron-userland/electron-builder#6191](https://github.com/electron-userland/electron-builder/issues/6191).
Cost of the workaround: the `.exe` Properties dialog on Windows shows
generic «Electron 42.1.0» metadata instead of «Vojo». Cosmetic only;
the binary itself runs correctly. Revisit when CI moves to a real
Windows runner (M3 GitHub Actions), where `signAndEditExecutable` can
flip back to `true`. Three host caches are mounted in to speed up subsequent builds:
- `~/.cache/electron` — Electron runtime download cache (~150MB)
- `~/.cache/electron-builder` — NSIS / app-update binaries
- `${PWD}` — project source (read-write, output goes to `release/`)
This is the workflow we use locally on WSL because Wine isn't installed natively. The same artifact is produced as a native Windows build. CI (M3, future) will run `electron-builder --win` directly on `windows-latest`.
`electron:build` writes `electron/dist-electron/package.json` with
`{"type":"commonjs"}` to override the root `"type":"module"` for the
compiled `.js` files. Required because Electron's main process loader
expects CJS unless you opt into ESM (which has separate pitfalls).
## Custom protocol — load-bearing
In production, the renderer is loaded from `vojo://app/` (trailing slash,
NOT `vojo://app/index.html` — that was the original choice and produced a
«Join index.html» screen because React Router parsed the `index.html`
segment as a space alias). The `vojo` scheme is registered as privileged
BEFORE `app.whenReady()` with `allowServiceWorkers: true`, `secure: true`,
`standard: true`, `supportFetchAPI: true`, `corsEnabled: true`,
`stream: true`, `codeCache: true`. This is the **one** thing that makes
the Vojo SW work in the packaged build. **Do not change
`loadURL(vojo://...)` to `loadFile(...)`** — SW will silently fail to
register.
`protocol.handle('vojo', ...)` maps `vojo://app/<path>` → file lookup
inside the packaged `dist/`. Two guards:
1. **Host check**: only `vojo://app` is accepted; any other host returns
403. Otherwise `vojo://evil/...` would resolve into a separate
(cookie/SW/IndexedDB-isolated) copy of the bundle with detached
storage — same content but split-brain state.
2. **Path-traversal guard**: `path.relative(distDir, filePath)` is checked
for `..`-prefix or absolute output — the canonical Node check.
`filePath.startsWith(distDir)` is **insufficient** (`/a/dist_evil` is a
prefix-match for `/a/dist`).
## IPC — security stance
- `contextIsolation: true`, `nodeIntegration: false` — Electron defaults.
- `preload.ts` exposes a minimal API: `platform` (string), `openExternal(url)`.
- `ipcMain.handle('vojo:open-external')` validates url scheme against an
allowlist (`http:`, `https:`, `mailto:`) and length (≤ 8KB) before calling
`shell.openExternal`. Don't widen the allowlist without thinking — e.g.
`file:` would let the renderer ask the OS to open arbitrary files.
- `setWindowOpenHandler` denies all `window.open()` and routes safe URLs
through `shell.openExternal`.
- `will-navigate` intercepts top-frame navigation: only `vojo://` (prod) and
`http://localhost:8080` (dev) are internal; everything else opens
externally.
## Push notifications — different model from Android
Android uses FCM + foreground service + Sygnal. **Electron does NOT use this
path.** Desktop model = «always running app»: matrix-js-sdk sync stays open
while the app is launched (Discord/Slack/Element Desktop pattern), and timeline
events fan out to `new Notification(...)` directly. Closed app = no
notifications. This is intentional and matches user expectations for desktop
Matrix clients.
The existing [`usePushNotifications.ts`](../../src/app/hooks/usePushNotifications.ts)
VAPID/Web-Push flow is web/PWA-only on this branch — Electron path is to
be added in **M4** of [`docs/plans/electron_desktop.md`](../plans/electron_desktop.md).
## Window chrome — Windows Controls Overlay
On Windows the `BrowserWindow` uses `titleBarStyle: 'hidden'` plus
`titleBarOverlay` to remove the native title bar but **keep real OS
min/max/close buttons** (drawn by Windows itself in the top-right
corner, with their native hover/snap/accessibility behavior). The
remaining bar area takes our Dawn-dark palette (`#0d0e11` /
`#e0e0e8` symbol color) so it blends with the renderer rather than
clashing with the system accent. Same pattern as Discord, Slack,
VS Code, Element Desktop.
macOS gets `titleBarStyle: 'hiddenInset'` (traffic lights inset
over a clean dark area). Linux keeps the system frame to avoid
breaking GTK/KWin window decorations.
**Caveat**: the 32px overlay area floats ON TOP of the renderer's
pixels (the renderer's content extends to `y=0`). If you put critical
UI in the top-right 100px, it visually overlaps the buttons. The
buttons remain clickable regardless, but content may look obscured.
Vojo's current SidebarNav (66px wide on the left) and PageRoot
content don't have anything load-bearing in the top-right strip, so
this is fine — verify before adding new top-bar widgets there.
**Drag region** — Electron does NOT make the hidden-titlebar area
draggable automatically. `main.ts` injects CSS via
`webContents.insertCSS` on `dom-ready` that adds a `body::before`
overlay with `-webkit-app-region: drag` sized by the Window Controls
Overlay API `env(titlebar-area-*)` variables. Body gets matching
`padding-top: env(titlebar-area-height, 32px)` so renderer content
shifts down instead of sitting under the drag strip. If new Vojo UI
ever puts elements at `top: 0` with `position: fixed`, they'll need
explicit `top: env(titlebar-area-height, 32px)` to clear the drag
region — body padding doesn't shift fixed children.
**Theme switching**: `titleBarOverlay.color` is currently hardcoded
dark. When light theme is fully wired up (see `architecture.md`
«Known follow-ups for light theme»), call
`win.setTitleBarOverlay({color, symbolColor})` from a theme-change
IPC to keep the bar in sync. Tracked as a follow-up, not implemented
in M2.
## Renderer-side platform detection
[`src/app/utils/electron.ts`](../../src/app/utils/electron.ts) mirrors the
shape of [`capacitor.ts`](../../src/app/utils/capacitor.ts): `isElectron()`,
`openExternalUrl(url)`, `setupExternalLinkHandler()`. The handlers from both
files are called in [`src/index.tsx`](../../src/index.tsx) at boot — each is
a no-op on the other platform.
Do **not** unify them into a single `platform.ts` — Capacitor's wrapper
imports `@capacitor/browser` at top level and runs in WebView even in dev;
Electron's wrapper has no peer dependency and detects via
`window.vojoElectron`.
## Build doesn't ship Android stuff
`electron-builder.json` `files` field includes ONLY `dist/**`,
`electron/dist-electron/**`, and `package.json`. The `android/` directory
is NOT in the asar — keeps the .exe lean and avoids accidentally shipping
keystore paths or build artifacts.
## Known caveats
- **`__dirname` in CJS output points to `electron/dist-electron/`** — that's
why `distDir = path.resolve(__dirname, '..', '..', 'dist')` walks up two
levels. If you change `outDir` in `electron/tsconfig.json`, retune this
path.
- **`sandbox: true` is on.** Preload uses only `contextBridge` and
`ipcRenderer`, both of which are sandbox-compatible per
[Electron sandbox docs](https://www.electronjs.org/docs/latest/tutorial/sandbox).
Don't add `fs`/`path`/`child_process` to preload — that requires
`sandbox: false` and weakens isolation. If you ever do, document why.
- **No code-signing in M0..M3.** SmartScreen on Windows shows «Windows
protected your PC» dialog; user clicks «More info → Run anyway». Drop-off
exists but acceptable for alpha. **OV cert (~$65/yr) does NOT bypass
SmartScreen instantly** — reputation accrues over ~thousands of installs
(weeksmonths). **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.

View file

@ -0,0 +1,57 @@
# Известный техдолг по линтеру
Эта папка фиксирует **известное состояние** `npm run typecheck` в репозитории. Build при этом зелёный, prod задеплоен — это исторический технический долг, не блокер. Папка нужна чтобы при любых изменениях кода сравнивать **delta** (только то что мы добавили), не путаясь в предсуществующих ошибках.
После апгрейда TypeScript 4.9 → 5.4 + `moduleResolution: "Bundler"` (см. историю коммита) основная масса (~803 из 835 предыдущих ошибок) исчезла. Осталось ~32 ошибки уже про реальные баги/несоответствия типов в нашем коде, не про модульное резолвинг. `npm run check:eslint` теперь — обычный зелёный чек (0 ошибок, 116 warnings), отдельный snapshot не нужен.
## Состав
| Файл | Что |
|---|---|
| `typecheck.snapshot.txt` | Полный stdout `npm run typecheck`. **~61 строка, ~32 ошибки.** |
| `diff.sh` | Скрипт сравнения: запускает текущий typecheck, сравнивает с snapshot-ом, выдаёт **только delta**. |
## Как пользоваться
```bash
bash docs/known-tech-debt-lint/diff.sh
```
На чистой ветке выводит:
```
=== typecheck diff vs known-tech-debt snapshot ===
no new typecheck errors
```
Если что-то сломал — выводит конкретные новые ошибки в формате `file.tsx(_,_): error TS...` (line/col маска чтобы pure-line-shift не давал phantom NEW + fixed). Реальные позиции — `npm run typecheck` напрямую. Если случайно починил предсуществующий долг — отчитается «(incidentally fixed: N)» к сведению.
Скрипт смотрит **working tree**, не staged-состояние. Для строгого pre-commit gate сначала apply'нуть свой stage в чистый worktree (`git stash --keep-index` + `bash diff.sh` + `git stash pop`).
`npm run check:eslint` запускайте напрямую — он зелёный.
## Что в долге (TL;DR)
**Typecheck (~32 ошибок):** реальные несоответствия типов. Категории:
- TS2345 keyof literal-union mismatch (~14): `mx.getAccountData(string)` / `mx.getStateEvent(...)` ждёт `keyof AccountDataEvents` (узкие литеральные типы), у нас передаются `AccountDataEvent.PoniesEmoteRooms`, `'m.call.member'`, `'in.cinny.spaces'` и т.п. — валидные Matrix event-types, но не в SDK-юнионе.
- TS2345 i18next signature (~3): `t('Room.members_count', { count: millify(...) })``count` хочет `number`, а `millify()` возвращает `string`. На рантайме отображается корректно (в локалях нет plural-вариантов).
- TS2345 / TS18048 `Room | undefined` / `Room | null` после `.filter((r) => !!r)` (~6): TS не пропускает truthy-фильтр без type predicate. UserChips.tsx, AddExisting.tsx, Invites.tsx, GlobalPacks.tsx. Runtime безопасно.
- TS2345 `IContent``RoomMessageEventContent` (1): MessageEditor.tsx — typing gap между общим content и room-message variant.
- TS7006 implicit `any` (6): event-handler params (`evt`, `event`, `ev`) в Message.tsx, EventReaders.tsx, UrlPreviewCard.tsx, LiveChip.tsx, MemberGlance.tsx, ReactionViewer.tsx.
- TS2540 read-only `sandbox` (1): CallEmbed.ts — `iframe.sandbox = "..."`. Современные DOM types сделали его `DOMTokenList` read-only, но браузеры всё ещё принимают строку.
- TS2353 unknown property `endpoint` (1): push.ts — лишнее поле в `setPusher.data`. SDK типы неполные, sygnal/UnifiedPush его читает.
- TS2322 `(number | undefined)[]``number[]` (1): usePowerLevelTags.ts — то же truthy-filter narrowing.
Build зелёный, ESLint зелёный. Все 32 оставшихся ошибки — type-strictness без runtime-импакта (truthy-filter narrowing, узкие SDK literal-union'ы, под-типированные event-handler params, слишком строгий DOM types). Это **известный долг**, не блокер. Будущая чистка — отдельный план (создать `docs/plans/typecheck_residual_cleanup.md` когда возьмёмся).
## Когда обновлять snapshot
Когда долг будет частично разруливаться отдельной задачей — после её мерджа пересоздать snapshot:
```bash
npm run typecheck > docs/known-tech-debt-lint/typecheck.snapshot.txt 2>&1
```
И обновить TL;DR в этом README.
Когда typecheck станет зелёным — удалить эту папку целиком и включить husky pre-commit hook.

View file

@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Compare current `npm run typecheck` output to the known tech-debt snapshot.
# Emits ONLY new errors introduced relative to the snapshot — does not dump
# the full output, so agents can read the result without burning context.
#
# Comparison is line/col-insensitive: each error's `(L,C):` location is masked
# to `(_,_):` before sorting + comm, so a pure line shift (e.g. one added/
# removed line above the error) doesn't trigger a phantom NEW + fixed pair.
# Re-run `npm run typecheck` to see real positions for any errors flagged here.
#
# Caveat: this checks the working tree, not the staged worktree. If you stage
# a fix but leave it unstaged, or vice versa, the diff reports the working-tree
# state. For a strict pre-commit gate, run from a clean stash-apply state.
#
# `npm run check:eslint` is now a normal green check (0 errors); no snapshot
# needed there. Run it directly if you want to see warnings.
#
# Usage:
# bash docs/known-tech-debt-lint/diff.sh
set -u
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
BASELINE_DIR="$ROOT/docs/known-tech-debt-lint"
TC_BASE="$BASELINE_DIR/typecheck.snapshot.txt"
# Temp files registered for cleanup on any exit path (success, error, ^C).
TMP_FILES=()
cleanup() { [ "${#TMP_FILES[@]}" -gt 0 ] && rm -f "${TMP_FILES[@]}"; }
trap cleanup EXIT INT TERM
# Tracks whether any new errors were introduced. Set to 1 by run_typecheck_diff
# when delta > 0; script exits with this code at end so CI / runbooks can use
# the script as a gate (e.g. `bash diff.sh && echo OK`).
NEW_ERRORS=0
run_typecheck_diff() {
echo "=== typecheck diff vs known-tech-debt snapshot ==="
local now tc_rc
now="$(mktemp)"
TMP_FILES+=("$now")
( cd "$ROOT" && npm run typecheck ) >"$now" 2>&1
tc_rc=$?
# Sanity-check: distinguish "tsc ran and reported errors" (rc=2, expected on
# this baseline) from "tsc/npm/node failed to run at all" (rc=other, broken
# toolchain). Without this guard a rc=127 "tsc: not found" or rc=1 npm error
# could produce stdout with no "error TS" lines and the diff would falsely
# report "no new errors" / "incidentally fixed: 33".
if [ "$tc_rc" -ne 0 ] && [ "$tc_rc" -ne 2 ] && ! grep -q "error TS" "$now"; then
echo " ERROR: 'npm run typecheck' did not run cleanly (exit=$tc_rc):"
sed 's/^/ /' "$now"
NEW_ERRORS=2
return
fi
# Mask `(line,col):` so a pure line shift doesn't change an error's identity.
# We compare on the masked form; for NEW lines we display the masked form (the
# real position is reproducible by running `npm run typecheck` directly).
# `sort` (NOT `sort -u`): we want to preserve cardinality so that two identical
# masked errors in the same file aren't collapsed to one — a regression that
# adds a duplicate error would otherwise be hidden by the first occurrence.
local mask_re='s/\([0-9]+,[0-9]+\):/(_,_):/'
local now_masked base_masked
now_masked="$(mktemp)"
base_masked="$(mktemp)"
TMP_FILES+=("$now_masked" "$base_masked")
sed -E "$mask_re" "$now" | grep -E "error TS" | sort > "$now_masked"
sed -E "$mask_re" "$TC_BASE" | grep -E "error TS" | sort > "$base_masked"
local new
new="$(comm -23 "$now_masked" "$base_masked")"
if [ -z "$new" ]; then
echo " no new typecheck errors"
else
local count
count="$(printf '%s\n' "$new" | wc -l)"
echo " NEW errors: $count (line/col masked — run \`npm run typecheck\` for real positions)"
printf '%s\n' "$new" | sed 's/^/ /'
NEW_ERRORS=1
fi
local fixed
fixed="$(comm -13 "$now_masked" "$base_masked")"
if [ -n "$fixed" ]; then
local fcount
fcount="$(printf '%s\n' "$fixed" | wc -l)"
echo " (incidentally fixed: $fcount)"
fi
}
run_typecheck_diff
exit "$NEW_ERRORS"

View file

@ -0,0 +1,54 @@
> vojo@4.11.1 typecheck
> tsc --noEmit
src/app/components/event-readers/EventReaders.tsx(82,31): error TS7006: Parameter 'event' implicitly has an 'any' type.
src/app/components/image-pack-view/RoomImagePack.tsx(47,9): error TS2345: Argument of type 'StateEvent.PoniesRoomEmotes' is not assignable to parameter of type 'keyof StateEvents'.
src/app/components/image-pack-view/UserImagePack.tsx(16,31): error TS2345: Argument of type 'AccountDataEvent.PoniesUserEmotes' is not assignable to parameter of type 'keyof AccountDataEvents'.
src/app/components/room-card/RoomCard.tsx(259,18): error TS2345: Argument of type '["Explore.members_count", { count: string; }]' is not assignable to parameter of type '[key: string | string[], options: TOptionsBase & $Dictionary & { defaultValue: string; }] | [key: string | string[], defaultValue: string, options?: (TOptionsBase & $Dictionary) | undefined] | [key: ...]'.
Type '["Explore.members_count", { count: string; }]' is not assignable to type '[key: "Explore.members_count" | "Explore.members_count"[], options?: (TOptionsBase & $Dictionary) | undefined]'.
Type at position 1 in source is not compatible with type at position 1 in target.
Type '{ count: string; }' is not assignable to type 'TOptionsBase & $Dictionary'.
Type '{ count: string; }' is not assignable to type 'TOptionsBase'.
Types of property 'count' are incompatible.
Type 'string' is not assignable to type 'number'.
src/app/components/url-preview/UrlPreviewCard.tsx(57,27): error TS7006: Parameter 'evt' implicitly has an 'any' type.
src/app/components/user-profile/UserChips.tsx(271,13): error TS18048: 'room' is possibly 'undefined'.
src/app/components/user-profile/UserChips.tsx(272,28): error TS2345: Argument of type 'Room | undefined' is not assignable to parameter of type 'Room'.
Type 'undefined' is not assignable to type 'Room'.
src/app/components/user-profile/UserChips.tsx(275,26): error TS18048: 'room' is possibly 'undefined'.
src/app/components/user-profile/UserChips.tsx(276,29): error TS2345: Argument of type 'Room | undefined' is not assignable to parameter of type 'Room'.
Type 'undefined' is not assignable to type 'Room'.
src/app/components/user-profile/UserChips.tsx(279,25): error TS2345: Argument of type 'Room | undefined' is not assignable to parameter of type 'Room'.
Type 'undefined' is not assignable to type 'Room'.
src/app/features/add-existing/AddExisting.tsx(168,18): error TS2345: Argument of type '(Room | undefined)[]' is not assignable to parameter of type 'Room[]'.
Type 'Room | undefined' is not assignable to type 'Room'.
Type 'undefined' is not assignable to type 'Room'.
src/app/features/call-status/LiveChip.tsx(90,35): error TS7006: Parameter 'evt' implicitly has an 'any' type.
src/app/features/call-status/MemberGlance.tsx(49,23): error TS7006: Parameter 'evt' implicitly has an 'any' type.
src/app/features/common-settings/general/RoomJoinRules.tsx(92,52): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
Type 'undefined' is not assignable to type 'string'.
src/app/features/room/message/Message.tsx(860,31): error TS7006: Parameter 'ev' implicitly has an 'any' type.
src/app/features/room/message/MessageEditor.tsx(156,39): error TS2345: Argument of type 'IContent' is not assignable to parameter of type 'RoomMessageEventContent'.
Type 'IContent' is not assignable to type 'BaseTimelineEvent & Without<(Without<ReplyEvent, NoRelationEvent> & NoRelationEvent) | (Without<NoRelationEvent, ReplyEvent> & ReplyEvent), (Without<...> & RelationEvent) | (Without<...> & ReplacementEvent<...>)> & Without<...> & ReplacementEvent<...> & FileContent'.
Property '"body"' is missing in type 'IContent' but required in type 'BaseTimelineEvent'.
src/app/features/room/reaction-viewer/ReactionViewer.tsx(135,33): error TS7006: Parameter 'event' implicitly has an 'any' type.
src/app/features/settings/developer-tools/DevelopTools.tsx(30,31): error TS2345: Argument of type 'string' is not assignable to parameter of type 'keyof AccountDataEvents'.
src/app/features/settings/developer-tools/DevelopTools.tsx(39,54): error TS2345: Argument of type 'string' is not assignable to parameter of type 'keyof AccountDataEvents'.
src/app/features/settings/emojis-stickers/GlobalPacks.tsx(161,44): error TS2345: Argument of type '(PackAddress | undefined)[]' is not assignable to parameter of type 'PackAddress[]'.
Type 'PackAddress | undefined' is not assignable to type 'PackAddress'.
Type 'undefined' is not assignable to type 'PackAddress'.
src/app/features/settings/emojis-stickers/GlobalPacks.tsx(164,39): error TS2345: Argument of type '(PackAddress | undefined)[]' is not assignable to parameter of type 'PackAddress[]'.
src/app/features/settings/emojis-stickers/GlobalPacks.tsx(311,27): error TS2345: Argument of type 'AccountDataEvent.PoniesEmoteRooms' is not assignable to parameter of type 'keyof AccountDataEvents'.
src/app/features/settings/emojis-stickers/GlobalPacks.tsx(328,31): error TS2345: Argument of type 'AccountDataEvent.PoniesEmoteRooms' is not assignable to parameter of type 'keyof AccountDataEvents'.
src/app/hooks/useAccountData.ts(7,62): error TS2345: Argument of type 'string' is not assignable to parameter of type 'keyof AccountDataEvents'.
src/app/hooks/usePowerLevelTags.ts(14,9): error TS2322: Type '(number | undefined)[]' is not assignable to type 'number[]'.
Type 'number | undefined' is not assignable to type 'number'.
Type 'undefined' is not assignable to type 'number'.
src/app/pages/client/inbox/Invites.tsx(722,45): error TS2345: Argument of type 'Room | null' is not assignable to parameter of type 'Room'.
Type 'null' is not assignable to type 'Room'.
src/app/pages/client/sidebar/SpaceTabs.tsx(750,27): error TS2345: Argument of type 'AccountDataEvent.VojoSpaces' is not assignable to parameter of type 'keyof AccountDataEvents'.
src/app/pages/client/sidebar/SpaceTabs.tsx(797,25): error TS2345: Argument of type 'AccountDataEvent.VojoSpaces' is not assignable to parameter of type 'keyof AccountDataEvents'.
src/app/plugins/call/CallEmbed.ts(129,12): error TS2540: Cannot assign to 'sandbox' because it is a read-only property.
src/app/plugins/recent-emoji.ts(45,21): error TS2345: Argument of type 'AccountDataEvent.ElementRecentEmoji' is not assignable to parameter of type 'keyof AccountDataEvents'.
src/app/utils/push.ts(160,9): error TS2353: Object literal may only specify known properties, and 'endpoint' does not exist in type '{ format?: string | undefined; url?: string | undefined; brand?: string | undefined; }'.

View file

@ -1,25 +0,0 @@
{
"appId": "chat.vojo.desktop",
"productName": "Vojo",
"asar": true,
"directories": {
"output": "release"
},
"files": ["dist/**/*", "electron/dist-electron/**/*", "package.json"],
"extraMetadata": {
"main": "electron/dist-electron/main.js"
},
"win": {
"target": ["zip"],
"artifactName": "Vojo-${version}-win-${arch}.${ext}",
"signAndEditExecutable": false
},
"mac": {
"target": ["dmg"],
"category": "public.app-category.social-networking"
},
"linux": {
"target": ["AppImage", "deb"],
"category": "Network"
}
}

View file

@ -1,264 +0,0 @@
import { app, BrowserWindow, protocol, net, shell, ipcMain } from 'electron';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { existsSync, promises as fsp } from 'node:fs';
// Dev-mode loads from Vite dev-server (http://localhost:8080) so HMR works.
// Prod-mode loads from in-process custom scheme `vojo://app/index.html`.
// `VOJO_ELECTRON_PROD=1` forces prod-mode in an un-packaged binary so the
// scheme + service-worker path can be validated without re-running
// electron-builder. The packaged app sets `app.isPackaged === true`.
const isDev = !app.isPackaged && process.env.VOJO_ELECTRON_PROD !== '1';
const DEV_URL = 'http://localhost:8080';
const APP_SCHEME = 'vojo';
const APP_HOST = 'app';
// Extensions that look like real web assets; for these, a missing file is a
// genuine 404. Anything else (including Matrix-flavoured `roomId/userId`
// segments like `!foo:vojo.chat` whose `path.extname` returns `.chat`) is
// treated as a SPA route and falls back to `index.html`. The allowlist is
// intentionally narrow — extend only when adding a new bundled asset kind.
const WEB_ASSET_EXTENSIONS = new Set([
'.js',
'.mjs',
'.cjs',
'.css',
'.html',
'.htm',
'.map',
'.json',
'.txt',
'.xml',
'.svg',
'.ico',
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.avif',
'.woff',
'.woff2',
'.ttf',
'.otf',
'.wasm',
]);
protocol.registerSchemesAsPrivileged([
{
scheme: APP_SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
allowServiceWorkers: true,
corsEnabled: true,
stream: true,
codeCache: true,
},
},
]);
const ALLOWED_EXTERNAL_SCHEMES = new Set(['http:', 'https:', 'mailto:']);
const isSafeExternal = (raw: unknown): raw is string => {
if (typeof raw !== 'string' || raw.length > 8 * 1024) return false;
try {
return ALLOWED_EXTERNAL_SCHEMES.has(new URL(raw).protocol);
} catch {
return false;
}
};
const distDir = path.resolve(__dirname, '..', '..', 'dist');
// React Router defaults to BrowserRouter against `window.location.pathname`,
// which for `vojo://app/...` would treat URL segments as routes (e.g.
// `vojo://app/index.html` resolved as a space alias `index.html`). Vojo
// already supports HashRouter via `clientConfig.hashRouter.enabled` in
// `config.json` — we override that to `true` for the Electron renderer so
// every route lives in `window.location.hash`, leaving the pathname stable
// for the protocol handler. The web/Android bundles see the unmodified
// config (hash router off).
const patchConfigForElectron = (raw: string): string => {
try {
const config: Record<string, unknown> = JSON.parse(raw);
const existing = (config.hashRouter as { basename?: string } | undefined) ?? {};
config.hashRouter = {
enabled: true,
basename: typeof existing.basename === 'string' ? existing.basename : '/',
};
return JSON.stringify(config);
} catch {
return raw;
}
};
const registerAppProtocol = () => {
protocol.handle(APP_SCHEME, async (request) => {
let url: URL;
try {
url = new URL(request.url);
} catch {
return new Response(null, { status: 400 });
}
// Reject foreign origins under the same scheme. `protocol.handle` does
// not validate the URL host, so without this check a renderer-side
// `window.location = 'vojo://evil/...'` would resolve into a separate
// (cookie/SW/IndexedDB-isolated) copy of the bundle — same content but
// detached storage. Only `vojo://app` is accepted.
if (url.hostname !== APP_HOST) {
return new Response(null, { status: 403 });
}
const rel = (url.pathname || '/').replace(/^\/+/, '') || 'index.html';
let filePath = path.normalize(path.join(distDir, rel));
// Path-traversal guard. `filePath.startsWith(distDir)` is unsafe — a
// sibling directory `/a/dist_evil` passes the prefix check against
// `/a/dist`. `path.relative` yields `..`-leading or absolute output
// for paths escaping `distDir`, which is the canonical Node check.
let relFromDist = path.relative(distDir, filePath);
if (relFromDist.startsWith('..') || path.isAbsolute(relFromDist)) {
return new Response(null, { status: 403 });
}
// SPA fallback for paths without a real file on disk. Under HashRouter
// (the Electron default — see `patchConfigForElectron`) reloads arrive
// with pathname `/`, so this rarely fires; kept as a safety net for
// manual URL edits and if someone ever reverts the HashRouter patch.
// We MUST NOT gate via `path.extname() !== ''` — Matrix room/user IDs
// like `!foo:vojo.chat` parse as having extension `.chat` and would
// wrongly 404. Use a narrow allowlist of real web-asset extensions.
if (!existsSync(filePath)) {
const ext = path.extname(rel).toLowerCase();
if (ext !== '' && WEB_ASSET_EXTENSIONS.has(ext)) {
return new Response('Not Found', { status: 404 });
}
filePath = path.join(distDir, 'index.html');
relFromDist = 'index.html';
}
// Override top-level `config.json` to force HashRouter on in Electron.
// Exact-match via `path.relative` is Windows-safe (case + separator
// normalization), unlike `path.dirname(filePath) === distDir`.
if (relFromDist === 'config.json') {
const raw = await fsp.readFile(filePath, 'utf-8');
return new Response(patchConfigForElectron(raw), {
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
}
return net.fetch(pathToFileURL(filePath).toString());
});
};
const createWindow = async () => {
const win = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 640,
minHeight: 480,
backgroundColor: '#0d0e11',
autoHideMenuBar: true,
// Native-looking chrome: Windows draws real min/max/close buttons via
// Window Controls Overlay, the rest of the bar matches our Dawn palette.
// Same pattern as Discord/Slack/VS Code/Element Desktop. On macOS
// `hiddenInset` keeps traffic lights but inset over a clean dark area.
// Linux keeps the system frame to avoid breaking GTK-decoration UX.
...(process.platform === 'win32' && {
titleBarStyle: 'hidden' as const,
titleBarOverlay: {
color: '#0d0e11',
symbolColor: '#e0e0e8',
height: 32,
},
}),
...(process.platform === 'darwin' && {
titleBarStyle: 'hiddenInset' as const,
}),
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
// sandbox: true is safe here — preload uses only `contextBridge` and
// `ipcRenderer`, both of which are exposed inside the Electron sandbox
// per https://www.electronjs.org/docs/latest/tutorial/sandbox.
sandbox: true,
},
});
// With `titleBarStyle: 'hidden'` Windows draws the min/max/close buttons
// but the rest of the bar is the renderer's pixel area — and Electron does
// NOT mark it draggable automatically. Inject a CSS drag-region overlay
// sized by the Window Controls Overlay API env() variables (the canonical
// way Microsoft / Chromium expose the safe drag area excluding the OS
// button strip). Body gets matching padding-top so content shifts down
// by 32px instead of sitting under the drag region.
if (process.platform === 'win32') {
win.webContents.on('dom-ready', () => {
win.webContents.insertCSS(`
body { padding-top: env(titlebar-area-height, 32px) !important; }
body::before {
content: "";
position: fixed;
top: env(titlebar-area-y, 0);
left: env(titlebar-area-x, 0);
width: env(titlebar-area-width, calc(100vw - 138px));
height: env(titlebar-area-height, 32px);
-webkit-app-region: drag;
z-index: 2147483647;
}
`);
});
}
win.webContents.setWindowOpenHandler(({ url }) => {
if (isSafeExternal(url)) shell.openExternal(url);
return { action: 'deny' };
});
win.webContents.on('will-navigate', (event, url) => {
let target: URL;
try {
target = new URL(url);
} catch {
event.preventDefault();
return;
}
// Strict match for the in-app origin. `target.protocol === 'vojo:'` alone
// would treat `vojo://evil/...` as internal even though the protocol
// handler now rejects it; preventing navigation upstream avoids the
// round-trip and keeps the renderer pinned to the canonical origin.
const isInternal =
(target.protocol === `${APP_SCHEME}:` && target.hostname === APP_HOST) ||
(isDev && target.origin === DEV_URL);
if (isInternal) return;
event.preventDefault();
if (isSafeExternal(url)) shell.openExternal(url);
});
if (isDev) {
await win.loadURL(DEV_URL);
win.webContents.openDevTools({ mode: 'detach' });
} else {
await win.loadURL(`${APP_SCHEME}://${APP_HOST}/`);
}
};
app.whenReady().then(() => {
if (!isDev) registerAppProtocol();
ipcMain.handle('vojo:open-external', async (_event, url: unknown) => {
if (isSafeExternal(url)) await shell.openExternal(url);
});
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});

View file

@ -1,6 +0,0 @@
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('vojoElectron', {
platform: process.platform,
openExternal: (url: string): Promise<void> => ipcRenderer.invoke('vojo:open-external', url),
});

View file

@ -1,18 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2022", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist-electron",
"rootDir": ".",
"resolveJsonModule": true,
"isolatedModules": true,
"sourceMap": true,
"types": ["node"]
},
"include": ["*.ts"]
}

View file

@ -23,8 +23,7 @@
property="og:description"
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
/>
<meta name="theme-color" content="#0d0e11" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#f2f2f7" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#000000" />
<link id="favicon" rel="shortcut icon" type="image/svg+xml" href="./public/res/svg/vojo.svg" />

2486
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,45 +1,35 @@
{
"name": "vojo",
"version": "0.2.0",
"description": "Vojo client for matrix server",
"version": "4.11.1",
"description": "Yet another matrix client",
"main": "index.js",
"type": "module",
"engines": {
"node": ">=22.12.0"
"node": ">=22.0.0"
},
"scripts": {
"start": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "npm run check:eslint && npm run check:prettier",
"check:eslint": "eslint --max-warnings 0 src",
"check:eslint": "eslint src",
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit",
"gen:push-strings": "node scripts/gen-push-strings.mjs",
"android:sync": "npx cap sync android",
"android:open": "npx cap open android",
"android:strip-sourcemaps": "find dist -name '*.map' -delete",
"android:apk:debug": "cd android && ./gradlew assembleDebug",
"android:apk:release": "cd android && ./gradlew assembleRelease",
"android:aab:release": "cd android && ./gradlew bundleRelease",
"build:android:debug": "npm run build && npm run android:strip-sourcemaps && npm run android:sync && npm run android:apk:debug",
"build:android:release": "npm run build && npm run android:strip-sourcemaps && npm run android:sync && npm run android:apk:release",
"build:android:aab": "npm run build && npm run android:strip-sourcemaps && npm run android:sync && npm run android:aab:release",
"electron:typecheck": "tsc --noEmit -p electron/tsconfig.json",
"electron:build": "tsc -p electron/tsconfig.json && node -e \"require('fs').writeFileSync('electron/dist-electron/package.json', JSON.stringify({type:'commonjs'}))\"",
"electron:dev": "concurrently -k -n vite,electron -c blue,green \"npm:start\" \"wait-on tcp:8080 && npm run electron:build && electron electron/dist-electron/main.js\"",
"electron:start": "electron electron/dist-electron/main.js",
"electron:start:prod": "cross-env VOJO_ELECTRON_PROD=1 electron electron/dist-electron/main.js",
"build:electron:win": "npm run build && npm run electron:build && electron-builder --win",
"build:electron:win:docker": "docker run --rm -v ${PWD}:/project -v ~/.cache/electron:/root/.cache/electron -v ~/.cache/electron-builder:/root/.cache/electron-builder -w /project electronuserland/builder:wine-mono /bin/bash -c \"trap 'chown -R 1000:1000 /project/dist /project/release /project/electron/dist-electron 2>/dev/null || true' EXIT; npm run build && npm run electron:build && npx electron-builder --win\"",
"build:electron:mac": "npm run build && npm run electron:build && electron-builder --mac",
"build:electron:linux": "npm run build && npm run electron:build && electron-builder --linux",
"build:android:debug": "npm run build && npm run android:sync && npm run android:apk:debug",
"build:android:release": "npm run build && npm run android:sync && npm run android:apk:release",
"build:android:aab": "npm run build && npm run android:sync && npm run android:aab:release",
"prepare": "husky install",
"commit": "git-cz"
},
"lint-staged": {
"*.{ts,tsx,js,jsx,mjs,cjs}": "eslint --max-warnings 0",
"*.{ts,tsx,js,jsx,mjs,cjs}": "eslint",
"*": "prettier --ignore-unknown --write"
},
"config": {
@ -135,11 +125,7 @@
"@typescript-eslint/parser": "7.18.0",
"@vitejs/plugin-react": "4.2.0",
"buffer": "6.0.3",
"concurrently": "9.2.1",
"cross-env": "7.0.3",
"cz-conventional-changelog": "3.3.0",
"electron": "42.1.0",
"electron-builder": "26.8.1",
"eslint": "8.57.1",
"eslint-config-airbnb": "19.0.4",
"eslint-config-prettier": "8.5.0",
@ -154,7 +140,6 @@
"vite": "5.4.19",
"vite-plugin-pwa": "0.20.5",
"vite-plugin-static-copy": "1.0.4",
"vite-plugin-top-level-await": "1.4.4",
"wait-on": "9.0.10"
"vite-plugin-top-level-await": "1.4.4"
}
}

View file

@ -1,388 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0d0e11" />
<meta name="robots" content="index,follow" />
<title>Vojo — Account deletion</title>
<style>
:root {
--bg: #0d0e11;
--panel: #181a20;
--surface: #21232b;
--text: #e6e6e9;
--text-strong: #f4f4f6;
--muted: rgba(230, 230, 233, 0.62);
--faint: rgba(230, 230, 233, 0.38);
--divider: rgba(255, 255, 255, 0.08);
--fleet: #9580ff;
--fleet-soft: #a59cff;
color-scheme: dark;
}
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, "SF Pro Text", "Inter", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body {
min-height: 100vh;
line-height: 1.7;
font-size: 16px;
}
.frame {
max-width: 680px;
margin: 0 auto;
padding: 56px 28px 120px;
}
header.doc {
padding-bottom: 28px;
margin-bottom: 40px;
border-bottom: 1px solid var(--divider);
}
.brand {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 22px;
color: var(--muted);
font-size: 13px;
letter-spacing: 0.2px;
}
.brand-dot {
width: 8px;
height: 8px;
border-radius: 99px;
background: var(--fleet);
}
.brand-name { color: var(--text-strong); font-weight: 600; }
.brand-sep { color: var(--faint); }
h1 {
font-size: 34px;
line-height: 1.15;
font-weight: 600;
margin: 0 0 10px;
letter-spacing: -0.6px;
color: var(--text-strong);
}
.effective { color: var(--muted); font-size: 14px; margin: 0; }
.lang-switch {
display: inline-flex;
margin-top: 24px;
font-size: 13px;
color: var(--muted);
}
.lang-switch button {
appearance: none;
-webkit-appearance: none;
background: transparent;
border: 0;
padding: 4px 0;
font: inherit;
color: var(--muted);
cursor: pointer;
transition: color .15s ease;
}
.lang-switch button:hover { color: var(--text); }
.lang-switch button[aria-pressed="true"] {
color: var(--text-strong);
font-weight: 600;
}
.lang-switch .sep {
padding: 0 10px;
color: var(--faint);
user-select: none;
}
h2 {
font-size: 20px;
font-weight: 600;
margin: 44px 0 12px;
letter-spacing: -0.2px;
color: var(--text-strong);
scroll-margin-top: 24px;
}
p { margin: 0 0 14px; color: var(--text); }
ul, ol {
margin: 0 0 18px;
padding-left: 22px;
}
ul li, ol li { margin: 8px 0; padding-left: 4px; }
ul li::marker, ol li::marker { color: var(--faint); }
ul li b, ol li b, p b { color: var(--text-strong); font-weight: 600; }
a {
color: var(--fleet-soft);
text-decoration: none;
border-bottom: 1px solid rgba(165, 156, 255, 0.35);
transition: color .15s ease, border-color .15s ease;
}
a:hover {
color: #c0b9ff;
border-bottom-color: rgba(192, 185, 255, 0.7);
}
.callout {
background: var(--panel);
border: 1px solid var(--divider);
border-radius: 12px;
padding: 18px 20px;
margin: 14px 0 20px;
}
.callout p:last-child { margin-bottom: 0; }
.callout a.email {
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.95em;
}
section[hidden] { display: none; }
section > h2:first-of-type { margin-top: 0; }
footer.doc {
margin-top: 64px;
padding-top: 24px;
border-top: 1px solid var(--divider);
font-size: 13px;
color: var(--faint);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
footer.doc .copy {
font-family: ui-monospace, "JetBrains Mono", monospace;
}
footer.doc a { color: var(--muted); border-bottom-color: transparent; }
footer.doc a:hover { color: var(--text); border-bottom-color: var(--divider); }
@media (max-width: 560px) {
.frame { padding: 36px 20px 96px; }
h1 { font-size: 28px; }
h2 { font-size: 18px; margin: 36px 0 10px; }
body { font-size: 15.5px; line-height: 1.65; }
}
</style>
</head>
<body>
<div class="frame">
<header class="doc">
<div class="brand">
<span class="brand-dot" aria-hidden="true"></span>
<span class="brand-name">Vojo</span>
<span class="brand-sep">·</span>
<span>Account deletion</span>
</div>
<h1 data-i18n-h1>Delete your account</h1>
<p class="effective" data-i18n-effective>Vojo Project · vojo.chat</p>
<div class="lang-switch" role="group" aria-label="Language">
<button type="button" data-lang="en" aria-pressed="true">English</button>
<span class="sep" aria-hidden="true">/</span>
<button type="button" data-lang="ru" aria-pressed="false">Русский</button>
</div>
</header>
<section lang="en" data-lang="en">
<p>This page explains how to request deletion of your <b>Vojo</b> account and the
data associated with it on the <code>vojo.chat</code> homeserver.</p>
<p>Vojo is maintained by the Vojo Project, an independent developer. The application
does not yet expose an in-app "Delete account" button; until it does, deletion is
handled by request to the address below. We reply to every request.</p>
<h2>How to request deletion</h2>
<ol>
<li>Send an email from any address to
<a class="email" href="mailto:vojochatdev@gmail.com?subject=Delete%20account">vojochatdev@gmail.com</a>
with the subject <b>“Delete account”</b>.</li>
<li>In the body of the email, include your full Matrix user ID — the
<code>@username:vojo.chat</code> identifier shown in Settings → Account.
If you have lost access to the account, describe enough detail (approximate
creation date, the email or recovery info you remember) so we can identify it
with reasonable certainty.</li>
<li>We acknowledge the request within a few business days and complete deletion
within <b>thirty days</b> of the original request.</li>
</ol>
<div class="callout">
<p><b>Contact:</b>
<a class="email" href="mailto:vojochatdev@gmail.com">vojochatdev@gmail.com</a></p>
<p style="margin-top: 6px;"><b>Subject line:</b> Delete account</p>
</div>
<h2>What gets deleted</h2>
<ul>
<li>Your account record on the <code>vojo.chat</code> homeserver (user profile,
display name, avatar).</li>
<li>Your active sessions and authentication tokens.</li>
<li>Your encryption keys held server-side.</li>
<li>Media files you uploaded to the <code>vojo.chat</code> media storage.</li>
<li>The push-notification registration (Firebase Cloud Messaging token) bound
to the account.</li>
</ul>
<h2>What we cannot delete on your behalf</h2>
<ul>
<li><b>Messages already delivered to other servers.</b> Matrix is a federated
network: when you sent a message into a room that included participants on
other homeservers, those messages were replicated to their servers and are
no longer under our control.</li>
<li><b>Copies held by other participants.</b> Anything you sent into a
conversation has been received by the people you sent it to. We cannot
reach into their devices or accounts.</li>
<li><b>Messages in rooms you no longer participate in.</b> A few residual
events (membership records, room state) may remain on the homeserver for
room consistency, but they no longer link to your deleted account.</li>
<li><b>Bridged third-party networks.</b> If you used a Telegram, Discord or
WhatsApp bridge, those networks hold their own copies of your messages
governed by their own retention policies; deactivating your Vojo account
does not delete data on those services.</li>
</ul>
<h2>Data retained after deletion</h2>
<p>After your account is deactivated, server access logs may retain your IP address
and request timestamps for up to thirty additional days as part of normal abuse-
prevention rotation. Backup snapshots covering the period before deletion are
rotated out within thirty days. After that period, no personal data attributable to
your account remains on our infrastructure.</p>
<h2>If you'd prefer to stay but stop receiving notifications</h2>
<p>If you only want notifications to stop and not to lose your account entirely,
you can simply sign out of the application on your device — this removes the
push-notification binding without deactivating the account.</p>
<h2>Privacy Policy</h2>
<p>For a fuller description of what we hold and why, see our
<a href="https://vojo.chat/privacy">Privacy Policy</a>.</p>
</section>
<section lang="ru" data-lang="ru" hidden>
<p>На этой странице описано, как запросить удаление вашей учётной записи <b>Vojo</b>
и связанных с ней данных на homeserver-е <code>vojo.chat</code>.</p>
<p>Vojo поддерживается проектом Vojo, независимым разработчиком. В приложении пока
нет кнопки «Удалить аккаунт»; до её появления удаление выполняется по запросу на
адрес ниже. Мы отвечаем на каждое обращение.</p>
<h2>Как запросить удаление</h2>
<ol>
<li>Напишите письмо с любого адреса на
<a class="email" href="mailto:vojochatdev@gmail.com?subject=Delete%20account">vojochatdev@gmail.com</a>
с темой <b>«Delete account»</b>.</li>
<li>В тексте письма укажите полный Matrix user ID — идентификатор вида
<code>@username:vojo.chat</code>, который виден в Настройки → Аккаунт.
Если доступа к аккаунту больше нет, опишите достаточно деталей
(примерная дата создания, email или recovery-информация, которую помните),
чтобы мы могли надёжно его опознать.</li>
<li>Мы подтверждаем получение запроса в течение нескольких рабочих дней и
завершаем удаление в течение <b>тридцати дней</b> с момента
первоначального обращения.</li>
</ol>
<div class="callout">
<p><b>Контакт:</b>
<a class="email" href="mailto:vojochatdev@gmail.com">vojochatdev@gmail.com</a></p>
<p style="margin-top: 6px;"><b>Тема письма:</b> Delete account</p>
</div>
<h2>Что будет удалено</h2>
<ul>
<li>Запись вашего аккаунта на homeserver-е <code>vojo.chat</code> (профиль,
отображаемое имя, аватар).</li>
<li>Активные сессии и токены аутентификации.</li>
<li>Ключи шифрования, хранящиеся на сервере.</li>
<li>Медиа-файлы, которые вы загружали в медиа-хранилище
<code>vojo.chat</code>.</li>
<li>Регистрация push-уведомлений (Firebase Cloud Messaging токен),
привязанная к аккаунту.</li>
</ul>
<h2>Что мы не можем удалить за вас</h2>
<ul>
<li><b>Сообщения, уже доставленные на другие серверы.</b> Matrix — федеративная
сеть: когда вы отправляли сообщение в комнату, где были участники с других
homeserver-ов, эти сообщения реплицировались на их серверы и больше не
находятся под нашим контролем.</li>
<li><b>Копии у других участников.</b> Всё, что вы отправили в переписку, уже
получили те, кому вы это отправляли. Мы не можем добраться до их устройств
и учётных записей.</li>
<li><b>Сообщения в комнатах, где вас больше нет.</b> Несколько остаточных
событий (записи о членстве, состояние комнаты) могут оставаться на
homeserver-е для целостности комнаты, но они уже не связаны с вашим
удалённым аккаунтом.</li>
<li><b>Подключённые сторонние сети.</b> Если вы пользовались мостами в
Telegram, Discord или WhatsApp, эти сети хранят собственные копии ваших
сообщений по своим политикам; деактивация аккаунта Vojo не удаляет данные
на этих сервисах.</li>
</ul>
<h2>Данные, остающиеся после удаления</h2>
<p>После деактивации аккаунта серверные журналы доступа могут содержать ваш IP-
адрес и время запросов ещё до тридцати дней в рамках обычной ротации. Резервные
копии, охватывающие период до удаления, ротируются и пропадают в течение
тридцати дней. По истечении этого срока никакие персональные данные, относящиеся
к вашему аккаунту, на нашей инфраструктуре не остаются.</p>
<h2>Если хотите остаться, но прекратить уведомления</h2>
<p>Если вы хотите только перестать получать уведомления, не теряя аккаунт целиком —
просто выйдите из приложения на своём устройстве. Это снимет привязку к push-
сервису без деактивации учётной записи.</p>
<h2>Политика конфиденциальности</h2>
<p>Более полное описание того, что у нас хранится и зачем — в
<a href="https://vojo.chat/privacy">Политике конфиденциальности</a>.</p>
</section>
<footer class="doc">
<span class="copy">© Vojo Project · 2026</span>
<a href="https://vojo.chat">vojo.chat</a>
</footer>
</div>
<script>
(function () {
var buttons = document.querySelectorAll('.lang-switch button');
var sections = document.querySelectorAll('section[data-lang]');
var h1 = document.querySelector('[data-i18n-h1]');
var eff = document.querySelector('[data-i18n-effective]');
var H1 = { en: 'Delete your account', ru: 'Удалить аккаунт' };
var EFF = { en: 'Vojo Project · vojo.chat', ru: 'Проект Vojo · vojo.chat' };
function setLang(lang) {
buttons.forEach(function (b) {
b.setAttribute('aria-pressed', String(b.dataset.lang === lang));
});
sections.forEach(function (s) {
s.hidden = s.dataset.lang !== lang;
});
if (h1 && H1[lang]) h1.textContent = H1[lang];
if (eff && EFF[lang]) eff.textContent = EFF[lang];
document.documentElement.lang = lang;
document.title = (lang === 'ru' ? 'Vojo — Удаление аккаунта' : 'Vojo — Account deletion');
try { localStorage.setItem('vojo-delete-lang', lang); } catch (e) {}
}
buttons.forEach(function (b) {
b.addEventListener('click', function () { setLang(b.dataset.lang); });
});
var stored = null;
try { stored = localStorage.getItem('vojo-delete-lang'); } catch (e) {}
setLang(stored || 'en');
})();
</script>
</body>
</html>

Binary file not shown.

View file

@ -90,8 +90,6 @@
"menu_emojis_stickers": "Emojis & Stickers",
"menu_developer_tools": "Developer Tools",
"menu_about": "About",
"drag_to_close": "Drag down to close",
"close": "Close",
"logout": "Logout",
"logout_confirm": "You're about to log out. Are you sure?",
"logout_failed": "Failed to logout! {{message}}",
@ -99,6 +97,7 @@
"logout_unverified_desc": "Verify your device before logging out to save your encrypted messages.",
"logout_alert_title": "Alert",
"logout_alert_desc": "Enable device verification or export your encrypted data from settings to avoid losing access to your messages.",
"general_title": "General",
"appearance": "Appearance",
"system_theme": "System",
@ -122,6 +121,7 @@
"url_preview": "Url Preview",
"url_preview_encrypted": "Url Preview in Encrypted Room",
"show_hidden_events": "Show Hidden Events",
"account_title": "Account",
"profile": "Profile",
"avatar": "Avatar",
@ -140,6 +140,7 @@
"select_user_desc": "Prevent receiving messages or invites from user by adding their userId.",
"block": "Block",
"users": "Users",
"notifications_title": "Notifications",
"block_messages": "Block Messages",
"block_messages_moved": "This option has been moved to \"Account > Block Users\" section.",
@ -184,6 +185,7 @@
"notif_disable": "Disable",
"notif_silent": "Notify Silent",
"notif_loud": "Notify Loud",
"devices_title": "Devices",
"security": "Security",
"device_verification": "Device Verification",
@ -226,6 +228,7 @@
"verify_other_desc": "Verify device identity and grant access to encrypted messages.",
"verify": "Verify",
"reset": "Reset",
"local_backup": "Local Backup",
"new_password": "New Password",
"confirm_password": "Confirm Password",
@ -239,6 +242,7 @@
"import_desc": "Load password protected copy of encryption data from device to decrypt your messages.",
"import": "Import",
"decrypt": "Decrypt",
"emojis_stickers_title": "Emojis & Stickers",
"default_pack": "Default Pack",
"unknown": "Unknown",
@ -248,6 +252,7 @@
"select_pack_desc": "Pick emoji and sticker packs from rooms to use globally.",
"select": "Select",
"room_packs": "Room Packs",
"close": "Close",
"select_all": "Select All",
"unselect_all": "Unselect All",
"no_packs": "No Packs",
@ -255,17 +260,15 @@
"apply_error": "Failed to apply changes! Please try again.",
"apply_ready": "Changes saved! Apply when ready.",
"apply_changes": "Apply Changes",
"about_title": "About",
"about_tagline": "Yet another matrix client.",
"options": "Options",
"clear_cache_title": "Clear Cache & Reload",
"clear_cache_desc": "Clear all your locally stored data and reload from server.",
"clear_cache": "Clear Cache",
"legal": "Legal",
"privacy_policy_title": "Privacy Policy",
"privacy_policy_desc": "How your data is handled.",
"privacy_policy_open": "Open",
"credits": "Credits",
"devtools_title": "Developer Tools",
"enable_devtools": "Enable Developer Tools",
"access_token": "Access Token",
@ -369,7 +372,7 @@
"create_chat": "Create Chat",
"create_chat_subtitle": "Start a private, encrypted chat by entering a username.",
"start_first_chat": "Start a chat",
"segment_dm": "Direct",
"segment_dm": "DM",
"segment_channels": "Channels",
"segment_bots": "Robots",
"self_row_label": "You",
@ -383,8 +386,7 @@
"e2e_encryption": "End-to-End Encryption",
"e2e_encryption_desc": "Once this feature is enabled, it can't be disabled after the room is created.",
"rate_limited": "Server rate-limited your request for {{minutes}} minutes!",
"create": "Create",
"close": "Close"
"create": "Create"
},
"Channels": {
"no_spaces_title": "No communities yet",
@ -394,12 +396,7 @@
"pick_channel_desc": "Choose a channel from the list on the left to start reading.",
"root_category": "Channels",
"workspace_switcher_aria": "Switch community",
"workspace_switcher_create_space": "Create community",
"workspace_switcher_drag_to_close": "Drag down to close",
"workspace_switcher_member_count_one": "{{count}} member",
"workspace_switcher_member_count_other": "{{count}} members",
"workspace_footer_subtitle": "Community",
"create_channel": "Create channel"
"workspace_switcher_active_marker": "Current"
},
"Call": {
"start": "Start call",
@ -424,19 +421,7 @@
"in_call": "In call",
"in_call_count": "{{count}} in call",
"connecting": "Connecting…",
"open_call_room": "Open call room",
"bubble_outgoing": "Outgoing call",
"bubble_incoming": "Incoming call",
"bubble_missed": "Missed call",
"bubble_cancelled": "Cancelled call",
"bubble_ongoing": "Ongoing call",
"bubble_in_progress": "In progress…",
"bubble_missed_count_one": "{{count}} missed call",
"bubble_missed_count_other": "{{count}} missed calls",
"bubble_cancelled_count_one": "{{count}} cancelled call",
"bubble_cancelled_count_other": "{{count}} cancelled calls",
"duration_minutes_seconds": "{{minutes}} min {{seconds}} sec",
"duration_seconds": "{{seconds}} sec"
"open_call_room": "Open call room"
},
"Room": {
"drag_to_close": "Drag up to close",
@ -448,6 +433,7 @@
"jump_to_latest": "Jump to Latest",
"today": "Today",
"yesterday": "Yesterday",
"view_reactions": "View Reactions",
"read_receipts": "Read Receipts",
"view_source": "View Source",
@ -459,6 +445,7 @@
"reply": "Reply",
"reply_in_thread": "Reply in Thread",
"edit_message": "Edit Message",
"delete_message": "Delete Message",
"delete_confirm": "This action is irreversible! Are you sure that you want to delete this message?",
"reason": "Reason",
@ -466,6 +453,7 @@
"delete_error": "Failed to delete message! Please try again.",
"deleting": "Deleting...",
"delete": "Delete",
"report_message": "Report Message",
"report_desc": "Report this message to server, which may then notify the appropriate people to take action.",
"report_reason": "Reason",
@ -474,11 +462,13 @@
"reporting": "Reporting...",
"report": "Report",
"no_reason": "No reason provided",
"is_typing": " is typing...",
"and": " and ",
"are_typing": " are typing...",
"others_count": "{{count}} others",
"drop_typing": "Dismiss typing indicator",
"members": "Members",
"members_count_one": "{{formattedCount}} Member",
"members_count_other": "{{formattedCount}} Members",
@ -495,30 +485,23 @@
"room_settings": "Room Settings",
"jump_to_time": "Jump to Time",
"leave_room": "Leave Room",
"send_message": "Send a message...",
"send_message_alt_1": "One line or many...",
"send_message_alt_2": "Write something right now...",
"send_message_alt_3": "Don't keep me waiting, type...",
"send_message_alt_4": "This line won't fill itself...",
"send_message_alt_5": "So... what's it gonna be?..",
"send_message_alt_6": "Nobody reads placeholders. But you did...",
"send_message_alt_7": "Letters here, please...",
"send_message_alt_8": "You stare at the placeholder. The placeholder stares back...",
"send_message_alt_9": "Congrats, you're in the 3% who read placeholders...",
"send_message_alt_10": "Fine, I'll wait... and wait...",
"send_message_alt_11": "After you...",
"drop_files": "Drop Files in \"{{name}}\"",
"drag_drop_desc": "Drag and drop files here or click for selection dialog",
"pinned_messages": "Pinned Messages",
"no_pinned_messages": "No Pinned Messages",
"no_pinned_messages_desc": "Users with sufficient permissions can pin messages from the message context menu.",
"open": "Open",
"failed_to_load": "Failed to load message!",
"time_label": "Time",
"date_label": "Date",
"preset": "Preset",
"beginning": "Beginning",
"open_timeline": "Open Timeline",
"message_deleted": "This message has been deleted",
"message_deleted_reason": "This message has been deleted. {{reason}}",
"unsupported_message": "Unsupported message",
@ -528,6 +511,7 @@
"broken_message": "Broken message",
"empty_message": "Empty message",
"edited": " (edited)",
"thread_caption": "Thread",
"thread_in_channel_subtitle": "in #{{channel}}",
"thread_close": "Close thread",
@ -544,6 +528,7 @@
"thread_summary_highlight_one": "{{count}} mention",
"thread_summary_highlight_other": "{{count}} mentions",
"no_post_permission": "You do not have permission to post in this room",
"conversation_beginning": "This is the beginning of conversation.",
"created_by": "Created by <bold>@{{creator}}</bold> on {{date}} {{time}}",
"invite_member": "Invite Member",
@ -554,6 +539,7 @@
"leave_room_error": "Failed to leave room! {{error}}",
"leaving": "Leaving...",
"leave": "Leave",
"member_broken": "Broken membership event",
"member_accepted_knock": "<bold>{{sender}}</bold> accepted <bold>{{user}}</bold>'s join request",
"member_invited": "<bold>{{sender}}</bold> invited <bold>{{user}}</bold>",
@ -571,7 +557,10 @@
"member_name_removed": "<bold>{{user}}</bold> removed their display name",
"member_avatar_changed": "<bold>{{user}}</bold> changed their avatar",
"member_avatar_removed": "<bold>{{user}}</bold> removed their avatar",
"member_no_change": "Membership event with no changes"
"member_no_change": "Membership event with no changes",
"member_ended_call": "<bold>{{user}}</bold> ended the call",
"member_joined_call": "<bold>{{user}}</bold> joined the call"
},
"Inbox": {
"invite_title": "Invite",
@ -579,15 +568,19 @@
"user_id_placeholder": "@username:server",
"reason_optional": "Reason (Optional)",
"invite_button": "Invite",
"notif_default": "Default",
"notif_all_messages": "All Messages",
"notif_mentions_keywords": "Mention & Keywords",
"notif_mute": "Mute",
"unverified_device": "Unverified Device",
"unverified_devices": "Unverified Devices"
},
"Explore": {
"explore_community": "Explore Community",
"add_server": "Add Server",
"add_server_desc": "Add server name to explore public communities.",
"server_name": "Server Name",
@ -595,11 +588,13 @@
"view": "View",
"featured": "Featured",
"servers": "Servers",
"featured_by_client": "Featured by Client",
"featured_by_client_desc": "Public rooms and spaces hand-picked by this client.",
"featured_spaces": "Featured Spaces",
"featured_rooms": "Featured Rooms",
"no_featured": "No featured rooms or spaces yet.",
"search": "Search",
"search_placeholder": "Search for keyword",
"clear": "Clear",
@ -618,9 +613,9 @@
"previous_page": "Previous Page",
"next_page": "Next Page",
"no_communities": "No communities found!",
"space_badge": "Space",
"members_count_one": "{{formattedCount}} Member",
"members_count_other": "{{formattedCount}} Members",
"members_count": "{{count}} Members",
"join": "Join",
"joining": "Joining",
"retry": "Retry",
@ -629,6 +624,7 @@
"view_error": "View Error",
"cancel": "Cancel"
},
"Create": {
"add_space": "Add Space",
"create_space": "Create Space",
@ -636,6 +632,7 @@
"join_with_address": "Join with Address",
"join_with_address_desc": "Join an existing community.",
"new_space": "New Space",
"access": "Access",
"name": "Name",
"topic_optional": "Topic (Optional)",
@ -647,38 +644,47 @@
"allow_federation_desc": "Users from other servers can join.",
"create": "Create",
"rate_limited": "Server rate-limited your request for {{minutes}} minutes!",
"access_restricted": "Restricted",
"access_restricted_desc": "Only members of the parent space can join.",
"access_private": "Private",
"access_private_desc": "Only people with an invite can join.",
"access_public": "Public",
"access_public_desc": "Anyone with the address can join.",
"address_optional": "Address (Optional)",
"address_hint": "Pick a unique address to make it discoverable.",
"address_taken": "This address is already taken. Please choose a different one.",
"founders": "Founders",
"founders_desc": "Privileged users assigned during creation. They have elevated control and can only be changed during an upgrade.",
"enter": "Enter",
"no_suggestions": "No Suggestions",
"no_suggestions_desc": "Enter a user ID and press Enter.",
"version": "Version",
"versions": "Versions",
"chat_room": "Chat Room",
"chat_room_desc": "Messages, photos, and videos.",
"voice_room": "Voice Room",
"voice_room_desc": "Live audio and video conversations.",
"new_chat_room": "New Chat Room",
"new_voice_room": "New Voice Room",
"existing_space": "Existing Space",
"add_room": "Add Room",
"existing_room": "Existing Room"
},
"RoomSettings": {
"general": "General",
"members": "Members",
"permissions": "Permissions",
"emojis_stickers": "Emojis & Stickers",
"developer_tools": "Developer Tools",
"profile": "Profile",
"edit": "Edit",
"unknown": "Unknown",
@ -690,25 +696,30 @@
"topic": "Topic",
"save": "Save",
"cancel": "Cancel",
"options": "Options",
"addresses": "Addresses",
"advanced_options": "Advanced Options",
"space_access": "Space Access",
"room_access": "Room Access",
"space_access_desc": "Change how people can join the space.",
"room_access_desc": "Change how people can join the room.",
"join_invite_only": "Invite Only",
"join_knock_invite": "Knock & Invite",
"join_space_members_or_knock": "Space Members or Knock",
"join_space_members": "Space Members",
"join_public": "Public",
"join_unsupported": "Unsupported",
"history_visibility": "Message History Visibility",
"history_visibility_desc": "Changes to history visibility will only apply to future messages and will not affect existing history.",
"visibility_after_invite": "After Invite",
"visibility_after_join": "After Join",
"visibility_all_messages": "All Messages",
"visibility_all_messages_guests": "All Messages (Guests)",
"room_encryption": "Room Encryption",
"encryption_enabled_desc": "Messages in this room are protected by end-to-end encryption.",
"encryption_disabled_desc": "Once enabled, encryption cannot be disabled!",
@ -717,9 +728,11 @@
"enable_encryption": "Enable Encryption",
"enable_encryption_confirm": "Are you sure? Once enabled, encryption cannot be disabled!",
"enable_e2e_encryption": "Enable E2E Encryption",
"publish_to_directory": "Publish to Directory",
"publish_space_desc": "List the space in the public directory to make it discoverable by others.",
"publish_room_desc": "List the room in the public directory to make it discoverable by others.",
"published_addresses": "Published Addresses",
"published_addresses_desc": "If access is <b>Public</b>, Published addresses will be used to join by anyone.",
"no_addresses": "No Addresses",
@ -733,11 +746,13 @@
"publish": "Publish",
"delete": "Delete",
"selected_count": "{{count}} Selected",
"local_addresses": "Local Addresses",
"local_addresses_desc": "Set local address so users can join through your homeserver.",
"collapse": "Collapse",
"expand": "Expand",
"loading": "Loading...",
"space_upgrade": "Space Upgrade",
"room_upgrade": "Room Upgrade",
"upgrade": "Upgrade",
@ -751,21 +766,25 @@
"old_room": "Old Room",
"open_new_space": "Open New Space",
"open_new_room": "Open New Room",
"members_count": "{{count}} Members",
"search": "Search",
"no_results": "No Results",
"results_count": "{{count}} Results",
"scroll_to_top": "Scroll to Top",
"no_membership_members": "No \"{{filter}}\" Members",
"filter_joined": "Joined",
"filter_invited": "Invited",
"filter_left": "Left",
"filter_kicked": "Kicked",
"filter_banned": "Banned",
"sort_a_to_z": "A to Z",
"sort_z_to_a": "Z to A",
"sort_newest": "Newest",
"sort_oldest": "Oldest",
"perm_messages": "Messages",
"perm_send_messages": "Send Messages",
"perm_send_stickers": "Send Stickers",
@ -798,10 +817,12 @@
"perm_manage_emojis_stickers": "Manage Emojis & Stickers",
"perm_change_server_acls": "Change Server ACLs",
"perm_modify_widgets": "Modify Widgets",
"founders": "Founders",
"founders_desc": "Founding members have all permissions and can only be changed during a room upgrade.",
"power_levels": "Power Levels",
"power_levels_desc": "Manage and customize incremental power levels for users.",
"new_power_level": "New Power Level",
"new_power_level_desc": "Create a new power level.",
"power_level_placeholder": "Bot",
@ -818,9 +839,11 @@
"failed_to_apply": "Failed to apply changes! Please try again.",
"apply_changes": "Apply Changes",
"and_above": "& Above",
"users": "Users",
"default_power": "Default Power",
"default_power_desc": "Default power level for all users.",
"packs": "Packs",
"new_pack": "New Pack",
"new_pack_desc": "Add your own emoji and sticker pack to use in room.",
@ -829,6 +852,7 @@
"view": "View",
"failed_to_remove_packs": "Failed to remove packs! Please try again.",
"delete_selected_packs": "Delete selected packs. ({{count}} selected)",
"enable_developer_tools": "Enable Developer Tools",
"room_id": "Room ID",
"room_id_desc": "Copy room ID to clipboard.",
@ -851,6 +875,7 @@
"message_event_type": "Message Event Type",
"send": "Send",
"state_key_optional": "State Key (Optional)",
"pack": "Pack",
"images_usage": "Images Usage",
"images_usage_desc": "Select how the images are being used: as emojis, as stickers, or as both.",
@ -865,6 +890,7 @@
"usage_both": "Both",
"usage_sticker": "Sticker",
"usage_emoji": "Emoji",
"power_goku": "Goku",
"power_manager": "Manager",
"power_founder": "Founder",
@ -874,6 +900,7 @@
"power_muted": "Muted",
"power_team": "Team"
},
"Push": {
"new_message": "New message",
"new_messages": "New messages",
@ -884,19 +911,7 @@
"invite_body": "{{inviter}} invited you to {{roomName}}",
"invite_body_no_room": "{{inviter}} invited you to a room",
"invite_body_no_inviter": "Invited you to {{roomName}}",
"invite_body_generic": "New invitation",
"missed_call": "Missed call",
"missed_call_body": "{{caller}} tried to call you",
"channel_group": "Chats",
"channel_dm": "Direct messages",
"channel_dm_description": "New messages from direct chats",
"channel_group_room": "Group chats",
"channel_group_room_description": "New messages from group chats and channels",
"self_name": "You",
"action_mark_as_read": "Mark as read",
"action_reply": "Reply",
"reply_hint": "Reply…",
"reply_failed": "Could not send your reply"
"invite_body_generic": "New invitation"
},
"Bots": {
"not_connected_title": "{{name}} is not connected",
@ -968,15 +983,5 @@
"copy_server": "Copy server",
"explore_community": "Explore community",
"open_in_browser": "Open in browser"
},
"Share": {
"share_text": "Ready to share text",
"share_image": "Ready to share image",
"share_video": "Ready to share video",
"share_audio": "Ready to share audio",
"share_file": "Ready to share: {{name}}",
"share_files": "Ready to share {{count}} files",
"tap_chat_to_send": "Open a chat to drop it in",
"cancel": "Cancel share"
}
}

View file

@ -90,8 +90,6 @@
"menu_emojis_stickers": "Эмодзи и стикеры",
"menu_developer_tools": "Инструменты разработчика",
"menu_about": "О приложении",
"drag_to_close": "Потянуть вниз чтобы закрыть",
"close": "Закрыть",
"logout": "Выйти",
"logout_confirm": "Вы собираетесь выйти из аккаунта. Вы уверены?",
"logout_failed": "Не удалось выйти! {{message}}",
@ -99,6 +97,7 @@
"logout_unverified_desc": "Верифицируйте устройство перед выходом, чтобы сохранить зашифрованные сообщения.",
"logout_alert_title": "Внимание",
"logout_alert_desc": "Включите верификацию устройства или экспортируйте зашифрованные данные в настройках, чтобы не потерять доступ к сообщениям.",
"general_title": "Общие",
"appearance": "Внешний вид",
"system_theme": "Системная",
@ -122,6 +121,7 @@
"url_preview": "Предпросмотр ссылок",
"url_preview_encrypted": "Предпросмотр ссылок в зашифрованных комнатах",
"show_hidden_events": "Показывать скрытые события",
"account_title": "Аккаунт",
"profile": "Профиль",
"avatar": "Аватар",
@ -140,6 +140,7 @@
"select_user_desc": "Заблокируйте получение сообщений и приглашений от пользователя, добавив его идентификатор.",
"block": "Заблокировать",
"users": "Пользователи",
"notifications_title": "Уведомления",
"block_messages": "Блокировка сообщений",
"block_messages_moved": "Эта опция перенесена в раздел «Аккаунт > Заблокированные пользователи».",
@ -184,6 +185,7 @@
"notif_disable": "Отключить",
"notif_silent": "Тихое уведомление",
"notif_loud": "Громкое уведомление",
"devices_title": "Устройства",
"security": "Безопасность",
"device_verification": "Верификация устройства",
@ -226,6 +228,7 @@
"verify_other_desc": "Подтвердите идентичность устройства и получите доступ к зашифрованным сообщениям.",
"verify": "Верифицировать",
"reset": "Сбросить",
"local_backup": "Локальная копия",
"new_password": "Новый пароль",
"confirm_password": "Подтвердите пароль",
@ -239,6 +242,7 @@
"import_desc": "Загрузите защищённую паролем копию ключей шифрования с устройства для расшифровки сообщений.",
"import": "Импорт",
"decrypt": "Расшифровать",
"emojis_stickers_title": "Эмодзи и стикеры",
"default_pack": "Пакет по умолчанию",
"unknown": "Неизвестно",
@ -248,6 +252,7 @@
"select_pack_desc": "Выберите пакеты эмодзи и стикеров из комнат для использования во всех комнатах.",
"select": "Выбрать",
"room_packs": "Пакеты комнат",
"close": "Закрыть",
"select_all": "Выбрать все",
"unselect_all": "Снять выделение",
"no_packs": "Нет пакетов",
@ -255,17 +260,15 @@
"apply_error": "Не удалось применить изменения! Попробуйте снова.",
"apply_ready": "Изменения сохранены! Примените, когда будете готовы.",
"apply_changes": "Применить изменения",
"about_title": "О приложении",
"about_tagline": "Ещё один клиент для Matrix.",
"options": "Параметры",
"clear_cache_title": "Очистить кэш и перезагрузить",
"clear_cache_desc": "Удалить все локально сохранённые данные и загрузить заново с сервера.",
"clear_cache": "Очистить кэш",
"legal": "Юридическое",
"privacy_policy_title": "Политика конфиденциальности",
"privacy_policy_desc": "Как обрабатываются ваши данные.",
"privacy_policy_open": "Открыть",
"credits": "Благодарности",
"devtools_title": "Инструменты разработчика",
"enable_devtools": "Включить инструменты разработчика",
"access_token": "Токен доступа",
@ -385,8 +388,7 @@
"e2e_encryption": "Сквозное шифрование",
"e2e_encryption_desc": "После включения эту функцию нельзя отключить после создания комнаты.",
"rate_limited": "Сервер ограничил частоту запросов на {{minutes}} мин.!",
"create": "Создать",
"close": "Закрыть"
"create": "Создать"
},
"Channels": {
"no_spaces_title": "Пока нет сообществ",
@ -396,14 +398,7 @@
"pick_channel_desc": "Откройте канал из списка слева, чтобы начать читать.",
"root_category": "Каналы",
"workspace_switcher_aria": "Сменить сообщество",
"workspace_switcher_create_space": "Создать сообщество",
"workspace_switcher_drag_to_close": "Потяните вниз, чтобы закрыть",
"workspace_switcher_member_count_one": "{{count}} участник",
"workspace_switcher_member_count_few": "{{count}} участника",
"workspace_switcher_member_count_many": "{{count}} участников",
"workspace_switcher_member_count_other": "{{count}} участника",
"workspace_footer_subtitle": "Сообщество",
"create_channel": "Создать канал"
"workspace_switcher_active_marker": "Текущее"
},
"Call": {
"start": "Позвонить",
@ -428,21 +423,7 @@
"in_call": "В звонке",
"in_call_count": "{{count}} в звонке",
"connecting": "Соединение…",
"open_call_room": "Открыть чат звонка",
"bubble_outgoing": "Исходящий звонок",
"bubble_incoming": "Входящий звонок",
"bubble_missed": "Пропущенный звонок",
"bubble_cancelled": "Отменённый звонок",
"bubble_ongoing": "Идёт звонок",
"bubble_in_progress": "Идёт сейчас…",
"bubble_missed_count_one": "{{count}} пропущенный звонок",
"bubble_missed_count_few": "{{count}} пропущенных звонка",
"bubble_missed_count_many": "{{count}} пропущенных звонков",
"bubble_cancelled_count_one": "{{count}} отменённый звонок",
"bubble_cancelled_count_few": "{{count}} отменённых звонка",
"bubble_cancelled_count_many": "{{count}} отменённых звонков",
"duration_minutes_seconds": "{{minutes}} мин {{seconds}} сек",
"duration_seconds": "{{seconds}} сек"
"open_call_room": "Открыть чат звонка"
},
"Room": {
"drag_to_close": "Потянуть вверх чтобы закрыть",
@ -454,6 +435,7 @@
"jump_to_latest": "К последним",
"today": "Сегодня",
"yesterday": "Вчера",
"view_reactions": "Реакции",
"read_receipts": "Подтверждения прочтения",
"view_source": "Исходный код",
@ -465,6 +447,7 @@
"reply": "Ответить",
"reply_in_thread": "Ответить в треде",
"edit_message": "Редактировать",
"delete_message": "Удалить сообщение",
"delete_confirm": "Это действие необратимо! Вы уверены, что хотите удалить это сообщение?",
"reason": "Причина",
@ -472,6 +455,7 @@
"delete_error": "Не удалось удалить сообщение! Попробуйте снова.",
"deleting": "Удаление...",
"delete": "Удалить",
"report_message": "Пожаловаться",
"report_desc": "Сообщить о нарушении на сервер, который может уведомить ответственных лиц для принятия мер.",
"report_reason": "Причина",
@ -480,11 +464,13 @@
"reporting": "Отправка...",
"report": "Пожаловаться",
"no_reason": "Причина не указана",
"is_typing": " печатает...",
"and": " и ",
"are_typing": " печатают...",
"others_count": "ещё {{count}}",
"drop_typing": "Скрыть индикатор набора",
"members": "Участники",
"members_count_one": "{{formattedCount}} участник",
"members_count_few": "{{formattedCount}} участника",
@ -503,30 +489,23 @@
"room_settings": "Настройки комнаты",
"jump_to_time": "Перейти к дате",
"leave_room": "Покинуть комнату",
"send_message": "Написать сообщение...",
"send_message_alt_1": "В одну строку или несколько...",
"send_message_alt_2": "Написать в эту минуту...",
"send_message_alt_3": "Не томи, пиши...",
"send_message_alt_4": "Эта строка сама себя не заполнит...",
"send_message_alt_5": "Ну так что?..",
"send_message_alt_6": "Никто не читает плейсхолдеры. Но вы прочитали...",
"send_message_alt_7": "Сюда буквы, пожалуйста...",
"send_message_alt_8": "Вы смотрите на плейсхолдер. Плейсхолдер смотрит на вас...",
"send_message_alt_9": "Поздравляю, вы в 3% людей, читающих плейсхолдеры...",
"send_message_alt_10": "Ну я подожду, подожду...",
"send_message_alt_11": "Только после вас...",
"drop_files": "Перетащите файлы в \"{{name}}\"",
"drag_drop_desc": "Перетащите файлы сюда или нажмите для выбора",
"pinned_messages": "Закреплённые сообщения",
"no_pinned_messages": "Нет закреплённых сообщений",
"no_pinned_messages_desc": "Пользователи с достаточным уровнем прав могут закреплять сообщения через контекстное меню.",
"open": "Открыть",
"failed_to_load": "Не удалось загрузить сообщение!",
"time_label": "Время",
"date_label": "Дата",
"preset": "Пресет",
"beginning": "Начало",
"open_timeline": "Открыть ленту",
"message_deleted": "Сообщение было удалено",
"message_deleted_reason": "Сообщение было удалено. {{reason}}",
"unsupported_message": "Неподдерживаемое сообщение",
@ -536,6 +515,7 @@
"broken_message": "Повреждённое сообщение",
"empty_message": "Пустое сообщение",
"edited": " (изменено)",
"thread_caption": "Тред",
"thread_in_channel_subtitle": "в #{{channel}}",
"thread_close": "Закрыть тред",
@ -558,6 +538,7 @@
"thread_summary_highlight_many": "{{count}} упоминаний",
"thread_summary_highlight_other": "{{count}} упоминания",
"no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате",
"conversation_beginning": "Начало переписки.",
"created_by": "Комната создана <bold>@{{creator}}</bold> {{date}} {{time}}",
"invite_member": "Пригласить",
@ -568,6 +549,7 @@
"leave_room_error": "Не удалось покинуть комнату! {{error}}",
"leaving": "Выход...",
"leave": "Покинуть",
"member_broken": "Некорректное событие участия",
"member_accepted_knock": "<bold>{{sender}}</bold> одобряет вступление <bold>{{user}}</bold>",
"member_invited": "<bold>{{sender}}</bold> приглашает <bold>{{user}}</bold>",
@ -585,7 +567,10 @@
"member_name_removed": "<bold>{{user}}</bold> убирает отображаемое имя",
"member_avatar_changed": "<bold>{{user}}</bold> меняет аватар",
"member_avatar_removed": "<bold>{{user}}</bold> убирает аватар",
"member_no_change": "Событие участия без изменений"
"member_no_change": "Событие участия без изменений",
"member_ended_call": "<bold>{{user}}</bold> больше не в звонке",
"member_joined_call": "<bold>{{user}}</bold> теперь в звонке"
},
"Inbox": {
"invite_title": "Пригласить",
@ -593,15 +578,19 @@
"user_id_placeholder": "@username:server",
"reason_optional": "Причина (необязательно)",
"invite_button": "Пригласить",
"notif_default": "По умолчанию",
"notif_all_messages": "Все сообщения",
"notif_mentions_keywords": "Упоминания и ключевые слова",
"notif_mute": "Без уведомлений",
"unverified_device": "Неподтверждённое устройство",
"unverified_devices": "Неподтверждённые устройства"
},
"Explore": {
"explore_community": "Обзор сообществ",
"add_server": "Добавить сервер",
"add_server_desc": "Укажите имя сервера для обзора публичных сообществ.",
"server_name": "Имя сервера",
@ -609,11 +598,13 @@
"view": "Открыть",
"featured": "Рекомендуемые",
"servers": "Серверы",
"featured_by_client": "Рекомендации клиента",
"featured_by_client_desc": "Подборка публичных комнат и пространств от этого клиента.",
"featured_spaces": "Рекомендуемые пространства",
"featured_rooms": "Рекомендуемые комнаты",
"no_featured": "Рекомендуемых комнат и пространств пока нет.",
"search": "Поиск",
"search_placeholder": "Поиск по ключевому слову",
"clear": "Очистить",
@ -632,11 +623,9 @@
"previous_page": "Предыдущая",
"next_page": "Следующая",
"no_communities": "Сообщества не найдены!",
"space_badge": "Пространство",
"members_count_one": "{{formattedCount}} участник",
"members_count_few": "{{formattedCount}} участника",
"members_count_many": "{{formattedCount}} участников",
"members_count_other": "{{formattedCount}} участника",
"members_count": "{{count}} участников",
"join": "Присоединиться",
"joining": "Вступление…",
"retry": "Повторить",
@ -645,6 +634,7 @@
"view_error": "Подробности",
"cancel": "Отмена"
},
"Create": {
"add_space": "Добавить пространство",
"create_space": "Создать пространство",
@ -652,6 +642,7 @@
"join_with_address": "Присоединиться по адресу",
"join_with_address_desc": "Присоединиться к существующему сообществу.",
"new_space": "Новое пространство",
"access": "Доступ",
"name": "Название",
"topic_optional": "Тема (необязательно)",
@ -663,38 +654,47 @@
"allow_federation_desc": "Пользователи с других серверов смогут присоединиться.",
"create": "Создать",
"rate_limited": "Сервер ограничил ваш запрос на {{minutes}} мин.!",
"access_restricted": "Ограниченный",
"access_restricted_desc": "Могут присоединиться только участники родительского пространства.",
"access_private": "Приватный",
"access_private_desc": "Могут присоединиться только приглашённые.",
"access_public": "Публичный",
"access_public_desc": "Любой, у кого есть адрес, может присоединиться.",
"address_optional": "Адрес (необязательно)",
"address_hint": "Выберите уникальный адрес, чтобы пространство можно было найти.",
"address_taken": "Этот адрес уже занят. Выберите другой.",
"founders": "Основатели",
"founders_desc": "Привилегированные пользователи, назначенные при создании. Они имеют расширенные полномочия; изменить их можно только при обновлении пространства.",
"enter": "Добавить",
"no_suggestions": "Нет предложений",
"no_suggestions_desc": "Введите ID пользователя и нажмите Добавить.",
"version": "Версия",
"versions": "Версии",
"chat_room": "Чат-комната",
"chat_room_desc": "Сообщения, фото и видео.",
"voice_room": "Голосовая комната",
"voice_room_desc": "Голосовые и видеозвонки в реальном времени.",
"new_chat_room": "Новая чат-комната",
"new_voice_room": "Новая голосовая комната",
"existing_space": "Существующее пространство",
"add_room": "Добавить комнату",
"existing_room": "Существующая комната"
},
"RoomSettings": {
"general": "Основные",
"members": "Участники",
"permissions": "Права доступа",
"emojis_stickers": "Эмодзи и стикеры",
"developer_tools": "Инструменты разработчика",
"profile": "Профиль",
"edit": "Редактировать",
"unknown": "Неизвестно",
@ -706,25 +706,30 @@
"topic": "Тема",
"save": "Сохранить",
"cancel": "Отмена",
"options": "Настройки",
"addresses": "Адреса",
"advanced_options": "Дополнительные настройки",
"space_access": "Доступ к пространству",
"room_access": "Доступ к комнате",
"space_access_desc": "Изменить способ вступления в пространство.",
"room_access_desc": "Изменить способ вступления в комнату.",
"join_invite_only": "Только по приглашению",
"join_knock_invite": "Запрос и приглашение",
"join_space_members_or_knock": "Участники пространства или запрос",
"join_space_members": "Участники пространства",
"join_public": "Публичный",
"join_unsupported": "Не поддерживается",
"history_visibility": "Видимость истории сообщений",
"history_visibility_desc": "Изменения видимости истории применяются только к новым сообщениям и не затрагивают существующую историю.",
"visibility_after_invite": "После приглашения",
"visibility_after_join": "После вступления",
"visibility_all_messages": "Все сообщения",
"visibility_all_messages_guests": "Все сообщения (гости)",
"room_encryption": "Шифрование комнаты",
"encryption_enabled_desc": "Сообщения в этой комнате защищены сквозным шифрованием.",
"encryption_disabled_desc": "После включения шифрование невозможно отключить!",
@ -733,9 +738,11 @@
"enable_encryption": "Включить шифрование",
"enable_encryption_confirm": "Вы уверены? После включения шифрование невозможно отключить!",
"enable_e2e_encryption": "Включить E2E-шифрование",
"publish_to_directory": "Показывать в поиске",
"publish_space_desc": "Сделать пространство видимым в общем списке, чтобы другие пользователи могли его найти.",
"publish_room_desc": "Сделать комнату видимой в общем списке, чтобы другие пользователи могли её найти.",
"published_addresses": "Опубликованные адреса",
"published_addresses_desc": "Если доступ <b>публичный</b>, опубликованные адреса будут использоваться для присоединения.",
"no_addresses": "Нет адресов",
@ -749,11 +756,13 @@
"publish": "Опубликовать",
"delete": "Удалить",
"selected_count": "Выбрано: {{count}}",
"local_addresses": "Локальные адреса",
"local_addresses_desc": "Задайте локальный адрес, чтобы пользователи могли присоединиться через ваш сервер.",
"collapse": "Свернуть",
"expand": "Развернуть",
"loading": "Загрузка...",
"space_upgrade": "Обновление пространства",
"room_upgrade": "Обновление комнаты",
"upgrade": "Обновить",
@ -767,21 +776,25 @@
"old_room": "Старая комната",
"open_new_space": "Открыть новое пространство",
"open_new_room": "Открыть новую комнату",
"members_count": "{{count}} участников",
"search": "Поиск",
"no_results": "Ничего не найдено",
"results_count": "{{count}} результатов",
"scroll_to_top": "Наверх",
"no_membership_members": "Нет участников «{{filter}}»",
"filter_joined": "Вступившие",
"filter_invited": "Приглашённые",
"filter_left": "Вышедшие",
"filter_kicked": "Исключённые",
"filter_banned": "Забаненные",
"sort_a_to_z": "А — Я",
"sort_z_to_a": "Я — А",
"sort_newest": "Новые",
"sort_oldest": "Старые",
"perm_messages": "Сообщения",
"perm_send_messages": "Отправка сообщений",
"perm_send_stickers": "Отправка стикеров",
@ -814,10 +827,12 @@
"perm_manage_emojis_stickers": "Управление эмодзи и стикерами",
"perm_change_server_acls": "Изменение ACL серверов",
"perm_modify_widgets": "Изменение виджетов",
"founders": "Основатели",
"founders_desc": "Основатели имеют все права. Изменить их состав можно только при обновлении комнаты.",
"power_levels": "Уровни власти",
"power_levels_desc": "Управление и настройка уровней власти для пользователей.",
"new_power_level": "Новый уровень власти",
"power_level_placeholder": "Бот",
"new_power_level_desc": "Создать новый уровень власти.",
@ -834,9 +849,11 @@
"failed_to_apply": "Не удалось применить изменения! Попробуйте ещё раз.",
"apply_changes": "Применить изменения",
"and_above": "и выше",
"users": "Пользователи",
"default_power": "Уровень по умолчанию",
"default_power_desc": "Уровень власти по умолчанию для всех пользователей.",
"packs": "Паки",
"new_pack": "Новый пак",
"new_pack_desc": "Добавьте свой пак эмодзи и стикеров для использования в комнате.",
@ -845,6 +862,7 @@
"view": "Открыть",
"failed_to_remove_packs": "Не удалось удалить паки! Попробуйте ещё раз.",
"delete_selected_packs": "Удалить выбранные паки. (Выбрано: {{count}})",
"enable_developer_tools": "Включить инструменты разработчика",
"room_id": "ID комнаты",
"room_id_desc": "Скопировать ID комнаты в буфер обмена.",
@ -867,6 +885,7 @@
"message_event_type": "Тип события сообщения",
"send": "Отправить",
"state_key_optional": "State Key (необязательно)",
"pack": "Пак",
"images_usage": "Использование изображений",
"images_usage_desc": "Выберите, как используются изображения: как эмодзи, как стикеры или как и то, и другое.",
@ -881,6 +900,7 @@
"usage_both": "Оба",
"usage_sticker": "Стикер",
"usage_emoji": "Эмодзи",
"power_goku": "Гоку",
"power_manager": "Менеджер",
"power_founder": "Основатель",
@ -890,6 +910,7 @@
"power_muted": "Без голоса",
"power_team": "Команда"
},
"Push": {
"new_message": "Новое сообщение",
"new_messages": "Новые сообщения",
@ -900,19 +921,7 @@
"invite_body": "{{inviter}} приглашает вас в {{roomName}}",
"invite_body_no_room": "{{inviter}} приглашает вас в комнату",
"invite_body_no_inviter": "Приглашение в {{roomName}}",
"invite_body_generic": "Новое приглашение",
"missed_call": "Пропущенный звонок",
"missed_call_body": "{{caller}} пытался вам дозвониться",
"channel_group": "Чаты",
"channel_dm": "Личные сообщения",
"channel_dm_description": "Новые сообщения из личных переписок",
"channel_group_room": "Групповые чаты",
"channel_group_room_description": "Новые сообщения из групповых чатов и каналов",
"self_name": "Я",
"action_mark_as_read": "Прочитано",
"action_reply": "Ответить",
"reply_hint": "Ответ…",
"reply_failed": "Не удалось отправить ответ"
"invite_body_generic": "Новое приглашение"
},
"Bots": {
"not_connected_title": "{{name}} не подключён",
@ -987,15 +996,5 @@
"copy_server": "Скопировать сервер",
"explore_community": "Открыть сервер",
"open_in_browser": "Открыть в браузере"
},
"Share": {
"share_text": "Готово к пересылке: текст",
"share_image": "Готово к пересылке: изображение",
"share_video": "Готово к пересылке: видео",
"share_audio": "Готово к пересылке: аудио",
"share_file": "Готово к пересылке: {{name}}",
"share_files": "Готово к пересылке: {{count}} файлов",
"tap_chat_to_send": "Откройте чат, чтобы отправить",
"cancel": "Отменить"
}
}

View file

@ -7,8 +7,8 @@
"display": "standalone",
"orientation": "portrait",
"start_url": "./",
"background_color": "#0d0e11",
"theme_color": "#0d0e11",
"background_color": "#000",
"theme_color": "#000",
"icons": [
{
"src": "./public/android/vojo.svg",

View file

@ -1,400 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0d0e11" />
<meta name="robots" content="index,follow" />
<title>Vojo — Privacy Policy</title>
<style>
:root {
--bg: #0d0e11;
--panel: #181a20;
--surface: #21232b;
--text: #e6e6e9;
--text-strong: #f4f4f6;
--muted: rgba(230, 230, 233, 0.62);
--faint: rgba(230, 230, 233, 0.38);
--divider: rgba(255, 255, 255, 0.08);
--fleet: #9580ff;
--fleet-soft: #a59cff;
color-scheme: dark;
}
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, "SF Pro Text", "Inter", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body {
min-height: 100vh;
line-height: 1.7;
font-size: 16px;
}
.frame {
max-width: 680px;
margin: 0 auto;
padding: 56px 28px 120px;
}
header.doc {
padding-bottom: 28px;
margin-bottom: 40px;
border-bottom: 1px solid var(--divider);
}
.brand {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 22px;
color: var(--muted);
font-size: 13px;
letter-spacing: 0.2px;
}
.brand-dot {
width: 8px;
height: 8px;
border-radius: 99px;
background: var(--fleet);
}
.brand-name { color: var(--text-strong); font-weight: 600; }
.brand-sep { color: var(--faint); }
h1 {
font-size: 34px;
line-height: 1.15;
font-weight: 600;
margin: 0 0 10px;
letter-spacing: -0.6px;
color: var(--text-strong);
}
.effective { color: var(--muted); font-size: 14px; margin: 0; }
.lang-switch {
display: inline-flex;
margin-top: 24px;
font-size: 13px;
color: var(--muted);
}
.lang-switch button {
appearance: none;
-webkit-appearance: none;
background: transparent;
border: 0;
padding: 4px 0;
font: inherit;
color: var(--muted);
cursor: pointer;
transition: color .15s ease;
}
.lang-switch button:hover { color: var(--text); }
.lang-switch button[aria-pressed="true"] {
color: var(--text-strong);
font-weight: 600;
}
.lang-switch .sep {
padding: 0 10px;
color: var(--faint);
user-select: none;
}
h2 {
font-size: 20px;
font-weight: 600;
margin: 44px 0 12px;
letter-spacing: -0.2px;
color: var(--text-strong);
scroll-margin-top: 24px;
}
p { margin: 0 0 14px; color: var(--text); }
ul {
margin: 0 0 18px;
padding-left: 22px;
}
ul li { margin: 8px 0; padding-left: 4px; }
ul li::marker { color: var(--faint); }
ul li b, p b { color: var(--text-strong); font-weight: 600; }
a {
color: var(--fleet-soft);
text-decoration: none;
border-bottom: 1px solid rgba(165, 156, 255, 0.35);
transition: color .15s ease, border-color .15s ease;
}
a:hover {
color: #c0b9ff;
border-bottom-color: rgba(192, 185, 255, 0.7);
}
section[hidden] { display: none; }
section > h2:first-of-type { margin-top: 0; }
footer.doc {
margin-top: 64px;
padding-top: 24px;
border-top: 1px solid var(--divider);
font-size: 13px;
color: var(--faint);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
footer.doc .copy {
font-family: ui-monospace, "JetBrains Mono", monospace;
}
footer.doc a { color: var(--muted); border-bottom-color: transparent; }
footer.doc a:hover { color: var(--text); border-bottom-color: var(--divider); }
@media (max-width: 560px) {
.frame { padding: 36px 20px 96px; }
h1 { font-size: 28px; }
h2 { font-size: 18px; margin: 36px 0 10px; }
body { font-size: 15.5px; line-height: 1.65; }
}
</style>
</head>
<body>
<div class="frame">
<header class="doc">
<div class="brand">
<span class="brand-dot" aria-hidden="true"></span>
<span class="brand-name">Vojo</span>
<span class="brand-sep">·</span>
<span>Legal</span>
</div>
<h1 data-i18n-h1>Privacy Policy</h1>
<p class="effective" data-i18n-effective>Effective 13 May 2026</p>
<div class="lang-switch" role="group" aria-label="Language">
<button type="button" data-lang="en" aria-pressed="true">English</button>
<span class="sep" aria-hidden="true">/</span>
<button type="button" data-lang="ru" aria-pressed="false">Русский</button>
</div>
</header>
<section lang="en" data-lang="en">
<p>This is the privacy policy for <b>Vojo</b>, a chat app built on the open
<a href="https://matrix.org" rel="noopener">Matrix</a> protocol. It's maintained by
the Vojo Project, an independent developer. If you have questions about anything
here, write to <a href="mailto:vojochatdev@gmail.com">vojochatdev@gmail.com</a>.</p>
<p>We try to keep this short and readable. If something is unclear, ask.</p>
<h2>How Vojo works, briefly</h2>
<p>Your messages, profile and rooms live on a Matrix server. By default that's
<code>vojo.chat</code>, which we run. You can also sign in to any other Matrix
server you trust — if you do, the operator of that server is the one holding your
data, not us.</p>
<h2>What we hold and what we use it for</h2>
<p>To make the app work we keep the obvious things: your account, the messages and
rooms you send and receive, the media you share, and basic technical data (IP
address, connection times) generated when your device talks to our servers. Your
device also caches messages and keys locally so you can read them offline and stay
signed in.</p>
<p>Direct conversations are end-to-end encrypted by default. In an encrypted room
we can see who's talking to whom and when, but not what they're saying. In an
unencrypted room we see the content too.</p>
<p>Voice calls are encrypted between participants. When your device can't reach the
other side directly, the audio is relayed through our infrastructure on its way
through — we don't record it and we don't keep it.</p>
<p>We use this data to run the service: deliver messages, sync your devices, ring
your phone for incoming calls, keep limited logs to fight abuse and spam. That's
the whole list. No advertising, no analytics, no resale, no profiling.</p>
<h2>Who else is involved</h2>
<ul>
<li><b>Our hosting provider.</b> The
<a href="https://www.hostinger.com" rel="noopener">Hostinger</a>
infrastructure carrying <code>vojo.chat</code> sits in the European Union.</li>
<li><b>Google's push service.</b> Push notifications go through Google so your
phone can wake up and ring or buzz. For end-to-end encrypted chats the
notification only carries the routing info needed to fetch the message
locally — the content stays on your Matrix server. For unencrypted chats
Google may see a short preview (who, where, snippet). This is the only
routine reason data leaves the EU; we rely on the Standard Contractual
Clauses for that transfer.</li>
<li><b>Bot checks.</b> Signing up, and a couple of optional features, briefly
load a third-party "are you a human" check. That provider sees your
interaction with the puzzle and is governed by its own privacy policy.</li>
<li><b>Optional bridges.</b> If you choose to connect Telegram, Discord or
WhatsApp through Vojo, your messages with those networks have to pass
through bridge infrastructure we run, and the network itself sees them
too. None of this turns on unless you opt in.</li>
</ul>
<h2>Permissions on your phone</h2>
<p>On Android we ask for: the microphone (only used during calls); notifications
(so we can show you messages and ring for calls); permission to show calls over
the lock screen and to keep a call running with the screen off; and network access.
That's it. We don't touch your address book, photo library, SMS, precise location
or call log.</p>
<h2>How long we keep things</h2>
<p>Your messages and account stay on your Matrix server until you delete them or
ask us to deactivate the account. Deletion is processed within about thirty days.
Server access logs are kept for no more than thirty days and then rotated out.</p>
<p>Data cached on your device goes away when you uninstall Vojo or clear its data
in your phone's settings. Signing out ends your session but doesn't always scrub
every cached message immediately — the cleanest reset is an uninstall.</p>
<h2>Your rights</h2>
<p>If you live in the EU/EEA (and in many other places the law works similarly),
you can ask us to show you what we hold, fix something that's wrong, delete your
data, hand it over in a portable form, or stop a particular use. You can also
withdraw any consent you've given for optional features, and complain to your
local data-protection authority if you think we're handling things badly. Email
the address at the top and we'll take it from there.</p>
<h2>Kids, changes, contact</h2>
<p>Vojo isn't aimed at anyone under 16, and we don't knowingly collect data from
children. If we change this policy in a way that actually affects you, we'll
update the date above and try to flag it inside the app. The current version
always lives at <a href="https://vojo.chat/privacy">vojo.chat/privacy</a>. For
anything else: <a href="mailto:vojochatdev@gmail.com">vojochatdev@gmail.com</a>.</p>
</section>
<section lang="ru" data-lang="ru" hidden>
<p>Это политика конфиденциальности <b>Vojo</b> — чат-приложения на открытом
протоколе <a href="https://matrix.org" rel="noopener">Matrix</a>. Его поддерживает
проект Vojo, независимый разработчик. Если по тексту возникают вопросы — пишите
на <a href="mailto:vojochatdev@gmail.com">vojochatdev@gmail.com</a>.</p>
<p>Постарались уложиться в нормальный читаемый объём. Если что-то непонятно —
спрашивайте.</p>
<h2>Как устроено</h2>
<p>Ваши сообщения, профиль и список комнат живут на Matrix-сервере. По умолчанию
это <code>vojo.chat</code>, который держим мы. Вы можете войти на любой другой
Matrix-сервер, которому доверяете — если так, оператором ваших данных будет тот
сервер, не мы.</p>
<h2>Что у нас лежит и зачем</h2>
<p>Чтобы приложение работало, у нас лежат предсказуемые вещи: ваш аккаунт, ваши
сообщения и комнаты, медиа, и базовые технические данные (IP, время запросов),
которые возникают, когда устройство разговаривает с нашими серверами. На самом
устройстве лежит локальный кэш сообщений и ключей — чтобы можно было читать
офлайн и не входить заново каждый раз.</p>
<p>Личные переписки по умолчанию защищены end-to-end шифрованием. В зашифрованной
комнате мы видим, кто кому пишет и когда, но не видим, что именно. В
незашифрованных комнатах мы видим и содержимое.</p>
<p>Голосовые звонки шифруются между участниками. Если устройство не может
дотянуться до собеседника напрямую, аудио ретранслируется через нашу
инфраструктуру по пути — мы его не записываем и не храним.</p>
<p>Все эти данные мы используем для того, чтобы сервис работал: доставка
сообщений, синхронизация устройств, входящие звонки, ограниченные логи для борьбы
со спамом и злоупотреблениями. Это весь список. Никакой рекламы, аналитики,
перепродажи или профилирования.</p>
<h2>Кто ещё в этом участвует</h2>
<ul>
<li><b>Наш хостинг.</b>
<a href="https://www.hostinger.com" rel="noopener">Hostinger</a> держит
инфраструктуру <code>vojo.chat</code> в Европейском союзе.</li>
<li><b>Push-сервис Google.</b> Push-уведомления идут через Google, чтобы
телефон проснулся и зазвонил. Для зашифрованных переписок уведомление
несёт только маршрутную информацию, нужную для того чтобы подгрузить
сообщение локально — содержимое остаётся на Matrix-сервере. Для
незашифрованных Google может видеть короткий предпросмотр (кто, где,
фрагмент). Это единственный регулярный случай, когда данные выходят за
пределы ЕС; передача идёт по Стандартным договорным условиям Европейской
комиссии.</li>
<li><b>Капча.</b> При регистрации и в паре дополнительных функций ненадолго
подгружается сторонняя проверка «вы не робот». Этот провайдер видит ваше
взаимодействие с капчей и регулируется собственной политикой.</li>
<li><b>Опциональные мосты.</b> Если вы решите подключить Telegram, Discord
или WhatsApp через Vojo, сообщения с этими сетями неизбежно проходят
через мостовую инфраструктуру, которую держим мы, и сама сеть тоже их
видит. Без вашего явного действия это не включается.</li>
</ul>
<h2>Разрешения на телефоне</h2>
<p>На Android приложение просит: микрофон (используется только во время звонка);
уведомления (чтобы показывать сообщения и звонки); право показывать звонок поверх
локскрина и держать его при выключенном экране; доступ к сети. И всё. Мы не
трогаем адресную книгу, фотогалерею, SMS, точную геолокацию и журнал вызовов.</p>
<h2>Сколько мы это храним</h2>
<p>Сообщения и аккаунт лежат на Matrix-сервере до тех пор, пока вы их не
удалите или не попросите деактивировать аккаунт. Удаление обрабатывается в
течение тридцати дней. Журналы доступа на сервере хранятся не более тридцати
дней, затем уходят на ротацию.</p>
<p>Кэш на устройстве пропадает, когда вы удаляете Vojo или очищаете его данные в
настройках телефона. Выход из аккаунта прекращает сессию, но не всегда подчищает
весь кэш сразу — самый чистый способ обнулиться — это переустановка.</p>
<h2>Ваши права</h2>
<p>Если вы живёте в ЕС/ЕЭЗ (а во многих других местах закон работает похоже), вы
можете попросить нас показать, что у нас лежит, поправить неверное, удалить,
отдать в переносимом виде, остановить конкретное использование. Можно отозвать
согласие на дополнительные функции и пожаловаться в местный надзорный орган, если
кажется, что мы что-то делаем не так. Напишите на адрес сверху, и пойдём
разбираться.</p>
<h2>Дети, изменения, контакты</h2>
<p>Vojo не рассчитан на людей младше 16 лет, и мы сознательно не собираем данные
детей. Если что-то поменяется так, что это вас реально касается, обновим дату в
начале и постараемся отметить это в самом приложении. Текущая версия всегда
лежит по адресу <a href="https://vojo.chat/privacy">vojo.chat/privacy</a>. По
любым другим вопросам:
<a href="mailto:vojochatdev@gmail.com">vojochatdev@gmail.com</a>.</p>
</section>
<footer class="doc">
<span class="copy">© Vojo Project · 2026</span>
<a href="https://vojo.chat">vojo.chat</a>
</footer>
</div>
<script>
(function () {
var buttons = document.querySelectorAll('.lang-switch button');
var sections = document.querySelectorAll('section[data-lang]');
var h1 = document.querySelector('[data-i18n-h1]');
var eff = document.querySelector('[data-i18n-effective]');
var H1 = { en: 'Privacy Policy', ru: 'Политика конфиденциальности' };
var EFF = { en: 'Effective 13 May 2026', ru: 'Действует с 13 мая 2026 г.' };
function setLang(lang) {
buttons.forEach(function (b) {
b.setAttribute('aria-pressed', String(b.dataset.lang === lang));
});
sections.forEach(function (s) {
s.hidden = s.dataset.lang !== lang;
});
if (h1 && H1[lang]) h1.textContent = H1[lang];
if (eff && EFF[lang]) eff.textContent = EFF[lang];
document.documentElement.lang = lang;
document.title = (lang === 'ru' ? 'Vojo — Политика конфиденциальности' : 'Vojo — Privacy Policy');
try { localStorage.setItem('vojo-privacy-lang', lang); } catch (e) {}
}
buttons.forEach(function (b) {
b.addEventListener('click', function () { setLang(b.dataset.lang); });
});
var stored = null;
try { stored = localStorage.getItem('vojo-privacy-lang'); } catch (e) {}
setLang(stored || 'en');
})();
</script>
</body>
</html>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

View file

@ -51,18 +51,6 @@ const ANDROID_KEYS = [
'invite_body_no_room',
'invite_body_no_inviter',
'invite_body_generic',
'missed_call',
'missed_call_body',
'channel_group',
'channel_dm',
'channel_dm_description',
'channel_group_room',
'channel_group_room_description',
'self_name',
'action_mark_as_read',
'action_reply',
'reply_hint',
'reply_failed',
];
// i18next uses named placeholders ({{inviter}}); Android string resources
@ -71,13 +59,9 @@ const ANDROID_KEYS = [
// inviter, roomName) always passes inviter in position 1, roomName in
// position 2, regardless of how the translators order them in the JSON.
// Adding a new placeholder: add it here AND update PushStrings accordingly.
// `caller` reuses position 1: it only appears in missed_call_body, which
// has no other placeholders, so the position assignment is keyed per-key
// in practice — the table just enumerates every placeholder name we accept.
const PLACEHOLDER_POSITIONS = {
inviter: 1,
roomName: 2,
caller: 1,
};
const LANGS = {
@ -131,7 +115,7 @@ function verifyParity(bundles) {
const locales = Object.keys(bundles);
const [first, ...rest] = locales;
const firstKeys = new Set(Object.keys(bundles[first]));
rest.forEach((locale) => {
for (const locale of rest) {
const keys = new Set(Object.keys(bundles[locale]));
const missingInOther = [...firstKeys].filter((k) => !keys.has(k));
const extraInOther = [...keys].filter((k) => !firstKeys.has(k));
@ -142,13 +126,13 @@ function verifyParity(bundles) {
` Extra in ${locale}: ${JSON.stringify(extraInOther)}`
);
}
});
ANDROID_KEYS.forEach((key) => {
locales.forEach((locale) => {
}
for (const key of ANDROID_KEYS) {
for (const locale of locales) {
if (typeof bundles[locale][key] !== 'string') {
throw new Error(`Push.${key} missing or non-string in ${locale}.json`);
}
});
}
// Placeholder tokens must match across locales for any given key —
// a translator adding {{user}} on one side silently produces
// literal-curly-brace output on the other surface.
@ -162,7 +146,7 @@ function verifyParity(bundles) {
return { locale, tokens };
});
const baseline = tokenSets[0];
tokenSets.slice(1).forEach((entry) => {
for (const entry of tokenSets.slice(1)) {
const baselineArr = [...baseline.tokens].sort();
const entryArr = [...entry.tokens].sort();
if (baselineArr.length !== entryArr.length || baselineArr.some((t, i) => t !== entryArr[i])) {
@ -172,8 +156,8 @@ function verifyParity(bundles) {
`${entry.locale}=${JSON.stringify(entryArr)}`
);
}
});
});
}
}
}
function emitResource(locale, bundle, resDir) {
@ -186,12 +170,12 @@ function emitResource(locale, bundle, resDir) {
'-->',
'<resources>',
];
ANDROID_KEYS.forEach((key) => {
for (const key of ANDROID_KEYS) {
const raw = bundle[key];
const { text, placeholders } = convertPlaceholders(raw, locale, key);
const formattedAttr = placeholders.size > 0 ? ' formatted="true"' : '';
lines.push(` <string name="push_${key}"${formattedAttr}>${xmlEscape(text)}</string>`);
});
}
lines.push('</resources>');
lines.push('');
const outPath = path.join(resDir, LANGS[locale], 'push_strings.xml');
@ -207,15 +191,15 @@ function main() {
}
const resDir = outIdx !== -1 ? path.resolve(process.argv[outIdx + 1]) : DEFAULT_OUT;
const bundles = Object.keys(LANGS).reduce((acc, locale) => {
acc[locale] = readBundle(locale);
return acc;
}, {});
const bundles = {};
for (const locale of Object.keys(LANGS)) {
bundles[locale] = readBundle(locale);
}
verifyParity(bundles);
Object.keys(LANGS).forEach((locale) => {
for (const locale of Object.keys(LANGS)) {
const outPath = emitResource(locale, bundles[locale], resDir);
process.stdout.write(` wrote ${path.relative(ROOT, outPath)}\n`);
});
}
}
try {

View file

@ -36,7 +36,7 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
>
{stageToComplete.type === AuthType.Password && (
<PasswordStage
userId={mx.getSafeUserId()}
userId={mx.getUserId()!}
stageData={stageToComplete}
onCancel={onCancel}
submitAuthDict={action}

View file

@ -42,7 +42,7 @@ function makeUIAAction<T>(
authData: IAuthData,
performAction: PerformAction<T>,
resolve: (data: T) => void,
reject: (error?: unknown) => void
reject: (error?: any) => void
): UIAAction<T> {
const action: UIAAction<T> = {
authData,

View file

@ -30,7 +30,7 @@ export const ImageOverlay = as<'div', ImageOverlayProps>(
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
onContextMenu={(evt: any) => evt.stopPropagation()}
>
{renderViewer({
src,

View file

@ -20,17 +20,7 @@ export function Modal500({ requestClose, children }: Modal500Props) {
escapeDeactivates: stopPropagation,
}}
>
<Modal
size="500"
variant="Background"
// Reset `--vojo-safe-top` for everything mounted inside the
// dialog. The Android status-bar inset is reserved by each
// page header's `padding-top: var(--vojo-safe-top)` for
// top-of-screen surfaces — but a centred 500px modal sits
// away from the screen edge, and the same padding inside it
// just adds dead space above its header.
style={{ ['--vojo-safe-top' as string]: '0px' }}
>
<Modal size="500" variant="Background">
{/* PageRoot rendered inside the dialog (Settings,
SpaceSettings, RoomSettings) would otherwise pick up
the web horseshoe layout void column + rounded

View file

@ -65,12 +65,6 @@ type RenderMessageContentProps = {
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
outlineAttachment?: boolean;
// Threaded into `ImageContent` so its onClick can open the new
// atom-driven horseshoe media viewer instead of the legacy
// `<Overlay>` modal. Set by `RoomTimeline`; non-Room callers
// (pin-menu, message search) leave it undefined and stay on the
// legacy modal until they're migrated.
eventId?: string;
};
export function RenderMessageContent({
displayName,
@ -84,7 +78,6 @@ export function RenderMessageContent({
htmlReactParserOptions,
linkifyOpts,
outlineAttachment,
eventId,
}: RenderMessageContentProps) {
const streamMedia = useStreamMediaContext();
const renderUrlsPreview = (urls: string[]) => {
@ -226,7 +219,6 @@ export function RenderMessageContent({
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
eventId={eventId}
renderImage={(p) => <Image {...p} loading="lazy" decoding="async" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
@ -266,7 +258,6 @@ export function RenderMessageContent({
body={body}
info={info}
{...props}
eventId={eventId}
renderThumbnail={
mediaAutoLoad
? () => (

View file

@ -39,8 +39,6 @@ export function SecretStorageRecoveryPassphrase({
bits
);
// matrix-js-sdk wants SecretStorageKeyDescriptionAesV1; our local type is structurally compatible but distinct.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
if (!match) {
@ -133,8 +131,6 @@ export function SecretStorageRecoveryKey({
async (recoveryKey) => {
const decodedRecoveryKey = decodeRecoveryKey(recoveryKey);
// matrix-js-sdk wants SecretStorageKeyDescriptionAesV1; our local type is structurally compatible but distinct.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
if (!match) {

View file

@ -34,9 +34,6 @@ export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
try {
validatedAuthMetadata = validateAuthMetadata(authMetadata);
} catch (e) {
// Auth-metadata parsing failure is non-fatal; the client falls
// back to legacy `.well-known` discovery. Surface to dev console.
// eslint-disable-next-line no-console
console.error(e);
}

View file

@ -6,7 +6,6 @@ import {
RestrictedAllowType,
Room,
} from 'matrix-js-sdk';
import type { StateEvents } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { RoomType, StateEvent } from '../../../types/matrix/room';
import { getViaServers } from '../../plugins/via-servers';
@ -18,7 +17,7 @@ export const createRoomCreationContent = (
allowFederation: boolean,
additionalCreators: string[] | undefined
): object => {
const content: Record<string, unknown> = {};
const content: Record<string, any> = {};
if (typeof type === 'string') {
content.type = type;
}
@ -153,11 +152,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
if (data.parent) {
await mx.sendStateEvent(
data.parent.roomId,
StateEvent.SpaceChild as keyof StateEvents,
StateEvent.SpaceChild as any,
{
auto_join: false,
suggested: false,
via: [getMxIdServer(mx.getSafeUserId()) ?? ''],
via: [getMxIdServer(mx.getUserId() ?? '') ?? ''],
},
result.room_id
);

View file

@ -24,7 +24,7 @@ type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
const roomAliasFromQueryText = (mx: MatrixClient, text: string) =>
isRoomAlias(`#${text}`)
? `#${text}`
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getSafeUserId())}`;
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
function UnknownRoomMentionItem({
query,

View file

@ -26,7 +26,7 @@ type MentionAutoCompleteHandler = (userId: string, name: string) => void;
const userIdFromQueryText = (mx: MatrixClient, text: string) =>
isUserId(`@${text}`)
? `@${text}`
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getSafeUserId())}`;
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
function UnknownMentionItem({
userId,
@ -92,7 +92,7 @@ export function UserMentionAutocomplete({
}: UserMentionAutocompleteProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { roomId } = room;
const roomId: string = room.roomId!;
const roomAliasOrId = room.getCanonicalAlias() || roomId;
const members = useRoomMembers(mx, roomId);

View file

@ -79,7 +79,7 @@ export const EventReaders = as<'div', EventReadersProps>(
key={readerId}
style={{ padding: `0 ${config.space.S200}` }}
radii="400"
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
onClick={(event) => {
openProfile(
room.roomId,
space?.roomId,

View file

@ -17,7 +17,7 @@ type RoomImagePackProps = {
export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
const mx = useMatrixClient();
const userId = mx.getSafeUserId();
const userId = mx.getUserId()!;
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);

View file

@ -8,7 +8,7 @@ import { useUserImagePack } from '../../hooks/useImagePacks';
export function UserImagePack() {
const mx = useMatrixClient();
const defaultPack = useMemo(() => new ImagePack(mx.getSafeUserId(), {}, undefined), [mx]);
const defaultPack = useMemo(() => new ImagePack(mx.getUserId() ?? '', {}, undefined), [mx]);
const imagePack = useUserImagePack();
const handleUpdate = useCallback(

View file

@ -0,0 +1,133 @@
import React, { FormEventHandler, useState } from 'react';
import { useTranslation } from 'react-i18next';
import FocusTrap from 'focus-trap-react';
import {
Dialog,
Overlay,
OverlayCenter,
OverlayBackdrop,
Header,
config,
Box,
Text,
IconButton,
Icon,
Icons,
Button,
Input,
color,
} from 'folds';
import { stopPropagation } from '../../utils/keyboard';
import { isRoomAlias, isRoomId } from '../../utils/matrix';
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to';
import { tryDecodeURIComponent } from '../../utils/dom';
type JoinAddressProps = {
onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void;
onCancel: () => void;
};
export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
const { t } = useTranslation();
const [invalid, setInvalid] = useState(false);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
setInvalid(false);
const target = evt.target as HTMLFormElement | undefined;
const addressInput = target?.addressInput as HTMLInputElement | undefined;
const address = addressInput?.value.trim();
if (!address) return;
if (isRoomId(address) || isRoomAlias(address)) {
onOpen(address);
return;
}
if (testMatrixTo(address)) {
const decodedAddress = tryDecodeURIComponent(address);
const toRoom = parseMatrixToRoom(decodedAddress);
if (toRoom) {
onOpen(toRoom.roomIdOrAlias, toRoom.viaServers);
return;
}
const toEvent = parseMatrixToRoomEvent(decodedAddress);
if (toEvent) {
onOpen(toEvent.roomIdOrAlias, toEvent.viaServers, toEvent.eventId);
return;
}
}
setInvalid(true);
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onCancel,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">{t('Home.join_with_address')}</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box
as="form"
onSubmit={handleSubmit}
style={{ padding: config.space.S400, paddingTop: 0 }}
direction="Column"
gap="400"
>
<Box direction="Column" gap="200">
<Text priority="400" size="T300">
{t('Home.join_address_desc')}
</Text>
<Text as="ul" size="T200" priority="300" style={{ paddingLeft: config.space.S400 }}>
<li>#community:server</li>
<li>https://matrix.to/#/#community:server</li>
<li>https://matrix.to/#/!xYzAj?via=server</li>
</Text>
</Box>
<Box direction="Column" gap="100">
<Text size="L400">{t('Home.address')}</Text>
<Input
size="500"
autoFocus
name="addressInput"
variant="Background"
placeholder="#community:server"
required
/>
{invalid && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{t('Home.invalid_address')}</b>
</Text>
)}
</Box>
<Button type="submit" variant="Primary">
<Text size="B400">{t('Home.open')}</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View file

@ -0,0 +1 @@
export * from './JoinAddressPrompt';

View file

@ -32,8 +32,6 @@ function computeBoxStyle(naturalW?: number, naturalH?: number): React.CSSPropert
// `object-fit: cover` (image) / `contain` (video) on the inner element.
const naturalAspect = naturalW && naturalH ? naturalW / naturalH : NaN;
if (
!naturalW ||
!naturalH ||
!Number.isFinite(naturalAspect) ||
naturalAspect < STREAM_MEDIA_MIN_ASPECT ||
naturalAspect > STREAM_MEDIA_MAX_ASPECT
@ -41,10 +39,10 @@ function computeBoxStyle(naturalW?: number, naturalH?: number): React.CSSPropert
return { width: toRem(STREAM_MEDIA_MAX_DIM), height: toRem(STREAM_MEDIA_MAX_DIM) };
}
if (naturalAspect >= 1) {
const w = Math.min(STREAM_MEDIA_MAX_DIM, naturalW);
const w = Math.min(STREAM_MEDIA_MAX_DIM, naturalW!);
return { width: toRem(w), height: toRem(w / naturalAspect) };
}
const h = Math.min(STREAM_MEDIA_MAX_DIM, naturalH);
const h = Math.min(STREAM_MEDIA_MAX_DIM, naturalH!);
return { width: toRem(h * naturalAspect), height: toRem(h) };
}

View file

@ -114,7 +114,7 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
onContextMenu={(evt: any) => evt.stopPropagation()}
>
{renderViewer({
name: body,
@ -203,7 +203,7 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
onContextMenu={(evt: any) => evt.stopPropagation()}
>
{renderViewer({
name: body,

View file

@ -31,8 +31,6 @@ import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../util
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
import { validBlurHash } from '../../../utils/blurHash';
import { useMediaViewerHost } from '../../../features/room/mediaViewerHostContext';
import { useOpenMediaViewer } from '../../../state/hooks/mediaViewer';
type RenderViewerProps = {
src: string;
@ -46,17 +44,7 @@ type RenderImageProps = {
onLoad: () => void;
onError: () => void;
onClick: () => void;
onKeyDown: (e: React.KeyboardEvent<HTMLImageElement>) => void;
tabIndex: number;
// `role="button"` so assistive tech announces the clickable
// image as a button rather than a plain image. Paired with
// `aria-label` and an Enter/Space `onKeyDown` to make the
// affordance keyboard-activatable per WAI-ARIA. Element-Web
// wraps in `<AccessibleButton>` — we keep the bare `<img>` to
// avoid relayout, which works because folds' `Image` is
// `as<'img'>` and just spreads these props onto the DOM node.
role: 'button';
'aria-label': string;
};
export type ImageContentProps = {
body: string;
@ -67,13 +55,6 @@ export type ImageContentProps = {
autoPlay?: boolean;
markedAsSpoiler?: boolean;
spoilerReason?: string;
// When provided AND the `MediaViewerHostContext` is non-null,
// clicking the thumbnail opens the atom-driven horseshoe viewer
// (mobile bottom-up sheet / desktop right pane) instead of the
// legacy full-screen `<Overlay>` viewer. Non-Room surfaces
// (pin-menu, message search) leave the host context as `null` and
// therefore keep the legacy modal even if they pass `eventId`.
eventId?: string;
renderViewer: (props: RenderViewerProps) => ReactNode;
renderImage: (props: RenderImageProps) => ReactNode;
};
@ -89,7 +70,6 @@ export const ImageContent = as<'div', ImageContentProps>(
autoPlay,
markedAsSpoiler,
spoilerReason,
eventId,
renderViewer,
renderImage,
...props
@ -99,37 +79,12 @@ export const ImageContent = as<'div', ImageContentProps>(
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
const host = useMediaViewerHost();
const openMediaViewer = useOpenMediaViewer();
const useAtomViewer = !!(host && eventId);
const [load, setLoad] = useState(false);
const [error, setError] = useState(false);
const [viewer, setViewer] = useState(false);
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const handleOpen = () => {
if (useAtomViewer && host && eventId) {
// The viewer body re-resolves + decrypts the media itself,
// owning the blob-URL lifecycle so it can revoke on close.
// We deliberately don't pass `srcState.data` here even when
// it's available — pinning a blob URL into the atom would
// leak it (the atom outlives the timeline thumbnail).
openMediaViewer({
roomId: host.roomId,
eventId,
kind: 'image',
url,
body,
info,
encInfo,
mimeType,
});
return;
}
setViewer(true);
};
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
@ -163,7 +118,7 @@ export const ImageContent = as<'div', ImageContentProps>(
return (
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
{!useAtomViewer && srcState.status === AsyncStatus.Success && (
{srcState.status === AsyncStatus.Success && (
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
@ -177,7 +132,7 @@ export const ImageContent = as<'div', ImageContentProps>(
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: React.MouseEvent<HTMLDivElement>) => evt.stopPropagation()}
onContextMenu={(evt: any) => evt.stopPropagation()}
>
{renderViewer({
src: srcState.data,
@ -213,25 +168,15 @@ export const ImageContent = as<'div', ImageContentProps>(
</Box>
)}
{srcState.status === AsyncStatus.Success && (
<Box
className={classNames(css.AbsoluteContainer, blurred ? css.Blur : css.ImageClickable)}
>
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
{renderImage({
alt: body,
title: body,
src: srcState.data,
onLoad: handleLoad,
onError: handleError,
onClick: handleOpen,
onKeyDown: (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOpen();
}
},
onClick: () => setViewer(true),
tabIndex: 0,
role: 'button',
'aria-label': body || 'Open media',
})}
</Box>
)}

View file

@ -32,8 +32,6 @@ import {
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { validBlurHash } from '../../../utils/blurHash';
import { useMediaViewerHost } from '../../../features/room/mediaViewerHostContext';
import { useOpenMediaViewer } from '../../../state/hooks/mediaViewer';
type RenderVideoProps = {
title: string;
@ -52,14 +50,6 @@ type VideoContentProps = {
autoPlay?: boolean;
markedAsSpoiler?: boolean;
spoilerReason?: string;
// When provided AND `MediaViewerHostContext` is non-null, tapping
// the thumbnail opens the atom-driven horseshoe viewer for video
// playback instead of loading + playing inline (which hands off to
// the browser's native video-element fullscreen when the user hits
// the controls' expand button — that's why the user used to see
// Chrome's default video viewer). Non-Room surfaces leave the
// host context as `null` and keep the inline player.
eventId?: string;
renderThumbnail?: () => ReactNode;
renderVideo: (props: RenderVideoProps) => ReactNode;
};
@ -75,7 +65,6 @@ export const VideoContent = as<'div', VideoContentProps>(
autoPlay,
markedAsSpoiler,
spoilerReason,
eventId,
renderThumbnail,
renderVideo,
...props
@ -85,9 +74,6 @@ export const VideoContent = as<'div', VideoContentProps>(
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
const host = useMediaViewerHost();
const openMediaViewer = useOpenMediaViewer();
const useAtomViewer = !!(host && eventId);
const [load, setLoad] = useState(false);
const [error, setError] = useState(false);
@ -120,29 +106,8 @@ export const VideoContent = as<'div', VideoContentProps>(
};
useEffect(() => {
// Skip inline preload in atom-viewer mode — the user gets the
// viewer's own resolve path on tap; preloading every visible
// video in the timeline would burn bandwidth and decrypt CPU
// for videos the user never opens.
if (useAtomViewer) return;
if (autoPlay) loadSrc();
}, [autoPlay, loadSrc, useAtomViewer]);
const openAtomViewer = useCallback(() => {
if (!host || !eventId) return;
// No `resolvedSrc` — viewer body owns blob-URL lifecycle; see
// the rationale in `ImageContent.handleOpen`.
openMediaViewer({
roomId: host.roomId,
eventId,
kind: 'video',
url,
body,
info,
encInfo,
mimeType,
});
}, [host, eventId, openMediaViewer, url, body, info, encInfo, mimeType]);
}, [autoPlay, loadSrc]);
return (
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
@ -164,21 +129,7 @@ export const VideoContent = as<'div', VideoContentProps>(
{renderThumbnail()}
</Box>
)}
{useAtomViewer && !blurred && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Button
variant="Secondary"
fill="Solid"
radii="300"
size="300"
onClick={openAtomViewer}
before={<Icon size="Inherit" src={Icons.Play} filled />}
>
<Text size="B300">Watch</Text>
</Button>
</Box>
)}
{!useAtomViewer && !autoPlay && !blurred && srcState.status === AsyncStatus.Idle && (
{!autoPlay && !blurred && srcState.status === AsyncStatus.Idle && (
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
<Button
variant="Secondary"
@ -192,7 +143,7 @@ export const VideoContent = as<'div', VideoContentProps>(
</Button>
</Box>
)}
{!useAtomViewer && srcState.status === AsyncStatus.Success && (
{srcState.status === AsyncStatus.Success && (
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
{renderVideo({
title: body,

View file

@ -1,36 +1,6 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { style } from '@vanilla-extract/css';
import { DefaultReset, config } from 'folds';
// Click affordance for the timeline image thumbnail. Without this
// the `<img>` looked decorative on web (the default cursor stays
// as `default`) even though it's clickable to open the media
// viewer. The subtle 0.92 brightness on hover doubles as the
// "this is interactive" signal — same idiom as how the rest of
// the app's clickable surfaces shift tone on hover.
//
// `will-change: filter` hints the compositor so the brightness
// transition runs on the GPU instead of repainting on the CPU —
// matters for large hi-DPI thumbnails on slower phones.
export const ImageClickable = style({
cursor: 'pointer',
transition: 'filter 120ms ease',
willChange: 'filter',
selectors: {
'&:hover': { filter: 'brightness(0.92)' },
},
});
// `:focus-visible` outline on the inner `<img>` (which carries
// `tabIndex` + `role="button"`, not the wrapper Box). Lives as a
// `globalStyle` because vanilla-extract's `style({...})` only
// permits selectors that target the class itself (`&...`) — a
// descendant selector like `& img:focus-visible` errors at build
// time. `globalStyle` is the documented escape valve for that.
globalStyle(`${ImageClickable} img:focus-visible`, {
outline: `2px solid currentColor`,
outlineOffset: '-2px',
});
export const RelativeBase = style([
DefaultReset,
{

View file

@ -1,4 +1,4 @@
import { globalStyle, style } from '@vanilla-extract/css';
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
// 36px circular avatar — a notch above folds `Avatar size="200"` (32px)
@ -121,106 +121,3 @@ export const ChannelSyslineBody = style({
minWidth: 0,
flex: 1,
});
// Bubble chrome applied when `ChannelLayout` is invoked with
// `headerInBubble` (thread drawer and channels main timeline pass it).
// Mirrors `StreamBubble` from the DM timeline so a channel row reads
// like a chat-bubble cluster: dark `Surface.Container` card with an
// asymmetric notch corner per `data-own`, sized `fit-content` so short
// bubbles shrink-wrap instead of stretching across the column.
// Reactions and the thread-summary card live as siblings of the body
// in `ChannelLayout`, so they stay OUTSIDE the bubble — identical
// composition to Stream. The `[data-bubble="true"]` row marker keeps
// the un-bubbled channel/sysline layout (pre-redesign callers) opt-in
// rather than forcing the look on every consumer.
globalStyle(`${ChannelRow}[data-bubble="true"] ${ChannelMessageBody}`, {
backgroundColor: color.Surface.Container,
color: color.SurfaceVariant.OnContainer,
border: `1px solid ${color.Surface.ContainerLine}`,
paddingTop: config.space.S200,
paddingBottom: config.space.S200,
paddingLeft: toRem(15),
paddingRight: toRem(15),
display: 'inline-block',
width: 'fit-content',
maxWidth: '100%',
minWidth: 0,
position: 'relative',
zIndex: 1,
// Clips the thread-summary footer's hover bg against the bubble's
// rounded BR/BL corners — without it the rectangular hover paint
// punches past the curve. No outflow content lives inside the bubble
// (option bar, reactions are siblings on the row) so clipping is
// safe.
overflow: 'hidden',
});
// Asymmetric corner per `data-own` — own messages flatten BOTTOM-LEFT
// (4px), incoming messages flatten TOP-LEFT. Same pattern as
// `StreamBubble.own`/`StreamBubble.others`.
globalStyle(`${ChannelRow}[data-bubble="true"][data-own="true"] ${ChannelMessageBody}`, {
borderRadius: `${toRem(16)} ${toRem(16)} ${toRem(16)} ${toRem(4)}`,
});
globalStyle(`${ChannelRow}[data-bubble="true"][data-own="false"] ${ChannelMessageBody}`, {
borderRadius: `${toRem(4)} ${toRem(16)} ${toRem(16)} ${toRem(16)}`,
// Peer (not-own) bubble bg — matches Stream layout's `peerBg`
// variant. Covers channels main timeline AND thread drawer
// (both pass `headerInBubble`, so `data-bubble="true"` fires).
backgroundColor: 'var(--vojo-peer-bubble-bg)',
});
// Small gap so the in-bubble header (username + time) doesn't sit flush
// against the first line of message text. Matches `StreamBubbleHeader`'s
// 2px gap.
globalStyle(`${ChannelRow}[data-bubble="true"] ${ChannelHeader}[data-in-bubble="true"]`, {
marginBottom: toRem(2),
});
// Thread-summary footer rendered INSIDE the bubble (rather than as a
// separate pill below). Negative L/R margin (matches the bubble's
// `paddingLeft/Right: 15px`) stretches the wrapper to the bubble's
// inner border edge so the 1px top rule reads as a section divider
// spanning the whole bubble. Negative `marginBottom` cancels the
// bubble's S200 bottom pad so the footer flushes against the bubble's
// rounded bottom edge — bubble + summary read as one card with a
// horizontal rule splitting them.
//
// The footer body keeps no own border/radius — it inherits the bubble's
// bottom corners via clipping (`ChannelMessageBody` itself doesn't
// `overflow: hidden`, but the rounded bottom of the bubble visually
// caps the footer anyway because the divider line never reaches the
// curved corner pixel).
export const ChannelBubbleThreadSummary = style({
marginTop: config.space.S200,
marginLeft: toRem(-15),
marginRight: toRem(-15),
marginBottom: `calc(-1 * ${config.space.S200})`,
borderTop: `1px solid ${color.Surface.ContainerLine}`,
});
// Footer button — strip the original ThreadSummaryCard pill chrome
// (own bg, radius, padding, max-width) so it reads as a flush bubble
// footer. Click target expands to the full footer width. Hover paints
// a subtle `SurfaceVariant.Container` shade that contrasts against
// the bubble's `Surface.Container` bg, signalling tappable footer
// without the pill silhouette returning.
globalStyle(`${ChannelBubbleThreadSummary} > button`, {
display: 'flex',
width: '100%',
maxWidth: 'none',
borderRadius: 0,
backgroundColor: 'transparent',
padding: `${config.space.S200} ${toRem(15)}`,
});
globalStyle(`${ChannelBubbleThreadSummary} > button:hover`, {
backgroundColor: color.SurfaceVariant.Container,
});
globalStyle(`${ChannelBubbleThreadSummary} > button:focus-visible`, {
// Inset the focus ring slightly so it doesn't punch through the
// bubble's rounded bottom corners on the BR/BL when the row is
// either own or incoming.
outlineOffset: toRem(-2),
});

View file

@ -25,16 +25,6 @@ export type ChannelLayoutProps = {
header?: ReactNode;
reactions?: ReactNode;
threadSummary?: ReactNode;
// Forwarded onto the row root as `data-own="true"|"false"`. Channels
// main timeline doesn't style off it; the thread-drawer bubble CSS
// reads it to mirror `StreamBubble`'s own-vs-incoming notch corner.
isOwn?: boolean;
// When true, the header is rendered INSIDE the message-body slot
// (above content) instead of as a sibling row above the body. Thread
// drawer flips this on so the bubble wraps the username + time the
// same way `StreamBubble` does in DM chat. Channels main timeline
// keeps this false — name + time stay above an unbordered body.
headerInBubble?: boolean;
onContextMenu?: MouseEventHandler<HTMLDivElement>;
};
@ -43,50 +33,20 @@ export type ChannelLayoutProps = {
// thread-summary, reactions in vertical flow.
export const ChannelLayout = as<'div', ChannelLayoutProps>(
(
{
className,
avatar,
header,
reactions,
threadSummary,
isOwn,
headerInBubble,
onContextMenu,
children,
...props
},
{ className, avatar, header, reactions, threadSummary, onContextMenu, children, ...props },
ref
) => (
<div
className={classNames(css.ChannelRow, className)}
onContextMenu={onContextMenu}
data-own={isOwn ? 'true' : 'false'}
data-bubble={headerInBubble ? 'true' : undefined}
{...props}
ref={ref}
>
<div className={css.ChannelAvatarSlot}>{avatar}</div>
<div className={css.ChannelBody}>
{!headerInBubble && header && <div className={css.ChannelHeader}>{header}</div>}
<div className={css.ChannelMessageBody}>
{headerInBubble && header && (
<div className={css.ChannelHeader} data-in-bubble="true">
{header}
</div>
)}
{children}
{headerInBubble && threadSummary && (
// Inside-bubble footer: stretches via negative margins to the
// bubble's inner border edge, paints a 1px top divider, and
// hosts the existing thread-summary button as a flush
// full-width chip. Reads as one continuous card with a
// section break instead of two stacked pills.
<div className={css.ChannelBubbleThreadSummary}>{threadSummary}</div>
)}
</div>
{!headerInBubble && threadSummary && (
<div className={css.ChannelThreadSummary}>{threadSummary}</div>
)}
{header && <div className={css.ChannelHeader}>{header}</div>}
<div className={css.ChannelMessageBody}>{children}</div>
{threadSummary && <div className={css.ChannelThreadSummary}>{threadSummary}</div>}
{reactions && <div className={css.ChannelReactions}>{reactions}</div>}
</div>
</div>

View file

@ -32,10 +32,6 @@ export type StreamLayoutProps = {
dotColor: string;
dotOpacity: number;
isOwn?: boolean;
// Peer (not-own) bubble bg — caller passes `!isOwn` so every
// «чужое» сообщение reshades to `--vojo-peer-bubble-bg`. Applies
// in 1-1 DMs, groups, channels alike. No effect for own messages.
peerBg?: boolean;
compact?: boolean;
header?: ReactNode;
railStart?: boolean;
@ -104,7 +100,6 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
dotColor,
dotOpacity,
isOwn,
peerBg,
compact,
header,
railStart,
@ -174,7 +169,6 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
className={css.StreamBubble({
own: !!isOwn,
compact: !!compact,
peerBg: !!peerBg,
mediaMode: !!mediaMode,
})}
ref={bubbleRef}

View file

@ -283,7 +283,7 @@ export const StreamRail = style({
left: '50%',
transform: 'translateX(-50%)',
width: StreamRailLineWidth,
background: 'var(--vojo-timeline-rail)',
background: color.Surface.Container,
pointerEvents: 'none',
zIndex: 0,
});
@ -424,15 +424,14 @@ export const StreamBubble = recipe({
zIndex: 1,
},
variants: {
// Asymmetric notch — own: bottom-left flat, three corners R500+.
// Incoming: top-left flat, three corners R500+. Mirrored on the
// vertical axis so own/peer read as opposing silhouettes.
// Asymmetric notch — own: top-left flat, three corners R500.
// Incoming: mirrored.
own: {
true: {
borderRadius: `${toRem(16)} ${toRem(16)} ${toRem(16)} ${toRem(4)}`,
borderRadius: `${toRem(4)} ${config.radii.R500} ${config.radii.R500} ${config.radii.R500}`,
},
false: {
borderRadius: `${toRem(4)} ${toRem(16)} ${toRem(16)} ${toRem(16)}`,
borderRadius: `${config.radii.R500} ${config.radii.R500} ${config.radii.R500} ${toRem(4)}`,
},
},
// Mobile fills the message column (block 100%); desktop fits content
@ -453,14 +452,6 @@ export const StreamBubble = recipe({
paddingRight: toRem(15),
},
},
// Peer (not-own) bubble bg — differentiation between «я» and
// «не я» across every room class. Media rows neutralize this via
// the `peerBg + mediaMode` compound below (order-independent).
peerBg: {
true: {
backgroundColor: 'var(--vojo-peer-bubble-bg)',
},
},
// Image messages: bubble becomes a transparent shell so the
// StreamMediaImage child supplies the visible chrome instead.
// `display: block, width: 100%` (NOT fit-content) so the bubble has a
@ -480,21 +471,9 @@ export const StreamBubble = recipe({
},
},
},
// Compound overrides — emitted after all variant classes, so they
// win the cascade regardless of variant declaration order. Keeps
// peer image / video bubbles transparent (the StreamMediaImage
// child supplies the chrome) even though `peerBg` would otherwise
// paint `--vojo-peer-bubble-bg` underneath.
compoundVariants: [
{
variants: { peerBg: true, mediaMode: true },
style: { backgroundColor: 'transparent' },
},
],
defaultVariants: {
own: false,
compact: false,
peerBg: false,
mediaMode: false,
},
});
@ -545,6 +524,7 @@ export const StreamSysline = style({
paddingBottom: toRem(2),
});
export const StreamSyslineBody = style({
fontSize: toRem(11.5),
color: color.Surface.OnContainer,
@ -651,7 +631,7 @@ export const StreamDayLineWrap = style({
export const StreamDayLineSegment = style({
flex: 1,
height: 1,
background: 'var(--vojo-timeline-rail)',
background: color.Surface.Container,
minWidth: toRem(8),
});

View file

@ -1,37 +0,0 @@
import { createContext, useContext } from 'react';
export type MobilePagerTab = 'direct' | 'channels' | 'bots';
// Set by MobileTabsPager around each of its three listing panes. Lets
// the StreamHeader inside the pane discover (a) that it's mounted in
// pager mode at all, (b) whether it's the currently active pane, and
// (c) how to request a tab switch with no animation (used by the
// invisible per-pane Segment buttons that capture taps in the safe-
// top + tabsRow band — see `StreamHeader.tsx` for why the row is
// `opacity:0` instead of `visibility:hidden`).
//
// "Mounted in pager mode" controls whether the per-pane tabs row
// renders visibly — when we're in pager mode the tabs row is painted
// invisible (the shared static header at pager root paints the visible
// tabs + icons), but the row still occupies its TABS_ROW_PX height so
// the curtain's snap geometry is unchanged.
//
// "isActive" controls which pane's curtain is wired to the shared
// static header's action icons via `mobilePagerCurtainAtom`.
//
// `selectTabInstant` is the tap commit path. It snaps the strip to
// the target tab without the swipe-finish CSS transition — taps feel
// snappy where swipes still animate. Swipes still go through the
// pager's gesture hook which uses a separate animated commit.
export type MobilePagerPaneInfo = {
isActive: boolean;
selectTabInstant: (target: MobilePagerTab) => void;
};
const MobilePagerPaneContext = createContext<MobilePagerPaneInfo | null>(null);
export const MobilePagerPaneProvider = MobilePagerPaneContext.Provider;
export function useMobilePagerPane(): MobilePagerPaneInfo | null {
return useContext(MobilePagerPaneContext);
}

View file

@ -1,48 +0,0 @@
import React from 'react';
import { Outlet, useMatch } from 'react-router-dom';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { isNativePlatform } from '../../utils/capacitor';
import { BOTS_PATH, CHANNELS_PATH, CHANNELS_SPACE_PATH, DIRECT_PATH } from '../../pages/paths';
import { MobileTabsPager } from './MobileTabsPager';
// Router-level wrapper around the three listing tabs (/direct/,
// /channels/, /bots/). When all of (mobile breakpoint, Capacitor
// native runtime, listing-root URL) hold, we hijack rendering and
// mount `MobileTabsPager` directly — the wrapped routes' Outlet is
// never read, so their `element` chains stay unmounted. Anywhere else
// — non-mobile breakpoints (tablet, desktop), non-Capacitor runtimes
// (mobile web, Electron desktop), AND detail URLs nested under any
// listing root (/direct/!roomId, /channels/!space/!roomId,
// /bots/:botId) — we pass through to `<Outlet/>` and the existing
// route tree renders unchanged.
//
// Channels has TWO listing-root URLs that both activate the pager on
// the Channels tab:
//
// * `/channels/` — landing (empty-state CTA when the user has no
// orphan spaces; otherwise pager-internal render of the active
// workspace via the persisted active-space resolver).
// * `/channels/!space/` — workspace listing for that specific space.
// This is what `commitTo('channels')` actually navigates to when
// an active space is known, so the user lands directly on the
// workspace view without bouncing through `/channels/` and the
// `ChannelsLanding` <Navigate> redirect (which previously cut the
// pager animation short and made the swipe feel jerky).
//
// Detail URLs nested under either (`/channels/!space/!roomId`, etc.)
// flip both matches to false and fall through to <Outlet/> — Room
// renders full-screen on mobile as before.
export function MobileTabsLayout() {
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
const native = isNativePlatform();
const directRoot = !!useMatch({ path: DIRECT_PATH, end: true });
const channelsRoot = !!useMatch({ path: CHANNELS_PATH, end: true });
const channelsSpaceRoot = !!useMatch({ path: CHANNELS_SPACE_PATH, end: true });
const botsRoot = !!useMatch({ path: BOTS_PATH, end: true });
const onListingRoot = directRoot || channelsRoot || channelsSpaceRoot || botsRoot;
if (!(mobile && native) || !onListingRoot) {
return <Outlet />;
}
return <MobileTabsPager />;
}

View file

@ -1,444 +0,0 @@
import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Outlet, useMatch, useNavigate } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { BOTS_PATH, CHANNELS_PATH, CHANNELS_SPACE_PATH, DIRECT_PATH } from '../../pages/paths';
import { useBotPresets } from '../../features/bots/catalog';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useOrphanSpaces } from '../../state/hooks/roomList';
import {
getCanonicalAliasOrRoomId,
getCanonicalAliasRoomId,
isRoomAlias,
} from '../../utils/matrix';
import { getChannelsSpacePath } from '../../pages/pathUtils';
import { SpaceProvider } from '../../hooks/useSpace';
import { Direct } from '../../pages/client/direct';
import { Channels, ChannelsRootNav, useActiveSpace } from '../../pages/client/channels';
import { Bots } from '../../pages/client/bots';
import { ChannelsModeProvider } from '../../hooks/useChannelsMode';
import { settingsSheetAtom } from '../../state/settingsSheet';
import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
import { MobilePagerPaneProvider, MobilePagerTab } from './MobilePagerPaneContext';
import { MobileTabsPagerHeader } from './MobileTabsPagerHeader';
import { useMobileTabsPagerGesture } from './useMobileTabsPagerGesture';
import { PAGER_EASING, PAGER_TRANSITION_MS, PANE_GAP_PX } from './geometry';
import * as css from './style.css';
// Aliased to the context's `MobilePagerTab` so the per-pane Segment's
// `selectTabInstant(target)` callback (exposed via MobilePagerPaneInfo)
// stays type-aligned with the pager's own `tabs` array.
type Tab = MobilePagerTab;
// URL-safe wrapper around decodeURIComponent — matches the same helper
// inside `useActiveSpace`. Used here to validate the URL `:spaceIdOrAlias`
// param before we decide whether to mount the pager or defer to the
// existing route tree's JoinBeforeNavigate fallback.
const safeDecode = (raw: string): string | undefined => {
try {
return decodeURIComponent(raw);
} catch {
return undefined;
}
};
type PaneSlotProps = {
isActive: boolean;
children: ReactNode;
};
// Wraps a pane's DOM box and toggles the `inert` HTMLElement property
// based on the active flag, plus mirrors it into `aria-hidden`.
//
// `inert` removes the off-screen pane subtree from focus order, click
// handling, and the accessibility tree — important so assistive tech
// (and stray keyboard focus on devices with hardware keyboards) can't
// reach controls that are visually translateX'd out of the viewport.
// `aria-hidden` is the long-supported half of the same intent and
// alone covers most screen readers. Both are applied for full
// portability across AT/browser combinations.
//
// `inert` is assigned via ref (not as a JSX attr) because React 18.2's
// HTMLAttributes typing doesn't include it. The underlying DOM
// property is supported by Chromium 102+ / Safari 15.5+ which covers
// Capacitor's WebView baseline.
function PaneSlot({ isActive, children }: PaneSlotProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current) ref.current.inert = !isActive;
}, [isActive]);
return (
<div ref={ref} className={css.pane} aria-hidden={!isActive || undefined}>
{children}
</div>
);
}
// Mobile + Capacitor horizontal swipe pager. Mounts all three listing
// surfaces once, slides between them via CSS transform.
//
// Visual layout decomposes into a STATIC overlay header at the top
// (segments + action icons, painted by `MobileTabsPagerHeader`) and a
// translating strip below it. Each pane's StreamHeader still renders
// its own tabs row but `visibility: hidden` in pager mode — kept in
// DOM only so the curtain's TABS_ROW_PX-based snap geometry is
// preserved. Action icons in the static header proxy through
// `mobilePagerCurtainAtom`, written by whichever pane is active.
//
// Channels tab specifics: the pane content depends on whether the
// user has at least one joined orphan space. If yes, we render
// `<Channels>` (workspace listing) keyed to the active space; if no,
// `<ChannelsRootNav>` paints the empty-state CTA. `commitTo('channels')`
// navigates to /channels/!{spaceId}/ when an active space is known so
// the swipe never bounces through /channels/ + the ChannelsLanding
// `<Navigate>` redirect (which previously cut the slide animation
// short and made the gesture feel jerky).
//
// Inter-pane gap: `PANE_GAP_PX` is inserted between adjacent panes
// via inline `gap` on the strip. The pagerRoot's SurfaceVariant
// backdrop shows through the gap during a swipe, matching the user
// request for a light-blue divider colour identical to the header.
//
// Invalid space URL fall-through: if the URL is `/channels/:alias/`
// but `:alias` doesn't resolve to a joined orphan space (deep-link
// to a workspace the user isn't in, or a typo), the pager bails out
// and renders `<Outlet/>` instead. That delegates to the existing
// `/channels/!space/` route element whose `RouteSpaceProvider` shows
// `JoinBeforeNavigate`. Without this guard, `useActiveSpace` would
// silently fall back to the persisted-or-first-orphan space and the
// pager would show a DIFFERENT workspace than the URL claims —
// confusing the user and breaking deep-link semantics.
export function MobileTabsPager() {
const mx = useMatrixClient();
const navigate = useNavigate();
const bots = useBotPresets();
// `end: true` matches the listing-root URL exactly. Detail URLs
// (/channels/!space/!room/, /direct/!room, /bots/:botId) flip these
// to false — and MobileTabsLayout above us would have rendered
// Outlet instead of the pager in that case, so we never see those
// states. Channels is matched via EITHER /channels/ (landing) OR
// /channels/!space/ (workspace listing) — both keep us on the
// channels tab.
const channelsRoot = !!useMatch({ path: CHANNELS_PATH, end: true });
const channelsSpaceMatch = useMatch({ path: CHANNELS_SPACE_PATH, end: true });
const channelsSpaceRoot = !!channelsSpaceMatch;
const channelsActive = channelsRoot || channelsSpaceRoot;
const botsRoot = !!useMatch({ path: BOTS_PATH, end: true });
// Active space resolution mirrors ChannelsLanding: URL > localStorage
// > first joined orphan. `useOrphanSpaces` filters `allRoomsAtom`
// through `isSpace(mx.getRoom) && !roomToParents.has(...)`, so every
// entry it returns is BOTH (a) a Space the user has joined and (b)
// an orphan (no parent Space). `useActiveSpace` then constrains its
// result to that orphan set. Net invariant: if `activeSpaceId` is
// defined, `mx.getRoom(activeSpaceId)` is a Space the user is
// currently a member of — which is exactly the precondition
// `RouteSpaceProvider` would otherwise enforce via
// `joinedSpaces.includes(space.roomId)` before mounting Channels.
// That's why the pager can mount Channels with a plain
// `<SpaceProvider value={...}>` and skip RouteSpaceProvider's
// JoinBeforeNavigate fallback path safely — for VALID URL spaces.
// Invalid URL spaces hit the early-return below.
const roomToParents = useAtomValue(roomToParentsAtom);
const orphanSpaceIds = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
const activeSpaceId = useActiveSpace(orphanSpaceIds);
const activeSpace = activeSpaceId ? mx.getRoom(activeSpaceId) : null;
// Validate the URL `:spaceIdOrAlias` param when on the workspace
// route. If the param exists but doesn't resolve to a joined orphan
// (deep-link to an unjoined / unknown space), we'll defer to the
// original route tree below instead of silently substituting another
// workspace.
const urlSpaceParam = channelsSpaceMatch?.params.spaceIdOrAlias;
const urlSpaceIsValid = useMemo(() => {
if (!urlSpaceParam) return true;
const decoded = safeDecode(urlSpaceParam);
if (!decoded) return false;
const resolved = isRoomAlias(decoded) ? getCanonicalAliasRoomId(mx, decoded) : decoded;
return resolved !== undefined && orphanSpaceIds.includes(resolved);
}, [mx, urlSpaceParam, orphanSpaceIds]);
const showBots = bots.length > 0 || botsRoot;
const tabs = useMemo<Tab[]>(() => {
const list: Tab[] = ['direct', 'channels'];
if (showBots) list.push('bots');
return list;
}, [showBots]);
const urlActiveIdx = useMemo(() => {
if (botsRoot) {
const i = tabs.indexOf('bots');
return i >= 0 ? i : 0;
}
if (channelsActive) {
const i = tabs.indexOf('channels');
return i >= 0 ? i : 0;
}
const i = tabs.indexOf('direct');
return i >= 0 ? i : 0;
}, [tabs, channelsActive, botsRoot]);
const [dragPx, setDragPxState] = useState(0);
const [dragging, setDraggingState] = useState(false);
// Tap-driven commits set this for one frame to suppress the strip's
// CSS transform transition. The strip jumps to the new tab without
// the 280ms slide animation that swipe commits intentionally play.
// Cleared in a rAF effect below so subsequent state changes resume
// with normal transitions.
const [instantSwitch, setInstantSwitch] = useState(false);
// Stored as a Tab NAME, not an index. The `tabs` array's length and
// composition can change at runtime (showBots flipping when the user
// navigates onto/off /bots/, or when a bot-config refresh adds a
// bot) — a stored index could end up pointing at the wrong tab or
// off the end of the array. A stable name lets us re-derive the
// index on every render via `tabs.indexOf(...)`.
//
// Load-bearing: react-router-dom v6's `useNavigate` does NOT auto-
// wrap in `React.startTransition` and its router-state update
// (`useSyncExternalStore`-backed) is asynchronous relative to React
// 18's auto-batching of setState in the same event handler. Without
// this pending lock, the commit path `setDragPx(0); setDragging
// (false); navigate(...)` can land in two renders — first with
// dragPx=0 at the OLD urlActiveIdx (strip snaps back to source tab),
// then with the NEW urlActiveIdx (strip animates to target). That
// two-stage flicker is exactly the "jerk on release" the user
// reported. See
// https://github.com/remix-run/react-router/issues/11003 for the
// upstream non-batching discussion. Do NOT remove without measuring.
// Cleared once the URL catches up (or after a safety timeout — see
// the effect below).
const [pendingTargetTab, setPendingTargetTab] = useState<Tab | null>(null);
const setDragPx = useCallback((px: number, drag: boolean) => {
setDragPxState(px);
setDraggingState(drag);
}, []);
const destinationFor = useCallback(
(tab: Tab): string => {
if (tab === 'direct') return DIRECT_PATH;
if (tab === 'bots') return BOTS_PATH;
if (activeSpaceId) {
const alias = getCanonicalAliasOrRoomId(mx, activeSpaceId);
return getChannelsSpacePath(alias);
}
return CHANNELS_PATH;
},
[mx, activeSpaceId]
);
// Shared commit core. `instant=true` flips `instantSwitch` for one
// frame so the strip's transform jump from old tab to new lands
// without the swipe-finish CSS transition — used by tap paths
// (static header Segments + per-pane Segments). `instant=false`
// preserves the animation — used by the swipe gesture commit so
// the strip smoothly completes the user's drag.
const commitToInternal = useCallback(
(idx: number, instant: boolean) => {
const target = tabs[idx];
if (!target) return;
setDragPxState(0);
setDraggingState(false);
if (instant) setInstantSwitch(true);
setPendingTargetTab(target);
navigate(destinationFor(target), { replace: true });
},
[tabs, navigate, destinationFor]
);
const commitToSwipe = useCallback(
(idx: number) => commitToInternal(idx, false),
[commitToInternal]
);
// Tap entry by tab name. Exposed to per-pane Segments via
// `MobilePagerPaneInfo.selectTabInstant` so the invisible per-pane
// segment buttons (which capture taps when the static header isn't
// z-elevated) route through the same instant commit path as the
// static header's own segment buttons.
const selectTabInstant = useCallback(
(target: Tab) => {
const i = tabs.indexOf(target);
if (i >= 0) commitToInternal(i, true);
},
[tabs, commitToInternal]
);
const onSelectDirect = useCallback(() => selectTabInstant('direct'), [selectTabInstant]);
const onSelectChannels = useCallback(() => selectTabInstant('channels'), [selectTabInstant]);
const onSelectBots = useCallback(() => selectTabInstant('bots'), [selectTabInstant]);
// Clear `instantSwitch` on the next paint frame so subsequent
// transform changes use the normal animated transition again. rAF
// (not setTimeout 0) so we cancel cleanly if a new commit re-arms
// the flag before the frame fires.
useEffect(() => {
if (!instantSwitch) return undefined;
const id = requestAnimationFrame(() => setInstantSwitch(false));
return () => cancelAnimationFrame(id);
}, [instantSwitch]);
const pendingTargetIdx = pendingTargetTab !== null ? tabs.indexOf(pendingTargetTab) : -1;
useEffect(() => {
if (pendingTargetTab === null) return undefined;
// Tab disappeared from the array mid-animation (e.g. /bots/ deep-
// link held the Bots tab visible, the user committed to Direct,
// and during the slide the bot config became empty so showBots
// flipped to false). The stored target no longer maps to any
// index — clear immediately so visualIdx falls back to urlActive.
if (pendingTargetIdx === -1) {
setPendingTargetTab(null);
return undefined;
}
if (pendingTargetIdx === urlActiveIdx) {
setPendingTargetTab(null);
return undefined;
}
const id = window.setTimeout(() => setPendingTargetTab(null), PAGER_TRANSITION_MS + 100);
return () => window.clearTimeout(id);
}, [pendingTargetTab, pendingTargetIdx, urlActiveIdx]);
const visualIdx = pendingTargetIdx >= 0 ? pendingTargetIdx : urlActiveIdx;
const visualDragPx = pendingTargetTab !== null ? 0 : dragPx;
// Suppress the pager gesture while ANY of:
// 1. A horseshoe sheet is open (Settings or workspace switcher).
// A horizontal swipe on the sheet body, or on the still-
// visible listing above the sheet, would steer the pager into
// a sibling tab and unmount the sheet's host.
// 2. A commit-slide animation is in flight (pendingTargetTab set).
// Starting a new gesture during the 280ms transition would
// either jump (because visualDragPx is forced to 0) or commit
// relative to a stale urlActiveIdx — same UX hazard React
// Navigation's TabView avoids by locking gestures during
// transitions.
const settingsSheetOpen = !!useAtomValue(settingsSheetAtom);
const workspaceSheetOpen = !!useAtomValue(channelsWorkspaceSheetAtom);
const gestureDisabled = settingsSheetOpen || workspaceSheetOpen || pendingTargetTab !== null;
const rootRef = useRef<HTMLDivElement>(null);
useMobileTabsPagerGesture({
rootRef,
activeIdx: urlActiveIdx,
tabsCount: tabs.length,
disabled: gestureDisabled,
setDragPx,
// Swipe commits keep the slide animation — the strip glides from
// the drag-released position to the target tab. Tap commits use
// `selectTabInstant` (passed down via per-pane context + the
// static header's `onSelectXxx` props) which sets `instantSwitch`
// for one frame to skip the transition.
commitTo: commitToSwipe,
});
// Gap-aware strip transform. Each adjacent pane is offset by an
// extra `PANE_GAP_PX` so a swipe past the gap zone exposes the
// pagerRoot backdrop colour, matching the static header tone.
// Memoised so the inline object identity is stable when dragPx
// doesn't change — avoids extra child re-renders when other state
// updates (e.g. atom subscription) tick the parent.
const stripStyle = useMemo<React.CSSProperties>(
() => ({
width: `calc(${tabs.length * 100}vw + ${(tabs.length - 1) * PANE_GAP_PX}px)`,
transform: `translate3d(calc(${-visualIdx * 100}vw - ${
visualIdx * PANE_GAP_PX
}px + ${visualDragPx}px), 0, 0)`,
// Transition suppressed while a finger drag is in flight (the
// strip follows the finger 1:1) AND for the single frame after
// a tap-driven commit (`instantSwitch`) so segment taps snap to
// the target tab without the 280ms slide. Swipe commits leave
// `instantSwitch` false so the slide-to-target animation plays.
transition:
dragging || instantSwitch ? 'none' : `transform ${PAGER_TRANSITION_MS}ms ${PAGER_EASING}`,
gap: `${PANE_GAP_PX}px`,
}),
[tabs.length, visualIdx, visualDragPx, dragging, instantSwitch]
);
// Per-pane context values memoised separately so each pane's
// `useMobilePagerPane()` consumer (the inner StreamHeader) only
// re-runs when ITS isActive flag toggles, not every time the parent
// re-renders (e.g. on every touchmove during a drag). Without this,
// a fresh `{ isActive: bool }` object per render would tick every
// pane's context subscription at 60Hz during a swipe.
const directIdx = useMemo(() => tabs.indexOf('direct'), [tabs]);
const channelsIdx = useMemo(() => tabs.indexOf('channels'), [tabs]);
const botsIdx = useMemo(() => tabs.indexOf('bots'), [tabs]);
const directPaneInfo = useMemo(
() => ({ isActive: urlActiveIdx === directIdx, selectTabInstant }),
[urlActiveIdx, directIdx, selectTabInstant]
);
const channelsPaneInfo = useMemo(
() => ({ isActive: urlActiveIdx === channelsIdx, selectTabInstant }),
[urlActiveIdx, channelsIdx, selectTabInstant]
);
const botsPaneInfo = useMemo(
() => ({ isActive: urlActiveIdx === botsIdx, selectTabInstant }),
[urlActiveIdx, botsIdx, selectTabInstant]
);
// The static header doesn't need useMatch of its own — `urlActiveIdx`
// is already the authoritative source of truth for which tab is
// active. Map it back to a Tab name and pass down.
const activeTab: Tab = tabs[urlActiveIdx] ?? 'direct';
// Invalid URL space — defer to the existing route tree which
// handles unjoined / unknown spaces via JoinBeforeNavigate. All
// hooks above must run unconditionally for rules-of-hooks
// compliance; this early-return is the first conditional render.
if (channelsSpaceRoot && !urlSpaceIsValid) {
return <Outlet />;
}
return (
<div ref={rootRef} className={css.pagerRoot}>
<MobileTabsPagerHeader
showBots={showBots}
activeTab={activeTab}
onSelectDirect={onSelectDirect}
onSelectChannels={onSelectChannels}
onSelectBots={onSelectBots}
/>
{/* `data-pager-pane="true"` flags everything inside the strip so
per-pane background paints (PageNav-inner surface,
MobileSettings / ChannelsWorkspace appBody, StreamHeader
stage + header) collapse to transparent in pager mode. With
both the strip and the static header at z-auto in pagerRoot,
DOM order puts the strip on top and with the strip's bg
layers transparent, the static header tabs show through every
pixel the curtain isn't covering. See `pagerStaticHeader` in
style.css.ts for the full overlay contract. */}
<div className={css.strip} style={stripStyle} data-pager-pane="true">
<MobilePagerPaneProvider value={directPaneInfo}>
<PaneSlot isActive={directPaneInfo.isActive}>
<Direct />
</PaneSlot>
</MobilePagerPaneProvider>
<MobilePagerPaneProvider value={channelsPaneInfo}>
<PaneSlot isActive={channelsPaneInfo.isActive}>
<ChannelsModeProvider value>
{activeSpace ? (
<SpaceProvider key={activeSpace.roomId} value={activeSpace}>
<Channels />
</SpaceProvider>
) : (
<ChannelsRootNav />
)}
</ChannelsModeProvider>
</PaneSlot>
</MobilePagerPaneProvider>
{showBots && (
<MobilePagerPaneProvider value={botsPaneInfo}>
<PaneSlot isActive={botsPaneInfo.isActive}>
<Bots />
</PaneSlot>
</MobilePagerPaneProvider>
)}
</div>
</div>
);
}

View file

@ -1,214 +0,0 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useAtomValue } from 'jotai';
import { Box, Icon, IconButton, Icons } from 'folds';
import {
curtainPinnedByTabAtom,
mobileHorseshoeActiveAtom,
mobilePagerCurtainAtom,
} from '../../state/mobilePagerHeader';
import { Segment } from '../stream-header/Segment';
import * as streamHeaderCss from '../stream-header/StreamHeader.css';
import * as css from './style.css';
// Positive z-index applied to the static pager header when any
// horseshoe sheet is geometrically active (drag in flight or sheet
// open). Any positive value beats the strip's `z-index: auto`
// stacking context in pagerRoot, so `z: 1` is sufficient; the constant
// just makes the intent explicit at the call site.
const PAGER_HEADER_ELEVATED_Z = 1;
type Tab = 'direct' | 'channels' | 'bots';
// Must match the `INLINE_FORM_ID` local constant in
// `StreamHeader.tsx`. The shared static header's action icons are
// `aria-controls`-linked to the form region that the active pane's
// StreamHeader renders inside its curtain — mirroring the original
// in-pane buttons' ARIA semantics so assistive tech still announces
// the relationship correctly. Keep the two literals in lockstep.
const INLINE_FORM_ID = 'stream-header-inline-form';
type MobileTabsPagerHeaderProps = {
showBots: boolean;
// Active tab name resolved by the parent pager from the URL. We
// accept it as a prop rather than re-running `useMatch` here — the
// pager already knows the answer and passing it down keeps the
// segment highlight in lock-step with the strip's visual position
// (one source of truth = `urlActiveIdx`).
activeTab: Tab;
onSelectDirect: () => void;
onSelectChannels: () => void;
onSelectBots: () => void;
};
// Static shared tabs row painted at the top of MobileTabsPager. Lives
// outside the swipe strip so it doesn't translate with the panes —
// addresses the user-visible regression where each pane's identical
// tabs row sliding underneath felt like "the header is moving" even
// though the segments matched at every pixel.
//
// Reuses `stream-header/StreamHeader.css.ts` classes (`tabsRow`,
// `tabsCluster`, `iconsCluster`) so the layout, padding, and segment
// styling stay identical to the per-pane tabs row that sits hidden
// underneath. The only structural difference is the surrounding
// `pagerStaticHeader` wrapper which positions this row absolute at
// the top of the pager and reserves the status-bar safe-area inset.
//
// Segment clicks call the pager's commit callbacks (so the swipe
// animation uses the same pendingTargetIdx path as a finger swipe).
// Action icons read `mobilePagerCurtainAtom` — the active pane's
// StreamHeader writes its curtain controls there, so Plus/Search/X
// drive whichever curtain is currently visible.
//
// ARIA: action icons mirror the original in-pane buttons'
// `aria-controls` / `aria-expanded` / `aria-haspopup` relationship to
// the form region (`#stream-header-inline-form`). When `iconsDisabled`
// (atom not yet populated on initial mount) the buttons report
// `aria-disabled` so assistive tech announces the unavailable state
// instead of silently failing on activation.
export function MobileTabsPagerHeader({
showBots,
activeTab,
onSelectDirect,
onSelectChannels,
onSelectBots,
}: MobileTabsPagerHeaderProps) {
const { t } = useTranslation();
const curtainControls = useAtomValue(mobilePagerCurtainAtom);
const isFormActive = curtainControls?.isFormActive ?? false;
const openChat = useCallback(() => curtainControls?.openChat(), [curtainControls]);
const openSearch = useCallback(() => curtainControls?.openSearch(), [curtainControls]);
const closeForm = useCallback(() => curtainControls?.closeForm(), [curtainControls]);
const iconsDisabled = curtainControls === null;
// Tab-specific override for the Plus button (Channels publishes
// «create channel» / «create community»). Falls back to the default
// «new chat» path that opens InlineNewChatForm via the curtain.
// `primaryAction.onClick` is already stable (memoised by the
// publishing pane), so we wire it directly into onClick without
// re-wrapping in another useCallback.
const primaryAction = curtainControls?.primaryAction ?? null;
// The static header does NOT translate to follow the curtain. It
// stays put; the curtain physically rises ABOVE it via z-stack — see
// the «curtain-overlay invariants» comment in style.css.ts on
// pagerStaticHeader for the bg / z-order contract.
//
// Z-elevation while a horseshoe sheet is GEOMETRICALLY active: the
// MobileSettings / ChannelsWorkspace container paints
// `VOJO_HORSESHOE_VOID_COLOR` (= #000 in dark theme) across the
// entire pane to drive the carve cut-out the moment `expandedPx > 0`.
// Without elevation that void bleeds up through the transparent
// strip-stack into the safe-top + tabsRow zone, turning the system-
// tray strip + tabs black. Bumping the static header into a positive
// z-index puts it ABOVE the strip's stacking context (positive z
// beats z:auto stacking contexts per CSS painting order), covering
// the void in its own y-band with SurfaceVariant bg + visible tabs.
//
// The atom tracks the GEOMETRIC signal (`expandedPx > 0`), not the
// sheet-open atoms, so elevation lands on the FIRST frame of drag —
// not 80 px later when the user crosses the commit threshold. The
// horseshoes' appBody flips to opaque in lockstep (same signal),
// containing the void to the bottom carve everywhere below the
// static header.
//
// Pinned-overrides-elevation: when the active pane's curtain is
// pinned the curtain itself contains the void — it covers everything
// from `y = safe-top` downward inside the strip's stacking context,
// and the opaque appBody (also flipped on `horseshoeActive`) covers
// the safe-top band above the curtain. Re-elevating the static
// header in that state would visibly «slice» the pinned curtain in
// the safe-top + tabsRow band, popping tabs back over what the user
// explicitly pulled up to cover. So we suppress elevation whenever
// the active tab's pin is set — preserves the «pinned hides tabs»
// invariant across sheet open/drag.
//
// The curtain pin gesture is suppressed while either sheet is open
// (see `StreamHeader.gestureDisabled`), so this elevation never
// races with a pin-in-progress drag.
const horseshoeActive = useAtomValue(mobileHorseshoeActiveAtom);
const pinnedByTab = useAtomValue(curtainPinnedByTabAtom);
const activePinned = !!pinnedByTab[activeTab];
const elevated = horseshoeActive && !activePinned;
return (
<div
className={css.pagerStaticHeader}
style={elevated ? { zIndex: PAGER_HEADER_ELEVATED_Z } : undefined}
>
<div className={streamHeaderCss.tabsRow}>
<div className={streamHeaderCss.tabsCluster}>
<Segment
active={activeTab === 'direct'}
label={t('Direct.segment_dm')}
onClick={onSelectDirect}
/>
<Segment
active={activeTab === 'channels'}
label={t('Direct.segment_channels')}
onClick={onSelectChannels}
/>
{showBots && (
<Segment
active={activeTab === 'bots'}
label={t('Direct.segment_bots')}
onClick={onSelectBots}
/>
)}
</div>
<Box grow="Yes" />
{isFormActive ? (
<IconButton
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={closeForm}
aria-label={t('Direct.close')}
aria-controls={INLINE_FORM_ID}
aria-expanded
disabled={iconsDisabled}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
) : (
<div className={streamHeaderCss.iconsCluster}>
<IconButton
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={primaryAction ? primaryAction.onClick : openChat}
aria-label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
// See StreamHeader's matching IconButton: drop only
// `aria-controls` when the override opens a portal
// Modal (no in-subtree form to point at). The
// override IS a dialog opener, so `aria-haspopup` +
// `aria-expanded={false}` stay accurate either way.
aria-controls={primaryAction ? undefined : INLINE_FORM_ID}
aria-expanded={false}
aria-haspopup="dialog"
disabled={iconsDisabled}
>
<Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} />
</IconButton>
<IconButton
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={openSearch}
aria-label={t('Search.search')}
aria-controls={INLINE_FORM_ID}
aria-expanded={false}
aria-haspopup="dialog"
disabled={iconsDisabled}
>
<Icon size="100" src={Icons.Search} />
</IconButton>
</div>
)}
</div>
</div>
);
}

View file

@ -1,40 +0,0 @@
// Mobile horizontal swipe pager — tuning constants.
//
// The pager is only active on Capacitor + mobile + listing-root URLs
// (/direct/, /channels/, /bots/). Everywhere else MobileTabsLayout
// passes through to <Outlet/> and these values are inert.
// Direction-resolve dead-zone (px). The finger must travel at least
// this far on either axis before we resolve the gesture as horizontal
// (engage the pager) or vertical (bail, let curtain / horseshoes /
// scroll take over).
export const DEAD_ZONE_PX = 12;
// Edge-guard band (px). Touchstart inside this strip from the L or R
// viewport edge is ignored — that zone belongs to the Android system
// back-gesture in edge-to-edge mode, and reacting to it would steal
// the back-swipe.
export const EDGE_GUARD_PX = 24;
// Rubber-band attenuation factor applied when the user pulls past the
// leftmost or rightmost tab boundary. Soft pull, never commits.
export const RUBBER_BAND_FACTOR = 0.35;
// Commit threshold = max(MIN_COMMIT_PX, viewport_width × COMMIT_FRACTION).
// Tuned wide on purpose (≈40% of viewport, floor 150px) so accidental
// horizontal jitter on long DM rows doesn't flip tabs.
export const MIN_COMMIT_PX = 150;
export const COMMIT_FRACTION = 0.4;
// Snap-back / commit-slide animation. Same curve & duration as the
// curtain commit so the two motions feel consistent.
export const PAGER_TRANSITION_MS = 280;
export const PAGER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
// Visible gap between adjacent panes inside the strip. Surfaces the
// `SurfaceVariant.Container` (pagerRoot's backdrop) during a swipe,
// matching the design intent of a light-blue divider between screens.
// Roughly 2× the standard horseshoe seam (`VOJO_HORSESHOE_GAP_PX=12`)
// — the inter-pane gap reads as a transitional void rather than a
// horseshoe surface boundary, so it's tuned wider to feel breathing.
export const PANE_GAP_PX = 24;

View file

@ -1,4 +0,0 @@
// `MobileTabsPager` is intentionally NOT exported — it's mounted only
// from `MobileTabsLayout` based on the routed activation conditions,
// never directly by route or app code.
export { MobileTabsLayout } from './MobileTabsLayout';

View file

@ -1,161 +0,0 @@
import { style } from '@vanilla-extract/css';
import { color } from 'folds';
// Pager root. Sits inside the authed shell's row-flex slot
// (ClientLayout → Box grow=Yes), so `flex: 1 1 0` fills the slot
// horizontally; `align-items: stretch` on the parent fills vertically.
//
// `touch-action: pan-y` lets the browser keep doing native vertical
// scroll (DM list virtualizer, curtain peek pull-down) without us
// having to call preventDefault on every move — only the pager's own
// listener calls preventDefault, and only after axis-resolve commits
// to "horizontal".
//
// `SurfaceVariant.Container` backdrop intentionally shows through
// (a) the inter-pane gap during a swipe — the gap colour the user
// asked for is "light blue same as the header", which IS this
// SurfaceVariant tone — and (b) any sub-pixel rounding seam at rest.
//
// `color: Background.OnContainer` mirrors what the route-level
// `<PageRoot>` would have set via its `ContainerColor({ variant:
// 'Background' })` wrapper. We mount Direct/Channels/Bots directly
// here, bypassing PageRoot for the swipe-pager experience, so without
// this declaration the form labels rendered inside the per-pane
// StreamHeader curtain (`<Text size="L400">` for Username/Server/
// Options) had `color: inherit` cascading all the way up to `body`,
// which sets no color → browser default black. The labels became
// invisible against the dark form background only on native (where the
// pager activates). Desktop / web go through PageRoot and inherit the
// expected light tone for free.
export const pagerRoot = style({
position: 'relative',
flex: '1 1 0',
minWidth: 0,
minHeight: 0,
height: '100%',
overflow: 'hidden',
touchAction: 'pan-y',
backgroundColor: color.SurfaceVariant.Container,
color: color.Background.OnContainer,
});
// Shared static tabs row painted BEHIND the strip in DOM order.
// Reserves the status-bar safe-area inset via padding-top so the
// segments + icons sit just below the system status bar, and so the
// backdrop colour extends through the inset zone (matching the per-pane
// PageNav's own `paddingTop: var(--vojo-safe-top)` so there's no
// visible band boundary at the inset edge).
//
// Curtain-overlay invariants (why no z-index here at rest):
//
// The chats curtain must visually rise ABOVE this header when the
// user pulls it up to the «pinned» snap — like a real blind sliding
// over the segments rather than the segments moving. The curtain
// lives inside each pane > stage with `z: 2` in stage's local
// stacking context. The stage / pane stack inside the swipe `strip`,
// which creates its own stacking context via `transform`. To let the
// curtain visually surface above this static header we:
//
// (a) leave both elements at `z-index: auto` in pagerRoot's
// stacking context (this block has no `zIndex` AT REST), so
// painting order falls back to DOM order. `pagerStaticHeader`
// is rendered BEFORE `strip` in MobileTabsPager — so the
// strip (and everything inside it that paints opaquely) paints
// on top.
//
// (b) tag the strip with `data-pager-pane="true"`. All per-pane
// background paints (PageNav-inner surface, MobileSettings/
// ChannelsWorkspace appBody, StreamHeader stage + header)
// become transparent under that selector, so the static
// header tabs show through every transparent layer of the
// strip until the curtain — the only remaining opaque element
// — covers them by being positioned at top: 0 (pinned snap).
//
// Breaking (a) or (b) re-introduces the «paravozik» regression where
// the tabs visually slide with the curtain. See git history for the
// user-feedback trail.
//
// Conditional z-elevation (horseshoe-active override, suppressed by pin):
//
// When a horseshoe sheet (Settings or workspace switcher) is
// geometrically active — i.e. `expandedPx > 0`, which covers both
// the in-flight drag and the committed-open state — the wrapping
// container paints `VOJO_HORSESHOE_VOID_COLOR` (= #000 in dark
// theme) across the entire pane so the carve at the sheet's top
// reads as a dark seam. With the transparent strip stack from (b),
// that void would bleed up through the safe-top + tabsRow zone,
// turning the system-tray strip + tabs solid black.
//
// `MobileTabsPagerHeader.tsx` bumps this element to a positive
// `zIndex` (inline style, driven by `mobileHorseshoeActiveAtom`)
// from the first frame of drag. Positive z beats the strip's
// `z: auto` stacking context, putting the static header back on
// top in the safe-top + tabsRow band — the void is contained to
// the carve area, tabs stay visible. The horseshoe's `appBody`
// flips back to opaque on the same signal so the void doesn't
// bleed into the chip-area band between the static header and
// the curtain top either. The curtain pin gesture is gated off
// in the same state (see `StreamHeader.gestureDisabled`) so no
// pin can race the elevation flip.
//
// Pinned-override: when the active pane's curtain is pinned, the
// curtain itself sits at the top of the stage (z:2 inside the
// strip's stacking ctx) and covers everything from `y = safe-top`
// downward — including the tabsRow band. Above the curtain
// (y=0..safe-top) the opaque appBody contains the void. Elevating
// the static header in that state would visibly slice the pinned
// curtain in the tabsRow band, popping tabs over what the user
// explicitly pulled up to cover. So `MobileTabsPagerHeader`
// suppresses elevation whenever `curtainPinnedByTabAtom[activeTab]`
// is true — preserves the «pinned hides tabs» invariant across
// sheet open/drag without re-introducing the void leak.
export const pagerStaticHeader = style({
position: 'absolute',
top: 0,
left: 0,
right: 0,
paddingTop: 'var(--vojo-safe-top, 0px)',
// The wrapped tabsRow has its own height of TABS_ROW_PX via the
// stream-header recipe; we don't set a fixed height here so the
// status-bar inset adds on top naturally.
backgroundColor: color.SurfaceVariant.Container,
});
// Horizontal strip carrying all three panes side-by-side. Width &
// transform are computed inline in the JSX (they depend on tabs.length
// and visualIdx + visualDragPx, and the gap math couples to them).
//
// `gap: PANE_GAP_PX` is what makes the inter-pane void visible during
// a swipe — the pagerRoot's SurfaceVariant.Container colour shows
// through the gap, matching the static header tone exactly.
export const strip = style({
display: 'flex',
flexDirection: 'row',
height: '100%',
willChange: 'transform',
});
// Each pane is exactly one viewport wide. CRITICALLY `display: flex;
// flex-direction: row` so the nested Folds PageNav (which is a flex
// child with `flex-grow: 1` on mobile to override its 256px recipe
// width) expands to fill the pane. A column-flex parent here would
// leave PageNav at 256px — the bug that ate the previous attempt.
//
// No paddingTop here: the per-pane StreamHeader still renders its
// own tabs row (kept for the curtain's TABS_ROW_PX snap math, just
// painted invisible via `opacity: 0` — load-bearing because
// `visibility: hidden` would remove the row from hit-testing and
// the per-pane Segments need to capture taps at rest, see
// `StreamHeader.tsx` tabsRow rationale), and PageNav's inner column
// reserves the status-bar safe-area inset via its own
// `paddingTop: var(--vojo-safe-top)`. The static header overlay at
// the pager root simply paints OVER the same screen zone, so the
// underlying geometry stays identical to non-pager mode.
export const pane = style({
display: 'flex',
flexDirection: 'row',
flexShrink: 0,
width: '100vw',
height: '100%',
minWidth: 0,
});

View file

@ -1,220 +0,0 @@
import { MutableRefObject, useEffect, useRef } from 'react';
import {
COMMIT_FRACTION,
DEAD_ZONE_PX,
EDGE_GUARD_PX,
MIN_COMMIT_PX,
RUBBER_BAND_FACTOR,
} from './geometry';
type Args = {
// Root element the touch listeners attach to. Touches outside this
// element never reach the pager — that's how we keep the gesture
// scoped to the listing surface and out of detail routes.
rootRef: MutableRefObject<HTMLDivElement | null>;
// Index of the currently active pane. Mirrored into a ref so the
// single bound effect reads fresh values without re-attaching.
activeIdx: number;
// Total number of panes. Used to clamp commit + rubber-band edges.
tabsCount: number;
// While true the listeners stay bound but every touchstart bails
// immediately. Used by the parent to suppress the gesture when an
// overlay sheet (settings, workspace switcher) is open — a swipe
// there shouldn't navigate sibling tabs.
disabled: boolean;
// Setter for the live drag delta. The pager component re-renders the
// strip transform on every change.
setDragPx: (px: number, dragging: boolean) => void;
// Commit a tab change. The caller is expected to reset dragPx to 0
// AND call navigate(replace) in the same React batch so the strip's
// transform jumps from (oldIdx, dragPx) to (newIdx, 0) in one render
// — CSS transition then animates the (small) remaining distance
// smoothly without an intermediate "snap back" flash.
commitTo: (idx: number) => void;
};
// Horizontal swipe driver for the mobile listing tab pager. Mirrors
// the shape of `useCurtainHandleGesture`: single listener bound to
// the pager root, refs for the latest snap/index state, axis-resolve
// in the dead-zone, rubber-band at boundaries, threshold-commit on
// release.
//
// Conflict resolution with other gestures sharing the same surface
// (curtain, MobileSettingsHorseshoe, ChannelsWorkspaceHorseshoe) is
// cooperative: every gesture-owner resolves axis at the same dead-
// zone (12px) and bails when its own axis doesn't dominate. The pager
// wins horizontal; the others win vertical.
export function useMobileTabsPagerGesture({
rootRef,
activeIdx,
tabsCount,
disabled,
setDragPx,
commitTo,
}: Args): void {
const activeRef = useRef(activeIdx);
const countRef = useRef(tabsCount);
const disabledRef = useRef(disabled);
activeRef.current = activeIdx;
countRef.current = tabsCount;
disabledRef.current = disabled;
useEffect(() => {
const root = rootRef.current;
if (!root) return undefined;
let startX: number | null = null;
let startY: number | null = null;
let engaged = false;
let bailed = false;
let lastDragPx = 0;
const reset = () => {
startX = null;
startY = null;
engaged = false;
bailed = false;
lastDragPx = 0;
};
const onTouchStart = (e: TouchEvent) => {
if (disabledRef.current) {
reset();
return;
}
if (e.touches.length !== 1) {
reset();
return;
}
const t = e.touches[0];
const vw = window.innerWidth;
// Android system back-gesture lives in the L/R edge strip in
// edge-to-edge mode. Ignore touches there so we don't fight it.
if (t.clientX < EDGE_GUARD_PX || t.clientX > vw - EDGE_GUARD_PX) {
reset();
return;
}
startX = t.clientX;
startY = t.clientY;
engaged = false;
bailed = false;
lastDragPx = 0;
};
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 1) {
// Second finger landed mid-gesture — abort without commit.
if (engaged) setDragPx(0, false);
reset();
bailed = true;
return;
}
// Defensive symmetry with onTouchStart's disabled check: a sheet
// opening async between touchstart and touchmove (e.g. atom flip
// from a delayed effect) shouldn't let an already-armed pager
// gesture commit through.
if (disabledRef.current) {
if (engaged) setDragPx(0, false);
reset();
bailed = true;
return;
}
if (startX === null || startY === null || bailed) return;
const t = e.touches[0];
const dx = t.clientX - startX;
const dy = t.clientY - startY;
if (!engaged) {
// Wait for the finger to leave the dead-zone before deciding
// who owns the gesture. The pager only engages when |dx|
// strictly dominates |dy|; ties go to vertical (curtain +
// horseshoe pull-down feels more natural than horizontal
// commit for ambiguous gestures).
if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) return;
if (Math.abs(dy) >= Math.abs(dx)) {
bailed = true;
return;
}
engaged = true;
}
if (e.cancelable) e.preventDefault();
const vw = window.innerWidth;
const idx = activeRef.current;
const count = countRef.current;
let drag = dx;
// Rubber-band at the leftmost (dx > 0 = trying to go past idx 0)
// and rightmost (dx < 0 = trying to go past last idx) boundary.
// Soft attenuation — commit threshold can never be reached, so
// the spring-back on release lands us back on the current tab.
if (idx === 0 && dx > 0) drag = dx * RUBBER_BAND_FACTOR;
else if (idx === count - 1 && dx < 0) drag = dx * RUBBER_BAND_FACTOR;
// Clamp to ±one viewport so an overshooting swipe doesn't
// translate the strip into nonsense territory.
drag = Math.max(-vw, Math.min(vw, drag));
lastDragPx = drag;
setDragPx(drag, true);
};
const onTouchEnd = () => {
if (!engaged) {
reset();
return;
}
// Defensive recheck symmetric with onTouchStart / onTouchMove:
// an overlay sheet could have opened between the last touchmove
// and this touchend (atom flip from a delayed effect, a system
// dialog, etc.). Committing under those circumstances would
// navigate sibling tabs from beneath the overlay — same hazard
// the touchstart/move gates exist to prevent. Spring back
// instead.
if (disabledRef.current) {
setDragPx(0, false);
reset();
return;
}
const vw = window.innerWidth;
const idx = activeRef.current;
const count = countRef.current;
const threshold = Math.max(MIN_COMMIT_PX, vw * COMMIT_FRACTION);
let nextIdx = idx;
// Negative drag (finger moved left) → next tab to the right.
// Positive drag (finger moved right) → previous tab to the left.
if (lastDragPx <= -threshold && idx < count - 1) nextIdx = idx + 1;
else if (lastDragPx >= threshold && idx > 0) nextIdx = idx - 1;
if (nextIdx !== idx) {
commitTo(nextIdx);
} else {
// No commit — re-enable transition and animate the strip back
// to its resting position at the current tab.
setDragPx(0, false);
}
reset();
};
const onTouchCancel = () => {
// System cancel (incoming call, scroll-take-over, etc.) — never
// commit; just spring back if a drag was in flight.
if (engaged) setDragPx(0, false);
reset();
};
root.addEventListener('touchstart', onTouchStart, { passive: true });
root.addEventListener('touchmove', onTouchMove, { passive: false });
root.addEventListener('touchend', onTouchEnd, { passive: true });
root.addEventListener('touchcancel', onTouchCancel, { passive: true });
return () => {
root.removeEventListener('touchstart', onTouchStart);
root.removeEventListener('touchmove', onTouchMove);
root.removeEventListener('touchend', onTouchEnd);
root.removeEventListener('touchcancel', onTouchCancel);
};
// setDragPx / commitTo are stable useCallbacks from the parent;
// activeIdx / tabsCount are mirrored via the refs above so the
// listener reads fresh values without re-binding on every nav.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rootRef, setDragPx, commitTo]);
}

View file

@ -9,13 +9,17 @@ import React, {
useRef,
useState,
} from 'react';
import { Box, Header, Line, Scroll, Text, as, color, toRem } from 'folds';
import { Box, Header, Line, Scroll, Text, as, toRem } from 'folds';
import { useAtom } from 'jotai';
import classNames from 'classnames';
import { ContainerColor } from '../../styles/ContainerColor.css';
import * as css from './style.css';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { SIDEBAR_WIDTH_MIN, clampSidebarWidth, sidebarWidthAtom } from '../../state/sidebarWidth';
import {
SIDEBAR_WIDTH_MIN,
clampSidebarWidth,
sidebarWidthAtom,
} from '../../state/sidebarWidth';
import {
VOJO_HORSESHOE_VOID_COLOR,
VOJO_HORSESHOE_GAP_PX,
@ -78,9 +82,12 @@ export function PageRoot({ nav, children }: PageRootProps) {
TL/BL carves expose the outer's void. The explicit
Background bg on the inner is what keeps the panel's
apparent colour unchanged for routes whose content has no
opaque bg of its own without it the outer void would
bleed through. */}
<Box grow="Yes" style={{ minWidth: 0, backgroundColor: VOJO_HORSESHOE_VOID_COLOR }}>
opaque bg of its own (e.g. ChannelsLanding) without it
the outer void would bleed through. */}
<Box
grow="Yes"
style={{ minWidth: 0, backgroundColor: VOJO_HORSESHOE_VOID_COLOR }}
>
<Box
grow="Yes"
className={ContainerColor({ variant: 'Background' })}
@ -110,29 +117,10 @@ export function PageRoot({ nav, children }: PageRootProps) {
type ClientDrawerLayoutProps = {
children: ReactNode;
resizable?: boolean;
// Round the inner column's right-side corners (TR + BR). Off by
// default — the page-nav right rounding was intentionally dropped
// from the standard pattern in commit 74d32eb. Re-enabled only at
// callsites that explicitly opt in. Currently unused in the tree
// (the Settings nav tried it and was reverted on product feedback),
// kept as a primitive for future nested-horseshoe surfaces that
// want a fully-rounded "island" nav between two voids.
roundedRight?: boolean;
// Background surface tone for the inner column. Default
// `'background'` reads the standard `Background.Container` (Dawn
// bg2 = #0d0e11) — the deepest surface, what every tab's nav uses.
// `'surfaceVariant'` swaps to `color.SurfaceVariant.Container`
// (Dawn bg = #181a20) — the «raised» chat-pane tone used by 1-1
// chats and the composer; visually a step lighter than the DM
// list, marking the Settings nav as a distinct surface without
// jumping outside the Dawn palette.
surface?: 'background' | 'surfaceVariant';
};
export function PageNav({
size,
resizable,
roundedRight,
surface,
children,
}: ClientDrawerLayoutProps & css.PageNavVariants) {
const screenSize = useScreenSizeContext();
@ -140,24 +128,9 @@ export function PageNav({
const horseshoe = useHorseshoeEnabled();
if (resizable && !isMobile) {
// `ResizablePageNav` is a function declaration (hoisted) below — the
// forward reference is safe at runtime.
// eslint-disable-next-line no-use-before-define
return <ResizablePageNav>{children}</ResizablePageNav>;
}
const radii = toRem(VOJO_HORSESHOE_RADIUS_PX);
const roundedRightStyle =
roundedRight && horseshoe
? { borderTopRightRadius: radii, borderBottomRightRadius: radii }
: undefined;
// Inline `backgroundColor` overrides whatever `PageNavInnerWebHorseshoe`
// sets via vanilla-extract — inline style wins on specificity, so
// we can override the default `Background.Container` without
// touching the recipe.
const surfaceStyle =
surface === 'surfaceVariant' ? { backgroundColor: color.SurfaceVariant.Container } : undefined;
return (
<Box
grow={isMobile ? 'Yes' : undefined}
@ -168,19 +141,11 @@ export function PageNav({
grow="Yes"
direction="Column"
className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined}
// Top inset for native: `#root` no longer reserves the status-bar
// height (src/index.css), so the page-nav extends to the screen
// top. The padding here pushes the page-nav header (workspace
// tabs, etc.) below the status-bar icons. Applied at the inner
// column rather than at the `PageNavHeader` recipe because the
// recipe uses a Folds `<Header size="...">` with a fixed height —
// padding there would clip the header content. `--vojo-safe-top`
// is 0 on web and inside Modal500-hosted dialogs.
style={{
paddingTop: 'var(--vojo-safe-top, 0px)',
...roundedRightStyle,
...surfaceStyle,
}}
// Bottom inset for native: keeps any nav-footer row (SelfRow,
// WorkspaceFooter, …) clear of the Android gesture pill / 3-button
// bar / iOS home indicator after `#root` stopped reserving the
// inset itself. `var(--vojo-safe-bottom)` resolves to 0 on web.
style={{ paddingBottom: 'var(--vojo-safe-bottom)' }}
>
{children}
</Box>
@ -193,7 +158,9 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
const handleRef = useRef<HTMLDivElement>(null);
const horseshoe = useHorseshoeEnabled();
const [savedWidth, setSavedWidth] = useAtom(sidebarWidthAtom);
const [vw, setVw] = useState<number>(typeof window !== 'undefined' ? window.innerWidth : 1280);
const [vw, setVw] = useState<number>(
typeof window !== 'undefined' ? window.innerWidth : 1280
);
const [dragging, setDragging] = useState(false);
// Live width during a drag — kept in component state so we don't write to
// the localStorage-backed atom on every pointermove (hundreds of sync disk
@ -306,19 +273,14 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
grow="Yes"
direction="Column"
className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined}
// Same native safe-top inset as the regular PageNav above —
// `var(--vojo-safe-top)` is 0 on web (where resizable is used)
// but kept here for symmetry / future-proofing.
style={{ paddingTop: 'var(--vojo-safe-top, 0px)' }}
// See twin block in `PageNav` above — same native safe-area
// protection for any footer row mounted inside a resizable
// page-nav. On web `var(--vojo-safe-bottom)` is 0.
style={{ paddingBottom: 'var(--vojo-safe-bottom)' }}
>
{children}
</Box>
{canResize && (
// Canonical WAI-ARIA window-splitter pattern: focusable separator
// with aria-orientation and current/min/max values. The strict
// role-supports-aria-props lookup table doesn't model the splitter
// sub-pattern, but assistive tech does.
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/role-supports-aria-props, jsx-a11y/no-noninteractive-tabindex
<div
ref={handleRef}
role="separator"
@ -327,7 +289,6 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
aria-valuemin={SIDEBAR_WIDTH_MIN}
aria-valuemax={maxW}
aria-label="Resize sidebar"
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
className={css.PageNavResizeHandle}
// On web the page-nav is followed by the horseshoe void gap
@ -382,12 +343,7 @@ export function PageNavContent({
scrollRef?: MutableRefObject<HTMLDivElement | null>;
}) {
return (
// `minHeight: 0` is the canonical flexbox fix for a scroll child inside
// a flex column: without it the Scroll's intrinsic content height pushes
// the wrapper to that height, the column overflows the viewport, and
// every sibling with default `flex-shrink: 1` (header / footer rows)
// gets squashed below its natural height.
<Box grow="Yes" direction="Column" style={{ minHeight: 0 }}>
<Box grow="Yes" direction="Column">
<Scroll
ref={scrollRef}
variant="Background"
@ -402,29 +358,15 @@ export function PageNavContent({
);
}
type PageVariantProps = {
// Background surface tone. Default `'Surface'` (Dawn bg2, #0d0e11)
// — the deepest tone used by every sub-page elsewhere in the app.
// `'SurfaceVariant'` (Dawn bg, #181a20) is one notch lighter and
// used by the Settings sub-pages so they read on the same surface
// tone as the Settings menu (which itself uses
// `surface="surfaceVariant"` on its PageNav). Other variants
// (`'Background'`, `'Primary'`, etc.) pass through unchanged in
// case a future surface needs a different tone — see folds tokens
// for the full set.
variant?: 'Background' | 'Surface' | 'SurfaceVariant' | 'Primary' | 'Secondary';
};
export const Page = as<'div', PageVariantProps>(
({ className, variant = 'Surface', ...props }, ref) => (
export const Page = as<'div'>(({ className, ...props }, ref) => (
<Box
grow="Yes"
direction="Column"
className={classNames(ContainerColor({ variant }), className)}
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
{...props}
ref={ref}
/>
)
);
));
export const PageHeader = as<'div', css.PageHeaderVariants>(
({ className, outlined, balance, ...props }, ref) => (

View file

@ -79,14 +79,6 @@ export const PageNav = recipe({
'300': {
width: toRem(222),
},
// Used by the Settings nav — ~1.43× the regular 300 (~317px =
// 1.3 × 1.1 over 222px). Settings labels are long
// ("Notifications", "Emojis & Stickers", "Developer Tools") and
// the 222px column truncated them; the wider column also gives
// the nested-horseshoe void gap on the right room to breathe.
'350': {
width: toRem(317),
},
},
},
defaultVariants: {

View file

@ -256,10 +256,7 @@ export const RoomCard = as<'div', RoomCardProps>(
<Box gap="100">
<Icon size="50" src={Icons.User} />
<Text size="T200">
{t('Explore.members_count', {
count: joinedMemberCount,
formattedCount: millify(joinedMemberCount),
})}
{t('Explore.members_count', { count: millify(joinedMemberCount) })}
</Text>
</Box>
)}

View file

@ -7,7 +7,7 @@ import { getMemberDisplayName, getStateEvent } from '../../utils/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { timeDayMonYear, timeHourMinute } from '../../utils/time';
import { timeDayMonthYear, timeHourMinute } from '../../utils/time';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { RoomAvatar } from '../room-avatar';
import { nameInitials } from '../../utils/common';
@ -75,7 +75,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
i18nKey="Room.created_by"
values={{
creator: creatorName,
date: timeDayMonYear(ts),
date: timeDayMonthYear(ts),
time: timeHourMinute(ts),
}}
components={{ bold: <b /> }}

View file

@ -1,32 +0,0 @@
import React from 'react';
import { Icon, IconSrc } from 'folds';
import * as css from './StreamHeader.css';
type ChipProps = {
iconSrc: IconSrc;
label: string;
onClick: () => void;
// When the curtain covers the chip its row is `height: 0` /
// `overflow: hidden`. We also flip `tabIndex` so keyboard users
// can't focus the invisible button on desktop (where peek-drag is
// unavailable). Re-enabled when the row is revealed.
hidden: boolean;
};
// Pill-shaped reveal button shown when the user drags the curtain down
// to a peek stage. Same geometry as the inline form's input bar so the
// transition chip → input feels like a content swap, not a layout move.
export function Chip({ iconSrc, label, onClick, hidden }: ChipProps) {
return (
<button
type="button"
onClick={onClick}
className={css.chip}
tabIndex={hidden ? -1 : 0}
aria-hidden={hidden || undefined}
>
<Icon size="50" src={iconSrc} />
<span className={css.chipPlaceholder}>{label}</span>
</button>
);
}

View file

@ -1,28 +0,0 @@
import React, { forwardRef } from 'react';
import * as css from './StreamHeader.css';
type SegmentProps = {
active: boolean;
disabled?: boolean;
label: string;
onClick?: () => void;
};
// Tab segment for the StreamHeader row. Active state is communicated
// by a violet dot (folds `Primary.Main`) and a heavier font weight.
export const Segment = forwardRef<HTMLButtonElement, SegmentProps>(
({ active, disabled, label, onClick }, ref) => (
<button
ref={ref}
type="button"
onClick={disabled ? undefined : onClick}
aria-pressed={active}
aria-disabled={disabled || undefined}
className={css.segment({ active, disabled })}
>
<span aria-hidden className={css.segmentDot({ active })} />
{label}
</button>
)
);
Segment.displayName = 'Segment';

View file

@ -1,349 +0,0 @@
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { color, config, toRem } from 'folds';
import {
CHIP_GAP_PX,
CURTAIN_BREATHER_PX,
CURTAIN_RADIUS_PX,
CURTAIN_SNAP_EASING,
CURTAIN_SNAP_MS,
HANDLE_HEIGHT_PX,
TABS_ROW_PX,
WEB_TABS_ROW_PX,
} from './geometry';
// Stage. Position-relative anchor. The header itself paints the
// light-blue backdrop; the curtain is layered ABOVE it via z-index.
//
// In pager mode the bg collapses to transparent so the pager's static
// header (sitting behind the strip in DOM order) shows through every
// pixel the curtain isn't covering. See
// `mobile-tabs-pager/style.css.ts::pagerStaticHeader` for the full
// curtain-overlay contract. The strip is tagged
// `data-pager-pane="true"` in MobileTabsPager.tsx, which gates this
// selector.
export const stage = style({
position: 'relative',
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
backgroundColor: color.SurfaceVariant.Container,
selectors: {
'[data-pager-pane="true"] &': {
backgroundColor: 'transparent',
},
},
});
// Header — always-rendered strip carrying tabs row + (optional) chip
// reveal area + (optional) active form. The curtain slides on top of
// the area BELOW the tabs row to cover/reveal those children.
//
// In pager mode the bg collapses to transparent for the same reason as
// `stage` above — let the static pager header show through where the
// curtain isn't. Chips have their own pill bg and the inline form is
// composed of folds-styled inputs with their own backgrounds, so the
// peek/form snaps stay visually opaque without this layer.
export const header = style({
position: 'absolute',
top: 0,
left: 0,
right: 0,
display: 'flex',
flexDirection: 'column',
// Higher than `stage`, lower than `curtain` so the curtain occludes
// everything below the tabs row when raised.
zIndex: 1,
backgroundColor: color.SurfaceVariant.Container,
selectors: {
'[data-pager-pane="true"] &': {
backgroundColor: 'transparent',
},
},
});
// Tabs row. Stays fully visible regardless of curtain position
// because the curtain's `top` floor equals `TABS_ROW_PX` on native
// (`WEB_TABS_ROW_PX` on web — see `geometry.ts::WEB_TABS_ROW_PX`).
//
// Web variant: shrink to `WEB_TABS_ROW_PX` (= 54 px = folds Header
// `size="600"`) so the row reads at the same height as the right-pane
// room `PageHeader`, AND own the 1 px divider rule as a
// `border-bottom`. Putting the rule on `tabsRow` (not on the curtain
// as a `border-top`) is load-bearing for pixel alignment: with the
// global `* { box-sizing: border-box }` reset (`src/index.css`),
// `tabsRow`'s 1 px bottom border lands at y=53→54 inside the 54 px
// box — exactly where PageHeader's outlined border-bottom paints. If
// the rule lived on the curtain's `border-top` at `top: 54`, it would
// paint at y=54→55, off-by-one against the right pane.
export const tabsRow = style({
flexShrink: 0,
height: toRem(TABS_ROW_PX),
display: 'flex',
alignItems: 'center',
padding: `0 ${toRem(8)}`,
selectors: {
'[data-platform="web"] &': {
height: toRem(WEB_TABS_ROW_PX),
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
},
},
});
export const tabsCluster = style({
display: 'flex',
alignItems: 'center',
gap: toRem(4),
alignSelf: 'stretch',
});
export const iconsCluster = style({
display: 'flex',
alignItems: 'center',
gap: toRem(4),
flexShrink: 0,
});
// Curtain. Layered above the header (z-index higher). Its top edge
// moves with the snap state (and live finger drag); its bottom edge
// is anchored to the stage bottom so the curtain's `bottomPinned`
// child (DirectSelfRow / WorkspaceFooter) stays glued to the visible
// viewport bottom regardless of where the curtain's top is.
//
// On native, only the TOP corners are rounded: the bottom is meant
// to read as continuous with the always-visible bottomPinned row
// (DirectSelfRow is the curtain's last flex child) — adding
// `borderBottomRadius` would crop the row's corners against the
// curtain's `overflow: hidden`, which visually reads as «a light-
// blue strip cuts into the row».
//
// Live finger tracking and snap commits both flow through React state
// updates to `top` so the transition is always coordinated with the
// rendered position — disabled during drag, restored on commit.
//
// Web variant (`[data-platform="web"]` on `stage`, set by
// StreamHeader.tsx when `!isNativePlatform()`): there is no pin/peek
// gesture, so the curtain is a purely static slab under the tabs row.
// Drop ONLY the «card» rounding (top corners flat). The divider rule
// at the seam is owned by `tabsRow.borderBottom` under the same
// selector — that placement keeps the rule pixel-aligned with the
// right-pane `PageHeader`'s outlined border (see `tabsRow` comment
// above). The curtain bg stays `Background.Container` so the chat-
// row rows (`NavItem variant="Background"`) keep blending into one
// continuous list surface — if we made the curtain transparent the
// rows would paint as dark cards over the lighter
// `SurfaceVariant.Container` stage.
export const curtain = style({
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
zIndex: 2,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden',
backgroundColor: color.Background.Container,
borderTopLeftRadius: toRem(CURTAIN_RADIUS_PX),
borderTopRightRadius: toRem(CURTAIN_RADIUS_PX),
transition: `top ${CURTAIN_SNAP_MS}ms ${CURTAIN_SNAP_EASING}`,
// Hint the compositor while the curtain is moving. Cheap since the
// curtain is the only element in this stacking context that animates.
willChange: 'top',
selectors: {
'[data-platform="web"] &': {
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
},
});
// Drag handle at the top of the curtain. Dedicated touch surface for
// the pin / unpin gesture so it doesn't compete with the chat list's
// vertical scroll. `touchAction: none` keeps the browser from claiming
// the gesture for native scroll heuristics — our `touchmove` listener
// in `useCurtainHandleGesture` drives every pixel of motion.
//
// Sits as the first flex child of the curtain so the list (or
// DirectEmpty / equivalent placeholder) takes the remaining space
// below it. `flexShrink: 0` locks the height so a long list doesn't
// squash the hit-zone.
export const handle = style({
flexShrink: 0,
height: toRem(HANDLE_HEIGHT_PX),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
touchAction: 'none',
});
// Visual «grabber» pill centred inside `handle`. Semi-transparent
// foreground so the affordance reads as «draggable» without competing
// with content beneath. Pure decoration — the parent `handle` div
// captures the touch.
//
// State machine mirrors `PageNavResizeHandle` on desktop: a subtle
// resting state, a more prominent «being dragged» state, and an even
// more prominent «threshold reached, release to commit» state. The
// state-driving `data-dragging` / `data-at-commit` attributes live on
// the parent `handle` div (set by StreamHeader.tsx from the gesture
// hook). Transition durations match the desktop handle (140ms ease)
// so the two affordances feel related.
export const handleBar = style({
width: toRem(40),
height: toRem(4),
borderRadius: toRem(2),
backgroundColor: color.Background.OnContainer,
opacity: 0.25,
pointerEvents: 'none',
transition:
'opacity 140ms ease, width 140ms ease, height 140ms ease, background-color 140ms ease',
selectors: {
// Dragging but threshold not yet reached: highlight, slight grow.
'[data-dragging="true"] &': {
opacity: 0.55,
width: toRem(48),
backgroundColor: color.Primary.Main,
},
// Threshold reached during drag: full stretch + opacity. Releasing
// here commits pin (or unpin). Reads as «yes, you're there».
'[data-dragging="true"][data-at-commit="true"] &': {
opacity: 0.9,
width: toRem(64),
height: toRem(5),
backgroundColor: color.Primary.Main,
},
},
});
// Wrapper around `bottomPinned` inside the curtain. Anchored to the
// curtain's flex-bottom by virtue of being the last child. The TSX
// collapses this slot to `{ height: 0, overflow: hidden }` when the
// on-screen keyboard rises (via `VisualViewport.height` shrink) so
// the row neither paints nor claims flex space above the keyboard.
// Without this compensation, `interactive-widget=resizes-content`
// (global viewport meta — load-bearing for the room composer)
// shrinks the layout viewport, dragging every `bottom: 0` element
// up over the inline form. The DirectSelfRow ending up immediately
// above the keyboard would block the user's view of the form they're
// typing into.
export const bottomPinnedSlot = style({
flexShrink: 0,
});
// Segment button (Direct / Channels / Bots).
export const segment = recipe({
base: {
appearance: 'none',
border: 'none',
background: 'transparent',
color: color.Background.OnContainer,
cursor: 'pointer',
padding: `${toRem(8)} ${toRem(10)}`,
borderRadius: toRem(8),
font: 'inherit',
fontSize: toRem(14),
lineHeight: 1.2,
display: 'inline-flex',
alignItems: 'center',
gap: toRem(8),
whiteSpace: 'nowrap',
fontWeight: 500,
WebkitAppearance: 'none',
},
variants: {
active: {
true: { fontWeight: 600 },
},
disabled: {
true: { opacity: 0.45, cursor: 'default' },
},
},
});
// Active-state dot inside each segment.
export const segmentDot = recipe({
base: {
width: toRem(6),
height: toRem(6),
borderRadius: '50%',
flexShrink: 0,
},
variants: {
active: {
true: { backgroundColor: color.Primary.Main },
false: { backgroundColor: 'transparent' },
},
},
});
// Chip row — outer clip-strip. Both rows reveal together when the
// user drags the curtain down to the `peek` snap.
//
// The `marginBottom` math is load-bearing for the snap-top
// calculation: the resting `top` of `peek` lands the curtain exactly
// where the next row would have begun, so the breather never "steals"
// pixels from the next chip's paddingTop. Two different values:
// - default (chip-to-chip): `CHIP_GAP_PX` — tighter, so the two
// pills read as a related pair when both are revealed.
// - `:last-child` (chip-to-curtain): `CURTAIN_BREATHER_PX` — wider,
// so the curtain's rounded top has comfortable air above the
// chip pill it lands above.
export const chipRow = style({
height: toRem(56),
marginBottom: toRem(CHIP_GAP_PX),
paddingLeft: toRem(24),
paddingRight: toRem(24),
paddingTop: toRem(8),
display: 'flex',
alignItems: 'flex-start',
selectors: {
'&:last-child': {
marginBottom: toRem(CURTAIN_BREATHER_PX),
},
},
});
// The chip pill itself.
export const chip = style({
appearance: 'none',
border: 'none',
display: 'flex',
alignItems: 'center',
gap: toRem(10),
width: '100%',
height: toRem(48),
padding: `${toRem(8)} ${toRem(14)}`,
borderRadius: toRem(20),
font: 'inherit',
fontSize: toRem(14),
textAlign: 'left',
cursor: 'pointer',
backgroundColor: color.Background.Container,
color: color.Background.OnContainer,
WebkitAppearance: 'none',
});
export const chipPlaceholder = style({
opacity: 0.65,
whiteSpace: 'nowrap',
});
// Active form area in the header. Outer is `position: relative`; the
// inner mounted form fills it with `top: 0` so the form's first
// element (input bar) sits flush at the line where chips would
// otherwise live.
export const formArea = style({
position: 'relative',
flexShrink: 0,
overflow: 'hidden',
});
export const formInner = style({
position: 'absolute',
top: 0,
left: 0,
right: 0,
padding: `${toRem(8)} ${toRem(24)} ${toRem(12)}`,
});

View file

@ -1,551 +0,0 @@
import React, {
MutableRefObject,
ReactNode,
TransitionEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useMatch, useNavigate } from 'react-router-dom';
import { useAtomValue, useSetAtom } from 'jotai';
import { Box, Icon, IconButton, Icons, toRem } from 'folds';
import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths';
import { isNativePlatform } from '../../utils/capacitor';
import { useBotPresets } from '../../features/bots/catalog';
import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext';
import {
MobilePagerCurtainControls,
StreamHeaderPrimaryAction,
mobilePagerCurtainAtom,
} from '../../state/mobilePagerHeader';
import { TABS_ROW_PX, WEB_TABS_ROW_PX } from './geometry';
import { settingsSheetAtom } from '../../state/settingsSheet';
import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
import * as css from './StreamHeader.css';
import { Segment } from './Segment';
import { Chip } from './Chip';
import { isFormSnap, snapTopPx, useCurtainState } from './useCurtainState';
import { useCurtainHandleGesture } from './useCurtainHandleGesture';
import { useCurtainBodyGesture } from './useCurtainBodyGesture';
import { InlineNewChatForm } from './forms/InlineNewChatForm';
import { InlineRoomSearch } from './forms/InlineRoomSearch';
const INLINE_FORM_ID = 'stream-header-inline-form';
type StreamHeaderProps = {
// Scroll viewport that hosts the chat list under the curtain. The
// curtain BODY gesture (`useCurtainBodyGesture`) reads this ref's
// `scrollHeight`/`clientHeight` to decide whether to engage: long
// lists keep native scroll, short / empty lists drive the curtain
// via body drag. May be a ref whose `.current` is null on listing
// surfaces that render their empty state directly as a curtain
// child without wrapping it in `PageNavContent` (Direct's
// `DirectEmpty`, ChannelsRootNav's `ChannelsLanding`) — the body
// gesture treats null as «not scrollable» and engages.
scrollRef: MutableRefObject<HTMLDivElement | null>;
// Curtain contents — the chat list. The list is rendered inside an
// `overflow: auto` div that the gesture hook listens to.
children: ReactNode;
// Optional row(s) pinned to the bottom of the curtain (DirectSelfRow,
// WorkspaceFooter). Hidden while a form is active so the on-screen
// keyboard's viewport resize doesn't push them up over the form
// (see commit 14ed080).
bottomPinned?: ReactNode;
// Stable identifier used to persist the curtain's pinned overlay
// across listing-pane remounts (the user taps into a Room and back,
// which unmounts the listing pane). Pin state is stored in
// `curtainPinnedByTabAtom[pinKey]` so it outlives any individual
// StreamHeader instance. Each listing tab (Direct/Channels/Bots)
// passes its own key; the Channels landing CTA and workspace
// listing share `"channels"` so pin survives the toggle between
// empty state and a chosen workspace.
pinKey: string;
// Optional override for the Plus button. When omitted the header
// renders the default «new chat» action that opens InlineNewChatForm
// via the curtain. Channels overrides this with «create channel» /
// «create community» so the same Plus slot launches a contextual
// action instead of the DM-creation form.
primaryAction?: StreamHeaderPrimaryAction;
};
export function StreamHeader({
scrollRef,
children,
bottomPinned,
pinKey,
primaryAction,
}: StreamHeaderProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const bots = useBotPresets();
const navOpts = useMemo(() => ({ replace: isNativePlatform() }), []);
const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: false });
const botsMatch = useMatch({ path: BOTS_PATH, caseSensitive: true, end: false });
const channelsMatch = useMatch({ path: CHANNELS_PATH, caseSensitive: true, end: false });
const showBotsSegment = bots.length > 0 || !!botsMatch;
// Pager mode wiring. When this StreamHeader is mounted inside
// MobileTabsPager, the shared static tabs row at the pager root
// owns the visible segments + action icons; our local tabs row is
// kept in DOM (preserving the curtain's TABS_ROW_PX-based snap
// geometry) but rendered with `opacity: 0` (still tap-able). Only
// the currently active pane writes its curtain controls to
// `mobilePagerCurtainAtom` so the shared icons drive THIS curtain.
//
// `selectTabInstant` is the pager's tap-commit entrypoint: when our
// invisible per-pane Segments capture a tap (because the static
// header sits behind the strip in z-stack at rest), routing through
// this callback runs the same commit path as the static header's
// taps and suppresses the swipe-finish slide animation so tab
// switches feel snappy. Falls back to a plain `navigate(...)` on
// surfaces outside pager mode (desktop, non-listing routes).
const pagerPane = useMobilePagerPane();
const inPagerMode = pagerPane !== null;
const isActivePagerPane = pagerPane?.isActive ?? false;
const selectTabInstant = pagerPane?.selectTabInstant ?? null;
const onSegmentDirect = useCallback(() => {
if (selectTabInstant) selectTabInstant('direct');
else navigate(DIRECT_PATH, navOpts);
}, [selectTabInstant, navigate, navOpts]);
const onSegmentChannels = useCallback(() => {
if (selectTabInstant) selectTabInstant('channels');
else navigate(CHANNELS_PATH, navOpts);
}, [selectTabInstant, navigate, navOpts]);
const onSegmentBots = useCallback(() => {
if (selectTabInstant) selectTabInstant('bots');
else navigate(BOTS_PATH, navOpts);
}, [selectTabInstant, navigate, navOpts]);
const curtain = useCurtainState(pinKey);
// Suppress every curtain gesture whenever the user is interacting
// with something else that would otherwise race the pin path:
//
// * Settings sheet open (DirectSelfRow-originated bottom sheet) —
// a drag-up on the still-visible list above the sheet would
// mutate the pin atom underneath the sheet and the user would
// see an unexpected pinned curtain on dismissal.
// * Workspace switcher sheet open — same shape, on the Channels
// workspace surface.
// * Inactive pager pane — the strip clips offscreen panes so they
// shouldn't receive touches in practice, but bind defense-in-
// depth so a stray pointer event on a translateX'd pane never
// pins someone else's tab.
//
// Mirrors `MobileTabsPager.gestureDisabled` which suppresses the
// pager's OWN horizontal-swipe gesture under the same conditions.
const settingsSheetOpen = !!useAtomValue(settingsSheetAtom);
const workspaceSheetOpen = !!useAtomValue(channelsWorkspaceSheetAtom);
const offscreenPagerPane = inPagerMode && !isActivePagerPane;
const gestureDisabled = settingsSheetOpen || workspaceSheetOpen || offscreenPagerPane;
// Two parallel curtain-gesture surfaces:
//
// * `useCurtainHandleGesture` — the dedicated 32 px drag-handle
// at the top of the curtain. Crisp 1:1 finger ↔ curtain. From
// closed the gesture is a free-range drag spanning pin↔closed↔
// peek in one motion (`closed-free`); other snaps drive single-
// destination transitions (unpin / close-peek / form-close).
// Engages regardless of whether the chat list is scrollable —
// the handle is a distinct surface and never competes with list
// scroll. Only rendered on native (`isNativePlatform()`).
//
// * `useCurtainBodyGesture` — anywhere on the curtain body
// OUTSIDE the handle (chat list, empty-state placeholder).
// Rubber-banded (0.65) for all transitions, so the body drag
// reads as physically «heavier» than the handle's crisp pull.
// Engages only when the chat list has no scrollable content;
// additionally bails on touches that start inside the bottom-
// pinned slot (DirectSelfRow / WorkspaceFooter have their own
// drag-to-open bottom sheets) and on touches that start while
// pinned (unpin is HANDLE-only — the user has to grab the
// dedicated affordance to release the lock).
//
// Both hooks share `handleVisual` (mirrors desktop
// `PageNavResizeHandle`: `dragging` lights up the grabber pill;
// `atCommit` stretches + brightens it once the user crosses the
// per-transition commit threshold). The two surfaces are mutually
// exclusive on each touch (handle's listener short-circuits when
// the touch starts on the handle; body's listener does the same
// when it ISN'T on the handle), so they never fight over the
// visual.
const handleRef = useRef<HTMLDivElement>(null);
const curtainRef = useRef<HTMLDivElement>(null);
const bottomPinnedRef = useRef<HTMLDivElement>(null);
const [handleVisual, setHandleVisual] = useState<{ dragging: boolean; atCommit: boolean }>({
dragging: false,
atCommit: false,
});
useCurtainHandleGesture({
handleRef,
snap: curtain.snap,
pinned: curtain.pinned,
setPinned: curtain.setPinned,
setLiveDrag: curtain.setLiveDrag,
commit: curtain.commit,
disabled: gestureDisabled,
setHandleState: setHandleVisual,
});
useCurtainBodyGesture({
curtainRef,
handleRef,
bottomPinnedRef,
scrollRef,
snap: curtain.snap,
pinned: curtain.pinned,
setPinned: curtain.setPinned,
setLiveDrag: curtain.setLiveDrag,
commit: curtain.commit,
disabled: gestureDisabled,
setHandleState: setHandleVisual,
});
const isActive = isFormSnap(curtain.snap);
const openSearch = useCallback(() => curtain.open('search'), [curtain]);
const openChat = useCallback(() => curtain.open('chat'), [curtain]);
const { close } = curtain;
// Memoised controls object so the cleanup's identity check (atom
// compare-and-clear) is meaningful — without useMemo a fresh object
// would be created on every render and the cleanup of an earlier
// render would never match the atom's current contents.
const pagerControls = useMemo<MobilePagerCurtainControls>(
() => ({
openSearch,
openChat,
closeForm: close,
isFormActive: isActive,
primaryAction: primaryAction ?? null,
}),
[openSearch, openChat, close, isActive, primaryAction]
);
const setPagerCurtain = useSetAtom(mobilePagerCurtainAtom);
useEffect(() => {
if (!isActivePagerPane) return undefined;
setPagerCurtain(pagerControls);
// Compare-and-clear cleanup: only wipe the atom if it still holds
// OUR controls. If another pane became active between this render
// and the cleanup (rapid tab switch), it has already overwritten
// the atom with its own controls — we must not clobber that.
return () => {
setPagerCurtain((prev) => (prev === pagerControls ? null : prev));
};
}, [isActivePagerPane, pagerControls, setPagerCurtain]);
// Curtain's `top` is the resting snap position plus the live drag
// delta. React-driven (no inline DOM writes), so finger-tracking and
// commit happen in the same render pipeline and there's no
// intermediate "snap back, then animate" flash on release.
//
// When `pinned` is true the local snap (kept at {closed, peek,
// form-*}) is overridden — the curtain rests at y = 0 inside the
// stage (= y = safe-top in viewport), covering the tabs row. The
// global pinned atom shares this state across every listing tab so
// swiping between Direct / Channels / Bots preserves the lock.
//
// `platformOffset` is the web-only shift that lifts every non-pinned
// snap by the delta between native and web tabs-row heights. Tabs
// row on web is `WEB_TABS_ROW_PX` (= 54px, matching PageHeader on
// the right pane); `snapTopPx` is computed against `TABS_ROW_PX`
// (= 64px) which stays authoritative for native pin/peek geometry.
// Subtracting the delta on web realigns the closed/form snaps with
// the smaller tabs row without touching the snap-state machine.
// Pinned (= 0) doesn't need the offset because the safe-top + native
// contract owns that case and pinned is native-only.
const platformOffset = isNativePlatform() ? 0 : WEB_TABS_ROW_PX - TABS_ROW_PX;
const curtainTop = curtain.pinned
? 0 + curtain.liveDragPx
: snapTopPx(curtain.snap, curtain.formHeightPx) + platformOffset + curtain.liveDragPx;
// After the curtain settles at `closed`, unmount any lingering form.
// Guarded so unrelated transitionend events (e.g. children's own
// transitions bubbling up) don't drop the form mid-animation.
const onCurtainTransitionEnd = useCallback(
(evt: TransitionEvent<HTMLDivElement>) => {
if (evt.target !== evt.currentTarget) return;
if (evt.propertyName !== 'top') return;
curtain.acknowledgeClosed();
},
[curtain]
);
// On-screen keyboard detection via VisualViewport API. Global
// viewport-meta is `interactive-widget=resizes-content` (load-
// bearing for the room composer's keyboard-follow behaviour), which
// shrinks the layout viewport when a soft keyboard appears. Any
// `bottom: 0` child — including DirectSelfRow inside the curtain —
// rises with the shrunken viewport and ends up sitting RIGHT ABOVE
// the keyboard, blocking the inline form the user is typing into.
//
// Fix: when the keyboard is up, collapse the `bottomPinned` slot
// to zero height so it neither claims flex space at the curtain
// bottom nor renders above the keyboard. The user perceives the
// keyboard as overlaying everything below the form (matching their
// mental model: "клавиатура рисуется поверх кнопок и чатов, кнопка
// настройки остаётся прибитой снизу"). The row reappears the moment
// the keyboard retracts.
//
// The reference-height tracking mirrors `AuthLayout.tsx`: bump the
// reference upward on every grow (so rotation / keyboard-close
// events stay self-correcting) and treat a meaningful shrink (>=
// KEYBOARD_PROBE_PX) as «keyboard is up». The probe avoids
// spurious flips on small browser-chrome animations.
const [keyboardOpen, setKeyboardOpen] = useState(false);
useEffect(() => {
// Desktop browsers / Electron have no soft keyboard, but their
// VisualViewport DOES shrink on aggressive page zoom (Ctrl+`+`).
// That used to slip past as a hidden quirk while the curtain
// rendered as a rounded card; once the web variant flattened the
// curtain it became a visible regression — the DirectSelfRow at
// the bottom would collapse to height: 0 under zoom and read as
// broken layout. Gate the listener to native so the probe only
// arms where a real soft keyboard can actually appear.
if (!isNativePlatform()) return undefined;
const vv = window.visualViewport;
if (!vv) return undefined;
const KEYBOARD_PROBE_PX = 100;
let referenceH = vv.height;
let rafId: number | null = null;
const apply = () => {
rafId = null;
if (vv.height > referenceH) referenceH = vv.height;
setKeyboardOpen(referenceH - vv.height >= KEYBOARD_PROBE_PX);
};
const onResize = () => {
if (rafId === null) rafId = requestAnimationFrame(apply);
};
apply();
vv.addEventListener('resize', onResize);
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
vv.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={css.stage} data-platform={isNativePlatform() ? undefined : 'web'}>
<header className={css.header}>
{/* Tabs row + action icons (always visible)
In pager mode the row stays mounted (curtain snap math
depends on its TABS_ROW_PX height) but is painted invisible
via `opacity: 0` because the shared static tabs row at the
pager root owns the visible chrome. Critically opacity (not
`visibility: hidden`) keeps the row HIT-TESTABLE while
invisible the strip's stacking context paints ON TOP of
the static pager header at rest (the curtain-rises-over-
header contract), so without per-pane taps the segments
would be unreachable. Both rows wire onClick to the same
navigate() destination, so whichever row captures the tap
the user-visible result is identical. The static header z-
elevates only while a horseshoe sheet is active and the
active pane isn't pinned in that window the static header
sits above the strip and captures taps directly; the rest
of the time taps land on this opacity-0 row and resolve
through its own per-pane onClick handlers.
`aria-hidden` removes the duplicate (per-pane) segments and
icons from the accessibility tree so screen readers don't
announce three sets of "Direct / Channels / Bots" plus the
single visible set from the shared static header. */}
<div
className={css.tabsRow}
style={inPagerMode ? { opacity: 0 } : undefined}
aria-hidden={inPagerMode || undefined}
>
<div className={css.tabsCluster}>
<Segment
active={!!directMatch}
label={t('Direct.segment_dm')}
onClick={onSegmentDirect}
/>
<Segment
active={!!channelsMatch}
label={t('Direct.segment_channels')}
onClick={onSegmentChannels}
/>
{showBotsSegment && (
<Segment
active={!!botsMatch}
label={t('Direct.segment_bots')}
onClick={onSegmentBots}
/>
)}
</div>
<Box grow="Yes" />
{isActive ? (
<IconButton
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={close}
aria-label={t('Direct.close')}
aria-controls={INLINE_FORM_ID}
aria-expanded
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
) : (
<div className={css.iconsCluster}>
<IconButton
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={primaryAction ? primaryAction.onClick : openChat}
aria-label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
// `aria-controls` points at the curtain-mounted form
// region — drop it when `primaryAction` opens a portal
// dialog (`Modal` lives outside this subtree, so there
// is nothing to control here). `aria-haspopup="dialog"`
// + `aria-expanded={false}` stay accurate for both
// branches: the override opens a true Modal dialog.
aria-controls={primaryAction ? undefined : INLINE_FORM_ID}
aria-expanded={false}
aria-haspopup="dialog"
>
<Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} />
</IconButton>
<IconButton
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={openSearch}
aria-label={t('Search.search')}
aria-controls={INLINE_FORM_ID}
aria-expanded={false}
aria-haspopup="dialog"
>
<Icon size="100" src={Icons.Search} />
</IconButton>
</div>
)}
</div>
{/* Chips vs form
Mutually exclusive. While a form is mounted (including the
curtain's close-snap window before `acknowledgeClosed`), the
chips stay unrendered so the form doesn't visually jump
from y = TABS_ROW_PX to y = TABS_ROW_PX + 2·CHIP_ROW_PX
mid-animation.
When chips are rendered: always present in their fixed
header positions; the curtain occludes them by z-stacking.
As the user drags the curtain down, the chips reveal from
underneath naturally. `Chip.hidden` only controls keyboard
focus (the chip paints normally; the curtain's z-index does
the visual hiding). */}
{curtain.activeForm ? (
<div
id={INLINE_FORM_ID}
role="region"
aria-label={
curtain.activeForm === 'search'
? t('Search.search')
: t('Direct.create_chat_subtitle')
}
className={css.formArea}
style={{
height: toRem(curtain.formHeightPx ?? 0),
}}
>
<div ref={curtain.formMeasureRef} className={css.formInner}>
{curtain.activeForm === 'search' && <InlineRoomSearch onClose={close} />}
{curtain.activeForm === 'chat' && <InlineNewChatForm onClose={close} />}
</div>
</div>
) : (
<>
<div className={css.chipRow}>
<Chip
iconSrc={Icons.Search}
label={t('Search.search')}
onClick={openSearch}
hidden={curtain.snap !== 'peek'}
/>
</div>
<div className={css.chipRow}>
<Chip
iconSrc={primaryAction ? primaryAction.iconSrc : Icons.Plus}
label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
onClick={primaryAction ? primaryAction.onClick : openChat}
hidden={curtain.snap !== 'peek'}
/>
</div>
</>
)}
</header>
{/* Curtain layer
Renders ABOVE the header (z-index higher). `top` combines the
snap-derived resting position with the live finger drag one
React-controlled inline style, no ref-based DOM writes. The
transition is disabled during the drag and restored on commit
so the snap commit animates smoothly without an intermediate
"snap back then animate forward" flash. */}
<div
ref={curtainRef}
className={css.curtain}
style={{
top: toRem(curtainTop),
transition: curtain.isDragging ? 'none' : undefined,
}}
onTransitionEnd={onCurtainTransitionEnd}
>
{/* Drag handle native-only. On web (desktop browsers,
Electron) the curtain has no interactive snap states, so
the handle would be pure decoration with no behaviour
behind it; rendering it conditionally drops the 32 px
grabber strip on those surfaces and lets the chat list
sit flush against the curtain's rounded top.
On native the handle hosts the authoritative curtain
gesture (pin / unpin / peek / close-peek / form-close)
and stays mounted across snap transitions so the gesture
surface is always reachable when there is one to make.
`data-dragging` / `data-at-commit` mirror the desktop
`PageNavResizeHandle`: CSS selectors on `handleBar` light
the pill up Primary-blue + stretch it when these flip.
Both attrs are emitted/cleared only via React state set by
the gesture hook (dedup'd), so the handle visual updates
without slamming the DOM on every touchmove. */}
{isNativePlatform() && (
<div
ref={handleRef}
className={css.handle}
data-dragging={handleVisual.dragging || undefined}
data-at-commit={handleVisual.atCommit || undefined}
aria-hidden
>
<div className={css.handleBar} />
</div>
)}
{children}
{/* `bottomPinned` (DirectSelfRow, WorkspaceFooter) is kept
mounted across snaps so the curtain reads as a self-
contained "screen" with its bottom row always pinned to
the stage bottom. While the on-screen keyboard is up the
slot collapses to `height: 0` so it neither paints nor
claims flex space above the keyboard (see the
`keyboardOpen` effect above for the rationale). */}
{bottomPinned && (
<div
ref={bottomPinnedRef}
className={css.bottomPinnedSlot}
style={keyboardOpen ? { height: 0, overflow: 'hidden' } : undefined}
>
{bottomPinned}
</div>
)}
</div>
</div>
);
}

View file

@ -1,18 +0,0 @@
import React from 'react';
import { CreateChat } from '../../../features/create-chat';
type Props = {
// Called after the form successfully creates or navigates to an
// existing DM. The StreamHeader uses this to close the curtain over
// the form.
onClose: () => void;
};
// Thin shell around the shared `CreateChat` form. The legacy
// `/direct/_create` route renders the same component with a Page/
// PageHero shell; here we only feed it the `onClose` callback and the
// tighter `gap='400'` rhythm so the form fits comfortably under the
// header.
export function InlineNewChatForm({ onClose }: Props) {
return <CreateChat gap="400" onCreated={onClose} />;
}

View file

@ -1,244 +0,0 @@
import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar, Box, Icon, Icons, Scroll, Text, color, toRem } from 'folds';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
import { UnreadBadge, UnreadBadgeCenter } from '../../../components/unread-badge';
import { getAllParents, getDirectRoomAvatarUrl, getRoomAvatarUrl, guessPerfectParent } from '../../../utils/room';
import { nameInitials } from '../../../utils/common';
import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix';
import { highlightText } from '../../../plugins/react-custom-html-parser';
import { getDmUserId, useRoomSearch } from '../../../features/search/useRoomSearch';
import { SEARCH_FORM_BASE_PX } from '../geometry';
type Props = {
onClose: () => void;
};
// Inline search panel mounted in the StreamHeader. Shares all search
// logic with the global Search modal via `useRoomSearch`; only the
// presentation chrome differs (no Modal/Overlay/FocusTrap, a custom
// row layout that matches the inline aesthetic).
export function InlineRoomSearch({ onClose }: Props) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { navigateRoom, navigateSpace } = useRoomNavigate();
const openRoomId = useCallback(
(roomId: string, isSpace: boolean) => {
if (isSpace) navigateSpace(roomId);
else navigateRoom(roomId);
onClose();
},
[navigateRoom, navigateSpace, onClose]
);
const {
inputRef,
scrollRef,
roomsToRender,
result,
listFocus,
queryHighlightRegex,
handleInputChange,
handleInputKeyDown,
handleRoomClick,
getRoom,
mDirects,
orphanSpaces,
roomToParents,
roomToUnread,
myUserId,
} = useRoomSearch({ onOpenRoomId: openRoomId });
// Focus the input on mount. Inline form opens via an explicit user
// action (chip tap or icon click), so this is request-initiated
// focus rather than ambient `autoFocus` — keeps screen readers
// happy.
useEffect(() => {
inputRef.current?.focus();
}, [inputRef]);
return (
<Box direction="Column" gap="200" style={{ height: toRem(SEARCH_FORM_BASE_PX - 40) }}>
{/* ── Input bar (matches chip geometry: h=48 / r=20 / pad 8/14)
so the chip input morph reads as a content crossfade. */}
<Box
alignItems="Center"
style={{
backgroundColor: color.Background.Container,
borderRadius: toRem(20),
padding: `${toRem(8)} ${toRem(14)}`,
height: toRem(48),
gap: toRem(10),
}}
>
<Icon size="50" src={Icons.Search} />
<input
ref={inputRef}
type="text"
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
placeholder={t('Search.search')}
autoComplete="off"
style={{
flex: 1,
appearance: 'none',
border: 'none',
outline: 'none',
background: 'transparent',
font: 'inherit',
fontSize: toRem(14),
color: color.Background.OnContainer,
minWidth: 0,
}}
/>
</Box>
{/* ── Result list ────────────────────────────────────── */}
<Box grow="Yes" style={{ minHeight: 0 }}>
<Scroll ref={scrollRef} size="300" hideTrack visibility="Hover">
{roomsToRender.length === 0 && (
<Box
grow="Yes"
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
style={{ paddingTop: toRem(40) }}
>
<Text size="H6" align="Center">
{result ? t('Search.no_match_found') : t('Search.no_rooms')}
</Text>
<Text size="T200" align="Center" priority="300">
{result
? t('Search.no_match_for_query', { query: result.query })
: t('Search.no_rooms_to_display')}
</Text>
</Box>
)}
{roomsToRender.length > 0 && (
<Box direction="Column" gap="100" style={{ padding: `${toRem(4)} 0` }}>
{roomsToRender.map((roomId, index) => {
const room = getRoom(roomId);
if (!room) return null;
const dm = mDirects.has(roomId);
const dmUserId = dm ? getDmUserId(roomId, getRoom, myUserId) : undefined;
const dmUsername = dmUserId ? getMxIdLocalPart(dmUserId) : undefined;
const dmUserServer = dmUserId ? getMxIdServer(dmUserId) : undefined;
const allParents = getAllParents(roomToParents, roomId);
const orphanParents = allParents
? orphanSpaces.filter((o) => allParents.has(o))
: undefined;
const perfectOrphanParent =
orphanParents && guessPerfectParent(mx, roomId, orphanParents);
const exactParents = roomToParents.get(roomId);
const perfectParent =
exactParents && guessPerfectParent(mx, roomId, Array.from(exactParents));
const unread = roomToUnread.get(roomId);
const focused = listFocus.index === index;
return (
<button
key={roomId}
type="button"
data-focus-index={index}
data-room-id={roomId}
data-space={room.isSpaceRoom()}
onClick={handleRoomClick}
aria-pressed={focused}
style={{
appearance: 'none',
WebkitAppearance: 'none',
border: 'none',
backgroundColor: focused ? color.Primary.Main : 'transparent',
color: focused ? color.Primary.OnMain : color.Background.OnContainer,
borderRadius: toRem(12),
padding: `${toRem(8)} ${toRem(10)}`,
width: '100%',
display: 'flex',
alignItems: 'center',
gap: toRem(10),
cursor: 'pointer',
textAlign: 'left',
font: 'inherit',
}}
>
<Avatar size="200" radii={dm ? '400' : '300'}>
{dm || room.isSpaceRoom() ? (
<RoomAvatar
roomId={room.roomId}
src={
dm
? getDirectRoomAvatarUrl(mx, room, 32, useAuthentication)
: getRoomAvatarUrl(mx, room, 32, useAuthentication)
}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon
size="100"
joinRule={room.getJoinRule()}
roomType={room.getType()}
/>
)}
</Avatar>
<Box grow="Yes" alignItems="Center" gap="100" style={{ minWidth: 0 }}>
<Text size="T400" truncate>
{queryHighlightRegex
? highlightText(queryHighlightRegex, [room.name])
: room.name}
</Text>
{dmUsername && (
<Text as="span" size="T200" priority="300" truncate>
@
{queryHighlightRegex
? highlightText(queryHighlightRegex, [dmUsername])
: dmUsername}
</Text>
)}
{!dm && perfectParent && perfectParent !== perfectOrphanParent && (
<Text size="T200" priority="300" truncate>
{getRoom(perfectParent)?.name ?? perfectParent}
</Text>
)}
</Box>
<Box gap="100" alignItems="Center" shrink="No">
{dmUserServer && (
<Text size="T200" priority="300" truncate>
<b>{dmUserServer}</b>
</Text>
)}
{!dm && perfectOrphanParent && (
<Text size="T200" priority="300" truncate>
<b>{getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent}</b>
</Text>
)}
{unread && (
<UnreadBadgeCenter>
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</UnreadBadgeCenter>
)}
</Box>
</button>
);
})}
</Box>
)}
</Scroll>
</Box>
</Box>
);
}

View file

@ -1,162 +0,0 @@
// ────────────────────────────────────────────────────────────────────
// StreamHeader geometry — shared constants for the curtain layout.
//
// Mental model: the chats card is a curtain layered ABOVE the header
// (z-index higher). The curtain's `top` is the visible part of the
// header below the always-pinned tabs row. When the curtain is fully
// closed it sits flush under the tabs row (covering chips + form area
// beneath). Dragging it DOWN reveals more of the header from underneath.
// Dragging UP raises the curtain back over the header.
//
// Snap stops (curtain.top, px):
// pinned = 0 (curtain sits flush at top of the stage, tabs row
// covered; the safe-top status-bar strip above the
// stage stays painted by the surrounding context —
// see «pinned visual contract» below)
// closed = TABS_ROW_PX
// peek = TABS_ROW_PX + 2·CHIP_ROW_PX + CHIP_GAP_PX
// + CURTAIN_BREATHER_PX
// form:* = TABS_ROW_PX + formHeight + CURTAIN_BREATHER_PX
//
// Pinned visual contract: at `pinned` the curtain's top edge lands at
// y = safe-top in viewport coords (because the stage starts after the
// PageNav / appBody padding-top: var(--vojo-safe-top)). The system tray
// strip stays painted by appBody / PageNav-inner / MobileTabsPager's
// static header — all of which use `SurfaceVariant.Container` for that
// zone, so the colour is continuous across surfaces. The curtain MUST
// NOT extend into the safe-top zone (otherwise system text is covered)
// and MUST NOT add internal padding-top (otherwise the chat list grows
// visually taller). The clamp on the up-drag (= -TABS_ROW_PX) enforces
// the first invariant; we deliberately do not add any padding inside
// the curtain to enforce the second.
// ────────────────────────────────────────────────────────────────────
// Tabs row height. Always visible above the curtain.
export const TABS_ROW_PX = 64;
// Web-only tabs-row height override. Matches folds `<Header size="600">`
// = 3.375rem = 54 px, which is the height the right-side `PageHeader`
// in the room chat panel (see `components/page/Page.tsx::PageHeader`)
// renders at. The 1 px divider rule on web lives as `tabsRow.
// borderBottom` so — under the global `* { box-sizing: border-box }`
// reset — it lands at y=53→54 inside this 54 px box, exactly matching
// where PageHeader's `outlined: true` border-bottom paints on the
// right pane. The two panes thus share one visible header baseline.
//
// Native keeps `TABS_ROW_PX` because the pin-gesture travel
// (`PIN_TRAVEL_PX`) is anchored to it and shrinking it would change
// the curtain's snap geometry. Web has no pin gesture, so the override
// is applied through two coordinated levers:
// 1. CSS `[data-platform="web"] &` selectors on `tabsRow` (height
// → `WEB_TABS_ROW_PX`, plus the divider as `borderBottom`).
// 2. TSX `platformOffset = WEB_TABS_ROW_PX - TABS_ROW_PX` (= -10)
// added to `curtainTop` so the closed / peek / form snaps all
// ride the reduced tabs row without recomputing `snapTopPx`.
export const WEB_TABS_ROW_PX = 54;
// Each peek-chip row. Reveals one chip's pill (h=48) + 8px top breather.
export const CHIP_ROW_PX = 56;
// Vertical gap BETWEEN two consecutive chip rows. Separate from
// `CURTAIN_BREATHER_PX` so the inter-chip spacing can read tighter
// than the breather between the last chip and the curtain's rounded
// top (the curtain's straight edge against a chip pill needs more
// air to avoid feeling «clamped», while two pills sitting in a
// vertical stack want to read as a pair).
export const CHIP_GAP_PX = 14;
// Initial estimate for the search form's outer height. The actual
// height is measured at runtime via ResizeObserver and adapts to the
// available viewport so the form never overflows the chats card.
export const SEARCH_FORM_BASE_PX = 360;
// Breathing strip between the bottom of any header content (revealed
// chip pill, form's last actionable element) and the top of the
// curtain. Painted by the header's `SurfaceVariant.Container` (light-
// blue) so the chip / Create button / search results never visually
// touch the curtain's rounded top — the user reads chips that sit
// flush with the curtain as «зажатые» rather than two separate
// affordances. Not applied at `closed` (nothing to breathe to).
export const CURTAIN_BREATHER_PX = 20;
// Curtain snap transition. Tuned tight for an in-app reveal —
// emphasized-decelerate territory.
export const CURTAIN_SNAP_MS = 280;
export const CURTAIN_SNAP_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
// Curtain card top-corner radius. Matches the composer card and the
// horseshoe surfaces elsewhere in the app.
export const CURTAIN_RADIUS_PX = 24;
// Total vertical travel of the curtain between `closed` and `peek` —
// the resting-top delta between the two snaps. Used as the basis for
// the peek-commit threshold: the user must drag (rubber-banded) at
// least COMMIT_THRESHOLD × PEEK_TRAVEL_PX before release for the snap
// to flip. Anything shorter reads as accidental and springs back.
export const PEEK_TRAVEL_PX = CHIP_ROW_PX + CHIP_GAP_PX + CHIP_ROW_PX + CURTAIN_BREATHER_PX;
// Touch gesture tuning. RUBBER_BAND dampens finger→curtain motion so
// the chip reveal feels resistive; COMMIT_THRESHOLD is the fraction of
// the full peek travel the user must cross on release for the snap to
// commit. Tuned high (≈90%) so anything below «дотянул почти до конца»
// reads as accidental and snaps back to `closed`.
export const RUBBER_BAND = 0.65;
export const DIRECTION_DEAD_ZONE_PX = 10;
export const COMMIT_THRESHOLD = 0.9;
// Pull-up distance (raw finger px) required to close an active form.
export const ACTIVE_CLOSE_THRESHOLD_PX = 100;
// Total vertical CURTAIN travel for the closed ↔ pinned gesture.
// Equals the tabs row height because pinning lifts the curtain by
// exactly that distance (from y = TABS_ROW_PX down to y = 0 inside
// the stage).
export const PIN_TRAVEL_PX = TABS_ROW_PX;
// Commit threshold for pin / unpin. Tuned very high (≈95%) so the
// user must drag the curtain almost-all-the-way to the cap before
// release for the snap to flip. Anything shorter reads as accidental
// and springs back to the previous resting snap.
//
// On the handle the up direction is 1:1 with no upper clamp (the
// «closed-free» transition spans the full pin↔closed↔peek range in
// one gesture and the curtain follows the finger off-screen freely);
// the committing curtain DISPLACEMENT is still
// `PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX` ≈ 61 px — essentially «drag
// the curtain across the full tabs-row height». On the body the same
// displacement is reached with a longer finger pull because the body
// path is rubber-banded (×0.65).
//
// Unpin's clamp is asymmetric — `pinned-free` lower-bounds the live
// delta at 0 (no destination above pinned) but leaves the upper
// direction unclamped so the same gesture can carry the curtain
// through closed into peek territory in one motion. The handle-only
// contract on unpin means the body never resolves to `pinned-free`,
// so the no-upper-clamp tolerance only applies on the dedicated
// drag-handle.
export const PIN_COMMIT_THRESHOLD = 0.95;
// Drag-handle hit-zone at the top of the curtain. NATIVE-ONLY: the
// handle is rendered only when `isNativePlatform()` is true (see
// StreamHeader.tsx) — on web (desktop / Electron) the curtain has
// no interactive snap states, so the handle would be pure
// decoration and is omitted entirely.
//
// On native the handle is the AUTHORITATIVE gesture surface —
// closed-free / unpin / close-peek / form-close all bind here with
// 1:1 finger ↔ curtain tracking, no matter whether the chat list
// inside the curtain is scrollable. See `useCurtainHandleGesture`
// for the full state machine.
//
// A parallel `useCurtainBodyGesture` bound to the curtain's body
// handles drag from anywhere on the card, but only when the inner
// chat list has no scrollable content AND the curtain isn't pinned
// (unpin is handle-only). Its dynamics are rubber-banded so the
// body drag reads as physically «heavier» than the handle's crisp
// pull.
//
// Size: 32 px tall — enough touch target to land on comfortably with
// a thumb (the visible grabber pill inside is much smaller, see
// `StreamHeader.css.ts::handleBar`). The list (or DirectEmpty / the
// equivalent placeholder) starts 32 px below the curtain's top edge
// on native; on web the list sits flush at the curtain's top.
export const HANDLE_HEIGHT_PX = 32;

View file

@ -1,2 +0,0 @@
export { StreamHeader } from './StreamHeader';
export { TABS_ROW_PX, CHIP_ROW_PX, CURTAIN_SNAP_MS, CURTAIN_SNAP_EASING } from './geometry';

View file

@ -1,380 +0,0 @@
import { MutableRefObject, useEffect, useRef } from 'react';
import { isNativePlatform } from '../../utils/capacitor';
import {
ACTIVE_CLOSE_THRESHOLD_PX,
COMMIT_THRESHOLD,
DIRECTION_DEAD_ZONE_PX,
PEEK_TRAVEL_PX,
PIN_COMMIT_THRESHOLD,
PIN_TRAVEL_PX,
RUBBER_BAND,
} from './geometry';
import { CurtainSnap, isFormSnap } from './useCurtainState';
import {
assertNeverCurtainTransition,
CurtainTransition,
resolveCurtainTransition,
} from './useCurtainHandleGesture';
type Args = {
// The curtain element. Touch listeners bind here so anywhere on the
// curtain body — the chat list, an empty-state placeholder, the
// DirectSelfRow / WorkspaceFooter at the bottom — can drive a
// gesture. The handle's own listener (`useCurtainHandleGesture`)
// is bound to a child element of this curtain and runs first; we
// explicitly bail on touches that originate inside the handle so
// the two surfaces don't double-engage.
curtainRef: MutableRefObject<HTMLDivElement | null>;
// The handle element. Used solely to short-circuit our listener
// when the touch starts inside the handle's hit-zone (the handle
// hook has already armed for that touch).
handleRef: MutableRefObject<HTMLDivElement | null>;
// The `bottomPinned` slot at the bottom of the curtain (hosts
// DirectSelfRow, WorkspaceFooter). These rows open their own bottom
// sheets via vertical drag, so a touch that starts there must NOT
// engage the curtain body — otherwise the
// user's «pull settings up» gesture would also pin the curtain
// and the two motions would visually fight. `null` is fine (the
// surface has no bottomPinned content); the contains() check is
// optional-chained.
bottomPinnedRef: MutableRefObject<HTMLDivElement | null>;
// Scroll viewport of the chat list inside the curtain. The body
// gesture engages only when this element is NOT scrollable
// (scrollHeight ≤ clientHeight + 1): on long lists the user's
// vertical drag must remain a native scroll gesture, on short /
// empty lists the same drag drives the curtain instead. Treated
// as «not scrollable» when `scrollRef.current` is null (some
// listing surfaces render their empty state DIRECTLY as a curtain
// child, bypassing `PageNavContent` — `Direct.tsx::DirectEmpty`,
// `ChannelsRootNav::ChannelsLanding` — so scrollRef stays null and
// the body gesture must still engage).
scrollRef: MutableRefObject<HTMLDivElement | null>;
// Current snap stop. Mirrored into a ref so the listener — bound
// once per `disabled` flip — reads fresh values without rebinding.
snap: CurtainSnap;
// Per-pane pinned overlay; also ref-mirrored.
pinned: boolean;
setPinned: (next: boolean) => void;
// Live drag delta sink — feeds the curtain's `top` via React state,
// no direct DOM writes.
setLiveDrag: (px: number, dragging: boolean) => void;
// Snap commit (peek / close-peek / form-close). Narrowed to the two
// non-form destinations the hook ever reaches. pin/unpin flips
// `pinned` instead.
commit: (next: 'peek' | 'closed') => void;
// Suppress gesture binding entirely. Same conditions as the handle
// hook — see StreamHeader's `gestureDisabled`.
disabled?: boolean;
// Shared handle-visual sink. The grabber pill at the top of the
// curtain animates Primary-blue + stretches whenever the user has
// crossed the per-transition commit threshold, on ANY surface —
// handle or body. Dedupe inside the hook keeps consumer re-renders
// bounded to actual state flips.
setHandleState?: (state: { dragging: boolean; atCommit: boolean }) => void;
};
// Touch-gesture driver for the curtain BODY (everything outside the
// dedicated drag-handle). Native-only.
//
// Why a second surface? On listing surfaces with content that fits in
// one screen (empty Direct / Bots / Channels states, the ChannelsLanding
// CTA, a workspace with few rooms) the user's natural «pull the curtain
// down to peek» / «push the curtain up to pin» gestures happen anywhere
// on the visible card. Restricting all motion to the 32 px handle on
// these surfaces felt artificial. On the other hand, surfaces with a
// scrollable list need their native vertical scroll preserved — so the
// body gesture is *conditional*: it engages only when the chat list
// has no scrollable content (scrollHeight ≤ clientHeight + 1). Long
// lists keep using the handle for curtain motion.
//
// Dynamics: all transitions use rubber-band 0.65 (= RUBBER_BAND) so
// the body drag feels physically «heavier» than the handle's crisp
// 1:1 — the user reads the two surfaces as distinct affordances. The
// commit math is expressed in CURTAIN displacement (lastDelta), not
// raw finger pull, so a body «commit at PIN_COMMIT_THRESHOLD ×
// PIN_TRAVEL_PX» visually matches a handle commit at the same point —
// only the finger pull needed to get there differs.
//
// Form-snap override: when a form is mounted, the chat list under it
// is mostly hidden but still in DOM with whatever scrollHeight it has.
// Skip the scrollable-bail in that case — the body's visible area is
// the strip BELOW the form, and a drag there is unambiguously a
// form-close intent (the only valid transition from form-* snap).
//
// Pinned override: the body gesture is INERT while the curtain is
// pinned. Unpin is exclusively the handle's contract — the user has
// to grab the dedicated pin-handle to release the lock, so an
// accidental drag anywhere on the visible card doesn't undo it. We
// bail at touchstart so no listener side-effects (preventDefault,
// liveDrag emit, …) can fire either.
export function useCurtainBodyGesture({
curtainRef,
handleRef,
bottomPinnedRef,
scrollRef,
snap,
pinned,
setPinned,
setLiveDrag,
commit,
disabled,
setHandleState,
}: Args): void {
const snapRef = useRef<CurtainSnap>(snap);
snapRef.current = snap;
const pinnedRef = useRef<boolean>(pinned);
pinnedRef.current = pinned;
const setPinnedRef = useRef(setPinned);
setPinnedRef.current = setPinned;
const commitRef = useRef(commit);
commitRef.current = commit;
const setHandleStateRef = useRef(setHandleState);
setHandleStateRef.current = setHandleState;
useEffect(() => {
if (!isNativePlatform()) return undefined;
if (disabled) return undefined;
const curtain = curtainRef.current;
if (!curtain) return undefined;
let startX: number | null = null;
let startY: number | null = null;
let direction: 'up' | 'down' | null = null;
let transition: CurtainTransition | null = null;
let engaged = false;
let lastDelta = 0;
// Same dedupe pattern as the handle hook — re-render the consumer
// only on actual visual-state flips.
let emittedDragging = false;
let emittedAtCommit = false;
const emitHandle = (dragging: boolean, atCommit: boolean) => {
if (dragging === emittedDragging && atCommit === emittedAtCommit) return;
emittedDragging = dragging;
emittedAtCommit = atCommit;
setHandleStateRef.current?.({ dragging, atCommit });
};
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
// Pinned bail — handle owns unpin exclusively. See the «Pinned
// override» note above the hook for the rationale.
if (pinnedRef.current) return;
// Hand off to the handle hook if the touch starts inside the
// handle's 32 px hit-zone — the handle's own listener has
// already armed for this touch.
const target = e.target as Node | null;
if (target && handleRef.current?.contains(target)) return;
// Hand off to the bottomPinned region (DirectSelfRow,
// WorkspaceFooter). Those rows host their own drag-to-open
// bottom sheets — engaging the curtain gesture here would pin
// the curtain in parallel with the sheet opening, and the two
// motions would visually fight.
if (target && bottomPinnedRef.current?.contains(target)) return;
// Scroll-aware bail: leave a scrollable chat list to its native
// vertical scroll. Skipped in form-* snaps because the visible
// body area there is the strip BELOW the form (where the list
// mostly isn't paintable anyway), and form-close is the only
// valid transition — letting the list scroll instead would
// strand the user in the form.
const list = scrollRef.current;
if (!isFormSnap(snapRef.current) && list && list.scrollHeight > list.clientHeight + 1) {
return;
}
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
direction = null;
transition = null;
engaged = false;
lastDelta = 0;
};
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 1) {
// Second finger landed mid-gesture — abort.
startX = null;
startY = null;
direction = null;
transition = null;
if (engaged) setLiveDrag(0, false);
engaged = false;
lastDelta = 0;
emitHandle(false, false);
return;
}
if (startY === null) return;
const delta = e.touches[0].clientY - startY;
const deltaX = startX !== null ? e.touches[0].clientX - startX : 0;
if (direction === null) {
if (Math.abs(delta) < DIRECTION_DEAD_ZONE_PX) return;
// Horizontal-bail: pager horizontal swipe wins ties → we drop.
if (Math.abs(deltaX) > Math.abs(delta)) {
startX = null;
startY = null;
direction = null;
return;
}
direction = delta > 0 ? 'down' : 'up';
transition = resolveCurtainTransition(snapRef.current, pinnedRef.current, direction);
if (transition === null) {
// (snap, pinned, direction) has no valid motion — pinned+up,
// peek+down, form+down. Bail without preventDefault so any
// native default (overscroll bounce, etc.) can still play.
startX = null;
startY = null;
direction = null;
return;
}
}
engaged = true;
e.preventDefault();
// Per-transition rubber-band dynamics + atCommit semantics. All
// thresholds expressed against CURTAIN displacement (lastDelta)
// so the body and the handle commit at the same visual point,
// only the finger pull needed differs.
let atCommit = false;
switch (transition) {
case 'closed-free':
// Rubber-banded free-range drag spanning pin↔closed↔peek
// in one motion. NO clamps either side — the curtain
// follows the finger off-screen upward and continuously
// into peek territory downward. Direction-aware atCommit
// shows the right commit feedback for whichever side the
// user is leaning into. Mirrors the handle's `closed-free`
// but with 0.65× displacement so the body drag reads as
// physically «heavier».
lastDelta = delta * RUBBER_BAND;
atCommit =
lastDelta <= 0
? -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD
: lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
break;
case 'close-peek':
// Rubber-banded up. No clamp either side — matches the
// original list-bound peek feel; a downward jitter past the
// peek snap is visually negligible against the rubber-band
// damping.
lastDelta = delta * RUBBER_BAND;
atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
break;
case 'form-close':
// Rubber-banded up; capped at 0 so an accidental downward
// jitter doesn't push the curtain below its form-snap top.
lastDelta = Math.min(0, delta * RUBBER_BAND);
atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
break;
case 'pinned-free':
// Unreachable on the body — the pinned bail at touchstart
// prevents the hook from ever resolving this transition.
// Kept here so the `never` default below stays exhaustive
// and a future opening of pinned-free on the body would
// need to wire the dispatch explicitly.
break;
case null:
// Unreachable: `engaged` is set only after `transition` is
// resolved non-null in the dead-zone block above.
break;
default: {
assertNeverCurtainTransition(transition);
break;
}
}
setLiveDrag(lastDelta, true);
emitHandle(true, atCommit);
};
const onTouchEnd = () => {
if (!engaged) {
startX = null;
startY = null;
direction = null;
transition = null;
return;
}
switch (transition) {
case 'closed-free':
// Direction-aware commit, sign-exclusive: pin wins UP-side,
// peek wins DOWN-side, below both thresholds spring back to
// closed.
if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(true);
} else if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
commitRef.current('peek');
} else {
setLiveDrag(0, false);
}
break;
case 'close-peek':
if (-lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
commitRef.current('closed');
} else {
setLiveDrag(0, false);
}
break;
case 'form-close':
if (-lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX) {
commitRef.current('closed');
} else {
setLiveDrag(0, false);
}
break;
case 'pinned-free':
case null:
// Both unreachable per the touchmove switch above; the
// setLiveDrag fallback preserves spring-back behaviour if a
// future change exposes either path here.
setLiveDrag(0, false);
break;
default: {
assertNeverCurtainTransition(transition);
setLiveDrag(0, false);
break;
}
}
startX = null;
startY = null;
direction = null;
transition = null;
engaged = false;
lastDelta = 0;
emitHandle(false, false);
};
const onTouchCancel = () => {
if (engaged) setLiveDrag(0, false);
startX = null;
startY = null;
direction = null;
transition = null;
engaged = false;
lastDelta = 0;
emitHandle(false, false);
};
curtain.addEventListener('touchstart', onTouchStart, { passive: true });
curtain.addEventListener('touchmove', onTouchMove, { passive: false });
curtain.addEventListener('touchend', onTouchEnd, { passive: true });
curtain.addEventListener('touchcancel', onTouchCancel, { passive: true });
return () => {
curtain.removeEventListener('touchstart', onTouchStart);
curtain.removeEventListener('touchmove', onTouchMove);
curtain.removeEventListener('touchend', onTouchEnd);
curtain.removeEventListener('touchcancel', onTouchCancel);
// Same teardown contract as the handle hook — see its cleanup for
// the rationale. If `disabled` flips true while a body drag is in
// flight, the touchend never reaches us and the curtain would stay
// frozen at the finger position until the next touch.
if (engaged) {
setLiveDrag(0, false);
emitHandle(false, false);
}
};
// setLiveDrag is stable; the ref args are stable. `snap`, `pinned`,
// `setPinned` and `commit` are ref-mirrored. Only `disabled` needs
// to tear listeners down — it's the sole effect dep.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [curtainRef, handleRef, bottomPinnedRef, scrollRef, setLiveDrag, disabled]);
}

View file

@ -1,483 +0,0 @@
import { MutableRefObject, useEffect, useRef } from 'react';
import { isNativePlatform } from '../../utils/capacitor';
import {
ACTIVE_CLOSE_THRESHOLD_PX,
COMMIT_THRESHOLD,
DIRECTION_DEAD_ZONE_PX,
PEEK_TRAVEL_PX,
PIN_COMMIT_THRESHOLD,
PIN_TRAVEL_PX,
} from './geometry';
import { CurtainSnap, isFormSnap } from './useCurtainState';
type Args = {
// Drag-handle element at the top of the curtain. ALL curtain
// gestures bind here — the chat list's scroll viewport is left to
// native vertical scroll so finger-down inside the list never races
// a pin / peek / form-close path. Mounted as the first flex child
// of the curtain in StreamHeader.tsx.
handleRef: MutableRefObject<HTMLDivElement | null>;
// Current snap stop. Mirrored into a ref so the listener (bound
// once per `disabled` flip) reads fresh values without re-attaching.
// Every snap participates: closed → pin/peek, peek → close-peek,
// form-* → form-close, pinned-overlay → unpin.
snap: CurtainSnap;
// Per-pane pinned overlay. When true the handle's drag-down path
// commits unpin; when false the snap drives which transition arms.
pinned: boolean;
// Setter for the pinned overlay; called on release once the user's
// drag past the commit threshold qualifies the gesture.
setPinned: (next: boolean) => void;
// Setter for the live drag delta during touchmove. The hook reads
// `liveDragPx` from the parent state too, so React drives the
// curtain's `top` re-render — no direct DOM writes.
setLiveDrag: (px: number, dragging: boolean) => void;
// Snap commit. Called on release for peek / close-peek / form-close
// (the pin / unpin paths flip `pinned` instead). Narrowed to the
// two non-form destinations the hook ever reaches. Also resets
// liveDragPx + isDragging atomically inside the parent state.
commit: (next: 'peek' | 'closed') => void;
// Suppress gesture binding entirely. Used to gate motion when a
// bottom sheet is open or when this pane is inactive inside the
// swipe pager.
disabled?: boolean;
// Optional sink for handle-visual state — drives the grabber pill's
// «idle / dragging / threshold reached» appearance via
// `data-dragging` and `data-at-commit` on the handle div. Called
// only when the state actually changes, so the consumer doesn't pay
// a re-render on every touchmove.
setHandleState?: (state: { dragging: boolean; atCommit: boolean }) => void;
};
// Curtain transitions either gesture surface can resolve. Each one
// has its own commit threshold and release destination (snap commit
// vs pin flip); per-surface dynamics (1:1 on the handle, rubber-band
// on the curtain body) decide how raw finger displacement translates
// into curtain motion — see `onTouchMove` here for the 1:1 branches
// and `useCurtainBodyGesture` for the rubber-banded equivalents.
//
// `closed-free` is the single free-range transition that spans the
// full pin↔closed↔peek vertical range in one gesture. From the closed
// snap, neither direction is locked at the dead-zone: the user can
// drag up past the safe-top zone OR down through the chip area in
// one motion, and the release decides pin / peek / snap-back based
// on the final position. The earlier pair of one-shot `pin` and
// `peek` transitions used a hard «gate» at the start point (each
// direction was clamped to one side of 0 once the dead-zone resolved
// the direction) and the user reported this as a regression — drag
// up, then back down, ran into an invisible wall at the closed
// position before peek could engage.
//
// `pinned-free` is the symmetric free-range transition for the
// pinned overlay: from pinned + drag DOWN the curtain follows the
// finger all the way through closed into peek territory in one
// motion. On release, peek wins if the finger crossed the absolute
// peek planka (PIN_TRAVEL_PX + COMMIT_THRESHOLD × PEEK_TRAVEL_PX —
// the same visual point peek commits at from closed-free), unpin
// wins if at least the unpin threshold was reached, otherwise snap
// back to pinned. UP is no-op (no destination above pinned). Only
// the handle resolves to `pinned-free` — the body gesture bails at
// touchstart while pinned so unpin remains a deliberate handle pull.
export type CurtainTransition = 'closed-free' | 'pinned-free' | 'close-peek' | 'form-close';
// Exhaustive-check helper. Used in the `default` branch of every
// switch over `CurtainTransition | null` so that adding a fifth
// variant to the union fails typecheck at every dispatch site
// rather than silently no-op'ing through default. The argument is
// prefixed with `_` so eslint's `argsIgnorePattern: '^_'` keeps the
// rule happy without us tagging it `// eslint-disable`.
export const assertNeverCurtainTransition = (_value: never): void => {};
// Decide which transition the gesture arms based on the snap state
// at direction-resolution time and the finger direction. `null` means
// the (snap, pinned, direction) triple has no valid motion and the
// gesture must bail so native scroll / pager swipe / nothing-at-all
// owns the touch.
//
// Direction guards encoded here:
// * pinned + UP → no-op (would push the curtain past safe-top
// on commit — no destination above pinned).
// * pinned + DOWN → pinned-free (HANDLE-only contract — the body
// hook bails entirely while pinned so unpin /
// peek-from-pinned stays a deliberate handle
// pull. See
// `useCurtainBodyGesture::onTouchStart`).
// * closed (any) → closed-free (single transition spanning the
// whole pin↔closed↔peek range; direction at
// the dead-zone matters only for the
// horizontal-bail check).
// * peek + UP → close-peek (retreat to closed).
// * peek + DOWN → no-op (nothing lower to reveal).
// * form-* + UP → form-close.
// * form-* + DOWN → no-op (form is already the lowest snap).
export function resolveCurtainTransition(
snap: CurtainSnap,
pinned: boolean,
direction: 'up' | 'down'
): CurtainTransition | null {
if (pinned) return direction === 'down' ? 'pinned-free' : null;
if (snap === 'closed') return 'closed-free';
if (snap === 'peek') return direction === 'up' ? 'close-peek' : null;
if (isFormSnap(snap)) return direction === 'up' ? 'form-close' : null;
return null;
}
// Touch-gesture driver for the dedicated 32 px drag-handle at the top
// of the curtain. Native-only: listeners aren't attached on web /
// desktop.
//
// The handle is the «authoritative» gesture surface — it owns every
// transition (closed-free, pinned-free, close-peek, form-close)
// with crisp 1:1 finger ↔ curtain tracking regardless of whether
// the chat list inside the curtain is scrollable. The curtain BODY
// has a parallel gesture (`useCurtainBodyGesture`) with rubber-
// banded dynamics that only engages when the body's chat list has
// no scrollable content — so the user can pull the curtain «from
// anywhere» on empty / short lists but a real list-scroll is never
// hijacked under their finger. The body is also fully inert while
// pinned, so unpin (and unpin → peek overshoot) stays a deliberate
// handle pull.
//
// Design rationale: gestures used to bind to the chat list's scroll
// viewport directly, which produced repeating «drag-at-scrollTop=0
// hijacks for pin/peek» bugs. Moving every transition onto a
// dedicated handle (plus an opt-in body surface that bails on
// scrollable lists) removes the scroll/gesture race entirely.
//
// Per-transition dynamics — all track the finger 1:1, but the clamp
// shapes differ to keep on-screen motion sensible while preserving
// the «drag up off-screen from anywhere» feel the user explicitly
// asked for:
// * closed-free — NO clamps either side. Finger goes off-
// screen up → curtain follows past safe-top;
// finger crosses back below the start point →
// curtain continues into peek territory in
// the same gesture. Direction-aware commit
// on release: pin if pulled UP past
// PIN_COMMIT_THRESHOLD × PIN_TRAVEL_PX, peek
// if pulled DOWN past COMMIT_THRESHOLD ×
// PEEK_TRAVEL_PX, else snap back to closed.
// * pinned-free — DOWN-only free-range drag from pinned.
// Clamped at 0 below (no destination above
// pinned), NO upper clamp — the finger can
// carry the curtain through closed into
// peek territory in one motion. Release
// decides peek (lastDelta ≥ PIN_TRAVEL_PX +
// COMMIT_THRESHOLD × PEEK_TRAVEL_PX), unpin
// (lastDelta ≥ PIN_COMMIT_THRESHOLD ×
// PIN_TRAVEL_PX), or snap back to pinned.
// * close-peek — capped at 0 below (no transition lower
// than peek), NO upper clamp (drag past
// closed into safe-top freely). Commit at
// COMMIT_THRESHOLD × PEEK_TRAVEL_PX.
// * form-close — capped at 0 so a downward jitter can't
// push the curtain below its form-snap top,
// NO upper clamp. Commit at
// ACTIVE_CLOSE_THRESHOLD_PX (absolute).
//
// Handle visual: emitHandle(true, atCommit) fires on every transition
// during touchmove so the grabber pill animates Primary-blue +
// stretches as the user crosses the commit threshold, no matter which
// motion is in flight. The dedupe keeps consumer re-renders bounded
// to actual state flips. The body hook shares the same setHandleState
// sink — only one of the two surfaces is engaged at any moment, so
// they never fight over the visual.
export function useCurtainHandleGesture({
handleRef,
snap,
pinned,
setPinned,
setLiveDrag,
commit,
disabled,
setHandleState,
}: Args): void {
const snapRef = useRef<CurtainSnap>(snap);
snapRef.current = snap;
const pinnedRef = useRef<boolean>(pinned);
pinnedRef.current = pinned;
const setPinnedRef = useRef(setPinned);
setPinnedRef.current = setPinned;
const commitRef = useRef(commit);
commitRef.current = commit;
const setHandleStateRef = useRef(setHandleState);
setHandleStateRef.current = setHandleState;
useEffect(() => {
if (!isNativePlatform()) return undefined;
if (disabled) return undefined;
const handle = handleRef.current;
if (!handle) return undefined;
let startX: number | null = null;
let startY: number | null = null;
let direction: 'up' | 'down' | null = null;
let transition: CurtainTransition | null = null;
let engaged = false;
let lastDelta = 0;
// Last visual state emitted to the consumer. We dedupe here so the
// setter (a React useState) only re-renders when something actually
// changed, not on every 60fps touchmove.
let emittedDragging = false;
let emittedAtCommit = false;
const emitHandle = (dragging: boolean, atCommit: boolean) => {
if (dragging === emittedDragging && atCommit === emittedAtCommit) return;
emittedDragging = dragging;
emittedAtCommit = atCommit;
setHandleStateRef.current?.({ dragging, atCommit });
};
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
direction = null;
transition = null;
engaged = false;
lastDelta = 0;
};
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 1) {
// Second finger landed mid-gesture — abort.
startX = null;
startY = null;
direction = null;
transition = null;
if (engaged) setLiveDrag(0, false);
engaged = false;
lastDelta = 0;
emitHandle(false, false);
return;
}
if (startY === null) return;
const delta = e.touches[0].clientY - startY;
const deltaX = startX !== null ? e.touches[0].clientX - startX : 0;
// Resolve a direction once the finger crosses the dead-zone.
if (direction === null) {
if (Math.abs(delta) < DIRECTION_DEAD_ZONE_PX) return;
// Horizontal-bail: if |dx| strictly exceeds |dy|, the user is
// swiping the mobile tab pager, not pulling the curtain. Drop
// tracking so the pager owns the gesture.
if (Math.abs(deltaX) > Math.abs(delta)) {
startX = null;
startY = null;
direction = null;
return;
}
direction = delta > 0 ? 'down' : 'up';
transition = resolveCurtainTransition(snapRef.current, pinnedRef.current, direction);
// (snap, pinned, direction) has no valid motion — pinned+up,
// peek+down, form+down. Bail so the gesture can be re-armed on
// the next touch sequence; no preventDefault is fired so the
// browser keeps any default behaviour (it would be a no-op
// here anyway — the handle has touchAction:none in CSS).
if (transition === null) {
startX = null;
startY = null;
direction = null;
return;
}
}
engaged = true;
e.preventDefault();
// Clamp the raw finger delta into the live curtain displacement
// (`lastDelta`). Stored separately because the commit math on
// release needs the same value the curtain was visually showing.
let atCommit = false;
switch (transition) {
case 'closed-free':
// Single free-range drag spanning pin↔closed↔peek. 1:1 with
// NO clamps either side: the curtain follows the finger off-
// screen upward (past safe-top) and continuously into peek
// territory downward in the same gesture. The release decides
// pin / peek / snap-back from the final lastDelta.
lastDelta = delta;
// Direction-aware atCommit so the grabber pill stretches
// whichever way the user is committing. Pin and peek are
// sign-exclusive (one branch can't fire simultaneously with
// the other) so a simple ternary on `lastDelta` suffices.
atCommit =
lastDelta <= 0
? -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD
: lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
break;
case 'pinned-free':
// 1:1 down from pinned. Clamped at 0 below (a downward
// jitter past the start mustn't push the curtain into
// safe-top — there's no destination above pinned), NO
// upper clamp — the curtain follows the finger through
// closed into peek territory in one motion.
lastDelta = Math.max(0, delta);
// atCommit fires as soon as ANY commit qualifies (the
// grabber pill stretches to signal «release works here»);
// it stays true past the unpin threshold all the way
// through peek, since both are valid landing zones.
atCommit = lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD;
break;
case 'close-peek':
// 1:1 up; delta is negative. Lower-capped at 0 (a downward
// jitter shouldn't push past the peek snap), NO upper clamp
// — the curtain follows the finger off-screen freely in the
// safe-top direction, matching the «drag up off-screen from
// anywhere» expectation.
lastDelta = Math.min(0, delta);
atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
break;
case 'form-close':
// Form close: finger moves UP (delta < 0). Track 1:1, capped
// at 0 so an accidental downward jitter doesn't push the
// curtain below its resting form-snap position.
lastDelta = Math.min(0, delta);
atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
break;
case null:
// Unreachable: `engaged` is set only after `transition` is
// resolved non-null in the dead-zone block above; reaching
// this case would imply the gesture engaged without a
// transition, which the control flow above forbids.
break;
default: {
// Exhaustive guard. The `never` cast turns a future addition
// to `CurtainTransition` into a compile error here — adding
// a fifth member without wiring its dispatch fails typecheck.
assertNeverCurtainTransition(transition);
break;
}
}
setLiveDrag(lastDelta, true);
emitHandle(true, atCommit);
};
const onTouchEnd = () => {
if (!engaged) {
startX = null;
startY = null;
direction = null;
transition = null;
return;
}
// Commit decision per transition. setPinned() and commit() each
// reset liveDragPx + isDragging in the same batched update —
// React renders the curtain at the new resting top with the snap
// transition re-enabled. Non-commit paths drop the live drag back
// to 0 with transition active so the curtain springs back.
switch (transition) {
case 'closed-free':
// Direction-aware commit from the free-range drag. Pin
// wins over peek if both somehow qualified (sign-exclusive
// in practice — lastDelta can't be simultaneously <0 and
// >0). Below either threshold, spring back to closed.
if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(true);
} else if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
commitRef.current('peek');
} else {
setLiveDrag(0, false);
}
break;
case 'pinned-free':
// Two-tier commit: peek wins if the finger crossed the
// absolute peek planka (matches the visual point peek
// commits at from closed-free — PIN_TRAVEL_PX to get to
// closed + COMMIT_THRESHOLD × PEEK_TRAVEL_PX through the
// chip area); otherwise unpin if at least the unpin
// threshold was reached; else snap back to pinned.
//
// The peek branch MUST clear `pinned` before committing
// the snap. The curtain's resting top is
// `pinned ? 0 : snapTopPx(snap)` — so commit('peek')
// alone would set snap='peek' yet leave the curtain
// visually at top=0 (the pin overlay wins). Both updates
// batch into one render inside this touchend handler.
if (lastDelta >= PIN_TRAVEL_PX + COMMIT_THRESHOLD * PEEK_TRAVEL_PX) {
setPinnedRef.current(false);
commitRef.current('peek');
} else if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(false);
} else {
setLiveDrag(0, false);
}
break;
case 'close-peek':
if (-lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
commitRef.current('closed');
} else {
setLiveDrag(0, false);
}
break;
case 'form-close':
if (-lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX) {
commitRef.current('closed');
} else {
setLiveDrag(0, false);
}
break;
case null:
// Unreachable: `engaged` is set only after `transition` is
// resolved non-null. Mirrors the touchmove switch.
setLiveDrag(0, false);
break;
default: {
// Exhaustive guard — see the touchmove switch for the same
// pattern. setLiveDrag fallback preserves spring-back if a
// future transition lands here unhandled at runtime.
assertNeverCurtainTransition(transition);
setLiveDrag(0, false);
break;
}
}
startX = null;
startY = null;
direction = null;
transition = null;
engaged = false;
lastDelta = 0;
emitHandle(false, false);
};
const onTouchCancel = () => {
// System cancel never commits — always snap back to current snap.
if (engaged) setLiveDrag(0, false);
startX = null;
startY = null;
direction = null;
transition = null;
engaged = false;
lastDelta = 0;
emitHandle(false, false);
};
handle.addEventListener('touchstart', onTouchStart, { passive: true });
handle.addEventListener('touchmove', onTouchMove, { passive: false });
handle.addEventListener('touchend', onTouchEnd, { passive: true });
handle.addEventListener('touchcancel', onTouchCancel, { passive: true });
return () => {
handle.removeEventListener('touchstart', onTouchStart);
handle.removeEventListener('touchmove', onTouchMove);
handle.removeEventListener('touchend', onTouchEnd);
handle.removeEventListener('touchcancel', onTouchCancel);
// If `disabled` flips true while a drag is in flight, the touchend
// we'd normally rely on for snap-back never reaches us (the listener
// is gone). Without an explicit reset the curtain stays frozen at
// the finger position with `transition: none` and the grabber pill
// stuck Primary-blue until the user starts a new touch — visible as
// a half-open curtain after, say, a sheet opens mid-drag.
if (engaged) {
setLiveDrag(0, false);
emitHandle(false, false);
}
};
// setLiveDrag is a stable useCallback; handleRef is stable. `snap`,
// `pinned`, `setPinned` and `commit` are mirrored via the refs
// above so the listener (bound once per `disabled` flip) reads
// fresh values without re-attaching every render. `disabled` is
// the only signal that needs to tear the listeners down — it goes
// into the deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [handleRef, setLiveDrag, disabled]);
}

Some files were not shown because too many files have changed in this diff Show more