Compare commits

...

211 Commits

Author SHA1 Message Date
byGalax 997252c4cb chore(desktop): release v0.19.1 2026-05-16 21:07:11 +02:00
byGalax 890d5dc2b7 feat(P4C.T4): SoundboardSettings — sync hook mount + per-row badges + remote-delete
Mount useSoundboardSync in SoundboardManagerDialog, thread badges map through
SoundboardCategoryGroup/SoundboardRow, render a cloud-state glyph badge inline
with each row's size/mime metadata, and wrap handleDelete to attempt a
best-effort remote delete via deleteRemoteSound before the local deleteSound call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:02:26 +02:00
byGalax 759113b9ce feat(P4C.T3): useSoundboardSync — initial diff + realtime + debounced push 2026-05-16 20:58:48 +02:00
byGalax f981904e7f feat(P4C.T2): shared soundboards wrappers + sealed-blob crypto + tests 2026-05-16 20:53:48 +02:00
byGalax 1fe196b839 feat(P4C.T1): user_soundboards table + soundboards storage bucket + RLS 2026-05-16 20:49:10 +02:00
byGalax 1b4cf63e07 docs(P4C): Phase 4C implementation plan (Soundboard Cloud-Sync, E2E encrypted) 2026-05-16 20:46:57 +02:00
byGalax 18197a9f95 feat(P4B.T7): MessageBubble renders whiteboard kind with Öffnen button
Adds a whiteboard branch to the MessageBubble ternary chain that renders
a card with an icon, label, and Öffnen button dispatching the
chatapp:open-whiteboard CustomEvent. ConversationPage now listens for
that event via a useEffect and calls setOpenWhiteboardId to open the
modal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 20:38:03 +02:00
byGalax 383245ec90 feat(P4B.T6): composer 'Whiteboard' button creates board + sends bubble 2026-05-16 20:36:03 +02:00
byGalax 5e59ee22f6 feat(P4B.T5): WhiteboardModal — fullscreen container + toolbar + clear-confirm 2026-05-16 20:29:24 +02:00
byGalax be6f5b9c6f feat(P4B.T4): WhiteboardCanvas — pen/eraser drawing engine 2026-05-16 20:27:09 +02:00
byGalax 2243c646ac feat(P4B.T3): useWhiteboardStrokes hook with INSERT + bulk DELETE realtime
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 20:25:22 +02:00
byGalax 3df7cc01ea feat(P4B.T2): WhiteboardPayload + shared whiteboards CRUD wrappers + tests
Adds WhiteboardPayload wire-format variant to MessagePayload/ParsedMessagePayload,
creates the whiteboards.ts CRUD wrapper (createWhiteboard, listWhiteboardStrokes,
insertWhiteboardStroke, clearWhiteboardStrokes), writes 5 unit tests (38/38 pass),
re-exports from chat/index.ts, and registers conversation_whiteboards +
whiteboard_strokes in packages/db-types so typecheck passes cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 20:23:45 +02:00
byGalax f95858c703 feat(P4B.T1): whiteboards + whiteboard_strokes tables with RLS + realtime 2026-05-16 20:01:28 +02:00
byGalax d29e2c1174 docs(P4B): Phase 4B implementation plan (Whiteboard / Snapshot-Sync) 2026-05-16 19:59:10 +02:00
byGalax 11a9c8b173 feat(P4A.T4): wire ImageAnnotator into composer attachment preview
Hover an image attachment in the composer to reveal a pencil-edit button;
clicking it opens ImageAnnotator and Save replaces the File in attachments[].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 19:48:57 +02:00
byGalax d623a59c87 feat(P4A.T3): annotator toolbar — tool/color/width/undo/redo controls 2026-05-16 19:46:00 +02:00
byGalax 2a2e334f51 feat(P4A.T2): annotator drawing engine — pen/arrow/rect/circle/text/highlighter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 19:44:04 +02:00
byGalax 9228cc635c feat(P4A.T1): ImageAnnotator modal skeleton with canvas mount + op-stack types 2026-05-16 19:41:36 +02:00
byGalax 7056bfdd1c docs(P4A): Phase 4A implementation plan (Image Annotation vor Send) 2026-05-16 19:38:26 +02:00
byGalax e8a5d6dc5e chore(desktop): release v0.19.0 2026-05-16 19:25:55 +02:00
byGalax c5851195c7 fix(call): strip-toggle icon no longer touches its frame edges
Shrink the StripToggleIcon SVG from 18x18 to 14x14 px so the 2-people
icon sits centred with ~11 px of breathing room inside its 36x36 button,
matching the h-4/w-4 small-icon convention used elsewhere in InCallPanel.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes shipped:

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

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

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

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

PinInput: focused + active-slot now show a brand-coloured ring, glow, and
a blinking caret so users see where the next keystroke lands.
2026-05-16 00:15:15 +02:00
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
byGalax b65a3994f3 chore(desktop): release v0.17.4 2026-05-13 22:25:11 +02:00
byGalax aa609389fa feat(chat): render Media/Files + Group Info drawers inline, not overlaid
Both right-hand panels used absolute inset-y-0 right-0 and floated on
top of the conversation, hiding the messages directly underneath the
panel and looking unlike Discord's actual layout. Restructure:

* MediaFilesDrawer: drop absolute/z-index/shadow chrome, become a
  static flex column (w-[380px] shrink-0) with a left border. Internal
  layout unchanged.
* GroupInfoPanel: same treatment (w-[320px] shrink-0). Dropped the
  backdrop-blur and slide-up animation that only made sense as a modal.
* ConversationPage: wrap the chat content (voice rail, in-call panel,
  messages list, drag-overlay, input form) in a new
  `flex min-w-0 flex-1 flex-col` chat-column, and make that column a
  sibling of the drawers inside a new `flex flex-1 flex-row` row. The
  conversation header + search bar stay full-width above the row.

Result: opening a drawer narrows the chat column instead of covering
it, matching Discord's behaviour. The chat-column wrapper also carries
the `relative` anchor previously held by the outer wrapper so the
drag-and-drop overlay positions correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:21:05 +02:00
byGalax cb3cbd8827 feat(chat): show caller's name on call event pills in groups
In group conversations the "Outgoing/Incoming/Missed call" system pill
gave no clue WHO triggered the event — fine in a 1:1 where the only two
players are obvious, useless in a group with three+ members. Discord
puts the caller's name in the pill; mirror that.

CallEventRow now takes a senderDisplayName prop (plumbed through from
MessageBubble) and switches non-own labels to the name-aware variants:

* ended  + !mine + name → "{name} hat einen Anruf gestartet"
* missed + !mine + name → "Verpasster Anruf von {name}"
* declined + !mine + name → "Anruf von {name} abgelehnt"

Own events (mine) stay generic ("Outgoing call" / "No answer") since
the user already knows they were the initiator. Fallback path without
a name keeps the previous generic labels so nothing regresses if the
sender is unresolvable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:20:21 +02:00
byGalax d1f38ce313 chore(desktop): release v0.17.3 2026-05-12 22:59:44 +02:00
byGalax 0d65a134fd feat(settings): click own avatar in profile preview to view fullscreen
Lightbox was previously a file-private component inside AttachmentImage
(used for enlarging chat image attachments). Extracted to a standalone
components/Lightbox.tsx so other surfaces can reuse the same dialog
without duplicating Esc/backdrop/body-overflow plumbing.

In SettingsPage's profile live-preview, the round avatar overlapping the
banner is now wrapped in a transparent button that opens the Lightbox
with the full-resolution avatar URL on click. Cursor switches to
zoom-in. Disabled when the user only has the initial-letter placeholder
(nothing meaningful to enlarge). Native button chrome (border, padding,
button-face background) is reset to keep the avatar circle's appearance
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:58:22 +02:00
byGalax 12c66d676a fix(chat): use useLayoutEffect for scroll restore + auto-bottom to avoid mount flicker
The scroll-position memory introduced in 0.17.2 still produced a visible
"chat appears at the top then jumps" frame when switching back into a
conversation. Cause: both scroll-affecting effects (auto-bottom on new
messages, restore on chat re-entry) used useEffect, which fires AFTER
the browser paints the freshly-committed DOM. So users saw scrollTop=0
for one frame before the effect ran and corrected it.

Switching both to useLayoutEffect moves the scroll write into the same
commit phase as the message-list DOM update, so the very first paint
already shows the correct position — single paint, no flicker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:58:07 +02:00
byGalax 0dde1dd1a3 chore(desktop): release v0.17.2 2026-05-12 22:24:32 +02:00
byGalax 81d3587a91 feat(chat): per-conversation scroll memory + version badge on changelog page
Two small UX polishes:

1. Switching between chats no longer slams you to the bottom. Each
   conversation's scroll position (pixel offset + stickToBottom flag)
   is remembered in a module-scoped Map for the lifetime of the
   renderer process. Discord-style: leave Chat A scrolled up, peek at
   another conversation, come back — same spot you were reading.
   Chats left at the bottom keep auto-following new messages on return.
   Reload resets everything (session-only, no localStorage).

   The restore runs once messages.length > 0 to avoid the browser
   clamping scrollTop to a near-zero scrollHeight before the message
   list has rendered. A small isRestoringRef guard prevents the
   programmatic scroll event from immediately overwriting the saved
   position with a clamped value.

2. Changelog page now shows a version badge in the header that compares
   the installed app version against entries[0].version from the
   server-side changelog feed. Three states:
   * `vX.Y.Z · aktuell` (emerald) — installed matches latest
   * `vX.Y.Z · Update verfügbar` + `neueste: vA.B.C` (amber) — outdated
   * `vX.Y.Z` neutral — installed is ahead of the published feed
     (dev/test builds)
   Semver compare is integer-major.minor.patch with a graceful
   garbage-fallback so a malformed version string doesn't false-flag
   a current install as outdated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:22:49 +02:00
byGalax 8be1105333 chore(desktop): release v0.17.1 2026-05-12 22:00:27 +02:00
byGalax f9e1d2f073 fix(secure-store): preserve original ciphertext on decrypt failure + startup path log
Critical hotfix for the 0.17.0 regression: users upgrading from 0.16.x
were logged out, and their next login wrote a fresh empty secure-store
on top of the original ciphertext — destroying device keys irrecoverably.

Why it happened: loadState used a blanket `catch {}` that conflated
"file doesn't exist (genuine new user)" with "file exists but can't be
decrypted (DPAPI / OSCrypt quirk after the install rename)". Both paths
returned an empty Map; the next scheduledSave then overwrote the
original .bin file with a fresh blob.

Fix:
* Separate ENOENT from decrypt/parse failures. ENOENT → empty Map. Any
  other read error → log, empty Map (no quarantine, matches old
  behaviour for transient lock issues).
* When decrypt/parse fails the original file is renamed to
  <file>.broken-<iso-ts> BEFORE returning empty Map. The next save
  writes to a fresh file; the original ciphertext is preserved on disk
  so a future build (or manual recovery) can still get at the bytes.
* Loud console.error around the failure so future regressions surface
  in main-process logs.

main.ts: move setPath('userData', appData/ChatApp) BEFORE setName so
any productName-derived path caching inside setName can't beat us to
it. Add a startup log of the resolved paths so future debugging has
hard evidence instead of guessing.

Affected users on 0.17.0 should still recover via Settings → Backup
Wiederherstellen (account-level keys are unchanged); this fix prevents
the data destruction for anyone who hasn't upgraded yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:54:14 +02:00
byGalax 341f5f227d chore(desktop): release v0.17.0 2026-05-12 21:35:29 +02:00
byGalax 8baac2fd1e fix(call): tile sizing polish + cinema-mode chrome suppression + spec/plan docs
Equal-grid cells no longer set aspect-video — on wide chat panels this
forced cell height = width × 9/16 (~400px on a 700px panel) which pushed
the row past the section's max-h and ate the controls bar below. n>=2
cells now fill grid tracks normally via auto-rows-fr; the solo case
(n=1) keeps a 16:9 silhouette via aspect-video + max-w + justify-self-
center so a single-user-alone-calling view doesn't stretch into a
full-width slab. Same change applied to the fullscreen-grid path plus
+16px bottom-padding (pb-28) so audio-only avatars' name chip clears
the floating controls bar.

Docked stage strip thumbs (focus + bento) switch from aspect-video
shrink-0 to flex-1 min-w-[200px] max-w-[460px] so 2-3 thumbs share the
row width evenly under the share above, instead of clinging to the left
edge with dead space to the right. Fullscreen-cinema strip keeps the
small aspect-video thumbs the user explicitly approved.

ScreenShareViewer gains a hideFullscreenToggle prop; cinema mode passes
it via a new `cinema` prop on TileRender so the in-share fullscreen icon
doesn't visually collide with FullscreenCall's strip-hidden toggle at
the same top-right corner.

docs/superpowers/specs + plans for the Discord-style tile handling
workstream are committed alongside the implementation that completed it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:31:26 +02:00
byGalax 05870ef8fa feat(call): split resolution/fps in share picker + restore window state after fullscreen
ScreenSharePickerModal now exposes Auflösung (Auto · 720p · 1080p · 1440p
· 4K) and FPS (30 · 60) as separate pill rows instead of bundled quality
presets — users can pick "1440p · 30 fps" or "4K · 30 fps" which the old
preset list didn't surface. The underlying screenShareSettings framerateOverride
slot already existed; the modal just stopped resetting it to null on every
start and now plumbs the chosen FPS through to startScreenShare.

Cinema-mode fullscreen on Windows had two defects:

1. Maximized → fullscreen left the taskbar drawn on top of the window
   because DWM kept the maximized work-area constraints. We now unmaximize
   first so DWM recomposes cleanly and setFullScreen actually covers the
   whole monitor including the taskbar strip.

2. Esc out of cinema came back as a small floating window even when the
   user had been maximized before clicking the Vollbild button — the
   unmaximize from (1) was never undone. We now memo the pre-fullscreen
   maximized flag per window-id and call win.maximize() once the
   leave-full-screen event has fired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:31:10 +02:00
byGalax f9e340dbec feat(branding): Netralax rebrand + Discord-style taskbar unread badge
App productName becomes Netralax (driving exe name and window title);
existing installs keep their %APPDATA%\ChatApp profile via an explicit
app.setPath('userData', appData/ChatApp) so no Login/Sounds/Secret store
data is lost.

The Windows taskbar overlay now renders a red bubble with the actual
unread count (Discord parity) instead of just a static red dot. Renderer
paints a 64×64 PNG via canvas — full-bleed red circle, white bold count
with a "99+" cap, no outer ring — and passes the data URL through the
existing setTrayUnread IPC. Main decodes via nativeImage and applies it
as the BrowserWindow overlay icon. Falls back to the static dot if the
renderer canvas pipeline is unavailable.

