Compare commits

...

99 Commits

Author SHA1 Message Date
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
byGalax d9377fc52a chore(desktop): release v0.18.0 2026-05-16 00:04:59 +02:00
byGalax 1c18078b1f docs: encryption-UX spec + plan from 2026-05-15 brainstorming session 2026-05-16 00:02:27 +02:00
byGalax 0e05a2cd85 refactor(desktop): replace VoiceChannelRail with CallPreviewPanel
Drops the always-on 'Sprach-Channel' banner. The preview panel renders
only when peers are in the active call (1:1 and group identical).
Calls are still started via the topbar phone icon.
2026-05-15 23:59:17 +02:00
byGalax b55ccf899f feat(desktop): add CallPreviewPanel — Discord-DM-style join surface 2026-05-15 23:56:02 +02:00
byGalax b2eb214d9f docs(plan): call-preview-panel implementation plan 2026-05-15 23:51:01 +02:00
byGalax 010a810485 docs(spec): call-preview-panel — only show when peer is in active call 2026-05-15 23:43:50 +02:00
byGalax 6a9a0bb804 fix(db): swap conversation_keys natural PK for synthetic row_id
Migration 20260515000002 failed on prod because dropping NOT NULL on
recipient_device_id was rejected (column is part of the natural primary
key). This fix-up drops the PK, adds a synthetic row_id BIGSERIAL PK,
re-applies the NOT NULL drop, and re-runs the indexes/policies that
were skipped after the failure.
2026-05-15 23:25:49 +02:00
byGalax f7c60945d0 refactor(shared): strip cryptographic device provisioning (now telemetry-only)
devices rows no longer carry public_key for crypto purposes. The whole
per-device key API surface (provisionNewDevice, loadDevicePrivateKey,
saveDevicePrivateKey, forgetDevicePrivateKey, restoreDeviceFromServerRecord)
is removed; registerDevice now records {name, platform} only. SQL drops the
NOT NULL on devices.public_key so future telemetry rows can omit it.

Note: SQL not applied locally - push via pnpm prod:migrate when ready.
2026-05-15 23:19:26 +02:00
byGalax 15ef9ece66 feat(desktop): proactively rewrap conv-keys for un-migrated peers on open
When a conversation opens, the local client checks every accepted member
for a recipient_user_id bundle on the active key version. Members without
one get a best-effort wrap from the local conv-key handle. This closes
the legacy migration gap where peer B couldn't read because no one had
yet wrapped the new per-user conv-key for them.
2026-05-15 23:12:35 +02:00
byGalax b789f4b10d feat(desktop): Settings security center (PIN change / recovery / reset)
Drops the manual backup-string flow; replaces it with PIN change,
recovery-code regeneration, and identity reset (all sealed via the new
user_keys table).
2026-05-15 23:09:05 +02:00
byGalax 02e1af4517 feat(desktop): DevicePage routes to UserKeySetup or UserKeyUnlock 2026-05-15 23:03:02 +02:00
byGalax e292df82f0 feat(desktop): UserKeyUnlock screen (PIN entry + recovery fallback) 2026-05-15 23:00:18 +02:00
byGalax b3804d805b feat(desktop): UserKeySetup screen (PIN + optional recovery code) 2026-05-15 22:57:57 +02:00
byGalax 02c4bb1c9b feat(desktop): shared PinInput component 2026-05-15 22:54:58 +02:00
byGalax 20216b37c6 refactor(desktop): AuthContext exposes userKeyState instead of device record
Replaces the per-device DeviceRecord lookup with a per-user discriminated
union (loading | needs-setup | needs-unlock | unlocked). Heartbeat block
deleted (telemetry no longer device-bound); webPush keyed by install-id.
2026-05-15 22:52:41 +02:00
byGalax 2c586351fc fix(shared): expose ./crypto/testBackend in package exports
Removes the @shared Vite-alias workaround in userIdentity.test.ts so
tsc can resolve the import without an extra paths entry.
2026-05-15 22:43:24 +02:00
byGalax b89a7e2617 refactor: swap device-id contexts for user-id contexts at call sites
Renames DecryptParams.ownDeviceId to ownUserId so decryptMessages actually
looks up bundles by user. Sweeps remaining OwnDeviceCtx and
loadDevicePrivateKey consumers in the desktop app to use cachedUserKey
from userIdentity. Files scheduled for deletion in later tasks
(BackupExportDialog, DeviceRestore, BackupRestoreDialog, BackupPromptBanner,
deviceBackup, DeviceRegistration) are left untouched.
2026-05-15 22:41:44 +02:00
byGalax 8d69329763 feat(desktop): user-identity orchestrator (setup/unlock/cache/change-PIN/reset) 2026-05-15 22:29:34 +02:00
byGalax 2fdbe9ee3a feat(shared): migrate legacy per-device conv-key bundles to per-user 2026-05-15 22:22:08 +02:00
byGalax 0d1d4496c3 refactor(shared): conv-keys target user-id instead of device-id 2026-05-15 22:17:02 +02:00
byGalax baf9c2e054 feat(shared): user-key DB wrappers (fetch/upload/unlock/attempt/reset) 2026-05-15 22:12:26 +02:00
byGalax b54fe0b56d test(shared): add mock supabase client helper for auth unit tests 2026-05-15 22:07:53 +02:00
byGalax 89eb8d97c6 feat(db): user-key RPCs (unlock, attempt, reset, migrate, share v2)
Note: not applied locally — push via pnpm prod:migrate when ready.
2026-05-15 22:06:32 +02:00
byGalax 32bff77fd0 feat(db): conversation_keys can target user_id alongside legacy device_id
Note: not applied locally — push via pnpm prod:migrate when ready.
2026-05-15 22:02:29 +02:00
byGalax d10b0fb5c3 feat(db): add user_keys table with RLS and public-key view
Note: not applied locally — push via pnpm prod:migrate when ready.
2026-05-15 22:00:52 +02:00
byGalax 1370f8794b feat(shared): seal/open user private key with PIN-derived Argon2id KEK 2026-05-15 21:58:53 +02:00
byGalax eef884c782 feat(shared): lift recovery-code primitives into shared crypto module 2026-05-15 21:54:57 +02:00
byGalax d9f3c6c562 fix(mobile): enable Metro package-exports resolution for @chat-app/shared
apps/mobile/lib/supabase.ts imports '@chat-app/shared/supabase'. The
shared package declares this subpath in its package.json "exports"
map. The dev Metro resolver respected it; the eager exporter used by
preview/production builds defaulted to legacy resolution and 404'd on
the subpath.

unstable_enablePackageExports=true makes Metro use Node's modern
exports-aware resolver in both modes. Despite the 'unstable_' prefix,
it's the recommended setting in Expo SDK 52 monorepos.
2026-05-15 02:00:29 +02:00
byGalax b61f929cf7 fix(shared): drop .js extensions from relative imports for Metro
packages/shared/src/index.ts and all sub-modules used .js extensions on
relative imports (e.g. './admin/index.js') pointing at .ts source files.
TypeScript with moduleResolution: "Bundler" doesn't need them, and
Metro's eager exporter (used for preview / production builds) reads
them literally and fails — only the dev-server Metro fell back to .ts.

Workspace typecheck remains 8/8 green; Vite and TS Bundler resolution
already accept both styles, so desktop is unaffected.
2026-05-15 01:52:14 +02:00
byGalax b0967dd2c5 fix(mobile): switch to shamefully-hoist=true for RN flat node_modules
Selective public-hoist patterns weren't enough — RN's index.js imports
invariant (and nullthrows, pretty-format, regenerator-runtime, and many
more) as bare transitive deps. Each fresh EAS build was hitting a
different missing-module error.

shamefully-hoist=true is Expo's official monorepo workaround: pnpm
mirrors npm/yarn's flat layout at the workspace root, so every package's
deps are reachable via standard Node resolution. Desktop (Vite-bundled)
is unaffected.
2026-05-15 00:34:14 +02:00
byGalax f8859b4601 fix(mobile): hoist expo + correct @expo/metro-runtime version for SDK 52
PackageList.java generated by RN autolinking imported expo.core.ExpoModulesPackage
(old SDK <48 name). Root cause: RN CLI couldn't read expo's react-native.config.js
through the pnpm symlink (it lives at .pnpm/expo@52..../expo/react-native.config.js),
so it fell back to deriving the import path from expo's Android gradle namespace
("expo.core") + assumed class name (ExpoModulesPackage), producing a path that
doesn't exist (the actual class lives at expo.modules.ExpoModulesPackage).

Added "expo" to .npmrc public-hoist-pattern so RN's autolinking can resolve
expo's react-native.config.js via the standard node_modules lookup. That config
explicitly sets packageImportPath to the correct expo.modules.ExpoModulesPackage.

Also corrected @expo/metro-runtime from 55.0.11 (added blindly earlier) to ~4.0.1
which is the SDK-52-aligned version.
2026-05-14 21:23:54 +02:00
byGalax 4068e8c7b6 fix(mobile): hoist RN/Expo transitive deps for Metro resolution
pnpm's default strict isolation hides @babel/runtime/helpers/* and other
transitive deps from Metro, which expects flat node_modules layout.
Each missing dep was a separate failed EAS build (metro-runtime,
@babel/runtime, ...) — added .npmrc with public-hoist-pattern entries
that catch the common RN/Expo families so we stop whack-a-moling them.
Also kept @babel/runtime as direct dep of @chat-app/mobile for safety.
2026-05-14 21:14:41 +02:00
byGalax cb8e729624 fix(mobile): add @expo/metro-runtime, drop deprecated expo-router/babel
expo-router@4 imports @expo/metro-runtime as the very first thing in its
entry-classic.js so Fast Refresh works; bundler errored without it.
Also dropped expo-router/babel from babel.config.js — its functionality
moved into babel-preset-expo in SDK 50.
2026-05-14 16:46:31 +02:00
byGalax 1e9395fdc2 fix(mobile): align expo-* package versions with SDK 52
Phase 0 installed expo-application/expo-image-picker/expo-dev-client at
^55.x assuming the major version matched the SDK number. It doesn't —
each expo-* package has its own major. Misaligned versions pulled in
expo-modules-core@2.2.3 which expects an API that SDK 52's gradle plugin
host doesn't provide, breaking the Gradle build with
'expo-module-gradle-plugin not found'.

`expo install --fix` aligned everything to SDK 52 baselines:
  expo-application 55.0.15 -> ~6.0.2
  expo-dev-client  55.0.33 -> ~5.0.20
  expo-image-picker 55.0.20 -> ~16.0.6
  expo-sqlite      15.0.6  -> ~15.1.4
  react-native     0.76.0  -> 0.76.9
  + RN companion libs (gesture-handler, screens, async-storage)
2026-05-14 16:21:31 +02:00
byGalax 9b3ef8c24f feat(mobile): add icon/adaptive-icon/splash placeholders (reused from desktop) 2026-05-14 14:05:00 +02:00
byGalax ba8ba75490 feat(mobile): add expo-dev-client for EAS development builds 2026-05-14 12:49:25 +02:00
byGalax 122191276e fix(mobile): use @config-plugins/react-native-webrtc, link EAS project
- replace bogus @livekit/* plugin entries with @config-plugins/react-native-webrtc@^10 (real Expo config plugin; LiveKit packages do not ship app.plugin.js)
- eas init linked project @bygalax/netralax (id 255e2fde-...)
- app.json picked up Expo defaults (Android permissions, owner)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:44:24 +02:00
byGalax 5a3dc15704 chore(mobile): extract ENDED_STATE_LINGER_MS constant
The 2500ms timer that drifts CallState from `ended` back to `idle` was
an inline magic number. Promote to a module-level constant with a
comment explaining why the value isn't arbitrary — picked from the
post-Phase-3 quality review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:24:05 +02:00
byGalax 10d3e91bbb chore(mobile): restore German umlauts + ellipsis in call screen labels
The Phase-3 implementer ASCII'd four strings to side-step a Windows
console encoding issue during the apply step. The TypeScript / Metro
toolchain handles non-ASCII string literals cleanly — the workaround
was unnecessary and produced ugly UI labels ("Anruf lauft", "Hoerer",
"Verbinde ..."). Restore the originals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:16:40 +02:00
byGalax 04da0f211d feat(mobile): phone-icon header button + navigate to /call on connect 2026-05-14 06:14:17 +02:00
byGalax 4d787b68b4 feat(mobile): in-call screen with participants + toolbar 2026-05-14 06:13:25 +02:00
byGalax c2df741055 feat(mobile): register /call full-screen modal route 2026-05-14 06:12:50 +02:00
byGalax b81c63a2d3 feat(mobile): mount CallProvider + global IncomingCallModal 2026-05-14 06:12:20 +02:00
byGalax 098e4f4c1d feat(mobile): IncomingCallModal with Annehmen/Ablehnen + name resolution 2026-05-14 06:11:45 +02:00
byGalax e83c23d698 feat(mobile): CallProvider with state machine + LiveKit room lifecycle 2026-05-14 06:11:00 +02:00
byGalax 68cd4108dd feat(mobile): callSignal subscribe + broadcast helpers 2026-05-14 06:06:53 +02:00
byGalax ed7cb72ebe chore(mobile): add LiveKit RN deps + mic permission + audio bg mode 2026-05-14 06:06:07 +02:00
byGalax 177a4c059f docs(mobile): phase 3 spec + plan — voice calls
9 tasks adding voice calls (1:1 + group) to the mobile app over the
same LiveKit + Supabase signaling stack the desktop uses:

  1. @livekit/react-native + @livekit/react-native-webrtc deps, mic
     permission strings, audio background mode, LiveKit Expo plugin.
  2. callSignal.ts subscribe/publish helpers over Supabase realtime.
  3. callContext.tsx state machine (idle/outgoing/incoming/connecting/
     connected/ended) + LiveKit room lifecycle + audio routing.
  4. IncomingCallModal at root with Annehmen/Ablehnen.
  5. Mount CallProvider + global IncomingCallModal in _layout.tsx.
  6. Register /call full-screen modal route.
  7. In-call screen with participants list + mute/speaker/hangup.
  8. Phone-icon header button on conversation detail + push to /call
     on connect.
  9. Workspace typecheck pass.

Out of scope: video, CallKit / ConnectionService native UI, VoIP push
wake-up, screen sharing, call history. Those are Phase 3.5 / 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:04:20 +02:00
byGalax 1440b11740 feat(mobile): wire image attachments, reactions, reply, delete into conversation detail 2026-05-14 00:22:18 +02:00
byGalax 286836f539 feat(mobile): MessageBubble with reply, attachment, reactions, delete state 2026-05-14 00:21:11 +02:00
byGalax 3b62fbc243 feat(mobile): MessageActionsSheet bottom modal — react/reply/delete 2026-05-14 00:20:08 +02:00
byGalax 8d7ed592b8 feat(mobile): ReactionStrip quick-reactor + ReactionPills display 2026-05-14 00:19:15 +02:00
byGalax 153e40bdcb feat(mobile): AttachmentImage component with on-demand decrypt + cache 2026-05-14 00:18:11 +02:00
byGalax 415fa10ed0 feat(mobile): in-memory attachment cache by handle id 2026-05-14 00:16:49 +02:00
byGalax 8b7b6a3b0f feat(mobile): imagePicker helper with library + camera flows 2026-05-14 00:16:25 +02:00
byGalax 05a17b7c1c chore(mobile): add expo-image-picker dep + permission strings 2026-05-14 00:15:40 +02:00
byGalax dd6ae63491 docs(mobile): phase 2 spec + plan — image attachments, reactions, reply, delete
9 tasks taking the mobile chat from text-only to feature parity on the
high-impact subset of the desktop's messaging surface:

  1. Add expo-image-picker dep + permission strings in app.json.
  2. imagePicker.ts helper for library + camera capture.
  3. attachmentCache.ts in-memory data-URL cache.
  4. AttachmentImage component with on-demand decrypt.
  5. ReactionStrip (6 emojis) + ReactionPills (count badges).
  6. MessageActionsSheet long-press modal (react/reply/delete).
  7. MessageBubble extraction with reply quote + deleted state.
  8. Wire everything into conversations/[id].tsx.
  9. Workspace typecheck pass.

Explicitly out of scope: file attachments, voice messages, edit,
forward, read receipts, typing, polls. Those move to Phase 2.5 / later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:14:39 +02:00
byGalax b1cdc4d184 chore(mobile): phase 1 quality-review fixups
Three small follow-ups from the post-Phase-1 quality review:

  * authContext.tsx — drop the dead `userId` extraction + the `void
    userId` suppressor that masked an unused-locals warning. The session
    is already implicitly threaded through the supabase client, so no
    consumer of ensureDevice needed the value.
  * authContext.tsx — switch the device-name string concat to a
    template literal for consistency with the rest of the codebase.
  * ErrorBoundary.tsx — replace the four inline hex literals with their
    `theme/colors.ts` constants. The boundary was authored in Phase 0
    before the theme module existed; this brings it in line with every
    Phase 1 screen.
  * apps/mobile/README.md — drop the stale Phase-0 paragraph about the
    `lib/sharedSmoke.ts` canary (deleted in Phase 1) and add a short
    pointer to the env-var setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:08:22 +02:00
byGalax 263d2455a8 feat(mobile): conversation detail with decrypt + send 2026-05-14 00:01:48 +02:00
byGalax 3755a927c3 feat(mobile): real chat list with pull-to-refresh + logout 2026-05-14 00:00:24 +02:00
byGalax 0b3a6280fc feat(mobile): Avatar + ConversationRow + relative-time helper 2026-05-13 23:59:53 +02:00
byGalax 3313dd84f3 feat(mobile): auth callback screen consumes magic-link tokens 2026-05-13 23:58:52 +02:00
byGalax 2a5f836b86 feat(mobile): magic-link login screen 2026-05-13 23:58:15 +02:00
byGalax bf02380c19 feat(mobile): session-gated (app) layout redirects to login when signed out 2026-05-13 23:57:43 +02:00
byGalax 375d28efa5 feat(mobile): AuthProvider + crypto-backend registration; drop sharedSmoke canary 2026-05-13 23:57:18 +02:00
byGalax 1e88941796 feat(mobile): supabase client wired through async-storage session 2026-05-13 23:55:35 +02:00
byGalax 7ac93a95e6 feat(mobile): crypto + secret-store + session-storage adapters 2026-05-13 23:55:07 +02:00
byGalax a275703ba6 feat(mobile): add AsyncStorage dep + env reader + theme constants 2026-05-13 23:54:10 +02:00
byGalax 035b5f078d docs(mobile): phase 1 spec + plan — Auth + Chat MVP
11 tasks taking the mobile app from Phase 0 shell to a working
end-to-end magic-link login + conversation list + decrypted detail +
text-send. All renderer-only, plumbed through @chat-app/shared's
CryptoBackend / SecretStore / Supabase-session-storage interfaces:

  1. Foundation deps (AsyncStorage), env reader, theme constants.
  2. Crypto + secret-store + session-storage adapters.
  3. Supabase client.
  4. AuthProvider + cleanup of Phase-0 sharedSmoke canary.
  5. Session-gated (app) layout.
  6. Magic-link login screen.
  7. Auth-callback deep-link screen.
  8. Avatar + ConversationRow + timeFormat primitives.
  9. Conversation list with pull-to-refresh + logout.
 10. Conversation detail with decrypt + send.
 11. Workspace-wide typecheck pass.

Push notifications, realtime, attachments, voice messages, edits,
reactions, calls are explicitly deferred to later phases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:52:37 +02:00
byGalax 13c6ad68f0 docs(mobile): phase 0 quickstart + roadmap pointer 2026-05-13 23:38:05 +02:00
byGalax 84a9f35ed8 chore(repo): add mobile:typecheck convenience script 2026-05-13 23:37:26 +02:00
byGalax 7eb8224bb0 feat(mobile): Netralax-branded Chats placeholder screen 2026-05-13 23:36:58 +02:00
byGalax 574436ab71 feat(mobile): Netralax landing screen with runtime version + nav smoke test 2026-05-13 23:36:31 +02:00
byGalax 5e483368ec feat(mobile): wrap root layout in GestureHandler + SafeArea + ErrorBoundary 2026-05-13 23:35:51 +02:00
byGalax b4457b74f5 chore(mobile): add runtime canary import from @chat-app/shared 2026-05-13 23:35:17 +02:00
byGalax bb7aeec345 feat(mobile): add ErrorBoundary for render-error fallback 2026-05-13 23:34:46 +02:00
byGalax 1bf4f4bb83 chore(mobile): add expo-application + react-native-gesture-handler for phase 0 shell 2026-05-13 23:34:10 +02:00
byGalax 3049a0e39d chore(mobile): add eas.json with development/preview/production build profiles 2026-05-13 23:33:47 +02:00
byGalax 2003a26c23 chore(mobile): rebrand app.json to Netralax + cloud.netralax.app bundle ids 2026-05-13 23:33:24 +02:00
byGalax 1e0cf67335 docs(mobile): phase 0 implementation plan
11 tasks, all renderer-only changes inside apps/mobile:
  1. app.json rebrand (Netralax, cloud.netralax.app ids).
  2. eas.json with development/preview/production profiles.
  3. Add expo-application + react-native-gesture-handler deps.
  4-5. ErrorBoundary component + sharedSmoke runtime canary.
  6-8. Root layout (SafeArea + GestureHandler + ErrorBoundary), Netralax
       landing screen with runtime version, Chats placeholder.
  9-10. Root mobile:typecheck script + README quickstart.
  11. Manual on-device verification handed back to the user.

Asset replacement (icon/splash) is deferred to Phase 4 — the dev client
doesn't care about icon polish.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:31:39 +02:00
byGalax 5f753412a6 docs(mobile): roadmap + phase-0 deployment foundation design
Mobile shipping is decomposed into 5 phases:
  0. Deployment Foundation — Netralax brand on a runnable dev build.
  1. Auth + Chat MVP — magic-link login, conversation list, text send.
  2. Messaging Features — attachments, voice messages, reactions.
  3. Voice/Video Calls — LiveKit RN + CallKit/ConnectionService.
  4. Polish + Store Submission — TestFlight, Play, signing.

Phase 0 spec lays out the concrete file changes:
  * app.json rename to Netralax + cloud.netralax.app bundle/package.
  * New eas.json with development/preview/production profiles.
  * SafeAreaProvider + GestureHandlerRootView + ErrorBoundary in the
    root layout, Netralax landing screen with runtime app version.
  * sharedSmoke.ts runtime import to verify Metro can resolve
    @chat-app/shared (which already has CryptoBackend/SecretStore
    interfaces designed for mobile adapters).
  * README quickstart for `eas init` + first dev client build.

No code changes here — just the planning surface. Implementation plan
follows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:27:52 +02:00
byGalax a560f24c45 chore(desktop): release v0.17.5 2026-05-13 22:35:43 +02:00
byGalax 87c2e45bb4 fix(preload): read appVersion from package.json instead of npm_package_version
`appVersion: process.env.npm_package_version ?? '0.0.0'` only worked in
dev — pnpm sets that env var while running its lifecycle scripts. In
the packaged Electron build npm_package_version is unset, so every
installed user saw `v0.0.0` on the Changelog page and the version badge
permanently flagged them as "Update verfügbar" against their own
actually-current version.

Replace with a static `import pkg from '../package.json'` so Vite
inlines the version string into the preload bundle at build time. The
release script bumps package.json before electron-builder runs, so the
inlined value always matches the freshly-released version.

resolveJsonModule + esModuleInterop are already on in tsconfig.node.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:33:18 +02:00
127 changed files with 15191 additions and 2444 deletions
+11
View File
@@ -0,0 +1,11 @@
# React Native / Expo expects a flat (npm/yarn-style) node_modules layout
# where ALL transitive deps are reachable from any package. pnpm's strict
# isolation breaks that — Metro hits a fresh missing-module error for every
# transitive dep (invariant, nullthrows, @babel/runtime/helpers/*, ...).
#
# shamefully-hoist=true hoists everything to the workspace-root node_modules
# (like npm/yarn) so all transitive deps are reachable via standard Node
# resolution. This is the official Expo monorepo workaround and is the
# only sustainable answer for Expo + RN + pnpm. Desktop (Electron+Vite)
# bundles via Vite, so the flatter layout doesn't affect it.
shamefully-hoist=true
+8 -1
View File
@@ -28,6 +28,13 @@ import {
type UpdateCheckResult,
type UpdateProgress,
} from './ipc-types';
// Static import of the desktop package.json so the bundler inlines the
// version string at build time. The previous `process.env.npm_package_-
// version` approach worked in dev (pnpm sets it as a script env var) but
// fell back to '0.0.0' in packaged builds — every installed user got
// flagged as "Update verfügbar" against their own actually-current
// version. resolveJsonModule + esModuleInterop are on in tsconfig.node.
import pkg from '../package.json';
type Unsubscribe = () => void;
@@ -40,7 +47,7 @@ function on<T>(channel: string, cb: (payload: T) => void): Unsubscribe {
const api = {
platform: ELECTRON_RUNTIME_MARKER,
osPlatform: process.platform as NodeJS.Platform,
appVersion: process.env.npm_package_version ?? '0.0.0',
appVersion: pkg.version,
// Screen sources ---------------------------------------------------------
getScreenSources: (): Promise<ScreenSource[]> => ipcRenderer.invoke(CHANNELS.SCREEN_GET_SOURCES),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chat-app/desktop",
"version": "0.17.4",
"version": "0.18.8",
"private": true,
"description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module",
+14 -11
View File
@@ -1,19 +1,18 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { startConversationKeySync } from '../lib/conversationKeySync';
import { startDeviceApprovalListener } from '../lib/deviceApproval';
import { ensureInstallId } from '../lib/installId';
import { ensureNotificationPermission } from '../lib/osNotify';
import { devLocalSecretStore } from '../lib/secretStore';
import { BackupPromptBanner } from './BackupPromptBanner';
import { cachedUserKey } from '../lib/userIdentity';
import { CallUI } from './CallUI';
import { DeviceApprovalBanner } from './DeviceApprovalBanner';
import { Sidebar } from './Sidebar';
export function AppShell() {
const { session, device } = useAuth();
const { session } = useAuth();
useEffect(() => {
// Prompt once per authenticated shell mount. Module-level guard prevents
// re-asking if the user already responded this session.
@@ -21,20 +20,25 @@ export function AppShell() {
}, []);
useEffect(() => {
if (!session?.user.id || !device?.id) return;
if (!session?.user.id) return;
const userId = session.user.id;
const deviceId = device.id;
const stopKeySync = startConversationKeySync(userId, deviceId);
// Per-install id replaces the per-device record now that crypto is
// user-keyed. The legacy conv-key-sync and device-approval subsystems
// still take a "deviceId" argument for telemetry / row identity; passing
// the install id keeps those wires intact until they are themselves
// reworked in later tasks (Task 16/17/19 territory).
const installId = ensureInstallId();
const stopKeySync = startConversationKeySync(userId, installId);
const stopApproval = startDeviceApprovalListener({
ownUserId: userId,
ownDeviceId: deviceId,
getPriv: () => loadDevicePrivateKey(devLocalSecretStore, userId, deviceId),
ownDeviceId: installId,
getPriv: () => cachedUserKey(userId),
});
return () => {
stopKeySync();
stopApproval();
};
}, [session?.user.id, device?.id]);
}, [session?.user.id]);
return (
<div className="relative flex min-h-screen overflow-hidden bg-surface text-fg">
@@ -48,7 +52,6 @@ export function AppShell() {
</main>
</div>
<CallUI />
<BackupPromptBanner />
<DeviceApprovalBanner />
</div>
);
@@ -1,306 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { type BackupBundle, exportDeviceBackupWithRecovery } from '../lib/deviceBackup';
import { AlertIcon, CopyIcon, LockIcon, ShieldIcon, SpinnerIcon, XIcon } from './icons';
interface Props {
open: boolean;
userId: string;
deviceId: string;
privateKey: Uint8Array;
onClose: () => void;
}
// Exports the device's private key + identity into a passphrase-protected
// portable string. The user can store this string anywhere (password manager,
// printed paper, encrypted file on a USB stick). Without it, losing local
// storage on this install means losing all past conversation keys.
export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose }: Props) {
const { t } = useTranslation(['app']);
const [passphrase, setPassphrase] = useState('');
const [confirm, setConfirm] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [bundle, setBundle] = useState<BackupBundle | null>(null);
const [copied, setCopied] = useState(false);
const [copiedRecovery, setCopiedRecovery] = useState(false);
const canGenerate = useMemo(() => {
return passphrase.length >= 8 && passphrase === confirm && !busy;
}, [passphrase, confirm, busy]);
const reset = useCallback(() => {
setPassphrase('');
setConfirm('');
setBundle(null);
setError(null);
setCopied(false);
setCopiedRecovery(false);
}, []);
const handleClose = useCallback(() => {
reset();
onClose();
}, [reset, onClose]);
const handleGenerate = useCallback(async () => {
if (!canGenerate) return;
setBusy(true);
setError(null);
try {
const b = await exportDeviceBackupWithRecovery({
userId,
deviceId,
privateKey,
passphrase,
});
setBundle(b);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}, [canGenerate, userId, deviceId, privateKey, passphrase]);
const copyText = async (text: string, marker: 'main' | 'recovery'): Promise<void> => {
try {
await navigator.clipboard.writeText(text);
if (marker === 'main') {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} else {
setCopiedRecovery(true);
window.setTimeout(() => setCopiedRecovery(false), 1500);
}
} catch {
/* fall back — user can select manually */
}
};
const handleDownload = useCallback(() => {
if (!bundle) return;
const text =
'=== Passphrase backup ===\n' +
bundle.passphraseBackup +
'\n\n=== Recovery code ===\n' +
bundle.recoveryCode +
'\n\n=== Recovery backup (use with the recovery code) ===\n' +
bundle.recoveryBackup +
'\n';
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chatapp-device-backup-${deviceId.slice(0, 8)}.txt`;
a.click();
URL.revokeObjectURL(url);
}, [bundle, deviceId]);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('app:backup.export_title', { defaultValue: 'Gerätesicherung erstellen' })}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={handleClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
>
<header className="flex items-center justify-between border-b border-line px-5 py-3">
<div className="flex items-center gap-2">
<ShieldIcon className="h-4 w-4 text-accent" />
<h3 className="font-display text-sm font-semibold text-fg">
{t('app:backup.export_title', { defaultValue: 'Gerätesicherung erstellen' })}
</h3>
</div>
<button
type="button"
onClick={handleClose}
aria-label="Close"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
</header>
<div className="flex-1 overflow-y-auto p-5">
{!bundle ? (
<>
<p className="text-sm text-fg-muted">
{t('app:backup.export_explainer', {
defaultValue:
'Verschlüssele den Geräteschlüssel mit einer Passphrase. Ohne Passphrase UND Backup-String ist keine Wiederherstellung möglich.',
})}
</p>
<div className="mt-4 space-y-3">
<div className="space-y-1">
<label className="block text-xs font-medium uppercase tracking-wide text-fg-muted">
{t('app:backup.passphrase', { defaultValue: 'Passphrase' })}
</label>
<input
type="password"
autoFocus
minLength={8}
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder="min. 8 Zeichen"
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/>
</div>
<div className="space-y-1">
<label className="block text-xs font-medium uppercase tracking-wide text-fg-muted">
{t('app:backup.passphrase_confirm', { defaultValue: 'Passphrase wiederholen' })}
</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/>
</div>
{confirm.length > 0 && confirm !== passphrase && (
<p className="text-xs text-rose-500 dark:text-rose-300">
{t('app:backup.passphrase_mismatch', { defaultValue: 'Passphrasen stimmen nicht überein.' })}
</p>
)}
</div>
<div className="mt-4 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-200">
<div className="flex items-start gap-2">
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<span>
{t('app:backup.export_warning', {
defaultValue:
'Anthropic: Backup + Passphrase sicher aufbewahren. Passphrase kann nicht wiederhergestellt werden.',
})}
</span>
</div>
</div>
{error && (
<p role="alert" className="mt-3 text-sm text-rose-600 dark:text-rose-200">
{error}
</p>
)}
</>
) : (
<>
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-xs text-emerald-700 dark:text-emerald-200">
<div className="flex items-start gap-2">
<LockIcon className="mt-0.5 h-4 w-4 shrink-0" />
<span>
{t('app:backup.export_success', {
defaultValue:
'Backup erstellt. Speichere diesen String + Passphrase in einem Passwortmanager oder drucke ihn aus.',
})}
</span>
</div>
</div>
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
Backup-String
</label>
<textarea
readOnly
value={bundle.passphraseBackup}
rows={6}
onFocus={(e) => e.currentTarget.select()}
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
/>
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={() => void copyText(bundle.passphraseBackup, 'main')}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
>
<CopyIcon className="h-4 w-4" />
<span>{copied ? 'Kopiert!' : 'Kopieren'}</span>
</button>
<button
type="button"
onClick={handleDownload}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
>
Als Datei speichern (alles)
</button>
</div>
<div className="mt-5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
<div className="flex items-start gap-2">
<ShieldIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-amber-700 dark:text-amber-200">
Recovery-Code (Passphrase vergessen?)
</p>
<p className="mt-0.5 text-[11px] text-amber-700/80 dark:text-amber-200/80">
Code separat aufbewahren. Mit dem Recovery-Backup unten lässt sich der Schlüssel
ohne Passphrase wiederherstellen.
</p>
<p className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest text-fg">
{bundle.recoveryCode}
</p>
</div>
</div>
</div>
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
Recovery-Backup-String
</label>
<textarea
readOnly
value={bundle.recoveryBackup}
rows={6}
onFocus={(e) => e.currentTarget.select()}
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
/>
<button
type="button"
onClick={() => void copyText(bundle.recoveryBackup, 'recovery')}
className="mt-2 inline-flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
>
<CopyIcon className="h-4 w-4" />
<span>{copiedRecovery ? 'Kopiert!' : 'Recovery-Backup kopieren'}</span>
</button>
</>
)}
</div>
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
{!bundle ? (
<>
<button
type="button"
onClick={handleClose}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
>
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
</button>
<button
type="button"
onClick={() => void handleGenerate()}
disabled={!canGenerate}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
<span>{t('app:backup.generate', { defaultValue: 'Backup erstellen' })}</span>
</button>
</>
) : (
<button
type="button"
onClick={handleClose}
className="cursor-pointer rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110"
>
{t('app:backup.done', { defaultValue: 'Fertig' })}
</button>
)}
</footer>
</div>
</div>
);
}
@@ -1,122 +0,0 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { devLocalSecretStore } from '../lib/secretStore';
import { BackupExportDialog } from './BackupExportDialog';
import { ShieldIcon, XIcon } from './icons';
const DISMISS_KEY = 'chatapp.backup.prompt.dismissed';
const SESSION_KEY = 'chatapp.backup.prompt';
// Post-registration nudge: right after a fresh device provision we set
// `chatapp.backup.prompt` in sessionStorage. This component reads it and
// shows a floating "mach jetzt ein Backup" banner until the user either
// creates one or explicitly dismisses (persisted in localStorage so we stop
// nagging across reloads).
export function BackupPromptBanner() {
const { t } = useTranslation(['app']);
const { profile, device } = useAuth();
const [visible, setVisible] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
useEffect(() => {
try {
if (window.localStorage.getItem(DISMISS_KEY) === '1') return;
if (window.sessionStorage.getItem(SESSION_KEY) !== '1') return;
setVisible(true);
} catch {
/* storage unavailable */
}
}, []);
const dismiss = useCallback((persist: boolean) => {
setVisible(false);
try {
window.sessionStorage.removeItem(SESSION_KEY);
if (persist) window.localStorage.setItem(DISMISS_KEY, '1');
} catch {
/* ignore */
}
}, []);
const openDialog = useCallback(async () => {
if (!profile?.userId || !device?.id) return;
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
if (!priv) return;
setPrivateKey(priv);
setDialogOpen(true);
}, [profile, device]);
const closeDialog = useCallback(() => {
setDialogOpen(false);
if (privateKey) {
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
}
setPrivateKey(null);
// After the user interacts with the dialog, drop the banner regardless
// of whether they actually completed the backup — they're aware now.
dismiss(true);
}, [privateKey, dismiss]);
if (!visible) return null;
return (
<>
<div
role="status"
className="fixed bottom-6 left-1/2 z-40 flex w-[min(92vw,520px)] -translate-x-1/2 items-start gap-3 rounded-2xl border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-800 shadow-xl backdrop-blur-md dark:text-amber-100"
>
<ShieldIcon className="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="min-w-0 flex-1">
<p className="font-semibold">
{t('app:backup.prompt_title', { defaultValue: 'Erstelle jetzt ein Geräte-Backup' })}
</p>
<p className="mt-0.5 text-xs text-amber-700/90 dark:text-amber-200/90">
{t('app:backup.prompt_body', {
defaultValue:
'Ohne Backup verlierst du Zugriff auf alte Nachrichten, wenn Browser oder Gerät ihren Speicher verlieren. Dauert 10 Sekunden.',
})}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => void openDialog()}
className="cursor-pointer rounded-md bg-amber-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-500"
>
{t('app:backup.prompt_create', { defaultValue: 'Jetzt erstellen' })}
</button>
<button
type="button"
onClick={() => dismiss(true)}
className="cursor-pointer rounded-md border border-amber-500/40 bg-transparent px-3 py-1.5 text-xs font-semibold text-amber-700 transition hover:bg-amber-500/15 dark:text-amber-200"
>
{t('app:backup.prompt_never', { defaultValue: 'Nicht mehr fragen' })}
</button>
</div>
</div>
<button
type="button"
onClick={() => dismiss(false)}
aria-label={t('app:backup.prompt_dismiss', { defaultValue: 'Später' })}
className="cursor-pointer text-amber-600/70 transition hover:text-amber-600 dark:text-amber-200/70 dark:hover:text-amber-200"
>
<XIcon className="h-4 w-4" />
</button>
</div>
{dialogOpen && profile?.userId && device?.id && privateKey && (
<BackupExportDialog
open={dialogOpen}
userId={profile.userId}
deviceId={device.id}
privateKey={privateKey}
onClose={closeDialog}
/>
)}
</>
);
}
@@ -1,86 +0,0 @@
import { useEffect } from 'react';
import { DeviceRestore } from './DeviceRestore';
import { AlertIcon, ShieldIcon, XIcon } from './icons';
interface Props {
open: boolean;
userId: string;
onClose: () => void;
}
// Modal wrapper around DeviceRestore for the already-signed-in case. A
// successful restore swaps the local device-identity for the one embedded
// in the backup string — the app then hard-reloads so every hook
// re-initialises against the restored keys (simpler than invalidating
// supabase-realtime subscriptions, stronghold caches, livekit rooms, etc.
// individually).
export function BackupRestoreDialog({ open, userId, onClose }: Props) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', onKey);
document.body.style.overflow = prev;
};
}, [open, onClose]);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="Backup wiederherstellen"
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-6 backdrop-blur-sm"
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-md flex-col gap-4"
>
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-ink-900/70 p-3 backdrop-blur-xl">
<div className="flex items-center gap-2">
<ShieldIcon className="h-4 w-4 text-brand-300" />
<h3 className="text-sm font-semibold text-white">
Gerät aus Backup wiederherstellen
</h3>
</div>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-white"
>
<XIcon className="h-4 w-4" />
</button>
</div>
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-100">
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
<p className="min-w-0 flex-1">
Restore ersetzt das aktuelle Gerät durch das aus dem Backup.
Die App lädt danach neu. Nachrichten, die auf diesem Gerät seit
dem Backup eingegangen sind, sind erst wieder lesbar, nachdem
Peer-Geräte den Conversation-Key erneut für die wiederhergestellte
Device-ID wrappen.
</p>
</div>
<DeviceRestore
userId={userId}
onRestored={() => {
// Hard reload — cleanest way to reset every hook, supabase
// realtime channel, stronghold handle, and cached state.
window.location.reload();
}}
/>
</div>
</div>
);
}
@@ -0,0 +1,118 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useCallPresence } from '../lib/useCallPresence';
import { Avatar } from './Avatar';
import { PhoneIcon, SpinnerIcon, XIcon } from './icons';
interface Props {
conversation: ConversationSummary;
}
const MAX_TILES = 7;
/**
* Discord-DM-style call preview panel. Renders only while peers are in the
* conversation's active call and the local user is NOT in it. Provides large
* avatar tiles plus a single "Beitreten" call-to-action. Calls are still
* STARTED via the topbar phone icon (`ConversationHeader.startCall`); this
* component never initiates — only joins.
*/
export function CallPreviewPanel({ conversation }: Props) {
const { t } = useTranslation(['app']);
const { session } = useAuth();
const { state, joinActiveCall } = useCall();
const presentIds = useCallPresence(conversation.id);
const [collapsed, setCollapsed] = useState(false);
const myId = session?.user.id ?? null;
const iAmIn =
(state.kind === 'connected' ||
state.kind === 'connecting' ||
state.kind === 'reconnecting') &&
state.conversationId === conversation.id;
const others = presentIds.filter((u) => u !== myId);
if (iAmIn || others.length === 0) return null;
const visibleTiles = others.slice(0, MAX_TILES);
const overflow = Math.max(0, others.length - MAX_TILES);
const busy = state.kind !== 'idle';
const handleJoin = () => {
if (busy) return;
void joinActiveCall(conversation.id, 'audio');
};
return (
<div className="border-b border-line bg-surface-3/70">
<div className="flex items-center gap-2 px-5 py-2 text-xs">
<PhoneIcon className="h-3.5 w-3.5 text-emerald-500" />
<span className="font-semibold uppercase tracking-[0.12em] text-fg-muted">
{t('app:call.active_in_conv', {
defaultValue: 'Laufender Anruf · {{count}} im Raum',
count: others.length,
})}
</span>
<button
type="button"
onClick={() => setCollapsed((c) => !c)}
aria-label={collapsed ? 'Anrufvorschau ausklappen' : 'Anrufvorschau einklappen'}
className="ml-auto inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-2 hover:text-fg"
>
<XIcon className="h-3.5 w-3.5" />
</button>
</div>
{!collapsed && (
<div className="flex flex-col items-center gap-4 px-5 pb-4 pt-1">
<div className="grid w-full max-w-2xl grid-cols-2 gap-3 md:grid-cols-3">
{visibleTiles.map((id) => {
const member = conversation.members.find((m) => m.userId === id);
const name = member?.profile?.displayName ?? '?';
return (
<div
key={id}
title={name}
className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-2 p-3"
>
<div className="h-16 w-16 overflow-hidden rounded-full">
<Avatar
url={member?.profile?.avatarUrl ?? null}
displayName={name}
className="h-full w-full text-base"
/>
</div>
<span className="line-clamp-1 text-xs font-medium text-fg">{name}</span>
</div>
);
})}
{overflow > 0 && (
<div className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-line bg-surface-2 p-3 text-fg-muted">
<span className="text-xl font-semibold">+{overflow}</span>
<span className="text-xs">weitere</span>
</div>
)}
</div>
<button
type="button"
onClick={handleJoin}
disabled={busy}
className="inline-flex w-full max-w-xs cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? (
<SpinnerIcon className="h-4 w-4" />
) : (
<PhoneIcon className="h-4 w-4" />
)}
<span>{t('app:call.join', { defaultValue: 'Beitreten' })}</span>
</button>
</div>
)}
</div>
);
}
@@ -1,127 +0,0 @@
import type { DeviceRecord } from '@chat-app/shared/auth';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { detectDesktopPlatform, registerCurrentDevice } from '../lib/device';
import { AlertIcon, ArrowRightIcon, LockIcon, ShieldIcon, SpinnerIcon } from './icons';
interface Props {
userId: string;
defaultName: string;
onRegistered: (device: DeviceRecord) => void;
}
export function DeviceRegistration({ userId, defaultName, onRegistered }: Props) {
const { t } = useTranslation(['auth', 'errors']);
const nameId = useId();
const [name, setName] = useState(defaultName);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
setBusy(true);
setError(null);
try {
const device = await registerCurrentDevice({ userId, name: trimmed });
onRegistered(device);
} catch (err: unknown) {
const code = extractErrorCode(err);
if (code) {
setError(t(`errors:${code}`, { defaultValue: t('errors:generic') }));
} else if (err instanceof Error) {
setError(err.message);
} else {
setError(t('errors:generic'));
}
} finally {
setBusy(false);
}
},
[name, userId, onRegistered, t],
);
const platform = detectDesktopPlatform();
return (
<form
onSubmit={handleSubmit}
className="w-full max-w-md animate-slide-up rounded-2xl border border-white/10 bg-ink-900/70 p-7 shadow-glow backdrop-blur-xl sm:p-8"
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-brand-500/20 ring-1 ring-brand-400/30">
<LockIcon className="h-5 w-5 text-brand-300" />
</div>
<div>
<h2 className="font-display text-lg font-semibold text-white">{t('auth:device.title')}</h2>
<p className="text-xs text-neutral-400">{t('auth:device.subtitle')}</p>
</div>
</div>
<div className="mt-6 space-y-1.5">
<label
htmlFor={nameId}
className="block text-xs font-medium uppercase tracking-wide text-neutral-400"
>
{t('auth:device.name_label')}
</label>
<input
id={nameId}
type="text"
required
autoFocus
maxLength={64}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('auth:device.name_placeholder')}
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
<p className="text-xs text-neutral-500">{t('auth:device.name_hint')}</p>
</div>
<div className="mt-5 rounded-lg border border-amber-500/20 bg-amber-500/10 p-3 text-xs text-amber-200">
<div className="flex items-start gap-2">
<ShieldIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
<span className="min-w-0 flex-1 break-words">{t('auth:device.security_note_dev')}</span>
</div>
</div>
<button
type="submit"
disabled={busy || name.trim().length === 0}
aria-busy={busy}
className="group mt-6 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? (
<>
<SpinnerIcon className="h-4 w-4" />
<span>{t('auth:device.cta_loading')}</span>
</>
) : (
<>
<span>{t('auth:device.cta')}</span>
<ArrowRightIcon className="h-4 w-4 transition group-hover:translate-x-0.5" />
</>
)}
</button>
{error && (
<div
role="alert"
className="mt-4 flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
>
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<p className="min-w-0 flex-1 break-words">{error}</p>
</div>
)}
<p className="mt-4 text-center text-xs text-neutral-500">
{t('auth:device.device_platform', { defaultValue: 'Platform' })}: {platform}
</p>
</form>
);
}
@@ -1,190 +0,0 @@
import {
type DeviceRecord,
restoreDeviceFromServerRecord,
} from '@chat-app/shared/auth';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
decodePrivateKeyFromBackup,
importDeviceBackup,
normalizeRecoveryCode,
} from '../lib/deviceBackup';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import { writeLocalDeviceId } from '../lib/device';
import { AlertIcon, ArrowRightIcon, LockIcon, ShieldIcon, SpinnerIcon } from './icons';
interface Props {
userId: string;
onRestored: (device: DeviceRecord) => void;
}
// Restores a device from a user-provided backup string. The backup embeds
// userId + deviceId + X25519 private key; we verify userId matches the current
// session, confirm the device row still exists server-side, and then re-seed
// the local vault + cached deviceId so the app treats this install as the
// original device (conv-key bundles stay valid, no "awaiting" state).
export function DeviceRestore({ userId, onRestored }: Props) {
const { t } = useTranslation(['app', 'errors']);
const [backup, setBackup] = useState('');
const [passphrase, setPassphrase] = useState('');
const [mode, setMode] = useState<'passphrase' | 'recovery'>('passphrase');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!backup.trim() || passphrase.length < 1 || busy) return;
setBusy(true);
setError(null);
let privateKey: Uint8Array | null = null;
try {
const secret =
mode === 'recovery' ? normalizeRecoveryCode(passphrase) : passphrase;
const payload = await importDeviceBackup(backup.trim(), secret);
privateKey = decodePrivateKeyFromBackup(payload);
const device = await restoreDeviceFromServerRecord({
client: supabase,
secretStore: devLocalSecretStore,
userId: payload.userId,
deviceId: payload.deviceId,
privateKey,
});
// Cache deviceId locally so findExistingDevice picks it up on next load.
writeLocalDeviceId(userId, device.id);
onRestored(device);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
} finally {
if (privateKey) {
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
}
setBusy(false);
}
},
[backup, passphrase, mode, userId, onRestored, busy],
);
return (
<form
onSubmit={handleSubmit}
className="w-full max-w-md animate-slide-up rounded-2xl border border-white/10 bg-ink-900/70 p-7 shadow-glow backdrop-blur-xl sm:p-8"
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/20 ring-1 ring-emerald-400/30">
<ShieldIcon className="h-5 w-5 text-emerald-300" />
</div>
<div>
<h2 className="font-display text-lg font-semibold text-white">
{t('app:backup.restore_title', { defaultValue: 'Backup wiederherstellen' })}
</h2>
<p className="text-xs text-neutral-400">
{t('app:backup.restore_subtitle', {
defaultValue: 'Bringe einen zuvor erstellten Backup-String + Passphrase mit.',
})}
</p>
</div>
</div>
<div className="mt-6 space-y-3">
<div className="space-y-1">
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
{t('app:backup.backup_string', { defaultValue: 'Backup-String' })}
</label>
<textarea
required
rows={5}
value={backup}
onChange={(e) => setBackup(e.target.value)}
placeholder="chatapp-backup-v1…"
className="w-full resize-none rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 font-mono text-[11px] leading-relaxed text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
{mode === 'passphrase' ? 'Passphrase' : 'Recovery-Code'}
</label>
<button
type="button"
onClick={() => {
setMode((m) => (m === 'passphrase' ? 'recovery' : 'passphrase'));
setPassphrase('');
setError(null);
}}
className="cursor-pointer text-[11px] font-semibold text-brand-300 hover:underline"
>
{mode === 'passphrase'
? 'Passphrase vergessen? Recovery-Code nutzen'
: 'Stattdessen Passphrase eingeben'}
</button>
</div>
<input
type={mode === 'passphrase' ? 'password' : 'text'}
required
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder={mode === 'recovery' ? 'XXXXXX-XXXXXX-XXXXXX-XXXXXX' : ''}
spellCheck={false}
autoComplete={mode === 'recovery' ? 'off' : 'current-password'}
className={
'w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40 ' +
(mode === 'recovery' ? 'font-mono tracking-widest' : '')
}
/>
{mode === 'recovery' && (
<p className="text-[11px] text-neutral-500">
Stattdessen den Recovery-Backup-String oben einfügen.
</p>
)}
</div>
</div>
<div className="mt-5 rounded-lg border border-brand-500/20 bg-brand-500/10 p-3 text-xs text-brand-100">
<div className="flex items-start gap-2">
<LockIcon className="mt-0.5 h-4 w-4 shrink-0 text-brand-300" />
<span className="min-w-0 flex-1 break-words">
{t('app:backup.restore_hint', {
defaultValue:
'Nach Wiederherstellung übernimmt dieses Gerät die alte Identität — existierende Nachrichten sind wieder entschlüsselbar.',
})}
</span>
</div>
</div>
<button
type="submit"
disabled={busy || backup.trim().length === 0 || passphrase.length === 0}
aria-busy={busy}
className="group mt-6 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-emerald-300 hover:to-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? (
<>
<SpinnerIcon className="h-4 w-4" />
<span>{t('app:backup.restoring', { defaultValue: 'Wiederherstellen…' })}</span>
</>
) : (
<>
<span>{t('app:backup.restore_cta', { defaultValue: 'Gerät wiederherstellen' })}</span>
<ArrowRightIcon className="h-4 w-4 transition group-hover:translate-x-0.5" />
</>
)}
</button>
{error && (
<div
role="alert"
className="mt-4 flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
>
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<p className="min-w-0 flex-1 break-words">{error}</p>
</div>
)}
</form>
);
}
@@ -1,4 +1,3 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import {
type AttachmentHandle,
type DecryptedMessage,
@@ -15,8 +14,9 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { devLocalSecretStore } from '../lib/secretStore';
import { ensureInstallId } from '../lib/installId';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import { Avatar } from './Avatar';
import { ForwardIcon, SpinnerIcon, UsersIcon, XIcon } from './icons';
@@ -33,7 +33,7 @@ interface Props {
// preview hints at the dropped attachment.
export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) {
const { t } = useTranslation(['app', 'errors']);
const { session, device } = useAuth();
const { session } = useAuth();
const { conversations } = useConversationsContext();
const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -84,13 +84,13 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
}
async function handleSend() {
if (!session?.user.id || !device?.id || !message) return;
if (!session?.user.id || !message) return;
if (selected.size === 0) return;
setBusy(true);
setError(null);
try {
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
if (!priv) throw new Error('private key not loaded');
const priv = await cachedUserKey(session.user.id);
if (!priv) throw new Error('user key not unlocked');
const hasAttachments = sourceAttachments.length > 0;
const text = preview || (hasAttachments ? '' : '');
@@ -138,7 +138,7 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
conversationId: convId,
plaintext: text,
senderUserId: session.user.id,
senderDeviceId: device.id,
senderDeviceId: ensureInstallId(),
senderPrivateKey: priv,
...(newHandles.length > 0 ? { attachmentHandles: newHandles } : {}),
});
+20 -9
View File
@@ -1,4 +1,3 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import {
type DecryptedMessage,
editEncryptedMessage,
@@ -11,8 +10,9 @@ import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { devLocalSecretStore } from '../lib/secretStore';
import { ensureInstallId } from '../lib/installId';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import type { AggregatedReaction } from '../lib/useMessageReactions';
import { summarizePollVotes } from '../lib/conversationFeatures';
import { extractFirstUrl } from '../lib/useLinkPreview';
@@ -98,7 +98,7 @@ export function MessageBubble({
highlighted = false,
}: Props) {
const { t } = useTranslation(['app']);
const { session, device } = useAuth();
const { session } = useAuth();
const parsed = parseMessagePayload(message.plaintext);
const initialText = parsed.kind === 'text' ? parsed.text : '';
@@ -191,7 +191,7 @@ export function MessageBubble({
}, [pickerOpen]);
const handleEditSave = useCallback(async () => {
if (!session || !device) return;
if (!session) return;
const trimmed = editText.trim();
if (!trimmed || trimmed === bodyText) {
setEditing(false);
@@ -201,15 +201,15 @@ export function MessageBubble({
setBusy(true);
setEditError(null);
try {
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
if (!priv) throw new Error('private key not loaded');
const priv = await cachedUserKey(session.user.id);
if (!priv) throw new Error('user key not unlocked');
await editEncryptedMessage({
client: supabase,
messageId: message.id,
conversationId,
newPlaintext: trimmed,
senderUserId: session.user.id,
senderDeviceId: device.id,
senderDeviceId: ensureInstallId(),
senderPrivateKey: priv,
});
setEditing(false);
@@ -225,7 +225,7 @@ export function MessageBubble({
} finally {
setBusy(false);
}
}, [editText, message.id, message.plaintext, conversationId, session, device, t]);
}, [editText, message.id, message.plaintext, conversationId, session, t]);
const handleDelete = useCallback(async () => {
if (busy) return;
@@ -416,7 +416,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}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useRef, useState } from 'react';
interface Props {
value: string;
onChange: (next: string) => void;
length?: number;
autoFocus?: boolean;
disabled?: boolean;
ariaLabel: string;
onSubmit?: () => void;
}
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
ref={ref}
aria-label={ariaLabel}
inputMode="numeric"
autoComplete="one-time-code"
pattern="\d*"
maxLength={length}
disabled={disabled}
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={classes}>
{filled ? '•' : active ? <span className="animate-pulse">|</span> : ''}
</span>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,140 @@
import { useState } from 'react';
import {
changePin,
type LegacyMigrationReport,
regenerateRecoveryCode,
resetIdentity,
retryLegacyMigration,
} from '../lib/userIdentity';
import { PinInput } from './PinInput';
import { ShieldIcon, SpinnerIcon } from './icons';
interface Props { userId: string }
export function SecurityCenter({ userId }: Props) {
const [pinOld, setPinOld] = useState('');
const [pinNew, setPinNew] = useState('');
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);
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);
try {
await changePin({ userId, oldPin: pinOld, newPin: pinNew });
setMsg('PIN geändert.'); setPinOld(''); setPinNew('');
} catch (err) {
setMsg(err instanceof Error ? err.message : String(err));
} finally { setBusy(false); }
}
async function handleRegenerateRecovery() {
setBusy(true); setMsg(null);
try { setRecovery(await regenerateRecoveryCode({ userId })); }
catch (err) { setMsg(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
}
async function handleReset() {
if (!window.confirm('Identität wirklich zurücksetzen? Alle bisherigen Chats werden für dich unlesbar.')) return;
setBusy(true); setMsg(null);
try {
const code = await resetIdentity({ userId, pin: pinNew || pinOld });
setRecovery(code);
setMsg('Identität zurückgesetzt.');
} catch (err) {
setMsg(err instanceof Error ? err.message : String(err));
} finally { setBusy(false); }
}
return (
<div className="space-y-6 rounded-xl border border-line bg-surface-2 p-5">
<div className="flex items-center gap-2">
<ShieldIcon className="h-4 w-4 text-accent" />
<h2 className="text-sm font-semibold">Sicherheit</h2>
</div>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">PIN ändern</h3>
<div className="space-y-2">
<PinInput ariaLabel="Aktuelle PIN" value={pinOld} onChange={setPinOld} />
<PinInput ariaLabel="Neue PIN" value={pinNew} onChange={setPinNew} />
<button type="button"
disabled={busy || pinOld.length !== 6 || pinNew.length !== 6}
onClick={() => void handleChangePin()}
className="rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg disabled:opacity-60"
>
{busy && <SpinnerIcon className="mr-1 inline h-4 w-4" />}PIN ändern
</button>
</div>
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">Recovery-Code</h3>
<button type="button" disabled={busy} onClick={() => void handleRegenerateRecovery()}
className="rounded-md border border-line bg-surface-3 px-3 py-2 text-sm hover:bg-surface-2"
>Neuen Recovery-Code erzeugen</button>
{recovery && (
<div className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest">
{recovery}
</div>
)}
</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-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>
<button type="button" disabled={busy} onClick={() => void handleReset()}
className="rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-sm text-rose-200 hover:bg-rose-500/20"
>Identität zurücksetzen</button>
</section>
{msg && <p role="status" className="text-sm text-fg-muted">{msg}</p>}
</div>
);
}
@@ -0,0 +1,93 @@
import { useCallback, useState } from 'react';
import { setupNewUserIdentity } from '../lib/userIdentity';
import { PinInput } from './PinInput';
import { ShieldIcon, SpinnerIcon } from './icons';
interface Props { userId: string; onComplete: () => void }
type Step = 'enter' | 'recovery';
export function UserKeySetup({ userId, onComplete }: Props) {
const [pin, setPin] = useState('');
const [confirm, setConfirm] = useState('');
const [withRecovery, setWithRecovery] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [step, setStep] = useState<Step>('enter');
const [recoveryCode, setRecoveryCode] = useState<string | null>(null);
const submit = useCallback(async () => {
if (pin.length !== 6 || confirm.length !== 6) {
setError('PIN muss 6 Ziffern haben.'); return;
}
if (pin !== confirm) {
setError('PINs stimmen nicht überein.'); return;
}
setBusy(true); setError(null);
try {
const r = await setupNewUserIdentity({ userId, pin, withRecovery });
setRecoveryCode(r.recoveryCode);
if (withRecovery) setStep('recovery');
else onComplete();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
} finally { setBusy(false); }
}, [pin, confirm, withRecovery, userId, onComplete]);
if (step === 'recovery') {
return (
<div className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100">
<div className="flex items-center gap-2 text-amber-300">
<ShieldIcon className="h-5 w-5" />
<h2 className="text-lg font-semibold">Recovery-Code</h2>
</div>
<p className="text-sm text-neutral-300">
Speichere diesen Code an einem sicheren Ort (Passwortmanager, ausgedruckt).
Mit ihm kannst du deinen Account auch ohne PIN entsperren.
</p>
<div className="select-all rounded-lg bg-ink-800 p-4 text-center font-mono text-lg tracking-widest">
{recoveryCode}
</div>
<div className="flex gap-2">
<button type="button"
className="flex-1 rounded-lg border border-white/10 bg-ink-800 px-3 py-2 text-sm text-neutral-200 hover:bg-white/5"
onClick={onComplete}
>Überspringen</button>
<button type="button"
className="flex-1 rounded-lg bg-brand-500 px-3 py-2 text-sm font-semibold text-white hover:bg-brand-400"
onClick={onComplete}
>Habe ich gespeichert</button>
</div>
</div>
);
}
return (
<form
onSubmit={(e) => { e.preventDefault(); void submit(); }}
className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100"
>
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN eingeben</label>
<PinInput value={pin} onChange={setPin} ariaLabel="PIN eingeben" autoFocus />
</div>
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN bestätigen</label>
<PinInput value={confirm} onChange={setConfirm} ariaLabel="PIN bestätigen" />
</div>
<label className="flex items-center gap-2 text-xs text-neutral-300">
<input type="checkbox" checked={withRecovery} onChange={(e) => setWithRecovery(e.target.checked)} />
Recovery-Code erstellen (empfohlen)
</label>
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
<button
type="submit"
disabled={busy}
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-3 text-sm font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
Weiter
</button>
</form>
);
}
@@ -0,0 +1,86 @@
import { useCallback, useState } from 'react';
import { loadOrUnlockUserKey } from '../lib/userIdentity';
import { PinInput } from './PinInput';
import { LockIcon, SpinnerIcon } from './icons';
interface Props {
userId: string;
hasRecovery: boolean;
lockedUntil?: string | null;
onUnlocked: () => void;
}
type Mode = 'pin' | 'recovery';
export function UserKeyUnlock({ userId, hasRecovery, lockedUntil, onUnlocked }: Props) {
const [mode, setMode] = useState<Mode>('pin');
const [value, setValue] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = useCallback(async () => {
setBusy(true); setError(null);
try {
const res = await loadOrUnlockUserKey({
userId, pin: value, isRecoveryCode: mode === 'recovery',
});
if (res.kind === 'unlocked') onUnlocked();
else if (res.kind === 'locked') setError('Konto gesperrt bis ' + res.lockedUntil);
else setError('Schlüssel auf dem Server nicht gefunden.');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
} finally { setBusy(false); }
}, [userId, value, mode, onUnlocked]);
return (
<form
onSubmit={(e) => { e.preventDefault(); void submit(); }}
className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100"
>
<div className="flex items-center gap-2">
<LockIcon className="h-5 w-5 text-brand-300" />
<h2 className="text-lg font-semibold">Account entsperren</h2>
</div>
{lockedUntil && (
<p role="alert" className="rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-xs text-rose-100">
Zu viele Fehlversuche. Gesperrt bis {lockedUntil}.
</p>
)}
{mode === 'pin' ? (
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN eingeben</label>
<PinInput value={value} onChange={setValue} ariaLabel="PIN eingeben" autoFocus onSubmit={() => void submit()} />
</div>
) : (
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400" htmlFor="recovery-input">Recovery-Code</label>
<input
id="recovery-input"
value={value}
onChange={(e) => setValue(e.target.value)}
spellCheck={false}
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2 font-mono tracking-widest text-white"
/>
</div>
)}
{hasRecovery && (
<button type="button"
onClick={() => { setMode((m) => (m === 'pin' ? 'recovery' : 'pin')); setValue(''); setError(null); }}
className="text-xs text-brand-300 hover:underline"
>
{mode === 'pin' ? 'PIN vergessen? Recovery-Code nutzen' : 'Stattdessen PIN verwenden'}
</button>
)}
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
<button
type="submit"
disabled={busy || (mode === 'pin' ? value.length !== 6 : value.trim().length === 0)}
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-3 text-sm font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
Entsperren
</button>
</form>
);
}
@@ -1,141 +0,0 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useCallPresence } from '../lib/useCallPresence';
import { Avatar } from './Avatar';
import { PhoneIcon, SpinnerIcon } from './icons';
interface Props {
conversation: ConversationSummary;
}
const MAX_AVATARS = 5;
/**
* Discord-style persistent voice-channel band rendered at the top of the
* message surface. Always visible for groups so any member can pop in
* without an invite-ring; for 1:1 conversations only when someone's already
* in (a soft "rejoin" affordance). Joining uses the existing joinActiveCall
* path which sends no invite signals.
*/
export function VoiceChannelRail({ conversation }: Props) {
const { t } = useTranslation(['app']);
const { session } = useAuth();
const { state, joinActiveCall } = useCall();
const presentIds = useCallPresence(conversation.id);
const myId = session?.user.id ?? null;
const iAmIn =
(state.kind === 'connected' ||
state.kind === 'connecting' ||
state.kind === 'reconnecting') &&
state.conversationId === conversation.id;
const others = presentIds.filter((u) => u !== myId);
const isGroup = conversation.type === 'group';
// For groups: persistent channel feel — show even when empty.
// For DMs: only when somebody is already in (matches Discord-DM behaviour).
const visible = !iAmIn && (isGroup || others.length > 0);
if (!visible) return null;
const showAvatars = others.slice(0, MAX_AVATARS);
const overflow = Math.max(0, others.length - MAX_AVATARS);
const busy = state.kind !== 'idle';
const handleJoin = () => {
if (busy) return;
void joinActiveCall(conversation.id, 'audio');
};
return (
<div
role="button"
tabIndex={0}
onClick={handleJoin}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleJoin();
}
}}
className={
'flex cursor-pointer items-center gap-3 border-b border-line px-5 py-2.5 text-sm transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(others.length > 0
? 'bg-emerald-500/10 hover:bg-emerald-500/15'
: 'bg-surface-3/70 hover:bg-surface-3')
}
>
<div
className={
'flex h-8 w-8 shrink-0 items-center justify-center rounded-md ' +
(others.length > 0 ? 'bg-emerald-500/20 text-emerald-500' : 'bg-accent/15 text-accent')
}
>
<PhoneIcon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
{t('app:call.voice_channel', { defaultValue: 'Sprach-Channel' })}
</p>
<p className="truncate text-xs text-fg">
{others.length === 0
? t('app:call.voice_empty', {
defaultValue: 'Niemand drin — sei der Erste.',
})
: t('app:call.voice_count', {
defaultValue: '{{count}} im Channel',
count: others.length,
})}
</p>
</div>
{showAvatars.length > 0 && (
<div className="flex -space-x-2">
{showAvatars.map((id) => {
const member = conversation.members.find((m) => m.userId === id);
return (
<div
key={id}
title={member?.profile?.displayName ?? '?'}
className="relative h-7 w-7 overflow-hidden rounded-full border-2 border-surface-3 bg-surface-2"
>
<Avatar
url={member?.profile?.avatarUrl ?? null}
displayName={member?.profile?.displayName ?? '?'}
className="h-full w-full text-[10px]"
/>
</div>
);
})}
{overflow > 0 && (
<span className="inline-flex h-7 min-w-[1.75rem] items-center justify-center rounded-full border-2 border-surface-3 bg-surface-2 px-1.5 text-[10px] font-semibold text-fg-muted">
+{overflow}
</span>
)}
</div>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleJoin();
}}
disabled={busy}
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? (
<SpinnerIcon className="h-3.5 w-3.5" />
) : (
<PhoneIcon className="h-3.5 w-3.5" />
)}
<span>
{others.length === 0
? t('app:call.voice_open', { defaultValue: 'Channel öffnen' })
: t('app:call.join', { defaultValue: 'Beitreten' })}
</span>
</button>
</div>
);
}
+7 -4
View File
@@ -23,11 +23,14 @@ export function RequireAuth() {
return <Outlet />;
}
// Forces a registered device on this install. Sends to /device otherwise.
// Forces a usable per-user encrypted key blob on this install. Sends to
// /device (the setup/unlock page) when the blob is missing or locked.
export function RequireDevice() {
const { device, deviceLookupDone } = useAuth();
if (!deviceLookupDone) return <FullScreenSpinner />;
if (!device) return <Navigate to="/device" replace />;
const { userKeyState } = useAuth();
if (userKeyState.status === 'loading') return <FullScreenSpinner />;
if (userKeyState.status === 'needs-setup' || userKeyState.status === 'needs-unlock') {
return <Navigate to="/device" replace />;
}
return <Outlet />;
}
+79 -61
View File
@@ -1,9 +1,8 @@
import {
type DeviceRecord,
fetchUserKeyBlob,
getOwnProfile,
signOut as supabaseSignOut,
type Profile,
touchDeviceLastSeen,
updateOwnProfile,
} from '@chat-app/shared/auth';
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
@@ -20,23 +19,34 @@ import {
} from 'react';
import { useTranslation } from 'react-i18next';
import { findExistingDevice } from '../lib/device';
import { PRESENCE_HEARTBEAT_MS } from '../lib/presence';
import { ensureInstallId } from '../lib/installId';
import { setSecretStoreUser } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import { cachedUserKey, ensureLegacyMigrated } from '../lib/userIdentity';
import { registerWebPush } from '../lib/webPush';
// Discriminated union describing the per-user encrypted key blob lifecycle:
//
// loading — initial state, or refresh in flight
// needs-setup — no row exists on Supabase; user must pick a PIN
// needs-unlock — row exists but local cache empty; PIN (or recovery code)
// required. `lockedUntil` non-null means the server-side
// rate limiter is currently rejecting attempts.
// unlocked — private key is in the local secret store and ready to use
export type UserKeyState =
| { status: 'loading' }
| { status: 'needs-setup' }
| { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean }
| { status: 'unlocked' };
interface AuthContextValue {
session: Session | null;
profile: Profile | null;
device: DeviceRecord | null;
userKeyState: UserKeyState;
// null while we're still resolving the very first auth state.
ready: boolean;
// null until a device lookup has finished for the current session.
deviceLookupDone: boolean;
refreshProfile: () => Promise<void>;
refreshDevice: () => Promise<void>;
setDevice: (device: DeviceRecord | null) => void;
refreshUserKeyState: () => Promise<void>;
signOut: () => Promise<void>;
}
@@ -47,8 +57,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [ready, setReady] = useState(false);
const [profile, setProfile] = useState<Profile | null>(null);
const [device, setDevice] = useState<DeviceRecord | null>(null);
const [deviceLookupDone, setDeviceLookupDone] = useState(false);
const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
const autoOnlineUserRef = useRef<string | null>(null);
// Initial session + auth subscription. We verify the cached JWT against the
@@ -112,41 +121,78 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, [session, i18n.resolvedLanguage]);
const refreshDevice = useCallback(async () => {
// Resolves the current state of the per-user encrypted key blob:
// 1. local cache hit → 'unlocked'
// 2. no remote row → 'needs-setup'
// 3. server rate-limit → 'needs-unlock' with lockedUntil set
// 4. otherwise → 'needs-unlock'; hasRecovery reflects whether a
// recovery-code blob is present so the UI can
// conditionally offer the recovery affordance
const refreshUserKeyState = useCallback(async () => {
if (!session) {
setDevice(null);
setDeviceLookupDone(false);
setUserKeyState({ status: 'loading' });
return;
}
setDeviceLookupDone(false);
const found = await findExistingDevice(session.user.id);
setDevice(found);
setDeviceLookupDone(true);
setUserKeyState({ status: 'loading' });
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);
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,
});
}, [session]);
// Re-pull profile + device whenever session flips.
// Re-pull profile + user-key state whenever session flips.
useEffect(() => {
if (!session) {
setProfile(null);
setDevice(null);
setDeviceLookupDone(false);
setUserKeyState({ status: 'loading' });
return;
}
void refreshProfile().catch((err: unknown) => {
console.error('refreshProfile failed', err);
});
void refreshDevice().catch((err: unknown) => {
console.error('refreshDevice failed', err);
setDeviceLookupDone(true);
void refreshUserKeyState().catch((err: unknown) => {
console.error('refreshUserKeyState failed', err);
// Treat an unrecoverable lookup error as "needs-setup" so the UI at
// least drives the user toward the setup/unlock page rather than
// hanging forever on the spinner.
setUserKeyState({ status: 'needs-setup' });
});
}, [session, refreshProfile, refreshDevice]);
}, [session, refreshProfile, refreshUserKeyState]);
// Best-effort web-push registration once we know the device id. No-op on
// Tauri (uses native notifications) or when VITE_VAPID_PUBLIC_KEY is unset.
// Best-effort web-push registration once we have a session. Keyed by an
// install-id (localStorage UUID) since there's no longer a per-device
// crypto record to key by. No-op on Tauri (uses native notifications) or
// when VITE_VAPID_PUBLIC_KEY is unset.
useEffect(() => {
if (!device?.id) return;
void registerWebPush(device.id);
}, [device?.id]);
if (!session) return;
const installId = ensureInstallId();
void registerWebPush(installId);
}, [session]);
// Auto online/offline transition.
//
@@ -188,32 +234,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
}, [session, profile, refreshProfile]);
useEffect(() => {
if (!session || !device?.id) return;
const touch = () => {
void touchDeviceLastSeen(supabase, device.id).catch((err: unknown) => {
console.warn('presence heartbeat failed', err);
});
};
const touchWhenVisible = () => {
if (document.visibilityState === 'visible') touch();
};
touch();
const heartbeat = window.setInterval(touch, PRESENCE_HEARTBEAT_MS);
window.addEventListener('focus', touch);
window.addEventListener('online', touch);
document.addEventListener('visibilitychange', touchWhenVisible);
return () => {
window.clearInterval(heartbeat);
window.removeEventListener('focus', touch);
window.removeEventListener('online', touch);
document.removeEventListener('visibilitychange', touchWhenVisible);
};
}, [session, device?.id]);
const signOut = useCallback(async () => {
await updateOwnProfile(supabase, { presenceState: 'offline' }).catch((err: unknown) => {
console.warn('offline update before sign-out failed', err);
@@ -225,15 +245,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
() => ({
session,
profile,
device,
userKeyState,
ready,
deviceLookupDone,
refreshProfile,
refreshDevice,
setDevice,
refreshUserKeyState,
signOut,
}),
[session, profile, device, ready, deviceLookupDone, refreshProfile, refreshDevice, signOut],
[session, profile, userKeyState, ready, refreshProfile, refreshUserKeyState, signOut],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+7 -11
View File
@@ -1,4 +1,3 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { sendEncryptedMessage } from '@chat-app/shared/chat';
import {
type CallKind,
@@ -114,8 +113,9 @@ import {
type ScreenSharePreset,
updateScreenShareSettings,
} from '../lib/screenShareSettings';
import { devLocalSecretStore } from '../lib/secretStore';
import { ensureInstallId } from '../lib/installId';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import { setWindowFullscreen } from '../lib/windowFullscreen';
export type CallState =
@@ -325,7 +325,7 @@ function newCallId(): string {
}
export function CallProvider({ children }: { children: ReactNode }) {
const { session, device, profile } = useAuth();
const { session, profile } = useAuth();
const { conversations } = useConversationsContext();
const myId = session?.user.id;
@@ -446,13 +446,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
mediaKind: CallKind,
durationSec: number,
) => {
if (!session?.user.id || !device?.id) return;
if (!session?.user.id) return;
try {
const priv = await loadDevicePrivateKey(
devLocalSecretStore,
session.user.id,
device.id,
);
const priv = await cachedUserKey(session.user.id);
if (!priv) return;
const payload = JSON.stringify({
v: 1,
@@ -466,14 +462,14 @@ export function CallProvider({ children }: { children: ReactNode }) {
conversationId,
plaintext: payload,
senderUserId: session.user.id,
senderDeviceId: device.id,
senderDeviceId: ensureInstallId(),
senderPrivateKey: priv,
});
} catch (err: unknown) {
console.error('emitCallEvent failed', err);
}
},
[session?.user.id, device?.id],
[session?.user.id],
);
const clearRingTimer = useCallback(() => {
+37 -36
View File
@@ -1,16 +1,14 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { type OwnDeviceCtx, shareConvKeyToDevice } from '@chat-app/shared/chat';
import { type OwnUserCtx, shareConvKeyToUser } from '@chat-app/shared/chat';
import { pgHexToBytes } from '@chat-app/shared/supabase';
import { devLocalSecretStore } from './secretStore';
import { supabase } from './supabase';
// Watches the `devices` table for new entries AND, on mount, scans every
// conversation we participate in for missing key bundles. Fills gaps by
// re-wrapping our active conv-key for the missing recipient devices.
// re-wrapping our active conv-key for the missing recipient users.
//
// This fixes the "cannot decrypt" cliff for devices that registered while
// no other participant device was online to share the key with them.
// This fixes the "cannot decrypt" cliff for users that joined while no
// other participant was online to share the key with them.
export interface SyncCtx {
myUserId: string;
@@ -57,8 +55,6 @@ export function startConversationKeySync(
void ownUserId;
void ownDeviceId;
void backfilledKey;
void loadDevicePrivateKey;
void devLocalSecretStore;
void supabase;
return () => {};
}
@@ -70,10 +66,10 @@ export function startConversationKeySync(
// `wrapForOneDevice` and `syncOneConversationGaps` are exported below for
// the device-approval module — once the user explicitly approves a new
// device the approval flow re-uses these helpers to wrap conv-keys for
// that specific deviceId.
// that specific user.
void (() => {
void listMyConversationIds;
void listConversationDevices;
void listConversationMembers;
void listExistingKeyRecipients;
void getActiveKeyVersion;
void syncAllExistingGaps;
@@ -94,9 +90,9 @@ async function listMyConversationIds(myUserId: string): Promise<string[]> {
return (data ?? []).map((r) => r.conversation_id as string);
}
async function listConversationDevices(
async function listConversationMembers(
conversationId: string,
): Promise<{ id: string; user_id: string; public_key: string }[]> {
): Promise<{ user_id: string; public_key: string }[]> {
const { data: members, error: mErr } = await supabase
.from('conversation_members')
.select('user_id')
@@ -109,15 +105,14 @@ async function listConversationDevices(
const userIds = (members ?? []).map((m) => m.user_id as string);
if (userIds.length === 0) return [];
const { data: devices, error: dErr } = await supabase
.from('devices')
.select('id, user_id, public_key')
const { data: keys, error: kErr } = await rawFrom('user_keys')
.select('user_id, public_key')
.in('user_id', userIds);
if (dErr) {
console.warn('keySync: devices lookup failed', dErr);
if (kErr) {
console.warn('keySync: user-keys lookup failed', kErr);
return [];
}
return (devices ?? []) as { id: string; user_id: string; public_key: string }[];
return (keys ?? []) as { user_id: string; public_key: string }[];
}
async function listExistingKeyRecipients(
@@ -125,14 +120,14 @@ async function listExistingKeyRecipients(
keyVersion: number,
): Promise<Set<string>> {
const { data, error } = await rawFrom('conversation_keys')
.select('recipient_device_id')
.select('recipient_user_id')
.eq('conversation_id', conversationId)
.eq('key_version', keyVersion);
if (error) {
console.warn('keySync: existing keys lookup failed', error);
return new Set();
}
return new Set((data ?? []).map((r: { recipient_device_id: string }) => r.recipient_device_id));
return new Set((data ?? []).map((r: { recipient_user_id: string }) => r.recipient_user_id));
}
async function getActiveKeyVersion(conversationId: string): Promise<number> {
@@ -160,33 +155,32 @@ async function syncAllExistingGaps(ctx: SyncCtx): Promise<void> {
export async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
const version = await getActiveKeyVersion(convId);
const devices = await listConversationDevices(convId);
if (devices.length === 0) return;
const members = await listConversationMembers(convId);
if (members.length === 0) return;
const recipients = await listExistingKeyRecipients(convId, version);
const ownCtx: OwnDeviceCtx = {
const ownCtx: OwnUserCtx = {
userId: ctx.myUserId,
deviceId: ctx.myDeviceId,
privateKey: ctx.priv,
};
for (const dev of devices) {
if (recipients.has(dev.id)) continue;
// Skip our own device — we already have the bundle if we're capable of
for (const m of members) {
if (recipients.has(m.user_id)) continue;
// Skip our own user — we already have the bundle if we're capable of
// sharing (or don't need it if we ourselves haven't been wrapped yet).
if (dev.id === ctx.myDeviceId) continue;
if (m.user_id === ctx.myUserId) continue;
try {
await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx);
await shareConvKeyToUser(supabase, convId, m.user_id, pgHexToBytes(m.public_key), ownCtx);
} catch (err: unknown) {
// Backfill is best-effort. Most common silent failures:
// - tryGetConvKey couldn't unwrap (another peer will fill the gap).
// - RLS rejects because the recipient's owner is a pending (not-yet
// - RLS rejects because the recipient is a pending (not-yet
// accepted) DM member, or was removed from the conv.
// Both are recoverable / expected, swallow without spam.
if (isExpectedShareFailure(err)) continue;
console.warn('keySync: shareConvKeyToDevice gap-fill failed', {
console.warn('keySync: shareConvKeyToUser gap-fill failed', {
convId,
recipient: dev.id,
recipient: m.user_id,
err,
});
}
@@ -209,12 +203,20 @@ function isExpectedShareFailure(err: unknown): boolean {
);
}
// Approval-flow helper. The legacy signature took (deviceId, userId,
// devicePubHex) because conv-keys were wrapped per-device. In the per-user
// model only the user dimension matters, so the device-id parameter is
// ignored and the public key passed in MUST be the recipient user's
// user_keys.public_key (callers will be updated alongside the broader
// approval-flow rework).
export async function wrapForOneDevice(
ctx: SyncCtx,
newDeviceId: string,
newDeviceUserId: string,
newDevicePubHex: string,
): Promise<void> {
void newDeviceId; // kept for API compat; no longer used
const myConvs = new Set(await listMyConversationIds(ctx.myUserId));
const { data: peerMember, error: pErr } = await supabase
.from('conversation_members')
@@ -230,16 +232,15 @@ export async function wrapForOneDevice(
if (sharedConvs.length === 0) return;
const newPub = pgHexToBytes(newDevicePubHex);
const ownCtx: OwnDeviceCtx = {
const ownCtx: OwnUserCtx = {
userId: ctx.myUserId,
deviceId: ctx.myDeviceId,
privateKey: ctx.priv,
};
for (const convId of sharedConvs) {
try {
await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx);
await shareConvKeyToUser(supabase, convId, newDeviceUserId, newPub, ownCtx);
} catch (err: unknown) {
console.warn('keySync: shareConvKeyToDevice failed', { convId, err });
console.warn('keySync: shareConvKeyToUser failed', { convId, err });
}
}
}
-73
View File
@@ -1,73 +0,0 @@
import {
deviceIdStorageKey,
listOwnDevices,
loadDevicePrivateKey,
provisionNewDevice,
touchDeviceLastSeen,
type DeviceRecord,
} from '@chat-app/shared/auth';
import type { DevicePlatform } from '@chat-app/shared/supabase';
import { devLocalSecretStore } from './secretStore';
import { supabase } from './supabase';
export function detectDesktopPlatform(): DevicePlatform {
const ua =
typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
? navigator.userAgent.toLowerCase()
: '';
if (ua.includes('mac')) return 'macos';
if (ua.includes('win')) return 'windows';
return 'linux';
}
export function readLocalDeviceId(userId: string): string | null {
return window.localStorage.getItem(deviceIdStorageKey(userId));
}
export function writeLocalDeviceId(userId: string, deviceId: string): void {
window.localStorage.setItem(deviceIdStorageKey(userId), deviceId);
}
export function clearLocalDeviceId(userId: string): void {
window.localStorage.removeItem(deviceIdStorageKey(userId));
}
// Look up the current install's device record. Returns null when either:
// - no device id is cached locally, or
// - the cached id was deleted server-side (e.g. wiped from Studio).
// In both cases the UI should prompt the user to register a fresh device.
export async function findExistingDevice(userId: string): Promise<DeviceRecord | null> {
const cachedId = readLocalDeviceId(userId);
if (!cachedId) return null;
const all = await listOwnDevices(supabase);
const hit = all.find((d) => d.id === cachedId) ?? null;
if (!hit) return null;
const priv = await loadDevicePrivateKey(devLocalSecretStore, userId, hit.id);
if (!priv) {
// Server row exists but we lost the private key locally — treat as fresh install.
return null;
}
void touchDeviceLastSeen(supabase, hit.id).catch(() => {
/* non-fatal */
});
return hit;
}
export async function registerCurrentDevice(params: {
userId: string;
name: string;
}): Promise<DeviceRecord> {
const device = await provisionNewDevice({
client: supabase,
secretStore: devLocalSecretStore,
userId: params.userId,
name: params.name,
platform: detectDesktopPlatform(),
});
writeLocalDeviceId(params.userId, device.id);
return device;
}
-198
View File
@@ -1,198 +0,0 @@
import { getCryptoBackend } from '@chat-app/shared/crypto';
import sodium from 'libsodium-wrappers-sumo';
import { pwhashArgon2id } from './nativeCryptoOps';
// Encrypts/decrypts the device private key with a user-provided passphrase
// so the backup string can be safely written down or stored in a password
// manager. Uses Argon2id (libsodium crypto_pwhash) for the KDF and
// XSalsa20-Poly1305 (crypto_secretbox) for the AEAD.
//
// Backup format (base64url-encoded blob, prefixed with a magic string so we
// can version it):
//
// chatapp-backup-v1.<base64url(salt(16) | nonce(24) | ciphertext)>
const MAGIC = 'chatapp-backup-v1.';
const SALT_LEN = 16; // crypto_pwhash_SALTBYTES
const NONCE_LEN = 24; // crypto_secretbox_NONCEBYTES
const KEY_LEN = 32; // crypto_secretbox_KEYBYTES
async function ensureSodium(): Promise<typeof sodium> {
await sodium.ready;
return sodium;
}
function b64url(bytes: Uint8Array): string {
let s = '';
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function unb64url(s: string): Uint8Array {
let str = s.replace(/-/g, '+').replace(/_/g, '/');
while (str.length % 4) str += '=';
const bin = atob(str);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function deriveKey(passphrase: string, salt: Uint8Array, _sodiumLib: typeof sodium): Promise<Uint8Array> {
return pwhashArgon2id({
password: passphrase,
salt,
outLen: KEY_LEN,
preset: 'moderate',
});
}
export async function exportDeviceKey(
privateKey: Uint8Array,
passphrase: string,
): Promise<string> {
if (passphrase.length < 8) throw new Error('Passphrase must be at least 8 characters.');
const s = await ensureSodium();
const salt = s.randombytes_buf(SALT_LEN);
const nonce = s.randombytes_buf(NONCE_LEN);
const key = await deriveKey(passphrase, salt, s);
const backend = getCryptoBackend();
const ciphertext = backend.secretbox(privateKey, nonce, key);
s.memzero(key);
const blob = new Uint8Array(SALT_LEN + NONCE_LEN + ciphertext.length);
blob.set(salt, 0);
blob.set(nonce, SALT_LEN);
blob.set(ciphertext, SALT_LEN + NONCE_LEN);
return MAGIC + b64url(blob);
}
export async function importDeviceKey(
backup: string,
passphrase: string,
): Promise<Uint8Array> {
if (!backup.startsWith(MAGIC)) {
throw new Error('Invalid backup format');
}
const blob = unb64url(backup.slice(MAGIC.length));
if (blob.length < SALT_LEN + NONCE_LEN + 1) {
throw new Error('Backup too short');
}
const salt = blob.slice(0, SALT_LEN);
const nonce = blob.slice(SALT_LEN, SALT_LEN + NONCE_LEN);
const ciphertext = blob.slice(SALT_LEN + NONCE_LEN);
const s = await ensureSodium();
const key = await deriveKey(passphrase, salt, s);
const backend = getCryptoBackend();
try {
return backend.secretboxOpen(ciphertext, nonce, key);
} catch {
throw new Error('Wrong passphrase or corrupt backup');
} finally {
s.memzero(key);
}
}
// Full-device backup. Wraps userId + deviceId + privateKey in a JSON payload
// before encrypting, so a restore flow can re-seed localStorage + vault + server
// device row without requiring the user to remember IDs.
export interface DeviceBackupPayload {
v: 2;
userId: string;
deviceId: string;
privateKeyB64: string;
}
export async function exportDeviceBackup(
params: {
userId: string;
deviceId: string;
privateKey: Uint8Array;
passphrase: string;
},
): Promise<string> {
const payload: DeviceBackupPayload = {
v: 2,
userId: params.userId,
deviceId: params.deviceId,
privateKeyB64: b64url(params.privateKey),
};
const bytes = new TextEncoder().encode(JSON.stringify(payload));
return exportDeviceKey(bytes, params.passphrase);
}
export async function importDeviceBackup(
backup: string,
passphrase: string,
): Promise<DeviceBackupPayload> {
const plain = await importDeviceKey(backup, passphrase);
const text = new TextDecoder().decode(plain);
try {
const obj = JSON.parse(text) as DeviceBackupPayload;
if (obj && obj.v === 2 && obj.userId && obj.deviceId && obj.privateKeyB64) {
return obj;
}
} catch {
/* fall through */
}
throw new Error('Backup format not supported — v2 expected');
}
export function decodePrivateKeyFromBackup(payload: DeviceBackupPayload): Uint8Array {
return unb64url(payload.privateKeyB64);
}
// ---------------------------------------------------------------------------
// Recovery code
// ---------------------------------------------------------------------------
//
// Generates a high-entropy code shown to the user once at backup time. The
// same payload is encrypted twice — once with the user's passphrase, once
// with the recovery code — so either string can decrypt the device key.
//
// The recovery code is 24 chars from a 32-symbol alphabet (no ambiguous
// characters), grouped as 4×6. ~120 bits of entropy.
const RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
export interface BackupBundle {
passphraseBackup: string;
recoveryBackup: string;
recoveryCode: string;
}
export async function exportDeviceBackupWithRecovery(params: {
userId: string;
deviceId: string;
privateKey: Uint8Array;
passphrase: string;
}): Promise<BackupBundle> {
const recoveryCode = await generateRecoveryCode();
const [passphraseBackup, recoveryBackup] = await Promise.all([
exportDeviceBackup(params),
exportDeviceBackup({ ...params, passphrase: recoveryCode }),
]);
return { passphraseBackup, recoveryBackup, recoveryCode };
}
async function generateRecoveryCode(): Promise<string> {
const s = await ensureSodium();
const raw = s.randombytes_buf(24);
let out = '';
for (let i = 0; i < raw.length; i++) {
out += RECOVERY_ALPHABET[raw[i]! % RECOVERY_ALPHABET.length];
if ((i + 1) % 6 === 0 && i !== raw.length - 1) out += '-';
}
return out;
}
// Normalizes a user-typed recovery code: strips dashes/spaces, uppercases,
// maps look-alike characters. Lets users enter the code with imperfect
// spacing without rejecting valid input.
export function normalizeRecoveryCode(input: string): string {
return input
.toUpperCase()
.replace(/[\s-]/g, '')
.split('')
.filter((c) => RECOVERY_ALPHABET.includes(c))
.join('');
}
+19
View File
@@ -0,0 +1,19 @@
// Per-install browser identifier. Replaces the legacy per-device crypto
// identity for non-crypto bookkeeping (push tokens, message sender_device_id
// metadata) where we just need a stable, opaque id for this browser/install.
//
// The user-key model has no device-bound crypto; the `devices` table and
// `messages.sender_device_id` column still exist but they're now plain
// telemetry. Using a localStorage UUID keeps the column populated without
// any of the old key-management overhead.
const KEY = 'chatapp.installId';
export function ensureInstallId(): string {
let id = window.localStorage.getItem(KEY);
if (!id) {
id = crypto.randomUUID();
window.localStorage.setItem(KEY, id);
}
return id;
}
+101 -9
View File
@@ -1,4 +1,4 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { fetchPeerPublicKeys } from '@chat-app/shared/auth';
import {
type AttachmentHandle,
type DecryptedMessage,
@@ -9,6 +9,8 @@ import {
MAX_ATTACHMENT_BYTES,
type MessageWithCipher,
sendEncryptedMessage,
shareConvKeyToUser,
tryGetConvKey,
} from '@chat-app/shared/chat';
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -30,8 +32,8 @@ import {
shouldGiveUp,
subscribeOutbox,
} from './messageOutbox';
import { devLocalSecretStore } from './secretStore';
import { supabase } from './supabase';
import { cachedUserKey } from './userIdentity';
interface State {
messages: DecryptedMessage[];
@@ -90,25 +92,115 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
});
}, [conversationId]);
// Load own private key once per (user, device).
// Load own user private key once per user.
useEffect(() => {
privateKeyRef.current = null;
if (!userId || !deviceId) return;
void loadDevicePrivateKey(devLocalSecretStore, userId, deviceId).then((pk) => {
if (!userId) return;
void cachedUserKey(userId).then((pk) => {
privateKeyRef.current = pk;
});
}, [userId, deviceId]);
}, [userId]);
// Proactive rewrap sweep: when a conversation opens, walk every accepted
// member and ensure the active conv-key has a `recipient_user_id` bundle
// for them. Members who are missing one (typically peers who haven't yet
// migrated to the per-user key model) get a best-effort wrap from the
// local conv-key handle. Closes the legacy migration gap so peer B can
// read on first unlock without manual intervention from A.
useEffect(() => {
if (!conversationId || !userId) return;
let cancelled = false;
const run = async (): Promise<void> => {
// Wait for the private key ref to be populated. Loop with backoff
// because it's set asynchronously by another effect; bail if the
// conversation switches.
for (let i = 0; i < 20 && !privateKeyRef.current && !cancelled; i++) {
await new Promise((r) => setTimeout(r, 100));
}
const priv = privateKeyRef.current;
if (!priv || cancelled) return;
try {
const { data: convRow, error: convErr } = await supabase
.from('conversations')
.select('active_key_version')
.eq('id', conversationId)
.single();
if (convErr || !convRow) return;
// db-types snapshot predates the active_key_version column; cast via unknown.
const version = (convRow as unknown as { active_key_version: number }).active_key_version;
const handle = await tryGetConvKey(supabase, conversationId, userId, priv, version);
if (!handle || cancelled) return;
const { data: members, error: mErr } = await supabase
.from('conversation_members')
.select('user_id, accepted')
.eq('conversation_id', conversationId);
if (mErr || !members) return;
const memberIds = (members as Array<{ user_id: string; accepted: boolean }>)
.filter((m) => m.accepted && m.user_id !== userId)
.map((m) => m.user_id);
if (memberIds.length === 0) return;
const peers = await fetchPeerPublicKeys(supabase, memberIds);
for (const peer of peers) {
if (cancelled) return;
const { count, error: cntErr } = await (
supabase as unknown as {
from: (t: string) => {
select: (s: string, o?: object) => {
eq: (...a: unknown[]) => {
eq: (...a: unknown[]) => {
eq: (
...a: unknown[]
) => Promise<{ count: number | null; error: unknown }>;
};
};
};
};
}
)
.from('conversation_keys')
.select('recipient_user_id', { count: 'exact', head: true })
.eq('conversation_id', conversationId)
.eq('recipient_user_id', peer.userId)
.eq('key_version', version);
if (cntErr) continue;
if ((count ?? 0) === 0) {
try {
await shareConvKeyToUser(
supabase,
conversationId,
peer.userId,
peer.publicKey,
{ userId, privateKey: priv },
);
} catch (err) {
console.warn('proactive rewrap failed for', peer.userId, err);
}
}
}
} catch (err) {
console.warn('proactive rewrap sweep failed', err);
}
};
void run();
return () => {
cancelled = true;
};
}, [conversationId, userId]);
const decryptBatch = useCallback(
async (messages: MessageWithCipher[]): Promise<DecryptedMessage[]> => {
const priv = privateKeyRef.current;
if (!priv || !deviceId || messages.length === 0) {
if (!priv || !userId || messages.length === 0) {
return messages.map((m) => ({ ...m, plaintext: null }));
}
return decryptMessages({
client: supabase,
messages,
ownDeviceId: deviceId,
ownUserId: userId,
ownPrivateKey: priv,
// Offload the symmetric decrypt + utf-8 decode to a Web Worker so
// the main thread stays responsive during bulk operations (initial
@@ -116,7 +208,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
aeadBatchDelegate: decryptBatchWorker,
});
},
[deviceId],
[userId],
);
const refresh = useCallback(async () => {
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it, beforeEach, vi, beforeAll } from 'vitest';
import { setCryptoBackend } from '@chat-app/shared/crypto';
import { makeWasmTestBackend } from '@chat-app/shared/crypto/testBackend';
beforeAll(async () => { setCryptoBackend(await makeWasmTestBackend()); });
const memStore: Record<string, Uint8Array> = {};
const fakeStore = {
getSecret: async (k: string) => memStore[k] ?? null,
setSecret: async (k: string, v: Uint8Array) => { memStore[k] = v; },
removeSecret: async (k: string) => { delete memStore[k]; },
};
vi.mock('./secretStore', () => ({
devLocalSecretStore: fakeStore,
setSecretStoreUser: vi.fn(),
isEncryptedVaultActive: () => false,
}));
vi.mock('./supabase', () => {
const rpcResponses = new Map<string, { data: unknown; error: unknown }>();
const rpc = vi.fn((name: string, _params: unknown) =>
Promise.resolve(rpcResponses.get(name) ?? { data: null, error: null }),
);
const supabase = {
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'me' } }, error: null }) },
rpc,
from: vi.fn(() => ({
select: () => ({ in: () => Promise.resolve({ data: [], error: null }) }),
})),
};
return { supabase, __setRpcResponse: (n: string, r: { data: unknown; error: unknown }) => rpcResponses.set(n, r) };
});
describe('userIdentity', () => {
beforeEach(() => { for (const k of Object.keys(memStore)) delete memStore[k]; });
it('setupNewUserIdentity uploads blob, caches private key in store', async () => {
const supabaseMod = await import('./supabase');
(supabaseMod as unknown as { __setRpcResponse: (n: string, r: { data: unknown; error: unknown }) => void })
.__setRpcResponse('reset_user_key', { data: 0, error: null });
const { setupNewUserIdentity, cachedUserKey } = await import('./userIdentity');
const result = await setupNewUserIdentity({ userId: 'me', pin: '123456', withRecovery: true });
expect(result.publicKey).toHaveLength(32);
expect(result.recoveryCode).toMatch(/^[A-Z2-9]{6}-[A-Z2-9]{6}-[A-Z2-9]{6}-[A-Z2-9]{6}$/);
const cached = await cachedUserKey('me');
expect(cached).toBeInstanceOf(Uint8Array);
expect(cached?.length).toBe(32);
});
it('loadOrUnlockUserKey unlocks with correct PIN, throws on wrong PIN', async () => {
const { setupNewUserIdentity, clearUserKeyCache, loadOrUnlockUserKey } =
await import('./userIdentity');
const supabaseMod = await import('./supabase');
const setRpc = (supabaseMod as unknown as { __setRpcResponse: (n: string, r: { data: unknown; error: unknown }) => void }).__setRpcResponse;
setRpc('reset_user_key', { data: 0, error: null });
await setupNewUserIdentity({ userId: 'me', pin: '123456', withRecovery: false });
const rpc = (supabaseMod.supabase as unknown as { rpc: ReturnType<typeof vi.fn> }).rpc;
const lastCall = rpc.mock.calls[rpc.mock.calls.length - 1]!;
const params = lastCall[1] as Record<string, string>;
setRpc('try_unlock_user_key', {
data: {
exists: true, locked: false,
sealed_private_key: params.p_sealed_private_b64,
salt: params.p_salt_b64,
kdf_params: params.p_kdf_params,
recovery_sealed_private_key: null, recovery_salt: null,
failed_attempts: 0, failed_recovery_attempts: 0,
recovery_locked_until: null, key_version: 1,
},
error: null,
});
setRpc('record_pin_attempt', { data: { failed_attempts: 0, locked_until: null }, error: null });
await clearUserKeyCache('me');
const ok = await loadOrUnlockUserKey({ userId: 'me', pin: '123456' });
expect(ok.kind).toBe('unlocked');
await clearUserKeyCache('me');
await expect(loadOrUnlockUserKey({ userId: 'me', pin: '654321' })).rejects.toThrow();
});
});
+258
View File
@@ -0,0 +1,258 @@
import {
fetchUserKeyBlob, listOwnDevices, recordPinAttempt,
resetUserKey, tryUnlockUserKey, uploadUserKeyBlob,
} from '@chat-app/shared/auth';
import {
generateRecoveryCode, generateUserKeyPair, normalizeRecoveryCode,
openUserKey, sealUserKey,
} from '@chat-app/shared/crypto';
import { migrateOwnLegacyBundles } from '@chat-app/shared/chat';
import { devLocalSecretStore } 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 devLocalSecretStore.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 };
}
// 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 = await derivePublicKey(priv);
await runLegacyMigration(userId, priv, pub);
}
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 devLocalSecretStore.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 devLocalSecretStore.getSecret(cacheKey(userId));
}
export async function clearUserKeyCache(userId: string): Promise<void> {
await devLocalSecretStore.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: await derivePublicKey(cached),
sealedPrivateKey: fresh.sealedPrivateKey,
salt: fresh.salt,
kdfParams: fresh.kdfParams,
});
void params.oldPin; // unused: 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: await derivePublicKey(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);
// resetUserKey already deletes legacy bundles; setupNewUserIdentity uploads
// the brand-new blob via the same RPC (UPSERT). After this, no migration
// pass runs because all legacy conv-keys are gone.
await resetUserKey(supabase, {
userId: params.userId,
publicKey: new Uint8Array(32), // overwritten by next upload
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 ?? '';
}
// 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<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> = {};
// 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) {
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;
}
// 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 = await derivePublicKey(priv);
return runLegacyMigration(userId, priv, pub);
}
async function derivePublicKey(privateKey: Uint8Array): Promise<Uint8Array> {
const sodium = (await import('libsodium-wrappers-sumo')).default;
await sodium.ready;
return sodium.crypto_scalarmult_base(privateKey);
}
+5 -4
View File
@@ -23,7 +23,7 @@ import {
} from '../components/icons';
import { InCallPanel } from '../components/InCallPanel';
import { IncomingCallPanel } from '../components/IncomingCallPanel';
import { VoiceChannelRail } from '../components/VoiceChannelRail';
import { CallPreviewPanel } from '../components/CallPreviewPanel';
import { MediaFilesDrawer } from '../components/MediaFilesDrawer';
import { MentionAutocomplete } from '../components/MentionAutocomplete';
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
@@ -37,6 +37,7 @@ import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { collectConversationAttachments, createPollPayload } from '../lib/conversationFeatures';
import { compressImages } from '../lib/imageCompress';
import { ensureInstallId } from '../lib/installId';
import { searchCachedMessages } from '../lib/messageCache';
import type { OutboxItem } from '../lib/messageOutbox';
import { useConversationMessages } from '../lib/useConversationMessages';
@@ -61,7 +62,7 @@ const scrollPositions = new Map<string, { scrollTop: number; stickToBottom: bool
export function ConversationPage() {
const { t } = useTranslation(['app', 'errors']);
const { id } = useParams<{ id: string }>();
const { session, device } = useAuth();
const { session } = useAuth();
const { conversations, setActiveConversation, markRead, unread } = useConversationsContext();
const conversation = useMemo(
@@ -75,7 +76,7 @@ export function ConversationPage() {
useConversationMessages({
conversationId: id,
userId: session?.user.id,
deviceId: device?.id,
deviceId: ensureInstallId(),
});
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
const {
@@ -653,7 +654,7 @@ export function ConversationPage() {
{/* Discord-style persistent voice-channel rail. Always visible in groups
so anyone can pop in without an invite-ring; hidden in 1:1s unless
someone is already waiting. Hides automatically once we're in. */}
{conversation && !incomingHere && <VoiceChannelRail conversation={conversation} />}
{conversation && !incomingHere && <CallPreviewPanel conversation={conversation} />}
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
{conversation && <InCallPanel conversation={conversation} />}
+18 -95
View File
@@ -1,109 +1,32 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { DeviceRegistration } from '../components/DeviceRegistration';
import { DeviceRestore } from '../components/DeviceRestore';
import { useAuth } from '../context/AuthContext';
type Mode = 'register' | 'restore';
import { UserKeySetup } from '../components/UserKeySetup';
import { UserKeyUnlock } from '../components/UserKeyUnlock';
export function DevicePage() {
const { t } = useTranslation(['app']);
const { session, profile, setDevice } = useAuth();
const { session, userKeyState, refreshUserKeyState } = useAuth();
const navigate = useNavigate();
const [mode, setMode] = useState<Mode>('register');
if (!session) return null; // Guarded by RequireAuth, but be defensive.
const defaultName =
(profile?.displayName ?? profile?.username ?? 'Desktop') + ' Desktop';
if (!session) return null;
const onComplete = async () => {
await refreshUserKeyState();
navigate('/chats', { replace: true });
};
return (
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-ink-950 px-5 py-10 text-neutral-100 sm:px-8">
<BackgroundStage />
<div className="relative z-10 w-full max-w-md space-y-3">
<div
role="tablist"
aria-label={t('app:backup.mode_label', { defaultValue: 'Gerätemodus' })}
className="flex gap-1 rounded-xl border border-white/10 bg-ink-900/60 p-1 backdrop-blur-xl"
>
<TabButton
active={mode === 'register'}
onClick={() => setMode('register')}
label={t('app:backup.mode_register', { defaultValue: 'Neu einrichten' })}
/>
<TabButton
active={mode === 'restore'}
onClick={() => setMode('restore')}
label={t('app:backup.mode_restore', { defaultValue: 'Backup wiederherstellen' })}
/>
</div>
{mode === 'register' ? (
<DeviceRegistration
<main className="flex min-h-screen items-center justify-center bg-ink-950 p-6">
<div className="w-full max-w-md">
{userKeyState.status === 'needs-setup' && (
<UserKeySetup userId={session.user.id} onComplete={onComplete} />
)}
{userKeyState.status === 'needs-unlock' && (
<UserKeyUnlock
userId={session.user.id}
defaultName={defaultName}
onRegistered={(device) => {
setDevice(device);
// Signal the chats page to open the post-registration backup
// prompt (AppShell reads this on mount).
try {
window.sessionStorage.setItem('chatapp.backup.prompt', '1');
} catch {
/* storage disabled — skip hint */
}
navigate('/chats', { replace: true });
}}
/>
) : (
<DeviceRestore
userId={session.user.id}
onRestored={(device) => {
setDevice(device);
navigate('/chats', { replace: true });
}}
hasRecovery={userKeyState.hasRecovery}
lockedUntil={userKeyState.lockedUntil}
onUnlocked={onComplete}
/>
)}
</div>
</main>
);
}
function TabButton({
active,
onClick,
label,
}: {
active: boolean;
onClick: () => void;
label: string;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={
'flex-1 cursor-pointer rounded-lg px-3 py-2 text-xs font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
(active
? 'bg-brand-500/20 text-brand-100 ring-1 ring-brand-400/40'
: 'text-neutral-400 hover:bg-white/5 hover:text-neutral-100')
}
>
{label}
</button>
);
}
function BackgroundStage() {
return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<div className="bg-grid absolute inset-0 opacity-[0.28]" />
<div className="absolute -left-32 top-1/4 h-[460px] w-[460px] rounded-full bg-brand-500/25 blur-3xl" />
<div className="absolute -right-32 bottom-0 h-[460px] w-[460px] rounded-full bg-fuchsia-500/15 blur-3xl" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_45%,rgba(5,5,7,0.65)_100%)]" />
</div>
);
}
+332 -237
View File
@@ -8,17 +8,26 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar } from '../components/Avatar';
import { BackupExportDialog } from '../components/BackupExportDialog';
import { BackupRestoreDialog } from '../components/BackupRestoreDialog';
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 { SecurityCenter } from '../components/SecurityCenter';
import { SoundboardSettings } from '../components/SoundboardSettings';
import { LockIcon } from '../components/icons';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useTheme } from '../context/ThemeContext';
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { isAutoStartEnabled, setAutoStart } from '../lib/autoStart';
import {
AVATAR_TARGET_DIM,
@@ -34,7 +43,6 @@ import {
} from '../lib/bannerUpload';
import { ImageCropDialog } from '../components/ImageCropDialog';
import { Lightbox } from '../components/Lightbox';
import { devLocalSecretStore } from '../lib/secretStore';
import {
getPttSettings,
keyCodeToLabel,
@@ -83,7 +91,7 @@ const LOCALE_LABELS: Record<SupportedLocale, string> = {
export function SettingsPage() {
const { t, i18n } = useTranslation(['app', 'common', 'auth']);
const { profile, device, refreshProfile, signOut } = useAuth();
const { profile, refreshProfile, signOut } = useAuth();
const [busy, setBusy] = useState(false);
async function patchProfile(patch: Parameters<typeof updateOwnProfile>[1]) {
@@ -103,153 +111,267 @@ 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' | '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: '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>
<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>
</aside>
{/* 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>
{/* 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>
{/* Startup */}
<Section title={t('app:settings.section_startup', { defaultValue: 'Start' })}>
<AutoStartControls />
</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>
)}
{/* 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 (
{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>
)}
{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>
)}
{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>
)}
{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>
)}
{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>
)}
{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 === '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
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')
}
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"
>
{LOCALE_LABELS[locale]}
<SignOutIcon className="h-4 w-4" />
{t('app:settings.sign_out')}
</button>
);
})}
</Section>
)}
</div>
</SettingRow>
</Section>
{/* 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>
{/* Notification sound (new messages) */}
<Section
title={t('app:settings.section_notifications', { defaultValue: 'Benachrichtigungen' })}
>
<NotificationSoundSettings disabled={busy} />
</Section>
{/* Ringtone (incoming custom) */}
<Section title={t('app:settings.section_ringtone', { defaultValue: 'Klingelton' })}>
<RingtoneSettings disabled={busy} />
</Section>
{/* Soundboard */}
<Section title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}>
<SoundboardSettings />
</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>
{/* Screen-share quality */}
<Section title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}>
<ScreenShareControls />
</Section>
{/* Devices */}
<Section title={t('app:settings.section_devices')}>
{device && (
<div className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-emerald-700 dark:text-emerald-200">
<LockIcon className="h-4 w-4" />
{t('app:settings.this_device')}
</div>
<dl className="mt-3 space-y-1.5 text-xs">
<Row label={t('auth:signed_in.display_name')} value={device.name} />
<Row label={t('auth:signed_in.device_platform')} value={device.platform} />
<Row label={t('auth:signed_in.user_id')} value={device.id} mono />
</dl>
</div>
)}
<DeviceKeyBackupControls />
</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>
</main>
</div>
</div>
);
@@ -1094,92 +1216,6 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
);
}
function DeviceKeyBackupControls() {
const { t } = useTranslation(['app']);
const { profile, device } = useAuth();
const [open, setOpen] = useState(false);
const [restoreOpen, setRestoreOpen] = useState(false);
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
const [err, setErr] = useState<string | null>(null);
const canRun = !!profile?.userId && !!device?.id;
async function handleOpen() {
if (!canRun) return;
setErr(null);
try {
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
if (!priv) throw new Error(t('app:backup.no_key_here', {
defaultValue: 'Kein Geräteschlüssel auf dieser Installation.',
}));
setPrivateKey(priv);
setOpen(true);
} catch (e: unknown) {
setErr(e instanceof Error ? e.message : 'failed to load key');
}
}
function handleClose() {
setOpen(false);
if (privateKey) {
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
}
setPrivateKey(null);
}
return (
<div className="mt-4 rounded-xl border border-line bg-surface-3 p-4">
<div className="text-sm font-semibold text-fg">
{t('app:settings.device_key_backup', { defaultValue: 'Geräteschlüssel-Backup' })}
</div>
<p className="mt-1 text-xs text-fg-muted">
{t('app:settings.device_key_backup_hint_v2', {
defaultValue:
'Backup deines Geräteschlüssels. Ohne Backup kein Zugriff auf alte Nachrichten wenn Gerät oder Browser-Storage verloren geht. Wiederherstellung passiert im Gerät-Einrichtungsbildschirm.',
})}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={!canRun}
onClick={() => void handleOpen()}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('app:backup.export_open', { defaultValue: 'Backup erstellen' })}
</button>
<button
type="button"
disabled={!profile?.userId}
onClick={() => setRestoreOpen(true)}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-semibold text-fg transition hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('app:backup.restore_open', { defaultValue: 'Backup wiederherstellen' })}
</button>
</div>
{err && (
<p className="mt-2 text-xs text-rose-600 dark:text-rose-300">{err}</p>
)}
{open && profile && device && privateKey && (
<BackupExportDialog
open={open}
userId={profile.userId}
deviceId={device.id}
privateKey={privateKey}
onClose={handleClose}
/>
)}
{profile && (
<BackupRestoreDialog
open={restoreOpen}
userId={profile.userId}
onClose={() => setRestoreOpen(false)}
/>
)}
</div>
);
}
function ScreenShareControls() {
const { t } = useTranslation(['app']);
const [cfg, setCfg] = useState<ScreenShareSettings>(() => getScreenShareSettings());
@@ -1241,21 +1277,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 ' +
+7
View File
@@ -0,0 +1,7 @@
# Copy to `.env.local` and fill in. Expo bundles only EXPO_PUBLIC_* vars
# into the JS, which is what we want for these — they are public Supabase
# project keys (anon key + URL) protected by RLS on the server.
EXPO_PUBLIC_SUPABASE_URL=https://your-supabase-host.example
EXPO_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_...
EXPO_PUBLIC_AUTH_REDIRECT_URL=netralax://auth/callback
+65 -14
View File
@@ -1,27 +1,78 @@
# @chat-app/mobile
Expo + React Native client for iOS and Android.
Expo + React Native client for **Netralax** on iOS and Android.
## Prerequisites
- Node 22+, pnpm 9+
- Xcode (iOS) / Android Studio (Android)
- Optional: `npm i -g eas-cli` for cloud builds
- Xcode (iOS) / Android Studio (Android) — only needed for local
prebuild + emulator runs; EAS Build runs everything in the cloud.
- An Expo account (free) — required for `eas init` below.
- Optional: `npm i -g eas-cli` for the CLI commands. If you skip
globals, prefix every `eas` invocation with `npx eas-cli`.
## Dev
## Phase 0 quickstart
```bash
# from repo root
# From the repo root.
pnpm install
pnpm mobile:dev # expo start
pnpm mobile:ios # native iOS run
pnpm mobile:android # native Android run
# One-time: claim an EAS project ID. This rewrites the placeholder
# `extra.eas.projectId` in app.json and links the local repo to your
# Expo dashboard. Commit the resulting app.json change.
cd apps/mobile
npx eas-cli init
# Sign in to Expo if you haven't.
npx eas-cli login
# Build a development client (custom dev-client APK + iOS simulator
# bundle). First iOS build prompts for Apple ID — free signing works
# for development distribution.
npx eas-cli build --platform all --profile development
```
## Notes
When the builds finish, EAS gives you a QR code / install link. Install
the dev client on your device, then start the Metro server from the repo
root:
- Uses Expo Router (file-based). Screens live under `app/`.
- Secrets stored via `expo-secure-store` (Keychain / Keystore).
- Local encrypted history via `expo-sqlite`.
- Crypto via `react-native-libsodium`.
- Shared business logic lives in `@chat-app/shared`.
```bash
pnpm mobile:dev # = pnpm --filter @chat-app/mobile dev = expo start --dev-client
```
Open the dev client on the device, scan the QR code, and Netralax's
Landing screen should render.
## Day-to-day
```bash
pnpm mobile:dev # Metro server
pnpm mobile:ios # native iOS run (local Xcode)
pnpm mobile:android # native Android run (local Android Studio)
pnpm mobile:typecheck # tsc --noEmit
```
## Architecture notes
- Expo Router (file-based) — screens live under `app/`.
- `expo-secure-store` — Keychain / Keystore-backed secret store, wrapped
by `lib/secretStore.ts` to implement `@chat-app/shared`'s `SecretStore`.
- `@react-native-async-storage/async-storage` — Supabase session-token
storage, surfaced via `lib/sessionStorage.ts`.
- `expo-sqlite` — local encrypted history (slated for a later phase).
- `react-native-libsodium` — crypto primitives, wrapped by
`lib/cryptoBackend.ts` to implement `@chat-app/shared`'s `CryptoBackend`.
Registered once at boot in `app/_layout.tsx`.
- `@chat-app/shared` — business logic shared with the desktop; the mobile
adapters plug into its `CryptoBackend` + `SecretStore` interfaces, and
every chat / auth call goes through the namespace exports.
## Env vars
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.
## Roadmap
See `docs/superpowers/specs/2026-05-13-mobile-deployment-roadmap.md`.
+44 -10
View File
@@ -1,11 +1,11 @@
{
"expo": {
"name": "ChatApp",
"slug": "chat-app",
"name": "Netralax",
"slug": "netralax",
"version": "0.1.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "chatapp",
"scheme": "netralax",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
@@ -13,16 +13,33 @@
"resizeMode": "contain",
"backgroundColor": "#0b0b0f"
},
"assetBundlePatterns": ["**/*"],
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.meinname.chatapp",
"bundleIdentifier": "cloud.netralax.app",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
"ITSAppUsesNonExemptEncryption": false,
"UIBackgroundModes": [
"audio"
],
"NSMicrophoneUsageDescription": "Netralax nutzt das Mikrofon für Sprachanrufe."
},
"bitcode": false
},
"android": {
"package": "com.meinname.chatapp",
"package": "cloud.netralax.app",
"permissions": [
"android.permission.RECORD_AUDIO",
"android.permission.ACCESS_NETWORK_STATE",
"android.permission.CAMERA",
"android.permission.INTERNET",
"android.permission.MODIFY_AUDIO_SETTINGS",
"android.permission.SYSTEM_ALERT_WINDOW",
"android.permission.WAKE_LOCK",
"android.permission.BLUETOOTH"
],
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0b0b0f"
@@ -37,6 +54,19 @@
{
"color": "#0b0b0f"
}
],
[
"expo-image-picker",
{
"photosPermission": "Netralax greift auf deine Fotos zu, damit du sie in Nachrichten teilen kannst.",
"cameraPermission": "Netralax nutzt die Kamera für Fotos in Nachrichten."
}
],
[
"@config-plugins/react-native-webrtc",
{
"microphonePermission": "Netralax nutzt das Mikrofon für Sprachanrufe."
}
]
],
"experiments": {
@@ -44,8 +74,12 @@
},
"extra": {
"eas": {
"projectId": "REPLACE_WITH_EAS_PROJECT_ID"
"projectId": "255e2fde-3e27-4749-be53-8f0e13ed0ab0"
},
"router": {
"origin": false
}
}
},
"owner": "bygalax"
}
}
+15 -5
View File
@@ -1,9 +1,19 @@
// Authenticated app group layout.
// Gate on session: if no session, redirect to `/`.
// Add tab bar / drawer nav here once we have more than one screen.
import { Redirect, Stack } from 'expo-router';
import { Stack } from 'expo-router';
import { useAuth } from '../../lib/authContext';
export default function AppLayout() {
return <Stack screenOptions={{ headerShown: true }} />;
const { session, loading } = useAuth();
if (loading) return null;
if (!session) return <Redirect href="/" />;
return (
<Stack screenOptions={{ headerShown: true }}>
<Stack.Screen name="chats" />
<Stack.Screen name="conversations/[id]" />
<Stack.Screen
name="call"
options={{ headerShown: false, presentation: 'fullScreenModal' }}
/>
</Stack>
);
}
+165
View File
@@ -0,0 +1,165 @@
import { useRouter } from 'expo-router';
import { useEffect, useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Avatar } from '../../components/Avatar';
import { useCall } from '../../lib/callContext';
import { colors } from '../../theme/colors';
export default function CallScreen() {
const router = useRouter();
const { state, toggleMute, toggleSpeaker, endCall } = useCall();
const [seconds, setSeconds] = useState(0);
useEffect(() => {
if (state.kind !== 'connected') return;
setSeconds(0);
const id = setInterval(() => setSeconds((s) => s + 1), 1000);
return () => clearInterval(id);
}, [state.kind]);
useEffect(() => {
if (state.kind === 'idle') {
router.back();
}
}, [state.kind, router]);
const duration = useMemo(() => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return [m, s].map((n) => String(n).padStart(2, '0')).join(':');
}, [seconds]);
if (state.kind === 'idle' || state.kind === 'ended') {
return (
<View style={styles.container}>
<Text style={styles.title}>Anruf beendet</Text>
</View>
);
}
if (state.kind === 'incoming') {
return null;
}
const connecting = state.kind === 'connecting' || state.kind === 'outgoing';
const participants = state.kind === 'connected' ? state.participants : [];
const muted = state.kind === 'connected' ? state.muted : false;
const speakerOn = state.kind === 'connected' ? state.speakerOn : false;
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>{connecting ? 'Verbinde …' : 'Anruf läuft'}</Text>
{state.kind === 'connected' && <Text style={styles.duration}>{duration}</Text>}
</View>
<View style={styles.participantList}>
{participants.length === 0 && (
<Text style={styles.empty}>Warten auf Teilnehmer </Text>
)}
{participants.map((p) => (
<View key={p.identity} style={styles.participantRow}>
<Avatar name={p.name} size={48} />
<View style={styles.participantText}>
<Text style={styles.participantName}>{p.name}</Text>
<Text style={[styles.participantStatus, p.speaking && styles.participantSpeaking]}>
{p.speaking ? 'Spricht …' : 'Stumm'}
</Text>
</View>
</View>
))}
</View>
<View style={styles.toolbar}>
<ToolbarButton
label={muted ? 'Stumm' : 'Mikro'}
active={muted}
onPress={() => {
void toggleMute();
}}
/>
<ToolbarButton
label={speakerOn ? 'Lautsprecher' : 'Hörer'}
active={speakerOn}
onPress={() => {
void toggleSpeaker();
}}
/>
<Pressable
style={styles.hangup}
onPress={() => {
void endCall();
}}
>
<Text style={styles.hangupText}>Auflegen</Text>
</Pressable>
</View>
</View>
);
}
function ToolbarButton({
label,
active,
onPress,
}: {
label: string;
active: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
style={[styles.toolbarButton, active && styles.toolbarButtonActive]}
>
<Text style={[styles.toolbarButtonText, active && styles.toolbarButtonTextActive]}>
{label}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg, padding: 24 },
header: { alignItems: 'center', marginTop: 24, gap: 6 },
title: { color: colors.text, fontSize: 24, fontWeight: '700' },
duration: { color: colors.textMuted, fontSize: 16, fontVariant: ['tabular-nums'] },
participantList: { flex: 1, marginTop: 24, gap: 12 },
empty: { color: colors.textMuted, textAlign: 'center', marginTop: 32 },
participantRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
backgroundColor: colors.surface,
padding: 12,
borderRadius: 12,
borderColor: colors.border,
borderWidth: 1,
},
participantText: { flex: 1 },
participantName: { color: colors.text, fontSize: 16, fontWeight: '600' },
participantStatus: { color: colors.textMuted, fontSize: 12, marginTop: 2 },
participantSpeaking: { color: colors.success, fontWeight: '700' },
toolbar: { flexDirection: 'row', gap: 12, paddingBottom: 16 },
toolbarButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 14,
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
alignItems: 'center',
},
toolbarButtonActive: { backgroundColor: colors.accentMuted, borderColor: colors.accent },
toolbarButtonText: { color: colors.text, fontWeight: '600' },
toolbarButtonTextActive: { color: colors.accent },
hangup: {
flex: 1,
paddingVertical: 14,
borderRadius: 14,
backgroundColor: colors.danger,
alignItems: 'center',
},
hangupText: { color: colors.text, fontWeight: '700' },
});
+120 -7
View File
@@ -1,18 +1,131 @@
// Chats list placeholder.
// Milestone 1 TODO: render list of conversations, decrypt last-message preview,
// navigate to a conversation screen on tap.
import { chat } from '@chat-app/shared';
import type { ConversationSummary } from '@chat-app/shared/chat';
import { Stack, useRouter } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Pressable,
RefreshControl,
StyleSheet,
Text,
View,
} from 'react-native';
import { StyleSheet, Text, View } from 'react-native';
import { ConversationRow } from '../../components/ConversationRow';
import { useAuth } from '../../lib/authContext';
import { supabase } from '../../lib/supabase';
import { colors } from '../../theme/colors';
// Phase 1 conversation list. The last-message preview is the static
// fallback ('…') — fetching + decrypting last-messages per conversation
// 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);
const load = useCallback(async () => {
setError(null);
try {
const result = await chat.listConversations(supabase);
setList(result.filter((c) => !c.archived));
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Konversationen konnten nicht geladen werden');
}
}, []);
useEffect(() => {
void load();
}, [load]);
const onRefresh = useCallback(async () => {
setRefreshing(true);
await load();
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}>
<Text style={styles.text}>Chats placeholder</Text>
<Stack.Screen
options={{
title: 'Chats',
headerStyle: { backgroundColor: colors.bg },
headerTitleStyle: { color: colors.text },
headerRight: () => (
<Pressable onPress={confirmLogout} hitSlop={10}>
<Text style={styles.logoutLink}>Abmelden</Text>
</Pressable>
),
}}
/>
{list === null && !error && (
<View style={styles.loading}>
<ActivityIndicator color={colors.accent} />
</View>
)}
{error && <Text style={styles.error}>{error}</Text>}
{list && list.length === 0 && !error && (
<View style={styles.empty}>
<Text style={styles.emptyText}>Noch keine Konversationen.</Text>
<Text style={styles.emptyHint}>
Lege dir auf dem Desktop einen Chat an er taucht hier auf, sobald du
herunterziehst, um zu aktualisieren.
</Text>
</View>
)}
{list && list.length > 0 && (
<FlatList
data={list}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<ConversationRow
conversation={item}
lastMessagePreview="…"
onPress={() => router.push('/(app)/conversations/' + item.id)}
/>
)}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.accent}
/>
}
ItemSeparatorComponent={() => <View style={styles.separator} />}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#0b0b0f' },
text: { color: '#fff' },
container: { flex: 1, backgroundColor: colors.bg },
loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
empty: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 8 },
emptyText: { color: colors.text, fontSize: 16, fontWeight: '600' },
emptyHint: { color: colors.textMuted, fontSize: 13, textAlign: 'center', lineHeight: 18 },
error: { color: colors.danger, padding: 16, textAlign: 'center' },
separator: { height: 1, backgroundColor: colors.border, marginLeft: 68 },
logoutLink: { color: colors.accent, fontSize: 14, fontWeight: '600', paddingHorizontal: 8 },
});
@@ -0,0 +1,448 @@
import { chat } from '@chat-app/shared';
import type {
ConversationSummary,
DecryptedMessage,
MessageReaction,
} from '@chat-app/shared/chat';
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActionSheetIOS,
ActivityIndicator,
Alert,
FlatList,
KeyboardAvoidingView,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { MessageActionsSheet } from '../../../components/MessageActionsSheet';
import { MessageBubble } from '../../../components/MessageBubble';
import { useAuth } from '../../../lib/authContext';
import { useCall } from '../../../lib/callContext';
import { captureFromCamera, pickFromLibrary, type PickedImage } from '../../../lib/imagePicker';
import { supabase } from '../../../lib/supabase';
import { colors } from '../../../theme/colors';
export default function ConversationDetail() {
const { id } = useLocalSearchParams<{ id: string }>();
const { user, device, ownPrivateKey } = useAuth();
const { state: callState, startCall } = useCall();
const router = useRouter();
// When a call moves into `connected`, jump to the in-call screen so
// the user can see participants + controls. The /call screen pops
// itself when callState returns to `idle`.
useEffect(() => {
if (callState.kind === 'connected') {
router.push('/(app)/call');
}
}, [callState.kind, router]);
const [conversation, setConversation] = useState<ConversationSummary | null>(null);
const [messages, setMessages] = useState<DecryptedMessage[] | null>(null);
const [reactions, setReactions] = useState<Map<string, MessageReaction[]>>(new Map());
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
const [activeMessage, setActiveMessage] = useState<DecryptedMessage | null>(null);
const listRef = useRef<FlatList<DecryptedMessage>>(null);
const load = useCallback(async () => {
if (!id || !device || !ownPrivateKey) return;
setError(null);
try {
const all = await chat.listConversations(supabase);
setConversation(all.find((c) => c.id === id) ?? null);
const ciphers = await chat.fetchConversationMessages(supabase, id, 50);
const decrypted = await chat.decryptMessages({
client: supabase,
messages: ciphers,
ownDeviceId: device.id,
ownPrivateKey,
});
setMessages(decrypted);
const rows = await chat.listReactionsForMessages(
supabase,
decrypted.map((m) => m.id),
);
const map = new Map<string, MessageReaction[]>();
for (const r of rows) {
const arr = map.get(r.messageId) ?? [];
arr.push(r);
map.set(r.messageId, arr);
}
setReactions(map);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Nachrichten konnten nicht geladen werden');
}
}, [id, device, ownPrivateKey]);
useEffect(() => {
void load();
}, [load]);
const members = useMemo(() => conversation?.members ?? [], [conversation]);
const senderName = useCallback(
(senderId: string) => {
if (senderId === user?.id) return 'Du';
const m = members.find((mm) => mm.userId === senderId);
return m?.profile?.displayName ?? m?.profile?.username ?? 'Unbekannt';
},
[members, user],
);
const title =
conversation?.type === 'group'
? (conversation.name ?? 'Gruppe')
: (conversation?.peer?.displayName ?? '…');
async function handleSendText() {
if (!text.trim() || !user || !device || !ownPrivateKey || !id || sending) return;
setSending(true);
setError(null);
const replyToId = replyTo?.id;
try {
await chat.sendEncryptedMessage({
client: supabase,
conversationId: id,
plaintext: text.trim(),
senderUserId: user.id,
senderDeviceId: device.id,
senderPrivateKey: ownPrivateKey,
...(replyToId ? { replyToId } : {}),
});
setText('');
setReplyTo(null);
await load();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Senden fehlgeschlagen');
} finally {
setSending(false);
}
}
async function sendImage(pick: PickedImage) {
if (!user || !device || !ownPrivateKey || !id) return;
setSending(true);
setError(null);
try {
const resp = await fetch(pick.uri);
const blob = await resp.blob();
const result = await chat.encryptAndUploadAttachment({
client: supabase,
conversationId: id,
file: blob,
mimeType: pick.mimeType,
sizeBytes: pick.sizeBytes,
width: pick.width,
height: pick.height,
});
await chat.sendEncryptedMessage({
client: supabase,
conversationId: id,
plaintext: '',
senderUserId: user.id,
senderDeviceId: device.id,
senderPrivateKey: ownPrivateKey,
attachmentHandles: [result.handle],
});
await load();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Bild senden fehlgeschlagen');
} finally {
setSending(false);
}
}
function openImageMenu() {
if (Platform.OS === 'ios') {
ActionSheetIOS.showActionSheetWithOptions(
{ options: ['Foto aufnehmen', 'Aus Galerie wählen', 'Abbrechen'], cancelButtonIndex: 2 },
async (idx) => {
if (idx === 0) {
const p = await captureFromCamera();
if (p) await sendImage(p);
} else if (idx === 1) {
const p = await pickFromLibrary();
if (p) await sendImage(p);
}
},
);
} else {
Alert.alert('Bild senden', undefined, [
{
text: 'Foto aufnehmen',
onPress: async () => {
const p = await captureFromCamera();
if (p) await sendImage(p);
},
},
{
text: 'Aus Galerie wählen',
onPress: async () => {
const p = await pickFromLibrary();
if (p) await sendImage(p);
},
},
{ text: 'Abbrechen', style: 'cancel' },
]);
}
}
async function handleReact(emoji: string, messageId: string) {
if (!user) return;
try {
const mine = reactions.get(messageId)?.some((r) => r.userId === user.id && r.emoji === emoji);
if (mine) {
await chat.removeReaction(supabase, messageId, emoji);
} else {
await chat.addReaction(supabase, messageId, emoji);
}
const rows = await chat.listReactionsForMessages(
supabase,
(messages ?? []).map((m) => m.id),
);
const map = new Map<string, MessageReaction[]>();
for (const r of rows) {
const arr = map.get(r.messageId) ?? [];
arr.push(r);
map.set(r.messageId, arr);
}
setReactions(map);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Reaktion fehlgeschlagen');
}
}
function handleDelete(messageId: string) {
Alert.alert('Nachricht löschen', 'Diese Nachricht für alle löschen?', [
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Löschen',
style: 'destructive',
onPress: async () => {
try {
await chat.softDeleteMessage(supabase, messageId);
await load();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Löschen fehlgeschlagen');
}
},
},
]);
}
const parentLookup = useMemo(() => {
const m = new Map<string, DecryptedMessage>();
for (const msg of messages ?? []) m.set(msg.id, msg);
return m;
}, [messages]);
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 80 : 0}
>
<Stack.Screen
options={{
title,
headerStyle: { backgroundColor: colors.bg },
headerTitleStyle: { color: colors.text },
headerBackTitle: 'Chats',
headerRight: () => (
<Pressable
onPress={() => {
if (!id) return;
void startCall(id);
}}
hitSlop={10}
style={styles.callBtn}
>
<Text style={styles.callBtnText}>📞</Text>
</Pressable>
),
}}
/>
{messages === null && !error && (
<View style={styles.loading}>
<ActivityIndicator color={colors.accent} />
</View>
)}
{error && <Text style={styles.error}>{error}</Text>}
{messages && (
<FlatList
ref={listRef}
data={messages}
keyExtractor={(m) => m.id}
contentContainerStyle={styles.listContent}
renderItem={({ item }) => {
const parent = item.replyToId ? (parentLookup.get(item.replyToId) ?? null) : null;
return (
<MessageBubble
message={item}
mine={item.senderId === user?.id}
senderName={senderName(item.senderId)}
parent={parent}
parentSenderName={parent ? senderName(parent.senderId) : ''}
time={new Date(item.createdAt).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})}
reactions={reactions.get(item.id) ?? []}
myUserId={user?.id ?? null}
ownDeviceId={device?.id ?? null}
ownPrivateKey={ownPrivateKey}
onLongPress={() => setActiveMessage(item)}
onToggleReaction={(emoji) => {
void handleReact(emoji, item.id);
}}
/>
);
}}
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
/>
)}
{replyTo && (
<View style={styles.replyBanner}>
<View style={styles.replyBannerLeft}>
<Text style={styles.replyBannerLabel}>
Antwort an {senderName(replyTo.senderId)}
</Text>
<Text style={styles.replyBannerBody} numberOfLines={1}>
{replyTo.plaintext ?? '…'}
</Text>
</View>
<Pressable onPress={() => setReplyTo(null)} hitSlop={10}>
<Text style={styles.replyBannerClose}>×</Text>
</Pressable>
</View>
)}
<View style={styles.inputRow}>
<Pressable
style={styles.plusBtn}
onPress={openImageMenu}
disabled={sending}
hitSlop={6}
>
<Text style={styles.plusText}></Text>
</Pressable>
<TextInput
value={text}
onChangeText={setText}
placeholder="Nachricht schreiben…"
placeholderTextColor={colors.textDim}
style={styles.input}
multiline
editable={!sending}
/>
<Pressable
style={[styles.sendBtn, (!text.trim() || sending) && styles.sendBtnDisabled]}
disabled={!text.trim() || sending}
onPress={handleSendText}
>
{sending ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.sendBtnText}>Senden</Text>
)}
</Pressable>
</View>
<MessageActionsSheet
visible={activeMessage !== null}
mine={activeMessage?.senderId === user?.id}
onClose={() => setActiveMessage(null)}
onReact={(emoji) => {
if (activeMessage) void handleReact(emoji, activeMessage.id);
}}
onReply={() => {
if (activeMessage) setReplyTo(activeMessage);
}}
onDelete={() => {
if (activeMessage) handleDelete(activeMessage.id);
}}
/>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg },
loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
error: { color: colors.danger, padding: 12, textAlign: 'center' },
listContent: { padding: 12 },
replyBanner: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 8,
borderTopWidth: 1,
borderTopColor: colors.border,
backgroundColor: colors.surface,
gap: 12,
},
replyBannerLeft: { flex: 1 },
replyBannerLabel: { color: colors.accent, fontSize: 12, fontWeight: '700' },
replyBannerBody: { color: colors.textMuted, fontSize: 13, marginTop: 2 },
replyBannerClose: { color: colors.textMuted, fontSize: 22, paddingHorizontal: 6 },
inputRow: {
flexDirection: 'row',
alignItems: 'flex-end',
padding: 8,
gap: 8,
borderTopWidth: 1,
borderTopColor: colors.border,
backgroundColor: colors.surface,
},
plusBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bg,
borderRadius: 10,
borderColor: colors.border,
borderWidth: 1,
},
plusText: { color: colors.text, fontSize: 20, lineHeight: 22 },
input: {
flex: 1,
minHeight: 40,
maxHeight: 120,
color: colors.text,
backgroundColor: colors.bg,
borderColor: colors.border,
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
},
sendBtn: {
backgroundColor: colors.accent,
borderRadius: 10,
paddingHorizontal: 16,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
sendBtnDisabled: { opacity: 0.5 },
sendBtnText: { color: colors.text, fontWeight: '600' },
callBtn: {
paddingHorizontal: 10,
paddingVertical: 4,
},
callBtnText: { fontSize: 18 },
});
+28 -11
View File
@@ -1,18 +1,35 @@
// Root layout for Expo Router.
// Wraps every screen. Place global providers here (Theme, Supabase/Auth context,
// SafeAreaProvider, GestureHandlerRootView, etc.) once they exist.
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 { 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 (
<>
<StatusBar style="auto" />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(app)" />
</Stack>
</>
<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>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useEffect, useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { supabase } from '../../lib/supabase';
import { colors } from '../../theme/colors';
// Supabase magic-link emails redirect to netralax://auth/callback with
// the tokens in either the URL fragment (#access_token=...&refresh_token=...)
// or the query string depending on the provider. Expo Router parses the
// query string into useLocalSearchParams. The hash portion would require
// expo-linking; we accept both shapes for safety.
export default function AuthCallback() {
const params = useLocalSearchParams<{
access_token?: string;
refresh_token?: string;
error?: string;
error_description?: string;
}>();
const router = useRouter();
const [status, setStatus] = useState<'working' | 'error'>('working');
const [message, setMessage] = useState<string>('');
useEffect(() => {
void (async () => {
if (params.error) {
setStatus('error');
setMessage(params.error_description ?? params.error);
return;
}
if (!params.access_token || !params.refresh_token) {
setStatus('error');
setMessage('Magic-Link-URL enthielt keine Tokens.');
return;
}
const { error } = await supabase.auth.setSession({
access_token: params.access_token,
refresh_token: params.refresh_token,
});
if (error) {
setStatus('error');
setMessage(error.message);
return;
}
router.replace('/(app)/chats');
})();
}, [params, router]);
return (
<View style={styles.container}>
{status === 'working' ? (
<>
<ActivityIndicator color={colors.accent} size="large" />
<Text style={styles.text}>Du wirst angemeldet</Text>
</>
) : (
<>
<Text style={styles.errorTitle}>Anmeldung fehlgeschlagen</Text>
<Text style={styles.text}>{message}</Text>
</>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bg,
padding: 24,
gap: 16,
},
text: { color: colors.textMuted, fontSize: 14, textAlign: 'center' },
errorTitle: { color: colors.danger, fontSize: 18, fontWeight: '600' },
});
+134 -12
View File
@@ -1,17 +1,105 @@
// Landing / Login screen.
//
// Milestone 1 TODO:
// - Show app logo + "Enter your email" field.
// - On submit, call shared/auth requestMagicLink(email).
// - Handle deep link return in app/_layout.tsx (or a dedicated auth callback route).
import { auth } from '@chat-app/shared';
import { Redirect } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { StyleSheet, Text, View } from 'react-native';
import { useAuth } from '../lib/authContext';
import { env } from '../lib/env';
import { supabase } from '../lib/supabase';
import { colors } from '../theme/colors';
// Phase 1 landing — magic-link login. Signup with invite code is not
// part of this MVP (accounts come from desktop / admin). After a
// successful request the user sees a "Check your inbox" state until
// 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 [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 (session) return <Redirect href="/(app)/chats" />;
async function handleSubmit() {
if (!email.includes('@')) {
setError('Bitte gültige E-Mail eingeben');
return;
}
setSubmitting(true);
setError(null);
try {
await auth.loginWithMagicLink(supabase, email.trim(), env.authRedirectUrl);
setSent(true);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Login fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
if (sent) {
return (
<View style={styles.container}>
<Text style={styles.title}>Netralax</Text>
<Text style={styles.subtitle}>Check deine Mails</Text>
<Text style={styles.help}>
Wir haben dir einen Anmelde-Link an {email} geschickt. Tipp auf den Link, um
dich anzumelden.
</Text>
<Pressable
style={[styles.button, styles.buttonGhost]}
onPress={() => {
setSent(false);
setEmail('');
}}
>
<Text style={styles.buttonText}>Andere E-Mail verwenden</Text>
</Pressable>
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.title}>ChatApp</Text>
<Text style={styles.subtitle}>Login placeholder magic link flow goes here.</Text>
<Text style={styles.title}>Netralax</Text>
<Text style={styles.subtitle}>Mit Magic-Link anmelden</Text>
<TextInput
value={email}
onChangeText={setEmail}
placeholder="du@beispiel.de"
placeholderTextColor={colors.textDim}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
textContentType="emailAddress"
style={styles.input}
editable={!submitting}
/>
{error && <Text style={styles.error}>{error}</Text>}
<Pressable
style={[styles.button, submitting && styles.buttonDisabled]}
onPress={handleSubmit}
disabled={submitting}
>
{submitting ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.buttonText}>Magic Link senden</Text>
)}
</Pressable>
</View>
);
}
@@ -21,9 +109,43 @@ const styles = StyleSheet.create({
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#0b0b0f',
backgroundColor: colors.bg,
padding: 24,
},
title: { color: '#fff', fontSize: 28, fontWeight: '600' },
subtitle: { color: '#9ca3af', marginTop: 8, textAlign: 'center' },
title: { color: colors.text, fontSize: 32, fontWeight: '700', letterSpacing: 1, marginBottom: 8 },
subtitle: { color: colors.textMuted, fontSize: 14, marginBottom: 24 },
help: {
color: colors.textMuted,
fontSize: 14,
textAlign: 'center',
marginHorizontal: 16,
marginBottom: 24,
lineHeight: 20,
},
input: {
width: '100%',
maxWidth: 360,
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
borderRadius: 12,
color: colors.text,
paddingHorizontal: 16,
paddingVertical: 14,
fontSize: 15,
marginBottom: 12,
},
error: { color: colors.danger, fontSize: 13, marginBottom: 12 },
button: {
width: '100%',
maxWidth: 360,
backgroundColor: colors.accent,
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
buttonGhost: { backgroundColor: 'transparent', borderWidth: 1, borderColor: colors.border },
buttonDisabled: { opacity: 0.6 },
buttonText: { color: colors.text, fontWeight: '600', fontSize: 15 },
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

-4
View File
@@ -2,9 +2,5 @@ module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
// expo-router requires this plugin
'expo-router/babel',
],
};
};
@@ -0,0 +1,94 @@
import { chat } from '@chat-app/shared';
import type { AttachmentHandle } from '@chat-app/shared/chat';
import { Buffer } from 'buffer';
import { useEffect, useState } from 'react';
import { ActivityIndicator, Image, StyleSheet, Text, View } from 'react-native';
import { getCachedAttachment, setCachedAttachment } from '../lib/attachmentCache';
import { supabase } from '../lib/supabase';
import { colors } from '../theme/colors';
interface Props {
handle: AttachmentHandle;
ownDeviceId: string;
ownPrivateKey: Uint8Array;
}
// Decrypts an encrypted image attachment on first mount and renders it
// inline. Subsequent mounts hit the in-memory cache. Failure (key not
// shared yet for this device, network error, etc.) shows a small error
// placeholder rather than crashing the parent message bubble.
export function AttachmentImage({ handle }: Props) {
const [dataUrl, setDataUrl] = useState<string | null>(() => getCachedAttachment(handle.id) ?? null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (dataUrl) return;
let cancelled = false;
void (async () => {
try {
const blob = await chat.downloadAndDecryptAttachment({
client: supabase,
handle,
});
const bytes = new Uint8Array(await blob.arrayBuffer());
const b64 = Buffer.from(bytes as unknown as ArrayLike<number>).toString('base64');
const url = 'data:' + handle.mimeType + ';base64,' + b64;
if (cancelled) return;
setCachedAttachment(handle.id, url);
setDataUrl(url);
} catch (err: unknown) {
if (cancelled) return;
setError(err instanceof Error ? err.message : 'decrypt failed');
}
})();
return () => {
cancelled = true;
};
}, [dataUrl, handle]);
// Aspect ratio honoured if available; fall back to a 4:3 placeholder.
const aspect =
handle.width && handle.height && handle.height > 0 ? handle.width / handle.height : 4 / 3;
if (error) {
return (
<View style={[styles.placeholder, { aspectRatio: aspect }]}>
<Text style={styles.errorText}>🔒 {error}</Text>
</View>
);
}
if (!dataUrl) {
return (
<View style={[styles.placeholder, { aspectRatio: aspect }]}>
<ActivityIndicator color={colors.accent} />
</View>
);
}
return (
<Image
source={{ uri: dataUrl }}
style={[styles.image, { aspectRatio: aspect }]}
resizeMode="cover"
/>
);
}
const styles = StyleSheet.create({
image: {
width: '100%',
borderRadius: 8,
marginTop: 4,
},
placeholder: {
width: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.surface,
borderRadius: 8,
borderColor: colors.border,
borderWidth: 1,
marginTop: 4,
},
errorText: { color: colors.danger, fontSize: 12 },
});
+23
View File
@@ -0,0 +1,23 @@
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '../theme/colors';
// Initial-letter avatar circle. Phase 2 will swap in an image-aware
// version that prefers profile.avatarUrl when present.
export function Avatar({ name, size = 40 }: { name: string; size?: number }) {
const letter = (name.trim().charAt(0) || '?').toUpperCase();
return (
<View style={[styles.circle, { width: size, height: size, borderRadius: size / 2 }]}>
<Text style={[styles.letter, { fontSize: size * 0.45 }]}>{letter}</Text>
</View>
);
}
const styles = StyleSheet.create({
circle: {
backgroundColor: colors.accentMuted,
alignItems: 'center',
justifyContent: 'center',
},
letter: { color: colors.accent, fontWeight: '700' },
});
@@ -0,0 +1,58 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { formatRelativeTime } from '../lib/timeFormat';
import { colors } from '../theme/colors';
import { Avatar } from './Avatar';
interface Props {
conversation: ConversationSummary;
lastMessagePreview: string;
onPress: () => void;
}
export function ConversationRow({ conversation, lastMessagePreview, onPress }: Props) {
const title =
conversation.type === 'group'
? (conversation.name ?? 'Gruppe')
: (conversation.peer?.displayName ?? '—');
const subtitle =
conversation.type === 'group'
? conversation.members.length + ' Mitglieder'
: '@' + (conversation.peer?.username ?? '');
return (
<Pressable
style={({ pressed }) => [styles.row, pressed && { backgroundColor: colors.surface }]}
onPress={onPress}
>
<Avatar name={title} />
<View style={styles.center}>
<View style={styles.titleLine}>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
<Text style={styles.time}>{formatRelativeTime(conversation.lastMessageAt)}</Text>
</View>
<Text style={styles.preview} numberOfLines={1}>
{lastMessagePreview || subtitle}
</Text>
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
gap: 12,
},
center: { flex: 1, minWidth: 0 },
titleLine: { flexDirection: 'row', alignItems: 'baseline', gap: 8 },
title: { color: colors.text, fontSize: 15, fontWeight: '600', flex: 1 },
time: { color: colors.textDim, fontSize: 11 },
preview: { color: colors.textMuted, fontSize: 13, marginTop: 2 },
});
+70
View File
@@ -0,0 +1,70 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '../theme/colors';
// Catch render errors anywhere below this boundary and show a readable
// fallback. Without it, a thrown error during render produces a white
// screen on TestFlight / production builds with no way for the user to
// recover short of force-closing the app. Mounted once at the top of
// app/_layout.tsx so it wraps every screen.
interface Props {
children: React.ReactNode;
}
interface State {
error: Error | null;
}
export class ErrorBoundary extends React.Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
// Bubble to Metro/console in dev so the redbox still shows; in prod
// builds this is the only place an exception trace surfaces.
console.error('[ErrorBoundary] caught render error', error, info.componentStack);
}
reset = (): void => {
this.setState({ error: null });
};
render(): React.ReactNode {
if (this.state.error) {
return (
<View style={styles.container}>
<Text style={styles.title}>Etwas ist schiefgelaufen</Text>
<Text style={styles.message}>{this.state.error.message}</Text>
<Pressable style={styles.button} onPress={this.reset}>
<Text style={styles.buttonText}>Erneut versuchen</Text>
</Pressable>
</View>
);
}
return this.props.children;
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bg,
padding: 24,
},
title: { color: colors.text, fontSize: 22, fontWeight: '600', marginBottom: 8 },
message: { color: colors.textMuted, textAlign: 'center', marginBottom: 24 },
button: {
backgroundColor: colors.accent,
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 10,
},
buttonText: { color: colors.text, fontWeight: '600' },
});
@@ -0,0 +1,109 @@
import { chat } from '@chat-app/shared';
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { Avatar } from './Avatar';
import { useCall } from '../lib/callContext';
import { supabase } from '../lib/supabase';
import { colors } from '../theme/colors';
// Full-screen modal that surfaces over any route when the CallContext
// reports an `incoming` state. Resolves the caller's display name from
// the conversation membership; falls back to "Anrufer" otherwise.
export function IncomingCallModal() {
const { state, acceptIncoming, rejectIncoming } = useCall();
const visible = state.kind === 'incoming';
const [conversation, setConversation] = useState<ConversationSummary | null>(null);
useEffect(() => {
if (state.kind !== 'incoming') {
setConversation(null);
return;
}
let cancelled = false;
void (async () => {
try {
const all = await chat.listConversations(supabase);
if (cancelled) return;
setConversation(all.find((c) => c.id === state.conversationId) ?? null);
} catch {
/* swallow */
}
})();
return () => {
cancelled = true;
};
}, [state]);
if (state.kind !== 'incoming') return null;
const callerName = (() => {
if (!conversation) return 'Anrufer';
const m = conversation.members.find((mm) => mm.userId === state.fromUserId);
return m?.profile?.displayName ?? m?.profile?.username ?? 'Anrufer';
})();
const conversationTitle =
conversation?.type === 'group' ? (conversation.name ?? 'Gruppe') : callerName;
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={rejectIncoming}>
<View style={styles.container}>
<Text style={styles.subtitle}>Eingehender Anruf</Text>
<Avatar name={callerName} size={96} />
<Text style={styles.title}>{callerName}</Text>
{conversation?.type === 'group' && (
<Text style={styles.subtitle}>in {conversationTitle}</Text>
)}
<View style={styles.actions}>
<Pressable
style={[styles.button, styles.buttonReject]}
onPress={() => {
void rejectIncoming();
}}
>
<Text style={styles.buttonText}>Ablehnen</Text>
</Pressable>
<Pressable
style={[styles.button, styles.buttonAccept]}
onPress={() => {
void acceptIncoming();
}}
>
<Text style={styles.buttonText}>Annehmen</Text>
</Pressable>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg,
alignItems: 'center',
justifyContent: 'center',
gap: 20,
paddingHorizontal: 32,
},
title: { color: colors.text, fontSize: 28, fontWeight: '700' },
subtitle: { color: colors.textMuted, fontSize: 14 },
actions: {
flexDirection: 'row',
gap: 24,
marginTop: 32,
},
button: {
paddingHorizontal: 28,
paddingVertical: 16,
borderRadius: 14,
minWidth: 140,
alignItems: 'center',
},
buttonAccept: { backgroundColor: colors.success },
buttonReject: { backgroundColor: colors.danger },
buttonText: { color: colors.text, fontWeight: '700', fontSize: 16 },
});
@@ -0,0 +1,103 @@
import { Modal, Pressable, StyleSheet, Text } from 'react-native';
import { ReactionStrip } from './ReactionStrip';
import { colors } from '../theme/colors';
interface Props {
visible: boolean;
mine: boolean;
onClose: () => void;
onReact: (emoji: string) => void;
onReply: () => void;
onDelete: () => void;
}
// Bottom-sheet-style modal opened on long-press of a message bubble.
// Contains the reaction quick-strip plus the action rows. The "Löschen"
// row is hidden for messages not authored by the current user — the
// server-side trigger would refuse anyway, but trimming UI keeps the
// surface honest.
export function MessageActionsSheet({
visible,
mine,
onClose,
onReact,
onReply,
onDelete,
}: Props) {
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Pressable style={styles.backdrop} onPress={onClose}>
<Pressable style={styles.sheet} onPress={() => undefined}>
<ReactionStrip
onReact={(e) => {
onReact(e);
onClose();
}}
/>
<Action label="Antworten" onPress={() => { onReply(); onClose(); }} />
{mine && (
<Action
label="Löschen"
danger
onPress={() => { onDelete(); onClose(); }}
/>
)}
<Action label="Abbrechen" muted onPress={onClose} />
</Pressable>
</Pressable>
</Modal>
);
}
function Action({
label,
danger,
muted,
onPress,
}: {
label: string;
danger?: boolean;
muted?: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [styles.action, pressed && styles.actionPressed]}
>
<Text style={[styles.actionText, danger && styles.actionDanger, muted && styles.actionMuted]}>
{label}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
justifyContent: 'flex-end',
backgroundColor: 'rgba(0,0,0,0.5)',
},
sheet: {
backgroundColor: colors.surface,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingBottom: 24,
},
action: {
paddingVertical: 14,
paddingHorizontal: 20,
borderBottomColor: colors.border,
borderBottomWidth: 1,
},
actionPressed: { backgroundColor: colors.bg },
actionText: { color: colors.text, fontSize: 15, fontWeight: '500' },
actionDanger: { color: colors.danger },
actionMuted: { color: colors.textMuted },
});
+132
View File
@@ -0,0 +1,132 @@
import { chat as chatNs } from '@chat-app/shared';
import type { DecryptedMessage, MessageReaction } from '@chat-app/shared/chat';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '../theme/colors';
import { AttachmentImage } from './AttachmentImage';
import { ReactionPills } from './ReactionPills';
interface Props {
message: DecryptedMessage;
mine: boolean;
senderName: string;
time: string;
parent: DecryptedMessage | null;
parentSenderName: string;
reactions: MessageReaction[];
myUserId: string | null;
ownDeviceId: string | null;
ownPrivateKey: Uint8Array | null;
onLongPress: () => void;
onToggleReaction: (emoji: string) => void;
}
// Single message bubble. Renders, in order:
// * Reply quote (when this message has a replyToId).
// * Body text (or "Nachricht gelöscht" placeholder).
// * Image attachment (max 1 in Phase 2 — first one wins).
// * Reaction pills.
// Long-press surfaces the MessageActionsSheet via the onLongPress callback.
export function MessageBubble({
message,
mine,
senderName,
time,
parent,
parentSenderName,
reactions,
myUserId,
ownDeviceId,
ownPrivateKey,
onLongPress,
onToggleReaction,
}: Props) {
const deleted = message.deletedAt !== null;
const parsed = chatNs.parseMessagePayload(message.plaintext);
const text = parsed.kind === 'text' ? parsed.text : '';
const attachments = parsed.kind === 'text' ? parsed.attachments : [];
const firstImage = attachments.find((a) => a.mimeType.startsWith('image/'));
return (
<View style={[styles.wrap, mine ? styles.wrapMine : styles.wrapOther]}>
<Pressable
onLongPress={onLongPress}
delayLongPress={250}
style={[styles.bubble, mine ? styles.bubbleMine : styles.bubbleOther]}
>
{!mine && !deleted && <Text style={styles.sender}>{senderName}</Text>}
{message.replyToId && (
<View style={[styles.replyQuote, mine ? styles.replyQuoteMine : null]}>
<Text style={styles.replyAuthor}>{parent ? parentSenderName : 'Original'}</Text>
<Text style={styles.replyBody} numberOfLines={2}>
{parent
? quotedPreview(parent)
: '↩ Original-Nachricht außerhalb dieses Fensters'}
</Text>
</View>
)}
{deleted ? (
<Text style={styles.deleted}>Nachricht gelöscht</Text>
) : (
<>
{text.length > 0 && <Text style={styles.body}>{text}</Text>}
{firstImage && ownDeviceId && ownPrivateKey && (
<AttachmentImage
handle={firstImage}
ownDeviceId={ownDeviceId}
ownPrivateKey={ownPrivateKey}
/>
)}
</>
)}
<Text style={styles.time}>{time}</Text>
</Pressable>
{!deleted && (
<ReactionPills
reactions={reactions}
myUserId={myUserId}
onToggle={onToggleReaction}
/>
)}
</View>
);
}
function quotedPreview(m: DecryptedMessage): string {
if (m.deletedAt) return '[gelöscht]';
const parsed = chatNs.parseMessagePayload(m.plaintext);
if (parsed.kind === 'text') {
if (parsed.text.length > 0) return parsed.text;
if (parsed.attachments.length > 0) return '📎 Anhang';
}
return '…';
}
const styles = StyleSheet.create({
wrap: { marginVertical: 4, maxWidth: '78%' },
wrapMine: { alignSelf: 'flex-end', alignItems: 'flex-end' },
wrapOther: { alignSelf: 'flex-start', alignItems: 'flex-start' },
bubble: { padding: 10, borderRadius: 14 },
bubbleMine: { backgroundColor: colors.accent },
bubbleOther: { backgroundColor: colors.surface, borderColor: colors.border, borderWidth: 1 },
sender: { color: colors.textMuted, fontSize: 11, fontWeight: '600', marginBottom: 4 },
body: { color: colors.text, fontSize: 15, lineHeight: 20 },
time: { color: colors.textDim, fontSize: 10, marginTop: 4, textAlign: 'right' },
deleted: { color: colors.textMuted, fontSize: 14, fontStyle: 'italic' },
replyQuote: {
borderLeftWidth: 3,
borderLeftColor: colors.accent,
paddingLeft: 8,
paddingVertical: 4,
marginBottom: 6,
backgroundColor: colors.bg,
borderRadius: 4,
},
replyQuoteMine: { backgroundColor: 'rgba(0,0,0,0.18)', borderLeftColor: colors.text },
replyAuthor: { color: colors.textMuted, fontSize: 11, fontWeight: '700' },
replyBody: { color: colors.text, fontSize: 13, opacity: 0.85 },
});
+67
View File
@@ -0,0 +1,67 @@
import type { MessageReaction } from '@chat-app/shared/chat';
import { useMemo } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '../theme/colors';
interface Props {
reactions: MessageReaction[];
myUserId: string | null;
onToggle: (emoji: string) => void;
}
// Renders the per-message reaction badges underneath a bubble. Pills
// the current user has reacted to get a tinted background so they know
// which ones to tap to un-react.
export function ReactionPills({ reactions, myUserId, onToggle }: Props) {
const grouped = useMemo(() => groupByEmoji(reactions, myUserId), [reactions, myUserId]);
if (grouped.length === 0) return null;
return (
<View style={styles.row}>
{grouped.map((g) => (
<Pressable
key={g.emoji}
onPress={() => onToggle(g.emoji)}
style={[styles.pill, g.mine && styles.pillMine]}
>
<Text style={styles.emoji}>{g.emoji}</Text>
<Text style={[styles.count, g.mine && styles.countMine]}>{g.count}</Text>
</Pressable>
))}
</View>
);
}
function groupByEmoji(
rows: MessageReaction[],
myUserId: string | null,
): Array<{ emoji: string; count: number; mine: boolean }> {
const m = new Map<string, { count: number; mine: boolean }>();
for (const r of rows) {
const prev = m.get(r.emoji) ?? { count: 0, mine: false };
m.set(r.emoji, {
count: prev.count + 1,
mine: prev.mine || (myUserId !== null && r.userId === myUserId),
});
}
return Array.from(m.entries()).map(([emoji, v]) => ({ emoji, ...v }));
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 4 },
pill: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 12,
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
gap: 4,
},
pillMine: { backgroundColor: colors.accentMuted, borderColor: colors.accent },
emoji: { fontSize: 13 },
count: { color: colors.textMuted, fontSize: 12, fontWeight: '600' },
countMine: { color: colors.accent },
});
+45
View File
@@ -0,0 +1,45 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '../theme/colors';
// Hardcoded 6-emoji quick reactor shown at the top of the long-press
// sheet. A full emoji picker is post-Phase-4; this is the Discord-style
// fast path the vast majority of reactions go through.
export const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🎉'] as const;
export function ReactionStrip({ onReact }: { onReact: (emoji: string) => void }) {
return (
<View style={styles.row}>
{QUICK_REACTIONS.map((e) => (
<Pressable
key={e}
onPress={() => onReact(e)}
style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}
hitSlop={6}
>
<Text style={styles.emoji}>{e}</Text>
</Pressable>
))}
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingVertical: 12,
paddingHorizontal: 8,
borderBottomColor: colors.border,
borderBottomWidth: 1,
},
button: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 22,
},
buttonPressed: { backgroundColor: colors.accentMuted },
emoji: { fontSize: 26 },
});
+45
View File
@@ -0,0 +1,45 @@
{
"cli": {
"version": ">= 13.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"simulator": true
},
"android": {
"buildType": "apk"
}
},
"preview": {
"distribution": "internal",
"ios": {
"simulator": false
},
"android": {
"buildType": "apk"
}
},
"production": {
"ios": {
"simulator": false
},
"android": {
"buildType": "app-bundle"
}
}
},
"submit": {
"production": {
"ios": {
"appleId": "REPLACE_WITH_APPLE_ID",
"ascAppId": "REPLACE_WITH_ASC_APP_ID"
},
"android": {
"serviceAccountKeyPath": "./play-service-account.json"
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
// Module-level memo of decrypted-attachment data URLs by handle id.
// Survives screen unmounts (e.g. user pops in and out of a conversation)
// but evicts on app restart — good enough for Phase 2; an LRU + disk
// cache is a later polish.
const cache = new Map<string, string>();
export function getCachedAttachment(id: string): string | undefined {
return cache.get(id);
}
export function setCachedAttachment(id: string, dataUrl: string): void {
cache.set(id, dataUrl);
}
export function clearAttachmentCache(): void {
cache.clear();
}
+122
View File
@@ -0,0 +1,122 @@
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 { secretStore } from './secretStore';
import { supabase } from './supabase';
// 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';
interface AuthContextValue {
session: Session | null;
user: User | null;
device: DeviceRecord | null;
ownPrivateKey: Uint8Array | null;
loading: boolean;
signOut: () => Promise<void>;
}
const Ctx = createContext<AuthContextValue | null>(null);
export function useAuth(): AuthContextValue {
const v = useContext(Ctx);
if (!v) throw new Error('useAuth() called outside <AuthProvider>');
return v;
}
export function AuthProvider({ children }: { children: React.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);
// 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 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,
});
await secretStore.setSecret(KEY_DEVICE_ID, new TextEncoder().encode(record.id));
await secretStore.setSecret(KEY_DEVICE_PRIVKEY, kp.privateKey);
setDevice(record);
setOwnPrivateKey(kp.privateKey);
}, []);
useEffect(() => {
let cancelled = false;
void (async () => {
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);
})();
const { data: sub } = supabase.auth.onAuthStateChange((_event, nextSession) => {
setSession(nextSession);
if (!nextSession) {
setDevice(null);
setOwnPrivateKey(null);
} else {
void ensureDevice(nextSession).catch((err) =>
console.warn('[auth] ensureDevice (state change) failed', err),
);
}
});
return () => {
cancelled = true;
sub.subscription.unsubscribe();
};
}, [ensureDevice]);
const signOut = useCallback(async (): Promise<void> => {
await supabase.auth.signOut();
await secretStore.removeSecret(KEY_DEVICE_ID);
await secretStore.removeSecret(KEY_DEVICE_PRIVKEY);
setDevice(null);
setOwnPrivateKey(null);
}, []);
const value: AuthContextValue = {
session,
user: session?.user ?? null,
device,
ownPrivateKey,
loading,
signOut,
};
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
+389
View File
@@ -0,0 +1,389 @@
import { AudioSession } from '@livekit/react-native';
import { Room, RoomEvent, Track } from 'livekit-client';
import { chat, rtc } from '@chat-app/shared';
import type { CallSignal } from '@chat-app/shared/rtc';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useAuth } from './authContext';
import { sendCallSignal, subscribeCallSignals } from './callSignal';
import { supabase } from './supabase';
type Identity = string;
export interface RemoteParticipantSummary {
identity: Identity;
name: string;
speaking: boolean;
}
export type CallState =
| { kind: 'idle' }
| { kind: 'outgoing'; callId: string; conversationId: string; peers: Identity[] }
| { kind: 'incoming'; callId: string; conversationId: string; fromUserId: Identity }
| { kind: 'connecting'; callId: string; conversationId: string }
| {
kind: 'connected';
callId: string;
conversationId: string;
muted: boolean;
speakerOn: boolean;
participants: RemoteParticipantSummary[];
}
| { kind: 'ended'; reason: 'normal' | 'rejected' | 'cancelled' | 'error' };
interface CallContextValue {
state: CallState;
startCall: (conversationId: string) => Promise<void>;
acceptIncoming: () => Promise<void>;
rejectIncoming: () => Promise<void>;
cancelOutgoing: () => Promise<void>;
endCall: () => Promise<void>;
toggleMute: () => Promise<void>;
toggleSpeaker: () => Promise<void>;
}
const Ctx = createContext<CallContextValue | null>(null);
export function useCall(): CallContextValue {
const v = useContext(Ctx);
if (!v) throw new Error('useCall() called outside <CallProvider>');
return v;
}
function randomCallId(): string {
return 'call-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36);
}
// Time the call state lingers in `ended` so the UI can render a transient
// status pill ("Anruf abgelehnt", "Anruf beendet") before snapping back
// to idle. Keep this comfortably above the user's reaction time but short
// enough that returning to a chat feels snappy.
const ENDED_STATE_LINGER_MS = 2500;
export function CallProvider({ children }: { children: React.ReactNode }) {
const { user } = useAuth();
const myUserId = user?.id ?? null;
const [state, setState] = useState<CallState>({ kind: 'idle' });
const roomRef = useRef<Room | null>(null);
const participantsRef = useRef<Map<Identity, RemoteParticipantSummary>>(new Map());
const peerIdsFor = useCallback(
async (conversationId: string): Promise<Identity[]> => {
if (!myUserId) return [];
const all = await chat.listConversations(supabase);
const conv = all.find((c) => c.id === conversationId);
if (!conv) return [];
return conv.members.map((m) => m.userId).filter((id) => id !== myUserId);
},
[myUserId],
);
const teardownRoom = useCallback(async () => {
const r = roomRef.current;
roomRef.current = null;
participantsRef.current.clear();
if (r) {
try {
await r.disconnect();
} catch (err) {
console.warn('[call] room.disconnect failed', err);
}
}
try {
await AudioSession.stopAudioSession();
} catch {
/* already stopped */
}
}, []);
const updateParticipantsState = useCallback(() => {
setState((prev) => {
if (prev.kind !== 'connected') return prev;
return {
...prev,
participants: Array.from(participantsRef.current.values()),
};
});
}, []);
const joinRoom = useCallback(
async (conversationId: string, callId: string): Promise<void> => {
const token = await rtc.fetchLivekitToken(supabase, conversationId);
await AudioSession.startAudioSession();
const room = new Room();
roomRef.current = room;
room
.on(RoomEvent.ParticipantConnected, (p) => {
participantsRef.current.set(p.identity, {
identity: p.identity,
name: p.name || p.identity,
speaking: p.isSpeaking,
});
updateParticipantsState();
})
.on(RoomEvent.ParticipantDisconnected, (p) => {
participantsRef.current.delete(p.identity);
updateParticipantsState();
})
.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
for (const [id, entry] of participantsRef.current) {
entry.speaking = speakers.some((sp) => sp.identity === id);
participantsRef.current.set(id, entry);
}
updateParticipantsState();
});
await room.connect(token.url, token.token);
await room.localParticipant.setMicrophoneEnabled(true);
for (const p of room.remoteParticipants.values()) {
participantsRef.current.set(p.identity, {
identity: p.identity,
name: p.name || p.identity,
speaking: p.isSpeaking,
});
}
setState({
kind: 'connected',
callId,
conversationId,
muted: false,
speakerOn: false,
participants: Array.from(participantsRef.current.values()),
});
},
[updateParticipantsState],
);
// Signal subscription. Routes incoming invites / cancels / rejects to
// state transitions. eslint-disable on deps because handleIncomingSignal
// is defined inline below; re-subscribing on every render would churn
// the realtime channel.
useEffect(() => {
if (!myUserId) return;
const unsub = subscribeCallSignals(supabase, myUserId, (sig) => {
handleIncomingSignal(sig);
});
return unsub;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [myUserId]);
function handleIncomingSignal(sig: CallSignal) {
setState((prev) => {
switch (sig.type) {
case 'invite':
if (prev.kind === 'idle') {
return {
kind: 'incoming',
callId: sig.callId,
conversationId: sig.conversationId,
fromUserId: sig.fromUserId,
};
}
return prev;
case 'cancel':
if (
(prev.kind === 'incoming' || prev.kind === 'connecting') &&
prev.callId === sig.callId
) {
return { kind: 'ended', reason: 'cancelled' };
}
return prev;
case 'reject':
if (prev.kind === 'outgoing' && prev.callId === sig.callId) {
void teardownRoom();
return { kind: 'ended', reason: 'rejected' };
}
return prev;
case 'accept':
return prev;
case 'end':
if (
(prev.kind === 'connected' || prev.kind === 'connecting') &&
prev.callId === sig.callId
) {
void teardownRoom();
return { kind: 'ended', reason: 'normal' };
}
return prev;
}
});
}
const startCall = useCallback(
async (conversationId: string): Promise<void> => {
if (!myUserId) throw new Error('not authenticated');
const callId = randomCallId();
const peers = await peerIdsFor(conversationId);
setState({ kind: 'outgoing', callId, conversationId, peers });
try {
await Promise.all(
peers.map((peerId) =>
sendCallSignal(supabase, peerId, {
type: 'invite',
callId,
conversationId,
fromUserId: myUserId,
kind: 'audio',
sentAt: new Date().toISOString(),
}),
),
);
await joinRoom(conversationId, callId);
} catch (err) {
console.warn('[call] startCall failed', err);
await teardownRoom();
setState({ kind: 'ended', reason: 'error' });
}
},
[myUserId, peerIdsFor, joinRoom, teardownRoom],
);
const acceptIncoming = useCallback(async (): Promise<void> => {
if (!myUserId) return;
if (state.kind !== 'incoming') return;
const { callId, conversationId, fromUserId } = state;
setState({ kind: 'connecting', callId, conversationId });
try {
await sendCallSignal(supabase, fromUserId, {
type: 'accept',
callId,
byUserId: myUserId,
});
await joinRoom(conversationId, callId);
} catch (err) {
console.warn('[call] acceptIncoming failed', err);
await teardownRoom();
setState({ kind: 'ended', reason: 'error' });
}
}, [myUserId, state, joinRoom, teardownRoom]);
const rejectIncoming = useCallback(async (): Promise<void> => {
if (!myUserId) return;
if (state.kind !== 'incoming') return;
const { callId, fromUserId } = state;
setState({ kind: 'ended', reason: 'rejected' });
try {
await sendCallSignal(supabase, fromUserId, {
type: 'reject',
callId,
byUserId: myUserId,
});
} catch (err) {
console.warn('[call] rejectIncoming send failed', err);
}
}, [myUserId, state]);
const cancelOutgoing = useCallback(async (): Promise<void> => {
if (!myUserId) return;
if (state.kind !== 'outgoing') return;
const { callId, peers } = state;
setState({ kind: 'ended', reason: 'cancelled' });
try {
await Promise.all(
peers.map((peerId) =>
sendCallSignal(supabase, peerId, {
type: 'cancel',
callId,
byUserId: myUserId,
}),
),
);
} catch (err) {
console.warn('[call] cancelOutgoing send failed', err);
}
await teardownRoom();
}, [myUserId, state, teardownRoom]);
const endCall = useCallback(async (): Promise<void> => {
if (!myUserId) return;
if (state.kind !== 'connected') {
await teardownRoom();
setState({ kind: 'idle' });
return;
}
const { callId, conversationId } = state;
const peers = await peerIdsFor(conversationId);
setState({ kind: 'ended', reason: 'normal' });
try {
await Promise.all(
peers.map((peerId) =>
sendCallSignal(supabase, peerId, {
type: 'end',
callId,
byUserId: myUserId,
}),
),
);
} catch (err) {
console.warn('[call] endCall send failed', err);
}
await teardownRoom();
}, [myUserId, state, peerIdsFor, teardownRoom]);
const toggleMute = useCallback(async (): Promise<void> => {
if (state.kind !== 'connected') return;
const r = roomRef.current;
if (!r) return;
const nextMuted = !state.muted;
try {
await r.localParticipant.setMicrophoneEnabled(!nextMuted);
setState((prev) =>
prev.kind === 'connected' ? { ...prev, muted: nextMuted } : prev,
);
} catch (err) {
console.warn('[call] toggleMute failed', err);
}
}, [state]);
const toggleSpeaker = useCallback(async (): Promise<void> => {
if (state.kind !== 'connected') return;
const nextSpeaker = !state.speakerOn;
try {
await AudioSession.selectAudioOutput(nextSpeaker ? 'speaker' : 'earpiece');
setState((prev) =>
prev.kind === 'connected' ? { ...prev, speakerOn: nextSpeaker } : prev,
);
} catch (err) {
console.warn('[call] toggleSpeaker failed', err);
}
}, [state]);
// Drift `ended` back to `idle` after the linger window so the UI can
// show a brief status pill before snapping back.
useEffect(() => {
if (state.kind !== 'ended') return;
const t = setTimeout(() => {
setState({ kind: 'idle' });
}, ENDED_STATE_LINGER_MS);
return () => clearTimeout(t);
}, [state]);
const value: CallContextValue = useMemo(
() => ({
state,
startCall,
acceptIncoming,
rejectIncoming,
cancelOutgoing,
endCall,
toggleMute,
toggleSpeaker,
}),
[state, startCall, acceptIncoming, rejectIncoming, cancelOutgoing, endCall, toggleMute, toggleSpeaker],
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export { Track };
+37
View File
@@ -0,0 +1,37 @@
import { rtc } from '@chat-app/shared';
import type { CallSignal } from '@chat-app/shared/rtc';
import type { AppSupabaseClient } from '@chat-app/shared/supabase';
// Thin wrapper around Supabase realtime broadcast for call signaling.
// One channel per peer userId. Subscriptions live for the lifetime of
// the AuthProvider's session; teardown returns a no-arg unsubscribe.
export type SignalListener = (signal: CallSignal) => void;
export function subscribeCallSignals(
client: AppSupabaseClient,
myUserId: string,
onSignal: SignalListener,
): () => void {
const ch = client.channel(rtc.signalTopic(myUserId));
ch.on('broadcast', { event: 'signal' }, (msg) => {
if (msg.payload && typeof msg.payload === 'object') {
onSignal(msg.payload as CallSignal);
}
});
void ch.subscribe();
return () => {
void client.removeChannel(ch);
};
}
export async function sendCallSignal(
client: AppSupabaseClient,
toUserId: string,
payload: CallSignal,
): Promise<void> {
const ch = client.channel(rtc.signalTopic(toUserId));
await ch.subscribe();
await ch.send({ type: 'broadcast', event: 'signal', payload });
await client.removeChannel(ch);
}
+28
View File
@@ -0,0 +1,28 @@
import * as s from 'react-native-libsodium';
import type { CryptoBackend } from '@chat-app/shared/crypto';
// react-native-libsodium re-exports libsodium-wrappers' API shape, so
// this adapter is the synchronous twin of the desktop one
// (`apps/desktop/src/lib/cryptoBackend.ts`). No WASM warm-up gate is
// needed — the native module is ready as soon as the module loads.
export function createLibsodiumBackend(): CryptoBackend {
return {
name: 'react-native-libsodium',
nonceLength: s.crypto_box_NONCEBYTES,
publicKeyLength: s.crypto_box_PUBLICKEYBYTES,
privateKeyLength: s.crypto_box_SECRETKEYBYTES,
secretboxKeyLength: s.crypto_secretbox_KEYBYTES,
secretboxNonceLength: s.crypto_secretbox_NONCEBYTES,
randomBytes: (n) => s.randombytes_buf(n),
generateKeyPair: () => {
const kp = s.crypto_box_keypair();
return { publicKey: kp.publicKey, privateKey: kp.privateKey };
},
box: (plaintext, nonce, recipientPublicKey, senderPrivateKey) =>
s.crypto_box_easy(plaintext, nonce, recipientPublicKey, senderPrivateKey),
boxOpen: (ciphertext, nonce, senderPublicKey, recipientPrivateKey) =>
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),
};
}
+19
View File
@@ -0,0 +1,19 @@
// 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.
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.',
);
}
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;
+48
View File
@@ -0,0 +1,48 @@
import * as ImagePicker from 'expo-image-picker';
export interface PickedImage {
uri: string;
mimeType: string;
sizeBytes: number;
width: number;
height: number;
}
// Pick an image from the photo library. Requests permission on demand;
// returns null on cancel / denial.
export async function pickFromLibrary(): Promise<PickedImage | null> {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) return null;
const res = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.85,
base64: false,
exif: false,
});
if (res.canceled || res.assets.length === 0) return null;
return toPicked(res.assets[0]!);
}
// Capture an image via the device camera. Same return shape as
// pickFromLibrary so call sites can stay shape-agnostic.
export async function captureFromCamera(): Promise<PickedImage | null> {
const perm = await ImagePicker.requestCameraPermissionsAsync();
if (!perm.granted) return null;
const res = await ImagePicker.launchCameraAsync({
quality: 0.85,
base64: false,
exif: false,
});
if (res.canceled || res.assets.length === 0) return null;
return toPicked(res.assets[0]!);
}
function toPicked(asset: ImagePicker.ImagePickerAsset): PickedImage {
return {
uri: asset.uri,
mimeType: asset.mimeType ?? 'image/jpeg',
sizeBytes: asset.fileSize ?? 0,
width: asset.width,
height: asset.height,
};
}
+20
View File
@@ -0,0 +1,20 @@
import { Buffer } from 'buffer';
import * as SecureStore from 'expo-secure-store';
import type { SecretStore } from '@chat-app/shared/auth';
// SecretStore contract uses Uint8Array values; SecureStore only takes
// strings, so we base64 at the boundary. iOS Keychain max value size
// is generous (a few MB); private keys are 32 bytes so we are well
// within limits.
export const secretStore: SecretStore = {
async getSecret(key) {
const v = await SecureStore.getItemAsync(key);
return v ? new Uint8Array(Buffer.from(v, 'base64')) : null;
},
async setSecret(key, value) {
await SecureStore.setItemAsync(key, Buffer.from(value).toString('base64'));
},
async removeSecret(key) {
await SecureStore.deleteItemAsync(key);
},
};
+7
View File
@@ -0,0 +1,7 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
// supabase-js v2 accepts any object with async getItem / setItem /
// removeItem returning Promise<string | null> / Promise<void>. RN's
// AsyncStorage matches that shape for shape; we just re-export it
// under a name that signals intent at the call site.
export const sessionStorage = AsyncStorage;
+16
View File
@@ -0,0 +1,16 @@
import { createClient } from '@chat-app/shared/supabase';
import { env } from './env';
import { sessionStorage } from './sessionStorage';
// Single Supabase client instance for the mobile app. Token storage goes
// to AsyncStorage so the session survives reboots. detectSessionInUrl is
// false because the magic-link callback is handled by our own
// app/auth/callback.tsx screen — Expo Router routes the deep link to
// that file, and we parse it explicitly.
export const supabase = createClient({
url: env.supabaseUrl,
anonKey: env.supabaseAnonKey,
sessionStorage,
detectSessionInUrl: false,
});
+18
View File
@@ -0,0 +1,18 @@
// Minimal relative-time formatter for chat lists. Matches the desktop's
// terse style ("Vor 5 Min", "Gestern", "12.05.").
export function formatRelativeTime(iso: string | null): string {
if (!iso) return '';
const then = new Date(iso).getTime();
if (!Number.isFinite(then)) return '';
const diffSec = (Date.now() - then) / 1000;
if (diffSec < 60) return 'Gerade eben';
if (diffSec < 3600) return 'Vor ' + Math.floor(diffSec / 60) + ' Min';
if (diffSec < 86400) return 'Vor ' + Math.floor(diffSec / 3600) + ' Std';
if (diffSec < 86400 * 2) return 'Gestern';
const d = new Date(iso);
return [
String(d.getDate()).padStart(2, '0'),
String(d.getMonth() + 1).padStart(2, '0'),
String(d.getFullYear()).slice(-2),
].join('.');
}
+5
View File
@@ -18,5 +18,10 @@ config.resolver.nodeModulesPaths = [
path.resolve(workspaceRoot, 'node_modules'),
];
config.resolver.disableHierarchicalLookup = true;
// Make Metro honor the "exports" field in package.json. @chat-app/shared
// maps subpaths like "./supabase" -> "./src/supabase/index.ts" via exports;
// without this flag the eager bundler (preview/production) ignores them
// and falls back to legacy main-only resolution, which 404s on subpaths.
config.resolver.unstable_enablePackageExports = true;
module.exports = config;
+15 -4
View File
@@ -18,22 +18,33 @@
"clean": "rm -rf .expo node_modules/.cache .turbo *.tsbuildinfo"
},
"dependencies": {
"@chat-app/shared": "workspace:*",
"@babel/runtime": "^7.29.2",
"@chat-app/db-types": "workspace:*",
"@chat-app/shared": "workspace:*",
"@config-plugins/react-native-webrtc": "^10.0.0",
"@expo/metro-runtime": "^4.0.1",
"@livekit/react-native": "^2.10.3",
"@livekit/react-native-webrtc": "^144.0.0",
"@react-native-async-storage/async-storage": "^1.23.1",
"@supabase/supabase-js": "^2.46.0",
"expo": "^52.0.0",
"expo-application": "^6.0.2",
"expo-constants": "~17.0.0",
"expo-dev-client": "^5.0.20",
"expo-image-picker": "^16.0.6",
"expo-linking": "~7.0.0",
"expo-notifications": "~0.29.0",
"expo-router": "~4.0.0",
"expo-secure-store": "~14.0.0",
"expo-sqlite": "~15.0.0",
"expo-sqlite": "~15.1.4",
"expo-status-bar": "~2.0.0",
"livekit-client": "^2.7.0",
"react": "18.3.1",
"react-native": "0.76.0",
"react-native": "0.76.9",
"react-native-gesture-handler": "^2.20.2",
"react-native-libsodium": "^1.3.0",
"react-native-safe-area-context": "~4.12.0",
"react-native-screens": "~4.1.0"
"react-native-screens": "~4.4.0"
},
"devDependencies": {
"@babel/core": "^7.25.0",
+25
View File
@@ -0,0 +1,25 @@
// Centralised hex constants. Anything new screen / component should
// import from here instead of inlining a literal so we have a single
// place to swap brand tones later. The Phase-0 review flagged five
// hex codes duplicated across components — this is the fix.
export const colors = {
// Surfaces / chrome
bg: '#0b0b0f',
surface: '#16161c',
border: '#27272e',
// Text
text: '#ffffff',
textMuted: '#9ca3af',
textDim: '#6b7280',
// Accents
accent: '#5865f2',
accentMuted: 'rgba(88,101,242,0.15)',
// Status
danger: '#ef4444',
success: '#10b981',
} as const;
export type ColorKey = keyof typeof colors;
@@ -0,0 +1,729 @@
# Mobile Phase 0 — Deployment Foundation 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:** Bring `apps/mobile` from an empty "ChatApp" Expo scaffold to a Netralax-branded shell that compiles, type-checks, runs locally via `expo start`, and is configured for `eas build` on iOS + Android.
**Architecture:** Renderer-only changes inside `apps/mobile`. No backend or shared-package work. The Expo managed workflow is preserved; New Architecture stays enabled. We wire SafeAreaProvider + GestureHandlerRootView + a minimal ErrorBoundary into the Expo Router root layout, rebrand `app.json`, add `eas.json` build profiles, and prove the `@chat-app/shared` workspace dep resolves end-to-end via a runtime smoke import.
**Tech Stack:** Expo SDK 52, React Native 0.76 (New Arch), expo-router 4, react-native-safe-area-context 4.12, react-native-gesture-handler (added in Task 3), expo-application (added in Task 3), TypeScript 5.6, `@chat-app/shared` (workspace).
**Spec:** [`docs/superpowers/specs/2026-05-13-mobile-phase-0-foundation-design.md`](../specs/2026-05-13-mobile-phase-0-foundation-design.md)
**Testing note:** No automated test framework for the mobile screens yet — vitest is wired in `apps/mobile/package.json` but there are no specs to run. Each task's gate is `pnpm --filter @chat-app/mobile typecheck` + a visual self-check (described per task). End-to-end device verification is the user's job at the end of the plan (Task 11).
---
## File structure
| File | Responsibility | Change |
|---|---|---|
| `apps/mobile/app.json` | Expo project manifest — name, slug, bundle ids, plugins, splash | Rebrand to Netralax + `cloud.netralax.app` ids |
| `apps/mobile/eas.json` | EAS Build/Submit profile config | CREATE — development / preview / production profiles |
| `apps/mobile/package.json` | Workspace dependencies | Add `expo-application`, `react-native-gesture-handler` |
| `apps/mobile/components/ErrorBoundary.tsx` | Render-error catcher for the whole tree | CREATE — class component with reload button |
| `apps/mobile/lib/sharedSmoke.ts` | Runtime canary import from `@chat-app/shared` | CREATE — proves Metro can resolve the workspace package |
| `apps/mobile/app/_layout.tsx` | Expo Router root layout | Wrap `Stack` in providers: GestureHandlerRootView → SafeAreaProvider → ErrorBoundary |
| `apps/mobile/app/index.tsx` | Landing screen | Netralax branding + runtime version display + nav link to Chats |
| `apps/mobile/app/(app)/chats.tsx` | Chats placeholder inside authenticated group | Netralax-branded "coming soon" copy |
| `apps/mobile/README.md` | Mobile workspace docs | Add Phase 0 quickstart section |
| Root `package.json` | Monorepo convenience scripts | Add `mobile:typecheck` (others already exist) |
No file is bigger than ~80 lines after this plan. Assets (icon.png, splash.png, adaptive-icon.png) are explicitly NOT replaced in Phase 0 — see Out-of-Scope note in the spec — the Expo default chrome stays until Phase 4 adds the final Netralax art.
---
## Task 1: Rebrand `app.json` to Netralax
**Files:**
- Modify: `apps/mobile/app.json`
- [ ] **Step 1: Replace the entire `app.json` content**
Write `apps/mobile/app.json` exactly as follows (newlines + 2-space indent):
```json
{
"expo": {
"name": "Netralax",
"slug": "netralax",
"version": "0.1.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "netralax",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#0b0b0f"
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "cloud.netralax.app",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"package": "cloud.netralax.app",
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0b0b0f"
}
},
"plugins": [
"expo-router",
"expo-secure-store",
"expo-sqlite",
[
"expo-notifications",
{
"color": "#0b0b0f"
}
]
],
"experiments": {
"typedRoutes": true
},
"extra": {
"eas": {
"projectId": "REPLACE_WITH_EAS_PROJECT_ID"
}
}
}
}
```
Note: `extra.eas.projectId` stays as the literal placeholder string. The user fills it in by running `eas init` (covered in the README task).
- [ ] **Step 2: Commit**
```bash
git add apps/mobile/app.json
git commit -m "chore(mobile): rebrand app.json to Netralax + cloud.netralax.app bundle ids"
```
---
## Task 2: Create `eas.json` with build profiles
**Files:**
- Create: `apps/mobile/eas.json`
- [ ] **Step 1: Create `apps/mobile/eas.json`**
```json
{
"cli": {
"version": ">= 13.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"simulator": true
},
"android": {
"buildType": "apk"
}
},
"preview": {
"distribution": "internal",
"ios": {
"simulator": false
},
"android": {
"buildType": "apk"
}
},
"production": {
"ios": {
"simulator": false
},
"android": {
"buildType": "app-bundle"
}
}
},
"submit": {
"production": {
"ios": {
"appleId": "REPLACE_WITH_APPLE_ID",
"ascAppId": "REPLACE_WITH_ASC_APP_ID"
},
"android": {
"serviceAccountKeyPath": "./play-service-account.json"
}
}
}
}
```
- [ ] **Step 2: Commit**
```bash
git add apps/mobile/eas.json
git commit -m "chore(mobile): add eas.json with development/preview/production build profiles"
```
---
## Task 3: Add Phase 0 runtime dependencies
**Files:**
- Modify: `apps/mobile/package.json`
- [ ] **Step 1: Install the two missing deps**
From the repo root:
```bash
pnpm --filter @chat-app/mobile add expo-application react-native-gesture-handler
```
`expo-application` is needed to read the runtime app version (`Application.nativeApplicationVersion`) on the Landing screen. `react-native-gesture-handler` is the standard root provider for any future gesture-based UI; expo-router and most RN libraries assume it's mounted at the root.
Expected: `apps/mobile/package.json` gains the two entries in `dependencies`. pnpm picks the Expo SDK 52-compatible versions (`expo-application@~6.0.x`, `react-native-gesture-handler@~2.20.x` — let pnpm + Expo's `install` resolver pick).
- [ ] **Step 2: Verify typecheck still passes**
```bash
pnpm --filter @chat-app/mobile typecheck
```
Expected: exit 0. New deps are TS-friendly out of the box.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/package.json pnpm-lock.yaml
git commit -m "chore(mobile): add expo-application + react-native-gesture-handler for phase 0 shell"
```
---
## Task 4: Create the ErrorBoundary component
**Files:**
- Create: `apps/mobile/components/ErrorBoundary.tsx`
- [ ] **Step 1: Create the component**
Write `apps/mobile/components/ErrorBoundary.tsx`:
```tsx
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
// Catch render errors anywhere below this boundary and show a readable
// fallback. Without it, a thrown error during render produces a white
// screen on TestFlight / production builds with no way for the user to
// recover short of force-closing the app. Mounted once at the top of
// app/_layout.tsx so it wraps every screen.
interface Props {
children: React.ReactNode;
}
interface State {
error: Error | null;
}
export class ErrorBoundary extends React.Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
// Bubble to Metro/console in dev so the redbox still shows; in prod
// builds this is the only place an exception trace surfaces.
console.error('[ErrorBoundary] caught render error', error, info.componentStack);
}
reset = (): void => {
this.setState({ error: null });
};
render(): React.ReactNode {
if (this.state.error) {
return (
<View style={styles.container}>
<Text style={styles.title}>Etwas ist schiefgelaufen</Text>
<Text style={styles.message}>{this.state.error.message}</Text>
<Pressable style={styles.button} onPress={this.reset}>
<Text style={styles.buttonText}>Erneut versuchen</Text>
</Pressable>
</View>
);
}
return this.props.children;
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#0b0b0f',
padding: 24,
},
title: { color: '#fff', fontSize: 22, fontWeight: '600', marginBottom: 8 },
message: { color: '#9ca3af', textAlign: 'center', marginBottom: 24 },
button: {
backgroundColor: '#5865f2',
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 10,
},
buttonText: { color: '#fff', fontWeight: '600' },
});
```
- [ ] **Step 2: Verify typecheck**
```bash
pnpm --filter @chat-app/mobile typecheck
```
Expected: exit 0.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/components/ErrorBoundary.tsx
git commit -m "feat(mobile): add ErrorBoundary for render-error fallback"
```
---
## Task 5: Create the sharedSmoke canary
**Files:**
- Create: `apps/mobile/lib/sharedSmoke.ts`
- [ ] **Step 1: Create the file**
Write `apps/mobile/lib/sharedSmoke.ts`:
```ts
// Runtime canary import from @chat-app/shared. Phase 0's only goal here
// is to prove that Metro can resolve the workspace package and that
// nothing in shared/crypto pulls in an RN-incompatible module path.
//
// `import type` would be erased by the TypeScript compiler before
// reaching Metro, so we deliberately import a runtime symbol —
// `crypto.setCryptoBackend` — and stash it in an exported reference.
// We never call it here; Phase 1's real adapter does that after
// constructing a CryptoBackend wrapping react-native-libsodium.
//
// This file is deleted in Phase 1 once the real adapter lands.
import { crypto } from '@chat-app/shared';
export const sharedSmoke = { register: crypto.setCryptoBackend };
```
- [ ] **Step 2: Verify typecheck**
```bash
pnpm --filter @chat-app/mobile typecheck
```
Expected: exit 0. If TS complains that `crypto` is not exported from `@chat-app/shared`, re-read `packages/shared/src/index.ts` to confirm the namespace name.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/lib/sharedSmoke.ts
git commit -m "chore(mobile): add runtime canary import from @chat-app/shared"
```
---
## Task 6: Rewrite the root layout with providers
**Files:**
- Modify: `apps/mobile/app/_layout.tsx`
- [ ] **Step 1: Replace the file content**
Write `apps/mobile/app/_layout.tsx`:
```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 { ErrorBoundary } from '../components/ErrorBoundary';
// Force-resolve @chat-app/shared at boot so Metro packaging issues
// surface during the very first dev-client load instead of mid-Phase-1.
// Reference the export so tree-shaking can't drop it.
import { sharedSmoke } from '../lib/sharedSmoke';
void sharedSmoke;
// Root layout for every Expo Router screen. Provider stack order:
// GestureHandlerRootView — required by gesture-driven libs (BottomSheet,
// swipeable rows, drawer nav). Must be the
// outermost so gesture state is global.
// SafeAreaProvider — feeds notch / status-bar insets to children.
// ErrorBoundary — last line of defence for render errors.
// Stack — Expo Router's screen registry.
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<ErrorBoundary>
<StatusBar style="auto" />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(app)" />
</Stack>
</ErrorBoundary>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
```
- [ ] **Step 2: Verify typecheck**
```bash
pnpm --filter @chat-app/mobile typecheck
```
Expected: exit 0.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/app/_layout.tsx
git commit -m "feat(mobile): wrap root layout in GestureHandler + SafeArea + ErrorBoundary"
```
---
## Task 7: Rebrand the Landing screen
**Files:**
- Modify: `apps/mobile/app/index.tsx`
- [ ] **Step 1: Replace the file content**
Write `apps/mobile/app/index.tsx`:
```tsx
import * as Application from 'expo-application';
import { Link } from 'expo-router';
import { Pressable, StyleSheet, Text, View } from 'react-native';
// Phase 0 landing screen. Just enough to prove the app boots and routes:
// * Netralax brand wordmark (replaces the previous "ChatApp" string).
// * Runtime version from expo-application so we can spot stale builds
// on a TestFlight tester's device at a glance.
// * Placeholder nav link into (app)/chats — exercises Expo Router so a
// routing regression shows up here instead of mid-Phase-1.
//
// Phase 1 replaces this with the actual magic-link login form.
export default function Landing() {
const version = Application.nativeApplicationVersion ?? '0.0.0';
return (
<View style={styles.container}>
<Text style={styles.title}>Netralax</Text>
<Text style={styles.subtitle}>Phase 0 — Deployment-Fundament</Text>
<Text style={styles.version}>v{version}</Text>
<Link href="/(app)/chats" asChild>
<Pressable style={styles.button}>
<Text style={styles.buttonText}>Weiter (Smoke-Test)</Text>
</Pressable>
</Link>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#0b0b0f',
padding: 24,
},
title: { color: '#fff', fontSize: 32, fontWeight: '700', letterSpacing: 1 },
subtitle: { color: '#9ca3af', marginTop: 8, marginBottom: 4 },
version: { color: '#6b7280', fontSize: 12, marginBottom: 32 },
button: {
backgroundColor: '#5865f2',
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 12,
},
buttonText: { color: '#fff', fontWeight: '600', fontSize: 15 },
});
```
- [ ] **Step 2: Verify typecheck**
```bash
pnpm --filter @chat-app/mobile typecheck
```
Expected: exit 0.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/app/index.tsx
git commit -m "feat(mobile): Netralax landing screen with runtime version + nav smoke test"
```
---
## Task 8: Rebrand the Chats placeholder
**Files:**
- Modify: `apps/mobile/app/(app)/chats.tsx`
- [ ] **Step 1: Replace the file content**
Write `apps/mobile/app/(app)/chats.tsx`:
```tsx
import { StyleSheet, Text, View } from 'react-native';
// Phase 0 stand-in for the conversation list. Confirms the authenticated
// route group (`(app)`) mounts without error after a navigation from
// Landing. Phase 1 replaces this with the real chat list.
export default function Chats() {
return (
<View style={styles.container}>
<Text style={styles.title}>Netralax</Text>
<Text style={styles.subtitle}>Chats — coming soon (Phase 1)</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#0b0b0f',
padding: 24,
},
title: { color: '#fff', fontSize: 28, fontWeight: '600' },
subtitle: { color: '#9ca3af', marginTop: 8, textAlign: 'center' },
});
```
- [ ] **Step 2: Verify typecheck**
```bash
pnpm --filter @chat-app/mobile typecheck
```
Expected: exit 0.
- [ ] **Step 3: Commit**
```bash
git add 'apps/mobile/app/(app)/chats.tsx'
git commit -m "feat(mobile): Netralax-branded Chats placeholder screen"
```
---
## Task 9: Add `mobile:typecheck` to root scripts
**Files:**
- Modify: `package.json` (root)
- [ ] **Step 1: Add the script**
Open `package.json` at the repo root and find the line:
```json
"mobile:android": "pnpm --filter @chat-app/mobile android",
```
Insert immediately after it:
```json
"mobile:typecheck": "pnpm --filter @chat-app/mobile typecheck",
```
Final scripts block excerpt should read:
```json
"mobile": "pnpm --filter @chat-app/mobile",
"mobile:dev": "pnpm --filter @chat-app/mobile dev",
"mobile:ios": "pnpm --filter @chat-app/mobile ios",
"mobile:android": "pnpm --filter @chat-app/mobile android",
"mobile:typecheck": "pnpm --filter @chat-app/mobile typecheck",
"desktop": "pnpm --filter @chat-app/desktop",
```
- [ ] **Step 2: Verify the script runs**
```bash
pnpm mobile:typecheck
```
Expected: exit 0 (forwards to mobile workspace, which currently has nothing to fail).
- [ ] **Step 3: Commit**
```bash
git add package.json
git commit -m "chore(repo): add mobile:typecheck convenience script"
```
---
## Task 10: Document Phase 0 quickstart in the mobile README
**Files:**
- Modify: `apps/mobile/README.md`
- [ ] **Step 1: Replace the README content**
Write `apps/mobile/README.md`:
````markdown
# @chat-app/mobile
Expo + React Native client for **Netralax** on iOS and Android.
## Prerequisites
- Node 22+, pnpm 9+
- Xcode (iOS) / Android Studio (Android) — only needed for local
prebuild + emulator runs; EAS Build runs everything in the cloud.
- An Expo account (free) — required for `eas init` below.
- Optional: `npm i -g eas-cli` for the CLI commands. If you skip
globals, prefix every `eas` invocation with `npx eas-cli`.
## Phase 0 quickstart
```bash
# From the repo root.
pnpm install
# One-time: claim an EAS project ID. This rewrites the placeholder
# `extra.eas.projectId` in app.json and links the local repo to your
# Expo dashboard. Commit the resulting app.json change.
cd apps/mobile
npx eas-cli init
# Sign in to Expo if you haven't.
npx eas-cli login
# Build a development client (custom dev-client APK + iOS simulator
# bundle). First iOS build prompts for Apple ID — free signing works
# for development distribution.
npx eas-cli build --platform all --profile development
```
When the builds finish, EAS gives you a QR code / install link. Install
the dev client on your device, then start the Metro server from the repo
root:
```bash
pnpm mobile:dev # = pnpm --filter @chat-app/mobile dev = expo start --dev-client
```
Open the dev client on the device, scan the QR code, and Netralax's
Landing screen should render.
## Day-to-day
```bash
pnpm mobile:dev # Metro server
pnpm mobile:ios # native iOS run (local Xcode)
pnpm mobile:android # native Android run (local Android Studio)
pnpm mobile:typecheck # tsc --noEmit
```
## Architecture notes
- Expo Router (file-based) — screens live under `app/`.
- `expo-secure-store` — Keychain / Keystore-backed secret store (used by
the mobile `SecretStore` adapter in Phase 1).
- `expo-sqlite` — local encrypted history (Phase 1).
- `react-native-libsodium` — crypto primitives (Phase 1's
`CryptoBackend` adapter wraps this).
- `@chat-app/shared` — business logic shared with the desktop, including
the `CryptoBackend` and `SecretStore` interfaces the mobile adapters
plug into.
- `lib/sharedSmoke.ts` exists only to prove Metro can resolve the
workspace package; it's deleted in Phase 1.
## Roadmap
See `docs/superpowers/specs/2026-05-13-mobile-deployment-roadmap.md`.
````
- [ ] **Step 2: Commit**
```bash
git add apps/mobile/README.md
git commit -m "docs(mobile): phase 0 quickstart + roadmap pointer"
```
---
## Task 11: End-to-end verification (manual, user-driven)
**Files:** None.
This task does no code changes. It hands the user a checklist they run on real hardware. The implementer subagent does NOT attempt to run `eas build` or boot a device.
- [ ] **Step 1: Typecheck pass (automated)**
```bash
pnpm mobile:typecheck
```
Expected: exit 0.
- [ ] **Step 2: Document hand-off**
The implementer must report back to the controller / user that Phase 0 is code-complete and list these manual verifications for the user to perform on their hardware:
1. `pnpm install` from a fresh clone resolves cleanly.
2. `cd apps/mobile && npx eas-cli init` claims a project ID and replaces the placeholder in `app.json`.
3. `npx eas-cli build --platform android --profile development` produces an installable APK.
4. `npx eas-cli build --platform ios --profile development` produces a simulator `.tar.gz` (or device `.ipa` if free Apple signing succeeds).
5. The dev client installs and Netralax's Landing screen renders with a non-empty version string.
6. Tapping the "Weiter (Smoke-Test)" button navigates to `(app)/chats` without crash.
7. Optional: simulate an error in `app/index.tsx` (throw inside the render) and confirm the ErrorBoundary shows the fallback screen with the "Erneut versuchen" button.
- [ ] **Step 3: No code commit at this step**
Verification is operational; no source changes here.
---
## Self-Review Notes
**Spec coverage:**
- Spec §1 (Netralax rebrand in app.json) → Task 1.
- Spec §2 (icon + splash) → DEFERRED. The spec asked for placeholder Netralax art, but image-generation isn't tractable from the implementer loop and the dev client doesn't care about icon polish. Expo's default chrome stays; `app.json` still references `./assets/icon.png` etc. so dropping in Netralax PNGs later is a no-op. Final art lands in Phase 4 alongside store-listing screenshots.
- Spec §3 (eas.json) → Task 2.
- Spec §4 (minimal app shell) → Tasks 6 (layout), 7 (landing), 8 (chats).
- Spec §4a (ErrorBoundary) → Task 4.
- Spec §5 (sharedSmoke) → Task 5.
- Spec §6 (README + scripts) → Tasks 9 (root scripts) + 10 (README).
- Spec §7 (typecheck + lint) → typecheck gates inside every task; lint stub deferred.
**Type consistency:** `sharedSmoke` shape `{ register: crypto.setCryptoBackend }` matches between spec §5 and Task 5. `ErrorBoundary` Props/State match between spec §4a and Task 4. Stack screen names (`index`, `(app)`) unchanged from the existing scaffold.
**Reading-order safety:** Every task block restates the file path and shows the complete code for the touched file. No "see Task N" references.
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,300 @@
# Call Preview Panel 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:** Replace the always-on "Sprach-Channel" banner with a Discord-DM-style preview panel that only renders when peers are in an active call, showing them as large avatar tiles with a single "Beitreten" button.
**Architecture:** `VoiceChannelRail.tsx` is renamed to `CallPreviewPanel.tsx` and rewritten with a new tile-grid layout. Visibility rule drops the `|| isGroup` clause so groups behave identically to 1:1 chats. All call-session wiring (`useCall`, `useCallPresence`, `joinActiveCall`, `ConversationHeader`'s phone icon for starting calls) stays unchanged.
**Tech Stack:** TypeScript, React 18, Tailwind, react-i18next, existing `useCallPresence` realtime hook.
**Spec:** `docs/superpowers/specs/2026-05-15-call-preview-panel-design.md`
---
## File Overview
**New files:**
- `apps/desktop/src/components/CallPreviewPanel.tsx` — the new panel.
**Deleted files:**
- `apps/desktop/src/components/VoiceChannelRail.tsx` — replaced by `CallPreviewPanel.tsx`.
**Modified files:**
- `apps/desktop/src/pages/ConversationPage.tsx` — change the import + JSX tag.
**i18n note:** No JSON resource changes needed. The orphaned keys (`voice_empty`, `voice_open`, `voice_channel`, `voice_count`) only existed as inline `defaultValue:` strings inside the deleted component — they were never in the locale files. The new component reuses the existing `app:call.active_in_conv` and `app:call.join` keys (present in both `de/app.json` and `en/app.json`).
---
## Task 1: Create `CallPreviewPanel.tsx`
**Files:**
- Create: `apps/desktop/src/components/CallPreviewPanel.tsx`
- [ ] **Step 1: Create the file**
`apps/desktop/src/components/CallPreviewPanel.tsx`:
```tsx
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useCallPresence } from '../lib/useCallPresence';
import { Avatar } from './Avatar';
import { PhoneIcon, SpinnerIcon, XIcon } from './icons';
interface Props {
conversation: ConversationSummary;
}
const MAX_TILES = 7;
/**
* Discord-DM-style call preview panel. Renders only while peers are in the
* conversation's active call and the local user is NOT in it. Provides large
* avatar tiles plus a single "Beitreten" call-to-action. Calls are still
* STARTED via the topbar phone icon (`ConversationHeader.startCall`); this
* component never initiates — only joins.
*/
export function CallPreviewPanel({ conversation }: Props) {
const { t } = useTranslation(['app']);
const { session } = useAuth();
const { state, joinActiveCall } = useCall();
const presentIds = useCallPresence(conversation.id);
const [collapsed, setCollapsed] = useState(false);
const myId = session?.user.id ?? null;
const iAmIn =
(state.kind === 'connected' ||
state.kind === 'connecting' ||
state.kind === 'reconnecting') &&
state.conversationId === conversation.id;
const others = presentIds.filter((u) => u !== myId);
if (iAmIn || others.length === 0) return null;
const visibleTiles = others.slice(0, MAX_TILES);
const overflow = Math.max(0, others.length - MAX_TILES);
const busy = state.kind !== 'idle';
const handleJoin = () => {
if (busy) return;
void joinActiveCall(conversation.id, 'audio');
};
return (
<div className="border-b border-line bg-surface-3/70">
<div className="flex items-center gap-2 px-5 py-2 text-xs">
<PhoneIcon className="h-3.5 w-3.5 text-emerald-500" />
<span className="font-semibold uppercase tracking-[0.12em] text-fg-muted">
{t('app:call.active_in_conv', {
defaultValue: 'Laufender Anruf · {{count}} im Raum',
count: others.length,
})}
</span>
<button
type="button"
onClick={() => setCollapsed((c) => !c)}
aria-label={collapsed ? 'Anrufvorschau ausklappen' : 'Anrufvorschau einklappen'}
className="ml-auto inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-2 hover:text-fg"
>
<XIcon className="h-3.5 w-3.5" />
</button>
</div>
{!collapsed && (
<div className="flex flex-col items-center gap-4 px-5 pb-4 pt-1">
<div className="grid w-full max-w-2xl grid-cols-2 gap-3 md:grid-cols-3">
{visibleTiles.map((id) => {
const member = conversation.members.find((m) => m.userId === id);
const name = member?.profile?.displayName ?? '?';
return (
<div
key={id}
title={name}
className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-2 p-3"
>
<div className="h-16 w-16 overflow-hidden rounded-full">
<Avatar
url={member?.profile?.avatarUrl ?? null}
displayName={name}
className="h-full w-full text-base"
/>
</div>
<span className="line-clamp-1 text-xs font-medium text-fg">{name}</span>
</div>
);
})}
{overflow > 0 && (
<div className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-line bg-surface-2 p-3 text-fg-muted">
<span className="text-xl font-semibold">+{overflow}</span>
<span className="text-xs">weitere</span>
</div>
)}
</div>
<button
type="button"
onClick={handleJoin}
disabled={busy}
className="inline-flex w-full max-w-xs cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? (
<SpinnerIcon className="h-4 w-4" />
) : (
<PhoneIcon className="h-4 w-4" />
)}
<span>{t('app:call.join', { defaultValue: 'Beitreten' })}</span>
</button>
</div>
)}
</div>
);
}
```
- [ ] **Step 2: Verify icon imports resolve**
```
cd D:/Programmieren/ChatApp-Electron/chat-app
grep -n "export const PhoneIcon\|export function PhoneIcon\|export const SpinnerIcon\|export function SpinnerIcon\|export const XIcon\|export function XIcon" apps/desktop/src/components/icons.tsx
```
Expected: all three icons exported. (Earlier tasks confirmed `PhoneIcon`, `SpinnerIcon`; `XIcon` is also used by other modals — verify.) If `XIcon` is missing under that name, swap the import to whatever the close-icon export is in `icons.tsx` (e.g. `CloseIcon`) and report the substitution.
- [ ] **Step 3: Typecheck**
```
pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -10
```
Expected: zero errors. (After the encryption-UX rollout the desktop typecheck is clean.)
- [ ] **Step 4: Commit**
```bash
cd D:/Programmieren/ChatApp-Electron/chat-app
git add apps/desktop/src/components/CallPreviewPanel.tsx
git commit -m "feat(desktop): add CallPreviewPanel — Discord-DM-style join surface"
```
---
## Task 2: Wire the new panel in `ConversationPage.tsx` and delete the old rail
**Files:**
- Modify: `apps/desktop/src/pages/ConversationPage.tsx`
- Delete: `apps/desktop/src/components/VoiceChannelRail.tsx`
- [ ] **Step 1: Swap the import**
In `apps/desktop/src/pages/ConversationPage.tsx`, replace this line (around line 26):
```ts
import { VoiceChannelRail } from '../components/VoiceChannelRail';
```
with:
```ts
import { CallPreviewPanel } from '../components/CallPreviewPanel';
```
- [ ] **Step 2: Swap the JSX usage**
In the same file, replace the JSX line (around line 657):
```tsx
{conversation && !incomingHere && <VoiceChannelRail conversation={conversation} />}
```
with:
```tsx
{conversation && !incomingHere && <CallPreviewPanel conversation={conversation} />}
```
- [ ] **Step 3: Delete the dead component**
```
rm apps/desktop/src/components/VoiceChannelRail.tsx
```
- [ ] **Step 4: Sweep for stragglers**
```
grep -rn "VoiceChannelRail" apps/desktop/src
```
Expected: no hits. If anything remains, clean it up.
- [ ] **Step 5: Typecheck**
```
pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -10
```
Expected: zero errors.
- [ ] **Step 6: Build the desktop renderer once to surface any tailwind/runtime issues**
```
pnpm --filter @chat-app/desktop build 2>&1 | tail -15
```
Expected: build succeeds. Tailwind classes used here (`bg-surface-3/70`, `border-line`, `bg-emerald-600`, `bg-surface-2`, `text-fg`, `text-fg-muted`, `aspect-square`, `line-clamp-1`, `grid-cols-2 md:grid-cols-3`) are all already used elsewhere in the desktop app and should be in the existing tailwind config.
- [ ] **Step 7: Commit**
```bash
cd D:/Programmieren/ChatApp-Electron/chat-app
git add apps/desktop/src/pages/ConversationPage.tsx
git rm apps/desktop/src/components/VoiceChannelRail.tsx
git commit -m "refactor(desktop): replace VoiceChannelRail with CallPreviewPanel
Drops the always-on 'Sprach-Channel' banner. The preview panel renders
only when peers are in the active call (1:1 and group identical).
Calls are still started via the topbar phone icon."
```
---
## Task 3: Manual smoke pass (USER)
This task is for the human operator after the previous two land. Run a fresh dev build and walk through the spec's smoke checklist:
- [ ] Open a 1:1 chat with no active call → panel not rendered.
- [ ] Open a group chat with no active call → panel not rendered (regression test for the bug).
- [ ] Have a peer start a call → panel appears, peer's avatar tile + "Beitreten" visible.
- [ ] Click "Beitreten" → joins the call; panel disappears (you're now `iAmIn`).
- [ ] Hang up → panel reappears with peer still in.
- [ ] Peer hangs up too → panel disappears.
- [ ] Resize the window narrow → grid collapses to 2 columns.
- [ ] Group call with 9 participants → 7 avatar tiles + one `+2` overflow tile.
- [ ] Click the `✕` in the panel header → tiles collapse, header stays. Click again → tiles re-expand.
If any step fails, capture the exact behavior and reopen the relevant task.
---
## Self-Review
**1. Spec coverage:**
- Visibility rule (`!iAmIn && others.length > 0`, no `|| isGroup`) — Task 1.
- Discord-DM tile layout, ~120×120px tiles, 2/3-column responsive grid, `+N` overflow at 8 — Task 1.
- "Aktiver Anruf · n im Channel" header reusing `app:call.active_in_conv` — Task 1.
- Centered "Beitreten" CTA, disabled when busy, `SpinnerIcon` while connecting — Task 1.
- Optional collapse via `useState<boolean>`, default expanded — Task 1.
- Mount point unchanged (`!incomingHere &&` gate retained) — Task 2.
- Old empty state and `Channel öffnen` removed by deleting the rail — Task 2.
- Manual smoke list — Task 3.
**2. Placeholder scan:** Clean. Every code block is complete; every command has expected output; the only `defaultValue:` strings are in the i18n calls (not placeholders, just fallback copy).
**3. Type consistency:** `Props { conversation: ConversationSummary }` matches the type used by the deleted rail and consumed by `ConversationPage.tsx`. `useCallPresence` returns `string[]` per `apps/desktop/src/lib/useCallPresence.ts`. `useCall().state.kind` values (`idle`, `connecting`, `connected`, `reconnecting`) match the existing `CallContext` discriminated union. `joinActiveCall(convId, 'audio')` matches the existing `useCall` API.
No gaps; plan is complete.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,91 @@
# Mobile Deployment Roadmap
**Date:** 2026-05-13
**Scope:** `apps/mobile` end-to-end ship to iOS App Store + Google Play
**Status:** Decomposition — each phase ships independently with its own spec.
---
## Where we are today
- `apps/mobile` is an Expo + React Native scaffold from the very first commit (`f7cfd2a initial`). It has the right deps (`expo-router`, `react-native-libsodium`, `expo-secure-store`, `expo-sqlite`, `expo-notifications`) but no real screens — just two placeholders ("ChatApp" landing, "Chats — placeholder").
- Earlier mobile work (image attachments, voice messages, voice calls, reactions, profile editing, push wiring) lives on the archived `byGalax/chat-app` GitHub repo. It targets a pre-Tauri→Electron `packages/shared` API and is not directly portable; we treat it as reference material, not a merge source.
- `packages/shared` is platform-agnostic by design (`CryptoBackend` and `SecretStore` interfaces wait for mobile adapters), so the desktop's business logic is reusable verbatim once we plug in RN-flavoured implementations.
- The desktop reached `v0.17.5` with a Netralax rebrand, Discord-style call/chat UX, and an EAS-free electron-builder/electron-updater release pipeline pointed at `update.netralax.cloud`.
## Goal
Ship the Netralax mobile app to iOS App Store + Google Play with the core Discord-style chat + voice/video calls of the desktop, working over the same self-hosted Supabase + LiveKit backend.
## Decomposition
Five phases, each with its own design spec, plan, and ship gate. We brainstorm and execute one at a time; later phases only get fleshed out once their prerequisites land.
### Phase 0 — Deployment Foundation
**Spec:** `2026-05-13-mobile-phase-0-foundation-design.md`
**Goal:** prove the pipeline. After Phase 0, `eas build` (or `expo run`) produces a runnable Netralax-branded shell on a real iPhone + Android device. No features yet — but every later phase ships through this same build pipeline.
### Phase 1 — Auth + Chat MVP
**Why next:** smallest end-to-end vertical slice that's actually useful. Users can log in, see conversations, send/receive text.
- Mobile `CryptoBackend` adapter wrapping `react-native-libsodium`.
- Mobile `SecretStore` adapter wrapping `expo-secure-store`.
- Magic-link auth + device-key registration (reuse `packages/shared/auth`).
- Conversation list (DM + group) with last-message preview.
- Conversation view: decrypt + render text messages, send text.
- Push notifications via `expo-notifications` + the existing `notify-push` edge function.
- Minimal profile + logout.
### Phase 2 — Messaging Features
**Why next:** mobile users expect parity with desktop on day-to-day messaging.
- Image attachments (camera roll + camera capture).
- File attachments (document picker).
- Voice messages (record + play).
- Reactions, edit, delete, reply, forward.
- Read receipts + delivery state.
- Typing indicator.
- Polls (optional / stretch).
### Phase 3 — Voice/Video Calls
**Why now and not Phase 1:** RN LiveKit + native call UX (CallKit / ConnectionService) is the heaviest single feature. Hard to scope without the basic chat working first, and not blocking for an early TestFlight.
- LiveKit React Native SDK (`@livekit/react-native` + `@livekit/react-native-webrtc`).
- Incoming call notifications wake the app via CallKit (iOS) + ConnectionService (Android).
- Outgoing call flow.
- Voice + video tracks, mute/hangup, speaker/earpiece switch, headset routing.
- Screen sharing is explicitly out of scope on mobile.
### Phase 4 — Polish + Store Submission
**Why last:** can't submit until the features are in.
- Privacy policy + Terms surfaced inside the app.
- Store-listing assets: screenshots, descriptions, age rating.
- Code signing: Apple Developer Program enrolment + provisioning, Android upload keystore.
- TestFlight internal + external testing.
- Google Play internal track → closed test → production.
## Cross-cutting decisions (locked in here, no per-phase relitigation)
| Topic | Decision | Rationale |
|---|---|---|
| Framework | Expo + React Native (existing scaffold) | Already set up; New Architecture enabled; managed-workflow gives EAS Build out of the box |
| Branding | Netralax everywhere | Matches the desktop rebrand released as v0.17.x |
| Bundle / package id | `cloud.netralax.app` (both iOS + Android) | Matches the desktop's AppUserModelId `cloud.netralax.desktop`; `com.meinname.chatapp` is dev-placeholder |
| Shared business logic | Consume `@chat-app/shared` verbatim | Designed for this; the only mobile-specific pieces are the `CryptoBackend` + `SecretStore` adapters |
| Old chat-app mobile sprints | Reference, do not merge | API drift since pre-Electron; cleaner to rewrite against current shared |
| Backend | Self-hosted Supabase at `update.netralax.cloud` + LiveKit | Unchanged from desktop |
| Push delivery | Expo Push Service for development; native APNS + FCM after EAS submit | Avoids managing certs in Phase 1 |
| State management | React local state + tiny context, same as desktop | Avoid Redux/Zustand bloat for a chat app |
| Persistent local store | `expo-sqlite` (already in deps) | Mirror desktop's better-sqlite3 schema |
## Ship gates between phases
A phase only ends — and the next one begins — when:
1. Manual verification of every spec'd flow passes on **both** an iOS device and an Android device.
2. The previous phase's release build still runs (no regression).
3. The phase's spec is reflected in the codebase (no straggling TODOs that were in scope).
## Out of scope for the entire roadmap
- Web build of the mobile app (Expo can technically emit one — not worth the bundle-cost double-duty since the Electron app already covers desktop).
- macOS / Windows React Native targets.
- Watch / TV / wearable apps.
- iPad-specific UI tuning beyond `"supportsTablet": true` (we ship the phone UI on tablet for now).
- Resurrecting any code from the archived `chat-app` mobile sprints.
@@ -0,0 +1,187 @@
# Mobile Phase 0 — Deployment Foundation
**Date:** 2026-05-13
**Scope:** `apps/mobile`
**Roadmap context:** `2026-05-13-mobile-deployment-roadmap.md`
---
## Problem
We can't even smoke-test a Netralax-branded build on a real device today. The Expo scaffold says "ChatApp" everywhere, the EAS project ID is a literal string `REPLACE_WITH_EAS_PROJECT_ID`, and the iOS bundle id / Android package id is the placeholder `com.meinname.chatapp`. Every later phase ships through this build pipeline, so it needs to work end-to-end before we touch features.
## Goal
After Phase 0, a developer (or the user) can run a single command from the repo root and get an installable `.ipa` or `.apk` carrying the Netralax brand. No real features — just a launch screen that renders, plus the harness underneath: SafeArea, navigation root, error boundary, the `@chat-app/shared` package compiling and importable from the mobile app, version readout, EAS config.
## Non-goals
- Auth, chat, calls, push wiring (Phases 13).
- App Store / Play Store submission (Phase 4).
- Resurrecting the legacy mobile sprints from the archived `chat-app` repo.
- Code-signing certificates / provisioning profiles. Phase 0 produces development-signed builds via EAS managed credentials; production signing is Phase 4.
## Design
### 1. Branding rename in `apps/mobile/app.json`
Replace every `ChatApp`/`com.meinname.chatapp` reference with the Netralax brand. Final values:
```json
{
"expo": {
"name": "Netralax",
"slug": "netralax",
"scheme": "netralax",
"version": "0.1.0",
"ios": {
"bundleIdentifier": "cloud.netralax.app",
"supportsTablet": true,
"infoPlist": { "ITSAppUsesNonExemptEncryption": false }
},
"android": {
"package": "cloud.netralax.app",
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0b0b0f"
}
},
"extra": { "eas": { "projectId": "REPLACE_WITH_EAS_PROJECT_ID" } }
}
}
```
`extra.eas.projectId` stays as the placeholder string in source — the user runs `eas init` once locally, which fills it in and commits. Documented in the mobile README.
### 2. App icon + splash
Replace `assets/icon.png`, `assets/adaptive-icon.png`, `assets/splash.png` with placeholder Netralax variants (purple-on-dark `N` mark matching the sidebar avatar in the desktop). A single 1024×1024 master plus the 1024-Android-foreground and 1284×2778 splash is enough for Phase 0. Final art lives in `apps/mobile/assets/`.
### 3. EAS configuration
Create `apps/mobile/eas.json`:
```json
{
"cli": { "version": ">= 13.0.0" },
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": { "simulator": true },
"android": { "buildType": "apk" }
},
"preview": {
"distribution": "internal",
"ios": { "simulator": false },
"android": { "buildType": "apk" }
},
"production": {
"ios": { "simulator": false },
"android": { "buildType": "app-bundle" }
}
},
"submit": {
"production": {
"ios": { "appleId": "REPLACE_WITH_APPLE_ID", "ascAppId": "REPLACE_WITH_ASC_APP_ID" },
"android": { "serviceAccountKeyPath": "./play-service-account.json" }
}
}
}
```
`production`-submit credentials stay as placeholders — Phase 4 fills them in. The `development` profile produces a custom dev client + APK that can be installed and used with `expo start --dev-client`.
### 4. Minimal app shell that doesn't crash
Four changes to verify the runtime is healthy:
- `app/_layout.tsx`: wrap the `<Stack>` in `<SafeAreaProvider>` from `react-native-safe-area-context`. Add `<GestureHandlerRootView style={{ flex: 1 }}>` outside it so future gesture-based UI is unblocked. Wrap everything in a top-level `<ErrorBoundary>` (new component, §4a) so an unhandled render error shows a readable fallback instead of a white-screen crash on a TestFlight build.
- `app/index.tsx`: render "Netralax" (not "ChatApp"), display the app version read from `expo-application` (`Application.nativeApplicationVersion`), and link to `/(app)/chats` via a placeholder button so navigation is proved.
- `app/(app)/chats.tsx`: same — render "Netralax — Chats coming soon" so the protected-stack mounts cleanly.
#### 4a. Error boundary
`apps/mobile/components/ErrorBoundary.tsx` is a small React class component that catches render errors and shows the error message + a "Reload" button. Same shape as the desktop's crash-recovery surface. Around 40 lines.
### 5. Shared package smoke import
Phase 0's gate is that `import { CryptoBackend } from '@chat-app/shared'` resolves and the Metro bundler doesn't choke on the shared package's exports. We don't *use* the import — just type it so a compile failure surfaces here, not later.
Concretely: add `apps/mobile/lib/sharedSmoke.ts` that pulls a **runtime** value (not a type-only import — type imports are erased by the TS compiler and never reach Metro). `@chat-app/shared` re-exports its modules as namespaces (`crypto`, `auth`, `chat`, `friends`, `rtc`, `supabase`, `i18n`, `admin`), so we go through one of those:
```ts
// Force-imports a runtime symbol from @chat-app/shared so Metro
// actually resolves the package on app boot. A type-only import would
// be erased and never surface a packaging problem. This file goes away
// once Phase 1's real crypto adapter lands and registers the backend
// for real.
import { crypto } from '@chat-app/shared';
// Pull the function into a runtime reference so the bundle keeps the
// import. Calling it without a backend would throw, so we just retain
// the reference.
export const sharedSmoke = { register: crypto.setCryptoBackend };
```
Imported from `_layout.tsx` once, value unused. Phase 1 removes it.
### 6. README + scripts
`apps/mobile/README.md` gets a "Phase 0 quickstart" section that walks through `eas init` + the first development build + installing the dev client on a device.
Root `package.json` gets convenience scripts only if they're missing: `mobile:dev`, `mobile:ios`, `mobile:android`, `mobile:typecheck` — all `pnpm --filter @chat-app/mobile <cmd>` wrappers.
### 7. Typecheck + lint plumbing
Verify `pnpm --filter @chat-app/mobile typecheck` already runs (the mobile `package.json` already declares it). If `eslint` is wired similarly, add it to a CI workflow stub — but actual CI integration is out of scope here.
## File structure (after Phase 0)
```
apps/mobile/
├── app/
│ ├── _layout.tsx ← MODIFIED: SafeArea, GestureHandler, providers shell
│ ├── index.tsx ← MODIFIED: Netralax landing with version + nav link
│ └── (app)/
│ ├── _layout.tsx ← unchanged
│ └── chats.tsx ← MODIFIED: "coming soon" placeholder text
├── assets/
│ ├── icon.png ← REPLACED: Netralax 1024×1024
│ ├── adaptive-icon.png ← REPLACED: foreground for Android adaptive
│ └── splash.png ← REPLACED: Netralax splash
├── lib/
│ └── sharedSmoke.ts ← NEW: temporary import-canary
├── app.json ← MODIFIED: Netralax branding, ids
├── eas.json ← NEW: build profiles
├── package.json ← unchanged (deps stay)
└── README.md ← MODIFIED: Phase 0 quickstart
```
## Risks
- **`react-native-libsodium` + New Architecture (Fabric / TurboModules).** `newArchEnabled: true` is in `app.json`. The library claims support, but past RN libsodium ports broke around the JSI migration. If the dev client crashes on require, fall back to disabling new arch in Phase 0 and re-enable in Phase 1 once the adapter is wired and tested.
- **`@chat-app/shared` Node-only paths.** The package was authored against Node + browser; the supabase client uses `fetch` and `WebSocket` which RN polyfills. If any export pulls `node:crypto` directly the Metro resolver may fail. The smoke import (§5) is the canary for this.
- **EAS account access.** `eas init` requires an Expo account. The user owns it; we treat the project ID slot as an external input.
- **Apple Developer enrolment.** Phase 0 builds for iOS need an Apple ID; EAS can generate dev certs automatically the first time `eas build --platform ios` runs against the user's Apple ID. No paid enrolment needed for the development profile (free signing).
## Verification (run before declaring Phase 0 done)
1. `pnpm --filter @chat-app/mobile typecheck` → exit 0.
2. `pnpm --filter @chat-app/mobile dev` (`expo start --dev-client` once a dev client is installed) → app boots on a real iPhone *and* a real Android device.
3. Landing screen shows "Netralax" + a non-empty version string read at runtime.
4. Tapping the placeholder nav link routes into `/(app)/chats` without crash.
5. `eas build --platform android --profile development` produces an APK that installs and launches on a physical Android device.
6. `eas build --platform ios --profile development` produces a `.tar.gz` (simulator build) **OR** an `.ipa` (device build, if free signing succeeded).
7. Optional: visual check that the icon + splash render as Netralax (not Expo's default) on both platforms.
If any of those fail, Phase 0 isn't done — fix in this phase rather than rolling it into Phase 1.
## Out of scope
- Push notification token registration (Phase 1).
- Linking to the Supabase URL / env-var injection (Phase 1).
- Local SQLite schema (Phase 1).
- Any screen beyond Landing + Chats-coming-soon.
- Reanimated / gesture-based animations.
- Dark / light mode polish — system default is fine.
@@ -0,0 +1,196 @@
# Mobile Phase 1 — Auth + Chat MVP
**Date:** 2026-05-13
**Scope:** `apps/mobile`
**Roadmap context:** `2026-05-13-mobile-deployment-roadmap.md`
---
## Problem
Phase 0 delivered a Netralax-branded shell. It boots, renders a placeholder, and is configured for `eas build`, but the app has no actual functionality. To call mobile "deployment-ready with working features" the user must be able to log in, see their chats, and send a text message — the smallest end-to-end vertical slice that's also useful.
## Goal
After Phase 1, a Netralax user on iOS or Android can:
1. Open the app, enter their account email, request a magic link.
2. Tap the link in their email (deep link returns to `netralax://auth/callback`), complete the session, and land on the chat list.
3. See their existing conversations (DMs + groups) with last-message previews, decrypted on-device.
4. Open a conversation, see the message history (decrypted), and send a new text message that the desktop client receives correctly.
5. Log out from a settings entry, returning to the login screen.
## Non-goals
- Signup with invite code (login-only MVP — accounts are provisioned via desktop or admin scripts).
- Attachments, voice messages, reactions, edits (Phase 2).
- Calls (Phase 3).
- Push notifications (deferred — chat works without push, the user pulls fresh data on screen focus / pull-to-refresh).
- Realtime subscription on conversations (deferred). Phase 1 polls / refetches on screen focus.
- Read receipts, typing indicator, presence (Phase 2).
- Friend list, profile edit, settings beyond logout.
## Design
### 1. Theme constants
`apps/mobile/theme/colors.ts` exports a flat `colors` object with the hex codes Phase 0 inlined across files (`#0b0b0f`, `#9ca3af`, `#fff`, `#6b7280`, `#5865f2`) plus a couple of new ones we need for Phase 1 (input border, danger). Every Phase 1 screen imports from here; no new hex literals appear in components.
### 2. Crypto adapter (`apps/mobile/lib/cryptoBackend.ts`)
A near-mirror of `apps/desktop/src/lib/cryptoBackend.ts` but wrapping `react-native-libsodium` instead of `libsodium-wrappers-sumo`. The two libraries expose the same primitive API (the RN port re-exports the libsodium-wrappers types) — only the import + the absence of `_sodium.ready` differs. Synchronous (no WASM warm-up needed on a native lib). Registered at boot via `crypto.setCryptoBackend(createLibsodiumBackend())` from `_layout.tsx`.
### 3. Secret store adapter (`apps/mobile/lib/secretStore.ts`)
Implements `SecretStore` (from `@chat-app/shared/auth/secure-storage`) backed by `expo-secure-store`. Per the contract, values are `Uint8Array`; we base64-encode at the boundary because `SecureStore` only accepts strings. `buffer` polyfill is part of RN's base set, no extra install.
### 4. Supabase session storage (`apps/mobile/lib/sessionStorage.ts`)
Supabase's JS client needs an async-storage object for session tokens. Use `@react-native-async-storage/async-storage` (new dep) — supabase-js v2 accepts its `getItem` / `setItem` / `removeItem` API directly.
### 5. Env config (`apps/mobile/lib/env.ts`)
Expo exposes vars prefixed `EXPO_PUBLIC_*` to the bundle via `process.env`. Read three:
- `EXPO_PUBLIC_SUPABASE_URL`
- `EXPO_PUBLIC_SUPABASE_ANON_KEY`
- `EXPO_PUBLIC_AUTH_REDIRECT_URL` — defaults to `netralax://auth/callback`.
`apps/mobile/.env.example` is added with placeholder values + a README pointer.
### 6. Supabase client (`apps/mobile/lib/supabase.ts`)
Mirrors desktop's `apps/desktop/src/lib/supabase.ts`. Calls `createClient` from `@chat-app/shared/supabase` with the mobile `sessionStorage` adapter and `detectSessionInUrl: false` (the callback screen parses the URL manually).
### 7. Auth context (`apps/mobile/lib/authContext.tsx`)
React Context provider that wraps the authenticated tree. Exposes:
- `session: Session | null`
- `user: User | null`
- `device: DeviceRecord | null` — the registered device for this install
- `loading: boolean` — true while hydrating from AsyncStorage on boot
- `signOut(): Promise<void>`
On mount:
1. `await supabase.auth.getSession()` — pulls from AsyncStorage.
2. If a session exists, derive the device by `listOwnDevices(supabase)` and matching on a locally-stored device id (in `secretStore` under key `device.id`).
3. If session exists but no device record yet (fresh install, account exists on other devices), call `registerDevice` with a fresh key pair, store the new device id + private key in `secretStore`.
The provider subscribes to `supabase.auth.onAuthStateChange` so signing out from anywhere updates the tree.
### 8. Route gating
- `app/index.tsx`: if `session` is set, redirect to `/(app)/chats`. Otherwise show the login form.
- `app/(app)/_layout.tsx`: if no `session`, redirect to `/`. (Existing file: change from "always render Stack" to "guard on session".)
### 9. Login screen (`app/index.tsx`)
Replaces the Phase-0 placeholder. Renders:
- Netralax wordmark.
- Email `TextInput`.
- "Magic Link senden" button → `auth.loginWithMagicLink(supabase, email, env.authRedirectUrl)`.
- After tap: show "Check deine Mails" state until the deep link fires.
- Error banner on failure.
### 10. Auth callback (`app/auth/callback.tsx`)
A new screen reachable via the `netralax://auth/callback` deep link. Reads URL params (Supabase magic-link callback puts `access_token` + `refresh_token` in the URL hash or query), calls `supabase.auth.setSession({...})`, routes to `/(app)/chats`. On failure, routes back to `/` with an error message.
Expo Router auto-handles the deep link → screen mapping when the scheme in `app.json` matches and the path matches the route file.
### 11. Conversation list (`app/(app)/chats.tsx`)
Replaces the Phase-0 placeholder. Uses `chat.listConversations(supabase)` from `@chat-app/shared/chat`. Renders a `FlatList` of `ConversationRow` (new component):
- DM: peer's display name + Avatar (initial-letter circle).
- Group: group name + "N Mitglieder".
- Last message preview — decrypted via `decryptMessages` if encrypted, else "…".
- Timestamp (relative — "Vor 5 Min").
Pull-to-refresh refetches.
Tap on row → navigates to `/(app)/conversations/[id]`.
A small icon-button in the header opens a logout modal calling `signOut()`.
### 12. Conversation detail (`app/(app)/conversations/[id].tsx`)
Loads:
- `fetchConversationMessages(supabase, { conversationId, limit: 50 })`.
- `listConversationDeviceKeys(supabase, conversationId)` for decryption.
- `decryptMessages({ messages, deviceKeys, myDeviceId, myPrivateKey })` to get plaintext.
Renders an inverted `FlatList` (newest at the bottom, like Discord). Each row shows sender name + body + timestamp. Attachments rendered as "📎 [Anhang]" placeholders — Phase 2 adds real rendering.
Bottom: a `TextInput` + send button. On send: `sendEncryptedMessage`. Optimistically appends to local state, then refetches.
### 13. Cleanup
- Delete `apps/mobile/lib/sharedSmoke.ts` and the `void sharedSmoke` import in `_layout.tsx`.
## File structure (after Phase 1)
```
apps/mobile/
├── app/
│ ├── _layout.tsx ← MODIFIED: register crypto backend, mount AuthProvider
│ ├── index.tsx ← MODIFIED: Login screen (magic link form)
│ ├── auth/
│ │ └── callback.tsx ← NEW: deep-link landing
│ └── (app)/
│ ├── _layout.tsx ← MODIFIED: session-gated stack
│ ├── chats.tsx ← MODIFIED: real conversation list + logout
│ └── conversations/
│ └── [id].tsx ← NEW: conversation view + send
├── components/
│ ├── ErrorBoundary.tsx ← unchanged
│ ├── Avatar.tsx ← NEW: initial-letter avatar circle
│ └── ConversationRow.tsx ← NEW: list row
├── lib/
│ ├── authContext.tsx ← NEW
│ ├── cryptoBackend.ts ← NEW
│ ├── env.ts ← NEW
│ ├── secretStore.ts ← NEW
│ ├── sessionStorage.ts ← NEW
│ ├── supabase.ts ← NEW
│ ├── timeFormat.ts ← NEW
│ └── sharedSmoke.ts ← DELETED
├── theme/
│ └── colors.ts ← NEW
├── .env.example ← NEW
└── package.json ← MODIFIED: + @react-native-async-storage/async-storage
```
## Risks
- **`react-native-libsodium` New Architecture compatibility.** v1.7 claims Fabric/TurboModule support. If `setCryptoBackend(createLibsodiumBackend())` crashes on app boot under New Arch, fall back to `"newArchEnabled": false` and re-evaluate.
- **`@chat-app/shared` ESM resolution under Metro.** Shared emits ESM with `.js` import specifiers. Metro's default resolver handles this; if it doesn't, set `metro.config.js`'s `resolver.unstable_enablePackageExports: true`.
- **Magic-link deep-link return.** Expo Router auto-maps `netralax://auth/callback` to `app/auth/callback.tsx` since the scheme in `app.json` matches (`netralax`) and `expo-linking` is in the prebuild.
- **Device key persistence.** The private key blob lives in `expo-secure-store` (Keychain on iOS, Keystore on Android). Uninstalling the app wipes the key — same as desktop. Backup/restore is post-Phase-4.
## Verification (manual on real hardware after typecheck passes)
1. `pnpm --filter @chat-app/mobile typecheck` → exit 0.
2. `.env.local` populated with the same `SUPABASE_URL` / `SUPABASE_ANON_KEY` the desktop uses → app boots, shows Login.
3. Enter an existing-user email → "Magic Link senden" succeeds → "Check deine Mails" state appears.
4. Open the email link on the device → callback URL fires Expo Router → app navigates to `/(app)/chats`.
5. Chat list loads; existing DMs and groups from the desktop are visible with decrypted previews.
6. Tap a conversation → detail screen opens, history loads, decrypted message bodies render newest-at-bottom.
7. Type "test from mobile" → send → desktop client of the same account sees the new message via Supabase realtime.
8. Pull-to-refresh on chat list re-fetches.
9. Tap settings → "Abmelden" → returns to Login screen, session cleared from AsyncStorage.
## Out of scope
- Push notifications (`expo-notifications` + `notify-push` edge function).
- Realtime subscription on the mobile side (relies on focus-refetch).
- Conversation creation (new DM / new group) from mobile.
- Attachments, voice messages, reactions, edits, replies, forwards (Phase 2).
- Read receipts, typing, delivery state (Phase 2).
- Voice/video calls (Phase 3).
- Backup/restore device keys (post-Phase-4).
- Profile editing, friends, admin tools (post-Phase-4).
@@ -0,0 +1,143 @@
# Mobile Phase 2 — Messaging Features
**Date:** 2026-05-14
**Scope:** `apps/mobile`
**Roadmap context:** `2026-05-13-mobile-deployment-roadmap.md`
---
## Problem
Phase 1 ships text-only chat. A mobile chat client without image attachments or reactions feels half-built. To make Netralax mobile genuinely competitive — and to call the goal of "features working" satisfied — we need the high-impact subset of the desktop's messaging feature set wired into mobile screens.
## Goal
After Phase 2, a Netralax mobile user can:
1. **Attach an image** to a message — from the photo library or via the camera — and watch it upload, encrypt, and arrive on the desktop client decrypted.
2. **React to a message** with an emoji via long-press → quick reaction strip; see reaction badges underneath the bubble; tap a badge to toggle their own reaction off.
3. **Reply to a message** via long-press → Reply → see the quoted source in a banner above the input; send the message with `replyToId` set; the reply renders the quoted preview on both sides.
4. **Delete an own message** via long-press → Delete → soft-delete confirmation; the bubble flips to "Diese Nachricht wurde gelöscht".
## Non-goals
- File / document attachments (high effort, low frequency on mobile — Phase 2.5).
- Voice messages (record + play UI is a substantial sub-feature — Phase 2.5).
- Edit own message (Phase 2.5).
- Forward (low priority — post-Phase-4).
- Read receipts + delivery state (needs realtime — Phase 1.5).
- Typing indicator (needs realtime — Phase 1.5).
- Polls (post-Phase-4).
- Reaction picker beyond a fixed 6-emoji strip (full picker is post-Phase-4).
- Multi-image gallery (one image per message in Phase 2).
## Design
### 1. New dependency
Add `expo-image-picker` to the mobile workspace. It bundles the OS image-picker + the camera-permission flow.
### 2. Image attachments
`apps/mobile/lib/imagePicker.ts` — wrapper around `expo-image-picker` that requests permissions on demand and returns a `{ uri, mimeType, sizeBytes, width, height }` handle or `null` on cancel.
Sending an image:
1. User taps a `+` button next to the input → `ActionSheet` with "Foto aufnehmen" / "Aus Galerie wählen" / "Abbrechen".
2. The picker returns the URI. The conversation detail loads the URI as a `Blob` via `fetch(uri).then((r) => r.blob())`.
3. Pass to `chat.encryptAndUploadAttachment({ client, conversationId, file, mimeType, sizeBytes, width, height })` — returns an `EncryptedAttachmentResult`.
4. After upload, call `chat.sendEncryptedMessage(...)` with `attachmentHandles: [result.handle]` and empty `plaintext`. `sendEncryptedMessage` writes the message_attachments rows internally.
Rendering an image:
1. `parseMessagePayload(plaintext)` returns either `{ kind: 'text', text, attachments }` or other shapes.
2. For text-with-attachments, the bubble renders the text plus an `<AttachmentImage>` per handle. Phase 2 caps at one image per message; multi-image is a future polish.
3. `<AttachmentImage>` calls `chat.downloadAndDecryptAttachment(...)`, gets a `Uint8Array`, converts to a data URL via `data:<mime>;base64,<b64>` and renders `<Image source={{ uri }} />`.
4. Cache by handle id in memory (`apps/mobile/lib/attachmentCache.ts`) to avoid re-downloading on re-render. No disk cache in Phase 2.
### 3. Reactions
`apps/mobile/components/ReactionStrip.tsx` — horizontal row of 6 hardcoded emoji buttons (👍 ❤️ 😂 😮 😢 🎉) shown inside the long-press modal.
Long-press on a message opens `MessageActionsSheet` (§6 below) which contains the reaction strip + action rows. Tapping an emoji calls `chat.addReaction(supabase, messageId, emoji)` (or `removeReaction` if the user already reacted with that emoji), closes the sheet, and re-fetches.
Display: `chat.listReactionsForMessages(supabase, messageIds)` runs after every message-load, stashed in a `Map<messageId, MessageReaction[]>`. The bubble's footer renders a `flex-row` of `[emoji count]` pills (`ReactionPills.tsx`); pills are tappable to toggle.
### 4. Reply
The reply target lives in a `replyTo: ChatMessage | null` state in `[id].tsx`.
Flow:
1. Long-press → sheet → "Antworten".
2. `setReplyTo(message)`.
3. A banner above the `TextInput` shows quoted sender + first line of body + an `X` to cancel.
4. On send: pass `replyToId: replyTo.id` to `sendEncryptedMessage`, then clear the banner.
Rendering a reply:
- A message with `replyToId` set looks up the parent in the local messages array. If found, render a compact quote line above the body inside the same outer bubble. If not, render "↩ Original-Nachricht außerhalb dieses Fensters".
### 5. Delete own message
Long-press on an own message → sheet → "Löschen" → `Alert.alert` confirm. On confirm: `chat.softDeleteMessage(supabase, messageId)`. The server trigger enforces sender-only + 24h window.
Bubble rendering for `deletedAt !== null`: italic placeholder ("Nachricht gelöscht") in `colors.textMuted`.
### 6. Shared message-action modal
`apps/mobile/components/MessageActionsSheet.tsx` — RN `Modal` with `presentationStyle="overFullScreen"` + `transparent`, rendered conditionally from `[id].tsx`. Props: `message`, `mine`, `onClose`, `onReact`, `onReply`, `onDelete`. The sheet renders the reaction strip + action rows on a `colors.surface` panel that slides from the bottom. Touching the backdrop dismisses.
### 7. Bubble extraction
The Phase-1 `MessageRow` was inlined in `[id].tsx`. Phase 2 extracts it to `apps/mobile/components/MessageBubble.tsx` because it now needs to render:
- Reply quote preview.
- Body text (or "Nachricht gelöscht").
- Attachment image.
- Reaction pills.
- Long-press handler.
The single-responsibility expansion warrants its own file.
## File structure (deltas)
| File | Status | Responsibility |
|---|---|---|
| `apps/mobile/package.json` | MODIFIED | Add `expo-image-picker` |
| `apps/mobile/app.json` | MODIFIED | Add `expo-image-picker` plugin with NS*UsageDescription strings |
| `apps/mobile/lib/imagePicker.ts` | NEW | Permission + pick helper |
| `apps/mobile/lib/attachmentCache.ts` | NEW | In-memory `Map<handleId, dataURL>` |
| `apps/mobile/components/AttachmentImage.tsx` | NEW | Renders an encrypted image attachment |
| `apps/mobile/components/ReactionStrip.tsx` | NEW | 6-emoji quick reactor |
| `apps/mobile/components/ReactionPills.tsx` | NEW | Below-bubble reaction counts |
| `apps/mobile/components/MessageActionsSheet.tsx` | NEW | Long-press modal with reactions + Reply/Delete |
| `apps/mobile/components/MessageBubble.tsx` | NEW | Bubble with text + attachments + reply preview + reactions + deleted state |
| `apps/mobile/app/(app)/conversations/[id].tsx` | MODIFIED | Wires attachments, reactions, reply, delete; uses `MessageBubble` |
## Risks
- **Image picker permissions on iOS.** `NSPhotoLibraryUsageDescription` + `NSCameraUsageDescription` are required in `Info.plist`. Expo manages them via the `expo-image-picker` plugin in `app.json`.
- **Encrypted-attachment data-URL size.** Decoded images can be several MB; converting to a `data:` URI inflates memory. Phase 2 accepts this with an in-memory LRU-free cache (good enough for a few images).
- **Reaction count race.** Two users react simultaneously → server stores both, local needs to refetch. `listReactionsForMessages` is cheap enough to call after each user reaction.
- **Soft-delete UX without realtime.** Other clients see the deletion only after refetch. Pull-to-refresh propagates; Phase 1.5 realtime would fix this.
## Verification
1. `pnpm --filter @chat-app/mobile typecheck` exits 0.
2. On a real device + the desktop signed into the same account:
- Mobile: snap a photo, send it. Desktop receives and renders it inline.
- Desktop: sends a message. Mobile receives it, long-presses, sends a 👍. Desktop shows the reaction badge.
- Mobile: long-press → Reply → type → send. Desktop shows the threaded reply preview.
- Mobile: long-press own message → Delete → confirm. Both clients show "Nachricht gelöscht" after refresh.
## Out of scope
- File / document attachments.
- Voice messages.
- Edit message.
- Forward.
- Full emoji picker.
- Disk-cached image decryption.
- Realtime subscriptions.
- Conversation creation from mobile.
@@ -0,0 +1,167 @@
# Mobile Phase 3 — Voice Calls
**Date:** 2026-05-14
**Scope:** `apps/mobile`
**Roadmap context:** `2026-05-13-mobile-deployment-roadmap.md`
---
## Problem
Phases 1+2 cover messaging. The desktop ships voice/video calls via LiveKit; without parity on mobile the app feels incomplete. Phase 3 brings audio calls (1:1 DM + groups) into the mobile client over the same LiveKit + Supabase-realtime signaling stack the desktop already uses.
## Goal
After Phase 3, a Netralax mobile user can:
1. Initiate a voice call from a conversation header — "Anrufen" button next to the title.
2. Receive an incoming-call modal when the peer / a group member starts a call, with **Annehmen** / **Ablehnen** buttons.
3. Join the LiveKit room on accept, hear other participants, and be heard.
4. Toggle mute + lautsprecher (speaker/earpiece), and end the call with the red hangup button.
5. See a participant list in the in-call screen so they know who's on the line.
## Non-goals
- **Video calls** — voice-only MVP. Camera toggle UI is reserved for Phase 3.5 because it requires extra permission strings, camera previews, and view tracks that double the surface.
- **CallKit / ConnectionService native UI** — the OS-level "incoming call" screen requires `react-native-callkeep` + native config + APNS VoIP / FCM data-only payloads. Phase 3 ships an in-app modal; native CallKit is Phase 3.5.
- **Push-based wakeup** — if the app is killed, the user doesn't get notified of an incoming call. Realtime subscription only works while the app is open.
- **Screen sharing** — explicitly out of scope on mobile.
- **Call recording, captions, soundboard** — desktop-only conveniences, post-Phase-4.
- **Call-stream end-to-end encryption beyond what the LiveKit token mint already enforces** — server-side RLS + short-lived JWT handle authorisation.
## Design
### 1. New dependencies
- `@livekit/react-native` — the LiveKit RN SDK; provides `Room`, `LocalParticipant`, `RemoteParticipant`, `AudioSession`.
- `@livekit/react-native-webrtc` — peer dep that ships the actual WebRTC stack as native modules.
Both LiveKit packages require native code, so the workspace already runs through EAS Dev Client (Phase 0 set up the dev profile). The `@livekit/react-native` Expo plugin needs registration in `app.json`.
### 2. Permissions
`app.json` gains `NSMicrophoneUsageDescription` (via plugin config) plus the Android `RECORD_AUDIO` permission. No camera string in Phase 3 since we don't capture video. Also add iOS `audio` background mode so the LiveKit room stays alive when the user backgrounds the app mid-call.
### 3. Call signaling
`apps/mobile/lib/callSignal.ts` — Supabase realtime channel subscription:
- `subscribeCallSignals(client, userId, onSignal)` subscribes to `signalTopic(userId)`, parses `CallSignal` payloads, returns an unsubscribe function.
- `sendCallSignal(client, toUserId, payload)` broadcasts to the peer's channel.
Mirrors the desktop pattern but in a much smaller surface — the desktop's CallContext is ~3000 lines because of features we're not shipping (active speaker, captions, screen share, soundboard, stats overlay, etc.). The mobile Phase 3 equivalent is ~400 lines.
### 4. Call state machine
`apps/mobile/lib/callContext.tsx` — React context holding:
```ts
type CallState =
| { kind: 'idle' }
| { kind: 'outgoing'; callId: string; conversationId: string; peers: string[] }
| { kind: 'incoming'; callId: string; conversationId: string; fromUserId: string }
| { kind: 'connecting'; callId: string; conversationId: string }
| { kind: 'connected'; callId: string; conversationId: string; room: Room; muted: boolean; speakerOn: boolean }
| { kind: 'ended'; reason: 'normal' | 'rejected' | 'cancelled' | 'error' };
```
Exposed actions:
- `startCall(conversationId)` — sends `invite` signals to every other member, transitions to `outgoing`, then joins the LiveKit room immediately so the user is "in" once the first peer accepts.
- `acceptIncoming()` — fetches LiveKit token, connects, sends `accept`, transitions to `connected`.
- `rejectIncoming()` — sends `reject`, transitions to `idle`.
- `cancelOutgoing()` — sends `cancel` to each invited peer, transitions to `idle`.
- `endCall()` — disconnects from LiveKit, sends `end` to participants, transitions to `idle`.
- `toggleMute()` — un/publishes the mic track.
- `toggleSpeaker()` — flips between speaker and earpiece via LiveKit's `AudioSession`.
The provider subscribes to call signals via §3 and surfaces incoming invites to whichever screen is currently mounted.
### 5. Audio routing
LiveKit RN's `AudioSession.startAudioSession()` + `selectAudioOutput()` handle the platform plumbing:
- iOS: AVAudioSession category `playAndRecord`, with the speaker on/off based on the toggle.
- Android: AudioManager speakerphone flag.
On `acceptIncoming()` and `startCall()` we call `AudioSession.startAudioSession()`; on `endCall()` we call `AudioSession.stopAudioSession()`.
### 6. Call screen UI
`apps/mobile/app/(app)/call.tsx` — full-screen route shown when `state.kind === 'connected'`.
Layout:
- Top: conversation name + call duration ("00:42").
- Middle: vertical list of participants — own + remotes — with avatars + names. "verbindet…" until they join, "spricht" emerald dot when active speaker.
- Bottom: 3-button toolbar — Mute, Speaker, Hangup. Hangup is a red circle, the others pill-style.
Routes are gated:
- If `state.kind === 'connected'` and we're not on `/call` → router pushes `/call`.
- If `state.kind === 'idle'` and we are on `/call` → router pops.
- If `state.kind === 'incoming'``IncomingCallModal` (§7) renders over the current screen.
### 7. Incoming-call modal
`apps/mobile/components/IncomingCallModal.tsx` — full-screen modal mounted at the root layout so it surfaces over any screen.
- Visible when `state.kind === 'incoming'`.
- Shows caller name (resolved via the conversation members), big avatar, ringing animation.
- Two buttons: **Annehmen** (green) and **Ablehnen** (red).
- Tap Annehmen → `acceptIncoming()` → routes to `/call` on success.
### 8. Conversation entry points
Add a phone-icon button in the conversation-detail header (`[id].tsx` Stack.Screen `headerRight`). On tap → `startCall(id)`. The icon is a simple PNG-character `📞` for the MVP — a vector icon set is a polish-pass.
### 9. Edge cases handled
- **App backgrounding mid-call:** LiveKit stays connected; audio continues. iOS `audio` background mode is required in `app.json` — added.
- **Network loss:** LiveKit auto-reconnects. We surface "Verbindung verloren — Wiederverbinden…" in the call screen.
- **Caller hangs up before answer:** the modal listens for `cancel` and auto-dismisses.
- **Both sides hang up simultaneously:** dual `end` signals are idempotent.
- **Joining a call that's already started in a group:** every member who got an invite can join the same LiveKit room.
## File structure (deltas)
| File | Status | Responsibility |
|---|---|---|
| `apps/mobile/package.json` | MODIFIED | Add `@livekit/react-native`, `@livekit/react-native-webrtc` |
| `apps/mobile/app.json` | MODIFIED | Microphone permission, iOS `audio` background mode, LiveKit plugin |
| `apps/mobile/lib/callSignal.ts` | NEW | Realtime subscribe/publish helpers |
| `apps/mobile/lib/callContext.tsx` | NEW | Call state machine, signal dispatch, LiveKit room lifecycle |
| `apps/mobile/components/IncomingCallModal.tsx` | NEW | Annehmen / Ablehnen modal |
| `apps/mobile/app/_layout.tsx` | MODIFIED | Mount `CallProvider` under `AuthProvider`; render `IncomingCallModal` at root |
| `apps/mobile/app/(app)/call.tsx` | NEW | In-call full-screen UI |
| `apps/mobile/app/(app)/_layout.tsx` | MODIFIED | Register the `call` route |
| `apps/mobile/app/(app)/conversations/[id].tsx` | MODIFIED | Phone-icon header button → `startCall(id)` |
## Risks
- **`@livekit/react-native-webrtc` + New Architecture.** Supported but a moving target. If a runtime crash surfaces under `newArchEnabled: true`, drop to `false` for the next dev build.
- **Audio session conflicts.** Other apps holding the audio session may interrupt. LiveKit's `AudioSession` API requests focus; we accept brief interruptions.
- **Realtime channel dies on app sleep.** Backgrounded app → iOS budgets out the JS bridge after ~30s. Phase 3 accepts this; Phase 3.5 with CallKit + VoIP push fixes it.
- **Token endpoint dependency.** The `mint-livekit-token` edge function must accept the mobile session JWT — same as desktop, server-side RLS already in place.
## Verification
1. `pnpm --filter @chat-app/mobile typecheck` exits 0.
2. With the mobile dev client built + desktop signed into the same account, on real hardware:
- Mobile starts a call → desktop sees incoming-call panel.
- Desktop accepts → audio flows both ways.
- Mute / speaker toggles work.
- Hangup ends the call cleanly on both sides.
3. Reverse direction.
4. Reject + cancel flows.
5. Group: any one member starts → all others get the modal; multiple can join.
## Out of scope (Phase 3.5 / 4)
- Video calls (camera + view tracks).
- Native CallKit / ConnectionService.
- VoIP push to wake the app from killed/background.
- Call history / missed-call UI on the chat list.
- Screen sharing.
- Active-speaker reorder, captions, in-call soundboard.
- Bluetooth headset routing menu beyond the simple toggle.
@@ -0,0 +1,126 @@
# Call Preview Panel — Design
**Date:** 2026-05-15
**Scope:** Desktop only (mobile call UI is separate).
**Status:** Approved by user.
## Problem
The current `VoiceChannelRail` component sits at the top of the chat feed and is **always visible in group conversations** with the empty-state "Niemand drin — sei der Erste · Channel öffnen". This Discord-server-style "voice channel" affordance is wrong for a friends-messenger: there is no concept of named voice rooms, calls are placed person-to-person via the topbar phone icon, and the always-on banner adds noise without value.
Concretely, the screenshot the user objected to shows the band rendered for a 3-person group chat with no active call — there's nothing to "open" because the topbar phone icon is the actual call-start entry point.
## Goals
- Banner appears **only when an active call exists** in the current conversation and the local user is not already in it.
- Behavior is identical for 1:1 and group conversations.
- When visible, the panel shows participants as Discord-DM-style tiles (large avatar + name), with a single prominent "Beitreten" button.
- Anrufe starten weiterhin über das Phone-Icon in `ConversationHeader` (`startCall`); diese Komponente initiiert keine neuen Calls.
## Non-Goals
- Live video preview of in-call participants. (Requires a LiveKit subscribe-only-mode wiring; deferred.)
- Speaking-indicator rings. (Requires LiveKit data-channel subscription without joining; deferred.)
- Per-participant mic-muted overlay icons. (Same reason; deferred.)
- Mobile (React Native) variant.
- Any change to the actual call session (`useCall`, `joinActiveCall`, ringing UI).
## Architecture
### Component swap
`apps/desktop/src/components/VoiceChannelRail.tsx` is renamed to `CallPreviewPanel.tsx` and rewritten. The old skinny one-line band layout is dropped entirely. No backwards-compat shim — the JSX import in `ConversationPage.tsx` is updated in-place.
### Visibility rule
```ts
const iAmIn =
(state.kind === 'connected' || state.kind === 'connecting' || state.kind === 'reconnecting')
&& state.conversationId === conversation.id;
const others = presentIds.filter((u) => u !== myId);
const visible = !iAmIn && others.length > 0;
```
Note: drops the `|| isGroup` clause that produced the always-on banner.
### Layout
Renders between `ConversationHeader` and the message list (same insertion point as today). When mounted, the panel pushes messages down — fine because the panel only mounts when there is an active call worth surfacing.
```
┌───────────────────────────────────────────────────────────┐
│ 📞 Aktiver Anruf · {n} im Channel [✕ minimieren] │
├───────────────────────────────────────────────────────────┤
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ Avatar │ │ Avatar │ │ Avatar │ responsive grid │
│ │ Anna │ │ Ben │ │ Cara │ │
│ └────────┘ └────────┘ └────────┘ │
│ │
│ [ 📞 Beitreten ] │
└───────────────────────────────────────────────────────────┘
```
- Tiles: ~120×120px squares. Avatar centered (large, fallback initials), display name beneath in a single line (truncate on overflow).
- Grid: `grid-cols-3` on desktop ≥768px, `grid-cols-2` below.
- More than 8 participants: render the first 7 tiles, replace the eighth with a `+N` overflow tile.
- "Beitreten" button: brand-green, ~280px wide, `py-3`, centered below the grid. `disabled` while `state.kind !== 'idle'` (showing `SpinnerIcon` to signal busy).
- Optional collapse: a small `✕` in the header toggles a local `useState<boolean>` to render only the header row when minimized. Default expanded. Collapse state is per-mount (not persisted across navigation).
### Data flow
Inputs (unchanged from current `VoiceChannelRail`):
- `useCallPresence(conversation.id)``string[]` of userIds currently in the room.
- `conversation.members[]` → join with userIds to look up `displayName` + `avatarUrl`.
- `useCall()` → reads `state` (to derive `iAmIn` + `busy`); calls `joinActiveCall(conversation.id, 'audio')` on click.
- `useAuth()` → reads `session.user.id` to filter "self" out of `others`.
No new hooks, no new realtime channels. The existing `useCallPresence` 3-second poll-fallback continues to recover from dropped presence events.
### What is removed
- The empty-state copy ("Niemand drin — sei der Erste") and the "Channel öffnen" button.
- The `isGroup ||` clause in the visibility rule.
- i18n keys orphaned by the empty state: `app:call.voice_empty` and `app:call.voice_open`. (Cleanup in `apps/desktop/src/lib/i18n/*` translation files.)
### What stays
- `useCallPresence`, `useCall`, `joinActiveCall` and the realtime `call-presence:<conversationId>` channel.
- `ConversationHeader` phone icon as the call-initiation entry point.
- Incoming-call UI (`incomingHere` branch in `ConversationPage`) and ringtone.
- The mount-point `{conversation && !incomingHere && <CallPreviewPanel conversation={conversation} />}` in `ConversationPage.tsx` — only the import name and the JSX tag change.
## Edge Cases
| Case | Behavior |
|------|----------|
| Local user is already connected | `iAmIn === true` → panel hidden. |
| Local user is connecting/reconnecting to this conv | `iAmIn === true` → panel hidden (avoids flashing during transition). |
| Realtime presence drops momentarily | 3-second poll fallback in `useCallPresence` reseeds `presentIds`; panel may briefly disappear and reappear. Acceptable. |
| Conversation has 0 accepted members | `presentIds` is empty → panel hidden. |
| Member in `presentIds` is not in `conversation.members[]` (e.g., recently removed peer still finishing leave) | Render tile with placeholder name "?" and the `Avatar` initials fallback. Don't crash. |
| User clicks "Beitreten" while `state.kind === 'connecting'` to a *different* conversation | Button is `disabled` because `busy = state.kind !== 'idle'`. User must wait or hang up first. |
| Incoming call to this conversation while the panel is visible | `incomingHere` branch in `ConversationPage` already takes precedence (`!incomingHere &&` gate at the JSX site) → panel hidden until the user accepts/dismisses the ring. |
## Testing
**Manual smoke (no new unit tests):**
1. Open a 1:1 chat with no active call → panel not rendered.
2. Open a group chat with no active call → panel not rendered. (Regression test for the bug we're fixing.)
3. Have peer start a call from another device → panel appears with peer's tile + "Beitreten".
4. Click "Beitreten" → joins the call; panel disappears (we're now `iAmIn`).
5. Hang up → panel reappears with peer still in.
6. Peer hangs up too → panel disappears.
7. Resize window narrow → grid collapses to 2 columns.
8. Group call with 9 participants → 7 avatar tiles + one `+2` overflow tile.
The component is view-only over hooks; behavior is fully covered by the existing `useCall` and `useCallPresence` test paths plus the manual smoke list above.
## Out of Scope (future)
- Live participant video preview when peer's camera is on.
- Speaking-indicator rings.
- Per-tile mic-muted / camera-off overlays.
- Sound/notification when a call starts in a conversation that's not currently focused (separate notification work).
- Mobile equivalent.
@@ -0,0 +1,248 @@
# Encryption UX Simplification — Design
**Date:** 2026-05-15
**Scope:** Desktop (Electron/Tauri) first. Mobile follows in a subsequent spec.
**Status:** Approved by user (sections 15).
## Problem
The current end-to-end-encryption flow is too complex for non-technical users:
1. **Re-login locks them out.** Each device has its own X25519 keypair, and conversation keys (`conversation_keys`) are wrapped per-device. When a user signs in on a fresh install (or after clearing app data), they get a new device row → no peer has wrapped the conv-key for that new device → they can neither read old chats nor send new messages until another online peer of theirs (or another member of the conversation) re-wraps the conv-key for the new device. This frequently strands users.
2. **Backup restore must be done twice.** Restoring from `BackupRestoreDialog` in Settings reseeds the local vault, but on a clean re-login the device-registration screen still requires another restore — most often because the secret-store backend probe (`setSecretStoreUser`) and the restore write race, or because users lose the cached `localStorage` device-id between sessions.
3. **Backup string + passphrase + recovery code is too much.** Users are asked to manage a long base64 blob, a passphrase, and a 24-char recovery code. Most never export a backup at all.
## Goals
- Re-login on a fresh device must be a single-step action ("enter PIN") that restores full read+write access immediately, with no dependency on peers being online.
- The user manages **one** secret (a 6-digit PIN) plus an optional one-time recovery code.
- No more raw "backup strings" to copy around.
- Existing chat history remains readable after the migration.
- Compromise of a single device still requires user action to fully revoke (rotate user key) — but per-device fine-grained revocation is intentionally dropped in favor of UX.
## Non-Goals
- Mobile rollout. Tracked in a follow-up spec; the schema and shared crypto primitives are designed to be reusable on React Native.
- Forward secrecy / Double Ratchet. Out of scope; we keep the Sender-Key model.
- Multi-account on a single OS user.
- A web-only (no Electron/Tauri) variant. We assume an OS-keychain-backed Stronghold/safeStorage is available; the localStorage fallback path remains as today's escape hatch.
## Architecture
### Identity model: per-user instead of per-device
Today: one X25519 keypair per `(user_id, device_id)`; `conversation_keys` rows are keyed by `recipient_device_id`.
New: **one X25519 keypair per `user_id`**, stored as a PIN-sealed blob in a new `user_keys` table. Devices remain only for telemetry / push / last-seen — they no longer carry cryptographic identity. Each device unlocks the same user key with the PIN and caches the cleartext private key in the OS keychain (Stronghold / safeStorage).
`conversation_keys` is rekeyed: `recipient_device_id``recipient_user_id`, `sender_device_id``sender_user_id`. Sender-key (symmetric XSalsa20-Poly1305 conv-key) and the wrapping primitive (`crypto_box`) stay unchanged.
### Trade-offs
- **Lost:** clean per-device revocation. If a single device is compromised, the user must rotate the whole user key (re-wrap conv-keys for every member of every conversation). For a friends-chat app with no enterprise revocation requirement, acceptable.
- **Lost:** "device fingerprint per session" trust signal.
- **Gained:** zero-friction re-login, zero peer dependency for new installs, single secret to remember, no manual backup string.
### Threat model
- Server (Supabase) is honest-but-curious. It must never see plaintext private keys or conv-keys.
- A 6-digit PIN is **not** brute-force resistant on its own. It is protected by:
- Argon2id KDF (`moderate` preset) raises per-attempt cost to ~hundreds of ms.
- **Server-side lockout counter** (`failed_attempts`, `locked_until` in `user_keys`) gates ciphertext delivery: after 10 failed attempts a 24h lock is applied. The client cannot brute-force locally because it must request the ciphertext from the server, which the server rate-limits.
- Recovery code (~120 bits) is the strong factor; PIN is the convenience factor.
- A network attacker with a stolen Supabase session token cannot decrypt anything without the PIN/recovery code.
- A local attacker with full disk access can read the cached cleartext key from Stronghold (same threat as today's per-device key model).
## Components
### `packages/shared`
| File | Change |
|------|--------|
| `crypto/userKey.ts` | NEW. `generateUserKeyPair()`, `sealUserKey({privateKey, pin, salt})`, `openUserKey({sealed, pin, salt})`. Argon2id (moderate preset) + XSalsa20-Poly1305. |
| `auth/userKey.ts` | NEW. DB wrappers: `fetchUserKeyBlob(client, userId)`, `uploadUserKeyBlob(...)`, `recordPinAttempt(...)`, `tryUnlockUserKey(...)` (calls SECURITY DEFINER RPC), `resetUserKeyWithRecovery(...)`. |
| `chat/convKeys.ts` | Refactor: `recipient_device_id``recipient_user_id`, `sender_device_id``sender_user_id`. `listDeviceKeys()` becomes `listMemberPublicKeys()` (joins `conversation_members``user_keys`). `OwnDeviceCtx``OwnUserCtx { userId; privateKey }`. |
| `auth/device.ts` | Strip cryptographic functions: remove `provisionNewDevice`, `restoreDeviceFromServerRecord`, `saveDevicePrivateKey`, `loadDevicePrivateKey`, `forgetDevicePrivateKey`. Keep telemetry helpers (`registerDevice`, `listOwnDevices`, `touchDeviceLastSeen`); they no longer take/store `public_key`. |
| `crypto/index.ts` | Re-export `userKey`. |
### `apps/desktop/src/lib`
| File | Change |
|------|--------|
| `userIdentity.ts` | NEW. `loadOrUnlockUserKey({pin})`, `setupNewUserIdentity({pin, withRecovery})`, `cachedUserKey()`, `clearUserKeyCache()`. Caches cleartext key in Stronghold under `chatapp.userpriv.<userId>`. |
| `secretStore.ts` | Unchanged interface. Stores `chatapp.userpriv.<userId>` instead of `chatapp.priv.<userId>.<deviceId>`. |
| `device.ts` | Strip `findExistingDevice`, `registerCurrentDevice`. Keep platform detection only. |
| `deviceBackup.ts` | DELETE. |
### `apps/desktop/src/components`
| File | Change |
|------|--------|
| `UserKeySetup.tsx` | NEW. PIN entry + confirm + "Recovery-Code anzeigen" + "Habe ich gespeichert" / "Überspringen (riskant)" buttons. |
| `UserKeyUnlock.tsx` | NEW. Numeric PIN pad. Surfaces remaining attempts + lockout countdown. Switches to "Recovery-Code eingeben" tab. |
| `DeviceRegistration.tsx` | Becomes onboarding wrapper that routes to `UserKeySetup` or `UserKeyUnlock` based on `fetchUserKeyBlob` result. Drop the device-name prompt (auto from hostname; editable in Settings). |
| `DeviceRestore.tsx` | DELETE. |
| `BackupExportDialog.tsx` | DELETE. |
| `BackupRestoreDialog.tsx` | DELETE. |
| `BackupPromptBanner.tsx` | DELETE. |
| `SettingsPage.tsx` | Replace backup section with: "PIN ändern", "Recovery-Code neu erzeugen", "Identität zurücksetzen". |
### Supabase schema
```sql
CREATE TABLE user_keys (
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
public_key BYTEA NOT NULL, -- 32 bytes X25519
sealed_private_key BYTEA NOT NULL, -- nonce(24) || ciphertext
salt BYTEA NOT NULL, -- 16 bytes
kdf_params JSONB NOT NULL, -- { algo: 'argon2id', preset: 'moderate', opslimit, memlimit }
recovery_sealed_private_key BYTEA NULL, -- present only if user kept recovery code
recovery_salt BYTEA NULL,
failed_attempts INT NOT NULL DEFAULT 0,
locked_until TIMESTAMPTZ NULL,
failed_recovery_attempts INT NOT NULL DEFAULT 0,
recovery_locked_until TIMESTAMPTZ NULL,
key_version INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- RLS: a user reads/writes only their own row.
ALTER TABLE user_keys ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_keys_self_rw ON user_keys
USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid());
-- Anyone authenticated may read peer public_key only (separate policy on a view or column-level).
CREATE VIEW user_public_keys AS
SELECT user_id, public_key, key_version FROM user_keys;
GRANT SELECT ON user_public_keys TO authenticated;
```
`conversation_keys`:
- ADD `recipient_user_id UUID`, `sender_user_id UUID` (nullable during migration).
- After migration cutoff: drop `recipient_device_id`, `sender_device_id`.
- New unique constraint: `(conversation_id, recipient_user_id, key_version)`.
RPCs (SECURITY DEFINER):
- `try_unlock_user_key(p_user_id UUID)` → returns `{ sealed, salt, kdf_params, locked: bool, locked_until }`. Honors lockout. Caller must already be `auth.uid() = p_user_id`. Does NOT decrement attempts (decryption is offline; client reports outcome via `record_pin_attempt`).
- `record_pin_attempt(p_user_id UUID, p_success BOOL, p_recovery BOOL)` → updates counters & lockout. Server-authoritative, atomic.
- `share_conv_keys(...)` → existing RPC, signature changes from `p_recipient_device_id` to `p_recipient_user_id` per bundle entry.
- `migrate_user_key_recipients(p_conv_id, p_user_id, p_old_device_id, p_new_bundles)` → bulk INSERT new `recipient_user_id` rows from re-wrapped bundles, idempotent via `ON CONFLICT (conversation_id, recipient_user_id, key_version) DO NOTHING`.
## Data Flow
### First-time setup
1. User signs in (magic link / password). `fetchUserKeyBlob(userId)``null`.
2. Routes to `UserKeySetup`. User chooses 6-digit PIN.
3. Client generates X25519 keypair + 16-byte salt; derives KEK via Argon2id; seals the private key.
4. Client generates a 24-char recovery code (32-symbol alphabet, ~120 bits) and seals the same private key with a recovery-derived KEK + separate salt.
5. UI shows recovery code once. User picks "Habe ich gespeichert" or "Überspringen". Skip leaves `recovery_sealed_private_key NULL`.
6. UPSERT `user_keys` row.
7. Cleartext private key cached in Stronghold (`chatapp.userpriv.<userId>`).
### Re-login on a fresh / wiped device
1. Sign-in OK. `fetchUserKeyBlob(userId)` returns row → routes to `UserKeyUnlock`.
2. User enters PIN. RPC `try_unlock_user_key` returns ciphertext+salt (or `locked: true`).
3. Client derives KEK + opens sealed key. On success: `record_pin_attempt(success=true)` resets counter; cleartext key cached in Stronghold; navigate to `/chats`.
4. On failure: `record_pin_attempt(success=false)` increments counter. UI shows remaining attempts.
5. On lockout: UI offers "Recovery-Code verwenden" tab. Same flow against `recovery_sealed_private_key` + `failed_recovery_attempts`.
### Sending a message (existing conversation)
1. `getOrCreateConvKey(convId, ownUserCtx)` looks up `conversation_keys` by `recipient_user_id = me`.
2. Bundle exists → unwrap with user private key → encrypt plaintext → send.
3. **Re-login works immediately**: the user's `public_key` is unchanged across sessions, so all existing wrapped conv-keys remain valid.
### New user joins a conversation
1. Existing member loads `public_key` from `user_public_keys`.
2. Calls `shareConvKeyToUser(convId, newUserId, newUserPubKey, ownCtx)` → wraps active conv-key, INSERT bundle.
### Migration of legacy `conversation_keys`
Triggered automatically the first time a user signs in after the upgrade and has both an old device-key in Stronghold and no `user_keys` row yet:
1. Setup flow runs (PIN, generate user keypair, upload `user_keys`).
2. Migration pass:
- List all `conversation_keys` rows where `recipient_device_id` belongs to one of the user's old devices.
- Unwrap with the old device private key.
- Re-wrap for `recipient_user_id = me` with the new user public key.
- Bulk-insert via `migrate_user_key_recipients` (`ON CONFLICT DO NOTHING`).
3. Old rows remain untouched; other members migrate independently in their own passes.
4. Cross-member rewrap: when any member opens a conversation, the client lists members lacking a `recipient_user_id` bundle and proactively wraps for them in the background.
5. After all active users have migrated (telemetry-tracked), a follow-up migration drops `recipient_device_id` / `sender_device_id`.
### PIN change (Settings)
1. Prompt for current PIN → unlock locally (without server round-trip; we already have ciphertext+salt cached or can fetch).
2. Generate new salt; reseal with new PIN-derived KEK.
3. UPSERT `user_keys` row (same `public_key`, no conv-key rewrap).
### Identity reset
Hard "burn it down" path. Generates new user keypair, replaces `user_keys`, deletes the user's `conversation_keys` bundles. Friends will see fingerprint-change in the future trust UI (not in MVP).
## Error Handling
| Case | Behavior |
|------|----------|
| Wrong PIN | Offline crypto fails → `record_pin_attempt(success=false)` → counter increments. Cooldown schedule: attempts 14 none, 59 backoff (5s, 30s, 2m, 10m, 1h), 10 → `locked_until = now() + 24h`. Counter resets on success. UI shows remaining attempts and any active cooldown. |
| Lockout active | `try_unlock_user_key` returns `{locked: true, locked_until}` without ciphertext. UI surfaces "Recovery-Code verwenden" tab. |
| Recovery-code attempts | Independent counter `failed_recovery_attempts`; same backoff schedule, threshold 20 → permanent lock requiring identity reset. |
| Forgot PIN, no recovery code | UI shows hard warning, then runs Identity-Reset flow (lose all old chats; fingerprint changes for friends). |
| Stronghold cache lost (reinstall, wipe) | Identical to "Re-login on fresh device". |
| `user_keys` row missing server-side but Stronghold has key | UI offers "Identität wieder hochladen": re-uploads using cached key + a fresh PIN entry. Telemetry-logged. |
| Concurrent setup race | `INSERT … ON CONFLICT (user_id) DO NOTHING` + re-fetch. Loser unlocks the winner's blob with the same PIN. Mismatched PINs → loser sees "use the PIN chosen on the other device". |
| Concurrent migration race | `migrate_user_key_recipients` inserts are idempotent; both clients converge to identical state. |
| Conv-key bundle missing despite membership | As today: `getOrCreateConvKey` throws "Awaiting conversation key". Background sweep on conversation-open triggers proactive rewrap from any online member. The new model makes this strictly rarer (one bundle per user, not per device). |
| Stronghold init fails | Existing fallback to localStorage retained. User-key lands in localStorage cleartext (same risk as today's dev-fallback path). |
## Testing
**Unit (`packages/shared`):**
- `crypto/userKey.test.ts`: roundtrip seal/open with correct PIN; wrong PIN throws; wrong salt throws; KDF preset is reproducible.
- `auth/userKey.test.ts`: fetch returns null vs. row; UPSERT behavior; `record_pin_attempt` increments; lockout transitions.
- `chat/convKeys.test.ts`: tests refactored to `recipient_user_id`. New: bootstrap with two members (each unwraps OK); share to new user works; awaiting-key path throws.
**Integration (`apps/desktop`):**
- `userIdentity.migration.test.ts`: fixture with legacy `recipient_device_id` rows. Run migration pass. Assert: new `recipient_user_id` rows exist with same plaintext conv-key; legacy rows untouched.
- Race test: two parallel migration runs converge without duplicate inserts.
**Component (`apps/desktop`):**
- `UserKeySetup.test.tsx`: PIN-mismatch error; recovery-code rendered exactly once; "Skip" path leaves `recovery_sealed_private_key NULL`.
- `UserKeyUnlock.test.tsx`: wrong PIN increments attempts; lockout countdown rendered; recovery tab functional.
**E2E (Playwright via `e2e-testing` skill, optional in first cut):**
- Happy path: login → setup PIN → send chat → sign-out → sign-in → PIN → old chats readable, new chats sendable.
- Cross-context: User1 device A ↔ User2 conversation. User1 signs out, signs in fresh context, enters PIN, can read+write **without** User2 being online.
**Manual smoke list (pre-release):**
1. Fresh install, setup with recovery save.
2. Fresh install, setup with recovery skip.
3. Existing user with legacy conv-keys → silent migration → old chats readable.
4. Existing user on second device: setup on A, sign-in on B with same PIN.
5. Forgot PIN → recovery-code → unlock OK.
6. PIN change in Settings.
7. Identity reset → old chats unreadable for me, new chats functional.
8. Lockout: 10 wrong PINs → lock + recovery tab.
## Out of Scope (future work)
- Mobile (React Native) port — separate spec.
- Trust-on-first-use fingerprint banner when peer rotates `key_version`.
- Linked-device pairing flow (Signal-style) as a third recovery vector.
- Forward-secret message keys (Double Ratchet).
- Hardware-backed PIN entry (Secure Enclave / TPM bound).
## Migration Cutoff
After ≥95% of MAU have a `user_keys` row (server telemetry), schedule a follow-up migration to drop `recipient_device_id` and `sender_device_id` columns.
+1
View File
@@ -23,6 +23,7 @@
"mobile:dev": "pnpm --filter @chat-app/mobile dev",
"mobile:ios": "pnpm --filter @chat-app/mobile ios",
"mobile:android": "pnpm --filter @chat-app/mobile android",
"mobile:typecheck": "pnpm --filter @chat-app/mobile typecheck",
"desktop": "pnpm --filter @chat-app/desktop",
"desktop:dev": "pnpm --filter @chat-app/desktop dev",
"desktop:build": "pnpm --filter @chat-app/desktop build",
+3 -3
View File
@@ -118,7 +118,7 @@ export type Database = {
last_seen_at: string
name: string
platform: Database["public"]["Enums"]["device_platform"]
public_key: string
public_key: string | null
user_id: string
}
Insert: {
@@ -127,7 +127,7 @@ export type Database = {
last_seen_at?: string
name: string
platform: Database["public"]["Enums"]["device_platform"]
public_key: string
public_key?: string | null
user_id: string
}
Update: {
@@ -136,7 +136,7 @@ export type Database = {
last_seen_at?: string
name?: string
platform?: Database["public"]["Enums"]["device_platform"]
public_key?: string
public_key?: string | null
user_id?: string
}
Relationships: []
+3
View File
@@ -10,6 +10,7 @@
".": "./src/index.ts",
"./supabase": "./src/supabase/index.ts",
"./crypto": "./src/crypto/index.ts",
"./crypto/testBackend": "./src/crypto/testBackend.ts",
"./auth": "./src/auth/index.ts",
"./chat": "./src/chat/index.ts",
"./admin": "./src/admin/index.ts",
@@ -40,7 +41,9 @@
"react-i18next": { "optional": false }
},
"devDependencies": {
"@types/libsodium-wrappers": "^0.7.14",
"@types/react": "^18.3.12",
"libsodium-wrappers-sumo": "0.7.15",
"react": "^18.3.1",
"react-i18next": "^15.1.1"
}
+3 -3
View File
@@ -1,8 +1,8 @@
// Admin-only helpers. Every call is RLS-gated server-side via the
// Admin-only helpers. Every call is RLS-gated server-side via the
// `current_user_is_admin()` helper + admin-specific policies. Non-admins
// trying to call these still get clean PostgREST 403/empty-result responses.
import type { AppSupabaseClient } from '../supabase/client.js';
import type { AppSupabaseClient } from '../supabase/client';
// --- Admin settings -------------------------------------------------------
@@ -189,7 +189,7 @@ export async function setUserFlag(
}
// ---------------------------------------------------------------------------
// Conversations (admin view reads all regardless of membership)
// Conversations (admin view — reads all regardless of membership)
// ---------------------------------------------------------------------------
export interface AdminConversationRow {
@@ -0,0 +1,30 @@
import { vi } from 'vitest';
import type { AppSupabaseClient } from '../../supabase/client';
export interface MockedRpcCall { name: string; params: unknown }
export interface MockClient {
client: AppSupabaseClient;
rpcCalls: MockedRpcCall[];
setRpcResponse: (name: string, response: { data?: unknown; error?: unknown }) => void;
}
export function makeMockClient(initialUserId = '11111111-1111-1111-1111-111111111111'): MockClient {
const rpcCalls: MockedRpcCall[] = [];
const rpcResponses = new Map<string, { data?: unknown; error?: unknown }>();
const client = {
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user: { id: initialUserId } }, error: null }),
},
rpc: vi.fn().mockImplementation((name: string, params: unknown) => {
rpcCalls.push({ name, params });
const r = rpcResponses.get(name) ?? { data: null, error: null };
return Promise.resolve(r);
}),
} as unknown as AppSupabaseClient;
return {
client,
rpcCalls,
setRpcResponse: (name, response) => rpcResponses.set(name, response),
};
}
+10 -146
View File
@@ -1,20 +1,20 @@
import { fromBase64, generateX25519KeyPair, toBase64, wipe } from '../crypto/index.js';
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea.js';
import type { AppSupabaseClient } from '../supabase/client.js';
import type { DevicePlatform } from '../supabase/types.js';
import type { SecretStore } from './secure-storage.js';
import { fromBase64, toBase64 } from '../crypto/index';
import type { AppSupabaseClient } from '../supabase/client';
import type { DevicePlatform } from '../supabase/types';
// After the per-user encryption refactor, devices rows are pure telemetry:
// they record which physical/browser installs are signed into the account so
// the user can audit them, but they no longer carry cryptographic identity.
export interface RegisterDeviceParams {
name: string; // user-facing, e.g. "Dennis Laptop"
platform: DevicePlatform;
publicKey: Uint8Array; // X25519 public key, 32 bytes
}
export interface DeviceRecord {
id: string;
name: string;
platform: DevicePlatform;
publicKey: Uint8Array;
lastSeenAt: string;
}
@@ -31,9 +31,8 @@ export async function registerDevice(
user_id: session.user.id,
name: params.name,
platform: params.platform,
public_key: bytesToPgHex(params.publicKey),
})
.select('id, name, platform, public_key, last_seen_at')
.select('id, name, platform, last_seen_at')
.single();
if (error) throw error;
@@ -41,7 +40,6 @@ export async function registerDevice(
id: data.id,
name: data.name,
platform: data.platform,
publicKey: pgHexToBytes(data.public_key),
lastSeenAt: data.last_seen_at,
};
}
@@ -52,7 +50,7 @@ export async function listOwnDevices(client: AppSupabaseClient): Promise<DeviceR
const { data, error } = await client
.from('devices')
.select('id, name, platform, public_key, last_seen_at')
.select('id, name, platform, last_seen_at')
.eq('user_id', session.user.id)
.order('last_seen_at', { ascending: false });
if (error) throw error;
@@ -61,7 +59,6 @@ export async function listOwnDevices(client: AppSupabaseClient): Promise<DeviceR
id: row.id,
name: row.name,
platform: row.platform,
publicKey: pgHexToBytes(row.public_key),
lastSeenAt: row.last_seen_at,
}));
}
@@ -77,139 +74,6 @@ export async function touchDeviceLastSeen(
if (error) throw error;
}
// ---------------------------------------------------------------------------
// End-to-end device provisioning flow.
// ---------------------------------------------------------------------------
export interface ProvisionDeviceParams {
client: AppSupabaseClient;
secretStore: SecretStore;
userId: string;
name: string;
platform: DevicePlatform;
}
export interface ProvisionResult {
device: DeviceRecord;
created: boolean;
}
function privateKeySecretName(userId: string, deviceId: string): string {
return `chatapp.priv.${userId}.${deviceId}`;
}
// Creates a brand-new device: generates an X25519 keypair, registers the public
// half with Supabase, stores the private half in the platform secret store.
export async function provisionNewDevice({
client,
secretStore,
userId,
name,
platform,
}: ProvisionDeviceParams): Promise<DeviceRecord> {
const kp = await generateX25519KeyPair();
const device = await registerDevice(client, {
name,
platform,
publicKey: kp.publicKey,
});
try {
await secretStore.setSecret(privateKeySecretName(userId, device.id), kp.privateKey);
} finally {
wipe(kp.privateKey);
}
return device;
}
// Loads the private key for `deviceId` from the secret store. Returns null if
// this install has never stored one.
export async function loadDevicePrivateKey(
secretStore: SecretStore,
userId: string,
deviceId: string,
): Promise<Uint8Array | null> {
return secretStore.getSecret(privateKeySecretName(userId, deviceId));
}
export async function forgetDevicePrivateKey(
secretStore: SecretStore,
userId: string,
deviceId: string,
): Promise<void> {
await secretStore.removeSecret(privateKeySecretName(userId, deviceId));
}
// Writes a device private key into the secret store. Used by the
// backup-restore flow to re-import a key generated on another machine.
export async function saveDevicePrivateKey(
secretStore: SecretStore,
userId: string,
deviceId: string,
privateKey: Uint8Array,
): Promise<void> {
await secretStore.setSecret(privateKeySecretName(userId, deviceId), privateKey);
}
// Restores a device record from a backup by re-seeding the local private key
// store for an EXISTING server-side device row. Does NOT insert a new row —
// the original row is kept intact so conversation-key bundles stay valid.
// Throws when the server-side device was removed (the backup is then unusable;
// user must provision a fresh device and get conv-keys shared from another
// live device).
export async function restoreDeviceFromServerRecord(params: {
client: AppSupabaseClient;
secretStore: SecretStore;
userId: string;
deviceId: string;
privateKey: Uint8Array;
}): Promise<DeviceRecord> {
const { data: session } = await params.client.auth.getUser();
if (!session.user) throw new Error('not authenticated');
if (session.user.id !== params.userId) {
throw new Error(
'Backup is for a different account — sign in as the owner before restoring.',
);
}
const { data: row, error } = await params.client
.from('devices')
.select('id, name, platform, public_key, last_seen_at')
.eq('id', params.deviceId)
.eq('user_id', params.userId)
.maybeSingle();
if (error) throw error;
if (!row) {
throw new Error(
'Device record not found on server — it was removed. Backup is no longer valid; register a new device instead.',
);
}
await saveDevicePrivateKey(
params.secretStore,
params.userId,
params.deviceId,
params.privateKey,
);
return {
id: row.id,
name: row.name,
platform: row.platform,
publicKey: pgHexToBytes(row.public_key),
lastSeenAt: row.last_seen_at,
};
}
// Lightweight helpers for platforms that want to cache their current device id
// in JSON storage (separate from the secret store, which only holds raw bytes).
export const DEVICE_ID_STORAGE_KEY_PREFIX = 'chatapp.device_id';
export function deviceIdStorageKey(userId: string): string {
return `${DEVICE_ID_STORAGE_KEY_PREFIX}.${userId}`;
}
// Intentional re-exports so app layers only need @chat-app/shared/auth.
export type { SecretStore } from './secure-storage.js';
export type { SecretStore } from './secure-storage';
export { toBase64 as base64FromBytes, fromBase64 as bytesFromBase64 };
+4 -3
View File
@@ -1,3 +1,4 @@
export * from './device.js';
export * from './magic-link.js';
export * from './profile.js';
export * from './device';
export * from './magic-link';
export * from './profile';
export * from './userKey';
+5 -5
View File
@@ -1,5 +1,5 @@
import type { SupportedLocale } from '../i18n/types.js';
import type { AppSupabaseClient } from '../supabase/client.js';
import type { SupportedLocale } from '../i18n/types';
import type { AppSupabaseClient } from '../supabase/client';
export interface SignupParams {
email: string;
@@ -58,7 +58,7 @@ export async function loginWithMagicLink(
}
// Parse a callback URL produced by the magic-link email and establish a session.
// Works for both PKCE (`?code=`) and legacy token-hash (`#access_token=`).
// Works for both PKCE (`?code=…`) and legacy token-hash (`#access_token=…`).
export async function completeSessionFromUrl(
client: AppSupabaseClient,
url: string,
@@ -91,14 +91,14 @@ export async function completeSessionFromUrl(
export async function signOut(client: AppSupabaseClient): Promise<void> {
// `scope: 'local'` only ends the session in THIS client. Without it Supabase
// defaults to 'global', which invalidates the user's refresh tokens
// everywhere meaning a logout in the browser would also kick the desktop
// everywhere — meaning a logout in the browser would also kick the desktop
// app (and vice versa) the next time it tries to refresh its token.
const { error } = await client.auth.signOut({ scope: 'local' });
if (error) throw error;
}
// Verify a 6-digit OTP that arrived via magic-link email. Establishes a
// session in the CURRENT webview no browser redirect involved. Used by the
// session in the CURRENT webview — no browser redirect involved. Used by the
// desktop/mobile apps where cross-origin redirects don't carry the session
// back to the native webview.
export async function verifyMagicLinkOtp(
+5 -5
View File
@@ -1,6 +1,6 @@
import type { SupportedLocale } from '../i18n/types.js';
import type { AppSupabaseClient } from '../supabase/client.js';
import type { Database, PresenceState } from '../supabase/types.js';
import type { SupportedLocale } from '../i18n/types';
import type { AppSupabaseClient } from '../supabase/client';
import type { Database, PresenceState } from '../supabase/types';
type ProfileUpdate = Database['public']['Tables']['profiles']['Update'];
@@ -75,7 +75,7 @@ export async function getProfileByUsername(
const { data, error } = await client
.from('profiles')
.select(PROFILE_COLS)
// citext column compares CI server-side send raw input, don't force case.
// citext column compares CI server-side — send raw input, don't force case.
.eq('username', username.trim())
.maybeSingle();
if (error) throw error;
@@ -89,7 +89,7 @@ export async function isUsernameAvailable(
const { count, error } = await client
.from('profiles')
.select('user_id', { count: 'exact', head: true })
// citext compares CI no manual normalization needed.
// citext compares CI — no manual normalization needed.
.eq('username', username.trim());
if (error) throw error;
return (count ?? 0) === 0;
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { makeMockClient } from './__tests__/mockClient';
import {
fetchUserKeyBlob,
uploadUserKeyBlob,
tryUnlockUserKey,
recordPinAttempt,
resetUserKey,
} from './userKey';
const USER_ID = '22222222-2222-2222-2222-222222222222';
describe('auth/userKey', () => {
let mock: ReturnType<typeof makeMockClient>;
beforeEach(() => { mock = makeMockClient(USER_ID); });
it('tryUnlockUserKey reports exists=false when row missing', async () => {
mock.setRpcResponse('try_unlock_user_key', { data: { exists: false }, error: null });
const res = await tryUnlockUserKey(mock.client, USER_ID);
expect(res.exists).toBe(false);
expect(mock.rpcCalls).toEqual([{ name: 'try_unlock_user_key', params: { p_user_id: USER_ID } }]);
});
it('tryUnlockUserKey returns ciphertext + salt when unlocked', async () => {
mock.setRpcResponse('try_unlock_user_key', {
data: {
exists: true, locked: false,
sealed_private_key: 'AAA=', salt: 'BBB=',
kdf_params: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 268435456 },
recovery_sealed_private_key: null, recovery_salt: null,
failed_attempts: 0, failed_recovery_attempts: 0,
recovery_locked_until: null, key_version: 1,
},
error: null,
});
const res = await tryUnlockUserKey(mock.client, USER_ID);
expect(res.exists).toBe(true); if (!res.exists) throw new Error();
expect(res.locked).toBe(false); if (res.locked) throw new Error();
expect(res.sealedPrivateKey).toBeInstanceOf(Uint8Array);
expect(res.salt).toBeInstanceOf(Uint8Array);
expect(res.kdfParams.preset).toBe('moderate');
});
it('tryUnlockUserKey returns lockout state without ciphertext', async () => {
const lockedUntil = '2026-05-16T00:00:00Z';
mock.setRpcResponse('try_unlock_user_key', {
data: { exists: true, locked: true, locked_until: lockedUntil },
error: null,
});
const res = await tryUnlockUserKey(mock.client, USER_ID);
expect(res.exists).toBe(true); if (!res.exists) throw new Error();
expect(res.locked).toBe(true); if (!res.locked) throw new Error();
expect(res.lockedUntil).toBe(lockedUntil);
});
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]),
sealedPrivateKey: new Uint8Array([4, 5]),
salt: new Uint8Array([6]),
kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 268435456 },
});
const params = mock.rpcCalls.at(-1)?.params as Record<string, unknown>;
// 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=');
expect(params.p_salt_b64).toBe('Bg==');
expect(params.p_recovery_sealed_b64).toBeNull();
});
it('recordPinAttempt forwards success/recovery flags', async () => {
mock.setRpcResponse('record_pin_attempt', { data: { failed_attempts: 0 }, error: null });
await recordPinAttempt(mock.client, USER_ID, false, false);
expect(mock.rpcCalls.at(-1)?.params).toEqual({
p_user_id: USER_ID, p_success: false, p_recovery: false,
});
});
it('fetchUserKeyBlob returns null when exists=false', async () => {
mock.setRpcResponse('try_unlock_user_key', { data: { exists: false }, error: null });
const res = await fetchUserKeyBlob(mock.client, USER_ID);
expect(res).toBeNull();
});
it('resetUserKey forwards recovery params', async () => {
mock.setRpcResponse('reset_user_key', { data: 5, error: null });
const deleted = await resetUserKey(mock.client, {
userId: USER_ID,
publicKey: new Uint8Array([1]),
sealedPrivateKey: new Uint8Array([2]),
salt: new Uint8Array([3]),
kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 1 },
recoverySealedPrivateKey: new Uint8Array([4]),
recoverySalt: new Uint8Array([5]),
});
expect(deleted).toBe(5);
const params = mock.rpcCalls.at(-1)?.params as Record<string, unknown>;
expect(params.p_recovery_sealed_b64).toBe('BA==');
expect(params.p_recovery_salt_b64).toBe('BQ==');
});
});
+180
View File
@@ -0,0 +1,180 @@
import type { AppSupabaseClient } from '../supabase/client';
import type { KdfParams } from '../crypto/userKey';
export type { KdfParams };
export interface UserKeyBlob {
exists: true;
sealedPrivateKey: Uint8Array;
salt: Uint8Array;
kdfParams: KdfParams;
recoverySealedPrivateKey: Uint8Array | null;
recoverySalt: Uint8Array | null;
failedAttempts: number;
failedRecoveryAttempts: number;
recoveryLockedUntil: string | null;
keyVersion: number;
}
export type UnlockResult =
| { exists: false }
| ({ exists: true; locked: false } & UserKeyBlob)
| { exists: true; locked: true; lockedUntil: string };
function b64ToBytes(s: string): Uint8Array {
const bin = atob(s);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function bytesToB64(b: Uint8Array): string {
let s = '';
for (const v of b) s += String.fromCharCode(v);
return btoa(s);
}
interface RpcCapable {
rpc: (name: string, params: unknown) => Promise<{ data: unknown; error: unknown }>;
}
function rpc(client: AppSupabaseClient): RpcCapable {
return client as unknown as RpcCapable;
}
export async function tryUnlockUserKey(
client: AppSupabaseClient,
userId: string,
): Promise<UnlockResult> {
const { data, error } = await rpc(client).rpc('try_unlock_user_key', { p_user_id: userId });
if (error) throw error;
const d = data as Record<string, unknown>;
if (!d?.exists) return { exists: false };
if (d.locked) {
return { exists: true, locked: true, lockedUntil: String(d.locked_until ?? '') };
}
return {
exists: true,
locked: false,
sealedPrivateKey: b64ToBytes(String(d.sealed_private_key)),
salt: b64ToBytes(String(d.salt)),
kdfParams: d.kdf_params as KdfParams,
recoverySealedPrivateKey: d.recovery_sealed_private_key
? b64ToBytes(String(d.recovery_sealed_private_key)) : null,
recoverySalt: d.recovery_salt ? b64ToBytes(String(d.recovery_salt)) : null,
failedAttempts: Number(d.failed_attempts ?? 0),
failedRecoveryAttempts: Number(d.failed_recovery_attempts ?? 0),
recoveryLockedUntil: (d.recovery_locked_until as string | null) ?? null,
keyVersion: Number(d.key_version ?? 1),
};
}
export async function fetchUserKeyBlob(
client: AppSupabaseClient,
userId: string,
): Promise<UnlockResult | null> {
const res = await tryUnlockUserKey(client, userId);
if (!res.exists) return null;
return res;
}
export interface UploadParams {
userId: string;
publicKey: Uint8Array;
sealedPrivateKey: Uint8Array;
salt: Uint8Array;
kdfParams: KdfParams;
recoverySealedPrivateKey?: Uint8Array | null;
recoverySalt?: Uint8Array | null;
}
export async function uploadUserKeyBlob(
client: AppSupabaseClient,
params: UploadParams,
): Promise<void> {
// 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),
p_salt_b64: bytesToB64(params.salt),
p_kdf_params: params.kdfParams,
p_recovery_sealed_b64: params.recoverySealedPrivateKey
? bytesToB64(params.recoverySealedPrivateKey) : null,
p_recovery_salt_b64: params.recoverySalt ? bytesToB64(params.recoverySalt) : null,
});
if (error) throw error;
}
export async function resetUserKey(
client: AppSupabaseClient,
params: UploadParams,
): Promise<number> {
const { data, error } = await rpc(client).rpc('reset_user_key', {
p_user_id: params.userId,
p_public_key_b64: bytesToB64(params.publicKey),
p_sealed_private_b64: bytesToB64(params.sealedPrivateKey),
p_salt_b64: bytesToB64(params.salt),
p_kdf_params: params.kdfParams,
p_recovery_sealed_b64: params.recoverySealedPrivateKey
? bytesToB64(params.recoverySealedPrivateKey) : null,
p_recovery_salt_b64: params.recoverySalt ? bytesToB64(params.recoverySalt) : null,
});
if (error) throw error;
return Number(data ?? 0);
}
export interface AttemptResult { failedAttempts: number; lockedUntil: string | null }
export async function recordPinAttempt(
client: AppSupabaseClient,
userId: string,
success: boolean,
recovery: boolean,
): Promise<AttemptResult> {
const { data, error } = await rpc(client).rpc('record_pin_attempt', {
p_user_id: userId, p_success: success, p_recovery: recovery,
});
if (error) throw error;
const d = (data ?? {}) as Record<string, unknown>;
return {
failedAttempts: Number(d.failed_attempts ?? 0),
lockedUntil: (d.locked_until as string | null) ?? null,
};
}
export interface PeerPublicKey {
userId: string;
publicKey: Uint8Array;
keyVersion: number;
}
export async function fetchPeerPublicKeys(
client: AppSupabaseClient,
userIds: string[],
): Promise<PeerPublicKey[]> {
if (userIds.length === 0) return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error } = await (client as any)
.from('user_public_keys')
.select('user_id, public_key, key_version')
.in('user_id', userIds);
if (error) throw error;
return (data ?? []).map((row: { user_id: string; public_key: string; key_version: number }) => ({
userId: row.user_id,
publicKey: pgHexToBytes(row.public_key),
keyVersion: row.key_version,
}));
}
function pgHexToBytes(hex: string): Uint8Array {
const s = hex.startsWith('\\x') ? hex.slice(2) : hex;
const out = new Uint8Array(s.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
return out;
}
+6 -6
View File
@@ -1,4 +1,4 @@
// Encrypted attachment upload / download.
// Encrypted attachment upload / download.
//
// Per-message symmetric key + nonce encrypts the raw blob (XSalsa20-Poly1305
// via secretbox). The encrypted blob is uploaded to Supabase Storage under
@@ -6,8 +6,8 @@
// travel inside the per-device message envelope as JSON, so the server never
// sees the decryption material.
import { fromBase64, getCryptoBackend, toBase64 } from '../crypto/index.js';
import type { AppSupabaseClient } from '../supabase/client.js';
import { fromBase64, getCryptoBackend, toBase64 } from '../crypto/index';
import type { AppSupabaseClient } from '../supabase/client';
export const ATTACHMENT_BUCKET = 'chat-attachments';
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; // 10 MB
@@ -22,7 +22,7 @@ export interface AttachmentHandle {
sizeBytes: number;
width?: number;
height?: number;
// base64-encoded only readable via per-device envelope decrypt.
// base64-encoded — only readable via per-device envelope decrypt.
keyB64: string;
nonceB64: string;
}
@@ -166,11 +166,11 @@ function ensureUuid(): string {
export interface EncryptedAttachmentResult {
handle: AttachmentHandle;
key: Uint8Array; // raw bytes caller is responsible for wiping
key: Uint8Array; // raw bytes — caller is responsible for wiping
nonce: Uint8Array;
}
// Encrypt + upload a blob. Does NOT insert the message_attachments row
// Encrypt + upload a blob. Does NOT insert the message_attachments row —
// the caller combines this with a message insert so everything commits
// atomically at the application layer.
export async function encryptAndUploadAttachment(params: {
+146 -146
View File
@@ -4,32 +4,18 @@ import {
generateConvKey,
unwrapConvKey,
wrapConvKeyForRecipient,
} from '../crypto/sessionKeys.js';
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea.js';
import type { AppSupabaseClient } from '../supabase/client.js';
} from '../crypto/sessionKeys';
import { fetchPeerPublicKeys } from '../auth/userKey';
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea';
import type { AppSupabaseClient } from '../supabase/client';
// db-types in this monorepo is a static snapshot generated against the older
// schema. The new `conversation_keys` table + `active_key_version` column on
// `conversations` aren't in there yet. Until the codegen catches up we bypass
// the typed builder for those calls.
function rawFrom(client: AppSupabaseClient, table: string) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (client as unknown as { from: (t: string) => any }).from(table);
}
// Per-conversation symmetric key management. Replaces per-device envelopes
// with a single conv-key (32-byte XSalsa20-Poly1305) wrapped to each device's
// X25519 pubkey via crypto_box.
interface DeviceKey {
deviceId: string;
export interface OwnUserCtx {
userId: string;
publicKey: Uint8Array;
}
export interface OwnDeviceCtx {
userId: string;
deviceId: string;
privateKey: Uint8Array;
}
@@ -39,37 +25,22 @@ export interface ConvKeyHandle {
key: Uint8Array;
}
// In-process cache to avoid re-fetching + re-unwrapping every send/decrypt.
const cache = new Map<string, ConvKeyHandle>();
const cacheKey = (convId: string, version: number) => convId + '@' + version;
const cacheKey = (convId: string, v: number) => convId + '@' + v;
export function clearConvKeyCache(): void {
cache.clear();
}
export function clearConvKeyCache(): void { cache.clear(); }
async function listDeviceKeys(
async function listMemberPublicKeys(
client: AppSupabaseClient,
conversationId: string,
): Promise<DeviceKey[]> {
const { data: members, error: mErr } = await client
): Promise<{ userId: string; publicKey: Uint8Array }[]> {
const { data: members, error } = 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) return [];
const { data: devices, error: dErr } = await client
.from('devices')
.select('id, user_id, public_key')
.in('user_id', memberIds);
if (dErr) throw dErr;
return (devices ?? []).map((d) => ({
deviceId: d.id,
userId: d.user_id,
publicKey: pgHexToBytes(d.public_key),
}));
if (error) throw error;
const ids = (members ?? []).filter((m) => m.accepted).map((m) => m.user_id);
return fetchPeerPublicKeys(client, ids);
}
async function fetchActiveKeyVersion(
@@ -77,212 +48,241 @@ async function fetchActiveKeyVersion(
conversationId: string,
): Promise<number> {
const { data, error } = await rawFrom(client, 'conversations')
.select('active_key_version')
.eq('id', conversationId)
.single();
.select('active_key_version').eq('id', conversationId).single();
if (error) throw error;
return (data as { active_key_version: number }).active_key_version;
}
interface SenderInfo {
senderDeviceId: string;
senderPublicKey: Uint8Array;
}
interface SenderInfo { senderUserId: string; senderPublicKey: Uint8Array }
async function fetchKeyBundle(
client: AppSupabaseClient,
conversationId: string,
ownDeviceId: string,
ownUserId: string,
keyVersion: number,
): Promise<{ encryptedKey: Uint8Array; nonce: Uint8Array; sender: SenderInfo } | null> {
const { data, error } = await rawFrom(client, 'conversation_keys')
.select('encrypted_key, nonce, sender_device_id')
.select('encrypted_key, nonce, sender_user_id')
.eq('conversation_id', conversationId)
.eq('recipient_device_id', ownDeviceId)
.eq('recipient_user_id', ownUserId)
.eq('key_version', keyVersion)
.maybeSingle();
if (error) throw error;
if (!data) return null;
const row = data as {
encrypted_key: string;
nonce: string;
sender_device_id: string;
};
const { data: dev, error: dErr } = await client
.from('devices')
.select('id, public_key')
.eq('id', row.sender_device_id)
.single();
if (dErr) throw dErr;
const row = data as { encrypted_key: string; nonce: string; sender_user_id: string };
const peers = await fetchPeerPublicKeys(client, [row.sender_user_id]);
const sender = peers[0];
if (!sender) throw new Error('sender public key missing');
return {
encryptedKey: pgHexToBytes(row.encrypted_key),
nonce: pgHexToBytes(row.nonce),
sender: {
senderDeviceId: dev.id,
senderPublicKey: pgHexToBytes(dev.public_key),
},
sender: { senderUserId: sender.userId, senderPublicKey: sender.publicKey },
};
}
// Strips the leading `\x` postgres bytea hex prefix so the RPC's
// `decode(text, 'hex')` accepts it.
function hexNoPrefix(bytes: Uint8Array): string {
return bytesToPgHex(bytes).slice(2);
}
function hexNoPrefix(bytes: Uint8Array): string { return bytesToPgHex(bytes).slice(2); }
// Bootstraps a brand-new conv-key, wrapping it for every member device that
// currently exists (including the caller's own devices). Used the first time
// a conversation needs a key, or when rotation is requested. All inserts go
// through `share_conv_keys` (SECURITY DEFINER) — silently skips invalid
// recipients, no per-row 403 console spam.
export async function bootstrapConvKey(
client: AppSupabaseClient,
conversationId: string,
own: OwnDeviceCtx,
own: OwnUserCtx,
keyVersion: number,
): Promise<ConvKeyHandle> {
const convKey = generateConvKey();
const recipients = await listDeviceKeys(client, conversationId);
if (recipients.length === 0) {
throw new Error('cannot bootstrap conv key — no recipient devices');
}
const bundles: Array<{ recipient_device_id: string; encrypted_key: string; nonce: string }> = [];
const recipients = await listMemberPublicKeys(client, conversationId);
if (recipients.length === 0) throw new Error('cannot bootstrap conv key — no recipients');
const bundles: Array<{ recipient_user_id: string; encrypted_key: string; nonce: string }> = [];
for (const r of recipients) {
const wrapped = await wrapConvKeyForRecipient(convKey, r.publicKey, own.privateKey);
bundles.push({
recipient_device_id: r.deviceId,
recipient_user_id: r.userId,
encrypted_key: hexNoPrefix(wrapped.ciphertext),
nonce: hexNoPrefix(wrapped.nonce),
});
}
// 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, 'share_conv_keys', {
p_conv_id: conversationId,
p_sender_device_id: own.deviceId,
p_sender_device_id: null,
p_sender_user_id: own.userId,
p_key_version: keyVersion,
p_bundles: bundles,
});
if (error) throw error;
const handle = { conversationId, keyVersion, key: convKey };
cache.set(cacheKey(conversationId, keyVersion), handle);
return handle;
}
// Resolves the current conv-key for `conversationId`. Order:
// 1) cache hit
// 2) DB row for own device → unwrap
// 3) bootstrap a brand-new key (only valid path if NO existing keys exist
// for any device — i.e. this is the conversation's very first message)
export async function getOrCreateConvKey(
client: AppSupabaseClient,
conversationId: string,
own: OwnDeviceCtx,
own: OwnUserCtx,
): Promise<ConvKeyHandle> {
const version = await fetchActiveKeyVersion(client, conversationId);
const cached = cache.get(cacheKey(conversationId, version));
if (cached) return cached;
const bundle = await fetchKeyBundle(client, conversationId, own.deviceId, version);
const bundle = await fetchKeyBundle(client, conversationId, own.userId, version);
if (bundle) {
const key = await unwrapConvKey(
bundle.encryptedKey,
bundle.nonce,
bundle.sender.senderPublicKey,
own.privateKey,
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, own.privateKey,
);
const handle = { conversationId, keyVersion: version, key };
cache.set(cacheKey(conversationId, version), handle);
return handle;
}
// No bundle yet for THIS device. Two cases:
// - I'm the first ever sender → bootstrap.
// - Conversation already has keys but my device wasn't included yet → I
// have to wait until an existing device wraps the key for me.
const { count, error: cntErr } = await rawFrom(client, 'conversation_keys')
.select('recipient_device_id', { count: 'exact', head: true })
.select('recipient_user_id', { count: 'exact', head: true })
.eq('conversation_id', conversationId)
.eq('key_version', version);
if (cntErr) throw cntErr;
if ((count ?? 0) > 0) {
throw new Error(
'Awaiting conversation key — another device must share it with this device.',
);
// 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);
}
// Read-only variant: never bootstraps. Returns null if no key bundle exists
// for this device yet.
// 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,
ownDeviceId: string,
ownUserId: string,
ownPrivateKey: Uint8Array,
keyVersion: number,
): Promise<ConvKeyHandle | null> {
const cached = cache.get(cacheKey(conversationId, keyVersion));
if (cached) return cached;
const bundle = await fetchKeyBundle(client, conversationId, ownDeviceId, keyVersion);
const bundle = await fetchKeyBundle(client, conversationId, ownUserId, keyVersion);
if (!bundle) return null;
const key = await unwrapConvKey(
bundle.encryptedKey,
bundle.nonce,
bundle.sender.senderPublicKey,
ownPrivateKey,
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, ownPrivateKey,
);
const handle = { conversationId, keyVersion, key };
cache.set(cacheKey(conversationId, keyVersion), handle);
return handle;
}
// Wraps the active conv-key for a single new device (e.g. when a peer
// registers a new device). The caller's device must have an unwrapped copy
// of the conv-key in cache (or be able to fetch it).
export async function shareConvKeyToDevice(
export async function shareConvKeyToUser(
client: AppSupabaseClient,
conversationId: string,
recipientDeviceId: string,
recipientUserId: string,
recipientPublicKey: Uint8Array,
own: OwnDeviceCtx,
own: OwnUserCtx,
): Promise<void> {
const version = await fetchActiveKeyVersion(client, conversationId);
const handle =
cache.get(cacheKey(conversationId, version)) ??
(await tryGetConvKey(client, conversationId, own.deviceId, own.privateKey, version));
if (!handle) {
throw new Error('cannot share conv key — own device does not have it yet');
}
const wrapped = await wrapConvKeyForRecipient(
handle.key,
recipientPublicKey,
own.privateKey,
);
(await tryGetConvKey(client, conversationId, own.userId, own.privateKey, version));
if (!handle) throw new Error('cannot share conv key — own user does not have it yet');
const wrapped = await wrapConvKeyForRecipient(handle.key, recipientPublicKey, own.privateKey);
// 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, 'share_conv_keys', {
p_conv_id: conversationId,
p_sender_device_id: own.deviceId,
p_sender_device_id: null,
p_sender_user_id: own.userId,
p_key_version: version,
p_bundles: [
{
recipient_device_id: recipientDeviceId,
encrypted_key: hexNoPrefix(wrapped.ciphertext),
nonce: hexNoPrefix(wrapped.nonce),
},
],
p_bundles: [{
recipient_user_id: recipientUserId,
encrypted_key: hexNoPrefix(wrapped.ciphertext),
nonce: hexNoPrefix(wrapped.nonce),
}],
});
if (error) throw error;
}
// Re-exports for convenience.
export { decryptWithConvKey, encryptWithConvKey };
+4 -4
View File
@@ -1,6 +1,6 @@
import type { ProfileBrief } from '../friends/index.js';
import type { AppSupabaseClient } from '../supabase/client.js';
import type { ConversationSummary } from './types.js';
import type { ProfileBrief } from '../friends/index';
import type { AppSupabaseClient } from '../supabase/client';
import type { ConversationSummary } from './types';
const PROFILE_BRIEF_COLS = 'user_id, username, display_name, avatar_url, banner_url';
@@ -63,7 +63,7 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
.in('conversation_id', convIds);
if (aErr) throw aErr;
// 4. Profiles for ALL distinct member user ids (including self the
// 4. Profiles for ALL distinct member user ids (including self — the
// member list in GroupInfoPanel needs our own display name too).
const memberIdsAll = Array.from(new Set((allMembers ?? []).map((m) => m.user_id)));
const profileMap = new Map<string, ProfileBrief>();
+4 -4
View File
@@ -1,5 +1,5 @@
import type { AppSupabaseClient } from '../supabase/client.js';
import type { MemberRole } from '../supabase/types.js';
import type { AppSupabaseClient } from '../supabase/client';
import type { MemberRole } from '../supabase/types';
async function currentUserId(client: AppSupabaseClient): Promise<string> {
const { data, error } = await client.auth.getUser();
@@ -14,7 +14,7 @@ export interface CreateGroupParams {
memberUserIds: string[];
}
// Client-side 3-step create: conversation self as admin peers as members.
// Client-side 3-step create: conversation → self as admin → peers as members.
// On any peer-insert failure the conversation still survives (partial group is
// still usable, admin can retry). On self-insert failure we delete the empty
// conversation to avoid orphans.
@@ -24,7 +24,7 @@ export async function createGroup(params: CreateGroupParams): Promise<string> {
if (trimmed.length === 0) throw new Error('group name required');
// Generate the uuid client-side so we don't need a post-insert SELECT on
// conversations the SELECT policy requires membership, which only exists
// conversations — the SELECT policy requires membership, which only exists
// AFTER we insert the creator's member row in step 2.
const conversationId = crypto.randomUUID();
+8 -7
View File
@@ -1,11 +1,12 @@
import type { AppSupabaseClient } from '../supabase/client.js';
import type { AppSupabaseClient } from '../supabase/client';
export * from './attachments.js';
export * from './conversations.js';
export * from './convKeys.js';
export * from './groups.js';
export * from './messages.js';
export * from './types.js';
export * from './attachments';
export * from './conversations';
export * from './convKeys';
export * from './groups';
export * from './messages';
export * from './types';
export * from './userKeyMigration';
// ----- RPC wrappers ---------------------------------------------------------
+31 -22
View File
@@ -1,14 +1,14 @@
import { bytesToUtf8, utf8ToBytes } from '../crypto/index.js';
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea.js';
import type { AppSupabaseClient } from '../supabase/client.js';
import { bytesToUtf8, utf8ToBytes } from '../crypto/index';
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea';
import type { AppSupabaseClient } from '../supabase/client';
import {
decryptWithConvKey,
encryptWithConvKey,
getOrCreateConvKey,
type OwnDeviceCtx,
type OwnUserCtx,
tryGetConvKey,
} from './convKeys.js';
import type { ChatMessage, DecryptedMessage } from './types.js';
} from './convKeys';
import type { ChatMessage, DecryptedMessage } from './types';
const MESSAGE_COLS =
'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at, ciphertext, nonce, key_version';
@@ -77,11 +77,13 @@ export async function listConversationDeviceKeys(
.in('user_id', memberIds);
if (dErr) throw dErr;
return (devices ?? []).map((d) => ({
deviceId: d.id,
userId: d.user_id,
publicKey: pgHexToBytes(d.public_key),
}));
return (devices ?? [])
.filter((d): d is typeof d & { public_key: string } => d.public_key !== null)
.map((d) => ({
deviceId: d.id,
userId: d.user_id,
publicKey: pgHexToBytes(d.public_key),
}));
}
export interface SendMessageParams {
@@ -89,10 +91,14 @@ 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
// Optional encrypted attachments — their handles are already materialised
// via `encryptAndUploadAttachment`. The caller is responsible for creating
// the corresponding message_attachments rows (see insertAttachmentRow) once
// the returned message id is known.
@@ -104,9 +110,8 @@ export interface SendMessageParams {
// send and shared with every existing recipient device. New devices that
// register later receive their key bundle through `shareConvKeyToDevice`.
export async function sendEncryptedMessage(params: SendMessageParams): Promise<ChatMessage> {
const ownCtx: OwnDeviceCtx = {
const ownCtx: OwnUserCtx = {
userId: params.senderUserId,
deviceId: params.senderDeviceId,
privateKey: params.senderPrivateKey,
};
const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx);
@@ -123,7 +128,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,
@@ -173,11 +183,10 @@ 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: OwnDeviceCtx = {
const ownCtx: OwnUserCtx = {
userId: params.senderUserId,
deviceId: params.senderDeviceId,
privateKey: params.senderPrivateKey,
};
const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx);
@@ -246,7 +255,7 @@ export async function listPeerReadsForMessages(
}
// Group variant: returns reads from ALL users (other than `excludeUserId`,
// typically caller themselves) keyed by message id user id timestamp.
// typically caller themselves) keyed by message id → user id → timestamp.
// RLS already filters out users whose receipts are off.
export async function listGroupReadsForMessages(
client: AppSupabaseClient,
@@ -442,7 +451,7 @@ export async function removeReaction(
export interface DecryptParams {
client: AppSupabaseClient;
messages: MessageWithCipher[];
ownDeviceId: string;
ownUserId: string;
ownPrivateKey: Uint8Array;
/**
* Optional delegate that performs the symmetric-decrypt + utf-8 decode
@@ -485,7 +494,7 @@ export async function decryptMessages(opts: DecryptParams): Promise<DecryptedMes
const handle = await tryGetConvKey(
opts.client,
m.conversationId,
opts.ownDeviceId,
opts.ownUserId,
opts.ownPrivateKey,
m.keyVersion,
);
+3 -3
View File
@@ -1,5 +1,5 @@
import type { ProfileBrief } from '../friends/index.js';
import type { ConversationType, MemberRole } from '../supabase/types.js';
import type { ProfileBrief } from '../friends/index';
import type { ConversationType, MemberRole } from '../supabase/types';
export interface ConversationMember {
userId: string;
@@ -26,7 +26,7 @@ export interface ConversationSummary {
// Caller's per-member preferences.
archived: boolean;
// ISO timestamp. null = not muted. Past timestamp = expired mute (treat as
// not muted the server row is kept for history until the next toggle).
// not muted — the server row is kept for history until the next toggle).
mutedUntil: string | null;
}
@@ -0,0 +1,91 @@
import { describe, expect, it, beforeAll } from 'vitest';
import { setCryptoBackend, getCryptoBackend } from '../crypto/backend';
import { makeWasmTestBackend } from '../crypto/testBackend';
import { encryptFor } from '../crypto/box';
import { migrateOwnLegacyBundles } from './userKeyMigration';
beforeAll(async () => { setCryptoBackend(await makeWasmTestBackend()); });
describe('migrateOwnLegacyBundles', () => {
it('re-wraps legacy device bundles to user recipients and skips non-own rows', async () => {
const backend = getCryptoBackend();
const senderKp = backend.generateKeyPair();
const oldDeviceKp = backend.generateKeyPair();
const newUserKp = backend.generateKeyPair();
const otherDeviceKp = backend.generateKeyPair();
const convKey = backend.randomBytes(backend.secretboxKeyLength);
const wrappedForOldDevice = await encryptFor(convKey, oldDeviceKp.publicKey, senderKp.privateKey);
const wrappedForOther = await encryptFor(convKey, otherDeviceKp.publicKey, senderKp.privateKey);
const calls: { name: string; params: unknown }[] = [];
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: () => ({
is: () => ({
not: () => Promise.resolve({
data: [
{
conversation_id: 'conv-1',
key_version: 1,
recipient_device_id: 'dev-old',
sender_device_id: 'dev-sender',
sender_user_id: 'sender-user',
encrypted_key: '\\x' + Buffer.from(wrappedForOldDevice.ciphertext).toString('hex'),
nonce: '\\x' + Buffer.from(wrappedForOldDevice.nonce).toString('hex'),
},
{
conversation_id: 'conv-2',
key_version: 1,
recipient_device_id: 'dev-other',
sender_device_id: 'dev-sender',
sender_user_id: 'sender-user',
encrypted_key: '\\x' + Buffer.from(wrappedForOther.ciphertext).toString('hex'),
nonce: '\\x' + Buffer.from(wrappedForOther.nonce).toString('hex'),
},
],
error: null,
}),
}),
}),
};
}
if (table === 'devices') {
return {
select: () => ({
in: () => Promise.resolve({
data: [{ id: 'dev-sender', user_id: 'sender-user', public_key: '\\x' + Buffer.from(senderKp.publicKey).toString('hex') }],
error: null,
}),
}),
};
}
throw new Error('unexpected table ' + table);
},
rpc: (name: string, params: unknown) => {
calls.push({ name, params });
return Promise.resolve({ data: 1, error: null });
},
} as unknown as Parameters<typeof migrateOwnLegacyBundles>[0]['client'];
const result = await migrateOwnLegacyBundles({
client: stubClient,
ownUserId: 'me-user',
ownNewPublicKey: newUserKp.publicKey,
ownNewPrivateKey: newUserKp.privateKey,
ownLegacyDeviceIds: ['dev-old'],
ownLegacyDevicePrivateKeys: { 'dev-old': oldDeviceKp.privateKey },
});
expect(result.migratedConversations).toBe(1);
expect(calls).toHaveLength(1);
expect(calls[0]!.name).toBe('migrate_user_key_recipients');
const params = calls[0]!.params as { p_conv_id: string; p_user_id: string };
expect(params.p_conv_id).toBe('conv-1');
expect(params.p_user_id).toBe('me-user');
});
});

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