Compare commits

..

117 Commits

Author SHA1 Message Date
byGalax 6d0e4fb1f0 perf(P6A.T5): pre-warm Supabase + avatar loading hints
Fire a no-await profiles query in AuthContext on session establish to absorb
cold-connection latency before the first user-triggered request. Add
loading='lazy' default to the central Avatar component so all off-screen
avatars (chat list, friends list, popovers, message senders) skip eager
Supabase Storage fetches; set loading='eager' on ConversationHeader (active
conv header) and CallParticipantTile inline imgs (both AudioContent and
VideoStub) which are always above-the-fold when visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:43:04 +02:00
byGalax 36ab7eca8a perf(P6A.T4): wrap all icon components in React.memo
All 57 exported SVG icon components in icons.tsx are now memoised via
React.memo, giving React permission to skip re-renders when props are
referentially equal.  Consumer icon-prop types updated from the legacy
SVGProps (includes string refs) to ComponentPropsWithoutRef<'svg'> so
the MemoExoticComponent return type satisfies TypeScript without casts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:39:47 +02:00
byGalax ddd696a790 perf(P6A.T3): memoize MessageBubble + stabilize parent callbacks 2026-05-16 23:30:50 +02:00
byGalax 53b3b5e1fc perf(P6A.T2): respect prefers-reduced-motion globally + gate confetti
- Update globals.css reduced-motion block: 0.01ms → 0.001ms durations
  and add scroll-behavior: auto to suppress all transitions/animations
  when OS reduced-motion preference is active.
- Gate canvas-confetti burst in GameModal behind matchMedia check so
  the particle effect is skipped entirely for users who opt out of motion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:25:01 +02:00