Also: app.setName('Netralax') + setAppUserModelId('cloud.netralax.desktop')
for Windows taskbar grouping and notification source attribution, and
release.mjs now reads productName dynamically from package.json so the
artifact lookup stays correct after the rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:30:54 +02:00
byGalax 91eedc0e8e fix(call): aspect-video for fullscreen grid+strip, switch section sizing to stageLayout 2026-05-12 19:03:23 +02:00
byGalax e56533918e feat(call): multi-share bento layout in stage + fullscreen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:54:23 +02:00
byGalax c87d4e82d3 feat(call): doubleclick on focused tile clears pin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:50:29 +02:00
byGalax 187f8dc95a feat(call): left-click toggles pin, drop manual focus mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:47:10 +02:00
byGalax f612c1bb50 feat(call): introduce StageLayout discriminated union
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:43:55 +02:00
byGalax a9d5e2d430 feat(call): uniform 16:9 grid cells, drop grid-rows constraint 2026-05-12 18:40:54 +02:00
byGalax a065cc0a2c feat(call): drop hardcoded 16:9 on screen-share preview button 2026-05-12 18:39:06 +02:00
byGalax 005aebd60b feat(call): VideoStub accepts fit prop, contain when focused 2026-05-12 18:36:32 +02:00
226 changed files with 34885 additions and 2877 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
+13
View File
@@ -106,6 +106,19 @@ export const CHANNELS = {
// Used by the call cinema mode to flip the host BrowserWindow into real
// OS fullscreen so the Windows taskbar / macOS menubar gets covered.
WINDOW_SET_FULLSCREEN: 'window:set-fullscreen',
// Wipe-on-close — main process pushes this to the renderer right before
// exiting if the user has enabled the Settings → Sicherheit toggle. The
// renderer clears its sensitive caches (memoryWipe.ts) and acks via
// `app:wipe-before-quit:done`; main quits after the ack (or a 2s safety
// timeout, whichever comes first).
/** Main → renderer: about to quit. Renderer wipes, then resolves. */
APP_WIPE_BEFORE_QUIT: 'app:wipe-before-quit',
// OS hostname — returns the machine hostname via Node's `os.hostname()`.
// Used by AuthContext on first launch to populate the `devices` row with
// a human-readable default device name. Returns null on failure.
APP_HOSTNAME: 'app:hostname',
} as const;
// ---- Screen sources ------------------------------------------------------
+66 -2
View File
@@ -6,11 +6,13 @@
// the old Tauri devUrl so nothing in the renderer code needs to change)
// and from the built dist in packaged mode.
import { app, BrowserWindow, desktopCapturer, Menu, session } from 'electron';
import { app, BrowserWindow, desktopCapturer, ipcMain, Menu, session } from 'electron';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { CHANNELS } from './ipc-types';
import { register as registerAudioLoopback } from './modules/audio-loopback';
import { register as registerAppHostname } from './modules/app-hostname';
import { register as registerAutostart } from './modules/autostart';
import { register as registerFsScoped } from './modules/fs-scoped';
import { register as registerNotifications } from './modules/notifications';
@@ -37,6 +39,26 @@ const __dirnameSafe = path.dirname(__filenameSafe);
const DEV_URL = 'http://localhost:1420';
const WINDOW_STATE_FILE = 'window-state.json';
// Pin userData FIRST — before any other Electron call that might cache a
// productName-derived path. The 0.17.0 release saw users get logged out
// after upgrading from 0.16.x: the most likely culprit was an internal
// path resolution kicking off the moment `setName('Netralax')` ran, so
// 0.17.1 swaps the order so the explicit override wins regardless of
// what setName triggers internally. The literal 'ChatApp' here is the
// pre-rename product folder — installed users' SQLite, secrets, sounds,
// IndexedDB all live there and we never want to leave them stranded by
// a future rebrand.
app.setPath('userData', path.join(app.getPath('appData'), 'ChatApp'));
// App branding. productName in package.json drives the packaged exe name
// (Netralax.exe) and electron-builder installer title. setName + the
// AppUserModelId cover the live process: window title fallback, Windows
// taskbar grouping, notification source attribution.
app.setName('Netralax');
if (process.platform === 'win32') {
app.setAppUserModelId('cloud.netralax.desktop');
}
// Run dev side-by-side with the installed packaged build by isolating the
// renderer profile / secret-store / SQLite / IndexedDB / localStorage in
// a separate userData dir. Without this both share `%APPDATA%\ChatApp`,
@@ -47,6 +69,21 @@ if (!app.isPackaged) {
app.setPath('userData', app.getPath('userData') + '-Dev');
}
// Startup diagnostics — the 0.17.0 logout regression was hard to debug
// because we had no record of the actual resolved paths. With this log
// any future user can paste their main-process output and we can tell
// at a glance whether userData ended up where we intended.
console.log(
'[main] resolved paths',
JSON.stringify({
appName: app.getName(),
appData: app.getPath('appData'),
userData: app.getPath('userData'),
isPackaged: app.isPackaged,
platform: process.platform,
}),
);
let mainWindow: BrowserWindow | null = null;
function resolvePreloadPath(): string {
@@ -74,7 +111,7 @@ async function createWindow(): Promise<BrowserWindow> {
const state = await loadState(WINDOW_STATE_FILE);
const win = new BrowserWindow({
title: app.isPackaged ? 'ChatApp' : 'ChatApp (Dev)',
title: app.isPackaged ? 'Netralax' : 'Netralax (Dev)',
width: state.width,
height: state.height,
...(state.x !== undefined ? { x: state.x } : {}),
@@ -218,6 +255,7 @@ if (!gotLock) {
registerFsScoped();
registerSql();
registerAutostart();
registerAppHostname();
// Window-dependent registrars — only call once mainWindow exists so
// event emitters have somewhere to send.
@@ -228,6 +266,32 @@ if (!gotLock) {
registerAudioLoopback(mainWindow);
});
// Wipe-on-close: when the user enables it in Settings, the renderer is given
// a chance to clear all sensitive caches before the app process exits. If
// the renderer doesn't ack within 2 seconds we force-quit anyway — better
// to lose the wipe than to hang the app shutdown.
//
// Note: there's a separate `before-quit` listener in modules/tray.ts that
// tears down the Tray instance. Electron fires both; the tray listener is
// synchronous and doesn't touch event.preventDefault, so it doesn't fight
// our deferred-quit dance here. The `wipeRequested` flag guards re-entry
// when our own `app.quit()` below fires `before-quit` a second time.
let wipeRequested = false;
app.on('before-quit', (event) => {
if (wipeRequested) return;
if (!mainWindow || mainWindow.isDestroyed()) return;
wipeRequested = true;
event.preventDefault();
mainWindow.webContents.send(CHANNELS.APP_WIPE_BEFORE_QUIT);
const done = new Promise<void>((resolve) => {
ipcMain.once(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done', () => resolve());
});
void Promise.race([done, new Promise<void>((r) => setTimeout(r, 2000))]).finally(() => {
app.quit();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
@@ -0,0 +1,21 @@
// app:hostname adapter — returns the OS hostname via Node's `os.hostname()`.
// The renderer has no Node access (contextIsolation is ON), so it must ask
// main for this value. Used by AuthContext on first launch to populate the
// `devices` row with a human-readable default device name.
import os from 'node:os';
import { ipcMain } from 'electron';
import { CHANNELS } from '../ipc-types';
export function register(): void {
ipcMain.handle(CHANNELS.APP_HOSTNAME, (): string | null => {
try {
const h = os.hostname();
return typeof h === 'string' && h.length > 0 ? h : null;
} catch {
return null;
}
});
}
+56 -8
View File
@@ -40,20 +40,68 @@ function filePathFor(userId: string, encrypted: boolean): string {
}
async function loadState(filePath: string, encrypted: boolean): Promise<Map<string, string>> {
// Read step. Distinguish "no file yet" (genuinely new user — empty Map
// is correct) from "file exists but unreadable" (corruption / DPAPI
// breakage — we MUST NOT let the next write overwrite those bytes,
// because the original ciphertext is the only path back to the user's
// device keys if a future build can fix the read path).
let rawBuf: Buffer | null = null;
let rawStr: string | null = null;
try {
if (encrypted) {
const buf = await fs.readFile(filePath);
const json = safeStorage.decryptString(buf);
rawBuf = await fs.readFile(filePath);
} else {
rawStr = await fs.readFile(filePath, 'utf8');
}
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException | null)?.code;
if (code === 'ENOENT') return new Map();
console.warn('[secure-store] read failed (non-ENOENT)', filePath, err);
// For non-ENOENT read failures (EACCES, EBUSY, …) don't quarantine —
// the file might be transiently locked. Empty map + future writes
// will attempt to overwrite, matching the pre-0.17.1 behaviour for
// these rarer cases.
return new Map();
}
// Parse / decrypt step.
try {
if (encrypted && rawBuf) {
const json = safeStorage.decryptString(rawBuf);
const parsed = JSON.parse(json) as { version?: number; entries?: Record<string, string> };
return new Map(Object.entries(parsed.entries ?? {}));
} else {
const raw = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as { version?: number; entries?: Record<string, string> };
}
if (rawStr) {
const parsed = JSON.parse(rawStr) as { version?: number; entries?: Record<string, string> };
return new Map(Object.entries(parsed.entries ?? {}));
}
} catch {
// Missing file or malformed contents — start fresh. The next write
// will overwrite with a fresh blob.
return new Map();
} catch (err: unknown) {
// CRITICAL: file existed but we couldn't decrypt or parse it. In the
// pre-0.17.1 build we silently started fresh — the next set() then
// scheduledSave() over-wrote the original ciphertext, destroying the
// user's device keys forever. Now we rename the original to
// `<file>.broken-<iso-ts>` BEFORE returning the empty map so the next
// write goes to a new file and the original bytes survive for
// forensics or a future decrypt-recovery path.
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const brokenPath = `${filePath}.broken-${ts}`;
try {
await fs.rename(filePath, brokenPath);
console.error(
`[secure-store] DECRYPT/PARSE FAILED for ${filePath} — preserved original at ${brokenPath}. Original error:`,
err,
);
} catch (renameErr: unknown) {
// Even rename failed — fall back to the old behaviour (silent empty
// map) but log loudly so it's visible in the main-process output.
console.error(
'[secure-store] rename of broken file failed; original may be overwritten on next save',
renameErr,
'original decrypt error:',
err,
);
}
return new Map();
}
}
+76 -40
View File
@@ -30,6 +30,17 @@ function resolveIconPathDev(): string {
return path.join(app.getAppPath(), 'resources', 'icon.ico');
}
// 16×16 grey square — last-ditch fallback when neither the packaged
// nor dev icon file resolves. Tray constructor on Windows throws when
// handed an empty NativeImage, which would tear down the whole
// registrar before `ipcMain.handle(TRAY_UNREAD)` runs — leaving the
// taskbar overlay badge wired but the renderer's invoke rejecting
// with "No handler registered". A non-empty placeholder keeps the
// constructor happy so the IPC handler always gets registered.
const FALLBACK_TRAY_PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAH0lEQVR42mNk' +
'YGD4z0ABYBxVOKpwVOGowlGFwwoBAEnYAR9XlIldAAAAAElFTkSuQmCC';
function loadTrayIcon(): NativeImage {
for (const p of [resolveIconPath(), resolveIconPathDev()]) {
try {
@@ -39,7 +50,7 @@ function loadTrayIcon(): NativeImage {
/* try next */
}
}
return nativeImage.createEmpty();
return nativeImage.createFromBuffer(Buffer.from(FALLBACK_TRAY_PNG_BASE64, 'base64'));
}
function buildOverlay(): NativeImage {
@@ -53,50 +64,75 @@ function buildOverlay(): NativeImage {
}
export function register(mainWindow: BrowserWindow): void {
const icon = loadTrayIcon();
trayRef = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon);
trayRef.setToolTip('ChatApp');
// Tray icon is best-effort: if neither the packaged resource nor the
// dev path resolves (e.g. icon.ico isn't shipped under resourcesPath
// in packaged builds — only the app icon goes into the .exe metadata),
// we still want the taskbar overlay badge to work. setOverlayIcon is
// a BrowserWindow method, so it functions even when the systray icon
// creation fails.
try {
const icon = loadTrayIcon();
if (!icon.isEmpty()) {
trayRef = new Tray(icon);
trayRef.setToolTip('Netralax');
const menu = Menu.buildFromTemplate([
{
label: 'Open',
click: (): void => {
const menu = Menu.buildFromTemplate([
{
label: 'Open',
click: (): void => {
if (mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
},
},
{ type: 'separator' },
{
label: 'Quit',
click: (): void => {
app.quit();
},
},
]);
trayRef.setContextMenu(menu);
trayRef.on('click', (): void => {
if (mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
},
},
{ type: 'separator' },
{
label: 'Quit',
click: (): void => {
app.quit();
},
},
]);
trayRef.setContextMenu(menu);
trayRef.on('click', (): void => {
if (mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
if (mainWindow.isVisible()) mainWindow.focus();
else mainWindow.show();
});
if (mainWindow.isVisible()) mainWindow.focus();
else mainWindow.show();
});
}
} catch (err: unknown) {
// Swallow: the overlay badge below is the user-visible bit. A missing
// systray icon is cosmetic and shouldn't take the unread handler with it.
console.warn('[tray] systray init failed, overlay badge still active', err);
}
ipcMain.handle(CHANNELS.TRAY_UNREAD, async (_evt, count: number): Promise<void> => {
const n = Math.max(0, Math.floor(Number(count) || 0));
if (trayRef && !trayRef.isDestroyed()) {
trayRef.setToolTip(n > 0 ? `ChatApp — ${n} unread` : 'ChatApp');
}
if (mainWindow.isDestroyed()) return;
if (process.platform === 'win32') {
if (n > 0) {
mainWindow.setOverlayIcon(buildOverlay(), `${n} unread`);
} else {
mainWindow.setOverlayIcon(null, '');
ipcMain.handle(
CHANNELS.TRAY_UNREAD,
async (_evt, count: number, badgeDataUrl?: string | null): Promise<void> => {
const n = Math.max(0, Math.floor(Number(count) || 0));
if (trayRef && !trayRef.isDestroyed()) {
trayRef.setToolTip(n > 0 ? `Netralax — ${n} ungelesen` : 'Netralax');
}
}
});
if (mainWindow.isDestroyed()) return;
if (process.platform !== 'win32') return;
if (n <= 0) {
mainWindow.setOverlayIcon(null, '');
return;
}
// Discord-style: prefer the renderer-painted badge (red circle with
// the actual unread number). Fall back to the static red dot only if
// the renderer didn't supply one or decoding failed — keeps the
// visual indicator alive even when the canvas pipeline is unavailable.
let overlay: NativeImage | null = null;
if (typeof badgeDataUrl === 'string' && badgeDataUrl.startsWith('data:image/')) {
const decoded = nativeImage.createFromDataURL(badgeDataUrl);
if (!decoded.isEmpty()) overlay = decoded;
}
mainWindow.setOverlayIcon(overlay ?? buildOverlay(), `${n} ungelesen`);
},
);
app.on('before-quit', () => {
try {
@@ -9,6 +9,14 @@ import { BrowserWindow, ipcMain } from 'electron';
import { CHANNELS } from '../ipc-types';
export function register(mainWindow: BrowserWindow): void {
// Per-window maximize-before-fullscreen memo. We have to drop the
// maximized flag on Windows before setFullScreen so DWM recomposes
// cleanly (taskbar quirk), but Electron doesn't remember that the
// window WAS maximized — exiting fullscreen would leave it as a small
// floating window. Track it ourselves keyed by window-id so a future
// multi-window setup doesn't cross-pollute state.
const wasMaximized = new Map<number, boolean>();
ipcMain.handle(CHANNELS.WINDOW_SET_FULLSCREEN, (evt, enabled: boolean) => {
try {
// Prefer the BrowserWindow that issued the IPC so multi-window setups
@@ -16,7 +24,38 @@ export function register(mainWindow: BrowserWindow): void {
// registered against (matches autostart.ts's app-singleton shape).
const win = BrowserWindow.fromWebContents(evt.sender) ?? mainWindow;
if (!win || win.isDestroyed()) return;
win.setFullScreen(!!enabled);
const id = win.id;
if (enabled) {
// Windows DWM quirk: maximized → fullscreen sometimes leaves the
// taskbar drawn on top of the window because DWM keeps the
// maximized work-area constraints. Drop the maximize flag first
// so setFullScreen covers the whole monitor cleanly. Remember the
// pre-fullscreen state so the exit path can restore it.
if (process.platform === 'win32') {
const was = win.isMaximized();
wasMaximized.set(id, was);
if (was) win.unmaximize();
}
win.setFullScreen(true);
} else {
win.setFullScreen(false);
// Restore maximize if we dropped it on entry. setFullScreen(false)
// emits 'leave-full-screen' asynchronously; maximize() needs to
// wait until the window is back in normal mode or it silently
// no-ops. The event fires same-tick in Electron 33, but we listen
// for it once just to be safe across versions.
if (process.platform === 'win32' && wasMaximized.get(id)) {
wasMaximized.delete(id);
const restore = (): void => {
if (!win.isDestroyed()) win.maximize();
};
if (win.isFullScreen()) {
win.once('leave-full-screen', restore);
} else {
restore();
}
}
}
} catch (err: unknown) {
console.warn('window setFullscreen failed', err);
throw err;
+12 -1
View File
@@ -58,7 +58,7 @@ export interface ElectronAPI {
notify: (args: NotifyArgs) => Promise<void>;
getNotificationPermission: () => Promise<'granted' | 'denied' | 'default'>;
setTrayUnread: (count: number) => Promise<void>;
setTrayUnread: (count: number, badgeDataUrl?: string | null) => Promise<void>;
secureStoreOpen: (args: SecureStoreOpenArgs) => Promise<SecureStoreHandle>;
secureStoreGet: (handle: string, key: string) => Promise<string | null>;
@@ -95,6 +95,17 @@ export interface ElectronAPI {
setAutoStart: (enabled: boolean) => Promise<void>;
setFullscreen: (enabled: boolean) => Promise<void>;
/** Subscribe to the main-process pre-quit notification. Used by the
* "Cache beim Schließen leeren" Settings toggle. */
onWipeBeforeQuit: (cb: () => Promise<void>) => () => void;
/** Returns the OS hostname (via Node's `os.hostname()`). Used by
* AuthContext on first launch to populate the `devices` row with a
* human-readable default device name. Optional: always feature-check
* via `typeof window.electronAPI?.getHostname === 'function'` because
* the web build has no preload bridge. */
getHostname?: () => Promise<string | null>;
}
declare global {
+35 -2
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),
@@ -94,7 +101,12 @@ const api = {
ipcRenderer.invoke(CHANNELS.NOTIFY_PERMISSION),
// Tray -------------------------------------------------------------------
setTrayUnread: (count: number): Promise<void> => ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count),
// `badgeDataUrl` (optional): renderer-painted PNG (data:image/png;base64)
// that main applies as the Windows taskbar overlay icon. We render in the
// renderer because main has no Canvas2D; passing a finished image avoids
// bundling a native canvas backend just for a 32×32 badge.
setTrayUnread: (count: number, badgeDataUrl?: string | null): Promise<void> =>
ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count, badgeDataUrl ?? null),
// Secure store -----------------------------------------------------------
secureStoreOpen: (args: SecureStoreOpenArgs): Promise<SecureStoreHandle> =>
@@ -147,6 +159,27 @@ const api = {
// Window fullscreen ------------------------------------------------------
setFullscreen: (enabled: boolean): Promise<void> =>
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
// OS hostname ------------------------------------------------------------
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.APP_HOSTNAME),
// Wipe-on-close ----------------------------------------------------------
// Subscribe to the main-process pre-quit notification. The renderer's
// callback does the actual wipe (memoryWipe.ts) and resolves; we ack
// unconditionally so main can finish quitting — better to lose the wipe
// than to hang the app shutdown if the callback throws.
onWipeBeforeQuit: (cb: () => Promise<void>): Unsubscribe => {
const handler = async (_evt: Electron.IpcRendererEvent): Promise<void> => {
try {
await cb();
} catch (err) {
console.warn('[wipe] renderer cb failed', err);
}
ipcRenderer.send(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done');
};
ipcRenderer.on(CHANNELS.APP_WIPE_BEFORE_QUIT, handler);
return () => ipcRenderer.removeListener(CHANNELS.APP_WIPE_BEFORE_QUIT, handler);
},
} as const;
export type ElectronAPI = typeof api;
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@chat-app/desktop",
"version": "0.16.3",
"version": "0.19.1",
"private": true,
"description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module",
@@ -54,7 +54,7 @@
},
"build": {
"appId": "com.meinname.chatapp",
"productName": "ChatApp",
"productName": "Netralax",
"directories": {
"output": "release",
"buildResources": "resources"
+2
View File
@@ -4,6 +4,7 @@ import { HashRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
import { AppShell } from './components/AppShell';
import { CrashToast } from './components/CrashToast';
import { ErrorBoundary } from './components/ErrorBoundary';
import { RemoteRevokedScreen } from './components/RemoteRevokedScreen';
import { SpinnerIcon } from './components/icons';
import { UpdateToast } from './components/UpdateToast';
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
@@ -168,6 +169,7 @@ export function App() {
</Routes>
<UpdateToast />
<CrashToast />
<RemoteRevokedScreen />
</HashRouter>
</CallProvider>
</ConversationsProvider>
+16 -11
View File
@@ -1,19 +1,20 @@
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 { useMentionNotifications } from '../lib/useMentionNotifications';
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();
useMentionNotifications(session?.user.id);
useEffect(() => {
// Prompt once per authenticated shell mount. Module-level guard prevents
// re-asking if the user already responded this session.
@@ -21,20 +22,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 +54,6 @@ export function AppShell() {
</main>
</div>
<CallUI />
<BackupPromptBanner />
<DeviceApprovalBanner />
</div>
);
+30 -41
View File
@@ -3,10 +3,17 @@ import { useEffect, useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon, XIcon } from './icons';
import { AlertIcon, SpinnerIcon } from './icons';
import { Lightbox } from './Lightbox';
import { ViewOnceImage } from './ViewOnceImage';
interface Props {
handle: AttachmentHandle;
/** True iff the local user sent this message. View-once images use this
* to suppress the burn (senders never burn their own attachment) and to
* pick the right preview chrome. Defaults to false so existing callers
* (e.g. MediaFilesDrawer) keep working without modification. */
mine?: boolean;
}
// Max inline-preview dimension. Full-resolution stays available for the
@@ -43,7 +50,7 @@ async function makeThumbnail(blob: Blob): Promise<Blob | null> {
}
}
export function AttachmentImage({ handle }: Props) {
export function AttachmentImage({ handle, mine = false }: Props) {
const [fullUrl, setFullUrl] = useState<string | null>(null);
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -115,6 +122,25 @@ export function AttachmentImage({ handle }: Props) {
);
}
// View-once attachments swap the standard image preview for the ViewOnceImage
// chrome (locked card → fullscreen lightbox + burn for recipient, normal
// image with badge for sender). We still kick off the decrypt above so the
// fullscreen lightbox has the decoded blob ready the moment the recipient
// taps. `handle.viewedAt` is undefined in the current renderer (it lives on
// the public attachment row, not in the encrypted payload) — the component
// treats undefined as "not yet burned" and uses the RPC response to flip
// the state locally after the recipient opens.
if (handle.viewOnce) {
return (
<ViewOnceImage
attachmentId={handle.id}
viewedAt={handle.viewedAt ?? null}
isSender={mine}
src={blobUrl}
/>
);
}
return (
<>
<button
@@ -136,42 +162,5 @@ export function AttachmentImage({ handle }: Props) {
);
}
function Lightbox({ url, onClose }: { url: string; onClose: () => void }) {
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prevOverflow;
};
}, [onClose]);
return (
<div
role="dialog"
aria-modal="true"
aria-label="Bildansicht"
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/90 p-6 backdrop-blur-sm animate-fade-in"
>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="absolute right-4 top-4 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-ink-900/80 text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<XIcon className="h-5 w-5" />
</button>
<img
src={url}
alt="attachment full"
onClick={(e) => e.stopPropagation()}
className="max-h-[92vh] max-w-[92vw] rounded-xl object-contain shadow-2xl"
/>
</div>
);
}
// Lightbox extracted to ./Lightbox.tsx so the settings avatar preview and
// any future surface can reuse the same dialog without duplication.
@@ -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>
);
}
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
import { useNickname } from '../lib/friendNicknames';
import {
CrownIcon,
HeadphonesOffIcon,
@@ -83,11 +84,13 @@ export interface ParticipantTileProps {
size?: 'default' | 'small';
focused?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
}
export function CallParticipantTile(props: ParticipantTileProps) {
const {
userId,
displayName,
me,
muted,
@@ -102,9 +105,17 @@ export function CallParticipantTile(props: ParticipantTileProps) {
size = 'default',
focused = false,
onClick,
onDoubleClick,
onContextMenu,
} = props;
// Apply the per-viewer nickname override once at the top — each tile is
// already per-participant, so a single hook call is fine. The resolved
// name is forwarded into the avatar sub-components below so their initial
// letter respects the nickname too.
const resolvedName = useNickname(userId, displayName);
const tileProps = { ...props, displayName: resolvedName };
const small = size === 'small';
// Discord-style: full tile border switches to emerald the whole time the
// user is speaking. Same treatment for audio + video tiles so the visual
@@ -120,6 +131,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
return (
<div
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
className={
'relative flex flex-col overflow-hidden rounded-[14px] border-[2px] bg-surface-3 transition-colors duration-150 ' +
@@ -129,9 +141,9 @@ export function CallParticipantTile(props: ParticipantTileProps) {
}
>
{video ? (
<VideoStub {...props} small={small} />
<VideoStub {...tileProps} small={small} fit={focused ? 'contain' : 'cover'} />
) : (
<AudioContent {...props} small={small} />
<AudioContent {...tileProps} small={small} />
)}
{speaking && (
@@ -184,7 +196,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
/>
)}
<span className="truncate">
{displayName}
{resolvedName}
{me ? ' (du)' : ''}
</span>
{e2ee && (
@@ -303,7 +315,8 @@ function VideoStub({
videoTrack,
me,
small,
}: ParticipantTileProps & { small: boolean }) {
fit,
}: ParticipantTileProps & { small: boolean; fit: 'cover' | 'contain' }) {
const videoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
const el = videoRef.current;
@@ -330,7 +343,8 @@ function VideoStub({
playsInline
muted
className={
'h-full w-full object-cover ' +
'h-full w-full ' +
(fit === 'contain' ? 'object-contain ' : 'object-cover ') +
(me ? 'scale-x-[-1]' : '') /* mirror local preview */
}
/>
@@ -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>
);
}
@@ -3,6 +3,7 @@ import type { PresenceState } from '@chat-app/shared/supabase';
import { useTranslation } from 'react-i18next';
import { useCall } from '../context/CallContext';
import { useNickname } from '../lib/friendNicknames';
import type { PeerPresence } from '../lib/usePeerPresence';
import { Avatar } from './Avatar';
import {
@@ -13,6 +14,7 @@ import {
UsersIcon,
VideoIcon,
} from './icons';
import { PinnedMessagesPill } from './PinnedMessagesPill';
const PRESENCE_DOT: Record<PresenceState, string> = {
online: 'bg-emerald-500',
@@ -29,6 +31,8 @@ interface Props {
onMediaClick?: () => void;
onProfileClick?: (ev: React.MouseEvent) => void;
onSearchClick?: () => void;
pinnedCount?: number;
onOpenPinned?: () => void;
}
export function ConversationHeader({
@@ -38,6 +42,8 @@ export function ConversationHeader({
onMediaClick,
onProfileClick,
onSearchClick,
pinnedCount = 0,
onOpenPinned,
}: Props) {
if (!conversation) {
return <header className="h-[65px] border-b border-line px-6 py-3" aria-busy="true" />;
@@ -48,10 +54,12 @@ export function ConversationHeader({
<HeaderBar
conversation={conversation}
peerPresence={peerPresence}
pinnedCount={pinnedCount}
{...(onInfoClick ? { onInfoClick } : {})}
{...(onMediaClick ? { onMediaClick } : {})}
{...(onProfileClick ? { onProfileClick } : {})}
{...(onSearchClick ? { onSearchClick } : {})}
{...(onOpenPinned ? { onOpenPinned } : {})}
/>
{/* Voice-channel rail rendered separately in ConversationPage so it
can sit between the message surface and the in-call dock. */}
@@ -66,6 +74,8 @@ interface HeaderBarProps {
onMediaClick?: () => void;
onProfileClick?: (ev: React.MouseEvent) => void;
onSearchClick?: () => void;
pinnedCount?: number;
onOpenPinned?: () => void;
}
function HeaderBar({
@@ -75,11 +85,19 @@ function HeaderBar({
onMediaClick,
onProfileClick,
onSearchClick,
pinnedCount = 0,
onOpenPinned,
}: HeaderBarProps) {
const { t } = useTranslation(['app']);
const isDm = conversation.type === 'dm';
const title = isDm ? (conversation.peer?.displayName ?? '?') : (conversation.name ?? '?');
// For DMs, route the peer's display name through the per-viewer nickname
// override. Falls back to the real displayName when no nickname is set.
const peerName = useNickname(
conversation.peer?.userId,
conversation.peer?.displayName ?? '?',
);
const title = isDm ? peerName : (conversation.name ?? '?');
const handle = isDm ? '@' + (conversation.peer?.username ?? '?') : '';
const peerAvatar = isDm
? (conversation.peer?.avatarUrl ?? null)
@@ -133,6 +151,7 @@ function HeaderBar({
<div className="flex min-w-0 items-center gap-2">
{!isDm && <UsersIcon className="h-4 w-4 shrink-0 text-fg-muted" />}
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
<PinnedMessagesPill count={pinnedCount} onClick={() => onOpenPinned?.()} />
</div>
<p className="truncate text-xs text-fg-muted">
{isDm ? (
@@ -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>
);
}
@@ -0,0 +1,32 @@
import type { ReactNode } from 'react';
interface Props {
icon: ReactNode;
title: string;
description: string;
action?: { label: string; onClick: () => void };
}
// Reusable empty-state placeholder: large icon + heading + description +
// optional primary CTA. Used everywhere there's a meaningfully-empty list
// (no chats, no friends, no search results, fresh conversation).
export function EmptyState({ icon, title, description, action }: Props) {
return (
<div className="flex h-full flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-accent/10 text-accent">
{icon}
</div>
<h3 className="font-display text-lg font-semibold text-fg">{title}</h3>
<p className="mt-2 max-w-xs text-sm text-fg-muted">{description}</p>
{action && (
<button
type="button"
onClick={action.onClick}
className="mt-5 inline-flex cursor-pointer items-center justify-center rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
{action.label}
</button>
)}
</div>
);
}
@@ -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 } : {}),
});
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useMemo, useState } from 'react';
import {
featuredGifs,
getRecentGifs,
type GifResult,
rememberRecentGif,
searchGifs,
} from '../lib/tenor';
import { SpinnerIcon, XIcon } from './icons';
interface Props {
open: boolean;
onClose: () => void;
onPick: (gif: GifResult) => void;
}
type Tab = 'trending' | 'search' | 'recent';
export function GifPicker({ open, onClose, onPick }: Props) {
const [tab, setTab] = useState<Tab>('trending');
const [query, setQuery] = useState('');
const [results, setResults] = useState<GifResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const recent = useMemo(() => getRecentGifs(), [open]);
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError(null);
const run = async () => {
try {
const gifs = tab === 'search' && query.trim().length > 0
? await searchGifs(query)
: tab === 'trending'
? await featuredGifs()
: [];
if (!cancelled) setResults(gifs);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'GIFs gerade nicht verfügbar');
} finally {
if (!cancelled) setLoading(false);
}
};
void run();
return () => { cancelled = true; };
}, [open, tab, query]);
if (!open) return null;
const visible = tab === 'recent' ? recent : results;
return (
<div className="absolute bottom-full left-0 z-30 mb-2 w-[440px] rounded-2xl border border-line bg-surface-2 shadow-xl">
<header className="flex items-center justify-between border-b border-line px-3 py-2">
<div className="flex items-center gap-1">
{(['trending', 'search', 'recent'] as Tab[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={
'cursor-pointer rounded-md px-2 py-1 text-xs font-medium transition ' +
(t === tab ? 'bg-accent/15 text-accent' : 'text-fg-muted hover:text-fg')
}
>
{t === 'trending' ? 'Trending' : t === 'search' ? 'Suche' : 'Zuletzt'}
</button>
))}
</div>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
>
<XIcon className="h-3.5 w-3.5" />
</button>
</header>
{tab === 'search' && (
<div className="border-b border-line px-3 py-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="GIFs suchen…"
className="w-full rounded-md border border-line bg-surface-3 px-2 py-1.5 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
</div>
)}
<div className="grid max-h-[360px] grid-cols-3 gap-1.5 overflow-y-auto p-2">
{loading && (
<div className="col-span-3 flex justify-center py-6 text-fg-muted">
<SpinnerIcon className="h-5 w-5" />
</div>
)}
{error && (
<p className="col-span-3 px-2 py-4 text-center text-xs text-rose-300">
{error === 'giphy_api_key_missing'
? 'GIFs sind nicht konfiguriert (VITE_GIPHY_API_KEY fehlt).'
: 'GIFs gerade nicht verfügbar.'}
</p>
)}
{!loading && !error && visible.length === 0 && (
<p className="col-span-3 px-2 py-6 text-center text-xs text-fg-muted">
{tab === 'recent' ? 'Noch keine zuletzt verwendeten GIFs.' : 'Keine Treffer.'}
</p>
)}
{!loading && !error && visible.map((g) => (
<button
key={g.id}
type="button"
onClick={() => { rememberRecentGif(g); onPick(g); onClose(); }}
className="overflow-hidden rounded-md border border-line bg-surface-3 hover:border-accent/40"
title={g.description}
>
<img src={g.previewUrl} alt={g.description || 'GIF'} className="h-24 w-full object-cover" loading="lazy" />
</button>
))}
</div>
</div>
);
}
@@ -93,7 +93,7 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
<aside
role="dialog"
aria-label={t('app:group.info_title')}
className="absolute inset-y-0 right-0 z-20 flex w-[320px] flex-col border-l border-white/5 bg-ink-900/95 shadow-xl backdrop-blur-xl animate-slide-up"
className="flex w-[320px] shrink-0 flex-col border-l border-white/5 bg-ink-900/95"
>
<header className="flex items-center justify-between border-b border-white/5 px-5 py-4">
<div>
@@ -0,0 +1,468 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { XIcon } from './icons';
export type AnnotatorTool = 'pen' | 'arrow' | 'rect' | 'circle' | 'text' | 'highlighter';
export type AnnotatorColor = '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7' | '#ffffff';
export type AnnotatorWidth = 2 | 4 | 8;
export interface AnnotatorOp {
tool: AnnotatorTool;
color: AnnotatorColor;
width: AnnotatorWidth;
points?: Array<{ x: number; y: number }>;
from?: { x: number; y: number };
to?: { x: number; y: number };
text?: string;
at?: { x: number; y: number };
}
interface Props {
file: File;
onCancel: () => void;
onSave: (next: File) => void;
}
export function ImageAnnotator({ file, onCancel, onSave }: Props) {
const { t } = useTranslation();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const [imageLoaded, setImageLoaded] = useState(false);
const [ops, setOps] = useState<AnnotatorOp[]>([]);
const [redoStack, setRedoStack] = useState<AnnotatorOp[]>([]);
// tool/color/width are read by Task 2 (drawing engine) and Task 3 (toolbar).
// Defaults shown here are intentional so T2's pointer handlers work
// immediately with sensible behavior before T3's UI is wired.
const [tool, setTool] = useState<AnnotatorTool>('pen');
const [color, setColor] = useState<AnnotatorColor>('#ef4444');
const [width, setWidth] = useState<AnnotatorWidth>(4);
const draftRef = useRef<AnnotatorOp | null>(null);
const [draftTick, setDraftTick] = useState(0);
useEffect(() => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
imageRef.current = img;
setImageLoaded(true);
};
img.onerror = () => {
console.error('ImageAnnotator: failed to decode source image');
onCancel();
};
img.src = url;
return () => URL.revokeObjectURL(url);
}, [file, onCancel]);
useEffect(() => {
if (!imageLoaded) return;
const cv = canvasRef.current;
const img = imageRef.current;
if (!cv || !img) return;
cv.width = img.naturalWidth;
cv.height = img.naturalHeight;
const ctx = cv.getContext('2d');
if (!ctx) return;
ctx.drawImage(img, 0, 0);
for (const op of ops) {
renderOp(ctx, op);
}
if (draftRef.current) {
renderOp(ctx, draftRef.current);
}
}, [imageLoaded, ops, draftTick]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
handleUndo();
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
e.preventDefault();
handleRedo();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
const handleUndo = () => {
setOps((cur) => {
if (cur.length === 0) return cur;
const next = cur.slice(0, -1);
setRedoStack((r) => [...r, cur[cur.length - 1]!]);
return next;
});
};
const handleRedo = () => {
setRedoStack((r) => {
if (r.length === 0) return r;
const top = r[r.length - 1]!;
setOps((cur) => [...cur, top]);
return r.slice(0, -1);
});
};
const handleReset = () => {
setOps([]);
setRedoStack([]);
};
const handleSave = () => {
const cv = canvasRef.current;
if (!cv) return;
cv.toBlob((blob) => {
if (!blob) {
console.error('ImageAnnotator: toBlob returned null');
return;
}
const baseName = file.name.replace(/\.[^.]+$/, '');
const next = new File([blob], baseName + '-annotated.png', {
type: 'image/png',
lastModified: Date.now(),
});
onSave(next);
}, 'image/png');
};
function canvasPoint(e: React.PointerEvent<HTMLCanvasElement>): { x: number; y: number } {
const cv = canvasRef.current!;
const rect = cv.getBoundingClientRect();
const scaleX = cv.width / rect.width;
const scaleY = cv.height / rect.height;
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY,
};
}
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!imageLoaded) return;
const cv = canvasRef.current;
if (!cv) return;
cv.setPointerCapture(e.pointerId);
const p = canvasPoint(e);
if (tool === 'text') {
const value = window.prompt(
t('app:annotator.text_prompt', { defaultValue: 'Text eingeben:' }),
'',
);
if (value !== null && value.trim().length > 0) {
setOps((cur) => [...cur, { tool: 'text', color, width, text: value, at: p }]);
setRedoStack([]);
}
return;
}
if (tool === 'pen' || tool === 'highlighter') {
draftRef.current = { tool, color, width, points: [p] };
} else {
draftRef.current = { tool, color, width, from: p, to: p };
}
setDraftTick((n) => n + 1);
};
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!draftRef.current) return;
const p = canvasPoint(e);
const cur = draftRef.current;
if (cur.tool === 'pen' || cur.tool === 'highlighter') {
cur.points = [...(cur.points ?? []), p];
} else {
cur.to = p;
}
setDraftTick((n) => n + 1);
};
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
const cv = canvasRef.current;
if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId);
const cur = draftRef.current;
draftRef.current = null;
if (!cur) return;
const hasContent =
(cur.tool === 'pen' || cur.tool === 'highlighter')
? (cur.points?.length ?? 0) >= 2
: !!(cur.from && cur.to && (cur.from.x !== cur.to.x || cur.from.y !== cur.to.y));
if (hasContent) {
setOps((p) => [...p, cur]);
setRedoStack([]);
}
setDraftTick((n) => n + 1);
};
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
className="fixed inset-0 z-[80] flex flex-col bg-ink-950/95"
>
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
<h2 className="font-display text-sm font-semibold text-fg">
{t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
</h2>
<button
type="button"
onClick={onCancel}
aria-label={t('app:annotator.cancel', { defaultValue: 'Abbrechen' })}
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
>
<XIcon className="h-3.5 w-3.5" />
</button>
</header>
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden p-6">
{imageLoaded ? (
<canvas
ref={canvasRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
className="max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-black shadow-2xl"
/>
) : (
<p className="text-sm text-fg-muted">
{t('app:annotator.loading', { defaultValue: 'Bild wird geladen…' })}
</p>
)}
</div>
<footer className="flex shrink-0 flex-wrap items-center gap-4 border-t border-line/40 bg-surface-2 px-4 py-3">
<div className="flex items-center gap-1">
{(['pen', 'highlighter', 'arrow', 'rect', 'circle', 'text'] as AnnotatorTool[]).map((id) => {
const label = t('app:annotator.tool.' + id, {
defaultValue:
id === 'pen' ? 'Stift'
: id === 'highlighter' ? 'Marker'
: id === 'arrow' ? 'Pfeil'
: id === 'rect' ? 'Rechteck'
: id === 'circle' ? 'Kreis'
: 'Text',
});
const active = tool === id;
return (
<button
key={id}
type="button"
onClick={() => setTool(id)}
aria-pressed={active}
title={label}
className={
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-xs font-semibold transition ' +
(active
? 'bg-accent/20 text-accent ring-2 ring-accent/40'
: 'bg-surface-3 text-fg-muted hover:bg-surface hover:text-fg')
}
>
{toolGlyph(id)}
</button>
);
})}
</div>
<div className="h-6 w-px bg-line/40" aria-hidden />
<div className="flex items-center gap-1">
{(['#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7', '#ffffff'] as AnnotatorColor[]).map((c) => {
const active = color === c;
return (
<button
key={c}
type="button"
onClick={() => setColor(c)}
aria-pressed={active}
aria-label={c}
className={
'h-6 w-6 cursor-pointer rounded-full border-2 transition ' +
(active ? 'border-fg scale-110' : 'border-line/40 hover:scale-105')
}
style={{ backgroundColor: c }}
/>
);
})}
</div>
<div className="h-6 w-px bg-line/40" aria-hidden />
<div className="flex items-center gap-1">
{([2, 4, 8] as AnnotatorWidth[]).map((w) => {
const active = width === w;
return (
<button
key={w}
type="button"
onClick={() => setWidth(w)}
aria-pressed={active}
title={w + 'px'}
className={
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md transition ' +
(active
? 'bg-accent/20 ring-2 ring-accent/40'
: 'bg-surface-3 hover:bg-surface')
}
>
<div
className="rounded-full bg-fg"
style={{ width: w + 2 + 'px', height: w + 2 + 'px' }}
/>
</button>
);
})}
</div>
<div className="h-6 w-px bg-line/40" aria-hidden />
<div className="flex items-center gap-1">
<button
type="button"
onClick={handleUndo}
disabled={ops.length === 0}
title={t('app:annotator.undo', { defaultValue: 'Rückgängig (Ctrl+Z)' })}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
>
</button>
<button
type="button"
onClick={handleRedo}
disabled={redoStack.length === 0}
title={t('app:annotator.redo', { defaultValue: 'Wiederholen (Ctrl+Y)' })}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
>
</button>
</div>
<div className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={handleReset}
disabled={ops.length === 0}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
>
{t('app:annotator.reset', { defaultValue: 'Zurücksetzen' })}
</button>
<button
type="button"
onClick={handleSave}
disabled={!imageLoaded}
className="cursor-pointer rounded-md bg-accent px-4 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('app:annotator.save', { defaultValue: 'Speichern' })}
</button>
</div>
</footer>
</div>
);
}
function toolGlyph(t: AnnotatorTool): string {
switch (t) {
case 'pen': return '✎';
case 'highlighter': return '🖍';
case 'arrow': return '↗';
case 'rect': return '▭';
case 'circle': return '◯';
case 'text': return 'T';
}
}
function renderOp(ctx: CanvasRenderingContext2D, op: AnnotatorOp): void {
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = op.color;
ctx.fillStyle = op.color;
ctx.lineWidth = op.width;
switch (op.tool) {
case 'pen': {
const pts = op.points;
if (!pts || pts.length < 1) break;
ctx.beginPath();
ctx.moveTo(pts[0]!.x, pts[0]!.y);
for (let i = 1; i < pts.length; i++) {
ctx.lineTo(pts[i]!.x, pts[i]!.y);
}
ctx.stroke();
break;
}
case 'highlighter': {
const pts = op.points;
if (!pts || pts.length < 1) break;
ctx.globalAlpha = 0.35;
ctx.lineWidth = op.width * 4;
ctx.beginPath();
ctx.moveTo(pts[0]!.x, pts[0]!.y);
for (let i = 1; i < pts.length; i++) {
ctx.lineTo(pts[i]!.x, pts[i]!.y);
}
ctx.stroke();
break;
}
case 'rect': {
const { from, to } = op;
if (!from || !to) break;
ctx.strokeRect(
Math.min(from.x, to.x),
Math.min(from.y, to.y),
Math.abs(to.x - from.x),
Math.abs(to.y - from.y),
);
break;
}
case 'circle': {
const { from, to } = op;
if (!from || !to) break;
const cx = (from.x + to.x) / 2;
const cy = (from.y + to.y) / 2;
const rx = Math.abs(to.x - from.x) / 2;
const ry = Math.abs(to.y - from.y) / 2;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
ctx.stroke();
break;
}
case 'arrow': {
const { from, to } = op;
if (!from || !to) break;
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
const angle = Math.atan2(to.y - from.y, to.x - from.x);
const head = Math.max(12, op.width * 3);
ctx.beginPath();
ctx.moveTo(to.x, to.y);
ctx.lineTo(
to.x - head * Math.cos(angle - Math.PI / 6),
to.y - head * Math.sin(angle - Math.PI / 6),
);
ctx.moveTo(to.x, to.y);
ctx.lineTo(
to.x - head * Math.cos(angle + Math.PI / 6),
to.y - head * Math.sin(angle + Math.PI / 6),
);
ctx.stroke();
break;
}
case 'text': {
const { at, text } = op;
if (!at || !text) break;
const fontSize = Math.max(14, op.width * 6);
ctx.font = '600 ' + fontSize + 'px Inter, system-ui, sans-serif';
ctx.textBaseline = 'top';
ctx.fillText(text, at.x, at.y);
break;
}
}
ctx.restore();
}
+262 -107
View File
@@ -1,7 +1,7 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import type { ConnectionQuality, RemoteParticipant, Room } from 'livekit-client';
import { RoomEvent, Track } from 'livekit-client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
@@ -27,7 +27,7 @@ import { CallControls } from './CallControls';
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
import { CallStatsOverlay } from './CallStatsOverlay';
import { ScreenSharePickerModal } from './ScreenSharePickerModal';
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
import { GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
import { ScreenShareContextMenu } from './ScreenShareContextMenu';
@@ -195,22 +195,6 @@ export function InCallPanel({ conversation }: Props) {
if (soundboardCount === 0) setSoundboardOpen(false);
}, [soundboardCount]);
// Active-speaker auto-focus uses "who most recently started speaking"
// rather than "exactly one speaker" — matches Discord more closely and
// handles the case where two people talk briefly without the focus
// collapsing to nobody.
const [lastStartedSpeakerId, setLastStartedSpeakerId] = useState<string | null>(null);
const prevActiveSpeakersRef = useRef<Set<string>>(new Set());
useEffect(() => {
for (const id of activeSpeakers) {
if (!prevActiveSpeakersRef.current.has(id)) {
setLastStartedSpeakerId(id);
break;
}
}
prevActiveSpeakersRef.current = new Set(activeSpeakers);
}, [activeSpeakers]);
// Single right-click dispatcher for all tiles. User-tiles open the volume
// menu; screen-tiles open the share-specific menu (volume + mute + stop
// watching). Self-tiles get no menu — no volume to control, and you can
@@ -310,12 +294,35 @@ export function InCallPanel({ conversation }: Props) {
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
: t('app:call.connected');
// Screen shares no longer auto-promote — the user opts in by clicking the
// "Bildschirm anschauen" overlay, which also toggles whether the audio
// plays. Focus falls back to the first tile so focus-mode always has
// something to show when no tile was explicitly picked.
const effectiveFocusedId = focusedId ?? tiles[0]?.id ?? null;
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
// Discord-style precedence:
// 1. focusedId set → 'focus', that tile is the stage.
// 2. ≥2 shares, no pin → 'bento', shares fill the stage, webcams strip.
// 3. exactly 1 share, no pin → 'focus' (auto-promote share).
// 4. no shares, no pin → 'equal-grid'.
type StageLayout =
| { kind: 'equal-grid' }
| { kind: 'focus'; bigTileId: string }
| { kind: 'bento'; shareIds: string[] };
const shareIds = tiles.filter((t) => t.kind === 'screen').map((t) => t.id);
const stageLayout: StageLayout = (() => {
if (focusedId !== null && tiles.some((t) => t.id === focusedId)) {
return { kind: 'focus', bigTileId: focusedId };
}
if (shareIds.length >= 2) return { kind: 'bento', shareIds };
if (shareIds.length === 1 && shareIds[0]) {
return { kind: 'focus', bigTileId: shareIds[0] };
}
return { kind: 'equal-grid' };
})();
// Tile that owns the big stage when layout is 'focus'. Resolved lazily by
// callers below — kept here just so the speaker prop on CallStage/Fullscreen
// stays consistent with the layout decision.
const bigTile =
stageLayout.kind === 'focus'
? tiles.find((t) => t.id === stageLayout.bigTileId)
: undefined;
const controls = (
<CallControls
@@ -385,24 +392,6 @@ export function InCallPanel({ conversation }: Props) {
}));
if (callMode === 'fullscreen') {
// In fullscreen, a "manual focus" = user explicitly picked someone OR
// the person who most recently started speaking (tracked in
// lastStartedSpeakerId). Screen shares are no longer an auto-focus
// trigger; they stay as equal-size grid tiles until the user clicks
// one. "Most recent speaker" beats "exactly one currently speaking"
// because two people briefly overlapping shouldn't kick us out of
// auto-focus.
const autoSpeaker =
focusedId === null && lastStartedSpeakerId !== null
? tiles.find(
(t) =>
t.kind === 'user' &&
!t.self &&
t.userId === lastStartedSpeakerId,
)
: undefined;
const hasFocus = focusedId !== null || autoSpeaker !== undefined;
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
return (
<>
{micError && (
@@ -418,7 +407,7 @@ export function InCallPanel({ conversation }: Props) {
)}
<FullscreenCall
tiles={tiles}
speaker={effectiveSpeaker}
speaker={bigTile}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members}
activeSpeakers={activeSpeakers}
@@ -523,10 +512,10 @@ export function InCallPanel({ conversation }: Props) {
// Focus mode dedicates the entire call-panel vertical slot to the speaker so
// the tile can grow in height (grid mode's 420px cap leaves it squashed).
const sectionClass =
callMode === 'focus'
stageLayout.kind === 'focus'
? 'flex min-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2'
: 'flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2';
const sectionHeight = callMode === 'focus' ? '75%' : '50%';
const sectionHeight = stageLayout.kind === 'focus' ? '75%' : '50%';
return (
<section
@@ -567,23 +556,14 @@ export function InCallPanel({ conversation }: Props) {
<CallStage
tiles={tiles}
speaker={speaker}
mode={callMode}
speaker={bigTile}
stageLayout={stageLayout}
activeSpeakers={activeSpeakers}
e2ee={isE2EEActive}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members}
onFocusTile={(id) => {
// Discord-style toggle: clicking the already-focused tile
// collapses back to grid; clicking another tile swaps focus;
// clicking any tile in grid mode focuses it.
if (callMode === 'focus' && focusedId === id) {
setFocusedId(null);
setCallMode('grid');
return;
}
setFocusedId(id);
if (callMode === 'grid') setCallMode('focus');
setFocusedId(focusedId === id ? null : id);
}}
onTileContextMenu={openTileContextMenu}
compact
@@ -601,9 +581,7 @@ export function InCallPanel({ conversation }: Props) {
y={volumeMenu.y}
pinned={focusedId === volumeMenu.tileId}
onTogglePin={() => {
const isPinned = focusedId === volumeMenu.tileId;
setFocusedId(isPinned ? null : volumeMenu.tileId);
if (!isPinned && callMode === 'grid') setCallMode('focus');
setFocusedId(focusedId === volumeMenu.tileId ? null : volumeMenu.tileId);
}}
{...(volumeMenu.self ? { renderVolume: false } : {})}
{...(volumeMenu.self
@@ -869,9 +847,6 @@ function ModeToggles({
<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
<GridIcon className="h-4 w-4" />
</ModeButton>
<ModeButton active={mode === 'focus'} onClick={() => onChange('focus')} label="Fokus">
<FocusIcon className="h-4 w-4" />
</ModeButton>
<ModeButton
active={mode === 'fullscreen'}
onClick={() => onChange('fullscreen')}
@@ -916,7 +891,12 @@ function ModeButton({
interface StageProps {
tiles: Tile[];
speaker: Tile | undefined;
mode: CallMode;
/** Discriminated layout decision driven by InCallPanel's StageLayout
* selector. Drives the bento-vs-grid-vs-focus render branch. */
stageLayout:
| { kind: 'equal-grid' }
| { kind: 'focus'; bigTileId: string }
| { kind: 'bento'; shareIds: string[] };
activeSpeakers: Set<string>;
e2ee: boolean;
remoteScreenShares: {
@@ -941,7 +921,9 @@ function TileRender({
conversationMembers,
size,
focused,
cinema,
onClick,
onDoubleClick,
onContextMenu,
}: {
tile: Tile;
@@ -951,7 +933,12 @@ function TileRender({
conversationMembers: StageProps['conversationMembers'];
size?: 'default' | 'small';
focused?: boolean;
/** True when rendered inside FullscreenCall's big-tile slot. Drives
* chrome-suppression on the inner ScreenShareViewer so its toggle
* doesn't visually collide with the cinema-mode strip-hidden button. */
cinema?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
}): JSX.Element {
if (tile.kind === 'screen') {
@@ -964,6 +951,7 @@ function TileRender({
return (
<div
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
>
@@ -971,6 +959,7 @@ function TileRender({
share={share}
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
displayName={member?.profile?.displayName ?? tile.displayName}
hideFullscreenToggle={cinema === true}
/>
</div>
);
@@ -994,6 +983,7 @@ function TileRender({
{...(size ? { size } : {})}
{...(focused ? { focused } : {})}
{...(onClick ? { onClick } : {})}
{...(onDoubleClick ? { onDoubleClick } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
/>
);
@@ -1002,7 +992,7 @@ function TileRender({
function CallStage({
tiles,
speaker,
mode,
stageLayout,
activeSpeakers,
e2ee,
remoteScreenShares,
@@ -1011,7 +1001,7 @@ function CallStage({
onTileContextMenu,
compact = false,
}: StageProps) {
if (mode === 'focus' && speaker) {
if (stageLayout.kind === 'focus' && speaker) {
const others = tiles.filter((p) => p.id !== speaker.id);
return (
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
@@ -1022,6 +1012,7 @@ function CallStage({
activeSpeakers={activeSpeakers}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onDoubleClick={() => onFocusTile(speaker.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker, e) }
: {})}
@@ -1032,7 +1023,11 @@ function CallStage({
{others.map((p) => (
<div
key={p.id}
className="h-full w-[240px] shrink-0 [&>div]:h-full"
// Docked strip: thumbs grow to share the row width evenly
// (flex-1) but stay bounded so 1-2 tiles don't stretch into
// 2:1 panoramas. Max-w cap keeps the visual rhythm aligned
// with the share above; min-w keeps them readable when many.
className="h-full flex-1 min-w-[200px] max-w-[460px] [&>div]:h-full [&>div]:w-full"
>
<TileRender
tile={p}
@@ -1054,13 +1049,91 @@ function CallStage({
);
}
// Grid
if (stageLayout.kind === 'bento') {
const shares = tiles.filter((t) => stageLayout.shareIds.includes(t.id));
const webcams = tiles.filter((t) => !stageLayout.shareIds.includes(t.id));
const bentoCols = gridColsFor(shares.length);
return (
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
<div className="min-h-0 flex-1">
<div
className={
'grid h-full max-h-full gap-2 place-content-center ' + bentoCols
}
>
{shares.map((s) => (
<div
key={s.id}
className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
>
<TileRender
tile={s}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(s.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
: {})}
/>
</div>
))}
</div>
</div>
{webcams.length > 0 && (
<div className="flex h-[180px] gap-2.5 overflow-x-auto">
{webcams.map((w) => (
<div
key={w.id}
// Same docked-strip sizing as the focus branch above.
className="h-full flex-1 min-w-[200px] max-w-[460px] [&>div]:h-full [&>div]:w-full"
>
<TileRender
tile={w}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
size="small"
onClick={() => onFocusTile(w.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
: {})}
/>
</div>
))}
</div>
)}
</div>
);
}
// Grid (equal-grid fallthrough)
const gridClass = gridColsFor(tiles.length);
// aspect-video on every cell pushed the row past the section height on
// wide chat panels — a single cell at full width forced height = width
// × 9/16 (~400px on a 700px panel), which clipped the controls bar
// below. Let cells fill grid tracks normally for n>=2, and only enforce
// a 16:9 silhouette (capped width, centred) for the solo-user case.
const isSolo = tiles.length === 1;
return (
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
<div className={'grid h-full gap-2 ' + gridClass}>
<div
className={
'grid h-full max-h-full gap-2 auto-rows-fr place-content-center ' +
gridClass
}
>
{tiles.map((p) => (
<div key={p.id} className="[&>div]:h-full [&>div]:w-full">
<div
key={p.id}
className={
isSolo
? 'aspect-video w-full max-w-[480px] justify-self-center [&>div]:h-full [&>div]:w-full'
: 'min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full'
}
>
<TileRender
tile={p}
activeSpeakers={activeSpeakers}
@@ -1086,6 +1159,7 @@ function FocusedTile({
remoteScreenShares,
conversationMembers,
onContextMenu,
onDoubleClick,
}: {
tile: Tile;
e2ee: boolean;
@@ -1093,6 +1167,7 @@ function FocusedTile({
remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers'];
onContextMenu?: (e: React.MouseEvent) => void;
onDoubleClick?: () => void;
}) {
return (
<div className="h-full [&>div]:h-full">
@@ -1104,6 +1179,7 @@ function FocusedTile({
conversationMembers={conversationMembers}
focused
{...(onContextMenu ? { onContextMenu } : {})}
{...(onDoubleClick ? { onDoubleClick } : {})}
/>
</div>
);
@@ -1112,17 +1188,16 @@ function FocusedTile({
const GRID_PAGE_SIZE = 12;
function gridColsFor(n: number): string {
// Explicit `grid-rows-*` so cells get a defined height (1fr of available
// space). Without this, implicit rows default to auto → they size to
// content, and a video element's intrinsic size blows the tile past the
// container bounds (overlapping the toolbar below).
if (n <= 1) return 'grid-cols-1 grid-rows-1';
if (n === 2) return 'grid-cols-2 grid-rows-1';
if (n === 3) return 'grid-cols-3 grid-rows-1';
if (n === 4) return 'grid-cols-2 grid-rows-2';
if (n <= 6) return 'grid-cols-3 grid-rows-2';
if (n <= 9) return 'grid-cols-3 grid-rows-3';
return 'grid-cols-4 grid-rows-3';
// Discord-style: column count only. Cells are `aspect-video` so their
// height follows from their width, and the container centers them
// vertically when the row stack is shorter than the available area.
if (n <= 1) return 'grid-cols-1';
if (n === 2) return 'grid-cols-2';
if (n === 3) return 'grid-cols-3';
if (n === 4) return 'grid-cols-2';
if (n <= 6) return 'grid-cols-3';
if (n <= 9) return 'grid-cols-3';
return 'grid-cols-4';
}
// Promote self + active speakers to the front of the tile list. Stable
@@ -1216,12 +1291,25 @@ function FullscreenCall({
const hasFocus = speaker !== undefined;
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
const fsShareIds = tiles
.filter((t) => t.kind === 'screen')
.map((t) => t.id);
const bentoMode = !hasFocus && fsShareIds.length >= 2;
const bentoShares = bentoMode
? tiles.filter((t) => fsShareIds.includes(t.id))
: [];
const bentoWebcams = bentoMode
? tiles.filter((t) => !fsShareIds.includes(t.id))
: [];
// Active-speaker reorder + paginate. When more than GRID_PAGE_SIZE tiles
// exist, slice them into pages. Reset to page 0 if the page count drops
// below the current page (someone left).
// exist, slice them into pages and prioritize active speakers onto page 1.
// Otherwise keep a stable order (Discord-style) so tiles don't shuffle
// whenever someone speaks.
const needsPagination = tiles.length > GRID_PAGE_SIZE;
const sortedGridTiles = useMemo(
() => prioritizeTiles(tiles, activeSpeakers),
[tiles, activeSpeakers],
() => (needsPagination ? prioritizeTiles(tiles, activeSpeakers) : tiles),
[tiles, activeSpeakers, needsPagination],
);
const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE));
useEffect(() => {
@@ -1235,14 +1323,18 @@ function FullscreenCall({
return (
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
{/* Content area. pb-24 reserves ~96px space at the bottom for the
floating controls bar so tiles never sit behind it. */}
<div className="relative flex min-h-0 flex-1 flex-col pb-24">
{/* Content area. pb-28 reserves ~112px space at the bottom for the
floating controls bar plus extra clearance so the tiles' bottom
name-chip (positioned `bottom-2` inside each tile) doesn't sit
directly underneath the controls — pb-24 was tight enough that
on wide screens with audio-only avatars the chip got eclipsed. */}
<div className="relative flex min-h-0 flex-1 flex-col pb-28">
{hasFocus ? (
<>
<div
className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
onClick={() => onFocusTile(speaker!.id)}
onDoubleClick={() => onFocusTile(speaker!.id)}
title="Zurück zur Übersicht"
>
<TileRender
@@ -1252,6 +1344,7 @@ function FullscreenCall({
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
focused
cinema
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker!, e) }
: {})}
@@ -1262,7 +1355,7 @@ function FullscreenCall({
{others.map((p) => (
<div
key={p.id}
className="h-full w-[220px] shrink-0 [&>div]:h-full [&>div]:w-full"
className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full"
>
<TileRender
tile={p}
@@ -1281,24 +1374,86 @@ function FullscreenCall({
</div>
)}
</>
) : bentoMode ? (
<div className="flex min-h-0 flex-1 flex-col gap-2 p-4">
<div className="min-h-0 flex-1">
<div className={'grid h-full max-h-full gap-2 place-content-center ' + gridColsFor(bentoShares.length)}>
{bentoShares.map((s) => (
<div key={s.id} className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full">
<TileRender
tile={s}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(s.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
: {})}
/>
</div>
))}
</div>
</div>
{bentoWebcams.length > 0 && (
<div className="flex h-[180px] gap-2 overflow-x-auto">
{bentoWebcams.map((w) => (
<div key={w.id} className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full">
<TileRender
tile={w}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
size="small"
onClick={() => onFocusTile(w.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
: {})}
/>
</div>
))}
</div>
)}
</div>
) : (
<div className="min-h-0 flex-1 p-4">
<div className={'grid h-full gap-2 ' + gridClass}>
{visibleTiles.map((p) => (
<div key={p.id} className="min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full">
<TileRender
tile={p}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(p.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/>
</div>
))}
<div
className={
'grid h-full max-h-full gap-2 auto-rows-fr place-content-center ' +
gridClass
}
>
{visibleTiles.map((p) => {
const isSolo = visibleTiles.length === 1;
return (
<div
key={p.id}
// Same fix as docked equal-grid: aspect-video w-full on
// wide screens pushed cell height to ~ width × 9/16,
// which dragged the tile's bottom name-chip down behind
// the floating controls bar. For n>=2 fill the grid
// tracks normally; for solo, cap width + centre.
className={
isSolo
? 'aspect-video w-full max-w-[720px] justify-self-center [&>div]:h-full [&>div]:w-full'
: 'min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full'
}
>
<TileRender
tile={p}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(p.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/>
</div>
);
})}
</div>
{pageCount > 1 && (
<div className="mt-2 flex items-center justify-center gap-3 text-xs text-fg-muted">
@@ -1387,8 +1542,8 @@ function FullscreenCall({
function StripToggleIcon({ hidden }: { hidden: boolean }) {
return (
<svg
width="18"
height="18"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
+51
View File
@@ -0,0 +1,51 @@
import { useEffect } from 'react';
import { XIcon } from './icons';
// Fullscreen image viewer. Backdrop click + Esc close. Originally lived
// inside AttachmentImage.tsx as a file-private component; extracted here
// so other surfaces (settings avatar preview, future profile popover,
// etc.) can reuse the exact same dialog without duplicating the chrome.
//
// The image itself stops click propagation so a click on the picture
// keeps the lightbox open — only the backdrop or the explicit close
// button dismisses.
export function Lightbox({ url, onClose }: { url: string; onClose: () => void }) {
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prevOverflow;
};
}, [onClose]);
return (
<div
role="dialog"
aria-modal="true"
aria-label="Bildansicht"
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/90 p-6 backdrop-blur-sm animate-fade-in"
>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="absolute right-4 top-4 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-ink-900/80 text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<XIcon className="h-5 w-5" />
</button>
<img
src={url}
alt=""
onClick={(e) => e.stopPropagation()}
className="max-h-[92vh] max-w-[92vw] rounded-xl object-contain shadow-2xl"
/>
</div>
);
}
@@ -31,7 +31,7 @@ export function MediaFilesDrawer({ open, index, senderNameFor, onJumpToMessage,
if (!open) return null;
return (
<aside className="absolute inset-y-0 right-0 z-40 flex w-full max-w-[380px] flex-col border-l border-line bg-surface-2 shadow-2xl dark:bg-[#2b2d31]">
<aside className="flex w-[380px] shrink-0 flex-col border-l border-line bg-surface-2 dark:bg-[#2b2d31]">
<header className="flex min-h-[65px] items-center gap-3 border-b border-line px-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent/10 text-accent">
<ImageIcon className="h-5 w-5" />
@@ -1,8 +1,11 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { useNickname } from '../lib/friendNicknames';
import { Avatar } from './Avatar';
type Member = ConversationSummary['members'][number];
interface Props {
members: ConversationSummary['members'];
query: string;
@@ -63,37 +66,58 @@ export function MentionAutocomplete({ members, query, excludeUserId, onSelect, o
aria-label="Mitglieder"
className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-64 overflow-y-auto rounded-xl border border-line bg-surface-2 p-1 shadow-xl dark:bg-[#2b2d31]"
>
{matches.map((m, idx) => {
const name = m.profile?.displayName ?? m.profile?.username ?? '?';
const handle = m.profile?.username ?? '';
const isActive = idx === active;
return (
<button
key={m.userId}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => setActive(idx)}
onClick={() => {
if (m.profile?.username) onSelect(m.profile.username);
}}
className={
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
(isActive
? 'bg-accent/20 text-fg'
: 'text-fg-muted hover:bg-surface-3 dark:hover:bg-[#383a40]')
}
>
<Avatar
displayName={name}
url={m.profile?.avatarUrl ?? null}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
</button>
);
})}
{matches.map((m, idx) => (
<MemberRow
key={m.userId}
member={m}
isActive={idx === active}
onActivate={() => setActive(idx)}
onSelect={onSelect}
/>
))}
</div>
);
}
// Extracted per-row component so we can call `useNickname` once per member
// at the top of THIS component (hooks can't run inside .map callbacks).
function MemberRow({
member,
isActive,
onActivate,
onSelect,
}: {
member: Member;
isActive: boolean;
onActivate: () => void;
onSelect: (username: string) => void;
}) {
const fallback = member.profile?.displayName ?? member.profile?.username ?? '?';
const name = useNickname(member.userId, fallback);
const handle = member.profile?.username ?? '';
return (
<button
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={onActivate}
onClick={() => {
if (member.profile?.username) onSelect(member.profile.username);
}}
className={
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
(isActive
? 'bg-accent/20 text-fg'
: 'text-fg-muted hover:bg-surface-3 dark:hover:bg-[#383a40]')
}
>
<Avatar
displayName={name}
url={member.profile?.avatarUrl ?? null}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
</button>
);
}
+119 -16
View File
@@ -1,4 +1,3 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import {
type DecryptedMessage,
editEncryptedMessage,
@@ -11,8 +10,10 @@ import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { devLocalSecretStore } from '../lib/secretStore';
import { useNickname } from '../lib/friendNicknames';
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';
@@ -30,6 +31,7 @@ import {
PencilIcon,
PhoneIcon,
PhoneOffIcon,
PinIcon,
ReplyIcon,
SmileIcon,
SpinnerIcon,
@@ -75,6 +77,10 @@ interface Props {
onAvatarClick?: (userId: string, ev: React.MouseEvent) => void;
/** Highlighted state — set briefly after a jump. */
highlighted?: boolean;
/** True iff this message is currently pinned (parent maintains the set). */
isPinned?: boolean;
/** Right-click menu → toggle pin/unpin for this message id. */
onTogglePin?: (messageId: string) => void;
}
export function MessageBubble({
@@ -96,9 +102,17 @@ export function MessageBubble({
onForward,
onAvatarClick,
highlighted = false,
isPinned = false,
onTogglePin,
}: Props) {
const { t } = useTranslation(['app']);
const { session, device } = useAuth();
const { session } = useAuth();
// Per-viewer nickname override for the message sender. Fallback chain
// keeps the existing behavior when no nickname is set. Skipped for the
// quoted-sender snippet — that's a different user.
const senderName = useNickname(message.senderId, senderDisplayName ?? '');
const resolvedSenderDisplayName = senderName.length > 0 ? senderName : null;
const parsed = parseMessagePayload(message.plaintext);
const initialText = parsed.kind === 'text' ? parsed.text : '';
@@ -191,7 +205,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 +215,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 +239,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;
@@ -281,7 +295,7 @@ export function MessageBubble({
<AvatarSlot
show={isLastOfRun}
url={senderAvatarUrl ?? null}
displayName={senderDisplayName ?? null}
displayName={resolvedSenderDisplayName}
{...(onAvatarClick
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
: {})}
@@ -294,7 +308,14 @@ export function MessageBubble({
}
if (parsed.kind === 'call_event') {
return <CallEventRow parsed={parsed} mine={mine} time={time} />;
return (
<CallEventRow
parsed={parsed}
mine={mine}
time={time}
senderDisplayName={resolvedSenderDisplayName}
/>
);
}
return (
@@ -308,7 +329,7 @@ export function MessageBubble({
<AvatarSlot
show={isLastOfRun}
url={senderAvatarUrl ?? null}
displayName={senderDisplayName ?? null}
displayName={resolvedSenderDisplayName}
{...(onAvatarClick
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
: {})}
@@ -409,7 +430,44 @@ 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 === 'whiteboard' ? (
<div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<rect x="3" y="4" width="18" height="13" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-fg">Whiteboard</div>
<div className="text-xs text-fg-muted">Gemeinsames Zeichnen</div>
</div>
<button
type="button"
onClick={() => {
window.dispatchEvent(
new CustomEvent('chatapp:open-whiteboard', { detail: { id: parsed.whiteboardId } }),
);
}}
disabled={!parsed.whiteboardId}
className="cursor-pointer rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
>
Öffnen
</button>
</div>
) : parsed.kind === 'poll' ? (
<PollCard
question={parsed.question}
@@ -433,7 +491,7 @@ export function MessageBubble({
return <AttachmentAudio key={a.id} handle={a} />;
}
if (a.mimeType.startsWith('image/')) {
return <AttachmentImage key={a.id} handle={a} />;
return <AttachmentImage key={a.id} handle={a} mine={mine} />;
}
if (a.mimeType.startsWith('video/')) {
return <AttachmentVideo key={a.id} handle={a} />;
@@ -617,6 +675,15 @@ export function MessageBubble({
setContextMenu(null);
void handleDelete();
}}
isPinned={isPinned}
{...(onTogglePin
? {
onTogglePin: () => {
setContextMenu(null);
onTogglePin(message.id);
},
}
: {})}
/>
)}
</div>
@@ -714,6 +781,8 @@ function MessageContextMenu({
onEdit,
onCopy,
onDelete,
isPinned,
onTogglePin,
}: {
x: number;
y: number;
@@ -728,6 +797,8 @@ function MessageContextMenu({
onEdit: () => void;
onCopy: () => void;
onDelete: () => void;
isPinned?: boolean;
onTogglePin?: () => void;
}) {
useEffect(() => {
function onKey(e: KeyboardEvent) {
@@ -794,6 +865,13 @@ function MessageContextMenu({
tone="danger"
/>
)}
{onTogglePin && (
<MenuItem
label={isPinned ? 'Anheftung entfernen' : 'Anpinnen'}
icon={<PinIcon className="h-4 w-4" />}
onClick={onTogglePin}
/>
)}
</div>,
document.body,
);
@@ -866,10 +944,15 @@ function CallEventRow({
parsed,
mine,
time,
senderDisplayName,
}: {
parsed: { status: string; mediaKind: string; durationSec: number };
mine: boolean;
time: string;
/** Discord-parity: in group chats the system pill should say WHO
* started/missed the call. Null means we don't know (fall back to the
* legacy generic labels). */
senderDisplayName: string | null;
}) {
const { t } = useTranslation(['app']);
const status = parsed.status;
@@ -880,16 +963,36 @@ function CallEventRow({
? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200'
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200';
// For non-own events we prefer the name-aware label so group chats
// make it clear who triggered the call event. Own events stay generic
// ("Outgoing call" / "No answer") since the user already knows they
// were the initiator.
const hasName = !mine && !!senderDisplayName;
const label =
status === 'ended'
? mine
? t('app:chats.call_outgoing', { defaultValue: 'Outgoing call' })
: t('app:chats.call_incoming', { defaultValue: 'Incoming call' })
: hasName
? t('app:chats.call_started_by', {
name: senderDisplayName,
defaultValue: '{{name}} hat einen Anruf gestartet',
})
: t('app:chats.call_incoming', { defaultValue: 'Incoming call' })
: status === 'missed'
? mine
? t('app:chats.call_no_answer', { defaultValue: 'No answer' })
: t('app:chats.call_missed', { defaultValue: 'Missed call' })
: t('app:chats.call_declined', { defaultValue: 'Call declined' });
: hasName
? t('app:chats.call_missed_by', {
name: senderDisplayName,
defaultValue: 'Verpasster Anruf von {{name}}',
})
: t('app:chats.call_missed', { defaultValue: 'Missed call' })
: hasName
? t('app:chats.call_declined_by', {
name: senderDisplayName,
defaultValue: 'Anruf von {{name}} abgelehnt',
})
: t('app:chats.call_declined', { defaultValue: 'Call declined' });
const duration = parsed.durationSec > 0 ? formatDuration(parsed.durationSec) : null;
@@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from 'react';
import { getNickname, setNickname } from '../lib/friendNicknames';
interface Props {
open: boolean;
userId: string;
displayName: string;
onClose: () => void;
}
export function NicknameDialog({ open, userId, displayName, onClose }: Props) {
const [value, setValue] = useState('');
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!open) return;
setValue(getNickname(userId) ?? '');
setTimeout(() => inputRef.current?.focus(), 0);
}, [open, userId]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
const submit = (): void => {
setNickname(userId, value);
onClose();
};
return (
<div
role="dialog"
aria-modal="true"
aria-label="Spitzname setzen"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="w-full max-w-sm rounded-2xl border border-line bg-surface-2 p-5 shadow-xl"
>
<h3 className="font-display text-base font-semibold text-fg">Spitzname für {displayName}</h3>
<p className="mt-1 text-xs text-fg-muted">
Nur du siehst diesen Namen. Leer lassen = den richtigen Namen verwenden.
</p>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
maxLength={32}
placeholder={displayName}
className="mt-4 w-full rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg hover:bg-surface-2"
>
Abbrechen
</button>
<button
type="button"
onClick={submit}
className="cursor-pointer rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg hover:brightness-110"
>
Speichern
</button>
</div>
</div>
</div>
);
}
+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,68 @@
import type { PinnedMessage } from '@chat-app/shared/chat';
import { PinIcon, XIcon } from './icons';
interface Props {
open: boolean;
pins: PinnedMessage[];
onClose: () => void;
onJump: (messageId: string) => void;
onUnpin: (messageId: string) => void;
// Optional preview-renderer: parent resolves messageId → short text/snippet
// since the panel itself doesn't decrypt. If absent, the panel just shows
// the message-id stub.
renderPreview?: (messageId: string) => React.ReactNode;
}
export function PinnedMessagesPanel({ open, pins, onClose, onJump, onUnpin, renderPreview }: Props) {
if (!open) return null;
return (
<aside
role="complementary"
aria-label="Angepinnte Nachrichten"
className="absolute right-0 top-0 z-30 flex h-full w-80 flex-col border-l border-line bg-surface-2 shadow-xl"
>
<header className="flex items-center justify-between border-b border-line px-4 py-3">
<div className="flex items-center gap-2">
<PinIcon className="h-4 w-4 text-accent" />
<h3 className="text-sm font-semibold text-fg">Angepinnt · {pins.length}</h3>
</div>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
</header>
{pins.length === 0 ? (
<p className="p-6 text-center text-xs text-fg-muted">Noch nichts angepinnt.</p>
) : (
<ul className="flex-1 overflow-y-auto">
{pins.map((p) => (
<li key={p.messageId} className="border-b border-line/60 px-4 py-3">
<button
type="button"
onClick={() => onJump(p.messageId)}
className="block w-full cursor-pointer text-left text-sm text-fg hover:text-accent"
>
{renderPreview ? renderPreview(p.messageId) : <span className="font-mono text-xs">{p.messageId.slice(0, 8)}</span>}
</button>
<div className="mt-1 flex items-center justify-between text-[11px] text-fg-muted">
<time dateTime={p.pinnedAt}>{new Date(p.pinnedAt).toLocaleString()}</time>
<button
type="button"
onClick={() => onUnpin(p.messageId)}
className="cursor-pointer text-rose-400 hover:underline"
>
Anheftung entfernen
</button>
</div>
</li>
))}
</ul>
)}
</aside>
);
}
@@ -0,0 +1,23 @@
import { PinIcon } from './icons';
interface Props {
count: number;
onClick: () => void;
}
// Compact chip rendered in the conv header that opens the pinned panel.
// Renders nothing when count is 0 so a fresh conv shows no clutter.
export function PinnedMessagesPill({ count, onClick }: Props) {
if (count === 0) return null;
return (
<button
type="button"
onClick={onClick}
className="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-full border border-line bg-surface-3 px-2.5 text-xs font-medium text-fg-muted transition hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
title="Angepinnte Nachrichten anzeigen"
>
<PinIcon className="h-3 w-3 text-accent" />
<span>{count} angepinnt</span>
</button>
);
}
@@ -0,0 +1,41 @@
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
export function RemoteRevokedScreen() {
const { t } = useTranslation();
const { revokedRemotely, acknowledgeRevocation } = useAuth();
if (!revokedRemotely) return null;
return (
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="remote-revoked-title"
className="fixed inset-0 z-[1000] flex items-center justify-center bg-ink-950/95 p-6"
>
<div className="max-w-sm rounded-2xl border border-line bg-surface-2 p-6 text-center shadow-xl">
<h2
id="remote-revoked-title"
className="mb-2 font-display text-xl font-semibold text-fg"
>
{t('app:auth.revoked_title', { defaultValue: 'Du wurdest remote abgemeldet' })}
</h2>
<p className="mb-5 text-sm text-fg-muted">
{t('app:auth.revoked_body', {
defaultValue:
'Ein anderes deiner Geräte hat diesen Login beendet. Aus Sicherheitsgründen wurden alle lokalen Daten gelöscht.',
})}
</p>
<button
type="button"
onClick={acknowledgeRevocation}
className="inline-flex cursor-pointer items-center justify-center rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
{t('app:auth.revoked_acknowledge', { defaultValue: 'Verstanden' })}
</button>
</div>
</div>
);
}
@@ -26,13 +26,65 @@ const TABS = [
];
type TabId = (typeof TABS)[number]['id'];
const QUALITY_PILLS: { id: ScreenSharePreset; label: string }[] = [
// Resolution and framerate are picked independently. The preset table
// (screenShareSettings.ts) still provides the per-tier bitrate/dimension
// caps, so we map (res, fps) → existing preset and rely on
// `framerateOverride` for the non-default framerate combinations
// (e.g. 1440p · 30, 4K · 30).
type ResChoice = 'auto' | '720p' | '1080p' | '1440p' | '4k';
type FpsChoice = 30 | 60;
const RES_PILLS: { id: ResChoice; label: string }[] = [
{ id: 'auto', label: 'Auto' },
{ id: '720p60', label: '720p · 60' },
{ id: '1080p60', label: '1080p · 60' },
{ id: '1440p60', label: '1440p · 60' },
{ id: '720p', label: '720p' },
{ id: '1080p', label: '1080p' },
{ id: '1440p', label: '1440p' },
{ id: '4k', label: '4K' },
];
const FPS_PILLS: { id: FpsChoice; label: string }[] = [
{ id: 30, label: '30 fps' },
{ id: 60, label: '60 fps' },
];
function presetForResFps(res: ResChoice, fps: FpsChoice): ScreenSharePreset {
switch (res) {
case 'auto':
return 'auto';
case '720p':
return fps === 60 ? '720p60' : '720p30';
case '1080p':
return fps === 60 ? '1080p60' : '1080p30';
case '1440p':
return '1440p60';
case '4k':
return '4k60';
}
}
function decomposePreset(
p: ScreenSharePreset,
framerateOverride: number | null,
): { res: ResChoice; fps: FpsChoice } {
const fallback: FpsChoice = framerateOverride === 30 ? 30 : 60;
switch (p) {
case 'auto':
return { res: 'auto', fps: framerateOverride === 60 ? 60 : 30 };
case '720p30':
return { res: '720p', fps: 30 };
case '720p60':
return { res: '720p', fps: 60 };
case '1080p30':
return { res: '1080p', fps: 30 };
case '1080p60':
return { res: '1080p', fps: 60 };
case '1440p60':
return { res: '1440p', fps: fallback };
case '4k60':
return { res: '4k', fps: fallback };
}
}
const THUMBNAIL_REFRESH_MS = 3500;
export function ScreenSharePickerModal({ onClose }: Props) {
@@ -43,9 +95,14 @@ export function ScreenSharePickerModal({ onClose }: Props) {
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [preset, setPreset] = useState<ScreenSharePreset>(
() => getScreenShareSettings().preset,
);
const [res, setRes] = useState<ResChoice>(() => {
const s = getScreenShareSettings();
return decomposePreset(s.preset, s.framerateOverride).res;
});
const [fps, setFps] = useState<FpsChoice>(() => {
const s = getScreenShareSettings();
return decomposePreset(s.preset, s.framerateOverride).fps;
});
const [audio, setAudio] = useState<boolean>(
() => getScreenShareSettings().includeSystemAudio,
);
@@ -109,13 +166,14 @@ export function ScreenSharePickerModal({ onClose }: Props) {
setError(null);
setBusy(true);
try {
// Persist quality + audio toggle. Also force-clear any stale duck
// setting users may have inherited from earlier builds — the
// native loopback addon excludes the app's own audio at OS level
// now, so JS-side ducking (which muted the user's incoming peer
// audio) is no longer needed and was causing "I can't hear anyone".
const preset = presetForResFps(res, fps);
// Persist resolution + fps + audio. The duck flag is force-cleared
// because the native loopback addon now excludes the app's own audio
// at OS level; JS-side ducking (which also muted incoming peer audio)
// was causing "I can't hear anyone" on earlier builds.
updateScreenShareSettings({
preset,
framerateOverride: fps,
includeSystemAudio: audio,
duckRemoteAudioWhileSharing: false,
});
@@ -125,7 +183,7 @@ export function ScreenSharePickerModal({ onClose }: Props) {
await startScreenShare({
preset,
displaySurface: tab === 'screen' ? 'monitor' : 'window',
framerate: null,
framerate: fps,
// Forward the picked source id so the native loopback path can
// switch into INCLUDE_TARGET_PROCESS_TREE for window-shares
// (parses HWND from `window:<HWND>:0`). For screen-shares this
@@ -271,16 +329,42 @@ export function ScreenSharePickerModal({ onClose }: Props) {
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
Qualität
Auflösung
</span>
<div className="flex gap-1">
{QUALITY_PILLS.map((q) => {
const active = preset === q.id;
{RES_PILLS.map((q) => {
const active = res === q.id;
return (
<button
key={q.id}
type="button"
onClick={() => setPreset(q.id)}
onClick={() => setRes(q.id)}
className={
'cursor-pointer rounded-md border px-2.5 py-1 text-[11px] font-medium transition focus:outline-none ' +
(active
? 'border-accent bg-accent/10 text-fg'
: 'border-line bg-surface-3 text-fg-muted hover:text-fg')
}
>
{q.label}
</button>
);
})}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
FPS
</span>
<div className="flex gap-1">
{FPS_PILLS.map((q) => {
const active = fps === q.id;
return (
<button
key={q.id}
type="button"
onClick={() => setFps(q.id)}
className={
'cursor-pointer rounded-md border px-2.5 py-1 text-[11px] font-medium transition focus:outline-none ' +
(active
@@ -9,6 +9,11 @@ interface ScreenShareViewerProps {
share: RemoteScreenShare;
avatarUrl: string | null;
displayName: string;
/** Suppress the in-share fullscreen toggle button. Used inside cinema
* mode where (a) the window is already OS-fullscreen and (b) the
* toggle collides visually with FullscreenCall's strip-hidden button
* at the same top-right corner. */
hideFullscreenToggle?: boolean;
}
// Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
@@ -21,6 +26,7 @@ export function ScreenShareViewer({
share,
avatarUrl,
displayName,
hideFullscreenToggle = false,
}: ScreenShareViewerProps) {
const { t } = useTranslation(['app']);
const videoRef = useRef<HTMLVideoElement | null>(null);
@@ -76,7 +82,7 @@ export function ScreenShareViewer({
defaultValue: displayName + ' teilt den Bildschirm',
})}
</span>
{watching && (
{watching && !hideFullscreenToggle && (
<button
type="button"
onClick={toggleFullscreen}
@@ -110,8 +116,7 @@ export function ScreenShareViewer({
type="button"
onClick={() => watchShare(share.participantId)}
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
style={{ aspectRatio: '16 / 9' }}
className="group relative block h-full w-full flex-1 cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
>
<BlurredTile avatarUrl={avatarUrl} letter={letter} />
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40">
@@ -0,0 +1,163 @@
import { useState } from 'react';
import { isWipeOnCloseEnabled, setWipeOnClose } from '../lib/memoryWipeSettings';
import {
changePin,
type LegacyMigrationReport,
regenerateRecoveryCode,
resetIdentity,
retryLegacyMigration,
} from '../lib/userIdentity';
import { PinInput } from './PinInput';
import { ShieldIcon, SpinnerIcon } from './icons';
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);
const [wipeOnClose, setWipeOnCloseState] = useState<boolean>(() => isWipeOnCloseEnabled());
async function handleRetryMigration() {
setBusy(true); setMsg(null); setMigration(null);
try {
const report = await retryLegacyMigration(userId);
setMigration(report);
} catch (err) {
setMsg(err instanceof Error ? err.message : String(err));
} finally { setBusy(false); }
}
async function handleChangePin() {
setBusy(true); setMsg(null);
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-fg-muted">
Cache beim Schließen leeren
</h3>
<p className="mb-2 text-xs text-fg-muted">
Beim Beenden der App werden alle entschlüsselten Caches gelöscht. Beim nächsten Start
musst du wieder deine PIN eingeben. Empfohlen für gemeinsam genutzte Rechner.
</p>
<label className="inline-flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={wipeOnClose}
onChange={(e) => {
setWipeOnClose(e.target.checked);
setWipeOnCloseState(e.target.checked);
}}
/>
<span>Aktivieren</span>
</label>
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-rose-400">Identität zurücksetzen</h3>
<p className="mb-2 text-xs text-fg-muted">Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.</p>
<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>
);
}
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { deleteSound as deleteRemoteSound } from '@chat-app/shared/chat';
import { getPttSettings } from '../lib/pttSettings';
import { codeToShortcut } from '../lib/globalShortcut';
import { invalidate as invalidateSoundCache, preload } from '../lib/soundboardPlayback';
@@ -15,6 +16,8 @@ import {
subscribeSoundboardChanges,
updateSound,
} from '../lib/soundboardStorage';
import { supabase } from '../lib/supabase';
import { useSoundboardSync, type SyncBadge } from '../hooks/useSoundboardSync';
import { Modal } from './Modal';
import {
AlertIcon,
@@ -46,6 +49,7 @@ export function SoundboardManagerDialog({ open, onClose }: Props) {
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [previewingId, setPreviewingId] = useState<string | null>(null);
const { badges } = useSoundboardSync();
const refresh = useCallback(async () => {
setLoading(true);
@@ -167,6 +171,11 @@ export function SoundboardManagerDialog({ open, onClose }: Props) {
}
setBusyId(id);
try {
try {
await deleteRemoteSound(supabase, id);
} catch (err) {
console.warn('remote sound delete failed (local delete proceeds)', err);
}
await deleteSound(id);
invalidateSoundCache(id);
if (previewingId === id) stopPreview();
@@ -301,6 +310,7 @@ export function SoundboardManagerDialog({ open, onClose }: Props) {
entriesTotal={entries}
busyId={busyId}
previewingId={previewingId}
badges={badges}
onPatch={handlePatch}
onDelete={handleDelete}
onPreview={handlePreview}
@@ -323,6 +333,7 @@ interface GroupProps {
entriesTotal: SoundboardEntry[];
busyId: string | null;
previewingId: string | null;
badges: Map<string, SyncBadge>;
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onPreview: (entry: SoundboardEntry) => Promise<void>;
@@ -335,6 +346,7 @@ function SoundboardCategoryGroup({
entriesTotal,
busyId,
previewingId,
badges,
onPatch,
onDelete,
onPreview,
@@ -370,6 +382,7 @@ function SoundboardCategoryGroup({
isLast={idx === entries.length - 1}
busy={busyId === entry.id}
previewing={previewingId === entry.id}
badge={badges.get(entry.id)}
onPatch={onPatch}
onDelete={onDelete}
onPreview={onPreview}
@@ -392,6 +405,7 @@ interface RowProps {
isLast: boolean;
busy: boolean;
previewing: boolean;
badge: SyncBadge | undefined;
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onPreview: (entry: SoundboardEntry) => Promise<void>;
@@ -406,6 +420,7 @@ function SoundboardRow({
isLast,
busy,
previewing,
badge,
onPatch,
onDelete,
onPreview,
@@ -503,6 +518,13 @@ function SoundboardRow({
)}
<p className="text-[10px] text-fg-muted">
{(entry.size / BYTES_PER_MB).toFixed(2)} MB · {entry.mime}
<span
title={badgeTitle(badge)}
aria-label={badgeTitle(badge)}
className="ml-2 inline-flex items-center text-[10px] font-medium text-fg-muted"
>
{badgeGlyph(badge)}
</span>
</p>
</div>
@@ -623,3 +645,25 @@ function SoundboardRow({
</li>
);
}
// ---------------------------------------------------------------------------
function badgeGlyph(b: SyncBadge | undefined): string {
switch (b) {
case 'uploading': return '↑';
case 'downloading': return '↓';
case 'error': return '⚠';
case 'synced':
default: return '☁';
}
}
function badgeTitle(b: SyncBadge | undefined): string {
switch (b) {
case 'uploading': return 'Hochladen…';
case 'downloading': return 'Wird heruntergeladen…';
case 'error': return 'Synchronisationsfehler';
case 'synced':
default: return 'Synchronisiert';
}
}
@@ -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>
);
}
@@ -0,0 +1,87 @@
import { useState } from 'react';
import { markAttachmentViewed } from '@chat-app/shared/chat';
import { supabase } from '../lib/supabase';
import { EyeOffIcon, LockIcon } from './icons';
interface Props {
attachmentId: string;
/** Already-viewed timestamp from the DB row. Renders tombstone immediately. */
viewedAt: string | null;
/** True iff the local user is the sender — they don't burn the view. */
isSender: boolean;
/** Decrypted image source; only fetched/displayed inside the lightbox. */
src: string;
}
// Three states:
// 1. viewedAt is null AND user is recipient → blurred lock card; tap opens
// fullscreen lightbox AND fires the mark-viewed RPC.
// 2. viewedAt is set → tombstone "Angesehen am …".
// 3. user is sender → normal image, tombstone update appears once recipient burns it.
export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) {
const [revealedAt, setRevealedAt] = useState<string | null>(viewedAt);
const [fullscreen, setFullscreen] = useState(false);
const burned = revealedAt !== null;
if (burned && !isSender) {
return (
<div className="flex h-32 w-48 items-center justify-center rounded-lg border border-dashed border-line bg-surface-3 text-xs text-fg-muted">
<EyeOffIcon className="mr-2 h-4 w-4" />
Angesehen am {new Date(revealedAt).toLocaleString()}
</div>
);
}
if (isSender) {
return (
<div className="relative">
<img src={src} alt="" className="max-h-72 rounded-lg" />
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-black/60 px-2 py-0.5 text-[10px] font-semibold text-white">
<EyeOffIcon className="h-3 w-3" /> Einmal ansehen
</span>
{burned && (
<span className="absolute right-2 bottom-2 rounded-full bg-emerald-500/80 px-2 py-0.5 text-[10px] font-semibold text-white">
Angesehen
</span>
)}
</div>
);
}
// Recipient, not yet viewed.
const handleOpen = async (): Promise<void> => {
try {
const res = await markAttachmentViewed(supabase, attachmentId);
if (res.viewedAt) setRevealedAt(res.viewedAt);
} catch (err) {
console.warn('mark-viewed failed', err);
}
setFullscreen(true);
};
return (
<>
<button
type="button"
onClick={() => void handleOpen()}
className="relative flex h-48 w-64 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-3 text-fg-muted hover:border-accent/40"
>
<LockIcon className="h-6 w-6 text-accent" />
<span className="text-xs font-medium">Einmal ansehen antippen</span>
</button>
{fullscreen && (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-6"
onClick={() => setFullscreen(false)}
>
<img src={src} alt="" className="max-h-full max-w-full rounded-lg" />
</div>
)}
</>
);
}
@@ -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>
);
}
@@ -0,0 +1,142 @@
import { useEffect, useRef, useState } from 'react';
import type { WhiteboardStroke } from '@chat-app/shared/chat';
export type WhiteboardTool = 'pen' | 'eraser';
export type WhiteboardColor = '#000000' | '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7';
export type WhiteboardWidth = 2 | 4 | 8;
export interface WhiteboardStrokePayload {
tool: WhiteboardTool;
color: WhiteboardColor;
width: WhiteboardWidth;
// [x, y, t-ms-since-stroke-start]
points: Array<[number, number, number]>;
}
interface Props {
strokes: WhiteboardStroke[];
tool: WhiteboardTool;
color: WhiteboardColor;
width: WhiteboardWidth;
onStroke: (payload: WhiteboardStrokePayload) => void;
logicalWidth?: number;
logicalHeight?: number;
}
const DEFAULT_LOGICAL_W = 1280;
const DEFAULT_LOGICAL_H = 720;
export function WhiteboardCanvas({
strokes,
tool,
color,
width,
onStroke,
logicalWidth = DEFAULT_LOGICAL_W,
logicalHeight = DEFAULT_LOGICAL_H,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const draftRef = useRef<WhiteboardStrokePayload | null>(null);
const strokeStartRef = useRef<number>(0);
const [, forceTick] = useState(0);
useEffect(() => {
const cv = canvasRef.current;
if (!cv) return;
cv.width = logicalWidth;
cv.height = logicalHeight;
const ctx = cv.getContext('2d');
if (!ctx) return;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, cv.width, cv.height);
for (const s of strokes) {
const payload = s.strokeJson as Partial<WhiteboardStrokePayload> | null;
if (payload) renderStroke(ctx, payload);
}
if (draftRef.current) renderStroke(ctx, draftRef.current);
});
function canvasPoint(e: React.PointerEvent<HTMLCanvasElement>): [number, number] {
const cv = canvasRef.current!;
const rect = cv.getBoundingClientRect();
const scaleX = cv.width / rect.width;
const scaleY = cv.height / rect.height;
return [(e.clientX - rect.left) * scaleX, (e.clientY - rect.top) * scaleY];
}
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
const cv = canvasRef.current;
if (!cv) return;
cv.setPointerCapture(e.pointerId);
const [x, y] = canvasPoint(e);
strokeStartRef.current = Date.now();
draftRef.current = {
tool,
color,
width,
points: [[x, y, 0]],
};
forceTick((n) => n + 1);
};
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!draftRef.current) return;
const [x, y] = canvasPoint(e);
draftRef.current.points.push([x, y, Date.now() - strokeStartRef.current]);
forceTick((n) => n + 1);
};
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
const cv = canvasRef.current;
if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId);
const draft = draftRef.current;
draftRef.current = null;
if (!draft) return;
if (draft.points.length < 2) {
forceTick((n) => n + 1);
return;
}
onStroke(draft);
forceTick((n) => n + 1);
};
return (
<canvas
ref={canvasRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
className="block max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-white shadow-2xl"
style={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
/>
);
}
function renderStroke(
ctx: CanvasRenderingContext2D,
s: Partial<WhiteboardStrokePayload>,
): void {
const points = Array.isArray(s.points) ? s.points : null;
if (!points || points.length < 1) return;
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = typeof s.width === 'number' ? s.width : 4;
if (s.tool === 'eraser') {
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = Math.max(8, (typeof s.width === 'number' ? s.width : 4) * 4);
} else {
ctx.strokeStyle = typeof s.color === 'string' ? s.color : '#000000';
}
ctx.beginPath();
ctx.moveTo(points[0]![0], points[0]![1]);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i]![0], points[i]![1]);
}
ctx.stroke();
ctx.restore();
}
@@ -0,0 +1,197 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useWhiteboardStrokes } from '../hooks/useWhiteboardStrokes';
import { XIcon } from './icons';
import {
WhiteboardCanvas,
type WhiteboardColor,
type WhiteboardTool,
type WhiteboardWidth,
} from './WhiteboardCanvas';
interface Props {
whiteboardId: string;
onClose: () => void;
}
const COLORS: WhiteboardColor[] = ['#000000', '#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7'];
const WIDTHS: WhiteboardWidth[] = [2, 4, 8];
export function WhiteboardModal({ whiteboardId, onClose }: Props) {
const { t } = useTranslation();
const { strokes, loading, error, insertStroke, clearAll } = useWhiteboardStrokes(whiteboardId);
const [tool, setTool] = useState<WhiteboardTool>('pen');
const [color, setColor] = useState<WhiteboardColor>('#000000');
const [width, setWidth] = useState<WhiteboardWidth>(4);
const [confirmClear, setConfirmClear] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const handleConfirmClear = async () => {
setConfirmClear(false);
try {
await clearAll();
} catch (err) {
console.error('clearAll failed', err);
}
};
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('app:whiteboard.title', { defaultValue: 'Whiteboard' })}
className="fixed inset-0 z-[80] flex flex-col bg-ink-950/95"
>
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
<h2 className="font-display text-sm font-semibold text-fg">
{t('app:whiteboard.title', { defaultValue: 'Whiteboard' })}
</h2>
<button
type="button"
onClick={onClose}
aria-label={t('app:whiteboard.close', { defaultValue: 'Schließen' })}
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
>
<XIcon className="h-3.5 w-3.5" />
</button>
</header>
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden p-6">
{loading ? (
<p className="text-sm text-fg-muted">
{t('app:whiteboard.loading', { defaultValue: 'Lädt…' })}
</p>
) : error ? (
<p className="text-sm text-rose-400">
{t('app:whiteboard.error', { defaultValue: 'Whiteboard konnte nicht geladen werden.' })}
</p>
) : (
<WhiteboardCanvas
strokes={strokes}
tool={tool}
color={color}
width={width}
onStroke={(payload) => void insertStroke(payload)}
/>
)}
</div>
<footer className="flex shrink-0 flex-wrap items-center gap-4 border-t border-line/40 bg-surface-2 px-4 py-3">
<div className="flex items-center gap-1">
{(['pen', 'eraser'] as WhiteboardTool[]).map((id) => {
const label = id === 'pen' ? 'Stift' : 'Radierer';
const active = tool === id;
return (
<button
key={id}
type="button"
onClick={() => setTool(id)}
aria-pressed={active}
title={label}
className={
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-xs font-semibold transition ' +
(active
? 'bg-accent/20 text-accent ring-2 ring-accent/40'
: 'bg-surface-3 text-fg-muted hover:bg-surface hover:text-fg')
}
>
{id === 'pen' ? '✎' : '⌫'}
</button>
);
})}
</div>
<div className="h-6 w-px bg-line/40" aria-hidden />
<div className="flex items-center gap-1">
{COLORS.map((c) => {
const active = color === c;
return (
<button
key={c}
type="button"
onClick={() => setColor(c)}
aria-pressed={active}
aria-label={c}
className={
'h-6 w-6 cursor-pointer rounded-full border-2 transition ' +
(active ? 'border-fg scale-110' : 'border-line/40 hover:scale-105')
}
style={{ backgroundColor: c }}
/>
);
})}
</div>
<div className="h-6 w-px bg-line/40" aria-hidden />
<div className="flex items-center gap-1">
{WIDTHS.map((w) => {
const active = width === w;
return (
<button
key={w}
type="button"
onClick={() => setWidth(w)}
aria-pressed={active}
title={w + 'px'}
className={
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md transition ' +
(active
? 'bg-accent/20 ring-2 ring-accent/40'
: 'bg-surface-3 hover:bg-surface')
}
>
<div
className="rounded-full bg-fg"
style={{ width: w + 2 + 'px', height: w + 2 + 'px' }}
/>
</button>
);
})}
</div>
<div className="ml-auto flex items-center gap-2">
{confirmClear ? (
<>
<span className="text-xs text-fg-muted">
{t('app:whiteboard.confirm_clear', { defaultValue: 'Alles löschen?' })}
</span>
<button
type="button"
onClick={() => setConfirmClear(false)}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface"
>
{t('app:whiteboard.cancel', { defaultValue: 'Abbrechen' })}
</button>
<button
type="button"
onClick={() => void handleConfirmClear()}
className="cursor-pointer rounded-md bg-rose-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-rose-500/90"
>
{t('app:whiteboard.confirm', { defaultValue: 'Ja, löschen' })}
</button>
</>
) : (
<button
type="button"
onClick={() => setConfirmClear(true)}
disabled={strokes.length === 0}
className="cursor-pointer rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
>
{t('app:whiteboard.clear_all', { defaultValue: 'Alles löschen' })}
</button>
)}
</div>
</footer>
</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 />;
}
@@ -0,0 +1,126 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useOwnDevices } from '../../hooks/useOwnDevices';
import { getDeviceRowId } from '../../lib/deviceRowId';
import { SpinnerIcon } from '../icons';
function formatLastSeen(iso: string, locale: string): string {
try {
const d = new Date(iso);
return d.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' });
} catch {
return iso;
}
}
export function DeviceListTab() {
const { t, i18n } = useTranslation();
const { devices, loading, error, revoke } = useOwnDevices();
const ownId = getDeviceRowId();
const [busy, setBusy] = useState<string | null>(null);
const [opError, setOpError] = useState<string | null>(null);
const handleRevoke = async (id: string) => {
setOpError(null);
setBusy(id);
try {
await revoke(id);
} catch (err) {
setOpError(err instanceof Error ? err.message : 'revoke failed');
} finally {
setBusy(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-10 text-fg-muted">
<SpinnerIcon className="h-5 w-5" />
</div>
);
}
if (error) {
return (
<p className="rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-3 text-sm text-rose-600 dark:text-rose-300">
{t('app:settings.devices.error', { defaultValue: 'Geräteliste konnte nicht geladen werden.' })}
</p>
);
}
if (devices.length === 0) {
return (
<p className="text-sm text-fg-muted">
{t('app:settings.devices.empty', { defaultValue: 'Noch keine Geräte angemeldet.' })}
</p>
);
}
return (
<div className="space-y-3">
{opError && (
<p className="rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2 text-sm text-rose-600 dark:text-rose-300">
{opError}
</p>
)}
<ul className="space-y-2">
{devices.map((d) => {
const isOwn = d.id === ownId;
const isRevoked = d.revokedAt !== null;
return (
<li
key={d.id}
className="flex items-center gap-3 rounded-lg border border-line bg-surface-2 px-4 py-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-fg">{d.name}</span>
{isOwn && (
<span className="rounded-md bg-accent/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
{t('app:settings.devices.this_device', { defaultValue: 'Dieses Gerät' })}
</span>
)}
{isRevoked && (
<span className="rounded-md bg-rose-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-rose-600 dark:text-rose-300">
{t('app:settings.devices.revoked', { defaultValue: 'Abgemeldet' })}
</span>
)}
</div>
<p className="mt-0.5 text-xs text-fg-muted">
{d.platform} · {t('app:settings.devices.last_seen', { defaultValue: 'zuletzt' })}{' '}
{formatLastSeen(d.lastSeenAt, i18n.language)}
</p>
</div>
{isOwn ? (
<button
type="button"
disabled
title={t('app:settings.devices.use_sign_out', { defaultValue: 'Nutze Sign-out' })}
className="cursor-not-allowed rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted opacity-60"
>
{t('app:settings.devices.use_sign_out', { defaultValue: 'Nutze Sign-out' })}
</button>
) : isRevoked ? (
<span className="text-xs text-fg-muted">
{t('app:settings.devices.already_revoked', { defaultValue: '—' })}
</span>
) : (
<button
type="button"
onClick={() => void handleRevoke(d.id)}
disabled={busy === d.id}
className="cursor-pointer rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-wait disabled:opacity-60 dark:text-rose-300"
>
{busy === d.id
? t('app:settings.devices.revoking', { defaultValue: 'Wird abgemeldet…' })
: t('app:settings.devices.revoke', { defaultValue: 'Abmelden' })}
</button>
)}
</li>
);
})}
</ul>
</div>
);
}
+207 -61
View File
@@ -1,12 +1,15 @@
import {
type DeviceRecord,
fetchUserKeyBlob,
getOwnProfile,
listOwnDevices,
registerDevice,
signOut as supabaseSignOut,
type Profile,
touchDeviceLastSeen,
type Profile,
updateOwnProfile,
} from '@chat-app/shared/auth';
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
import type { DevicePlatform } from '@chat-app/shared/supabase';
import type { Session } from '@supabase/supabase-js';
import {
createContext,
@@ -20,24 +23,40 @@ import {
} from 'react';
import { useTranslation } from 'react-i18next';
import { findExistingDevice } from '../lib/device';
import { PRESENCE_HEARTBEAT_MS } from '../lib/presence';
import { clearDeviceRowId, getDeviceRowId, setDeviceRowId } from '../lib/deviceRowId';
import { ensureInstallId } from '../lib/installId';
import { wipeLocalState } from '../lib/memoryWipe';
import { isWipeOnCloseEnabled } from '../lib/memoryWipeSettings';
import { setSecretStoreUser } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import { cachedUserKey, 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>;
revokedRemotely: boolean;
acknowledgeRevocation: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
@@ -47,8 +66,8 @@ 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 [revokedRemotely, setRevokedRemotely] = useState(false);
const autoOnlineUserRef = useRef<string | null>(null);
// Initial session + auth subscription. We verify the cached JWT against the
@@ -112,41 +131,142 @@ 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]);
// Phase 3: ensure this install owns exactly one devices row. The row is
// pure session-list telemetry — it does not carry any cryptographic
// material since the per-user-key refactor. We re-use the row across
// restarts via localStorage (chatapp.deviceRowId.v1); a memory-wipe is
// intentionally treated as "new install".
useEffect(() => {
if (!session) return;
let cancelled = false;
void (async () => {
try {
const existing = getDeviceRowId();
if (existing) {
const rows = await listOwnDevices(supabase);
const match = rows.find((r) => r.id === existing && r.revokedAt === null);
if (match) {
await touchDeviceLastSeen(supabase, existing).catch(() => {});
return;
}
// Row gone / revoked: drop the stale id and fall through to
// registering a fresh one.
clearDeviceRowId();
}
if (cancelled) return;
const hostname =
(typeof window.electronAPI?.getHostname === 'function'
? await window.electronAPI.getHostname().catch(() => null)
: null) ?? 'Desktop';
// Map Node's process.platform values to the schema's device_platform
// enum ('windows' | 'macos' | 'linux' | 'ios' | 'android'). Default
// to 'linux' for unknown/web-build cases — the field is only used
// for the session-list UI icon.
const osPlatform = window.electronAPI?.osPlatform;
const platform: DevicePlatform =
osPlatform === 'win32'
? 'windows'
: osPlatform === 'darwin'
? 'macos'
: 'linux';
const created = await registerDevice(supabase, {
name: hostname.slice(0, 64),
platform,
});
if (!cancelled) setDeviceRowId(created.id);
} catch (err) {
console.warn('ensure device row failed', err);
}
})();
return () => {
cancelled = true;
};
}, [session]);
// Wipe-on-close: register a handler the main process pings on `before-quit`
// when the user has enabled the Settings → Sicherheit toggle. No-op outside
// Electron (web build has no preload bridge) or when the toggle is off.
useEffect(() => {
if (typeof window.electronAPI?.onWipeBeforeQuit !== 'function') return;
const unsub = window.electronAPI.onWipeBeforeQuit(async () => {
if (!isWipeOnCloseEnabled()) return;
await wipeLocalState(session?.user.id ?? null);
});
return unsub;
}, [session?.user.id]);
// Auto online/offline transition.
//
@@ -188,52 +308,78 @@ 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 () => {
const uid = session?.user.id ?? null;
await updateOwnProfile(supabase, { presenceState: 'offline' }).catch((err: unknown) => {
console.warn('offline update before sign-out failed', err);
});
await supabaseSignOut(supabase);
await wipeLocalState(uid);
}, [session]);
const acknowledgeRevocation = useCallback(() => {
setRevokedRemotely(false);
}, []);
// Phase 3: listen for own-device revocations. The same channel also fires
// when *another* of the user's installs is revoked — we ignore those (we
// only force-sign-out when OUR row's revoked_at flips). The UI's device
// list refetches independently via its own subscription in useOwnDevices.
useEffect(() => {
if (!session) return;
const userId = session.user.id;
const channel = supabase
.channel('devices:self:' + userId)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'devices',
filter: 'user_id=eq.' + userId,
},
(payload) => {
const ownId = getDeviceRowId();
const row = payload.new as { id?: string; revoked_at?: string | null } | null;
if (!row || !ownId) return;
if (row.id !== ownId) return;
if (row.revoked_at) {
setRevokedRemotely(true);
void signOut().catch((err) => {
console.warn('forced signOut after revoke failed', err);
});
}
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [session, signOut]);
const value = useMemo<AuthContextValue>(
() => ({
session,
profile,
device,
userKeyState,
ready,
deviceLookupDone,
refreshProfile,
refreshDevice,
setDevice,
refreshUserKeyState,
signOut,
revokedRemotely,
acknowledgeRevocation,
}),
[session, profile, device, ready, deviceLookupDone, refreshProfile, refreshDevice, signOut],
[
session,
profile,
userKeyState,
ready,
refreshProfile,
refreshUserKeyState,
signOut,
revokedRemotely,
acknowledgeRevocation,
],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+31 -24
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(() => {
@@ -1446,20 +1442,29 @@ export function CallProvider({ children }: { children: ReactNode }) {
// the same system audio twice and Chromium's version would
// include our own renderer playback.
audio: preferNativeLoopback ? false : wantAudio,
// Preset dims are passed as `ideal` constraints (not fixed width/height)
// so Chromium picks the closest match preserving the source's aspect
// ratio instead of CROPPING non-matching monitors. Without this, a 16:10
// monitor (1920×1200, 2560×1600) streamed under a 16:9 preset loses its
// bottom strip — including the Windows taskbar.
//
// LiveKit's VideoResolution types declare width/height as `number`, but
// Chromium's getDisplayMedia accepts the full MediaTrackConstraints shape
// including { ideal: N } objects — we cast through `unknown` to satisfy tsc.
...(ssParams.dims
? {
resolution: {
width: ssParams.dims.width,
height: ssParams.dims.height,
width: { ideal: ssParams.dims.width },
height: { ideal: ssParams.dims.height },
frameRate: fps,
},
} as unknown as { width: number; height: number; frameRate: number },
}
: {
resolution: {
width: 3840,
height: 2160,
frameRate: fps,
},
width: undefined,
height: undefined,
} as unknown as { width: number; height: number; frameRate: number },
}),
...(displaySurface
? ({ displaySurface } as { displaySurface: DisplaySurfaceHint })
@@ -2008,14 +2013,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
const syncGlobalShortcuts = () => {
if (!isTauriRuntime()) return;
// Only register an OS-level shortcut if the user explicitly opted in.
// Window-scoped firing happens via the `onKey` listener above and works
// for every enabled binding regardless of the `global` flag.
const enabledAndGlobal = (b: VoiceHotkeys[HotkeyKind]) => b.enabled && b.global;
const desired: Record<HotkeyKind, string | null> = {
mute: settings.mute.enabled ? bindingToTauriShortcut(settings.mute) : null,
deafen: settings.deafen.enabled ? bindingToTauriShortcut(settings.deafen) : null,
hangup: settings.hangup.enabled ? bindingToTauriShortcut(settings.hangup) : null,
screenShare: settings.screenShare.enabled
? bindingToTauriShortcut(settings.screenShare)
: null,
video: settings.video.enabled ? bindingToTauriShortcut(settings.video) : null,
mute: enabledAndGlobal(settings.mute) ? bindingToTauriShortcut(settings.mute) : null,
deafen: enabledAndGlobal(settings.deafen) ? bindingToTauriShortcut(settings.deafen) : null,
hangup: enabledAndGlobal(settings.hangup) ? bindingToTauriShortcut(settings.hangup) : null,
screenShare: enabledAndGlobal(settings.screenShare) ? bindingToTauriShortcut(settings.screenShare) : null,
video: enabledAndGlobal(settings.video) ? bindingToTauriShortcut(settings.video) : null,
};
for (const kind of KINDS) {
const want = desired[kind];
+74
View File
@@ -0,0 +1,74 @@
import { useCallback, useEffect, useState } from 'react';
import { type DeviceRecord, listOwnDevices, revokeDevice } from '@chat-app/shared/auth';
import { useAuth } from '../context/AuthContext';
import { supabase } from '../lib/supabase';
interface State {
devices: DeviceRecord[];
loading: boolean;
error: string | null;
}
export function useOwnDevices(): {
devices: DeviceRecord[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
revoke: (deviceId: string) => Promise<void>;
} {
const { session } = useAuth();
const userId = session?.user.id ?? null;
const [state, setState] = useState<State>({ devices: [], loading: true, error: null });
const refresh = useCallback(async () => {
try {
setState((s) => ({ ...s, loading: true, error: null }));
const list = await listOwnDevices(supabase);
setState({ devices: list, loading: false, error: null });
} catch (err) {
setState((s) => ({
...s,
loading: false,
error: err instanceof Error ? err.message : 'failed to load devices',
}));
}
}, []);
useEffect(() => {
if (!userId) {
setState({ devices: [], loading: false, error: null });
return;
}
void refresh();
const channel = supabase
.channel('devices:list:' + userId)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'devices',
filter: 'user_id=eq.' + userId,
},
() => {
void refresh();
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [userId, refresh]);
const revoke = useCallback(
async (deviceId: string) => {
await revokeDevice(supabase, deviceId);
await refresh();
},
[refresh],
);
return { devices: state.devices, loading: state.loading, error: state.error, refresh, revoke };
}
+276
View File
@@ -0,0 +1,276 @@
import { useEffect, useRef, useState } from 'react';
import { getCryptoBackend } from '@chat-app/shared/crypto';
import {
decryptSoundEnvelope,
downloadSoundCiphertext,
encryptSoundBlob,
listOwnSounds,
type RemoteSound,
upsertSound,
uploadSoundCiphertext,
} from '@chat-app/shared/chat';
import { useAuth } from '../context/AuthContext';
import {
deleteRawStoredSound,
getRawStoredSound,
listSounds,
putRawStoredSound,
type SoundboardEntry,
subscribeSoundboardChanges,
} from '../lib/soundboardStorage';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
export type SyncBadge = 'synced' | 'uploading' | 'downloading' | 'error';
const PUSH_DEBOUNCE_MS = 500;
const DELETE_GRACE_MS = 5000;
function storagePathFor(userId: string, soundId: string): string {
return userId + '/' + soundId + '.bin';
}
export function useSoundboardSync(): {
badges: Map<string, SyncBadge>;
initialPullDone: boolean;
} {
const { session } = useAuth();
const userId = session?.user.id ?? null;
const [badges, setBadges] = useState<Map<string, SyncBadge>>(new Map());
const [initialPullDone, setInitialPullDone] = useState(false);
const debounceRef = useRef<number | null>(null);
function setBadge(id: string, badge: SyncBadge): void {
setBadges((cur) => {
const next = new Map(cur);
next.set(id, badge);
return next;
});
}
useEffect(() => {
if (!userId) {
setInitialPullDone(false);
setBadges(new Map());
return;
}
let cancelled = false;
let teardown: (() => void) | null = null;
const init = async () => {
const priv = await cachedUserKey(userId);
if (!priv) return;
const pub = getCryptoBackend().scalarMultBase(priv);
try {
await runDiff(userId, priv, pub);
} catch (err) {
console.error('soundboard initial diff failed', err);
}
if (cancelled) return;
setInitialPullDone(true);
const unsubLocal = subscribeSoundboardChanges(() => {
if (debounceRef.current !== null) window.clearTimeout(debounceRef.current);
debounceRef.current = window.setTimeout(() => {
debounceRef.current = null;
void runDiff(userId, priv, pub).catch((err) => {
console.error('soundboard push diff failed', err);
});
}, PUSH_DEBOUNCE_MS);
});
const channel = supabase
.channel('soundboards:' + userId)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'user_soundboards',
filter: 'user_id=eq.' + userId,
},
() => {
void runDiff(userId, priv, pub).catch((err) => {
console.error('soundboard realtime pull failed', err);
});
},
)
.subscribe();
teardown = () => {
unsubLocal();
void supabase.removeChannel(channel);
if (debounceRef.current !== null) {
window.clearTimeout(debounceRef.current);
debounceRef.current = null;
}
};
};
void init();
return () => {
cancelled = true;
teardown?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]);
async function runDiff(uid: string, priv: Uint8Array, pub: Uint8Array): Promise<void> {
const [localList, remoteList] = await Promise.all([
listSounds(),
listOwnSounds(supabase),
]);
const remoteById = new Map<string, RemoteSound>();
for (const r of remoteList) remoteById.set(r.id, r);
const localById = new Map<string, SoundboardEntry>();
for (const l of localList) localById.set(l.id, l);
// Push pass
for (const local of localList) {
const remote = remoteById.get(local.id);
const localIso = new Date(local.updatedAt).toISOString();
if (!remote) {
await uploadAndUpsert(local, uid, priv, pub, localIso);
} else {
const remoteMs = Date.parse(remote.updatedAt);
if (local.updatedAt > remoteMs) {
await upsertMetadataOnly(local, remote, localIso);
}
}
}
// Pull pass
for (const remote of remoteList) {
const local = localById.get(remote.id);
const remoteMs = Date.parse(remote.updatedAt);
if (!local) {
await pullAndStore(remote, priv, pub);
} else if (remoteMs > local.updatedAt) {
const stored = await getRawStoredSound(remote.id);
if (stored) {
await putRawStoredSound({
...stored,
name: remote.name,
mime: remote.mime,
size: remote.size,
category: remote.category,
hotkey: remote.hotkey,
gain: remote.gain,
order: remote.sortOrder,
updatedAt: remoteMs,
});
setBadge(remote.id, 'synced');
}
} else {
setBadge(remote.id, 'synced');
}
}
// Remote-absence → local delete (with grace window for fresh adds).
const now = Date.now();
for (const local of localList) {
if (!remoteById.has(local.id) && now - local.updatedAt > DELETE_GRACE_MS) {
await deleteRawStoredSound(local.id);
}
}
}
async function uploadAndUpsert(
local: SoundboardEntry,
uid: string,
priv: Uint8Array,
pub: Uint8Array,
localIso: string,
): Promise<void> {
setBadge(local.id, 'uploading');
try {
const stored = await getRawStoredSound(local.id);
if (!stored) return;
const ciphertext = await encryptSoundBlob(stored.blob, pub, priv);
const path = storagePathFor(uid, local.id);
await uploadSoundCiphertext(supabase, path, ciphertext);
await upsertSound(supabase, {
id: local.id,
name: local.name,
mime: local.mime,
size: local.size,
category: local.category,
hotkey: local.hotkey,
gain: local.gain,
sortOrder: local.order,
storagePath: path,
updatedAtIso: localIso,
});
setBadge(local.id, 'synced');
} catch (err) {
console.error('soundboard upload failed', err);
setBadge(local.id, 'error');
}
}
async function upsertMetadataOnly(
local: SoundboardEntry,
remote: RemoteSound,
localIso: string,
): Promise<void> {
setBadge(local.id, 'uploading');
try {
await upsertSound(supabase, {
id: local.id,
name: local.name,
mime: local.mime,
size: local.size,
category: local.category,
hotkey: local.hotkey,
gain: local.gain,
sortOrder: local.order,
storagePath: remote.storagePath,
updatedAtIso: localIso,
});
setBadge(local.id, 'synced');
} catch (err) {
console.error('soundboard metadata upload failed', err);
setBadge(local.id, 'error');
}
}
async function pullAndStore(
remote: RemoteSound,
priv: Uint8Array,
pub: Uint8Array,
): Promise<void> {
setBadge(remote.id, 'downloading');
try {
const envelope = await downloadSoundCiphertext(supabase, remote.storagePath);
const plain = await decryptSoundEnvelope(envelope, pub, priv);
// Copy into a fresh ArrayBuffer so Blob accepts it across TS lib variants
// (mirrors the pattern in @chat-app/shared/chat/attachments.ts).
const copy = new Uint8Array(plain.byteLength);
copy.set(plain);
const blob = new Blob([copy.buffer], { type: remote.mime });
const ms = Date.parse(remote.updatedAt);
await putRawStoredSound({
id: remote.id,
name: remote.name,
mime: remote.mime,
size: remote.size,
category: remote.category,
hotkey: remote.hotkey,
gain: remote.gain,
order: remote.sortOrder,
createdAt: Date.parse(remote.createdAt),
updatedAt: ms,
blob,
});
setBadge(remote.id, 'synced');
} catch (err) {
console.error('soundboard pull failed', err);
setBadge(remote.id, 'error');
}
}
return { badges, initialPullDone };
}
@@ -0,0 +1,130 @@
import { useCallback, useEffect, useState } from 'react';
import {
clearWhiteboardStrokes,
insertWhiteboardStroke,
listWhiteboardStrokes,
type WhiteboardStroke,
} from '@chat-app/shared/chat';
import { supabase } from '../lib/supabase';
interface State {
strokes: WhiteboardStroke[];
loading: boolean;
error: string | null;
}
export function useWhiteboardStrokes(whiteboardId: string | null): {
strokes: WhiteboardStroke[];
loading: boolean;
error: string | null;
insertStroke: (strokeJson: unknown) => Promise<void>;
clearAll: () => Promise<void>;
} {
const [state, setState] = useState<State>({ strokes: [], loading: true, error: null });
useEffect(() => {
if (!whiteboardId) {
setState({ strokes: [], loading: false, error: null });
return;
}
let cancelled = false;
void (async () => {
try {
setState((s) => ({ ...s, loading: true, error: null }));
const list = await listWhiteboardStrokes(supabase, whiteboardId);
if (!cancelled) setState({ strokes: list, loading: false, error: null });
} catch (err) {
if (!cancelled) {
setState({
strokes: [],
loading: false,
error: err instanceof Error ? err.message : 'failed to load strokes',
});
}
}
})();
const channel = supabase
.channel('whiteboard:' + whiteboardId)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'whiteboard_strokes',
filter: 'whiteboard_id=eq.' + whiteboardId,
},
(payload) => {
const row = payload.new as {
id?: string;
whiteboard_id?: string;
author_user_id?: string;
stroke_json?: unknown;
created_at?: string;
} | null;
if (!row?.id || !row.whiteboard_id || !row.author_user_id || !row.created_at) return;
const next: WhiteboardStroke = {
id: row.id,
whiteboardId: row.whiteboard_id,
authorUserId: row.author_user_id,
strokeJson: row.stroke_json,
createdAt: row.created_at,
};
setState((s) => {
if (s.strokes.some((x) => x.id === next.id)) return s;
return { ...s, strokes: [...s.strokes, next] };
});
},
)
.on(
'postgres_changes',
{
event: 'DELETE',
schema: 'public',
table: 'whiteboard_strokes',
filter: 'whiteboard_id=eq.' + whiteboardId,
},
() => {
// Bulk delete via "Clear all" — drop everything; future inserts
// come back via the INSERT branch above.
setState((s) => ({ ...s, strokes: [] }));
},
)
.subscribe();
return () => {
cancelled = true;
void supabase.removeChannel(channel);
};
}, [whiteboardId]);
const insertStroke = useCallback(
async (strokeJson: unknown) => {
if (!whiteboardId) return;
try {
await insertWhiteboardStroke(supabase, { whiteboardId, strokeJson });
// No optimistic append — realtime echoes the row back in <150ms.
} catch (err) {
console.error('insertWhiteboardStroke failed', err);
setState((s) => ({
...s,
error: err instanceof Error ? err.message : 'stroke insert failed',
}));
}
},
[whiteboardId],
);
const clearAll = useCallback(async () => {
if (!whiteboardId) return;
await clearWhiteboardStrokes(supabase, whiteboardId);
}, [whiteboardId]);
return {
strokes: state.strokes,
loading: state.loading,
error: state.error,
insertStroke,
clearAll,
};
}
@@ -4,6 +4,7 @@ import {
type AttachmentHandle,
type DecryptedMessage,
type PollOption,
type WhiteboardPayload,
} from '@chat-app/shared/chat';
export type AttachmentBucket = 'media' | 'audio' | 'files';
@@ -97,6 +98,15 @@ export function createPollPayload(question: string, optionTexts: string[]): stri
});
}
export function createWhiteboardPayload(whiteboardId: string): string {
const payload: WhiteboardPayload = {
v: 1,
type: 'whiteboard',
whiteboard_id: whiteboardId,
};
return serializeMessagePayload(payload);
}
export function summarizePollVotes(
options: PollOption[],
reactions: ReactionSummaryInput[],
+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 });
}
}
}
+8
View File
@@ -25,5 +25,13 @@ export async function createLibsodiumBackend(): Promise<CryptoBackend> {
s.crypto_box_open_easy(ciphertext, nonce, senderPublicKey, recipientPrivateKey),
secretbox: (plaintext, nonce, key) => s.crypto_secretbox_easy(plaintext, nonce, key),
secretboxOpen: (ciphertext, nonce, key) => s.crypto_secretbox_open_easy(ciphertext, nonce, key),
pwhashConsts: {
OPSLIMIT_MODERATE: s.crypto_pwhash_OPSLIMIT_MODERATE,
MEMLIMIT_MODERATE: s.crypto_pwhash_MEMLIMIT_MODERATE,
ALG_ARGON2ID13: s.crypto_pwhash_ALG_ARGON2ID13,
},
pwhash: (outLen, password, salt, opslimit, memlimit, alg) =>
s.crypto_pwhash(outLen, password, salt, opslimit, memlimit, alg),
scalarMultBase: (priv) => s.crypto_scalarmult_base(priv),
};
}
-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('');
}
+30
View File
@@ -0,0 +1,30 @@
// localStorage key for "the devices.id row that belongs to THIS install".
// Reset on memory-wipe (NOT preserved) — a wiped install is conceptually a
// fresh install, so registering a new row is correct.
const KEY = 'chatapp.deviceRowId.v1';
export function getDeviceRowId(): string | null {
try {
const v = window.localStorage.getItem(KEY);
return v && v.length > 0 ? v : null;
} catch {
return null;
}
}
export function setDeviceRowId(id: string): void {
try {
window.localStorage.setItem(KEY, id);
} catch {
/* quota */
}
}
export function clearDeviceRowId(): void {
try {
window.localStorage.removeItem(KEY);
} catch {
/* no-op */
}
}
+64
View File
@@ -0,0 +1,64 @@
import { useSyncExternalStore } from 'react';
// Local-only friend nickname overrides. Stored in localStorage keyed by the
// peer's user-id. Empty/missing value = use the real display name.
// Local-only by design — friends never see your nickname for them.
const STORAGE_KEY = 'chatapp.friendNicknames.v1';
let cache: Record<string, string> | null = null;
const listeners = new Set<() => void>();
function load(): Record<string, string> {
if (cache) return cache;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) { cache = {}; return cache; }
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === 'object') {
cache = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string' && v.trim().length > 0) cache[k] = v;
}
return cache;
}
} catch { /* corrupted; fall through */ }
cache = {};
return cache;
}
function persist(): void {
try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cache ?? {})); }
catch { /* quota / private mode */ }
for (const l of listeners) l();
}
export function getNickname(userId: string): string | null {
return load()[userId] ?? null;
}
export function setNickname(userId: string, nickname: string | null): void {
const store = load();
const trimmed = nickname?.trim() ?? '';
if (trimmed.length === 0) {
if (!(userId in store)) return;
delete store[userId];
} else {
if (store[userId] === trimmed) return;
store[userId] = trimmed;
}
persist();
}
// Reactive hook: returns the current nickname for a user, or `fallback`
// when no nickname is set. Re-renders when ANY nickname changes (cheap,
// the set is small).
export function useNickname(userId: string | null | undefined, fallback: string): string {
const subscribe = (cb: () => void) => {
listeners.add(cb);
return () => { listeners.delete(cb); };
};
const getSnapshot = () => (userId ? getNickname(userId) : null);
const nickname = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return nickname ?? fallback;
}
+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;
}
+55
View File
@@ -0,0 +1,55 @@
import { clearConvKeyCache } from '@chat-app/shared/chat';
import { devLocalSecretStore } from './secretStore';
// Aggressively scrub local crypto + chat caches on sign-out (and on
// app-close if the user opted in). Preserves things that aren't sensitive
// and would be annoying to lose (theme, locale, install-id).
//
// We can't enumerate IndexedDB names without async + the indexedDB API,
// so we list the ones we know about explicitly. Adding a new local store
// later? Append to LOCAL_DBS.
const LOCAL_DBS = ['soundboard', 'message-cache', 'chatapp-attachments'];
const PRESERVE_LOCAL_STORAGE = new Set([
'chatapp.theme',
'chatapp.locale',
'chatapp.installId',
'chatapp.wipeOnClose.v1',
'i18nextLng',
]);
export async function wipeLocalState(userId: string | null): Promise<void> {
// 1. Per-conversation key cache (in-memory).
try { clearConvKeyCache(); } catch { /* never throws but be defensive */ }
// 2. Stronghold / secret-store: drop the user-priv blob for this user.
if (userId) {
try { await devLocalSecretStore.removeSecret('chatapp.userpriv.' + userId); }
catch (err) { console.warn('[wipe] userpriv remove failed', err); }
}
// 3. localStorage — preserve only the explicit whitelist.
try {
const keysToDrop: string[] = [];
for (let i = 0; i < window.localStorage.length; i++) {
const k = window.localStorage.key(i);
if (k && !PRESERVE_LOCAL_STORAGE.has(k)) keysToDrop.push(k);
}
for (const k of keysToDrop) window.localStorage.removeItem(k);
} catch (err) { console.warn('[wipe] localStorage clear failed', err); }
// 4. sessionStorage — always full.
try { window.sessionStorage.clear(); } catch { /* ignored */ }
// 5. IndexedDB — delete known databases. Resolves even when blocked so
// we don't hang sign-out forever.
await Promise.allSettled(LOCAL_DBS.map((name) => new Promise<void>((resolve) => {
try {
const req = window.indexedDB.deleteDatabase(name);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
} catch { resolve(); }
})));
}
@@ -0,0 +1,24 @@
// Persisted toggle for the "wipe local caches on app close" feature.
// Read by SecurityCenter (UI) and AuthContext (renderer-side IPC handler).
//
// Stored in localStorage so the preference survives sessions without
// touching the secret-store or SQLite. The key is on the preserve
// whitelist in lib/memoryWipe.ts so a sign-out wipe (or a closing wipe)
// doesn't blow away the user's own preference.
const KEY = 'chatapp.wipeOnClose.v1';
export function isWipeOnCloseEnabled(): boolean {
try {
return window.localStorage.getItem(KEY) === '1';
} catch {
return false;
}
}
export function setWipeOnClose(enabled: boolean): void {
try {
window.localStorage.setItem(KEY, enabled ? '1' : '0');
} catch {
/* ignored */
}
}
+49
View File
@@ -369,3 +369,52 @@ export async function isHotkeyTaken(
}
return false;
}
// --- Sync-engine bypass helpers ------------------------------------------
// Used by useSoundboardSync to write/delete IndexedDB rows without firing
// notifyChange — pulls and remote-driven deletes are not "edits". If they
// triggered notifyChange the engine would loop:
// push debounce → upsert → realtime → pull → notifyChange → push debounce → ...
export async function putRawStoredSound(stored: {
id: string;
name: string;
mime: string;
size: number;
category: string | null;
hotkey: string | null;
gain: number;
order: number;
createdAt: number;
updatedAt: number;
blob: Blob;
}): Promise<void> {
await tx(SOUNDS_STORE, 'readwrite', (s) => s.put(stored));
}
export async function deleteRawStoredSound(id: string): Promise<void> {
await tx(SOUNDS_STORE, 'readwrite', (s) => s.delete(id));
}
export async function getRawStoredSound(id: string): Promise<{
id: string;
name: string;
mime: string;
size: number;
category: string | null;
hotkey: string | null;
gain: number;
order: number;
createdAt: number;
updatedAt: number;
blob: Blob;
} | null> {
const db = await openDb();
return new Promise((resolve, reject) => {
const t = db.transaction(SOUNDS_STORE, 'readonly');
const s = t.objectStore(SOUNDS_STORE);
const req = s.get(id);
req.onsuccess = () => resolve((req.result as any) ?? null);
req.onerror = () => reject(req.error);
});
}
+115
View File
@@ -0,0 +1,115 @@
// GIPHY v1 client — Google closed Tenor to new API clients in Jan 2026, so
// the picker uses GIPHY's Developer API instead. Provision a free key at
// https://developers.giphy.com/dashboard/ and expose it via the renderer
// env var VITE_GIPHY_API_KEY (e.g. in apps/desktop/.env.local). When unset,
// every call throws — the picker degrades to a friendly
// "GIFs sind nicht konfiguriert" message via the consumer's catch.
//
// The file is still named `tenor.ts` for import-path stability; rename later
// if it bothers anyone.
const ENDPOINT = 'https://api.giphy.com/v1/gifs';
const RECENT_KEY = 'chatapp.gifRecent.v1';
const RECENT_MAX = 24;
const API_KEY = (
(import.meta as unknown as { env?: { VITE_GIPHY_API_KEY?: string } }).env
?.VITE_GIPHY_API_KEY ?? ''
).trim();
export interface GifResult {
id: string;
// Animated full-size URL (typically <2 MB).
url: string;
// Small preview shown in the picker grid.
previewUrl: string;
width: number;
height: number;
description: string;
}
interface GiphyImage {
url?: string;
width?: string;
height?: string;
}
interface GiphyResult {
id: string;
title?: string;
images?: {
original?: GiphyImage;
fixed_height?: GiphyImage;
fixed_height_small?: GiphyImage;
preview_gif?: GiphyImage;
};
}
function mapResult(r: GiphyResult): GifResult | null {
const full = r.images?.original ?? r.images?.fixed_height;
const preview =
r.images?.fixed_height_small ?? r.images?.preview_gif ?? r.images?.fixed_height ?? full;
if (!full?.url || !preview?.url) return null;
return {
id: r.id,
url: full.url,
previewUrl: preview.url,
width: full.width ? Number(full.width) : 0,
height: full.height ? Number(full.height) : 0,
description: r.title ?? '',
};
}
async function fetchGiphy(path: string, params: Record<string, string>): Promise<GifResult[]> {
if (!API_KEY) {
throw new Error('giphy_api_key_missing');
}
const search = new URLSearchParams({ api_key: API_KEY, rating: 'pg-13', ...params });
const res = await fetch(`${ENDPOINT}/${path}?${search.toString()}`);
if (!res.ok) throw new Error('giphy http ' + res.status);
const json = (await res.json()) as { data?: GiphyResult[] };
return (json.data ?? []).map(mapResult).filter((x): x is GifResult => x !== null);
}
export function isGifProviderConfigured(): boolean {
return API_KEY.length > 0;
}
// Back-compat alias for the original Tenor function name. New code should
// use isGifProviderConfigured.
export const isTenorConfigured = isGifProviderConfigured;
export async function searchGifs(query: string, locale: string = 'de'): Promise<GifResult[]> {
if (!query.trim()) return featuredGifs(locale);
return fetchGiphy('search', { q: query, limit: '30', lang: locale });
}
export async function featuredGifs(_locale: string = 'de'): Promise<GifResult[]> {
return fetchGiphy('trending', { limit: '30' });
}
export function getRecentGifs(): GifResult[] {
try {
const raw = window.localStorage.getItem(RECENT_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(x): x is GifResult =>
x != null &&
typeof x === 'object' &&
typeof (x as GifResult).url === 'string',
);
} catch {
return [];
}
}
export function rememberRecentGif(gif: GifResult): void {
const current = getRecentGifs().filter((g) => g.id !== gif.id);
const next = [gif, ...current].slice(0, RECENT_MAX);
try {
window.localStorage.setItem(RECENT_KEY, JSON.stringify(next));
} catch {
/* quota */
}
}
+53 -4
View File
@@ -1,14 +1,63 @@
import { isTauriRuntime } from './globalShortcut';
// Pushes the current aggregate unread count to main, which updates the
// Tray tooltip and (on Windows) the taskbar overlay icon. No-op
// outside the Electron runtime (e.g. browser dev preview) so no guards
// needed at call-sites.
// Tray tooltip and (on Windows) the taskbar overlay icon. We render the
// badge here in the renderer because main has no Canvas2D — painting a
// red bubble with the count, encoding to PNG, and handing the buffer
// to main keeps the implementation free of a native canvas dependency.
//
// No-op outside the Electron runtime (e.g. browser dev preview) so no
// guards needed at call-sites.
export async function updateTrayUnread(count: number): Promise<void> {
if (!isTauriRuntime()) return;
const n = Math.max(0, Math.floor(count));
const badgeDataUrl = n > 0 ? renderBadgePng(n) : null;
try {
await window.electronAPI.setTrayUnread(Math.max(0, Math.floor(count)));
await window.electronAPI.setTrayUnread(n, badgeDataUrl);
} catch (err: unknown) {
console.warn('updateTrayUnread failed', err);
}
}
// Discord-style red bubble with white count.
//
// Source is rendered at 64×64 so Windows' high-quality downsample to the
// 16×16 taskbar overlay slot retains crisp edges (4× supersampling).
// Earlier 32×32 + 2px white ring blurred badly: the ring became a half
// pixel at the target size, and the bigger font fell into AA mush. Now
// no outer ring (Discord doesn't use one either) and a full-bleed circle.
function renderBadgePng(count: number): string | null {
if (typeof document === 'undefined') return null;
const size = 64;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// Solid red bubble, full bleed.
const center = size / 2;
ctx.beginPath();
ctx.arc(center, center, center, 0, Math.PI * 2);
ctx.fillStyle = '#ef4444';
ctx.fill();
// Count label — Discord parity: cap at 99 with a "+" once we cross it.
// Sizes are tuned per glyph count so each variant fills the bubble
// without clipping when Windows downsamples to 16×16.
const label = count > 99 ? '99+' : String(count);
const fontPx = label.length >= 3 ? 30 : label.length === 2 ? 40 : 48;
ctx.fillStyle = '#fff';
// Segoe UI is the Windows system font; explicit weight 800 keeps the
// glyph chunky after downsampling. Fallbacks cover macOS/Linux.
ctx.font = `800 ${fontPx}px "Segoe UI", system-ui, -apple-system, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// +2 vertical nudge: most system fonts render numerals visually high
// relative to the baseline mid-point; the offset re-centres them.
ctx.fillText(label, center, center + 2);
return canvas.toDataURL('image/png');
}
+121 -11
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[];
@@ -68,7 +70,12 @@ function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
}
export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & {
send: (text: string, images?: File[], replyToId?: string | null) => Promise<void>;
send: (
text: string,
images?: File[],
replyToId?: string | null,
opts?: { viewOnce?: boolean },
) => Promise<void>;
refresh: () => Promise<void>;
pending: OutboxItem[];
retryPending: (id: string) => void;
@@ -90,25 +97,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 +213,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
aeadBatchDelegate: decryptBatchWorker,
});
},
[deviceId],
[userId],
);
const refresh = useCallback(async () => {
@@ -467,7 +564,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
);
const send = useCallback(
async (text: string, images: File[] = [], replyToId: string | null = null) => {
async (
text: string,
images: File[] = [],
replyToId: string | null = null,
opts: { viewOnce?: boolean } = {},
) => {
const trimmed = text.trim();
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
const priv = privateKeyRef.current;
@@ -539,6 +641,14 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
...(dims.width !== undefined ? { width: dims.width } : {}),
...(dims.height !== undefined ? { height: dims.height } : {}),
});
// Stamp the view-once flag on each handle the caller requested it
// for. The flag rides inside the encrypted payload (so peers can
// render the locked card without leaking who-sent-what to the
// server) AND lands on the public message_attachments row via
// insertAttachmentRow below (where the mark-viewed RPC enforces it).
if (opts.viewOnce) {
res.handle.viewOnce = true;
}
handles.push(res.handle);
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce));
}
@@ -0,0 +1,45 @@
import { useEffect } from 'react';
import { notify } from './osNotify';
import { supabase } from './supabase';
interface MentionRow {
message_id: string;
mentioned_user_id: string;
conversation_id: string;
}
// Subscribes to my own message_mentions inserts and fires an OS notification
// for each one. Bypasses per-conv mute (mentions override mute by design).
//
// We don't decrypt the body here — the notification just says "Du wurdest
// erwähnt". The conv list highlight + the in-app navigation reveal context.
export function useMentionNotifications(userId: string | undefined): void {
useEffect(() => {
if (!userId) return;
const channel = supabase
.channel('mentions:' + userId)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'message_mentions',
filter: 'mentioned_user_id=eq.' + userId,
},
(payload) => {
const row = payload.new as MentionRow | null;
if (!row) return;
void notify({
title: 'Du wurdest erwähnt',
body: 'Tippe um die Nachricht zu lesen.',
force: true,
});
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [userId]);
}
+48
View File
@@ -0,0 +1,48 @@
import { listPinnedMessages, type PinnedMessage } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { supabase } from './supabase';
// Live list of pinned messages for one conversation. Subscribes to the
// `pinned_messages` realtime channel for the conv so the header pill +
// side-panel update without a refetch.
export function usePinnedMessages(conversationId: string | undefined): PinnedMessage[] {
const [pins, setPins] = useState<PinnedMessage[]>([]);
useEffect(() => {
if (!conversationId) {
setPins([]);
return;
}
let cancelled = false;
void listPinnedMessages(supabase, conversationId).then((rows) => {
if (!cancelled) setPins(rows);
});
const channel = supabase
.channel('pinned_messages:' + conversationId)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'pinned_messages',
filter: 'conversation_id=eq.' + conversationId,
},
() => {
void listPinnedMessages(supabase, conversationId).then((rows) => {
if (!cancelled) setPins(rows);
});
},
)
.subscribe();
return () => {
cancelled = true;
void supabase.removeChannel(channel);
};
}, [conversationId]);
return pins;
}
+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();
});
});
+256
View File
@@ -0,0 +1,256 @@
import {
fetchUserKeyBlob, listOwnDevices, recordPinAttempt,
resetUserKey, tryUnlockUserKey, uploadUserKeyBlob,
} from '@chat-app/shared/auth';
import {
generateRecoveryCode, generateUserKeyPair, getCryptoBackend, normalizeRecoveryCode,
openUserKey, sealUserKey,
} from '@chat-app/shared/crypto';
import { 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 = 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: 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: 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 = derivePublicKey(priv);
return runLegacyMigration(userId, priv, pub);
}
function derivePublicKey(privateKey: Uint8Array): Uint8Array {
return getCryptoBackend().scalarMultBase(privateKey);
}
+13 -40
View File
@@ -20,6 +20,13 @@ export interface VoiceHotkeyBinding {
shift: boolean;
alt: boolean;
enabled: boolean;
/**
* When true, the hotkey is registered as an OS-level shortcut and fires
* even when the app isn't focused. When false (default) the binding only
* fires from the window's keydown listener so e.g. setting "M" as mute
* doesn't break typing "m" everywhere else on the system.
*/
global: boolean;
}
export interface VoiceHotkeys {
@@ -36,46 +43,11 @@ export interface VoiceHotkeys {
export type VoiceHotkeyKind = keyof VoiceHotkeys;
const DEFAULTS: VoiceHotkeys = {
mute: {
key: 'KeyM',
keyLabel: 'Ctrl+Shift+M',
ctrl: true,
shift: true,
alt: false,
enabled: false,
},
deafen: {
key: 'KeyD',
keyLabel: 'Ctrl+Shift+D',
ctrl: true,
shift: true,
alt: false,
enabled: false,
},
hangup: {
key: 'KeyH',
keyLabel: 'Ctrl+Shift+H',
ctrl: true,
shift: true,
alt: false,
enabled: false,
},
screenShare: {
key: 'KeyE',
keyLabel: 'Ctrl+Shift+E',
ctrl: true,
shift: true,
alt: false,
enabled: false,
},
video: {
key: 'KeyV',
keyLabel: 'Ctrl+Shift+V',
ctrl: true,
shift: true,
alt: false,
enabled: false,
},
mute: { key: 'KeyM', keyLabel: 'Ctrl+Shift+M', ctrl: true, shift: true, alt: false, enabled: false, global: false },
deafen: { key: 'KeyD', keyLabel: 'Ctrl+Shift+D', ctrl: true, shift: true, alt: false, enabled: false, global: false },
hangup: { key: 'KeyH', keyLabel: 'Ctrl+Shift+H', ctrl: true, shift: true, alt: false, enabled: false, global: false },
screenShare: { key: 'KeyE', keyLabel: 'Ctrl+Shift+E', ctrl: true, shift: true, alt: false, enabled: false, global: false },
video: { key: 'KeyV', keyLabel: 'Ctrl+Shift+V', ctrl: true, shift: true, alt: false, enabled: false, global: false },
};
type Listener = (s: VoiceHotkeys) => void;
@@ -93,6 +65,7 @@ function validateBinding(raw: unknown, fallback: VoiceHotkeyBinding): VoiceHotke
shift: typeof b.shift === 'boolean' ? b.shift : fallback.shift,
alt: typeof b.alt === 'boolean' ? b.alt : fallback.alt,
enabled: typeof b.enabled === 'boolean' ? b.enabled : fallback.enabled,
global: typeof b.global === 'boolean' ? b.global : fallback.global,
};
}
+97 -2
View File
@@ -1,15 +1,33 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { fetchChangelog, type ChangelogEntry } from '../lib/changelog';
import { SparklesIcon, SpinnerIcon } from '../components/icons';
const PAGE_SIZE = 10;
// Installed app version comes from the preload bridge (process.env.npm_-
// package_version at preload build time). Falls back to '0.0.0' outside
// Electron so the page still renders in a browser preview.
const installedVersion = window.electronAPI?.appVersion ?? '0.0.0';
export function ChangelogPage() {
const [entries, setEntries] = useState<ChangelogEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [visible, setVisible] = useState(PAGE_SIZE);
// Compare the installed version against the top changelog entry. The
// server-side changelog is sorted newest-first by the release script, so
// entries[0] is always the published latest.
const latestVersion = entries?.[0]?.version ?? null;
const versionStatus = useMemo<'loading' | 'current' | 'outdated' | 'ahead'>(() => {
if (entries === null) return 'loading';
if (!latestVersion) return 'current';
const cmp = compareSemver(installedVersion, latestVersion);
if (cmp === 0) return 'current';
if (cmp < 0) return 'outdated';
return 'ahead';
}, [entries, latestVersion]);
useEffect(() => {
let cancelled = false;
void (async () => {
@@ -34,7 +52,7 @@ export function ChangelogPage() {
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent/15 text-accent">
<SparklesIcon className="h-5 w-5" />
</div>
<div>
<div className="flex-1">
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
Was ist neu
</h1>
@@ -42,6 +60,11 @@ export function ChangelogPage() {
Alle Änderungen in dieser App, neueste zuerst.
</p>
</div>
<VersionBadge
status={versionStatus}
installed={installedVersion}
latest={latestVersion}
/>
</header>
{entries === null && !error && (
@@ -118,3 +141,75 @@ function formatDate(iso: string): string {
return iso;
}
}
// Compact status chip in the header that tells the user whether their
// installed build matches the latest published version. Three visual
// tones: emerald (current), amber (outdated → update available), neutral
// (loading / unknown). The "ahead" case (dev build > released) shares the
// neutral tone since users running it always know what they're doing.
function VersionBadge({
status,
installed,
latest,
}: {
status: 'loading' | 'current' | 'outdated' | 'ahead';
installed: string;
latest: string | null;
}) {
if (status === 'loading') {
return (
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface-2 px-2.5 py-1 text-[11px] font-medium tabular-nums text-fg-muted">
v{installed}
</span>
);
}
if (status === 'current') {
return (
<span
title="Du läufst auf der neuesten Version."
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-emerald-500/40 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-semibold tabular-nums text-emerald-700 dark:text-emerald-300"
>
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
v{installed} · aktuell
</span>
);
}
if (status === 'outdated' && latest) {
return (
<span
title={`Update verfügbar — neueste Version: v${latest}.`}
className="inline-flex shrink-0 flex-col items-end gap-0.5 rounded-md border border-amber-400/50 bg-amber-400/10 px-2.5 py-1 text-[11px] font-semibold tabular-nums text-amber-800 dark:text-amber-200"
>
<span>v{installed} · Update verfügbar</span>
<span className="text-[10px] font-normal opacity-80">neueste: v{latest}</span>
</span>
);
}
return (
<span
title="Du läufst auf einer neueren Version als veröffentlicht (z.B. Dev-Build)."
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface-2 px-2.5 py-1 text-[11px] font-medium tabular-nums text-fg-muted"
>
v{installed}
</span>
);
}
// Lightweight semver comparator: parses major.minor.patch as ints and
// compares numerically. Returns negative if a < b, zero if equal, positive
// if a > b. Handles malformed inputs by treating non-numeric segments as
// 0 so a typo doesn't flag a perfectly current install as outdated.
function compareSemver(a: string, b: string): number {
const parse = (s: string): [number, number, number] => {
const parts = s.split('.').map((p) => {
const n = parseInt(p, 10);
return Number.isFinite(n) ? n : 0;
});
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
};
const [aMaj, aMin, aPat] = parse(a);
const [bMaj, bMin, bPat] = parse(b);
if (aMaj !== bMaj) return aMaj - bMaj;
if (aMin !== bMin) return aMin - bMin;
return aPat - bPat;
}
+24 -22
View File
@@ -2,10 +2,11 @@ import { acceptDm, type ConversationSummary, isConversationMuted } from '@chat-a
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, Outlet, useParams } from 'react-router-dom';
import { NavLink, Outlet, useNavigate, useParams } from 'react-router-dom';
import { ConversationRowMenu } from '../components/ConversationRowMenu';
import { CreateGroupDialog } from '../components/CreateGroupDialog';
import { EmptyState } from '../components/EmptyState';
import {
AddUserIcon,
ArchiveIcon,
@@ -119,6 +120,7 @@ function ConversationList({
archivedCount,
}: ConversationListProps) {
const { t } = useTranslation(['app']);
const navigate = useNavigate();
return (
<aside
@@ -223,27 +225,27 @@ function ConversationList({
) : error ? (
<p className="px-4 py-2 text-xs text-rose-500 dark:text-rose-300">{error}</p>
) : items.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-line bg-surface-3 text-accent">
{showArchived ? (
<ArchiveIcon className="h-5 w-5" />
) : (
<ChatBubbleIcon className="h-5 w-5" />
)}
</div>
<p className="text-sm text-fg-muted">
{showArchived
? t('app:chats.archived_empty_title', { defaultValue: 'Nichts archiviert' })
: t('app:chats.empty_title')}
</p>
<p className="text-xs text-fg-muted/80">
{showArchived
? t('app:chats.archived_empty_subtitle', {
defaultValue: 'Archivierte Unterhaltungen erscheinen hier.',
})
: t('app:chats.empty_subtitle')}
</p>
</div>
showArchived ? (
<EmptyState
icon={<ArchiveIcon className="h-8 w-8" />}
title={t('app:chats.archived_empty_title', { defaultValue: 'Nichts archiviert' })}
description={t('app:chats.archived_empty_subtitle', {
defaultValue: 'Archivierte Unterhaltungen erscheinen hier.',
})}
/>
) : (
<EmptyState
icon={<ChatBubbleIcon className="h-8 w-8" />}
title={t('app:chats.empty_title', { defaultValue: 'Noch keine Chats' })}
description={t('app:chats.empty_desc', {
defaultValue: 'Lade einen Freund ein und schreibe die erste Nachricht.',
})}
action={{
label: t('app:chats.empty_cta', { defaultValue: 'Freunde verwalten' }),
onClick: () => navigate('/friends'),
}}
/>
)
) : (
<VirtualConversationList
items={items}
+310 -41
View File
@@ -1,32 +1,39 @@
import { parseMessagePayload } from '@chat-app/shared/chat';
import { parseMessagePayload, pinMessage, unpinMessage } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { ConversationHeader } from '../components/ConversationHeader';
import { EmojiPicker } from '../components/EmojiPicker';
import { EmptyState } from '../components/EmptyState';
import { ForwardDialog } from '../components/ForwardDialog';
import { GifPicker } from '../components/GifPicker';
import { GroupInfoPanel } from '../components/GroupInfoPanel';
import {
AlertIcon,
ArrowRightIcon,
ChevronDownIcon,
ChevronUpIcon,
EyeOffIcon,
PencilIcon,
PlusIcon,
PollIcon,
ReplyIcon,
SearchIcon,
SendIcon,
SmileIcon,
SpinnerIcon,
XIcon,
} from '../components/icons';
import { ImageAnnotator } from '../components/ImageAnnotator';
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';
import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel';
import { PollComposerDialog } from '../components/PollComposerDialog';
import { UserProfilePopover } from '../components/UserProfilePopover';
import { TypingIndicator } from '../components/TypingIndicator';
@@ -35,9 +42,18 @@ import type { DecryptedMessage } from '@chat-app/shared/chat';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { collectConversationAttachments, createPollPayload } from '../lib/conversationFeatures';
import {
collectConversationAttachments,
createPollPayload,
createWhiteboardPayload,
} from '../lib/conversationFeatures';
import { WhiteboardModal } from '../components/WhiteboardModal';
import { createWhiteboard } from '@chat-app/shared/chat';
import { compressImages } from '../lib/imageCompress';
import { ensureInstallId } from '../lib/installId';
import { searchCachedMessages } from '../lib/messageCache';
import { supabase } from '../lib/supabase';
import { type GifResult } from '../lib/tenor';
import type { OutboxItem } from '../lib/messageOutbox';
import { useConversationMessages } from '../lib/useConversationMessages';
import { useMessageReactions } from '../lib/useMessageReactions';
@@ -45,14 +61,24 @@ import { useGroupReceipts } from '../lib/useGroupReceipts';
import { markDelivered, useMessageDeliveries } from '../lib/useMessageDeliveries';
import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useMessageReads';
import { usePeerPresence } from '../lib/usePeerPresence';
import { usePinnedMessages } from '../lib/usePinnedMessages';
import { useTypingChannel } from '../lib/useTypingChannel';
const STICK_THRESHOLD = 80;
// Per-conversation scroll memory. Module-scoped so it survives re-mounts
// of ConversationPage when the route param (`id`) changes — switching
// chats unmounts/remounts the page in our router setup. Session-only
// (lost on reload, like Discord). The `stickToBottom` flag is preserved
// alongside the pixel offset so a chat the user left at the bottom keeps
// auto-following new messages when they return; a chat scrolled up
// returns to the exact spot the user was reading.
const scrollPositions = new Map<string, { scrollTop: number; stickToBottom: boolean }>();
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(
@@ -66,7 +92,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 {
@@ -136,10 +162,13 @@ export function ConversationPage() {
const [sendError, setSendError] = useState<string | null>(null);
const [stickToBottom, setStickToBottom] = useState(true);
const [attachments, setAttachments] = useState<File[]>([]);
const [annotatingIndex, setAnnotatingIndex] = useState<number | null>(null);
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false);
const [pollDialogOpen, setPollDialogOpen] = useState(false);
const [pollSending, setPollSending] = useState(false);
const [openWhiteboardId, setOpenWhiteboardId] = useState<string | null>(null);
const [creatingWhiteboard, setCreatingWhiteboard] = useState(false);
const [pollError, setPollError] = useState<string | null>(null);
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
const [forwardTarget, setForwardTarget] = useState<DecryptedMessage | null>(null);
@@ -165,6 +194,47 @@ export function ConversationPage() {
} | null>(null);
const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null);
const [emojiOpen, setEmojiOpen] = useState(false);
const [gifPickerOpen, setGifPickerOpen] = useState(false);
// Sticky toggle: when on, the next image(s) sent are marked view-once.
// Auto-clears on a successful send so the composer doesn't accidentally
// burn the message-after-next.
const [viewOnceNext, setViewOnceNext] = useState(false);
const pins = usePinnedMessages(id);
const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false);
const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]);
const handleTogglePin = useCallback(
async (messageId: string) => {
if (!id || !myId) return;
try {
if (pinnedIds.has(messageId)) {
await unpinMessage(supabase, id, messageId);
} else {
await pinMessage(supabase, id, messageId, myId);
}
} catch (err) {
console.warn('pin toggle failed', err);
}
},
[id, myId, pinnedIds],
);
const handleGifPick = useCallback(
async (gif: GifResult) => {
try {
// Download the GIF bytes once and feed them into the existing
// image-attachment pipeline so the result is end-to-end-encrypted
// like any normal image.
const res = await fetch(gif.url);
const blob = await res.blob();
const file = new File([blob], `tenor-${gif.id}.gif`, { type: 'image/gif' });
await send('', [file], null);
} catch (err) {
console.warn('GIF send failed', err);
}
},
[send],
);
const scrollRef = useRef<HTMLDivElement>(null);
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -393,21 +463,73 @@ export function ConversationPage() {
};
}, [id, setActiveConversation]);
useEffect(() => {
const onOpen = (e: Event) => {
const detail = (e as CustomEvent<{ id?: string }>).detail;
if (detail?.id) setOpenWhiteboardId(detail.id);
};
window.addEventListener('chatapp:open-whiteboard', onOpen);
return () => window.removeEventListener('chatapp:open-whiteboard', onOpen);
}, []);
useEffect(() => {
if (id && messages.length > 0) markRead(id);
}, [id, messages.length, markRead]);
useEffect(() => {
// useLayoutEffect: run synchronously after DOM commit, before the
// browser paints. Using useEffect here let one frame of "scrollTop = 0
// (top of list)" paint between message-list mount and the auto-scroll,
// which is exactly the "flickers to a different position, then jumps"
// glitch users saw when re-entering a chat. Layout-effect fires while
// the message list is in the DOM but before paint, so the first frame
// already shows the correct scroll position.
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el || !stickToBottom) return;
el.scrollTop = el.scrollHeight;
}, [messages.length, stickToBottom]);
useEffect(() => {
setStickToBottom(true);
// Restore saved scroll position once the conversation's messages have
// actually rendered. The earlier version fired on `[id]` alone and ran
// before the message list populated — scrollHeight was still tiny, so
// `el.scrollTop = saved.scrollTop` got clamped to 0 by the browser
// and the user landed at the top instead of the saved position. By
// waiting for `messages.length > 0` we know the rendered scrollHeight
// is meaningful. `restoredForRef` ensures the restore runs at most
// once per chat switch (subsequent message arrivals don't re-trigger).
const restoredForRef = useRef<string | null>(null);
const isRestoringRef = useRef(false);
// useLayoutEffect, same reason as above: writing scrollTop here happens
// before the first paint of the freshly-mounted chat, so the user
// doesn't see a frame at scrollTop=0 before the jump to the saved
// position. Combined with the messages.length gate this means the
// re-entry shows the message list AT the saved scroll location in one
// single paint — no "loaded then jumped" effect.
useLayoutEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [id]);
if (!el || !id) return;
if (restoredForRef.current === id) return;
// Wait for the conversation's messages to populate; for a chat that
// truly has zero messages the bottom and the top are the same anyway.
if (messages.length === 0) return;
restoredForRef.current = id;
const saved = scrollPositions.get(id);
// Suppress handleScroll's persistence during the programmatic scroll
// below — otherwise the browser's clamp/normalisation could write a
// different scrollTop back into the Map and lose the saved position.
isRestoringRef.current = true;
if (saved && !saved.stickToBottom) {
el.scrollTop = saved.scrollTop;
setStickToBottom(false);
} else {
setStickToBottom(true);
el.scrollTop = el.scrollHeight;
}
requestAnimationFrame(() => {
isRestoringRef.current = false;
});
}, [id, messages.length]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
@@ -416,7 +538,14 @@ export function ConversationPage() {
const nextStick = distanceFromBottom < STICK_THRESHOLD;
setStickToBottom(nextStick);
if (nextStick) setNewMessagesWhileAway(0);
}, []);
// Persist position per chat so re-entering this conversation lands
// where the user left off (see scrollPositions module-level Map).
// Skipped during the in-flight restore so we don't immediately
// overwrite the saved position with a clamped value.
if (id && !isRestoringRef.current) {
scrollPositions.set(id, { scrollTop: el.scrollTop, stickToBottom: nextStick });
}
}, [id]);
const jumpToBottom = useCallback(() => {
const el = scrollRef.current;
@@ -432,10 +561,13 @@ export function ConversationPage() {
setSending(true);
setSendError(null);
try {
await send(text, attachments, replyTo?.id ?? null);
await send(text, attachments, replyTo?.id ?? null, { viewOnce: viewOnceNext });
setText('');
setAttachments([]);
setReplyTo(null);
// Reset the sticky view-once flag so it only applies to the message
// the user explicitly armed it for — Snapchat / WhatsApp parity.
setViewOnceNext(false);
if (fileInputRef.current) fileInputRef.current.value = '';
setStickToBottom(true);
notifyStopTyping();
@@ -473,6 +605,23 @@ export function ConversationPage() {
[send, replyTo?.id, notifyStopTyping],
);
const handleCreateWhiteboard = useCallback(async () => {
if (!id || creatingWhiteboard) return;
setCreatingWhiteboard(true);
try {
const board = await createWhiteboard(supabase, id);
const payload = createWhiteboardPayload(board.id);
await send(payload, [], replyTo?.id ?? null);
setReplyTo(null);
setStickToBottom(true);
setOpenWhiteboardId(board.id);
} catch (err: unknown) {
setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden');
} finally {
setCreatingWhiteboard(false);
}
}, [id, creatingWhiteboard, send, replyTo?.id]);
async function ingestFiles(files: File[]) {
const compressed = await compressImages(files);
const next: File[] = [];
@@ -543,6 +692,8 @@ export function ConversationPage() {
: {})}
onSearchClick={() => setSearchOpen((v) => !v)}
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
pinnedCount={pins.length}
onOpenPinned={() => setPinnedPanelOpen(true)}
/>
)}
@@ -584,31 +735,19 @@ export function ConversationPage() {
/>
)}
{isGroup && conversation && (
<GroupInfoPanel
open={infoPanelOpen}
onClose={() => setInfoPanelOpen(false)}
conversation={conversation}
/>
)}
<MediaFilesDrawer
open={mediaDrawerOpen}
index={attachmentIndex}
senderNameFor={senderNameFor}
onJumpToMessage={(messageId) => {
setMediaDrawerOpen(false);
jumpToMessage(messageId);
}}
onClose={() => setMediaDrawerOpen(false)}
/>
{/* 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} />}
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
{conversation && <InCallPanel conversation={conversation} />}
{/* Layout row: chat column on the left grows to fill remaining width;
right-hand drawers (Media/Files, Group Info) render as inline
siblings so opening one narrows the chat instead of floating on
top of it (Discord parity). The chat-column wrapper holds the
`relative` anchor for the drag-and-drop overlay further below. */}
<div className="flex min-h-0 flex-1 flex-row">
<div className="relative flex min-w-0 flex-1 flex-col">
{/* 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 && <CallPreviewPanel conversation={conversation} />}
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
{conversation && <InCallPanel conversation={conversation} />}
<div
ref={scrollRef}
@@ -622,7 +761,13 @@ export function ConversationPage() {
) : error ? (
<Banner>{error}</Banner>
) : messages.length === 0 ? (
<p className="text-center text-sm text-fg-muted"></p>
<EmptyState
icon={<SendIcon className="h-8 w-8" />}
title={t('app:chats.conv_empty_title', { defaultValue: 'Sag Hallo 👋' })}
description={t('app:chats.conv_empty_desc', {
defaultValue: 'Hier ist noch nichts. Schreibe die erste Nachricht.',
})}
/>
) : (
<ul className="space-y-0.5">
{displayCount < messages.length && (
@@ -701,6 +846,8 @@ export function ConversationPage() {
});
}}
highlighted={highlightedId === m.id}
isPinned={pinnedIds.has(m.id)}
onTogglePin={handleTogglePin}
/>
</li>
);
@@ -807,6 +954,9 @@ export function ConversationPage() {
key={idx}
file={file}
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
{...(file.type.startsWith('image/')
? { onEdit: () => setAnnotatingIndex(idx) }
: {})}
/>
))}
</div>
@@ -865,6 +1015,16 @@ export function ConversationPage() {
>
<PollIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => void handleCreateWhiteboard()}
disabled={creatingWhiteboard}
title={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
aria-label={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-[#313338]"
>
<WhiteboardIcon className="h-4 w-4" />
</button>
<div className="relative">
<button
type="button"
@@ -894,6 +1054,36 @@ export function ConversationPage() {
onClose={() => setEmojiOpen(false)}
/>
</div>
<div className="relative">
<button
type="button"
onClick={() => setGifPickerOpen((v) => !v)}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
aria-label="GIF"
title="GIF einfügen"
>
<span className="text-xs font-bold">GIF</span>
</button>
<GifPicker
open={gifPickerOpen}
onClose={() => setGifPickerOpen(false)}
onPick={(gif) => void handleGifPick(gif)}
/>
</div>
<button
type="button"
onClick={() => setViewOnceNext((v) => !v)}
aria-pressed={viewOnceNext}
title={viewOnceNext ? 'Nächstes Bild: einmal ansehen' : 'Nächstes Bild: normal'}
className={
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md transition ' +
(viewOnceNext
? 'bg-accent/20 text-accent'
: 'text-fg-muted hover:bg-surface-3 hover:text-fg')
}
>
<EyeOffIcon className="h-4 w-4" />
</button>
<VoiceRecorder
disabled={sending}
onComplete={async (file) => {
@@ -960,6 +1150,27 @@ export function ConversationPage() {
</button>
</div>
</form>
</div>
{isGroup && conversation && (
<GroupInfoPanel
open={infoPanelOpen}
onClose={() => setInfoPanelOpen(false)}
conversation={conversation}
/>
)}
<MediaFilesDrawer
open={mediaDrawerOpen}
index={attachmentIndex}
senderNameFor={senderNameFor}
onJumpToMessage={(messageId) => {
setMediaDrawerOpen(false);
jumpToMessage(messageId);
}}
onClose={() => setMediaDrawerOpen(false)}
/>
</div>
<ForwardDialog
open={forwardTarget !== null}
@@ -991,6 +1202,35 @@ export function ConversationPage() {
onClose={() => setProfilePopover(null)}
/>
)}
<PinnedMessagesPanel
open={pinnedPanelOpen}
pins={pins}
onClose={() => setPinnedPanelOpen(false)}
onJump={(_messageId) => {
// Future: scroll to message. For now just close the panel.
setPinnedPanelOpen(false);
}}
onUnpin={(messageId) => void handleTogglePin(messageId)}
/>
{annotatingIndex !== null && attachments[annotatingIndex] && (
<ImageAnnotator
file={attachments[annotatingIndex]!}
onCancel={() => setAnnotatingIndex(null)}
onSave={(next) => {
setAttachments((prev) => prev.map((f, i) => (i === annotatingIndex ? next : f)));
setAnnotatingIndex(null);
}}
/>
)}
{openWhiteboardId && (
<WhiteboardModal
whiteboardId={openWhiteboardId}
onClose={() => setOpenWhiteboardId(null)}
/>
)}
</div>
);
}
@@ -1218,7 +1458,15 @@ function Banner({ children }: { children: React.ReactNode }) {
);
}
function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => void }) {
function AttachmentPreview({
file,
onRemove,
onEdit,
}: {
file: File;
onRemove: () => void;
onEdit?: () => void;
}) {
const isImage = file.type.startsWith('image/');
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
@@ -1228,7 +1476,7 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
return () => URL.revokeObjectURL(u);
}, [file, isImage]);
return (
<div className="relative overflow-hidden rounded-lg border border-line bg-surface-2 dark:bg-[#2b2d31]">
<div className="group relative overflow-hidden rounded-lg border border-line bg-surface-2 dark:bg-[#2b2d31]">
{isImage && url ? (
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
) : (
@@ -1240,6 +1488,17 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
<span className="text-fg-muted">{(file.size / 1024).toFixed(0)} KB</span>
</div>
)}
{isImage && onEdit && (
<button
type="button"
onClick={onEdit}
aria-label="Bearbeiten"
title="Bearbeiten"
className="absolute bottom-1 left-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white opacity-0 transition group-hover:opacity-100 hover:bg-accent/80"
>
<PencilIcon className="h-3 w-3" />
</button>
)}
<button
type="button"
onClick={onRemove}
@@ -1251,3 +1510,13 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
</div>
);
}
function WhiteboardIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" {...props}>
<rect x="3" y="4" width="18" height="13" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
);
}
+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>
);
}
+83 -22
View File
@@ -8,12 +8,15 @@ import {
sendFriendRequest,
} from '@chat-app/shared/friends';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Avatar } from '../components/Avatar';
import { EmptyState } from '../components/EmptyState';
import { NicknameDialog } from '../components/NicknameDialog';
import {
AddUserIcon,
AlertIcon,
ChatBubbleIcon,
CheckCircleIcon,
@@ -39,6 +42,12 @@ export function FriendsPage() {
const [searchError, setSearchError] = useState<string | null>(null);
const [pendingId, setPendingId] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [nicknameDialog, setNicknameDialog] = useState<{ userId: string; displayName: string } | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const focusSearch = useCallback(() => {
searchInputRef.current?.focus();
}, []);
useEffect(() => {
const id = window.setTimeout(() => setDebounced(query.trim()), 250);
@@ -119,6 +128,7 @@ export function FriendsPage() {
<div className="relative">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-muted" />
<input
ref={searchInputRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
@@ -160,24 +170,44 @@ export function FriendsPage() {
{loading ? (
<LoadingRow />
) : tab === 'friends' ? (
<FriendList
items={accepted}
emptyKey="app:friends.empty_friends"
renderActions={(f) => (
<FriendActions
busy={pendingId === f.peer.userId || pendingId === 'msg-' + f.peer.userId}
onMessage={() =>
performAction('msg-' + f.peer.userId, async () => {
const id = await createDm(supabase, f.peer.userId);
navigate('/chats/' + id);
})
}
onUnfriend={() =>
performAction(f.peer.userId, () => removeFriendship(supabase, f.peer.userId))
}
/>
)}
/>
accepted.length === 0 ? (
<EmptyState
icon={<AddUserIcon className="h-8 w-8" />}
title={t('app:friends.empty_title', { defaultValue: 'Noch keine Freunde' })}
description={t('app:friends.empty_desc', {
defaultValue: 'Suche einen Friend per Username oder schicke eine Einladung.',
})}
action={{
label: t('app:friends.empty_cta', { defaultValue: 'Friend hinzufügen' }),
onClick: focusSearch,
}}
/>
) : (
<FriendList
items={accepted}
emptyKey="app:friends.empty_friends"
onRowContextMenu={(f) =>
setNicknameDialog({
userId: f.peer.userId,
displayName: f.peer.displayName ?? f.peer.username ?? 'Freund',
})
}
renderActions={(f) => (
<FriendActions
busy={pendingId === f.peer.userId || pendingId === 'msg-' + f.peer.userId}
onMessage={() =>
performAction('msg-' + f.peer.userId, async () => {
const id = await createDm(supabase, f.peer.userId);
navigate('/chats/' + id);
})
}
onUnfriend={() =>
performAction(f.peer.userId, () => removeFriendship(supabase, f.peer.userId))
}
/>
)}
/>
)
) : tab === 'pending' ? (
<FriendList
items={outgoing}
@@ -211,6 +241,12 @@ export function FriendsPage() {
/>
)}
</div>
<NicknameDialog
open={nicknameDialog !== null}
userId={nicknameDialog?.userId ?? ''}
displayName={nicknameDialog?.displayName ?? ''}
onClose={() => setNicknameDialog(null)}
/>
</div>
);
}
@@ -253,10 +289,12 @@ function FriendList({
items,
emptyKey,
renderActions,
onRowContextMenu,
}: {
items: Friendship[];
emptyKey: string;
renderActions: (f: Friendship) => React.ReactNode;
onRowContextMenu?: (f: Friendship) => void;
}) {
const { t } = useTranslation(['app']);
if (items.length === 0) {
@@ -274,16 +312,39 @@ function FriendList({
<ul className="space-y-1.5">
{items.map((f) => (
<li key={f.peer.userId}>
<FriendRow profile={f.peer}>{renderActions(f)}</FriendRow>
<FriendRow
profile={f.peer}
{...(onRowContextMenu
? {
onContextMenu: (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
onRowContextMenu(f);
},
}
: {})}
>
{renderActions(f)}
</FriendRow>
</li>
))}
</ul>
);
}
function FriendRow({ profile, children }: { profile: ProfileBrief; children: React.ReactNode }) {
function FriendRow({
profile,
children,
onContextMenu,
}: {
profile: ProfileBrief;
children: React.ReactNode;
onContextMenu?: (e: React.MouseEvent<HTMLDivElement>) => void;
}) {
return (
<div className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3">
<div
onContextMenu={onContextMenu}
className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3"
>
<Avatar
url={profile.avatarUrl}
displayName={profile.displayName ?? profile.username}
+416 -257
View File
@@ -8,17 +8,27 @@ 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 { DeviceListTab } from '../components/settings/DeviceListTab';
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,
@@ -33,7 +43,7 @@ import {
uploadBannerBlob,
} from '../lib/bannerUpload';
import { ImageCropDialog } from '../components/ImageCropDialog';
import { devLocalSecretStore } from '../lib/secretStore';
import { Lightbox } from '../components/Lightbox';
import {
getPttSettings,
keyCodeToLabel,
@@ -82,7 +92,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]) {
@@ -102,153 +112,279 @@ export function SettingsPage() {
void patchProfile({ locale });
}
// Tab pattern (macOS / Discord / GitHub style): the sidebar selects ONE
// section and only that section renders. activeTab is the single source of
// truth — no IntersectionObserver to drift, no smooth-scroll, no anchor-link
// routing conflict with HashRouter.
type TabId =
| 'profile' | 'appearance' | 'privacy' | 'notifications'
| 'voice' | 'screen-share' | 'soundboard' | 'security' | 'devices' | 'account';
const tabs: Array<{ id: TabId; label: string; Icon: typeof UsersIcon }> = [
{ id: 'profile', label: t('app:settings.nav_profile', { defaultValue: 'Profil' }), Icon: UsersIcon },
{ id: 'appearance', label: t('app:settings.nav_appearance', { defaultValue: 'Erscheinungsbild' }), Icon: SunIcon },
{ id: 'privacy', label: t('app:settings.nav_privacy', { defaultValue: 'Privatsphäre' }), Icon: ShieldIcon },
{ id: 'notifications', label: t('app:settings.nav_notifications', { defaultValue: 'Benachrichtigungen' }), Icon: BellIcon },
{ id: 'voice', label: t('app:settings.nav_voice', { defaultValue: 'Sprache & Anrufe' }), Icon: MicIcon },
{ id: 'screen-share', label: t('app:settings.nav_screen_share', { defaultValue: 'Bildschirmfreigabe' }), Icon: MonitorShareIcon },
{ id: 'soundboard', label: t('app:settings.nav_soundboard', { defaultValue: 'Soundboard' }), Icon: MusicIcon },
{ id: 'security', label: t('app:settings.nav_security', { defaultValue: 'Sicherheit' }), Icon: LockIcon },
{ id: 'devices', label: t('app:settings.nav_devices', { defaultValue: 'Geräte' }), Icon: MonitorShareIcon },
{ id: 'account', label: t('app:settings.nav_account', { defaultValue: 'Konto' }), Icon: SignOutIcon },
];
const [activeTab, setActiveTab] = useState<TabId>('profile');
return (
<div className="min-h-full bg-surface-3 text-fg">
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
<header className="mb-2">
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:settings.title')}
</h1>
</header>
<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 === 'devices' && (
<Section
title={t('app:settings.section_devices', { defaultValue: 'Geräte' })}
description={t('app:settings.section_devices_hint', {
defaultValue: 'Übersicht aller Geräte, die mit deinem Konto angemeldet sind.',
})}
>
<DeviceListTab />
</Section>
)}
{activeTab === 'account' && (
<Section
title={t('app:settings.section_account_mgmt', { defaultValue: 'Konto verwalten' })}
description={t('app:settings.section_account_mgmt_hint', {
defaultValue: 'Abmelden oder Konto-Aktionen.',
})}
tone="danger"
>
<button
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>
);
@@ -452,22 +588,44 @@ function VoiceHotkeyControls({ kind }: { kind: VoiceHotkeyKind }) {
<SettingRow
label={t('app:settings.hotkey_binding', { defaultValue: 'Hotkey' })}
>
<button
type="button"
onClick={() => setCapturing((v) => !v)}
className={
'inline-flex min-w-[10rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(capturing
? 'border-accent bg-accent/20 text-fg animate-pulse'
: 'border-line bg-surface-3 text-fg hover:brightness-95')
}
>
{capturing
? t('app:settings.hotkey_press_combo', {
defaultValue: 'Kombination drücken…',
})
: binding.keyLabel}
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setCapturing((v) => !v)}
className={
'inline-flex min-w-[10rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(capturing
? 'border-accent bg-accent/20 text-fg animate-pulse'
: 'border-line bg-surface-3 text-fg hover:brightness-95')
}
>
{capturing
? t('app:settings.hotkey_press_combo', {
defaultValue: 'Kombination drücken…',
})
: binding.keyLabel}
</button>
<button
type="button"
onClick={() => updateVoiceHotkey(kind, { global: !binding.global })}
disabled={!binding.enabled}
aria-pressed={binding.global}
title={
binding.global
? 'Global: feuert auch wenn Netralax nicht fokussiert ist (PTT-Stil)'
: 'Nur im Fenster: feuert nur wenn Netralax fokussiert ist (empfohlen)'
}
className={
'ml-2 inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px] font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 ' +
(binding.global
? 'border-accent/40 bg-accent/15 text-accent'
: 'border-line bg-surface-3 text-fg-muted hover:text-fg')
}
>
<span aria-hidden>🌐</span>
<span>{binding.global ? 'Global' : 'Im Fenster'}</span>
</button>
</div>
</SettingRow>
</>
);
@@ -799,6 +957,9 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
// ratios.
const [cropFile, setCropFile] = useState<File | null>(null);
const [cropKind, setCropKind] = useState<'avatar' | 'banner' | null>(null);
// Lightbox toggle for the avatar live-preview. Clicking the in-page
// avatar opens a fullscreen view; clicking outside / Esc dismisses.
const [avatarPreviewOpen, setAvatarPreviewOpen] = useState(false);
const userId = profile?.userId;
const avatarUrl = profile?.avatarUrl ?? null;
@@ -930,11 +1091,33 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
className="relative z-10 flex items-end gap-3 px-4 pb-3"
style={{ marginTop: '-2rem' }}
>
<Avatar
url={avatarUrl}
displayName={displayName}
className="h-16 w-16 rounded-full text-2xl ring-4 ring-surface-3"
/>
<button
type="button"
onClick={() => {
if (avatarUrl) setAvatarPreviewOpen(true);
}}
// Disabled when there's no uploaded avatar — clicking the
// generated-initial placeholder would open an empty lightbox.
disabled={!avatarUrl}
aria-label={
avatarUrl
? t('app:settings.avatar_preview', { defaultValue: 'Profilbild vergrößern' })
: undefined
}
// appearance-none + reset border/bg/padding so the native
// button chrome (outset border, button-face background, 1px
// padding) doesn't draw a box around the avatar circle.
className={
'appearance-none border-0 bg-transparent p-0 rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(avatarUrl ? 'cursor-zoom-in' : 'cursor-default')
}
>
<Avatar
url={avatarUrl}
displayName={displayName}
className="h-16 w-16 rounded-full text-2xl ring-4 ring-surface-3"
/>
</button>
<div className="min-w-0 flex-1 pb-1">
<div className="truncate text-sm font-semibold text-fg">
{displayName ?? '—'}
@@ -1061,91 +1244,8 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
}}
onClose={closeCropDialog}
/>
</div>
);
}
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)}
/>
{avatarPreviewOpen && avatarUrl && (
<Lightbox url={avatarUrl} onClose={() => setAvatarPreviewOpen(false)} />
)}
</div>
);
@@ -1212,21 +1312,80 @@ function formatBitrate(kbps: number): string {
return kbps + ' kbps';
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
function Section({
title,
description,
children,
tone,
}: {
title: string;
description?: string;
children: React.ReactNode;
tone?: 'default' | 'danger';
}) {
return (
<section className="rounded-2xl border border-line bg-surface-2 p-5">
<h2 className="mb-4 text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
{title}
</h2>
<div className="space-y-3">{children}</div>
<section
className={
'rounded-2xl border bg-surface-2 p-6 ' +
(tone === 'danger' ? 'border-rose-500/30' : 'border-line')
}
>
<header className="mb-5 border-b border-line pb-4">
<h2 className={'font-display text-lg font-semibold ' + (tone === 'danger' ? 'text-rose-500 dark:text-rose-300' : 'text-fg')}>
{title}
</h2>
{description && (
<p className="mt-1 text-xs text-fg-muted">{description}</p>
)}
</header>
<div className="space-y-4">{children}</div>
</section>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
// Sub-heading inside a Section — used to chunk dense sections like Voice into
// smaller named groups (Audio-Gerät / Qualität / PTT / Hotkeys / E2EE).
// No border: `--color-line` is already a semi-transparent token, and applying
// the `/60` opacity modifier brightens it (Tailwind overrides the original
// alpha) which made the sub-cards look harsher than the outer Section. Plain
// background tint + caps heading carry the grouping signal on their own.
function SubSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-2 rounded-xl bg-surface-3/50 p-4">
<h3 className="text-[11px] font-semibold uppercase tracking-[0.1em] text-fg-muted">
{title}
</h3>
<div className="space-y-3">{children}</div>
</div>
);
}
// Lighter wrapper for a single related extra control inside a Section that
// doesn't warrant its own SubSection card (e.g., autostart toggle inside
// Appearance).
function SubGroup({ children }: { children: React.ReactNode }) {
return (
<div className="space-y-3 border-t border-line pt-4">{children}</div>
);
}
function Row({
label,
value,
mono,
icon,
}: {
label: string;
value: string;
mono?: boolean;
icon?: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-4">
<dt className="text-sm text-fg-muted">{label}</dt>
<dt className="flex items-center gap-1.5 text-sm text-fg-muted">
{icon}
{label}
</dt>
<dd
className={
'max-w-[60%] truncate text-right text-sm text-fg ' +
+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
+82 -14
View File
@@ -1,27 +1,95 @@
# @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.
## EAS Builds and Environment Variables
Production and preview builds load `EXPO_PUBLIC_*` from EAS Secrets —
`.env.local` is only honoured by `expo start` locally. Without the
secrets configured, an APK installs but `env.ts` throws on first read
and `<BootError>` renders.
Required secrets (create once per project):
```bash
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_URL --value '<project-supabase-url>'
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value '<sb_publishable_key>'
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_AUTH_REDIRECT_URL --value 'netralax://auth/callback'
```
Check with `npx eas-cli secret:list`.
## Roadmap
See `docs/superpowers/specs/2026-05-13-mobile-deployment-roadmap.md`.
+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"
}
}
+38 -5
View File
@@ -1,9 +1,42 @@
// 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 { ActivityIndicator, StyleSheet, View } from 'react-native';
import { Stack } from 'expo-router';
import { useAuth } from '../../lib/authContext';
import { colors } from '../../theme/colors';
export default function AppLayout() {
return <Stack screenOptions={{ headerShown: true }} />;
const { ready, session, userKeyState } = useAuth();
if (!ready) {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.accent} />
</View>
);
}
if (!session) return <Redirect href="/" />;
if (userKeyState.status === 'loading') {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.accent} />
</View>
);
}
if (userKeyState.status === 'needs-setup') return <Redirect href="/(app)/setup" />;
if (userKeyState.status === 'needs-unlock') return <Redirect href="/(app)/unlock" />;
return (
<Stack screenOptions={{ headerStyle: { backgroundColor: colors.bg } }}>
<Stack.Screen name="chats" />
<Stack.Screen name="conversations/[id]" />
<Stack.Screen name="call" options={{ presentation: 'fullScreenModal' }} />
<Stack.Screen name="settings" options={{ presentation: 'card' }} />
<Stack.Screen name="setup" options={{ headerShown: false }} />
<Stack.Screen name="unlock" options={{ headerShown: false }} />
</Stack>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg },
});
+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' },
});
+104 -7
View File
@@ -1,18 +1,115 @@
// 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,
FlatList,
Pressable,
RefreshControl,
StyleSheet,
Text,
View,
} from 'react-native';
import { StyleSheet, Text, View } from 'react-native';
import { ConversationRow } from '../../components/ConversationRow';
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 [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]);
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={() => router.push('/(app)/settings')} hitSlop={10}>
<Text style={styles.logoutLink}>Einstellungen</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,445 @@
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, userId, 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 || !userId || !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,
ownUserId: userId,
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, userId, 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 || !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,
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 || !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,
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}
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 },
});
@@ -0,0 +1,18 @@
import { Stack } from 'expo-router';
import { colors } from '../../../theme/colors';
export default function SettingsLayout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.bg },
headerTitleStyle: { color: colors.text },
headerTintColor: colors.accent,
}}
>
<Stack.Screen name="index" options={{ title: 'Einstellungen' }} />
<Stack.Screen name="security" options={{ title: 'Sicherheit' }} />
</Stack>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { useRouter } from 'expo-router';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useAuth } from '../../../lib/authContext';
import { colors } from '../../../theme/colors';
export default function SettingsIndex() {
const router = useRouter();
const { signOut } = useAuth();
function confirmLogout() {
Alert.alert('Abmelden', 'Diese Sitzung beenden?', [
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Abmelden', style: 'destructive', onPress: () => void signOut() },
]);
}
return (
<View style={styles.container}>
<Pressable style={styles.row} onPress={() => router.push('/(app)/settings/security')}>
<Text style={styles.rowTitle}>Sicherheit</Text>
<Text style={styles.rowHint}>PIN ändern, Recovery-Code, Identität zurücksetzen</Text>
</Pressable>
<Pressable style={[styles.row, styles.danger]} onPress={confirmLogout}>
<Text style={[styles.rowTitle, styles.dangerText]}>Abmelden</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg, padding: 16, gap: 12 },
row: {
backgroundColor: colors.surface,
padding: 16,
borderRadius: 12,
borderColor: colors.border,
borderWidth: 1,
},
rowTitle: { color: colors.text, fontSize: 16, fontWeight: '600' },
rowHint: { color: colors.textMuted, fontSize: 13, marginTop: 4 },
danger: { borderColor: colors.danger },
dangerText: { color: colors.danger },
});
+200
View File
@@ -0,0 +1,200 @@
import { useState } from 'react';
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { PinInput } from '../../../components/PinInput';
import { useAuth } from '../../../lib/authContext';
import {
changePin,
regenerateRecoveryCode,
resetIdentity,
retryLegacyMigration,
type LegacyMigrationReport,
} from '../../../lib/userIdentity';
import { colors } from '../../../theme/colors';
export default function SecuritySettings() {
const { userId, refreshUserKeyState } = useAuth();
const [oldPin, setOldPin] = useState('');
const [newPin, setNewPin] = useState('');
const [newRecovery, setNewRecovery] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [report, setReport] = useState<LegacyMigrationReport | null>(null);
async function handleChangePin() {
if (!userId) return;
if (oldPin.length !== 6 || newPin.length !== 6) {
setError('Beide PINs müssen 6 Ziffern haben.');
return;
}
setSubmitting(true);
setError(null);
try {
await changePin({ userId, oldPin, newPin });
setOldPin('');
setNewPin('');
Alert.alert('PIN geändert', 'Die neue PIN gilt sofort auf allen Geräten.');
} catch (e) {
setError(e instanceof Error ? e.message : 'PIN-Änderung fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
async function handleRegenerateRecovery() {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
const code = await regenerateRecoveryCode({ userId });
setNewRecovery(code);
} catch (e) {
setError(e instanceof Error ? e.message : 'Recovery-Code-Erzeugung fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
function handleReset() {
if (!userId) return;
Alert.alert(
'Identität zurücksetzen?',
'Alle bisherigen Chats werden für dich unlesbar. Diese Aktion kann nicht rückgängig gemacht werden.',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Zurücksetzen',
style: 'destructive',
onPress: async () => {
setSubmitting(true);
try {
await resetIdentity({ userId, pin: '000000' });
await refreshUserKeyState();
} catch (e) {
setError(e instanceof Error ? e.message : 'Reset fehlgeschlagen');
} finally {
setSubmitting(false);
}
},
},
],
);
}
async function handleRetryMigration() {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
const r = await retryLegacyMigration(userId);
setReport(r);
} catch (e) {
setError(e instanceof Error ? e.message : 'Migration fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.section}>PIN ändern</Text>
<Text style={styles.label}>Alte PIN</Text>
<PinInput value={oldPin} onChange={setOldPin} ariaLabel="Alte PIN" />
<Text style={styles.label}>Neue PIN</Text>
<PinInput value={newPin} onChange={setNewPin} ariaLabel="Neue PIN" />
<Pressable style={styles.primary} onPress={handleChangePin} disabled={submitting}>
{submitting ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.primaryText}>PIN aktualisieren</Text>
)}
</Pressable>
<Text style={styles.section}>Recovery-Code</Text>
<Pressable style={styles.primary} onPress={handleRegenerateRecovery} disabled={submitting}>
<Text style={styles.primaryText}>Neuen Recovery-Code erzeugen</Text>
</Pressable>
{newRecovery && (
<View style={styles.codeBox}>
<Text style={styles.codeText} selectable>
{newRecovery}
</Text>
</View>
)}
<Text style={styles.section}>Migration</Text>
<Pressable style={styles.primary} onPress={handleRetryMigration} disabled={submitting}>
<Text style={styles.primaryText}>Migration erneut versuchen</Text>
</Pressable>
{report && (
<View style={styles.report}>
<Text style={styles.reportLine}>Geräte (Server): {report.serverDevices}</Text>
<Text style={styles.reportLine}>
Lokale Schlüssel im Vault:{' '}
{report.strongholdKeysFromServerDevices + report.strongholdKeysFromBundleScan}
</Text>
<Text style={styles.reportLine}>
Versucht: {report.attempted}, Erfolgreich: {report.migrated}
</Text>
<Text style={styles.reportLine}>
Übersprungen: kein lokaler Schlüssel = {report.noStrongholdKey}, Decrypt-Fehler ={' '}
{report.decryptFailed}, RPC-Fehler = {report.rpcFailed}
</Text>
</View>
)}
<Text style={styles.section}>Gefahrenbereich</Text>
<Pressable style={[styles.primary, styles.danger]} onPress={handleReset} disabled={submitting}>
<Text style={[styles.primaryText, styles.dangerText]}>Identität zurücksetzen</Text>
</Pressable>
{error && <Text style={styles.error}>{error}</Text>}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { padding: 16, gap: 8, backgroundColor: colors.bg },
section: { color: colors.text, fontSize: 16, fontWeight: '700', marginTop: 16 },
label: { color: colors.textMuted, fontSize: 12, marginTop: 4 },
primary: {
backgroundColor: colors.accent,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
marginTop: 8,
},
primaryText: { color: colors.text, fontWeight: '700' },
danger: { backgroundColor: 'transparent', borderColor: colors.danger, borderWidth: 1 },
dangerText: { color: colors.danger },
codeBox: {
backgroundColor: colors.surface,
padding: 12,
borderRadius: 10,
marginTop: 4,
},
codeText: {
color: colors.text,
fontFamily: 'Courier',
fontSize: 16,
letterSpacing: 1.2,
textAlign: 'center',
},
report: {
backgroundColor: colors.surface,
padding: 12,
borderRadius: 10,
marginTop: 4,
gap: 4,
},
reportLine: { color: colors.text, fontSize: 13 },
error: { color: colors.danger, marginTop: 12 },
});
+195
View File
@@ -0,0 +1,195 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { PinInput } from '../../components/PinInput';
import { useAuth } from '../../lib/authContext';
import { setupNewUserIdentity } from '../../lib/userIdentity';
import { colors } from '../../theme/colors';
type Step = 'pin' | 'confirm' | 'recovery' | 'done';
export default function SetupScreen() {
const router = useRouter();
const { userId, refreshUserKeyState } = useAuth();
const [step, setStep] = useState<Step>('pin');
const [pin, setPin] = useState('');
const [confirm, setConfirm] = useState('');
const [recoveryCode, setRecoveryCode] = useState<string | null>(null);
const [withRecovery, setWithRecovery] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function runSetup(saveRecovery: boolean) {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
const out = await setupNewUserIdentity({ userId, pin, withRecovery: saveRecovery });
setRecoveryCode(out.recoveryCode);
if (out.recoveryCode) {
setStep('recovery');
} else {
setStep('done');
await refreshUserKeyState();
router.replace('/(app)/chats');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Setup fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
if (step === 'pin') {
return (
<View style={styles.container}>
<Text style={styles.title}>PIN festlegen</Text>
<Text style={styles.subtitle}>
Mit dieser 6-stelligen PIN entsperrst du Netralax auf jedem Gerät.
</Text>
<PinInput value={pin} onChange={setPin} ariaLabel="Neue PIN" autoFocus />
{error && <Text style={styles.error}>{error}</Text>}
<Pressable
style={[styles.primary, pin.length !== 6 && styles.disabled]}
disabled={pin.length !== 6}
onPress={() => setStep('confirm')}
>
<Text style={styles.primaryText}>Weiter</Text>
</Pressable>
</View>
);
}
if (step === 'confirm') {
return (
<View style={styles.container}>
<Text style={styles.title}>PIN bestätigen</Text>
<Text style={styles.subtitle}>Bitte gib dieselbe PIN erneut ein.</Text>
<PinInput value={confirm} onChange={setConfirm} ariaLabel="PIN bestätigen" autoFocus />
{error && <Text style={styles.error}>{error}</Text>}
<Pressable
style={[styles.primary, confirm.length !== 6 && styles.disabled]}
disabled={confirm.length !== 6 || submitting}
onPress={() => {
if (pin !== confirm) {
setError('PIN stimmt nicht überein.');
setConfirm('');
return;
}
void runSetup(withRecovery);
}}
>
{submitting ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.primaryText}>PIN speichern</Text>
)}
</Pressable>
<Pressable onPress={() => setWithRecovery((v) => !v)} style={styles.secondary}>
<Text style={styles.secondaryText}>
{withRecovery
? 'Recovery-Code überspringen (riskant)'
: 'Recovery-Code erzeugen (empfohlen)'}
</Text>
</Pressable>
</View>
);
}
if (step === 'recovery' && recoveryCode) {
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>Recovery-Code</Text>
<Text style={styles.subtitle}>
Notiere dir diesen Code an einem sicheren Ort. Du brauchst ihn, wenn du deine PIN
vergisst. Wir zeigen ihn dir nur EINMAL.
</Text>
<View style={styles.codeBox}>
<Text style={styles.codeText} selectable>
{recoveryCode}
</Text>
</View>
<Pressable
style={styles.primary}
onPress={async () => {
await refreshUserKeyState();
router.replace('/(app)/chats');
}}
>
<Text style={styles.primaryText}>Habe ich gespeichert</Text>
</Pressable>
<Pressable
style={styles.secondary}
onPress={() => {
Alert.alert(
'Sicher?',
'Ohne Recovery-Code verlierst du den Zugriff, wenn du die PIN vergisst.',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Weiter ohne',
style: 'destructive',
onPress: async () => {
await refreshUserKeyState();
router.replace('/(app)/chats');
},
},
],
);
}}
>
<Text style={styles.secondaryText}>Überspringen</Text>
</Pressable>
</ScrollView>
);
}
return null;
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
paddingTop: 64,
backgroundColor: colors.bg,
gap: 16,
},
title: { color: colors.text, fontSize: 24, fontWeight: '700' },
subtitle: { color: colors.textMuted, fontSize: 14, lineHeight: 20 },
error: { color: colors.danger, fontSize: 13 },
primary: {
backgroundColor: colors.accent,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
marginTop: 12,
},
primaryText: { color: colors.text, fontWeight: '700' },
disabled: { opacity: 0.5 },
secondary: { paddingVertical: 12, alignItems: 'center' },
secondaryText: { color: colors.accent, fontWeight: '600' },
codeBox: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
padding: 16,
borderRadius: 12,
},
codeText: {
color: colors.text,
fontSize: 20,
fontFamily: 'Courier',
letterSpacing: 1.5,
textAlign: 'center',
},
});
+194
View File
@@ -0,0 +1,194 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { PinInput } from '../../components/PinInput';
import { useAuth } from '../../lib/authContext';
import { loadOrUnlockUserKey, resetIdentity } from '../../lib/userIdentity';
import { colors } from '../../theme/colors';
type Mode = 'pin' | 'recovery';
export default function UnlockScreen() {
const router = useRouter();
const { userId, userKeyState, refreshUserKeyState } = useAuth();
const [mode, setMode] = useState<Mode>('pin');
const [pin, setPin] = useState('');
const [recovery, setRecovery] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const locked =
userKeyState.status === 'needs-unlock' && userKeyState.lockedUntil !== null;
const hasRecovery =
userKeyState.status === 'needs-unlock' ? userKeyState.hasRecovery : false;
async function attempt(value: string, isRecoveryCode: boolean) {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
const out = await loadOrUnlockUserKey({ userId, pin: value, isRecoveryCode });
if (out.kind === 'unlocked') {
await refreshUserKeyState();
router.replace('/(app)/chats');
return;
}
if (out.kind === 'locked') {
setError('Konto bis ' + new Date(out.lockedUntil).toLocaleString('de-DE') + ' gesperrt.');
await refreshUserKeyState();
return;
}
setError('Kein Identitäts-Datensatz auf dem Server.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Entsperren fehlgeschlagen');
if (isRecoveryCode) setRecovery('');
else setPin('');
await refreshUserKeyState();
} finally {
setSubmitting(false);
}
}
async function handleReset() {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
await resetIdentity({ userId, pin: '000000' });
await refreshUserKeyState();
router.replace('/(app)/setup');
} catch (err) {
setError(err instanceof Error ? err.message : 'Reset fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
return (
<View style={styles.container}>
<Text style={styles.title}>Netralax entsperren</Text>
<View style={styles.tabs}>
<Pressable
style={[styles.tab, mode === 'pin' && styles.tabActive]}
onPress={() => setMode('pin')}
>
<Text style={[styles.tabText, mode === 'pin' && styles.tabTextActive]}>PIN</Text>
</Pressable>
<Pressable
style={[styles.tab, mode === 'recovery' && styles.tabActive]}
disabled={!hasRecovery}
onPress={() => setMode('recovery')}
>
<Text style={[styles.tabText, mode === 'recovery' && styles.tabTextActive]}>
Recovery-Code
</Text>
</Pressable>
</View>
{mode === 'pin' ? (
<>
<PinInput
value={pin}
onChange={setPin}
ariaLabel="PIN eingeben"
autoFocus
disabled={locked || submitting}
/>
<Pressable
style={[styles.primary, (pin.length !== 6 || locked || submitting) && styles.disabled]}
disabled={pin.length !== 6 || locked || submitting}
onPress={() => attempt(pin, false)}
>
{submitting ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.primaryText}>Entsperren</Text>
)}
</Pressable>
</>
) : (
<>
<Text style={styles.subtitle}>Recovery-Code eingeben (mit oder ohne Bindestriche):</Text>
<Text style={styles.recoveryDisplay} selectable>
{recovery}
</Text>
<Pressable
style={[styles.primary, recovery.length === 0 && styles.disabled]}
disabled={recovery.length === 0 || submitting}
onPress={() => attempt(recovery, true)}
>
{submitting ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.primaryText}>Mit Recovery entsperren</Text>
)}
</Pressable>
</>
)}
{error && <Text style={styles.error}>{error}</Text>}
{locked && (
<Pressable style={styles.danger} onPress={handleReset}>
<Text style={styles.dangerText}>Identität zurücksetzen (alte Chats verloren)</Text>
</Pressable>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg,
paddingTop: 64,
paddingHorizontal: 24,
gap: 16,
},
title: { color: colors.text, fontSize: 22, fontWeight: '700' },
subtitle: { color: colors.textMuted, fontSize: 13 },
tabs: { flexDirection: 'row', gap: 8 },
tab: {
flex: 1,
paddingVertical: 10,
alignItems: 'center',
borderBottomWidth: 2,
borderBottomColor: colors.border,
},
tabActive: { borderBottomColor: colors.accent },
tabText: { color: colors.textMuted, fontWeight: '600' },
tabTextActive: { color: colors.text },
recoveryDisplay: {
color: colors.text,
fontFamily: 'Courier',
fontSize: 18,
letterSpacing: 1.5,
backgroundColor: colors.surface,
padding: 16,
borderRadius: 10,
},
primary: {
backgroundColor: colors.accent,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
primaryText: { color: colors.text, fontWeight: '700' },
disabled: { opacity: 0.5 },
danger: {
paddingVertical: 12,
alignItems: 'center',
borderColor: colors.danger,
borderWidth: 1,
borderRadius: 12,
},
dangerText: { color: colors.danger, fontWeight: '700' },
error: { color: colors.danger, fontSize: 13 },
});
+27 -11
View File
@@ -1,18 +1,34 @@
// Root layout for Expo Router.
// Wraps every screen. Place global providers here (Theme, Supabase/Auth context,
// SafeAreaProvider, GestureHandlerRootView, etc.) once they exist.
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { AppBootstrap } from '../components/AppBootstrap';
import { ErrorBoundary } from '../components/ErrorBoundary';
import { IncomingCallModal } from '../components/IncomingCallModal';
import { AuthProvider } from '../lib/authContext';
import { CallProvider } from '../lib/callContext';
export default function RootLayout() {
return (
<>
<StatusBar style="auto" />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(app)" />
</Stack>
</>
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<AppBootstrap>
<ErrorBoundary>
<AuthProvider>
<CallProvider>
<StatusBar style="auto" />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(app)" />
<Stack.Screen name="auth/callback" />
</Stack>
<IncomingCallModal />
</CallProvider>
</AuthProvider>
</ErrorBoundary>
</AppBootstrap>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
+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, ready } = useAuth();
const [email, setEmail] = useState('');
const [submitting, setSubmitting] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!ready) 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',
],
};
};
+51
View File
@@ -0,0 +1,51 @@
import { crypto } from '@chat-app/shared';
import { type ReactNode, useEffect, useState } from 'react';
import { createLibsodiumBackend } from '../lib/cryptoBackend';
import { BootError } from './BootError';
import { BootSplash } from './BootSplash';
interface Props {
children: ReactNode;
}
// React Native exposes ErrorUtils on the global. The types ship with RN but
// we cast defensively because the renderer used by Vitest does not.
interface RNErrorUtils {
getGlobalHandler: () => (err: Error, isFatal?: boolean) => void;
setGlobalHandler: (handler: (err: Error, isFatal?: boolean) => void) => void;
}
// Initialises the crypto backend inside a useEffect (not at module-eval) so
// any failure surfaces in the React tree. Also installs a global JS error
// handler that routes unhandled throws to BootError; this catches errors
// thrown during render (e.g. the lazy env proxy reading a missing var) that
// would otherwise escape every per-screen ErrorBoundary.
export function AppBootstrap({ children }: Props) {
const [error, setError] = useState<Error | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
try {
crypto.setCryptoBackend(createLibsodiumBackend());
setReady(true);
} catch (e: unknown) {
setError(e instanceof Error ? e : new Error(String(e)));
}
}, []);
useEffect(() => {
const eu = (globalThis as unknown as { ErrorUtils?: RNErrorUtils }).ErrorUtils;
if (!eu) return;
const prev = eu.getGlobalHandler();
eu.setGlobalHandler((err, isFatal) => {
prev?.(err, isFatal);
setError(err);
});
return () => eu.setGlobalHandler(prev);
}, []);
if (error) return <BootError error={error} />;
if (!ready) return <BootSplash />;
return <>{children}</>;
}
@@ -0,0 +1,92 @@
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;
}
// 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' },
});

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