Compare commits

...

284 Commits

Author SHA1 Message Date
byGalax 27160145f9 chore(desktop): release v0.21.8 2026-06-02 21:37:29 +02:00
byGalax 8ea2cb48e9 debug(desktop): on-screen scroll-metrics overlay (temporary) 2026-06-02 21:34:04 +02:00
byGalax c3ef995404 chore(desktop): release v0.21.7 2026-06-02 21:04:37 +02:00
byGalax b4ed3aced0 fix(desktop): MessageList anchors via direct scrollTop + flex-1 height
scrollToIndex raced the virtualizer's own layout effect and depended on size estimates, leaving the list pinned at the top on open (and flickering as it settled). Drive scrollTop = scrollHeight directly for the bottom case (order-independent, true bottom) and re-pin on measure; switch the scroll root from h-full to flex-1 min-h-0 so it always has a bounded, scrollable height.
2026-06-02 21:00:19 +02:00
byGalax 30d00194be chore(desktop): release v0.21.6 2026-06-02 20:46:53 +02:00
byGalax 31b394a6e0 chore(desktop): drop react-virtuoso + scroll debug instrumentation 2026-06-02 20:44:56 +02:00
byGalax 271d6fff5c feat(desktop): use MessageList in ConversationPage (replace react-virtuoso) 2026-06-02 20:32:08 +02:00
byGalax 8b8d71bc4d feat(desktop): TanStack-Virtual MessageList with deferred reveal 2026-06-02 20:27:31 +02:00
byGalax 40f36cb182 refactor(desktop): export VirtuosoRow type for MessageList 2026-06-02 20:26:21 +02:00
byGalax 2372731504 feat(desktop): expose reactions reveal-gate flag (ready) 2026-06-02 20:26:00 +02:00
byGalax 43a99a8d6d feat(desktop): pure scroll-decision logic for new message list 2026-06-02 20:25:06 +02:00
byGalax f73abbd860 build(desktop): add @tanstack/react-virtual 2026-06-02 20:24:15 +02:00
byGalax e822f6f58f docs(plan): message-list / scroll rewrite implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:22:28 +02:00
byGalax 255dbdc712 docs(spec): message-list / scroll rewrite design (TanStack Virtual + deferred reveal)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:16:42 +02:00
byGalax 51a8630114 chore(desktop): release v0.21.5 2026-06-02 19:41:46 +02:00
byGalax d539656535 fix(conversation): anchor message list to bottom on chat switch
initialTopMostItemIndex was a plain index (top-aligned), so react-virtuoso painted with estimated row heights then corrected scrollTop after measuring the real (taller) dynamic bubbles — a visible jump on every chat switch. Use { index: 'LAST', align: 'end' } to pin the bottom edge instead, matching react-virtuoso's canonical chat pattern; the restore-to-saved-row path stays align: 'start'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:39:04 +02:00
byGalax 5bc30c950c feat(infra): migrate self-hosted backend to netralax.de
Move Supabase + LiveKit from the netralax.cloud VPS to a new netralax.de server. Adds the migration runbook (docs/), one-time move scripts (scripts/migrate/), and prod Caddy/LiveKit config templates (infra/). Repoints the desktop publish/changelog URLs and prod ops config to .de. JWT_SECRET + VAPID copied identically so already-installed clients keep working; the new server also serves the legacy .cloud hostnames.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:39:04 +02:00
byGalax 588b843904 chore(desktop): release v0.21.4 2026-05-21 23:12:38 +02:00
byGalax dbf8030e93 fix(conv-key): rotate-instead-of-share + cache invalidation; fire-first realtime inserts (no sound-vs-text gap)
Friend-DM "Nachricht nicht lesbar" recurred even after v0.21.3 because the
proactive sweep called shareConvKeyToUser, which reads the module-level
conv-key cache first. After a server-side cleanup the cache still held the
stale locally-bootstrapped key, so each side wrapped its own different key
for the peer and the bundles diverged anew.

Switch the sweep to rotate_conv_key when any peer's user-id bundle is
missing at the active version: a fresh symmetric key is generated, wrapped
for every member at their CURRENT pubkey, and the active version is bumped
under a row-level FOR UPDATE lock. Concurrent rotations are race-safe — the
loser sees "new version must be greater" and bails; the winner's bundles
propagate via realtime.

Realtime conversation_keys subscription now invalidates the cache for the
affected (conversationId, key_version) on any INSERT/UPDATE/DELETE — so
admin cleanups, peer rotations, or device wraps can no longer leave a
stale entry in this client's session cache.

queueInsert now fires the first event of a quiet period immediately and
only collapses follow-up bursts. BATCH_WINDOW_MS dropped 250 → 80 ms.
This closes the ~250 ms gap between the notification sound and the
message body appearing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:10:36 +02:00
byGalax 615770722e chore(desktop): release v0.21.3 2026-05-21 22:52:50 +02:00
byGalax f1cba99b9e fix(conv-key): bootstrap re-fetches canonical key after share to handle concurrent race 2026-05-21 22:51:17 +02:00
byGalax f60c5c676a chore(desktop): release v0.21.2 2026-05-18 15:40:56 +02:00
byGalax 8e6be3256d fix(conv-key): rotate on unwrap failure (post-reset_user_key recovery) 2026-05-18 15:38:31 +02:00
byGalax 508c53b451 chore(desktop): release v0.21.1 2026-05-18 14:40:29 +02:00
byGalax e2f86bc377 fix(chat): snap to bottom after send to mask composer-shrink layout shift 2026-05-18 14:29:31 +02:00
byGalax 92a6e01a26 fix(chat): instant scroll + larger at-bottom threshold + footer spacer (Discord-clean) 2026-05-18 14:21:04 +02:00
byGalax 3d959aaadf fix(chat): auto-rotate stuck conv-keys on chat open (receive-side recovery) 2026-05-18 14:03:35 +02:00
byGalax 787437c3f1 chore(desktop): release v0.21.0 2026-05-17 20:04:00 +02:00
byGalax be5647281d chore(mobile): track expo-generated .gitignore 2026-05-17 20:00:18 +02:00
byGalax fc8fc275cb fix(soundboard): mount-once hotkey registration to avoid call-state churn 2026-05-17 17:49:08 +02:00
byGalax e19a71e892 feat(profile): preserve animation on GIF/APNG/animated-WebP avatar uploads
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 17:38:07 +02:00
byGalax 11869be443 feat(soundboard): hotkeys fire outside calls with local-only playback
Lifts the `state.kind === 'connected'` guard from the soundboard hotkey
useEffect so OS-level shortcuts are always registered. Inside a call the
existing `playSoundboard` path routes audio into the LiveKit pipeline so
peers hear; outside a call the new `playSoundboardLocal` helper fetches
the blob via `getSoundBlob`, creates a short-lived object URL, and plays
through a fresh HTMLAudioElement on the system default output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 17:33:50 +02:00
byGalax 3fa8b6dbc1 feat(call): shared annotation overlay on screen share 2026-05-17 17:30:05 +02:00
byGalax f9f8e3fdb8 feat(whiteboard): live cursors via broadcast channel 2026-05-17 17:24:34 +02:00
byGalax 4ed3f04300 fix(view-once): hold-to-view pattern + content-protection during reveal 2026-05-17 17:21:22 +02:00
byGalax a7ffcbff83 feat(voice): playback-speed toggle (1x/1.5x/2x) with per-user default 2026-05-17 17:09:01 +02:00
byGalax 82600915f1 feat(composer): persist text + reply target per chat across restarts 2026-05-17 17:06:43 +02:00
byGalax 93098a74ca docs(phase8): feature batch implementation plan 2026-05-17 16:54:56 +02:00
byGalax c8f0e8efd5 chore(desktop): release v0.20.1 2026-05-17 15:11:11 +02:00
byGalax fd9b8a88d6 docs(chat-switch): implementation plan for chat-switch flicker fix 2026-05-17 15:09:43 +02:00
byGalax ab2f7130fe fix(chat-switch): seed lastPendingCountRef from outbox to suppress mount scroll 2026-05-17 15:02:40 +02:00
byGalax b9a3dde1aa refactor(chat-switch): drop redundant id-change reset effect 2026-05-17 14:54:31 +02:00
byGalax 65d2446804 fix(chat-switch): remount ConversationPage per conversation id 2026-05-17 14:51:22 +02:00
byGalax faa12a4ebb feat(chat-switch): hydrate useConversationMessages from in-memory cache
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 14:48:48 +02:00
byGalax 37dd1b4f23 feat(chat-switch): in-memory message cache helper 2026-05-17 14:39:01 +02:00
byGalax d803773261 chore(desktop): release v0.20.0 2026-05-17 13:21:50 +02:00
byGalax 49855c5d3f fix(console-noise): pre-warm via auth.getSession + demote stuck crypto-migration logs to debug 2026-05-17 01:43:53 +02:00
byGalax c9a64bf898 feat(call): remove live-captions feature (privacy-inconsistent with E2E, unused) 2026-05-17 01:39:42 +02:00
byGalax 7f704e80f6 feat(P7.T4): per-attachment view-once toggle on AttachmentPreview 2026-05-17 01:25:08 +02:00
byGalax 940432d287 feat(P7.T3): composer toolbar — 5 inline buttons + popover for Bild/Poll/Aktivitäten 2026-05-17 01:18:32 +02:00
byGalax 28b6d64936 feat(P7.T2): ComposerActionsMenu popover (Bild/Datei + Umfrage + Aktivitäten group)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:14:34 +02:00
byGalax 92baa626d6 docs(P7): Phase 7 composer redesign plan (Hybrid: + menu + per-attachment view-once) 2026-05-17 01:11:04 +02:00
byGalax cd7ef8dccc fix(P6): AttachmentImage Lightbox blob URL — split unmount-only revoke, lock deps to handle.id 2026-05-17 01:09:09 +02:00
byGalax 58efc66ca7 fix(P4A): guard ImageAnnotator load callbacks with cancelled flag (React strict double-mount) 2026-05-17 01:03:37 +02:00
byGalax 1e139fb86e fix(P4A): ImageAnnotator effect-race — stabilize onCancel via ref so URL.revoke doesn't fire mid-decode 2026-05-17 01:01:27 +02:00
byGalax eeb713f03d fix(P6): convert positional bindings to named-params object for better-sqlite3 2026-05-17 00:59:39 +02:00
byGalax 837b5a326e fix(P6): hoist savedPositionRef above initialTopMostIndex (TDZ) + auto-open DevTools in dev 2026-05-17 00:56:34 +02:00
byGalax b1f37752d6 perf(P6C.T12): optimistic UI for mute / mentions-only / archive / pin / device-revoke
Audit of write-actions revealed that send (and edit via realtime UPDATE) are
already optimistic via local state insertion in `useConversationMessages`,
and friend nicknames are pure-local localStorage. Five user-write actions
were waiting on the ~100-200 ms server roundtrip + realtime echo:

* Toggle mute (`setConversationMutedUntil`)
* Toggle mentions-only (`setConversationMentionsOnly`)
* Toggle archive (`setConversationArchived`)
* Pin / unpin message (`pinMessage` / `unpinMessage`)
* Revoke device (`revokeDevice` RPC)

All five now flip local state synchronously and roll back on failure. The
existing realtime subscriptions reconcile canonically (no-op when the
optimistic patch already matches the server row), so this is purely a UX
latency improvement — no protocol or persistence changes.

Reactions (`toggleReaction` / `voteExclusive`) were intentionally skipped
this round: rollback semantics for the exclusive-vote path with multiple
sequential awaits are messy enough to warrant a dedicated pass.
2026-05-17 00:44:28 +02:00
byGalax 854c4b91a8 perf(P6C.T11): bundle audit + opt-in visualizer for future audits
Audited the renderer bundle with rollup-plugin-visualizer. Top offenders
(libsodium-sumo 292KB gz, livekit-client 177KB gz, @supabase 149KB gz)
all have justified usage and no viable swap. Mediapipe + track-processors
are already lazy-loaded into a separate chunk on first BackgroundBlur
activation. Bundle has zero duplicate packages, no moment/lodash/dayjs,
no syntax-highlight libs, no polyfills — already lean from T1+T6.

Added rollup-plugin-visualizer as a dev-dep, gated behind ANALYZE=true
so production builds pay no cost. Run with:
  ANALYZE=true pnpm --filter @chat-app/desktop build
to regenerate stats.html (treemap) + stats.json (raw) for future audits.
2026-05-17 00:35:41 +02:00
byGalax d3b708636f perf(P6B.T8): virtualize message list with react-virtuoso
Switches the ConversationPage chat list from a full O(N) render to
windowed rendering via react-virtuoso. On long histories only the
visible rows (plus a 400px overscan buffer) live in the DOM, ending the
scroll jank and layout thrashing that hit conversations with >500
messages.

Preserved behaviors:
- Newest message visible on open via initialTopMostItemIndex.
- Auto-scroll on send via a pending-count-based effect (the old
  setStickToBottom + useLayoutEffect pattern doesn't apply now that
  Virtuoso owns the scroll element).
- Realtime auto-follow only when scrolled to bottom (followOutput).
- 'New messages while away' counter + 'jump to newest' pill via
  atBottomStateChange.
- Pinned-message / reply / search jumps via virtuosoRef.scrollToIndex;
  expands displayCount on the fly if the target is outside the
  rendered slice. Flash highlight unchanged.
- Load-older infinite scroll via Virtuoso startReached (replaces the
  IntersectionObserver-on-sentinel pattern).