byGalax a4573b315d perf(P6A.T1): lazy-load ImageAnnotator/Whiteboard/WatchTogether/GameModal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:22:52 +02:00
byGalax c271e95100 docs(P6): Phase 6 performance pack plan (Groups A/B/C + deferred channel pooling) 2026-05-16 23:16:42 +02:00
byGalax 888ed1b217 feat(P5C.T4): confetti burst on game win
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 22:26:25 +02:00
byGalax a974e5b8ab feat(P5C.T3): recompute message_mentions on edit
After the ciphertext UPDATE succeeds in editEncryptedMessage, delete the
existing message_mentions rows for that message then re-resolve and
re-insert from the new plaintext — matching the best-effort pattern used
by insertMessage. No signature change needed; conversationId and
newPlaintext were already present in EditMessageParams.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 22:24:17 +02:00
byGalax 7ebc5f6c9d feat(P5C.T2): per-conv 'mentions only' toggle + notification gate 2026-05-16 22:22:04 +02:00
byGalax b8b451ef4f feat(P5C.T2-sql): conversation_members.mentions_only column 2026-05-16 22:17:04 +02:00
byGalax d7c0c3d0a2 feat(P5C.T1): empty-state for empty chat-list search results 2026-05-16 22:14:33 +02:00
byGalax 70ce824120 docs(P5C): Phase 5C polish plan (4 spec sub-items) 2026-05-16 22:12:27 +02:00
byGalax d10840e0b2 feat(P5B.T6): composer Spielen button + bubble dispatch + game-picker
Wires mini-game entry points: createGamePayload helper in conversationFeatures.ts (GamePayload import added), game state/handler/picker-dialog/modal-mount/open-event-listener in ConversationPage.tsx (DM-only guard uses conversation.members[].userId camelCase as confirmed), and parsed.kind==='game' bubble branch in MessageBubble.tsx dispatching chatapp:open-game CustomEvent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:58:10 +02:00
byGalax cd59ee30d6 feat(P5B.T5): GameModal — picks board + turn/winner status 2026-05-16 21:53:42 +02:00
byGalax e56e22631b feat(P5B.T4): TicTacToeBoard + ConnectFourBoard render-only components 2026-05-16 21:51:06 +02:00
byGalax dbf90504b4 feat(P5B.T3): useGame hook with realtime + makeMove 2026-05-16 21:49:00 +02:00
byGalax 256a613134 feat(P5B.T2): GamePayload + games wrappers + winner-detect helpers + tests
Adds conversation_games table type + game_make_move RPC to db-types, GamePayload variant and parseMessagePayload branch to shared/attachments, games.ts with createGame/getGame/makeGameMove wrappers plus pure tttWinningLine/c4WinningCells/c4DropRow/isBoardFull helpers, 11 tests covering all winner helpers, and re-export from chat/index.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:47:36 +02:00
byGalax e1423dba32 feat(P5B.T1): conversation_games table + game_make_move RPC + RLS + realtime 2026-05-16 21:42:47 +02:00
byGalax 7730828403 docs(P5B): Phase 5B implementation plan (Mini-Games) 2026-05-16 21:39:12 +02:00
byGalax ecbd11e369 feat(P5A.T5): composer Watch-Together button + bubble dispatch 2026-05-16 21:30:22 +02:00
byGalax 7d5f3b37cd feat(P5A.T4): WatchTogetherModal — YouTube IFrame + drift reconcile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:26:59 +02:00
byGalax 7ce6b1c1d4 feat(P5A.T3): useWatchSession hook with realtime + throttled push
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:23:39 +02:00
byGalax 4399d39f08 feat(P5A.T2): WatchTogetherPayload + wrappers + parseYouTubeUrl + tests 2026-05-16 21:21:30 +02:00
byGalax ad239ec549 feat(P5A.T1): conversation_watch_sessions table with RLS + realtime 2026-05-16 21:16:38 +02:00
byGalax d1193142ae docs(P5A): Phase 5A implementation plan (Watch-Together / YouTube) 2026-05-16 21:14:38 +02:00
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
143 changed files with 23475 additions and 468 deletions
+13
View File
@@ -106,6 +106,19 @@ export const CHANNELS = {
// Used by the call cinema mode to flip the host BrowserWindow into real
// OS fullscreen so the Windows taskbar / macOS menubar gets covered.
WINDOW_SET_FULLSCREEN: 'window:set-fullscreen',
// Wipe-on-close — main process pushes this to the renderer right before
// exiting if the user has enabled the Settings → Sicherheit toggle. The
// renderer clears its sensitive caches (memoryWipe.ts) and acks via
// `app:wipe-before-quit:done`; main quits after the ack (or a 2s safety
// timeout, whichever comes first).
/** Main → renderer: about to quit. Renderer wipes, then resolves. */
APP_WIPE_BEFORE_QUIT: 'app:wipe-before-quit',
// OS hostname — returns the machine hostname via Node's `os.hostname()`.
// Used by AuthContext on first launch to populate the `devices` row with
// a human-readable default device name. Returns null on failure.
APP_HOSTNAME: 'app:hostname',
} as const;
// ---- Screen sources ------------------------------------------------------
+30 -1
View File
@@ -6,11 +6,13 @@
// the old Tauri devUrl so nothing in the renderer code needs to change)
// and from the built dist in packaged mode.
import { app, BrowserWindow, desktopCapturer, Menu, session } from 'electron';
import { app, BrowserWindow, desktopCapturer, ipcMain, Menu, session } from 'electron';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { CHANNELS } from './ipc-types';
import { register as registerAudioLoopback } from './modules/audio-loopback';
import { register as registerAppHostname } from './modules/app-hostname';
import { register as registerAutostart } from './modules/autostart';
import { register as registerFsScoped } from './modules/fs-scoped';
import { register as registerNotifications } from './modules/notifications';
@@ -253,6 +255,7 @@ if (!gotLock) {
registerFsScoped();
registerSql();
registerAutostart();
registerAppHostname();
// Window-dependent registrars — only call once mainWindow exists so
// event emitters have somewhere to send.
@@ -263,6 +266,32 @@ if (!gotLock) {
registerAudioLoopback(mainWindow);
});
// Wipe-on-close: when the user enables it in Settings, the renderer is given
// a chance to clear all sensitive caches before the app process exits. If
// the renderer doesn't ack within 2 seconds we force-quit anyway — better
// to lose the wipe than to hang the app shutdown.
//
// Note: there's a separate `before-quit` listener in modules/tray.ts that
// tears down the Tray instance. Electron fires both; the tray listener is
// synchronous and doesn't touch event.preventDefault, so it doesn't fight
// our deferred-quit dance here. The `wipeRequested` flag guards re-entry
// when our own `app.quit()` below fires `before-quit` a second time.
let wipeRequested = false;
app.on('before-quit', (event) => {
if (wipeRequested) return;
if (!mainWindow || mainWindow.isDestroyed()) return;
wipeRequested = true;
event.preventDefault();
mainWindow.webContents.send(CHANNELS.APP_WIPE_BEFORE_QUIT);
const done = new Promise<void>((resolve) => {
ipcMain.once(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done', () => resolve());
});
void Promise.race([done, new Promise<void>((r) => setTimeout(r, 2000))]).finally(() => {
app.quit();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
@@ -0,0 +1,21 @@
// app:hostname adapter — returns the OS hostname via Node's `os.hostname()`.
// The renderer has no Node access (contextIsolation is ON), so it must ask
// main for this value. Used by AuthContext on first launch to populate the
// `devices` row with a human-readable default device name.
import os from 'node:os';
import { ipcMain } from 'electron';
import { CHANNELS } from '../ipc-types';
export function register(): void {
ipcMain.handle(CHANNELS.APP_HOSTNAME, (): string | null => {
try {
const h = os.hostname();
return typeof h === 'string' && h.length > 0 ? h : null;
} catch {
return null;
}
});
}
+27 -2
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,8 +64,16 @@ function buildOverlay(): NativeImage {
}
export function register(mainWindow: BrowserWindow): void {
// 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();
trayRef = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon);
if (!icon.isEmpty()) {
trayRef = new Tray(icon);
trayRef.setToolTip('Netralax');
const menu = Menu.buildFromTemplate([
@@ -82,6 +101,12 @@ export function register(mainWindow: BrowserWindow): void {
if (mainWindow.isVisible()) mainWindow.focus();
else mainWindow.show();
});
}
} catch (err: unknown) {
// Swallow: the overlay badge below is the user-visible bit. A missing
// systray icon is cosmetic and shouldn't take the unread handler with it.
console.warn('[tray] systray init failed, overlay badge still active', err);
}
ipcMain.handle(
CHANNELS.TRAY_UNREAD,
+11
View File
@@ -95,6 +95,17 @@ export interface ElectronAPI {
setAutoStart: (enabled: boolean) => Promise<void>;
setFullscreen: (enabled: boolean) => Promise<void>;
/** Subscribe to the main-process pre-quit notification. Used by the
* "Cache beim Schließen leeren" Settings toggle. */
onWipeBeforeQuit: (cb: () => Promise<void>) => () => void;
/** Returns the OS hostname (via Node's `os.hostname()`). Used by
* AuthContext on first launch to populate the `devices` row with a
* human-readable default device name. Optional: always feature-check
* via `typeof window.electronAPI?.getHostname === 'function'` because
* the web build has no preload bridge. */
getHostname?: () => Promise<string | null>;
}
declare global {
+21
View File
@@ -159,6 +159,27 @@ const api = {
// Window fullscreen ------------------------------------------------------
setFullscreen: (enabled: boolean): Promise<void> =>
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
// OS hostname ------------------------------------------------------------
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.APP_HOSTNAME),
// Wipe-on-close ----------------------------------------------------------
// Subscribe to the main-process pre-quit notification. The renderer's
// callback does the actual wipe (memoryWipe.ts) and resolves; we ack
// unconditionally so main can finish quitting — better to lose the wipe
// than to hang the app shutdown if the callback throws.
onWipeBeforeQuit: (cb: () => Promise<void>): Unsubscribe => {
const handler = async (_evt: Electron.IpcRendererEvent): Promise<void> => {
try {
await cb();
} catch (err) {
console.warn('[wipe] renderer cb failed', err);
}
ipcRenderer.send(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done');
};
ipcRenderer.on(CHANNELS.APP_WIPE_BEFORE_QUIT, handler);
return () => ipcRenderer.removeListener(CHANNELS.APP_WIPE_BEFORE_QUIT, handler);
},
} as const;
export type ElectronAPI = typeof api;
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chat-app/desktop",
"version": "0.18.7",
"version": "0.19.1",
"private": true,
"description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module",
@@ -25,6 +25,7 @@
"@livekit/track-processors": "^0.7.2",
"@supabase/supabase-js": "^2.46.0",
"better-sqlite3": "^11.3.0",
"canvas-confetti": "^1.9.4",
"electron-updater": "^6.3.0",
"i18next": "^23.16.4",
"libsodium-wrappers-sumo": "0.7.15",
@@ -38,6 +39,7 @@
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.0",
"@types/canvas-confetti": "^1.9.0",
"@types/libsodium-wrappers": "^0.7.14",
"@types/libsodium-wrappers-sumo": "^0.8.2",
"@types/react": "^18.3.12",
+2
View File
@@ -4,6 +4,7 @@ import { HashRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
import { AppShell } from './components/AppShell';
import { CrashToast } from './components/CrashToast';
import { ErrorBoundary } from './components/ErrorBoundary';
import { RemoteRevokedScreen } from './components/RemoteRevokedScreen';
import { SpinnerIcon } from './components/icons';
import { UpdateToast } from './components/UpdateToast';
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
@@ -168,6 +169,7 @@ export function App() {
</Routes>
<UpdateToast />
<CrashToast />
<RemoteRevokedScreen />
</HashRouter>
</CallProvider>
</ConversationsProvider>
+2
View File
@@ -6,6 +6,7 @@ import { startConversationKeySync } from '../lib/conversationKeySync';
import { startDeviceApprovalListener } from '../lib/deviceApproval';
import { ensureInstallId } from '../lib/installId';
import { ensureNotificationPermission } from '../lib/osNotify';
import { useMentionNotifications } from '../lib/useMentionNotifications';
import { cachedUserKey } from '../lib/userIdentity';
import { CallUI } from './CallUI';
import { DeviceApprovalBanner } from './DeviceApprovalBanner';
@@ -13,6 +14,7 @@ import { Sidebar } from './Sidebar';
export function AppShell() {
const { session } = useAuth();
useMentionNotifications(session?.user.id);
useEffect(() => {
// Prompt once per authenticated shell mount. Module-level guard prevents
// re-asking if the user already responded this session.
@@ -5,9 +5,15 @@ import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon } from './icons';
import { Lightbox } from './Lightbox';
import { ViewOnceImage } from './ViewOnceImage';
interface Props {
handle: AttachmentHandle;
/** True iff the local user sent this message. View-once images use this
* to suppress the burn (senders never burn their own attachment) and to
* pick the right preview chrome. Defaults to false so existing callers
* (e.g. MediaFilesDrawer) keep working without modification. */
mine?: boolean;
}
// Max inline-preview dimension. Full-resolution stays available for the
@@ -44,7 +50,7 @@ async function makeThumbnail(blob: Blob): Promise<Blob | null> {
}
}
export function AttachmentImage({ handle }: Props) {
export function AttachmentImage({ handle, mine = false }: Props) {
const [fullUrl, setFullUrl] = useState<string | null>(null);
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -116,6 +122,25 @@ export function AttachmentImage({ handle }: Props) {
);
}
// View-once attachments swap the standard image preview for the ViewOnceImage
// chrome (locked card → fullscreen lightbox + burn for recipient, normal
// image with badge for sender). We still kick off the decrypt above so the
// fullscreen lightbox has the decoded blob ready the moment the recipient
// taps. `handle.viewedAt` is undefined in the current renderer (it lives on
// the public attachment row, not in the encrypted payload) — the component
// treats undefined as "not yet burned" and uses the RPC response to flip
// the state locally after the recipient opens.
if (handle.viewOnce) {
return (
<ViewOnceImage
attachmentId={handle.id}
viewedAt={handle.viewedAt ?? null}
isSender={mine}
src={blobUrl}
/>
);
}
return (
<>
<button
+7
View File
@@ -11,6 +11,11 @@ interface Props {
// brand-tinted fallback; callers can override (e.g. to colour-by-id).
fallbackClass?: string;
alt?: string;
// Browser loading hint. Use 'eager' for above-the-fold avatars (e.g. the
// active conversation header, call tiles). Defaults to 'lazy' so off-screen
// avatars (chat list rows, friends list, popovers) don't hammer Supabase
// Storage on initial render.
loading?: 'eager' | 'lazy';
}
export function Avatar({
@@ -19,6 +24,7 @@ export function Avatar({
className = 'h-10 w-10',
fallbackClass = 'bg-accent/20 text-accent',
alt,
loading = 'lazy',
}: Props) {
const effectiveUrl = useCachedAvatarUrl(url);
if (effectiveUrl) {
@@ -28,6 +34,7 @@ export function Avatar({
alt={alt ?? displayName ?? ''}
className={'shrink-0 rounded-full object-cover ' + className}
draggable={false}
loading={loading}
/>
);
}
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
import { useNickname } from '../lib/friendNicknames';
import {
CrownIcon,
HeadphonesOffIcon,
@@ -89,6 +90,7 @@ export interface ParticipantTileProps {
export function CallParticipantTile(props: ParticipantTileProps) {
const {
userId,
displayName,
me,
muted,
@@ -107,6 +109,13 @@ export function CallParticipantTile(props: ParticipantTileProps) {
onContextMenu,
} = props;
// Apply the per-viewer nickname override once at the top — each tile is
// already per-participant, so a single hook call is fine. The resolved
// name is forwarded into the avatar sub-components below so their initial
// letter respects the nickname too.
const resolvedName = useNickname(userId, displayName);
const tileProps = { ...props, displayName: resolvedName };
const small = size === 'small';
// Discord-style: full tile border switches to emerald the whole time the
// user is speaking. Same treatment for audio + video tiles so the visual
@@ -132,9 +141,9 @@ export function CallParticipantTile(props: ParticipantTileProps) {
}
>
{video ? (
<VideoStub {...props} small={small} fit={focused ? 'contain' : 'cover'} />
<VideoStub {...tileProps} small={small} fit={focused ? 'contain' : 'cover'} />
) : (
<AudioContent {...props} small={small} />
<AudioContent {...tileProps} small={small} />
)}
{speaking && (
@@ -187,7 +196,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
/>
)}
<span className="truncate">
{displayName}
{resolvedName}
{me ? ' (du)' : ''}
</span>
{e2ee && (
@@ -282,6 +291,7 @@ function AudioContent({
src={avatarUrl}
alt=""
className="relative h-full w-full rounded-full object-cover"
loading="eager"
/>
) : (
<span
@@ -357,7 +367,7 @@ function VideoStub({
}
>
{avatarUrl ? (
<img src={avatarUrl} alt="" className="h-full w-full rounded-full object-cover" />
<img src={avatarUrl} alt="" className="h-full w-full rounded-full object-cover" loading="eager" />
) : (
letter
)}
@@ -0,0 +1,61 @@
import {
C4_COLS,
C4_ROWS,
c4DropRow,
c4WinningCells,
type Cell,
} from '@chat-app/shared/chat';
interface Props {
board: Cell[];
myPlayerIdx: 0 | 1 | null;
disabled: boolean;
onMove: (column: number) => void;
}
export function ConnectFourBoard({ board, myPlayerIdx, disabled, onMove }: Props) {
const winCells = c4WinningCells(board);
const winSet = new Set<number>(winCells ?? []);
return (
<div
className="mx-auto w-full max-w-2xl rounded-2xl bg-sky-900/40 p-3"
role="grid"
aria-label="Vier-Gewinnt"
>
<div
className="grid gap-1.5"
style={{ gridTemplateColumns: 'repeat(' + C4_COLS + ', minmax(0, 1fr))' }}
>
{Array.from({ length: C4_ROWS * C4_COLS }, (_, idx) => {
const cell = board[idx];
const col = idx % C4_COLS;
const dropTo = c4DropRow(board, col);
const canClickColumn = !disabled && dropTo >= 0 && myPlayerIdx !== null;
const inWin = winSet.has(idx);
return (
<button
key={idx}
type="button"
onClick={() => canClickColumn && onMove(col)}
disabled={!canClickColumn}
aria-label={'Spalte ' + (col + 1) + (cell !== null ? ' belegt' : '')}
className={
'flex aspect-square items-center justify-center rounded-full border-2 transition ' +
(inWin
? 'border-emerald-300 bg-emerald-400 shadow-[0_0_12px_rgba(110,231,183,0.7)]'
: cell === 0
? 'border-rose-300 bg-rose-500'
: cell === 1
? 'border-amber-300 bg-amber-400'
: canClickColumn
? 'cursor-pointer border-sky-700 bg-sky-950 hover:bg-sky-900'
: 'cursor-not-allowed border-sky-800 bg-sky-950 opacity-80')
}
/>
);
})}
</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)
@@ -111,9 +129,9 @@ function HeaderBar({
className="relative shrink-0 rounded-full text-left transition enabled:cursor-pointer enabled:hover:ring-2 enabled:hover:ring-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-default"
>
{isDm ? (
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" loading="eager" />
) : peerAvatar ? (
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" loading="eager" />
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/20 text-accent">
<UsersIcon className="h-5 w-5" />
@@ -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 ? (
@@ -178,7 +197,7 @@ function HeaderBar({
interface HeaderActionButtonProps {
label: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
onClick?: () => void;
disabled?: boolean;
tone?: 'default' | 'accent';
@@ -1,6 +1,7 @@
import {
muteDurationToIso,
setConversationArchived,
setConversationMentionsOnly,
setConversationMutedUntil,
} from '@chat-app/shared/chat';
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
@@ -8,12 +9,13 @@ import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { supabase } from '../lib/supabase';
import { ArchiveIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
import { ArchiveIcon, AtIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
interface Props {
conversationId: string;
archived: boolean;
mutedUntil: string | null;
mentionsOnly: boolean;
}
interface MuteOption {
@@ -50,7 +52,7 @@ interface MenuPos {
// escape the sidebar's `overflow-y-auto` clipping context. Position is
// computed from the trigger's bounding rect — menu anchors right-aligned
// under the trigger so it doesn't push off-screen on narrow windows.
export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Props) {
export function ConversationRowMenu({ conversationId, archived, mutedUntil, mentionsOnly }: Props) {
const { t } = useTranslation(['app']);
const [open, setOpen] = useState(false);
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
@@ -145,6 +147,21 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Pr
[conversationId],
);
const handleMentionsOnly = useCallback(
async (next: boolean) => {
setOpen(false);
try {
await setConversationMentionsOnly(supabase, {
conversationId,
mentionsOnly: next,
});
} catch (err: unknown) {
console.warn('mentions-only toggle failed', err);
}
},
[conversationId],
);
return (
<>
<button
@@ -196,6 +213,16 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Pr
}}
hasSubmenu={!isMuted}
/>
<MenuItem
icon={<AtIcon className="h-4 w-4" />}
label={
(mentionsOnly ? '✓ ' : '') +
t('app:chats.mentions_only', {
defaultValue: 'Nur bei @Mentions benachrichtigen',
})
}
onClick={() => void handleMentionsOnly(!mentionsOnly)}
/>
</div>,
document.body,
)}
@@ -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>
);
}
+133
View File
@@ -0,0 +1,133 @@
import confetti from 'canvas-confetti';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useGame } from '../hooks/useGame';
import { ConnectFourBoard } from './ConnectFourBoard';
import { TicTacToeBoard } from './TicTacToeBoard';
import { XIcon } from './icons';
interface Props {
gameId: string;
onClose: () => void;
}
export function GameModal({ gameId, onClose }: Props) {
const { t } = useTranslation();
const { game, loading, error, makeMove } = useGame(gameId);
const { session: auth } = useAuth();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const myUserId = auth?.user.id ?? null;
const myPlayerIdx: 0 | 1 | null =
!game || !myUserId
? null
: game.players[0] === myUserId
? 0
: game.players[1] === myUserId
? 1
: null;
const isMyTurn = !!game && game.currentTurnUserId === myUserId;
const finished = !!game?.finishedAt;
const winnerIdx: 0 | 1 | null =
!game?.winnerUserId
? null
: game.players[0] === game.winnerUserId
? 0
: game.players[1] === game.winnerUserId
? 1
: null;
useEffect(() => {
if (finished && winnerIdx !== null && winnerIdx === myPlayerIdx) {
// Respect the OS-level reduced-motion preference.
if (
typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
return;
}
// Two bursts from the lower corners for a celebratory feel.
void confetti({
particleCount: 80,
spread: 60,
origin: { x: 0.2, y: 0.9 },
});
void confetti({
particleCount: 80,
spread: 60,
origin: { x: 0.8, y: 0.9 },
});
}
}, [finished, winnerIdx, myPlayerIdx]);
const title =
game?.gameType === 'c4'
? t('app:game.c4', { defaultValue: 'Vier-Gewinnt' })
: t('app:game.ttt', { defaultValue: 'Tic-Tac-Toe' });
const statusLine = (() => {
if (loading) return t('app:game.loading', { defaultValue: 'Lädt…' });
if (error) return error;
if (!game) return t('app:game.missing', { defaultValue: 'Spiel nicht gefunden.' });
if (finished) {
if (winnerIdx === null) return t('app:game.draw', { defaultValue: 'Unentschieden!' });
if (winnerIdx === myPlayerIdx) return t('app:game.you_win', { defaultValue: 'Du hast gewonnen!' });
return t('app:game.you_lose', { defaultValue: 'Du hast verloren.' });
}
if (myPlayerIdx === null) return t('app:game.spectator', { defaultValue: 'Du schaust nur zu.' });
if (isMyTurn) return t('app:game.your_turn', { defaultValue: 'Du bist dran' });
return t('app:game.opponent_turn', { defaultValue: 'Gegner ist dran…' });
})();
return (
<div
role="dialog"
aria-modal="true"
aria-label={title}
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">{title}</h2>
<button
type="button"
onClick={onClose}
aria-label={t('app:game.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-auto p-6">
{game?.gameType === 'ttt' ? (
<TicTacToeBoard
board={game.state.board}
myPlayerIdx={myPlayerIdx}
disabled={!isMyTurn || finished}
onMove={(cell) => void makeMove({ cell }).catch(() => {})}
/>
) : game?.gameType === 'c4' ? (
<ConnectFourBoard
board={game.state.board}
myPlayerIdx={myPlayerIdx}
disabled={!isMyTurn || finished}
onMove={(column) => void makeMove({ column }).catch(() => {})}
/>
) : null}
</div>
<footer className="flex shrink-0 items-center justify-center border-t border-line/40 bg-surface-2 px-4 py-3 text-sm font-medium text-fg">
{statusLine}
</footer>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useMemo, useState } from 'react';
import {
featuredGifs,
getRecentGifs,
type GifResult,
rememberRecentGif,
searchGifs,
} from '../lib/tenor';
import { SpinnerIcon, XIcon } from './icons';
interface Props {
open: boolean;
onClose: () => void;
onPick: (gif: GifResult) => void;
}
type Tab = 'trending' | 'search' | 'recent';
export function GifPicker({ open, onClose, onPick }: Props) {
const [tab, setTab] = useState<Tab>('trending');
const [query, setQuery] = useState('');
const [results, setResults] = useState<GifResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const recent = useMemo(() => getRecentGifs(), [open]);
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError(null);
const run = async () => {
try {
const gifs = tab === 'search' && query.trim().length > 0
? await searchGifs(query)
: tab === 'trending'
? await featuredGifs()
: [];
if (!cancelled) setResults(gifs);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'GIFs gerade nicht verfügbar');
} finally {
if (!cancelled) setLoading(false);
}
};
void run();
return () => { cancelled = true; };
}, [open, tab, query]);
if (!open) return null;
const visible = tab === 'recent' ? recent : results;
return (
<div className="absolute bottom-full left-0 z-30 mb-2 w-[440px] rounded-2xl border border-line bg-surface-2 shadow-xl">
<header className="flex items-center justify-between border-b border-line px-3 py-2">
<div className="flex items-center gap-1">
{(['trending', 'search', 'recent'] as Tab[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={
'cursor-pointer rounded-md px-2 py-1 text-xs font-medium transition ' +
(t === tab ? 'bg-accent/15 text-accent' : 'text-fg-muted hover:text-fg')
}
>
{t === 'trending' ? 'Trending' : t === 'search' ? 'Suche' : 'Zuletzt'}
</button>
))}
</div>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
>
<XIcon className="h-3.5 w-3.5" />
</button>
</header>
{tab === 'search' && (
<div className="border-b border-line px-3 py-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="GIFs suchen…"
className="w-full rounded-md border border-line bg-surface-3 px-2 py-1.5 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40"
/>
</div>
)}
<div className="grid max-h-[360px] grid-cols-3 gap-1.5 overflow-y-auto p-2">
{loading && (
<div className="col-span-3 flex justify-center py-6 text-fg-muted">
<SpinnerIcon className="h-5 w-5" />
</div>
)}
{error && (
<p className="col-span-3 px-2 py-4 text-center text-xs text-rose-300">
{error === 'giphy_api_key_missing'
? 'GIFs sind nicht konfiguriert (VITE_GIPHY_API_KEY fehlt).'
: 'GIFs gerade nicht verfügbar.'}
</p>
)}
{!loading && !error && visible.length === 0 && (
<p className="col-span-3 px-2 py-6 text-center text-xs text-fg-muted">
{tab === 'recent' ? 'Noch keine zuletzt verwendeten GIFs.' : 'Keine Treffer.'}
</p>
)}
{!loading && !error && visible.map((g) => (
<button
key={g.id}
type="button"
onClick={() => { rememberRecentGif(g); onPick(g); onClose(); }}
className="overflow-hidden rounded-md border border-line bg-surface-3 hover:border-accent/40"
title={g.description}
>
<img src={g.previewUrl} alt={g.description || 'GIF'} className="h-24 w-full object-cover" loading="lazy" />
</button>
))}
</div>
</div>
);
}
@@ -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();
}
+2 -2
View File
@@ -1542,8 +1542,8 @@ function FullscreenCall({
function StripToggleIcon({ hidden }: { hidden: boolean }) {
return (
<svg
width="18"
height="18"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -1,8 +1,11 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { useNickname } from '../lib/friendNicknames';
import { Avatar } from './Avatar';
type Member = ConversationSummary['members'][number];
interface Props {
members: ConversationSummary['members'];
query: string;
@@ -63,19 +66,43 @@ 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;
{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
key={m.userId}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => setActive(idx)}
onMouseEnter={onActivate}
onClick={() => {
if (m.profile?.username) onSelect(m.profile.username);
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 ' +
@@ -86,14 +113,11 @@ export function MentionAutocomplete({ members, query, excludeUserId, onSelect, o
>
<Avatar
displayName={name}
url={m.profile?.avatarUrl ?? null}
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>
);
})}
</div>
);
}
+143 -12
View File
@@ -5,11 +5,12 @@ import {
softDeleteMessage,
} from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useNickname } from '../lib/friendNicknames';
import { ensureInstallId } from '../lib/installId';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
@@ -30,6 +31,7 @@ import {
PencilIcon,
PhoneIcon,
PhoneOffIcon,
PinIcon,
ReplyIcon,
SmileIcon,
SpinnerIcon,
@@ -58,8 +60,17 @@ interface Props {
senderAvatarUrl?: string | null | undefined;
conversationId: string;
reactions: AggregatedReaction[];
onToggleReaction: (emoji: string) => Promise<void>;
onVotePoll?: (emoji: string, optionEmojis: string[]) => Promise<void>;
/**
* Toggle a reaction on this message. Receives the message id so the parent
* can pass a stable handler reference across every row (lets `React.memo`
* actually skip re-renders triggered by composer keystrokes / typing pings).
*/
onToggleReaction: (messageId: string, emoji: string) => Promise<void>;
/**
* Cast/clear an exclusive poll vote. Receives the message id for the same
* reason as `onToggleReaction`.
*/
onVotePoll?: (messageId: string, emoji: string, optionEmojis: string[]) => Promise<void>;
showSeen?: boolean;
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
deliveryState?: 'sent' | 'delivered' | 'read';
@@ -75,9 +86,13 @@ 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({
function MessageBubbleInner({
message,
mine,
groupedWithPrev,
@@ -96,10 +111,18 @@ export function MessageBubble({
onForward,
onAvatarClick,
highlighted = false,
isPinned = false,
onTogglePin,
}: Props) {
const { t } = useTranslation(['app']);
const { session } = useAuth();
// Per-viewer nickname override for the message sender. Fallback chain
// keeps the existing behavior when no nickname is set. Skipped for the
// quoted-sender snippet — that's a different user.
const senderName = useNickname(message.senderId, senderDisplayName ?? '');
const resolvedSenderDisplayName = senderName.length > 0 ? senderName : null;
const parsed = parseMessagePayload(message.plaintext);
const initialText = parsed.kind === 'text' ? parsed.text : '';
const initialAttachments = parsed.kind === 'text' ? parsed.attachments : [];
@@ -243,12 +266,12 @@ export function MessageBubble({
async (emoji: string) => {
setPickerOpen(false);
try {
await onToggleReaction(emoji);
await onToggleReaction(message.id, emoji);
} catch (err: unknown) {
console.error('toggleReaction failed', err);
}
},
[onToggleReaction],
[onToggleReaction, message.id],
);
const copyableText =
@@ -281,7 +304,7 @@ export function MessageBubble({
<AvatarSlot
show={isLastOfRun}
url={senderAvatarUrl ?? null}
displayName={senderDisplayName ?? null}
displayName={resolvedSenderDisplayName}
{...(onAvatarClick
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
: {})}
@@ -299,7 +322,7 @@ export function MessageBubble({
parsed={parsed}
mine={mine}
time={time}
senderDisplayName={senderDisplayName ?? null}
senderDisplayName={resolvedSenderDisplayName}
/>
);
}
@@ -315,7 +338,7 @@ export function MessageBubble({
<AvatarSlot
show={isLastOfRun}
url={senderAvatarUrl ?? null}
displayName={senderDisplayName ?? null}
displayName={resolvedSenderDisplayName}
{...(onAvatarClick
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
: {})}
@@ -428,6 +451,84 @@ export function MessageBubble({
defaultValue: 'Nachricht nicht lesbar',
})}
</span>
) : parsed.kind === 'game' ? (
<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="6" width="18" height="12" rx="3" />
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
</svg>
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-fg">{parsed.gameType === 'c4' ? 'Vier-Gewinnt' : 'Tic-Tac-Toe'}</div>
<div className="text-xs text-fg-muted">Gemeinsam spielen</div>
</div>
<button
type="button"
onClick={() => {
window.dispatchEvent(
new CustomEvent('chatapp:open-game', { detail: { id: parsed.gameId } }),
);
}}
disabled={!parsed.gameId}
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"
>
Spielen
</button>
</div>
) : parsed.kind === 'watch_together' ? (
<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="14" rx="2" />
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
</svg>
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-fg">Watch Together</div>
<div className="text-xs text-fg-muted">YouTube synchronisiert ansehen</div>
</div>
<button
type="button"
onClick={() => {
window.dispatchEvent(
new CustomEvent('chatapp:open-watch-together', { detail: { id: parsed.sessionId } }),
);
}}
disabled={!parsed.sessionId}
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"
>
Beitreten
</button>
</div>
) : 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}
@@ -435,7 +536,9 @@ export function MessageBubble({
reactions={reactions}
mine={mine}
onVote={(emoji) =>
onVotePoll ? onVotePoll(emoji, pollOptionEmojis) : onToggleReaction(emoji)
onVotePoll
? onVotePoll(message.id, emoji, pollOptionEmojis)
: onToggleReaction(message.id, emoji)
}
/>
) : (
@@ -451,7 +554,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} />;
@@ -501,7 +604,7 @@ export function MessageBubble({
<button
key={r.emoji + ':' + r.count}
type="button"
onClick={() => void onToggleReaction(r.emoji)}
onClick={() => void onToggleReaction(message.id, r.emoji)}
className={
'inline-flex origin-bottom animate-reaction-pop cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(r.mine
@@ -635,6 +738,15 @@ export function MessageBubble({
setContextMenu(null);
void handleDelete();
}}
isPinned={isPinned}
{...(onTogglePin
? {
onTogglePin: () => {
setContextMenu(null);
onTogglePin(message.id);
},
}
: {})}
/>
)}
</div>
@@ -642,6 +754,14 @@ export function MessageBubble({
);
}
/**
* Memoized export. Skips re-rendering when none of its props' shallow
* references change — i.e. when the parent re-renders due to composer
* keystrokes, typing-indicator updates, presence pings, etc. Relies on
* the parent passing stable callback refs (see `ConversationPage`).
*/
export const MessageBubble = memo(MessageBubbleInner);
function PollCard({
question,
options,
@@ -732,6 +852,8 @@ function MessageContextMenu({
onEdit,
onCopy,
onDelete,
isPinned,
onTogglePin,
}: {
x: number;
y: number;
@@ -746,6 +868,8 @@ function MessageContextMenu({
onEdit: () => void;
onCopy: () => void;
onDelete: () => void;
isPinned?: boolean;
onTogglePin?: () => void;
}) {
useEffect(() => {
function onKey(e: KeyboardEvent) {
@@ -812,6 +936,13 @@ function MessageContextMenu({
tone="danger"
/>
)}
{onTogglePin && (
<MenuItem
label={isPinned ? 'Anheftung entfernen' : 'Anpinnen'}
icon={<PinIcon className="h-4 w-4" />}
onClick={onTogglePin}
/>
)}
</div>,
document.body,
);
@@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from 'react';
import { getNickname, setNickname } from '../lib/friendNicknames';
interface Props {
open: boolean;
userId: string;
displayName: string;
onClose: () => void;
}
export function NicknameDialog({ open, userId, displayName, onClose }: Props) {
const [value, setValue] = useState('');
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!open) return;
setValue(getNickname(userId) ?? '');
setTimeout(() => inputRef.current?.focus(), 0);
}, [open, userId]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
const submit = (): void => {
setNickname(userId, value);
onClose();
};
return (
<div
role="dialog"
aria-modal="true"
aria-label="Spitzname setzen"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="w-full max-w-sm rounded-2xl border border-line bg-surface-2 p-5 shadow-xl"
>
<h3 className="font-display text-base font-semibold text-fg">Spitzname für {displayName}</h3>
<p className="mt-1 text-xs text-fg-muted">
Nur du siehst diesen Namen. Leer lassen = den richtigen Namen verwenden.
</p>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
maxLength={32}
placeholder={displayName}
className="mt-4 w-full rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg hover:bg-surface-2"
>
Abbrechen
</button>
<button
type="button"
onClick={submit}
className="cursor-pointer rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg hover:brightness-110"
>
Speichern
</button>
</div>
</div>
</div>
);
}
@@ -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>
);
}
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { isWipeOnCloseEnabled, setWipeOnClose } from '../lib/memoryWipeSettings';
import {
changePin,
type LegacyMigrationReport,
@@ -19,6 +20,7 @@ export function SecurityCenter({ userId }: Props) {
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);
@@ -126,6 +128,27 @@ export function SecurityCenter({ userId }: Props) {
)}
</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>
+3 -3
View File
@@ -17,7 +17,7 @@ import {
interface NavItem {
to: string;
labelKey: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
badge?: 'friends' | 'chats';
}
@@ -95,7 +95,7 @@ export function Sidebar() {
interface RailNavLinkProps {
to: string;
label: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
badge?: number;
}
@@ -135,7 +135,7 @@ function RailNavLink({ to, label, icon: Icon, badge = 0 }: RailNavLinkProps) {
interface RailIconButtonProps {
label: string;
onClick: () => void;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
tone?: 'default' | 'danger';
}
@@ -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,53 @@
import { type Cell, tttWinningLine } from '@chat-app/shared/chat';
interface Props {
board: Cell[];
myPlayerIdx: 0 | 1 | null;
disabled: boolean;
onMove: (cell: number) => void;
}
const MARK = ['×', '○'] as const;
export function TicTacToeBoard({ board, myPlayerIdx, disabled, onMove }: Props) {
const winLine = tttWinningLine(board);
const winSet = new Set<number>(winLine ?? []);
return (
<div
className="mx-auto grid w-full max-w-md grid-cols-3 gap-2 p-4"
style={{ aspectRatio: '1 / 1' }}
role="grid"
aria-label="Tic-Tac-Toe"
>
{board.map((cell, i) => {
const filled = cell !== null;
const inWin = winSet.has(i);
const canClick = !disabled && !filled && myPlayerIdx !== null;
return (
<button
key={i}
type="button"
onClick={() => canClick && onMove(i)}
disabled={!canClick}
aria-label={'Feld ' + (i + 1) + (filled ? ' belegt' : ' frei')}
className={
'flex aspect-square items-center justify-center rounded-xl border-2 text-5xl font-bold transition ' +
(inWin
? 'border-emerald-400 bg-emerald-400/20 text-emerald-200'
: filled
? cell === 0
? 'border-rose-500/60 bg-rose-500/10 text-rose-300'
: 'border-sky-500/60 bg-sky-500/10 text-sky-300'
: canClick
? 'cursor-pointer border-line bg-surface-2 text-fg-muted hover:bg-surface-3 hover:text-fg'
: 'cursor-not-allowed border-line bg-surface-2 text-fg-muted opacity-60')
}
>
{filled ? MARK[cell as 0 | 1] : ''}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,87 @@
import { useState } from 'react';
import { markAttachmentViewed } from '@chat-app/shared/chat';
import { supabase } from '../lib/supabase';
import { EyeOffIcon, LockIcon } from './icons';
interface Props {
attachmentId: string;
/** Already-viewed timestamp from the DB row. Renders tombstone immediately. */
viewedAt: string | null;
/** True iff the local user is the sender — they don't burn the view. */
isSender: boolean;
/** Decrypted image source; only fetched/displayed inside the lightbox. */
src: string;
}
// Three states:
// 1. viewedAt is null AND user is recipient → blurred lock card; tap opens
// fullscreen lightbox AND fires the mark-viewed RPC.
// 2. viewedAt is set → tombstone "Angesehen am …".
// 3. user is sender → normal image, tombstone update appears once recipient burns it.
export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) {
const [revealedAt, setRevealedAt] = useState<string | null>(viewedAt);
const [fullscreen, setFullscreen] = useState(false);
const burned = revealedAt !== null;
if (burned && !isSender) {
return (
<div className="flex h-32 w-48 items-center justify-center rounded-lg border border-dashed border-line bg-surface-3 text-xs text-fg-muted">
<EyeOffIcon className="mr-2 h-4 w-4" />
Angesehen am {new Date(revealedAt).toLocaleString()}
</div>
);
}
if (isSender) {
return (
<div className="relative">
<img src={src} alt="" className="max-h-72 rounded-lg" />
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-black/60 px-2 py-0.5 text-[10px] font-semibold text-white">
<EyeOffIcon className="h-3 w-3" /> Einmal ansehen
</span>
{burned && (
<span className="absolute right-2 bottom-2 rounded-full bg-emerald-500/80 px-2 py-0.5 text-[10px] font-semibold text-white">
Angesehen
</span>
)}
</div>
);
}
// Recipient, not yet viewed.
const handleOpen = async (): Promise<void> => {
try {
const res = await markAttachmentViewed(supabase, attachmentId);
if (res.viewedAt) setRevealedAt(res.viewedAt);
} catch (err) {
console.warn('mark-viewed failed', err);
}
setFullscreen(true);
};
return (
<>
<button
type="button"
onClick={() => void handleOpen()}
className="relative flex h-48 w-64 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-3 text-fg-muted hover:border-accent/40"
>
<LockIcon className="h-6 w-6 text-accent" />
<span className="text-xs font-medium">Einmal ansehen antippen</span>
</button>
{fullscreen && (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-6"
onClick={() => setFullscreen(false)}
>
<img src={src} alt="" className="max-h-full max-w-full rounded-lg" />
</div>
)}
</>
);
}
@@ -0,0 +1,260 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useWatchSession } from '../hooks/useWatchSession';
import { XIcon } from './icons';
interface YTPlayer {
playVideo: () => void;
pauseVideo: () => void;
seekTo: (seconds: number, allowSeekAhead: boolean) => void;
getCurrentTime: () => number;
getPlayerState: () => number;
destroy: () => void;
}
interface YTPlayerOptions {
width: string | number;
height: string | number;
videoId: string;
playerVars?: { autoplay?: 0 | 1; controls?: 0 | 1; modestbranding?: 0 | 1 };
events?: {
onReady?: (ev: { target: YTPlayer }) => void;
onStateChange?: (ev: { data: number; target: YTPlayer }) => void;
};
}
interface YTNamespace {
Player: new (elementId: string | HTMLElement, opts: YTPlayerOptions) => YTPlayer;
PlayerState: { UNSTARTED: -1; ENDED: 0; PLAYING: 1; PAUSED: 2; BUFFERING: 3; CUED: 5 };
}
declare global {
interface Window {
YT?: YTNamespace;
onYouTubeIframeAPIReady?: () => void;
}
}
const IFRAME_API_URL = 'https://www.youtube.com/iframe_api';
let apiPromise: Promise<YTNamespace> | null = null;
function loadIframeApi(): Promise<YTNamespace> {
if (apiPromise) return apiPromise;
apiPromise = new Promise((resolve, reject) => {
if (typeof window === 'undefined') {
reject(new Error('no window'));
return;
}
if (window.YT?.Player) {
resolve(window.YT);
return;
}
const prev = window.onYouTubeIframeAPIReady;
window.onYouTubeIframeAPIReady = () => {
try {
prev?.();
} catch {
/* ignore */
}
if (window.YT?.Player) resolve(window.YT);
else reject(new Error('YT namespace missing after ready'));
};
const existing = document.querySelector(
'script[src="' + IFRAME_API_URL + '"]',
);
if (existing) return;
const tag = document.createElement('script');
tag.src = IFRAME_API_URL;
tag.async = true;
document.head.appendChild(tag);
});
return apiPromise;
}
interface Props {
sessionId: string;
onClose: () => void;
}
const DRIFT_THRESHOLD_SECONDS = 2;
export function WatchTogetherModal({ sessionId, onClose }: Props) {
const { t } = useTranslation();
const { session, pushState, endSession, error, loading } = useWatchSession(sessionId);
const { session: auth } = useAuth();
const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<YTPlayer | null>(null);
const [playerReady, setPlayerReady] = useState(false);
const ownerId = session?.ownerUserId ?? null;
const isOwner = !!auth?.user.id && auth.user.id === ownerId;
const ended = !!session?.endedAt;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
useEffect(() => {
if (!session?.videoId || !mountRef.current) return;
if (playerRef.current) return;
let disposed = false;
void loadIframeApi().then((YT) => {
if (disposed || !mountRef.current) return;
playerRef.current = new YT.Player(mountRef.current, {
width: '100%',
height: '100%',
videoId: session.videoId,
playerVars: { autoplay: 1, controls: isOwner ? 1 : 0, modestbranding: 1 },
events: {
onReady: () => setPlayerReady(true),
onStateChange: (ev) => {
if (!isOwner) return;
const playing = ev.data === YT.PlayerState.PLAYING;
const pos = ev.target.getCurrentTime();
pushState({ playing, positionSeconds: pos, updatedAtMs: Date.now() });
},
},
});
}).catch((err) => {
console.error('YouTube IFrame API failed', err);
});
return () => {
disposed = true;
try {
playerRef.current?.destroy();
} catch {
/* ignore */
}
playerRef.current = null;
};
}, [session?.videoId, isOwner, pushState]);
useEffect(() => {
if (!isOwner || !playerReady) return;
const id = window.setInterval(() => {
const p = playerRef.current;
if (!p) return;
try {
const state = p.getPlayerState();
const playing = state === window.YT?.PlayerState.PLAYING;
pushState({
playing,
positionSeconds: p.getCurrentTime(),
updatedAtMs: Date.now(),
});
} catch {
/* ignore */
}
}, 1000);
return () => window.clearInterval(id);
}, [isOwner, playerReady, pushState]);
useEffect(() => {
if (isOwner || !playerReady || !session) return;
const p = playerRef.current;
if (!p) return;
const remoteAgeSec = Math.max(0, (Date.now() - session.currentState.updatedAtMs) / 1000);
const projectedRemote = session.currentState.playing
? session.currentState.positionSeconds + remoteAgeSec
: session.currentState.positionSeconds;
let local = 0;
try {
local = p.getCurrentTime();
} catch {
return;
}
if (Math.abs(local - projectedRemote) > DRIFT_THRESHOLD_SECONDS) {
try {
p.seekTo(projectedRemote, true);
} catch {
/* ignore */
}
}
try {
const state = p.getPlayerState();
const localPlaying = state === window.YT?.PlayerState.PLAYING;
if (session.currentState.playing && !localPlaying) {
p.playVideo();
} else if (!session.currentState.playing && localPlaying) {
p.pauseVideo();
}
} catch {
/* ignore */
}
}, [isOwner, playerReady, session]);
const handleClose = async () => {
if (isOwner && !ended) {
try {
await endSession();
} catch (err) {
console.warn('endSession failed', err);
}
}
onClose();
};
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('app:watch.title', { defaultValue: 'Watch Together' })}
className="fixed inset-0 z-[80] flex flex-col bg-black"
>
<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:watch.title', { defaultValue: 'Watch Together' })}
{ended && (
<span className="ml-2 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:watch.ended', { defaultValue: 'Beendet' })}
</span>
)}
</h2>
<button
type="button"
onClick={() => void handleClose()}
aria-label={t('app:watch.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 bg-black p-4">
{loading ? (
<p className="text-sm text-fg-muted">
{t('app:watch.loading', { defaultValue: 'Lädt…' })}
</p>
) : error ? (
<p className="text-sm text-rose-400">{error}</p>
) : !session ? (
<p className="text-sm text-fg-muted">
{t('app:watch.missing', { defaultValue: 'Session nicht gefunden.' })}
</p>
) : (
<div className="aspect-video w-full max-w-5xl">
<div ref={mountRef} className="h-full w-full" />
</div>
)}
</div>
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-line/40 bg-surface-2 px-4 py-2 text-xs text-fg-muted">
<span>
{isOwner
? t('app:watch.you_are_host', { defaultValue: 'Du steuerst die Wiedergabe.' })
: t('app:watch.you_are_guest', { defaultValue: 'Nur der Host kann steuern.' })}
</span>
<span>
{session?.currentState.playing
? t('app:watch.playing', { defaultValue: '▶ Läuft' })
: t('app:watch.paused', { defaultValue: '⏸ Pause' })}
</span>
</footer>
</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>
);
}
+122 -61
View File
@@ -1,6 +1,7 @@
// Inline SVG icons — no icon library dependency. Lucide-style stroke=1.75.
import { memo } from 'react';
type IconProps = React.SVGProps<SVGSVGElement>;
type IconProps = React.ComponentPropsWithoutRef<'svg'>;
function Base({ children, ...props }: IconProps & { children: React.ReactNode }) {
return (
@@ -20,7 +21,7 @@ function Base({ children, ...props }: IconProps & { children: React.ReactNode })
);
}
export function MailIcon(props: IconProps) {
function MailIconInner(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="5" width="18" height="14" rx="2" />
@@ -28,8 +29,9 @@ export function MailIcon(props: IconProps) {
</Base>
);
}
export const MailIcon = memo(MailIconInner);
export function AtIcon(props: IconProps) {
function AtIconInner(props: IconProps) {
return (
<Base {...props}>
<circle cx="12" cy="12" r="4" />
@@ -37,8 +39,9 @@ export function AtIcon(props: IconProps) {
</Base>
);
}
export const AtIcon = memo(AtIconInner);
export function TicketIcon(props: IconProps) {
function TicketIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M3 8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2a2 2 0 1 0 0 4v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2a2 2 0 1 0 0-4V8Z" />
@@ -46,8 +49,9 @@ export function TicketIcon(props: IconProps) {
</Base>
);
}
export const TicketIcon = memo(TicketIconInner);
export function ArrowRightIcon(props: IconProps) {
function ArrowRightIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M5 12h14" />
@@ -55,8 +59,9 @@ export function ArrowRightIcon(props: IconProps) {
</Base>
);
}
export const ArrowRightIcon = memo(ArrowRightIconInner);
export function CheckCircleIcon(props: IconProps) {
function CheckCircleIconInner(props: IconProps) {
return (
<Base {...props}>
<circle cx="12" cy="12" r="9" />
@@ -64,8 +69,9 @@ export function CheckCircleIcon(props: IconProps) {
</Base>
);
}
export const CheckCircleIcon = memo(CheckCircleIconInner);
export function AlertIcon(props: IconProps) {
function AlertIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M12 9v4" />
@@ -74,8 +80,9 @@ export function AlertIcon(props: IconProps) {
</Base>
);
}
export const AlertIcon = memo(AlertIconInner);
export function ShieldIcon(props: IconProps) {
function ShieldIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M12 3 4 6v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V6l-8-3Z" />
@@ -83,8 +90,9 @@ export function ShieldIcon(props: IconProps) {
</Base>
);
}
export const ShieldIcon = memo(ShieldIconInner);
export function LockIcon(props: IconProps) {
function LockIconInner(props: IconProps) {
return (
<Base {...props}>
<rect x="4" y="11" width="16" height="10" rx="2" />
@@ -92,8 +100,9 @@ export function LockIcon(props: IconProps) {
</Base>
);
}
export const LockIcon = memo(LockIconInner);
export function WifiLowIcon(props: IconProps) {
function WifiLowIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M5 12.55a11 11 0 0 1 14 0" opacity="0.35" />
@@ -102,8 +111,9 @@ export function WifiLowIcon(props: IconProps) {
</Base>
);
}
export const WifiLowIcon = memo(WifiLowIconInner);
export function WifiOffIcon(props: IconProps) {
function WifiOffIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="m2 2 20 20" />
@@ -116,8 +126,9 @@ export function WifiOffIcon(props: IconProps) {
</Base>
);
}
export const WifiOffIcon = memo(WifiOffIconInner);
export function PinIcon(props: IconProps) {
function PinIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M12 17v5" />
@@ -125,8 +136,9 @@ export function PinIcon(props: IconProps) {
</Base>
);
}
export const PinIcon = memo(PinIconInner);
export function EyeOffIcon(props: IconProps) {
function EyeOffIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="m2 2 20 20" />
@@ -136,8 +148,9 @@ export function EyeOffIcon(props: IconProps) {
</Base>
);
}
export const EyeOffIcon = memo(EyeOffIconInner);
export function CaptionsIcon(props: IconProps) {
function CaptionsIconInner(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="6" width="18" height="12" rx="2" />
@@ -146,8 +159,9 @@ export function CaptionsIcon(props: IconProps) {
</Base>
);
}
export const CaptionsIcon = memo(CaptionsIconInner);
export function PinOffIcon(props: IconProps) {
function PinOffIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="m2 2 20 20" />
@@ -157,8 +171,9 @@ export function PinOffIcon(props: IconProps) {
</Base>
);
}
export const PinOffIcon = memo(PinOffIconInner);
export function SparklesIcon(props: IconProps) {
function SparklesIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M12 3v4" />
@@ -172,8 +187,9 @@ export function SparklesIcon(props: IconProps) {
</Base>
);
}
export const SparklesIcon = memo(SparklesIconInner);
export function SpinnerIcon(props: IconProps) {
function SpinnerIconInner(props: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -208,16 +224,18 @@ export function SpinnerIcon(props: IconProps) {
</svg>
);
}
export const SpinnerIcon = memo(SpinnerIconInner);
export function ChatBubbleIcon(props: IconProps) {
function ChatBubbleIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z" />
</Base>
);
}
export const ChatBubbleIcon = memo(ChatBubbleIconInner);
export function UsersIcon(props: IconProps) {
function UsersIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
@@ -227,8 +245,9 @@ export function UsersIcon(props: IconProps) {
</Base>
);
}
export const UsersIcon = memo(UsersIconInner);
export function GearIcon(props: IconProps) {
function GearIconInner(props: IconProps) {
return (
<Base {...props}>
<circle cx="12" cy="12" r="3" />
@@ -236,8 +255,9 @@ export function GearIcon(props: IconProps) {
</Base>
);
}
export const GearIcon = memo(GearIconInner);
export function SearchIcon(props: IconProps) {
function SearchIconInner(props: IconProps) {
return (
<Base {...props}>
<circle cx="11" cy="11" r="7" />
@@ -245,16 +265,18 @@ export function SearchIcon(props: IconProps) {
</Base>
);
}
export const SearchIcon = memo(SearchIconInner);
export function PlusIcon(props: IconProps) {
function PlusIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M12 5v14M5 12h14" />
</Base>
);
}
export const PlusIcon = memo(PlusIconInner);
export function SignOutIcon(props: IconProps) {
function SignOutIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
@@ -263,32 +285,36 @@ export function SignOutIcon(props: IconProps) {
</Base>
);
}
export const SignOutIcon = memo(SignOutIconInner);
export function MenuIcon(props: IconProps) {
function MenuIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M3 6h18M3 12h18M3 18h18" />
</Base>
);
}
export const MenuIcon = memo(MenuIconInner);
export function ChevronDownIcon(props: IconProps) {
function ChevronDownIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="m6 9 6 6 6-6" />
</Base>
);
}
export const ChevronDownIcon = memo(ChevronDownIconInner);
export function PencilIcon(props: IconProps) {
function PencilIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</Base>
);
}
export const PencilIcon = memo(PencilIconInner);
export function TrashIcon(props: IconProps) {
function TrashIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M3 6h18" />
@@ -299,8 +325,9 @@ export function TrashIcon(props: IconProps) {
</Base>
);
}
export const TrashIcon = memo(TrashIconInner);
export function SmileIcon(props: IconProps) {
function SmileIconInner(props: IconProps) {
return (
<Base {...props}>
<circle cx="12" cy="12" r="9" />
@@ -310,8 +337,9 @@ export function SmileIcon(props: IconProps) {
</Base>
);
}
export const SmileIcon = memo(SmileIconInner);
export function CopyIcon(props: IconProps) {
function CopyIconInner(props: IconProps) {
return (
<Base {...props}>
<rect x="9" y="9" width="13" height="13" rx="2" />
@@ -319,16 +347,18 @@ export function CopyIcon(props: IconProps) {
</Base>
);
}
export const CopyIcon = memo(CopyIconInner);
export function PhoneIcon(props: IconProps) {
function PhoneIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.79 19.79 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92Z" />
</Base>
);
}
export const PhoneIcon = memo(PhoneIconInner);
export function PhoneOffIcon(props: IconProps) {
function PhoneOffIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-3.48-3.05" />
@@ -337,8 +367,9 @@ export function PhoneOffIcon(props: IconProps) {
</Base>
);
}
export const PhoneOffIcon = memo(PhoneOffIconInner);
export function MicIcon(props: IconProps) {
function MicIconInner(props: IconProps) {
return (
<Base {...props}>
<rect x="9" y="2" width="6" height="12" rx="3" />
@@ -348,8 +379,9 @@ export function MicIcon(props: IconProps) {
</Base>
);
}
export const MicIcon = memo(MicIconInner);
export function MicOffIcon(props: IconProps) {
function MicOffIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M1 1l22 22" />
@@ -362,8 +394,9 @@ export function MicOffIcon(props: IconProps) {
</Base>
);
}
export const MicOffIcon = memo(MicOffIconInner);
export function InfoIcon(props: IconProps) {
function InfoIconInner(props: IconProps) {
return (
<Base {...props}>
<circle cx="12" cy="12" r="9" />
@@ -372,16 +405,18 @@ export function InfoIcon(props: IconProps) {
</Base>
);
}
export const InfoIcon = memo(InfoIconInner);
export function XIcon(props: IconProps) {
function XIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M18 6 6 18M6 6l12 12" />
</Base>
);
}
export const XIcon = memo(XIconInner);
export function MonitorShareIcon(props: IconProps) {
function MonitorShareIconInner(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="4" width="18" height="12" rx="2" />
@@ -390,8 +425,9 @@ export function MonitorShareIcon(props: IconProps) {
</Base>
);
}
export const MonitorShareIcon = memo(MonitorShareIconInner);
export function MonitorStopIcon(props: IconProps) {
function MonitorStopIconInner(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="4" width="18" height="12" rx="2" />
@@ -400,8 +436,9 @@ export function MonitorStopIcon(props: IconProps) {
</Base>
);
}
export const MonitorStopIcon = memo(MonitorStopIconInner);
export function SunIcon(props: IconProps) {
function SunIconInner(props: IconProps) {
return (
<Base {...props}>
<circle cx="12" cy="12" r="4" />
@@ -409,16 +446,18 @@ export function SunIcon(props: IconProps) {
</Base>
);
}
export const SunIcon = memo(SunIconInner);
export function MoonIcon(props: IconProps) {
function MoonIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79Z" />
</Base>
);
}
export const MoonIcon = memo(MoonIconInner);
export function GridIcon(props: IconProps) {
function GridIconInner(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="3" width="7" height="7" rx="1.5" />
@@ -428,8 +467,9 @@ export function GridIcon(props: IconProps) {
</Base>
);
}
export const GridIcon = memo(GridIconInner);
export function ImageIcon(props: IconProps) {
function ImageIconInner(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="5" width="18" height="14" rx="2" />
@@ -438,8 +478,9 @@ export function ImageIcon(props: IconProps) {
</Base>
);
}
export const ImageIcon = memo(ImageIconInner);
export function FileIcon(props: IconProps) {
function FileIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z" />
@@ -447,8 +488,9 @@ export function FileIcon(props: IconProps) {
</Base>
);
}
export const FileIcon = memo(FileIconInner);
export function PollIcon(props: IconProps) {
function PollIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M5 19V9" />
@@ -458,8 +500,9 @@ export function PollIcon(props: IconProps) {
</Base>
);
}
export const PollIcon = memo(PollIconInner);
export function FocusIcon(props: IconProps) {
function FocusIconInner(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="3" width="18" height="18" rx="2" />
@@ -467,8 +510,9 @@ export function FocusIcon(props: IconProps) {
</Base>
);
}
export const FocusIcon = memo(FocusIconInner);
export function MaximizeIcon(props: IconProps) {
function MaximizeIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M4 9V5a1 1 0 0 1 1-1h4" />
@@ -478,8 +522,9 @@ export function MaximizeIcon(props: IconProps) {
</Base>
);
}
export const MaximizeIcon = memo(MaximizeIconInner);
export function VideoIcon(props: IconProps) {
function VideoIconInner(props: IconProps) {
return (
<Base {...props}>
<rect x="2" y="6" width="15" height="12" rx="2" />
@@ -487,16 +532,18 @@ export function VideoIcon(props: IconProps) {
</Base>
);
}
export const VideoIcon = memo(VideoIconInner);
export function CrownIcon(props: IconProps) {
function CrownIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="m3 7 4 4 5-6 5 6 4-4v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
</Base>
);
}
export const CrownIcon = memo(CrownIconInner);
export function MusicIcon(props: IconProps) {
function MusicIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M9 18V5l12-2v13" />
@@ -505,16 +552,18 @@ export function MusicIcon(props: IconProps) {
</Base>
);
}
export const MusicIcon = memo(MusicIconInner);
export function SendIcon(props: IconProps) {
function SendIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="m3 11 18-8-8 18-2-8-8-2Z" />
</Base>
);
}
export const SendIcon = memo(SendIconInner);
export function ArchiveIcon(props: IconProps) {
function ArchiveIconInner(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="4" width="18" height="5" rx="1" />
@@ -523,8 +572,9 @@ export function ArchiveIcon(props: IconProps) {
</Base>
);
}
export const ArchiveIcon = memo(ArchiveIconInner);
export function BellIcon(props: IconProps) {
function BellIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M6 8a6 6 0 1 1 12 0c0 4 1.5 5 2 6H4c.5-1 2-2 2-6Z" />
@@ -532,8 +582,9 @@ export function BellIcon(props: IconProps) {
</Base>
);
}
export const BellIcon = memo(BellIconInner);
export function BellOffIcon(props: IconProps) {
function BellOffIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M9.5 18a2.5 2.5 0 0 0 5 0" />
@@ -545,8 +596,9 @@ export function BellOffIcon(props: IconProps) {
</Base>
);
}
export const BellOffIcon = memo(BellOffIconInner);
export function MoreVerticalIcon(props: IconProps) {
function MoreVerticalIconInner(props: IconProps) {
return (
<Base {...props}>
<circle cx="12" cy="5" r="1.5" />
@@ -555,8 +607,9 @@ export function MoreVerticalIcon(props: IconProps) {
</Base>
);
}
export const MoreVerticalIcon = memo(MoreVerticalIconInner);
export function HeadphonesIcon(props: IconProps) {
function HeadphonesIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M3 14v-2a9 9 0 0 1 18 0v2" />
@@ -565,8 +618,9 @@ export function HeadphonesIcon(props: IconProps) {
</Base>
);
}
export const HeadphonesIcon = memo(HeadphonesIconInner);
export function HeadphonesOffIcon(props: IconProps) {
function HeadphonesOffIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M3 14v-2a9 9 0 0 1 11.3-8.7" />
@@ -577,8 +631,9 @@ export function HeadphonesOffIcon(props: IconProps) {
</Base>
);
}
export const HeadphonesOffIcon = memo(HeadphonesOffIconInner);
export function ReplyIcon(props: IconProps) {
function ReplyIconInner(props: IconProps) {
return (
<Base {...props}>
<polyline points="9 17 4 12 9 7" />
@@ -586,8 +641,9 @@ export function ReplyIcon(props: IconProps) {
</Base>
);
}
export const ReplyIcon = memo(ReplyIconInner);
export function ForwardIcon(props: IconProps) {
function ForwardIconInner(props: IconProps) {
return (
<Base {...props}>
<polyline points="15 17 20 12 15 7" />
@@ -595,16 +651,18 @@ export function ForwardIcon(props: IconProps) {
</Base>
);
}
export const ForwardIcon = memo(ForwardIconInner);
export function ChevronUpIcon(props: IconProps) {
function ChevronUpIconInner(props: IconProps) {
return (
<Base {...props}>
<polyline points="18 15 12 9 6 15" />
</Base>
);
}
export const ChevronUpIcon = memo(ChevronUpIconInner);
export function AddUserIcon(props: IconProps) {
function AddUserIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
@@ -613,12 +671,13 @@ export function AddUserIcon(props: IconProps) {
</Base>
);
}
export const AddUserIcon = memo(AddUserIconInner);
// Soft-N mark: rounded-square violet tile with a stylised "N" glyph. Used
// wherever the app needs a standalone icon (sidebar rail, auth screen,
// favicon). Colour decisions sit inside the SVG so consumers just size the
// element via `className`.
export function LogoMark(props: IconProps) {
function LogoMarkInner(props: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -638,10 +697,11 @@ export function LogoMark(props: IconProps) {
</svg>
);
}
export const LogoMark = memo(LogoMarkInner);
// Full lockup: soft-N mark + "Netralax" wordmark. `tone` decides text colour:
// "dark" = white text (use on dark background), "light" = near-black.
export function LogoLockup({
function LogoLockupInner({
tone = 'dark',
...props
}: IconProps & { tone?: 'dark' | 'light' }) {
@@ -676,3 +736,4 @@ export function LogoLockup({
</svg>
);
}
export const LogoLockup = memo(LogoLockupInner);
@@ -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>
);
}
+137 -1
View File
@@ -1,11 +1,15 @@
import {
fetchUserKeyBlob,
getOwnProfile,
listOwnDevices,
registerDevice,
signOut as supabaseSignOut,
touchDeviceLastSeen,
type Profile,
updateOwnProfile,
} from '@chat-app/shared/auth';
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
import type { DevicePlatform } from '@chat-app/shared/supabase';
import type { Session } from '@supabase/supabase-js';
import {
createContext,
@@ -19,7 +23,10 @@ import {
} from 'react';
import { useTranslation } from 'react-i18next';
import { clearDeviceRowId, getDeviceRowId, setDeviceRowId } from '../lib/deviceRowId';
import { ensureInstallId } from '../lib/installId';
import { wipeLocalState } from '../lib/memoryWipe';
import { isWipeOnCloseEnabled } from '../lib/memoryWipeSettings';
import { setSecretStoreUser } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import { cachedUserKey, ensureLegacyMigrated } from '../lib/userIdentity';
@@ -48,6 +55,8 @@ interface AuthContextValue {
refreshProfile: () => Promise<void>;
refreshUserKeyState: () => Promise<void>;
signOut: () => Promise<void>;
revokedRemotely: boolean;
acknowledgeRevocation: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
@@ -58,6 +67,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
const [profile, setProfile] = useState<Profile | null>(null);
const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
const [revokedRemotely, setRevokedRemotely] = useState(false);
const autoOnlineUserRef = useRef<string | null>(null);
// Initial session + auth subscription. We verify the cached JWT against the
@@ -194,6 +204,78 @@ export function AuthProvider({ children }: { children: ReactNode }) {
void registerWebPush(installId);
}, [session]);
// Pre-warm Supabase: fires the first round-trip in the background so the
// first user-triggered query (e.g. loading conversations) doesn't pay
// the cold-connection latency.
useEffect(() => {
if (!session) return;
void supabase.from('profiles').select('id').limit(1).then(() => undefined);
}, [session]);
// Phase 3: ensure this install owns exactly one devices row. The row is
// pure session-list telemetry — it does not carry any cryptographic
// material since the per-user-key refactor. We re-use the row across
// restarts via localStorage (chatapp.deviceRowId.v1); a memory-wipe is
// intentionally treated as "new install".
useEffect(() => {
if (!session) return;
let cancelled = false;
void (async () => {
try {
const existing = getDeviceRowId();
if (existing) {
const rows = await listOwnDevices(supabase);
const match = rows.find((r) => r.id === existing && r.revokedAt === null);
if (match) {
await touchDeviceLastSeen(supabase, existing).catch(() => {});
return;
}
// Row gone / revoked: drop the stale id and fall through to
// registering a fresh one.
clearDeviceRowId();
}
if (cancelled) return;
const hostname =
(typeof window.electronAPI?.getHostname === 'function'
? await window.electronAPI.getHostname().catch(() => null)
: null) ?? 'Desktop';
// Map Node's process.platform values to the schema's device_platform
// enum ('windows' | 'macos' | 'linux' | 'ios' | 'android'). Default
// to 'linux' for unknown/web-build cases — the field is only used
// for the session-list UI icon.
const osPlatform = window.electronAPI?.osPlatform;
const platform: DevicePlatform =
osPlatform === 'win32'
? 'windows'
: osPlatform === 'darwin'
? 'macos'
: 'linux';
const created = await registerDevice(supabase, {
name: hostname.slice(0, 64),
platform,
});
if (!cancelled) setDeviceRowId(created.id);
} catch (err) {
console.warn('ensure device row failed', err);
}
})();
return () => {
cancelled = true;
};
}, [session]);
// Wipe-on-close: register a handler the main process pings on `before-quit`
// when the user has enabled the Settings → Sicherheit toggle. No-op outside
// Electron (web build has no preload bridge) or when the toggle is off.
useEffect(() => {
if (typeof window.electronAPI?.onWipeBeforeQuit !== 'function') return;
const unsub = window.electronAPI.onWipeBeforeQuit(async () => {
if (!isWipeOnCloseEnabled()) return;
await wipeLocalState(session?.user.id ?? null);
});
return unsub;
}, [session?.user.id]);
// Auto online/offline transition.
//
// - On mount with a session whose last persisted state is `offline`, flip
@@ -235,12 +317,54 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, [session, profile, refreshProfile]);
const signOut = useCallback(async () => {
const uid = session?.user.id ?? null;
await updateOwnProfile(supabase, { presenceState: 'offline' }).catch((err: unknown) => {
console.warn('offline update before sign-out failed', err);
});
await supabaseSignOut(supabase);
await wipeLocalState(uid);
}, [session]);
const acknowledgeRevocation = useCallback(() => {
setRevokedRemotely(false);
}, []);
// Phase 3: listen for own-device revocations. The same channel also fires
// when *another* of the user's installs is revoked — we ignore those (we
// only force-sign-out when OUR row's revoked_at flips). The UI's device
// list refetches independently via its own subscription in useOwnDevices.
useEffect(() => {
if (!session) return;
const userId = session.user.id;
const channel = supabase
.channel('devices:self:' + userId)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'devices',
filter: 'user_id=eq.' + userId,
},
(payload) => {
const ownId = getDeviceRowId();
const row = payload.new as { id?: string; revoked_at?: string | null } | null;
if (!row || !ownId) return;
if (row.id !== ownId) return;
if (row.revoked_at) {
setRevokedRemotely(true);
void signOut().catch((err) => {
console.warn('forced signOut after revoke failed', err);
});
}
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [session, signOut]);
const value = useMemo<AuthContextValue>(
() => ({
session,
@@ -250,8 +374,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
refreshProfile,
refreshUserKeyState,
signOut,
revokedRemotely,
acknowledgeRevocation,
}),
[session, profile, userKeyState, ready, refreshProfile, refreshUserKeyState, signOut],
[
session,
profile,
userKeyState,
ready,
refreshProfile,
refreshUserKeyState,
signOut,
revokedRemotely,
acknowledgeRevocation,
],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+24 -13
View File
@@ -1442,20 +1442,29 @@ export function CallProvider({ children }: { children: ReactNode }) {
// the same system audio twice and Chromium's version would
// include our own renderer playback.
audio: preferNativeLoopback ? false : wantAudio,
// Preset dims are passed as `ideal` constraints (not fixed width/height)
// so Chromium picks the closest match preserving the source's aspect
// ratio instead of CROPPING non-matching monitors. Without this, a 16:10
// monitor (1920×1200, 2560×1600) streamed under a 16:9 preset loses its
// bottom strip — including the Windows taskbar.
//
// LiveKit's VideoResolution types declare width/height as `number`, but
// Chromium's getDisplayMedia accepts the full MediaTrackConstraints shape
// including { ideal: N } objects — we cast through `unknown` to satisfy tsc.
...(ssParams.dims
? {
resolution: {
width: ssParams.dims.width,
height: ssParams.dims.height,
width: { ideal: ssParams.dims.width },
height: { ideal: ssParams.dims.height },
frameRate: fps,
},
} as unknown as { width: number; height: number; frameRate: number },
}
: {
resolution: {
width: 3840,
height: 2160,
frameRate: fps,
},
width: undefined,
height: undefined,
} as unknown as { width: number; height: number; frameRate: number },
}),
...(displaySurface
? ({ displaySurface } as { displaySurface: DisplaySurfaceHint })
@@ -2004,14 +2013,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
const syncGlobalShortcuts = () => {
if (!isTauriRuntime()) return;
// Only register an OS-level shortcut if the user explicitly opted in.
// Window-scoped firing happens via the `onKey` listener above and works
// for every enabled binding regardless of the `global` flag.
const enabledAndGlobal = (b: VoiceHotkeys[HotkeyKind]) => b.enabled && b.global;
const desired: Record<HotkeyKind, string | null> = {
mute: settings.mute.enabled ? bindingToTauriShortcut(settings.mute) : null,
deafen: settings.deafen.enabled ? bindingToTauriShortcut(settings.deafen) : null,
hangup: settings.hangup.enabled ? bindingToTauriShortcut(settings.hangup) : null,
screenShare: settings.screenShare.enabled
? bindingToTauriShortcut(settings.screenShare)
: null,
video: settings.video.enabled ? bindingToTauriShortcut(settings.video) : null,
mute: enabledAndGlobal(settings.mute) ? bindingToTauriShortcut(settings.mute) : null,
deafen: enabledAndGlobal(settings.deafen) ? bindingToTauriShortcut(settings.deafen) : null,
hangup: enabledAndGlobal(settings.hangup) ? bindingToTauriShortcut(settings.hangup) : null,
screenShare: enabledAndGlobal(settings.screenShare) ? bindingToTauriShortcut(settings.screenShare) : null,
video: enabledAndGlobal(settings.video) ? bindingToTauriShortcut(settings.video) : null,
};
for (const kind of KINDS) {
const want = desired[kind];
@@ -225,7 +225,12 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
(c) => c.id === row.conversation_id,
);
const muted = isConversationMuted(convForMute?.mutedUntil ?? null);
if (presenceRef.current !== 'dnd' && !muted) {
// "Mentions only" silences non-mention messages here. Mentions
// still fire via the independent useMentionNotifications
// subscription on message_mentions, so this branch doesn't
// lose the @-alerts.
const mentionsOnly = convForMute?.mentionsOnly ?? false;
if (presenceRef.current !== 'dnd' && !muted && !mentionsOnly) {
playNotificationTone();
const conv = conversationsRef.current.find(
(c) => c.id === row.conversation_id,
+75
View File
@@ -0,0 +1,75 @@
import { useCallback, useEffect, useState } from 'react';
import { getGame, makeGameMove, type GameRecord } from '@chat-app/shared/chat';
import { supabase } from '../lib/supabase';
export function useGame(gameId: string | null): {
game: GameRecord | null;
loading: boolean;
error: string | null;
makeMove: (move: object) => Promise<void>;
} {
const [game, setGame] = useState<GameRecord | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!gameId) {
setGame(null);
setLoading(false);
setError(null);
return;
}
let cancelled = false;
void (async () => {
try {
setLoading(true);
setError(null);
const fresh = await getGame(supabase, gameId);
if (!cancelled) {
setGame(fresh);
setLoading(false);
}
} catch (err) {
if (!cancelled) {
setLoading(false);
setError(err instanceof Error ? err.message : 'failed to load game');
}
}
})();
const channel = supabase
.channel('game:' + gameId)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'conversation_games',
filter: 'id=eq.' + gameId,
},
() => {
void getGame(supabase, gameId)
.then((fresh) => { if (fresh) setGame(fresh); })
.catch((err) => { console.warn('game realtime refetch failed', err); });
},
)
.subscribe();
return () => {
cancelled = true;
void supabase.removeChannel(channel);
};
}, [gameId]);
const makeMove = useCallback(async (move: object) => {
if (!gameId) return;
try {
await makeGameMove(supabase, { gameId, move });
} catch (err) {
setError(err instanceof Error ? err.message : 'move failed');
throw err;
}
}, [gameId]);
return { game, loading, error, makeMove };
}
+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 };
}
+145
View File
@@ -0,0 +1,145 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
endWatchSession,
getWatchSession,
updateWatchSessionState,
type WatchSession,
type WatchSessionState,
} from '@chat-app/shared/chat';
import { supabase } from '../lib/supabase';
const PUSH_THROTTLE_MS = 500;
export function useWatchSession(sessionId: string | null): {
session: WatchSession | null;
loading: boolean;
error: string | null;
pushState: (state: WatchSessionState) => void;
endSession: () => Promise<void>;
} {
const [session, setSession] = useState<WatchSession | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const pendingRef = useRef<WatchSessionState | null>(null);
const lastPushAtRef = useRef<number>(0);
const pushTimerRef = useRef<number | null>(null);
useEffect(() => {
if (!sessionId) {
setSession(null);
setLoading(false);
setError(null);
return;
}
let cancelled = false;
void (async () => {
try {
setLoading(true);
setError(null);
const fresh = await getWatchSession(supabase, sessionId);
if (!cancelled) {
setSession(fresh);
setLoading(false);
}
} catch (err) {
if (!cancelled) {
setLoading(false);
setError(err instanceof Error ? err.message : 'failed to load session');
}
}
})();
const channel = supabase
.channel('watch_session:' + sessionId)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'conversation_watch_sessions',
filter: 'id=eq.' + sessionId,
},
(payload) => {
const row = payload.new as {
id?: string;
ended_at?: string | null;
current_state?: unknown;
} | null;
if (!row?.id) return;
const raw = (row.current_state ?? {}) as Partial<{
playing: boolean;
position_seconds: number;
updated_at_ms: number;
}>;
setSession((cur) => {
if (!cur) return cur;
return {
...cur,
endedAt: row.ended_at ?? null,
currentState: {
playing: typeof raw.playing === 'boolean' ? raw.playing : cur.currentState.playing,
positionSeconds:
typeof raw.position_seconds === 'number'
? raw.position_seconds
: cur.currentState.positionSeconds,
updatedAtMs:
typeof raw.updated_at_ms === 'number'
? raw.updated_at_ms
: cur.currentState.updatedAtMs,
},
};
});
},
)
.subscribe();
return () => {
cancelled = true;
void supabase.removeChannel(channel);
if (pushTimerRef.current !== null) {
window.clearTimeout(pushTimerRef.current);
pushTimerRef.current = null;
}
};
}, [sessionId]);
// Throttled writer: keeps the latest state in pendingRef; fires at most
// once per PUSH_THROTTLE_MS. Trailing-edge push guarantees the final
// state is always sent even when a rapid burst stops before the leading-
// edge timeout expires.
const pushState = useCallback((state: WatchSessionState) => {
if (!sessionId) return;
pendingRef.current = state;
const now = Date.now();
const elapsed = now - lastPushAtRef.current;
if (elapsed >= PUSH_THROTTLE_MS) {
lastPushAtRef.current = now;
const toPush = pendingRef.current;
pendingRef.current = null;
void updateWatchSessionState(supabase, sessionId, toPush).catch((err) => {
console.warn('updateWatchSessionState failed', err);
});
return;
}
if (pushTimerRef.current !== null) window.clearTimeout(pushTimerRef.current);
pushTimerRef.current = window.setTimeout(() => {
pushTimerRef.current = null;
const toPush = pendingRef.current;
if (!toPush) return;
pendingRef.current = null;
lastPushAtRef.current = Date.now();
void updateWatchSessionState(supabase, sessionId, toPush).catch((err) => {
console.warn('updateWatchSessionState trailing failed', err);
});
}, PUSH_THROTTLE_MS - elapsed);
}, [sessionId]);
const endSession = useCallback(async () => {
if (!sessionId) return;
await endWatchSession(supabase, sessionId);
}, [sessionId]);
return { session, loading, error, pushState, endSession };
}
@@ -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,9 @@ import {
type AttachmentHandle,
type DecryptedMessage,
type PollOption,
type WhiteboardPayload,
type WatchTogetherPayload,
type GamePayload,
} from '@chat-app/shared/chat';
export type AttachmentBucket = 'media' | 'audio' | 'files';
@@ -97,6 +100,34 @@ 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 createWatchTogetherPayload(sessionId: string): string {
const payload: WatchTogetherPayload = {
v: 1,
type: 'watch_together',
session_id: sessionId,
};
return serializeMessagePayload(payload);
}
export function createGamePayload(gameId: string, gameType: 'ttt' | 'c4'): string {
const payload: GamePayload = {
v: 1,
type: 'game',
game_id: gameId,
game_type: gameType,
};
return serializeMessagePayload(payload);
}
export function summarizePollVotes(
options: PollOption[],
reactions: ReactionSummaryInput[],
+8
View File
@@ -25,5 +25,13 @@ export async function createLibsodiumBackend(): Promise<CryptoBackend> {
s.crypto_box_open_easy(ciphertext, nonce, senderPublicKey, recipientPrivateKey),
secretbox: (plaintext, nonce, key) => s.crypto_secretbox_easy(plaintext, nonce, key),
secretboxOpen: (ciphertext, nonce, key) => s.crypto_secretbox_open_easy(ciphertext, nonce, key),
pwhashConsts: {
OPSLIMIT_MODERATE: s.crypto_pwhash_OPSLIMIT_MODERATE,
MEMLIMIT_MODERATE: s.crypto_pwhash_MEMLIMIT_MODERATE,
ALG_ARGON2ID13: s.crypto_pwhash_ALG_ARGON2ID13,
},
pwhash: (outLen, password, salt, opslimit, memlimit, alg) =>
s.crypto_pwhash(outLen, password, salt, opslimit, memlimit, alg),
scalarMultBase: (priv) => s.crypto_scalarmult_base(priv),
};
}
+30
View File
@@ -0,0 +1,30 @@
// localStorage key for "the devices.id row that belongs to THIS install".
// Reset on memory-wipe (NOT preserved) — a wiped install is conceptually a
// fresh install, so registering a new row is correct.
const KEY = 'chatapp.deviceRowId.v1';
export function getDeviceRowId(): string | null {
try {
const v = window.localStorage.getItem(KEY);
return v && v.length > 0 ? v : null;
} catch {
return null;
}
}
export function setDeviceRowId(id: string): void {
try {
window.localStorage.setItem(KEY, id);
} catch {
/* quota */
}
}
export function clearDeviceRowId(): void {
try {
window.localStorage.removeItem(KEY);
} catch {
/* no-op */
}
}
+64
View File
@@ -0,0 +1,64 @@
import { useSyncExternalStore } from 'react';
// Local-only friend nickname overrides. Stored in localStorage keyed by the
// peer's user-id. Empty/missing value = use the real display name.
// Local-only by design — friends never see your nickname for them.
const STORAGE_KEY = 'chatapp.friendNicknames.v1';
let cache: Record<string, string> | null = null;
const listeners = new Set<() => void>();
function load(): Record<string, string> {
if (cache) return cache;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) { cache = {}; return cache; }
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === 'object') {
cache = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string' && v.trim().length > 0) cache[k] = v;
}
return cache;
}
} catch { /* corrupted; fall through */ }
cache = {};
return cache;
}
function persist(): void {
try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cache ?? {})); }
catch { /* quota / private mode */ }
for (const l of listeners) l();
}
export function getNickname(userId: string): string | null {
return load()[userId] ?? null;
}
export function setNickname(userId: string, nickname: string | null): void {
const store = load();
const trimmed = nickname?.trim() ?? '';
if (trimmed.length === 0) {
if (!(userId in store)) return;
delete store[userId];
} else {
if (store[userId] === trimmed) return;
store[userId] = trimmed;
}
persist();
}
// Reactive hook: returns the current nickname for a user, or `fallback`
// when no nickname is set. Re-renders when ANY nickname changes (cheap,
// the set is small).
export function useNickname(userId: string | null | undefined, fallback: string): string {
const subscribe = (cb: () => void) => {
listeners.add(cb);
return () => { listeners.delete(cb); };
};
const getSnapshot = () => (userId ? getNickname(userId) : null);
const nickname = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return nickname ?? fallback;
}
+55
View File
@@ -0,0 +1,55 @@
import { clearConvKeyCache } from '@chat-app/shared/chat';
import { devLocalSecretStore } from './secretStore';
// Aggressively scrub local crypto + chat caches on sign-out (and on
// app-close if the user opted in). Preserves things that aren't sensitive
// and would be annoying to lose (theme, locale, install-id).
//
// We can't enumerate IndexedDB names without async + the indexedDB API,
// so we list the ones we know about explicitly. Adding a new local store
// later? Append to LOCAL_DBS.
const LOCAL_DBS = ['soundboard', 'message-cache', 'chatapp-attachments'];
const PRESERVE_LOCAL_STORAGE = new Set([
'chatapp.theme',
'chatapp.locale',
'chatapp.installId',
'chatapp.wipeOnClose.v1',
'i18nextLng',
]);
export async function wipeLocalState(userId: string | null): Promise<void> {
// 1. Per-conversation key cache (in-memory).
try { clearConvKeyCache(); } catch { /* never throws but be defensive */ }
// 2. Stronghold / secret-store: drop the user-priv blob for this user.
if (userId) {
try { await devLocalSecretStore.removeSecret('chatapp.userpriv.' + userId); }
catch (err) { console.warn('[wipe] userpriv remove failed', err); }
}
// 3. localStorage — preserve only the explicit whitelist.
try {
const keysToDrop: string[] = [];
for (let i = 0; i < window.localStorage.length; i++) {
const k = window.localStorage.key(i);
if (k && !PRESERVE_LOCAL_STORAGE.has(k)) keysToDrop.push(k);
}
for (const k of keysToDrop) window.localStorage.removeItem(k);
} catch (err) { console.warn('[wipe] localStorage clear failed', err); }
// 4. sessionStorage — always full.
try { window.sessionStorage.clear(); } catch { /* ignored */ }
// 5. IndexedDB — delete known databases. Resolves even when blocked so
// we don't hang sign-out forever.
await Promise.allSettled(LOCAL_DBS.map((name) => new Promise<void>((resolve) => {
try {
const req = window.indexedDB.deleteDatabase(name);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
} catch { resolve(); }
})));
}
@@ -0,0 +1,24 @@
// Persisted toggle for the "wipe local caches on app close" feature.
// Read by SecurityCenter (UI) and AuthContext (renderer-side IPC handler).
//
// Stored in localStorage so the preference survives sessions without
// touching the secret-store or SQLite. The key is on the preserve
// whitelist in lib/memoryWipe.ts so a sign-out wipe (or a closing wipe)
// doesn't blow away the user's own preference.
const KEY = 'chatapp.wipeOnClose.v1';
export function isWipeOnCloseEnabled(): boolean {
try {
return window.localStorage.getItem(KEY) === '1';
} catch {
return false;
}
}
export function setWipeOnClose(enabled: boolean): void {
try {
window.localStorage.setItem(KEY, enabled ? '1' : '0');
} catch {
/* ignored */
}
}
+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 */
}
}
@@ -70,7 +70,12 @@ function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
}
export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & {
send: (text: string, images?: File[], replyToId?: string | null) => Promise<void>;
send: (
text: string,
images?: File[],
replyToId?: string | null,
opts?: { viewOnce?: boolean },
) => Promise<void>;
refresh: () => Promise<void>;
pending: OutboxItem[];
retryPending: (id: string) => void;
@@ -559,7 +564,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
);
const send = useCallback(
async (text: string, images: File[] = [], replyToId: string | null = null) => {
async (
text: string,
images: File[] = [],
replyToId: string | null = null,
opts: { viewOnce?: boolean } = {},
) => {
const trimmed = text.trim();
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
const priv = privateKeyRef.current;
@@ -631,6 +641,14 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
...(dims.width !== undefined ? { width: dims.width } : {}),
...(dims.height !== undefined ? { height: dims.height } : {}),
});
// Stamp the view-once flag on each handle the caller requested it
// for. The flag rides inside the encrypted payload (so peers can
// render the locked card without leaking who-sent-what to the
// server) AND lands on the public message_attachments row via
// insertAttachmentRow below (where the mark-viewed RPC enforces it).
if (opts.viewOnce) {
res.handle.viewOnce = true;
}
handles.push(res.handle);
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce));
}
@@ -0,0 +1,45 @@
import { useEffect } from 'react';
import { notify } from './osNotify';
import { supabase } from './supabase';
interface MentionRow {
message_id: string;
mentioned_user_id: string;
conversation_id: string;
}
// Subscribes to my own message_mentions inserts and fires an OS notification
// for each one. Bypasses per-conv mute (mentions override mute by design).
//
// We don't decrypt the body here — the notification just says "Du wurdest
// erwähnt". The conv list highlight + the in-app navigation reveal context.
export function useMentionNotifications(userId: string | undefined): void {
useEffect(() => {
if (!userId) return;
const channel = supabase
.channel('mentions:' + userId)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'message_mentions',
filter: 'mentioned_user_id=eq.' + userId,
},
(payload) => {
const row = payload.new as MentionRow | null;
if (!row) return;
void notify({
title: 'Du wurdest erwähnt',
body: 'Tippe um die Nachricht zu lesen.',
force: true,
});
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [userId]);
}
+48
View File
@@ -0,0 +1,48 @@
import { listPinnedMessages, type PinnedMessage } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { supabase } from './supabase';
// Live list of pinned messages for one conversation. Subscribes to the
// `pinned_messages` realtime channel for the conv so the header pill +
// side-panel update without a refetch.
export function usePinnedMessages(conversationId: string | undefined): PinnedMessage[] {
const [pins, setPins] = useState<PinnedMessage[]>([]);
useEffect(() => {
if (!conversationId) {
setPins([]);
return;
}
let cancelled = false;
void listPinnedMessages(supabase, conversationId).then((rows) => {
if (!cancelled) setPins(rows);
});
const channel = supabase
.channel('pinned_messages:' + conversationId)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'pinned_messages',
filter: 'conversation_id=eq.' + conversationId,
},
() => {
void listPinnedMessages(supabase, conversationId).then((rows) => {
if (!cancelled) setPins(rows);
});
},
)
.subscribe();
return () => {
cancelled = true;
void supabase.removeChannel(channel);
};
}, [conversationId]);
return pins;
}
+7 -9
View File
@@ -3,7 +3,7 @@ import {
resetUserKey, tryUnlockUserKey, uploadUserKeyBlob,
} from '@chat-app/shared/auth';
import {
generateRecoveryCode, generateUserKeyPair, normalizeRecoveryCode,
generateRecoveryCode, generateUserKeyPair, getCryptoBackend, normalizeRecoveryCode,
openUserKey, sealUserKey,
} from '@chat-app/shared/crypto';
import { migrateOwnLegacyBundles } from '@chat-app/shared/chat';
@@ -50,7 +50,7 @@ export async function setupNewUserIdentity(p: SetupParams): Promise<SetupResult>
export async function ensureLegacyMigrated(userId: string): Promise<void> {
const priv = await devLocalSecretStore.getSecret(cacheKey(userId));
if (!priv) return;
const pub = await derivePublicKey(priv);
const pub = derivePublicKey(priv);
await runLegacyMigration(userId, priv, pub);
}
@@ -105,7 +105,7 @@ export async function changePin(params: {
const fresh = await sealUserKey({ privateKey: cached, pin: params.newPin });
await uploadUserKeyBlob(supabase, {
userId: params.userId,
publicKey: await derivePublicKey(cached),
publicKey: derivePublicKey(cached),
sealedPrivateKey: fresh.sealedPrivateKey,
salt: fresh.salt,
kdfParams: fresh.kdfParams,
@@ -122,7 +122,7 @@ export async function regenerateRecoveryCode(params: { userId: string }): Promis
const sealed = await sealUserKey({ privateKey: cached, pin: normalizeRecoveryCode(recoveryCode) });
await uploadUserKeyBlob(supabase, {
userId: params.userId,
publicKey: await derivePublicKey(cached),
publicKey: derivePublicKey(cached),
sealedPrivateKey: blob.sealedPrivateKey,
salt: blob.salt,
kdfParams: blob.kdfParams,
@@ -247,12 +247,10 @@ async function runLegacyMigration(
export async function retryLegacyMigration(userId: string): Promise<LegacyMigrationReport> {
const priv = await devLocalSecretStore.getSecret(cacheKey(userId));
if (!priv) throw new Error('user key not cached locally — re-login required');
const pub = await derivePublicKey(priv);
const pub = derivePublicKey(priv);
return runLegacyMigration(userId, priv, pub);
}
async function derivePublicKey(privateKey: Uint8Array): Promise<Uint8Array> {
const sodium = (await import('libsodium-wrappers-sumo')).default;
await sodium.ready;
return sodium.crypto_scalarmult_base(privateKey);
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,
};
}
+32 -21
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
@@ -222,28 +224,36 @@ function ConversationList({
</div>
) : error ? (
<p className="px-4 py-2 text-xs text-rose-500 dark:text-rose-300">{error}</p>
) : query.trim().length > 0 && items.length === 0 ? (
<EmptyState
icon={<SearchIcon className="h-8 w-8" />}
title={t('app:chats.search_empty_title', { defaultValue: 'Keine Treffer' })}
description={t('app:chats.search_empty_desc', {
defaultValue: 'Keine Chats passen zu "' + query.trim() + '".',
})}
/>
) : 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', {
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.',
})
: t('app:chats.empty_subtitle')}
</p>
</div>
})}
/>
) : (
<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}
@@ -415,6 +425,7 @@ function ConversationRow({
conversationId={item.id}
archived={item.archived}
mutedUntil={item.mutedUntil}
mentionsOnly={item.mentionsOnly}
/>
</NavLink>
);
+497 -19
View File
@@ -1,32 +1,42 @@
import { parseMessagePayload } from '@chat-app/shared/chat';
import { parseMessagePayload, pinMessage, unpinMessage } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { lazy, Suspense, 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';
const ImageAnnotator = lazy(() =>
import('../components/ImageAnnotator').then((m) => ({ default: m.ImageAnnotator })),
);
import { InCallPanel } from '../components/InCallPanel';
import { IncomingCallPanel } from '../components/IncomingCallPanel';
import { CallPreviewPanel } from '../components/CallPreviewPanel';
import { MediaFilesDrawer } from '../components/MediaFilesDrawer';
import { MentionAutocomplete } from '../components/MentionAutocomplete';
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
import type { AggregatedReaction } from '../lib/useMessageReactions';
import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel';
import { PollComposerDialog } from '../components/PollComposerDialog';
import { UserProfilePopover } from '../components/UserProfilePopover';
import { TypingIndicator } from '../components/TypingIndicator';
@@ -35,10 +45,28 @@ 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,
createWatchTogetherPayload,
createGamePayload,
} from '../lib/conversationFeatures';
const WhiteboardModal = lazy(() =>
import('../components/WhiteboardModal').then((m) => ({ default: m.WhiteboardModal })),
);
const WatchTogetherModal = lazy(() =>
import('../components/WatchTogetherModal').then((m) => ({ default: m.WatchTogetherModal })),
);
const GameModal = lazy(() =>
import('../components/GameModal').then((m) => ({ default: m.GameModal })),
);
import { createWhiteboard, createWatchSession, parseYouTubeUrl, createGame, type GameType } 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';
@@ -46,10 +74,17 @@ 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;
// Stable empty-reactions sentinel. We pass this when a message has no
// reactions instead of `[]` literal — a fresh array per render would defeat
// `React.memo` on `MessageBubble` since the `reactions` prop reference would
// change on every parent render.
const EMPTY_REACTIONS: AggregatedReaction[] = [];
// 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
@@ -146,10 +181,22 @@ 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 [openWatchSessionId, setOpenWatchSessionId] = useState<string | null>(null);
const [watchDialogOpen, setWatchDialogOpen] = useState(false);
const [watchUrl, setWatchUrl] = useState('');
const [watchError, setWatchError] = useState<string | null>(null);
const [watchCreating, setWatchCreating] = useState(false);
const [openGameId, setOpenGameId] = useState<string | null>(null);
const [gameDialogOpen, setGameDialogOpen] = useState(false);
const [gameError, setGameError] = useState<string | null>(null);
const [gameCreating, setGameCreating] = useState(false);
const [pollError, setPollError] = useState<string | null>(null);
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
const [forwardTarget, setForwardTarget] = useState<DecryptedMessage | null>(null);
@@ -175,6 +222,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);
@@ -288,6 +376,20 @@ export function ConversationPage() {
[messageById, senderNameFor, t],
);
// Pre-compute quoted refs per message into a stable map. Calling
// `buildQuoted(m.replyToId)` inline inside the `.map` returned a fresh
// object on every parent render, defeating `React.memo` on MessageBubble.
// With the map memoized on the same deps as `buildQuoted`, each bubble
// gets a stable `quoted` reference until the underlying data actually
// changes (new messages, sender renames, language switch).
const quotedByMessage = useMemo(() => {
const out = new Map<string, QuotedRef | null>();
for (const m of messages) {
out.set(m.id, buildQuoted(m.replyToId));
}
return out;
}, [messages, buildQuoted]);
const jumpToMessage = useCallback((targetId: string) => {
const el = scrollRef.current?.querySelector<HTMLElement>(
'[data-message-id="' + CSS.escape(targetId) + '"]',
@@ -307,6 +409,19 @@ export function ConversationPage() {
setForwardTarget(m);
}, []);
// Stable handler for MessageBubble's `onAvatarClick`. Previously this was
// an inline arrow in the `.map`, which gave every row a fresh callback ref
// and defeated `React.memo` on the bubble (every parent re-render — every
// keystroke in the composer — re-rendered all 200 bubbles).
const handleAvatarClick = useCallback((uid: string, ev: React.MouseEvent) => {
ev.stopPropagation();
setProfilePopover({
userId: uid,
x: ev.clientX,
y: ev.clientY,
});
}, []);
const searchActive = useMemo(
() =>
searchQuery.trim().length > 0 ||
@@ -403,6 +518,33 @@ 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(() => {
const onOpen = (e: Event) => {
const detail = (e as CustomEvent<{ id?: string }>).detail;
if (detail?.id) setOpenWatchSessionId(detail.id);
};
window.addEventListener('chatapp:open-watch-together', onOpen);
return () => window.removeEventListener('chatapp:open-watch-together', onOpen);
}, []);
useEffect(() => {
const onOpen = (e: Event) => {
const detail = (e as CustomEvent<{ id?: string }>).detail;
if (detail?.id) setOpenGameId(detail.id);
};
window.addEventListener('chatapp:open-game', onOpen);
return () => window.removeEventListener('chatapp:open-game', onOpen);
}, []);
useEffect(() => {
if (id && messages.length > 0) markRead(id);
}, [id, messages.length, markRead]);
@@ -492,10 +634,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();
@@ -533,6 +678,80 @@ 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]);
const handleStartWatchTogether = useCallback(async () => {
if (!id) return;
const videoId = parseYouTubeUrl(watchUrl);
if (!videoId) {
setWatchError('Ungültige YouTube-URL.');
return;
}
setWatchCreating(true);
setWatchError(null);
try {
const ws = await createWatchSession(supabase, { conversationId: id, videoId });
const payload = createWatchTogetherPayload(ws.id);
await send(payload, [], replyTo?.id ?? null);
setReplyTo(null);
setStickToBottom(true);
setWatchDialogOpen(false);
setWatchUrl('');
setOpenWatchSessionId(ws.id);
} catch (err: unknown) {
setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten');
} finally {
setWatchCreating(false);
}
}, [id, watchUrl, send, replyTo?.id]);
const handleStartGame = useCallback(async (gameType: GameType) => {
if (!id) return;
if (!conversation || conversation.members.length !== 2) {
setGameError('Spiele aktuell nur in 1:1-Chats.');
return;
}
const opponent = conversation.members.find((m) => m.userId !== myId);
if (!opponent) {
setGameError('Kein Gegner gefunden.');
return;
}
setGameCreating(true);
setGameError(null);
try {
const game = await createGame(supabase, {
conversationId: id,
gameType,
opponentUserId: opponent.userId,
});
const payload = createGamePayload(game.id, gameType);
await send(payload, [], replyTo?.id ?? null);
setReplyTo(null);
setStickToBottom(true);
setGameDialogOpen(false);
setOpenGameId(game.id);
} catch (err: unknown) {
setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten');
} finally {
setGameCreating(false);
}
}, [id, conversation, myId, send, replyTo?.id]);
async function ingestFiles(files: File[]) {
const compressed = await compressImages(files);
const next: File[] = [];
@@ -603,6 +822,8 @@ export function ConversationPage() {
: {})}
onSearchClick={() => setSearchOpen((v) => !v)}
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
pinnedCount={pins.length}
onOpenPinned={() => setPinnedPanelOpen(true)}
/>
)}
@@ -670,7 +891,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 && (
@@ -719,9 +946,9 @@ export function ConversationPage() {
senderDisplayName={senderProfile?.displayName}
senderAvatarUrl={senderProfile?.avatarUrl}
conversationId={id ?? ''}
reactions={reactionsByMessage.get(m.id) ?? []}
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
onVotePoll={(emoji, optionEmojis) => votePoll(m.id, emoji, optionEmojis)}
reactions={reactionsByMessage.get(m.id) ?? EMPTY_REACTIONS}
onToggleReaction={toggleReaction}
onVotePoll={votePoll}
showSeen={m.id === lastSeenMessageId}
{...(m.senderId === myId
? {
@@ -736,19 +963,14 @@ export function ConversationPage() {
}),
}
: {})}
quoted={buildQuoted(m.replyToId)}
quoted={quotedByMessage.get(m.id) ?? null}
onJumpToMessage={jumpToMessage}
onReply={handleReply}
onForward={handleForward}
onAvatarClick={(uid, ev) => {
ev.stopPropagation();
setProfilePopover({
userId: uid,
x: ev.clientX,
y: ev.clientY,
});
}}
onAvatarClick={handleAvatarClick}
highlighted={highlightedId === m.id}
isPinned={pinnedIds.has(m.id)}
onTogglePin={handleTogglePin}
/>
</li>
);
@@ -855,6 +1077,9 @@ export function ConversationPage() {
key={idx}
file={file}
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
{...(file.type.startsWith('image/')
? { onEdit: () => setAnnotatingIndex(idx) }
: {})}
/>
))}
</div>
@@ -913,6 +1138,34 @@ 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>
<button
type="button"
onClick={() => setWatchDialogOpen(true)}
title={t('app:composer.watch_together', { defaultValue: 'Watch Together' })}
aria-label={t('app:composer.watch_together', { defaultValue: 'Watch Together' })}
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 dark:hover:bg-[#313338]"
>
<PlayBoxIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setGameDialogOpen(true)}
title={t('app:composer.game', { defaultValue: 'Spielen' })}
aria-label={t('app:composer.game', { defaultValue: 'Spielen' })}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-3 hover:text-fg"
>
<GameIcon className="h-4 w-4" />
</button>
<div className="relative">
<button
type="button"
@@ -942,6 +1195,36 @@ export function ConversationPage() {
onClose={() => setEmojiOpen(false)}
/>
</div>
<div className="relative">
<button
type="button"
onClick={() => setGifPickerOpen((v) => !v)}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
aria-label="GIF"
title="GIF einfügen"
>
<span className="text-xs font-bold">GIF</span>
</button>
<GifPicker
open={gifPickerOpen}
onClose={() => setGifPickerOpen(false)}
onPick={(gif) => void handleGifPick(gif)}
/>
</div>
<button
type="button"
onClick={() => setViewOnceNext((v) => !v)}
aria-pressed={viewOnceNext}
title={viewOnceNext ? 'Nächstes Bild: einmal ansehen' : 'Nächstes Bild: normal'}
className={
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md transition ' +
(viewOnceNext
? 'bg-accent/20 text-accent'
: 'text-fg-muted hover:bg-surface-3 hover:text-fg')
}
>
<EyeOffIcon className="h-4 w-4" />
</button>
<VoiceRecorder
disabled={sending}
onComplete={async (file) => {
@@ -1060,6 +1343,152 @@ 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] && (
<Suspense fallback={null}>
<ImageAnnotator
file={attachments[annotatingIndex]!}
onCancel={() => setAnnotatingIndex(null)}
onSave={(next) => {
setAttachments((prev) => prev.map((f, i) => (i === annotatingIndex ? next : f)));
setAnnotatingIndex(null);
}}
/>
</Suspense>
)}
{openWhiteboardId && (
<Suspense fallback={null}>
<WhiteboardModal
whiteboardId={openWhiteboardId}
onClose={() => setOpenWhiteboardId(null)}
/>
</Suspense>
)}
{openWatchSessionId && (
<Suspense fallback={null}>
<WatchTogetherModal
sessionId={openWatchSessionId}
onClose={() => setOpenWatchSessionId(null)}
/>
</Suspense>
)}
{openGameId && (
<Suspense fallback={null}>
<GameModal
gameId={openGameId}
onClose={() => setOpenGameId(null)}
/>
</Suspense>
)}
{gameDialogOpen && (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-[70] flex items-center justify-center bg-ink-950/90 p-6"
onClick={(e) => {
if (e.target === e.currentTarget) setGameDialogOpen(false);
}}
>
<div className="w-full max-w-sm rounded-2xl border border-line bg-surface-2 p-5 shadow-xl">
<h2 className="mb-3 font-display text-lg font-semibold text-fg">
{t('app:game.pick_title', { defaultValue: 'Spiel auswählen' })}
</h2>
{gameError && (
<p className="mb-2 text-xs text-rose-400">{gameError}</p>
)}
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => void handleStartGame('ttt')}
disabled={gameCreating}
className="flex flex-col items-center gap-2 rounded-xl border border-line bg-surface-3 p-4 text-fg transition hover:border-accent/40 hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="text-3xl">×</span>
<span className="text-xs font-semibold">Tic-Tac-Toe</span>
</button>
<button
type="button"
onClick={() => void handleStartGame('c4')}
disabled={gameCreating}
className="flex flex-col items-center gap-2 rounded-xl border border-line bg-surface-3 p-4 text-fg transition hover:border-accent/40 hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="text-3xl">🔴🟡</span>
<span className="text-xs font-semibold">Vier-Gewinnt</span>
</button>
</div>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={() => { setGameDialogOpen(false); setGameError(null); }}
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:game.cancel', { defaultValue: 'Abbrechen' })}
</button>
</div>
</div>
</div>
)}
{watchDialogOpen && (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-[70] flex items-center justify-center bg-ink-950/90 p-6"
onClick={(e) => {
if (e.target === e.currentTarget) setWatchDialogOpen(false);
}}
>
<div className="w-full max-w-md rounded-2xl border border-line bg-surface-2 p-5 shadow-xl">
<h2 className="mb-3 font-display text-lg font-semibold text-fg">
{t('app:watch.dialog_title', { defaultValue: 'Watch Together starten' })}
</h2>
<input
type="url"
placeholder="https://youtu.be/..."
value={watchUrl}
onChange={(e) => { setWatchUrl(e.target.value); setWatchError(null); }}
className="mb-2 w-full rounded-md 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-1 focus:ring-accent/40"
/>
{watchError && (
<p className="mb-2 text-xs text-rose-400">{watchError}</p>
)}
<div className="mt-3 flex items-center justify-end gap-2">
<button
type="button"
onClick={() => { setWatchDialogOpen(false); setWatchUrl(''); setWatchError(null); }}
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:watch.cancel', { defaultValue: 'Abbrechen' })}
</button>
<button
type="button"
onClick={() => void handleStartWatchTogether()}
disabled={watchCreating || !watchUrl.trim()}
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"
>
{watchCreating
? t('app:watch.starting', { defaultValue: 'Startet…' })
: t('app:watch.start', { defaultValue: 'Starten' })}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -1287,7 +1716,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(() => {
@@ -1297,7 +1734,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" />
) : (
@@ -1309,6 +1746,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}
@@ -1320,3 +1768,33 @@ 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>
);
}
function PlayBoxIcon(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="14" rx="2" />
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
</svg>
);
}
function GameIcon(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="6" width="18" height="12" rx="3" />
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
</svg>
);
}
+65 -4
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,9 +170,28 @@ export function FriendsPage() {
{loading ? (
<LoadingRow />
) : tab === 'friends' ? (
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}
@@ -178,6 +207,7 @@ export function FriendsPage() {
/>
)}
/>
)
) : 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}
+41 -2
View File
@@ -23,6 +23,7 @@ import {
import { MicTestSection } from '../components/MicTestSection';
import { NotificationSoundSettings } from '../components/NotificationSoundSettings';
import { RingtoneSettings } from '../components/RingtoneSettings';
import { DeviceListTab } from '../components/settings/DeviceListTab';
import { SecurityCenter } from '../components/SecurityCenter';
import { SoundboardSettings } from '../components/SoundboardSettings';
import { useAuth } from '../context/AuthContext';
@@ -117,7 +118,7 @@ export function SettingsPage() {
// routing conflict with HashRouter.
type TabId =
| 'profile' | 'appearance' | 'privacy' | 'notifications'
| 'voice' | 'screen-share' | 'soundboard' | 'security' | 'account';
| '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 },
@@ -128,6 +129,7 @@ export function SettingsPage() {
{ 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 },
];
@@ -352,6 +354,17 @@ export function SettingsPage() {
</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' })}
@@ -575,6 +588,7 @@ function VoiceHotkeyControls({ kind }: { kind: VoiceHotkeyKind }) {
<SettingRow
label={t('app:settings.hotkey_binding', { defaultValue: 'Hotkey' })}
>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setCapturing((v) => !v)}
@@ -591,6 +605,27 @@ function VoiceHotkeyControls({ kind }: { kind: VoiceHotkeyKind }) {
})
: 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>
</>
);
@@ -1310,9 +1345,13 @@ function Section({
// 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 border border-line/60 bg-surface-3/40 p-4">
<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>
+3 -2
View File
@@ -85,9 +85,10 @@
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}
}
+17
View File
@@ -73,6 +73,23 @@ Copy `.env.example` to `.env.local` and fill in the same Supabase host +
anon key the desktop uses. Expo bundles only `EXPO_PUBLIC_*`-prefixed
vars into the JS, which is what the three required values use.
## EAS Builds and Environment Variables
Production and preview builds load `EXPO_PUBLIC_*` from EAS Secrets —
`.env.local` is only honoured by `expo start` locally. Without the
secrets configured, an APK installs but `env.ts` throws on first read
and `<BootError>` renders.
Required secrets (create once per project):
```bash
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_URL --value '<project-supabase-url>'
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value '<sb_publishable_key>'
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_AUTH_REDIRECT_URL --value 'netralax://auth/callback'
```
Check with `npx eas-cli secret:list`.
## Roadmap
See `docs/superpowers/specs/2026-05-13-mobile-deployment-roadmap.md`.
+31 -8
View File
@@ -1,19 +1,42 @@
import { Redirect, Stack } from 'expo-router';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { useAuth } from '../../lib/authContext';
import { colors } from '../../theme/colors';
export default function AppLayout() {
const { session, loading } = useAuth();
if (loading) return null;
if (!session) return <Redirect href="/" />;
const { ready, session, userKeyState } = useAuth();
if (!ready) {
return (
<Stack screenOptions={{ headerShown: true }}>
<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={{ headerShown: false, presentation: 'fullScreenModal' }}
/>
<Stack.Screen name="call" options={{ presentation: 'fullScreenModal' }} />
<Stack.Screen name="settings" options={{ presentation: 'card' }} />
<Stack.Screen name="setup" options={{ headerShown: false }} />
<Stack.Screen name="unlock" options={{ headerShown: false }} />
</Stack>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg },
});
+2 -18
View File
@@ -4,7 +4,6 @@ import { Stack, useRouter } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Pressable,
RefreshControl,
@@ -14,7 +13,6 @@ import {
} from 'react-native';
import { ConversationRow } from '../../components/ConversationRow';
import { useAuth } from '../../lib/authContext';
import { supabase } from '../../lib/supabase';
import { colors } from '../../theme/colors';
@@ -23,7 +21,6 @@ import { colors } from '../../theme/colors';
// is a Phase 1.5 follow-up. Tap → conversation detail.
export default function Chats() {
const router = useRouter();
const { signOut } = useAuth();
const [list, setList] = useState<ConversationSummary[] | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -48,19 +45,6 @@ export default function Chats() {
setRefreshing(false);
}, [load]);
const confirmLogout = () => {
Alert.alert('Abmelden', 'Diese Sitzung beenden?', [
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Abmelden',
style: 'destructive',
onPress: () => {
void signOut();
},
},
]);
};
return (
<View style={styles.container}>
<Stack.Screen
@@ -69,8 +53,8 @@ export default function Chats() {
headerStyle: { backgroundColor: colors.bg },
headerTitleStyle: { color: colors.text },
headerRight: () => (
<Pressable onPress={confirmLogout} hitSlop={10}>
<Text style={styles.logoutLink}>Abmelden</Text>
<Pressable onPress={() => router.push('/(app)/settings')} hitSlop={10}>
<Text style={styles.logoutLink}>Einstellungen</Text>
</Pressable>
),
}}
+6 -9
View File
@@ -30,7 +30,7 @@ import { colors } from '../../../theme/colors';
export default function ConversationDetail() {
const { id } = useLocalSearchParams<{ id: string }>();
const { user, device, ownPrivateKey } = useAuth();
const { user, userId, ownPrivateKey } = useAuth();
const { state: callState, startCall } = useCall();
const router = useRouter();
@@ -54,7 +54,7 @@ export default function ConversationDetail() {
const listRef = useRef<FlatList<DecryptedMessage>>(null);
const load = useCallback(async () => {
if (!id || !device || !ownPrivateKey) return;
if (!id || !userId || !ownPrivateKey) return;
setError(null);
try {
const all = await chat.listConversations(supabase);
@@ -63,7 +63,7 @@ export default function ConversationDetail() {
const decrypted = await chat.decryptMessages({
client: supabase,
messages: ciphers,
ownDeviceId: device.id,
ownUserId: userId,
ownPrivateKey,
});
setMessages(decrypted);
@@ -81,7 +81,7 @@ export default function ConversationDetail() {
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Nachrichten konnten nicht geladen werden');
}
}, [id, device, ownPrivateKey]);
}, [id, userId, ownPrivateKey]);
useEffect(() => {
void load();
@@ -103,7 +103,7 @@ export default function ConversationDetail() {
: (conversation?.peer?.displayName ?? '…');
async function handleSendText() {
if (!text.trim() || !user || !device || !ownPrivateKey || !id || sending) return;
if (!text.trim() || !user || !ownPrivateKey || !id || sending) return;
setSending(true);
setError(null);
const replyToId = replyTo?.id;
@@ -113,7 +113,6 @@ export default function ConversationDetail() {
conversationId: id,
plaintext: text.trim(),
senderUserId: user.id,
senderDeviceId: device.id,
senderPrivateKey: ownPrivateKey,
...(replyToId ? { replyToId } : {}),
});
@@ -128,7 +127,7 @@ export default function ConversationDetail() {
}
async function sendImage(pick: PickedImage) {
if (!user || !device || !ownPrivateKey || !id) return;
if (!user || !ownPrivateKey || !id) return;
setSending(true);
setError(null);
try {
@@ -148,7 +147,6 @@ export default function ConversationDetail() {
conversationId: id,
plaintext: '',
senderUserId: user.id,
senderDeviceId: device.id,
senderPrivateKey: ownPrivateKey,
attachmentHandles: [result.handle],
});
@@ -300,7 +298,6 @@ export default function ConversationDetail() {
})}
reactions={reactions.get(item.id) ?? []}
myUserId={user?.id ?? null}
ownDeviceId={device?.id ?? null}
ownPrivateKey={ownPrivateKey}
onLongPress={() => setActiveMessage(item)}
onToggleReaction={(emoji) => {
@@ -0,0 +1,18 @@
import { Stack } from 'expo-router';
import { colors } from '../../../theme/colors';
export default function SettingsLayout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.bg },
headerTitleStyle: { color: colors.text },
headerTintColor: colors.accent,
}}
>
<Stack.Screen name="index" options={{ title: 'Einstellungen' }} />
<Stack.Screen name="security" options={{ title: 'Sicherheit' }} />
</Stack>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { useRouter } from 'expo-router';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useAuth } from '../../../lib/authContext';
import { colors } from '../../../theme/colors';
export default function SettingsIndex() {
const router = useRouter();
const { signOut } = useAuth();
function confirmLogout() {
Alert.alert('Abmelden', 'Diese Sitzung beenden?', [
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Abmelden', style: 'destructive', onPress: () => void signOut() },
]);
}
return (
<View style={styles.container}>
<Pressable style={styles.row} onPress={() => router.push('/(app)/settings/security')}>
<Text style={styles.rowTitle}>Sicherheit</Text>
<Text style={styles.rowHint}>PIN ändern, Recovery-Code, Identität zurücksetzen</Text>
</Pressable>
<Pressable style={[styles.row, styles.danger]} onPress={confirmLogout}>
<Text style={[styles.rowTitle, styles.dangerText]}>Abmelden</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg, padding: 16, gap: 12 },
row: {
backgroundColor: colors.surface,
padding: 16,
borderRadius: 12,
borderColor: colors.border,
borderWidth: 1,
},
rowTitle: { color: colors.text, fontSize: 16, fontWeight: '600' },
rowHint: { color: colors.textMuted, fontSize: 13, marginTop: 4 },
danger: { borderColor: colors.danger },
dangerText: { color: colors.danger },
});
+200
View File
@@ -0,0 +1,200 @@
import { useState } from 'react';
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { PinInput } from '../../../components/PinInput';
import { useAuth } from '../../../lib/authContext';
import {
changePin,
regenerateRecoveryCode,
resetIdentity,
retryLegacyMigration,
type LegacyMigrationReport,
} from '../../../lib/userIdentity';
import { colors } from '../../../theme/colors';
export default function SecuritySettings() {
const { userId, refreshUserKeyState } = useAuth();
const [oldPin, setOldPin] = useState('');
const [newPin, setNewPin] = useState('');
const [newRecovery, setNewRecovery] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [report, setReport] = useState<LegacyMigrationReport | null>(null);
async function handleChangePin() {
if (!userId) return;
if (oldPin.length !== 6 || newPin.length !== 6) {
setError('Beide PINs müssen 6 Ziffern haben.');
return;
}
setSubmitting(true);
setError(null);
try {
await changePin({ userId, oldPin, newPin });
setOldPin('');
setNewPin('');
Alert.alert('PIN geändert', 'Die neue PIN gilt sofort auf allen Geräten.');
} catch (e) {
setError(e instanceof Error ? e.message : 'PIN-Änderung fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
async function handleRegenerateRecovery() {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
const code = await regenerateRecoveryCode({ userId });
setNewRecovery(code);
} catch (e) {
setError(e instanceof Error ? e.message : 'Recovery-Code-Erzeugung fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
function handleReset() {
if (!userId) return;
Alert.alert(
'Identität zurücksetzen?',
'Alle bisherigen Chats werden für dich unlesbar. Diese Aktion kann nicht rückgängig gemacht werden.',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Zurücksetzen',
style: 'destructive',
onPress: async () => {
setSubmitting(true);
try {
await resetIdentity({ userId, pin: '000000' });
await refreshUserKeyState();
} catch (e) {
setError(e instanceof Error ? e.message : 'Reset fehlgeschlagen');
} finally {
setSubmitting(false);
}
},
},
],
);
}
async function handleRetryMigration() {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
const r = await retryLegacyMigration(userId);
setReport(r);
} catch (e) {
setError(e instanceof Error ? e.message : 'Migration fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.section}>PIN ändern</Text>
<Text style={styles.label}>Alte PIN</Text>
<PinInput value={oldPin} onChange={setOldPin} ariaLabel="Alte PIN" />
<Text style={styles.label}>Neue PIN</Text>
<PinInput value={newPin} onChange={setNewPin} ariaLabel="Neue PIN" />
<Pressable style={styles.primary} onPress={handleChangePin} disabled={submitting}>
{submitting ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.primaryText}>PIN aktualisieren</Text>
)}
</Pressable>
<Text style={styles.section}>Recovery-Code</Text>
<Pressable style={styles.primary} onPress={handleRegenerateRecovery} disabled={submitting}>
<Text style={styles.primaryText}>Neuen Recovery-Code erzeugen</Text>
</Pressable>
{newRecovery && (
<View style={styles.codeBox}>
<Text style={styles.codeText} selectable>
{newRecovery}
</Text>
</View>
)}
<Text style={styles.section}>Migration</Text>
<Pressable style={styles.primary} onPress={handleRetryMigration} disabled={submitting}>
<Text style={styles.primaryText}>Migration erneut versuchen</Text>
</Pressable>
{report && (
<View style={styles.report}>
<Text style={styles.reportLine}>Geräte (Server): {report.serverDevices}</Text>
<Text style={styles.reportLine}>
Lokale Schlüssel im Vault:{' '}
{report.strongholdKeysFromServerDevices + report.strongholdKeysFromBundleScan}
</Text>
<Text style={styles.reportLine}>
Versucht: {report.attempted}, Erfolgreich: {report.migrated}
</Text>
<Text style={styles.reportLine}>
Übersprungen: kein lokaler Schlüssel = {report.noStrongholdKey}, Decrypt-Fehler ={' '}
{report.decryptFailed}, RPC-Fehler = {report.rpcFailed}
</Text>
</View>
)}
<Text style={styles.section}>Gefahrenbereich</Text>
<Pressable style={[styles.primary, styles.danger]} onPress={handleReset} disabled={submitting}>
<Text style={[styles.primaryText, styles.dangerText]}>Identität zurücksetzen</Text>
</Pressable>
{error && <Text style={styles.error}>{error}</Text>}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { padding: 16, gap: 8, backgroundColor: colors.bg },
section: { color: colors.text, fontSize: 16, fontWeight: '700', marginTop: 16 },
label: { color: colors.textMuted, fontSize: 12, marginTop: 4 },
primary: {
backgroundColor: colors.accent,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
marginTop: 8,
},
primaryText: { color: colors.text, fontWeight: '700' },
danger: { backgroundColor: 'transparent', borderColor: colors.danger, borderWidth: 1 },
dangerText: { color: colors.danger },
codeBox: {
backgroundColor: colors.surface,
padding: 12,
borderRadius: 10,
marginTop: 4,
},
codeText: {
color: colors.text,
fontFamily: 'Courier',
fontSize: 16,
letterSpacing: 1.2,
textAlign: 'center',
},
report: {
backgroundColor: colors.surface,
padding: 12,
borderRadius: 10,
marginTop: 4,
gap: 4,
},
reportLine: { color: colors.text, fontSize: 13 },
error: { color: colors.danger, marginTop: 12 },
});
+195
View File
@@ -0,0 +1,195 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { PinInput } from '../../components/PinInput';
import { useAuth } from '../../lib/authContext';
import { setupNewUserIdentity } from '../../lib/userIdentity';
import { colors } from '../../theme/colors';
type Step = 'pin' | 'confirm' | 'recovery' | 'done';
export default function SetupScreen() {
const router = useRouter();
const { userId, refreshUserKeyState } = useAuth();
const [step, setStep] = useState<Step>('pin');
const [pin, setPin] = useState('');
const [confirm, setConfirm] = useState('');
const [recoveryCode, setRecoveryCode] = useState<string | null>(null);
const [withRecovery, setWithRecovery] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function runSetup(saveRecovery: boolean) {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
const out = await setupNewUserIdentity({ userId, pin, withRecovery: saveRecovery });
setRecoveryCode(out.recoveryCode);
if (out.recoveryCode) {
setStep('recovery');
} else {
setStep('done');
await refreshUserKeyState();
router.replace('/(app)/chats');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Setup fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
if (step === 'pin') {
return (
<View style={styles.container}>
<Text style={styles.title}>PIN festlegen</Text>
<Text style={styles.subtitle}>
Mit dieser 6-stelligen PIN entsperrst du Netralax auf jedem Gerät.
</Text>
<PinInput value={pin} onChange={setPin} ariaLabel="Neue PIN" autoFocus />
{error && <Text style={styles.error}>{error}</Text>}
<Pressable
style={[styles.primary, pin.length !== 6 && styles.disabled]}
disabled={pin.length !== 6}
onPress={() => setStep('confirm')}
>
<Text style={styles.primaryText}>Weiter</Text>
</Pressable>
</View>
);
}
if (step === 'confirm') {
return (
<View style={styles.container}>
<Text style={styles.title}>PIN bestätigen</Text>
<Text style={styles.subtitle}>Bitte gib dieselbe PIN erneut ein.</Text>
<PinInput value={confirm} onChange={setConfirm} ariaLabel="PIN bestätigen" autoFocus />
{error && <Text style={styles.error}>{error}</Text>}
<Pressable
style={[styles.primary, confirm.length !== 6 && styles.disabled]}
disabled={confirm.length !== 6 || submitting}
onPress={() => {
if (pin !== confirm) {
setError('PIN stimmt nicht überein.');
setConfirm('');
return;
}
void runSetup(withRecovery);
}}
>
{submitting ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.primaryText}>PIN speichern</Text>
)}
</Pressable>
<Pressable onPress={() => setWithRecovery((v) => !v)} style={styles.secondary}>
<Text style={styles.secondaryText}>
{withRecovery
? 'Recovery-Code überspringen (riskant)'
: 'Recovery-Code erzeugen (empfohlen)'}
</Text>
</Pressable>
</View>
);
}
if (step === 'recovery' && recoveryCode) {
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>Recovery-Code</Text>
<Text style={styles.subtitle}>
Notiere dir diesen Code an einem sicheren Ort. Du brauchst ihn, wenn du deine PIN
vergisst. Wir zeigen ihn dir nur EINMAL.
</Text>
<View style={styles.codeBox}>
<Text style={styles.codeText} selectable>
{recoveryCode}
</Text>
</View>
<Pressable
style={styles.primary}
onPress={async () => {
await refreshUserKeyState();
router.replace('/(app)/chats');
}}
>
<Text style={styles.primaryText}>Habe ich gespeichert</Text>
</Pressable>
<Pressable
style={styles.secondary}
onPress={() => {
Alert.alert(
'Sicher?',
'Ohne Recovery-Code verlierst du den Zugriff, wenn du die PIN vergisst.',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Weiter ohne',
style: 'destructive',
onPress: async () => {
await refreshUserKeyState();
router.replace('/(app)/chats');
},
},
],
);
}}
>
<Text style={styles.secondaryText}>Überspringen</Text>
</Pressable>
</ScrollView>
);
}
return null;
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
paddingTop: 64,
backgroundColor: colors.bg,
gap: 16,
},
title: { color: colors.text, fontSize: 24, fontWeight: '700' },
subtitle: { color: colors.textMuted, fontSize: 14, lineHeight: 20 },
error: { color: colors.danger, fontSize: 13 },
primary: {
backgroundColor: colors.accent,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
marginTop: 12,
},
primaryText: { color: colors.text, fontWeight: '700' },
disabled: { opacity: 0.5 },
secondary: { paddingVertical: 12, alignItems: 'center' },
secondaryText: { color: colors.accent, fontWeight: '600' },
codeBox: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
padding: 16,
borderRadius: 12,
},
codeText: {
color: colors.text,
fontSize: 20,
fontFamily: 'Courier',
letterSpacing: 1.5,
textAlign: 'center',
},
});
+194
View File
@@ -0,0 +1,194 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { PinInput } from '../../components/PinInput';
import { useAuth } from '../../lib/authContext';
import { loadOrUnlockUserKey, resetIdentity } from '../../lib/userIdentity';
import { colors } from '../../theme/colors';
type Mode = 'pin' | 'recovery';
export default function UnlockScreen() {
const router = useRouter();
const { userId, userKeyState, refreshUserKeyState } = useAuth();
const [mode, setMode] = useState<Mode>('pin');
const [pin, setPin] = useState('');
const [recovery, setRecovery] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const locked =
userKeyState.status === 'needs-unlock' && userKeyState.lockedUntil !== null;
const hasRecovery =
userKeyState.status === 'needs-unlock' ? userKeyState.hasRecovery : false;
async function attempt(value: string, isRecoveryCode: boolean) {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
const out = await loadOrUnlockUserKey({ userId, pin: value, isRecoveryCode });
if (out.kind === 'unlocked') {
await refreshUserKeyState();
router.replace('/(app)/chats');
return;
}
if (out.kind === 'locked') {
setError('Konto bis ' + new Date(out.lockedUntil).toLocaleString('de-DE') + ' gesperrt.');
await refreshUserKeyState();
return;
}
setError('Kein Identitäts-Datensatz auf dem Server.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Entsperren fehlgeschlagen');
if (isRecoveryCode) setRecovery('');
else setPin('');
await refreshUserKeyState();
} finally {
setSubmitting(false);
}
}
async function handleReset() {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
await resetIdentity({ userId, pin: '000000' });
await refreshUserKeyState();
router.replace('/(app)/setup');
} catch (err) {
setError(err instanceof Error ? err.message : 'Reset fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
return (
<View style={styles.container}>
<Text style={styles.title}>Netralax entsperren</Text>
<View style={styles.tabs}>
<Pressable
style={[styles.tab, mode === 'pin' && styles.tabActive]}
onPress={() => setMode('pin')}
>
<Text style={[styles.tabText, mode === 'pin' && styles.tabTextActive]}>PIN</Text>
</Pressable>
<Pressable
style={[styles.tab, mode === 'recovery' && styles.tabActive]}
disabled={!hasRecovery}
onPress={() => setMode('recovery')}
>
<Text style={[styles.tabText, mode === 'recovery' && styles.tabTextActive]}>
Recovery-Code
</Text>
</Pressable>
</View>
{mode === 'pin' ? (
<>
<PinInput
value={pin}
onChange={setPin}
ariaLabel="PIN eingeben"
autoFocus
disabled={locked || submitting}
/>
<Pressable
style={[styles.primary, (pin.length !== 6 || locked || submitting) && styles.disabled]}
disabled={pin.length !== 6 || locked || submitting}
onPress={() => attempt(pin, false)}
>
{submitting ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.primaryText}>Entsperren</Text>
)}
</Pressable>
</>
) : (
<>
<Text style={styles.subtitle}>Recovery-Code eingeben (mit oder ohne Bindestriche):</Text>
<Text style={styles.recoveryDisplay} selectable>
{recovery}
</Text>
<Pressable
style={[styles.primary, recovery.length === 0 && styles.disabled]}
disabled={recovery.length === 0 || submitting}
onPress={() => attempt(recovery, true)}
>
{submitting ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.primaryText}>Mit Recovery entsperren</Text>
)}
</Pressable>
</>
)}
{error && <Text style={styles.error}>{error}</Text>}
{locked && (
<Pressable style={styles.danger} onPress={handleReset}>
<Text style={styles.dangerText}>Identität zurücksetzen (alte Chats verloren)</Text>
</Pressable>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg,
paddingTop: 64,
paddingHorizontal: 24,
gap: 16,
},
title: { color: colors.text, fontSize: 22, fontWeight: '700' },
subtitle: { color: colors.textMuted, fontSize: 13 },
tabs: { flexDirection: 'row', gap: 8 },
tab: {
flex: 1,
paddingVertical: 10,
alignItems: 'center',
borderBottomWidth: 2,
borderBottomColor: colors.border,
},
tabActive: { borderBottomColor: colors.accent },
tabText: { color: colors.textMuted, fontWeight: '600' },
tabTextActive: { color: colors.text },
recoveryDisplay: {
color: colors.text,
fontFamily: 'Courier',
fontSize: 18,
letterSpacing: 1.5,
backgroundColor: colors.surface,
padding: 16,
borderRadius: 10,
},
primary: {
backgroundColor: colors.accent,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
primaryText: { color: colors.text, fontWeight: '700' },
disabled: { opacity: 0.5 },
danger: {
paddingVertical: 12,
alignItems: 'center',
borderColor: colors.danger,
borderWidth: 1,
borderRadius: 12,
},
dangerText: { color: colors.danger, fontWeight: '700' },
error: { color: colors.danger, fontSize: 13 },
});
+3 -4
View File
@@ -1,21 +1,19 @@
import { crypto } from '@chat-app/shared';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { AppBootstrap } from '../components/AppBootstrap';
import { ErrorBoundary } from '../components/ErrorBoundary';
import { IncomingCallModal } from '../components/IncomingCallModal';
import { AuthProvider } from '../lib/authContext';
import { CallProvider } from '../lib/callContext';
import { createLibsodiumBackend } from '../lib/cryptoBackend';
crypto.setCryptoBackend(createLibsodiumBackend());
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<AppBootstrap>
<ErrorBoundary>
<AuthProvider>
<CallProvider>
@@ -29,6 +27,7 @@ export default function RootLayout() {
</CallProvider>
</AuthProvider>
</ErrorBoundary>
</AppBootstrap>
</SafeAreaProvider>
</GestureHandlerRootView>
);
+2 -2
View File
@@ -21,13 +21,13 @@ import { colors } from '../theme/colors';
// they tap the email link and Expo Router routes the deep link to
// app/auth/callback.tsx.
export default function Landing() {
const { session, loading } = useAuth();
const { session, ready } = useAuth();
const [email, setEmail] = useState('');
const [submitting, setSubmitting] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
if (loading) return null;
if (!ready) return null;
if (session) return <Redirect href="/(app)/chats" />;
async function handleSubmit() {
+51
View File
@@ -0,0 +1,51 @@
import { crypto } from '@chat-app/shared';
import { type ReactNode, useEffect, useState } from 'react';
import { createLibsodiumBackend } from '../lib/cryptoBackend';
import { BootError } from './BootError';
import { BootSplash } from './BootSplash';
interface Props {
children: ReactNode;
}
// React Native exposes ErrorUtils on the global. The types ship with RN but
// we cast defensively because the renderer used by Vitest does not.
interface RNErrorUtils {
getGlobalHandler: () => (err: Error, isFatal?: boolean) => void;
setGlobalHandler: (handler: (err: Error, isFatal?: boolean) => void) => void;
}
// Initialises the crypto backend inside a useEffect (not at module-eval) so
// any failure surfaces in the React tree. Also installs a global JS error
// handler that routes unhandled throws to BootError; this catches errors
// thrown during render (e.g. the lazy env proxy reading a missing var) that
// would otherwise escape every per-screen ErrorBoundary.
export function AppBootstrap({ children }: Props) {
const [error, setError] = useState<Error | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
try {
crypto.setCryptoBackend(createLibsodiumBackend());
setReady(true);
} catch (e: unknown) {
setError(e instanceof Error ? e : new Error(String(e)));
}
}, []);
useEffect(() => {
const eu = (globalThis as unknown as { ErrorUtils?: RNErrorUtils }).ErrorUtils;
if (!eu) return;
const prev = eu.getGlobalHandler();
eu.setGlobalHandler((err, isFatal) => {
prev?.(err, isFatal);
setError(err);
});
return () => eu.setGlobalHandler(prev);
}, []);
if (error) return <BootError error={error} />;
if (!ready) return <BootSplash />;
return <>{children}</>;
}
@@ -10,8 +10,6 @@ import { colors } from '../theme/colors';
interface Props {
handle: AttachmentHandle;
ownDeviceId: string;
ownPrivateKey: Uint8Array;
}
// Decrypts an encrypted image attachment on first mount and renders it
+51
View File
@@ -0,0 +1,51 @@
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { colors } from '../theme/colors';
interface Props {
error: Error;
}
// Last-resort fallback. Renders whenever AppBootstrap's init effect throws or
// when the global JS error handler catches an unhandled exception. The
// env-diagnostic line ("env-ok" / "env-missing") makes future bug reports
// triageable from a single screenshot.
export function BootError({ error }: Props) {
const envOk = Boolean(process.env.EXPO_PUBLIC_SUPABASE_URL);
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>App-Start fehlgeschlagen</Text>
<Text style={styles.message}>{error.message}</Text>
<View style={styles.diagnostic}>
<Text style={styles.diagnosticLabel}>EXPO_PUBLIC_SUPABASE_URL:</Text>
<Text style={[styles.diagnosticValue, envOk ? styles.ok : styles.bad]}>
{envOk ? 'env-ok' : 'env-missing'}
</Text>
</View>
{error.stack && <Text style={styles.stack}>{error.stack}</Text>}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flexGrow: 1,
backgroundColor: colors.bg,
padding: 24,
paddingTop: 64,
gap: 12,
},
title: { color: colors.text, fontSize: 20, fontWeight: '700' },
message: { color: colors.danger, fontSize: 14, lineHeight: 20 },
diagnostic: { flexDirection: 'row', gap: 8, marginTop: 8 },
diagnosticLabel: { color: colors.textMuted, fontSize: 12 },
diagnosticValue: { fontSize: 12, fontWeight: '700' },
ok: { color: colors.success },
bad: { color: colors.danger },
stack: {
color: colors.textDim,
fontSize: 11,
fontFamily: 'Courier',
marginTop: 16,
},
});
+22
View File
@@ -0,0 +1,22 @@
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { colors } from '../theme/colors';
// Shown while AppBootstrap is initialising the crypto backend. Identical
// background to the Expo splash so the handoff is invisible to the user.
export function BootSplash() {
return (
<View style={styles.container}>
<ActivityIndicator color={colors.accent} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bg,
},
});
+2 -8
View File
@@ -15,7 +15,6 @@ interface Props {
parentSenderName: string;
reactions: MessageReaction[];
myUserId: string | null;
ownDeviceId: string | null;
ownPrivateKey: Uint8Array | null;
onLongPress: () => void;
onToggleReaction: (emoji: string) => void;
@@ -36,7 +35,6 @@ export function MessageBubble({
parentSenderName,
reactions,
myUserId,
ownDeviceId,
ownPrivateKey,
onLongPress,
onToggleReaction,
@@ -72,12 +70,8 @@ export function MessageBubble({
) : (
<>
{text.length > 0 && <Text style={styles.body}>{text}</Text>}
{firstImage && ownDeviceId && ownPrivateKey && (
<AttachmentImage
handle={firstImage}
ownDeviceId={ownDeviceId}
ownPrivateKey={ownPrivateKey}
/>
{firstImage && ownPrivateKey && (
<AttachmentImage handle={firstImage} />
)}
</>
)}
+31
View File
@@ -0,0 +1,31 @@
import { fireEvent, render } from '@testing-library/react-native';
import { describe, expect, it, vi } from 'vitest';
import { PinInput } from './PinInput';
describe('<PinInput>', () => {
it('appends digits to the underlying value and stops at length', () => {
const onChange = vi.fn();
const { getByTestId } = render(
<PinInput value="" onChange={onChange} length={6} ariaLabel="PIN" />,
);
fireEvent.changeText(getByTestId('pin-input'), '1234567890');
expect(onChange).toHaveBeenCalledWith('123456');
});
it('strips non-digits', () => {
const onChange = vi.fn();
const { getByTestId } = render(
<PinInput value="" onChange={onChange} length={6} ariaLabel="PIN" />,
);
fireEvent.changeText(getByTestId('pin-input'), '1a2b3c');
expect(onChange).toHaveBeenCalledWith('123');
});
it('renders one bullet per filled slot', () => {
const { getAllByText } = render(
<PinInput value="123" onChange={() => {}} length={6} ariaLabel="PIN" />,
);
expect(getAllByText('•').length).toBe(3);
});
});
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useRef } from 'react';
import {
Pressable,
StyleSheet,
Text,
TextInput,
View,
type TextInput as TextInputType,
} from 'react-native';
import { colors } from '../theme/colors';
interface Props {
value: string;
onChange: (next: string) => void;
length?: number;
autoFocus?: boolean;
disabled?: boolean;
ariaLabel: string;
onSubmit?: () => void;
}
// Six-slot numeric PIN entry. The actual input is an invisible TextInput
// that captures the numeric keyboard; visible slots render bullets when
// filled. Tapping anywhere on the row re-focuses the input.
export function PinInput({
value,
onChange,
length = 6,
autoFocus,
disabled,
ariaLabel,
onSubmit,
}: Props) {
const ref = useRef<TextInputType | null>(null);
useEffect(() => {
if (autoFocus) ref.current?.focus();
}, [autoFocus]);
return (
<Pressable onPress={() => ref.current?.focus()} style={styles.row}>
<TextInput
ref={ref}
testID="pin-input"
accessibilityLabel={ariaLabel}
keyboardType="numeric"
textContentType="oneTimeCode"
autoComplete="one-time-code"
maxLength={length}
editable={!disabled}
value={value}
onChangeText={(t) => onChange(t.replace(/\D/g, '').slice(0, length))}
onSubmitEditing={() => {
if (value.length === length) onSubmit?.();
}}
style={styles.hidden}
/>
<View style={styles.slots}>
{Array.from({ length }).map((_, i) => {
const filled = i < value.length;
return (
<View key={i} style={[styles.slot, filled && styles.slotFilled]}>
{filled && <Text style={styles.bullet}></Text>}
</View>
);
})}
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
row: { alignItems: 'center' },
hidden: {
position: 'absolute',
width: 1,
height: 1,
opacity: 0,
},
slots: { flexDirection: 'row', gap: 8 },
slot: {
width: 40,
height: 48,
borderRadius: 10,
borderWidth: 1,
borderColor: colors.border,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.surface,
},
slotFilled: {
borderColor: colors.accent,
backgroundColor: colors.bg,
},
bullet: { color: colors.text, fontSize: 22 },
});
+72 -65
View File
@@ -1,23 +1,31 @@
import type { Session, User } from '@supabase/supabase-js';
import { auth, crypto } from '@chat-app/shared';
import type { DeviceRecord } from '@chat-app/shared/auth';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { Platform } from 'react-native';
import { fetchUserKeyBlob } from '@chat-app/shared/auth';
import {
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import { secretStore } from './secretStore';
import { supabase } from './supabase';
import { cachedUserKey, ensureLegacyMigrated } from './userIdentity';
// Locally-stored secrets keyed by stable names. Mirrors the desktop
// convention so the migration tests (later) can compare snapshots.
const KEY_DEVICE_ID = 'device.id';
const KEY_DEVICE_PRIVKEY = 'device.privateKey';
export type UserKeyState =
| { status: 'loading' }
| { status: 'needs-setup' }
| { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean }
| { status: 'unlocked' };
interface AuthContextValue {
session: Session | null;
user: User | null;
device: DeviceRecord | null;
userId: string | null;
ownPrivateKey: Uint8Array | null;
loading: boolean;
userKeyState: UserKeyState;
ready: boolean;
refreshUserKeyState: () => Promise<void>;
signOut: () => Promise<void>;
}
@@ -29,44 +37,48 @@ export function useAuth(): AuthContextValue {
return v;
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [device, setDevice] = useState<DeviceRecord | null>(null);
const [ownPrivateKey, setOwnPrivateKey] = useState<Uint8Array | null>(null);
const [loading, setLoading] = useState(true);
const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
const [ready, setReady] = useState(false);
// Resolve or create the device record for this install given an active
// session. Stores the private key in expo-secure-store on first run.
const ensureDevice = useCallback(async (_currentSession: Session): Promise<void> => {
const savedDeviceId = await secretStore.getSecret(KEY_DEVICE_ID);
const savedPrivKey = await secretStore.getSecret(KEY_DEVICE_PRIVKEY);
if (savedDeviceId && savedPrivKey) {
const devices = await auth.listOwnDevices(supabase);
const deviceIdStr = new TextDecoder().decode(savedDeviceId);
const match = devices.find((d) => d.id === deviceIdStr);
if (match) {
setDevice(match);
setOwnPrivateKey(savedPrivKey);
const refreshUserKeyState = useCallback(async () => {
const s = session;
if (!s) {
setUserKeyState({ status: 'loading' });
setOwnPrivateKey(null);
return;
}
// Stored id no longer matches any device on the server (revoked,
// wiped). Fall through to register a fresh one.
}
const backend = crypto.getCryptoBackend();
const kp = backend.generateKeyPair();
const platform = Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'linux';
const record = await auth.registerDevice(supabase, {
name: `Netralax Mobile (${Platform.OS})`,
platform,
publicKey: kp.publicKey,
setUserKeyState({ status: 'loading' });
const cached = await cachedUserKey(s.user.id);
if (cached) {
setOwnPrivateKey(cached);
setUserKeyState({ status: 'unlocked' });
void ensureLegacyMigrated(s.user.id).catch((err) => {
console.warn('legacy migration on auth-resume failed', err);
});
await secretStore.setSecret(KEY_DEVICE_ID, new TextEncoder().encode(record.id));
await secretStore.setSecret(KEY_DEVICE_PRIVKEY, kp.privateKey);
setDevice(record);
setOwnPrivateKey(kp.privateKey);
}, []);
return;
}
const blob = await fetchUserKeyBlob(supabase, s.user.id);
if (!blob || !blob.exists) {
setUserKeyState({ status: 'needs-setup' });
return;
}
if (blob.locked) {
setUserKeyState({
status: 'needs-unlock',
lockedUntil: blob.lockedUntil,
hasRecovery: false,
});
return;
}
setUserKeyState({
status: 'needs-unlock',
lockedUntil: null,
hasRecovery: blob.recoverySealedPrivateKey !== null,
});
}, [session]);
useEffect(() => {
let cancelled = false;
@@ -74,48 +86,43 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const { data } = await supabase.auth.getSession();
if (cancelled) return;
setSession(data.session);
if (data.session) {
try {
await ensureDevice(data.session);
} catch (err) {
console.warn('[auth] ensureDevice failed', err);
}
}
setLoading(false);
setReady(true);
})();
const { data: sub } = supabase.auth.onAuthStateChange((_event, nextSession) => {
setSession(nextSession);
setReady(true);
if (!nextSession) {
setDevice(null);
setOwnPrivateKey(null);
} else {
void ensureDevice(nextSession).catch((err) =>
console.warn('[auth] ensureDevice (state change) failed', err),
);
setUserKeyState({ status: 'loading' });
}
});
return () => {
cancelled = true;
sub.subscription.unsubscribe();
};
}, [ensureDevice]);
}, []);
const signOut = useCallback(async (): Promise<void> => {
useEffect(() => {
void refreshUserKeyState().catch((err) => {
console.warn('refreshUserKeyState failed', err);
setUserKeyState({ status: 'needs-setup' });
});
}, [session, refreshUserKeyState]);
const signOut = useCallback(async () => {
await supabase.auth.signOut();
await secretStore.removeSecret(KEY_DEVICE_ID);
await secretStore.removeSecret(KEY_DEVICE_PRIVKEY);
setDevice(null);
setOwnPrivateKey(null);
setUserKeyState({ status: 'loading' });
}, []);
const value: AuthContextValue = {
session,
user: session?.user ?? null,
device,
userId: session?.user.id ?? null,
ownPrivateKey,
loading,
userKeyState,
ready,
refreshUserKeyState,
signOut,
};
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
+8
View File
@@ -24,5 +24,13 @@ export function createLibsodiumBackend(): CryptoBackend {
s.crypto_box_open_easy(ciphertext, nonce, senderPublicKey, recipientPrivateKey),
secretbox: (plaintext, nonce, key) => s.crypto_secretbox_easy(plaintext, nonce, key),
secretboxOpen: (ciphertext, nonce, key) => s.crypto_secretbox_open_easy(ciphertext, nonce, key),
pwhashConsts: {
OPSLIMIT_MODERATE: s.crypto_pwhash_OPSLIMIT_MODERATE,
MEMLIMIT_MODERATE: s.crypto_pwhash_MEMLIMIT_MODERATE,
ALG_ARGON2ID13: s.crypto_pwhash_ALG_ARGON2ID13,
},
pwhash: (outLen, password, salt, opslimit, memlimit, alg) =>
s.crypto_pwhash(outLen, password, salt, opslimit, memlimit, alg),
scalarMultBase: (priv) => s.crypto_scalarmult_base(priv),
};
}
+30
View File
@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
describe('mobile env (lazy proxy)', () => {
beforeEach(() => {
vi.resetModules();
delete process.env.EXPO_PUBLIC_SUPABASE_URL;
delete process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
delete process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL;
});
it('importing the module does NOT throw when required vars are missing', async () => {
await expect(import('./env')).resolves.toBeTruthy();
});
it('reading a property with no env set throws a clear error', async () => {
const mod = await import('./env');
expect(() => mod.env.supabaseUrl).toThrowError(
/Missing required env var EXPO_PUBLIC_SUPABASE_URL/,
);
});
it('reading a property after setting env returns the value and memoises', async () => {
process.env.EXPO_PUBLIC_SUPABASE_URL = 'https://example.supabase.co';
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY = 'anon-123';
const mod = await import('./env');
expect(mod.env.supabaseUrl).toBe('https://example.supabase.co');
expect(mod.env.supabaseAnonKey).toBe('anon-123');
expect(mod.env.authRedirectUrl).toBe('netralax://auth/callback');
});
});
+28 -6
View File
@@ -1,19 +1,41 @@
// EXPO_PUBLIC_* vars are inlined at bundle time by Expo's Babel plugin.
// We pull them through a single typed module so a missing var is a loud
// startup error rather than a confusing Supabase 401 later.
// EXPO_PUBLIC_* vars are inlined at bundle time by Expo's Babel plugin (or
// shipped via EAS Secrets for EAS builds — see apps/mobile/README.md).
// We pull them through a Proxy so missing vars throw on first READ, not at
// module-eval time. That keeps the throw inside the React tree where the
// <BootError> boundary can render it as a readable screen instead of a blank
// white window.
function required(name: string): string {
const v = process.env[name];
if (!v || v.length === 0) {
throw new Error(
'Missing required env var ' + name + '. Set it in apps/mobile/.env.local — see .env.example.',
'Missing required env var ' + name +
'. Set it via `eas secret:create --scope project --name ' + name +
' --value ...` or in apps/mobile/.env.local for local dev (see .env.example).',
);
}
return v;
}
export const env = {
interface EnvShape {
supabaseUrl: string;
supabaseAnonKey: string;
authRedirectUrl: string;
}
function readEnv(): EnvShape {
return {
supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'),
supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'),
authRedirectUrl: process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL ?? 'netralax://auth/callback',
} as const;
};
}
let cached: EnvShape | null = null;
export const env: EnvShape = new Proxy({} as EnvShape, {
get(_target, key: string | symbol): unknown {
cached ??= readEnv();
return cached[key as keyof EnvShape];
},
});
+9
View File
@@ -0,0 +1,9 @@
import { secretStore } from './secretStore';
// During the migration window the orchestrator probes SecureStore for legacy
// per-device private keys. We keep this read-only — never write — so a future
// reset can safely wipe the new chatapp.userpriv.* slot without affecting
// pre-existing legacy entries.
export function legacyDeviceKey(userId: string, deviceId: string): Promise<Uint8Array | null> {
return secretStore.getSecret('chatapp.priv.' + userId + '.' + deviceId);
}
+61
View File
@@ -0,0 +1,61 @@
import { crypto } from '@chat-app/shared';
import { makeWasmTestBackend } from '@chat-app/shared/crypto/testBackend';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
// We mock react-native modules so this Vitest file can run in Node without
// loading native code. The mocks live next to the test for clarity.
vi.mock('expo-secure-store', () => {
const store = new Map<string, string>();
return {
getItemAsync: vi.fn(async (k: string) => store.get(k) ?? null),
setItemAsync: vi.fn(async (k: string, v: string) => {
store.set(k, v);
}),
deleteItemAsync: vi.fn(async (k: string) => {
store.delete(k);
}),
};
});
const rpcImpl = vi.fn();
vi.mock('./supabase', () => ({
supabase: {
rpc: (name: string, params: unknown) => rpcImpl(name, params),
from: () => ({
select: () => ({ in: () => Promise.resolve({ data: [], error: null }) }),
}),
},
}));
beforeAll(async () => {
crypto.setCryptoBackend(await makeWasmTestBackend());
});
beforeEach(() => {
rpcImpl.mockReset();
});
describe('mobile userIdentity', () => {
it('setupNewUserIdentity uploads + caches', async () => {
rpcImpl.mockResolvedValue({ data: 0, error: null });
const { setupNewUserIdentity, cachedUserKey } = await import('./userIdentity');
const out = await setupNewUserIdentity({
userId: 'user-1',
pin: '123456',
withRecovery: true,
});
expect(out.publicKey.length).toBe(32);
expect(out.recoveryCode).toMatch(/^[A-Z0-9-]+$/);
const cached = await cachedUserKey('user-1');
expect(cached).not.toBeNull();
expect(cached!.length).toBe(32);
expect(rpcImpl).toHaveBeenCalledWith('upsert_user_key', expect.any(Object));
});
it('loadOrUnlockUserKey returns `missing` when blob does not exist', async () => {
rpcImpl.mockResolvedValue({ data: { exists: false }, error: null });
const { loadOrUnlockUserKey } = await import('./userIdentity');
const out = await loadOrUnlockUserKey({ userId: 'user-2', pin: '000000' });
expect(out.kind).toBe('missing');
});
});
+266
View File
@@ -0,0 +1,266 @@
import { migrateOwnLegacyBundles } from '@chat-app/shared/chat';
import {
fetchUserKeyBlob,
listOwnDevices,
recordPinAttempt,
resetUserKey,
tryUnlockUserKey,
uploadUserKeyBlob,
} from '@chat-app/shared/auth';
import {
generateRecoveryCode,
generateUserKeyPair,
getCryptoBackend,
normalizeRecoveryCode,
openUserKey,
sealUserKey,
} from '@chat-app/shared/crypto';
import { legacyDeviceKey } from './legacyDeviceVault';
import { secretStore } from './secretStore';
import { supabase } from './supabase';
const cacheKey = (userId: string) => 'chatapp.userpriv.' + userId;
export interface SetupParams {
userId: string;
pin: string;
withRecovery: boolean;
}
export interface SetupResult {
publicKey: Uint8Array;
recoveryCode: string | null;
}
export async function setupNewUserIdentity(p: SetupParams): Promise<SetupResult> {
const kp = await generateUserKeyPair();
const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: p.pin });
let recoveryCode: string | null = null;
let recoverySealed: { sealedPrivateKey: Uint8Array; salt: Uint8Array } | null = null;
if (p.withRecovery) {
recoveryCode = await generateRecoveryCode();
const r = await sealUserKey({
privateKey: kp.privateKey,
pin: normalizeRecoveryCode(recoveryCode),
});
recoverySealed = { sealedPrivateKey: r.sealedPrivateKey, salt: r.salt };
}
await uploadUserKeyBlob(supabase, {
userId: p.userId,
publicKey: kp.publicKey,
sealedPrivateKey: sealed.sealedPrivateKey,
salt: sealed.salt,
kdfParams: sealed.kdfParams,
recoverySealedPrivateKey: recoverySealed?.sealedPrivateKey ?? null,
recoverySalt: recoverySealed?.salt ?? null,
});
await secretStore.setSecret(cacheKey(p.userId), kp.privateKey);
void ensureLegacyMigrated(p.userId).catch((err) => {
console.warn('legacy conv-key migration failed', err);
});
return { publicKey: kp.publicKey, recoveryCode };
}
export interface UnlockParams {
userId: string;
pin: string;
isRecoveryCode?: boolean;
}
export type UnlockOutcome =
| { kind: 'unlocked' }
| { kind: 'locked'; lockedUntil: string }
| { kind: 'missing' };
export async function loadOrUnlockUserKey(p: UnlockParams): Promise<UnlockOutcome> {
const remote = await tryUnlockUserKey(supabase, p.userId);
if (!remote.exists) return { kind: 'missing' };
if (remote.locked) return { kind: 'locked', lockedUntil: remote.lockedUntil };
const secret = p.isRecoveryCode ? normalizeRecoveryCode(p.pin) : p.pin;
const sealed = p.isRecoveryCode ? remote.recoverySealedPrivateKey : remote.sealedPrivateKey;
const salt = p.isRecoveryCode ? remote.recoverySalt : remote.salt;
if (!sealed || !salt) throw new Error('no recovery blob configured');
let priv: Uint8Array;
try {
priv = await openUserKey({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
} catch (err) {
await recordPinAttempt(supabase, p.userId, false, p.isRecoveryCode === true).catch(() => {});
throw err;
}
await recordPinAttempt(supabase, p.userId, true, p.isRecoveryCode === true).catch(() => {});
await secretStore.setSecret(cacheKey(p.userId), priv);
void ensureLegacyMigrated(p.userId).catch((err) => {
console.warn('legacy conv-key migration failed', err);
});
return { kind: 'unlocked' };
}
export async function cachedUserKey(userId: string): Promise<Uint8Array | null> {
return secretStore.getSecret(cacheKey(userId));
}
export async function clearUserKeyCache(userId: string): Promise<void> {
await secretStore.removeSecret(cacheKey(userId));
}
export async function userKeyExistsRemotely(userId: string): Promise<boolean> {
const blob = await fetchUserKeyBlob(supabase, userId);
return blob !== null;
}
export async function changePin(params: {
userId: string;
oldPin: string;
newPin: string;
}): Promise<void> {
const cached = await cachedUserKey(params.userId);
if (!cached) throw new Error('user key not cached locally — re-login required');
const fresh = await sealUserKey({ privateKey: cached, pin: params.newPin });
await uploadUserKeyBlob(supabase, {
userId: params.userId,
publicKey: getCryptoBackend().scalarMultBase(cached),
sealedPrivateKey: fresh.sealedPrivateKey,
salt: fresh.salt,
kdfParams: fresh.kdfParams,
});
void params.oldPin; // cached key already proves old PIN was correct
}
export async function regenerateRecoveryCode(params: { userId: string }): Promise<string> {
const cached = await cachedUserKey(params.userId);
if (!cached) throw new Error('user key not cached locally');
const blob = await fetchUserKeyBlob(supabase, params.userId);
if (!blob || !blob.exists || blob.locked) {
throw new Error('cannot regenerate recovery while locked');
}
const recoveryCode = await generateRecoveryCode();
const sealed = await sealUserKey({
privateKey: cached,
pin: normalizeRecoveryCode(recoveryCode),
});
await uploadUserKeyBlob(supabase, {
userId: params.userId,
publicKey: getCryptoBackend().scalarMultBase(cached),
sealedPrivateKey: blob.sealedPrivateKey,
salt: blob.salt,
kdfParams: blob.kdfParams,
recoverySealedPrivateKey: sealed.sealedPrivateKey,
recoverySalt: sealed.salt,
});
return recoveryCode;
}
export async function resetIdentity(params: {
userId: string;
pin: string;
}): Promise<string> {
await clearUserKeyCache(params.userId);
await resetUserKey(supabase, {
userId: params.userId,
publicKey: new Uint8Array(32),
sealedPrivateKey: new Uint8Array(40),
salt: new Uint8Array(16),
kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 1, memlimit: 1 },
});
const setup = await setupNewUserIdentity({
userId: params.userId,
pin: params.pin,
withRecovery: true,
});
return setup.recoveryCode ?? '';
}
export interface LegacyMigrationReport {
serverDevices: number;
strongholdKeysFromServerDevices: number;
strongholdKeysFromBundleScan: number;
attempted: number;
migrated: number;
noStrongholdKey: number;
decryptFailed: number;
rpcFailed: number;
}
export async function ensureLegacyMigrated(userId: string): Promise<LegacyMigrationReport | null> {
const priv = await cachedUserKey(userId);
if (!priv) return null;
const pub = getCryptoBackend().scalarMultBase(priv);
return runLegacyMigration(userId, priv, pub);
}
export async function retryLegacyMigration(userId: string): Promise<LegacyMigrationReport> {
const priv = await cachedUserKey(userId);
if (!priv) throw new Error('user key not cached locally — re-login required');
const pub = getCryptoBackend().scalarMultBase(priv);
return runLegacyMigration(userId, priv, pub);
}
async function runLegacyMigration(
userId: string,
ownNewPriv: Uint8Array,
ownNewPub: Uint8Array,
): Promise<LegacyMigrationReport> {
const report: LegacyMigrationReport = {
serverDevices: 0,
strongholdKeysFromServerDevices: 0,
strongholdKeysFromBundleScan: 0,
attempted: 0,
migrated: 0,
noStrongholdKey: 0,
decryptFailed: 0,
rpcFailed: 0,
};
const devices = await listOwnDevices(supabase);
report.serverDevices = devices.length;
const ownLegacyDevicePrivateKeys: Record<string, Uint8Array> = {};
for (const d of devices) {
const k = await legacyDeviceKey(userId, d.id);
if (k) ownLegacyDevicePrivateKeys[d.id] = k;
}
report.strongholdKeysFromServerDevices = Object.keys(ownLegacyDevicePrivateKeys).length;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: scanRowsRaw } = await (supabase as any)
.from('conversation_keys')
.select('recipient_device_id')
.is('recipient_user_id', null)
.not('recipient_device_id', 'is', null);
const scanIds = Array.from(
new Set(
((scanRowsRaw ?? []) as { recipient_device_id: string }[])
.map((r) => r.recipient_device_id)
.filter((id): id is string => Boolean(id)),
),
);
for (const id of scanIds) {
if (ownLegacyDevicePrivateKeys[id]) continue;
const k = await legacyDeviceKey(userId, id);
if (k) {
ownLegacyDevicePrivateKeys[id] = k;
report.strongholdKeysFromBundleScan += 1;
}
}
const ids = Object.keys(ownLegacyDevicePrivateKeys);
if (ids.length === 0) {
console.warn('[crypto-migration] no legacy private keys in vault — nothing to migrate');
return report;
}
const result = await migrateOwnLegacyBundles({
client: supabase,
ownUserId: userId,
ownNewPublicKey: ownNewPub,
ownNewPrivateKey: ownNewPriv,
ownLegacyDeviceIds: ids,
ownLegacyDevicePrivateKeys,
});
report.attempted = result.attempted;
report.migrated = result.migratedConversations;
report.noStrongholdKey = result.noStrongholdKey;
report.decryptFailed = result.decryptFailed;
report.rpcFailed = result.rpcFailed;
return report;
}
+6 -2
View File
@@ -15,7 +15,8 @@
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rm -rf .expo node_modules/.cache .turbo *.tsbuildinfo"
"clean": "rm -rf .expo node_modules/.cache .turbo *.tsbuildinfo",
"check:env": "node scripts/check-env.mjs"
},
"dependencies": {
"@babel/runtime": "^7.29.2",
@@ -48,6 +49,9 @@
},
"devDependencies": {
"@babel/core": "^7.25.0",
"@types/react": "~18.3.12"
"@testing-library/react-native": "^12.9.0",
"@types/react": "~18.3.12",
"jsdom": "^29.1.1",
"react-test-renderer": "18.3.1"
}
}
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env node
// Compares apps/mobile/.env.example with .env.local. Surfaces missing keys
// so an onboarding dev doesn't ship a build that white-screens.
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const mobileRoot = resolve(here, '..');
const examplePath = resolve(mobileRoot, '.env.example');
const localPath = resolve(mobileRoot, '.env.local');
if (!existsSync(localPath)) {
console.error('No .env.local found at ' + localPath);
console.error('Copy .env.example to .env.local and fill in values.');
process.exit(1);
}
function keysOf(path) {
return new Set(
readFileSync(path, 'utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
.map((line) => line.split('=', 1)[0]),
);
}
const exampleKeys = keysOf(examplePath);
const localKeys = keysOf(localPath);
const missing = [...exampleKeys].filter((k) => !localKeys.has(k));
if (missing.length > 0) {
console.error('Missing keys in .env.local: ' + missing.join(', '));
process.exit(1);
}
console.log('env-ok: all .env.example keys present in .env.local');
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig } from 'vitest/config';
import { fileURLToPath } from 'node:url';
const shimPath = fileURLToPath(new URL('./vitest.rn-shim.ts', import.meta.url));
// Vitest cannot transform React Native's Flow-typed source. We redirect
// every `react-native` reference to a minimal host-component shim
// (`vitest.rn-shim.ts`) so @testing-library/react-native v12 + jsdom can
// render the few primitives PinInput uses.
//
// Two interception layers are needed:
// 1. resolve.alias — covers ESM imports vite sees during transformation
// of inlined dependencies (e.g. when @testing-library/react-native
// goes through vite's pipeline because of `server.deps.inline`).
// 2. setupFiles patches Module._resolveFilename so any pure-CJS
// `require("react-native")` that escapes vite also lands on the shim.
export default defineConfig({
test: {
environment: 'jsdom',
include: ['**/*.test.ts', '**/*.test.tsx'],
setupFiles: ['./vitest.setup.ts'],
server: {
deps: {
inline: [/@testing-library\/react-native/],
},
},
},
resolve: {
alias: [
{ find: /^react-native$/, replacement: shimPath },
{ find: /^react-native\/(.*)$/, replacement: shimPath },
],
},
});
+84
View File
@@ -0,0 +1,84 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// Minimal `react-native` surface used by vitest tests. Each visible
// primitive is a forwardRef component that renders a lower-case React
// element so react-test-renderer treats it as a regular host node, and
// @testing-library/react-native's queries can traverse the tree.
// The shim is loaded by Module._resolveFilename in vitest.setup.ts.
import * as React from 'react';
function makeHost(name: string) {
const C: any = React.forwardRef(function HostComponent(
props: Record<string, unknown>,
ref: unknown,
) {
return React.createElement(name, { ...props, ref });
});
C.displayName = name;
return C;
}
export const View = makeHost('View');
export const Text = makeHost('Text');
export const TextInput = makeHost('TextInput');
export const Pressable = makeHost('Pressable');
export const Image = makeHost('Image');
export const ScrollView = makeHost('ScrollView');
export const Modal = makeHost('Modal');
export const Switch = makeHost('Switch');
export const Platform = { OS: 'test', select: (m: any) => m.default ?? m.test ?? null };
export const StyleSheet = {
create<T extends Record<string, unknown>>(s: T): T {
return s;
},
flatten(s: unknown): unknown {
if (Array.isArray(s)) {
const flat: Record<string, unknown> = {};
for (const part of s) Object.assign(flat, part ?? {});
return flat;
}
return s ?? {};
},
hairlineWidth: 1,
absoluteFill: {},
absoluteFillObject: {},
};
export const Dimensions = { get: () => ({ width: 360, height: 640 }) };
export const NativeModules: Record<string, unknown> = {};
export const Animated = { View, Text, Image, createAnimatedComponent: (c: unknown) => c };
export const Linking = { openURL: async () => undefined };
export const Alert = { alert: () => undefined };
export const Appearance = { getColorScheme: () => 'dark' };
export const AccessibilityInfo = {
isScreenReaderEnabled: async () => false,
addEventListener: () => ({ remove: () => undefined }),
};
export const PixelRatio = { get: () => 2 };
export const Keyboard = { dismiss: () => undefined };
export const useWindowDimensions = () => ({ width: 360, height: 640 });
const defaultExport = {
View,
Text,
TextInput,
Pressable,
Image,
ScrollView,
Modal,
Switch,
Platform,
StyleSheet,
Dimensions,
NativeModules,
Animated,
Linking,
Alert,
Appearance,
AccessibilityInfo,
PixelRatio,
Keyboard,
useWindowDimensions,
};
export default defaultExport;
+25
View File
@@ -0,0 +1,25 @@
// Vitest cannot transform React Native's Flow-typed source. We monkey-patch
// Node's CommonJS resolver so every bare `react-native` request returns our
// minimal host-component shim. This file is referenced by `setupFiles` in
// vitest.config.ts and runs before any test file imports execute.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Module = require('node:module');
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const shimPath = path.join(here, 'vitest.rn-shim.ts');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const originalResolve = (Module as any)._resolveFilename;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Module as any)._resolveFilename = function patched(
request: string,
parent: unknown,
...rest: unknown[]
): string {
if (request === 'react-native' || request.startsWith('react-native/')) {
return shimPath;
}
return originalResolve.call(this, request, parent, ...rest);
};
@@ -0,0 +1,779 @@
# Android White-Screen RCA — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Identify and fix the cause of the Android white-screen-after-install on the mobile build, and ship defense-in-depth so the next boot-time failure renders a readable error screen instead of a blank window.
**Architecture:** Wire EXPO_PUBLIC_* into EAS builds via EAS Secrets (the leading hypothesis). Make `env.ts` lazy so missing variables throw inside React. Add an `<AppBootstrap>` boundary inside `_layout.tsx` that initialises the crypto backend in a `useEffect`, renders a splash while loading, and routes any error to a `<BootError>` view. Install a global JS error handler as the last-resort net. Then run the diagnostic playbook against a fresh APK to confirm which hypothesis actually fired.
**Tech Stack:** Expo SDK 52, React Native 0.76, TypeScript, EAS Build, expo-secure-store, react-native-libsodium.
**Spec:** `docs/superpowers/specs/2026-05-16-android-whitescreen-rca-design.md`
**Decisions inherited from spec review gate:**
- EAS Secrets (not `eas.json env`) for `EXPO_PUBLIC_*` values.
- `newArchEnabled: false` is acceptable as a temporary rollback if H2 confirms.
---
## File Overview
**New files (mobile app):**
- `apps/mobile/components/BootError.tsx` — full-screen error fallback with env diagnostic
- `apps/mobile/components/BootSplash.tsx` — minimal splash shown while the crypto backend warms up
- `apps/mobile/components/AppBootstrap.tsx` — boundary that initialises the crypto backend in a `useEffect` and routes errors to `BootError`
- `apps/mobile/lib/env.test.ts` — lazy proxy + missing-var coverage
- `apps/mobile/scripts/check-env.mjs` — optional lint comparing `.env.example` against `.env.local`
**Modified files (mobile app):**
- `apps/mobile/lib/env.ts` — convert to lazy proxy
- `apps/mobile/app/_layout.tsx` — remove the module-eval crypto init; mount `<AppBootstrap>` at the top of the tree
- `apps/mobile/README.md` — add an "EAS env" section
- `apps/mobile/package.json` — add `check:env` script
**No files deleted.** No native code changes.
---
## Phase 0 — Wire env into EAS builds (the leading hypothesis fix)
### Task 1: Create EAS Secrets and document the contract
**Files:**
- Modify: `apps/mobile/README.md`
- [ ] **Step 1: Confirm EAS CLI is installed and authenticated**
Run:
```bash
cd apps/mobile
npx eas-cli --version
npx eas-cli whoami
```
Expected: a version string ≥ 13.0.0, and the `whoami` output shows the `bygalax` owner (matches `app.json` `expo.owner`). If `whoami` errors, run `npx eas-cli login` interactively in a terminal (this plan cannot be executed in a sandboxed shell).
- [ ] **Step 2: Create the three project-scoped secrets**
Run, substituting the real Supabase project URL and `sb_publishable_…` anon key (look them up in `apps/mobile/.env.local`):
```bash
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_URL --value '<project-supabase-url>'
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value '<sb_publishable_key>'
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_AUTH_REDIRECT_URL --value 'netralax://auth/callback'
```
Expected: each command prints `✔ Created a new secret EXPO_PUBLIC_…`. List to confirm:
```bash
npx eas-cli secret:list
```
Expected: all three names present, type `STRING`, scope `PROJECT`.
- [ ] **Step 3: Document the env contract in README**
Append to `apps/mobile/README.md`:
````markdown
## EAS Builds and Environment Variables
Production and preview builds load `EXPO_PUBLIC_*` from EAS Secrets — `.env.local` is only honoured by `expo start` locally.
Required secrets (create once per project):
```bash
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_URL --value '<project-supabase-url>'
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value '<sb_publishable_key>'
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_AUTH_REDIRECT_URL --value 'netralax://auth/callback'
```
Check with `npx eas-cli secret:list`. Missing values cause `env.ts` to throw at the first read, which the `<BootError>` view renders.
````
- [ ] **Step 4: Commit**
```bash
git add apps/mobile/README.md
git commit -m "docs(mobile): document EAS Secrets contract for EXPO_PUBLIC_*"
```
---
## Phase 1 — Lazy env proxy (TDD)
### Task 2: Failing test for missing env var
**Files:**
- Create: `apps/mobile/lib/env.test.ts`
- [ ] **Step 1: Write the failing test**
Create `apps/mobile/lib/env.test.ts`:
```ts
import { beforeEach, describe, expect, it, vi } from 'vitest';
describe('mobile env (lazy proxy)', () => {
beforeEach(() => {
vi.resetModules();
delete process.env.EXPO_PUBLIC_SUPABASE_URL;
delete process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
delete process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL;
});
it('importing the module does NOT throw when required vars are missing', async () => {
await expect(import('./env')).resolves.toBeTruthy();
});
it('reading a property with no env set throws a clear error', async () => {
const mod = await import('./env');
expect(() => mod.env.supabaseUrl).toThrowError(
/Missing required env var EXPO_PUBLIC_SUPABASE_URL/,
);
});
it('reading a property after setting env returns the value and memoises', async () => {
process.env.EXPO_PUBLIC_SUPABASE_URL = 'https://example.supabase.co';
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY = 'anon-123';
const mod = await import('./env');
expect(mod.env.supabaseUrl).toBe('https://example.supabase.co');
expect(mod.env.supabaseAnonKey).toBe('anon-123');
expect(mod.env.authRedirectUrl).toBe('netralax://auth/callback');
});
});
```
- [ ] **Step 2: Run test, confirm it fails for the right reason**
Run:
```bash
pnpm --filter @chat-app/mobile test -- env.test
```
Expected: `importing the module does NOT throw when required vars are missing` FAILS because the current `env.ts` evaluates `required(...)` at module-eval time. This proves the test is wired correctly. Do NOT proceed if a different test fails first — fix the wiring before editing source.
### Task 3: Convert `env.ts` to a lazy proxy
**Files:**
- Modify: `apps/mobile/lib/env.ts`
- [ ] **Step 1: Replace the module body with the lazy proxy**
Overwrite `apps/mobile/lib/env.ts` with:
```ts
// EXPO_PUBLIC_* vars are inlined at bundle time by Expo's Babel plugin (or
// shipped via EAS Secrets for EAS builds — see apps/mobile/README.md).
// We pull them through a Proxy so missing vars throw on first READ, not at
// module-eval time. That keeps the throw inside the React tree where the
// <BootError> boundary can render it as a readable screen instead of a blank
// white window.
function required(name: string): string {
const v = process.env[name];
if (!v || v.length === 0) {
throw new Error(
'Missing required env var ' + name +
'. Set it via `eas secret:create --scope project --name ' + name +
' --value ...` or in apps/mobile/.env.local for local dev (see .env.example).',
);
}
return v;
}
interface EnvShape {
supabaseUrl: string;
supabaseAnonKey: string;
authRedirectUrl: string;
}
function readEnv(): EnvShape {
return {
supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'),
supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'),
authRedirectUrl: process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL ?? 'netralax://auth/callback',
};
}
let cached: EnvShape | null = null;
export const env: EnvShape = new Proxy({} as EnvShape, {
get(_target, key: string | symbol): unknown {
cached ??= readEnv();
return cached[key as keyof EnvShape];
},
});
```
- [ ] **Step 2: Re-run the env tests**
```bash
pnpm --filter @chat-app/mobile test -- env.test
```
Expected: all three tests PASS.
- [ ] **Step 3: Run the full mobile test suite**
```bash
pnpm --filter @chat-app/mobile test
```
Expected: no regressions. If a test depended on the old eager-throw behaviour, update it inline to expect a lazy throw.
- [ ] **Step 4: Commit**
```bash
git add apps/mobile/lib/env.ts apps/mobile/lib/env.test.ts
git commit -m "fix(mobile): lazy env proxy so missing EXPO_PUBLIC vars throw inside React"
```
---
## Phase 2 — Bootstrap boundary + global handler
### Task 4: Create `BootSplash` and `BootError`
**Files:**
- Create: `apps/mobile/components/BootSplash.tsx`
- Create: `apps/mobile/components/BootError.tsx`
- [ ] **Step 1: Create the splash**
`apps/mobile/components/BootSplash.tsx`:
```tsx
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { colors } from '../theme/colors';
// Shown while AppBootstrap is initialising the crypto backend. Identical
// background to the Expo splash so the handoff is invisible to the user.
export function BootSplash() {
return (
<View style={styles.container}>
<ActivityIndicator color={colors.accent} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bg,
},
});
```
- [ ] **Step 2: Create the error view**
`apps/mobile/components/BootError.tsx`:
```tsx
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { colors } from '../theme/colors';
interface Props {
error: Error;
}
// Last-resort fallback. Renders whenever AppBootstrap's init effect throws or
// when the global JS error handler catches an unhandled exception. The
// env-diagnostic line ("env-ok" / "env-missing") makes future bug reports
// triageable from a single screenshot.
export function BootError({ error }: Props) {
const envOk = Boolean(process.env.EXPO_PUBLIC_SUPABASE_URL);
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>App-Start fehlgeschlagen</Text>
<Text style={styles.message}>{error.message}</Text>
<View style={styles.diagnostic}>
<Text style={styles.diagnosticLabel}>EXPO_PUBLIC_SUPABASE_URL:</Text>
<Text style={[styles.diagnosticValue, envOk ? styles.ok : styles.bad]}>
{envOk ? 'env-ok' : 'env-missing'}
</Text>
</View>
{error.stack && <Text style={styles.stack}>{error.stack}</Text>}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flexGrow: 1,
backgroundColor: colors.bg,
padding: 24,
paddingTop: 64,
gap: 12,
},
title: { color: colors.text, fontSize: 20, fontWeight: '700' },
message: { color: colors.danger, fontSize: 14, lineHeight: 20 },
diagnostic: { flexDirection: 'row', gap: 8, marginTop: 8 },
diagnosticLabel: { color: colors.textMuted, fontSize: 12 },
diagnosticValue: { fontSize: 12, fontWeight: '700' },
ok: { color: colors.success },
bad: { color: colors.danger },
stack: {
color: colors.textDim,
fontSize: 11,
fontFamily: 'Courier',
marginTop: 16,
},
});
```
- [ ] **Step 3: Commit (no test yet — these are render-only components)**
```bash
git add apps/mobile/components/BootSplash.tsx apps/mobile/components/BootError.tsx
git commit -m "feat(mobile): BootSplash + BootError fallback views for AppBootstrap"
```
### Task 5: Create `AppBootstrap` boundary
**Files:**
- Create: `apps/mobile/components/AppBootstrap.tsx`
- [ ] **Step 1: Write the boundary**
`apps/mobile/components/AppBootstrap.tsx`:
```tsx
import { crypto } from '@chat-app/shared';
import { type ReactNode, useEffect, useState } from 'react';
import { createLibsodiumBackend } from '../lib/cryptoBackend';
import { BootError } from './BootError';
import { BootSplash } from './BootSplash';
interface Props {
children: ReactNode;
}
// React Native exposes ErrorUtils on the global. The types ship with RN but
// we cast defensively because the renderer used by Vitest does not.
interface RNErrorUtils {
getGlobalHandler: () => (err: Error, isFatal?: boolean) => void;
setGlobalHandler: (handler: (err: Error, isFatal?: boolean) => void) => void;
}
// Initialises the crypto backend inside a useEffect (not at module-eval) so
// any failure surfaces in the React tree. Also installs a global JS error
// handler that routes unhandled throws to BootError; this catches errors
// thrown during render (e.g. the lazy env proxy reading a missing var) that
// would otherwise escape every per-screen ErrorBoundary.
export function AppBootstrap({ children }: Props) {
const [error, setError] = useState<Error | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
try {
crypto.setCryptoBackend(createLibsodiumBackend());
setReady(true);
} catch (e: unknown) {
setError(e instanceof Error ? e : new Error(String(e)));
}
}, []);
useEffect(() => {
const eu = (globalThis as unknown as { ErrorUtils?: RNErrorUtils }).ErrorUtils;
if (!eu) return;
const prev = eu.getGlobalHandler();
eu.setGlobalHandler((err, isFatal) => {
prev?.(err, isFatal);
setError(err);
});
return () => eu.setGlobalHandler(prev);
}, []);
if (error) return <BootError error={error} />;
if (!ready) return <BootSplash />;
return <>{children}</>;
}
```
- [ ] **Step 2: Typecheck**
```bash
pnpm --filter @chat-app/mobile typecheck
```
Expected: no new errors.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/components/AppBootstrap.tsx
git commit -m "feat(mobile): AppBootstrap boundary — defers crypto init, catches global throws"
```
### Task 6: Rewire `_layout.tsx`
**Files:**
- Modify: `apps/mobile/app/_layout.tsx`
- [ ] **Step 1: Replace the module-eval crypto init with `<AppBootstrap>`**
Overwrite `apps/mobile/app/_layout.tsx` with:
```tsx
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { AppBootstrap } from '../components/AppBootstrap';
import { ErrorBoundary } from '../components/ErrorBoundary';
import { IncomingCallModal } from '../components/IncomingCallModal';
import { AuthProvider } from '../lib/authContext';
import { CallProvider } from '../lib/callContext';
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<AppBootstrap>
<ErrorBoundary>
<AuthProvider>
<CallProvider>
<StatusBar style="auto" />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(app)" />
<Stack.Screen name="auth/callback" />
</Stack>
<IncomingCallModal />
</CallProvider>
</AuthProvider>
</ErrorBoundary>
</AppBootstrap>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
```
Two structural changes vs. the prior version:
1. Removed the top-level `crypto.setCryptoBackend(createLibsodiumBackend())` call — it now runs inside `AppBootstrap`'s `useEffect`.
2. `<AppBootstrap>` sits OUTSIDE `<ErrorBoundary>` so a boot failure renders `<BootError>` instead of trying (and failing) to hit `ErrorBoundary`'s consumer-tree path.
- [ ] **Step 2: Typecheck**
```bash
pnpm --filter @chat-app/mobile typecheck
```
Expected: no errors. The previously-direct `crypto` + `createLibsodiumBackend` imports are now gone from `_layout.tsx`; if either is reported as unused, remove the stale import.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/app/_layout.tsx
git commit -m "fix(mobile): defer crypto backend init into AppBootstrap (prevents white-screen)"
```
---
## Phase 3 — Local validation (sanity before remote build)
### Task 7: Local smoke (Expo Go / dev client) — env-missing path
**Files:**
- None (local sanity, no edits)
- [ ] **Step 1: Temporarily clear local env**
```bash
mv apps/mobile/.env.local apps/mobile/.env.local.bak
```
- [ ] **Step 2: Start the dev server**
```bash
pnpm --filter @chat-app/mobile dev
```
In a connected Android emulator / device, open the dev client.
Expected: app reaches `<BootError>` with the message `Missing required env var EXPO_PUBLIC_SUPABASE_URL ...` and the diagnostic line `EXPO_PUBLIC_SUPABASE_URL: env-missing`. **No white screen.**
- [ ] **Step 3: Restore env**
```bash
mv apps/mobile/.env.local.bak apps/mobile/.env.local
```
Reload the dev client.
Expected: app boots normally to the login screen.
- [ ] **Step 4: No commit (validation only)**
No-op.
---
## Phase 4 — Remote build validation (the actual RCA)
### Task 8: Run the diagnostic playbook against a real APK
**Files:**
- None (investigative; outcome determines whether Phase 5 fixes are needed)
- [ ] **Step 1: Build the preview APK with EAS Secrets present**
```bash
cd apps/mobile
npx eas-cli build --profile preview --platform android
```
Expected: build succeeds. Note the APK URL.
- [ ] **Step 2: Install on a connected Android device**
```bash
adb install -r <downloaded-apk>.apk
```
Expected: install succeeds.
- [ ] **Step 3: Capture logs while launching**
```bash
adb logcat -c
adb logcat *:E ReactNative:V ReactNativeJS:V &
# tap the launcher icon for the app
```
Triage the first 50 lines for the first `Error`, `Exception`, or `FATAL` after the app starts.
- [ ] **Step 4: Match the trace against a hypothesis**
| Trace pattern | Hypothesis | Next action |
|---|---|---|
| `Missing required env var EXPO_PUBLIC_…` rendered to BootError (no red box) | H1 — fix already applied | Skip to Phase 6 |
| `Native module … not found` / `RNLibsodium not found` | H3 — libsodium native autolink missing | Phase 5 Task 9 |
| `JNI DETECTED ERROR` / `Fatal signal 11 (SIGSEGV)` before any RN log | H2 — new arch + incompatible lib | Phase 5 Task 10 |
| `libsodium-wrappers-sumo` or `WebAssembly` in the trace | H4 — covered by the mobile-encryption-port plan | Note the trace; merge that plan next |
| App reaches login screen | H1 was the root cause; nothing more to do | Skip to Phase 6 |
- [ ] **Step 5: Write a one-paragraph note in the PR description**
Capture which hypothesis confirmed, log lines, and which Phase 5 task (if any) was needed. This becomes the regression record.
---
## Phase 5 — Hypothesis-specific fixes (conditional)
Only run the tasks that the Step 4 triage selected. If H1 alone resolves it, skip Phase 5 entirely.
### Task 9 (conditional, H3): Force re-link `react-native-libsodium`
**Files:**
- Modify: `apps/mobile/app.json` (only if `expo prebuild` adds a plugin entry — see below)
- [ ] **Step 1: Run `expo prebuild` to regenerate native projects**
```bash
cd apps/mobile
npx expo prebuild --clean --platform android
```
Expected: an `android/` directory is created (or refreshed), and `app.json` may gain a `plugins` entry for `react-native-libsodium` if the lib ships a config plugin.
- [ ] **Step 2: Rebuild and re-test**
```bash
npx eas-cli build --profile preview --platform android
```
Install, repeat Phase 4 Step 3-4.
Expected: native module is now found. If still missing, escalate to the lib's GitHub issues — it likely needs a manual Gradle entry in `android/app/build.gradle`.
- [ ] **Step 3: Commit any generated config**
If `app.json` changed, commit the diff:
```bash
git add apps/mobile/app.json
git commit -m "fix(mobile): re-link react-native-libsodium via expo prebuild"
```
If `android/` is ignored (Expo managed flow), document the prebuild step in the README under the "EAS env" section instead.
### Task 10 (conditional, H2): Temporarily disable `newArchEnabled`
**Files:**
- Modify: `apps/mobile/app.json`
- [ ] **Step 1: Toggle the flag**
Edit `apps/mobile/app.json`, change `"newArchEnabled": true``"newArchEnabled": false`. Note in the commit message which library is suspected and the upstream issue link.
- [ ] **Step 2: Rebuild and re-test**
```bash
npx eas-cli build --profile preview --platform android
```
Install, repeat Phase 4 Step 3-4.
Expected: app boots to login.
- [ ] **Step 3: Open a follow-up issue**
Create a tracking issue in the repo titled `mobile: re-enable newArchEnabled once <lib> is Fabric-ready` with the trace from Phase 4 attached. Link to the affected lib's tracker.
- [ ] **Step 4: Commit**
```bash
git add apps/mobile/app.json
git commit -m "fix(mobile): temporarily disable newArchEnabled (white-screen on Android)
Suspected incompatibility: <library@version>. Tracked in #<issue>."
```
---
## Phase 6 — Optional hardening
### Task 11: `check:env` lint script
**Files:**
- Create: `apps/mobile/scripts/check-env.mjs`
- Modify: `apps/mobile/package.json`
- [ ] **Step 1: Add the script**
`apps/mobile/scripts/check-env.mjs`:
```js
#!/usr/bin/env node
// Compares apps/mobile/.env.example with .env.local. Surfaces missing keys
// so an onboarding dev doesn't ship a build that white-screens.
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const mobileRoot = resolve(here, '..');
const examplePath = resolve(mobileRoot, '.env.example');
const localPath = resolve(mobileRoot, '.env.local');
if (!existsSync(localPath)) {
console.error('No .env.local found at ' + localPath);
console.error('Copy .env.example to .env.local and fill in values.');
process.exit(1);
}
function keysOf(path) {
return new Set(
readFileSync(path, 'utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
.map((line) => line.split('=', 1)[0]),
);
}
const exampleKeys = keysOf(examplePath);
const localKeys = keysOf(localPath);
const missing = [...exampleKeys].filter((k) => !localKeys.has(k));
if (missing.length > 0) {
console.error('Missing keys in .env.local: ' + missing.join(', '));
process.exit(1);
}
console.log('env-ok: all .env.example keys present in .env.local');
```
- [ ] **Step 2: Wire the script into `package.json`**
In `apps/mobile/package.json`, under `scripts`, add:
```json
"check:env": "node scripts/check-env.mjs"
```
- [ ] **Step 3: Smoke**
```bash
pnpm --filter @chat-app/mobile run check:env
```
Expected: `env-ok: all .env.example keys present in .env.local`.
- [ ] **Step 4: Commit**
```bash
git add apps/mobile/scripts/check-env.mjs apps/mobile/package.json
git commit -m "chore(mobile): check:env script lints .env.local against .env.example"
```
---
## Phase 7 — Wrap-up
### Task 12: PR + post-mortem note
- [ ] **Step 1: Push branch + open PR**
```bash
git push -u origin <branch-name>
gh pr create --title "fix(mobile): Android white-screen RCA + defense-in-depth" --body "..."
```
PR body must contain:
```
## Summary
- Lazy env proxy so missing EXPO_PUBLIC_* throws inside React.
- AppBootstrap boundary mounts before AuthProvider; renders BootError on init failure.
- Global ErrorUtils handler routes unhandled throws to BootError.
- EAS Secrets documented in README; eas.json untouched.
## RCA outcome
<paste Phase 4 Step 5 paragraph>
## Test plan
- [x] Local: env-cleared dev client shows BootError, not white screen.
- [x] Remote: preview APK installed on Android device; <result>.
- [x] check:env script passes.
- [x] All mobile unit tests green.
```
- [ ] **Step 2: Mark plan complete**
This plan is done when the Android preview build opens to the login screen and `<BootError>` renders correctly with env intentionally cleared.
---
## Spec coverage check
- Hypothesis H1 — EAS Secrets (Task 1), lazy env (Task 3), env test (Task 2). ✅
- Hypothesis H2 — Diagnostic (Task 8), conditional Task 10 fix. ✅
- Hypothesis H3 — Diagnostic (Task 8), conditional Task 9 fix. ✅
- Hypothesis H4 — Diagnostic only (Task 8); structural fix belongs to the mobile-encryption-port plan. ✅
- Hypothesis H5 — Documented in Task 8 Step 4 table; cost of an asset eyeball is zero, no separate task needed. ✅
- Defense-in-depth 1 (lazy env) — Tasks 2-3. ✅
- Defense-in-depth 2 (AppBootstrap) — Tasks 4-6. ✅
- Defense-in-depth 3 (global handler) — Task 5. ✅
- Defense-in-depth 4 (SecureStore probe) — deferred (see Out of Scope). ✅
- Defense-in-depth 5 (`check:env`) — Task 11. ✅
## Out of scope for this plan (in spec, but deferred)
- SecureStore availability probe — only relevant on rare Android factory-test profiles. Add later if Phase 4 surfaces a SecureStore symptom.
- Sentry / Bugsnag integration — spec lists this under future work.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,927 @@
# Phase 3 — Security & Devices: Session/Device List + Revoke
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let the user see every install currently signed into their account, revoke any one remotely, and force-sign-out any device whose row is flipped to `revoked_at != null` (either by another install or — in the future — by an admin).
**Architecture:**
- Server: extend the existing telemetry-only `devices` table with a `revoked_at` column + a `revoke_device(p_device_id uuid)` SECURITY DEFINER RPC. Publish `devices` to `supabase_realtime` so every signed-in client gets postgres_changes events for its own rows.
- Client: on each post-sign-in startup, "ensure" exactly one `devices` row exists for this install (registered the first time, then re-used). Subscribe to own `devices` for revoked_at flips → force `signOut()` (which already chains through `wipeLocalState`). A new "Geräte" tab in Settings lists rows + offers a Revoke button per row (own row's button is disabled, label "Nutze Sign-out").
- A new full-screen `RemoteRevokedScreen` is rendered as a top-level overlay (inside `AuthProvider`) whenever `revokedRemotely` is true, so the user sees "Du wurdest remote abgemeldet" *before* the router bounces them to `/auth`.
**Tech Stack:** Postgres + RLS + RPC + Supabase Realtime (postgres_changes); React 18 + HashRouter; Electron preload IPC for OS hostname; existing `wipeLocalState` and `signOut` chain in `AuthContext`.
**Non-goals:**
- No "rename device" UI (out of scope for Phase 3 spec).
- No "log out all other devices" bulk action.
- No admin-side revoke; spec only requires user-driven revoke of own installs.
---
## Pre-flight
- [ ] **Verify current working directory and clean status**
Run: `cd "D:\Programmieren\ChatApp-Electron\chat-app" && git status`
Expected: branch `main`, no uncommitted changes (except possibly `apps/desktop/.env.local` which is gitignored).
- [ ] **Confirm tooling is green before starting**
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/shared test --run`
Expected: all green. If anything is red, STOP and report — don't start on a broken baseline.
---
## Task 1: SQL migration — devices.revoked_at + revoke_device RPC + realtime publication
**Why:** The whole feature pivots on a server-side authoritative "is this install still allowed in" flag plus a way for any of the user's own installs to flip it.
**Files:**
- Create: `supabase/migrations/20260516000005_device_revocation.sql`
**Migration must be idempotent** — pre-Phase-2 we re-ran migrations against prod by hand, and pushing again must not error. Use `add column if not exists`, `create or replace function`, `do $$ ... if not exists ... $$` for publication adds.
- [ ] **Step 1: Write the SQL migration**
Create `supabase/migrations/20260516000005_device_revocation.sql`:
```sql
-- Phase 3: per-device revocation. Adds a revoked_at flag, an RPC that lets
-- the device's owner flip it, and publishes the devices table to realtime
-- so every signed-in install can react to its own row being revoked.
alter table public.devices
add column if not exists revoked_at timestamptz null;
create index if not exists devices_user_revoked_idx
on public.devices(user_id, revoked_at);
-- RLS: existing policy on devices already gates by user_id; an UPDATE done
-- via the RPC runs with SECURITY DEFINER so we don't widen the RLS surface.
create or replace function public.revoke_device(p_device_id uuid)
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
v_owner uuid;
begin
if auth.uid() is null then
raise exception 'not authenticated' using errcode = '28000';
end if;
select user_id into v_owner
from public.devices
where id = p_device_id;
if v_owner is null then
raise exception 'device not found' using errcode = 'P0002';
end if;
if v_owner <> auth.uid() then
raise exception 'not authorized' using errcode = '42501';
end if;
update public.devices
set revoked_at = now()
where id = p_device_id
and revoked_at is null;
end;
$$;
revoke all on function public.revoke_device(uuid) from public;
grant execute on function public.revoke_device(uuid) to authenticated;
-- Replica identity full so UPDATE events deliver the full new row (including
-- revoked_at) to subscribers; default REPLICA IDENTITY DEFAULT only sends
-- the primary key columns, which would force a refetch on every event.
alter table public.devices replica identity full;
-- Add devices to the supabase_realtime publication if not already present.
do $$
begin
if not exists (
select 1
from pg_publication_tables
where pubname = 'supabase_realtime'
and schemaname = 'public'
and tablename = 'devices'
) then
execute 'alter publication supabase_realtime add table public.devices';
end if;
end
$$;
```
- [ ] **Step 2: Commit the migration**
```bash
cd "D:\Programmieren\ChatApp-Electron\chat-app"
git add supabase/migrations/20260516000005_device_revocation.sql
git commit -m "feat(P3.T1): devices.revoked_at + revoke_device RPC + realtime publication"
```
Do NOT push to prod yet — `bash scripts/prod/push-migrations.sh <filter>` is only run when explicitly authorized by the user. This task is local-commit-only.
---
## Task 2: Shared wrapper — DeviceRecord.revokedAt + revokeDevice helper + listOwnDevices update
**Why:** The desktop renderer only ever talks to Supabase through `@chat-app/shared` wrappers. Adding `revokedAt` to `DeviceRecord` and a `revokeDevice` helper keeps the contract consistent across packages and gives us a typed RPC client.
**Files:**
- Modify: `packages/shared/src/auth/device.ts`
- Test: `packages/shared/src/auth/device.test.ts` (CREATE — file doesn't exist today)
- [ ] **Step 1: Write the failing test**
Create `packages/shared/src/auth/device.test.ts`:
```ts
import { describe, expect, it, vi } from 'vitest';
import { listOwnDevices, revokeDevice } from './device';
function makeClient(overrides: {
user?: { id: string } | null;
selectData?: Array<{ id: string; name: string; platform: string; last_seen_at: string; revoked_at: string | null }>;
rpcImpl?: (fn: string, params: unknown) => Promise<{ data: unknown; error: unknown }>;
}): any {
const builder: any = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
order: vi.fn().mockResolvedValue({ data: overrides.selectData ?? [], error: null }),
};
return {
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: overrides.user ?? { id: 'u-1' } } }) },
from: vi.fn().mockReturnValue(builder),
rpc: vi.fn().mockImplementation(overrides.rpcImpl ?? (async () => ({ data: null, error: null }))),
};
}
describe('listOwnDevices', () => {
it('maps revoked_at into revokedAt', async () => {
const client = makeClient({
selectData: [
{ id: 'd-1', name: 'Laptop', platform: 'desktop', last_seen_at: '2026-05-16T00:00:00Z', revoked_at: null },
{ id: 'd-2', name: 'Old phone', platform: 'mobile', last_seen_at: '2026-05-10T00:00:00Z', revoked_at: '2026-05-15T12:00:00Z' },
],
});
const out = await listOwnDevices(client);
expect(out).toEqual([
{ id: 'd-1', name: 'Laptop', platform: 'desktop', lastSeenAt: '2026-05-16T00:00:00Z', revokedAt: null },
{ id: 'd-2', name: 'Old phone', platform: 'mobile', lastSeenAt: '2026-05-10T00:00:00Z', revokedAt: '2026-05-15T12:00:00Z' },
]);
});
});
describe('revokeDevice', () => {
it('invokes the revoke_device RPC with the device id', async () => {
const rpc = vi.fn().mockResolvedValue({ data: null, error: null });
const client = makeClient({ rpcImpl: rpc });
await revokeDevice(client, 'd-42');
expect(rpc).toHaveBeenCalledWith('revoke_device', { p_device_id: 'd-42' });
});
it('throws when the RPC returns an error', async () => {
const client = makeClient({
rpcImpl: async () => ({ data: null, error: { message: 'not authorized', code: '42501' } as any }),
});
await expect(revokeDevice(client, 'd-99')).rejects.toThrow(/not authorized/);
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `pnpm --filter @chat-app/shared test --run device`
Expected: FAIL — `revokeDevice` not exported, `revokedAt` missing from mapped row.
- [ ] **Step 3: Implement revokedAt and revokeDevice in the wrapper**
Edit `packages/shared/src/auth/device.ts`:
- Extend `DeviceRecord`:
```ts
export interface DeviceRecord {
id: string;
name: string;
platform: DevicePlatform;
lastSeenAt: string;
revokedAt: string | null;
}
```
- Update `registerDevice`'s select + return-mapper to include `revoked_at` / `revokedAt: null`:
```ts
.select('id, name, platform, last_seen_at, revoked_at')
```
```ts
return {
id: data.id,
name: data.name,
platform: data.platform,
lastSeenAt: data.last_seen_at,
revokedAt: data.revoked_at,
};
```
- Update `listOwnDevices` to select+map revoked_at:
```ts
.select('id, name, platform, last_seen_at, revoked_at')
```
```ts
return data.map((row) => ({
id: row.id,
name: row.name,
platform: row.platform,
lastSeenAt: row.last_seen_at,
revokedAt: row.revoked_at,
}));
```
- Add the new helper at the end of the file (above the re-exports):
```ts
export async function revokeDevice(
client: AppSupabaseClient,
deviceId: string,
): Promise<void> {
const { error } = await client.rpc('revoke_device', { p_device_id: deviceId });
if (error) throw new Error(error.message);
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `pnpm --filter @chat-app/shared test --run device`
Expected: PASS — 3 tests in `device.test.ts` pass.
- [ ] **Step 5: Run full shared typecheck + test suite**
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/shared test --run`
Expected: all green. If a downstream file uses `DeviceRecord` and breaks on the missing field, fix the call site to handle `revokedAt: null` (don't suppress with `as any`).
- [ ] **Step 6: Commit**
```bash
git add packages/shared/src/auth/device.ts packages/shared/src/auth/device.test.ts
git commit -m "feat(P3.T2): DeviceRecord.revokedAt + revokeDevice wrapper"
```
---
## Task 3: Preload IPC — `app:hostname` for the device-name default
**Why:** The first time we register a `devices` row for this install we need a human-readable name. Renderer has no Node access so we expose a tiny IPC that returns `os.hostname()`. If the call ever fails (e.g. web build), the caller falls back to a static "Desktop".
**Files:**
- Modify: `apps/desktop/electron/ipc-types.ts` — add channel constant
- Modify: `apps/desktop/electron/main.ts` — register handler
- Modify: `apps/desktop/electron/preload.ts` — expose method
- Modify: `apps/desktop/electron/preload-types.d.ts` — extend renderer typing
- [ ] **Step 1: Find the existing CHANNELS constant and add the new channel**
Run: `grep -n "CHANNELS =" apps/desktop/electron/ipc-types.ts`
Add a new entry next to the other `app:*` channels (or at the end if there are none):
```ts
export const CHANNELS = {
// … existing …
appHostname: 'app:hostname',
} as const;
```
(Pick a name that matches the existing convention in that file — if other entries use snake_case keys, match them.)
- [ ] **Step 2: Register the handler in main.ts**
Locate the place in `apps/desktop/electron/main.ts` where other `ipcMain.handle(...)` calls live (search for `ipcMain.handle`). Add:
```ts
import os from 'node:os';
// …
ipcMain.handle(CHANNELS.appHostname, () => {
try {
const h = os.hostname();
return typeof h === 'string' && h.length > 0 ? h : null;
} catch {
return null;
}
});
```
- [ ] **Step 3: Expose the method on the preload bridge**
Edit `apps/desktop/electron/preload.ts` — in the `contextBridge.exposeInMainWorld('electronAPI', { ... })` object, add:
```ts
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.appHostname),
```
(Match the formatting/style of nearby entries.)
- [ ] **Step 4: Extend the renderer typing**
Edit `apps/desktop/electron/preload-types.d.ts` to add `getHostname?: () => Promise<string | null>` to the `ElectronAPI` interface (make it optional so the renderer code that consumes it must always `if (typeof window.electronAPI?.getHostname === 'function')`).
- [ ] **Step 5: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add apps/desktop/electron/ipc-types.ts apps/desktop/electron/main.ts apps/desktop/electron/preload.ts apps/desktop/electron/preload-types.d.ts
git commit -m "feat(P3.T3): app:hostname IPC for device-name default"
```
---
## Task 4: AuthContext — ensure device row + revocation realtime + revokedRemotely flag
**Why:** Every install needs (a) a `devices` row on the server so it shows up in the Geräte tab and (b) a live subscription that triggers a forced sign-out when its row gets revoked.
**Files:**
- Create: `apps/desktop/src/lib/deviceRowId.ts` — tiny localStorage helper
- Modify: `apps/desktop/src/context/AuthContext.tsx` — ensure-row effect + realtime subscription + `revokedRemotely` state
- [ ] **Step 1: Create the deviceRowId helper**
Create `apps/desktop/src/lib/deviceRowId.ts`:
```ts
// localStorage key for "the devices.id row that belongs to THIS install".
// Reset on memory-wipe (NOT preserved) — a wiped install is conceptually a
// fresh install, so registering a new row is correct.
const KEY = 'chatapp.deviceRowId.v1';
export function getDeviceRowId(): string | null {
try {
const v = window.localStorage.getItem(KEY);
return v && v.length > 0 ? v : null;
} catch {
return null;
}
}
export function setDeviceRowId(id: string): void {
try {
window.localStorage.setItem(KEY, id);
} catch {
/* quota */
}
}
export function clearDeviceRowId(): void {
try {
window.localStorage.removeItem(KEY);
} catch {
/* no-op */
}
}
```
- [ ] **Step 2: Add `revokedRemotely` to the AuthContext value + wire ensure-device effect**
Edit `apps/desktop/src/context/AuthContext.tsx`:
1. Extend the `AuthContextValue` interface with:
```ts
revokedRemotely: boolean;
acknowledgeRevocation: () => void;
```
2. Import the new helper + shared wrappers + supabase client (most of these are already imported; verify):
```ts
import { listOwnDevices, registerDevice, touchDeviceLastSeen } from '@chat-app/shared/auth';
import { clearDeviceRowId, getDeviceRowId, setDeviceRowId } from '../lib/deviceRowId';
```
3. Add a `const [revokedRemotely, setRevokedRemotely] = useState(false);` next to the other `useState`s in `AuthProvider`.
4. Add `acknowledgeRevocation` as a `useCallback` that just calls `setRevokedRemotely(false)`. Include it in the `value` memo + the deps array.
5. After the existing `void registerWebPush(installId);` effect, add an "ensure device row" effect:
```ts
// Phase 3: ensure this install owns exactly one devices row. The row is
// pure session-list telemetry — it does not carry any cryptographic
// material since the per-user-key refactor. We re-use the row across
// restarts via localStorage (chatapp.deviceRowId.v1); a memory-wipe is
// intentionally treated as "new install".
useEffect(() => {
if (!session) return;
let cancelled = false;
void (async () => {
try {
const existing = getDeviceRowId();
if (existing) {
const rows = await listOwnDevices(supabase);
const match = rows.find((r) => r.id === existing && r.revokedAt === null);
if (match) {
await touchDeviceLastSeen(supabase, existing).catch(() => {});
return;
}
// Row gone / revoked: drop the stale id and fall through to
// registering a fresh one.
clearDeviceRowId();
}
if (cancelled) return;
const hostname =
(typeof window.electronAPI?.getHostname === 'function'
? await window.electronAPI.getHostname().catch(() => null)
: null) ?? 'Desktop';
const created = await registerDevice(supabase, {
name: hostname.slice(0, 64),
platform: 'desktop',
});
if (!cancelled) setDeviceRowId(created.id);
} catch (err) {
console.warn('ensure device row failed', err);
}
})();
return () => {
cancelled = true;
};
}, [session]);
```
6. Add a "revocation realtime" effect AFTER the ensure-device effect:
```ts
// Phase 3: listen for own-device revocations. The same channel also fires
// when *another* of the user's installs is revoked — we ignore those (we
// only force-sign-out when OUR row's revoked_at flips). The UI's device
// list refetches independently via its own subscription in useOwnDevices.
useEffect(() => {
if (!session) return;
const userId = session.user.id;
const channel = supabase
.channel('devices:self:' + userId)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'devices',
filter: 'user_id=eq.' + userId,
},
(payload) => {
const ownId = getDeviceRowId();
const row = payload.new as { id?: string; revoked_at?: string | null } | null;
if (!row || !ownId) return;
if (row.id !== ownId) return;
if (row.revoked_at) {
setRevokedRemotely(true);
// Force-sign-out chain. signOut already wipes localStorage.
void signOut().catch((err) => {
console.warn('forced signOut after revoke failed', err);
});
}
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [session, signOut]);
```
Place this AFTER `signOut` is defined (which is the `useCallback` at line ~251). If a hoisting/order issue makes that awkward, define a `signOutRef = useRef<() => Promise<void>>()` updated by an effect and call `signOutRef.current?.()` inside the realtime callback — but try the direct approach first.
7. Add `revokedRemotely` + `acknowledgeRevocation` to the `value` object + deps array of its `useMemo`.
8. Do NOT reset `revokedRemotely` in `signOut` itself — the flag is only ever set by the realtime callback, and `acknowledgeRevocation` is the explicit reset path.
- [ ] **Step 3: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS. If a complaint about the supabase channel filter syntax appears, double-check it matches existing realtime subscriptions in the codebase (search `postgres_changes` for an example).
- [ ] **Step 4: Commit**
```bash
git add apps/desktop/src/lib/deviceRowId.ts apps/desktop/src/context/AuthContext.tsx
git commit -m "feat(P3.T4): ensure device row + revoke-realtime + revokedRemotely flag"
```
---
## Task 5: `useOwnDevices` hook — initial list + realtime refresh
**Why:** The Geräte tab needs a live, mutable list. Pulling once on mount means a freshly-revoked row stays visible until manual refresh; subscribing keeps the list in sync with reality.
**Files:**
- Create: `apps/desktop/src/hooks/useOwnDevices.ts`
- [ ] **Step 1: Write the hook**
Create `apps/desktop/src/hooks/useOwnDevices.ts`:
```ts
import { useCallback, useEffect, useState } from 'react';
import { type DeviceRecord, listOwnDevices, revokeDevice } from '@chat-app/shared/auth';
import { useAuth } from '../context/AuthContext';
import { supabase } from '../lib/supabase';
interface State {
devices: DeviceRecord[];
loading: boolean;
error: string | null;
}
export function useOwnDevices(): {
devices: DeviceRecord[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
revoke: (deviceId: string) => Promise<void>;
} {
const { session } = useAuth();
const userId = session?.user.id ?? null;
const [state, setState] = useState<State>({ devices: [], loading: true, error: null });
const refresh = useCallback(async () => {
try {
setState((s) => ({ ...s, loading: true, error: null }));
const list = await listOwnDevices(supabase);
setState({ devices: list, loading: false, error: null });
} catch (err) {
setState((s) => ({
...s,
loading: false,
error: err instanceof Error ? err.message : 'failed to load devices',
}));
}
}, []);
useEffect(() => {
if (!userId) {
setState({ devices: [], loading: false, error: null });
return;
}
void refresh();
const channel = supabase
.channel('devices:list:' + userId)
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'devices', filter: 'user_id=eq.' + userId },
() => {
void refresh();
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [userId, refresh]);
const revoke = useCallback(async (deviceId: string) => {
await revokeDevice(supabase, deviceId);
await refresh();
}, [refresh]);
return { devices: state.devices, loading: state.loading, error: state.error, refresh, revoke };
}
```
If `../lib/supabase` doesn't export the client under the name `supabase`, follow the convention used by neighboring hooks (e.g. `usePinnedMessages.ts`, `useMentionNotifications.ts`) for both the import and any path differences.
- [ ] **Step 2: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add apps/desktop/src/hooks/useOwnDevices.ts
git commit -m "feat(P3.T5): useOwnDevices hook with realtime refresh"
```
---
## Task 6: SettingsPage — new "Geräte" tab between Sicherheit and Konto
**Why:** This is the user-facing surface — a sortable list of installs with a Revoke button per row, the own install marked with a badge and a disabled button.
**Files:**
- Create: `apps/desktop/src/components/settings/DeviceListTab.tsx`
- Modify: `apps/desktop/src/pages/SettingsPage.tsx` (TabId union, tabs array, content panel)
If the directory `apps/desktop/src/components/settings/` does not already exist, create it. Check first with `ls apps/desktop/src/components/`.
- [ ] **Step 1: Build the tab component**
Create `apps/desktop/src/components/settings/DeviceListTab.tsx`:
```tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useOwnDevices } from '../../hooks/useOwnDevices';
import { getDeviceRowId } from '../../lib/deviceRowId';
import { SpinnerIcon } from '../icons';
function formatLastSeen(iso: string, locale: string): string {
try {
const d = new Date(iso);
return d.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' });
} catch {
return iso;
}
}
export function DeviceListTab() {
const { t, i18n } = useTranslation();
const { devices, loading, error, revoke } = useOwnDevices();
const ownId = getDeviceRowId();
const [busy, setBusy] = useState<string | null>(null);
const [opError, setOpError] = useState<string | null>(null);
const handleRevoke = async (id: string) => {
setOpError(null);
setBusy(id);
try {
await revoke(id);
} catch (err) {
setOpError(err instanceof Error ? err.message : 'revoke failed');
} finally {
setBusy(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-10 text-fg-muted">
<SpinnerIcon className="h-5 w-5" />
</div>
);
}
if (error) {
return (
<p className="rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-3 text-sm text-rose-600 dark:text-rose-300">
{t('app:settings.devices.error', { defaultValue: 'Geräteliste konnte nicht geladen werden.' })}
</p>
);
}
if (devices.length === 0) {
return (
<p className="text-sm text-fg-muted">
{t('app:settings.devices.empty', { defaultValue: 'Noch keine Geräte angemeldet.' })}
</p>
);
}
return (
<div className="space-y-3">
{opError && (
<p className="rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2 text-sm text-rose-600 dark:text-rose-300">
{opError}
</p>
)}
<ul className="space-y-2">
{devices.map((d) => {
const isOwn = d.id === ownId;
const isRevoked = d.revokedAt !== null;
return (
<li
key={d.id}
className="flex items-center gap-3 rounded-lg border border-line bg-surface-2 px-4 py-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-fg">{d.name}</span>
{isOwn && (
<span className="rounded-md bg-accent/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
{t('app:settings.devices.this_device', { defaultValue: 'Dieses Gerät' })}
</span>
)}
{isRevoked && (
<span className="rounded-md bg-rose-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-rose-600 dark:text-rose-300">
{t('app:settings.devices.revoked', { defaultValue: 'Abgemeldet' })}
</span>
)}
</div>
<p className="mt-0.5 text-xs text-fg-muted">
{d.platform} · {t('app:settings.devices.last_seen', { defaultValue: 'zuletzt' })}{' '}
{formatLastSeen(d.lastSeenAt, i18n.language)}
</p>
</div>
{isOwn ? (
<button
type="button"
disabled
title={t('app:settings.devices.use_sign_out', { defaultValue: 'Nutze Sign-out' })}
className="cursor-not-allowed rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted opacity-60"
>
{t('app:settings.devices.use_sign_out', { defaultValue: 'Nutze Sign-out' })}
</button>
) : isRevoked ? (
<span className="text-xs text-fg-muted">
{t('app:settings.devices.already_revoked', { defaultValue: '—' })}
</span>
) : (
<button
type="button"
onClick={() => void handleRevoke(d.id)}
disabled={busy === d.id}
className="cursor-pointer rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-wait disabled:opacity-60 dark:text-rose-300"
>
{busy === d.id
? t('app:settings.devices.revoking', { defaultValue: 'Wird abgemeldet…' })
: t('app:settings.devices.revoke', { defaultValue: 'Abmelden' })}
</button>
)}
</li>
);
})}
</ul>
</div>
);
}
```
- [ ] **Step 2: Wire the tab into SettingsPage**
In `apps/desktop/src/pages/SettingsPage.tsx`:
1. At the top with other imports, add:
```ts
import { DeviceListTab } from '../components/settings/DeviceListTab';
```
(Match the path style of nearby `../components/...` imports.)
2. Around line 118120, extend the `TabId` union to include `'devices'`:
```ts
type TabId =
| 'profile' | 'appearance' | 'privacy' | 'notifications'
| 'voice' | 'screen-share' | 'soundboard' | 'security' | 'devices' | 'account';
```
3. In the `tabs` array around line 122132, insert a new entry between `'security'` and `'account'`:
```ts
{ id: 'devices', label: t('app:settings.nav_devices', { defaultValue: 'Geräte' }), Icon: MonitorShareIcon },
```
`MonitorShareIcon` is already imported (line 128 uses it for screen-share). If that feels wrong stylistically, use an existing alternative such as `LockIcon` or whichever icon set the file uses — but do NOT add a new icon import; pick from what's already imported.
4. In the content panel (after the `activeTab === 'security'` block at line ~344 and before the `activeTab === 'account'` block at line ~355), add:
```tsx
{activeTab === 'devices' && (
<Section
title={t('app:settings.section_devices', { defaultValue: 'Geräte' })}
description={t('app:settings.section_devices_hint', {
defaultValue: 'Übersicht aller Geräte, die mit deinem Konto angemeldet sind.',
})}
>
<DeviceListTab />
</Section>
)}
```
- [ ] **Step 3: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add apps/desktop/src/components/settings/DeviceListTab.tsx apps/desktop/src/pages/SettingsPage.tsx
git commit -m "feat(P3.T6): Settings 'Geräte' tab with revoke + this-device badge"
```
---
## Task 7: RemoteRevokedScreen — overlay shown when revokedRemotely flips to true
**Why:** When the user (or another of their installs) revokes THIS install, we force `signOut()` which races against the realtime delivery + the route navigation. The user should see a clear "Du wurdest remote abgemeldet" screen — not just be silently bounced to /auth.
**Files:**
- Create: `apps/desktop/src/components/RemoteRevokedScreen.tsx`
- Modify: `apps/desktop/src/App.tsx` — render the overlay inside `AuthProvider`
- [ ] **Step 1: Build the overlay**
Create `apps/desktop/src/components/RemoteRevokedScreen.tsx`:
```tsx
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
export function RemoteRevokedScreen() {
const { t } = useTranslation();
const { revokedRemotely, acknowledgeRevocation } = useAuth();
if (!revokedRemotely) return null;
return (
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="remote-revoked-title"
className="fixed inset-0 z-[1000] flex items-center justify-center bg-ink-950/95 p-6"
>
<div className="max-w-sm rounded-2xl border border-line bg-surface-2 p-6 text-center shadow-xl">
<h2
id="remote-revoked-title"
className="mb-2 font-display text-xl font-semibold text-fg"
>
{t('app:auth.revoked_title', { defaultValue: 'Du wurdest remote abgemeldet' })}
</h2>
<p className="mb-5 text-sm text-fg-muted">
{t('app:auth.revoked_body', {
defaultValue:
'Ein anderes deiner Geräte hat diesen Login beendet. Aus Sicherheitsgründen wurden alle lokalen Daten gelöscht.',
})}
</p>
<button
type="button"
onClick={acknowledgeRevocation}
className="inline-flex cursor-pointer items-center justify-center rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-contrast transition hover:bg-accent/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
{t('app:auth.revoked_acknowledge', { defaultValue: 'Verstanden' })}
</button>
</div>
</div>
);
}
```
- [ ] **Step 2: Mount the overlay in App.tsx**
In `apps/desktop/src/App.tsx`:
1. Add the import at the top with other component imports:
```ts
import { RemoteRevokedScreen } from './components/RemoteRevokedScreen';
```
2. Inside the `<HashRouter>` block, alongside `<UpdateToast />` and `<CrashToast />` (right before `</HashRouter>`), add:
```tsx
<RemoteRevokedScreen />
```
- [ ] **Step 3: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add apps/desktop/src/components/RemoteRevokedScreen.tsx apps/desktop/src/App.tsx
git commit -m "feat(P3.T7): RemoteRevokedScreen overlay for own-device revocation"
```
---
## Final gate
- [ ] **Step 1: Run full typecheck across both packages**
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck`
Expected: both PASS.
- [ ] **Step 2: Run full shared test suite**
Run: `pnpm --filter @chat-app/shared test --run`
Expected: PASS — at least the previous 30 tests + the 3 new device tests added in T2 = 33 tests passing.
- [ ] **Step 3: Verify no uncommitted changes**
Run: `git status`
Expected: clean working tree on `main` (`apps/desktop/.env.local` is gitignored and may show in `git status --ignored`, that's fine).
- [ ] **Step 4: Report to user**
Report: "Phase 3 code-complete on `main`. New migration `20260516000005_device_revocation.sql` is committed locally but NOT yet pushed to prod — say the word and I run `bash scripts/prod/push-migrations.sh device_revocation` (filter pattern matches the new file). After the prod push, smoke test in dev: Settings → Geräte should list this install with the 'Dieses Gerät' badge. To test revoke, sign into a second install (or use prod CLI), revoke this install from the other one → this client should pop the 'Du wurdest remote abgemeldet' overlay and bounce to /auth. **No release** — version stays 0.18.8 until all 5 phases done."
---
## Self-review checklist (resolved inline)
1. **Spec coverage:**
- existing devices table → T1 reuses it
- Geräte tab between Sicherheit and Konto → T6
- name · platform · last_seen → T6 row layout
- "Dieses Gerät" badge → T6 isOwn branch
- revoked_at column → T1
- revoke_device RPC validates user_id = auth.uid() → T1
- realtime subscription on own devices → T4
- forced signOut + Memory-Wipe on own revoke → T4 (signOut already chains wipeLocalState)
- "Du wurdest remote abgemeldet" screen → T7
- own device's revoke button disabled, label "Nutze Sign-out" → T6 isOwn branch
2. **Placeholders:** none — every step has concrete code.
3. **Type consistency:**
- `DeviceRecord.revokedAt: string | null` introduced in T2; consumed by T5 (hook) and T6 (UI) consistently
- `revokeDevice(client, deviceId): Promise<void>` defined T2, used T5 (via hook) and indirectly T6 (via hook's `revoke`)
- `getDeviceRowId(): string | null` defined T4, used in T4 (AuthContext) + T6 (DeviceListTab)
- `revokedRemotely` + `acknowledgeRevocation` added to AuthContext value in T4, consumed by T7
4. **One ambiguity surfaced + resolved:** the spec says Realtime → forced signOut. We also need the *revoking* device (when revoking ANOTHER device) to NOT receive any extra signOut for itself — handled by T4's `row.id !== ownId` early return.
5. **Memory-wipe interaction:** the `chatapp.deviceRowId.v1` key is intentionally NOT added to `PRESERVE_LOCAL_STORAGE`, so a memory-wipe gets a fresh device row on next sign-in. That matches the spirit of memory-wipe = fresh install.
@@ -0,0 +1,849 @@
# Phase 4A — Image Annotation vor Send
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let the user mark up an image attachment (pen, arrow, rectangle, circle, text, highlighter) BEFORE it's sent. The annotated copy replaces the original `File` in the composer's attachments[] state; the recipient sees the flattened PNG with the annotation baked in (no separate metadata, no decoding work on receive).
**Architecture:**
- New self-contained component `ImageAnnotator.tsx` (~400 LOC, no external deps): a full-screen modal with a single `<canvas>` rendering the original image plus an in-memory "ops stack" of drawing commands. Every tool stroke is one op; undo pops from the stack into a redo buffer; redo moves it back. The canvas re-renders the whole stack on every change — simple, debuggable, fast at typical image sizes.
- New `AttachmentPreview` prop `onEdit?: () => void`. When the preview is an image and `onEdit` is wired, a `✏` button overlays the thumb. Clicking it opens `ImageAnnotator`; on Save the modal calls `onSave(newFile)` and `ConversationPage` swaps the entry in `attachments[]`.
- Save flow: `canvas.toBlob({ type: 'image/png' })` → wrap in `new File([blob], original.name.replace(/\.\w+$/, '') + '-annotated.png', { type: 'image/png', lastModified: Date.now() })` → return through `onSave`.
**Tech Stack:** React 18 + TypeScript + HTML5 Canvas. Reuses Tailwind classes from the existing modal/toolbar code in the app. No new dependencies.
**Non-goals:**
- No image-only crop / rotate / filter (just annotation).
- No persistence of in-progress annotations between modal opens.
- No collaboration / sharing of the op-stack (recipient sees flattened PNG only).
- No SVG / vector output; PNG raster only.
---
## Pre-flight
- [ ] **Verify clean working tree on `main`**
Run: `cd "D:\Programmieren\ChatApp-Electron\chat-app" && git status`
Expected: clean (ignored `.env.local` is fine).
- [ ] **Confirm tooling is green**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS. If it's red, STOP and report — don't start on a broken baseline.
---
## Task 1: ImageAnnotator component skeleton + canvas mount + op-stack types
**Why:** Get the modal rendering with the image visible inside the canvas before adding any drawing logic. Establishes the file's structure (state, refs, types) that the next tasks fill in.
**Files:**
- Create: `apps/desktop/src/components/ImageAnnotator.tsx`
- [ ] **Step 1: Write the file**
Create `apps/desktop/src/components/ImageAnnotator.tsx`:
```tsx
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[]>([]);
const [tool, setTool] = useState<AnnotatorTool>('pen');
const [color, setColor] = useState<AnnotatorColor>('#ef4444');
const [width, setWidth] = useState<AnnotatorWidth>(4);
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);
}
}, [imageLoaded, ops]);
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');
};
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}
className="max-h-full max-w-full cursor-crosshair 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 items-center justify-between gap-3 border-t border-line/40 bg-surface-2 px-4 py-3">
<div className="text-xs text-fg-muted">
{ops.length === 0
? t('app:annotator.no_changes', { defaultValue: 'Keine Änderungen' })
: ops.length + ' ' + t('app:annotator.changes', { defaultValue: 'Änderungen' })}
</div>
<div className="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>
);
}
// renderOp is filled in by Task 2. Stub for now so the canvas-replay loop
// in the main useEffect compiles cleanly.
function renderOp(_ctx: CanvasRenderingContext2D, _op: AnnotatorOp): void {
// implemented in Task 2
}
```
If `text-accent-fg` doesn't exist in this project's Tailwind, use the same class the existing accent buttons use — grep `Grep -n "bg-accent " apps/desktop/src/components/RemoteRevokedScreen.tsx` (P3.T7 wrote this very recently) to find the conventional pair.
- [ ] **Step 2: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
cd "D:\Programmieren\ChatApp-Electron\chat-app"
git add apps/desktop/src/components/ImageAnnotator.tsx
git commit -m "feat(P4A.T1): ImageAnnotator modal skeleton with canvas mount + op-stack types"
```
---
## Task 2: Drawing implementation — render all 6 tools + capture pointer events
**Why:** This is the drawing engine. After this task the user can free-hand-draw + shapes on the canvas with the defaults (pen / red / width 4). Tool/color/width pickers come in Task 3.
**Files:**
- Modify: `apps/desktop/src/components/ImageAnnotator.tsx` (replace the `renderOp` stub + add pointer handlers + draft state)
- [ ] **Step 1: Replace the `renderOp` stub with the real renderer**
At the bottom of the file, replace the stub with:
```ts
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();
}
```
- [ ] **Step 2: Add pointer-event capture + draft state**
Inside the component, near the other refs, add:
```ts
const draftRef = useRef<AnnotatorOp | null>(null);
const [draftTick, setDraftTick] = useState(0);
```
Replace the existing main render `useEffect` body so it ALSO draws the in-progress draft (live preview during pointer-down):
```ts
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]);
```
Add this helper right above the `return` (to convert client coords to internal canvas coords — important because the canvas is scaled to fit):
```ts
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,
};
}
```
Add these handlers (also above the `return`):
```ts
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);
};
```
Wire the handlers onto the `<canvas>` element — replace the existing `<canvas ref={canvasRef} className="..." />` JSX with:
```tsx
<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"
/>
```
- [ ] **Step 3: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add apps/desktop/src/components/ImageAnnotator.tsx
git commit -m "feat(P4A.T2): annotator drawing engine — pen/arrow/rect/circle/text/highlighter"
```
---
## Task 3: Toolbar — tool picker, color swatches, width swatches, undo/redo
**Why:** The user can already DRAW (default pen + red + width 4) — but can't change tool/color/width or visibly undo. This task adds the controls.
**Files:**
- Modify: `apps/desktop/src/components/ImageAnnotator.tsx` (replace the footer)
- [ ] **Step 1: Add the tool/color/width palette to the footer**
Replace the existing `<footer>` element with:
```tsx
<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>
```
And add this helper at the bottom of the file (after `renderOp`):
```ts
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';
}
}
```
- [ ] **Step 2: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add apps/desktop/src/components/ImageAnnotator.tsx
git commit -m "feat(P4A.T3): annotator toolbar — tool/color/width/undo/redo controls"
```
---
## Task 4: Wire annotator into the composer — "✏" overlay on image previews + Save round-trip
**Why:** The annotator is fully functional but unreachable from the UI. This task adds the entry point.
**Files:**
- Modify: `apps/desktop/src/pages/ConversationPage.tsx` (the `AttachmentPreview` function at line ~1393 + the call site at line ~915 + add ImageAnnotator import/state)
- [ ] **Step 1: Extend `AttachmentPreview` with an `onEdit` prop**
Replace the existing `AttachmentPreview` function (line ~1393) with:
```tsx
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(() => {
if (!isImage) return;
const u = URL.createObjectURL(file);
setUrl(u);
return () => URL.revokeObjectURL(u);
}, [file, isImage]);
return (
<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" />
) : (
<div className="flex h-20 w-32 flex-col justify-center gap-0.5 px-2 text-[10px]">
<span className="truncate font-semibold text-fg" title={file.name}>
{file.name || 'Datei'}
</span>
<span className="text-fg-muted">{file.type || 'unbekannt'}</span>
<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}
aria-label="Entfernen"
className="absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white transition hover:bg-rose-500/80"
>
<XIcon className="h-3 w-3" />
</button>
</div>
);
}
```
If `PencilIcon` isn't already exported from `./components/icons` (grep first: `Grep -n "PencilIcon\|EditIcon" apps/desktop/src/components/icons*`), inline this small SVG below `AttachmentPreview`:
```tsx
function PencilIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
);
}
```
- [ ] **Step 2: Add ImageAnnotator state + import to ConversationPage**
In the import block at the top of the file (alongside other `../components/...` imports), add:
```ts
import { ImageAnnotator } from '../components/ImageAnnotator';
```
Near the top of `ConversationPage` where other `useState`s live (e.g. near `attachments`), add:
```ts
const [annotatingIndex, setAnnotatingIndex] = useState<number | null>(null);
```
- [ ] **Step 3: Pass `onEdit` to AttachmentPreview**
In the `attachments.map(...)` at line ~915, change the JSX to:
```tsx
{attachments.map((file, idx) => (
<AttachmentPreview
key={idx}
file={file}
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
onEdit={
file.type.startsWith('image/')
? () => setAnnotatingIndex(idx)
: undefined
}
/>
))}
```
- [ ] **Step 4: Render the annotator at the page root when `annotatingIndex !== null`**
Place this near the end of the JSX return — just before the closing `</div>` of the page root:
```tsx
{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);
}}
/>
)}
```
- [ ] **Step 5: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add apps/desktop/src/pages/ConversationPage.tsx
git commit -m "feat(P4A.T4): wire ImageAnnotator into composer attachment preview"
```
---
## Final gate
- [ ] **Step 1: Typecheck both packages**
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck`
Expected: both PASS.
- [ ] **Step 2: Run shared tests (sanity — these changes don't touch shared)**
Run: `pnpm --filter @chat-app/shared test -- --run`
Expected: PASS — same count as Phase 3 baseline (33 tests).
- [ ] **Step 3: Verify no uncommitted changes**
Run: `git status`
Expected: clean working tree on `main`.
- [ ] **Step 4: Report**
Report: "Phase 4A (Image Annotation) code-complete on `main`. Restart dev, attach an image in any chat, hover the preview → ✏ button appears → click → annotator opens. Draw, choose tools/colors/widths, undo/redo, Save → preview updates with the annotated PNG, ready to send. No migration. Released? defer to user."
---
## Self-review (resolved inline)
1. **Spec coverage** (against `docs/superpowers/specs/2026-05-16-fifteen-features-design.md` lines 97-101):
- "Attachment-picker for images shows a new ✏ Bearbeiten button before send" → T4 adds the overlay button (image-only via `file.type.startsWith('image/')`).
- "Opens `<ImageAnnotator>` modal: canvas overlay on the image" → T1 mounts canvas at image's natural size.
- "Tools: pen, arrow, rectangle, circle, text, highlighter" → T2 implements all 6 in `renderOp`.
- "6 colors, 3 stroke widths" → T3 swatches use the exact AnnotatorColor/AnnotatorWidth tuples from T1.
- "Undo/Redo stack, Reset, Save" → T1 callbacks + T3 toolbar buttons; Ctrl+Z / Ctrl+Y bindings in T1 keydown effect.
- "On Save: canvas.toBlob({type: 'image/png'}) flattens" → T1's `handleSave` + T4's `setAttachments(... map ... idx === annotatingIndex ? next : f)`.
2. **Placeholders:** none. Every step has concrete code.
3. **Type consistency:**
- `AnnotatorTool`/`AnnotatorColor`/`AnnotatorWidth` defined in T1, consumed everywhere downstream.
- `AnnotatorOp` fields match `renderOp` switch arms in T2.
- `ImageAnnotator` props shape (`file`/`onCancel`/`onSave`) used identically in T4's call site.
- `AttachmentPreview` extended with optional `onEdit?: () => void` in T4; existing callers passing only `file`/`onRemove` continue to compile because it's optional.
4. **One ambiguity surfaced + resolved:** the spec doesn't say whether to overwrite the original File or keep a side-by-side copy. Plan replaces the slot with a `<basename>-annotated.png` File so the recipient sees the marked-up version with a clear filename hint.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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