Compare commits

...

87 Commits

Author SHA1 Message Date
byGalax e8a5d6dc5e chore(desktop): release v0.19.0 2026-05-16 19:25:55 +02:00
byGalax c5851195c7 fix(call): strip-toggle icon no longer touches its frame edges
Shrink the StripToggleIcon SVG from 18x18 to 14x14 px so the 2-people
icon sits centred with ~11 px of breathing room inside its 36x36 button,
matching the h-4/w-4 small-icon convention used elsewhere in InCallPanel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 19:21:26 +02:00
byGalax 258ad3511d fix(screen-share): preset dims no longer crop non-16:9 monitors
Pass width/height as { ideal: N } constraints (not exact values) so
Chromium preserves the source's aspect ratio instead of cropping
non-matching monitors. Previously a 1920x1200 monitor under a 1080p
preset lost the bottom 120px including the Windows taskbar.

The auto-preset fallback now omits width/height entirely (both set to
undefined) so getDisplayMedia uses the screen's native resolution.
Cast through `unknown` to satisfy LiveKit's VideoResolution type, which
declares width/height as plain number but Chromium accepts the full
MediaTrackConstraints shape at runtime.
2026-05-16 19:19:30 +02:00
byGalax 2583613d7f feat(P3.T7): RemoteRevokedScreen overlay for own-device revocation 2026-05-16 19:15:15 +02:00
byGalax 04d9909e92 feat(P3.T6): Settings 'Geräte' tab with revoke + this-device badge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 19:13:31 +02:00
byGalax e05f5445e7 feat(P3.T5): useOwnDevices hook with realtime refresh
Adds useOwnDevices React hook that fetches the user device list via
listOwnDevices and re-fetches on any postgres_changes event on the
devices table, matching the channel/filter convention in AuthContext.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:10:48 +02:00
byGalax a4f7a16c90 feat(P3.T4): ensure device row + revoke-realtime + revokedRemotely flag 2026-05-16 19:08:17 +02:00
byGalax f6e0e4dd09 feat(P3.T3): app:hostname IPC for device-name default 2026-05-16 19:04:03 +02:00
byGalax d449e128a8 docs(hotfix): screen-share taskbar crop + strip-toggle icon padding plan 2026-05-16 18:59:59 +02:00
byGalax 5b0aa24a8d feat(P3.T2): DeviceRecord.revokedAt + revokeDevice wrapper
Extend DeviceRecord with revokedAt field, update registerDevice and
listOwnDevices selects to include revoked_at, add revokeDevice RPC
helper, update db-types to reflect T1 migration schema, and add
vitest coverage for all new behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 18:52:34 +02:00
byGalax 540b506f91 feat(P3.T1): devices.revoked_at + revoke_device RPC + realtime publication
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 18:48:22 +02:00
byGalax ede0d00f5b docs(P3): Phase 3 implementation plan (Session/Device List + Revoke) 2026-05-16 18:45:46 +02:00
byGalax ef1f9f45d8 fix(desktop): swap GIF provider from Tenor (closed Jan 2026) to GIPHY
Google closed Tenor v2 to new API clients in Jan 2026, so the only
people who could use the picker were those with a pre-existing Google
Cloud Console key. Swapped to GIPHY's Developer API (still open, free
keys at https://developers.giphy.com/dashboard/).

- Env var renamed VITE_TENOR_API_KEY → VITE_GIPHY_API_KEY
- Endpoint, response mapping, error sentinel updated
- File still named tenor.ts for import-path stability — renaming
  later if it bothers anyone
- Public GifResult interface unchanged so the picker UI didn't need
  edits beyond the error-message switch
2026-05-16 18:25:18 +02:00
byGalax 95156a65eb feat(desktop): view-once image attachments (sender toggle + recipient lightbox + tombstone)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:18:39 +02:00
byGalax 915569db39 feat(shared): mark_attachment_viewed + view_once on AttachmentHandle 2026-05-16 18:12:15 +02:00
byGalax 526f9d7bcc feat(db): message_attachments.view_once + mark_attachment_viewed RPC 2026-05-16 18:09:36 +02:00
byGalax cd79b77e6f feat(desktop): GIF button + picker wired into composer 2026-05-16 18:07:40 +02:00
byGalax 7726498218 feat(desktop): GIF picker popover (Tenor + trending/search/recent) 2026-05-16 18:05:45 +02:00
byGalax f1d9602a76 fix(desktop): read Tenor API key from VITE_TENOR_API_KEY env, no hardcoded credential 2026-05-16 18:03:20 +02:00
byGalax 45bcbb449c feat(desktop): Tenor v2 GIF search/featured/recent client 2026-05-16 18:02:01 +02:00
byGalax ef98efb938 feat(desktop): mention notifications via realtime + osNotify 2026-05-16 17:59:57 +02:00
byGalax 7355b343c8 feat(shared): sendEncryptedMessage inserts mention rows after the message row 2026-05-16 17:57:52 +02:00
byGalax 1a39c1bb33 feat(shared): parseMentionUsernames + insertMentions helpers 2026-05-16 17:55:20 +02:00
byGalax 70209be1de feat(db): message_mentions table (RLS: mentioned user + author can SELECT) 2026-05-16 17:52:40 +02:00
byGalax 3dbeabd268 feat(desktop): pin/unpin from message context menu + side panel 2026-05-16 17:51:01 +02:00
byGalax 6bff163f09 feat(desktop): pinned-messages side panel 2026-05-16 17:47:39 +02:00
byGalax ddeedeb71d feat(desktop): pinned-messages pill in conv header 2026-05-16 17:45:43 +02:00
byGalax 7e85ffe548 feat(desktop): usePinnedMessages live hook 2026-05-16 17:43:45 +02:00
byGalax 0d30a462b3 feat(shared): pinned-messages list/pin/unpin helpers 2026-05-16 17:42:09 +02:00
byGalax 745b8cd69d feat(db): pinned_messages table (≤5 per conv via trigger) 2026-05-16 17:39:42 +02:00
byGalax 5ace6735d8 docs(plan): phase 2 — messaging (pinned / mentions / GIF / view-once) 2026-05-16 17:36:21 +02:00
byGalax ffaa6ceb70 feat(mobile): SecurityCenter — PIN change, recovery, reset, migration retry 2026-05-16 17:23:04 +02:00
byGalax ccac822cb9 feat(mobile): settings hub with security section + signout 2026-05-16 17:21:57 +02:00
byGalax 74a0ef6c32 refactor(mobile): chats header opens settings instead of inline logout 2026-05-16 17:18:56 +02:00
byGalax 9de2c368bf feat(desktop): optional wipe-on-close (Settings -> Sicherheit) 2026-05-16 17:17:59 +02:00
byGalax 88345420e0 refactor(mobile): AttachmentImage drops device-keyed props 2026-05-16 17:17:37 +02:00
byGalax 0354676d2f refactor(mobile): MessageBubble drops ownDeviceId prop 2026-05-16 17:17:30 +02:00
byGalax cb7bd99fb4 refactor(mobile): conversation detail uses ownUserId + drops senderDeviceId 2026-05-16 17:13:47 +02:00
byGalax 3b55fcaf2c feat(mobile): (app) layout routes by userKeyState 2026-05-16 17:11:05 +02:00
byGalax 7ae1d5ba8c feat(mobile): UserKey unlock screen (PIN entry + recovery tab + reset) 2026-05-16 17:09:50 +02:00
byGalax 50bfb5b137 feat(desktop): wipe local crypto + caches on sign-out 2026-05-16 17:09:01 +02:00
byGalax 41359816f1 feat(mobile): UserKey setup screen (PIN + optional recovery code) 2026-05-16 17:08:47 +02:00
byGalax 8ad212291a feat(desktop): apply friend nicknames in header / bubble / mentions / call tile 2026-05-16 17:06:18 +02:00
byGalax 18a6365586 feat(mobile): PinInput component — 6-digit numeric pad 2026-05-16 17:04:25 +02:00
byGalax 898b7469bb feat(desktop): set-nickname dialog + right-click trigger in friends list 2026-05-16 17:01:38 +02:00
byGalax a5eadef663 feat(desktop): friendNicknames local store + useNickname hook 2026-05-16 16:58:14 +02:00
byGalax e8074e7da0 feat(desktop): empty-state for empty conversation 2026-05-16 16:56:27 +02:00
byGalax 88409aff5d feat(desktop): empty-state for friends list 2026-05-16 16:54:12 +02:00
byGalax b7d7a85253 feat(desktop): empty-state for chat list
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:51:37 +02:00
byGalax 502c6af1b8 feat(desktop): EmptyState primitive component 2026-05-16 16:49:16 +02:00
byGalax adc9686035 refactor(mobile): AuthProvider exposes userKeyState instead of device record 2026-05-16 16:47:55 +02:00
byGalax 472665b980 fix(desktop): tray badge — IPC handler died when icon.ico missing from packaged resources 2026-05-16 16:47:19 +02:00
byGalax c52c65faa9 feat(mobile): userIdentity orchestrator (setup/unlock/cache/change-PIN/reset) 2026-05-16 16:43:50 +02:00
byGalax 79f1786ac6 feat(desktop): Settings — 🌐 Global toggle per voice hotkey 2026-05-16 16:43:00 +02:00
byGalax ca77214e2d fix(desktop): voice hotkeys are window-scoped unless Global is toggled
The old code registered every enabled hotkey through Electron globalShortcut
API, which captures system-wide. Setting M as mute meant m could not be
typed in any other app. Now the OS-level registration only happens when
binding.global === true; otherwise the existing window-keydown listener
handles it.
2026-05-16 16:41:20 +02:00
byGalax 8c27e8eafa feat(mobile): legacyDeviceVault helper for per-device key probing 2026-05-16 16:41:18 +02:00
byGalax 69733168ed feat(desktop): add 'global' flag to voice hotkey binding (default false) 2026-05-16 16:38:51 +02:00
byGalax bf87add18d feat(mobile): extend cryptoBackend with pwhash + scalarMultBase 2026-05-16 16:37:09 +02:00
byGalax c871687fd2 refactor(desktop): derivePublicKey via CryptoBackend.scalarMultBase 2026-05-16 16:35:36 +02:00
byGalax 61a1eb37ea refactor(shared): route userKey through CryptoBackend (no libsodium-wrappers-sumo) 2026-05-16 16:33:50 +02:00
byGalax 9b9802ff6e docs(plan): phase 1 — quality & fixes (hotkey-bug, tray-audit, empty-states, nicknames, memory-wipe) 2026-05-16 16:33:38 +02:00
byGalax 2463081949 feat(shared): extend CryptoBackend with pwhash + scalarMultBase 2026-05-16 16:30:52 +02:00
byGalax 1eea80c529 chore(mobile): check:env script lints .env.local against .env.example 2026-05-16 16:27:21 +02:00
byGalax 830dac4cdd fix(mobile): defer crypto backend init into AppBootstrap (prevents white-screen) 2026-05-16 16:25:10 +02:00
byGalax cb46483f5d feat(mobile): AppBootstrap boundary — defers crypto init, catches global throws 2026-05-16 16:24:34 +02:00
byGalax 87fed820dc feat(mobile): BootSplash + BootError fallback views for AppBootstrap 2026-05-16 16:23:26 +02:00
byGalax fca5211008 docs(spec): fifteen-features initiative (0.19.0) — 5 phases, single end-of-batch release 2026-05-16 16:21:32 +02:00
byGalax c430a590fc fix(mobile): lazy env proxy so missing EXPO_PUBLIC vars throw inside React 2026-05-16 16:21:02 +02:00
byGalax 3b884b0415 docs(mobile): document EAS Secrets contract for EXPO_PUBLIC_* 2026-05-16 16:14:23 +02:00
byGalax affff0b433 docs(plan): mobile encryption port + Android white-screen RCA plans
Two implementation plans for the 2026-05-16 specs.

- Android white-screen: 12 tasks across 7 phases. Phase 0 wires EAS Secrets,
  Phase 1-2 ship the lazy env proxy + AppBootstrap boundary + global JS
  error handler, Phase 3-4 validate against a real APK, Phase 5 has
  conditional hypothesis-specific fixes, Phase 6-7 close out.

- Mobile encryption port: 24 tasks across 8 phases. Extends shared
  CryptoBackend with pwhash + scalarMultBase (the change that lets mobile
  stop loading libsodium-wrappers-sumo in Hermes), refactors desktop
  derivePublicKey through the same backend, mirrors the desktop
  userIdentity orchestrator and Auth flow on RN with new PinInput, setup,
  unlock, and security-settings screens, updates every device-keyed call
  site, and ends with a manual Android smoke list.

Each plan ships with a spec-coverage checklist and explicit out-of-scope
list. White-screen plan must land first; mobile-encryption plan depends
on AppBootstrap deferring crypto init.
2026-05-16 16:01:51 +02:00
byGalax a5c0889b0a docs: mobile encryption-UX port + Android white-screen RCA specs (2026-05-16)
Two specs from the 2026-05-16 brainstorming session:

- mobile encryption-UX port: bring apps/mobile to feature parity with
  desktop v0.18.x user-key/PIN identity. Includes the shared CryptoBackend
  extension (pwhash + scalarMultBase) that removes the libsodium-wrappers-sumo
  Hermes blocker.

- Android white-screen RCA: five ranked hypotheses, ordered diagnostic
  playbook (env-missing, newArch, module-eval crypto init, shared sodium
  side-effects, asset paths), plus defense-in-depth (lazy env proxy,
  AppBootstrap boundary, global JS error handler) that ships regardless
  of which hypothesis confirms.

User decisions resolved at the review gate:
- EAS Secrets for EXPO_PUBLIC_* (not eas.json env block).
- newArchEnabled: false acceptable as a temporary rollback if H2 confirms.
2026-05-16 15:46:40 +02:00
byGalax 90fa0a7e95 chore(desktop): release v0.18.8 2026-05-16 15:35:24 +02:00
byGalax 767db72847 fix(desktop): drop bright border on Settings SubSection cards
The inner cards (Nachrichten-Ton / Klingelton / Audio-Gerät / Hotkeys /
…) looked like they had a stark white outline. The cause: --color-line
is a semi-transparent white token, and applying the `/60` alpha modifier
in `border-line/60` overrides the original alpha — so the inner border
ended up brighter than the outer Section's normal `border-line`.

Drop the border entirely. Background tint + caps heading is enough
grouping signal in a tab-pattern panel.
2026-05-16 15:33:36 +02:00
byGalax 0288d7a476 chore(desktop): release v0.18.7 2026-05-16 15:30:14 +02:00
byGalax 34b972ec2a refactor(desktop): Settings = tab pattern instead of scroll + observer
The previous build used IntersectionObserver to highlight whichever
section was in view. With nine sections of unequal heights and smooth-
scroll firing observer callbacks mid-scroll, the active highlight
drifted (clicking 'Konto' showed 'Soundboard' as active because the
last section never crossed the observer's 20%-30% band).

Switched to a tab pattern (macOS System Settings / Discord / GitHub
style): the sidebar selects ONE section, only that section renders.
`useState<TabId>` is the single source of truth; no observer, no
scrolling between sections, no anchor links to clash with HashRouter.
Mobile fallback (<lg) gets a `<select>` dropdown above the panel.

Drops the now-unused `id` prop from Section and removes the
IntersectionObserver effect.
2026-05-16 15:28:03 +02:00
byGalax c55173800b chore(desktop): release v0.18.6 2026-05-16 01:13:54 +02:00
byGalax fc9f1ec143 fix(desktop): Settings sidebar redirected to /chats instead of scrolling
App uses HashRouter, so an <a href="#profile"> changes the routing
hash and the router can't find a match — it falls back to /chats.
Replace anchors with buttons that scroll the target section via
scrollIntoView and update the active highlight optimistically.
2026-05-16 01:12:50 +02:00
byGalax 12e4b597a0 chore(desktop): release v0.18.5 2026-05-16 01:09:54 +02:00
byGalax 5c05afb009 feat(desktop): redesign Settings + fix unreadable bubble text
Two UI fixes:

1. MessageBubble: 'Nachricht nicht lesbar' was rendered with
   text-fg-muted on the blue 'mine' bubble — invisible. Now uses
   text-accent-fg/80 on mine, text-fg-muted on peer (still
   ≥4.5:1 contrast in both modes).

2. SettingsPage: redesigned from a long single-column scroll into a
   sticky-sidebar + content layout (lg+) with:
   - 9 anchor-linked sections with icons in the sidebar
   - IntersectionObserver highlights the active section
   - Each section has a description subtitle for context
   - Voice (the densest section) is now sub-grouped into Audio-Gerät /
     Qualität / PTT / Hotkeys / E2EE via SubSection cards
   - Notifications consolidates message-sound + ringtone
   - Danger-toned account section visually separated
   - Mobile fallback is the original single-column scroll
2026-05-16 01:08:28 +02:00
byGalax a636a3c1c1 chore(desktop): release v0.18.4 2026-05-16 01:00:34 +02:00
byGalax c9fe4879e0 fix(shared): stop sending fake install-id as messages.sender_device_id
Task 12 (the AuthContext userKeyState refactor) replaced the per-device
DeviceRecord lookup with a localStorage UUID via ensureInstallId(). That
UUID was then passed straight through to messages.sender_device_id on
INSERT.

The messages_insert_member RLS policy requires sender_device_id to be
NULL OR to match a row in `devices` owned by the caller. The localStorage
UUID matches neither -> 403 -> outbox endlessly retries with "Wiederhole".

Fix: SendMessageParams.senderDeviceId becomes optional, and the message
INSERT coerces undefined to NULL. The column is pure telemetry post-conv-
keys so passing NULL is correct. Existing call sites that hand in
ensureInstallId() still typecheck (string is assignable to string|null|undefined)
but the row is written with NULL until those callers stop passing it.
2026-05-16 00:59:28 +02:00
byGalax 61462516d2 chore(desktop): release v0.18.3 2026-05-16 00:49:32 +02:00
byGalax d39a0fb6dc fix: stop reset_user_key from wiping conv-key bundles + auto-rotate stuck convs
Root cause of "alle Nachrichten verschlüsselt + kann nicht schreiben":
uploadUserKeyBlob (called by setupNewUserIdentity, changePin and
regenerateRecoveryCode) routed through reset_user_key, which DELETES
every conversation_keys row addressed to the user or one of their
devices. So setting a PIN destroyed every legacy bundle BEFORE the
migration could re-wrap them. The user ended up with user_keys set,
zero un-migrated bundles, no decryption, no send.

Fixes shipped:

  * supabase/migrations/20260516000001_user_key_rpcs_v2.sql
    - upsert_user_key: same UPSERT, NO delete. Used everywhere except
      "Identität zurücksetzen" (which keeps reset_user_key on purpose).
    - rotate_conv_key: bumps active_key_version atomically and inserts
      a fresh batch of bundles (per-user + per-device fallback).
  * shared/auth/userKey.ts: uploadUserKeyBlob now calls upsert_user_key.
  * shared/chat/convKeys.ts: new rotateConvKey() that wraps the fresh
    conv-key for every member's user_keys (preferred) and falls back to
    each member's per-device public_key for peers still on 0.17.x.
  * shared/chat/convKeys.ts: getOrCreateConvKey auto-triggers rotate
    when the user has no recipient_user_id row at the active version
    but rows exist (the deadlock case). Existing outbox retries drain
    on their own once the rotate completes — no manual button.
  * desktop/MessageBubble.tsx: "...cannot decrypt" is now a softer,
    German "Nachricht nicht lesbar" so users don't think the app
    crashed when historical messages can't be unwrapped.
2026-05-16 00:48:28 +02:00
byGalax 9207f473cd chore(desktop): release v0.18.2 2026-05-16 00:27:54 +02:00
byGalax 5367544b59 feat(desktop): diagnostic + manual retry for legacy key migration
The 0.18.1 fix relied on an existing-device + present-stronghold-key match.
That fails for users who:
  - had multiple device registrations and only retain the latest device's
    private key in the local vault
  - had a vault wipe / fresh OS install at some point
  - have device rows that vanished server-side but keys still locally

Migration now scans conversation_keys for distinct un-migrated
recipient_device_ids visible to the user (RLS-filtered) and probes the
stronghold for each, regardless of whether the server still lists that
device. Result struct surfaces attempted/migrated/noKey/decryptFail/rpcFail
counters; SecurityCenter shows them via a new "Migration erneut ausführen"
button so users can self-diagnose without DevTools.

Also adds [crypto-migration] console.info breadcrumbs at every decision
point so a single F12 shows what happened.
2026-05-16 00:25:48 +02:00
byGalax e6b698bf14 chore(desktop): release v0.18.1 2026-05-16 00:16:53 +02:00
byGalax 6caa674c19 fix(shared): legacy conv-key migration query used .eq(null) instead of .is(null)
PostgREST translates .eq('col', null) to `col = NULL` which is always false
in SQL. The migration silently returned zero rows -> setupNewUserIdentity
fired but re-wrapped nothing -> users could set a PIN but every send threw
'Awaiting key'. Switching to .is('col', null) emits `col IS NULL` and the
migration finally finds its work.

Also makes the migration trigger idempotent and re-fires it on:
  - every successful loadOrUnlockUserKey
  - AuthContext startup when the user-key is already cached
so users stuck on 0.18.0 auto-recover the moment they install 0.18.1.

PinInput: focused + active-slot now show a brand-coloured ring, glow, and
a blinking caret so users see where the next keystroke lands.
2026-05-16 00:15:15 +02:00
108 changed files with 13143 additions and 527 deletions
+13
View File
@@ -106,6 +106,19 @@ export const CHANNELS = {
// Used by the call cinema mode to flip the host BrowserWindow into real
// OS fullscreen so the Windows taskbar / macOS menubar gets covered.
WINDOW_SET_FULLSCREEN: 'window:set-fullscreen',
// Wipe-on-close — main process pushes this to the renderer right before
// exiting if the user has enabled the Settings → Sicherheit toggle. The
// renderer clears its sensitive caches (memoryWipe.ts) and acks via
// `app:wipe-before-quit:done`; main quits after the ack (or a 2s safety
// timeout, whichever comes first).
/** Main → renderer: about to quit. Renderer wipes, then resolves. */
APP_WIPE_BEFORE_QUIT: 'app:wipe-before-quit',
// OS hostname — returns the machine hostname via Node's `os.hostname()`.
// Used by AuthContext on first launch to populate the `devices` row with
// a human-readable default device name. Returns null on failure.
APP_HOSTNAME: 'app:hostname',
} as const;
// ---- Screen sources ------------------------------------------------------
+30 -1
View File
@@ -6,11 +6,13 @@
// the old Tauri devUrl so nothing in the renderer code needs to change)
// and from the built dist in packaged mode.
import { app, BrowserWindow, desktopCapturer, Menu, session } from 'electron';
import { app, BrowserWindow, desktopCapturer, ipcMain, Menu, session } from 'electron';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { CHANNELS } from './ipc-types';
import { register as registerAudioLoopback } from './modules/audio-loopback';
import { register as registerAppHostname } from './modules/app-hostname';
import { register as registerAutostart } from './modules/autostart';
import { register as registerFsScoped } from './modules/fs-scoped';
import { register as registerNotifications } from './modules/notifications';
@@ -253,6 +255,7 @@ if (!gotLock) {
registerFsScoped();
registerSql();
registerAutostart();
registerAppHostname();
// Window-dependent registrars — only call once mainWindow exists so
// event emitters have somewhere to send.
@@ -263,6 +266,32 @@ if (!gotLock) {
registerAudioLoopback(mainWindow);
});
// Wipe-on-close: when the user enables it in Settings, the renderer is given
// a chance to clear all sensitive caches before the app process exits. If
// the renderer doesn't ack within 2 seconds we force-quit anyway — better
// to lose the wipe than to hang the app shutdown.
//
// Note: there's a separate `before-quit` listener in modules/tray.ts that
// tears down the Tray instance. Electron fires both; the tray listener is
// synchronous and doesn't touch event.preventDefault, so it doesn't fight
// our deferred-quit dance here. The `wipeRequested` flag guards re-entry
// when our own `app.quit()` below fires `before-quit` a second time.
let wipeRequested = false;
app.on('before-quit', (event) => {
if (wipeRequested) return;
if (!mainWindow || mainWindow.isDestroyed()) return;
wipeRequested = true;
event.preventDefault();
mainWindow.webContents.send(CHANNELS.APP_WIPE_BEFORE_QUIT);
const done = new Promise<void>((resolve) => {
ipcMain.once(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done', () => resolve());
});
void Promise.race([done, new Promise<void>((r) => setTimeout(r, 2000))]).finally(() => {
app.quit();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
@@ -0,0 +1,21 @@
// app:hostname adapter — returns the OS hostname via Node's `os.hostname()`.
// The renderer has no Node access (contextIsolation is ON), so it must ask
// main for this value. Used by AuthContext on first launch to populate the
// `devices` row with a human-readable default device name.
import os from 'node:os';
import { ipcMain } from 'electron';
import { CHANNELS } from '../ipc-types';
export function register(): void {
ipcMain.handle(CHANNELS.APP_HOSTNAME, (): string | null => {
try {
const h = os.hostname();
return typeof h === 'string' && h.length > 0 ? h : null;
} catch {
return null;
}
});
}
+52 -27
View File
@@ -30,6 +30,17 @@ function resolveIconPathDev(): string {
return path.join(app.getAppPath(), 'resources', 'icon.ico');
}
// 16×16 grey square — last-ditch fallback when neither the packaged
// nor dev icon file resolves. Tray constructor on Windows throws when
// handed an empty NativeImage, which would tear down the whole
// registrar before `ipcMain.handle(TRAY_UNREAD)` runs — leaving the
// taskbar overlay badge wired but the renderer's invoke rejecting
// with "No handler registered". A non-empty placeholder keeps the
// constructor happy so the IPC handler always gets registered.
const FALLBACK_TRAY_PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAH0lEQVR42mNk' +
'YGD4z0ABYBxVOKpwVOGowlGFwwoBAEnYAR9XlIldAAAAAElFTkSuQmCC';
function loadTrayIcon(): NativeImage {
for (const p of [resolveIconPath(), resolveIconPathDev()]) {
try {
@@ -39,7 +50,7 @@ function loadTrayIcon(): NativeImage {
/* try next */
}
}
return nativeImage.createEmpty();
return nativeImage.createFromBuffer(Buffer.from(FALLBACK_TRAY_PNG_BASE64, 'base64'));
}
function buildOverlay(): NativeImage {
@@ -53,35 +64,49 @@ function buildOverlay(): NativeImage {
}
export function register(mainWindow: BrowserWindow): void {
const icon = loadTrayIcon();
trayRef = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon);
trayRef.setToolTip('Netralax');
// Tray icon is best-effort: if neither the packaged resource nor the
// dev path resolves (e.g. icon.ico isn't shipped under resourcesPath
// in packaged builds — only the app icon goes into the .exe metadata),
// we still want the taskbar overlay badge to work. setOverlayIcon is
// a BrowserWindow method, so it functions even when the systray icon
// creation fails.
try {
const icon = loadTrayIcon();
if (!icon.isEmpty()) {
trayRef = new Tray(icon);
trayRef.setToolTip('Netralax');
const menu = Menu.buildFromTemplate([
{
label: 'Open',
click: (): void => {
const menu = Menu.buildFromTemplate([
{
label: 'Open',
click: (): void => {
if (mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
},
},
{ type: 'separator' },
{
label: 'Quit',
click: (): void => {
app.quit();
},
},
]);
trayRef.setContextMenu(menu);
trayRef.on('click', (): void => {
if (mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
},
},
{ type: 'separator' },
{
label: 'Quit',
click: (): void => {
app.quit();
},
},
]);
trayRef.setContextMenu(menu);
trayRef.on('click', (): void => {
if (mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
if (mainWindow.isVisible()) mainWindow.focus();
else mainWindow.show();
});
if (mainWindow.isVisible()) mainWindow.focus();
else mainWindow.show();
});
}
} catch (err: unknown) {
// Swallow: the overlay badge below is the user-visible bit. A missing
// systray icon is cosmetic and shouldn't take the unread handler with it.
console.warn('[tray] systray init failed, overlay badge still active', err);
}
ipcMain.handle(
CHANNELS.TRAY_UNREAD,
+11
View File
@@ -95,6 +95,17 @@ export interface ElectronAPI {
setAutoStart: (enabled: boolean) => Promise<void>;
setFullscreen: (enabled: boolean) => Promise<void>;
/** Subscribe to the main-process pre-quit notification. Used by the
* "Cache beim Schließen leeren" Settings toggle. */
onWipeBeforeQuit: (cb: () => Promise<void>) => () => void;
/** Returns the OS hostname (via Node's `os.hostname()`). Used by
* AuthContext on first launch to populate the `devices` row with a
* human-readable default device name. Optional: always feature-check
* via `typeof window.electronAPI?.getHostname === 'function'` because
* the web build has no preload bridge. */
getHostname?: () => Promise<string | null>;
}
declare global {
+21
View File
@@ -159,6 +159,27 @@ const api = {
// Window fullscreen ------------------------------------------------------
setFullscreen: (enabled: boolean): Promise<void> =>
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
// OS hostname ------------------------------------------------------------
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.APP_HOSTNAME),
// Wipe-on-close ----------------------------------------------------------
// Subscribe to the main-process pre-quit notification. The renderer's
// callback does the actual wipe (memoryWipe.ts) and resolves; we ack
// unconditionally so main can finish quitting — better to lose the wipe
// than to hang the app shutdown if the callback throws.
onWipeBeforeQuit: (cb: () => Promise<void>): Unsubscribe => {
const handler = async (_evt: Electron.IpcRendererEvent): Promise<void> => {
try {
await cb();
} catch (err) {
console.warn('[wipe] renderer cb failed', err);
}
ipcRenderer.send(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done');
};
ipcRenderer.on(CHANNELS.APP_WIPE_BEFORE_QUIT, handler);
return () => ipcRenderer.removeListener(CHANNELS.APP_WIPE_BEFORE_QUIT, handler);
},
} as const;
export type ElectronAPI = typeof api;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chat-app/desktop",
"version": "0.18.0",
"version": "0.19.0",
"private": true,
"description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module",
+2
View File
@@ -4,6 +4,7 @@ import { HashRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
import { AppShell } from './components/AppShell';
import { CrashToast } from './components/CrashToast';
import { ErrorBoundary } from './components/ErrorBoundary';
import { RemoteRevokedScreen } from './components/RemoteRevokedScreen';
import { SpinnerIcon } from './components/icons';
import { UpdateToast } from './components/UpdateToast';
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
@@ -168,6 +169,7 @@ export function App() {
</Routes>
<UpdateToast />
<CrashToast />
<RemoteRevokedScreen />
</HashRouter>
</CallProvider>
</ConversationsProvider>
+2
View File
@@ -6,6 +6,7 @@ import { startConversationKeySync } from '../lib/conversationKeySync';
import { startDeviceApprovalListener } from '../lib/deviceApproval';
import { ensureInstallId } from '../lib/installId';
import { ensureNotificationPermission } from '../lib/osNotify';
import { useMentionNotifications } from '../lib/useMentionNotifications';
import { cachedUserKey } from '../lib/userIdentity';
import { CallUI } from './CallUI';
import { DeviceApprovalBanner } from './DeviceApprovalBanner';
@@ -13,6 +14,7 @@ import { Sidebar } from './Sidebar';
export function AppShell() {
const { session } = useAuth();
useMentionNotifications(session?.user.id);
useEffect(() => {
// Prompt once per authenticated shell mount. Module-level guard prevents
// re-asking if the user already responded this session.
@@ -5,9 +5,15 @@ import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon } from './icons';
import { Lightbox } from './Lightbox';
import { ViewOnceImage } from './ViewOnceImage';
interface Props {
handle: AttachmentHandle;
/** True iff the local user sent this message. View-once images use this
* to suppress the burn (senders never burn their own attachment) and to
* pick the right preview chrome. Defaults to false so existing callers
* (e.g. MediaFilesDrawer) keep working without modification. */
mine?: boolean;
}
// Max inline-preview dimension. Full-resolution stays available for the
@@ -44,7 +50,7 @@ async function makeThumbnail(blob: Blob): Promise<Blob | null> {
}
}
export function AttachmentImage({ handle }: Props) {
export function AttachmentImage({ handle, mine = false }: Props) {
const [fullUrl, setFullUrl] = useState<string | null>(null);
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -116,6 +122,25 @@ export function AttachmentImage({ handle }: Props) {
);
}
// View-once attachments swap the standard image preview for the ViewOnceImage
// chrome (locked card → fullscreen lightbox + burn for recipient, normal
// image with badge for sender). We still kick off the decrypt above so the
// fullscreen lightbox has the decoded blob ready the moment the recipient
// taps. `handle.viewedAt` is undefined in the current renderer (it lives on
// the public attachment row, not in the encrypted payload) — the component
// treats undefined as "not yet burned" and uses the RPC response to flip
// the state locally after the recipient opens.
if (handle.viewOnce) {
return (
<ViewOnceImage
attachmentId={handle.id}
viewedAt={handle.viewedAt ?? null}
isSender={mine}
src={blobUrl}
/>
);
}
return (
<>
<button
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
import { useNickname } from '../lib/friendNicknames';
import {
CrownIcon,
HeadphonesOffIcon,
@@ -89,6 +90,7 @@ export interface ParticipantTileProps {
export function CallParticipantTile(props: ParticipantTileProps) {
const {
userId,
displayName,
me,
muted,
@@ -107,6 +109,13 @@ export function CallParticipantTile(props: ParticipantTileProps) {
onContextMenu,
} = props;
// Apply the per-viewer nickname override once at the top — each tile is
// already per-participant, so a single hook call is fine. The resolved
// name is forwarded into the avatar sub-components below so their initial
// letter respects the nickname too.
const resolvedName = useNickname(userId, displayName);
const tileProps = { ...props, displayName: resolvedName };
const small = size === 'small';
// Discord-style: full tile border switches to emerald the whole time the
// user is speaking. Same treatment for audio + video tiles so the visual
@@ -132,9 +141,9 @@ export function CallParticipantTile(props: ParticipantTileProps) {
}
>
{video ? (
<VideoStub {...props} small={small} fit={focused ? 'contain' : 'cover'} />
<VideoStub {...tileProps} small={small} fit={focused ? 'contain' : 'cover'} />
) : (
<AudioContent {...props} small={small} />
<AudioContent {...tileProps} small={small} />
)}
{speaking && (
@@ -187,7 +196,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
/>
)}
<span className="truncate">
{displayName}
{resolvedName}
{me ? ' (du)' : ''}
</span>
{e2ee && (
@@ -3,6 +3,7 @@ import type { PresenceState } from '@chat-app/shared/supabase';
import { useTranslation } from 'react-i18next';
import { useCall } from '../context/CallContext';
import { useNickname } from '../lib/friendNicknames';
import type { PeerPresence } from '../lib/usePeerPresence';
import { Avatar } from './Avatar';
import {
@@ -13,6 +14,7 @@ import {
UsersIcon,
VideoIcon,
} from './icons';
import { PinnedMessagesPill } from './PinnedMessagesPill';
const PRESENCE_DOT: Record<PresenceState, string> = {
online: 'bg-emerald-500',
@@ -29,6 +31,8 @@ interface Props {
onMediaClick?: () => void;
onProfileClick?: (ev: React.MouseEvent) => void;
onSearchClick?: () => void;
pinnedCount?: number;
onOpenPinned?: () => void;
}
export function ConversationHeader({
@@ -38,6 +42,8 @@ export function ConversationHeader({
onMediaClick,
onProfileClick,
onSearchClick,
pinnedCount = 0,
onOpenPinned,
}: Props) {
if (!conversation) {
return <header className="h-[65px] border-b border-line px-6 py-3" aria-busy="true" />;
@@ -48,10 +54,12 @@ export function ConversationHeader({
<HeaderBar
conversation={conversation}
peerPresence={peerPresence}
pinnedCount={pinnedCount}
{...(onInfoClick ? { onInfoClick } : {})}
{...(onMediaClick ? { onMediaClick } : {})}
{...(onProfileClick ? { onProfileClick } : {})}
{...(onSearchClick ? { onSearchClick } : {})}
{...(onOpenPinned ? { onOpenPinned } : {})}
/>
{/* Voice-channel rail rendered separately in ConversationPage so it
can sit between the message surface and the in-call dock. */}
@@ -66,6 +74,8 @@ interface HeaderBarProps {
onMediaClick?: () => void;
onProfileClick?: (ev: React.MouseEvent) => void;
onSearchClick?: () => void;
pinnedCount?: number;
onOpenPinned?: () => void;
}
function HeaderBar({
@@ -75,11 +85,19 @@ function HeaderBar({
onMediaClick,
onProfileClick,
onSearchClick,
pinnedCount = 0,
onOpenPinned,
}: HeaderBarProps) {
const { t } = useTranslation(['app']);
const isDm = conversation.type === 'dm';
const title = isDm ? (conversation.peer?.displayName ?? '?') : (conversation.name ?? '?');
// For DMs, route the peer's display name through the per-viewer nickname
// override. Falls back to the real displayName when no nickname is set.
const peerName = useNickname(
conversation.peer?.userId,
conversation.peer?.displayName ?? '?',
);
const title = isDm ? peerName : (conversation.name ?? '?');
const handle = isDm ? '@' + (conversation.peer?.username ?? '?') : '';
const peerAvatar = isDm
? (conversation.peer?.avatarUrl ?? null)
@@ -133,6 +151,7 @@ function HeaderBar({
<div className="flex min-w-0 items-center gap-2">
{!isDm && <UsersIcon className="h-4 w-4 shrink-0 text-fg-muted" />}
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
<PinnedMessagesPill count={pinnedCount} onClick={() => onOpenPinned?.()} />
</div>
<p className="truncate text-xs text-fg-muted">
{isDm ? (
@@ -0,0 +1,32 @@
import type { ReactNode } from 'react';
interface Props {
icon: ReactNode;
title: string;
description: string;
action?: { label: string; onClick: () => void };
}
// Reusable empty-state placeholder: large icon + heading + description +
// optional primary CTA. Used everywhere there's a meaningfully-empty list
// (no chats, no friends, no search results, fresh conversation).
export function EmptyState({ icon, title, description, action }: Props) {
return (
<div className="flex h-full flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-accent/10 text-accent">
{icon}
</div>
<h3 className="font-display text-lg font-semibold text-fg">{title}</h3>
<p className="mt-2 max-w-xs text-sm text-fg-muted">{description}</p>
{action && (
<button
type="button"
onClick={action.onClick}
className="mt-5 inline-flex cursor-pointer items-center justify-center rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
{action.label}
</button>
)}
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useMemo, useState } from 'react';
import {
featuredGifs,
getRecentGifs,
type GifResult,
rememberRecentGif,
searchGifs,
} from '../lib/tenor';
import { SpinnerIcon, XIcon } from './icons';
interface Props {
open: boolean;
onClose: () => void;
onPick: (gif: GifResult) => void;
}
type Tab = 'trending' | 'search' | 'recent';
export function GifPicker({ open, onClose, onPick }: Props) {
const [tab, setTab] = useState<Tab>('trending');
const [query, setQuery] = useState('');
const [results, setResults] = useState<GifResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const recent = useMemo(() => getRecentGifs(), [open]);
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError(null);
const run = async () => {
try {
const gifs = tab === 'search' && query.trim().length > 0
? await searchGifs(query)
: tab === 'trending'
? await featuredGifs()
: [];
if (!cancelled) setResults(gifs);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'GIFs gerade nicht verfügbar');
} finally {
if (!cancelled) setLoading(false);
}
};
void run();
return () => { cancelled = true; };
}, [open, tab, query]);
if (!open) return null;
const visible = tab === 'recent' ? recent : results;
return (
<div className="absolute bottom-full left-0 z-30 mb-2 w-[440px] rounded-2xl border border-line bg-surface-2 shadow-xl">
<header className="flex items-center justify-between border-b border-line px-3 py-2">
<div className="flex items-center gap-1">
{(['trending', 'search', 'recent'] as Tab[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={
'cursor-pointer rounded-md px-2 py-1 text-xs font-medium transition ' +
(t === tab ? 'bg-accent/15 text-accent' : 'text-fg-muted hover:text-fg')
}
>
{t === 'trending' ? 'Trending' : t === 'search' ? 'Suche' : 'Zuletzt'}
</button>
))}
</div>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
>
<XIcon className="h-3.5 w-3.5" />
</button>
</header>
{tab === 'search' && (
<div className="border-b border-line px-3 py-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="GIFs suchen…"
className="w-full rounded-md border border-line bg-surface-3 px-2 py-1.5 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
</div>
)}
<div className="grid max-h-[360px] grid-cols-3 gap-1.5 overflow-y-auto p-2">
{loading && (
<div className="col-span-3 flex justify-center py-6 text-fg-muted">
<SpinnerIcon className="h-5 w-5" />
</div>
)}
{error && (
<p className="col-span-3 px-2 py-4 text-center text-xs text-rose-300">
{error === 'giphy_api_key_missing'
? 'GIFs sind nicht konfiguriert (VITE_GIPHY_API_KEY fehlt).'
: 'GIFs gerade nicht verfügbar.'}
</p>
)}
{!loading && !error && visible.length === 0 && (
<p className="col-span-3 px-2 py-6 text-center text-xs text-fg-muted">
{tab === 'recent' ? 'Noch keine zuletzt verwendeten GIFs.' : 'Keine Treffer.'}
</p>
)}
{!loading && !error && visible.map((g) => (
<button
key={g.id}
type="button"
onClick={() => { rememberRecentGif(g); onPick(g); onClose(); }}
className="overflow-hidden rounded-md border border-line bg-surface-3 hover:border-accent/40"
title={g.description}
>
<img src={g.previewUrl} alt={g.description || 'GIF'} className="h-24 w-full object-cover" loading="lazy" />
</button>
))}
</div>
</div>
);
}
+2 -2
View File
@@ -1542,8 +1542,8 @@ function FullscreenCall({
function StripToggleIcon({ hidden }: { hidden: boolean }) {
return (
<svg
width="18"
height="18"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -1,8 +1,11 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { useNickname } from '../lib/friendNicknames';
import { Avatar } from './Avatar';
type Member = ConversationSummary['members'][number];
interface Props {
members: ConversationSummary['members'];
query: string;
@@ -63,37 +66,58 @@ export function MentionAutocomplete({ members, query, excludeUserId, onSelect, o
aria-label="Mitglieder"
className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-64 overflow-y-auto rounded-xl border border-line bg-surface-2 p-1 shadow-xl dark:bg-[#2b2d31]"
>
{matches.map((m, idx) => {
const name = m.profile?.displayName ?? m.profile?.username ?? '?';
const handle = m.profile?.username ?? '';
const isActive = idx === active;
return (
<button
key={m.userId}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => setActive(idx)}
onClick={() => {
if (m.profile?.username) onSelect(m.profile.username);
}}
className={
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
(isActive
? 'bg-accent/20 text-fg'
: 'text-fg-muted hover:bg-surface-3 dark:hover:bg-[#383a40]')
}
>
<Avatar
displayName={name}
url={m.profile?.avatarUrl ?? null}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
</button>
);
})}
{matches.map((m, idx) => (
<MemberRow
key={m.userId}
member={m}
isActive={idx === active}
onActivate={() => setActive(idx)}
onSelect={onSelect}
/>
))}
</div>
);
}
// Extracted per-row component so we can call `useNickname` once per member
// at the top of THIS component (hooks can't run inside .map callbacks).
function MemberRow({
member,
isActive,
onActivate,
onSelect,
}: {
member: Member;
isActive: boolean;
onActivate: () => void;
onSelect: (username: string) => void;
}) {
const fallback = member.profile?.displayName ?? member.profile?.username ?? '?';
const name = useNickname(member.userId, fallback);
const handle = member.profile?.username ?? '';
return (
<button
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={onActivate}
onClick={() => {
if (member.profile?.username) onSelect(member.profile.username);
}}
className={
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
(isActive
? 'bg-accent/20 text-fg'
: 'text-fg-muted hover:bg-surface-3 dark:hover:bg-[#383a40]')
}
>
<Avatar
displayName={name}
url={member.profile?.avatarUrl ?? null}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
</button>
);
}
+50 -5
View File
@@ -10,6 +10,7 @@ import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useNickname } from '../lib/friendNicknames';
import { ensureInstallId } from '../lib/installId';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
@@ -30,6 +31,7 @@ import {
PencilIcon,
PhoneIcon,
PhoneOffIcon,
PinIcon,
ReplyIcon,
SmileIcon,
SpinnerIcon,
@@ -75,6 +77,10 @@ interface Props {
onAvatarClick?: (userId: string, ev: React.MouseEvent) => void;
/** Highlighted state — set briefly after a jump. */
highlighted?: boolean;
/** True iff this message is currently pinned (parent maintains the set). */
isPinned?: boolean;
/** Right-click menu → toggle pin/unpin for this message id. */
onTogglePin?: (messageId: string) => void;
}
export function MessageBubble({
@@ -96,10 +102,18 @@ export function MessageBubble({
onForward,
onAvatarClick,
highlighted = false,
isPinned = false,
onTogglePin,
}: Props) {
const { t } = useTranslation(['app']);
const { session } = useAuth();
// Per-viewer nickname override for the message sender. Fallback chain
// keeps the existing behavior when no nickname is set. Skipped for the
// quoted-sender snippet — that's a different user.
const senderName = useNickname(message.senderId, senderDisplayName ?? '');
const resolvedSenderDisplayName = senderName.length > 0 ? senderName : null;
const parsed = parseMessagePayload(message.plaintext);
const initialText = parsed.kind === 'text' ? parsed.text : '';
const initialAttachments = parsed.kind === 'text' ? parsed.attachments : [];
@@ -281,7 +295,7 @@ export function MessageBubble({
<AvatarSlot
show={isLastOfRun}
url={senderAvatarUrl ?? null}
displayName={senderDisplayName ?? null}
displayName={resolvedSenderDisplayName}
{...(onAvatarClick
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
: {})}
@@ -299,7 +313,7 @@ export function MessageBubble({
parsed={parsed}
mine={mine}
time={time}
senderDisplayName={senderDisplayName ?? null}
senderDisplayName={resolvedSenderDisplayName}
/>
);
}
@@ -315,7 +329,7 @@ export function MessageBubble({
<AvatarSlot
show={isLastOfRun}
url={senderAvatarUrl ?? null}
displayName={senderDisplayName ?? null}
displayName={resolvedSenderDisplayName}
{...(onAvatarClick
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
: {})}
@@ -416,7 +430,18 @@ export function MessageBubble({
</button>
)}
{message.plaintext === null ? (
<span className="italic opacity-70">cannot decrypt</span>
<span
className={
'italic ' +
// Mine = blue/accent bubble → use accent-fg with reduced opacity
// (still meets 4.5:1). Peer = surface-2 grey → muted-fg works.
(mine ? 'text-accent-fg/80' : 'text-fg-muted')
}
>
{t('app:chats.unreadable', {
defaultValue: 'Nachricht nicht lesbar',
})}
</span>
) : parsed.kind === 'poll' ? (
<PollCard
question={parsed.question}
@@ -440,7 +465,7 @@ export function MessageBubble({
return <AttachmentAudio key={a.id} handle={a} />;
}
if (a.mimeType.startsWith('image/')) {
return <AttachmentImage key={a.id} handle={a} />;
return <AttachmentImage key={a.id} handle={a} mine={mine} />;
}
if (a.mimeType.startsWith('video/')) {
return <AttachmentVideo key={a.id} handle={a} />;
@@ -624,6 +649,15 @@ export function MessageBubble({
setContextMenu(null);
void handleDelete();
}}
isPinned={isPinned}
{...(onTogglePin
? {
onTogglePin: () => {
setContextMenu(null);
onTogglePin(message.id);
},
}
: {})}
/>
)}
</div>
@@ -721,6 +755,8 @@ function MessageContextMenu({
onEdit,
onCopy,
onDelete,
isPinned,
onTogglePin,
}: {
x: number;
y: number;
@@ -735,6 +771,8 @@ function MessageContextMenu({
onEdit: () => void;
onCopy: () => void;
onDelete: () => void;
isPinned?: boolean;
onTogglePin?: () => void;
}) {
useEffect(() => {
function onKey(e: KeyboardEvent) {
@@ -801,6 +839,13 @@ function MessageContextMenu({
tone="danger"
/>
)}
{onTogglePin && (
<MenuItem
label={isPinned ? 'Anheftung entfernen' : 'Anpinnen'}
icon={<PinIcon className="h-4 w-4" />}
onClick={onTogglePin}
/>
)}
</div>,
document.body,
);
@@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from 'react';
import { getNickname, setNickname } from '../lib/friendNicknames';
interface Props {
open: boolean;
userId: string;
displayName: string;
onClose: () => void;
}
export function NicknameDialog({ open, userId, displayName, onClose }: Props) {
const [value, setValue] = useState('');
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!open) return;
setValue(getNickname(userId) ?? '');
setTimeout(() => inputRef.current?.focus(), 0);
}, [open, userId]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
const submit = (): void => {
setNickname(userId, value);
onClose();
};
return (
<div
role="dialog"
aria-modal="true"
aria-label="Spitzname setzen"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="w-full max-w-sm rounded-2xl border border-line bg-surface-2 p-5 shadow-xl"
>
<h3 className="font-display text-base font-semibold text-fg">Spitzname für {displayName}</h3>
<p className="mt-1 text-xs text-fg-muted">
Nur du siehst diesen Namen. Leer lassen = den richtigen Namen verwenden.
</p>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
maxLength={32}
placeholder={displayName}
className="mt-4 w-full rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg hover:bg-surface-2"
>
Abbrechen
</button>
<button
type="button"
onClick={submit}
className="cursor-pointer rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg hover:brightness-110"
>
Speichern
</button>
</div>
</div>
</div>
);
}
+24 -9
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
interface Props {
value: string;
@@ -12,7 +12,12 @@ interface Props {
export function PinInput({ value, onChange, length = 6, autoFocus, disabled, ariaLabel, onSubmit }: Props) {
const ref = useRef<HTMLInputElement | null>(null);
const [focused, setFocused] = useState(false);
useEffect(() => { if (autoFocus) ref.current?.focus(); }, [autoFocus]);
// Index of the next empty slot the next keystroke will fill. When the user
// has typed all `length` digits, no slot is "active" — the form should
// submit instead of pretending one is still focused.
const activeIndex = value.length < length ? value.length : -1;
return (
<div className="relative flex justify-center" onClick={() => ref.current?.focus()}>
<input
@@ -26,20 +31,30 @@ export function PinInput({ value, onChange, length = 6, autoFocus, disabled, ari
value={value}
onChange={(e) => onChange(e.target.value.replace(/\D/g, '').slice(0, length))}
onKeyDown={(e) => { if (e.key === 'Enter' && value.length === length) onSubmit?.(); }}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className="absolute h-px w-px overflow-hidden p-0 opacity-0"
/>
<div className="flex gap-2">
{Array.from({ length }).map((_, i) => {
const filled = i < value.length;
const active = focused && i === activeIndex;
let classes =
'flex h-12 w-10 items-center justify-center rounded-lg border text-lg font-semibold transition ';
if (filled) {
classes += 'border-brand-400 bg-brand-500/10 text-white';
} else if (active) {
// Brand-coloured ring + glow so the user immediately sees where
// the next keystroke lands. The animated cursor inside reinforces
// the "input is alive" feeling.
classes += 'border-brand-400 bg-brand-500/10 text-brand-300 ring-2 ring-brand-400/40 shadow-[0_0_12px_-2px] shadow-brand-500/40';
} else {
classes += 'border-white/10 bg-ink-800 text-neutral-500';
}
return (
<span key={i}
className={
'flex h-12 w-10 items-center justify-center rounded-lg border text-lg font-semibold ' +
(filled
? 'border-brand-400 bg-brand-500/10 text-white'
: 'border-white/10 bg-ink-800 text-neutral-500')
}
>{filled ? '•' : ''}</span>
<span key={i} className={classes}>
{filled ? '•' : active ? <span className="animate-pulse">|</span> : ''}
</span>
);
})}
</div>
@@ -0,0 +1,68 @@
import type { PinnedMessage } from '@chat-app/shared/chat';
import { PinIcon, XIcon } from './icons';
interface Props {
open: boolean;
pins: PinnedMessage[];
onClose: () => void;
onJump: (messageId: string) => void;
onUnpin: (messageId: string) => void;
// Optional preview-renderer: parent resolves messageId → short text/snippet
// since the panel itself doesn't decrypt. If absent, the panel just shows
// the message-id stub.
renderPreview?: (messageId: string) => React.ReactNode;
}
export function PinnedMessagesPanel({ open, pins, onClose, onJump, onUnpin, renderPreview }: Props) {
if (!open) return null;
return (
<aside
role="complementary"
aria-label="Angepinnte Nachrichten"
className="absolute right-0 top-0 z-30 flex h-full w-80 flex-col border-l border-line bg-surface-2 shadow-xl"
>
<header className="flex items-center justify-between border-b border-line px-4 py-3">
<div className="flex items-center gap-2">
<PinIcon className="h-4 w-4 text-accent" />
<h3 className="text-sm font-semibold text-fg">Angepinnt · {pins.length}</h3>
</div>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
</header>
{pins.length === 0 ? (
<p className="p-6 text-center text-xs text-fg-muted">Noch nichts angepinnt.</p>
) : (
<ul className="flex-1 overflow-y-auto">
{pins.map((p) => (
<li key={p.messageId} className="border-b border-line/60 px-4 py-3">
<button
type="button"
onClick={() => onJump(p.messageId)}
className="block w-full cursor-pointer text-left text-sm text-fg hover:text-accent"
>
{renderPreview ? renderPreview(p.messageId) : <span className="font-mono text-xs">{p.messageId.slice(0, 8)}</span>}
</button>
<div className="mt-1 flex items-center justify-between text-[11px] text-fg-muted">
<time dateTime={p.pinnedAt}>{new Date(p.pinnedAt).toLocaleString()}</time>
<button
type="button"
onClick={() => onUnpin(p.messageId)}
className="cursor-pointer text-rose-400 hover:underline"
>
Anheftung entfernen
</button>
</div>
</li>
))}
</ul>
)}
</aside>
);
}
@@ -0,0 +1,23 @@
import { PinIcon } from './icons';
interface Props {
count: number;
onClick: () => void;
}
// Compact chip rendered in the conv header that opens the pinned panel.
// Renders nothing when count is 0 so a fresh conv shows no clutter.
export function PinnedMessagesPill({ count, onClick }: Props) {
if (count === 0) return null;
return (
<button
type="button"
onClick={onClick}
className="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-full border border-line bg-surface-3 px-2.5 text-xs font-medium text-fg-muted transition hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
title="Angepinnte Nachrichten anzeigen"
>
<PinIcon className="h-3 w-3 text-accent" />
<span>{count} angepinnt</span>
</button>
);
}
@@ -0,0 +1,41 @@
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
export function RemoteRevokedScreen() {
const { t } = useTranslation();
const { revokedRemotely, acknowledgeRevocation } = useAuth();
if (!revokedRemotely) return null;
return (
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="remote-revoked-title"
className="fixed inset-0 z-[1000] flex items-center justify-center bg-ink-950/95 p-6"
>
<div className="max-w-sm rounded-2xl border border-line bg-surface-2 p-6 text-center shadow-xl">
<h2
id="remote-revoked-title"
className="mb-2 font-display text-xl font-semibold text-fg"
>
{t('app:auth.revoked_title', { defaultValue: 'Du wurdest remote abgemeldet' })}
</h2>
<p className="mb-5 text-sm text-fg-muted">
{t('app:auth.revoked_body', {
defaultValue:
'Ein anderes deiner Geräte hat diesen Login beendet. Aus Sicherheitsgründen wurden alle lokalen Daten gelöscht.',
})}
</p>
<button
type="button"
onClick={acknowledgeRevocation}
className="inline-flex cursor-pointer items-center justify-center rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
{t('app:auth.revoked_acknowledge', { defaultValue: 'Verstanden' })}
</button>
</div>
</div>
);
}
+74 -1
View File
@@ -1,6 +1,13 @@
import { useState } from 'react';
import { changePin, regenerateRecoveryCode, resetIdentity } from '../lib/userIdentity';
import { isWipeOnCloseEnabled, setWipeOnClose } from '../lib/memoryWipeSettings';
import {
changePin,
type LegacyMigrationReport,
regenerateRecoveryCode,
resetIdentity,
retryLegacyMigration,
} from '../lib/userIdentity';
import { PinInput } from './PinInput';
import { ShieldIcon, SpinnerIcon } from './icons';
@@ -12,6 +19,18 @@ export function SecurityCenter({ userId }: Props) {
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [recovery, setRecovery] = useState<string | null>(null);
const [migration, setMigration] = useState<LegacyMigrationReport | null>(null);
const [wipeOnClose, setWipeOnCloseState] = useState<boolean>(() => isWipeOnCloseEnabled());
async function handleRetryMigration() {
setBusy(true); setMsg(null); setMigration(null);
try {
const report = await retryLegacyMigration(userId);
setMigration(report);
} catch (err) {
setMsg(err instanceof Error ? err.message : String(err));
} finally { setBusy(false); }
}
async function handleChangePin() {
setBusy(true); setMsg(null);
@@ -76,6 +95,60 @@ export function SecurityCenter({ userId }: Props) {
)}
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">Schlüssel-Migration reparieren</h3>
<p className="mb-2 text-xs text-fg-muted">
Versucht, alte Conversation-Schlüssel erneut für deine neue Identität zu re-wrappen.
Sicher zu klicken wenn Nachrichten verschlüsselt bleiben oder du nicht senden kannst.
</p>
<button type="button" disabled={busy} onClick={() => void handleRetryMigration()}
className="rounded-md border border-line bg-surface-3 px-3 py-2 text-sm hover:bg-surface-2"
>
{busy && <SpinnerIcon className="mr-1 inline h-4 w-4" />}Migration erneut ausführen
</button>
{migration && (
<div className="mt-2 rounded border border-line bg-surface-3 p-3 text-xs text-fg-muted">
<div>Geräte (Server): {migration.serverDevices}</div>
<div>Lokale Schlüssel im Vault: {migration.strongholdKeysFromServerDevices}
{migration.strongholdKeysFromBundleScan > 0 && (
<> (+{migration.strongholdKeysFromBundleScan} aus Bundle-Scan)</>
)}
</div>
<div>Versucht: {migration.attempted}, Erfolgreich: <span className="text-emerald-500">{migration.migrated}</span></div>
<div>Übersprungen (kein lokaler Schlüssel): {migration.noStrongholdKey}</div>
<div>Entschlüsselung gescheitert: {migration.decryptFailed}</div>
<div>Server-Fehler: {migration.rpcFailed}</div>
{migration.attempted > 0 && migration.migrated === 0 && (
<p className="mt-2 text-rose-300">
Keine Bundles migriert. Vermutlich hast du den ursprünglichen Geräteschlüssel nicht mehr lokal.
Nutze "Identität zurücksetzen" wenn du neu starten willst (alte Chats gehen verloren).
</p>
)}
</div>
)}
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">
Cache beim Schließen leeren
</h3>
<p className="mb-2 text-xs text-fg-muted">
Beim Beenden der App werden alle entschlüsselten Caches gelöscht. Beim nächsten Start
musst du wieder deine PIN eingeben. Empfohlen für gemeinsam genutzte Rechner.
</p>
<label className="inline-flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={wipeOnClose}
onChange={(e) => {
setWipeOnClose(e.target.checked);
setWipeOnCloseState(e.target.checked);
}}
/>
<span>Aktivieren</span>
</label>
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-rose-400">Identität zurücksetzen</h3>
<p className="mb-2 text-xs text-fg-muted">Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.</p>
@@ -0,0 +1,87 @@
import { useState } from 'react';
import { markAttachmentViewed } from '@chat-app/shared/chat';
import { supabase } from '../lib/supabase';
import { EyeOffIcon, LockIcon } from './icons';
interface Props {
attachmentId: string;
/** Already-viewed timestamp from the DB row. Renders tombstone immediately. */
viewedAt: string | null;
/** True iff the local user is the sender — they don't burn the view. */
isSender: boolean;
/** Decrypted image source; only fetched/displayed inside the lightbox. */
src: string;
}
// Three states:
// 1. viewedAt is null AND user is recipient → blurred lock card; tap opens
// fullscreen lightbox AND fires the mark-viewed RPC.
// 2. viewedAt is set → tombstone "Angesehen am …".
// 3. user is sender → normal image, tombstone update appears once recipient burns it.
export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) {
const [revealedAt, setRevealedAt] = useState<string | null>(viewedAt);
const [fullscreen, setFullscreen] = useState(false);
const burned = revealedAt !== null;
if (burned && !isSender) {
return (
<div className="flex h-32 w-48 items-center justify-center rounded-lg border border-dashed border-line bg-surface-3 text-xs text-fg-muted">
<EyeOffIcon className="mr-2 h-4 w-4" />
Angesehen am {new Date(revealedAt).toLocaleString()}
</div>
);
}
if (isSender) {
return (
<div className="relative">
<img src={src} alt="" className="max-h-72 rounded-lg" />
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-black/60 px-2 py-0.5 text-[10px] font-semibold text-white">
<EyeOffIcon className="h-3 w-3" /> Einmal ansehen
</span>
{burned && (
<span className="absolute right-2 bottom-2 rounded-full bg-emerald-500/80 px-2 py-0.5 text-[10px] font-semibold text-white">
Angesehen
</span>
)}
</div>
);
}
// Recipient, not yet viewed.
const handleOpen = async (): Promise<void> => {
try {
const res = await markAttachmentViewed(supabase, attachmentId);
if (res.viewedAt) setRevealedAt(res.viewedAt);
} catch (err) {
console.warn('mark-viewed failed', err);
}
setFullscreen(true);
};
return (
<>
<button
type="button"
onClick={() => void handleOpen()}
className="relative flex h-48 w-64 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-3 text-fg-muted hover:border-accent/40"
>
<LockIcon className="h-6 w-6 text-accent" />
<span className="text-xs font-medium">Einmal ansehen antippen</span>
</button>
{fullscreen && (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-6"
onClick={() => setFullscreen(false)}
>
<img src={src} alt="" className="max-h-full max-w-full rounded-lg" />
</div>
)}
</>
);
}
@@ -0,0 +1,126 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useOwnDevices } from '../../hooks/useOwnDevices';
import { getDeviceRowId } from '../../lib/deviceRowId';
import { SpinnerIcon } from '../icons';
function formatLastSeen(iso: string, locale: string): string {
try {
const d = new Date(iso);
return d.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' });
} catch {
return iso;
}
}
export function DeviceListTab() {
const { t, i18n } = useTranslation();
const { devices, loading, error, revoke } = useOwnDevices();
const ownId = getDeviceRowId();
const [busy, setBusy] = useState<string | null>(null);
const [opError, setOpError] = useState<string | null>(null);
const handleRevoke = async (id: string) => {
setOpError(null);
setBusy(id);
try {
await revoke(id);
} catch (err) {
setOpError(err instanceof Error ? err.message : 'revoke failed');
} finally {
setBusy(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-10 text-fg-muted">
<SpinnerIcon className="h-5 w-5" />
</div>
);
}
if (error) {
return (
<p className="rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-3 text-sm text-rose-600 dark:text-rose-300">
{t('app:settings.devices.error', { defaultValue: 'Geräteliste konnte nicht geladen werden.' })}
</p>
);
}
if (devices.length === 0) {
return (
<p className="text-sm text-fg-muted">
{t('app:settings.devices.empty', { defaultValue: 'Noch keine Geräte angemeldet.' })}
</p>
);
}
return (
<div className="space-y-3">
{opError && (
<p className="rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2 text-sm text-rose-600 dark:text-rose-300">
{opError}
</p>
)}
<ul className="space-y-2">
{devices.map((d) => {
const isOwn = d.id === ownId;
const isRevoked = d.revokedAt !== null;
return (
<li
key={d.id}
className="flex items-center gap-3 rounded-lg border border-line bg-surface-2 px-4 py-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-fg">{d.name}</span>
{isOwn && (
<span className="rounded-md bg-accent/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
{t('app:settings.devices.this_device', { defaultValue: 'Dieses Gerät' })}
</span>
)}
{isRevoked && (
<span className="rounded-md bg-rose-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-rose-600 dark:text-rose-300">
{t('app:settings.devices.revoked', { defaultValue: 'Abgemeldet' })}
</span>
)}
</div>
<p className="mt-0.5 text-xs text-fg-muted">
{d.platform} · {t('app:settings.devices.last_seen', { defaultValue: 'zuletzt' })}{' '}
{formatLastSeen(d.lastSeenAt, i18n.language)}
</p>
</div>
{isOwn ? (
<button
type="button"
disabled
title={t('app:settings.devices.use_sign_out', { defaultValue: 'Nutze Sign-out' })}
className="cursor-not-allowed rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted opacity-60"
>
{t('app:settings.devices.use_sign_out', { defaultValue: 'Nutze Sign-out' })}
</button>
) : isRevoked ? (
<span className="text-xs text-fg-muted">
{t('app:settings.devices.already_revoked', { defaultValue: '—' })}
</span>
) : (
<button
type="button"
onClick={() => void handleRevoke(d.id)}
disabled={busy === d.id}
className="cursor-pointer rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-wait disabled:opacity-60 dark:text-rose-300"
>
{busy === d.id
? t('app:settings.devices.revoking', { defaultValue: 'Wird abgemeldet…' })
: t('app:settings.devices.revoke', { defaultValue: 'Abmelden' })}
</button>
)}
</li>
);
})}
</ul>
</div>
);
}
+136 -2
View File
@@ -1,11 +1,15 @@
import {
fetchUserKeyBlob,
getOwnProfile,
listOwnDevices,
registerDevice,
signOut as supabaseSignOut,
touchDeviceLastSeen,
type Profile,
updateOwnProfile,
} from '@chat-app/shared/auth';
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
import type { DevicePlatform } from '@chat-app/shared/supabase';
import type { Session } from '@supabase/supabase-js';
import {
createContext,
@@ -19,10 +23,13 @@ import {
} from 'react';
import { useTranslation } from 'react-i18next';
import { clearDeviceRowId, getDeviceRowId, setDeviceRowId } from '../lib/deviceRowId';
import { ensureInstallId } from '../lib/installId';
import { wipeLocalState } from '../lib/memoryWipe';
import { isWipeOnCloseEnabled } from '../lib/memoryWipeSettings';
import { setSecretStoreUser } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import { cachedUserKey, ensureLegacyMigrated } from '../lib/userIdentity';
import { registerWebPush } from '../lib/webPush';
// Discriminated union describing the per-user encrypted key blob lifecycle:
@@ -48,6 +55,8 @@ interface AuthContextValue {
refreshProfile: () => Promise<void>;
refreshUserKeyState: () => Promise<void>;
signOut: () => Promise<void>;
revokedRemotely: boolean;
acknowledgeRevocation: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
@@ -58,6 +67,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
const [profile, setProfile] = useState<Profile | null>(null);
const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
const [revokedRemotely, setRevokedRemotely] = useState(false);
const autoOnlineUserRef = useRef<string | null>(null);
// Initial session + auth subscription. We verify the cached JWT against the
@@ -137,6 +147,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const cached = await cachedUserKey(session.user.id);
if (cached) {
setUserKeyState({ status: 'unlocked' });
// Best-effort: re-wrap any unmigrated legacy bundles. Idempotent (RPC
// uses ON CONFLICT DO NOTHING). Recovers users who set up under 0.18.0
// where the migration query had a `.eq(null)` bug that made it a no-op.
void ensureLegacyMigrated(session.user.id).catch((err) => {
console.warn('legacy conv-key migration on auth-resume failed', err);
});
return;
}
const blob = await fetchUserKeyBlob(supabase, session.user.id);
@@ -188,6 +204,70 @@ export function AuthProvider({ children }: { children: ReactNode }) {
void registerWebPush(installId);
}, [session]);
// Phase 3: ensure this install owns exactly one devices row. The row is
// pure session-list telemetry — it does not carry any cryptographic
// material since the per-user-key refactor. We re-use the row across
// restarts via localStorage (chatapp.deviceRowId.v1); a memory-wipe is
// intentionally treated as "new install".
useEffect(() => {
if (!session) return;
let cancelled = false;
void (async () => {
try {
const existing = getDeviceRowId();
if (existing) {
const rows = await listOwnDevices(supabase);
const match = rows.find((r) => r.id === existing && r.revokedAt === null);
if (match) {
await touchDeviceLastSeen(supabase, existing).catch(() => {});
return;
}
// Row gone / revoked: drop the stale id and fall through to
// registering a fresh one.
clearDeviceRowId();
}
if (cancelled) return;
const hostname =
(typeof window.electronAPI?.getHostname === 'function'
? await window.electronAPI.getHostname().catch(() => null)
: null) ?? 'Desktop';
// Map Node's process.platform values to the schema's device_platform
// enum ('windows' | 'macos' | 'linux' | 'ios' | 'android'). Default
// to 'linux' for unknown/web-build cases — the field is only used
// for the session-list UI icon.
const osPlatform = window.electronAPI?.osPlatform;
const platform: DevicePlatform =
osPlatform === 'win32'
? 'windows'
: osPlatform === 'darwin'
? 'macos'
: 'linux';
const created = await registerDevice(supabase, {
name: hostname.slice(0, 64),
platform,
});
if (!cancelled) setDeviceRowId(created.id);
} catch (err) {
console.warn('ensure device row failed', err);
}
})();
return () => {
cancelled = true;
};
}, [session]);
// Wipe-on-close: register a handler the main process pings on `before-quit`
// when the user has enabled the Settings → Sicherheit toggle. No-op outside
// Electron (web build has no preload bridge) or when the toggle is off.
useEffect(() => {
if (typeof window.electronAPI?.onWipeBeforeQuit !== 'function') return;
const unsub = window.electronAPI.onWipeBeforeQuit(async () => {
if (!isWipeOnCloseEnabled()) return;
await wipeLocalState(session?.user.id ?? null);
});
return unsub;
}, [session?.user.id]);
// Auto online/offline transition.
//
// - On mount with a session whose last persisted state is `offline`, flip
@@ -229,12 +309,54 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, [session, profile, refreshProfile]);
const signOut = useCallback(async () => {
const uid = session?.user.id ?? null;
await updateOwnProfile(supabase, { presenceState: 'offline' }).catch((err: unknown) => {
console.warn('offline update before sign-out failed', err);
});
await supabaseSignOut(supabase);
await wipeLocalState(uid);
}, [session]);
const acknowledgeRevocation = useCallback(() => {
setRevokedRemotely(false);
}, []);
// Phase 3: listen for own-device revocations. The same channel also fires
// when *another* of the user's installs is revoked — we ignore those (we
// only force-sign-out when OUR row's revoked_at flips). The UI's device
// list refetches independently via its own subscription in useOwnDevices.
useEffect(() => {
if (!session) return;
const userId = session.user.id;
const channel = supabase
.channel('devices:self:' + userId)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'devices',
filter: 'user_id=eq.' + userId,
},
(payload) => {
const ownId = getDeviceRowId();
const row = payload.new as { id?: string; revoked_at?: string | null } | null;
if (!row || !ownId) return;
if (row.id !== ownId) return;
if (row.revoked_at) {
setRevokedRemotely(true);
void signOut().catch((err) => {
console.warn('forced signOut after revoke failed', err);
});
}
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [session, signOut]);
const value = useMemo<AuthContextValue>(
() => ({
session,
@@ -244,8 +366,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
refreshProfile,
refreshUserKeyState,
signOut,
revokedRemotely,
acknowledgeRevocation,
}),
[session, profile, userKeyState, ready, refreshProfile, refreshUserKeyState, signOut],
[
session,
profile,
userKeyState,
ready,
refreshProfile,
refreshUserKeyState,
signOut,
revokedRemotely,
acknowledgeRevocation,
],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+24 -13
View File
@@ -1442,20 +1442,29 @@ export function CallProvider({ children }: { children: ReactNode }) {
// the same system audio twice and Chromium's version would
// include our own renderer playback.
audio: preferNativeLoopback ? false : wantAudio,
// Preset dims are passed as `ideal` constraints (not fixed width/height)
// so Chromium picks the closest match preserving the source's aspect
// ratio instead of CROPPING non-matching monitors. Without this, a 16:10
// monitor (1920×1200, 2560×1600) streamed under a 16:9 preset loses its
// bottom strip — including the Windows taskbar.
//
// LiveKit's VideoResolution types declare width/height as `number`, but
// Chromium's getDisplayMedia accepts the full MediaTrackConstraints shape
// including { ideal: N } objects — we cast through `unknown` to satisfy tsc.
...(ssParams.dims
? {
resolution: {
width: ssParams.dims.width,
height: ssParams.dims.height,
width: { ideal: ssParams.dims.width },
height: { ideal: ssParams.dims.height },
frameRate: fps,
},
} as unknown as { width: number; height: number; frameRate: number },
}
: {
resolution: {
width: 3840,
height: 2160,
frameRate: fps,
},
width: undefined,
height: undefined,
} as unknown as { width: number; height: number; frameRate: number },
}),
...(displaySurface
? ({ displaySurface } as { displaySurface: DisplaySurfaceHint })
@@ -2004,14 +2013,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
const syncGlobalShortcuts = () => {
if (!isTauriRuntime()) return;
// Only register an OS-level shortcut if the user explicitly opted in.
// Window-scoped firing happens via the `onKey` listener above and works
// for every enabled binding regardless of the `global` flag.
const enabledAndGlobal = (b: VoiceHotkeys[HotkeyKind]) => b.enabled && b.global;
const desired: Record<HotkeyKind, string | null> = {
mute: settings.mute.enabled ? bindingToTauriShortcut(settings.mute) : null,
deafen: settings.deafen.enabled ? bindingToTauriShortcut(settings.deafen) : null,
hangup: settings.hangup.enabled ? bindingToTauriShortcut(settings.hangup) : null,
screenShare: settings.screenShare.enabled
? bindingToTauriShortcut(settings.screenShare)
: null,
video: settings.video.enabled ? bindingToTauriShortcut(settings.video) : null,
mute: enabledAndGlobal(settings.mute) ? bindingToTauriShortcut(settings.mute) : null,
deafen: enabledAndGlobal(settings.deafen) ? bindingToTauriShortcut(settings.deafen) : null,
hangup: enabledAndGlobal(settings.hangup) ? bindingToTauriShortcut(settings.hangup) : null,
screenShare: enabledAndGlobal(settings.screenShare) ? bindingToTauriShortcut(settings.screenShare) : null,
video: enabledAndGlobal(settings.video) ? bindingToTauriShortcut(settings.video) : null,
};
for (const kind of KINDS) {
const want = desired[kind];
+74
View File
@@ -0,0 +1,74 @@
import { useCallback, useEffect, useState } from 'react';
import { type DeviceRecord, listOwnDevices, revokeDevice } from '@chat-app/shared/auth';
import { useAuth } from '../context/AuthContext';
import { supabase } from '../lib/supabase';
interface State {
devices: DeviceRecord[];
loading: boolean;
error: string | null;
}
export function useOwnDevices(): {
devices: DeviceRecord[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
revoke: (deviceId: string) => Promise<void>;
} {
const { session } = useAuth();
const userId = session?.user.id ?? null;
const [state, setState] = useState<State>({ devices: [], loading: true, error: null });
const refresh = useCallback(async () => {
try {
setState((s) => ({ ...s, loading: true, error: null }));
const list = await listOwnDevices(supabase);
setState({ devices: list, loading: false, error: null });
} catch (err) {
setState((s) => ({
...s,
loading: false,
error: err instanceof Error ? err.message : 'failed to load devices',
}));
}
}, []);
useEffect(() => {
if (!userId) {
setState({ devices: [], loading: false, error: null });
return;
}
void refresh();
const channel = supabase
.channel('devices:list:' + userId)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'devices',
filter: 'user_id=eq.' + userId,
},
() => {
void refresh();
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [userId, refresh]);
const revoke = useCallback(
async (deviceId: string) => {
await revokeDevice(supabase, deviceId);
await refresh();
},
[refresh],
);
return { devices: state.devices, loading: state.loading, error: state.error, refresh, revoke };
}
+8
View File
@@ -25,5 +25,13 @@ export async function createLibsodiumBackend(): Promise<CryptoBackend> {
s.crypto_box_open_easy(ciphertext, nonce, senderPublicKey, recipientPrivateKey),
secretbox: (plaintext, nonce, key) => s.crypto_secretbox_easy(plaintext, nonce, key),
secretboxOpen: (ciphertext, nonce, key) => s.crypto_secretbox_open_easy(ciphertext, nonce, key),
pwhashConsts: {
OPSLIMIT_MODERATE: s.crypto_pwhash_OPSLIMIT_MODERATE,
MEMLIMIT_MODERATE: s.crypto_pwhash_MEMLIMIT_MODERATE,
ALG_ARGON2ID13: s.crypto_pwhash_ALG_ARGON2ID13,
},
pwhash: (outLen, password, salt, opslimit, memlimit, alg) =>
s.crypto_pwhash(outLen, password, salt, opslimit, memlimit, alg),
scalarMultBase: (priv) => s.crypto_scalarmult_base(priv),
};
}
+30
View File
@@ -0,0 +1,30 @@
// localStorage key for "the devices.id row that belongs to THIS install".
// Reset on memory-wipe (NOT preserved) — a wiped install is conceptually a
// fresh install, so registering a new row is correct.
const KEY = 'chatapp.deviceRowId.v1';
export function getDeviceRowId(): string | null {
try {
const v = window.localStorage.getItem(KEY);
return v && v.length > 0 ? v : null;
} catch {
return null;
}
}
export function setDeviceRowId(id: string): void {
try {
window.localStorage.setItem(KEY, id);
} catch {
/* quota */
}
}
export function clearDeviceRowId(): void {
try {
window.localStorage.removeItem(KEY);
} catch {
/* no-op */
}
}
+64
View File
@@ -0,0 +1,64 @@
import { useSyncExternalStore } from 'react';
// Local-only friend nickname overrides. Stored in localStorage keyed by the
// peer's user-id. Empty/missing value = use the real display name.
// Local-only by design — friends never see your nickname for them.
const STORAGE_KEY = 'chatapp.friendNicknames.v1';
let cache: Record<string, string> | null = null;
const listeners = new Set<() => void>();
function load(): Record<string, string> {
if (cache) return cache;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) { cache = {}; return cache; }
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === 'object') {
cache = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string' && v.trim().length > 0) cache[k] = v;
}
return cache;
}
} catch { /* corrupted; fall through */ }
cache = {};
return cache;
}
function persist(): void {
try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cache ?? {})); }
catch { /* quota / private mode */ }
for (const l of listeners) l();
}
export function getNickname(userId: string): string | null {
return load()[userId] ?? null;
}
export function setNickname(userId: string, nickname: string | null): void {
const store = load();
const trimmed = nickname?.trim() ?? '';
if (trimmed.length === 0) {
if (!(userId in store)) return;
delete store[userId];
} else {
if (store[userId] === trimmed) return;
store[userId] = trimmed;
}
persist();
}
// Reactive hook: returns the current nickname for a user, or `fallback`
// when no nickname is set. Re-renders when ANY nickname changes (cheap,
// the set is small).
export function useNickname(userId: string | null | undefined, fallback: string): string {
const subscribe = (cb: () => void) => {
listeners.add(cb);
return () => { listeners.delete(cb); };
};
const getSnapshot = () => (userId ? getNickname(userId) : null);
const nickname = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return nickname ?? fallback;
}
+55
View File
@@ -0,0 +1,55 @@
import { clearConvKeyCache } from '@chat-app/shared/chat';
import { devLocalSecretStore } from './secretStore';
// Aggressively scrub local crypto + chat caches on sign-out (and on
// app-close if the user opted in). Preserves things that aren't sensitive
// and would be annoying to lose (theme, locale, install-id).
//
// We can't enumerate IndexedDB names without async + the indexedDB API,
// so we list the ones we know about explicitly. Adding a new local store
// later? Append to LOCAL_DBS.
const LOCAL_DBS = ['soundboard', 'message-cache', 'chatapp-attachments'];
const PRESERVE_LOCAL_STORAGE = new Set([
'chatapp.theme',
'chatapp.locale',
'chatapp.installId',
'chatapp.wipeOnClose.v1',
'i18nextLng',
]);
export async function wipeLocalState(userId: string | null): Promise<void> {
// 1. Per-conversation key cache (in-memory).
try { clearConvKeyCache(); } catch { /* never throws but be defensive */ }
// 2. Stronghold / secret-store: drop the user-priv blob for this user.
if (userId) {
try { await devLocalSecretStore.removeSecret('chatapp.userpriv.' + userId); }
catch (err) { console.warn('[wipe] userpriv remove failed', err); }
}
// 3. localStorage — preserve only the explicit whitelist.
try {
const keysToDrop: string[] = [];
for (let i = 0; i < window.localStorage.length; i++) {
const k = window.localStorage.key(i);
if (k && !PRESERVE_LOCAL_STORAGE.has(k)) keysToDrop.push(k);
}
for (const k of keysToDrop) window.localStorage.removeItem(k);
} catch (err) { console.warn('[wipe] localStorage clear failed', err); }
// 4. sessionStorage — always full.
try { window.sessionStorage.clear(); } catch { /* ignored */ }
// 5. IndexedDB — delete known databases. Resolves even when blocked so
// we don't hang sign-out forever.
await Promise.allSettled(LOCAL_DBS.map((name) => new Promise<void>((resolve) => {
try {
const req = window.indexedDB.deleteDatabase(name);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
} catch { resolve(); }
})));
}
@@ -0,0 +1,24 @@
// Persisted toggle for the "wipe local caches on app close" feature.
// Read by SecurityCenter (UI) and AuthContext (renderer-side IPC handler).
//
// Stored in localStorage so the preference survives sessions without
// touching the secret-store or SQLite. The key is on the preserve
// whitelist in lib/memoryWipe.ts so a sign-out wipe (or a closing wipe)
// doesn't blow away the user's own preference.
const KEY = 'chatapp.wipeOnClose.v1';
export function isWipeOnCloseEnabled(): boolean {
try {
return window.localStorage.getItem(KEY) === '1';
} catch {
return false;
}
}
export function setWipeOnClose(enabled: boolean): void {
try {
window.localStorage.setItem(KEY, enabled ? '1' : '0');
} catch {
/* ignored */
}
}
+115
View File
@@ -0,0 +1,115 @@
// GIPHY v1 client — Google closed Tenor to new API clients in Jan 2026, so
// the picker uses GIPHY's Developer API instead. Provision a free key at
// https://developers.giphy.com/dashboard/ and expose it via the renderer
// env var VITE_GIPHY_API_KEY (e.g. in apps/desktop/.env.local). When unset,
// every call throws — the picker degrades to a friendly
// "GIFs sind nicht konfiguriert" message via the consumer's catch.
//
// The file is still named `tenor.ts` for import-path stability; rename later
// if it bothers anyone.
const ENDPOINT = 'https://api.giphy.com/v1/gifs';
const RECENT_KEY = 'chatapp.gifRecent.v1';
const RECENT_MAX = 24;
const API_KEY = (
(import.meta as unknown as { env?: { VITE_GIPHY_API_KEY?: string } }).env
?.VITE_GIPHY_API_KEY ?? ''
).trim();
export interface GifResult {
id: string;
// Animated full-size URL (typically <2 MB).
url: string;
// Small preview shown in the picker grid.
previewUrl: string;
width: number;
height: number;
description: string;
}
interface GiphyImage {
url?: string;
width?: string;
height?: string;
}
interface GiphyResult {
id: string;
title?: string;
images?: {
original?: GiphyImage;
fixed_height?: GiphyImage;
fixed_height_small?: GiphyImage;
preview_gif?: GiphyImage;
};
}
function mapResult(r: GiphyResult): GifResult | null {
const full = r.images?.original ?? r.images?.fixed_height;
const preview =
r.images?.fixed_height_small ?? r.images?.preview_gif ?? r.images?.fixed_height ?? full;
if (!full?.url || !preview?.url) return null;
return {
id: r.id,
url: full.url,
previewUrl: preview.url,
width: full.width ? Number(full.width) : 0,
height: full.height ? Number(full.height) : 0,
description: r.title ?? '',
};
}
async function fetchGiphy(path: string, params: Record<string, string>): Promise<GifResult[]> {
if (!API_KEY) {
throw new Error('giphy_api_key_missing');
}
const search = new URLSearchParams({ api_key: API_KEY, rating: 'pg-13', ...params });
const res = await fetch(`${ENDPOINT}/${path}?${search.toString()}`);
if (!res.ok) throw new Error('giphy http ' + res.status);
const json = (await res.json()) as { data?: GiphyResult[] };
return (json.data ?? []).map(mapResult).filter((x): x is GifResult => x !== null);
}
export function isGifProviderConfigured(): boolean {
return API_KEY.length > 0;
}
// Back-compat alias for the original Tenor function name. New code should
// use isGifProviderConfigured.
export const isTenorConfigured = isGifProviderConfigured;
export async function searchGifs(query: string, locale: string = 'de'): Promise<GifResult[]> {
if (!query.trim()) return featuredGifs(locale);
return fetchGiphy('search', { q: query, limit: '30', lang: locale });
}
export async function featuredGifs(_locale: string = 'de'): Promise<GifResult[]> {
return fetchGiphy('trending', { limit: '30' });
}
export function getRecentGifs(): GifResult[] {
try {
const raw = window.localStorage.getItem(RECENT_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(x): x is GifResult =>
x != null &&
typeof x === 'object' &&
typeof (x as GifResult).url === 'string',
);
} catch {
return [];
}
}
export function rememberRecentGif(gif: GifResult): void {
const current = getRecentGifs().filter((g) => g.id !== gif.id);
const next = [gif, ...current].slice(0, RECENT_MAX);
try {
window.localStorage.setItem(RECENT_KEY, JSON.stringify(next));
} catch {
/* quota */
}
}
@@ -70,7 +70,12 @@ function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
}
export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & {
send: (text: string, images?: File[], replyToId?: string | null) => Promise<void>;
send: (
text: string,
images?: File[],
replyToId?: string | null,
opts?: { viewOnce?: boolean },
) => Promise<void>;
refresh: () => Promise<void>;
pending: OutboxItem[];
retryPending: (id: string) => void;
@@ -559,7 +564,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
);
const send = useCallback(
async (text: string, images: File[] = [], replyToId: string | null = null) => {
async (
text: string,
images: File[] = [],
replyToId: string | null = null,
opts: { viewOnce?: boolean } = {},
) => {
const trimmed = text.trim();
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
const priv = privateKeyRef.current;
@@ -631,6 +641,14 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
...(dims.width !== undefined ? { width: dims.width } : {}),
...(dims.height !== undefined ? { height: dims.height } : {}),
});
// Stamp the view-once flag on each handle the caller requested it
// for. The flag rides inside the encrypted payload (so peers can
// render the locked card without leaking who-sent-what to the
// server) AND lands on the public message_attachments row via
// insertAttachmentRow below (where the mark-viewed RPC enforces it).
if (opts.viewOnce) {
res.handle.viewOnce = true;
}
handles.push(res.handle);
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce));
}
@@ -0,0 +1,45 @@
import { useEffect } from 'react';
import { notify } from './osNotify';
import { supabase } from './supabase';
interface MentionRow {
message_id: string;
mentioned_user_id: string;
conversation_id: string;
}
// Subscribes to my own message_mentions inserts and fires an OS notification
// for each one. Bypasses per-conv mute (mentions override mute by design).
//
// We don't decrypt the body here — the notification just says "Du wurdest
// erwähnt". The conv list highlight + the in-app navigation reveal context.
export function useMentionNotifications(userId: string | undefined): void {
useEffect(() => {
if (!userId) return;
const channel = supabase
.channel('mentions:' + userId)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'message_mentions',
filter: 'mentioned_user_id=eq.' + userId,
},
(payload) => {
const row = payload.new as MentionRow | null;
if (!row) return;
void notify({
title: 'Du wurdest erwähnt',
body: 'Tippe um die Nachricht zu lesen.',
force: true,
});
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [userId]);
}
+48
View File
@@ -0,0 +1,48 @@
import { listPinnedMessages, type PinnedMessage } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { supabase } from './supabase';
// Live list of pinned messages for one conversation. Subscribes to the
// `pinned_messages` realtime channel for the conv so the header pill +
// side-panel update without a refetch.
export function usePinnedMessages(conversationId: string | undefined): PinnedMessage[] {
const [pins, setPins] = useState<PinnedMessage[]>([]);
useEffect(() => {
if (!conversationId) {
setPins([]);
return;
}
let cancelled = false;
void listPinnedMessages(supabase, conversationId).then((rows) => {
if (!cancelled) setPins(rows);
});
const channel = supabase
.channel('pinned_messages:' + conversationId)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'pinned_messages',
filter: 'conversation_id=eq.' + conversationId,
},
() => {
void listPinnedMessages(supabase, conversationId).then((rows) => {
if (!cancelled) setPins(rows);
});
},
)
.subscribe();
return () => {
cancelled = true;
void supabase.removeChannel(channel);
};
}, [conversationId]);
return pins;
}
+104 -12
View File
@@ -3,7 +3,7 @@ import {
resetUserKey, tryUnlockUserKey, uploadUserKeyBlob,
} from '@chat-app/shared/auth';
import {
generateRecoveryCode, generateUserKeyPair, normalizeRecoveryCode,
generateRecoveryCode, generateUserKeyPair, getCryptoBackend, normalizeRecoveryCode,
openUserKey, sealUserKey,
} from '@chat-app/shared/crypto';
import { migrateOwnLegacyBundles } from '@chat-app/shared/chat';
@@ -36,12 +36,24 @@ export async function setupNewUserIdentity(p: SetupParams): Promise<SetupResult>
recoverySalt: recoverySealed?.salt ?? null,
});
await devLocalSecretStore.setSecret(cacheKey(p.userId), kp.privateKey);
void runLegacyMigration(p.userId, kp.privateKey, kp.publicKey).catch((err) => {
void ensureLegacyMigrated(p.userId).catch((err) => {
console.warn('legacy conv-key migration failed', err);
});
return { publicKey: kp.publicKey, recoveryCode };
}
// Background, idempotent re-wrap of own legacy conv-key bundles for the new
// per-user identity. Safe to call repeatedly: the underlying RPC uses
// ON CONFLICT DO NOTHING. Triggered on every successful unlock so users who
// upgraded to 0.18.0 (where the .eq(null) bug made setup-time migration a
// no-op) auto-recover on the next launch.
export async function ensureLegacyMigrated(userId: string): Promise<void> {
const priv = await devLocalSecretStore.getSecret(cacheKey(userId));
if (!priv) return;
const pub = derivePublicKey(priv);
await runLegacyMigration(userId, priv, pub);
}
export interface UnlockParams { userId: string; pin: string; isRecoveryCode?: boolean }
export type UnlockOutcome =
@@ -66,6 +78,9 @@ export async function loadOrUnlockUserKey(p: UnlockParams): Promise<UnlockOutcom
}
await recordPinAttempt(supabase, p.userId, true, p.isRecoveryCode === true).catch(() => {});
await devLocalSecretStore.setSecret(cacheKey(p.userId), priv);
void ensureLegacyMigrated(p.userId).catch((err) => {
console.warn('legacy conv-key migration failed', err);
});
return { kind: 'unlocked' };
}
@@ -90,7 +105,7 @@ export async function changePin(params: {
const fresh = await sealUserKey({ privateKey: cached, pin: params.newPin });
await uploadUserKeyBlob(supabase, {
userId: params.userId,
publicKey: await derivePublicKey(cached),
publicKey: derivePublicKey(cached),
sealedPrivateKey: fresh.sealedPrivateKey,
salt: fresh.salt,
kdfParams: fresh.kdfParams,
@@ -107,7 +122,7 @@ export async function regenerateRecoveryCode(params: { userId: string }): Promis
const sealed = await sealUserKey({ privateKey: cached, pin: normalizeRecoveryCode(recoveryCode) });
await uploadUserKeyBlob(supabase, {
userId: params.userId,
publicKey: await derivePublicKey(cached),
publicKey: derivePublicKey(cached),
sealedPrivateKey: blob.sealedPrivateKey,
salt: blob.salt,
kdfParams: blob.kdfParams,
@@ -133,21 +148,84 @@ export async function resetIdentity(params: { userId: string; pin: string }): Pr
return setup.recoveryCode ?? '';
}
// Returned by ensureLegacyMigrated and SecurityCenter's manual retry. Lets
// the UI surface "X conv-keys re-wrapped, Y stuck because no key in vault."
export interface LegacyMigrationReport {
serverDevices: number;
strongholdKeysFromServerDevices: number;
strongholdKeysFromBundleScan: number;
attempted: number;
migrated: number;
noStrongholdKey: number;
decryptFailed: number;
rpcFailed: number;
}
async function runLegacyMigration(
userId: string,
ownNewPriv: Uint8Array,
ownNewPub: Uint8Array,
): Promise<void> {
): Promise<LegacyMigrationReport> {
const report: LegacyMigrationReport = {
serverDevices: 0,
strongholdKeysFromServerDevices: 0,
strongholdKeysFromBundleScan: 0,
attempted: 0,
migrated: 0,
noStrongholdKey: 0,
decryptFailed: 0,
rpcFailed: 0,
};
const devices = await listOwnDevices(supabase);
if (devices.length === 0) return;
report.serverDevices = devices.length;
const ownLegacyDevicePrivateKeys: Record<string, Uint8Array> = {};
// 1) Try every server-listed device first.
for (const d of devices) {
const k = await devLocalSecretStore.getSecret(`chatapp.priv.${userId}.${d.id}`);
if (k) ownLegacyDevicePrivateKeys[d.id] = k;
}
report.strongholdKeysFromServerDevices = Object.keys(ownLegacyDevicePrivateKeys).length;
// 2) Scan our visible un-migrated conversation_keys for distinct
// recipient_device_ids and probe stronghold for each. This catches the case
// where a device row was deleted server-side but its key remains locally,
// OR where listOwnDevices missed a device because of an RLS edge.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: scanRowsRaw } = await (supabase as any)
.from('conversation_keys')
.select('recipient_device_id')
.is('recipient_user_id', null)
.not('recipient_device_id', 'is', null);
const scanIds = Array.from(new Set(
((scanRowsRaw ?? []) as { recipient_device_id: string }[])
.map((r) => r.recipient_device_id)
.filter((id): id is string => !!id),
));
for (const id of scanIds) {
if (ownLegacyDevicePrivateKeys[id]) continue;
const k = await devLocalSecretStore.getSecret(`chatapp.priv.${userId}.${id}`);
if (k) {
ownLegacyDevicePrivateKeys[id] = k;
report.strongholdKeysFromBundleScan += 1;
}
}
console.info(
'[crypto-migration] vault scan:',
'serverDevices=' + report.serverDevices,
'keysFromServerList=' + report.strongholdKeysFromServerDevices,
'extraKeysFromBundleScan=' + report.strongholdKeysFromBundleScan,
);
const ids = Object.keys(ownLegacyDevicePrivateKeys);
if (ids.length === 0) return;
await migrateOwnLegacyBundles({
if (ids.length === 0) {
console.warn('[crypto-migration] no legacy private keys in vault — nothing to migrate');
return report;
}
const result = await migrateOwnLegacyBundles({
client: supabase,
ownUserId: userId,
ownNewPublicKey: ownNewPub,
@@ -155,10 +233,24 @@ async function runLegacyMigration(
ownLegacyDeviceIds: ids,
ownLegacyDevicePrivateKeys,
});
report.attempted = result.attempted;
report.migrated = result.migratedConversations;
report.noStrongholdKey = result.noStrongholdKey;
report.decryptFailed = result.decryptFailed;
report.rpcFailed = result.rpcFailed;
return report;
}
async function derivePublicKey(privateKey: Uint8Array): Promise<Uint8Array> {
const sodium = (await import('libsodium-wrappers-sumo')).default;
await sodium.ready;
return sodium.crypto_scalarmult_base(privateKey);
// Public wrapper for SecurityCenter's "Migration erneut versuchen" button.
// Returns a structured report so the UI can render numbers and reasons.
export async function retryLegacyMigration(userId: string): Promise<LegacyMigrationReport> {
const priv = await devLocalSecretStore.getSecret(cacheKey(userId));
if (!priv) throw new Error('user key not cached locally — re-login required');
const pub = derivePublicKey(priv);
return runLegacyMigration(userId, priv, pub);
}
function derivePublicKey(privateKey: Uint8Array): Uint8Array {
return getCryptoBackend().scalarMultBase(privateKey);
}
+13 -40
View File
@@ -20,6 +20,13 @@ export interface VoiceHotkeyBinding {
shift: boolean;
alt: boolean;
enabled: boolean;
/**
* When true, the hotkey is registered as an OS-level shortcut and fires
* even when the app isn't focused. When false (default) the binding only
* fires from the window's keydown listener so e.g. setting "M" as mute
* doesn't break typing "m" everywhere else on the system.
*/
global: boolean;
}
export interface VoiceHotkeys {
@@ -36,46 +43,11 @@ export interface VoiceHotkeys {
export type VoiceHotkeyKind = keyof VoiceHotkeys;
const DEFAULTS: VoiceHotkeys = {
mute: {
key: 'KeyM',
keyLabel: 'Ctrl+Shift+M',
ctrl: true,
shift: true,
alt: false,
enabled: false,
},
deafen: {
key: 'KeyD',
keyLabel: 'Ctrl+Shift+D',
ctrl: true,
shift: true,
alt: false,
enabled: false,
},
hangup: {
key: 'KeyH',
keyLabel: 'Ctrl+Shift+H',
ctrl: true,
shift: true,
alt: false,
enabled: false,
},
screenShare: {
key: 'KeyE',
keyLabel: 'Ctrl+Shift+E',
ctrl: true,
shift: true,
alt: false,
enabled: false,
},
video: {
key: 'KeyV',
keyLabel: 'Ctrl+Shift+V',
ctrl: true,
shift: true,
alt: false,
enabled: false,
},
mute: { key: 'KeyM', keyLabel: 'Ctrl+Shift+M', ctrl: true, shift: true, alt: false, enabled: false, global: false },
deafen: { key: 'KeyD', keyLabel: 'Ctrl+Shift+D', ctrl: true, shift: true, alt: false, enabled: false, global: false },
hangup: { key: 'KeyH', keyLabel: 'Ctrl+Shift+H', ctrl: true, shift: true, alt: false, enabled: false, global: false },
screenShare: { key: 'KeyE', keyLabel: 'Ctrl+Shift+E', ctrl: true, shift: true, alt: false, enabled: false, global: false },
video: { key: 'KeyV', keyLabel: 'Ctrl+Shift+V', ctrl: true, shift: true, alt: false, enabled: false, global: false },
};
type Listener = (s: VoiceHotkeys) => void;
@@ -93,6 +65,7 @@ function validateBinding(raw: unknown, fallback: VoiceHotkeyBinding): VoiceHotke
shift: typeof b.shift === 'boolean' ? b.shift : fallback.shift,
alt: typeof b.alt === 'boolean' ? b.alt : fallback.alt,
enabled: typeof b.enabled === 'boolean' ? b.enabled : fallback.enabled,
global: typeof b.global === 'boolean' ? b.global : fallback.global,
};
}
+24 -22
View File
@@ -2,10 +2,11 @@ import { acceptDm, type ConversationSummary, isConversationMuted } from '@chat-a
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, Outlet, useParams } from 'react-router-dom';
import { NavLink, Outlet, useNavigate, useParams } from 'react-router-dom';
import { ConversationRowMenu } from '../components/ConversationRowMenu';
import { CreateGroupDialog } from '../components/CreateGroupDialog';
import { EmptyState } from '../components/EmptyState';
import {
AddUserIcon,
ArchiveIcon,
@@ -119,6 +120,7 @@ function ConversationList({
archivedCount,
}: ConversationListProps) {
const { t } = useTranslation(['app']);
const navigate = useNavigate();
return (
<aside
@@ -223,27 +225,27 @@ function ConversationList({
) : error ? (
<p className="px-4 py-2 text-xs text-rose-500 dark:text-rose-300">{error}</p>
) : items.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-line bg-surface-3 text-accent">
{showArchived ? (
<ArchiveIcon className="h-5 w-5" />
) : (
<ChatBubbleIcon className="h-5 w-5" />
)}
</div>
<p className="text-sm text-fg-muted">
{showArchived
? t('app:chats.archived_empty_title', { defaultValue: 'Nichts archiviert' })
: t('app:chats.empty_title')}
</p>
<p className="text-xs text-fg-muted/80">
{showArchived
? t('app:chats.archived_empty_subtitle', {
defaultValue: 'Archivierte Unterhaltungen erscheinen hier.',
})
: t('app:chats.empty_subtitle')}
</p>
</div>
showArchived ? (
<EmptyState
icon={<ArchiveIcon className="h-8 w-8" />}
title={t('app:chats.archived_empty_title', { defaultValue: 'Nichts archiviert' })}
description={t('app:chats.archived_empty_subtitle', {
defaultValue: 'Archivierte Unterhaltungen erscheinen hier.',
})}
/>
) : (
<EmptyState
icon={<ChatBubbleIcon className="h-8 w-8" />}
title={t('app:chats.empty_title', { defaultValue: 'Noch keine Chats' })}
description={t('app:chats.empty_desc', {
defaultValue: 'Lade einen Freund ein und schreibe die erste Nachricht.',
})}
action={{
label: t('app:chats.empty_cta', { defaultValue: 'Freunde verwalten' }),
onClick: () => navigate('/friends'),
}}
/>
)
) : (
<VirtualConversationList
items={items}
+106 -3
View File
@@ -1,4 +1,4 @@
import { parseMessagePayload } from '@chat-app/shared/chat';
import { parseMessagePayload, pinMessage, unpinMessage } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -6,17 +6,21 @@ import { useParams } from 'react-router-dom';
import { ConversationHeader } from '../components/ConversationHeader';
import { EmojiPicker } from '../components/EmojiPicker';
import { EmptyState } from '../components/EmptyState';
import { ForwardDialog } from '../components/ForwardDialog';
import { GifPicker } from '../components/GifPicker';
import { GroupInfoPanel } from '../components/GroupInfoPanel';
import {
AlertIcon,
ArrowRightIcon,
ChevronDownIcon,
ChevronUpIcon,
EyeOffIcon,
PlusIcon,
PollIcon,
ReplyIcon,
SearchIcon,
SendIcon,
SmileIcon,
SpinnerIcon,
XIcon,
@@ -27,6 +31,7 @@ import { CallPreviewPanel } from '../components/CallPreviewPanel';
import { MediaFilesDrawer } from '../components/MediaFilesDrawer';
import { MentionAutocomplete } from '../components/MentionAutocomplete';
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel';
import { PollComposerDialog } from '../components/PollComposerDialog';
import { UserProfilePopover } from '../components/UserProfilePopover';
import { TypingIndicator } from '../components/TypingIndicator';
@@ -39,6 +44,8 @@ import { collectConversationAttachments, createPollPayload } from '../lib/conver
import { compressImages } from '../lib/imageCompress';
import { ensureInstallId } from '../lib/installId';
import { searchCachedMessages } from '../lib/messageCache';
import { supabase } from '../lib/supabase';
import { type GifResult } from '../lib/tenor';
import type { OutboxItem } from '../lib/messageOutbox';
import { useConversationMessages } from '../lib/useConversationMessages';
import { useMessageReactions } from '../lib/useMessageReactions';
@@ -46,6 +53,7 @@ import { useGroupReceipts } from '../lib/useGroupReceipts';
import { markDelivered, useMessageDeliveries } from '../lib/useMessageDeliveries';
import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useMessageReads';
import { usePeerPresence } from '../lib/usePeerPresence';
import { usePinnedMessages } from '../lib/usePinnedMessages';
import { useTypingChannel } from '../lib/useTypingChannel';
const STICK_THRESHOLD = 80;
@@ -175,6 +183,47 @@ export function ConversationPage() {
} | null>(null);
const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null);
const [emojiOpen, setEmojiOpen] = useState(false);
const [gifPickerOpen, setGifPickerOpen] = useState(false);
// Sticky toggle: when on, the next image(s) sent are marked view-once.
// Auto-clears on a successful send so the composer doesn't accidentally
// burn the message-after-next.
const [viewOnceNext, setViewOnceNext] = useState(false);
const pins = usePinnedMessages(id);
const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false);
const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]);
const handleTogglePin = useCallback(
async (messageId: string) => {
if (!id || !myId) return;
try {
if (pinnedIds.has(messageId)) {
await unpinMessage(supabase, id, messageId);
} else {
await pinMessage(supabase, id, messageId, myId);
}
} catch (err) {
console.warn('pin toggle failed', err);
}
},
[id, myId, pinnedIds],
);
const handleGifPick = useCallback(
async (gif: GifResult) => {
try {
// Download the GIF bytes once and feed them into the existing
// image-attachment pipeline so the result is end-to-end-encrypted
// like any normal image.
const res = await fetch(gif.url);
const blob = await res.blob();
const file = new File([blob], `tenor-${gif.id}.gif`, { type: 'image/gif' });
await send('', [file], null);
} catch (err) {
console.warn('GIF send failed', err);
}
},
[send],
);
const scrollRef = useRef<HTMLDivElement>(null);
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -492,10 +541,13 @@ export function ConversationPage() {
setSending(true);
setSendError(null);
try {
await send(text, attachments, replyTo?.id ?? null);
await send(text, attachments, replyTo?.id ?? null, { viewOnce: viewOnceNext });
setText('');
setAttachments([]);
setReplyTo(null);
// Reset the sticky view-once flag so it only applies to the message
// the user explicitly armed it for — Snapchat / WhatsApp parity.
setViewOnceNext(false);
if (fileInputRef.current) fileInputRef.current.value = '';
setStickToBottom(true);
notifyStopTyping();
@@ -603,6 +655,8 @@ export function ConversationPage() {
: {})}
onSearchClick={() => setSearchOpen((v) => !v)}
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
pinnedCount={pins.length}
onOpenPinned={() => setPinnedPanelOpen(true)}
/>
)}
@@ -670,7 +724,13 @@ export function ConversationPage() {
) : error ? (
<Banner>{error}</Banner>
) : messages.length === 0 ? (
<p className="text-center text-sm text-fg-muted"></p>
<EmptyState
icon={<SendIcon className="h-8 w-8" />}
title={t('app:chats.conv_empty_title', { defaultValue: 'Sag Hallo 👋' })}
description={t('app:chats.conv_empty_desc', {
defaultValue: 'Hier ist noch nichts. Schreibe die erste Nachricht.',
})}
/>
) : (
<ul className="space-y-0.5">
{displayCount < messages.length && (
@@ -749,6 +809,8 @@ export function ConversationPage() {
});
}}
highlighted={highlightedId === m.id}
isPinned={pinnedIds.has(m.id)}
onTogglePin={handleTogglePin}
/>
</li>
);
@@ -942,6 +1004,36 @@ export function ConversationPage() {
onClose={() => setEmojiOpen(false)}
/>
</div>
<div className="relative">
<button
type="button"
onClick={() => setGifPickerOpen((v) => !v)}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
aria-label="GIF"
title="GIF einfügen"
>
<span className="text-xs font-bold">GIF</span>
</button>
<GifPicker
open={gifPickerOpen}
onClose={() => setGifPickerOpen(false)}
onPick={(gif) => void handleGifPick(gif)}
/>
</div>
<button
type="button"
onClick={() => setViewOnceNext((v) => !v)}
aria-pressed={viewOnceNext}
title={viewOnceNext ? 'Nächstes Bild: einmal ansehen' : 'Nächstes Bild: normal'}
className={
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md transition ' +
(viewOnceNext
? 'bg-accent/20 text-accent'
: 'text-fg-muted hover:bg-surface-3 hover:text-fg')
}
>
<EyeOffIcon className="h-4 w-4" />
</button>
<VoiceRecorder
disabled={sending}
onComplete={async (file) => {
@@ -1060,6 +1152,17 @@ export function ConversationPage() {
onClose={() => setProfilePopover(null)}
/>
)}
<PinnedMessagesPanel
open={pinnedPanelOpen}
pins={pins}
onClose={() => setPinnedPanelOpen(false)}
onJump={(_messageId) => {
// Future: scroll to message. For now just close the panel.
setPinnedPanelOpen(false);
}}
onUnpin={(messageId) => void handleTogglePin(messageId)}
/>
</div>
);
}
+83 -22
View File
@@ -8,12 +8,15 @@ import {
sendFriendRequest,
} from '@chat-app/shared/friends';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Avatar } from '../components/Avatar';
import { EmptyState } from '../components/EmptyState';
import { NicknameDialog } from '../components/NicknameDialog';
import {
AddUserIcon,
AlertIcon,
ChatBubbleIcon,
CheckCircleIcon,
@@ -39,6 +42,12 @@ export function FriendsPage() {
const [searchError, setSearchError] = useState<string | null>(null);
const [pendingId, setPendingId] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [nicknameDialog, setNicknameDialog] = useState<{ userId: string; displayName: string } | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const focusSearch = useCallback(() => {
searchInputRef.current?.focus();
}, []);
useEffect(() => {
const id = window.setTimeout(() => setDebounced(query.trim()), 250);
@@ -119,6 +128,7 @@ export function FriendsPage() {
<div className="relative">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-muted" />
<input
ref={searchInputRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
@@ -160,24 +170,44 @@ export function FriendsPage() {
{loading ? (
<LoadingRow />
) : tab === 'friends' ? (
<FriendList
items={accepted}
emptyKey="app:friends.empty_friends"
renderActions={(f) => (
<FriendActions
busy={pendingId === f.peer.userId || pendingId === 'msg-' + f.peer.userId}
onMessage={() =>
performAction('msg-' + f.peer.userId, async () => {
const id = await createDm(supabase, f.peer.userId);
navigate('/chats/' + id);
})
}
onUnfriend={() =>
performAction(f.peer.userId, () => removeFriendship(supabase, f.peer.userId))
}
/>
)}
/>
accepted.length === 0 ? (
<EmptyState
icon={<AddUserIcon className="h-8 w-8" />}
title={t('app:friends.empty_title', { defaultValue: 'Noch keine Freunde' })}
description={t('app:friends.empty_desc', {
defaultValue: 'Suche einen Friend per Username oder schicke eine Einladung.',
})}
action={{
label: t('app:friends.empty_cta', { defaultValue: 'Friend hinzufügen' }),
onClick: focusSearch,
}}
/>
) : (
<FriendList
items={accepted}
emptyKey="app:friends.empty_friends"
onRowContextMenu={(f) =>
setNicknameDialog({
userId: f.peer.userId,
displayName: f.peer.displayName ?? f.peer.username ?? 'Freund',
})
}
renderActions={(f) => (
<FriendActions
busy={pendingId === f.peer.userId || pendingId === 'msg-' + f.peer.userId}
onMessage={() =>
performAction('msg-' + f.peer.userId, async () => {
const id = await createDm(supabase, f.peer.userId);
navigate('/chats/' + id);
})
}
onUnfriend={() =>
performAction(f.peer.userId, () => removeFriendship(supabase, f.peer.userId))
}
/>
)}
/>
)
) : tab === 'pending' ? (
<FriendList
items={outgoing}
@@ -211,6 +241,12 @@ export function FriendsPage() {
/>
)}
</div>
<NicknameDialog
open={nicknameDialog !== null}
userId={nicknameDialog?.userId ?? ''}
displayName={nicknameDialog?.displayName ?? ''}
onClose={() => setNicknameDialog(null)}
/>
</div>
);
}
@@ -253,10 +289,12 @@ function FriendList({
items,
emptyKey,
renderActions,
onRowContextMenu,
}: {
items: Friendship[];
emptyKey: string;
renderActions: (f: Friendship) => React.ReactNode;
onRowContextMenu?: (f: Friendship) => void;
}) {
const { t } = useTranslation(['app']);
if (items.length === 0) {
@@ -274,16 +312,39 @@ function FriendList({
<ul className="space-y-1.5">
{items.map((f) => (
<li key={f.peer.userId}>
<FriendRow profile={f.peer}>{renderActions(f)}</FriendRow>
<FriendRow
profile={f.peer}
{...(onRowContextMenu
? {
onContextMenu: (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
onRowContextMenu(f);
},
}
: {})}
>
{renderActions(f)}
</FriendRow>
</li>
))}
</ul>
);
}
function FriendRow({ profile, children }: { profile: ProfileBrief; children: React.ReactNode }) {
function FriendRow({
profile,
children,
onContextMenu,
}: {
profile: ProfileBrief;
children: React.ReactNode;
onContextMenu?: (e: React.MouseEvent<HTMLDivElement>) => void;
}) {
return (
<div className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3">
<div
onContextMenu={onContextMenu}
className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3"
>
<Avatar
url={profile.avatarUrl}
displayName={profile.displayName ?? profile.username}
+380 -147
View File
@@ -8,9 +8,22 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar } from '../components/Avatar';
import {
AtIcon,
BellIcon,
LockIcon,
MicIcon,
MonitorShareIcon,
MusicIcon,
ShieldIcon,
SignOutIcon,
SunIcon,
UsersIcon,
} from '../components/icons';
import { MicTestSection } from '../components/MicTestSection';
import { NotificationSoundSettings } from '../components/NotificationSoundSettings';
import { RingtoneSettings } from '../components/RingtoneSettings';
import { DeviceListTab } from '../components/settings/DeviceListTab';
import { SecurityCenter } from '../components/SecurityCenter';
import { SoundboardSettings } from '../components/SoundboardSettings';
import { useAuth } from '../context/AuthContext';
@@ -99,140 +112,279 @@ export function SettingsPage() {
void patchProfile({ locale });
}
// Tab pattern (macOS / Discord / GitHub style): the sidebar selects ONE
// section and only that section renders. activeTab is the single source of
// truth — no IntersectionObserver to drift, no smooth-scroll, no anchor-link
// routing conflict with HashRouter.
type TabId =
| 'profile' | 'appearance' | 'privacy' | 'notifications'
| 'voice' | 'screen-share' | 'soundboard' | 'security' | 'devices' | 'account';
const tabs: Array<{ id: TabId; label: string; Icon: typeof UsersIcon }> = [
{ id: 'profile', label: t('app:settings.nav_profile', { defaultValue: 'Profil' }), Icon: UsersIcon },
{ id: 'appearance', label: t('app:settings.nav_appearance', { defaultValue: 'Erscheinungsbild' }), Icon: SunIcon },
{ id: 'privacy', label: t('app:settings.nav_privacy', { defaultValue: 'Privatsphäre' }), Icon: ShieldIcon },
{ id: 'notifications', label: t('app:settings.nav_notifications', { defaultValue: 'Benachrichtigungen' }), Icon: BellIcon },
{ id: 'voice', label: t('app:settings.nav_voice', { defaultValue: 'Sprache & Anrufe' }), Icon: MicIcon },
{ id: 'screen-share', label: t('app:settings.nav_screen_share', { defaultValue: 'Bildschirmfreigabe' }), Icon: MonitorShareIcon },
{ id: 'soundboard', label: t('app:settings.nav_soundboard', { defaultValue: 'Soundboard' }), Icon: MusicIcon },
{ id: 'security', label: t('app:settings.nav_security', { defaultValue: 'Sicherheit' }), Icon: LockIcon },
{ id: 'devices', label: t('app:settings.nav_devices', { defaultValue: 'Geräte' }), Icon: MonitorShareIcon },
{ id: 'account', label: t('app:settings.nav_account', { defaultValue: 'Konto' }), Icon: SignOutIcon },
];
const [activeTab, setActiveTab] = useState<TabId>('profile');
return (
<div className="min-h-full bg-surface-3 text-fg">
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
<header className="mb-2">
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:settings.title')}
</h1>
</header>
{/* Account */}
<Section title={t('app:settings.section_account')}>
<ProfileVisualsControls patchProfile={patchProfile} busy={busy} />
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
<DisplayNameControls patchProfile={patchProfile} busy={busy} />
<Row label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
</Section>
{/* Startup */}
<Section title={t('app:settings.section_startup', { defaultValue: 'Start' })}>
<AutoStartControls />
</Section>
{/* Appearance */}
<Section title={t('app:settings.section_appearance')}>
<ThemeRow />
<SettingRow label={t('app:settings.language')}>
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
{SUPPORTED_LOCALES.map((locale) => {
const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
return (
<button
key={locale}
type="button"
disabled={busy}
onClick={() => void handleLocaleChange(locale)}
className={
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(active
? 'bg-accent text-accent-fg'
: 'text-fg-muted hover:text-fg')
}
>
{LOCALE_LABELS[locale]}
</button>
);
})}
<div className="mx-auto grid max-w-6xl gap-8 px-6 py-8 lg:grid-cols-[14rem_minmax(0,1fr)]">
{/* Sidebar */}
<aside className="hidden lg:block">
<div className="sticky top-8 space-y-1">
<h1 className="mb-4 px-3 font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:settings.title')}
</h1>
<nav aria-label={t('app:settings.title')} role="tablist" aria-orientation="vertical">
{tabs.map(({ id, label, Icon }) => {
const active = activeTab === id;
return (
<button
key={id}
type="button"
role="tab"
aria-selected={active}
aria-controls={'settings-panel-' + id}
onClick={() => setActiveTab(id)}
className={
'flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(active
? 'bg-accent/15 text-fg'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg')
}
>
<Icon
className={
'h-4 w-4 shrink-0 ' + (active ? 'text-accent' : 'text-fg-muted')
}
/>
<span className="truncate">{label}</span>
</button>
);
})}
</nav>
</div>
</SettingRow>
</Section>
</aside>
{/* Privacy */}
<Section title={t('app:settings.section_privacy')}>
<Toggle
label={t('app:settings.show_read_receipts')}
hint={t('app:settings.show_read_receipts_hint')}
checked={profile?.showReadReceipts ?? true}
disabled={busy || !profile}
onChange={(v) => void patchProfile({ showReadReceipts: v })}
/>
<Toggle
label={t('app:settings.allow_dms_strangers')}
hint={t('app:settings.allow_dms_strangers_hint')}
checked={profile?.allowDmsFromStrangers ?? true}
disabled={busy || !profile}
onChange={(v) => void patchProfile({ allowDmsFromStrangers: v })}
/>
</Section>
{/* Content panel — only the active tab renders */}
<main className="min-w-0">
{/* Mobile-only header + tab selector (sidebar is hidden below lg) */}
<div className="mb-6 space-y-3 lg:hidden">
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:settings.title')}
</h1>
<select
value={activeTab}
onChange={(e) => setActiveTab(e.target.value as TabId)}
className="w-full cursor-pointer rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
aria-label={t('app:settings.title')}
>
{tabs.map(({ id, label }) => (
<option key={id} value={id}>{label}</option>
))}
</select>
</div>
{/* Notification sound (new messages) */}
<Section
title={t('app:settings.section_notifications', { defaultValue: 'Benachrichtigungen' })}
>
<NotificationSoundSettings disabled={busy} />
</Section>
<div
id={'settings-panel-' + activeTab}
role="tabpanel"
aria-labelledby={'settings-tab-' + activeTab}
>
{activeTab === 'profile' && (
<Section
title={t('app:settings.section_account')}
description={t('app:settings.section_account_hint', {
defaultValue: 'Dein öffentliches Profil und wie andere dich sehen.',
})}
>
<ProfileVisualsControls patchProfile={patchProfile} busy={busy} />
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
<DisplayNameControls patchProfile={patchProfile} busy={busy} />
<Row icon={<AtIcon className="h-3.5 w-3.5" />} label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
</Section>
)}
{/* Ringtone (incoming custom) */}
<Section title={t('app:settings.section_ringtone', { defaultValue: 'Klingelton' })}>
<RingtoneSettings disabled={busy} />
</Section>
{activeTab === 'appearance' && (
<Section
title={t('app:settings.section_appearance')}
description={t('app:settings.section_appearance_hint', {
defaultValue: 'Theme, Sprache und Verhalten beim Systemstart.',
})}
>
<ThemeRow />
<SettingRow label={t('app:settings.language')}>
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
{SUPPORTED_LOCALES.map((locale) => {
const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
return (
<button
key={locale}
type="button"
disabled={busy}
onClick={() => void handleLocaleChange(locale)}
className={
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(active
? 'bg-accent text-accent-fg'
: 'text-fg-muted hover:text-fg')
}
>
{LOCALE_LABELS[locale]}
</button>
);
})}
</div>
</SettingRow>
<SubGroup>
<AutoStartControls />
</SubGroup>
</Section>
)}
{/* Soundboard */}
<Section title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}>
<SoundboardSettings />
</Section>
{activeTab === 'privacy' && (
<Section
title={t('app:settings.section_privacy')}
description={t('app:settings.section_privacy_hint', {
defaultValue: 'Wer dich kontaktieren darf und was Friends von dir sehen.',
})}
>
<Toggle
label={t('app:settings.show_read_receipts')}
hint={t('app:settings.show_read_receipts_hint')}
checked={profile?.showReadReceipts ?? true}
disabled={busy || !profile}
onChange={(v) => void patchProfile({ showReadReceipts: v })}
/>
<Toggle
label={t('app:settings.allow_dms_strangers')}
hint={t('app:settings.allow_dms_strangers_hint')}
checked={profile?.allowDmsFromStrangers ?? true}
disabled={busy || !profile}
onChange={(v) => void patchProfile({ allowDmsFromStrangers: v })}
/>
</Section>
)}
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
<AudioDeviceControls />
<div className="mt-3 border-t border-line pt-3">
<AudioQualityControls />
</div>
<div className="mt-3 border-t border-line pt-3">
<PttControls />
</div>
<div className="mt-3 border-t border-line pt-3">
<VoiceHotkeyControls kind="mute" />
</div>
<div className="mt-3 border-t border-line pt-3">
<VoiceHotkeyControls kind="deafen" />
</div>
<div className="mt-3 border-t border-line pt-3">
<VoiceHotkeyControls kind="hangup" />
</div>
<div className="mt-3 border-t border-line pt-3">
<VoiceHotkeyControls kind="screenShare" />
</div>
<div className="mt-3 border-t border-line pt-3">
<VoiceHotkeyControls kind="video" />
</div>
<div className="mt-3 border-t border-line pt-3">
<CallE2EEControls />
</div>
</Section>
{activeTab === 'notifications' && (
<Section
title={t('app:settings.section_notifications', { defaultValue: 'Benachrichtigungen' })}
description={t('app:settings.section_notifications_hint', {
defaultValue: 'Töne für eingehende Nachrichten und Anrufe.',
})}
>
<SubSection title={t('app:settings.subsection_message_sound', { defaultValue: 'Nachrichten-Ton' })}>
<NotificationSoundSettings disabled={busy} />
</SubSection>
<SubSection title={t('app:settings.subsection_ringtone', { defaultValue: 'Klingelton bei Anruf' })}>
<RingtoneSettings disabled={busy} />
</SubSection>
</Section>
)}
{/* Screen-share quality */}
<Section title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}>
<ScreenShareControls />
</Section>
{activeTab === 'voice' && (
<Section
title={t('app:settings.section_voice', { defaultValue: 'Sprache & Anrufe' })}
description={t('app:settings.section_voice_hint', {
defaultValue: 'Mikrofon, Audio-Qualität und Hotkeys für Anrufe.',
})}
>
<SubSection title={t('app:settings.subsection_audio_device', { defaultValue: 'Audio-Gerät' })}>
<AudioDeviceControls />
</SubSection>
<SubSection title={t('app:settings.subsection_audio_quality', { defaultValue: 'Audio-Qualität' })}>
<AudioQualityControls />
</SubSection>
<SubSection title={t('app:settings.subsection_ptt', { defaultValue: 'Push-to-Talk' })}>
<PttControls />
</SubSection>
<SubSection title={t('app:settings.subsection_hotkeys', { defaultValue: 'Hotkeys' })}>
<div className="space-y-2">
<VoiceHotkeyControls kind="mute" />
<VoiceHotkeyControls kind="deafen" />
<VoiceHotkeyControls kind="hangup" />
<VoiceHotkeyControls kind="screenShare" />
<VoiceHotkeyControls kind="video" />
</div>
</SubSection>
<SubSection title={t('app:settings.subsection_call_e2ee', { defaultValue: 'Anruf-Verschlüsselung' })}>
<CallE2EEControls />
</SubSection>
</Section>
)}
{/* Security */}
<Section title={t('app:settings.section_security', { defaultValue: 'Sicherheit' })}>
{profile?.userId && <SecurityCenter userId={profile.userId} />}
</Section>
{activeTab === 'screen-share' && (
<Section
title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}
description={t('app:settings.section_screen_share_hint', {
defaultValue: 'Auflösung und Bitrate beim Teilen deines Bildschirms.',
})}
>
<ScreenShareControls />
</Section>
)}
{/* Danger zone */}
<Section title={t('app:settings.danger_zone')}>
<button
type="button"
onClick={() => void signOut()}
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50 dark:text-rose-300"
>
{t('app:settings.sign_out')}
</button>
</Section>
{activeTab === 'soundboard' && (
<Section
title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}
description={t('app:settings.section_soundboard_hint', {
defaultValue: 'Eigene Sounds für Anrufe — verwaltet & abspielbar mit Hotkey.',
})}
>
<SoundboardSettings />
</Section>
)}
{activeTab === 'security' && (
<Section
title={t('app:settings.section_security', { defaultValue: 'Sicherheit' })}
description={t('app:settings.section_security_hint', {
defaultValue: 'PIN, Recovery-Code und Schlüssel-Reparatur.',
})}
>
{profile?.userId && <SecurityCenter userId={profile.userId} />}
</Section>
)}
{activeTab === 'devices' && (
<Section
title={t('app:settings.section_devices', { defaultValue: 'Geräte' })}
description={t('app:settings.section_devices_hint', {
defaultValue: 'Übersicht aller Geräte, die mit deinem Konto angemeldet sind.',
})}
>
<DeviceListTab />
</Section>
)}
{activeTab === 'account' && (
<Section
title={t('app:settings.section_account_mgmt', { defaultValue: 'Konto verwalten' })}
description={t('app:settings.section_account_mgmt_hint', {
defaultValue: 'Abmelden oder Konto-Aktionen.',
})}
tone="danger"
>
<button
type="button"
onClick={() => void signOut()}
className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50 dark:text-rose-300"
>
<SignOutIcon className="h-4 w-4" />
{t('app:settings.sign_out')}
</button>
</Section>
)}
</div>
</main>
</div>
</div>
);
@@ -436,22 +588,44 @@ function VoiceHotkeyControls({ kind }: { kind: VoiceHotkeyKind }) {
<SettingRow
label={t('app:settings.hotkey_binding', { defaultValue: 'Hotkey' })}
>
<button
type="button"
onClick={() => setCapturing((v) => !v)}
className={
'inline-flex min-w-[10rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(capturing
? 'border-accent bg-accent/20 text-fg animate-pulse'
: 'border-line bg-surface-3 text-fg hover:brightness-95')
}
>
{capturing
? t('app:settings.hotkey_press_combo', {
defaultValue: 'Kombination drücken…',
})
: binding.keyLabel}
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setCapturing((v) => !v)}
className={
'inline-flex min-w-[10rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(capturing
? 'border-accent bg-accent/20 text-fg animate-pulse'
: 'border-line bg-surface-3 text-fg hover:brightness-95')
}
>
{capturing
? t('app:settings.hotkey_press_combo', {
defaultValue: 'Kombination drücken…',
})
: binding.keyLabel}
</button>
<button
type="button"
onClick={() => updateVoiceHotkey(kind, { global: !binding.global })}
disabled={!binding.enabled}
aria-pressed={binding.global}
title={
binding.global
? 'Global: feuert auch wenn Netralax nicht fokussiert ist (PTT-Stil)'
: 'Nur im Fenster: feuert nur wenn Netralax fokussiert ist (empfohlen)'
}
className={
'ml-2 inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px] font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 ' +
(binding.global
? 'border-accent/40 bg-accent/15 text-accent'
: 'border-line bg-surface-3 text-fg-muted hover:text-fg')
}
>
<span aria-hidden>🌐</span>
<span>{binding.global ? 'Global' : 'Im Fenster'}</span>
</button>
</div>
</SettingRow>
</>
);
@@ -1138,21 +1312,80 @@ function formatBitrate(kbps: number): string {
return kbps + ' kbps';
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
function Section({
title,
description,
children,
tone,
}: {
title: string;
description?: string;
children: React.ReactNode;
tone?: 'default' | 'danger';
}) {
return (
<section className="rounded-2xl border border-line bg-surface-2 p-5">
<h2 className="mb-4 text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
{title}
</h2>
<div className="space-y-3">{children}</div>
<section
className={
'rounded-2xl border bg-surface-2 p-6 ' +
(tone === 'danger' ? 'border-rose-500/30' : 'border-line')
}
>
<header className="mb-5 border-b border-line pb-4">
<h2 className={'font-display text-lg font-semibold ' + (tone === 'danger' ? 'text-rose-500 dark:text-rose-300' : 'text-fg')}>
{title}
</h2>
{description && (
<p className="mt-1 text-xs text-fg-muted">{description}</p>
)}
</header>
<div className="space-y-4">{children}</div>
</section>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
// Sub-heading inside a Section — used to chunk dense sections like Voice into
// smaller named groups (Audio-Gerät / Qualität / PTT / Hotkeys / E2EE).
// No border: `--color-line` is already a semi-transparent token, and applying
// the `/60` opacity modifier brightens it (Tailwind overrides the original
// alpha) which made the sub-cards look harsher than the outer Section. Plain
// background tint + caps heading carry the grouping signal on their own.
function SubSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-2 rounded-xl bg-surface-3/50 p-4">
<h3 className="text-[11px] font-semibold uppercase tracking-[0.1em] text-fg-muted">
{title}
</h3>
<div className="space-y-3">{children}</div>
</div>
);
}
// Lighter wrapper for a single related extra control inside a Section that
// doesn't warrant its own SubSection card (e.g., autostart toggle inside
// Appearance).
function SubGroup({ children }: { children: React.ReactNode }) {
return (
<div className="space-y-3 border-t border-line pt-4">{children}</div>
);
}
function Row({
label,
value,
mono,
icon,
}: {
label: string;
value: string;
mono?: boolean;
icon?: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-4">
<dt className="text-sm text-fg-muted">{label}</dt>
<dt className="flex items-center gap-1.5 text-sm text-fg-muted">
{icon}
{label}
</dt>
<dd
className={
'max-w-[60%] truncate text-right text-sm text-fg ' +
+17
View File
@@ -73,6 +73,23 @@ Copy `.env.example` to `.env.local` and fill in the same Supabase host +
anon key the desktop uses. Expo bundles only `EXPO_PUBLIC_*`-prefixed
vars into the JS, which is what the three required values use.
## EAS Builds and Environment Variables
Production and preview builds load `EXPO_PUBLIC_*` from EAS Secrets —
`.env.local` is only honoured by `expo start` locally. Without the
secrets configured, an APK installs but `env.ts` throws on first read
and `<BootError>` renders.
Required secrets (create once per project):
```bash
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_URL --value '<project-supabase-url>'
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value '<sb_publishable_key>'
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_AUTH_REDIRECT_URL --value 'netralax://auth/callback'
```
Check with `npx eas-cli secret:list`.
## Roadmap
See `docs/superpowers/specs/2026-05-13-mobile-deployment-roadmap.md`.
+30 -7
View File
@@ -1,19 +1,42 @@
import { Redirect, Stack } from 'expo-router';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { useAuth } from '../../lib/authContext';
import { colors } from '../../theme/colors';
export default function AppLayout() {
const { session, loading } = useAuth();
if (loading) return null;
const { ready, session, userKeyState } = useAuth();
if (!ready) {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.accent} />
</View>
);
}
if (!session) return <Redirect href="/" />;
if (userKeyState.status === 'loading') {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.accent} />
</View>
);
}
if (userKeyState.status === 'needs-setup') return <Redirect href="/(app)/setup" />;
if (userKeyState.status === 'needs-unlock') return <Redirect href="/(app)/unlock" />;
return (
<Stack screenOptions={{ headerShown: true }}>
<Stack screenOptions={{ headerStyle: { backgroundColor: colors.bg } }}>
<Stack.Screen name="chats" />
<Stack.Screen name="conversations/[id]" />
<Stack.Screen
name="call"
options={{ headerShown: false, presentation: 'fullScreenModal' }}
/>
<Stack.Screen name="call" options={{ presentation: 'fullScreenModal' }} />
<Stack.Screen name="settings" options={{ presentation: 'card' }} />
<Stack.Screen name="setup" options={{ headerShown: false }} />
<Stack.Screen name="unlock" options={{ headerShown: false }} />
</Stack>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg },
});
+2 -18
View File
@@ -4,7 +4,6 @@ import { Stack, useRouter } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Pressable,
RefreshControl,
@@ -14,7 +13,6 @@ import {
} from 'react-native';
import { ConversationRow } from '../../components/ConversationRow';
import { useAuth } from '../../lib/authContext';
import { supabase } from '../../lib/supabase';
import { colors } from '../../theme/colors';
@@ -23,7 +21,6 @@ import { colors } from '../../theme/colors';
// is a Phase 1.5 follow-up. Tap → conversation detail.
export default function Chats() {
const router = useRouter();
const { signOut } = useAuth();
const [list, setList] = useState<ConversationSummary[] | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -48,19 +45,6 @@ export default function Chats() {
setRefreshing(false);
}, [load]);
const confirmLogout = () => {
Alert.alert('Abmelden', 'Diese Sitzung beenden?', [
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Abmelden',
style: 'destructive',
onPress: () => {
void signOut();
},
},
]);
};
return (
<View style={styles.container}>
<Stack.Screen
@@ -69,8 +53,8 @@ export default function Chats() {
headerStyle: { backgroundColor: colors.bg },
headerTitleStyle: { color: colors.text },
headerRight: () => (
<Pressable onPress={confirmLogout} hitSlop={10}>
<Text style={styles.logoutLink}>Abmelden</Text>
<Pressable onPress={() => router.push('/(app)/settings')} hitSlop={10}>
<Text style={styles.logoutLink}>Einstellungen</Text>
</Pressable>
),
}}
+6 -9
View File
@@ -30,7 +30,7 @@ import { colors } from '../../../theme/colors';
export default function ConversationDetail() {
const { id } = useLocalSearchParams<{ id: string }>();
const { user, device, ownPrivateKey } = useAuth();
const { user, userId, ownPrivateKey } = useAuth();
const { state: callState, startCall } = useCall();
const router = useRouter();
@@ -54,7 +54,7 @@ export default function ConversationDetail() {
const listRef = useRef<FlatList<DecryptedMessage>>(null);
const load = useCallback(async () => {
if (!id || !device || !ownPrivateKey) return;
if (!id || !userId || !ownPrivateKey) return;
setError(null);
try {
const all = await chat.listConversations(supabase);
@@ -63,7 +63,7 @@ export default function ConversationDetail() {
const decrypted = await chat.decryptMessages({
client: supabase,
messages: ciphers,
ownDeviceId: device.id,
ownUserId: userId,
ownPrivateKey,
});
setMessages(decrypted);
@@ -81,7 +81,7 @@ export default function ConversationDetail() {
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Nachrichten konnten nicht geladen werden');
}
}, [id, device, ownPrivateKey]);
}, [id, userId, ownPrivateKey]);
useEffect(() => {
void load();
@@ -103,7 +103,7 @@ export default function ConversationDetail() {
: (conversation?.peer?.displayName ?? '…');
async function handleSendText() {
if (!text.trim() || !user || !device || !ownPrivateKey || !id || sending) return;
if (!text.trim() || !user || !ownPrivateKey || !id || sending) return;
setSending(true);
setError(null);
const replyToId = replyTo?.id;
@@ -113,7 +113,6 @@ export default function ConversationDetail() {
conversationId: id,
plaintext: text.trim(),
senderUserId: user.id,
senderDeviceId: device.id,
senderPrivateKey: ownPrivateKey,
...(replyToId ? { replyToId } : {}),
});
@@ -128,7 +127,7 @@ export default function ConversationDetail() {
}
async function sendImage(pick: PickedImage) {
if (!user || !device || !ownPrivateKey || !id) return;
if (!user || !ownPrivateKey || !id) return;
setSending(true);
setError(null);
try {
@@ -148,7 +147,6 @@ export default function ConversationDetail() {
conversationId: id,
plaintext: '',
senderUserId: user.id,
senderDeviceId: device.id,
senderPrivateKey: ownPrivateKey,
attachmentHandles: [result.handle],
});
@@ -300,7 +298,6 @@ export default function ConversationDetail() {
})}
reactions={reactions.get(item.id) ?? []}
myUserId={user?.id ?? null}
ownDeviceId={device?.id ?? null}
ownPrivateKey={ownPrivateKey}
onLongPress={() => setActiveMessage(item)}
onToggleReaction={(emoji) => {
@@ -0,0 +1,18 @@
import { Stack } from 'expo-router';
import { colors } from '../../../theme/colors';
export default function SettingsLayout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.bg },
headerTitleStyle: { color: colors.text },
headerTintColor: colors.accent,
}}
>
<Stack.Screen name="index" options={{ title: 'Einstellungen' }} />
<Stack.Screen name="security" options={{ title: 'Sicherheit' }} />
</Stack>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { useRouter } from 'expo-router';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useAuth } from '../../../lib/authContext';
import { colors } from '../../../theme/colors';
export default function SettingsIndex() {
const router = useRouter();
const { signOut } = useAuth();
function confirmLogout() {
Alert.alert('Abmelden', 'Diese Sitzung beenden?', [
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Abmelden', style: 'destructive', onPress: () => void signOut() },
]);
}
return (
<View style={styles.container}>
<Pressable style={styles.row} onPress={() => router.push('/(app)/settings/security')}>
<Text style={styles.rowTitle}>Sicherheit</Text>
<Text style={styles.rowHint}>PIN ändern, Recovery-Code, Identität zurücksetzen</Text>
</Pressable>
<Pressable style={[styles.row, styles.danger]} onPress={confirmLogout}>
<Text style={[styles.rowTitle, styles.dangerText]}>Abmelden</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg, padding: 16, gap: 12 },
row: {
backgroundColor: colors.surface,
padding: 16,
borderRadius: 12,
borderColor: colors.border,
borderWidth: 1,
},
rowTitle: { color: colors.text, fontSize: 16, fontWeight: '600' },
rowHint: { color: colors.textMuted, fontSize: 13, marginTop: 4 },
danger: { borderColor: colors.danger },
dangerText: { color: colors.danger },
});
+200
View File
@@ -0,0 +1,200 @@
import { useState } from 'react';
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { PinInput } from '../../../components/PinInput';
import { useAuth } from '../../../lib/authContext';
import {
changePin,
regenerateRecoveryCode,
resetIdentity,
retryLegacyMigration,
type LegacyMigrationReport,
} from '../../../lib/userIdentity';
import { colors } from '../../../theme/colors';
export default function SecuritySettings() {
const { userId, refreshUserKeyState } = useAuth();
const [oldPin, setOldPin] = useState('');
const [newPin, setNewPin] = useState('');
const [newRecovery, setNewRecovery] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [report, setReport] = useState<LegacyMigrationReport | null>(null);
async function handleChangePin() {
if (!userId) return;
if (oldPin.length !== 6 || newPin.length !== 6) {
setError('Beide PINs müssen 6 Ziffern haben.');
return;
}
setSubmitting(true);
setError(null);
try {
await changePin({ userId, oldPin, newPin });
setOldPin('');
setNewPin('');
Alert.alert('PIN geändert', 'Die neue PIN gilt sofort auf allen Geräten.');
} catch (e) {
setError(e instanceof Error ? e.message : 'PIN-Änderung fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
async function handleRegenerateRecovery() {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
const code = await regenerateRecoveryCode({ userId });
setNewRecovery(code);
} catch (e) {
setError(e instanceof Error ? e.message : 'Recovery-Code-Erzeugung fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
function handleReset() {
if (!userId) return;
Alert.alert(
'Identität zurücksetzen?',
'Alle bisherigen Chats werden für dich unlesbar. Diese Aktion kann nicht rückgängig gemacht werden.',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Zurücksetzen',
style: 'destructive',
onPress: async () => {
setSubmitting(true);
try {
await resetIdentity({ userId, pin: '000000' });
await refreshUserKeyState();
} catch (e) {
setError(e instanceof Error ? e.message : 'Reset fehlgeschlagen');
} finally {
setSubmitting(false);
}
},
},
],
);
}
async function handleRetryMigration() {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
const r = await retryLegacyMigration(userId);
setReport(r);
} catch (e) {
setError(e instanceof Error ? e.message : 'Migration fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.section}>PIN ändern</Text>
<Text style={styles.label}>Alte PIN</Text>
<PinInput value={oldPin} onChange={setOldPin} ariaLabel="Alte PIN" />
<Text style={styles.label}>Neue PIN</Text>
<PinInput value={newPin} onChange={setNewPin} ariaLabel="Neue PIN" />
<Pressable style={styles.primary} onPress={handleChangePin} disabled={submitting}>
{submitting ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.primaryText}>PIN aktualisieren</Text>
)}
</Pressable>
<Text style={styles.section}>Recovery-Code</Text>
<Pressable style={styles.primary} onPress={handleRegenerateRecovery} disabled={submitting}>
<Text style={styles.primaryText}>Neuen Recovery-Code erzeugen</Text>
</Pressable>
{newRecovery && (
<View style={styles.codeBox}>
<Text style={styles.codeText} selectable>
{newRecovery}
</Text>
</View>
)}
<Text style={styles.section}>Migration</Text>
<Pressable style={styles.primary} onPress={handleRetryMigration} disabled={submitting}>
<Text style={styles.primaryText}>Migration erneut versuchen</Text>
</Pressable>
{report && (
<View style={styles.report}>
<Text style={styles.reportLine}>Geräte (Server): {report.serverDevices}</Text>
<Text style={styles.reportLine}>
Lokale Schlüssel im Vault:{' '}
{report.strongholdKeysFromServerDevices + report.strongholdKeysFromBundleScan}
</Text>
<Text style={styles.reportLine}>
Versucht: {report.attempted}, Erfolgreich: {report.migrated}
</Text>
<Text style={styles.reportLine}>
Übersprungen: kein lokaler Schlüssel = {report.noStrongholdKey}, Decrypt-Fehler ={' '}
{report.decryptFailed}, RPC-Fehler = {report.rpcFailed}
</Text>
</View>
)}
<Text style={styles.section}>Gefahrenbereich</Text>
<Pressable style={[styles.primary, styles.danger]} onPress={handleReset} disabled={submitting}>
<Text style={[styles.primaryText, styles.dangerText]}>Identität zurücksetzen</Text>
</Pressable>
{error && <Text style={styles.error}>{error}</Text>}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { padding: 16, gap: 8, backgroundColor: colors.bg },
section: { color: colors.text, fontSize: 16, fontWeight: '700', marginTop: 16 },
label: { color: colors.textMuted, fontSize: 12, marginTop: 4 },
primary: {
backgroundColor: colors.accent,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
marginTop: 8,
},
primaryText: { color: colors.text, fontWeight: '700' },
danger: { backgroundColor: 'transparent', borderColor: colors.danger, borderWidth: 1 },
dangerText: { color: colors.danger },
codeBox: {
backgroundColor: colors.surface,
padding: 12,
borderRadius: 10,
marginTop: 4,
},
codeText: {
color: colors.text,
fontFamily: 'Courier',
fontSize: 16,
letterSpacing: 1.2,
textAlign: 'center',
},
report: {
backgroundColor: colors.surface,
padding: 12,
borderRadius: 10,
marginTop: 4,
gap: 4,
},
reportLine: { color: colors.text, fontSize: 13 },
error: { color: colors.danger, marginTop: 12 },
});
+195
View File
@@ -0,0 +1,195 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { PinInput } from '../../components/PinInput';
import { useAuth } from '../../lib/authContext';
import { setupNewUserIdentity } from '../../lib/userIdentity';
import { colors } from '../../theme/colors';
type Step = 'pin' | 'confirm' | 'recovery' | 'done';
export default function SetupScreen() {
const router = useRouter();
const { userId, refreshUserKeyState } = useAuth();
const [step, setStep] = useState<Step>('pin');
const [pin, setPin] = useState('');
const [confirm, setConfirm] = useState('');
const [recoveryCode, setRecoveryCode] = useState<string | null>(null);
const [withRecovery, setWithRecovery] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function runSetup(saveRecovery: boolean) {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
const out = await setupNewUserIdentity({ userId, pin, withRecovery: saveRecovery });
setRecoveryCode(out.recoveryCode);
if (out.recoveryCode) {
setStep('recovery');
} else {
setStep('done');
await refreshUserKeyState();
router.replace('/(app)/chats');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Setup fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
if (step === 'pin') {
return (
<View style={styles.container}>
<Text style={styles.title}>PIN festlegen</Text>
<Text style={styles.subtitle}>
Mit dieser 6-stelligen PIN entsperrst du Netralax auf jedem Gerät.
</Text>
<PinInput value={pin} onChange={setPin} ariaLabel="Neue PIN" autoFocus />
{error && <Text style={styles.error}>{error}</Text>}
<Pressable
style={[styles.primary, pin.length !== 6 && styles.disabled]}
disabled={pin.length !== 6}
onPress={() => setStep('confirm')}
>
<Text style={styles.primaryText}>Weiter</Text>
</Pressable>
</View>
);
}
if (step === 'confirm') {
return (
<View style={styles.container}>
<Text style={styles.title}>PIN bestätigen</Text>
<Text style={styles.subtitle}>Bitte gib dieselbe PIN erneut ein.</Text>
<PinInput value={confirm} onChange={setConfirm} ariaLabel="PIN bestätigen" autoFocus />
{error && <Text style={styles.error}>{error}</Text>}
<Pressable
style={[styles.primary, confirm.length !== 6 && styles.disabled]}
disabled={confirm.length !== 6 || submitting}
onPress={() => {
if (pin !== confirm) {
setError('PIN stimmt nicht überein.');
setConfirm('');
return;
}
void runSetup(withRecovery);
}}
>
{submitting ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.primaryText}>PIN speichern</Text>
)}
</Pressable>
<Pressable onPress={() => setWithRecovery((v) => !v)} style={styles.secondary}>
<Text style={styles.secondaryText}>
{withRecovery
? 'Recovery-Code überspringen (riskant)'
: 'Recovery-Code erzeugen (empfohlen)'}
</Text>
</Pressable>
</View>
);
}
if (step === 'recovery' && recoveryCode) {
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>Recovery-Code</Text>
<Text style={styles.subtitle}>
Notiere dir diesen Code an einem sicheren Ort. Du brauchst ihn, wenn du deine PIN
vergisst. Wir zeigen ihn dir nur EINMAL.
</Text>
<View style={styles.codeBox}>
<Text style={styles.codeText} selectable>
{recoveryCode}
</Text>
</View>
<Pressable
style={styles.primary}
onPress={async () => {
await refreshUserKeyState();
router.replace('/(app)/chats');
}}
>
<Text style={styles.primaryText}>Habe ich gespeichert</Text>
</Pressable>
<Pressable
style={styles.secondary}
onPress={() => {
Alert.alert(
'Sicher?',
'Ohne Recovery-Code verlierst du den Zugriff, wenn du die PIN vergisst.',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Weiter ohne',
style: 'destructive',
onPress: async () => {
await refreshUserKeyState();
router.replace('/(app)/chats');
},
},
],
);
}}
>
<Text style={styles.secondaryText}>Überspringen</Text>
</Pressable>
</ScrollView>
);
}
return null;
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
paddingTop: 64,
backgroundColor: colors.bg,
gap: 16,
},
title: { color: colors.text, fontSize: 24, fontWeight: '700' },
subtitle: { color: colors.textMuted, fontSize: 14, lineHeight: 20 },
error: { color: colors.danger, fontSize: 13 },
primary: {
backgroundColor: colors.accent,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
marginTop: 12,
},
primaryText: { color: colors.text, fontWeight: '700' },
disabled: { opacity: 0.5 },
secondary: { paddingVertical: 12, alignItems: 'center' },
secondaryText: { color: colors.accent, fontWeight: '600' },
codeBox: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
padding: 16,
borderRadius: 12,
},
codeText: {
color: colors.text,
fontSize: 20,
fontFamily: 'Courier',
letterSpacing: 1.5,
textAlign: 'center',
},
});
+194
View File
@@ -0,0 +1,194 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { PinInput } from '../../components/PinInput';
import { useAuth } from '../../lib/authContext';
import { loadOrUnlockUserKey, resetIdentity } from '../../lib/userIdentity';
import { colors } from '../../theme/colors';
type Mode = 'pin' | 'recovery';
export default function UnlockScreen() {
const router = useRouter();
const { userId, userKeyState, refreshUserKeyState } = useAuth();
const [mode, setMode] = useState<Mode>('pin');
const [pin, setPin] = useState('');
const [recovery, setRecovery] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const locked =
userKeyState.status === 'needs-unlock' && userKeyState.lockedUntil !== null;
const hasRecovery =
userKeyState.status === 'needs-unlock' ? userKeyState.hasRecovery : false;
async function attempt(value: string, isRecoveryCode: boolean) {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
const out = await loadOrUnlockUserKey({ userId, pin: value, isRecoveryCode });
if (out.kind === 'unlocked') {
await refreshUserKeyState();
router.replace('/(app)/chats');
return;
}
if (out.kind === 'locked') {
setError('Konto bis ' + new Date(out.lockedUntil).toLocaleString('de-DE') + ' gesperrt.');
await refreshUserKeyState();
return;
}
setError('Kein Identitäts-Datensatz auf dem Server.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Entsperren fehlgeschlagen');
if (isRecoveryCode) setRecovery('');
else setPin('');
await refreshUserKeyState();
} finally {
setSubmitting(false);
}
}
async function handleReset() {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
await resetIdentity({ userId, pin: '000000' });
await refreshUserKeyState();
router.replace('/(app)/setup');
} catch (err) {
setError(err instanceof Error ? err.message : 'Reset fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
return (
<View style={styles.container}>
<Text style={styles.title}>Netralax entsperren</Text>
<View style={styles.tabs}>
<Pressable
style={[styles.tab, mode === 'pin' && styles.tabActive]}
onPress={() => setMode('pin')}
>
<Text style={[styles.tabText, mode === 'pin' && styles.tabTextActive]}>PIN</Text>
</Pressable>
<Pressable
style={[styles.tab, mode === 'recovery' && styles.tabActive]}
disabled={!hasRecovery}
onPress={() => setMode('recovery')}
>
<Text style={[styles.tabText, mode === 'recovery' && styles.tabTextActive]}>
Recovery-Code
</Text>
</Pressable>
</View>
{mode === 'pin' ? (
<>
<PinInput
value={pin}
onChange={setPin}
ariaLabel="PIN eingeben"
autoFocus
disabled={locked || submitting}
/>
<Pressable
style={[styles.primary, (pin.length !== 6 || locked || submitting) && styles.disabled]}
disabled={pin.length !== 6 || locked || submitting}
onPress={() => attempt(pin, false)}
>
{submitting ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.primaryText}>Entsperren</Text>
)}
</Pressable>
</>
) : (
<>
<Text style={styles.subtitle}>Recovery-Code eingeben (mit oder ohne Bindestriche):</Text>
<Text style={styles.recoveryDisplay} selectable>
{recovery}
</Text>
<Pressable
style={[styles.primary, recovery.length === 0 && styles.disabled]}
disabled={recovery.length === 0 || submitting}
onPress={() => attempt(recovery, true)}
>
{submitting ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.primaryText}>Mit Recovery entsperren</Text>
)}
</Pressable>
</>
)}
{error && <Text style={styles.error}>{error}</Text>}
{locked && (
<Pressable style={styles.danger} onPress={handleReset}>
<Text style={styles.dangerText}>Identität zurücksetzen (alte Chats verloren)</Text>
</Pressable>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg,
paddingTop: 64,
paddingHorizontal: 24,
gap: 16,
},
title: { color: colors.text, fontSize: 22, fontWeight: '700' },
subtitle: { color: colors.textMuted, fontSize: 13 },
tabs: { flexDirection: 'row', gap: 8 },
tab: {
flex: 1,
paddingVertical: 10,
alignItems: 'center',
borderBottomWidth: 2,
borderBottomColor: colors.border,
},
tabActive: { borderBottomColor: colors.accent },
tabText: { color: colors.textMuted, fontWeight: '600' },
tabTextActive: { color: colors.text },
recoveryDisplay: {
color: colors.text,
fontFamily: 'Courier',
fontSize: 18,
letterSpacing: 1.5,
backgroundColor: colors.surface,
padding: 16,
borderRadius: 10,
},
primary: {
backgroundColor: colors.accent,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
primaryText: { color: colors.text, fontWeight: '700' },
disabled: { opacity: 0.5 },
danger: {
paddingVertical: 12,
alignItems: 'center',
borderColor: colors.danger,
borderWidth: 1,
borderRadius: 12,
},
dangerText: { color: colors.danger, fontWeight: '700' },
error: { color: colors.danger, fontSize: 13 },
});
+16 -17
View File
@@ -1,34 +1,33 @@
import { crypto } from '@chat-app/shared';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { AppBootstrap } from '../components/AppBootstrap';
import { ErrorBoundary } from '../components/ErrorBoundary';
import { IncomingCallModal } from '../components/IncomingCallModal';
import { AuthProvider } from '../lib/authContext';
import { CallProvider } from '../lib/callContext';
import { createLibsodiumBackend } from '../lib/cryptoBackend';
crypto.setCryptoBackend(createLibsodiumBackend());
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<ErrorBoundary>
<AuthProvider>
<CallProvider>
<StatusBar style="auto" />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(app)" />
<Stack.Screen name="auth/callback" />
</Stack>
<IncomingCallModal />
</CallProvider>
</AuthProvider>
</ErrorBoundary>
<AppBootstrap>
<ErrorBoundary>
<AuthProvider>
<CallProvider>
<StatusBar style="auto" />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(app)" />
<Stack.Screen name="auth/callback" />
</Stack>
<IncomingCallModal />
</CallProvider>
</AuthProvider>
</ErrorBoundary>
</AppBootstrap>
</SafeAreaProvider>
</GestureHandlerRootView>
);
+2 -2
View File
@@ -21,13 +21,13 @@ import { colors } from '../theme/colors';
// they tap the email link and Expo Router routes the deep link to
// app/auth/callback.tsx.
export default function Landing() {
const { session, loading } = useAuth();
const { session, ready } = useAuth();
const [email, setEmail] = useState('');
const [submitting, setSubmitting] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
if (loading) return null;
if (!ready) return null;
if (session) return <Redirect href="/(app)/chats" />;
async function handleSubmit() {
+51
View File
@@ -0,0 +1,51 @@
import { crypto } from '@chat-app/shared';
import { type ReactNode, useEffect, useState } from 'react';
import { createLibsodiumBackend } from '../lib/cryptoBackend';
import { BootError } from './BootError';
import { BootSplash } from './BootSplash';
interface Props {
children: ReactNode;
}
// React Native exposes ErrorUtils on the global. The types ship with RN but
// we cast defensively because the renderer used by Vitest does not.
interface RNErrorUtils {
getGlobalHandler: () => (err: Error, isFatal?: boolean) => void;
setGlobalHandler: (handler: (err: Error, isFatal?: boolean) => void) => void;
}
// Initialises the crypto backend inside a useEffect (not at module-eval) so
// any failure surfaces in the React tree. Also installs a global JS error
// handler that routes unhandled throws to BootError; this catches errors
// thrown during render (e.g. the lazy env proxy reading a missing var) that
// would otherwise escape every per-screen ErrorBoundary.
export function AppBootstrap({ children }: Props) {
const [error, setError] = useState<Error | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
try {
crypto.setCryptoBackend(createLibsodiumBackend());
setReady(true);
} catch (e: unknown) {
setError(e instanceof Error ? e : new Error(String(e)));
}
}, []);
useEffect(() => {
const eu = (globalThis as unknown as { ErrorUtils?: RNErrorUtils }).ErrorUtils;
if (!eu) return;
const prev = eu.getGlobalHandler();
eu.setGlobalHandler((err, isFatal) => {
prev?.(err, isFatal);
setError(err);
});
return () => eu.setGlobalHandler(prev);
}, []);
if (error) return <BootError error={error} />;
if (!ready) return <BootSplash />;
return <>{children}</>;
}
@@ -10,8 +10,6 @@ import { colors } from '../theme/colors';
interface Props {
handle: AttachmentHandle;
ownDeviceId: string;
ownPrivateKey: Uint8Array;
}
// Decrypts an encrypted image attachment on first mount and renders it
+51
View File
@@ -0,0 +1,51 @@
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { colors } from '../theme/colors';
interface Props {
error: Error;
}
// Last-resort fallback. Renders whenever AppBootstrap's init effect throws or
// when the global JS error handler catches an unhandled exception. The
// env-diagnostic line ("env-ok" / "env-missing") makes future bug reports
// triageable from a single screenshot.
export function BootError({ error }: Props) {
const envOk = Boolean(process.env.EXPO_PUBLIC_SUPABASE_URL);
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>App-Start fehlgeschlagen</Text>
<Text style={styles.message}>{error.message}</Text>
<View style={styles.diagnostic}>
<Text style={styles.diagnosticLabel}>EXPO_PUBLIC_SUPABASE_URL:</Text>
<Text style={[styles.diagnosticValue, envOk ? styles.ok : styles.bad]}>
{envOk ? 'env-ok' : 'env-missing'}
</Text>
</View>
{error.stack && <Text style={styles.stack}>{error.stack}</Text>}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flexGrow: 1,
backgroundColor: colors.bg,
padding: 24,
paddingTop: 64,
gap: 12,
},
title: { color: colors.text, fontSize: 20, fontWeight: '700' },
message: { color: colors.danger, fontSize: 14, lineHeight: 20 },
diagnostic: { flexDirection: 'row', gap: 8, marginTop: 8 },
diagnosticLabel: { color: colors.textMuted, fontSize: 12 },
diagnosticValue: { fontSize: 12, fontWeight: '700' },
ok: { color: colors.success },
bad: { color: colors.danger },
stack: {
color: colors.textDim,
fontSize: 11,
fontFamily: 'Courier',
marginTop: 16,
},
});
+22
View File
@@ -0,0 +1,22 @@
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { colors } from '../theme/colors';
// Shown while AppBootstrap is initialising the crypto backend. Identical
// background to the Expo splash so the handoff is invisible to the user.
export function BootSplash() {
return (
<View style={styles.container}>
<ActivityIndicator color={colors.accent} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bg,
},
});
+2 -8
View File
@@ -15,7 +15,6 @@ interface Props {
parentSenderName: string;
reactions: MessageReaction[];
myUserId: string | null;
ownDeviceId: string | null;
ownPrivateKey: Uint8Array | null;
onLongPress: () => void;
onToggleReaction: (emoji: string) => void;
@@ -36,7 +35,6 @@ export function MessageBubble({
parentSenderName,
reactions,
myUserId,
ownDeviceId,
ownPrivateKey,
onLongPress,
onToggleReaction,
@@ -72,12 +70,8 @@ export function MessageBubble({
) : (
<>
{text.length > 0 && <Text style={styles.body}>{text}</Text>}
{firstImage && ownDeviceId && ownPrivateKey && (
<AttachmentImage
handle={firstImage}
ownDeviceId={ownDeviceId}
ownPrivateKey={ownPrivateKey}
/>
{firstImage && ownPrivateKey && (
<AttachmentImage handle={firstImage} />
)}
</>
)}
+31
View File
@@ -0,0 +1,31 @@
import { fireEvent, render } from '@testing-library/react-native';
import { describe, expect, it, vi } from 'vitest';
import { PinInput } from './PinInput';
describe('<PinInput>', () => {
it('appends digits to the underlying value and stops at length', () => {
const onChange = vi.fn();
const { getByTestId } = render(
<PinInput value="" onChange={onChange} length={6} ariaLabel="PIN" />,
);
fireEvent.changeText(getByTestId('pin-input'), '1234567890');
expect(onChange).toHaveBeenCalledWith('123456');
});
it('strips non-digits', () => {
const onChange = vi.fn();
const { getByTestId } = render(
<PinInput value="" onChange={onChange} length={6} ariaLabel="PIN" />,
);
fireEvent.changeText(getByTestId('pin-input'), '1a2b3c');
expect(onChange).toHaveBeenCalledWith('123');
});
it('renders one bullet per filled slot', () => {
const { getAllByText } = render(
<PinInput value="123" onChange={() => {}} length={6} ariaLabel="PIN" />,
);
expect(getAllByText('•').length).toBe(3);
});
});
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useRef } from 'react';
import {
Pressable,
StyleSheet,
Text,
TextInput,
View,
type TextInput as TextInputType,
} from 'react-native';
import { colors } from '../theme/colors';
interface Props {
value: string;
onChange: (next: string) => void;
length?: number;
autoFocus?: boolean;
disabled?: boolean;
ariaLabel: string;
onSubmit?: () => void;
}
// Six-slot numeric PIN entry. The actual input is an invisible TextInput
// that captures the numeric keyboard; visible slots render bullets when
// filled. Tapping anywhere on the row re-focuses the input.
export function PinInput({
value,
onChange,
length = 6,
autoFocus,
disabled,
ariaLabel,
onSubmit,
}: Props) {
const ref = useRef<TextInputType | null>(null);
useEffect(() => {
if (autoFocus) ref.current?.focus();
}, [autoFocus]);
return (
<Pressable onPress={() => ref.current?.focus()} style={styles.row}>
<TextInput
ref={ref}
testID="pin-input"
accessibilityLabel={ariaLabel}
keyboardType="numeric"
textContentType="oneTimeCode"
autoComplete="one-time-code"
maxLength={length}
editable={!disabled}
value={value}
onChangeText={(t) => onChange(t.replace(/\D/g, '').slice(0, length))}
onSubmitEditing={() => {
if (value.length === length) onSubmit?.();
}}
style={styles.hidden}
/>
<View style={styles.slots}>
{Array.from({ length }).map((_, i) => {
const filled = i < value.length;
return (
<View key={i} style={[styles.slot, filled && styles.slotFilled]}>
{filled && <Text style={styles.bullet}></Text>}
</View>
);
})}
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
row: { alignItems: 'center' },
hidden: {
position: 'absolute',
width: 1,
height: 1,
opacity: 0,
},
slots: { flexDirection: 'row', gap: 8 },
slot: {
width: 40,
height: 48,
borderRadius: 10,
borderWidth: 1,
borderColor: colors.border,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.surface,
},
slotFilled: {
borderColor: colors.accent,
backgroundColor: colors.bg,
},
bullet: { color: colors.text, fontSize: 22 },
});
+73 -66
View File
@@ -1,23 +1,31 @@
import type { Session, User } from '@supabase/supabase-js';
import { auth, crypto } from '@chat-app/shared';
import type { DeviceRecord } from '@chat-app/shared/auth';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { Platform } from 'react-native';
import { fetchUserKeyBlob } from '@chat-app/shared/auth';
import {
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import { secretStore } from './secretStore';
import { supabase } from './supabase';
import { cachedUserKey, ensureLegacyMigrated } from './userIdentity';
// Locally-stored secrets keyed by stable names. Mirrors the desktop
// convention so the migration tests (later) can compare snapshots.
const KEY_DEVICE_ID = 'device.id';
const KEY_DEVICE_PRIVKEY = 'device.privateKey';
export type UserKeyState =
| { status: 'loading' }
| { status: 'needs-setup' }
| { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean }
| { status: 'unlocked' };
interface AuthContextValue {
session: Session | null;
user: User | null;
device: DeviceRecord | null;
userId: string | null;
ownPrivateKey: Uint8Array | null;
loading: boolean;
userKeyState: UserKeyState;
ready: boolean;
refreshUserKeyState: () => Promise<void>;
signOut: () => Promise<void>;
}
@@ -29,44 +37,48 @@ export function useAuth(): AuthContextValue {
return v;
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [device, setDevice] = useState<DeviceRecord | null>(null);
const [ownPrivateKey, setOwnPrivateKey] = useState<Uint8Array | null>(null);
const [loading, setLoading] = useState(true);
const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
const [ready, setReady] = useState(false);
// Resolve or create the device record for this install given an active
// session. Stores the private key in expo-secure-store on first run.
const ensureDevice = useCallback(async (_currentSession: Session): Promise<void> => {
const savedDeviceId = await secretStore.getSecret(KEY_DEVICE_ID);
const savedPrivKey = await secretStore.getSecret(KEY_DEVICE_PRIVKEY);
if (savedDeviceId && savedPrivKey) {
const devices = await auth.listOwnDevices(supabase);
const deviceIdStr = new TextDecoder().decode(savedDeviceId);
const match = devices.find((d) => d.id === deviceIdStr);
if (match) {
setDevice(match);
setOwnPrivateKey(savedPrivKey);
return;
}
// Stored id no longer matches any device on the server (revoked,
// wiped). Fall through to register a fresh one.
const refreshUserKeyState = useCallback(async () => {
const s = session;
if (!s) {
setUserKeyState({ status: 'loading' });
setOwnPrivateKey(null);
return;
}
const backend = crypto.getCryptoBackend();
const kp = backend.generateKeyPair();
const platform = Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'linux';
const record = await auth.registerDevice(supabase, {
name: `Netralax Mobile (${Platform.OS})`,
platform,
publicKey: kp.publicKey,
setUserKeyState({ status: 'loading' });
const cached = await cachedUserKey(s.user.id);
if (cached) {
setOwnPrivateKey(cached);
setUserKeyState({ status: 'unlocked' });
void ensureLegacyMigrated(s.user.id).catch((err) => {
console.warn('legacy migration on auth-resume failed', err);
});
return;
}
const blob = await fetchUserKeyBlob(supabase, s.user.id);
if (!blob || !blob.exists) {
setUserKeyState({ status: 'needs-setup' });
return;
}
if (blob.locked) {
setUserKeyState({
status: 'needs-unlock',
lockedUntil: blob.lockedUntil,
hasRecovery: false,
});
return;
}
setUserKeyState({
status: 'needs-unlock',
lockedUntil: null,
hasRecovery: blob.recoverySealedPrivateKey !== null,
});
await secretStore.setSecret(KEY_DEVICE_ID, new TextEncoder().encode(record.id));
await secretStore.setSecret(KEY_DEVICE_PRIVKEY, kp.privateKey);
setDevice(record);
setOwnPrivateKey(kp.privateKey);
}, []);
}, [session]);
useEffect(() => {
let cancelled = false;
@@ -74,48 +86,43 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const { data } = await supabase.auth.getSession();
if (cancelled) return;
setSession(data.session);
if (data.session) {
try {
await ensureDevice(data.session);
} catch (err) {
console.warn('[auth] ensureDevice failed', err);
}
}
setLoading(false);
setReady(true);
})();
const { data: sub } = supabase.auth.onAuthStateChange((_event, nextSession) => {
setSession(nextSession);
setReady(true);
if (!nextSession) {
setDevice(null);
setOwnPrivateKey(null);
} else {
void ensureDevice(nextSession).catch((err) =>
console.warn('[auth] ensureDevice (state change) failed', err),
);
setUserKeyState({ status: 'loading' });
}
});
return () => {
cancelled = true;
sub.subscription.unsubscribe();
};
}, [ensureDevice]);
}, []);
const signOut = useCallback(async (): Promise<void> => {
useEffect(() => {
void refreshUserKeyState().catch((err) => {
console.warn('refreshUserKeyState failed', err);
setUserKeyState({ status: 'needs-setup' });
});
}, [session, refreshUserKeyState]);
const signOut = useCallback(async () => {
await supabase.auth.signOut();
await secretStore.removeSecret(KEY_DEVICE_ID);
await secretStore.removeSecret(KEY_DEVICE_PRIVKEY);
setDevice(null);
setOwnPrivateKey(null);
setUserKeyState({ status: 'loading' });
}, []);
const value: AuthContextValue = {
session,
user: session?.user ?? null,
device,
userId: session?.user.id ?? null,
ownPrivateKey,
loading,
userKeyState,
ready,
refreshUserKeyState,
signOut,
};
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
+8
View File
@@ -24,5 +24,13 @@ export function createLibsodiumBackend(): CryptoBackend {
s.crypto_box_open_easy(ciphertext, nonce, senderPublicKey, recipientPrivateKey),
secretbox: (plaintext, nonce, key) => s.crypto_secretbox_easy(plaintext, nonce, key),
secretboxOpen: (ciphertext, nonce, key) => s.crypto_secretbox_open_easy(ciphertext, nonce, key),
pwhashConsts: {
OPSLIMIT_MODERATE: s.crypto_pwhash_OPSLIMIT_MODERATE,
MEMLIMIT_MODERATE: s.crypto_pwhash_MEMLIMIT_MODERATE,
ALG_ARGON2ID13: s.crypto_pwhash_ALG_ARGON2ID13,
},
pwhash: (outLen, password, salt, opslimit, memlimit, alg) =>
s.crypto_pwhash(outLen, password, salt, opslimit, memlimit, alg),
scalarMultBase: (priv) => s.crypto_scalarmult_base(priv),
};
}
+30
View File
@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
describe('mobile env (lazy proxy)', () => {
beforeEach(() => {
vi.resetModules();
delete process.env.EXPO_PUBLIC_SUPABASE_URL;
delete process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
delete process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL;
});
it('importing the module does NOT throw when required vars are missing', async () => {
await expect(import('./env')).resolves.toBeTruthy();
});
it('reading a property with no env set throws a clear error', async () => {
const mod = await import('./env');
expect(() => mod.env.supabaseUrl).toThrowError(
/Missing required env var EXPO_PUBLIC_SUPABASE_URL/,
);
});
it('reading a property after setting env returns the value and memoises', async () => {
process.env.EXPO_PUBLIC_SUPABASE_URL = 'https://example.supabase.co';
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY = 'anon-123';
const mod = await import('./env');
expect(mod.env.supabaseUrl).toBe('https://example.supabase.co');
expect(mod.env.supabaseAnonKey).toBe('anon-123');
expect(mod.env.authRedirectUrl).toBe('netralax://auth/callback');
});
});
+31 -9
View File
@@ -1,19 +1,41 @@
// EXPO_PUBLIC_* vars are inlined at bundle time by Expo's Babel plugin.
// We pull them through a single typed module so a missing var is a loud
// startup error rather than a confusing Supabase 401 later.
// EXPO_PUBLIC_* vars are inlined at bundle time by Expo's Babel plugin (or
// shipped via EAS Secrets for EAS builds — see apps/mobile/README.md).
// We pull them through a Proxy so missing vars throw on first READ, not at
// module-eval time. That keeps the throw inside the React tree where the
// <BootError> boundary can render it as a readable screen instead of a blank
// white window.
function required(name: string): string {
const v = process.env[name];
if (!v || v.length === 0) {
throw new Error(
'Missing required env var ' + name + '. Set it in apps/mobile/.env.local — see .env.example.',
'Missing required env var ' + name +
'. Set it via `eas secret:create --scope project --name ' + name +
' --value ...` or in apps/mobile/.env.local for local dev (see .env.example).',
);
}
return v;
}
export const env = {
supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'),
supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'),
authRedirectUrl: process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL ?? 'netralax://auth/callback',
} as const;
interface EnvShape {
supabaseUrl: string;
supabaseAnonKey: string;
authRedirectUrl: string;
}
function readEnv(): EnvShape {
return {
supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'),
supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'),
authRedirectUrl: process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL ?? 'netralax://auth/callback',
};
}
let cached: EnvShape | null = null;
export const env: EnvShape = new Proxy({} as EnvShape, {
get(_target, key: string | symbol): unknown {
cached ??= readEnv();
return cached[key as keyof EnvShape];
},
});
+9
View File
@@ -0,0 +1,9 @@
import { secretStore } from './secretStore';
// During the migration window the orchestrator probes SecureStore for legacy
// per-device private keys. We keep this read-only — never write — so a future
// reset can safely wipe the new chatapp.userpriv.* slot without affecting
// pre-existing legacy entries.
export function legacyDeviceKey(userId: string, deviceId: string): Promise<Uint8Array | null> {
return secretStore.getSecret('chatapp.priv.' + userId + '.' + deviceId);
}
+61
View File
@@ -0,0 +1,61 @@
import { crypto } from '@chat-app/shared';
import { makeWasmTestBackend } from '@chat-app/shared/crypto/testBackend';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
// We mock react-native modules so this Vitest file can run in Node without
// loading native code. The mocks live next to the test for clarity.
vi.mock('expo-secure-store', () => {
const store = new Map<string, string>();
return {
getItemAsync: vi.fn(async (k: string) => store.get(k) ?? null),
setItemAsync: vi.fn(async (k: string, v: string) => {
store.set(k, v);
}),
deleteItemAsync: vi.fn(async (k: string) => {
store.delete(k);
}),
};
});
const rpcImpl = vi.fn();
vi.mock('./supabase', () => ({
supabase: {
rpc: (name: string, params: unknown) => rpcImpl(name, params),
from: () => ({
select: () => ({ in: () => Promise.resolve({ data: [], error: null }) }),
}),
},
}));
beforeAll(async () => {
crypto.setCryptoBackend(await makeWasmTestBackend());
});
beforeEach(() => {
rpcImpl.mockReset();
});
describe('mobile userIdentity', () => {
it('setupNewUserIdentity uploads + caches', async () => {
rpcImpl.mockResolvedValue({ data: 0, error: null });
const { setupNewUserIdentity, cachedUserKey } = await import('./userIdentity');
const out = await setupNewUserIdentity({
userId: 'user-1',
pin: '123456',
withRecovery: true,
});
expect(out.publicKey.length).toBe(32);
expect(out.recoveryCode).toMatch(/^[A-Z0-9-]+$/);
const cached = await cachedUserKey('user-1');
expect(cached).not.toBeNull();
expect(cached!.length).toBe(32);
expect(rpcImpl).toHaveBeenCalledWith('upsert_user_key', expect.any(Object));
});
it('loadOrUnlockUserKey returns `missing` when blob does not exist', async () => {
rpcImpl.mockResolvedValue({ data: { exists: false }, error: null });
const { loadOrUnlockUserKey } = await import('./userIdentity');
const out = await loadOrUnlockUserKey({ userId: 'user-2', pin: '000000' });
expect(out.kind).toBe('missing');
});
});
+266
View File
@@ -0,0 +1,266 @@
import { migrateOwnLegacyBundles } from '@chat-app/shared/chat';
import {
fetchUserKeyBlob,
listOwnDevices,
recordPinAttempt,
resetUserKey,
tryUnlockUserKey,
uploadUserKeyBlob,
} from '@chat-app/shared/auth';
import {
generateRecoveryCode,
generateUserKeyPair,
getCryptoBackend,
normalizeRecoveryCode,
openUserKey,
sealUserKey,
} from '@chat-app/shared/crypto';
import { legacyDeviceKey } from './legacyDeviceVault';
import { secretStore } from './secretStore';
import { supabase } from './supabase';
const cacheKey = (userId: string) => 'chatapp.userpriv.' + userId;
export interface SetupParams {
userId: string;
pin: string;
withRecovery: boolean;
}
export interface SetupResult {
publicKey: Uint8Array;
recoveryCode: string | null;
}
export async function setupNewUserIdentity(p: SetupParams): Promise<SetupResult> {
const kp = await generateUserKeyPair();
const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: p.pin });
let recoveryCode: string | null = null;
let recoverySealed: { sealedPrivateKey: Uint8Array; salt: Uint8Array } | null = null;
if (p.withRecovery) {
recoveryCode = await generateRecoveryCode();
const r = await sealUserKey({
privateKey: kp.privateKey,
pin: normalizeRecoveryCode(recoveryCode),
});
recoverySealed = { sealedPrivateKey: r.sealedPrivateKey, salt: r.salt };
}
await uploadUserKeyBlob(supabase, {
userId: p.userId,
publicKey: kp.publicKey,
sealedPrivateKey: sealed.sealedPrivateKey,
salt: sealed.salt,
kdfParams: sealed.kdfParams,
recoverySealedPrivateKey: recoverySealed?.sealedPrivateKey ?? null,
recoverySalt: recoverySealed?.salt ?? null,
});
await secretStore.setSecret(cacheKey(p.userId), kp.privateKey);
void ensureLegacyMigrated(p.userId).catch((err) => {
console.warn('legacy conv-key migration failed', err);
});
return { publicKey: kp.publicKey, recoveryCode };
}
export interface UnlockParams {
userId: string;
pin: string;
isRecoveryCode?: boolean;
}
export type UnlockOutcome =
| { kind: 'unlocked' }
| { kind: 'locked'; lockedUntil: string }
| { kind: 'missing' };
export async function loadOrUnlockUserKey(p: UnlockParams): Promise<UnlockOutcome> {
const remote = await tryUnlockUserKey(supabase, p.userId);
if (!remote.exists) return { kind: 'missing' };
if (remote.locked) return { kind: 'locked', lockedUntil: remote.lockedUntil };
const secret = p.isRecoveryCode ? normalizeRecoveryCode(p.pin) : p.pin;
const sealed = p.isRecoveryCode ? remote.recoverySealedPrivateKey : remote.sealedPrivateKey;
const salt = p.isRecoveryCode ? remote.recoverySalt : remote.salt;
if (!sealed || !salt) throw new Error('no recovery blob configured');
let priv: Uint8Array;
try {
priv = await openUserKey({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
} catch (err) {
await recordPinAttempt(supabase, p.userId, false, p.isRecoveryCode === true).catch(() => {});
throw err;
}
await recordPinAttempt(supabase, p.userId, true, p.isRecoveryCode === true).catch(() => {});
await secretStore.setSecret(cacheKey(p.userId), priv);
void ensureLegacyMigrated(p.userId).catch((err) => {
console.warn('legacy conv-key migration failed', err);
});
return { kind: 'unlocked' };
}
export async function cachedUserKey(userId: string): Promise<Uint8Array | null> {
return secretStore.getSecret(cacheKey(userId));
}
export async function clearUserKeyCache(userId: string): Promise<void> {
await secretStore.removeSecret(cacheKey(userId));
}
export async function userKeyExistsRemotely(userId: string): Promise<boolean> {
const blob = await fetchUserKeyBlob(supabase, userId);
return blob !== null;
}
export async function changePin(params: {
userId: string;
oldPin: string;
newPin: string;
}): Promise<void> {
const cached = await cachedUserKey(params.userId);
if (!cached) throw new Error('user key not cached locally — re-login required');
const fresh = await sealUserKey({ privateKey: cached, pin: params.newPin });
await uploadUserKeyBlob(supabase, {
userId: params.userId,
publicKey: getCryptoBackend().scalarMultBase(cached),
sealedPrivateKey: fresh.sealedPrivateKey,
salt: fresh.salt,
kdfParams: fresh.kdfParams,
});
void params.oldPin; // cached key already proves old PIN was correct
}
export async function regenerateRecoveryCode(params: { userId: string }): Promise<string> {
const cached = await cachedUserKey(params.userId);
if (!cached) throw new Error('user key not cached locally');
const blob = await fetchUserKeyBlob(supabase, params.userId);
if (!blob || !blob.exists || blob.locked) {
throw new Error('cannot regenerate recovery while locked');
}
const recoveryCode = await generateRecoveryCode();
const sealed = await sealUserKey({
privateKey: cached,
pin: normalizeRecoveryCode(recoveryCode),
});
await uploadUserKeyBlob(supabase, {
userId: params.userId,
publicKey: getCryptoBackend().scalarMultBase(cached),
sealedPrivateKey: blob.sealedPrivateKey,
salt: blob.salt,
kdfParams: blob.kdfParams,
recoverySealedPrivateKey: sealed.sealedPrivateKey,
recoverySalt: sealed.salt,
});
return recoveryCode;
}
export async function resetIdentity(params: {
userId: string;
pin: string;
}): Promise<string> {
await clearUserKeyCache(params.userId);
await resetUserKey(supabase, {
userId: params.userId,
publicKey: new Uint8Array(32),
sealedPrivateKey: new Uint8Array(40),
salt: new Uint8Array(16),
kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 1, memlimit: 1 },
});
const setup = await setupNewUserIdentity({
userId: params.userId,
pin: params.pin,
withRecovery: true,
});
return setup.recoveryCode ?? '';
}
export interface LegacyMigrationReport {
serverDevices: number;
strongholdKeysFromServerDevices: number;
strongholdKeysFromBundleScan: number;
attempted: number;
migrated: number;
noStrongholdKey: number;
decryptFailed: number;
rpcFailed: number;
}
export async function ensureLegacyMigrated(userId: string): Promise<LegacyMigrationReport | null> {
const priv = await cachedUserKey(userId);
if (!priv) return null;
const pub = getCryptoBackend().scalarMultBase(priv);
return runLegacyMigration(userId, priv, pub);
}
export async function retryLegacyMigration(userId: string): Promise<LegacyMigrationReport> {
const priv = await cachedUserKey(userId);
if (!priv) throw new Error('user key not cached locally — re-login required');
const pub = getCryptoBackend().scalarMultBase(priv);
return runLegacyMigration(userId, priv, pub);
}
async function runLegacyMigration(
userId: string,
ownNewPriv: Uint8Array,
ownNewPub: Uint8Array,
): Promise<LegacyMigrationReport> {
const report: LegacyMigrationReport = {
serverDevices: 0,
strongholdKeysFromServerDevices: 0,
strongholdKeysFromBundleScan: 0,
attempted: 0,
migrated: 0,
noStrongholdKey: 0,
decryptFailed: 0,
rpcFailed: 0,
};
const devices = await listOwnDevices(supabase);
report.serverDevices = devices.length;
const ownLegacyDevicePrivateKeys: Record<string, Uint8Array> = {};
for (const d of devices) {
const k = await legacyDeviceKey(userId, d.id);
if (k) ownLegacyDevicePrivateKeys[d.id] = k;
}
report.strongholdKeysFromServerDevices = Object.keys(ownLegacyDevicePrivateKeys).length;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: scanRowsRaw } = await (supabase as any)
.from('conversation_keys')
.select('recipient_device_id')
.is('recipient_user_id', null)
.not('recipient_device_id', 'is', null);
const scanIds = Array.from(
new Set(
((scanRowsRaw ?? []) as { recipient_device_id: string }[])
.map((r) => r.recipient_device_id)
.filter((id): id is string => Boolean(id)),
),
);
for (const id of scanIds) {
if (ownLegacyDevicePrivateKeys[id]) continue;
const k = await legacyDeviceKey(userId, id);
if (k) {
ownLegacyDevicePrivateKeys[id] = k;
report.strongholdKeysFromBundleScan += 1;
}
}
const ids = Object.keys(ownLegacyDevicePrivateKeys);
if (ids.length === 0) {
console.warn('[crypto-migration] no legacy private keys in vault — nothing to migrate');
return report;
}
const result = await migrateOwnLegacyBundles({
client: supabase,
ownUserId: userId,
ownNewPublicKey: ownNewPub,
ownNewPrivateKey: ownNewPriv,
ownLegacyDeviceIds: ids,
ownLegacyDevicePrivateKeys,
});
report.attempted = result.attempted;
report.migrated = result.migratedConversations;
report.noStrongholdKey = result.noStrongholdKey;
report.decryptFailed = result.decryptFailed;
report.rpcFailed = result.rpcFailed;
return report;
}
+6 -2
View File
@@ -15,7 +15,8 @@
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rm -rf .expo node_modules/.cache .turbo *.tsbuildinfo"
"clean": "rm -rf .expo node_modules/.cache .turbo *.tsbuildinfo",
"check:env": "node scripts/check-env.mjs"
},
"dependencies": {
"@babel/runtime": "^7.29.2",
@@ -48,6 +49,9 @@
},
"devDependencies": {
"@babel/core": "^7.25.0",
"@types/react": "~18.3.12"
"@testing-library/react-native": "^12.9.0",
"@types/react": "~18.3.12",
"jsdom": "^29.1.1",
"react-test-renderer": "18.3.1"
}
}
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env node
// Compares apps/mobile/.env.example with .env.local. Surfaces missing keys
// so an onboarding dev doesn't ship a build that white-screens.
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const mobileRoot = resolve(here, '..');
const examplePath = resolve(mobileRoot, '.env.example');
const localPath = resolve(mobileRoot, '.env.local');
if (!existsSync(localPath)) {
console.error('No .env.local found at ' + localPath);
console.error('Copy .env.example to .env.local and fill in values.');
process.exit(1);
}
function keysOf(path) {
return new Set(
readFileSync(path, 'utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
.map((line) => line.split('=', 1)[0]),
);
}
const exampleKeys = keysOf(examplePath);
const localKeys = keysOf(localPath);
const missing = [...exampleKeys].filter((k) => !localKeys.has(k));
if (missing.length > 0) {
console.error('Missing keys in .env.local: ' + missing.join(', '));
process.exit(1);
}
console.log('env-ok: all .env.example keys present in .env.local');
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig } from 'vitest/config';
import { fileURLToPath } from 'node:url';
const shimPath = fileURLToPath(new URL('./vitest.rn-shim.ts', import.meta.url));
// Vitest cannot transform React Native's Flow-typed source. We redirect
// every `react-native` reference to a minimal host-component shim
// (`vitest.rn-shim.ts`) so @testing-library/react-native v12 + jsdom can
// render the few primitives PinInput uses.
//
// Two interception layers are needed:
// 1. resolve.alias — covers ESM imports vite sees during transformation
// of inlined dependencies (e.g. when @testing-library/react-native
// goes through vite's pipeline because of `server.deps.inline`).
// 2. setupFiles patches Module._resolveFilename so any pure-CJS
// `require("react-native")` that escapes vite also lands on the shim.
export default defineConfig({
test: {
environment: 'jsdom',
include: ['**/*.test.ts', '**/*.test.tsx'],
setupFiles: ['./vitest.setup.ts'],
server: {
deps: {
inline: [/@testing-library\/react-native/],
},
},
},
resolve: {
alias: [
{ find: /^react-native$/, replacement: shimPath },
{ find: /^react-native\/(.*)$/, replacement: shimPath },
],
},
});
+84
View File
@@ -0,0 +1,84 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// Minimal `react-native` surface used by vitest tests. Each visible
// primitive is a forwardRef component that renders a lower-case React
// element so react-test-renderer treats it as a regular host node, and
// @testing-library/react-native's queries can traverse the tree.
// The shim is loaded by Module._resolveFilename in vitest.setup.ts.
import * as React from 'react';
function makeHost(name: string) {
const C: any = React.forwardRef(function HostComponent(
props: Record<string, unknown>,
ref: unknown,
) {
return React.createElement(name, { ...props, ref });
});
C.displayName = name;
return C;
}
export const View = makeHost('View');
export const Text = makeHost('Text');
export const TextInput = makeHost('TextInput');
export const Pressable = makeHost('Pressable');
export const Image = makeHost('Image');
export const ScrollView = makeHost('ScrollView');
export const Modal = makeHost('Modal');
export const Switch = makeHost('Switch');
export const Platform = { OS: 'test', select: (m: any) => m.default ?? m.test ?? null };
export const StyleSheet = {
create<T extends Record<string, unknown>>(s: T): T {
return s;
},
flatten(s: unknown): unknown {
if (Array.isArray(s)) {
const flat: Record<string, unknown> = {};
for (const part of s) Object.assign(flat, part ?? {});
return flat;
}
return s ?? {};
},
hairlineWidth: 1,
absoluteFill: {},
absoluteFillObject: {},
};
export const Dimensions = { get: () => ({ width: 360, height: 640 }) };
export const NativeModules: Record<string, unknown> = {};
export const Animated = { View, Text, Image, createAnimatedComponent: (c: unknown) => c };
export const Linking = { openURL: async () => undefined };
export const Alert = { alert: () => undefined };
export const Appearance = { getColorScheme: () => 'dark' };
export const AccessibilityInfo = {
isScreenReaderEnabled: async () => false,
addEventListener: () => ({ remove: () => undefined }),
};
export const PixelRatio = { get: () => 2 };
export const Keyboard = { dismiss: () => undefined };
export const useWindowDimensions = () => ({ width: 360, height: 640 });
const defaultExport = {
View,
Text,
TextInput,
Pressable,
Image,
ScrollView,
Modal,
Switch,
Platform,
StyleSheet,
Dimensions,
NativeModules,
Animated,
Linking,
Alert,
Appearance,
AccessibilityInfo,
PixelRatio,
Keyboard,
useWindowDimensions,
};
export default defaultExport;
+25
View File
@@ -0,0 +1,25 @@
// Vitest cannot transform React Native's Flow-typed source. We monkey-patch
// Node's CommonJS resolver so every bare `react-native` request returns our
// minimal host-component shim. This file is referenced by `setupFiles` in
// vitest.config.ts and runs before any test file imports execute.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Module = require('node:module');
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const shimPath = path.join(here, 'vitest.rn-shim.ts');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const originalResolve = (Module as any)._resolveFilename;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Module as any)._resolveFilename = function patched(
request: string,
parent: unknown,
...rest: unknown[]
): string {
if (request === 'react-native' || request.startsWith('react-native/')) {
return shimPath;
}
return originalResolve.call(this, request, parent, ...rest);
};
@@ -0,0 +1,779 @@
# Android White-Screen RCA — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Identify and fix the cause of the Android white-screen-after-install on the mobile build, and ship defense-in-depth so the next boot-time failure renders a readable error screen instead of a blank window.
**Architecture:** Wire EXPO_PUBLIC_* into EAS builds via EAS Secrets (the leading hypothesis). Make `env.ts` lazy so missing variables throw inside React. Add an `<AppBootstrap>` boundary inside `_layout.tsx` that initialises the crypto backend in a `useEffect`, renders a splash while loading, and routes any error to a `<BootError>` view. Install a global JS error handler as the last-resort net. Then run the diagnostic playbook against a fresh APK to confirm which hypothesis actually fired.
**Tech Stack:** Expo SDK 52, React Native 0.76, TypeScript, EAS Build, expo-secure-store, react-native-libsodium.
**Spec:** `docs/superpowers/specs/2026-05-16-android-whitescreen-rca-design.md`
**Decisions inherited from spec review gate:**
- EAS Secrets (not `eas.json env`) for `EXPO_PUBLIC_*` values.
- `newArchEnabled: false` is acceptable as a temporary rollback if H2 confirms.
---
## File Overview
**New files (mobile app):**
- `apps/mobile/components/BootError.tsx` — full-screen error fallback with env diagnostic
- `apps/mobile/components/BootSplash.tsx` — minimal splash shown while the crypto backend warms up
- `apps/mobile/components/AppBootstrap.tsx` — boundary that initialises the crypto backend in a `useEffect` and routes errors to `BootError`
- `apps/mobile/lib/env.test.ts` — lazy proxy + missing-var coverage
- `apps/mobile/scripts/check-env.mjs` — optional lint comparing `.env.example` against `.env.local`
**Modified files (mobile app):**
- `apps/mobile/lib/env.ts` — convert to lazy proxy
- `apps/mobile/app/_layout.tsx` — remove the module-eval crypto init; mount `<AppBootstrap>` at the top of the tree
- `apps/mobile/README.md` — add an "EAS env" section
- `apps/mobile/package.json` — add `check:env` script
**No files deleted.** No native code changes.
---
## Phase 0 — Wire env into EAS builds (the leading hypothesis fix)
### Task 1: Create EAS Secrets and document the contract
**Files:**
- Modify: `apps/mobile/README.md`
- [ ] **Step 1: Confirm EAS CLI is installed and authenticated**
Run:
```bash
cd apps/mobile
npx eas-cli --version
npx eas-cli whoami
```
Expected: a version string ≥ 13.0.0, and the `whoami` output shows the `bygalax` owner (matches `app.json` `expo.owner`). If `whoami` errors, run `npx eas-cli login` interactively in a terminal (this plan cannot be executed in a sandboxed shell).
- [ ] **Step 2: Create the three project-scoped secrets**
Run, substituting the real Supabase project URL and `sb_publishable_…` anon key (look them up in `apps/mobile/.env.local`):
```bash
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_URL --value '<project-supabase-url>'
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value '<sb_publishable_key>'
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_AUTH_REDIRECT_URL --value 'netralax://auth/callback'
```
Expected: each command prints `✔ Created a new secret EXPO_PUBLIC_…`. List to confirm:
```bash
npx eas-cli secret:list
```
Expected: all three names present, type `STRING`, scope `PROJECT`.
- [ ] **Step 3: Document the env contract in README**
Append to `apps/mobile/README.md`:
````markdown
## EAS Builds and Environment Variables
Production and preview builds load `EXPO_PUBLIC_*` from EAS Secrets — `.env.local` is only honoured by `expo start` locally.
Required secrets (create once per project):
```bash
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_URL --value '<project-supabase-url>'
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value '<sb_publishable_key>'
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_AUTH_REDIRECT_URL --value 'netralax://auth/callback'
```
Check with `npx eas-cli secret:list`. Missing values cause `env.ts` to throw at the first read, which the `<BootError>` view renders.
````
- [ ] **Step 4: Commit**
```bash
git add apps/mobile/README.md
git commit -m "docs(mobile): document EAS Secrets contract for EXPO_PUBLIC_*"
```
---
## Phase 1 — Lazy env proxy (TDD)
### Task 2: Failing test for missing env var
**Files:**
- Create: `apps/mobile/lib/env.test.ts`
- [ ] **Step 1: Write the failing test**
Create `apps/mobile/lib/env.test.ts`:
```ts
import { beforeEach, describe, expect, it, vi } from 'vitest';
describe('mobile env (lazy proxy)', () => {
beforeEach(() => {
vi.resetModules();
delete process.env.EXPO_PUBLIC_SUPABASE_URL;
delete process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
delete process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL;
});
it('importing the module does NOT throw when required vars are missing', async () => {
await expect(import('./env')).resolves.toBeTruthy();
});
it('reading a property with no env set throws a clear error', async () => {
const mod = await import('./env');
expect(() => mod.env.supabaseUrl).toThrowError(
/Missing required env var EXPO_PUBLIC_SUPABASE_URL/,
);
});
it('reading a property after setting env returns the value and memoises', async () => {
process.env.EXPO_PUBLIC_SUPABASE_URL = 'https://example.supabase.co';
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY = 'anon-123';
const mod = await import('./env');
expect(mod.env.supabaseUrl).toBe('https://example.supabase.co');
expect(mod.env.supabaseAnonKey).toBe('anon-123');
expect(mod.env.authRedirectUrl).toBe('netralax://auth/callback');
});
});
```
- [ ] **Step 2: Run test, confirm it fails for the right reason**
Run:
```bash
pnpm --filter @chat-app/mobile test -- env.test
```
Expected: `importing the module does NOT throw when required vars are missing` FAILS because the current `env.ts` evaluates `required(...)` at module-eval time. This proves the test is wired correctly. Do NOT proceed if a different test fails first — fix the wiring before editing source.
### Task 3: Convert `env.ts` to a lazy proxy
**Files:**
- Modify: `apps/mobile/lib/env.ts`
- [ ] **Step 1: Replace the module body with the lazy proxy**
Overwrite `apps/mobile/lib/env.ts` with:
```ts
// EXPO_PUBLIC_* vars are inlined at bundle time by Expo's Babel plugin (or
// shipped via EAS Secrets for EAS builds — see apps/mobile/README.md).
// We pull them through a Proxy so missing vars throw on first READ, not at
// module-eval time. That keeps the throw inside the React tree where the
// <BootError> boundary can render it as a readable screen instead of a blank
// white window.
function required(name: string): string {
const v = process.env[name];
if (!v || v.length === 0) {
throw new Error(
'Missing required env var ' + name +
'. Set it via `eas secret:create --scope project --name ' + name +
' --value ...` or in apps/mobile/.env.local for local dev (see .env.example).',
);
}
return v;
}
interface EnvShape {
supabaseUrl: string;
supabaseAnonKey: string;
authRedirectUrl: string;
}
function readEnv(): EnvShape {
return {
supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'),
supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'),
authRedirectUrl: process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL ?? 'netralax://auth/callback',
};
}
let cached: EnvShape | null = null;
export const env: EnvShape = new Proxy({} as EnvShape, {
get(_target, key: string | symbol): unknown {
cached ??= readEnv();
return cached[key as keyof EnvShape];
},
});
```
- [ ] **Step 2: Re-run the env tests**
```bash
pnpm --filter @chat-app/mobile test -- env.test
```
Expected: all three tests PASS.
- [ ] **Step 3: Run the full mobile test suite**
```bash
pnpm --filter @chat-app/mobile test
```
Expected: no regressions. If a test depended on the old eager-throw behaviour, update it inline to expect a lazy throw.
- [ ] **Step 4: Commit**
```bash
git add apps/mobile/lib/env.ts apps/mobile/lib/env.test.ts
git commit -m "fix(mobile): lazy env proxy so missing EXPO_PUBLIC vars throw inside React"
```
---
## Phase 2 — Bootstrap boundary + global handler
### Task 4: Create `BootSplash` and `BootError`
**Files:**
- Create: `apps/mobile/components/BootSplash.tsx`
- Create: `apps/mobile/components/BootError.tsx`
- [ ] **Step 1: Create the splash**
`apps/mobile/components/BootSplash.tsx`:
```tsx
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { colors } from '../theme/colors';
// Shown while AppBootstrap is initialising the crypto backend. Identical
// background to the Expo splash so the handoff is invisible to the user.
export function BootSplash() {
return (
<View style={styles.container}>
<ActivityIndicator color={colors.accent} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bg,
},
});
```
- [ ] **Step 2: Create the error view**
`apps/mobile/components/BootError.tsx`:
```tsx
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { colors } from '../theme/colors';
interface Props {
error: Error;
}
// Last-resort fallback. Renders whenever AppBootstrap's init effect throws or
// when the global JS error handler catches an unhandled exception. The
// env-diagnostic line ("env-ok" / "env-missing") makes future bug reports
// triageable from a single screenshot.
export function BootError({ error }: Props) {
const envOk = Boolean(process.env.EXPO_PUBLIC_SUPABASE_URL);
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>App-Start fehlgeschlagen</Text>
<Text style={styles.message}>{error.message}</Text>
<View style={styles.diagnostic}>
<Text style={styles.diagnosticLabel}>EXPO_PUBLIC_SUPABASE_URL:</Text>
<Text style={[styles.diagnosticValue, envOk ? styles.ok : styles.bad]}>
{envOk ? 'env-ok' : 'env-missing'}
</Text>
</View>
{error.stack && <Text style={styles.stack}>{error.stack}</Text>}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flexGrow: 1,
backgroundColor: colors.bg,
padding: 24,
paddingTop: 64,
gap: 12,
},
title: { color: colors.text, fontSize: 20, fontWeight: '700' },
message: { color: colors.danger, fontSize: 14, lineHeight: 20 },
diagnostic: { flexDirection: 'row', gap: 8, marginTop: 8 },
diagnosticLabel: { color: colors.textMuted, fontSize: 12 },
diagnosticValue: { fontSize: 12, fontWeight: '700' },
ok: { color: colors.success },
bad: { color: colors.danger },
stack: {
color: colors.textDim,
fontSize: 11,
fontFamily: 'Courier',
marginTop: 16,
},
});
```
- [ ] **Step 3: Commit (no test yet — these are render-only components)**
```bash
git add apps/mobile/components/BootSplash.tsx apps/mobile/components/BootError.tsx
git commit -m "feat(mobile): BootSplash + BootError fallback views for AppBootstrap"
```
### Task 5: Create `AppBootstrap` boundary
**Files:**
- Create: `apps/mobile/components/AppBootstrap.tsx`
- [ ] **Step 1: Write the boundary**
`apps/mobile/components/AppBootstrap.tsx`:
```tsx
import { crypto } from '@chat-app/shared';
import { type ReactNode, useEffect, useState } from 'react';
import { createLibsodiumBackend } from '../lib/cryptoBackend';
import { BootError } from './BootError';
import { BootSplash } from './BootSplash';
interface Props {
children: ReactNode;
}
// React Native exposes ErrorUtils on the global. The types ship with RN but
// we cast defensively because the renderer used by Vitest does not.
interface RNErrorUtils {
getGlobalHandler: () => (err: Error, isFatal?: boolean) => void;
setGlobalHandler: (handler: (err: Error, isFatal?: boolean) => void) => void;
}
// Initialises the crypto backend inside a useEffect (not at module-eval) so
// any failure surfaces in the React tree. Also installs a global JS error
// handler that routes unhandled throws to BootError; this catches errors
// thrown during render (e.g. the lazy env proxy reading a missing var) that
// would otherwise escape every per-screen ErrorBoundary.
export function AppBootstrap({ children }: Props) {
const [error, setError] = useState<Error | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
try {
crypto.setCryptoBackend(createLibsodiumBackend());
setReady(true);
} catch (e: unknown) {
setError(e instanceof Error ? e : new Error(String(e)));
}
}, []);
useEffect(() => {
const eu = (globalThis as unknown as { ErrorUtils?: RNErrorUtils }).ErrorUtils;
if (!eu) return;
const prev = eu.getGlobalHandler();
eu.setGlobalHandler((err, isFatal) => {
prev?.(err, isFatal);
setError(err);
});
return () => eu.setGlobalHandler(prev);
}, []);
if (error) return <BootError error={error} />;
if (!ready) return <BootSplash />;
return <>{children}</>;
}
```
- [ ] **Step 2: Typecheck**
```bash
pnpm --filter @chat-app/mobile typecheck
```
Expected: no new errors.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/components/AppBootstrap.tsx
git commit -m "feat(mobile): AppBootstrap boundary — defers crypto init, catches global throws"
```
### Task 6: Rewire `_layout.tsx`
**Files:**
- Modify: `apps/mobile/app/_layout.tsx`
- [ ] **Step 1: Replace the module-eval crypto init with `<AppBootstrap>`**
Overwrite `apps/mobile/app/_layout.tsx` with:
```tsx
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { AppBootstrap } from '../components/AppBootstrap';
import { ErrorBoundary } from '../components/ErrorBoundary';
import { IncomingCallModal } from '../components/IncomingCallModal';
import { AuthProvider } from '../lib/authContext';
import { CallProvider } from '../lib/callContext';
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<AppBootstrap>
<ErrorBoundary>
<AuthProvider>
<CallProvider>
<StatusBar style="auto" />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(app)" />
<Stack.Screen name="auth/callback" />
</Stack>
<IncomingCallModal />
</CallProvider>
</AuthProvider>
</ErrorBoundary>
</AppBootstrap>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
```
Two structural changes vs. the prior version:
1. Removed the top-level `crypto.setCryptoBackend(createLibsodiumBackend())` call — it now runs inside `AppBootstrap`'s `useEffect`.
2. `<AppBootstrap>` sits OUTSIDE `<ErrorBoundary>` so a boot failure renders `<BootError>` instead of trying (and failing) to hit `ErrorBoundary`'s consumer-tree path.
- [ ] **Step 2: Typecheck**
```bash
pnpm --filter @chat-app/mobile typecheck
```
Expected: no errors. The previously-direct `crypto` + `createLibsodiumBackend` imports are now gone from `_layout.tsx`; if either is reported as unused, remove the stale import.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/app/_layout.tsx
git commit -m "fix(mobile): defer crypto backend init into AppBootstrap (prevents white-screen)"
```
---
## Phase 3 — Local validation (sanity before remote build)
### Task 7: Local smoke (Expo Go / dev client) — env-missing path
**Files:**
- None (local sanity, no edits)
- [ ] **Step 1: Temporarily clear local env**
```bash
mv apps/mobile/.env.local apps/mobile/.env.local.bak
```
- [ ] **Step 2: Start the dev server**
```bash
pnpm --filter @chat-app/mobile dev
```
In a connected Android emulator / device, open the dev client.
Expected: app reaches `<BootError>` with the message `Missing required env var EXPO_PUBLIC_SUPABASE_URL ...` and the diagnostic line `EXPO_PUBLIC_SUPABASE_URL: env-missing`. **No white screen.**
- [ ] **Step 3: Restore env**
```bash
mv apps/mobile/.env.local.bak apps/mobile/.env.local
```
Reload the dev client.
Expected: app boots normally to the login screen.
- [ ] **Step 4: No commit (validation only)**
No-op.
---
## Phase 4 — Remote build validation (the actual RCA)
### Task 8: Run the diagnostic playbook against a real APK
**Files:**
- None (investigative; outcome determines whether Phase 5 fixes are needed)
- [ ] **Step 1: Build the preview APK with EAS Secrets present**
```bash
cd apps/mobile
npx eas-cli build --profile preview --platform android
```
Expected: build succeeds. Note the APK URL.
- [ ] **Step 2: Install on a connected Android device**
```bash
adb install -r <downloaded-apk>.apk
```
Expected: install succeeds.
- [ ] **Step 3: Capture logs while launching**
```bash
adb logcat -c
adb logcat *:E ReactNative:V ReactNativeJS:V &
# tap the launcher icon for the app
```
Triage the first 50 lines for the first `Error`, `Exception`, or `FATAL` after the app starts.
- [ ] **Step 4: Match the trace against a hypothesis**
| Trace pattern | Hypothesis | Next action |
|---|---|---|
| `Missing required env var EXPO_PUBLIC_…` rendered to BootError (no red box) | H1 — fix already applied | Skip to Phase 6 |
| `Native module … not found` / `RNLibsodium not found` | H3 — libsodium native autolink missing | Phase 5 Task 9 |
| `JNI DETECTED ERROR` / `Fatal signal 11 (SIGSEGV)` before any RN log | H2 — new arch + incompatible lib | Phase 5 Task 10 |
| `libsodium-wrappers-sumo` or `WebAssembly` in the trace | H4 — covered by the mobile-encryption-port plan | Note the trace; merge that plan next |
| App reaches login screen | H1 was the root cause; nothing more to do | Skip to Phase 6 |
- [ ] **Step 5: Write a one-paragraph note in the PR description**
Capture which hypothesis confirmed, log lines, and which Phase 5 task (if any) was needed. This becomes the regression record.
---
## Phase 5 — Hypothesis-specific fixes (conditional)
Only run the tasks that the Step 4 triage selected. If H1 alone resolves it, skip Phase 5 entirely.
### Task 9 (conditional, H3): Force re-link `react-native-libsodium`
**Files:**
- Modify: `apps/mobile/app.json` (only if `expo prebuild` adds a plugin entry — see below)
- [ ] **Step 1: Run `expo prebuild` to regenerate native projects**
```bash
cd apps/mobile
npx expo prebuild --clean --platform android
```
Expected: an `android/` directory is created (or refreshed), and `app.json` may gain a `plugins` entry for `react-native-libsodium` if the lib ships a config plugin.
- [ ] **Step 2: Rebuild and re-test**
```bash
npx eas-cli build --profile preview --platform android
```
Install, repeat Phase 4 Step 3-4.
Expected: native module is now found. If still missing, escalate to the lib's GitHub issues — it likely needs a manual Gradle entry in `android/app/build.gradle`.
- [ ] **Step 3: Commit any generated config**
If `app.json` changed, commit the diff:
```bash
git add apps/mobile/app.json
git commit -m "fix(mobile): re-link react-native-libsodium via expo prebuild"
```
If `android/` is ignored (Expo managed flow), document the prebuild step in the README under the "EAS env" section instead.
### Task 10 (conditional, H2): Temporarily disable `newArchEnabled`
**Files:**
- Modify: `apps/mobile/app.json`
- [ ] **Step 1: Toggle the flag**
Edit `apps/mobile/app.json`, change `"newArchEnabled": true``"newArchEnabled": false`. Note in the commit message which library is suspected and the upstream issue link.
- [ ] **Step 2: Rebuild and re-test**
```bash
npx eas-cli build --profile preview --platform android
```
Install, repeat Phase 4 Step 3-4.
Expected: app boots to login.
- [ ] **Step 3: Open a follow-up issue**
Create a tracking issue in the repo titled `mobile: re-enable newArchEnabled once <lib> is Fabric-ready` with the trace from Phase 4 attached. Link to the affected lib's tracker.
- [ ] **Step 4: Commit**
```bash
git add apps/mobile/app.json
git commit -m "fix(mobile): temporarily disable newArchEnabled (white-screen on Android)
Suspected incompatibility: <library@version>. Tracked in #<issue>."
```
---
## Phase 6 — Optional hardening
### Task 11: `check:env` lint script
**Files:**
- Create: `apps/mobile/scripts/check-env.mjs`
- Modify: `apps/mobile/package.json`
- [ ] **Step 1: Add the script**
`apps/mobile/scripts/check-env.mjs`:
```js
#!/usr/bin/env node
// Compares apps/mobile/.env.example with .env.local. Surfaces missing keys
// so an onboarding dev doesn't ship a build that white-screens.
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const mobileRoot = resolve(here, '..');
const examplePath = resolve(mobileRoot, '.env.example');
const localPath = resolve(mobileRoot, '.env.local');
if (!existsSync(localPath)) {
console.error('No .env.local found at ' + localPath);
console.error('Copy .env.example to .env.local and fill in values.');
process.exit(1);
}
function keysOf(path) {
return new Set(
readFileSync(path, 'utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
.map((line) => line.split('=', 1)[0]),
);
}
const exampleKeys = keysOf(examplePath);
const localKeys = keysOf(localPath);
const missing = [...exampleKeys].filter((k) => !localKeys.has(k));
if (missing.length > 0) {
console.error('Missing keys in .env.local: ' + missing.join(', '));
process.exit(1);
}
console.log('env-ok: all .env.example keys present in .env.local');
```
- [ ] **Step 2: Wire the script into `package.json`**
In `apps/mobile/package.json`, under `scripts`, add:
```json
"check:env": "node scripts/check-env.mjs"
```
- [ ] **Step 3: Smoke**
```bash
pnpm --filter @chat-app/mobile run check:env
```
Expected: `env-ok: all .env.example keys present in .env.local`.
- [ ] **Step 4: Commit**
```bash
git add apps/mobile/scripts/check-env.mjs apps/mobile/package.json
git commit -m "chore(mobile): check:env script lints .env.local against .env.example"
```
---
## Phase 7 — Wrap-up
### Task 12: PR + post-mortem note
- [ ] **Step 1: Push branch + open PR**
```bash
git push -u origin <branch-name>
gh pr create --title "fix(mobile): Android white-screen RCA + defense-in-depth" --body "..."
```
PR body must contain:
```
## Summary
- Lazy env proxy so missing EXPO_PUBLIC_* throws inside React.
- AppBootstrap boundary mounts before AuthProvider; renders BootError on init failure.
- Global ErrorUtils handler routes unhandled throws to BootError.
- EAS Secrets documented in README; eas.json untouched.
## RCA outcome
<paste Phase 4 Step 5 paragraph>
## Test plan
- [x] Local: env-cleared dev client shows BootError, not white screen.
- [x] Remote: preview APK installed on Android device; <result>.
- [x] check:env script passes.
- [x] All mobile unit tests green.
```
- [ ] **Step 2: Mark plan complete**
This plan is done when the Android preview build opens to the login screen and `<BootError>` renders correctly with env intentionally cleared.
---
## Spec coverage check
- Hypothesis H1 — EAS Secrets (Task 1), lazy env (Task 3), env test (Task 2). ✅
- Hypothesis H2 — Diagnostic (Task 8), conditional Task 10 fix. ✅
- Hypothesis H3 — Diagnostic (Task 8), conditional Task 9 fix. ✅
- Hypothesis H4 — Diagnostic only (Task 8); structural fix belongs to the mobile-encryption-port plan. ✅
- Hypothesis H5 — Documented in Task 8 Step 4 table; cost of an asset eyeball is zero, no separate task needed. ✅
- Defense-in-depth 1 (lazy env) — Tasks 2-3. ✅
- Defense-in-depth 2 (AppBootstrap) — Tasks 4-6. ✅
- Defense-in-depth 3 (global handler) — Task 5. ✅
- Defense-in-depth 4 (SecureStore probe) — deferred (see Out of Scope). ✅
- Defense-in-depth 5 (`check:env`) — Task 11. ✅
## Out of scope for this plan (in spec, but deferred)
- SecureStore availability probe — only relevant on rare Android factory-test profiles. Add later if Phase 4 surfaces a SecureStore symptom.
- Sentry / Bugsnag integration — spec lists this under future work.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,927 @@
# Phase 3 — Security & Devices: Session/Device List + Revoke
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let the user see every install currently signed into their account, revoke any one remotely, and force-sign-out any device whose row is flipped to `revoked_at != null` (either by another install or — in the future — by an admin).
**Architecture:**
- Server: extend the existing telemetry-only `devices` table with a `revoked_at` column + a `revoke_device(p_device_id uuid)` SECURITY DEFINER RPC. Publish `devices` to `supabase_realtime` so every signed-in client gets postgres_changes events for its own rows.
- Client: on each post-sign-in startup, "ensure" exactly one `devices` row exists for this install (registered the first time, then re-used). Subscribe to own `devices` for revoked_at flips → force `signOut()` (which already chains through `wipeLocalState`). A new "Geräte" tab in Settings lists rows + offers a Revoke button per row (own row's button is disabled, label "Nutze Sign-out").
- A new full-screen `RemoteRevokedScreen` is rendered as a top-level overlay (inside `AuthProvider`) whenever `revokedRemotely` is true, so the user sees "Du wurdest remote abgemeldet" *before* the router bounces them to `/auth`.
**Tech Stack:** Postgres + RLS + RPC + Supabase Realtime (postgres_changes); React 18 + HashRouter; Electron preload IPC for OS hostname; existing `wipeLocalState` and `signOut` chain in `AuthContext`.
**Non-goals:**
- No "rename device" UI (out of scope for Phase 3 spec).
- No "log out all other devices" bulk action.
- No admin-side revoke; spec only requires user-driven revoke of own installs.
---
## Pre-flight
- [ ] **Verify current working directory and clean status**
Run: `cd "D:\Programmieren\ChatApp-Electron\chat-app" && git status`
Expected: branch `main`, no uncommitted changes (except possibly `apps/desktop/.env.local` which is gitignored).
- [ ] **Confirm tooling is green before starting**
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/shared test --run`
Expected: all green. If anything is red, STOP and report — don't start on a broken baseline.
---
## Task 1: SQL migration — devices.revoked_at + revoke_device RPC + realtime publication
**Why:** The whole feature pivots on a server-side authoritative "is this install still allowed in" flag plus a way for any of the user's own installs to flip it.
**Files:**
- Create: `supabase/migrations/20260516000005_device_revocation.sql`
**Migration must be idempotent** — pre-Phase-2 we re-ran migrations against prod by hand, and pushing again must not error. Use `add column if not exists`, `create or replace function`, `do $$ ... if not exists ... $$` for publication adds.
- [ ] **Step 1: Write the SQL migration**
Create `supabase/migrations/20260516000005_device_revocation.sql`:
```sql
-- Phase 3: per-device revocation. Adds a revoked_at flag, an RPC that lets
-- the device's owner flip it, and publishes the devices table to realtime
-- so every signed-in install can react to its own row being revoked.
alter table public.devices
add column if not exists revoked_at timestamptz null;
create index if not exists devices_user_revoked_idx
on public.devices(user_id, revoked_at);
-- RLS: existing policy on devices already gates by user_id; an UPDATE done
-- via the RPC runs with SECURITY DEFINER so we don't widen the RLS surface.
create or replace function public.revoke_device(p_device_id uuid)
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
v_owner uuid;
begin
if auth.uid() is null then
raise exception 'not authenticated' using errcode = '28000';
end if;
select user_id into v_owner
from public.devices
where id = p_device_id;
if v_owner is null then
raise exception 'device not found' using errcode = 'P0002';
end if;
if v_owner <> auth.uid() then
raise exception 'not authorized' using errcode = '42501';
end if;
update public.devices
set revoked_at = now()
where id = p_device_id
and revoked_at is null;
end;
$$;
revoke all on function public.revoke_device(uuid) from public;
grant execute on function public.revoke_device(uuid) to authenticated;
-- Replica identity full so UPDATE events deliver the full new row (including
-- revoked_at) to subscribers; default REPLICA IDENTITY DEFAULT only sends
-- the primary key columns, which would force a refetch on every event.
alter table public.devices replica identity full;
-- Add devices to the supabase_realtime publication if not already present.
do $$
begin
if not exists (
select 1
from pg_publication_tables
where pubname = 'supabase_realtime'
and schemaname = 'public'
and tablename = 'devices'
) then
execute 'alter publication supabase_realtime add table public.devices';
end if;
end
$$;
```
- [ ] **Step 2: Commit the migration**
```bash
cd "D:\Programmieren\ChatApp-Electron\chat-app"
git add supabase/migrations/20260516000005_device_revocation.sql
git commit -m "feat(P3.T1): devices.revoked_at + revoke_device RPC + realtime publication"
```
Do NOT push to prod yet — `bash scripts/prod/push-migrations.sh <filter>` is only run when explicitly authorized by the user. This task is local-commit-only.
---
## Task 2: Shared wrapper — DeviceRecord.revokedAt + revokeDevice helper + listOwnDevices update
**Why:** The desktop renderer only ever talks to Supabase through `@chat-app/shared` wrappers. Adding `revokedAt` to `DeviceRecord` and a `revokeDevice` helper keeps the contract consistent across packages and gives us a typed RPC client.
**Files:**
- Modify: `packages/shared/src/auth/device.ts`
- Test: `packages/shared/src/auth/device.test.ts` (CREATE — file doesn't exist today)
- [ ] **Step 1: Write the failing test**
Create `packages/shared/src/auth/device.test.ts`:
```ts
import { describe, expect, it, vi } from 'vitest';
import { listOwnDevices, revokeDevice } from './device';
function makeClient(overrides: {
user?: { id: string } | null;
selectData?: Array<{ id: string; name: string; platform: string; last_seen_at: string; revoked_at: string | null }>;
rpcImpl?: (fn: string, params: unknown) => Promise<{ data: unknown; error: unknown }>;
}): any {
const builder: any = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockResolvedValue({ data: overrides.selectData ?? [], error: null }),
};
return {
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: overrides.user ?? { id: 'u-1' } } }) },
from: vi.fn().mockReturnValue(builder),
rpc: vi.fn().mockImplementation(overrides.rpcImpl ?? (async () => ({ data: null, error: null }))),
};
}
describe('listOwnDevices', () => {
it('maps revoked_at into revokedAt', async () => {
const client = makeClient({
selectData: [
{ id: 'd-1', name: 'Laptop', platform: 'desktop', last_seen_at: '2026-05-16T00:00:00Z', revoked_at: null },
{ id: 'd-2', name: 'Old phone', platform: 'mobile', last_seen_at: '2026-05-10T00:00:00Z', revoked_at: '2026-05-15T12:00:00Z' },
],
});
const out = await listOwnDevices(client);
expect(out).toEqual([
{ id: 'd-1', name: 'Laptop', platform: 'desktop', lastSeenAt: '2026-05-16T00:00:00Z', revokedAt: null },
{ id: 'd-2', name: 'Old phone', platform: 'mobile', lastSeenAt: '2026-05-10T00:00:00Z', revokedAt: '2026-05-15T12:00:00Z' },
]);
});
});
describe('revokeDevice', () => {
it('invokes the revoke_device RPC with the device id', async () => {
const rpc = vi.fn().mockResolvedValue({ data: null, error: null });
const client = makeClient({ rpcImpl: rpc });
await revokeDevice(client, 'd-42');
expect(rpc).toHaveBeenCalledWith('revoke_device', { p_device_id: 'd-42' });
});
it('throws when the RPC returns an error', async () => {
const client = makeClient({
rpcImpl: async () => ({ data: null, error: { message: 'not authorized', code: '42501' } as any }),
});
await expect(revokeDevice(client, 'd-99')).rejects.toThrow(/not authorized/);
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `pnpm --filter @chat-app/shared test --run device`
Expected: FAIL — `revokeDevice` not exported, `revokedAt` missing from mapped row.
- [ ] **Step 3: Implement revokedAt and revokeDevice in the wrapper**
Edit `packages/shared/src/auth/device.ts`:
- Extend `DeviceRecord`:
```ts
export interface DeviceRecord {
id: string;
name: string;
platform: DevicePlatform;
lastSeenAt: string;
revokedAt: string | null;
}
```
- Update `registerDevice`'s select + return-mapper to include `revoked_at` / `revokedAt: null`:
```ts
.select('id, name, platform, last_seen_at, revoked_at')
```
```ts
return {
id: data.id,
name: data.name,
platform: data.platform,
lastSeenAt: data.last_seen_at,
revokedAt: data.revoked_at,
};
```
- Update `listOwnDevices` to select+map revoked_at:
```ts
.select('id, name, platform, last_seen_at, revoked_at')
```
```ts
return data.map((row) => ({
id: row.id,
name: row.name,
platform: row.platform,
lastSeenAt: row.last_seen_at,
revokedAt: row.revoked_at,
}));
```
- Add the new helper at the end of the file (above the re-exports):
```ts
export async function revokeDevice(
client: AppSupabaseClient,
deviceId: string,
): Promise<void> {
const { error } = await client.rpc('revoke_device', { p_device_id: deviceId });
if (error) throw new Error(error.message);
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `pnpm --filter @chat-app/shared test --run device`
Expected: PASS — 3 tests in `device.test.ts` pass.
- [ ] **Step 5: Run full shared typecheck + test suite**
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/shared test --run`
Expected: all green. If a downstream file uses `DeviceRecord` and breaks on the missing field, fix the call site to handle `revokedAt: null` (don't suppress with `as any`).
- [ ] **Step 6: Commit**
```bash
git add packages/shared/src/auth/device.ts packages/shared/src/auth/device.test.ts
git commit -m "feat(P3.T2): DeviceRecord.revokedAt + revokeDevice wrapper"
```
---
## Task 3: Preload IPC — `app:hostname` for the device-name default
**Why:** The first time we register a `devices` row for this install we need a human-readable name. Renderer has no Node access so we expose a tiny IPC that returns `os.hostname()`. If the call ever fails (e.g. web build), the caller falls back to a static "Desktop".
**Files:**
- Modify: `apps/desktop/electron/ipc-types.ts` — add channel constant
- Modify: `apps/desktop/electron/main.ts` — register handler
- Modify: `apps/desktop/electron/preload.ts` — expose method
- Modify: `apps/desktop/electron/preload-types.d.ts` — extend renderer typing
- [ ] **Step 1: Find the existing CHANNELS constant and add the new channel**
Run: `grep -n "CHANNELS =" apps/desktop/electron/ipc-types.ts`
Add a new entry next to the other `app:*` channels (or at the end if there are none):
```ts
export const CHANNELS = {
// … existing …
appHostname: 'app:hostname',
} as const;
```
(Pick a name that matches the existing convention in that file — if other entries use snake_case keys, match them.)
- [ ] **Step 2: Register the handler in main.ts**
Locate the place in `apps/desktop/electron/main.ts` where other `ipcMain.handle(...)` calls live (search for `ipcMain.handle`). Add:
```ts
import os from 'node:os';
// …
ipcMain.handle(CHANNELS.appHostname, () => {
try {
const h = os.hostname();
return typeof h === 'string' && h.length > 0 ? h : null;
} catch {
return null;
}
});
```
- [ ] **Step 3: Expose the method on the preload bridge**
Edit `apps/desktop/electron/preload.ts` — in the `contextBridge.exposeInMainWorld('electronAPI', { ... })` object, add:
```ts
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.appHostname),
```
(Match the formatting/style of nearby entries.)
- [ ] **Step 4: Extend the renderer typing**
Edit `apps/desktop/electron/preload-types.d.ts` to add `getHostname?: () => Promise<string | null>` to the `ElectronAPI` interface (make it optional so the renderer code that consumes it must always `if (typeof window.electronAPI?.getHostname === 'function')`).
- [ ] **Step 5: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add apps/desktop/electron/ipc-types.ts apps/desktop/electron/main.ts apps/desktop/electron/preload.ts apps/desktop/electron/preload-types.d.ts
git commit -m "feat(P3.T3): app:hostname IPC for device-name default"
```
---
## Task 4: AuthContext — ensure device row + revocation realtime + revokedRemotely flag
**Why:** Every install needs (a) a `devices` row on the server so it shows up in the Geräte tab and (b) a live subscription that triggers a forced sign-out when its row gets revoked.
**Files:**
- Create: `apps/desktop/src/lib/deviceRowId.ts` — tiny localStorage helper
- Modify: `apps/desktop/src/context/AuthContext.tsx` — ensure-row effect + realtime subscription + `revokedRemotely` state
- [ ] **Step 1: Create the deviceRowId helper**
Create `apps/desktop/src/lib/deviceRowId.ts`:
```ts
// localStorage key for "the devices.id row that belongs to THIS install".
// Reset on memory-wipe (NOT preserved) — a wiped install is conceptually a
// fresh install, so registering a new row is correct.
const KEY = 'chatapp.deviceRowId.v1';
export function getDeviceRowId(): string | null {
try {
const v = window.localStorage.getItem(KEY);
return v && v.length > 0 ? v : null;
} catch {
return null;
}
}
export function setDeviceRowId(id: string): void {
try {
window.localStorage.setItem(KEY, id);
} catch {
/* quota */
}
}
export function clearDeviceRowId(): void {
try {
window.localStorage.removeItem(KEY);
} catch {
/* no-op */
}
}
```
- [ ] **Step 2: Add `revokedRemotely` to the AuthContext value + wire ensure-device effect**
Edit `apps/desktop/src/context/AuthContext.tsx`:
1. Extend the `AuthContextValue` interface with:
```ts
revokedRemotely: boolean;
acknowledgeRevocation: () => void;
```
2. Import the new helper + shared wrappers + supabase client (most of these are already imported; verify):
```ts
import { listOwnDevices, registerDevice, touchDeviceLastSeen } from '@chat-app/shared/auth';
import { clearDeviceRowId, getDeviceRowId, setDeviceRowId } from '../lib/deviceRowId';
```
3. Add a `const [revokedRemotely, setRevokedRemotely] = useState(false);` next to the other `useState`s in `AuthProvider`.
4. Add `acknowledgeRevocation` as a `useCallback` that just calls `setRevokedRemotely(false)`. Include it in the `value` memo + the deps array.
5. After the existing `void registerWebPush(installId);` effect, add an "ensure device row" effect:
```ts
// Phase 3: ensure this install owns exactly one devices row. The row is
// pure session-list telemetry — it does not carry any cryptographic
// material since the per-user-key refactor. We re-use the row across
// restarts via localStorage (chatapp.deviceRowId.v1); a memory-wipe is
// intentionally treated as "new install".
useEffect(() => {
if (!session) return;
let cancelled = false;
void (async () => {
try {
const existing = getDeviceRowId();
if (existing) {
const rows = await listOwnDevices(supabase);
const match = rows.find((r) => r.id === existing && r.revokedAt === null);
if (match) {
await touchDeviceLastSeen(supabase, existing).catch(() => {});
return;
}
// Row gone / revoked: drop the stale id and fall through to
// registering a fresh one.
clearDeviceRowId();
}
if (cancelled) return;
const hostname =
(typeof window.electronAPI?.getHostname === 'function'
? await window.electronAPI.getHostname().catch(() => null)
: null) ?? 'Desktop';
const created = await registerDevice(supabase, {
name: hostname.slice(0, 64),
platform: 'desktop',
});
if (!cancelled) setDeviceRowId(created.id);
} catch (err) {
console.warn('ensure device row failed', err);
}
})();
return () => {
cancelled = true;
};
}, [session]);
```
6. Add a "revocation realtime" effect AFTER the ensure-device effect:
```ts
// Phase 3: listen for own-device revocations. The same channel also fires
// when *another* of the user's installs is revoked — we ignore those (we
// only force-sign-out when OUR row's revoked_at flips). The UI's device
// list refetches independently via its own subscription in useOwnDevices.
useEffect(() => {
if (!session) return;
const userId = session.user.id;
const channel = supabase
.channel('devices:self:' + userId)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'devices',
filter: 'user_id=eq.' + userId,
},
(payload) => {
const ownId = getDeviceRowId();
const row = payload.new as { id?: string; revoked_at?: string | null } | null;
if (!row || !ownId) return;
if (row.id !== ownId) return;
if (row.revoked_at) {
setRevokedRemotely(true);
// Force-sign-out chain. signOut already wipes localStorage.
void signOut().catch((err) => {
console.warn('forced signOut after revoke failed', err);
});
}
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [session, signOut]);
```
Place this AFTER `signOut` is defined (which is the `useCallback` at line ~251). If a hoisting/order issue makes that awkward, define a `signOutRef = useRef<() => Promise<void>>()` updated by an effect and call `signOutRef.current?.()` inside the realtime callback — but try the direct approach first.
7. Add `revokedRemotely` + `acknowledgeRevocation` to the `value` object + deps array of its `useMemo`.
8. Do NOT reset `revokedRemotely` in `signOut` itself — the flag is only ever set by the realtime callback, and `acknowledgeRevocation` is the explicit reset path.
- [ ] **Step 3: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS. If a complaint about the supabase channel filter syntax appears, double-check it matches existing realtime subscriptions in the codebase (search `postgres_changes` for an example).
- [ ] **Step 4: Commit**
```bash
git add apps/desktop/src/lib/deviceRowId.ts apps/desktop/src/context/AuthContext.tsx
git commit -m "feat(P3.T4): ensure device row + revoke-realtime + revokedRemotely flag"
```
---
## Task 5: `useOwnDevices` hook — initial list + realtime refresh
**Why:** The Geräte tab needs a live, mutable list. Pulling once on mount means a freshly-revoked row stays visible until manual refresh; subscribing keeps the list in sync with reality.
**Files:**
- Create: `apps/desktop/src/hooks/useOwnDevices.ts`
- [ ] **Step 1: Write the hook**
Create `apps/desktop/src/hooks/useOwnDevices.ts`:
```ts
import { useCallback, useEffect, useState } from 'react';
import { type DeviceRecord, listOwnDevices, revokeDevice } from '@chat-app/shared/auth';
import { useAuth } from '../context/AuthContext';
import { supabase } from '../lib/supabase';
interface State {
devices: DeviceRecord[];
loading: boolean;
error: string | null;
}
export function useOwnDevices(): {
devices: DeviceRecord[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
revoke: (deviceId: string) => Promise<void>;
} {
const { session } = useAuth();
const userId = session?.user.id ?? null;
const [state, setState] = useState<State>({ devices: [], loading: true, error: null });
const refresh = useCallback(async () => {
try {
setState((s) => ({ ...s, loading: true, error: null }));
const list = await listOwnDevices(supabase);
setState({ devices: list, loading: false, error: null });
} catch (err) {
setState((s) => ({
...s,
loading: false,
error: err instanceof Error ? err.message : 'failed to load devices',
}));
}
}, []);
useEffect(() => {
if (!userId) {
setState({ devices: [], loading: false, error: null });
return;
}
void refresh();
const channel = supabase
.channel('devices:list:' + userId)
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'devices', filter: 'user_id=eq.' + userId },
() => {
void refresh();
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [userId, refresh]);
const revoke = useCallback(async (deviceId: string) => {
await revokeDevice(supabase, deviceId);
await refresh();
}, [refresh]);
return { devices: state.devices, loading: state.loading, error: state.error, refresh, revoke };
}
```
If `../lib/supabase` doesn't export the client under the name `supabase`, follow the convention used by neighboring hooks (e.g. `usePinnedMessages.ts`, `useMentionNotifications.ts`) for both the import and any path differences.
- [ ] **Step 2: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add apps/desktop/src/hooks/useOwnDevices.ts
git commit -m "feat(P3.T5): useOwnDevices hook with realtime refresh"
```
---
## Task 6: SettingsPage — new "Geräte" tab between Sicherheit and Konto
**Why:** This is the user-facing surface — a sortable list of installs with a Revoke button per row, the own install marked with a badge and a disabled button.
**Files:**
- Create: `apps/desktop/src/components/settings/DeviceListTab.tsx`
- Modify: `apps/desktop/src/pages/SettingsPage.tsx` (TabId union, tabs array, content panel)
If the directory `apps/desktop/src/components/settings/` does not already exist, create it. Check first with `ls apps/desktop/src/components/`.
- [ ] **Step 1: Build the tab component**
Create `apps/desktop/src/components/settings/DeviceListTab.tsx`:
```tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useOwnDevices } from '../../hooks/useOwnDevices';
import { getDeviceRowId } from '../../lib/deviceRowId';
import { SpinnerIcon } from '../icons';
function formatLastSeen(iso: string, locale: string): string {
try {
const d = new Date(iso);
return d.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' });
} catch {
return iso;
}
}
export function DeviceListTab() {
const { t, i18n } = useTranslation();
const { devices, loading, error, revoke } = useOwnDevices();
const ownId = getDeviceRowId();
const [busy, setBusy] = useState<string | null>(null);
const [opError, setOpError] = useState<string | null>(null);
const handleRevoke = async (id: string) => {
setOpError(null);
setBusy(id);
try {
await revoke(id);
} catch (err) {
setOpError(err instanceof Error ? err.message : 'revoke failed');
} finally {
setBusy(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-10 text-fg-muted">
<SpinnerIcon className="h-5 w-5" />
</div>
);
}
if (error) {
return (
<p className="rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-3 text-sm text-rose-600 dark:text-rose-300">
{t('app:settings.devices.error', { defaultValue: 'Geräteliste konnte nicht geladen werden.' })}
</p>
);
}
if (devices.length === 0) {
return (
<p className="text-sm text-fg-muted">
{t('app:settings.devices.empty', { defaultValue: 'Noch keine Geräte angemeldet.' })}
</p>
);
}
return (
<div className="space-y-3">
{opError && (
<p className="rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2 text-sm text-rose-600 dark:text-rose-300">
{opError}
</p>
)}
<ul className="space-y-2">
{devices.map((d) => {
const isOwn = d.id === ownId;
const isRevoked = d.revokedAt !== null;
return (
<li
key={d.id}
className="flex items-center gap-3 rounded-lg border border-line bg-surface-2 px-4 py-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-fg">{d.name}</span>
{isOwn && (
<span className="rounded-md bg-accent/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
{t('app:settings.devices.this_device', { defaultValue: 'Dieses Gerät' })}
</span>
)}
{isRevoked && (
<span className="rounded-md bg-rose-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-rose-600 dark:text-rose-300">
{t('app:settings.devices.revoked', { defaultValue: 'Abgemeldet' })}
</span>
)}
</div>
<p className="mt-0.5 text-xs text-fg-muted">
{d.platform} · {t('app:settings.devices.last_seen', { defaultValue: 'zuletzt' })}{' '}
{formatLastSeen(d.lastSeenAt, i18n.language)}
</p>
</div>
{isOwn ? (
<button
type="button"
disabled
title={t('app:settings.devices.use_sign_out', { defaultValue: 'Nutze Sign-out' })}
className="cursor-not-allowed rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted opacity-60"
>
{t('app:settings.devices.use_sign_out', { defaultValue: 'Nutze Sign-out' })}
</button>
) : isRevoked ? (
<span className="text-xs text-fg-muted">
{t('app:settings.devices.already_revoked', { defaultValue: '—' })}
</span>
) : (
<button
type="button"
onClick={() => void handleRevoke(d.id)}
disabled={busy === d.id}
className="cursor-pointer rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-wait disabled:opacity-60 dark:text-rose-300"
>
{busy === d.id
? t('app:settings.devices.revoking', { defaultValue: 'Wird abgemeldet…' })
: t('app:settings.devices.revoke', { defaultValue: 'Abmelden' })}
</button>
)}
</li>
);
})}
</ul>
</div>
);
}
```
- [ ] **Step 2: Wire the tab into SettingsPage**
In `apps/desktop/src/pages/SettingsPage.tsx`:
1. At the top with other imports, add:
```ts
import { DeviceListTab } from '../components/settings/DeviceListTab';
```
(Match the path style of nearby `../components/...` imports.)
2. Around line 118120, extend the `TabId` union to include `'devices'`:
```ts
type TabId =
| 'profile' | 'appearance' | 'privacy' | 'notifications'
| 'voice' | 'screen-share' | 'soundboard' | 'security' | 'devices' | 'account';
```
3. In the `tabs` array around line 122132, insert a new entry between `'security'` and `'account'`:
```ts
{ id: 'devices', label: t('app:settings.nav_devices', { defaultValue: 'Geräte' }), Icon: MonitorShareIcon },
```
`MonitorShareIcon` is already imported (line 128 uses it for screen-share). If that feels wrong stylistically, use an existing alternative such as `LockIcon` or whichever icon set the file uses — but do NOT add a new icon import; pick from what's already imported.
4. In the content panel (after the `activeTab === 'security'` block at line ~344 and before the `activeTab === 'account'` block at line ~355), add:
```tsx
{activeTab === 'devices' && (
<Section
title={t('app:settings.section_devices', { defaultValue: 'Geräte' })}
description={t('app:settings.section_devices_hint', {
defaultValue: 'Übersicht aller Geräte, die mit deinem Konto angemeldet sind.',
})}
>
<DeviceListTab />
</Section>
)}
```
- [ ] **Step 3: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add apps/desktop/src/components/settings/DeviceListTab.tsx apps/desktop/src/pages/SettingsPage.tsx
git commit -m "feat(P3.T6): Settings 'Geräte' tab with revoke + this-device badge"
```
---
## Task 7: RemoteRevokedScreen — overlay shown when revokedRemotely flips to true
**Why:** When the user (or another of their installs) revokes THIS install, we force `signOut()` which races against the realtime delivery + the route navigation. The user should see a clear "Du wurdest remote abgemeldet" screen — not just be silently bounced to /auth.
**Files:**
- Create: `apps/desktop/src/components/RemoteRevokedScreen.tsx`
- Modify: `apps/desktop/src/App.tsx` — render the overlay inside `AuthProvider`
- [ ] **Step 1: Build the overlay**
Create `apps/desktop/src/components/RemoteRevokedScreen.tsx`:
```tsx
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
export function RemoteRevokedScreen() {
const { t } = useTranslation();
const { revokedRemotely, acknowledgeRevocation } = useAuth();
if (!revokedRemotely) return null;
return (
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="remote-revoked-title"
className="fixed inset-0 z-[1000] flex items-center justify-center bg-ink-950/95 p-6"
>
<div className="max-w-sm rounded-2xl border border-line bg-surface-2 p-6 text-center shadow-xl">
<h2
id="remote-revoked-title"
className="mb-2 font-display text-xl font-semibold text-fg"
>
{t('app:auth.revoked_title', { defaultValue: 'Du wurdest remote abgemeldet' })}
</h2>
<p className="mb-5 text-sm text-fg-muted">
{t('app:auth.revoked_body', {
defaultValue:
'Ein anderes deiner Geräte hat diesen Login beendet. Aus Sicherheitsgründen wurden alle lokalen Daten gelöscht.',
})}
</p>
<button
type="button"
onClick={acknowledgeRevocation}
className="inline-flex cursor-pointer items-center justify-center rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-contrast transition hover:bg-accent/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
{t('app:auth.revoked_acknowledge', { defaultValue: 'Verstanden' })}
</button>
</div>
</div>
);
}
```
- [ ] **Step 2: Mount the overlay in App.tsx**
In `apps/desktop/src/App.tsx`:
1. Add the import at the top with other component imports:
```ts
import { RemoteRevokedScreen } from './components/RemoteRevokedScreen';
```
2. Inside the `<HashRouter>` block, alongside `<UpdateToast />` and `<CrashToast />` (right before `</HashRouter>`), add:
```tsx
<RemoteRevokedScreen />
```
- [ ] **Step 3: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add apps/desktop/src/components/RemoteRevokedScreen.tsx apps/desktop/src/App.tsx
git commit -m "feat(P3.T7): RemoteRevokedScreen overlay for own-device revocation"
```
---
## Final gate
- [ ] **Step 1: Run full typecheck across both packages**
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck`
Expected: both PASS.
- [ ] **Step 2: Run full shared test suite**
Run: `pnpm --filter @chat-app/shared test --run`
Expected: PASS — at least the previous 30 tests + the 3 new device tests added in T2 = 33 tests passing.
- [ ] **Step 3: Verify no uncommitted changes**
Run: `git status`
Expected: clean working tree on `main` (`apps/desktop/.env.local` is gitignored and may show in `git status --ignored`, that's fine).
- [ ] **Step 4: Report to user**
Report: "Phase 3 code-complete on `main`. New migration `20260516000005_device_revocation.sql` is committed locally but NOT yet pushed to prod — say the word and I run `bash scripts/prod/push-migrations.sh device_revocation` (filter pattern matches the new file). After the prod push, smoke test in dev: Settings → Geräte should list this install with the 'Dieses Gerät' badge. To test revoke, sign into a second install (or use prod CLI), revoke this install from the other one → this client should pop the 'Du wurdest remote abgemeldet' overlay and bounce to /auth. **No release** — version stays 0.18.8 until all 5 phases done."
---
## Self-review checklist (resolved inline)
1. **Spec coverage:**
- existing devices table → T1 reuses it
- Geräte tab between Sicherheit and Konto → T6
- name · platform · last_seen → T6 row layout
- "Dieses Gerät" badge → T6 isOwn branch
- revoked_at column → T1
- revoke_device RPC validates user_id = auth.uid() → T1
- realtime subscription on own devices → T4
- forced signOut + Memory-Wipe on own revoke → T4 (signOut already chains wipeLocalState)
- "Du wurdest remote abgemeldet" screen → T7
- own device's revoke button disabled, label "Nutze Sign-out" → T6 isOwn branch
2. **Placeholders:** none — every step has concrete code.
3. **Type consistency:**
- `DeviceRecord.revokedAt: string | null` introduced in T2; consumed by T5 (hook) and T6 (UI) consistently
- `revokeDevice(client, deviceId): Promise<void>` defined T2, used T5 (via hook) and indirectly T6 (via hook's `revoke`)
- `getDeviceRowId(): string | null` defined T4, used in T4 (AuthContext) + T6 (DeviceListTab)
- `revokedRemotely` + `acknowledgeRevocation` added to AuthContext value in T4, consumed by T7
4. **One ambiguity surfaced + resolved:** the spec says Realtime → forced signOut. We also need the *revoking* device (when revoking ANOTHER device) to NOT receive any extra signOut for itself — handled by T4's `row.id !== ownId` early return.
5. **Memory-wipe interaction:** the `chatapp.deviceRowId.v1` key is intentionally NOT added to `PRESERVE_LOCAL_STORAGE`, so a memory-wipe gets a fresh device row on next sign-in. That matches the spirit of memory-wipe = fresh install.
@@ -0,0 +1,284 @@
# Screen-Share UI Fixes (Hotfix)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix two visible bugs in the Discord-style screen-share viewer:
1. **Taskbar abgeschnitten** — when a sender shares their entire monitor, the receiver sees the bottom of the screen (Windows taskbar area) cut off.
2. **Top-right icon overflows its frame** — the small button at the top-right corner of the in-share strip (`StripToggleIcon` / participants-toggle) renders its 2-people SVG too large for its container, with no top/bottom padding.
**Architecture / cause analysis:**
**Bug 1** has TWO possible root causes that both need to be ruled in or out:
- *Sender side (most likely)*: `apps/desktop/src/lib/screenShareSettings.ts` defines fixed `dims` for every non-`auto` preset (e.g. `1080p30` = 1920×1080). `CallContext.tsx:1445-1459` passes those dims as `resolution: { width, height, frameRate }` to LiveKit's `setScreenShareEnabled`. LiveKit forwards them to Chromium's `getDisplayMedia` as exact constraints. When the sender's primary monitor isn't 16:9 (e.g. a common 1920×1200 laptop, a 2560×1600 16:10 panel, a vertical secondary monitor, or a HiDPI scaled display), Chromium *crops* the frame to match — chopping the bottom strip where the Windows taskbar lives. The `auto` preset (`dims: null`) is unaffected because it omits the constraints.
- *Receiver side*: `apps/desktop/src/components/InCallPanel.tsx:1326-1330` reserves `pb-28` (112px) at the bottom of the cinema-mode content area for the floating controls bar, and `ScreenShareViewer.tsx:99-113` uses `object-contain` on the `<video>`. The math already protects against overlap, but worth a visual sanity check during the smoke test.
**Bug 2** is in `apps/desktop/src/components/InCallPanel.tsx:1497-1524` — the `StripToggleIcon` button is `h-9 w-9` (36×36) wrapping an `<svg width="18" height="18">`. The SVG's `viewBox` is `0 0 24 24` but several of its paths actually reach beyond y=24 (`M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2` puts the bottom edge at y=23 and the top at y=11, but combined with `M16 3.13a4 4 0 0 1 0 7.75` the icon extends roughly y=3.13 to y=23 — almost the full viewBox, leaving ~1px padding). At 18×18 that translates to <1px visible breathing room. Combined with any container line-height or rounding, the icon appears to touch the frame edge. Fix: drop the explicit `width="18" height="18"` and let it inherit a Tailwind size class, OR shrink the icon to fit cleanly inside the 36px button.
**Tech Stack:** React 18 + TailwindCSS + LiveKit client. No backend changes.
**Non-goals:**
- Not redesigning the cinema-mode controls layout.
- Not adding a new "fit-to-monitor" preset option (could be a follow-up if cropping is widespread).
- Not changing audio-share behavior.
---
## Pre-flight
- [ ] **Verify clean working tree on `main`**
Run: `cd "D:\Programmieren\ChatApp-Electron\chat-app" && git status`
Expected: only Phase 3 commits ahead of last good baseline; no unrelated uncommitted changes.
---
## Task 1: Diagnose — confirm sender-side cropping vs receiver-side overlay for Bug 1
**Why:** Before changing the preset behavior we want to confirm the symptom matches the hypothesis. The user's screenshot shows the Windows taskbar visible-but-truncated; if the truncation is exactly the height difference between 1080 and the monitor's actual height, the sender-side hypothesis is confirmed.
**Files (read-only):**
- `apps/desktop/src/context/CallContext.tsx:1395-1470` — where the preset dims are passed to LiveKit
- `apps/desktop/src/lib/screenShareSettings.ts` — preset dims table
- `apps/desktop/src/components/InCallPanel.tsx:1320-1410` — cinema-mode layout for receiver
- [ ] **Step 1: Inspect the sender's current preset (in dev / chat with user)**
Ask the user: "Which screen-share preset is set on the sender's machine (Settings → Bildschirmfreigabe → Voreinstellung)? And what's their primary monitor resolution (right-click desktop → Anzeigeeinstellungen → Auflösung)?"
If preset is `auto` → sender-side hypothesis is FALSE; jump to Step 3 (receiver-side investigation).
If preset is anything else (e.g. `1080p30`) AND monitor resolution doesn't match the preset's aspect ratio → sender-side hypothesis is CONFIRMED; proceed to Task 2.
- [ ] **Step 2: Reproduce locally if access exists**
Set a known non-matching configuration:
- Settings → Bildschirmfreigabe → Voreinstellung = `1080p · 30 fps`
- On a monitor with native 1920×1200 (or any non-16:9) resolution
- Start a share with the user's account in a second window
- Observe: bottom of receiver's view should be the cropped band where the taskbar would be
Document the observed monitor resolution + preset in the commit message of Task 2.
- [ ] **Step 3: Inspect receiver-side cinema layout for any overlap regression**
Read `apps/desktop/src/components/InCallPanel.tsx` lines 1324-1485. Verify that:
- The content area has `pb-28` (line 1331 today)
- The control bar's wrapping div has `absolute bottom-4` (line 1528 today)
- `4 + ~80 (controls height) ≈ 84 < 112 (pb-28)` → no overlap
If the math still holds, the receiver-side path is clean and Bug 1 is purely sender-side.
**No commit for this task** — diagnosis only. Findings inform Task 2.
---
## Task 2: Fix — preset dims act as aspect-preserving maxima, not hard crops (Bug 1)
**Why:** The user expects "1080p" to mean *quality cap*, not *forced crop*. A non-16:9 monitor should still stream its entire surface, just scaled to fit within the preset's pixel budget.
**Files:**
- Modify: `apps/desktop/src/context/CallContext.tsx` (the `setScreenShareEnabled` call around line 1438)
Two viable approaches — Task 2 picks Option A; Option B is a fallback if A doesn't work in LiveKit.
### Option A (preferred): drop fixed dims, rely on bitrate cap only
LiveKit's screen-share publish accepts a `videoSimulcastLayers` / `screenShareEncoding.maxBitrate` knob independently of resolution. By only constraining frame rate + bitrate, we let the capture run at native resolution and downscale via encoder.
- [ ] **Step 1: Find the LiveKit screen-share publish call**
Read `apps/desktop/src/context/CallContext.tsx` around line 1438-1465 to confirm the current shape of the `setScreenShareEnabled` options object.
- [ ] **Step 2: Replace the `resolution.{width,height}` block with an aspect-preserving variant**
The current block (lines ~1445-1459):
```ts
...(ssParams.dims
? {
resolution: {
width: ssParams.dims.width,
height: ssParams.dims.height,
frameRate: fps,
},
}
: {
resolution: {
width: 3840,
height: 2160,
frameRate: fps,
},
}),
```
Change to (verify LiveKit option shapes against the current installed version with `pnpm list livekit-client` first — if `screenShareEncoding` isn't supported, fall back to Option B):
```ts
// Preset dims become a max-height cap that the encoder honours via the
// videoEncoding.maxBitrate + frameRate combo. We deliberately do NOT pass
// resolution.width/height to getDisplayMedia — Chromium treats those as
// exact constraints and CROPS non-matching monitors (e.g. 16:10 panels
// lose their bottom strip, including the Windows taskbar).
//
// Quality is still scoped to the preset via the bitrate ceiling configured
// elsewhere in screenShareSettings; we just stop forcing the geometry.
...(ssParams.dims
? {
resolution: {
frameRate: fps,
},
}
: {
resolution: {
frameRate: fps,
},
}),
```
If `resolution` requires at least one of `width`/`height` in the installed LiveKit type, pass only the height as `ideal` (not the width) — Chromium will then scale width to preserve aspect:
```ts
resolution: {
height: { ideal: ssParams.dims.height } as unknown as number,
frameRate: fps,
},
```
(The `as unknown as number` cast handles a LiveKit type that expects a plain number; the runtime accepts the MediaTrackConstraints object form because it's forwarded to `getDisplayMedia`.)
- [ ] **Step 3: Typecheck + smoke test plan**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS. If LiveKit's TS types reject the change, fall through to Option B below.
Smoke test sequence (user-driven; don't need to verify in code):
1. Set preset to `1080p · 30 fps`
2. Share entire primary monitor
3. Receiver sees the full monitor including the taskbar — no crop
4. Switch to a Windows window source → still shares fully
5. Switch preset to `auto` → still works
6. Switch preset to `720p · 30 fps` → bitrate drops, but no crop
### Option B (fallback): keep dims, switch to `ideal` constraints
If Option A doesn't typecheck, replace the resolution block with:
```ts
// `ideal` lets Chromium pick the closest match without cropping when the
// source's native resolution doesn't fit exactly. Using fixed width/height
// causes Chromium to crop non-matching monitors (e.g. 16:10 panels lose
// the bottom strip with the taskbar).
...(ssParams.dims
? {
resolution: {
width: { ideal: ssParams.dims.width } as unknown as number,
height: { ideal: ssParams.dims.height } as unknown as number,
frameRate: fps,
},
}
: {
resolution: {
frameRate: fps,
},
}),
```
- [ ] **Step 4: Commit (whichever option compiled)**
```bash
git add apps/desktop/src/context/CallContext.tsx
git commit -m "fix(screen-share): preset dims no longer crop non-16:9 monitors"
```
---
## Task 3: Fix — StripToggleIcon padding inside its frame (Bug 2)
**Why:** The participants strip-toggle button (`absolute right-5 top-5` in cinema mode) is `h-9 w-9` (36×36) with an 18×18 SVG whose path data fills nearly the entire viewBox, leaving no visible padding. The icon appears glued to the frame edges.
**Files:**
- Modify: `apps/desktop/src/components/InCallPanel.tsx` (the `StripToggleIcon` function around line 1542-1561)
- [ ] **Step 1: Shrink the icon's actual rendered size**
Read `apps/desktop/src/components/InCallPanel.tsx` lines 1542-1561 to confirm the current SVG shape.
Change the SVG element from:
```tsx
<svg
width="18"
height="18"
viewBox="0 0 24 24"
...
>
```
to:
```tsx
<svg
width="14"
height="14"
viewBox="0 0 24 24"
...
>
```
Rationale: 14px inside a 36px button leaves 11px of padding on each side (36 14 = 22, /2 = 11), matching the visual rhythm of other icon buttons in the same file (e.g. the `FullscreenIcon` in ScreenShareViewer uses `h-3.5 w-3.5` = 14px inside `h-6 w-6` = 24px = ~5px padding, but the cinema strip-toggle is on a larger 36px button and benefits from a similar ratio).
- [ ] **Step 2: Verify visually adjacent icon-buttons match the new ratio**
Skim the file for other `h-9 w-9` buttons and confirm their inner-icon sizes (`grep -n "h-9 w-9" apps/desktop/src/components/InCallPanel.tsx`). If most use ~14px icons, the change is in line; if they use 18px, leave 18 alone and instead fix the SVG's viewBox padding by widening it to `viewBox="-2 -2 28 28"` so the visible content sits in the middle:
```tsx
<svg
width="18"
height="18"
viewBox="-2 -2 28 28"
...
>
```
Pick whichever approach the file's existing aesthetic favours. Default to **Step 1's shrink-to-14** unless Step 2's grep proves 18 is the house norm.
- [ ] **Step 3: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS (this is a pure JSX change).
- [ ] **Step 4: Commit**
```bash
git add apps/desktop/src/components/InCallPanel.tsx
git commit -m "fix(call): strip-toggle icon no longer touches its frame edges"
```
---
## Final gate
- [ ] **Step 1: Typecheck both packages**
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck`
Expected: both PASS.
- [ ] **Step 2: Run shared tests (sanity — these changes don't touch shared, but confirm no flakes)**
Run: `pnpm --filter @chat-app/shared test --run`
Expected: PASS — same count as before this hotfix.
- [ ] **Step 3: Smoke test in dev (user-driven)**
User starts `pnpm dev`, runs a screen-share to a second account, and verifies:
- Taskbar visible at bottom of the streamed monitor (any preset)
- Strip-toggle button in cinema mode shows the 2-people icon with comfortable padding inside its 36px frame
Report back: "Hotfix code-complete. Restart dev, share your monitor at any preset — taskbar should now stream fully. Cinema-mode strip-toggle button should have visible padding around the icon. **No release** — version stays 0.18.8."
---
## Self-review
1. **Bug coverage:**
- Bug 1 (taskbar crop) → Task 2 (sender-side preset constraint change)
- Bug 2 (icon overflow) → Task 3 (SVG size adjustment)
- Diagnosis Task 1 confirms the root cause before applying fixes
2. **Placeholders:** none.
3. **Risk:** Option A in Task 2 changes the screen-share quality model — bitrate cap remains, but visual fidelity may improve OR encoder may push more bandwidth than before for the same preset name. Acceptable: the previous behaviour cropped content, which is strictly worse. The user can fall back to `720p` if bandwidth becomes an issue.
4. **Aspect-ratio interaction with low-bitrate presets:** `720p · 30 fps` previously enforced a 1280×720 max — a 4K monitor streamed under this preset now passes native 3840×2160 frames to the encoder, capped only by bitrate. The encoder will downscale to stay under the budget, but the receiver renders at the source resolution × encoder scale-factor. If this becomes a CPU/quality concern, re-introduce a height cap via Option B's `ideal` constraints (which Chromium honours preferring aspect ratio over exact match).
@@ -0,0 +1,304 @@
# Android White-Screen RCA — Design
**Date:** 2026-05-16
**Scope:** Diagnose and fix the white-screen-after-install symptom reported on Android for the `apps/mobile` (Expo SDK 52 / RN 0.76 / new architecture) build. Includes both an ordered diagnostic playbook and defense-in-depth changes we ship regardless of which hypothesis turns out to be the root cause, so a future regression of the same shape lands inside the ErrorBoundary rather than leaving users at a blank window.
**Status:** Approved by user (verbal, sections covered in brainstorming).
**Related:** [`2026-05-16-mobile-encryption-ux-port-design.md`](./2026-05-16-mobile-encryption-ux-port-design.md) — depends on the env-fix in this spec landing first.
## Problem
User-reported symptom: installing the Android build (likely a `preview` or `production` EAS build) produces a fully white screen after the launcher icon is tapped. No native crash, no recoverable error UI, no logs visible to the user. The desktop and dev builds work, so the failure is bound to Android release-mode bundling, the New Architecture toggle, or a module-eval throw before the React tree mounts.
The current `ErrorBoundary` (`apps/mobile/components/ErrorBoundary.tsx`) is mounted inside `_layout.tsx`. Any throw *before* `_layout.tsx`'s default export runs — including throws from `import` side effects — bypasses it entirely. The empty splash screen lingers, then React mounts nothing, leaving a white window.
## Goals
- Identify the root cause empirically by running an ordered diagnostic playbook on a real Android build.
- Land a defense-in-depth patch that ensures future boot-time errors are visible to the user, not silent.
- Restore Android installability of `preview` and `production` profile builds.
## Non-Goals
- Adding native error reporting (Sentry, Bugsnag). Tracked separately.
- A general refactor of bootstrap order. Touches stay minimal and surgical.
- Diagnosing iOS bootstrap issues (no symptom reported there; the hardening here helps iOS regardless).
## Hypotheses (ordered by likelihood)
Confidence is judged from static evidence: file content, package.json, eas.json, manifest, and the boot-order of module imports.
### H1 — Missing `EXPO_PUBLIC_*` env vars in the built APK *(highest confidence)*
`apps/mobile/lib/env.ts`:
```ts
function required(name: string): string {
const v = process.env[name];
if (!v || v.length === 0) {
throw new Error('Missing required env var ' + name + '. ...');
}
return v;
}
export const env = {
supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'),
supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'),
// ...
};
```
The `export const env = { ... }` evaluates the moment any importer reaches this module. `_layout.tsx``AuthProvider``supabase.ts``env.ts`. So this runs at app boot, **before** the React tree mounts, **before** the `ErrorBoundary` exists.
Expo only inlines `EXPO_PUBLIC_*` from `.env` files when bundling locally with `expo start`. EAS Build does **not** read `.env.local`. The contract is that the project's `eas.json` either declares an `env` block per profile or relies on EAS Secrets created via `eas secret:create`. Today's `apps/mobile/eas.json` has no `env` keys in any profile.
→ The APK is shipped with `process.env.EXPO_PUBLIC_SUPABASE_URL === undefined``required()` throws → module-eval failure → React never mounts → white screen.
The Android-only framing is incidental: the user simply hasn't tested iOS in this configuration yet; iOS would white-screen too with the same APK contents.
### H2 — `newArchEnabled: true` + an incompatible native lib *(medium-high confidence)*
`app.json` sets `"newArchEnabled": true`. Several native deps installed are not yet uniformly bridgeless / Fabric-ready as of SDK 52:
- `react-native-libsodium` ^1.3.0
- `@livekit/react-native-webrtc` ^144.0.0
- `@config-plugins/react-native-webrtc` ^10.0.0
- `@livekit/react-native` ^2.10.3
- `react-native-gesture-handler` ^2.20.2 (generally fine but historically a culprit on Android Fabric)
If any of these crashes at JNI link time, the JS bundle never runs — fully white window because the Android shell waits for the JS thread to send its first frame.
### H3 — `crypto.setCryptoBackend(createLibsodiumBackend())` at module top-level *(medium confidence)*
`apps/mobile/app/_layout.tsx` line 13 calls `crypto.setCryptoBackend(createLibsodiumBackend())` at module-eval. `createLibsodiumBackend` reads constants like `s.crypto_box_NONCEBYTES`. If `react-native-libsodium`'s native module isn't autolinked (pnpm symlinking + prebuild without an explicit pod / Gradle entry occasionally produces this), `s.crypto_box_NONCEBYTES` is `undefined`. That alone doesn't throw, but the backend object then carries `nonceLength: undefined`. A later code path that reads `nonceLength` and calls `randomBytes(undefined)` throws asynchronously and either produces a red box (dev) or a silent failure (release).
Weaker hypothesis on its own — usually masked by H1 or H2 — but worth ruling out.
### H4 — Module-eval side effects in `@chat-app/shared` *(low-medium confidence)*
`packages/shared/src/crypto/userKey.ts` does `import sodium from 'libsodium-wrappers-sumo'`. If anything in the mobile entrypoint reaches into the shared `crypto` index, libsodium-wrappers-sumo's module body runs in Hermes. Sumo is the "compatibility build" and is known to fail to instantiate on Hermes; even on success its global side effects (`globalThis.crypto`) can collide.
Resolved as part of the mobile-encryption spec (the `CryptoBackend` extension removes the direct `libsodium-wrappers-sumo` dependency from shared). Diagnostic-only entry here.
### H5 — Asset / splash path or manifest issue *(low confidence)*
`app.json` references `./assets/icon.png`, `./assets/adaptive-icon.png`, `./assets/splash.png`. If any of these were lost during a `git mv` or rename, EAS Build would still succeed but the Android launcher could choke. Symptom would more likely be "cannot install" or a missing icon, not pure white, so this is the lowest-likelihood branch.
## Architecture
### Diagnostic playbook (the actual investigation)
Order matters — each step rules out a hypothesis with a minimum-cost action.
**Step 0 — Capture logs.** With the user-reported APK on a connected Android device:
```bash
adb logcat -c
adb logcat *:E ReactNative:V ReactNativeJS:V
# launch the app
```
Triage the first 50 lines for `Error`, `Exception`, `FATAL`. The matching hypothesis determines which fix below to apply first.
**Step 1 — Validate H1.** Even without logs, this is mechanically falsifiable:
```bash
cd apps/mobile
npx expo export --platform android --dev false --output-dir /tmp/expo-android-export
grep -r "Missing required env var" /tmp/expo-android-export/_expo/static/js || true
grep -r "EXPO_PUBLIC_SUPABASE_URL" /tmp/expo-android-export/_expo/static/js || true
```
If "Missing required env var" appears as a literal in the bundle (it will, because it's a thrown Error string), and a Supabase URL string does not appear, H1 is confirmed.
**Step 2 — Validate H2.** Toggle `newArchEnabled: false` in `app.json`, `eas build --profile preview --platform android`, install, retest. If the white-screen disappears, H2 holds. (Do not ship with new-arch off; the fix is to upgrade or replace the incompatible lib, not to permanently disable new-arch.)
**Step 3 — Validate H3.** With the env fix in place (or temporarily hardcoded values), wrap the `crypto.setCryptoBackend(...)` call in `try/catch` that surfaces to a fallback `<View>` (see "Defense-in-depth"). If the fallback now renders, H3 was real.
**Step 4 — Validate H4.** Only after H1 / H2 / H3 are eliminated. Run the bundle through `metro` with verbose logging; look for `libsodium-wrappers-sumo` in the trace. The mobile-encryption port spec removes this risk structurally.
**Step 5 — H5 sweep.** `ls apps/mobile/assets` — confirm every path in `app.json` resolves to an actual file.
### Fixes per hypothesis
**Fix H1 — Wire env vars into EAS builds (REQUIRED — ship regardless of which RCA hypothesis confirms).**
Two acceptable paths:
1. **EAS Secrets (recommended for production).**
```bash
eas secret:create --scope project --name EXPO_PUBLIC_SUPABASE_URL --value 'https://<project>.supabase.co'
eas secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value 'sb_publishable_...'
eas secret:create --scope project --name EXPO_PUBLIC_AUTH_REDIRECT_URL --value 'netralax://auth/callback'
```
No `eas.json` change needed; secrets propagate automatically to all profiles. **This is the path chosen for this release.**
2. **`eas.json` `env` block (rejected — kept here only as a reference for future profiles where Secrets are not yet provisioned).**
```json
{
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"env": {
"EXPO_PUBLIC_SUPABASE_URL": "https://<project>.supabase.co",
"EXPO_PUBLIC_SUPABASE_ANON_KEY": "sb_publishable_...",
"EXPO_PUBLIC_AUTH_REDIRECT_URL": "netralax://auth/callback"
},
"ios": { "simulator": true },
"android": { "buildType": "apk" }
},
"preview": { "...": "same env block" },
"production": { "...": "same env block" }
}
}
```
Pick one consistently across profiles.
**Fix H2 — newArchEnabled gating.** Identify the incompatible lib. Probable suspect order:
1. `@livekit/react-native-webrtc` — verify against the lib's CHANGELOG that the installed major version declares Fabric support.
2. `react-native-libsodium` — same check.
If a lib is not yet new-arch-ready, the temporary fix is `"newArchEnabled": false` in `app.json` and to file an issue upstream. The permanent fix is an upgrade or replacement (`react-native-sodium-jsi`, `op-sqlite`-style native modules).
**Fix H3 — Defer crypto backend init to React lifecycle.** Move the `crypto.setCryptoBackend(createLibsodiumBackend())` call out of module top-level into a `useEffect` inside an `<AppBootstrap>` boundary. While the backend is initialising, render an `ActivityIndicator`; on failure, render a fallback `<View>` with the error text. This makes any constants-undefined failure user-visible rather than silent. (Also fulfilled by the mobile-encryption spec.)
**Fix H4 — Backend extension (covered by mobile-encryption spec).** Once shared no longer imports `libsodium-wrappers-sumo` at module level, this risk disappears.
**Fix H5 — Repair asset paths.** If a file is missing, `git mv` it back to the path declared in `app.json` or update `app.json` to match.
### Defense-in-depth (ship regardless of root cause)
These changes ship as part of this spec because they harden bootup against any future boot-time throw of the same shape. None of them is a workaround for the actual RCA — they ensure the next failure produces a readable screen, not a white one.
1. **Lazy `env`.** Convert `apps/mobile/lib/env.ts` from `export const env = {...}` to a lazy proxy:
```ts
function required(name: string): string {
const v = process.env[name];
if (!v || v.length === 0) {
throw new Error('Missing required env var ' + name + '. Set it via EAS Secret or eas.json env block.');
}
return v;
}
type EnvShape = {
supabaseUrl: string;
supabaseAnonKey: string;
authRedirectUrl: string;
};
function readEnv(): EnvShape {
return {
supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'),
supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'),
authRedirectUrl: process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL ?? 'netralax://auth/callback',
};
}
let cached: EnvShape | null = null;
export const env = new Proxy({} as EnvShape, {
get(_t, key) {
cached ??= readEnv();
return cached[key as keyof EnvShape];
},
});
```
Effect: missing env vars throw the first time someone reads `env.supabaseUrl`, which happens inside React, where `<ErrorBoundary>` is mounted and can render the message.
2. **Bootstrap boundary.** `apps/mobile/app/_layout.tsx` introduces `<AppBootstrap>`:
```tsx
function AppBootstrap({ children }: { children: ReactNode }) {
const [error, setError] = useState<Error | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
try {
crypto.setCryptoBackend(createLibsodiumBackend());
setReady(true);
} catch (e) {
setError(e instanceof Error ? e : new Error(String(e)));
}
}, []);
if (error) return <BootError error={error} />;
if (!ready) return <BootSplash />;
return <>{children}</>;
}
```
`BootError` is a minimal `<View>` with the message + a dump of `process.env.EXPO_PUBLIC_SUPABASE_URL ? 'env-ok' : 'env-missing'` so future white-screen reports can be triaged in one screenshot.
3. **Global JS error handler.** Inside the same boundary, register a fallback for unhandled errors that escape every React-level boundary:
```ts
useEffect(() => {
const prev = ErrorUtils.getGlobalHandler();
ErrorUtils.setGlobalHandler((err, isFatal) => {
prev?.(err, isFatal);
setError(err);
});
return () => ErrorUtils.setGlobalHandler(prev);
}, []);
```
This catches throws that happen during e.g. lazy `env` reads in render paths and surfaces them. Negligible runtime overhead.
4. **`SecureStore` smoke probe.** Optional, cheap: a `useEffect` that calls `SecureStore.isAvailableAsync()` and reports failure in the same `BootError` path. Helps catch the rare Android profile where secure storage is disabled.
5. **`.env.local` parity check (lint).** Add a `npm run check:env` script that compares `.env.example` against `.env.local` to surface missing keys in dev. Cheap insurance against the same class of bug recurring during onboarding.
## Data Flow
This spec changes no data flow. The runtime data flow remains: env vars in → React mounts → AuthProvider → screens. The only structural change is *when* `env` is read (lazily, inside React) and *where* crypto backend init runs (inside a React effect).
## Components Touched
| File | Change |
|------|--------|
| `apps/mobile/eas.json` | Add `env` blocks (or document EAS Secret names) per profile. Ship as part of this spec. |
| `apps/mobile/lib/env.ts` | Convert to lazy proxy; preserves the public surface. |
| `apps/mobile/app/_layout.tsx` | Introduce `<AppBootstrap>` + `<BootError>` + `<BootSplash>`; move `setCryptoBackend` call into the bootstrap effect; install global JS error handler. |
| `apps/mobile/components/BootError.tsx` | NEW. Minimal fallback that renders the error message + env-diagnostic line. |
| `apps/mobile/components/ErrorBoundary.tsx` | No change in behaviour; remains the per-screen boundary. |
| `apps/mobile/README.md` | Add an "EAS env" section pointing at `eas secret:create` / `eas.json env`. |
| `apps/mobile/package.json` | Optional `check:env` script. |
## Error Handling
| Case | Behaviour |
|------|-----------|
| Missing `EXPO_PUBLIC_*` at runtime | `BootError` renders with the specific variable name and the env-missing diagnostic. |
| Crypto backend constants undefined | `BootError` renders; user sees "Crypto-Backend konnte nicht geladen werden" + the underlying message. |
| `SecureStore` unavailable | `BootError` with explicit hint; app does not boot further. |
| Global unhandled JS error | Global handler routes to `BootError` (or whatever screen is currently mounted, via the per-screen `ErrorBoundary`). |
| All H-fixes applied; new bug appears | The defense-in-depth path catches it; we get a stack instead of a white screen. |
## Testing
**Manual (the real validation — pre-merge):**
1. With env block / EAS Secret in place: `eas build --profile preview --platform android`. Install. App opens to login screen.
2. With env block intentionally removed locally: `npx expo run:android --no-bundler-reload` — the `BootError` view must render with "Missing required env var EXPO_PUBLIC_SUPABASE_URL". No white screen.
3. Crypto backend simulated failure: temporarily stub `createLibsodiumBackend` to throw; verify `BootError` shows the underlying message.
4. Global handler smoke: place a `throw new Error('boom')` inside `setTimeout(..., 100)` in `_layout.tsx`; verify it surfaces.
**Automated (cheap, ship with):**
- `apps/mobile/lib/env.test.ts` — lazy proxy returns env var when set; throws on first read when missing; subsequent reads memoise.
- `apps/mobile/components/BootError.test.tsx` — renders message + env diagnostic.
## Decisions (resolved at brainstorming user-review gate)
- **EAS Secret over `eas.json env`.** All `EXPO_PUBLIC_*` variables ship via `eas secret:create --scope project`. `eas.json` is not touched for env wiring. Rationale: keeps `eas.json` future-proof for non-public values; avoids accidental commits.
- **newArchEnabled rollback acceptable.** If H2 confirms, we ship with `"newArchEnabled": false` while the offending lib is upgraded or replaced. Re-enable in a follow-up release.
## Out of Scope (future work)
- Sentry / Bugsnag integration so future regressions are auto-reported.
- Migrating off `@livekit/react-native-webrtc` if it turns out to be the new-arch blocker.
- iOS-specific bootstrap hardening (no symptom reported there; the changes here help iOS anyway).
- A `doctor`-style CLI command that lints `.env.local` against `.env.example` and `eas.json`.
@@ -0,0 +1,199 @@
# Fifteen-Features Initiative — Design
**Date:** 2026-05-16
**Scope:** Desktop only (Electron). Mobile is out of scope.
**Status:** Approved by user (sections 16).
## Problem
The desktop app has reached a stable point (encryption rolled out, Settings refactored). The user identified fifteen feature gaps to close as one coordinated initiative — quality fixes, messaging power, security UI, creative tools, and shared activities. Releasing each piecemeal would mean nine bumps and nine test cycles; instead this initiative ships as one `0.19.0` after a single end-to-end smoke pass.
## Goals
- Close all fifteen features without a release until the user signs off.
- Fix the global-shortcut capture bug that currently breaks every keystroke after a hotkey is set.
- No surprise releases — every commit is `git commit` only, no `pnpm release`. Version stays on `0.18.8` throughout, bumps to `0.19.0` once.
- Each phase ends with a clean typecheck + green shared tests.
## Non-Goals
- Mobile / React Native port of any of these features.
- Twitch in Watch-Together (YouTube only for the MVP).
- Chess or other mini-games beyond Tic-Tac-Toe + Vier-Gewinnt.
- Operational Transform / CRDT for the Whiteboard (snapshot model only).
- Live video preview or speaking indicators in the existing CallPreviewPanel.
- Hot-patch releases between phases.
## Phasing
| Phase | Theme | Features | Rough Effort |
|-------|-------|----------|--------------|
| 1 | Quality & Fixes | Hotkey-Bug Fix, Tray-Badge Audit, Empty-States, Friend-Nicknames, Memory-Wipe | 34 days |
| 2 | Messaging | Pinned Messages, Mentions-Notifications, GIF-Picker, View-Once Media | ~1 week |
| 3 | Security & Devices | Session/Device List + Revoke | 23 days |
| 4 | Creative Tools | Image-Annotation, Soundboard Cloud-Sync, Whiteboard (Snapshot-Sync) | ~1.5 weeks |
| 5 | Activities | Watch-Together (YouTube), Mini-Games (Tic-Tac-Toe + 4-in-a-Row) | ~1.5 weeks |
Phases run sequentially. Within a phase, features are independent and can be implemented in any order.
## Architecture (per feature)
### Phase 1 — Quality & Fixes
**Hotkey-Bug Fix.** The current code (`apps/desktop/src/lib/globalShortcut.ts`) registers every hotkey through Electron's `globalShortcut` API, which captures system-wide. Setting "M" as Mute means "M" can't be typed anywhere on the OS while the app is running. Fix: split into two registration modes.
- **Window-scoped (default):** DOM `keydown` listener on `window`, only fires while the app has focus. Used for mute, deafen, hangup, screen-share, video toggle.
- **Global (opt-in):** Keep `globalShortcut.register()` for PTT and any hotkey the user explicitly toggles "Global" on.
UI change: every hotkey row in Settings → Voice → Hotkeys gets a `🌐 Global`-toggle next to the key-capture button. Default off.
**Tray-Badge Audit.** Tray code already exists (`electron/modules/tray.ts`, `lib/trayBadge.ts`, called from `ConversationsContext`). Verify the count is actually pushed and the badge renders on Windows. Likely a wiring or icon-loading bug; spec-time fix scope unknown until investigated.
**Empty-States.** Four locations: empty chat list, empty friends list, empty conversation (just-created with no messages), empty search results. Each gets an illustration (SVG, light/dark variant), a heading, a description, and a primary action button (e.g., "Friend einladen", "Erste Nachricht schreiben"). Reuses existing icon palette.
**Friend-Nicknames.** Local-only override stored in `localStorage` keyed by `userId`. Surfaces wherever a profile name is shown: chat header, message bubble sender, friends list, mention autocomplete, call participant tile. UI: right-click friend in friends list → "Spitzname setzen" → input modal. Empty = use real display name.
**Memory-Wipe.**
- *Always on sign-out:* clear Stronghold (`chatapp.userpriv.*`), IndexedDB (soundboard, message-cache), SQLite cache, conv-key in-memory cache, `localStorage` except theme + locale + installId.
- *Configurable on app-close (Settings → Security → "Cache beim Schließen leeren"):* hooks `before-quit` in main process → IPC to renderer → wipe → quit. Next start requires PIN.
### Phase 2 — Messaging
**Pinned Messages.**
- DB: `pinned_messages (conversation_id uuid, message_id uuid, pinned_by uuid, pinned_at timestamptz, PK(conversation_id, message_id))`. CHECK via trigger: max 5 pins per conversation.
- RLS: any accepted conv-member can `SELECT` / `INSERT` / `DELETE`.
- UI: right-click message → "Anpinnen" / "Anheftung aufheben". Conv header shows compact "📌 N angepinnt"-pill; click opens a right-side panel with the pinned list; clicking an item scrolls to + flashes the original message.
- Realtime: subscribe `pinned_messages:<conv_id>` channel for live updates.
**Mentions-Notifications.**
- `sendEncryptedMessage` parses plaintext for `@<username>` tokens before encrypting. Username → user_id lookup against `conversation_members` joined with `profiles`. Bulk-insert into new `message_mentions (message_id, mentioned_user_id, PK both)`.
- RLS: mentioned user (and message author) can SELECT.
- Realtime: each client subscribes `message_mentions WHERE mentioned_user_id = me`. On insert → fire web/desktop notification with `[@] <sender>: <decrypted snippet>` and the configured mention sound. Bypasses per-conv mute.
- Per-conv setting "Nur bei @Mentions benachrichtigen" (stored alongside the existing mute setting).
**GIF-Picker.**
- Provider: Tenor v2 (`https://tenor.googleapis.com/v2/search`, no per-user key — public key passable). Trending + Search + Recent (localStorage of last 24 URLs).
- New component `<GifPicker>` Popover anchored to the composer's GIF button (next to attachment plus).
- On click: resolved GIF URL is fetched once, uploaded as a normal attachment (mime `image/gif`) so it lives in the same per-conv attachment storage and is end-to-end-encrypted like any other image. Trade-off: a sent GIF doesn't stay free — it counts as an attachment (~few MB).
**View-Once Media.**
- Per-attachment flag `view_once boolean` (new column on `message_attachments`).
- Sender UI: attachment-picker toggle "👁 Einmal ansehen".
- Recipient UI: bubble shows blurred lock-overlay; tap → fullscreen view; close → bubble flips to "👁 Angesehen, von <time>". Storage object is deleted server-side via RPC `mark_attachment_viewed(p_attachment_id)` triggered atomically with the view.
- Auto-purge: Supabase cron deletes view-once attachments untouched after 7 days.
- Sender sees the same "Angesehen"-bubble update (so they know it was opened).
### Phase 3 — Security & Devices
**Session/Device List + Revoke.**
- Uses the existing `devices` table (telemetry only since the 0.18 encryption refactor).
- New SettingsPage tab "Geräte" between Sicherheit and Konto. Lists `name · platform · last_seen` per device, "Dieses Gerät"-badge for the local install.
- New column `devices.revoked_at timestamptz NULL`. Revoke RPC `revoke_device(p_device_id uuid)`: requires `device.user_id = auth.uid()`, sets `revoked_at = now()`.
- Realtime: every client subscribes `devices:user_id=me`. On its own `revoked_at` change → forced `signOut()` + Memory-Wipe + screen "Du wurdest remote abgemeldet".
- The own device's revoke button is disabled in the list (label: "Nutze Sign-out").
### Phase 4 — Creative Tools
**Image-Annotation vor Send.**
- Attachment-picker for images shows a new "✏ Bearbeiten" button before send.
- Opens `<ImageAnnotator>` modal: canvas overlay on the image. Tools: pen, arrow, rectangle, circle, text, highlighter. 6 colors, 3 stroke widths. Undo/Redo stack, Reset, Save.
- On Save: `canvas.toBlob({type: 'image/png'})` flattens the annotation into the original, replaces the upload-queue entry.
- Custom implementation (~400 LOC). No external dep (tldraw etc. would be overkill).
**Soundboard Cloud-Sync.**
- New table `user_soundboards (id, user_id, name, mime, size, category, hotkey, gain, sort_order, storage_path, created_at, updated_at)`.
- Storage: bucket `soundboards/<user_id>/<sound_id>.bin`. Audio payload is encrypted client-side with the user's existing per-user X25519 key (reuses `crypto/box`). Server only sees ciphertext.
- Local IndexedDB stays the working store; sync is bidirectional.
- Sync engine: on app start + on every local edit (debounced 500ms), diff IndexedDB ↔ remote → upload new/changed, download remote-newer. Realtime subscription on own `user_soundboards` rows triggers pulls when another device of the user edits.
- Conflict resolution: last-write-wins by `updated_at`. UI shows "☁ Sync OK / ↑ Hochladen…/ ⚠ Konflikt" badge per sound.
**Whiteboard (Snapshot-Sync).**
- New tables `conversation_whiteboards (id, conversation_id, owner_user_id, created_at)` and `whiteboard_strokes (id, whiteboard_id, author_user_id, stroke_json jsonb, created_at)`.
- UI: composer menu → "✏ Whiteboard" → starts a new whiteboard inline in the chat as a bubble (preview + author + "Öffnen"-button). Click → fullscreen modal with canvas.
- Tools: pen, eraser, 6 colors, 3 widths. "Clear all" with confirm dialog.
- Each pen stroke (on pointerup) inserts a row into `whiteboard_strokes`. Realtime subscription on `whiteboard_id` renders incoming strokes from other authors. Strokes are JSON: `{tool, color, width, points: [[x, y, t], …]}`.
- Whiteboard is permanent — bubble in the chat stays, history is preserved, anyone in the conv can reopen it later.
### Phase 5 — Activities
**Watch-Together (YouTube).**
- New table `conversation_watch_sessions (id, conversation_id, owner_user_id, video_id text, started_at, ended_at NULL, current_state jsonb)`. `current_state = { playing: bool, position_seconds: number, updated_at_ms: number }`.
- Composer → "📺 Watch Together" → modal with URL input → extract video-id (regex on YouTube URL formats) → INSERT row → inline bubble "Anna hat Watch-Together gestartet · [Beitreten]".
- Click Beitreten → fullscreen modal with YouTube IFrame Player API. Controls (play/pause/seek) by owner are broadcast via the session row's `current_state` update. Other clients reconcile their local player when drift > 2 seconds.
- IFrame API loaded lazily via `<script src="https://www.youtube.com/iframe_api">`. No build-time dep.
- Auto-end after 12h inactivity (cron). Owner-leave sets `ended_at`. Non-owner leave keeps session open for re-join.
**Mini-Games (Tic-Tac-Toe + Vier-Gewinnt).**
- Shared infrastructure for both:
- New table `conversation_games (id, conversation_id, game_type text, state jsonb, players jsonb, current_turn_user_id, winner_user_id NULL, created_at, finished_at NULL)`.
- Move RPC `game_make_move(p_game_id uuid, p_move jsonb)`. Server-authoritative state machine validates and applies the move (no client trust). Returns updated state.
- Realtime subscription on the game row renders state.
- Tic-Tac-Toe: 3×3 board, cell index 08, server validates cell-is-empty + correct turn + win line detection (8 winning lines).
- Vier-Gewinnt: 7×6 board, column 06 with gravity, server validates column-not-full + correct turn + 4-in-row detection (horizontal/vertical/2 diagonals).
- UI: composer → "🎮 Spiel starten" → submenu (Tic-Tac-Toe / Vier-Gewinnt) → inline invite bubble. Recipient sees "Annehmen"-button. Both then see a ~250×250 board in their bubble. Click a valid cell/column = RPC move. Turn indicator + winner reveal with confetti animation (`canvas-confetti`).
- Scope: 1v1 only (2-player games). Group games out of scope.
## Data Flow Highlights
- **No race conditions on mini-game moves**: server RPC is atomic via `BEGIN; SELECT ... FOR UPDATE; validate; UPDATE; COMMIT;`. Last-write loses.
- **No race on watch-together state**: owner-only writes. Other clients always reconcile from `current_state.updated_at_ms`.
- **No race on whiteboard**: strokes are append-only; concurrent strokes from multiple authors interleave naturally.
- **Mentions on edit**: when a message is edited, the `message_mentions` rows for that message are recomputed (delete-then-insert in the same RPC).
## Error Handling
| Case | Behavior |
|------|----------|
| Hotkey conflict (same accelerator double-bound) | Capture-modal shows "Bereits belegt von <Funktion>". Save disabled until user picks something else. |
| Tray-badge fails to render on Linux | Silent fallback to text-only tooltip; only Windows has overlay-icon API. |
| Friend has no nickname | Falls back to display_name. |
| Memory-wipe IPC times out at quit | Force-exit anyway after 2s timeout; user accepts ephemeral data may remain in OS swap. |
| Pinned-message limit reached (5) | "Anpinnen" action shows toast "Max 5 angepinnt — heften Sie eine andere ab". |
| Mention notification fires for a muted device | Always shows (mention overrides mute). |
| Tenor API down | GIF-Picker shows "GIFs gerade nicht verfügbar". |
| View-once attachment opened concurrently by two devices of recipient | First view RPC wins; second sees "Bereits angesehen". |
| Watch-together: video deleted from YouTube | Player surfaces YouTube's own error UI inline. |
| Mini-game: opponent disconnects mid-game | Game stays open. On reconnect (refresh) the latest state is fetched. After 24h auto-finishes as draw. |
| Whiteboard stroke insert fails | Stroke stays local; UI shows a "↻ Retry"-badge. |
## Testing
**Per phase:** typecheck (`pnpm --filter @chat-app/shared typecheck` + `pnpm --filter @chat-app/desktop typecheck`) and shared tests (`pnpm --filter @chat-app/shared test`) must be green.
**Final smoke list (user runs after Phase 5 completes):**
1. Hotkey "M" as Mute, default (window-scoped). Type "m" in Composer → letter appears, mute doesn't fire.
2. Hotkey "M" toggled to Global → "m" outside the app triggers mute.
3. Friend sends DM → tray-badge shows 1; click opens app focused.
4. Empty chat list → "Friend einladen"-button visible + functional.
5. Right-click friend → set nickname "Bestie" → chat header shows "Bestie".
6. Sign-out → Storage Inspector shows no Stronghold key, no user-priv, no conv-key cache.
7. Memory-Wipe toggle on → close app, reopen → PIN required.
8. Pin a message → conv header shows "📌 1 angepinnt" → click → side panel lists it.
9. Friend writes "@mich" → notification with [@] prefix + mention sound, even on muted conv.
10. GIF-Picker → search "cat" → click → GIF sent in chat.
11. View-once image sent → recipient opens once → bubble shows "Angesehen", image is gone.
12. Settings → Geräte → revoke another device → that device is force-signed-out.
13. Send image → "Bearbeiten" → draw arrow → send → arrow is in the final image.
14. Add soundboard sound on device A → device B (same user, different install) → sound appears.
15. Open whiteboard → friend draws → strokes appear within 1s.
16. Start Watch-Together with YouTube URL → friend joins → owner pauses → friend's player pauses.
17. Start Tic-Tac-Toe → friend accepts → alternate moves → win animation.
18. Start Vier-Gewinnt → play → 4-in-row detection correct.
## Release Policy
- **No `pnpm release` calls** during Phases 15. Only `git commit` and `git tag` (if any) — none of which trigger upload.
- Version stays on `0.18.8` throughout. The migration files and feature commits accumulate on `main`.
- After the user signs off on the smoke list, a single `pnpm release 0.19.0 "<combined notes>"` bumps and ships everything.
## Out of Scope (future)
- Mobile equivalents of any of these.
- Twitch, Vimeo, or self-hosted video in Watch-Together.
- Chess, Pong, or other mini-games beyond the two.
- Operational-transform / CRDT for Whiteboard (post-MVP if collaboration friction shows).
- Cross-conversation pinning / global pin list.
- Mention auto-suggest improvements (existing `MentionAutocomplete` is fine).
- Skin-tone reaction picker.
- Voice-message transcription.
@@ -0,0 +1,285 @@
# Mobile Encryption-UX Port — Design
**Date:** 2026-05-16
**Scope:** Bring `apps/mobile` (Expo / React Native) to feature parity with the desktop user-key + PIN model shipped in v0.18.x. Touches the mobile app, the shared crypto-backend abstraction, and the mobile build config. Server schema and RPCs are unchanged — the desktop spec already migrated them.
**Status:** Approved by user (verbal, sections covered in brainstorming).
**Related:** [`2026-05-15-encryption-ux-simplification-design.md`](./2026-05-15-encryption-ux-simplification-design.md) — desktop spec this port mirrors. [`2026-05-16-android-whitescreen-rca-design.md`](./2026-05-16-android-whitescreen-rca-design.md) — must land first; the env-fix it specifies is a prerequisite for this port.
## Problem
Mobile is still on the legacy per-device identity model:
- `apps/mobile/lib/authContext.tsx` generates an X25519 keypair on first sign-in, registers a `devices` row, stores `device.id` + `device.privateKey` in `expo-secure-store`, and exposes `device`/`ownPrivateKey`.
- All call sites (`conversations/[id].tsx`, `MessageBubble`, `AttachmentImage`) pass `senderDeviceId` / `ownDeviceId` to the shared chat helpers.
- There is no PIN, no `user_keys` row, no recovery code, no migration of legacy bundles.
Consequences:
1. Mobile users locked out after re-install — the new install creates a fresh device-key that no peer has wrapped any conv-key for. Desktop peers that have upgraded to ≥ v0.18 only wrap for `recipient_user_id`, so mobile won't receive a bundle.
2. Friends-list interop is broken when mixing mobile (device-keyed) and desktop (user-keyed) on the same account.
3. The "one PIN, single secret" UX promise from the desktop spec doesn't hold cross-platform.
## Goals
- Mobile uses the same `user_keys`-based identity as desktop. `userId` + PIN-sealed private key, optional 24-char recovery code. No `devices.public_key` reliance for messaging crypto.
- Sign-in on a fresh mobile install is one PIN entry away from full read + write access; no peer dependency.
- Existing legacy conv-key bundles for the user are migrated transparently on first PIN unlock (same `migrateOwnLegacyBundles` helper that desktop uses).
- Shared crypto code stops importing `libsodium-wrappers-sumo` at module level — Argon2id KDF and `crypto_scalarmult_base` route through the `CryptoBackend` interface so React Native's Hermes runtime never has to load WASM.
- Single source of truth: every behaviour already in `apps/desktop/src/lib/userIdentity.ts` is reused, not re-implemented from scratch. Mobile gets a thin platform shell.
## Non-Goals
- Re-deriving the desktop spec's threat model or migration cutoff — both inherited unchanged.
- Replacing `react-native-libsodium`. We extend it through the backend abstraction.
- Multi-account on a single device.
- Per-device fingerprint UI. Same trade-off as desktop: identity rotation is the only revocation path.
- Biometric (Face ID / fingerprint) unlock as a PIN alternative — tracked separately.
- Cross-device pairing via QR. Out of scope.
## Architecture
### The libsodium-wrappers-sumo problem (blocker — must land first)
`packages/shared/src/crypto/userKey.ts` currently does:
```ts
import sodium from 'libsodium-wrappers-sumo';
// ...
await sodium.ready;
return sodium.crypto_pwhash(..., sodium.crypto_pwhash_ALG_ARGON2ID13);
```
And `apps/desktop/src/lib/userIdentity.ts` does a similar direct call to `sodium.crypto_scalarmult_base` in `derivePublicKey`.
`libsodium-wrappers-sumo` is JS + WASM. On Hermes (React Native) it either fails to instantiate or is prohibitively slow / large. Even if it worked, shipping WASM through Metro requires a custom transformer.
**Fix:** Extend the `CryptoBackend` contract so the shared user-key code never references `libsodium-wrappers-sumo` directly. Test code (`testBackend.ts`) keeps its WASM import — it only runs in Node / Vitest.
Backend additions (`packages/shared/src/crypto/backend.ts`):
```ts
export interface CryptoBackend {
// ... existing members ...
readonly pwhashConsts: {
OPSLIMIT_MODERATE: number;
MEMLIMIT_MODERATE: number;
ALG_ARGON2ID13: number;
};
pwhash(
outLen: number,
password: string,
salt: Uint8Array,
opslimit: number,
memlimit: number,
alg: number,
): Uint8Array;
scalarMultBase(privateKey: Uint8Array): Uint8Array;
}
```
`packages/shared/src/crypto/userKey.ts` is refactored so `deriveKek` and `defaultKdfParams()` pull from `getCryptoBackend()` instead of `sodium`. `apps/desktop/src/lib/userIdentity.ts` swaps the inline `await import('libsodium-wrappers-sumo')` block in `derivePublicKey` for `getCryptoBackend().scalarMultBase(priv)`.
Desktop backend adapter (`apps/desktop/src/lib/cryptoBackend.ts`) routes the new methods to `libsodium-wrappers`. Mobile backend adapter (`apps/mobile/lib/cryptoBackend.ts`) routes them to `react-native-libsodium`, which already exposes `crypto_pwhash`, `crypto_scalarmult_base`, and the `crypto_pwhash_*` constants (verified in `react-native-libsodium/lib/typescript/lib.d.ts`).
Naming note: TypeScript forbids a property and a method with the same name. `pwhashConsts` (object) + `pwhash` (function) keeps both addressable; one rename is the only deviation from the libsodium naming.
### Mobile identity orchestrator
`apps/mobile/lib/userIdentity.ts` — new file, mirrors `apps/desktop/src/lib/userIdentity.ts` 1:1. Public API:
| Function | Behaviour |
|----------|-----------|
| `setupNewUserIdentity({ userId, pin, withRecovery })` | Generate keypair, seal with PIN, optionally seal with recovery code, UPSERT `user_keys` via `uploadUserKeyBlob`, cache cleartext key in `SecureStore` under `chatapp.userpriv.<userId>`, fire-and-forget `ensureLegacyMigrated`. |
| `loadOrUnlockUserKey({ userId, pin, isRecoveryCode })` | RPC `try_unlock_user_key`, derive KEK, open sealed blob. On success: `record_pin_attempt(true)`, cache key, fire-and-forget legacy migration. On failure: `record_pin_attempt(false)`, throw. Returns `{ kind: 'unlocked' \| 'locked' \| 'missing' }`. |
| `cachedUserKey(userId)` | Returns `Uint8Array \| null` from SecureStore. |
| `clearUserKeyCache(userId)` | SecureStore remove. |
| `changePin({ userId, oldPin, newPin })` | Cached key proves old PIN; reseal with new PIN; UPSERT. |
| `regenerateRecoveryCode({ userId })` | Generate new 24-char code, seal cached key with it, UPSERT recovery-only fields. |
| `resetIdentity({ userId, pin })` | `resetUserKey` + `setupNewUserIdentity`; returns recovery code. |
| `retryLegacyMigration(userId)` | Runs the migration helper from `@chat-app/shared/chat` for the SecurityCenter "retry" affordance; returns the structured `LegacyMigrationReport`. |
| `userKeyExistsRemotely(userId)` | Thin wrapper around `fetchUserKeyBlob`. |
The desktop file already factors `runLegacyMigration` cleanly. Mobile uses an identical implementation; the only platform difference is the SecureStore adapter for legacy device-key probing (`chatapp.priv.<userId>.<deviceId>`).
### AuthProvider rewrite
`apps/mobile/lib/authContext.tsx` is rewritten to mirror `apps/desktop/src/context/AuthContext.tsx`:
```ts
type UserKeyState =
| { status: 'loading' }
| { status: 'needs-setup' }
| { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean }
| { status: 'unlocked' };
interface AuthContextValue {
session: Session | null;
userId: string | null;
ownPrivateKey: Uint8Array | null; // cached cleartext user key when 'unlocked'
userKeyState: UserKeyState;
ready: boolean;
refreshUserKeyState: () => Promise<void>;
signOut: () => Promise<void>;
}
```
- On session resume: `cachedUserKey(userId)` first. Hit → `unlocked` and key is exposed to consumers. Miss → fetch blob, decide `needs-setup` vs `needs-unlock`.
- `device` / `ensureDevice` disappear. The `devices` table is no longer used for messaging crypto; mobile can still call `auth.registerDevice` for telemetry / push routing in a follow-up, but it is not on the boot path.
- Legacy migration: when transitioning to `unlocked` via either setup or unlock, `ensureLegacyMigrated` runs in the background.
### Routing / screen graph
`apps/mobile/app/_layout.tsx` removes the module-eval `crypto.setCryptoBackend(...)` call (see white-screen spec) and replaces it with a guarded `useEffect` inside an `<AppBootstrap>` component that:
1. Initialises the crypto backend.
2. Renders a small splash with `ActivityIndicator` while `ready === false`.
3. Mounts the rest of the tree only after backend init succeeds.
`apps/mobile/app/(app)/_layout.tsx` becomes the gate:
- `userKeyState.status === 'loading'``<ActivityIndicator/>`
- `userKeyState.status === 'needs-setup'``<Redirect href="/(app)/setup" />`
- `userKeyState.status === 'needs-unlock'``<Redirect href="/(app)/unlock" />`
- `userKeyState.status === 'unlocked'` → render `<Stack>` with `chats`, `conversations/[id]`, `call`, `settings/*`.
New screens:
| File | Purpose |
|------|---------|
| `apps/mobile/app/(app)/setup.tsx` | First-time PIN setup. Two PIN entries, confirm, "Recovery-Code anzeigen", "Habe ich gespeichert" / "Überspringen (riskant)". On success: navigates to `/(app)/chats`. |
| `apps/mobile/app/(app)/unlock.tsx` | PIN entry. Shows remaining attempts + lockout countdown. "Recovery-Code verwenden" tab. On success: navigates to `/(app)/chats`. |
| `apps/mobile/app/(app)/settings/index.tsx` | New settings hub. Profile placeholder + section cards (Sicherheit, Abmelden). |
| `apps/mobile/app/(app)/settings/security.tsx` | PIN ändern, Recovery-Code neu erzeugen, Identität zurücksetzen, "Migration erneut versuchen" with structured report. |
`apps/mobile/components/PinInput.tsx` — React-Native pendant of the desktop `PinInput.tsx`. Six bullet slots + invisible `TextInput` with `keyboardType="numeric"`, `textContentType="oneTimeCode"`, autoFocus, max-length 6. Numpad bring-up is handled by the OS keyboard.
### Call-site updates
Every shared-chat call must move from device-keyed args to user-keyed args. The shared helpers already expect `senderUserId` / `ownUserId` after v0.18 (`decryptMessages` takes `ownUserId`; `sendEncryptedMessage` takes `senderUserId` and treats `senderDeviceId` as deprecated telemetry). Concrete edits:
| File | Edit |
|------|------|
| `apps/mobile/app/(app)/conversations/[id].tsx` | Replace `device.id` references with `userId`. `decryptMessages` already takes `ownUserId`; remove the dead `ownDeviceId: device.id` line. `sendEncryptedMessage` already takes `senderUserId`; drop `senderDeviceId`. Source `userId` + `ownPrivateKey` from `useAuth()`. |
| `apps/mobile/components/MessageBubble.tsx` | Drop the `ownDeviceId` prop. `AttachmentImage` no longer reads it. |
| `apps/mobile/components/AttachmentImage.tsx` | Drop the `ownDeviceId` prop entirely. |
| `apps/mobile/app/(app)/chats.tsx` | No crypto changes; only the type of `useAuth()` shifts (no `device` field). |
### Mobile crypto-backend extension
`apps/mobile/lib/cryptoBackend.ts` adds the new members:
```ts
pwhashConsts: {
OPSLIMIT_MODERATE: s.crypto_pwhash_OPSLIMIT_MODERATE,
MEMLIMIT_MODERATE: s.crypto_pwhash_MEMLIMIT_MODERATE,
ALG_ARGON2ID13: s.crypto_pwhash_ALG_ARGON2ID13,
},
pwhash: (outLen, password, salt, opslimit, memlimit, alg) =>
s.crypto_pwhash(outLen, password, salt, opslimit, memlimit, alg),
scalarMultBase: (priv) => s.crypto_scalarmult_base(priv),
```
Desktop adapter mirrors the structure against `libsodium-wrappers`.
### What we delete
- `apps/mobile/lib/authContext.tsx` — device-id constants `KEY_DEVICE_ID`, `KEY_DEVICE_PRIVKEY`, `ensureDevice`, the device-list lookup. The file is rewritten, not patched.
- `device`/`ownDeviceId` props through the component tree.
### What stays
- `apps/mobile/lib/secretStore.ts` (still used; new key is `chatapp.userpriv.<userId>`).
- `apps/mobile/lib/cryptoBackend.ts` (extended, not replaced).
- `react-native-libsodium`, `expo-secure-store`, all supabase wiring.
- All chat / call / attachment / reactions UI components — they consume `ownPrivateKey` and a user-id, both of which are still available, just sourced differently.
## Data Flow
### First sign-in (no `user_keys` row server-side)
1. User completes magic-link sign-in. `AuthProvider` lands `session`.
2. `refreshUserKeyState`: `cachedUserKey` returns null; `fetchUserKeyBlob` returns null → `userKeyState = 'needs-setup'`.
3. `(app)` layout redirects to `/(app)/setup`.
4. User picks PIN (6 digits), confirms. Optionally taps "Recovery-Code anzeigen", confirms.
5. `setupNewUserIdentity` runs: generate keypair → seal with PIN → optionally seal with recovery code → UPSERT `user_keys` → cache cleartext key.
6. `ensureLegacyMigrated` runs in background (no-op on fresh accounts; rewraps legacy bundles when a desktop install has existing chats).
7. `refreshUserKeyState` re-runs → `unlocked``(app)` mounts the chats stack.
### Re-install / fresh device, account already has `user_keys`
1. Sign in. `cachedUserKey` returns null (fresh SecureStore).
2. `fetchUserKeyBlob` returns the row → `needs-unlock` (or `needs-unlock` with lockout if `locked_until > now`).
3. Routed to `/(app)/unlock`. User enters PIN.
4. `loadOrUnlockUserKey` → RPC → derive KEK → open → cache → success.
5. `ensureLegacyMigrated` triggers in background; reinstall users have nothing to migrate locally (no old SecureStore entries), so the helper exits with `noStrongholdKey > 0` and the UI is unaffected.
6. UI navigates to `/(app)/chats`.
### Sending a message
Identical to desktop. `useAuth().ownPrivateKey` (now the user-key) + `session.user.id``chat.sendEncryptedMessage`. `getOrCreateConvKey` finds the bundle by `recipient_user_id = me` and works on the very first send without peer involvement.
### PIN change / recovery regenerate / identity reset
Reuses `changePin`, `regenerateRecoveryCode`, `resetIdentity` exactly as on desktop. UI in `settings/security.tsx`.
### Forgot PIN, no recovery
`unlock.tsx` exposes "Identität zurücksetzen" after the user has been locked. Triggers `resetIdentity` which generates a new keypair, replaces the `user_keys` row, deletes own `conversation_keys` bundles, and re-runs setup. User loses access to old chats — same trade-off as desktop.
### Upgrade-in-place migration (existing v0.1.x mobile users)
A user who already has the app v0.1.x installed has a `chatapp.priv.<userId>.<deviceId>` entry in SecureStore. On first launch of the upgraded build:
1. Setup or unlock runs as above and produces a fresh `chatapp.userpriv.<userId>`.
2. `ensureLegacyMigrated` reads the old SecureStore entries (the orchestrator probes both `listOwnDevices` and scans `conversation_keys` for `recipient_device_id`s, identical to desktop).
3. Re-wraps and uploads via `migrate_user_key_recipients`.
4. Old SecureStore entries are left in place (no destructive cleanup until a follow-up release confirms the migration succeeded — same as desktop).
## Error Handling
| Case | Behaviour |
|------|-----------|
| Wrong PIN | `record_pin_attempt(false)` increments. UI shows remaining attempts. Same backoff schedule as desktop. |
| Lockout | `try_unlock_user_key` returns `locked: true`. UI surfaces lockout time + "Recovery-Code verwenden" tab if `hasRecovery`. |
| Recovery missing + lockout | UI shows hard warning + "Identität zurücksetzen" path. |
| SecureStore unavailable (rare; Android factory-test profiles) | Boot screen surfaces error from the global handler. No silent fallback — losing the local cache means re-entering PIN every launch, which is acceptable; we do not fall back to AsyncStorage cleartext storage. |
| Migration failure | Background only; never blocks unlock. `SecurityCenter` exposes structured report + "Migration erneut versuchen" button. |
| `crypto_pwhash` rejects (input length, memory limit) | Treated as identical to wrong-PIN (record attempt, surface generic "PIN falsch"). Logged with `console.warn` for diagnostics. |
| `react-native-libsodium` constants undefined at boot | `setCryptoBackend` throws inside the `<AppBootstrap>` `useEffect`; the error is caught and surfaced in a fallback `<View>` instead of a white screen. |
| Server unreachable during unlock | `try_unlock_user_key` rejects → unlock screen shows "Server nicht erreichbar" + Retry button; cached key (if any) is unaffected. |
## Testing
**Shared (`packages/shared`):**
- `crypto/userKey.test.ts` — already exists, must stay green after the refactor. Run against the WASM test backend AND a stub backend that records calls (to assert `pwhash` is only invoked with the `MODERATE` preset).
- `crypto/backend.contract.test.ts` — NEW. Defines a backend contract test that any adapter must pass: pwhash determinism, `scalarMultBase` produces the public key matching `generateKeyPair()` private→public mapping.
**Mobile unit (`apps/mobile`):**
- `lib/userIdentity.test.ts` — setup/unlock/changePin/regenerateRecovery/reset roundtrip with a mocked Supabase RPC and the test crypto backend.
- `lib/cryptoBackend.test.ts` — pwhash determinism + length assertions. Skipped on Node when `react-native-libsodium` isn't loadable; runs in `expo-test`/Device-Farm context.
- `components/PinInput.test.tsx` — typing accumulates, max length, submit on full input.
**Manual smoke list (pre-merge):**
1. Fresh install Android. Sign in. Set PIN with recovery. Send message. Sign out. Sign in. Enter PIN. Old + new chats work.
2. Fresh install iOS. Same flow.
3. Reinstall scenario: nuke app data, reinstall, sign in, enter PIN. No peer needs to be online.
4. Cross-platform: send from desktop, receive on mobile that was set up on a different device. Decrypts.
5. Wrong PIN 5×, 10× → lockout banner + recovery tab.
6. Recovery code unlock works.
7. PIN change in Settings → sign out → sign in with new PIN.
8. Identity reset → re-setup → fresh recovery code → old chats unreadable (expected), new chats work.
9. Upgrade-in-place from v0.1.0 with existing chats: legacy migration rewraps; "Migration erneut versuchen" shows non-zero `migrated`.
## Migration Sequencing
Two-stage rollout to keep server compatibility.
1. **v0.2.0 (silent upgrade):** New mobile build with user-keys + PIN. Falls back gracefully when peers are still on legacy desktop (uses `recipient_device_id` for legacy peers via existing `rotateConvKey` legacy branch).
2. **v0.3.0 (cleanup):** Drop the device-key probing path once telemetry confirms ≥95% of mobile installs have a `user_keys` row. Aligns with the desktop spec's migration cutoff.
## Out of Scope (future work)
- Biometric unlock (Face ID / fingerprint) as an alternative to PIN entry.
- Cross-device pairing flow (QR / Bluetooth handshake) for a no-PIN re-install path.
- Push-notification routing via per-install device tokens (separate from crypto identity).
- Device-list management UI on mobile.
+4
View File
@@ -119,6 +119,7 @@ export type Database = {
name: string
platform: Database["public"]["Enums"]["device_platform"]
public_key: string | null
revoked_at: string | null
user_id: string
}
Insert: {
@@ -128,6 +129,7 @@ export type Database = {
name: string
platform: Database["public"]["Enums"]["device_platform"]
public_key?: string | null
revoked_at?: string | null
user_id: string
}
Update: {
@@ -137,6 +139,7 @@ export type Database = {
name?: string
platform?: Database["public"]["Enums"]["device_platform"]
public_key?: string | null
revoked_at?: string | null
user_id?: string
}
Relationships: []
@@ -481,6 +484,7 @@ export type Database = {
Functions: {
accept_dm: { Args: { conversation_id: string }; Returns: undefined }
are_friends: { Args: { a: string; b: string }; Returns: boolean }
revoke_device: { Args: { p_device_id: string }; Returns: undefined }
attachment_object_conv_id: {
Args: { object_name: string }
Returns: string
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from 'vitest';
import { listOwnDevices, revokeDevice } from './device';
function makeClient(overrides: {
user?: { id: string } | null;
selectData?: Array<{ id: string; name: string; platform: string; last_seen_at: string; revoked_at: string | null }>;
rpcImpl?: (fn: string, params: unknown) => Promise<{ data: unknown; error: unknown }>;
}): any {
const builder: any = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockResolvedValue({ data: overrides.selectData ?? [], error: null }),
};
return {
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: overrides.user ?? { id: 'u-1' } } }) },
from: vi.fn().mockReturnValue(builder),
rpc: vi.fn().mockImplementation(overrides.rpcImpl ?? (async () => ({ data: null, error: null }))),
};
}
describe('listOwnDevices', () => {
it('maps revoked_at into revokedAt', async () => {
const client = makeClient({
selectData: [
{ id: 'd-1', name: 'Laptop', platform: 'desktop', last_seen_at: '2026-05-16T00:00:00Z', revoked_at: null },
{ id: 'd-2', name: 'Old phone', platform: 'mobile', last_seen_at: '2026-05-10T00:00:00Z', revoked_at: '2026-05-15T12:00:00Z' },
],
});
const out = await listOwnDevices(client);
expect(out).toEqual([
{ id: 'd-1', name: 'Laptop', platform: 'desktop', lastSeenAt: '2026-05-16T00:00:00Z', revokedAt: null },
{ id: 'd-2', name: 'Old phone', platform: 'mobile', lastSeenAt: '2026-05-10T00:00:00Z', revokedAt: '2026-05-15T12:00:00Z' },
]);
});
});
describe('revokeDevice', () => {
it('invokes the revoke_device RPC with the device id', async () => {
const rpc = vi.fn().mockResolvedValue({ data: null, error: null });
const client = makeClient({ rpcImpl: rpc });
await revokeDevice(client, 'd-42');
expect(rpc).toHaveBeenCalledWith('revoke_device', { p_device_id: 'd-42' });
});
it('throws when the RPC returns an error', async () => {
const client = makeClient({
rpcImpl: async () => ({ data: null, error: { message: 'not authorized', code: '42501' } as any }),
});
await expect(revokeDevice(client, 'd-99')).rejects.toThrow(/not authorized/);
});
});
+13 -2
View File
@@ -16,6 +16,7 @@ export interface DeviceRecord {
name: string;
platform: DevicePlatform;
lastSeenAt: string;
revokedAt: string | null;
}
export async function registerDevice(
@@ -32,7 +33,7 @@ export async function registerDevice(
name: params.name,
platform: params.platform,
})
.select('id, name, platform, last_seen_at')
.select('id, name, platform, last_seen_at, revoked_at')
.single();
if (error) throw error;
@@ -41,6 +42,7 @@ export async function registerDevice(
name: data.name,
platform: data.platform,
lastSeenAt: data.last_seen_at,
revokedAt: data.revoked_at,
};
}
@@ -50,7 +52,7 @@ export async function listOwnDevices(client: AppSupabaseClient): Promise<DeviceR
const { data, error } = await client
.from('devices')
.select('id, name, platform, last_seen_at')
.select('id, name, platform, last_seen_at, revoked_at')
.eq('user_id', session.user.id)
.order('last_seen_at', { ascending: false });
if (error) throw error;
@@ -60,6 +62,7 @@ export async function listOwnDevices(client: AppSupabaseClient): Promise<DeviceR
name: row.name,
platform: row.platform,
lastSeenAt: row.last_seen_at,
revokedAt: row.revoked_at,
}));
}
@@ -74,6 +77,14 @@ export async function touchDeviceLastSeen(
if (error) throw error;
}
export async function revokeDevice(
client: AppSupabaseClient,
deviceId: string,
): Promise<void> {
const { error } = await client.rpc('revoke_device', { p_device_id: deviceId });
if (error) throw new Error(error.message);
}
// Intentional re-exports so app layers only need @chat-app/shared/auth.
export type { SecretStore } from './secure-storage';
export { toBase64 as base64FromBytes, fromBase64 as bytesFromBase64 };
+5 -3
View File
@@ -54,8 +54,8 @@ describe('auth/userKey', () => {
expect(res.lockedUntil).toBe(lockedUntil);
});
it('uploadUserKeyBlob upserts via reset_user_key RPC', async () => {
mock.setRpcResponse('reset_user_key', { data: 0, error: null });
it('uploadUserKeyBlob upserts via upsert_user_key RPC (non-destructive)', async () => {
mock.setRpcResponse('upsert_user_key', { data: null, error: null });
await uploadUserKeyBlob(mock.client, {
userId: USER_ID,
publicKey: new Uint8Array([1, 2, 3]),
@@ -64,7 +64,9 @@ describe('auth/userKey', () => {
kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 268435456 },
});
const params = mock.rpcCalls.at(-1)?.params as Record<string, unknown>;
expect(mock.rpcCalls.at(-1)?.name).toBe('reset_user_key');
// Crucial: must hit upsert_user_key, NOT reset_user_key — the latter
// deletes every conversation_keys row for the user (0.18.00.18.2 bug).
expect(mock.rpcCalls.at(-1)?.name).toBe('upsert_user_key');
expect(params.p_user_id).toBe(USER_ID);
expect(params.p_public_key_b64).toBe('AQID');
expect(params.p_sealed_private_b64).toBe('BAU=');
+7 -1
View File
@@ -92,7 +92,13 @@ export async function uploadUserKeyBlob(
client: AppSupabaseClient,
params: UploadParams,
): Promise<void> {
const { error } = await rpc(client).rpc('reset_user_key', {
// Non-destructive UPSERT — must NOT touch conversation_keys. Used for the
// first-time PIN setup, PIN change, and recovery-code regeneration. The
// 0.18.00.18.2 builds wired this to `reset_user_key` which DELETED every
// legacy conv-key bundle for the user before the migration could re-wrap
// them, leaving people unable to read or send. `upsert_user_key` writes
// only the user_keys row and leaves conversation_keys alone.
const { error } = await rpc(client).rpc('upsert_user_key', {
p_user_id: params.userId,
p_public_key_b64: bytesToB64(params.publicKey),
p_sealed_private_b64: bytesToB64(params.sealedPrivateKey),
+11
View File
@@ -25,6 +25,16 @@ export interface AttachmentHandle {
// base64-encoded — only readable via per-device envelope decrypt.
keyB64: string;
nonceB64: string;
/** View-once flag set by sender on the encrypted envelope and mirrored
* to `message_attachments.view_once` so the renderer can show a "tap to
* view" tombstone and call the mark-viewed RPC on first open. */
viewOnce?: boolean;
/** ISO timestamp the first non-sender opened the attachment. Populated by
* the mark-viewed RPC (server-authoritative); undefined until burnt. */
viewedAt?: string | null;
/** UUID of the first non-sender who viewed. Populated alongside `viewedAt`
* by the mark-viewed RPC so the renderer can render attribution. */
viewedBy?: string | null;
}
export type CallEventStatus = 'ended' | 'missed' | 'declined';
@@ -256,6 +266,7 @@ export async function insertAttachmentRow(
nonce: blobNonceHex,
mime_type: handle.mimeType,
size_bytes: handle.sizeBytes,
view_once: handle.viewOnce ?? false,
};
if (handle.width !== undefined) row.width = handle.width;
if (handle.height !== undefined) row.height = handle.height;
+95 -1
View File
@@ -138,11 +138,105 @@ export async function getOrCreateConvKey(
.eq('key_version', version);
if (cntErr) throw cntErr;
if ((count ?? 0) > 0) {
throw new Error('Awaiting conversation key — another user must share it with this user.');
// Rows exist for this version, but none for me. Either I lost the device-key
// that originally received my bundle, or my own bundle was wiped by the
// 0.18.0 reset_user_key bug. Either way, the only way out is to mint a fresh
// conv-key at version+1 and wrap it for everyone we can. Old messages stay
// unreadable for me; new ones flow.
console.info('[conv-key] no bundle for me at v' + version + ' — auto-rotating');
return rotateConvKey(client, conversationId, own);
}
return bootstrapConvKey(client, conversationId, own, version);
}
// Mints a fresh conv-key at active_key_version + 1 and wraps it for every
// accepted member. Per-user bundles take priority; for members lacking a
// user_keys row we fall back to per-device wrapping (one bundle per device)
// so peers still on the legacy 0.17.x client can decrypt with their device
// private key. Caller must own a copy of their private key in `own`.
export async function rotateConvKey(
client: AppSupabaseClient,
conversationId: string,
own: OwnUserCtx,
): Promise<ConvKeyHandle> {
const currentVersion = await fetchActiveKeyVersion(client, conversationId);
const newVersion = currentVersion + 1;
const { data: members, error: mErr } = await client
.from('conversation_members')
.select('user_id, accepted')
.eq('conversation_id', conversationId);
if (mErr) throw mErr;
const memberIds = (members ?? []).filter((m) => m.accepted).map((m) => m.user_id);
if (memberIds.length === 0) throw new Error('cannot rotate — no accepted members');
const userKeys = await fetchPeerPublicKeys(client, memberIds);
const userKeyByUserId = new Map(userKeys.map((k) => [k.userId, k.publicKey]));
const missingUserKeyMembers = memberIds.filter((id) => !userKeyByUserId.has(id));
let legacyDevices: { userId: string; deviceId: string; publicKey: Uint8Array }[] = [];
if (missingUserKeyMembers.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: devs, error: dErr } = await (client as any)
.from('devices')
.select('id, user_id, public_key')
.in('user_id', missingUserKeyMembers)
.not('public_key', 'is', null);
if (dErr) throw dErr;
legacyDevices = (devs ?? []).map((d: { id: string; user_id: string; public_key: string }) => ({
userId: d.user_id,
deviceId: d.id,
publicKey: pgHexToBytes(d.public_key),
}));
}
const convKey = generateConvKey();
const bundles: Array<{
recipient_user_id?: string;
recipient_device_id?: string;
encrypted_key: string;
nonce: string;
}> = [];
for (const k of userKeys) {
const wrapped = await wrapConvKeyForRecipient(convKey, k.publicKey, own.privateKey);
bundles.push({
recipient_user_id: k.userId,
encrypted_key: hexNoPrefix(wrapped.ciphertext),
nonce: hexNoPrefix(wrapped.nonce),
});
}
for (const d of legacyDevices) {
const wrapped = await wrapConvKeyForRecipient(convKey, d.publicKey, own.privateKey);
bundles.push({
recipient_device_id: d.deviceId,
encrypted_key: hexNoPrefix(wrapped.ciphertext),
nonce: hexNoPrefix(wrapped.nonce),
});
}
if (bundles.length === 0) {
throw new Error('cannot rotate — no peers have a public key (no user_keys, no devices)');
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rpc = (client as unknown as { rpc: (n: string, p: object) => Promise<{ error: any }> }).rpc;
const { error } = await rpc.call(client, 'rotate_conv_key', {
p_conv_id: conversationId,
p_sender_user_id: own.userId,
p_new_version: newVersion,
p_bundles: bundles,
});
if (error) throw error;
const handle = { conversationId, keyVersion: newVersion, key: convKey };
cache.set(cacheKey(conversationId, newVersion), handle);
console.info(
'[conv-key] rotated conversation ' + conversationId.slice(0, 8) +
' from v' + currentVersion + ' to v' + newVersion +
' — wrapped for ' + userKeys.length + ' user-keys + ' + legacyDevices.length + ' legacy devices',
);
return handle;
}
export async function tryGetConvKey(
client: AppSupabaseClient,
conversationId: string,
+3
View File
@@ -7,6 +7,9 @@ export * from './groups';
export * from './messages';
export * from './types';
export * from './userKeyMigration';
export * from './pinnedMessages';
export * from './mentions';
export * from './viewOnceAttachments';
// ----- RPC wrappers ---------------------------------------------------------
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { parseMentionUsernames } from './mentions';
describe('parseMentionUsernames', () => {
it('extracts a leading mention', () => {
expect(parseMentionUsernames('@anna hi')).toEqual(['anna']);
});
it('extracts mid-sentence', () => {
expect(parseMentionUsernames('hey @ben_c what do you think')).toEqual(['ben_c']);
});
it('lowercases usernames', () => {
expect(parseMentionUsernames('hi @Anna')).toEqual(['anna']);
});
it('deduplicates', () => {
expect(parseMentionUsernames('@xx and @xx again')).toEqual(['xx']);
});
it('ignores emails (no preceding boundary)', () => {
expect(parseMentionUsernames('mail me at foo@bar.com')).toEqual([]);
});
it('rejects 1-char names', () => {
expect(parseMentionUsernames('@a')).toEqual([]);
});
it('handles multiple in one message', () => {
expect(parseMentionUsernames('@anna, @ben and @cara')).toEqual(['anna', 'ben', 'cara']);
});
});
+60
View File
@@ -0,0 +1,60 @@
import type { AppSupabaseClient } from '../supabase/client';
// `@anna_b` style — letters, digits, underscore, dot, dash, 2-32 chars.
// Conservative on purpose: false negatives (a real username we don't match)
// are recoverable (no notification fires); false positives (matching a
// non-username) just become an INSERT that the FK check rejects.
const MENTION_RE = /(?:^|[\s,;:!?(])@([a-zA-Z0-9_.-]{2,32})/g;
export function parseMentionUsernames(plaintext: string): string[] {
const out = new Set<string>();
for (const m of plaintext.matchAll(MENTION_RE)) {
if (m[1]) out.add(m[1].toLowerCase());
}
return [...out];
}
export interface MentionResolver {
// Resolves an array of @usernames in this conversation to user-ids.
// Returns only memberships that exist + are accepted.
resolveUsernames(conversationId: string, usernames: string[]): Promise<Map<string, string>>;
}
export function makeMentionResolver(client: AppSupabaseClient): MentionResolver {
return {
async resolveUsernames(conversationId, usernames) {
if (usernames.length === 0) return new Map();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error } = await (client as any)
.from('conversation_members')
.select('user_id, accepted, profiles!inner(username)')
.eq('conversation_id', conversationId)
.eq('accepted', true)
.in('profiles.username', usernames);
if (error) throw error;
const out = new Map<string, string>();
for (const row of (data ?? []) as Array<{ user_id: string; profiles: { username: string } }>) {
out.set(row.profiles.username.toLowerCase(), row.user_id);
}
return out;
},
};
}
export async function insertMentions(
client: AppSupabaseClient,
messageId: string,
conversationId: string,
mentionedUserIds: string[],
): Promise<void> {
if (mentionedUserIds.length === 0) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error } = await (client as any).from('message_mentions').insert(
mentionedUserIds.map((uid) => ({
message_id: messageId,
mentioned_user_id: uid,
conversation_id: conversationId,
})),
);
if (error) throw error;
}
+35 -3
View File
@@ -8,6 +8,7 @@ import {
type OwnUserCtx,
tryGetConvKey,
} from './convKeys';
import { insertMentions, makeMentionResolver, parseMentionUsernames } from './mentions';
import type { ChatMessage, DecryptedMessage } from './types';
const MESSAGE_COLS =
@@ -91,7 +92,11 @@ export interface SendMessageParams {
conversationId: string;
plaintext: string;
senderUserId: string;
senderDeviceId: string;
// Optional now: post-conv-keys this is pure telemetry. The 0.18 builds
// started passing a localStorage UUID that doesn't exist in the devices
// table; messages.sender_device_id RLS then 403'd every insert. Senders
// pass null (or an actually-registered device id, if they have one).
senderDeviceId?: string | null;
senderPrivateKey: Uint8Array;
replyToId?: string;
// Optional encrypted attachments — their handles are already materialised
@@ -124,7 +129,12 @@ export async function sendEncryptedMessage(params: SendMessageParams): Promise<C
const insertPayload: Record<string, unknown> = {
conversation_id: params.conversationId,
sender_id: params.senderUserId,
sender_device_id: params.senderDeviceId,
// ALWAYS null until we re-introduce a real per-install devices row.
// Desktop callers currently pass a localStorage UUID (ensureInstallId)
// which doesn't exist in the devices table; the messages_insert_member
// RLS policy then 403s because the id can't be proven to belong to the
// caller. NULL satisfies the policy ("sender_device_id IS NULL OR …").
sender_device_id: null,
ciphertext: bytesToPgHex(cipher.ciphertext),
nonce: bytesToPgHex(cipher.nonce),
key_version: handle.keyVersion,
@@ -137,6 +147,28 @@ export async function sendEncryptedMessage(params: SendMessageParams): Promise<C
.select(MESSAGE_COLS)
.single();
if (insertErr) throw insertErr;
// Mention rows are inserted on a best-effort basis after the message itself
// lands. If parsing or resolution fails the message still goes through —
// worst case the mentioned user doesn't get a notification.
const usernames = parseMentionUsernames(params.plaintext);
if (usernames.length > 0) {
try {
const resolver = makeMentionResolver(params.client);
const resolved = await resolver.resolveUsernames(params.conversationId, usernames);
if (resolved.size > 0) {
await insertMentions(
params.client,
(messageRow as unknown as { id: string }).id,
params.conversationId,
[...resolved.values()],
);
}
} catch (err) {
console.warn('mention insert failed', err);
}
}
return mapMessage(messageRow as unknown as MessageRow);
}
@@ -174,7 +206,7 @@ export interface EditMessageParams {
// Re-encrypts the message body with the conv-key and updates the row.
// Server-side trigger enforces 24h window + sender-only rule.
export async function editEncryptedMessage(
params: EditMessageParams & { senderUserId: string; senderDeviceId: string },
params: EditMessageParams & { senderUserId: string; senderDeviceId?: string | null },
): Promise<void> {
const ownCtx: OwnUserCtx = {
userId: params.senderUserId,
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { listPinnedMessages, pinMessage, unpinMessage } from './pinnedMessages';
function fakeClient(rows: Array<Record<string, unknown>>) {
const calls: Array<{ name: string; payload: unknown }> = [];
const client = {
from(_table: string) {
return {
select: () => ({
eq: () => ({
order: () => Promise.resolve({ data: rows, error: null }),
}),
}),
insert: (payload: unknown) => {
calls.push({ name: 'insert', payload });
return Promise.resolve({ error: null });
},
delete: () => ({
eq: () => ({
eq: () => {
calls.push({ name: 'delete', payload: null });
return Promise.resolve({ error: null });
},
}),
}),
};
},
};
return { client, calls };
}
describe('pinnedMessages', () => {
it('listPinnedMessages maps DB rows to camelCase', async () => {
const rows = [
{ conversation_id: 'c1', message_id: 'm1', pinned_by: 'u1', pinned_at: '2026-05-16T10:00:00Z' },
];
const { client } = fakeClient(rows);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await listPinnedMessages(client as any, 'c1');
expect(result).toEqual([
{ conversationId: 'c1', messageId: 'm1', pinnedBy: 'u1', pinnedAt: '2026-05-16T10:00:00Z' },
]);
});
it('pinMessage inserts with the right shape', async () => {
const { client, calls } = fakeClient([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await pinMessage(client as any, 'c1', 'm1', 'u1');
expect(calls).toEqual([
{ name: 'insert', payload: { conversation_id: 'c1', message_id: 'm1', pinned_by: 'u1' } },
]);
});
it('unpinMessage deletes by conversation_id + message_id', async () => {
const { client, calls } = fakeClient([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await unpinMessage(client as any, 'c1', 'm1');
expect(calls).toEqual([{ name: 'delete', payload: null }]);
});
});
@@ -0,0 +1,65 @@
import type { AppSupabaseClient } from '../supabase/client';
export interface PinnedMessage {
conversationId: string;
messageId: string;
pinnedBy: string;
pinnedAt: string;
}
interface Row {
conversation_id: string;
message_id: string;
pinned_by: string;
pinned_at: string;
}
function mapRow(r: Row): PinnedMessage {
return {
conversationId: r.conversation_id,
messageId: r.message_id,
pinnedBy: r.pinned_by,
pinnedAt: r.pinned_at,
};
}
export async function listPinnedMessages(
client: AppSupabaseClient,
conversationId: string,
): Promise<PinnedMessage[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error } = await (client as any)
.from('pinned_messages')
.select('conversation_id, message_id, pinned_by, pinned_at')
.eq('conversation_id', conversationId)
.order('pinned_at', { ascending: false });
if (error) throw error;
return (data ?? []).map(mapRow);
}
export async function pinMessage(
client: AppSupabaseClient,
conversationId: string,
messageId: string,
pinnedBy: string,
): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error } = await (client as any)
.from('pinned_messages')
.insert({ conversation_id: conversationId, message_id: messageId, pinned_by: pinnedBy });
if (error) throw error;
}
export async function unpinMessage(
client: AppSupabaseClient,
conversationId: string,
messageId: string,
): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error } = await (client as any)
.from('pinned_messages')
.delete()
.eq('conversation_id', conversationId)
.eq('message_id', messageId);
if (error) throw error;
}
@@ -23,10 +23,11 @@ describe('migrateOwnLegacyBundles', () => {
const stubClient = {
from: (table: string) => {
if (table === 'conversation_keys') {
// Mirrors the new chain: .select(...).is('recipient_user_id', null).not('recipient_device_id', 'is', null)
return {
select: () => ({
in: () => ({
eq: () => Promise.resolve({
is: () => ({
not: () => Promise.resolve({
data: [
{
conversation_id: 'conv-1',
+42 -5
View File
@@ -14,6 +14,13 @@ export interface MigrateParams {
export interface MigrateResult {
migratedConversations: number;
errors: { conversationId: string; reason: string }[];
// Per-pair detail collected even on success so the SecurityCenter "retry"
// button can show "X / Y bundles re-wrapped, Z stuck because the device
// private key is no longer in the local vault."
attempted: number;
noStrongholdKey: number;
decryptFailed: number;
rpcFailed: number;
}
interface LegacyRow {
@@ -45,30 +52,50 @@ async function fetchSenderPubKeys(
}
export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<MigrateResult> {
const result: MigrateResult = { migratedConversations: 0, errors: [] };
if (params.ownLegacyDeviceIds.length === 0) return result;
const result: MigrateResult = {
migratedConversations: 0, errors: [],
attempted: 0, noStrongholdKey: 0, decryptFailed: 0, rpcFailed: 0,
};
if (params.ownLegacyDeviceIds.length === 0) {
console.info('[crypto-migration] no legacy device-ids to consider — skipping');
return result;
}
// RLS already filters this query down to rows where one of the recipient
// device-ids belongs to us. We do NOT pre-filter by `ownLegacyDeviceIds`
// anymore — historically, users can have device rows server-side whose
// private key is no longer in the local vault (fresh OS install, vault
// wiped, etc.). Conversely, the vault may hold a key for a device-id the
// server forgot. The decisive check is "do we have the matching private
// key in stronghold?" — which we evaluate per row below.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: rowsRaw, error } = await (params.client as any)
.from('conversation_keys')
.select('conversation_id, key_version, recipient_device_id, sender_device_id, sender_user_id, encrypted_key, nonce')
.in('recipient_device_id', params.ownLegacyDeviceIds)
.eq('recipient_user_id', null as unknown as string);
.is('recipient_user_id', null)
.not('recipient_device_id', 'is', null);
if (error) throw error;
const rows = (rowsRaw ?? []) as LegacyRow[];
console.info('[crypto-migration] legacy rows visible to me: ' + rows.length);
if (rows.length === 0) return result;
const senderDeviceIds = Array.from(new Set(rows.map((r) => r.sender_device_id).filter(Boolean)));
const senderMap = await fetchSenderPubKeys(params.client, senderDeviceIds);
for (const row of rows) {
result.attempted += 1;
const ownPriv = params.ownLegacyDevicePrivateKeys[row.recipient_device_id];
if (!ownPriv) {
result.errors.push({ conversationId: row.conversation_id, reason: 'no legacy private key in store' });
result.noStrongholdKey += 1;
result.errors.push({
conversationId: row.conversation_id,
reason: 'no private key in stronghold for device ' + row.recipient_device_id.slice(0, 8),
});
continue;
}
const sender = senderMap.get(row.sender_device_id);
if (!sender) {
result.decryptFailed += 1;
result.errors.push({ conversationId: row.conversation_id, reason: 'sender device not found' });
continue;
}
@@ -79,6 +106,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
sender.publicKey, ownPriv,
);
} catch (err) {
result.decryptFailed += 1;
result.errors.push({
conversationId: row.conversation_id,
reason: err instanceof Error ? err.message : String(err),
@@ -98,6 +126,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
}],
});
if (rpcError) {
result.rpcFailed += 1;
result.errors.push({
conversationId: row.conversation_id,
reason: (rpcError as Error).message ?? String(rpcError),
@@ -107,5 +136,13 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
result.migratedConversations += 1;
}
console.info(
'[crypto-migration] result:',
'attempted=' + result.attempted,
'migrated=' + result.migratedConversations,
'noKey=' + result.noStrongholdKey,
'decryptFail=' + result.decryptFailed,
'rpcFail=' + result.rpcFailed,
);
return result;
}
@@ -0,0 +1,29 @@
import type { AppSupabaseClient } from '../supabase/client';
export interface ViewOnceResult {
viewOnce: boolean;
/** ISO timestamp set the moment the first non-sender viewed. */
viewedAt?: string | null;
/** True iff the caller is the original sender (they don't burn the view). */
self?: boolean;
/** True iff someone else already viewed before this call. */
already?: boolean;
}
export async function markAttachmentViewed(
client: AppSupabaseClient,
attachmentId: string,
): Promise<ViewOnceResult> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error } = await (client as any).rpc('mark_attachment_viewed', {
p_attachment_id: attachmentId,
});
if (error) throw error;
const d = (data ?? {}) as Record<string, unknown>;
return {
viewOnce: Boolean(d.view_once),
viewedAt: typeof d.viewed_at === 'string' ? d.viewed_at : null,
self: Boolean(d.self),
already: Boolean(d.already),
};
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import type { CryptoBackend } from './backend';
import { makeWasmTestBackend } from './testBackend';
describe('CryptoBackend contract — extended (pwhash + scalarMultBase)', () => {
it('pwhashConsts exposes the constants userKey.ts needs', async () => {
const backend: CryptoBackend = await makeWasmTestBackend();
expect(backend.pwhashConsts.OPSLIMIT_MODERATE).toBeGreaterThan(0);
expect(backend.pwhashConsts.MEMLIMIT_MODERATE).toBeGreaterThan(0);
expect(backend.pwhashConsts.ALG_ARGON2ID13).toBeGreaterThan(0);
});
it('pwhash is deterministic for fixed input + salt', async () => {
const backend: CryptoBackend = await makeWasmTestBackend();
const salt = new Uint8Array(16).fill(7);
const a = backend.pwhash(
32,
'hunter2',
salt,
backend.pwhashConsts.OPSLIMIT_MODERATE,
backend.pwhashConsts.MEMLIMIT_MODERATE,
backend.pwhashConsts.ALG_ARGON2ID13,
);
const b = backend.pwhash(
32,
'hunter2',
salt,
backend.pwhashConsts.OPSLIMIT_MODERATE,
backend.pwhashConsts.MEMLIMIT_MODERATE,
backend.pwhashConsts.ALG_ARGON2ID13,
);
expect(a).toEqual(b);
expect(a.length).toBe(32);
});
it('scalarMultBase maps the private key of a generated keypair to its public key', async () => {
const backend: CryptoBackend = await makeWasmTestBackend();
const kp = backend.generateKeyPair();
const derived = backend.scalarMultBase(kp.privateKey);
expect(derived).toEqual(kp.publicKey);
});
});
+24
View File
@@ -47,6 +47,30 @@ export interface CryptoBackend {
// distributed per recipient via crypto_box envelopes.
secretbox(plaintext: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array;
secretboxOpen(ciphertext: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array;
// ---------------------------------------------------------------------
// Key derivation + public-key derivation. Added so the user-key flow
// (Argon2id KDF, scalarmult-base for public-key derivation from a cached
// private key) does not have to import libsodium-wrappers-sumo at module
// top level — that import does not load in React Native's Hermes runtime.
// ---------------------------------------------------------------------
readonly pwhashConsts: {
OPSLIMIT_MODERATE: number;
MEMLIMIT_MODERATE: number;
ALG_ARGON2ID13: number;
};
pwhash(
outLen: number,
password: string,
salt: Uint8Array,
opslimit: number,
memlimit: number,
alg: number,
): Uint8Array;
scalarMultBase(privateKey: Uint8Array): Uint8Array;
}
let current: CryptoBackend | null = null;

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