- Per-conversation position memory now keys on row index instead of
  pixel scrollTop (the latter isn't meaningful under virtualization).

Also wires the PinnedMessagesPanel onJump callback (previously a TODO
that just closed the panel) into jumpToMessage, since virtualization
made the smooth-scroll-from-pinned UX easy to deliver as a side
benefit.
2026-05-17 00:20:41 +02:00
byGalax c449943b52 perf(P6B.T7): WebP thumbnails for image attachments (320px max, thumb-first render) 2026-05-17 00:12:03 +02:00
byGalax db59e3f658 perf(P6B.T6): offload Argon2 + userKey unseal to Web Worker
PIN-unlock used to freeze the renderer for ~1-2 s on mid-hardware while
the moderate-preset Argon2id KDF + sealed-key secretbox open ran on the
main thread. Push that work into a Vite-bundled ESM Web Worker so the
unlock screen stays responsive.

The worker (apps/desktop/src/workers/crypto.worker.ts) bundles its own
libsodium-wrappers-sumo instance and registers a fresh CryptoBackend
inside the worker realm. Client wrapper (apps/desktop/src/lib/cryptoWorker.ts)
spawns a one-shot worker per unlock — workers are cheap, PIN-unlock is
once-per-session, and one-shot avoids the request-id bookkeeping that the
existing decrypt.worker needs for high-volume per-message decrypts.

Falls back to inline main-thread openUserKey when the Worker constructor
is unavailable (vitest's jsdom) or when worker spawn / round-trip fails
(strict CSP). All 14 desktop + 71 shared tests still pass — the existing
loadOrUnlockUserKey test exercises the inline-fallback branch.

Private key bytes are transferred (zero-copy) back to the main thread,
detaching the worker-side ArrayBuffer view on transfer.

Build emits crypto.worker-<hash>.js (~2.5 MB, mostly libsodium WASM glue
duplicated from the main bundle). Acceptable trade-off for the unblocked
UI; a future change could lazy-load libsodium on the main thread to drop
the duplication.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:05:00 +02:00
byGalax 6c6828006b feat(P6B.T9): PIN-Idle-Auto-Lock setting + idle watcher
Adds opt-in (default OFF) auto-lock: after X minutes of no user input
the app calls signOut() (full memory wipe + PIN re-entry on next open).
Settings dropdown (Aus / 5 / 15 / 30 / 60 min) lives in SecurityCenter
below the existing wipe-on-close toggle. The idle timer is mounted in
AppShell via useIdleAutoLock; activity events are throttled to 1 Hz to
avoid timer thrash on rapid mouse movement. The localStorage key is
added to PRESERVE_LOCAL_STORAGE so a wipe never silently disables the
feature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:59:27 +02:00
byGalax 21376daf39 perf(P6B.T10): i18next tree-shake — prune dead locale keys
Audited all four i18next namespaces (common, auth, errors, app) against
static t() call-site grep across the entire desktop source tree.
Pruned 40 dead keys per locale, 80 total lines removed across 6 files.

Dead keys removed:
- app: call.{e2ee_active_hint,still_live,voice_connected},
       chats.{show_archived,show_active}, admin.nav,
       friends.confirm_unfriend, settings.{danger_zone,this_device,
       presence,section_ringtone}
- auth: signed_in.{title,session_active,user_id,admin,yes,no,sign_out,
        device_active,device_platform,device_registered_at},
        entire device.* section (old device-registration UI)
- common: loading, cancel, retry, online, offline, idle, dnd, invisible

Dynamic-key patterns were found and respected: t('errors:'+code)
keeps all errors keys; t('app:presence.'+val) keeps all presence keys;
t('app:annotator.tool.'+id) uses defaultValue so its locale entries
were not required.
2026-05-16 23:55:04 +02:00
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
byGalax 0288d7a476 chore(desktop): release v0.18.7 2026-05-16 15:30:14 +02:00
byGalax 34b972ec2a refactor(desktop): Settings = tab pattern instead of scroll + observer
The previous build used IntersectionObserver to highlight whichever
section was in view. With nine sections of unequal heights and smooth-
scroll firing observer callbacks mid-scroll, the active highlight
drifted (clicking 'Konto' showed 'Soundboard' as active because the
last section never crossed the observer's 20%-30% band).

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

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

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

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

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

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

Fixes shipped:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

resolveJsonModule + esModuleInterop are already on in tsconfig.node.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:33:18 +02:00
byGalax b65a3994f3 chore(desktop): release v0.17.4 2026-05-13 22:25:11 +02:00
byGalax aa609389fa feat(chat): render Media/Files + Group Info drawers inline, not overlaid
Both right-hand panels used absolute inset-y-0 right-0 and floated on
top of the conversation, hiding the messages directly underneath the
panel and looking unlike Discord's actual layout. Restructure:

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:58:07 +02:00
296 changed files with 47150 additions and 3561 deletions
+5 -1
View File
@@ -7,7 +7,11 @@
# stopped publishing Tauri releases to this host.
# Host serving latest.yml + installer artifacts over HTTPS.
UPDATE_HOST=update.netralax.cloud
# NOTE: during the .cloud→.de transition the new VPS must ALSO serve the same
# artifacts under update.netralax.cloud (point its DNS at the new IP) so that
# already-installed clients — which have update.netralax.cloud baked in — can
# still pull the release that switches them over to .de.
UPDATE_HOST=update.netralax.de
# SSH user on UPDATE_HOST with write access to UPDATE_REMOTE_PATH.
UPDATE_SSH_USER=chatapp-deploy
+4
View File
@@ -33,6 +33,10 @@ web-build/
apps/desktop/out/
apps/desktop/release/
# Bundle visualizer reports
apps/desktop/stats.html
apps/desktop/stats.json
# Logs
*.log
npm-debug.log*
+11
View File
@@ -0,0 +1,11 @@
# React Native / Expo expects a flat (npm/yarn-style) node_modules layout
# where ALL transitive deps are reachable from any package. pnpm's strict
# isolation breaks that — Metro hits a fresh missing-module error for every
# transitive dep (invariant, nullthrows, @babel/runtime/helpers/*, ...).
#
# shamefully-hoist=true hoists everything to the workspace-root node_modules
# (like npm/yarn) so all transitive deps are reachable via standard Node
# resolution. This is the official Expo monorepo workaround and is the
# only sustainable answer for Expo + RN + pnpm. Desktop (Electron+Vite)
# bundles via Vite, so the flatter layout doesn't affect it.
shamefully-hoist=true
+27 -1
View File
@@ -10,6 +10,9 @@
import react from '@vitejs/plugin-react';
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import path from 'node:path';
import { visualizer } from 'rollup-plugin-visualizer';
const ANALYZE = process.env.ANALYZE === 'true';
const rendererAliases = {
'@': path.resolve(__dirname, './src'),
@@ -44,7 +47,30 @@ export default defineConfig({
// bundle. Relative base produces `./assets/...` which works in both
// dev (served from /) and packaged builds.
base: './',
plugins: [react()],
// Visualizer plugins are gated behind ANALYZE=true so the production
// build never pays the analysis cost. Re-enable with:
// ANALYZE=true pnpm --filter @chat-app/desktop build
// which writes apps/desktop/stats.html (treemap) + stats.json (raw).
plugins: [
react(),
...(ANALYZE
? [
visualizer({
filename: 'stats.html',
template: 'treemap',
gzipSize: true,
brotliSize: true,
open: false,
}),
visualizer({
filename: 'stats.json',
template: 'raw-data',
gzipSize: true,
brotliSize: true,
}),
]
: []),
],
resolve: {
alias: rendererAliases,
},
+24
View File
@@ -106,6 +106,24 @@ 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',
// Content-protection toggle. Enables/disables OS-level screenshot/screen-
// recording block (WDA_MONITOR on Windows, NSWindowSharingNone on macOS)
// while a view-once image is being revealed. No-op on Linux X11.
WINDOW_SET_CONTENT_PROTECTION: 'window:set-content-protection',
// 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 ------------------------------------------------------
@@ -258,6 +276,12 @@ export interface UpdateProgress {
total: number;
}
// ---- Window content protection -------------------------------------------
export interface WindowSetContentProtectionArgs {
enabled: boolean;
}
// ---- Runtime marker ------------------------------------------------------
/** Value exposed on `window.electronAPI.platform`. Used by the renderer
+43 -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';
@@ -24,6 +26,7 @@ import { register as registerShortcuts } from './modules/shortcuts';
import { register as registerSql } from './modules/sql';
import { register as registerTray } from './modules/tray';
import { register as registerUpdater } from './modules/updater';
import { register as registerWindowContentProtection } from './modules/window-content-protection';
import { register as registerWindowFullscreen } from './modules/window-fullscreen';
import { attach as attachWindowState, loadState } from './window-state';
@@ -145,6 +148,17 @@ async function createWindow(): Promise<BrowserWindow> {
attachWindowState(win, WINDOW_STATE_FILE);
if (!app.isPackaged) {
// Auto-open DevTools in dev — the menu bar is stripped (Discord-style)
// so F12 / Ctrl+Shift+I have no chord; opening detached gives a
// separate inspector window for easy debugging.
win.webContents.openDevTools({ mode: 'detach' });
// Forward renderer console messages to the main-process stdout so
// errors during local dev are visible in the terminal too (helps when
// the inspector isn't focused).
win.webContents.on('console-message', (_event, level, message, line, sourceId) => {
const tag = level === 3 ? 'error' : level === 2 ? 'warn' : level === 1 ? 'log' : 'info';
console.log('[renderer ' + tag + ']', message, '(' + sourceId + ':' + line + ')');
});
await win.loadURL(DEV_URL);
} else {
await win.loadFile(resolveRendererIndex());
@@ -253,6 +267,7 @@ if (!gotLock) {
registerFsScoped();
registerSql();
registerAutostart();
registerAppHostname();
// Window-dependent registrars — only call once mainWindow exists so
// event emitters have somewhere to send.
@@ -260,9 +275,36 @@ if (!gotLock) {
registerTray(mainWindow);
registerUpdater(mainWindow);
registerWindowFullscreen(mainWindow);
registerWindowContentProtection(mainWindow);
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;
}
});
}
+25 -4
View File
@@ -6,8 +6,10 @@
// (<1ms per op for the current workload).
//
// Binding param style: Tauri's plugin-sql used $1, $2... positionals with
// bindings as an array. SQLite natively accepts $N so existing queries
// keep working unmodified.
// bindings as an array. SQLite parses `$NAME` as a NAMED parameter
// (NAME = `1`, `2`, …), not as positional, so better-sqlite3 wants the
// bindings as `{ '1': v1, '2': v2 }` not `[v1, v2]`. We accept the old
// array-shape from callers and convert to the named-object on the way in.
import { app, ipcMain } from 'electron';
import Database from 'better-sqlite3';
@@ -40,6 +42,20 @@ function requireHandle(h: string): Handle {
return entry;
}
// Convert a positional bindings array `[v1, v2]` to the named-params object
// `{ '1': v1, '2': v2 }` that better-sqlite3 needs when the SQL uses
// `$1`/`$2` named placeholders. Returns the original array (spread later)
// when it's empty.
function bindParams(bindings: unknown[] | undefined): Record<string, unknown> | [] {
const arr = bindings ?? [];
if (arr.length === 0) return [];
const obj: Record<string, unknown> = {};
for (let i = 0; i < arr.length; i++) {
obj[String(i + 1)] = arr[i];
}
return obj;
}
export function register(): void {
ipcMain.handle(CHANNELS.SQL_LOAD, async (_evt, args: SqlLoadArgs): Promise<string> => {
const rawName = stripPrefix(args.name);
@@ -59,7 +75,8 @@ export function register(): void {
async (_evt, args: SqlExecuteArgs): Promise<SqlExecuteResult> => {
const entry = requireHandle(args.handle);
const stmt = entry.db.prepare(args.query);
const info = stmt.run(...((args.bindings ?? []) as unknown[]));
const params = bindParams(args.bindings);
const info = Array.isArray(params) ? stmt.run() : stmt.run(params);
return {
rowsAffected: info.changes,
lastInsertId:
@@ -75,7 +92,11 @@ export function register(): void {
async (_evt, args: SqlSelectArgs): Promise<SqlSelectResult> => {
const entry = requireHandle(args.handle);
const stmt = entry.db.prepare(args.query);
const rows = stmt.all(...((args.bindings ?? []) as unknown[])) as Record<string, unknown>[];
const params = bindParams(args.bindings);
const rows = (Array.isArray(params) ? stmt.all() : stmt.all(params)) as Record<
string,
unknown
>[];
return rows;
},
);
+52 -27
View File
@@ -30,6 +30,17 @@ function resolveIconPathDev(): string {
return path.join(app.getAppPath(), 'resources', 'icon.ico');
}
// 16×16 grey square — last-ditch fallback when neither the packaged
// nor dev icon file resolves. Tray constructor on Windows throws when
// handed an empty NativeImage, which would tear down the whole
// registrar before `ipcMain.handle(TRAY_UNREAD)` runs — leaving the
// taskbar overlay badge wired but the renderer's invoke rejecting
// with "No handler registered". A non-empty placeholder keeps the
// constructor happy so the IPC handler always gets registered.
const FALLBACK_TRAY_PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAH0lEQVR42mNk' +
'YGD4z0ABYBxVOKpwVOGowlGFwwoBAEnYAR9XlIldAAAAAElFTkSuQmCC';
function loadTrayIcon(): NativeImage {
for (const p of [resolveIconPath(), resolveIconPathDev()]) {
try {
@@ -39,7 +50,7 @@ function loadTrayIcon(): NativeImage {
/* try next */
}
}
return nativeImage.createEmpty();
return nativeImage.createFromBuffer(Buffer.from(FALLBACK_TRAY_PNG_BASE64, 'base64'));
}
function buildOverlay(): NativeImage {
@@ -53,35 +64,49 @@ function buildOverlay(): NativeImage {
}
export function register(mainWindow: BrowserWindow): void {
const icon = loadTrayIcon();
trayRef = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon);
trayRef.setToolTip('Netralax');
// Tray icon is best-effort: if neither the packaged resource nor the
// dev path resolves (e.g. icon.ico isn't shipped under resourcesPath
// in packaged builds — only the app icon goes into the .exe metadata),
// we still want the taskbar overlay badge to work. setOverlayIcon is
// a BrowserWindow method, so it functions even when the systray icon
// creation fails.
try {
const icon = loadTrayIcon();
if (!icon.isEmpty()) {
trayRef = new Tray(icon);
trayRef.setToolTip('Netralax');
const menu = Menu.buildFromTemplate([
{
label: 'Open',
click: (): void => {
const menu = Menu.buildFromTemplate([
{
label: 'Open',
click: (): void => {
if (mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
},
},
{ type: 'separator' },
{
label: 'Quit',
click: (): void => {
app.quit();
},
},
]);
trayRef.setContextMenu(menu);
trayRef.on('click', (): void => {
if (mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
},
},
{ type: 'separator' },
{
label: 'Quit',
click: (): void => {
app.quit();
},
},
]);
trayRef.setContextMenu(menu);
trayRef.on('click', (): void => {
if (mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
if (mainWindow.isVisible()) mainWindow.focus();
else mainWindow.show();
});
if (mainWindow.isVisible()) mainWindow.focus();
else mainWindow.show();
});
}
} catch (err: unknown) {
// Swallow: the overlay badge below is the user-visible bit. A missing
// systray icon is cosmetic and shouldn't take the unread handler with it.
console.warn('[tray] systray init failed, overlay badge still active', err);
}
ipcMain.handle(
CHANNELS.TRAY_UNREAD,
@@ -0,0 +1,36 @@
// Window content-protection adapter. Enables / disables OS-level
// screenshot and screen-recording blocking on the host BrowserWindow.
//
// Windows: WDA_MONITOR (SetWindowDisplayAffinity) — the window surface
// appears black in any screen capture tool (OBS, Snipping Tool,
// Win+PrtScr, etc.) while protection is enabled.
// macOS: NSWindowSharingNone — equivalent coverage for QuickTime,
// Cmd+Shift+3/4, and external recorders.
// Linux: No-op. Electron exposes the API on all platforms but the
// X11/Wayland compositors don't honour it in Electron 33.
//
// Called by the renderer during view-once image reveals so the image
// cannot be captured by an OS-level screenshot while it is on screen.
import { BrowserWindow, ipcMain } from 'electron';
import { CHANNELS, type WindowSetContentProtectionArgs } from '../ipc-types';
export function register(mainWindow: BrowserWindow): void {
ipcMain.handle(
CHANNELS.WINDOW_SET_CONTENT_PROTECTION,
(_evt, args: WindowSetContentProtectionArgs) => {
// Electron's setContentProtection covers Windows (WDA_MONITOR) and
// macOS (NSWindowSharingNone) in one call. No-op on Linux X11.
// Wrapped in try/catch because the window can already be destroyed
// by the time this fires during a teardown.
try {
const win = BrowserWindow.fromWebContents(_evt.sender) ?? mainWindow;
if (!win || win.isDestroyed()) return;
win.setContentProtection(args.enabled);
} catch (err) {
console.warn('setContentProtection failed', err);
}
},
);
}
+17
View File
@@ -95,6 +95,23 @@ export interface ElectronAPI {
setAutoStart: (enabled: boolean) => Promise<void>;
setFullscreen: (enabled: boolean) => Promise<void>;
/** Block OS-level screen capture (Win+PrtScr, OBS, etc.) while a
* view-once image is being revealed. Covers Windows (WDA_MONITOR) and
* macOS (NSWindowSharingNone). No-op on Linux X11. Optional: always
* feature-check because the web build has no preload bridge. */
setContentProtection?: (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 {
+33 -1
View File
@@ -28,6 +28,13 @@ import {
type UpdateCheckResult,
type UpdateProgress,
} from './ipc-types';
// Static import of the desktop package.json so the bundler inlines the
// version string at build time. The previous `process.env.npm_package_-
// version` approach worked in dev (pnpm sets it as a script env var) but
// fell back to '0.0.0' in packaged builds — every installed user got
// flagged as "Update verfügbar" against their own actually-current
// version. resolveJsonModule + esModuleInterop are on in tsconfig.node.
import pkg from '../package.json';
type Unsubscribe = () => void;
@@ -40,7 +47,7 @@ function on<T>(channel: string, cb: (payload: T) => void): Unsubscribe {
const api = {
platform: ELECTRON_RUNTIME_MARKER,
osPlatform: process.platform as NodeJS.Platform,
appVersion: process.env.npm_package_version ?? '0.0.0',
appVersion: pkg.version,
// Screen sources ---------------------------------------------------------
getScreenSources: (): Promise<ScreenSource[]> => ipcRenderer.invoke(CHANNELS.SCREEN_GET_SOURCES),
@@ -152,6 +159,31 @@ const api = {
// Window fullscreen ------------------------------------------------------
setFullscreen: (enabled: boolean): Promise<void> =>
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
// Window content protection ----------------------------------------------
setContentProtection: (enabled: boolean): Promise<void> =>
ipcRenderer.invoke(CHANNELS.WINDOW_SET_CONTENT_PROTECTION, { 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;
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@chat-app/desktop",
"version": "0.17.2",
"version": "0.21.8",
"private": true,
"description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module",
@@ -24,7 +24,9 @@
"@livekit/components-react": "^2.9.0",
"@livekit/track-processors": "^0.7.2",
"@supabase/supabase-js": "^2.46.0",
"@tanstack/react-virtual": "^3.10.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 +40,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",
@@ -49,6 +52,7 @@
"electron-vite": "^2.3.0",
"postcss": "^8.4.49",
"rimraf": "^6.0.0",
"rollup-plugin-visualizer": "^7.0.1",
"tailwindcss": "^3.4.15",
"vite": "^5.4.11"
},
@@ -91,7 +95,7 @@
"publish": [
{
"provider": "generic",
"url": "https://update.netralax.cloud/windows/"
"url": "https://update.netralax.de/windows/"
}
]
}
+22 -3
View File
@@ -1,9 +1,10 @@
import { lazy, Suspense } from 'react';
import { HashRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
import { lazy, Suspense, useEffect } from 'react';
import { HashRouter, Navigate, Outlet, Route, Routes, useParams } 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';
@@ -12,6 +13,7 @@ import { CallProvider } from './context/CallContext';
import { ConversationsProvider } from './context/ConversationsContext';
import { FriendshipsProvider } from './context/FriendshipsContext';
import { ThemeProvider } from './context/ThemeContext';
import { hydrateDrafts } from './lib/composerDraftStore';
import { AuthPage } from './pages/AuthPage';
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
import { ConversationPage } from './pages/ConversationPage';
@@ -59,7 +61,23 @@ function RouteBoundary({ scope }: { scope: string }) {
);
}
// Forces a fresh `ConversationPage` instance per `:id` so React unmounts
// the previous conversation entirely on switch. Without this, the same
// component instance handles every conversation, which leaks state
// between chats (messages, scroll position, composer drafts) for one
// render frame and gives the "flicker" we're trying to remove.
// `useConversationMessages` re-hydrates from `messageMemoryCache` on the
// fresh mount so previously-visited chats still render instantly.
function ConversationRoute() {
const { id } = useParams<{ id: string }>();
return <ConversationPage key={id ?? '__no_id__'} />;
}
export function App() {
useEffect(() => {
void hydrateDrafts();
}, []);
return (
<ErrorBoundary scope="root">
<ThemeProvider>
@@ -113,7 +131,7 @@ export function App() {
path=":id"
element={
<ErrorBoundary scope="conversation">
<ConversationPage />
<ConversationRoute />
</ErrorBoundary>
}
/>
@@ -168,6 +186,7 @@ export function App() {
</Routes>
<UpdateToast />
<CrashToast />
<RemoteRevokedScreen />
</HashRouter>
</CallProvider>
</ConversationsProvider>
+18 -11
View File
@@ -1,19 +1,22 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useIdleAutoLock } from '../hooks/useIdleAutoLock';
import { startConversationKeySync } from '../lib/conversationKeySync';
import { startDeviceApprovalListener } from '../lib/deviceApproval';
import { ensureInstallId } from '../lib/installId';
import { ensureNotificationPermission } from '../lib/osNotify';
import { devLocalSecretStore } from '../lib/secretStore';
import { BackupPromptBanner } from './BackupPromptBanner';
import { useMentionNotifications } from '../lib/useMentionNotifications';
import { cachedUserKey } from '../lib/userIdentity';
import { CallUI } from './CallUI';
import { DeviceApprovalBanner } from './DeviceApprovalBanner';
import { Sidebar } from './Sidebar';
export function AppShell() {
const { session, device } = useAuth();
const { session } = useAuth();
useIdleAutoLock();
useMentionNotifications(session?.user.id);
useEffect(() => {
// Prompt once per authenticated shell mount. Module-level guard prevents
// re-asking if the user already responded this session.
@@ -21,20 +24,25 @@ export function AppShell() {
}, []);
useEffect(() => {
if (!session?.user.id || !device?.id) return;
if (!session?.user.id) return;
const userId = session.user.id;
const deviceId = device.id;
const stopKeySync = startConversationKeySync(userId, deviceId);
// Per-install id replaces the per-device record now that crypto is
// user-keyed. The legacy conv-key-sync and device-approval subsystems
// still take a "deviceId" argument for telemetry / row identity; passing
// the install id keeps those wires intact until they are themselves
// reworked in later tasks (Task 16/17/19 territory).
const installId = ensureInstallId();
const stopKeySync = startConversationKeySync(userId, installId);
const stopApproval = startDeviceApprovalListener({
ownUserId: userId,
ownDeviceId: deviceId,
getPriv: () => loadDevicePrivateKey(devLocalSecretStore, userId, deviceId),
ownDeviceId: installId,
getPriv: () => cachedUserKey(userId),
});
return () => {
stopKeySync();
stopApproval();
};
}, [session?.user.id, device?.id]);
}, [session?.user.id]);
return (
<div className="relative flex min-h-screen overflow-hidden bg-surface text-fg">
@@ -48,7 +56,6 @@ export function AppShell() {
</main>
</div>
<CallUI />
<BackupPromptBanner />
<DeviceApprovalBanner />
</div>
);
@@ -2,6 +2,7 @@ import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/s
import { useEffect, useMemo, useRef, useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
import { getVoiceSpeed, setVoiceSpeed, VOICE_SPEEDS, type VoiceSpeed } from '../lib/voiceSpeedSettings';
import { supabase } from '../lib/supabase';
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
@@ -23,6 +24,7 @@ export function AttachmentAudio({ handle }: Props) {
const [duration, setDuration] = useState<number>(0);
const [position, setPosition] = useState<number>(0);
const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState<VoiceSpeed>(() => getVoiceSpeed());
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
@@ -105,6 +107,12 @@ export function AttachmentAudio({ handle }: Props) {
};
}, [arrayBuf]);
useEffect(() => {
const el = audioRef.current;
if (!el) return;
el.playbackRate = speed;
}, [speed, blobUrl]);
const fallbackPeaks = useMemo(
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
[peaks],
@@ -154,6 +162,29 @@ export function AttachmentAudio({ handle }: Props) {
<PlayGlyph />
)}
</button>
<div className="flex shrink-0 items-center gap-0.5 rounded-md bg-surface-3 p-0.5 text-[10px] font-semibold text-fg-muted">
{VOICE_SPEEDS.map((s) => {
const active = s === speed;
return (
<button
key={s}
type="button"
onClick={() => {
setSpeed(s);
setVoiceSpeed(s);
}}
className={
'flex h-6 w-7 cursor-pointer items-center justify-center rounded transition ' +
(active ? 'bg-accent text-accent-fg' : 'hover:bg-surface hover:text-fg')
}
aria-pressed={active}
title={'Wiedergabegeschwindigkeit ' + s + '×'}
>
{s}×
</button>
);
})}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div
role="slider"
+164 -51
View File
@@ -1,12 +1,23 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import {
type AttachmentHandle,
downloadAndDecryptAttachment,
downloadAndDecryptAttachmentThumb,
} from '@chat-app/shared/chat';
import { useEffect, useRef, useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon, XIcon } from './icons';
import { AlertIcon, SpinnerIcon } from './icons';
import { Lightbox } from './Lightbox';
import { ViewOnceImage } from './ViewOnceImage';
interface Props {
handle: AttachmentHandle;
/** True iff the local user sent this message. View-once images use this
* to suppress the burn (senders never burn their own attachment) and to
* pick the right preview chrome. Defaults to false so existing callers
* (e.g. MediaFilesDrawer) keep working without modification. */
mine?: boolean;
}
// Max inline-preview dimension. Full-resolution stays available for the
@@ -43,12 +54,23 @@ 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);
const [lightboxOpen, setLightboxOpen] = useState(false);
// Whether the sender shipped a pre-built WebP thumb alongside this attachment
// (Phase 6B+). When true we render the bubble from just the thumb and only
// fetch the full blob when the user opens the lightbox or for view-once.
const hasServerThumb = Boolean(handle.thumbStoragePath && handle.thumbNonceB64);
// View-once needs the full blob ready instantly the moment the recipient
// taps (otherwise we'd show a spinner during the burn animation, then race
// the "mark viewed" RPC). Same for the legacy path with no server thumb —
// we have to download the whole thing just to render the client-side
// makeThumbnail fallback.
const needsEagerFull = handle.viewOnce === true || !hasServerThumb;
useEffect(() => {
let cancelled = false;
const created: string[] = [];
@@ -62,9 +84,39 @@ export function AttachmentImage({ handle }: Props) {
return u;
};
// OPFS cache → decrypt → generate thumbnail for inline display.
// Lightbox swaps to the full blob when opened.
const thumbCacheId = handle.id + '-thumb';
void (async () => {
// Phase 6B fast path: if the sender shipped a server-side WebP thumb,
// grab it first so the bubble paints from ~20KB instead of waiting on
// the multi-MB full blob. The OPFS cache is keyed separately so the
// thumb survives independent of full-blob eviction.
if (hasServerThumb) {
try {
let thumbBlob: Blob | null = await getCachedAttachment(thumbCacheId);
if (!thumbBlob) {
thumbBlob = await downloadAndDecryptAttachmentThumb({
client: supabase,
handle,
});
if (thumbBlob) void putCachedAttachment(thumbCacheId, thumbBlob);
}
if (cancelled) return;
if (thumbBlob) {
setThumbUrl(take(thumbBlob));
}
} catch (err: unknown) {
// Thumb decrypt failure isn't fatal — fall through to the full
// blob path below so the user still sees the image.
console.warn('thumb load failed', err);
}
}
// Eagerly resolve the full blob when we need it for view-once or as
// the only render source (no server thumb). For the thumb-first path
// the full blob is deferred until the lightbox opens (see below).
if (!needsEagerFull) return;
const cached = await getCachedAttachment(handle.id);
let blob: Blob;
if (cached) {
@@ -83,10 +135,14 @@ export function AttachmentImage({ handle }: Props) {
if (cancelled) return;
const full = take(blob);
setFullUrl(full);
const thumb = await makeThumbnail(blob);
if (cancelled) return;
if (thumb) {
setThumbUrl(take(thumb));
// Pre-Phase-6B fallback: no server thumb shipped, so re-derive a
// smaller preview on the client to keep memory pressure down.
if (!hasServerThumb) {
const thumb = await makeThumbnail(blob);
if (cancelled) return;
if (thumb) {
setThumbUrl(take(thumb));
}
}
})();
@@ -94,7 +150,74 @@ export function AttachmentImage({ handle }: Props) {
cancelled = true;
for (const u of created) URL.revokeObjectURL(u);
};
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
}, [
handle,
handle.id,
handle.storagePath,
handle.keyB64,
handle.nonceB64,
handle.thumbStoragePath,
handle.thumbNonceB64,
hasServerThumb,
needsEagerFull,
]);
// Lazy full-image fetch for click-to-expand (only kicks in when we
// skipped the eager full-blob download above). Resolves into the same
// `fullUrl` state that the Lightbox consumes; the thumb URL keeps
// backing the bubble until the lightbox actually mounts.
//
// CRITICAL: do NOT revoke the just-created blob URL in this effect's
// cleanup. Setting `fullUrl` re-triggers the effect (state change → re-
// run → previous cleanup fires → URL revoked → Lightbox renders
// referenced-but-revoked URL → "ERR_FILE_NOT_FOUND"). The dedicated
// unmount-only effect below tracks the current URL via ref and revokes
// it once when the component truly leaves the tree.
//
// Deps locked to `handle.id` (not `handle`) — handles are immutable per
// attachment id, so object-identity churn from parent re-renders must
// not re-trigger the fetch.
useEffect(() => {
if (!lightboxOpen) return;
if (fullUrl) return;
let cancelled = false;
void (async () => {
const cached = await getCachedAttachment(handle.id);
let blob: Blob;
if (cached) {
blob = cached;
} else {
try {
blob = await downloadAndDecryptAttachment({ client: supabase, handle });
void putCachedAttachment(handle.id, blob);
} catch (err: unknown) {
if (!cancelled) setError(err instanceof Error ? err.message : 'download failed');
return;
}
}
if (cancelled) return;
const u = URL.createObjectURL(blob);
setFullUrl(u);
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lightboxOpen, handle.id]);
// Track the currently-published fullUrl in a ref so the unmount-only
// cleanup below can revoke whatever URL is live at teardown time
// without subscribing to fullUrl changes (which would re-trigger and
// revoke prematurely — see the comment above the fetch effect).
const fullUrlRef = useRef<string | null>(null);
useEffect(() => {
fullUrlRef.current = fullUrl;
}, [fullUrl]);
useEffect(() => {
return () => {
if (fullUrlRef.current) URL.revokeObjectURL(fullUrlRef.current);
};
}, []);
const blobUrl = thumbUrl ?? fullUrl;
@@ -115,6 +238,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
@@ -131,47 +273,18 @@ export function AttachmentImage({ handle }: Props) {
className="block h-auto max-h-80 w-auto max-w-full object-contain"
/>
</button>
{lightboxOpen && fullUrl && <Lightbox url={fullUrl} onClose={() => setLightboxOpen(false)} />}
{lightboxOpen && (
<Lightbox
// Prefer the full blob the moment it's available; otherwise show
// the thumb so the user sees *something* during the lazy fetch
// (typical full-blob fetch is 100ms2s depending on size).
url={fullUrl ?? blobUrl}
onClose={() => setLightboxOpen(false)}
/>
)}
</>
);
}
function Lightbox({ url, onClose }: { url: string; onClose: () => void }) {
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prevOverflow;
};
}, [onClose]);
return (
<div
role="dialog"
aria-modal="true"
aria-label="Bildansicht"
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/90 p-6 backdrop-blur-sm animate-fade-in"
>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="absolute right-4 top-4 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-ink-900/80 text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<XIcon className="h-5 w-5" />
</button>
<img
src={url}
alt="attachment full"
onClick={(e) => e.stopPropagation()}
className="max-h-[92vh] max-w-[92vw] rounded-xl object-contain shadow-2xl"
/>
</div>
);
}
// Lightbox extracted to ./Lightbox.tsx so the settings avatar preview and
// any future surface can reuse the same dialog without duplication.
+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,306 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { type BackupBundle, exportDeviceBackupWithRecovery } from '../lib/deviceBackup';
import { AlertIcon, CopyIcon, LockIcon, ShieldIcon, SpinnerIcon, XIcon } from './icons';
interface Props {
open: boolean;
userId: string;
deviceId: string;
privateKey: Uint8Array;
onClose: () => void;
}
// Exports the device's private key + identity into a passphrase-protected
// portable string. The user can store this string anywhere (password manager,
// printed paper, encrypted file on a USB stick). Without it, losing local
// storage on this install means losing all past conversation keys.
export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose }: Props) {
const { t } = useTranslation(['app']);
const [passphrase, setPassphrase] = useState('');
const [confirm, setConfirm] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [bundle, setBundle] = useState<BackupBundle | null>(null);
const [copied, setCopied] = useState(false);
const [copiedRecovery, setCopiedRecovery] = useState(false);
const canGenerate = useMemo(() => {
return passphrase.length >= 8 && passphrase === confirm && !busy;
}, [passphrase, confirm, busy]);
const reset = useCallback(() => {
setPassphrase('');
setConfirm('');
setBundle(null);
setError(null);
setCopied(false);
setCopiedRecovery(false);
}, []);
const handleClose = useCallback(() => {
reset();
onClose();
}, [reset, onClose]);
const handleGenerate = useCallback(async () => {
if (!canGenerate) return;
setBusy(true);
setError(null);
try {
const b = await exportDeviceBackupWithRecovery({
userId,
deviceId,
privateKey,
passphrase,
});
setBundle(b);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}, [canGenerate, userId, deviceId, privateKey, passphrase]);
const copyText = async (text: string, marker: 'main' | 'recovery'): Promise<void> => {
try {
await navigator.clipboard.writeText(text);
if (marker === 'main') {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} else {
setCopiedRecovery(true);
window.setTimeout(() => setCopiedRecovery(false), 1500);
}
} catch {
/* fall back — user can select manually */
}
};
const handleDownload = useCallback(() => {
if (!bundle) return;
const text =
'=== Passphrase backup ===\n' +
bundle.passphraseBackup +
'\n\n=== Recovery code ===\n' +
bundle.recoveryCode +
'\n\n=== Recovery backup (use with the recovery code) ===\n' +
bundle.recoveryBackup +
'\n';
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chatapp-device-backup-${deviceId.slice(0, 8)}.txt`;
a.click();
URL.revokeObjectURL(url);
}, [bundle, deviceId]);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('app:backup.export_title', { defaultValue: 'Gerätesicherung erstellen' })}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={handleClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
>
<header className="flex items-center justify-between border-b border-line px-5 py-3">
<div className="flex items-center gap-2">
<ShieldIcon className="h-4 w-4 text-accent" />
<h3 className="font-display text-sm font-semibold text-fg">
{t('app:backup.export_title', { defaultValue: 'Gerätesicherung erstellen' })}
</h3>
</div>
<button
type="button"
onClick={handleClose}
aria-label="Close"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
</header>
<div className="flex-1 overflow-y-auto p-5">
{!bundle ? (
<>
<p className="text-sm text-fg-muted">
{t('app:backup.export_explainer', {
defaultValue:
'Verschlüssele den Geräteschlüssel mit einer Passphrase. Ohne Passphrase UND Backup-String ist keine Wiederherstellung möglich.',
})}
</p>
<div className="mt-4 space-y-3">
<div className="space-y-1">
<label className="block text-xs font-medium uppercase tracking-wide text-fg-muted">
{t('app:backup.passphrase', { defaultValue: 'Passphrase' })}
</label>
<input
type="password"
autoFocus
minLength={8}
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder="min. 8 Zeichen"
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/>
</div>
<div className="space-y-1">
<label className="block text-xs font-medium uppercase tracking-wide text-fg-muted">
{t('app:backup.passphrase_confirm', { defaultValue: 'Passphrase wiederholen' })}
</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/>
</div>
{confirm.length > 0 && confirm !== passphrase && (
<p className="text-xs text-rose-500 dark:text-rose-300">
{t('app:backup.passphrase_mismatch', { defaultValue: 'Passphrasen stimmen nicht überein.' })}
</p>
)}
</div>
<div className="mt-4 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-200">
<div className="flex items-start gap-2">
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<span>
{t('app:backup.export_warning', {
defaultValue:
'Anthropic: Backup + Passphrase sicher aufbewahren. Passphrase kann nicht wiederhergestellt werden.',
})}
</span>
</div>
</div>
{error && (
<p role="alert" className="mt-3 text-sm text-rose-600 dark:text-rose-200">
{error}
</p>
)}
</>
) : (
<>
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-xs text-emerald-700 dark:text-emerald-200">
<div className="flex items-start gap-2">
<LockIcon className="mt-0.5 h-4 w-4 shrink-0" />
<span>
{t('app:backup.export_success', {
defaultValue:
'Backup erstellt. Speichere diesen String + Passphrase in einem Passwortmanager oder drucke ihn aus.',
})}
</span>
</div>
</div>
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
Backup-String
</label>
<textarea
readOnly
value={bundle.passphraseBackup}
rows={6}
onFocus={(e) => e.currentTarget.select()}
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
/>
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={() => void copyText(bundle.passphraseBackup, 'main')}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
>
<CopyIcon className="h-4 w-4" />
<span>{copied ? 'Kopiert!' : 'Kopieren'}</span>
</button>
<button
type="button"
onClick={handleDownload}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
>
Als Datei speichern (alles)
</button>
</div>
<div className="mt-5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
<div className="flex items-start gap-2">
<ShieldIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-amber-700 dark:text-amber-200">
Recovery-Code (Passphrase vergessen?)
</p>
<p className="mt-0.5 text-[11px] text-amber-700/80 dark:text-amber-200/80">
Code separat aufbewahren. Mit dem Recovery-Backup unten lässt sich der Schlüssel
ohne Passphrase wiederherstellen.
</p>
<p className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest text-fg">
{bundle.recoveryCode}
</p>
</div>
</div>
</div>
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
Recovery-Backup-String
</label>
<textarea
readOnly
value={bundle.recoveryBackup}
rows={6}
onFocus={(e) => e.currentTarget.select()}
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
/>
<button
type="button"
onClick={() => void copyText(bundle.recoveryBackup, 'recovery')}
className="mt-2 inline-flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
>
<CopyIcon className="h-4 w-4" />
<span>{copiedRecovery ? 'Kopiert!' : 'Recovery-Backup kopieren'}</span>
</button>
</>
)}
</div>
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
{!bundle ? (
<>
<button
type="button"
onClick={handleClose}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
>
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
</button>
<button
type="button"
onClick={() => void handleGenerate()}
disabled={!canGenerate}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
<span>{t('app:backup.generate', { defaultValue: 'Backup erstellen' })}</span>
</button>
</>
) : (
<button
type="button"
onClick={handleClose}
className="cursor-pointer rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110"
>
{t('app:backup.done', { defaultValue: 'Fertig' })}
</button>
)}
</footer>
</div>
</div>
);
}
@@ -1,122 +0,0 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { devLocalSecretStore } from '../lib/secretStore';
import { BackupExportDialog } from './BackupExportDialog';
import { ShieldIcon, XIcon } from './icons';
const DISMISS_KEY = 'chatapp.backup.prompt.dismissed';
const SESSION_KEY = 'chatapp.backup.prompt';
// Post-registration nudge: right after a fresh device provision we set
// `chatapp.backup.prompt` in sessionStorage. This component reads it and
// shows a floating "mach jetzt ein Backup" banner until the user either
// creates one or explicitly dismisses (persisted in localStorage so we stop
// nagging across reloads).
export function BackupPromptBanner() {
const { t } = useTranslation(['app']);
const { profile, device } = useAuth();
const [visible, setVisible] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
useEffect(() => {
try {
if (window.localStorage.getItem(DISMISS_KEY) === '1') return;
if (window.sessionStorage.getItem(SESSION_KEY) !== '1') return;
setVisible(true);
} catch {
/* storage unavailable */
}
}, []);
const dismiss = useCallback((persist: boolean) => {
setVisible(false);
try {
window.sessionStorage.removeItem(SESSION_KEY);
if (persist) window.localStorage.setItem(DISMISS_KEY, '1');
} catch {
/* ignore */
}
}, []);
const openDialog = useCallback(async () => {
if (!profile?.userId || !device?.id) return;
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
if (!priv) return;
setPrivateKey(priv);
setDialogOpen(true);
}, [profile, device]);
const closeDialog = useCallback(() => {
setDialogOpen(false);
if (privateKey) {
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
}
setPrivateKey(null);
// After the user interacts with the dialog, drop the banner regardless
// of whether they actually completed the backup — they're aware now.
dismiss(true);
}, [privateKey, dismiss]);
if (!visible) return null;
return (
<>
<div
role="status"
className="fixed bottom-6 left-1/2 z-40 flex w-[min(92vw,520px)] -translate-x-1/2 items-start gap-3 rounded-2xl border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-800 shadow-xl backdrop-blur-md dark:text-amber-100"
>
<ShieldIcon className="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="min-w-0 flex-1">
<p className="font-semibold">
{t('app:backup.prompt_title', { defaultValue: 'Erstelle jetzt ein Geräte-Backup' })}
</p>
<p className="mt-0.5 text-xs text-amber-700/90 dark:text-amber-200/90">
{t('app:backup.prompt_body', {
defaultValue:
'Ohne Backup verlierst du Zugriff auf alte Nachrichten, wenn Browser oder Gerät ihren Speicher verlieren. Dauert 10 Sekunden.',
})}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => void openDialog()}
className="cursor-pointer rounded-md bg-amber-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-500"
>
{t('app:backup.prompt_create', { defaultValue: 'Jetzt erstellen' })}
</button>
<button
type="button"
onClick={() => dismiss(true)}
className="cursor-pointer rounded-md border border-amber-500/40 bg-transparent px-3 py-1.5 text-xs font-semibold text-amber-700 transition hover:bg-amber-500/15 dark:text-amber-200"
>
{t('app:backup.prompt_never', { defaultValue: 'Nicht mehr fragen' })}
</button>
</div>
</div>
<button
type="button"
onClick={() => dismiss(false)}
aria-label={t('app:backup.prompt_dismiss', { defaultValue: 'Später' })}
className="cursor-pointer text-amber-600/70 transition hover:text-amber-600 dark:text-amber-200/70 dark:hover:text-amber-200"
>
<XIcon className="h-4 w-4" />
</button>
</div>
{dialogOpen && profile?.userId && device?.id && privateKey && (
<BackupExportDialog
open={dialogOpen}
userId={profile.userId}
deviceId={device.id}
privateKey={privateKey}
onClose={closeDialog}
/>
)}
</>
);
}
@@ -1,86 +0,0 @@
import { useEffect } from 'react';
import { DeviceRestore } from './DeviceRestore';
import { AlertIcon, ShieldIcon, XIcon } from './icons';
interface Props {
open: boolean;
userId: string;
onClose: () => void;
}
// Modal wrapper around DeviceRestore for the already-signed-in case. A
// successful restore swaps the local device-identity for the one embedded
// in the backup string — the app then hard-reloads so every hook
// re-initialises against the restored keys (simpler than invalidating
// supabase-realtime subscriptions, stronghold caches, livekit rooms, etc.
// individually).
export function BackupRestoreDialog({ open, userId, onClose }: Props) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', onKey);
document.body.style.overflow = prev;
};
}, [open, onClose]);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="Backup wiederherstellen"
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-6 backdrop-blur-sm"
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-md flex-col gap-4"
>
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-ink-900/70 p-3 backdrop-blur-xl">
<div className="flex items-center gap-2">
<ShieldIcon className="h-4 w-4 text-brand-300" />
<h3 className="text-sm font-semibold text-white">
Gerät aus Backup wiederherstellen
</h3>
</div>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-white"
>
<XIcon className="h-4 w-4" />
</button>
</div>
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-100">
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
<p className="min-w-0 flex-1">
Restore ersetzt das aktuelle Gerät durch das aus dem Backup.
Die App lädt danach neu. Nachrichten, die auf diesem Gerät seit
dem Backup eingegangen sind, sind erst wieder lesbar, nachdem
Peer-Geräte den Conversation-Key erneut für die wiederhergestellte
Device-ID wrappen.
</p>
</div>
<DeviceRestore
userId={userId}
onRestored={() => {
// Hard reload — cleanest way to reset every hook, supabase
// realtime channel, stronghold handle, and cached state.
window.location.reload();
}}
/>
</div>
</div>
);
}
@@ -1,55 +0,0 @@
import { useEffect, useState } from 'react';
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useCall } from '../context/CallContext';
interface Props {
conversation: ConversationSummary;
}
const STALE_AFTER_MS = 5000;
/** Discord-style live-captions overlay. Pinned to the bottom-center of the
* call surface; renders the most recent caption per participant, fading
* entries out after `STALE_AFTER_MS` of silence. Self-captions are shown
* too so the speaker can sanity-check what's being broadcast. */
export function CallCaptionsOverlay({ conversation }: Props) {
const { captions } = useCall();
// Re-render every second so stale entries fade without needing the data
// channel to fire — captions module just stores timestamps.
const [, setNow] = useState(Date.now());
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, []);
const now = Date.now();
const visible = Object.entries(captions)
.filter(([, v]) => now - v.timestamp < STALE_AFTER_MS)
.sort(([, a], [, b]) => a.timestamp - b.timestamp);
if (visible.length === 0) return null;
return (
<div className="pointer-events-none absolute inset-x-0 bottom-24 z-30 flex flex-col items-center gap-1.5 px-6">
{visible.map(([identity, c]) => {
const member = conversation.members.find((m) => m.userId === identity);
const name = member?.profile?.displayName ?? '?';
const age = now - c.timestamp;
const opacity = age > 3500 ? 1 - (age - 3500) / (STALE_AFTER_MS - 3500) : 1;
return (
<div
key={identity}
style={{ opacity: Math.max(0, opacity) }}
className="max-w-[760px] rounded-lg bg-black/72 px-3.5 py-1.5 text-sm text-white shadow-md backdrop-blur-md transition-opacity"
>
<span className="mr-2 text-[11px] font-semibold uppercase tracking-wide text-white/55">
{name}
</span>
<span className={c.final ? '' : 'italic text-white/85'}>{c.text}</span>
</div>
);
})}
</div>
);
}
@@ -1,7 +1,6 @@
import { useTranslation } from 'react-i18next';
import {
CaptionsIcon,
HeadphonesIcon,
HeadphonesOffIcon,
MicIcon,
@@ -32,10 +31,6 @@ interface Props {
/** Toggle the in-call soundboard popover. Active = panel currently open. */
onToggleSoundboard?: () => void;
soundboardOpen?: boolean;
/** Discord-style live-captions toggle. Optional — pages that don't support
* SpeechRecognition (Firefox) skip the prop and the button is hidden. */
onToggleCaptions?: () => void;
captionsOn?: boolean;
participantsOpen?: boolean;
/** Compact variant used inside the docked call (36px buttons). */
compact?: boolean;
@@ -59,8 +54,6 @@ export function CallControls({
onOpenParticipants,
onToggleSoundboard,
soundboardOpen = false,
onToggleCaptions,
captionsOn = false,
participantsOpen = false,
compact = false,
glass = false,
@@ -148,22 +141,6 @@ export function CallControls({
<MusicIcon className="h-5 w-5" />
</CallButton>
)}
{onToggleCaptions && (
<CallButton
label={
captionsOn
? t('app:call.captions_off', { defaultValue: 'Untertitel aus' })
: t('app:call.captions_on', { defaultValue: 'Untertitel an' })
}
active={captionsOn}
activeTone="accent"
onClick={onToggleCaptions}
glass={glass}
className={btnSize}
>
<CaptionsIcon className="h-5 w-5" />
</CallButton>
)}
{onOpenParticipants && (
<CallButton
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
@@ -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,118 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useCallPresence } from '../lib/useCallPresence';
import { Avatar } from './Avatar';
import { PhoneIcon, SpinnerIcon, XIcon } from './icons';
interface Props {
conversation: ConversationSummary;
}
const MAX_TILES = 7;
/**
* Discord-DM-style call preview panel. Renders only while peers are in the
* conversation's active call and the local user is NOT in it. Provides large
* avatar tiles plus a single "Beitreten" call-to-action. Calls are still
* STARTED via the topbar phone icon (`ConversationHeader.startCall`); this
* component never initiates — only joins.
*/
export function CallPreviewPanel({ conversation }: Props) {
const { t } = useTranslation(['app']);
const { session } = useAuth();
const { state, joinActiveCall } = useCall();
const presentIds = useCallPresence(conversation.id);
const [collapsed, setCollapsed] = useState(false);
const myId = session?.user.id ?? null;
const iAmIn =
(state.kind === 'connected' ||
state.kind === 'connecting' ||
state.kind === 'reconnecting') &&
state.conversationId === conversation.id;
const others = presentIds.filter((u) => u !== myId);
if (iAmIn || others.length === 0) return null;
const visibleTiles = others.slice(0, MAX_TILES);
const overflow = Math.max(0, others.length - MAX_TILES);
const busy = state.kind !== 'idle';
const handleJoin = () => {
if (busy) return;
void joinActiveCall(conversation.id, 'audio');
};
return (
<div className="border-b border-line bg-surface-3/70">
<div className="flex items-center gap-2 px-5 py-2 text-xs">
<PhoneIcon className="h-3.5 w-3.5 text-emerald-500" />
<span className="font-semibold uppercase tracking-[0.12em] text-fg-muted">
{t('app:call.active_in_conv', {
defaultValue: 'Laufender Anruf · {{count}} im Raum',
count: others.length,
})}
</span>
<button
type="button"
onClick={() => setCollapsed((c) => !c)}
aria-label={collapsed ? 'Anrufvorschau ausklappen' : 'Anrufvorschau einklappen'}
className="ml-auto inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-2 hover:text-fg"
>
<XIcon className="h-3.5 w-3.5" />
</button>
</div>
{!collapsed && (
<div className="flex flex-col items-center gap-4 px-5 pb-4 pt-1">
<div className="grid w-full max-w-2xl grid-cols-2 gap-3 md:grid-cols-3">
{visibleTiles.map((id) => {
const member = conversation.members.find((m) => m.userId === id);
const name = member?.profile?.displayName ?? '?';
return (
<div
key={id}
title={name}
className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-2 p-3"
>
<div className="h-16 w-16 overflow-hidden rounded-full">
<Avatar
url={member?.profile?.avatarUrl ?? null}
displayName={name}
className="h-full w-full text-base"
/>
</div>
<span className="line-clamp-1 text-xs font-medium text-fg">{name}</span>
</div>
);
})}
{overflow > 0 && (
<div className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-line bg-surface-2 p-3 text-fg-muted">
<span className="text-xl font-semibold">+{overflow}</span>
<span className="text-xs">weitere</span>
</div>
)}
</div>
<button
type="button"
onClick={handleJoin}
disabled={busy}
className="inline-flex w-full max-w-xs cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? (
<SpinnerIcon className="h-4 w-4" />
) : (
<PhoneIcon className="h-4 w-4" />
)}
<span>{t('app:call.join', { defaultValue: 'Beitreten' })}</span>
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,250 @@
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { MonitorShareIcon, PollIcon } from './icons';
interface Props {
anchorRef: React.RefObject<HTMLButtonElement | null>;
open: boolean;
onClose: () => void;
onAttachFile: () => void;
onCreatePoll: () => void;
onCreateWhiteboard: () => void;
onStartWatchTogether: () => void;
onStartGame: () => void;
canStartGame?: boolean;
}
export function ComposerActionsMenu({
anchorRef,
open,
onClose,
onAttachFile,
onCreatePoll,
onCreateWhiteboard,
onStartWatchTogether,
onStartGame,
canStartGame = true,
}: Props) {
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement | null>(null);
const firstItemRef = useRef<HTMLButtonElement | null>(null);
// Auto-focus the first item when menu opens (a11y) + click-outside/Esc handlers
useEffect(() => {
if (!open) return;
firstItemRef.current?.focus();
const onDocClick = (e: MouseEvent) => {
const target = e.target as Node | null;
if (!target) return;
if (menuRef.current?.contains(target)) return;
if (anchorRef.current?.contains(target)) return;
onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', onDocClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocClick);
document.removeEventListener('keydown', onKey);
};
}, [open, onClose, anchorRef]);
if (!open) return null;
// Each item: closes the menu, then runs the action.
const items: Array<{
key: string;
label: string;
Icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
action: () => void;
disabled?: boolean;
disabledTitle?: string;
section: 'top' | 'activities';
}> = [
{
key: 'attach',
label: t('app:composer.menu.attach', { defaultValue: 'Bild / Datei' }),
Icon: PaperclipIcon,
action: onAttachFile,
section: 'top',
},
{
key: 'poll',
label: t('app:composer.menu.poll', { defaultValue: 'Umfrage' }),
Icon: PollIcon,
action: onCreatePoll,
section: 'top',
},
{
key: 'whiteboard',
label: t('app:composer.menu.whiteboard', { defaultValue: 'Whiteboard' }),
Icon: MonitorShareIcon,
action: onCreateWhiteboard,
section: 'activities',
},
{
key: 'watch',
label: t('app:composer.menu.watch', { defaultValue: 'Watch Together' }),
Icon: PlayBoxIcon,
action: onStartWatchTogether,
section: 'activities',
},
{
key: 'game',
label: t('app:composer.menu.game', { defaultValue: 'Spiel starten' }),
Icon: GameIcon,
action: onStartGame,
disabled: !canStartGame,
disabledTitle: t('app:composer.menu.game_dm_only', {
defaultValue: 'Nur in 1:1-Chats',
}),
section: 'activities',
},
];
const handleItemClick = (item: (typeof items)[number]) => {
if (item.disabled) return;
onClose();
item.action();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const focusable = menuRef.current?.querySelectorAll<HTMLButtonElement>(
'button[role="menuitem"]:not([disabled])',
);
if (!focusable || focusable.length === 0) return;
const list = Array.from(focusable);
const idx = list.findIndex((el) => el === document.activeElement);
if (e.key === 'ArrowDown') {
e.preventDefault();
list[(idx + 1) % list.length]?.focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
list[(idx - 1 + list.length) % list.length]?.focus();
}
};
const topItems = items.filter((i) => i.section === 'top');
const activityItems = items.filter((i) => i.section === 'activities');
return (
<div
ref={menuRef}
role="menu"
onKeyDown={handleKeyDown}
// Positioned absolutely above the anchor; the wrapping parent (the
// composer) must be `position: relative` for this to anchor correctly.
className="absolute bottom-full left-0 z-30 mb-2 w-56 overflow-hidden rounded-xl border border-line bg-surface-2 shadow-xl"
>
{topItems.map((item, idx) => {
const Icon = item.Icon;
const isFirst = idx === 0;
return (
<button
key={item.key}
ref={isFirst ? firstItemRef : undefined}
type="button"
role="menuitem"
onClick={() => handleItemClick(item)}
disabled={item.disabled}
title={item.disabled ? item.disabledTitle : undefined}
className={
'flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left text-sm font-medium text-fg transition focus:outline-none ' +
(item.disabled
? 'cursor-not-allowed opacity-50'
: 'hover:bg-surface-3 focus-visible:bg-surface-3')
}
>
<Icon className="h-4 w-4 shrink-0 text-fg-muted" />
<span className="flex-1 truncate">{item.label}</span>
</button>
);
})}
<div
role="separator"
className="border-t border-line/60"
aria-hidden="true"
/>
<div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t('app:composer.menu.section_activities', { defaultValue: 'Aktivitäten' })}
</div>
{activityItems.map((item) => {
const Icon = item.Icon;
return (
<button
key={item.key}
type="button"
role="menuitem"
onClick={() => handleItemClick(item)}
disabled={item.disabled}
title={item.disabled ? item.disabledTitle : undefined}
className={
'flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left text-sm font-medium text-fg transition focus:outline-none ' +
(item.disabled
? 'cursor-not-allowed opacity-50'
: 'hover:bg-surface-3 focus-visible:bg-surface-3')
}
>
<Icon className="h-4 w-4 shrink-0 text-fg-muted" />
<span className="flex-1 truncate">{item.label}</span>
</button>
);
})}
</div>
);
}
// --- Inline icons not in the central icons module ----------------------
function PaperclipIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg>
);
}
function PlayBoxIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
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.ComponentPropsWithoutRef<'svg'>) {
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>
);
}
@@ -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,19 +1,22 @@
import {
muteDurationToIso,
setConversationArchived,
setConversationMentionsOnly,
setConversationMutedUntil,
} from '@chat-app/shared/chat';
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useConversationsContext } from '../context/ConversationsContext';
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,8 +53,9 @@ 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 { patchConversation } = useConversationsContext();
const [open, setOpen] = useState(false);
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
const [menuPos, setMenuPos] = useState<MenuPos | null>(null);
@@ -120,29 +124,59 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Pr
const isMuted =
mutedUntil !== null && new Date(mutedUntil).getTime() > Date.now();
// Optimistic updates: flip the local state before the server RPC so the
// bell / archive icon / checkmark update on the same frame as the click.
// Realtime echo via ConversationsContext will reconcile (no-op since the
// optimistic patch already matches the server row). On error we restore
// the previous value so the menu doesn't lie about persisted state.
const handleArchive = useCallback(
async (next: boolean) => {
setOpen(false);
const previous = archived;
patchConversation(conversationId, { archived: next });
try {
await setConversationArchived(supabase, conversationId, next);
} catch (err: unknown) {
patchConversation(conversationId, { archived: previous });
console.error('archive toggle failed', err);
}
},
[conversationId],
[conversationId, archived, patchConversation],
);
const handleMute = useCallback(
async (minutes: number | null) => {
setOpen(false);
setSubmenuOpen(null);
const previous = mutedUntil;
const nextIso = muteDurationToIso(minutes);
patchConversation(conversationId, { mutedUntil: nextIso });
try {
await setConversationMutedUntil(supabase, conversationId, muteDurationToIso(minutes));
await setConversationMutedUntil(supabase, conversationId, nextIso);
} catch (err: unknown) {
patchConversation(conversationId, { mutedUntil: previous });
console.error('mute toggle failed', err);
}
},
[conversationId],
[conversationId, mutedUntil, patchConversation],
);
const handleMentionsOnly = useCallback(
async (next: boolean) => {
setOpen(false);
const previous = mentionsOnly;
patchConversation(conversationId, { mentionsOnly: next });
try {
await setConversationMentionsOnly(supabase, {
conversationId,
mentionsOnly: next,
});
} catch (err: unknown) {
patchConversation(conversationId, { mentionsOnly: previous });
console.warn('mentions-only toggle failed', err);
}
},
[conversationId, mentionsOnly, patchConversation],
);
return (
@@ -196,6 +230,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,
)}
@@ -1,127 +0,0 @@
import type { DeviceRecord } from '@chat-app/shared/auth';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { detectDesktopPlatform, registerCurrentDevice } from '../lib/device';
import { AlertIcon, ArrowRightIcon, LockIcon, ShieldIcon, SpinnerIcon } from './icons';
interface Props {
userId: string;
defaultName: string;
onRegistered: (device: DeviceRecord) => void;
}
export function DeviceRegistration({ userId, defaultName, onRegistered }: Props) {
const { t } = useTranslation(['auth', 'errors']);
const nameId = useId();
const [name, setName] = useState(defaultName);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
setBusy(true);
setError(null);
try {
const device = await registerCurrentDevice({ userId, name: trimmed });
onRegistered(device);
} catch (err: unknown) {
const code = extractErrorCode(err);
if (code) {
setError(t(`errors:${code}`, { defaultValue: t('errors:generic') }));
} else if (err instanceof Error) {
setError(err.message);
} else {
setError(t('errors:generic'));
}
} finally {
setBusy(false);
}
},
[name, userId, onRegistered, t],
);
const platform = detectDesktopPlatform();
return (
<form
onSubmit={handleSubmit}
className="w-full max-w-md animate-slide-up rounded-2xl border border-white/10 bg-ink-900/70 p-7 shadow-glow backdrop-blur-xl sm:p-8"
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-brand-500/20 ring-1 ring-brand-400/30">
<LockIcon className="h-5 w-5 text-brand-300" />
</div>
<div>
<h2 className="font-display text-lg font-semibold text-white">{t('auth:device.title')}</h2>
<p className="text-xs text-neutral-400">{t('auth:device.subtitle')}</p>
</div>
</div>
<div className="mt-6 space-y-1.5">
<label
htmlFor={nameId}
className="block text-xs font-medium uppercase tracking-wide text-neutral-400"
>
{t('auth:device.name_label')}
</label>
<input
id={nameId}
type="text"
required
autoFocus
maxLength={64}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('auth:device.name_placeholder')}
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
<p className="text-xs text-neutral-500">{t('auth:device.name_hint')}</p>
</div>
<div className="mt-5 rounded-lg border border-amber-500/20 bg-amber-500/10 p-3 text-xs text-amber-200">
<div className="flex items-start gap-2">
<ShieldIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
<span className="min-w-0 flex-1 break-words">{t('auth:device.security_note_dev')}</span>
</div>
</div>
<button
type="submit"
disabled={busy || name.trim().length === 0}
aria-busy={busy}
className="group mt-6 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? (
<>
<SpinnerIcon className="h-4 w-4" />
<span>{t('auth:device.cta_loading')}</span>
</>
) : (
<>
<span>{t('auth:device.cta')}</span>
<ArrowRightIcon className="h-4 w-4 transition group-hover:translate-x-0.5" />
</>
)}
</button>
{error && (
<div
role="alert"
className="mt-4 flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
>
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<p className="min-w-0 flex-1 break-words">{error}</p>
</div>
)}
<p className="mt-4 text-center text-xs text-neutral-500">
{t('auth:device.device_platform', { defaultValue: 'Platform' })}: {platform}
</p>
</form>
);
}
@@ -1,190 +0,0 @@
import {
type DeviceRecord,
restoreDeviceFromServerRecord,
} from '@chat-app/shared/auth';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
decodePrivateKeyFromBackup,
importDeviceBackup,
normalizeRecoveryCode,
} from '../lib/deviceBackup';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import { writeLocalDeviceId } from '../lib/device';
import { AlertIcon, ArrowRightIcon, LockIcon, ShieldIcon, SpinnerIcon } from './icons';
interface Props {
userId: string;
onRestored: (device: DeviceRecord) => void;
}
// Restores a device from a user-provided backup string. The backup embeds
// userId + deviceId + X25519 private key; we verify userId matches the current
// session, confirm the device row still exists server-side, and then re-seed
// the local vault + cached deviceId so the app treats this install as the
// original device (conv-key bundles stay valid, no "awaiting" state).
export function DeviceRestore({ userId, onRestored }: Props) {
const { t } = useTranslation(['app', 'errors']);
const [backup, setBackup] = useState('');
const [passphrase, setPassphrase] = useState('');
const [mode, setMode] = useState<'passphrase' | 'recovery'>('passphrase');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!backup.trim() || passphrase.length < 1 || busy) return;
setBusy(true);
setError(null);
let privateKey: Uint8Array | null = null;
try {
const secret =
mode === 'recovery' ? normalizeRecoveryCode(passphrase) : passphrase;
const payload = await importDeviceBackup(backup.trim(), secret);
privateKey = decodePrivateKeyFromBackup(payload);
const device = await restoreDeviceFromServerRecord({
client: supabase,
secretStore: devLocalSecretStore,
userId: payload.userId,
deviceId: payload.deviceId,
privateKey,
});
// Cache deviceId locally so findExistingDevice picks it up on next load.
writeLocalDeviceId(userId, device.id);
onRestored(device);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
} finally {
if (privateKey) {
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
}
setBusy(false);
}
},
[backup, passphrase, mode, userId, onRestored, busy],
);
return (
<form
onSubmit={handleSubmit}
className="w-full max-w-md animate-slide-up rounded-2xl border border-white/10 bg-ink-900/70 p-7 shadow-glow backdrop-blur-xl sm:p-8"
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/20 ring-1 ring-emerald-400/30">
<ShieldIcon className="h-5 w-5 text-emerald-300" />
</div>
<div>
<h2 className="font-display text-lg font-semibold text-white">
{t('app:backup.restore_title', { defaultValue: 'Backup wiederherstellen' })}
</h2>
<p className="text-xs text-neutral-400">
{t('app:backup.restore_subtitle', {
defaultValue: 'Bringe einen zuvor erstellten Backup-String + Passphrase mit.',
})}
</p>
</div>
</div>
<div className="mt-6 space-y-3">
<div className="space-y-1">
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
{t('app:backup.backup_string', { defaultValue: 'Backup-String' })}
</label>
<textarea
required
rows={5}
value={backup}
onChange={(e) => setBackup(e.target.value)}
placeholder="chatapp-backup-v1…"
className="w-full resize-none rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 font-mono text-[11px] leading-relaxed text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
{mode === 'passphrase' ? 'Passphrase' : 'Recovery-Code'}
</label>
<button
type="button"
onClick={() => {
setMode((m) => (m === 'passphrase' ? 'recovery' : 'passphrase'));
setPassphrase('');
setError(null);
}}
className="cursor-pointer text-[11px] font-semibold text-brand-300 hover:underline"
>
{mode === 'passphrase'
? 'Passphrase vergessen? Recovery-Code nutzen'
: 'Stattdessen Passphrase eingeben'}
</button>
</div>
<input
type={mode === 'passphrase' ? 'password' : 'text'}
required
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder={mode === 'recovery' ? 'XXXXXX-XXXXXX-XXXXXX-XXXXXX' : ''}
spellCheck={false}
autoComplete={mode === 'recovery' ? 'off' : 'current-password'}
className={
'w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40 ' +
(mode === 'recovery' ? 'font-mono tracking-widest' : '')
}
/>
{mode === 'recovery' && (
<p className="text-[11px] text-neutral-500">
Stattdessen den Recovery-Backup-String oben einfügen.
</p>
)}
</div>
</div>
<div className="mt-5 rounded-lg border border-brand-500/20 bg-brand-500/10 p-3 text-xs text-brand-100">
<div className="flex items-start gap-2">
<LockIcon className="mt-0.5 h-4 w-4 shrink-0 text-brand-300" />
<span className="min-w-0 flex-1 break-words">
{t('app:backup.restore_hint', {
defaultValue:
'Nach Wiederherstellung übernimmt dieses Gerät die alte Identität — existierende Nachrichten sind wieder entschlüsselbar.',
})}
</span>
</div>
</div>
<button
type="submit"
disabled={busy || backup.trim().length === 0 || passphrase.length === 0}
aria-busy={busy}
className="group mt-6 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-emerald-300 hover:to-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? (
<>
<SpinnerIcon className="h-4 w-4" />
<span>{t('app:backup.restoring', { defaultValue: 'Wiederherstellen…' })}</span>
</>
) : (
<>
<span>{t('app:backup.restore_cta', { defaultValue: 'Gerät wiederherstellen' })}</span>
<ArrowRightIcon className="h-4 w-4 transition group-hover:translate-x-0.5" />
</>
)}
</button>
{error && (
<div
role="alert"
className="mt-4 flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
>
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<p className="min-w-0 flex-1 break-words">{error}</p>
</div>
)}
</form>
);
}
@@ -0,0 +1,32 @@
import type { ReactNode } from 'react';
interface Props {
icon: ReactNode;
title: string;
description: string;
action?: { label: string; onClick: () => void };
}
// Reusable empty-state placeholder: large icon + heading + description +
// optional primary CTA. Used everywhere there's a meaningfully-empty list
// (no chats, no friends, no search results, fresh conversation).
export function EmptyState({ icon, title, description, action }: Props) {
return (
<div className="flex h-full flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-accent/10 text-accent">
{icon}
</div>
<h3 className="font-display text-lg font-semibold text-fg">{title}</h3>
<p className="mt-2 max-w-xs text-sm text-fg-muted">{description}</p>
{action && (
<button
type="button"
onClick={action.onClick}
className="mt-5 inline-flex cursor-pointer items-center justify-center rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
{action.label}
</button>
)}
</div>
);
}
@@ -1,4 +1,3 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import {
type AttachmentHandle,
type DecryptedMessage,
@@ -15,8 +14,9 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { devLocalSecretStore } from '../lib/secretStore';
import { ensureInstallId } from '../lib/installId';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import { Avatar } from './Avatar';
import { ForwardIcon, SpinnerIcon, UsersIcon, XIcon } from './icons';
@@ -33,7 +33,7 @@ interface Props {
// preview hints at the dropped attachment.
export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) {
const { t } = useTranslation(['app', 'errors']);
const { session, device } = useAuth();
const { session } = useAuth();
const { conversations } = useConversationsContext();
const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -84,13 +84,13 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
}
async function handleSend() {
if (!session?.user.id || !device?.id || !message) return;
if (!session?.user.id || !message) return;
if (selected.size === 0) return;
setBusy(true);
setError(null);
try {
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
if (!priv) throw new Error('private key not loaded');
const priv = await cachedUserKey(session.user.id);
if (!priv) throw new Error('user key not unlocked');
const hasAttachments = sourceAttachments.length > 0;
const text = preview || (hasAttachments ? '' : '');
@@ -138,7 +138,7 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
conversationId: convId,
plaintext: text,
senderUserId: session.user.id,
senderDeviceId: device.id,
senderDeviceId: ensureInstallId(),
senderPrivateKey: priv,
...(newHandles.length > 0 ? { attachmentHandles: newHandles } : {}),
});
+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>
);
}
@@ -93,7 +93,7 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
<aside
role="dialog"
aria-label={t('app:group.info_title')}
className="absolute inset-y-0 right-0 z-20 flex w-[320px] flex-col border-l border-white/5 bg-ink-900/95 shadow-xl backdrop-blur-xl animate-slide-up"
className="flex w-[320px] shrink-0 flex-col border-l border-white/5 bg-ink-900/95"
>
<header className="flex items-center justify-between border-b border-white/5 px-5 py-4">
<div>
@@ -0,0 +1,490 @@
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);
// Hold a stable ref to onCancel so the image-load effect doesn't depend
// on its identity. Without this, parents that pass an inline `() => …`
// re-render the modal on every keystroke / state change, the effect re-
// runs, the previous URL.createObjectURL gets revoked WHILE the new img
// is still decoding → img.onerror fires ("file not found") → onCancel →
// modal flashes open + closes instantly.
const onCancelRef = useRef(onCancel);
useEffect(() => { onCancelRef.current = onCancel; }, [onCancel]);
useEffect(() => {
// React 18 strict mode in dev double-mounts effects to test idempotency.
// The first run creates a blob URL, sets img.src, returns a cleanup
// that revokes — and the cleanup fires BEFORE the (still-in-flight)
// image fetch completes. The browser then emits ERR_FILE_NOT_FOUND for
// the revoked URL → img.onerror → modal closes instantly. The
// `cancelled` flag guards every callback so a torn-down run can't
// close the modal that the second mount just opened.
let cancelled = false;
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
if (cancelled) return;
imageRef.current = img;
setImageLoaded(true);
};
img.onerror = () => {
if (cancelled) return;
console.error('ImageAnnotator: failed to decode source image');
onCancelRef.current();
};
img.src = url;
return () => {
cancelled = true;
URL.revokeObjectURL(url);
};
}, [file]);
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 -32
View File
@@ -15,14 +15,7 @@ import {
listSounds,
subscribeSoundboardChanges,
} from '../lib/soundboardStorage';
import {
getLiveCaptionsSettings,
isLiveCaptionsSupported,
subscribeLiveCaptionsSettings,
updateLiveCaptionsSettings,
} from '../lib/liveCaptions';
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
import { CallCaptionsOverlay } from './CallCaptionsOverlay';
import { CallControls } from './CallControls';
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
import { CallStatsOverlay } from './CallStatsOverlay';
@@ -119,17 +112,6 @@ export function InCallPanel({ conversation }: Props) {
const [sharePickerOpen, setSharePickerOpen] = useState(false);
// Discord-style debug stats overlay (Ctrl+Shift+S toggles).
const [statsOverlayOpen, setStatsOverlayOpen] = useState(false);
// Live-captions toggle — mirror of getLiveCaptionsSettings().enabled so the
// controls bar can show an "active" state without polling. Captions
// broadcasting is wired in CallContext via useLiveCaptions; this only
// tracks the toggle state for the button.
const [captionsEnabled, setCaptionsEnabled] = useState<boolean>(
() => getLiveCaptionsSettings().enabled,
);
useEffect(
() => subscribeLiveCaptionsSettings((s) => setCaptionsEnabled(s.enabled)),
[],
);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
// Match the modifier exactly to avoid clobbering other Ctrl+Shift combos.
@@ -362,15 +344,6 @@ export function InCallPanel({ conversation }: Props) {
soundboardOpen,
}
: {})}
// Live-Captions only when SpeechRecognition is available in the
// runtime — Firefox lacks it, would just show a dead button.
{...(isLiveCaptionsSupported()
? {
onToggleCaptions: () =>
updateLiveCaptionsSettings({ enabled: !captionsEnabled }),
captionsOn: captionsEnabled,
}
: {})}
onHangup={() => void hangup()}
compact={callMode !== 'fullscreen'}
glass={callMode === 'fullscreen'}
@@ -499,7 +472,6 @@ export function InCallPanel({ conversation }: Props) {
onClose={() => setStatsOverlayOpen(false)}
/>
)}
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
</>
);
}
@@ -644,8 +616,6 @@ export function InCallPanel({ conversation }: Props) {
onClose={() => setStatsOverlayOpen(false)}
/>
)}
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
</section>
);
}
@@ -1542,8 +1512,8 @@ function FullscreenCall({
function StripToggleIcon({ hidden }: { hidden: boolean }) {
return (
<svg
width="18"
height="18"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
+51
View File
@@ -0,0 +1,51 @@
import { useEffect } from 'react';
import { XIcon } from './icons';
// Fullscreen image viewer. Backdrop click + Esc close. Originally lived
// inside AttachmentImage.tsx as a file-private component; extracted here
// so other surfaces (settings avatar preview, future profile popover,
// etc.) can reuse the exact same dialog without duplicating the chrome.
//
// The image itself stops click propagation so a click on the picture
// keeps the lightbox open — only the backdrop or the explicit close
// button dismisses.
export function Lightbox({ url, onClose }: { url: string; onClose: () => void }) {
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prevOverflow;
};
}, [onClose]);
return (
<div
role="dialog"
aria-modal="true"
aria-label="Bildansicht"
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/90 p-6 backdrop-blur-sm animate-fade-in"
>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="absolute right-4 top-4 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-ink-900/80 text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<XIcon className="h-5 w-5" />
</button>
<img
src={url}
alt=""
onClick={(e) => e.stopPropagation()}
className="max-h-[92vh] max-w-[92vw] rounded-xl object-contain shadow-2xl"
/>
</div>
);
}
@@ -31,7 +31,7 @@ export function MediaFilesDrawer({ open, index, senderNameFor, onJumpToMessage,
if (!open) return null;
return (
<aside className="absolute inset-y-0 right-0 z-40 flex w-full max-w-[380px] flex-col border-l border-line bg-surface-2 shadow-2xl dark:bg-[#2b2d31]">
<aside className="flex w-[380px] shrink-0 flex-col border-l border-line bg-surface-2 dark:bg-[#2b2d31]">
<header className="flex min-h-[65px] items-center gap-3 border-b border-line px-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent/10 text-accent">
<ImageIcon className="h-5 w-5" />
@@ -1,8 +1,11 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { useNickname } from '../lib/friendNicknames';
import { Avatar } from './Avatar';
type Member = ConversationSummary['members'][number];
interface Props {
members: ConversationSummary['members'];
query: string;
@@ -63,37 +66,58 @@ export function MentionAutocomplete({ members, query, excludeUserId, onSelect, o
aria-label="Mitglieder"
className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-64 overflow-y-auto rounded-xl border border-line bg-surface-2 p-1 shadow-xl dark:bg-[#2b2d31]"
>
{matches.map((m, idx) => {
const name = m.profile?.displayName ?? m.profile?.username ?? '?';
const handle = m.profile?.username ?? '';
const isActive = idx === active;
return (
<button
key={m.userId}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => setActive(idx)}
onClick={() => {
if (m.profile?.username) onSelect(m.profile.username);
}}
className={
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
(isActive
? 'bg-accent/20 text-fg'
: 'text-fg-muted hover:bg-surface-3 dark:hover:bg-[#383a40]')
}
>
<Avatar
displayName={name}
url={m.profile?.avatarUrl ?? null}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
</button>
);
})}
{matches.map((m, idx) => (
<MemberRow
key={m.userId}
member={m}
isActive={idx === active}
onActivate={() => setActive(idx)}
onSelect={onSelect}
/>
))}
</div>
);
}
// Extracted per-row component so we can call `useNickname` once per member
// at the top of THIS component (hooks can't run inside .map callbacks).
function MemberRow({
member,
isActive,
onActivate,
onSelect,
}: {
member: Member;
isActive: boolean;
onActivate: () => void;
onSelect: (username: string) => void;
}) {
const fallback = member.profile?.displayName ?? member.profile?.username ?? '?';
const name = useNickname(member.userId, fallback);
const handle = member.profile?.username ?? '';
return (
<button
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={onActivate}
onClick={() => {
if (member.profile?.username) onSelect(member.profile.username);
}}
className={
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
(isActive
? 'bg-accent/20 text-fg'
: 'text-fg-muted hover:bg-surface-3 dark:hover:bg-[#383a40]')
}
>
<Avatar
displayName={name}
url={member.profile?.avatarUrl ?? null}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
</button>
);
}
+198 -24
View File
@@ -1,4 +1,3 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import {
type DecryptedMessage,
editEncryptedMessage,
@@ -6,13 +5,15 @@ 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 { devLocalSecretStore } from '../lib/secretStore';
import { useNickname } from '../lib/friendNicknames';
import { ensureInstallId } from '../lib/installId';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import type { AggregatedReaction } from '../lib/useMessageReactions';
import { summarizePollVotes } from '../lib/conversationFeatures';
import { extractFirstUrl } from '../lib/useLinkPreview';
@@ -30,6 +31,7 @@ import {
PencilIcon,
PhoneIcon,
PhoneOffIcon,
PinIcon,
ReplyIcon,
SmileIcon,
SpinnerIcon,
@@ -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,9 +111,17 @@ export function MessageBubble({
onForward,
onAvatarClick,
highlighted = false,
isPinned = false,
onTogglePin,
}: Props) {
const { t } = useTranslation(['app']);
const { session, device } = useAuth();
const { session } = useAuth();
// Per-viewer nickname override for the message sender. Fallback chain
// keeps the existing behavior when no nickname is set. Skipped for the
// quoted-sender snippet — that's a different user.
const senderName = useNickname(message.senderId, senderDisplayName ?? '');
const resolvedSenderDisplayName = senderName.length > 0 ? senderName : null;
const parsed = parseMessagePayload(message.plaintext);
const initialText = parsed.kind === 'text' ? parsed.text : '';
@@ -191,7 +214,7 @@ export function MessageBubble({
}, [pickerOpen]);
const handleEditSave = useCallback(async () => {
if (!session || !device) return;
if (!session) return;
const trimmed = editText.trim();
if (!trimmed || trimmed === bodyText) {
setEditing(false);
@@ -201,15 +224,15 @@ export function MessageBubble({
setBusy(true);
setEditError(null);
try {
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
if (!priv) throw new Error('private key not loaded');
const priv = await cachedUserKey(session.user.id);
if (!priv) throw new Error('user key not unlocked');
await editEncryptedMessage({
client: supabase,
messageId: message.id,
conversationId,
newPlaintext: trimmed,
senderUserId: session.user.id,
senderDeviceId: device.id,
senderDeviceId: ensureInstallId(),
senderPrivateKey: priv,
});
setEditing(false);
@@ -225,7 +248,7 @@ export function MessageBubble({
} finally {
setBusy(false);
}
}, [editText, message.id, message.plaintext, conversationId, session, device, t]);
}, [editText, message.id, message.plaintext, conversationId, session, t]);
const handleDelete = useCallback(async () => {
if (busy) return;
@@ -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) }
: {})}
@@ -294,7 +317,14 @@ export function MessageBubble({
}
if (parsed.kind === 'call_event') {
return <CallEventRow parsed={parsed} mine={mine} time={time} />;
return (
<CallEventRow
parsed={parsed}
mine={mine}
time={time}
senderDisplayName={resolvedSenderDisplayName}
/>
);
}
return (
@@ -308,7 +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) }
: {})}
@@ -409,7 +439,96 @@ export function MessageBubble({
</button>
)}
{message.plaintext === null ? (
<span className="italic opacity-70">cannot decrypt</span>
<span
className={
'italic ' +
// Mine = blue/accent bubble → use accent-fg with reduced opacity
// (still meets 4.5:1). Peer = surface-2 grey → muted-fg works.
(mine ? 'text-accent-fg/80' : 'text-fg-muted')
}
>
{t('app:chats.unreadable', {
defaultValue: 'Nachricht nicht lesbar',
})}
</span>
) : parsed.kind === '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}
@@ -417,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)
}
/>
) : (
@@ -433,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} />;
@@ -483,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
@@ -617,6 +738,15 @@ export function MessageBubble({
setContextMenu(null);
void handleDelete();
}}
isPinned={isPinned}
{...(onTogglePin
? {
onTogglePin: () => {
setContextMenu(null);
onTogglePin(message.id);
},
}
: {})}
/>
)}
</div>
@@ -624,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,
@@ -714,6 +852,8 @@ function MessageContextMenu({
onEdit,
onCopy,
onDelete,
isPinned,
onTogglePin,
}: {
x: number;
y: number;
@@ -728,6 +868,8 @@ function MessageContextMenu({
onEdit: () => void;
onCopy: () => void;
onDelete: () => void;
isPinned?: boolean;
onTogglePin?: () => void;
}) {
useEffect(() => {
function onKey(e: KeyboardEvent) {
@@ -794,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,
);
@@ -866,10 +1015,15 @@ function CallEventRow({
parsed,
mine,
time,
senderDisplayName,
}: {
parsed: { status: string; mediaKind: string; durationSec: number };
mine: boolean;
time: string;
/** Discord-parity: in group chats the system pill should say WHO
* started/missed the call. Null means we don't know (fall back to the
* legacy generic labels). */
senderDisplayName: string | null;
}) {
const { t } = useTranslation(['app']);
const status = parsed.status;
@@ -880,16 +1034,36 @@ function CallEventRow({
? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200'
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200';
// For non-own events we prefer the name-aware label so group chats
// make it clear who triggered the call event. Own events stay generic
// ("Outgoing call" / "No answer") since the user already knows they
// were the initiator.
const hasName = !mine && !!senderDisplayName;
const label =
status === 'ended'
? mine
? t('app:chats.call_outgoing', { defaultValue: 'Outgoing call' })
: t('app:chats.call_incoming', { defaultValue: 'Incoming call' })
: hasName
? t('app:chats.call_started_by', {
name: senderDisplayName,
defaultValue: '{{name}} hat einen Anruf gestartet',
})
: t('app:chats.call_incoming', { defaultValue: 'Incoming call' })
: status === 'missed'
? mine
? t('app:chats.call_no_answer', { defaultValue: 'No answer' })
: t('app:chats.call_missed', { defaultValue: 'Missed call' })
: t('app:chats.call_declined', { defaultValue: 'Call declined' });
: hasName
? t('app:chats.call_missed_by', {
name: senderDisplayName,
defaultValue: 'Verpasster Anruf von {{name}}',
})
: t('app:chats.call_missed', { defaultValue: 'Missed call' })
: hasName
? t('app:chats.call_declined_by', {
name: senderDisplayName,
defaultValue: 'Anruf von {{name}} abgelehnt',
})
: t('app:chats.call_declined', { defaultValue: 'Call declined' });
const duration = parsed.durationSec > 0 ? formatDuration(parsed.durationSec) : null;
+280
View File
@@ -0,0 +1,280 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
type MutableRefObject,
type ReactNode,
type RefObject,
} from 'react';
import { isNearBottom, isNearTop } from '../lib/scrollController';
import type { VirtuosoRow } from '../pages/ConversationPage';
export interface MessageListHandle {
scrollToBottom(behavior?: ScrollBehavior): void;
scrollToRow(index: number, align?: 'center' | 'end', behavior?: ScrollBehavior): void;
}
export interface MessageListProps {
rows: VirtuosoRow[];
renderRow: (index: number, row: VirtuosoRow) => ReactNode;
computeKey: (row: VirtuosoRow) => string;
/** Initial scroll target for a freshly-mounted list. */
initialAnchor: { type: 'bottom' } | { type: 'row'; index: number };
/** Reveal gate — the list stays hidden behind a spinner until true, so the
* post-paint height cascade (reactions/divider) is never visible. */
ready: boolean;
estimateRowHeight?: number;
atBottomThreshold?: number;
onReachTop?: () => void;
onAtBottomChange?: (atBottom: boolean) => void;
onTopRowChange?: (topIndex: number) => void;
}
export const MessageList = forwardRef<MessageListHandle, MessageListProps>(function MessageList(
{
rows,
renderRow,
computeKey,
initialAnchor,
ready,
estimateRowHeight = 64,
atBottomThreshold = 64,
onReachTop,
onAtBottomChange,
onTopRowChange,
},
ref,
) {
const scrollElRef = useRef<HTMLDivElement>(null);
const [revealed, setRevealed] = useState(false);
const atBottomRef = useRef(true);
// Load-older preservation: remember the first row key + scrollHeight so a
// prepend can be detected and the viewport restored.
const prevFirstKeyRef = useRef<string | null>(null);
const prevScrollHeightRef = useRef(0);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollElRef.current,
estimateSize: () => estimateRowHeight,
overscan: 8,
getItemKey: (index) => computeKey(rows[index]!),
});
const readMetrics = useCallback(() => {
const el = scrollElRef.current;
return el
? { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }
: { scrollTop: 0, scrollHeight: 0, clientHeight: 0 };
}, []);
const pinToBottom = useCallback(() => {
const el = scrollElRef.current;
if (!el) return;
const before = el.scrollTop;
el.scrollTop = el.scrollHeight;
// eslint-disable-next-line no-console
console.log(
`[scroll] pin sh=${el.scrollHeight} ch=${el.clientHeight} top:${Math.round(before)}->${Math.round(el.scrollTop)}`,
);
}, []);
// Deferred reveal: when ready, position at the anchor (before paint), let one
// measure cycle settle (just-rendered rows get their real heights), re-pin,
// then reveal — so what appears is already final.
//
// For the bottom case we drive scrollTop = scrollHeight DIRECTLY rather than
// virtualizer.scrollToIndex: scrollToIndex depends on the virtualizer's own
// layout effect having run first (effect ordering is not guaranteed) and on its
// size estimates — when it lost that race the list stayed pinned at the TOP.
// Driving the DOM scrollTop is order-independent and always lands at the true
// bottom; the stick-to-bottom effect re-pins as the heights settle.
useLayoutEffect(() => {
if (!ready || revealed || rows.length === 0) return;
const el = scrollElRef.current;
if (!el) return;
// eslint-disable-next-line no-console
console.log(
`[scroll] reveal anchor=${initialAnchor.type} rows=${rows.length} total=${Math.round(virtualizer.getTotalSize())} sh=${el.scrollHeight} ch=${el.clientHeight}`,
);
if (initialAnchor.type === 'bottom') {
pinToBottom();
atBottomRef.current = true;
} else {
virtualizer.scrollToIndex(Math.max(0, Math.min(initialAnchor.index, rows.length - 1)), {
align: 'start',
});
atBottomRef.current = false;
}
onAtBottomChange?.(atBottomRef.current);
requestAnimationFrame(() => {
if (atBottomRef.current) {
pinToBottom();
} else if (initialAnchor.type === 'row') {
// Re-apply after the virtualizer's own layout effect has run + measured,
// so the saved scrolled-up row lands accurately (same ordering caveat as
// the bottom case, handled here by deferring a frame).
virtualizer.scrollToIndex(Math.max(0, Math.min(initialAnchor.index, rows.length - 1)), {
align: 'start',
});
}
requestAnimationFrame(() => setRevealed(true));
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, rows.length]);
// Stick-to-bottom: whenever content grows (new row OR a measured row got
// taller) and we were at the bottom, re-pin to the true bottom. Runs while
// hidden too, so the list stays pinned through the initial measure settle.
useLayoutEffect(() => {
// eslint-disable-next-line no-console
console.log(
`[scroll] stick? atBottom=${atBottomRef.current} total=${Math.round(virtualizer.getTotalSize())} revealed=${revealed}`,
);
if (!atBottomRef.current) return;
pinToBottom();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows.length, virtualizer.getTotalSize()]);
// Load-older preservation: if rows were prepended (first key changed and the
// user is near the top), restore scrollTop by the height delta so the viewport
// stays put instead of jumping.
useLayoutEffect(() => {
const firstKey = rows.length > 0 ? computeKey(rows[0]!) : null;
const el = scrollElRef.current;
if (el && revealed && prevFirstKeyRef.current && firstKey !== prevFirstKeyRef.current) {
const delta = el.scrollHeight - prevScrollHeightRef.current;
if (delta > 0 && el.scrollTop < atBottomThreshold * 4) {
el.scrollTop += delta;
}
}
prevFirstKeyRef.current = firstKey;
prevScrollHeightRef.current = el?.scrollHeight ?? 0;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows]);
const handleScroll = useCallback(() => {
const m = readMetrics();
const atBottom = isNearBottom(m, atBottomThreshold);
if (atBottom !== atBottomRef.current) {
atBottomRef.current = atBottom;
onAtBottomChange?.(atBottom);
}
if (isNearTop(m, atBottomThreshold * 4)) onReachTop?.();
const first = virtualizer.getVirtualItems()[0];
if (first) onTopRowChange?.(first.index);
}, [atBottomThreshold, onAtBottomChange, onReachTop, onTopRowChange, readMetrics, virtualizer]);
useImperativeHandle(
ref,
() => ({
scrollToBottom: () => {
atBottomRef.current = true;
pinToBottom();
},
scrollToRow: (index, align = 'center') => {
virtualizer.scrollToIndex(index, { align });
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[virtualizer, rows.length],
);
const items = virtualizer.getVirtualItems();
return (
<>
<ScrollDebugOverlay
scrollElRef={scrollElRef}
getTotal={() => virtualizer.getTotalSize()}
atBottomRef={atBottomRef}
revealed={revealed}
ready={ready}
/>
<div
ref={scrollElRef}
onScroll={handleScroll}
className="min-h-0 flex-1 overflow-y-auto"
style={{ opacity: revealed ? 1 : 0, position: 'relative' }}
>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
{items.map((vi) => (
<div
key={vi.key}
data-index={vi.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
}}
>
{renderRow(vi.index, rows[vi.index]!)}
</div>
))}
</div>
{/* 12px bottom breathing space (matches the old Virtuoso Footer). */}
<div style={{ height: 12 }} />
</div>
</>
);
});
interface ScrollDebugOverlayProps {
scrollElRef: RefObject<HTMLDivElement>;
getTotal: () => number;
atBottomRef: MutableRefObject<boolean>;
revealed: boolean;
ready: boolean;
}
// TEMP on-screen debug readout — remove after the chat-switch scroll bug is fixed.
function ScrollDebugOverlay({
scrollElRef,
getTotal,
atBottomRef,
revealed,
ready,
}: ScrollDebugOverlayProps) {
const [, force] = useState(0);
useEffect(() => {
let raf = 0;
const loop = () => {
force((n) => (n + 1) % 1_000_000);
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
const el = scrollElRef.current;
const sh = el?.scrollHeight ?? 0;
const ch = el?.clientHeight ?? 0;
const st = el ? Math.round(el.scrollTop) : 0;
return (
<div
style={{
position: 'fixed',
top: 8,
right: 8,
zIndex: 99999,
background: 'rgba(0,0,0,0.82)',
color: '#19ff8a',
font: '11px/1.4 monospace',
padding: '6px 9px',
borderRadius: 5,
pointerEvents: 'none',
whiteSpace: 'pre',
}}
>
{`scrollHeight=${sh} clientHeight=${ch}\nscrollTop=${st}\ntotalSize=${Math.round(getTotal())}\nSCROLLABLE=${sh > ch + 4 ? 'YES' : 'NO'}\natBottom=${atBottomRef.current} revealed=${revealed} ready=${ready}`}
</div>
);
}
@@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from 'react';
import { getNickname, setNickname } from '../lib/friendNicknames';
interface Props {
open: boolean;
userId: string;
displayName: string;
onClose: () => void;
}
export function NicknameDialog({ open, userId, displayName, onClose }: Props) {
const [value, setValue] = useState('');
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!open) return;
setValue(getNickname(userId) ?? '');
setTimeout(() => inputRef.current?.focus(), 0);
}, [open, userId]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
const submit = (): void => {
setNickname(userId, value);
onClose();
};
return (
<div
role="dialog"
aria-modal="true"
aria-label="Spitzname setzen"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="w-full max-w-sm rounded-2xl border border-line bg-surface-2 p-5 shadow-xl"
>
<h3 className="font-display text-base font-semibold text-fg">Spitzname für {displayName}</h3>
<p className="mt-1 text-xs text-fg-muted">
Nur du siehst diesen Namen. Leer lassen = den richtigen Namen verwenden.
</p>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
maxLength={32}
placeholder={displayName}
className="mt-4 w-full rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg hover:bg-surface-2"
>
Abbrechen
</button>
<button
type="button"
onClick={submit}
className="cursor-pointer rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg hover:brightness-110"
>
Speichern
</button>
</div>
</div>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useRef, useState } from 'react';
interface Props {
value: string;
onChange: (next: string) => void;
length?: number;
autoFocus?: boolean;
disabled?: boolean;
ariaLabel: string;
onSubmit?: () => void;
}
export function PinInput({ value, onChange, length = 6, autoFocus, disabled, ariaLabel, onSubmit }: Props) {
const ref = useRef<HTMLInputElement | null>(null);
const [focused, setFocused] = useState(false);
useEffect(() => { if (autoFocus) ref.current?.focus(); }, [autoFocus]);
// Index of the next empty slot the next keystroke will fill. When the user
// has typed all `length` digits, no slot is "active" — the form should
// submit instead of pretending one is still focused.
const activeIndex = value.length < length ? value.length : -1;
return (
<div className="relative flex justify-center" onClick={() => ref.current?.focus()}>
<input
ref={ref}
aria-label={ariaLabel}
inputMode="numeric"
autoComplete="one-time-code"
pattern="\d*"
maxLength={length}
disabled={disabled}
value={value}
onChange={(e) => onChange(e.target.value.replace(/\D/g, '').slice(0, length))}
onKeyDown={(e) => { if (e.key === 'Enter' && value.length === length) onSubmit?.(); }}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className="absolute h-px w-px overflow-hidden p-0 opacity-0"
/>
<div className="flex gap-2">
{Array.from({ length }).map((_, i) => {
const filled = i < value.length;
const active = focused && i === activeIndex;
let classes =
'flex h-12 w-10 items-center justify-center rounded-lg border text-lg font-semibold transition ';
if (filled) {
classes += 'border-brand-400 bg-brand-500/10 text-white';
} else if (active) {
// Brand-coloured ring + glow so the user immediately sees where
// the next keystroke lands. The animated cursor inside reinforces
// the "input is alive" feeling.
classes += 'border-brand-400 bg-brand-500/10 text-brand-300 ring-2 ring-brand-400/40 shadow-[0_0_12px_-2px] shadow-brand-500/40';
} else {
classes += 'border-white/10 bg-ink-800 text-neutral-500';
}
return (
<span key={i} className={classes}>
{filled ? '•' : active ? <span className="animate-pulse">|</span> : ''}
</span>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,68 @@
import type { PinnedMessage } from '@chat-app/shared/chat';
import { PinIcon, XIcon } from './icons';
interface Props {
open: boolean;
pins: PinnedMessage[];
onClose: () => void;
onJump: (messageId: string) => void;
onUnpin: (messageId: string) => void;
// Optional preview-renderer: parent resolves messageId → short text/snippet
// since the panel itself doesn't decrypt. If absent, the panel just shows
// the message-id stub.
renderPreview?: (messageId: string) => React.ReactNode;
}
export function PinnedMessagesPanel({ open, pins, onClose, onJump, onUnpin, renderPreview }: Props) {
if (!open) return null;
return (
<aside
role="complementary"
aria-label="Angepinnte Nachrichten"
className="absolute right-0 top-0 z-30 flex h-full w-80 flex-col border-l border-line bg-surface-2 shadow-xl"
>
<header className="flex items-center justify-between border-b border-line px-4 py-3">
<div className="flex items-center gap-2">
<PinIcon className="h-4 w-4 text-accent" />
<h3 className="text-sm font-semibold text-fg">Angepinnt · {pins.length}</h3>
</div>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
</header>
{pins.length === 0 ? (
<p className="p-6 text-center text-xs text-fg-muted">Noch nichts angepinnt.</p>
) : (
<ul className="flex-1 overflow-y-auto">
{pins.map((p) => (
<li key={p.messageId} className="border-b border-line/60 px-4 py-3">
<button
type="button"
onClick={() => onJump(p.messageId)}
className="block w-full cursor-pointer text-left text-sm text-fg hover:text-accent"
>
{renderPreview ? renderPreview(p.messageId) : <span className="font-mono text-xs">{p.messageId.slice(0, 8)}</span>}
</button>
<div className="mt-1 flex items-center justify-between text-[11px] text-fg-muted">
<time dateTime={p.pinnedAt}>{new Date(p.pinnedAt).toLocaleString()}</time>
<button
type="button"
onClick={() => onUnpin(p.messageId)}
className="cursor-pointer text-rose-400 hover:underline"
>
Anheftung entfernen
</button>
</div>
</li>
))}
</ul>
)}
</aside>
);
}
@@ -0,0 +1,23 @@
import { PinIcon } from './icons';
interface Props {
count: number;
onClick: () => void;
}
// Compact chip rendered in the conv header that opens the pinned panel.
// Renders nothing when count is 0 so a fresh conv shows no clutter.
export function PinnedMessagesPill({ count, onClick }: Props) {
if (count === 0) return null;
return (
<button
type="button"
onClick={onClick}
className="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-full border border-line bg-surface-3 px-2.5 text-xs font-medium text-fg-muted transition hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
title="Angepinnte Nachrichten anzeigen"
>
<PinIcon className="h-3 w-3 text-accent" />
<span>{count} angepinnt</span>
</button>
);
}
@@ -0,0 +1,41 @@
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
export function RemoteRevokedScreen() {
const { t } = useTranslation();
const { revokedRemotely, acknowledgeRevocation } = useAuth();
if (!revokedRemotely) return null;
return (
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="remote-revoked-title"
className="fixed inset-0 z-[1000] flex items-center justify-center bg-ink-950/95 p-6"
>
<div className="max-w-sm rounded-2xl border border-line bg-surface-2 p-6 text-center shadow-xl">
<h2
id="remote-revoked-title"
className="mb-2 font-display text-xl font-semibold text-fg"
>
{t('app:auth.revoked_title', { defaultValue: 'Du wurdest remote abgemeldet' })}
</h2>
<p className="mb-5 text-sm text-fg-muted">
{t('app:auth.revoked_body', {
defaultValue:
'Ein anderes deiner Geräte hat diesen Login beendet. Aus Sicherheitsgründen wurden alle lokalen Daten gelöscht.',
})}
</p>
<button
type="button"
onClick={acknowledgeRevocation}
className="inline-flex cursor-pointer items-center justify-center rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
{t('app:auth.revoked_acknowledge', { defaultValue: 'Verstanden' })}
</button>
</div>
</div>
);
}
@@ -0,0 +1,220 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { supabase } from '../lib/supabase';
import { PencilIcon, XIcon } from './icons';
interface Stroke {
id: string;
userId: string;
color: string;
// normalized 0..1 coordinates so any viewer's canvas size renders consistently
points: Array<[number, number]>;
bornAt: number;
}
interface Props {
/** Stable per-share key. Use the share's participantId. */
shareKey: string;
/** Render annotations transparently (off when the toolbar is closed). */
enabled: boolean;
onToggleEnabled: (next: boolean) => void;
}
const FADE_MS = 8000;
const COLORS = ['#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7', '#000000'] as const;
export function ScreenShareAnnotations({ shareKey, enabled, onToggleEnabled }: Props) {
const { session } = useAuth();
const userId = session?.user.id ?? 'anon';
const [color, setColor] = useState<string>(COLORS[0]);
const [strokes, setStrokes] = useState<Stroke[]>([]);
const draftRef = useRef<Stroke | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const broadcastRef = useRef<((s: Stroke) => void) | null>(null);
// Subscribe to remote strokes.
useEffect(() => {
const channel = supabase.channel('screen-annotation:' + shareKey, {
config: { broadcast: { self: false } },
});
channel.on('broadcast', { event: 'stroke' }, (payload) => {
const s = payload.payload as Stroke | undefined;
if (!s || s.userId === userId) return;
setStrokes((prev) => [...prev, { ...s, bornAt: Date.now() }]);
});
channel.subscribe();
broadcastRef.current = (s: Stroke) => {
void channel.send({
type: 'broadcast',
event: 'stroke',
payload: s,
});
};
return () => {
broadcastRef.current = null;
void supabase.removeChannel(channel);
};
}, [shareKey, userId]);
// Garbage-collect faded strokes after FADE_MS + a small grace window.
useEffect(() => {
if (strokes.length === 0) return;
const id = setInterval(() => {
const cutoff = Date.now() - FADE_MS - 500;
setStrokes((prev) => {
const next = prev.filter((s) => s.bornAt > cutoff);
return next.length === prev.length ? prev : next;
});
}, 1000);
return () => clearInterval(id);
}, [strokes.length]);
// Paint the canvas on every render tick.
useEffect(() => {
const cv = canvasRef.current;
const container = containerRef.current;
if (!cv || !container) return;
const rect = container.getBoundingClientRect();
if (cv.width !== rect.width || cv.height !== rect.height) {
cv.width = Math.max(1, Math.floor(rect.width));
cv.height = Math.max(1, Math.floor(rect.height));
}
const ctx = cv.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, cv.width, cv.height);
const now = Date.now();
const drawStroke = (s: Stroke) => {
const age = now - s.bornAt;
const alpha = Math.max(0, 1 - age / FADE_MS);
if (alpha <= 0) return;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = s.color;
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
for (let i = 0; i < s.points.length; i++) {
const [nx, ny] = s.points[i]!;
const x = nx * cv.width;
const y = ny * cv.height;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.restore();
};
for (const s of strokes) drawStroke(s);
if (draftRef.current) drawStroke(draftRef.current);
});
// Animation frame loop so faded strokes visually decay between paints.
useEffect(() => {
if (strokes.length === 0 && !draftRef.current) return;
let raf = 0;
const tick = () => {
// Nudge state to force a re-paint. Slightly hacky but cheaper than a
// dedicated refresh state.
setStrokes((prev) => prev.slice());
raf = window.requestAnimationFrame(tick);
};
raf = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(raf);
}, [strokes.length]);
const normalized = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const container = containerRef.current;
if (!container) return [0, 0] as [number, number];
const rect = container.getBoundingClientRect();
const nx = (e.clientX - rect.left) / rect.width;
const ny = (e.clientY - rect.top) / rect.height;
return [Math.min(1, Math.max(0, nx)), Math.min(1, Math.max(0, ny))] as [number, number];
}, []);
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!enabled) return;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
draftRef.current = {
id: Math.random().toString(36).slice(2),
userId,
color,
points: [normalized(e)],
bornAt: Date.now(),
};
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!draftRef.current) return;
draftRef.current.points.push(normalized(e));
setStrokes((prev) => prev.slice()); // cheap re-render trigger
};
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
const target = e.currentTarget as HTMLDivElement;
if (target.hasPointerCapture(e.pointerId)) target.releasePointerCapture(e.pointerId);
const draft = draftRef.current;
draftRef.current = null;
if (!draft || draft.points.length < 2) {
setStrokes((prev) => prev.slice());
return;
}
setStrokes((prev) => [...prev, draft]);
broadcastRef.current?.(draft);
};
return (
<>
<div
ref={containerRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
className={
'absolute inset-0 z-10 ' +
(enabled ? 'cursor-crosshair touch-none' : 'pointer-events-none')
}
>
<canvas
ref={canvasRef}
className="pointer-events-none absolute inset-0 h-full w-full"
/>
</div>
<div className="absolute right-3 top-12 z-20 flex flex-col items-end gap-1">
<button
type="button"
onClick={() => onToggleEnabled(!enabled)}
aria-pressed={enabled}
title={enabled ? 'Annotation aus' : 'Annotation an'}
className={
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-full text-white shadow-lg transition ' +
(enabled ? 'bg-accent' : 'bg-black/60 hover:bg-black/80')
}
>
{enabled ? <XIcon className="h-3.5 w-3.5" /> : <PencilIcon className="h-3.5 w-3.5" />}
</button>
{enabled && (
<div className="flex items-center gap-1 rounded-full bg-black/60 p-1 shadow-lg">
{COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
aria-label={c}
aria-pressed={c === color}
className={
'h-5 w-5 cursor-pointer rounded-full border-2 transition ' +
(c === color ? 'border-white scale-110' : 'border-white/30 hover:scale-105')
}
style={{ backgroundColor: c }}
/>
))}
</div>
)}
</div>
</>
);
}
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { type RemoteScreenShare, useCall } from '../context/CallContext';
import { MonitorShareIcon } from './icons';
import { ScreenShareAnnotations } from './ScreenShareAnnotations';
interface ScreenShareViewerProps {
share: RemoteScreenShare;
@@ -34,6 +35,7 @@ export function ScreenShareViewer({
const { watchingShareUserIds, watchShare } = useCall();
const watching = watchingShareUserIds.has(share.participantId);
const [isFullscreen, setIsFullscreen] = useState(false);
const [annotateEnabled, setAnnotateEnabled] = useState(false);
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
useEffect(() => {
@@ -96,21 +98,28 @@ export function ScreenShareViewer({
</div>
{watching ? (
<video
ref={videoRef}
autoPlay
playsInline
muted
// Suppress the browser's built-in <video> context menu
// ("Save Video As…", PiP, …) so the right-click event bubbles
// to the wrapping tile div in InCallPanel — that's where the
// app's volume / mute menu is wired up. Without this, the
// native menu opens on top of ours in focus + fullscreen
// modes (where the video covers the whole tile).
onContextMenu={(e) => e.preventDefault()}
onDoubleClick={toggleFullscreen}
className="block h-full w-full flex-1 cursor-zoom-in bg-black object-contain"
/>
<div className="relative h-full w-full flex-1">
<video
ref={videoRef}
autoPlay
playsInline
muted
// Suppress the browser's built-in <video> context menu
// ("Save Video As…", PiP, …) so the right-click event bubbles
// to the wrapping tile div in InCallPanel — that's where the
// app's volume / mute menu is wired up. Without this, the
// native menu opens on top of ours in focus + fullscreen
// modes (where the video covers the whole tile).
onContextMenu={(e) => e.preventDefault()}
onDoubleClick={toggleFullscreen}
className="block h-full w-full cursor-zoom-in bg-black object-contain"
/>
<ScreenShareAnnotations
shareKey={share.participantId}
enabled={annotateEnabled}
onToggleEnabled={setAnnotateEnabled}
/>
</div>
) : (
<button
type="button"
@@ -0,0 +1,203 @@
import { useState } from 'react';
import {
type AutoLockMinutes,
getAutoLockMinutes,
notifyAutoLockChanged,
setAutoLockMinutes,
} from '../lib/autoLockSettings';
import { isWipeOnCloseEnabled, setWipeOnClose } from '../lib/memoryWipeSettings';
import {
changePin,
type LegacyMigrationReport,
regenerateRecoveryCode,
resetIdentity,
retryLegacyMigration,
} from '../lib/userIdentity';
import { PinInput } from './PinInput';
import { ShieldIcon, SpinnerIcon } from './icons';
interface Props { userId: string }
export function SecurityCenter({ userId }: Props) {
const [pinOld, setPinOld] = useState('');
const [pinNew, setPinNew] = useState('');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [recovery, setRecovery] = useState<string | null>(null);
const [migration, setMigration] = useState<LegacyMigrationReport | null>(null);
const [wipeOnClose, setWipeOnCloseState] = useState<boolean>(() => isWipeOnCloseEnabled());
const [autoLockMinutes, setAutoLockMinutesState] = useState<AutoLockMinutes>(() => getAutoLockMinutes());
async function handleRetryMigration() {
setBusy(true); setMsg(null); setMigration(null);
try {
const report = await retryLegacyMigration(userId);
setMigration(report);
} catch (err) {
setMsg(err instanceof Error ? err.message : String(err));
} finally { setBusy(false); }
}
async function handleChangePin() {
setBusy(true); setMsg(null);
try {
await changePin({ userId, oldPin: pinOld, newPin: pinNew });
setMsg('PIN geändert.'); setPinOld(''); setPinNew('');
} catch (err) {
setMsg(err instanceof Error ? err.message : String(err));
} finally { setBusy(false); }
}
async function handleRegenerateRecovery() {
setBusy(true); setMsg(null);
try { setRecovery(await regenerateRecoveryCode({ userId })); }
catch (err) { setMsg(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
}
async function handleReset() {
if (!window.confirm('Identität wirklich zurücksetzen? Alle bisherigen Chats werden für dich unlesbar.')) return;
setBusy(true); setMsg(null);
try {
const code = await resetIdentity({ userId, pin: pinNew || pinOld });
setRecovery(code);
setMsg('Identität zurückgesetzt.');
} catch (err) {
setMsg(err instanceof Error ? err.message : String(err));
} finally { setBusy(false); }
}
return (
<div className="space-y-6 rounded-xl border border-line bg-surface-2 p-5">
<div className="flex items-center gap-2">
<ShieldIcon className="h-4 w-4 text-accent" />
<h2 className="text-sm font-semibold">Sicherheit</h2>
</div>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">PIN ändern</h3>
<div className="space-y-2">
<PinInput ariaLabel="Aktuelle PIN" value={pinOld} onChange={setPinOld} />
<PinInput ariaLabel="Neue PIN" value={pinNew} onChange={setPinNew} />
<button type="button"
disabled={busy || pinOld.length !== 6 || pinNew.length !== 6}
onClick={() => void handleChangePin()}
className="rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg disabled:opacity-60"
>
{busy && <SpinnerIcon className="mr-1 inline h-4 w-4" />}PIN ändern
</button>
</div>
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">Recovery-Code</h3>
<button type="button" disabled={busy} onClick={() => void handleRegenerateRecovery()}
className="rounded-md border border-line bg-surface-3 px-3 py-2 text-sm hover:bg-surface-2"
>Neuen Recovery-Code erzeugen</button>
{recovery && (
<div className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest">
{recovery}
</div>
)}
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">Schlüssel-Migration reparieren</h3>
<p className="mb-2 text-xs text-fg-muted">
Versucht, alte Conversation-Schlüssel erneut für deine neue Identität zu re-wrappen.
Sicher zu klicken wenn Nachrichten verschlüsselt bleiben oder du nicht senden kannst.
</p>
<button type="button" disabled={busy} onClick={() => void handleRetryMigration()}
className="rounded-md border border-line bg-surface-3 px-3 py-2 text-sm hover:bg-surface-2"
>
{busy && <SpinnerIcon className="mr-1 inline h-4 w-4" />}Migration erneut ausführen
</button>
{migration && (
<div className="mt-2 rounded border border-line bg-surface-3 p-3 text-xs text-fg-muted">
<div>Geräte (Server): {migration.serverDevices}</div>
<div>Lokale Schlüssel im Vault: {migration.strongholdKeysFromServerDevices}
{migration.strongholdKeysFromBundleScan > 0 && (
<> (+{migration.strongholdKeysFromBundleScan} aus Bundle-Scan)</>
)}
</div>
<div>Versucht: {migration.attempted}, Erfolgreich: <span className="text-emerald-500">{migration.migrated}</span></div>
<div>Übersprungen (kein lokaler Schlüssel): {migration.noStrongholdKey}</div>
<div>Entschlüsselung gescheitert: {migration.decryptFailed}</div>
<div>Server-Fehler: {migration.rpcFailed}</div>
{migration.attempted > 0 && migration.migrated === 0 && (
<p className="mt-2 text-rose-300">
Keine Bundles migriert. Vermutlich hast du den ursprünglichen Geräteschlüssel nicht mehr lokal.
Nutze "Identität zurücksetzen" wenn du neu starten willst (alte Chats gehen verloren).
</p>
)}
</div>
)}
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">
Cache beim Schließen leeren
</h3>
<p className="mb-2 text-xs text-fg-muted">
Beim Beenden der App werden alle entschlüsselten Caches gelöscht. Beim nächsten Start
musst du wieder deine PIN eingeben. Empfohlen für gemeinsam genutzte Rechner.
</p>
<label className="inline-flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={wipeOnClose}
onChange={(e) => {
setWipeOnClose(e.target.checked);
setWipeOnCloseState(e.target.checked);
}}
/>
<span>Aktivieren</span>
</label>
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">
Auto-Lock nach Inaktivität
</h3>
<p className="mb-2 text-xs text-fg-muted">
Verlangt erneute PIN-Eingabe nach der gewählten Inaktivitätsdauer. Empfohlen für gemeinsam genutzte Rechner.
</p>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-fg">Automatisch sperren</div>
<div className="text-xs text-fg-muted">
Verlangt erneute PIN-Eingabe nach X Minuten Inaktivität.
</div>
</div>
<select
value={autoLockMinutes}
onChange={(e) => {
const next = Number(e.target.value) as AutoLockMinutes;
setAutoLockMinutes(next);
notifyAutoLockChanged(next);
setAutoLockMinutesState(next);
}}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<option value={0}>Aus</option>
<option value={5}>5 min</option>
<option value={15}>15 min</option>
<option value={30}>30 min</option>
<option value={60}>60 min</option>
</select>
</div>
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-rose-400">Identität zurücksetzen</h3>
<p className="mb-2 text-xs text-fg-muted">Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.</p>
<button type="button" disabled={busy} onClick={() => void handleReset()}
className="rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-sm text-rose-200 hover:bg-rose-500/20"
>Identität zurücksetzen</button>
</section>
{msg && <p role="status" className="text-sm text-fg-muted">{msg}</p>}
</div>
);
}
+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,93 @@
import { useCallback, useState } from 'react';
import { setupNewUserIdentity } from '../lib/userIdentity';
import { PinInput } from './PinInput';
import { ShieldIcon, SpinnerIcon } from './icons';
interface Props { userId: string; onComplete: () => void }
type Step = 'enter' | 'recovery';
export function UserKeySetup({ userId, onComplete }: Props) {
const [pin, setPin] = useState('');
const [confirm, setConfirm] = useState('');
const [withRecovery, setWithRecovery] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [step, setStep] = useState<Step>('enter');
const [recoveryCode, setRecoveryCode] = useState<string | null>(null);
const submit = useCallback(async () => {
if (pin.length !== 6 || confirm.length !== 6) {
setError('PIN muss 6 Ziffern haben.'); return;
}
if (pin !== confirm) {
setError('PINs stimmen nicht überein.'); return;
}
setBusy(true); setError(null);
try {
const r = await setupNewUserIdentity({ userId, pin, withRecovery });
setRecoveryCode(r.recoveryCode);
if (withRecovery) setStep('recovery');
else onComplete();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
} finally { setBusy(false); }
}, [pin, confirm, withRecovery, userId, onComplete]);
if (step === 'recovery') {
return (
<div className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100">
<div className="flex items-center gap-2 text-amber-300">
<ShieldIcon className="h-5 w-5" />
<h2 className="text-lg font-semibold">Recovery-Code</h2>
</div>
<p className="text-sm text-neutral-300">
Speichere diesen Code an einem sicheren Ort (Passwortmanager, ausgedruckt).
Mit ihm kannst du deinen Account auch ohne PIN entsperren.
</p>
<div className="select-all rounded-lg bg-ink-800 p-4 text-center font-mono text-lg tracking-widest">
{recoveryCode}
</div>
<div className="flex gap-2">
<button type="button"
className="flex-1 rounded-lg border border-white/10 bg-ink-800 px-3 py-2 text-sm text-neutral-200 hover:bg-white/5"
onClick={onComplete}
>Überspringen</button>
<button type="button"
className="flex-1 rounded-lg bg-brand-500 px-3 py-2 text-sm font-semibold text-white hover:bg-brand-400"
onClick={onComplete}
>Habe ich gespeichert</button>
</div>
</div>
);
}
return (
<form
onSubmit={(e) => { e.preventDefault(); void submit(); }}
className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100"
>
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN eingeben</label>
<PinInput value={pin} onChange={setPin} ariaLabel="PIN eingeben" autoFocus />
</div>
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN bestätigen</label>
<PinInput value={confirm} onChange={setConfirm} ariaLabel="PIN bestätigen" />
</div>
<label className="flex items-center gap-2 text-xs text-neutral-300">
<input type="checkbox" checked={withRecovery} onChange={(e) => setWithRecovery(e.target.checked)} />
Recovery-Code erstellen (empfohlen)
</label>
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
<button
type="submit"
disabled={busy}
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-3 text-sm font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
Weiter
</button>
</form>
);
}
@@ -0,0 +1,86 @@
import { useCallback, useState } from 'react';
import { loadOrUnlockUserKey } from '../lib/userIdentity';
import { PinInput } from './PinInput';
import { LockIcon, SpinnerIcon } from './icons';
interface Props {
userId: string;
hasRecovery: boolean;
lockedUntil?: string | null;
onUnlocked: () => void;
}
type Mode = 'pin' | 'recovery';
export function UserKeyUnlock({ userId, hasRecovery, lockedUntil, onUnlocked }: Props) {
const [mode, setMode] = useState<Mode>('pin');
const [value, setValue] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = useCallback(async () => {
setBusy(true); setError(null);
try {
const res = await loadOrUnlockUserKey({
userId, pin: value, isRecoveryCode: mode === 'recovery',
});
if (res.kind === 'unlocked') onUnlocked();
else if (res.kind === 'locked') setError('Konto gesperrt bis ' + res.lockedUntil);
else setError('Schlüssel auf dem Server nicht gefunden.');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
} finally { setBusy(false); }
}, [userId, value, mode, onUnlocked]);
return (
<form
onSubmit={(e) => { e.preventDefault(); void submit(); }}
className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100"
>
<div className="flex items-center gap-2">
<LockIcon className="h-5 w-5 text-brand-300" />
<h2 className="text-lg font-semibold">Account entsperren</h2>
</div>
{lockedUntil && (
<p role="alert" className="rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-xs text-rose-100">
Zu viele Fehlversuche. Gesperrt bis {lockedUntil}.
</p>
)}
{mode === 'pin' ? (
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN eingeben</label>
<PinInput value={value} onChange={setValue} ariaLabel="PIN eingeben" autoFocus onSubmit={() => void submit()} />
</div>
) : (
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400" htmlFor="recovery-input">Recovery-Code</label>
<input
id="recovery-input"
value={value}
onChange={(e) => setValue(e.target.value)}
spellCheck={false}
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2 font-mono tracking-widest text-white"
/>
</div>
)}
{hasRecovery && (
<button type="button"
onClick={() => { setMode((m) => (m === 'pin' ? 'recovery' : 'pin')); setValue(''); setError(null); }}
className="text-xs text-brand-300 hover:underline"
>
{mode === 'pin' ? 'PIN vergessen? Recovery-Code nutzen' : 'Stattdessen PIN verwenden'}
</button>
)}
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
<button
type="submit"
disabled={busy || (mode === 'pin' ? value.length !== 6 : value.trim().length === 0)}
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-3 text-sm font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
Entsperren
</button>
</form>
);
}
@@ -0,0 +1,148 @@
import { useEffect, useRef, 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. Press-and-
// hold reveals the image fullscreen; release closes it 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.
//
// While revealed, the renderer window enables content-protection
// (`win.setContentProtection(true)`) so OS-level screen capture (OBS, Win/Cmd
// snipping tools, screen recorders) sees a black/empty surface. Re-enabled
// on release / unmount.
export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) {
const [revealedAt, setRevealedAt] = useState<string | null>(viewedAt);
const [revealing, setRevealing] = useState(false);
const burnedRef = useRef(false);
const holdingRef = useRef(false);
const burned = revealedAt !== null;
// Tear down screen-capture protection if the component unmounts mid-reveal.
useEffect(() => {
return () => {
if (revealing || holdingRef.current) {
void window.electronAPI?.setContentProtection?.(false).catch(() => {});
}
};
}, [revealing]);
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>
);
}
const startReveal = async (): Promise<void> => {
if (burnedRef.current) return;
burnedRef.current = true;
holdingRef.current = true;
try {
await window.electronAPI?.setContentProtection?.(true);
} catch (err) {
console.warn('setContentProtection enable failed', err);
}
// The user may have released during the await. If so, skip showing the
// dialog and run the close-path directly so we don't leave the renderer
// in protected mode with no visible UI.
if (!holdingRef.current) {
// User released during the IPC await — endReveal already fired and is
// responsible for teardown (setContentProtection(false) + mark-viewed).
// Skipping teardown here avoids a duplicate markAttachmentViewed RPC.
return;
}
setRevealing(true);
};
const endReveal = async (): Promise<void> => {
if (!holdingRef.current && !revealing) return;
holdingRef.current = false;
if (revealing) setRevealing(false);
await teardownReveal();
};
const teardownReveal = async (): Promise<void> => {
try {
await window.electronAPI?.setContentProtection?.(false);
} catch (err) {
console.warn('setContentProtection disable failed', err);
}
try {
const res = await markAttachmentViewed(supabase, attachmentId);
if (res.viewedAt) setRevealedAt(res.viewedAt);
} catch (err) {
console.warn('mark-viewed failed', err);
burnedRef.current = false;
}
};
return (
<>
<button
type="button"
onPointerDown={() => void startReveal()}
onPointerUp={() => void endReveal()}
onPointerLeave={() => void endReveal()}
onPointerCancel={() => void endReveal()}
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 select-none"
>
<LockIcon className="h-6 w-6 text-accent" />
<span className="text-xs font-medium">Gedrückt halten zum Ansehen</span>
</button>
{revealing && (
<div
role="dialog"
aria-modal="true"
aria-label="Einmal-ansehen Bild"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-6"
>
<img
src={src}
alt=""
className="max-h-full max-w-full select-none rounded-lg"
draggable={false}
/>
<span className="absolute bottom-6 left-1/2 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold text-white">
Loslassen zum Schließen Aufnahme blockiert
</span>
</div>
)}
</>
);
}
@@ -1,141 +0,0 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useCallPresence } from '../lib/useCallPresence';
import { Avatar } from './Avatar';
import { PhoneIcon, SpinnerIcon } from './icons';
interface Props {
conversation: ConversationSummary;
}
const MAX_AVATARS = 5;
/**
* Discord-style persistent voice-channel band rendered at the top of the
* message surface. Always visible for groups so any member can pop in
* without an invite-ring; for 1:1 conversations only when someone's already
* in (a soft "rejoin" affordance). Joining uses the existing joinActiveCall
* path which sends no invite signals.
*/
export function VoiceChannelRail({ conversation }: Props) {
const { t } = useTranslation(['app']);
const { session } = useAuth();
const { state, joinActiveCall } = useCall();
const presentIds = useCallPresence(conversation.id);
const myId = session?.user.id ?? null;
const iAmIn =
(state.kind === 'connected' ||
state.kind === 'connecting' ||
state.kind === 'reconnecting') &&
state.conversationId === conversation.id;
const others = presentIds.filter((u) => u !== myId);
const isGroup = conversation.type === 'group';
// For groups: persistent channel feel — show even when empty.
// For DMs: only when somebody is already in (matches Discord-DM behaviour).
const visible = !iAmIn && (isGroup || others.length > 0);
if (!visible) return null;
const showAvatars = others.slice(0, MAX_AVATARS);
const overflow = Math.max(0, others.length - MAX_AVATARS);
const busy = state.kind !== 'idle';
const handleJoin = () => {
if (busy) return;
void joinActiveCall(conversation.id, 'audio');
};
return (
<div
role="button"
tabIndex={0}
onClick={handleJoin}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleJoin();
}
}}
className={
'flex cursor-pointer items-center gap-3 border-b border-line px-5 py-2.5 text-sm transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(others.length > 0
? 'bg-emerald-500/10 hover:bg-emerald-500/15'
: 'bg-surface-3/70 hover:bg-surface-3')
}
>
<div
className={
'flex h-8 w-8 shrink-0 items-center justify-center rounded-md ' +
(others.length > 0 ? 'bg-emerald-500/20 text-emerald-500' : 'bg-accent/15 text-accent')
}
>
<PhoneIcon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
{t('app:call.voice_channel', { defaultValue: 'Sprach-Channel' })}
</p>
<p className="truncate text-xs text-fg">
{others.length === 0
? t('app:call.voice_empty', {
defaultValue: 'Niemand drin — sei der Erste.',
})
: t('app:call.voice_count', {
defaultValue: '{{count}} im Channel',
count: others.length,
})}
</p>
</div>
{showAvatars.length > 0 && (
<div className="flex -space-x-2">
{showAvatars.map((id) => {
const member = conversation.members.find((m) => m.userId === id);
return (
<div
key={id}
title={member?.profile?.displayName ?? '?'}
className="relative h-7 w-7 overflow-hidden rounded-full border-2 border-surface-3 bg-surface-2"
>
<Avatar
url={member?.profile?.avatarUrl ?? null}
displayName={member?.profile?.displayName ?? '?'}
className="h-full w-full text-[10px]"
/>
</div>
);
})}
{overflow > 0 && (
<span className="inline-flex h-7 min-w-[1.75rem] items-center justify-center rounded-full border-2 border-surface-3 bg-surface-2 px-1.5 text-[10px] font-semibold text-fg-muted">
+{overflow}
</span>
)}
</div>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleJoin();
}}
disabled={busy}
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? (
<SpinnerIcon className="h-3.5 w-3.5" />
) : (
<PhoneIcon className="h-3.5 w-3.5" />
)}
<span>
{others.length === 0
? t('app:call.voice_open', { defaultValue: 'Channel öffnen' })
: t('app:call.join', { defaultValue: 'Beitreten' })}
</span>
</button>
</div>
);
}
@@ -0,0 +1,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,234 @@
import { useEffect, useRef, useState } from 'react';
import type { WhiteboardStroke } from '@chat-app/shared/chat';
import { useAuth } from '../context/AuthContext';
import { openCursorSession, type CursorEvent, type CursorSession } from '../lib/whiteboardCursors';
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;
/** Enables live-cursor broadcast when set. */
whiteboardId?: string | null;
}
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,
whiteboardId,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const draftRef = useRef<WhiteboardStrokePayload | null>(null);
const strokeStartRef = useRef<number>(0);
const [, forceTick] = useState(0);
const { session, profile } = useAuth();
const [remoteCursors, setRemoteCursors] = useState<Map<string, CursorEvent & { lastSeen: number }>>(
() => new Map(),
);
const cursorSessionRef = useRef<CursorSession | null>(null);
useEffect(() => {
if (!whiteboardId) return;
const me = session?.user;
if (!me) return;
const displayName = profile?.displayName ?? me.email ?? me.id.slice(0, 8);
const s = openCursorSession(
whiteboardId,
{ userId: me.id, displayName },
(ev) => {
setRemoteCursors((prev) => {
const next = new Map(prev);
next.set(ev.userId, { ...ev, lastSeen: Date.now() });
return next;
});
},
);
cursorSessionRef.current = s;
return () => {
s.close();
cursorSessionRef.current = null;
};
}, [whiteboardId, session?.user, profile?.displayName]);
// Stale-cursor sweep: drop cursors that haven't been heard from in 2s. Cheap
// poll because the Map is tiny (at most one entry per active collaborator).
useEffect(() => {
if (remoteCursors.size === 0) return;
const id = setInterval(() => {
const now = Date.now();
setRemoteCursors((prev) => {
let changed = false;
const next = new Map(prev);
for (const [k, v] of next) {
if (now - v.lastSeen > 2000) {
next.delete(k);
changed = true;
}
}
return changed ? next : prev;
});
}, 1000);
return () => clearInterval(id);
}, [remoteCursors.size]);
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>) => {
const [x, y] = canvasPoint(e);
cursorSessionRef.current?.send(x, y);
if (!draftRef.current) return;
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 (
<div
className="relative"
style={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
>
<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={{ width: '100%', height: '100%' }}
/>
{Array.from(remoteCursors.values()).map((c) => {
const pctX = (c.x / logicalWidth) * 100;
const pctY = (c.y / logicalHeight) * 100;
return (
<div
key={c.userId}
aria-hidden="true"
className="pointer-events-none absolute"
style={{ left: pctX + '%', top: pctY + '%', transform: 'translate(-2px, -2px)' }}
>
<span
className="block h-2 w-2 rounded-full border-2 border-white shadow"
style={{ backgroundColor: colorForUserId(c.userId) }}
/>
<span className="ml-2 inline-block translate-y-[-2px] rounded-full bg-black/60 px-1.5 py-0.5 text-[9px] font-semibold text-white">
{c.displayName}
</span>
</div>
);
})}
</div>
);
}
function colorForUserId(userId: string): string {
// Deterministic hue from the user id so each collaborator gets a stable
// colour across sessions. Saturation/lightness fixed to keep the cursor
// legible against the white canvas.
let hash = 0;
for (let i = 0; i < userId.length; i++) hash = (hash * 31 + userId.charCodeAt(i)) | 0;
const hue = Math.abs(hash) % 360;
return 'hsl(' + hue + ', 70%, 50%)';
}
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,198 @@
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)}
whiteboardId={whiteboardId}
/>
)}
</div>
<footer className="flex shrink-0 flex-wrap items-center gap-4 border-t border-line/40 bg-surface-2 px-4 py-3">
<div className="flex items-center gap-1">
{(['pen', 'eraser'] as WhiteboardTool[]).map((id) => {
const label = id === 'pen' ? 'Stift' : 'Radierer';
const active = tool === id;
return (
<button
key={id}
type="button"
onClick={() => setTool(id)}
aria-pressed={active}
title={label}
className={
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-xs font-semibold transition ' +
(active
? 'bg-accent/20 text-accent ring-2 ring-accent/40'
: 'bg-surface-3 text-fg-muted hover:bg-surface hover:text-fg')
}
>
{id === 'pen' ? '✎' : '⌫'}
</button>
);
})}
</div>
<div className="h-6 w-px bg-line/40" aria-hidden />
<div className="flex items-center gap-1">
{COLORS.map((c) => {
const active = color === c;
return (
<button
key={c}
type="button"
onClick={() => setColor(c)}
aria-pressed={active}
aria-label={c}
className={
'h-6 w-6 cursor-pointer rounded-full border-2 transition ' +
(active ? 'border-fg scale-110' : 'border-line/40 hover:scale-105')
}
style={{ backgroundColor: c }}
/>
);
})}
</div>
<div className="h-6 w-px bg-line/40" aria-hidden />
<div className="flex items-center gap-1">
{WIDTHS.map((w) => {
const active = width === w;
return (
<button
key={w}
type="button"
onClick={() => setWidth(w)}
aria-pressed={active}
title={w + 'px'}
className={
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md transition ' +
(active
? 'bg-accent/20 ring-2 ring-accent/40'
: 'bg-surface-3 hover:bg-surface')
}
>
<div
className="rounded-full bg-fg"
style={{ width: w + 2 + 'px', height: w + 2 + 'px' }}
/>
</button>
);
})}
</div>
<div className="ml-auto flex items-center gap-2">
{confirmClear ? (
<>
<span className="text-xs text-fg-muted">
{t('app:whiteboard.confirm_clear', { defaultValue: 'Alles löschen?' })}
</span>
<button
type="button"
onClick={() => setConfirmClear(false)}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface"
>
{t('app:whiteboard.cancel', { defaultValue: 'Abbrechen' })}
</button>
<button
type="button"
onClick={() => void handleConfirmClear()}
className="cursor-pointer rounded-md bg-rose-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-rose-500/90"
>
{t('app:whiteboard.confirm', { defaultValue: 'Ja, löschen' })}
</button>
</>
) : (
<button
type="button"
onClick={() => setConfirmClear(true)}
disabled={strokes.length === 0}
className="cursor-pointer rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
>
{t('app:whiteboard.clear_all', { defaultValue: 'Alles löschen' })}
</button>
)}
</div>
</footer>
</div>
);
}
+7 -4
View File
@@ -23,11 +23,14 @@ export function RequireAuth() {
return <Outlet />;
}
// Forces a registered device on this install. Sends to /device otherwise.
// Forces a usable per-user encrypted key blob on this install. Sends to
// /device (the setup/unlock page) when the blob is missing or locked.
export function RequireDevice() {
const { device, deviceLookupDone } = useAuth();
if (!deviceLookupDone) return <FullScreenSpinner />;
if (!device) return <Navigate to="/device" replace />;
const { userKeyState } = useAuth();
if (userKeyState.status === 'loading') return <FullScreenSpinner />;
if (userKeyState.status === 'needs-setup' || userKeyState.status === 'needs-unlock') {
return <Navigate to="/device" replace />;
}
return <Outlet />;
}
+124 -64
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,18 +148,19 @@ export function EyeOffIcon(props: IconProps) {
</Base>
);
}
export const EyeOffIcon = memo(EyeOffIconInner);
export function CaptionsIcon(props: IconProps) {
function EyeIconInner(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="6" width="18" height="12" rx="2" />
<path d="M7 13a2 2 0 1 1 0-2" />
<path d="M14 13a2 2 0 1 1 0-2" />
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</Base>
);
}
export const EyeIcon = memo(EyeIconInner);
export function PinOffIcon(props: IconProps) {
function PinOffIconInner(props: IconProps) {
return (
<Base {...props}>
<path d="m2 2 20 20" />
@@ -157,8 +170,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 +186,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 +223,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 +244,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 +254,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 +264,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 +284,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 +324,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 +336,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 +346,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 +366,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 +378,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 +393,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 +404,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 +424,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 +435,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 +445,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 +466,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 +477,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 +487,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 +499,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 +509,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 +521,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 +531,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 +551,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 +571,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 +581,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 +595,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 +606,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 +617,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 +630,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 +640,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 +650,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 +670,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 +696,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 +735,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>
);
}
+220 -61
View File
@@ -1,12 +1,15 @@
import {
type DeviceRecord,
fetchUserKeyBlob,
getOwnProfile,
listOwnDevices,
registerDevice,
signOut as supabaseSignOut,
type Profile,
touchDeviceLastSeen,
type Profile,
updateOwnProfile,
} from '@chat-app/shared/auth';
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
import type { DevicePlatform } from '@chat-app/shared/supabase';
import type { Session } from '@supabase/supabase-js';
import {
createContext,
@@ -20,24 +23,40 @@ import {
} from 'react';
import { useTranslation } from 'react-i18next';
import { findExistingDevice } from '../lib/device';
import { PRESENCE_HEARTBEAT_MS } from '../lib/presence';
import { clearDeviceRowId, getDeviceRowId, setDeviceRowId } from '../lib/deviceRowId';
import { ensureInstallId } from '../lib/installId';
import { wipeLocalState } from '../lib/memoryWipe';
import { isWipeOnCloseEnabled } from '../lib/memoryWipeSettings';
import { setSecretStoreUser } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import { cachedUserKey, ensureLegacyMigrated } from '../lib/userIdentity';
import { registerWebPush } from '../lib/webPush';
// Discriminated union describing the per-user encrypted key blob lifecycle:
//
// loading — initial state, or refresh in flight
// needs-setup — no row exists on Supabase; user must pick a PIN
// needs-unlock — row exists but local cache empty; PIN (or recovery code)
// required. `lockedUntil` non-null means the server-side
// rate limiter is currently rejecting attempts.
// unlocked — private key is in the local secret store and ready to use
export type UserKeyState =
| { status: 'loading' }
| { status: 'needs-setup' }
| { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean }
| { status: 'unlocked' };
interface AuthContextValue {
session: Session | null;
profile: Profile | null;
device: DeviceRecord | null;
userKeyState: UserKeyState;
// null while we're still resolving the very first auth state.
ready: boolean;
// null until a device lookup has finished for the current session.
deviceLookupDone: boolean;
refreshProfile: () => Promise<void>;
refreshDevice: () => Promise<void>;
setDevice: (device: DeviceRecord | null) => void;
refreshUserKeyState: () => Promise<void>;
signOut: () => Promise<void>;
revokedRemotely: boolean;
acknowledgeRevocation: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
@@ -47,8 +66,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [ready, setReady] = useState(false);
const [profile, setProfile] = useState<Profile | null>(null);
const [device, setDevice] = useState<DeviceRecord | null>(null);
const [deviceLookupDone, setDeviceLookupDone] = useState(false);
const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
const [revokedRemotely, setRevokedRemotely] = useState(false);
const autoOnlineUserRef = useRef<string | null>(null);
// Initial session + auth subscription. We verify the cached JWT against the
@@ -112,41 +131,155 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, [session, i18n.resolvedLanguage]);
const refreshDevice = useCallback(async () => {
// Resolves the current state of the per-user encrypted key blob:
// 1. local cache hit → 'unlocked'
// 2. no remote row → 'needs-setup'
// 3. server rate-limit → 'needs-unlock' with lockedUntil set
// 4. otherwise → 'needs-unlock'; hasRecovery reflects whether a
// recovery-code blob is present so the UI can
// conditionally offer the recovery affordance
const refreshUserKeyState = useCallback(async () => {
if (!session) {
setDevice(null);
setDeviceLookupDone(false);
setUserKeyState({ status: 'loading' });
return;
}
setDeviceLookupDone(false);
const found = await findExistingDevice(session.user.id);
setDevice(found);
setDeviceLookupDone(true);
setUserKeyState({ status: 'loading' });
const cached = await cachedUserKey(session.user.id);
if (cached) {
setUserKeyState({ status: 'unlocked' });
// Best-effort: re-wrap any unmigrated legacy bundles. Idempotent (RPC
// uses ON CONFLICT DO NOTHING). Recovers users who set up under 0.18.0
// where the migration query had a `.eq(null)` bug that made it a no-op.
void ensureLegacyMigrated(session.user.id).catch((err) => {
console.warn('legacy conv-key migration on auth-resume failed', err);
});
return;
}
const blob = await fetchUserKeyBlob(supabase, session.user.id);
if (!blob || !blob.exists) {
setUserKeyState({ status: 'needs-setup' });
return;
}
if (blob.locked) {
setUserKeyState({
status: 'needs-unlock',
lockedUntil: blob.lockedUntil,
hasRecovery: false,
});
return;
}
setUserKeyState({
status: 'needs-unlock',
lockedUntil: null,
hasRecovery: blob.recoverySealedPrivateKey !== null,
});
}, [session]);
// Re-pull profile + device whenever session flips.
// Re-pull profile + user-key state whenever session flips.
useEffect(() => {
if (!session) {
setProfile(null);
setDevice(null);
setDeviceLookupDone(false);
setUserKeyState({ status: 'loading' });
return;
}
void refreshProfile().catch((err: unknown) => {
console.error('refreshProfile failed', err);
});
void refreshDevice().catch((err: unknown) => {
console.error('refreshDevice failed', err);
setDeviceLookupDone(true);
void refreshUserKeyState().catch((err: unknown) => {
console.error('refreshUserKeyState failed', err);
// Treat an unrecoverable lookup error as "needs-setup" so the UI at
// least drives the user toward the setup/unlock page rather than
// hanging forever on the spinner.
setUserKeyState({ status: 'needs-setup' });
});
}, [session, refreshProfile, refreshDevice]);
}, [session, refreshProfile, refreshUserKeyState]);
// Best-effort web-push registration once we know the device id. No-op on
// Tauri (uses native notifications) or when VITE_VAPID_PUBLIC_KEY is unset.
// Best-effort web-push registration once we have a session. Keyed by an
// install-id (localStorage UUID) since there's no longer a per-device
// crypto record to key by. No-op on Tauri (uses native notifications) or
// when VITE_VAPID_PUBLIC_KEY is unset.
useEffect(() => {
if (!device?.id) return;
void registerWebPush(device.id);
}, [device?.id]);
if (!session) return;
const installId = ensureInstallId();
void registerWebPush(installId);
}, [session]);
// 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.
//
// Uses auth.getSession() instead of a `profiles` SELECT because the
// SELECT race-fired before the supabase client committed its JWT to
// request headers, causing a 400 from PostgREST on app boot. Auth
// endpoints don't depend on RLS and tolerate the race.
useEffect(() => {
if (!session) return;
void supabase.auth.getSession();
}, [session]);
// Phase 3: ensure this install owns exactly one devices row. The row is
// pure session-list telemetry — it does not carry any cryptographic
// material since the per-user-key refactor. We re-use the row across
// restarts via localStorage (chatapp.deviceRowId.v1); a memory-wipe is
// intentionally treated as "new install".
useEffect(() => {
if (!session) return;
let cancelled = false;
void (async () => {
try {
const existing = getDeviceRowId();
if (existing) {
const rows = await listOwnDevices(supabase);
const match = rows.find((r) => r.id === existing && r.revokedAt === null);
if (match) {
await touchDeviceLastSeen(supabase, existing).catch(() => {});
return;
}
// Row gone / revoked: drop the stale id and fall through to
// registering a fresh one.
clearDeviceRowId();
}
if (cancelled) return;
const hostname =
(typeof window.electronAPI?.getHostname === 'function'
? await window.electronAPI.getHostname().catch(() => null)
: null) ?? 'Desktop';
// Map Node's process.platform values to the schema's device_platform
// enum ('windows' | 'macos' | 'linux' | 'ios' | 'android'). Default
// to 'linux' for unknown/web-build cases — the field is only used
// for the session-list UI icon.
const osPlatform = window.electronAPI?.osPlatform;
const platform: DevicePlatform =
osPlatform === 'win32'
? 'windows'
: osPlatform === 'darwin'
? 'macos'
: 'linux';
const created = await registerDevice(supabase, {
name: hostname.slice(0, 64),
platform,
});
if (!cancelled) setDeviceRowId(created.id);
} catch (err) {
console.warn('ensure device row failed', err);
}
})();
return () => {
cancelled = true;
};
}, [session]);
// Wipe-on-close: register a handler the main process pings on `before-quit`
// when the user has enabled the Settings → Sicherheit toggle. No-op outside
// Electron (web build has no preload bridge) or when the toggle is off.
useEffect(() => {
if (typeof window.electronAPI?.onWipeBeforeQuit !== 'function') return;
const unsub = window.electronAPI.onWipeBeforeQuit(async () => {
if (!isWipeOnCloseEnabled()) return;
await wipeLocalState(session?.user.id ?? null);
});
return unsub;
}, [session?.user.id]);
// Auto online/offline transition.
//
@@ -188,52 +321,78 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
}, [session, profile, refreshProfile]);
useEffect(() => {
if (!session || !device?.id) return;
const touch = () => {
void touchDeviceLastSeen(supabase, device.id).catch((err: unknown) => {
console.warn('presence heartbeat failed', err);
});
};
const touchWhenVisible = () => {
if (document.visibilityState === 'visible') touch();
};
touch();
const heartbeat = window.setInterval(touch, PRESENCE_HEARTBEAT_MS);
window.addEventListener('focus', touch);
window.addEventListener('online', touch);
document.addEventListener('visibilitychange', touchWhenVisible);
return () => {
window.clearInterval(heartbeat);
window.removeEventListener('focus', touch);
window.removeEventListener('online', touch);
document.removeEventListener('visibilitychange', touchWhenVisible);
};
}, [session, device?.id]);
const signOut = useCallback(async () => {
const uid = session?.user.id ?? null;
await updateOwnProfile(supabase, { presenceState: 'offline' }).catch((err: unknown) => {
console.warn('offline update before sign-out failed', err);
});
await supabaseSignOut(supabase);
await wipeLocalState(uid);
}, [session]);
const acknowledgeRevocation = useCallback(() => {
setRevokedRemotely(false);
}, []);
// Phase 3: listen for own-device revocations. The same channel also fires
// when *another* of the user's installs is revoked — we ignore those (we
// only force-sign-out when OUR row's revoked_at flips). The UI's device
// list refetches independently via its own subscription in useOwnDevices.
useEffect(() => {
if (!session) return;
const userId = session.user.id;
const channel = supabase
.channel('devices:self:' + userId)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'devices',
filter: 'user_id=eq.' + userId,
},
(payload) => {
const ownId = getDeviceRowId();
const row = payload.new as { id?: string; revoked_at?: string | null } | null;
if (!row || !ownId) return;
if (row.id !== ownId) return;
if (row.revoked_at) {
setRevokedRemotely(true);
void signOut().catch((err) => {
console.warn('forced signOut after revoke failed', err);
});
}
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [session, signOut]);
const value = useMemo<AuthContextValue>(
() => ({
session,
profile,
device,
userKeyState,
ready,
deviceLookupDone,
refreshProfile,
refreshDevice,
setDevice,
refreshUserKeyState,
signOut,
revokedRemotely,
acknowledgeRevocation,
}),
[session, profile, device, ready, deviceLookupDone, refreshProfile, refreshDevice, signOut],
[
session,
profile,
userKeyState,
ready,
refreshProfile,
refreshUserKeyState,
signOut,
revokedRemotely,
acknowledgeRevocation,
],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+58 -77
View File
@@ -1,4 +1,3 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { sendEncryptedMessage } from '@chat-app/shared/chat';
import {
type CallKind,
@@ -40,7 +39,6 @@ import {
playUndeafenBeep,
playUnmuteBeep,
} from '../lib/callSounds';
import { useLiveCaptions } from '../lib/useLiveCaptions';
import { setCallWakeLock } from '../lib/wakeLock';
import { notify } from '../lib/osNotify';
import {
@@ -100,6 +98,7 @@ import {
subscribeScreenShareVolumes,
} from '../lib/screenShareVolumes';
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
import { playSoundboardLocal } from '../lib/soundboardLocalPlay';
import { playEntry } from '../lib/soundboardPlayback';
import {
getPrefs as getSoundboardPrefs,
@@ -114,8 +113,9 @@ import {
type ScreenSharePreset,
updateScreenShareSettings,
} from '../lib/screenShareSettings';
import { devLocalSecretStore } from '../lib/secretStore';
import { ensureInstallId } from '../lib/installId';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import { setWindowFullscreen } from '../lib/windowFullscreen';
export type CallState =
@@ -209,14 +209,6 @@ interface CallContextValue {
* invite (fromUserId). Cleared on disconnect. Drives the crown badge,
* but only in group calls. Null while idle or in 1:1 contexts. */
callHostId: string | null;
/** identity -> latest live-caption fragment received via data channel.
* Includes own captions for self-overlay. Receivers prune entries whose
* timestamp is older than ~5s so stale lines fade out. */
captions: Record<string, { text: string; final: boolean; timestamp: number }>;
/** Surface a caption for the local user the live-captions hook calls
* this on every interim/final SpeechRecognition result so the overlay
* shows our own line without going through the SFU round-trip. */
pushLocalCaption: (text: string, final: boolean) => void;
/** identity -> mute state. Broadcast from peer whenever mic-gain flips.
* Needed because we can't rely on LiveKit's native isMicrophoneEnabled
* the mic pipeline keeps the track published with sound flowing even
@@ -325,7 +317,7 @@ function newCallId(): string {
}
export function CallProvider({ children }: { children: ReactNode }) {
const { session, device, profile } = useAuth();
const { session, profile } = useAuth();
const { conversations } = useConversationsContext();
const myId = session?.user.id;
@@ -347,9 +339,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
// useEffect) so peers don't hear themselves echoed back when the OS-level
// process-tree exclusion isn't watertight.
const [isCapturingSystemAudio, setIsCapturingSystemAudio] = useState(false);
const [captions, setCaptions] = useState<
Record<string, { text: string; final: boolean; timestamp: number }>
>({});
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
const [callMode, setCallModeState] = useState<CallMode>('grid');
const [focusedId, setFocusedIdState] = useState<string | null>(null);
@@ -446,13 +435,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
mediaKind: CallKind,
durationSec: number,
) => {
if (!session?.user.id || !device?.id) return;
if (!session?.user.id) return;
try {
const priv = await loadDevicePrivateKey(
devLocalSecretStore,
session.user.id,
device.id,
);
const priv = await cachedUserKey(session.user.id);
if (!priv) return;
const payload = JSON.stringify({
v: 1,
@@ -466,14 +451,14 @@ export function CallProvider({ children }: { children: ReactNode }) {
conversationId,
plaintext: payload,
senderUserId: session.user.id,
senderDeviceId: device.id,
senderDeviceId: ensureInstallId(),
senderPrivateKey: priv,
});
} catch (err: unknown) {
console.error('emitCallEvent failed', err);
}
},
[session?.user.id, device?.id],
[session?.user.id],
);
const clearRingTimer = useCallback(() => {
@@ -832,7 +817,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
setRemoteScreenShares([]);
setConnectionQualities({});
setCallHostId(null);
setCaptions({});
setIsScreenSharing(false);
setIsE2EEActive(false);
}
@@ -974,8 +958,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
type?: string;
deafened?: boolean;
muted?: boolean;
captionText?: string;
captionFinal?: boolean;
};
const id: string = participant.identity;
if (msg.type === 'presence') {
@@ -995,15 +977,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
}
return;
}
if (msg.type === 'caption' && typeof msg.captionText === 'string') {
const text2 = msg.captionText;
const final = msg.captionFinal === true;
setCaptions((prev) => ({
...prev,
[id]: { text: text2, final, timestamp: Date.now() },
}));
return;
}
} catch {
/* ignore malformed */
}
@@ -1446,20 +1419,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 })
@@ -1749,17 +1731,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
});
}, []);
const pushLocalCaption = useCallback(
(text: string, final: boolean) => {
if (!myId) return;
setCaptions((prev) => ({
...prev,
[myId]: { text, final, timestamp: Date.now() },
}));
},
[myId],
);
const toggleCamera = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
@@ -2008,14 +1979,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];
@@ -2285,15 +2258,36 @@ export function CallProvider({ children }: { children: ReactNode }) {
pipelineRef.current?.setMonitorGain(prefs.monitorGain);
}, []);
// Global soundboard hotkey registration — runs only while connected so the
// OS-level shortcuts don't fire when the user is outside of a call.
// Global soundboard hotkey registration — always-on so the OS-level
// shortcuts fire even outside a call (Stream-Deck-style local SFX). Inside
// a call we route through `playSoundboard` so peers hear; outside a call
// we fall back to `playSoundboardLocal` which plays through the system
// default output only.
//
// We deliberately do NOT depend on `state.kind` in the effect dep array:
// every call state transition (idle → connecting → connected → reconnecting
// → ...) would trigger a full unregister+re-register cycle through IPC, and
// during the 150 ms gap the hotkeys are silently dead. Instead we read the
// current call state through a ref that's always kept in sync.
const callStateKindRef = useRef(state.kind);
callStateKindRef.current = state.kind;
const playSoundboardRef = useRef(playSoundboard);
playSoundboardRef.current = playSoundboard;
useEffect(() => {
if (state.kind !== 'connected') return;
const teardown = startSoundboardHotkeys((id) => {
void playSoundboard(id);
if (callStateKindRef.current === 'connected') {
void playSoundboardRef.current(id);
} else {
void (async () => {
const entries = await listSoundboard();
const entry = entries.find((e) => e.id === id);
if (entry) await playSoundboardLocal(entry);
})();
}
});
return teardown;
}, [state.kind, playSoundboard]);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
updateAudioSettings({ inputDeviceId: deviceId });
@@ -2596,15 +2590,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener('keydown', onKey);
}, [callMode, state.kind]);
// Discord-style live-captions broadcaster — runs on the local mic while
// we're connected, and ships interim/final transcripts on the LiveKit
// DataChannel so peers can render them.
useLiveCaptions({
room,
active: state.kind === 'connected' || state.kind === 'reconnecting',
onLocalCaption: pushLocalCaption,
});
const value = useMemo<CallContextValue>(
() => ({
state,
@@ -2619,8 +2604,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
remoteMute,
connectionQualities,
callHostId,
captions,
pushLocalCaption,
remoteScreenShares,
lastCallConversationId,
callMode,
@@ -2676,8 +2659,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
remoteMute,
connectionQualities,
callHostId,
captions,
pushLocalCaption,
remoteScreenShares,
lastCallConversationId,
callMode,
@@ -54,6 +54,15 @@ interface ConversationsContextValue {
refresh: () => Promise<void>;
markRead: (conversationId: string) => void;
setActiveConversation: (conversationId: string | null) => void;
// Optimistic patch for the caller's per-membership preferences (mute /
// mentions-only / archive). Mutations to `conversation_members` echo back
// via the realtime channel and `refresh()` reconciles canonically, but the
// ~100-200ms roundtrip leaves the UI looking unresponsive. Callers patch
// immediately, snapshot the previous state, and roll back on failure.
patchConversation: (
conversationId: string,
patch: Partial<Pick<ConversationSummary, 'archived' | 'mutedUntil' | 'mentionsOnly'>>,
) => void;
}
const ConversationsContext = createContext<ConversationsContextValue | null>(null);
@@ -164,6 +173,19 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
[markRead],
);
const patchConversation = useCallback<ConversationsContextValue['patchConversation']>(
(convId, patch) => {
setConversations((prev) => {
const idx = prev.findIndex((c) => c.id === convId);
if (idx === -1) return prev;
const next = [...prev];
next[idx] = { ...next[idx]!, ...patch };
return next;
});
},
[],
);
useEffect(() => {
if (!myId) {
setConversations([]);
@@ -225,7 +247,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,
@@ -303,6 +330,7 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
refresh,
markRead,
setActiveConversation,
patchConversation,
}),
[
conversations,
@@ -313,6 +341,7 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
refresh,
markRead,
setActiveConversation,
patchConversation,
],
);
+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 };
}
+79
View File
@@ -0,0 +1,79 @@
import { useEffect, useRef } from 'react';
import { useAuth } from '../context/AuthContext';
import {
getAutoLockMinutes,
subscribeAutoLockSetting,
type AutoLockMinutes,
} from '../lib/autoLockSettings';
const ACTIVITY_EVENTS: Array<keyof WindowEventMap> = [
'keydown',
'mousedown',
'pointermove',
'touchstart',
'wheel',
];
// Throttle activity-event resets to once per second to avoid thrashing the
// timer on rapid mouse movement.
const RESET_THROTTLE_MS = 1000;
export function useIdleAutoLock(): void {
const { session, signOut } = useAuth();
const minutesRef = useRef<AutoLockMinutes>(getAutoLockMinutes());
const timerRef = useRef<number | null>(null);
const lastResetAtRef = useRef<number>(0);
// Keep minutesRef live to the setting.
useEffect(() => {
const unsub = subscribeAutoLockSetting((v) => {
minutesRef.current = v;
scheduleNext();
});
return unsub;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Helper: schedule the lock based on the current setting.
function scheduleNext(): void {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
const min = minutesRef.current;
if (min === 0) return; // disabled
timerRef.current = window.setTimeout(() => {
timerRef.current = null;
// Fire the lock. signOut wipes local state and navigates to /device
// (PIN re-entry screen).
void signOut().catch((err) => console.warn('auto-lock signOut failed', err));
}, min * 60 * 1000);
}
useEffect(() => {
if (!session) return;
scheduleNext();
const onActivity = () => {
const now = Date.now();
if (now - lastResetAtRef.current < RESET_THROTTLE_MS) return;
lastResetAtRef.current = now;
scheduleNext();
};
for (const ev of ACTIVITY_EVENTS) {
window.addEventListener(ev, onActivity, { passive: true });
}
return () => {
for (const ev of ACTIVITY_EVENTS) {
window.removeEventListener(ev, onActivity);
}
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session]);
}
+101
View File
@@ -0,0 +1,101 @@
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) => {
// Optimistic: flip `revokedAt` on the row so the "Abgemeldet" badge
// appears on the same frame as the click. Capture a snapshot so we
// can restore exactly on RPC failure (the realtime subscription's
// own UPDATE echo would otherwise reconcile back to "not revoked"
// anyway). Skip if the row isn't in our list — nothing to undo.
let snapshot: DeviceRecord[] | null = null;
const stampedAt = new Date().toISOString();
setState((prev) => {
if (!prev.devices.some((d) => d.id === deviceId)) return prev;
snapshot = prev.devices;
return {
...prev,
devices: prev.devices.map((d) =>
d.id === deviceId ? { ...d, revokedAt: d.revokedAt ?? stampedAt } : d,
),
};
});
try {
await revokeDevice(supabase, deviceId);
// Skip the eager refresh: the realtime UPDATE on `devices` triggers
// refresh() via the subscription and the optimistic row already
// shows the badge. Avoids a list flicker between optimistic and
// canonical state.
} catch (err) {
if (snapshot) {
setState((prev) => ({ ...prev, devices: snapshot! }));
}
throw err;
}
},
[],
);
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,
};
}
+43
View File
@@ -0,0 +1,43 @@
// Per-install setting for PIN-idle-auto-lock. 0 = disabled.
// Values match the dropdown options (5/15/30/60 minutes).
const KEY = 'chatapp.autoLockMinutes.v1';
export type AutoLockMinutes = 0 | 5 | 15 | 30 | 60;
const VALID: AutoLockMinutes[] = [0, 5, 15, 30, 60];
export function getAutoLockMinutes(): AutoLockMinutes {
try {
const raw = window.localStorage.getItem(KEY);
if (!raw) return 0;
const n = Number(raw);
if (VALID.includes(n as AutoLockMinutes)) return n as AutoLockMinutes;
return 0;
} catch {
return 0;
}
}
type Listener = (value: AutoLockMinutes) => void;
const listeners = new Set<Listener>();
export function subscribeAutoLockSetting(l: Listener): () => void {
listeners.add(l);
return () => listeners.delete(l);
}
export function notifyAutoLockChanged(value: AutoLockMinutes): void {
for (const l of listeners) {
try { l(value); } catch (err) { console.warn(err); }
}
}
export function setAutoLockMinutes(value: AutoLockMinutes): void {
try {
window.localStorage.setItem(KEY, String(value));
} catch {
/* quota */
}
notifyAutoLockChanged(value);
}
+68 -1
View File
@@ -43,19 +43,86 @@ async function resizeToSquare(file: File): Promise<Blob> {
}
}
const MAX_ANIMATED_BYTES = 2 * 1024 * 1024; // 2 MB hard cap on animated uploads
const ANIMATED_MIMES = new Set(['image/gif', 'image/apng', 'image/webp', 'image/png']);
export async function uploadAvatar(userId: string, file: File): Promise<string> {
if (!file.type.startsWith('image/')) {
throw new Error('only image files are accepted');
}
// Animated formats bypass the canvas re-encode (which would strip
// animation by sampling the first frame). We still validate dimensions
// and size so a 40-MB animated WebP can't slip through.
if (ANIMATED_MIMES.has(file.type) && (await isAnimated(file))) {
if (file.size > MAX_ANIMATED_BYTES) {
throw new Error('animated avatar too large (max 2 MB)');
}
const dims = await readDimensions(file);
if (dims.width > MAX_DIM || dims.height > MAX_DIM) {
throw new Error('animated avatar exceeds ' + MAX_DIM + 'px (got ' + dims.width + 'x' + dims.height + ')');
}
return uploadAvatarBlob(userId, file);
}
const blob = await resizeToSquare(file);
return uploadAvatarBlob(userId, blob);
}
async function readDimensions(file: File): Promise<{ width: number; height: number }> {
const url = URL.createObjectURL(file);
try {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error('image load failed'));
i.src = url;
});
return { width: img.naturalWidth, height: img.naturalHeight };
} finally {
URL.revokeObjectURL(url);
}
}
async function isAnimated(file: File): Promise<boolean> {
// GIF: any GIF89a/GIF87a header is treated as potentially animated. The
// static-GIF case (one image-descriptor block) is rare enough that
// re-encoding wouldn't save much, so we accept the false-positives.
if (file.type === 'image/gif') return true;
// APNG: presence of an 'acTL' chunk inside the PNG stream. Scan the
// first 64 KB — APNGs put acTL near the front, before IDAT.
if (file.type === 'image/apng' || file.type === 'image/png') {
const head = await file.slice(0, 65536).arrayBuffer();
return containsBytes(head, [0x61, 0x63, 0x54, 0x4c]); // 'acTL'
}
// Animated WebP: 'ANIM' chunk in the RIFF container.
if (file.type === 'image/webp') {
const head = await file.slice(0, 65536).arrayBuffer();
return containsBytes(head, [0x41, 0x4e, 0x49, 0x4d]); // 'ANIM'
}
return false;
}
function containsBytes(buf: ArrayBuffer, needle: number[]): boolean {
const view = new Uint8Array(buf);
const len = view.length;
const nlen = needle.length;
outer: for (let i = 0; i + nlen <= len; i++) {
for (let j = 0; j < nlen; j++) {
if (view[i + j] !== needle[j]) continue outer;
}
return true;
}
return false;
}
// Upload an already-cropped Blob (e.g. from ImageCropDialog) without going
// through the legacy center-crop. Caller is responsible for sizing — the
// dialog already clamps to MAX_DIM via its outputWidth.
export async function uploadAvatarBlob(userId: string, blob: Blob): Promise<string> {
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg';
const ext =
blob.type === 'image/webp' ? 'webp' :
blob.type === 'image/gif' ? 'gif' :
blob.type === 'image/apng' || blob.type === 'image/png' ? 'png' :
'jpg';
// Random filename so old uploads don't get overwritten before we update
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
// also forces clients to fetch the new image.
+6 -2
View File
@@ -1,11 +1,15 @@
// In-app changelog feed.
//
// The release script (`scripts/release.mjs`) maintains a single
// `changelog.json` file alongside `latest.json` on update.netralax.cloud.
// `changelog.json` file alongside `latest.json` on update.netralax.de.
// The list is newest-first, capped at 200 entries server-side, and rewritten
// after every release.
//
// NOTE: already-installed clients still fetch this from update.netralax.cloud
// (baked into their bundle), so Caddy on the new VPS must keep serving the
// update.netralax.cloud vhost from the same directory during the transition.
const CHANGELOG_URL = 'https://update.netralax.cloud/windows/changelog.json';
const CHANGELOG_URL = 'https://update.netralax.de/windows/changelog.json';
export interface ChangelogEntry {
version: string;
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
__resetForTests,
clearDraft,
getDraftSync,
hasDraft,
hydrateDrafts,
setDraft,
} from './composerDraftStore';
const sqlExecuteMock = vi.fn().mockResolvedValue(undefined);
const sqlSelectMock = vi.fn().mockResolvedValue([]);
const sqlLoadMock = vi.fn().mockResolvedValue('mock-handle');
vi.stubGlobal('window', {
electronAPI: {
platform: 'electron-chatapp-v1',
sqlLoad: sqlLoadMock,
sqlExecute: sqlExecuteMock,
sqlSelect: sqlSelectMock,
},
});
describe('composerDraftStore', () => {
beforeEach(() => {
sqlExecuteMock.mockClear();
sqlSelectMock.mockClear();
sqlLoadMock.mockClear();
__resetForTests();
});
afterEach(() => {
__resetForTests();
});
it('returns null for an unknown conversation', () => {
expect(getDraftSync('unknown')).toBeNull();
expect(hasDraft('unknown')).toBe(false);
});
it('stores and returns a draft synchronously after set', () => {
setDraft('a', { text: 'hi', replyToId: null });
const draft = getDraftSync('a');
expect(draft).not.toBeNull();
expect(draft?.text).toBe('hi');
expect(draft?.replyToId).toBeNull();
expect(hasDraft('a')).toBe(true);
});
it('isolates drafts per conversation', () => {
setDraft('a', { text: 'one', replyToId: null });
setDraft('b', { text: 'two', replyToId: 'msg-9' });
expect(getDraftSync('a')?.text).toBe('one');
expect(getDraftSync('b')?.replyToId).toBe('msg-9');
});
it('clearDraft removes the draft from memory', () => {
setDraft('a', { text: 'one', replyToId: null });
clearDraft('a');
expect(getDraftSync('a')).toBeNull();
expect(hasDraft('a')).toBe(false);
});
it('treats an empty-string text + null reply as "no draft"', () => {
setDraft('a', { text: '', replyToId: null });
expect(getDraftSync('a')).toBeNull();
expect(hasDraft('a')).toBe(false);
});
it('hydrateDrafts populates the in-memory map from SQLite rows', async () => {
sqlSelectMock.mockResolvedValueOnce([
{ conversation_id: 'a', text: 'persisted', reply_to_id: 'msg-1', updated_at: '2026-05-17T00:00:00Z' },
]);
await hydrateDrafts();
expect(getDraftSync('a')?.text).toBe('persisted');
expect(getDraftSync('a')?.replyToId).toBe('msg-1');
});
});
+160
View File
@@ -0,0 +1,160 @@
// Composer-draft persistence. Two-tier semantics:
// * In-memory `Map<convId, Draft>` for instant synchronous reads on
// mount (mirrors the `messageMemoryCache` pattern from Phase 7).
// * SQLite (`composer_drafts` table, schema in `messageCache.ts`) for
// cross-restart persistence. Writes are debounced and fire-and-forget
// — losing the last 400ms of typing on a hard crash is acceptable;
// blocking the keystroke handler is not.
//
// Attachments are intentionally NOT serialized:
// * Files don't round-trip through SQLite cleanly (binary blobs blow
// up the cache size).
// * `replyToId` IS persisted; the consuming page looks up the actual
// message by id at render time.
import { isTauriRuntime } from './globalShortcut';
const DB_NAME = 'chatapp-cache';
const WRITE_DEBOUNCE_MS = 400;
interface Draft {
text: string;
replyToId: string | null;
}
interface DraftRow {
conversation_id: string;
text: string;
reply_to_id: string | null;
updated_at: string;
}
const drafts = new Map<string, Draft>();
const pendingWrites = new Map<string, ReturnType<typeof setTimeout>>();
let handlePromise: Promise<string | null> | null = null;
async function getHandle(): Promise<string | null> {
if (handlePromise) return handlePromise;
if (!isTauriRuntime()) {
handlePromise = Promise.resolve(null);
return handlePromise;
}
handlePromise = (async () => {
try {
const handle = await window.electronAPI.sqlLoad({ name: DB_NAME });
// Self-contained DDL — the same statement also runs from
// `messageCache.ts`'s init path, but we don't want to depend on
// call order. SQLite's `CREATE TABLE IF NOT EXISTS` is idempotent
// so the double-creation is safe.
await window.electronAPI.sqlExecute({
handle,
query:
`CREATE TABLE IF NOT EXISTS composer_drafts (
conversation_id TEXT PRIMARY KEY,
text TEXT NOT NULL,
reply_to_id TEXT,
updated_at TEXT NOT NULL
)`,
bindings: [],
});
return handle;
} catch (err: unknown) {
console.warn('composerDraftStore: sqlLoad failed', err);
return null;
}
})();
return handlePromise;
}
export function getDraftSync(conversationId: string): Draft | null {
const stored = drafts.get(conversationId);
if (!stored) return null;
return { text: stored.text, replyToId: stored.replyToId };
}
export function hasDraft(conversationId: string): boolean {
return drafts.has(conversationId);
}
export function setDraft(conversationId: string, draft: Draft): void {
if (draft.text.length === 0 && draft.replyToId === null) {
if (drafts.has(conversationId)) {
drafts.delete(conversationId);
scheduleWrite(conversationId);
}
return;
}
drafts.set(conversationId, { text: draft.text, replyToId: draft.replyToId });
scheduleWrite(conversationId);
}
export function clearDraft(conversationId: string): void {
if (!drafts.has(conversationId)) return;
drafts.delete(conversationId);
scheduleWrite(conversationId);
}
function scheduleWrite(conversationId: string): void {
const existing = pendingWrites.get(conversationId);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
pendingWrites.delete(conversationId);
void flushOne(conversationId);
}, WRITE_DEBOUNCE_MS);
pendingWrites.set(conversationId, timer);
}
async function flushOne(conversationId: string): Promise<void> {
const handle = await getHandle();
if (!handle) return;
const draft = drafts.get(conversationId);
try {
if (draft) {
await window.electronAPI.sqlExecute({
handle,
query:
`INSERT INTO composer_drafts (conversation_id, text, reply_to_id, updated_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT(conversation_id) DO UPDATE SET
text = excluded.text,
reply_to_id = excluded.reply_to_id,
updated_at = excluded.updated_at`,
bindings: [conversationId, draft.text, draft.replyToId, new Date().toISOString()],
});
} else {
await window.electronAPI.sqlExecute({
handle,
query: 'DELETE FROM composer_drafts WHERE conversation_id = $1',
bindings: [conversationId],
});
}
} catch (err: unknown) {
console.warn('composerDraftStore: flush failed', err);
}
}
export async function hydrateDrafts(): Promise<void> {
const handle = await getHandle();
if (!handle) return;
try {
const rows = (await window.electronAPI.sqlSelect({
handle,
query: 'SELECT conversation_id, text, reply_to_id, updated_at FROM composer_drafts',
bindings: [],
})) as unknown as DraftRow[];
for (const r of rows) {
if (!r.conversation_id || typeof r.text !== 'string') continue;
if (r.text.length === 0 && r.reply_to_id === null) continue;
drafts.set(r.conversation_id, { text: r.text, replyToId: r.reply_to_id });
}
} catch (err: unknown) {
console.warn('composerDraftStore: hydrate failed', err);
}
}
export function __resetForTests(): void {
for (const t of pendingWrites.values()) clearTimeout(t);
pendingWrites.clear();
drafts.clear();
handlePromise = null;
}
@@ -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[],
+37 -36
View File
@@ -1,16 +1,14 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { type OwnDeviceCtx, shareConvKeyToDevice } from '@chat-app/shared/chat';
import { type OwnUserCtx, shareConvKeyToUser } from '@chat-app/shared/chat';
import { pgHexToBytes } from '@chat-app/shared/supabase';
import { devLocalSecretStore } from './secretStore';
import { supabase } from './supabase';
// Watches the `devices` table for new entries AND, on mount, scans every
// conversation we participate in for missing key bundles. Fills gaps by
// re-wrapping our active conv-key for the missing recipient devices.
// re-wrapping our active conv-key for the missing recipient users.
//
// This fixes the "cannot decrypt" cliff for devices that registered while
// no other participant device was online to share the key with them.
// This fixes the "cannot decrypt" cliff for users that joined while no
// other participant was online to share the key with them.
export interface SyncCtx {
myUserId: string;
@@ -57,8 +55,6 @@ export function startConversationKeySync(
void ownUserId;
void ownDeviceId;
void backfilledKey;
void loadDevicePrivateKey;
void devLocalSecretStore;
void supabase;
return () => {};
}
@@ -70,10 +66,10 @@ export function startConversationKeySync(
// `wrapForOneDevice` and `syncOneConversationGaps` are exported below for
// the device-approval module — once the user explicitly approves a new
// device the approval flow re-uses these helpers to wrap conv-keys for
// that specific deviceId.
// that specific user.
void (() => {
void listMyConversationIds;
void listConversationDevices;
void listConversationMembers;
void listExistingKeyRecipients;
void getActiveKeyVersion;
void syncAllExistingGaps;
@@ -94,9 +90,9 @@ async function listMyConversationIds(myUserId: string): Promise<string[]> {
return (data ?? []).map((r) => r.conversation_id as string);
}
async function listConversationDevices(
async function listConversationMembers(
conversationId: string,
): Promise<{ id: string; user_id: string; public_key: string }[]> {
): Promise<{ user_id: string; public_key: string }[]> {
const { data: members, error: mErr } = await supabase
.from('conversation_members')
.select('user_id')
@@ -109,15 +105,14 @@ async function listConversationDevices(
const userIds = (members ?? []).map((m) => m.user_id as string);
if (userIds.length === 0) return [];
const { data: devices, error: dErr } = await supabase
.from('devices')
.select('id, user_id, public_key')
const { data: keys, error: kErr } = await rawFrom('user_keys')
.select('user_id, public_key')
.in('user_id', userIds);
if (dErr) {
console.warn('keySync: devices lookup failed', dErr);
if (kErr) {
console.warn('keySync: user-keys lookup failed', kErr);
return [];
}
return (devices ?? []) as { id: string; user_id: string; public_key: string }[];
return (keys ?? []) as { user_id: string; public_key: string }[];
}
async function listExistingKeyRecipients(
@@ -125,14 +120,14 @@ async function listExistingKeyRecipients(
keyVersion: number,
): Promise<Set<string>> {
const { data, error } = await rawFrom('conversation_keys')
.select('recipient_device_id')
.select('recipient_user_id')
.eq('conversation_id', conversationId)
.eq('key_version', keyVersion);
if (error) {
console.warn('keySync: existing keys lookup failed', error);
return new Set();
}
return new Set((data ?? []).map((r: { recipient_device_id: string }) => r.recipient_device_id));
return new Set((data ?? []).map((r: { recipient_user_id: string }) => r.recipient_user_id));
}
async function getActiveKeyVersion(conversationId: string): Promise<number> {
@@ -160,33 +155,32 @@ async function syncAllExistingGaps(ctx: SyncCtx): Promise<void> {
export async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
const version = await getActiveKeyVersion(convId);
const devices = await listConversationDevices(convId);
if (devices.length === 0) return;
const members = await listConversationMembers(convId);
if (members.length === 0) return;
const recipients = await listExistingKeyRecipients(convId, version);
const ownCtx: OwnDeviceCtx = {
const ownCtx: OwnUserCtx = {
userId: ctx.myUserId,
deviceId: ctx.myDeviceId,
privateKey: ctx.priv,
};
for (const dev of devices) {
if (recipients.has(dev.id)) continue;
// Skip our own device — we already have the bundle if we're capable of
for (const m of members) {
if (recipients.has(m.user_id)) continue;
// Skip our own user — we already have the bundle if we're capable of
// sharing (or don't need it if we ourselves haven't been wrapped yet).
if (dev.id === ctx.myDeviceId) continue;
if (m.user_id === ctx.myUserId) continue;
try {
await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx);
await shareConvKeyToUser(supabase, convId, m.user_id, pgHexToBytes(m.public_key), ownCtx);
} catch (err: unknown) {
// Backfill is best-effort. Most common silent failures:
// - tryGetConvKey couldn't unwrap (another peer will fill the gap).
// - RLS rejects because the recipient's owner is a pending (not-yet
// - RLS rejects because the recipient is a pending (not-yet
// accepted) DM member, or was removed from the conv.
// Both are recoverable / expected, swallow without spam.
if (isExpectedShareFailure(err)) continue;
console.warn('keySync: shareConvKeyToDevice gap-fill failed', {
console.warn('keySync: shareConvKeyToUser gap-fill failed', {
convId,
recipient: dev.id,
recipient: m.user_id,
err,
});
}
@@ -209,12 +203,20 @@ function isExpectedShareFailure(err: unknown): boolean {
);
}
// Approval-flow helper. The legacy signature took (deviceId, userId,
// devicePubHex) because conv-keys were wrapped per-device. In the per-user
// model only the user dimension matters, so the device-id parameter is
// ignored and the public key passed in MUST be the recipient user's
// user_keys.public_key (callers will be updated alongside the broader
// approval-flow rework).
export async function wrapForOneDevice(
ctx: SyncCtx,
newDeviceId: string,
newDeviceUserId: string,
newDevicePubHex: string,
): Promise<void> {
void newDeviceId; // kept for API compat; no longer used
const myConvs = new Set(await listMyConversationIds(ctx.myUserId));
const { data: peerMember, error: pErr } = await supabase
.from('conversation_members')
@@ -230,16 +232,15 @@ export async function wrapForOneDevice(
if (sharedConvs.length === 0) return;
const newPub = pgHexToBytes(newDevicePubHex);
const ownCtx: OwnDeviceCtx = {
const ownCtx: OwnUserCtx = {
userId: ctx.myUserId,
deviceId: ctx.myDeviceId,
privateKey: ctx.priv,
};
for (const convId of sharedConvs) {
try {
await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx);
await shareConvKeyToUser(supabase, convId, newDeviceUserId, newPub, ownCtx);
} catch (err: unknown) {
console.warn('keySync: shareConvKeyToDevice failed', { convId, err });
console.warn('keySync: shareConvKeyToUser failed', { convId, err });
}
}
}
+8
View File
@@ -25,5 +25,13 @@ export async function createLibsodiumBackend(): Promise<CryptoBackend> {
s.crypto_box_open_easy(ciphertext, nonce, senderPublicKey, recipientPrivateKey),
secretbox: (plaintext, nonce, key) => s.crypto_secretbox_easy(plaintext, nonce, key),
secretboxOpen: (ciphertext, nonce, key) => s.crypto_secretbox_open_easy(ciphertext, nonce, key),
pwhashConsts: {
OPSLIMIT_MODERATE: s.crypto_pwhash_OPSLIMIT_MODERATE,
MEMLIMIT_MODERATE: s.crypto_pwhash_MEMLIMIT_MODERATE,
ALG_ARGON2ID13: s.crypto_pwhash_ALG_ARGON2ID13,
},
pwhash: (outLen, password, salt, opslimit, memlimit, alg) =>
s.crypto_pwhash(outLen, password, salt, opslimit, memlimit, alg),
scalarMultBase: (priv) => s.crypto_scalarmult_base(priv),
};
}
+73
View File
@@ -0,0 +1,73 @@
// Main-thread wrapper around the crypto Web Worker (Argon2id pwhash +
// sealed user-key open). Each unlock attempt spawns a fresh one-shot worker
// — workers are cheap and the pwhash is a one-time cost per login, so we
// avoid the bookkeeping needed for a persistent request queue.
//
// Falls back to inline (main-thread) `openUserKey` when the `Worker`
// constructor is unavailable (e.g. vitest's jsdom environment, strict CSPs).
// The fallback path is identical in semantics to the worker path; the only
// difference is whether it blocks the main thread.
//
// Why not a long-lived worker? The KDF cost dwarfs the spawn cost (~1-2 s
// vs. a few ms), and PIN-unlock happens at most once per session. Keeping a
// worker resident would also require a request-id correlation map which the
// decrypt.worker uses (because per-message decrypts are high-volume).
import { openUserKey } from '@chat-app/shared/crypto';
import type {
OpenUserKeyInput,
OpenUserKeyResult,
} from '../workers/crypto.worker';
type WorkerResponse =
| { ok: true; result: OpenUserKeyResult }
| { ok: false; error: string };
export type { OpenUserKeyInput, OpenUserKeyResult };
export async function openUserKeyInWorker(
input: OpenUserKeyInput,
): Promise<OpenUserKeyResult> {
const worker = new Worker(
new URL('../workers/crypto.worker.ts', import.meta.url),
{ type: 'module' },
);
try {
return await new Promise<OpenUserKeyResult>((resolve, reject) => {
worker.addEventListener('message', (ev: MessageEvent<WorkerResponse>) => {
const msg = ev.data;
if (msg && msg.ok) resolve(msg.result);
else reject(new Error(msg?.error ?? 'crypto worker returned malformed response'));
});
worker.addEventListener('error', (ev: ErrorEvent) => {
reject(new Error(ev.message || 'crypto worker error'));
});
worker.postMessage({ op: 'openUserKey', input });
});
} finally {
worker.terminate();
}
}
// Public entry point: route through the worker when possible, fall back to
// the synchronous-on-main-thread path otherwise. Callers should prefer this
// over importing `openUserKey` directly so we get the worker speedup
// everywhere it's available.
export async function openUserKeyMaybeWorker(
input: OpenUserKeyInput,
): Promise<Uint8Array> {
if (typeof Worker === 'undefined') {
return openUserKey(input);
}
try {
const result = await openUserKeyInWorker(input);
return result.privateKey;
} catch (err) {
// If the worker spawn or message round-trip fails (e.g. CSP blocks
// module workers in some packaging modes), fall back to inline so the
// unlock still succeeds — just with a brief main-thread hitch.
console.warn('[cryptoWorker] worker path failed, falling back to inline', err);
return openUserKey(input);
}
}
-73
View File
@@ -1,73 +0,0 @@
import {
deviceIdStorageKey,
listOwnDevices,
loadDevicePrivateKey,
provisionNewDevice,
touchDeviceLastSeen,
type DeviceRecord,
} from '@chat-app/shared/auth';
import type { DevicePlatform } from '@chat-app/shared/supabase';
import { devLocalSecretStore } from './secretStore';
import { supabase } from './supabase';
export function detectDesktopPlatform(): DevicePlatform {
const ua =
typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
? navigator.userAgent.toLowerCase()
: '';
if (ua.includes('mac')) return 'macos';
if (ua.includes('win')) return 'windows';
return 'linux';
}
export function readLocalDeviceId(userId: string): string | null {
return window.localStorage.getItem(deviceIdStorageKey(userId));
}
export function writeLocalDeviceId(userId: string, deviceId: string): void {
window.localStorage.setItem(deviceIdStorageKey(userId), deviceId);
}
export function clearLocalDeviceId(userId: string): void {
window.localStorage.removeItem(deviceIdStorageKey(userId));
}
// Look up the current install's device record. Returns null when either:
// - no device id is cached locally, or
// - the cached id was deleted server-side (e.g. wiped from Studio).
// In both cases the UI should prompt the user to register a fresh device.
export async function findExistingDevice(userId: string): Promise<DeviceRecord | null> {
const cachedId = readLocalDeviceId(userId);
if (!cachedId) return null;
const all = await listOwnDevices(supabase);
const hit = all.find((d) => d.id === cachedId) ?? null;
if (!hit) return null;
const priv = await loadDevicePrivateKey(devLocalSecretStore, userId, hit.id);
if (!priv) {
// Server row exists but we lost the private key locally — treat as fresh install.
return null;
}
void touchDeviceLastSeen(supabase, hit.id).catch(() => {
/* non-fatal */
});
return hit;
}
export async function registerCurrentDevice(params: {
userId: string;
name: string;
}): Promise<DeviceRecord> {
const device = await provisionNewDevice({
client: supabase,
secretStore: devLocalSecretStore,
userId: params.userId,
name: params.name,
platform: detectDesktopPlatform(),
});
writeLocalDeviceId(params.userId, device.id);
return device;
}
-198
View File
@@ -1,198 +0,0 @@
import { getCryptoBackend } from '@chat-app/shared/crypto';
import sodium from 'libsodium-wrappers-sumo';
import { pwhashArgon2id } from './nativeCryptoOps';
// Encrypts/decrypts the device private key with a user-provided passphrase
// so the backup string can be safely written down or stored in a password
// manager. Uses Argon2id (libsodium crypto_pwhash) for the KDF and
// XSalsa20-Poly1305 (crypto_secretbox) for the AEAD.
//
// Backup format (base64url-encoded blob, prefixed with a magic string so we
// can version it):
//
// chatapp-backup-v1.<base64url(salt(16) | nonce(24) | ciphertext)>
const MAGIC = 'chatapp-backup-v1.';
const SALT_LEN = 16; // crypto_pwhash_SALTBYTES
const NONCE_LEN = 24; // crypto_secretbox_NONCEBYTES
const KEY_LEN = 32; // crypto_secretbox_KEYBYTES
async function ensureSodium(): Promise<typeof sodium> {
await sodium.ready;
return sodium;
}
function b64url(bytes: Uint8Array): string {
let s = '';
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function unb64url(s: string): Uint8Array {
let str = s.replace(/-/g, '+').replace(/_/g, '/');
while (str.length % 4) str += '=';
const bin = atob(str);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function deriveKey(passphrase: string, salt: Uint8Array, _sodiumLib: typeof sodium): Promise<Uint8Array> {
return pwhashArgon2id({
password: passphrase,
salt,
outLen: KEY_LEN,
preset: 'moderate',
});
}
export async function exportDeviceKey(
privateKey: Uint8Array,
passphrase: string,
): Promise<string> {
if (passphrase.length < 8) throw new Error('Passphrase must be at least 8 characters.');
const s = await ensureSodium();
const salt = s.randombytes_buf(SALT_LEN);
const nonce = s.randombytes_buf(NONCE_LEN);
const key = await deriveKey(passphrase, salt, s);
const backend = getCryptoBackend();
const ciphertext = backend.secretbox(privateKey, nonce, key);
s.memzero(key);
const blob = new Uint8Array(SALT_LEN + NONCE_LEN + ciphertext.length);
blob.set(salt, 0);
blob.set(nonce, SALT_LEN);
blob.set(ciphertext, SALT_LEN + NONCE_LEN);
return MAGIC + b64url(blob);
}
export async function importDeviceKey(
backup: string,
passphrase: string,
): Promise<Uint8Array> {
if (!backup.startsWith(MAGIC)) {
throw new Error('Invalid backup format');
}
const blob = unb64url(backup.slice(MAGIC.length));
if (blob.length < SALT_LEN + NONCE_LEN + 1) {
throw new Error('Backup too short');
}
const salt = blob.slice(0, SALT_LEN);
const nonce = blob.slice(SALT_LEN, SALT_LEN + NONCE_LEN);
const ciphertext = blob.slice(SALT_LEN + NONCE_LEN);
const s = await ensureSodium();
const key = await deriveKey(passphrase, salt, s);
const backend = getCryptoBackend();
try {
return backend.secretboxOpen(ciphertext, nonce, key);
} catch {
throw new Error('Wrong passphrase or corrupt backup');
} finally {
s.memzero(key);
}
}
// Full-device backup. Wraps userId + deviceId + privateKey in a JSON payload
// before encrypting, so a restore flow can re-seed localStorage + vault + server
// device row without requiring the user to remember IDs.
export interface DeviceBackupPayload {
v: 2;
userId: string;
deviceId: string;
privateKeyB64: string;
}
export async function exportDeviceBackup(
params: {
userId: string;
deviceId: string;
privateKey: Uint8Array;
passphrase: string;
},
): Promise<string> {
const payload: DeviceBackupPayload = {
v: 2,
userId: params.userId,
deviceId: params.deviceId,
privateKeyB64: b64url(params.privateKey),
};
const bytes = new TextEncoder().encode(JSON.stringify(payload));
return exportDeviceKey(bytes, params.passphrase);
}
export async function importDeviceBackup(
backup: string,
passphrase: string,
): Promise<DeviceBackupPayload> {
const plain = await importDeviceKey(backup, passphrase);
const text = new TextDecoder().decode(plain);
try {
const obj = JSON.parse(text) as DeviceBackupPayload;
if (obj && obj.v === 2 && obj.userId && obj.deviceId && obj.privateKeyB64) {
return obj;
}
} catch {
/* fall through */
}
throw new Error('Backup format not supported — v2 expected');
}
export function decodePrivateKeyFromBackup(payload: DeviceBackupPayload): Uint8Array {
return unb64url(payload.privateKeyB64);
}
// ---------------------------------------------------------------------------
// Recovery code
// ---------------------------------------------------------------------------
//
// Generates a high-entropy code shown to the user once at backup time. The
// same payload is encrypted twice — once with the user's passphrase, once
// with the recovery code — so either string can decrypt the device key.
//
// The recovery code is 24 chars from a 32-symbol alphabet (no ambiguous
// characters), grouped as 4×6. ~120 bits of entropy.
const RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
export interface BackupBundle {
passphraseBackup: string;
recoveryBackup: string;
recoveryCode: string;
}
export async function exportDeviceBackupWithRecovery(params: {
userId: string;
deviceId: string;
privateKey: Uint8Array;
passphrase: string;
}): Promise<BackupBundle> {
const recoveryCode = await generateRecoveryCode();
const [passphraseBackup, recoveryBackup] = await Promise.all([
exportDeviceBackup(params),
exportDeviceBackup({ ...params, passphrase: recoveryCode }),
]);
return { passphraseBackup, recoveryBackup, recoveryCode };
}
async function generateRecoveryCode(): Promise<string> {
const s = await ensureSodium();
const raw = s.randombytes_buf(24);
let out = '';
for (let i = 0; i < raw.length; i++) {
out += RECOVERY_ALPHABET[raw[i]! % RECOVERY_ALPHABET.length];
if ((i + 1) % 6 === 0 && i !== raw.length - 1) out += '-';
}
return out;
}
// Normalizes a user-typed recovery code: strips dashes/spaces, uppercases,
// maps look-alike characters. Lets users enter the code with imperfect
// spacing without rejecting valid input.
export function normalizeRecoveryCode(input: string): string {
return input
.toUpperCase()
.replace(/[\s-]/g, '')
.split('')
.filter((c) => RECOVERY_ALPHABET.includes(c))
.join('');
}
+30
View File
@@ -0,0 +1,30 @@
// localStorage key for "the devices.id row that belongs to THIS install".
// Reset on memory-wipe (NOT preserved) — a wiped install is conceptually a
// fresh install, so registering a new row is correct.
const KEY = 'chatapp.deviceRowId.v1';
export function getDeviceRowId(): string | null {
try {
const v = window.localStorage.getItem(KEY);
return v && v.length > 0 ? v : null;
} catch {
return null;
}
}
export function setDeviceRowId(id: string): void {
try {
window.localStorage.setItem(KEY, id);
} catch {
/* quota */
}
}
export function clearDeviceRowId(): void {
try {
window.localStorage.removeItem(KEY);
} catch {
/* no-op */
}
}
+64
View File
@@ -0,0 +1,64 @@
import { useSyncExternalStore } from 'react';
// Local-only friend nickname overrides. Stored in localStorage keyed by the
// peer's user-id. Empty/missing value = use the real display name.
// Local-only by design — friends never see your nickname for them.
const STORAGE_KEY = 'chatapp.friendNicknames.v1';
let cache: Record<string, string> | null = null;
const listeners = new Set<() => void>();
function load(): Record<string, string> {
if (cache) return cache;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) { cache = {}; return cache; }
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === 'object') {
cache = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string' && v.trim().length > 0) cache[k] = v;
}
return cache;
}
} catch { /* corrupted; fall through */ }
cache = {};
return cache;
}
function persist(): void {
try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cache ?? {})); }
catch { /* quota / private mode */ }
for (const l of listeners) l();
}
export function getNickname(userId: string): string | null {
return load()[userId] ?? null;
}
export function setNickname(userId: string, nickname: string | null): void {
const store = load();
const trimmed = nickname?.trim() ?? '';
if (trimmed.length === 0) {
if (!(userId in store)) return;
delete store[userId];
} else {
if (store[userId] === trimmed) return;
store[userId] = trimmed;
}
persist();
}
// Reactive hook: returns the current nickname for a user, or `fallback`
// when no nickname is set. Re-renders when ANY nickname changes (cheap,
// the set is small).
export function useNickname(userId: string | null | undefined, fallback: string): string {
const subscribe = (cb: () => void) => {
listeners.add(cb);
return () => { listeners.delete(cb); };
};
const getSnapshot = () => (userId ? getNickname(userId) : null);
const nickname = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return nickname ?? fallback;
}
+48
View File
@@ -55,3 +55,51 @@ function renameToWebp(original: string): string {
export async function compressImages(files: File[]): Promise<File[]> {
return Promise.all(files.map((f) => compressImage(f)));
}
// Bandwidth threshold for thumb generation. Below ~50KB the WebP overhead
// of a fresh re-encode can exceed the original; not worth a second upload.
const THUMB_SKIP_BELOW_BYTES = 50 * 1024;
const THUMB_MAX_DIM = 320;
const THUMB_QUALITY = 0.7;
// Animated formats lose motion when redrawn onto a Canvas, so we skip them
// and let the receiver render the full file. GIF is the dominant case; the
// rest stay too (apng/animated-webp).
const THUMB_ANIMATED_MIME = /^image\/(gif|apng)$/;
// Generates a small WebP preview thumb (max 320×320) from an image file.
// Used by the send path so each image attachment can ship a tiny inline
// preview alongside the encrypted full blob. Returns `null` when:
// - the input isn't an image,
// - the input is animated (GIF/APNG — would lose motion),
// - the input is already small enough that a thumb wouldn't save bandwidth,
// - OffscreenCanvas / createImageBitmap aren't available, or
// - decode/encode threw (corrupt input).
// The caller treats `null` as "skip thumb" and uploads only the full blob.
export async function generateWebPThumb(
file: File,
maxDim: number = THUMB_MAX_DIM,
): Promise<Blob | null> {
if (!file.type.startsWith('image/')) return null;
if (THUMB_ANIMATED_MIME.test(file.type)) return null;
if (file.size < THUMB_SKIP_BELOW_BYTES) return null;
if (typeof createImageBitmap !== 'function') return null;
if (typeof OffscreenCanvas !== 'function') return null;
try {
const bitmap = await createImageBitmap(file);
const ratio = Math.min(maxDim / bitmap.width, maxDim / bitmap.height, 1);
const w = Math.max(1, Math.round(bitmap.width * ratio));
const h = Math.max(1, Math.round(bitmap.height * ratio));
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext('2d');
if (!ctx) {
bitmap.close();
return null;
}
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
return await canvas.convertToBlob({ type: 'image/webp', quality: THUMB_QUALITY });
} catch (err: unknown) {
console.warn('generateWebPThumb failed', err);
return null;
}
}
+19
View File
@@ -0,0 +1,19 @@
// Per-install browser identifier. Replaces the legacy per-device crypto
// identity for non-crypto bookkeeping (push tokens, message sender_device_id
// metadata) where we just need a stable, opaque id for this browser/install.
//
// The user-key model has no device-bound crypto; the `devices` table and
// `messages.sender_device_id` column still exist but they're now plain
// telemetry. Using a localStorage UUID keeps the column populated without
// any of the old key-management overhead.
const KEY = 'chatapp.installId';
export function ensureInstallId(): string {
let id = window.localStorage.getItem(KEY);
if (!id) {
id = crypto.randomUUID();
window.localStorage.setItem(KEY, id);
}
return id;
}
-119
View File
@@ -1,119 +0,0 @@
// Discord-style live captions. Uses the browser's SpeechRecognition API to
// transcribe the LOCAL user's mic, then broadcasts each interim/final result
// to peers via the LiveKit DataChannel. Receivers store and display them.
//
// Privacy note: speech recognition runs in the browser. On Chromium-based
// runtimes (incl. Tauri's WebView2 on Windows) this calls into the browser's
// own engine, which today reaches Google's cloud — same trade-off as Discord.
// We ship a hard off switch and require an explicit user toggle.
const STORAGE_KEY = 'chatapp.liveCaptions.v1';
export interface LiveCaptionsSettings {
enabled: boolean;
/** BCP-47 language tag, e.g. "de-DE" or "en-US". Auto-detect uses navigator.language. */
lang: string | null;
}
const DEFAULTS: LiveCaptionsSettings = {
enabled: false,
lang: null,
};
type Listener = (s: LiveCaptionsSettings) => void;
const listeners = new Set<Listener>();
let cached: LiveCaptionsSettings | null = null;
function read(): LiveCaptionsSettings {
if (cached) return cached;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
cached = DEFAULTS;
return cached;
}
const parsed = JSON.parse(raw) as Partial<LiveCaptionsSettings>;
cached = {
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULTS.enabled,
lang:
typeof parsed.lang === 'string' && parsed.lang.length > 0
? parsed.lang
: DEFAULTS.lang,
};
return cached;
} catch {
cached = DEFAULTS;
return cached;
}
}
function write(s: LiveCaptionsSettings): void {
cached = s;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} catch {
/* quota / private mode */
}
for (const l of listeners) l(s);
}
export function getLiveCaptionsSettings(): LiveCaptionsSettings {
return read();
}
export function updateLiveCaptionsSettings(
patch: Partial<LiveCaptionsSettings>,
): LiveCaptionsSettings {
const next = { ...read(), ...patch };
write(next);
return next;
}
export function subscribeLiveCaptionsSettings(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
// Browser feature-detect. Chromium ships under both names; Firefox lacks it
// outright. Returns the constructor or null.
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
interface SpeechRecognitionLike extends EventTarget {
continuous: boolean;
interimResults: boolean;
lang: string;
start: () => void;
stop: () => void;
abort: () => void;
onresult: ((e: SpeechRecognitionEventLike) => void) | null;
onerror: ((e: SpeechRecognitionErrorLike) => void) | null;
onend: (() => void) | null;
}
interface SpeechRecognitionEventLike {
resultIndex: number;
results: ArrayLike<{
isFinal: boolean;
[index: number]: { transcript: string };
length: number;
}>;
}
interface SpeechRecognitionErrorLike {
error: string;
}
export function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null {
const w = window as unknown as {
SpeechRecognition?: SpeechRecognitionCtor;
webkitSpeechRecognition?: SpeechRecognitionCtor;
};
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
}
export function isLiveCaptionsSupported(): boolean {
return getSpeechRecognitionCtor() !== null;
}
export type {
SpeechRecognitionLike,
SpeechRecognitionEventLike,
SpeechRecognitionErrorLike,
};
+56
View File
@@ -0,0 +1,56 @@
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',
'chatapp.autoLockMinutes.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 */
}
}
Binary file not shown.
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it } from 'vitest';
import type { DecryptedMessage } from '@chat-app/shared/chat';
import {
__resetForTests,
getCachedMessages,
hasCachedMessages,
setCachedMessages,
} from './messageMemoryCache';
function msg(id: string): DecryptedMessage {
return {
id,
conversationId: 'conv-1',
senderId: 'sender-1',
senderDeviceId: null,
replyToId: null,
editedAt: null,
deletedAt: null,
createdAt: '2026-05-17T00:00:00Z',
plaintext: 'hi ' + id,
};
}
describe('messageMemoryCache', () => {
afterEach(() => {
__resetForTests();
});
it('returns empty array when nothing is cached', () => {
expect(getCachedMessages('unknown')).toEqual([]);
expect(hasCachedMessages('unknown')).toBe(false);
});
it('stores and returns messages per conversation', () => {
setCachedMessages('a', [msg('m1'), msg('m2')]);
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1', 'm2']);
expect(hasCachedMessages('a')).toBe(true);
});
it('isolates conversations', () => {
setCachedMessages('a', [msg('m1')]);
setCachedMessages('b', [msg('m9')]);
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1']);
expect(getCachedMessages('b').map((m) => m.id)).toEqual(['m9']);
});
it('overwrites prior cache when set again', () => {
setCachedMessages('a', [msg('m1')]);
setCachedMessages('a', [msg('m1'), msg('m2')]);
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1', 'm2']);
});
it('treats an explicit empty list as "cached"', () => {
// A conversation that genuinely has zero messages should still be
// flagged as cached so the hook skips the loading spinner on re-entry.
setCachedMessages('a', []);
expect(hasCachedMessages('a')).toBe(true);
expect(getCachedMessages('a')).toEqual([]);
});
});
@@ -0,0 +1,37 @@
// In-memory cache of the most-recently-rendered messages for each
// conversation. Survives React component unmount/remount (used by
// `useConversationMessages` to initialize state synchronously when
// ConversationPage is remounted on chat switch). Session-scoped — lost
// on full app reload. The SQLite cache (`messageCache.ts`) is still the
// source of truth for cross-session persistence; this layer just shaves
// off the round-trip-to-disk spinner flash.
//
// Two-tier semantics:
// * `hasCachedMessages(id)` returns true even for a known-empty chat
// so the hook can suppress the loading spinner on re-entry.
// * `getCachedMessages(id)` returns a defensive copy so callers can't
// mutate the cached array.
import type { DecryptedMessage } from '@chat-app/shared/chat';
const cache = new Map<string, DecryptedMessage[]>();
export function getCachedMessages(conversationId: string): DecryptedMessage[] {
const stored = cache.get(conversationId);
return stored ? stored.slice() : [];
}
export function hasCachedMessages(conversationId: string): boolean {
return cache.has(conversationId);
}
export function setCachedMessages(
conversationId: string,
messages: DecryptedMessage[],
): void {
cache.set(conversationId, messages.slice());
}
export function __resetForTests(): void {
cache.clear();
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { isNearBottom, isNearTop, resolveInitialAnchor } from './scrollController';
const m = (scrollTop: number, scrollHeight: number, clientHeight: number) => ({
scrollTop,
scrollHeight,
clientHeight,
});
describe('isNearBottom', () => {
it('true exactly at the bottom', () => {
expect(isNearBottom(m(900, 1000, 100), 64)).toBe(true);
});
it('true within threshold', () => {
expect(isNearBottom(m(860, 1000, 100), 64)).toBe(true);
});
it('false beyond threshold', () => {
expect(isNearBottom(m(800, 1000, 100), 64)).toBe(false);
});
});
describe('isNearTop', () => {
it('true at top', () => {
expect(isNearTop(m(0, 1000, 100), 64)).toBe(true);
});
it('false past threshold', () => {
expect(isNearTop(m(200, 1000, 100), 64)).toBe(false);
});
});
describe('resolveInitialAnchor', () => {
it('anchors to last row at end by default (no saved position)', () => {
expect(resolveInitialAnchor(null, 50)).toEqual({ index: 49, align: 'end' });
});
it('anchors to bottom when saved position stuck to bottom', () => {
expect(resolveInitialAnchor({ topmostIndex: 10, stickToBottom: true }, 50)).toEqual({
index: 49,
align: 'end',
});
});
it('restores the saved row at the top when scrolled up', () => {
expect(resolveInitialAnchor({ topmostIndex: 12, stickToBottom: false }, 50)).toEqual({
index: 12,
align: 'start',
});
});
it('clamps a stale saved index to the current row count', () => {
expect(resolveInitialAnchor({ topmostIndex: 999, stickToBottom: false }, 50)).toEqual({
index: 49,
align: 'start',
});
});
it('handles an empty list', () => {
expect(resolveInitialAnchor(null, 0)).toEqual({ index: 0, align: 'end' });
});
});
+43
View File
@@ -0,0 +1,43 @@
// Pure, DOM-free scroll-decision logic for MessageList. Unit-tested so the
// tricky math is verified without a browser (jsdom has no layout).
export interface ScrollMetrics {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}
/** Distance from the bottom edge is within `threshold` px. */
export function isNearBottom(m: ScrollMetrics, threshold: number): boolean {
return m.scrollHeight - (m.scrollTop + m.clientHeight) <= threshold;
}
/** Scroll offset is within `threshold` px of the top. */
export function isNearTop(m: ScrollMetrics, threshold: number): boolean {
return m.scrollTop <= threshold;
}
export interface SavedPosition {
topmostIndex: number;
stickToBottom: boolean;
}
export interface Anchor {
index: number;
align: 'start' | 'end';
}
/**
* Where a freshly-opened chat should start.
* - default / "left at bottom" last row, aligned to the viewport bottom.
* - "left scrolled up" the saved top-most row, aligned to the viewport top
* (clamped in case the cached row count shrank).
*/
export function resolveInitialAnchor(saved: SavedPosition | null, rowCount: number): Anchor {
if (rowCount <= 0) return { index: 0, align: 'end' };
if (saved && !saved.stickToBottom) {
const index = Math.max(0, Math.min(saved.topmostIndex, rowCount - 1));
return { index, align: 'start' };
}
return { index: rowCount - 1, align: 'end' };
}
@@ -0,0 +1,44 @@
// Plays a soundboard entry to the local default audio output. Used when no
// call pipeline is active (the in-call path routes via the LiveKit
// publishing pipeline so peers hear; this path is local-only). Fetches the
// blob via getSoundBlob and creates a short-lived object URL for the audio
// element.
import { getSoundBlob, type SoundboardEntry } from './soundboardStorage';
const activeAudios = new Set<HTMLAudioElement>();
export async function playSoundboardLocal(entry: SoundboardEntry): Promise<void> {
const blob = await getSoundBlob(entry.id);
if (!blob) return;
const src = URL.createObjectURL(blob);
const el = new Audio(src);
// Use the per-entry gain as local volume. SoundboardEntry exposes `gain`
// (0..1) which mirrors the value used in the in-call pipeline.
el.volume = Math.max(0, Math.min(1, entry.gain));
activeAudios.add(el);
const cleanup = () => {
activeAudios.delete(el);
URL.revokeObjectURL(src);
};
el.addEventListener('ended', cleanup);
el.addEventListener('error', cleanup);
try {
await el.play();
} catch (err) {
cleanup();
console.warn('soundboardLocalPlay failed', err);
}
}
export function stopSoundboardLocal(): void {
for (const el of activeAudios) {
try {
el.pause();
el.currentTime = 0;
} catch {
/* ignore */
}
}
activeAudios.clear();
}
+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 */
}
}
+274 -49
View File
@@ -1,19 +1,22 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import {
type AttachmentHandle,
clearConvKeyCache,
type DecryptedMessage,
decryptMessages,
encryptAndUploadAttachment,
fetchConversationMessages,
getOrCreateConvKey,
insertAttachmentRow,
MAX_ATTACHMENT_BYTES,
type MessageWithCipher,
rotateConvKey,
sendEncryptedMessage,
} from '@chat-app/shared/chat';
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { decryptBatch as decryptBatchWorker } from './decryptWorker';
import { generateWebPThumb } from './imageCompress';
import {
loadCachedMessages,
persistMessages,
@@ -30,8 +33,13 @@ import {
shouldGiveUp,
subscribeOutbox,
} from './messageOutbox';
import { devLocalSecretStore } from './secretStore';
import {
getCachedMessages,
hasCachedMessages,
setCachedMessages,
} from './messageMemoryCache';
import { supabase } from './supabase';
import { cachedUserKey } from './userIdentity';
interface State {
messages: DecryptedMessage[];
@@ -68,13 +76,31 @@ 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?: { viewOnceFlags?: boolean[] },
) => Promise<void>;
refresh: () => Promise<void>;
pending: OutboxItem[];
retryPending: (id: string) => void;
cancelPending: (id: string) => void;
} {
const [state, setState] = useState<State>({ messages: [], loading: true, error: null });
// Initialize from the in-memory cache so a previously-viewed chat shows
// content on the very first render after the parent remounts on `:id`
// change. `loading` stays true ONLY for never-seen conversations (cache
// miss) so the spinner doesn't flash on every chat switch.
const [state, setState] = useState<State>(() => {
if (conversationId && hasCachedMessages(conversationId)) {
return {
messages: getCachedMessages(conversationId),
loading: false,
error: null,
};
}
return { messages: [], loading: true, error: null };
});
const [pending, setPending] = useState<OutboxItem[]>(() =>
conversationId ? getOutbox(conversationId) : [],
);
@@ -90,25 +116,148 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
});
}, [conversationId]);
// Load own private key once per (user, device).
// Load own user private key once per user.
useEffect(() => {
privateKeyRef.current = null;
if (!userId || !deviceId) return;
void loadDevicePrivateKey(devLocalSecretStore, userId, deviceId).then((pk) => {
if (!userId) return;
void cachedUserKey(userId).then((pk) => {
privateKeyRef.current = pk;
});
}, [userId, deviceId]);
}, [userId]);
// Proactive rewrap sweep: when a conversation opens, ensure the active
// conv-key has a `recipient_user_id` bundle for every accepted member.
//
// If any peer is missing a bundle at the active version, the previous
// implementation called `shareConvKeyToUser` for each missing peer —
// that helper reads from the module-level conv-key cache first, and if
// the cache held a STALE locally-generated key (from a buggy bootstrap
// race in an earlier app version), the stale key got propagated to the
// peer's row. Both sides then encrypt with mutually un-mergeable keys
// and every message is "Nachricht nicht lesbar" forever (incident:
// conv aae12d84).
//
// The replacement: when any peer is missing, call `rotateConvKey` once.
// Rotation generates a fresh symmetric key locally, fetches each member's
// CURRENT pubkey, wraps the fresh key for everyone, and atomically bumps
// `active_key_version` via the `rotate_conv_key` RPC (FOR UPDATE lock
// serialises concurrent rotations). This bypasses the cache entirely:
// the new version's cache entry is the just-rotated key, and the stale
// entry at the old version is irrelevant because nobody reads it any more.
useEffect(() => {
if (!conversationId || !userId) return;
let cancelled = false;
const run = async (): Promise<void> => {
// Wait for the private key ref to be populated. Loop with backoff
// because it's set asynchronously by another effect; bail if the
// conversation switches.
for (let i = 0; i < 20 && !privateKeyRef.current && !cancelled; i++) {
await new Promise((r) => setTimeout(r, 100));
}
const priv = privateKeyRef.current;
if (!priv || cancelled) return;
try {
const { data: convRow, error: convErr } = await supabase
.from('conversations')
.select('active_key_version')
.eq('id', conversationId)
.single();
if (convErr || !convRow) return;
// db-types snapshot predates the active_key_version column; cast via unknown.
const version = (convRow as unknown as { active_key_version: number }).active_key_version;
// First, make sure we have a usable handle for the active version
// (this auto-rotates if we're locked out of our own bundle — the
// recovery path added in v0.21.1/v0.21.2).
const handle = await getOrCreateConvKey(supabase, conversationId, {
userId,
privateKey: priv,
});
if (cancelled) return;
if (handle.keyVersion > version) return; // already rotated by helper
// Check membership state on the server.
const { data: members, error: mErr } = await supabase
.from('conversation_members')
.select('user_id, accepted')
.eq('conversation_id', conversationId);
if (mErr || !members) return;
const peerIds = (members as Array<{ user_id: string; accepted: boolean }>)
.filter((m) => m.accepted && m.user_id !== userId)
.map((m) => m.user_id);
if (peerIds.length === 0) return;
// Count how many of the peers have a recipient_user_id bundle at
// the active version. If any are missing, rotate to V+1 — the
// rotation will wrap a fresh key for every accepted member with a
// user_keys row.
const { data: existingRows, error: rowsErr } = await (
supabase as unknown as {
from: (t: string) => {
select: (s: string) => {
eq: (c: string, v: string) => {
eq: (c: string, v: number) => {
in: (c: string, v: string[]) => Promise<{
data: Array<{ recipient_user_id: string }> | null;
error: unknown;
}>;
};
};
};
};
}
)
.from('conversation_keys')
.select('recipient_user_id')
.eq('conversation_id', conversationId)
.eq('key_version', version)
.in('recipient_user_id', peerIds);
if (rowsErr) return;
const wrappedPeerIds = new Set(
(existingRows ?? []).map((r) => r.recipient_user_id),
);
const missing = peerIds.filter((id) => !wrappedPeerIds.has(id));
if (missing.length === 0) return;
// At least one peer is missing a bundle — rotate. We deliberately do
// NOT use the cached conv-key here. The rotation generates a fresh
// key wrapped to every current member's CURRENT pubkey, so any
// staleness in the local cache for the OLD version is irrelevant
// going forward.
try {
await rotateConvKey(supabase, conversationId, {
userId,
privateKey: priv,
});
} catch (err) {
// Most likely cause: a concurrent peer also called rotate and
// won the race; their bumped active_key_version makes our
// `p_new_version <= cur_version` and the RPC raises. That's fine —
// the next chat-open / send will fetch the new active version and
// unwrap the bundle that peer wrapped for us.
console.warn('proactive rotate failed (likely concurrent rotation)', err);
}
} catch (err) {
console.warn('proactive rewrap sweep failed', err);
}
};
void run();
return () => {
cancelled = true;
};
}, [conversationId, userId]);
const decryptBatch = useCallback(
async (messages: MessageWithCipher[]): Promise<DecryptedMessage[]> => {
const priv = privateKeyRef.current;
if (!priv || !deviceId || messages.length === 0) {
if (!priv || !userId || messages.length === 0) {
return messages.map((m) => ({ ...m, plaintext: null }));
}
return decryptMessages({
client: supabase,
messages,
ownDeviceId: deviceId,
ownUserId: userId,
ownPrivateKey: priv,
// Offload the symmetric decrypt + utf-8 decode to a Web Worker so
// the main thread stays responsive during bulk operations (initial
@@ -116,7 +265,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
aeadBatchDelegate: decryptBatchWorker,
});
},
[deviceId],
[userId],
);
const refresh = useCallback(async () => {
@@ -131,6 +280,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
const rows = await fetchConversationMessages(supabase, conversationId);
const decrypted = await decryptBatch(rows);
setState({ messages: decrypted, loading: false, error: null });
setCachedMessages(conversationId, decrypted);
// Persist the fresh batch to the local cache so next conversation
// switch / app start can hydrate instantly. Fire-and-forget — cache
// write failure is never user-visible.
@@ -150,12 +300,17 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
// when the server response lands. On cache-miss this is a ~5ms no-op.
useEffect(() => {
if (!conversationId) return;
// Memory cache already populated state synchronously — skip the disk
// round-trip entirely. The canonical data lands shortly via refresh();
// the SQLite cache only matters for cold-start hydration.
if (hasCachedMessages(conversationId)) return;
let cancelled = false;
void loadCachedMessages(conversationId).then((cached) => {
if (cancelled || cached.length === 0) return;
setState((prev) => {
// Don't clobber a fresh server response that already landed.
if (prev.messages.length > 0) return prev;
setCachedMessages(conversationId, cached);
return { messages: cached, loading: false, error: null };
});
});
@@ -240,7 +395,9 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
if (!decrypted) return;
setState((prev) => {
if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
return { ...prev, messages: [...prev.messages, decrypted!] };
const next = [...prev.messages, decrypted!];
if (conversationId) setCachedMessages(conversationId, next);
return { ...prev, messages: next };
});
},
[conversationId, deviceId, decryptBatch],
@@ -260,6 +417,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
editedAt: partial.editedAt,
deletedAt: partial.deletedAt,
};
if (conversationId) setCachedMessages(conversationId, next);
return { ...prev, messages: next };
});
if (partial.editedAt && !partial.deletedAt) {
@@ -323,6 +481,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
if (idx === -1) return prev;
const next = [...prev.messages];
next[idx] = decrypted!;
if (conversationId) setCachedMessages(conversationId, next);
return { ...prev, messages: next };
});
}
@@ -330,25 +489,32 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
[conversationId, deviceId, decryptBatch],
);
const handleDelete = useCallback((row: Record<string, unknown>) => {
const id = String(row.id);
setState((prev) => ({
...prev,
messages: prev.messages.filter((m) => m.id !== id),
}));
void deleteCachedMessage(id);
}, []);
const handleDelete = useCallback(
(row: Record<string, unknown>) => {
const id = String(row.id);
setState((prev) => {
const next = prev.messages.filter((m) => m.id !== id);
if (conversationId) setCachedMessages(conversationId, next);
return { ...prev, messages: next };
});
void deleteCachedMessage(id);
},
[conversationId],
);
useEffect(() => {
if (!conversationId || !userId || !deviceId) return;
void refresh();
// Batch INSERT bursts so a paste / backfill doesn't fire N parallel
// refetches + decrypts. If more than BATCH_BURST_THRESHOLD ids arrive
// within BATCH_WINDOW_MS, collapse to a single refresh() which pulls
// the last 100 in one query — cheaper and keeps order stable. For
// lone inserts the per-id path stays so latency is unchanged.
const BATCH_WINDOW_MS = 250;
// refetches + decrypts. The first event in a quiet period fires
// `handleInsert` immediately so single incoming messages don't sit
// behind a debounce timer (previous behaviour: 250 ms blank between
// notification-sound and message body). Subsequent events arriving
// within BATCH_WINDOW_MS of the first are buffered; if the burst grows
// past BATCH_BURST_THRESHOLD the buffered tail collapses into one
// `refresh()` instead of N individual refetches.
const BATCH_WINDOW_MS = 80;
const BATCH_BURST_THRESHOLD = 3;
let burstBuffer: Array<Record<string, unknown>> = [];
let burstTimer: number | null = null;
@@ -367,6 +533,15 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
}
};
const queueInsert = (row: Record<string, unknown>) => {
if (burstBuffer.length === 0 && burstTimer === null) {
// First event in a quiet period — fire immediately so the user sees
// the message right when they hear the notification sound. Arm a
// short window in case a burst follows; follow-ups go through the
// buffer and may collapse into a refresh.
void handleInsert(row);
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
return;
}
burstBuffer.push(row);
if (burstTimer === null) {
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
@@ -393,18 +568,46 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
}
},
)
// When a peer device wraps the conversation-key for us (e.g. we just
// registered a fresh device), re-decrypt the visible messages.
// Any conversation_keys change for this conv invalidates the cached
// conv-key for the affected version. The module-level cache in
// shared/chat/convKeys.ts otherwise holds the previously-unwrapped key
// forever within a session — which is exactly what propagated the
// stale local bootstrap key in conv aae12d84, recreating divergent
// bundles after a server-side cleanup. Clearing on any INSERT/UPDATE/
// DELETE for the conv forces the next `getOrCreateConvKey` /
// `tryGetConvKey` call to re-fetch the canonical bundle from the
// server. Cheap (a single Map.delete), defensive, and avoids stale-
// cache propagation across all of {peer rotation, device wrap, admin
// cleanup}.
//
// We also keep the historical "device wrap → refresh" trigger so a
// freshly-registered device of our own re-decrypts in place.
.on(
'postgres_changes',
{
event: 'INSERT',
event: '*',
schema: 'public',
table: 'conversation_keys',
filter: 'conversation_id=eq.' + conversationId,
},
(payload: { new: { recipient_device_id?: string } }) => {
if (payload.new?.recipient_device_id === deviceId) {
(payload: {
eventType: 'INSERT' | 'UPDATE' | 'DELETE';
new: { recipient_device_id?: string; key_version?: number };
old: { recipient_device_id?: string; key_version?: number };
}) => {
const v =
payload.eventType === 'DELETE'
? payload.old?.key_version
: payload.new?.key_version;
if (typeof v === 'number') {
clearConvKeyCache(conversationId, v);
} else {
clearConvKeyCache(conversationId);
}
if (
payload.eventType === 'INSERT' &&
payload.new?.recipient_device_id === deviceId
) {
void refresh();
}
},
@@ -454,20 +657,24 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
});
setState((prev) => {
if (prev.messages.some((m) => m.id === msg.id)) return prev;
return {
...prev,
messages: [
...prev.messages,
{ ...msg, plaintext: text } as DecryptedMessage,
],
};
const next = [
...prev.messages,
{ ...msg, plaintext: text } as DecryptedMessage,
];
setCachedMessages(convId, next);
return { ...prev, messages: next };
});
},
[],
);
const send = useCallback(
async (text: string, images: File[] = [], replyToId: string | null = null) => {
async (
text: string,
images: File[] = [],
replyToId: string | null = null,
opts: { viewOnceFlags?: boolean[] } = {},
) => {
const trimmed = text.trim();
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
const priv = privateKeyRef.current;
@@ -523,13 +730,23 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
// 1. Upload + encrypt each image. Collect handles + raw blob nonces
// (so the public attachment row can reference the blob-level nonce).
// P7.T4: view-once is now a per-attachment flag rather than a
// composer-wide toggle. `opts.viewOnceFlags` is a parallel array;
// missing entries (or whole-array absence) default to false.
const handles: AttachmentHandle[] = [];
const blobNonceHexByHandleId = new Map<string, string>();
for (const file of images) {
for (let i = 0; i < images.length; i++) {
const file = images[i]!;
if (file.size > MAX_ATTACHMENT_BYTES) {
throw new Error('attachment exceeds max size (10 MB)');
}
const dims = await readImageDimensions(file);
// Phase 6B: generate a small WebP preview thumb so the receiver's
// bubble loads fast (typical 320×240 WebP is 1030KB vs the full
// image's 110MB). `generateWebPThumb` short-circuits to null on
// non-images, animated formats, and small files — and on failure;
// the upload helper then just skips the second upload.
const thumbBlob = await generateWebPThumb(file);
const res = await encryptAndUploadAttachment({
client: supabase,
conversationId,
@@ -538,7 +755,16 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
sizeBytes: file.size,
...(dims.width !== undefined ? { width: dims.width } : {}),
...(dims.height !== undefined ? { height: dims.height } : {}),
...(thumbBlob ? { thumbBlob } : {}),
});
// Stamp the view-once flag on each handle the caller flagged. 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.viewOnceFlags?.[i]) {
res.handle.viewOnce = true;
}
handles.push(res.handle);
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce));
}
@@ -564,16 +790,15 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
: JSON.stringify({ v: 1, text: trimmed, attachments: handles });
setState((prev) => {
if (prev.messages.some((m) => m.id === msg.id)) return prev;
return {
...prev,
messages: [
...prev.messages,
{
...msg,
plaintext: attachmentsPayload,
} as DecryptedMessage,
],
};
const next = [
...prev.messages,
{
...msg,
plaintext: attachmentsPayload,
} as DecryptedMessage,
];
setCachedMessages(conversationId, next);
return { ...prev, messages: next };
});
// 4. Insert public attachment metadata rows pointing at the new message.

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