Compare commits

...

29 Commits

Author SHA1 Message Date
byGalax b89ec90813 chore: bump version to 0.8.0
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Voice messages, offline queue, delivery receipts, group call scaling,
recovery code, push notifications scaffolding, admin panel, search v2,
focus-flicker fix.
2026-04-21 01:15:57 +02:00
byGalax a04ecf7a19 feat: voice messages, offline queue, delivery ticks, volume slider, admin + scaling
- Voice messages: MediaRecorder → encrypted attachment, custom waveform
  player via OfflineAudioContext, 60s limit + live mic-level meter
- Offline message queue: localStorage outbox, exponential backoff retries,
  optimistic pending bubble with retry/discard
- Delivery indicator: message_deliveries table + RLS (reciprocal receipts),
  ✓ / ✓✓ / ✓✓-blue tick states, group-aware (all members must ack)
- Per-participant volume slider in calls via right-click tile menu,
  persisted to localStorage, applied to attached audio elements
- Group call scaling: grid up to 12 tiles with pagination,
  active-speaker auto-promotion in fullscreen
- Push notifications scaffolding: service worker, VAPID subscription
  registration, notify-push edge function skeleton
- Backup recovery code: 24-char base32 code (~120 bits entropy) as
  alternative decrypt path, restore UI with mode toggle
- Admin panel: conversations list, audit log (admin_audit_log table +
  admin_log_action RPC), audit entry on user flag toggle
- Search v2: sender filter, attachment-only toggle, date range
- Reactions pop animation (scale 0.4→1.15→1 on count change)
- Message list windowing (150 default, expand via IntersectionObserver)
- Stub cleanup: removed dead ScreenshareStub from CallParticipantTile

Fixes:
- Focus-triggered flicker: dropped window.focus listeners in three spots,
  throttled visibilitychange/online wake-refreshes to 30s, keep existing
  data visible during background re-syncs (no more spinner on every click)
- Voice attachment audio element collapsed to 0px on peer side — now
  forces 280px min-width on bubble

Migrations (push required):
  20260421000001_message_deliveries.sql
  20260421000002_admin_audit_log.sql

Server TODO:
  VAPID keys + notify-push edge function deploy
2026-04-21 01:14:16 +02:00
byGalax da85f0ba54 feat: call UX overhaul — deafen sync, share dialog, fullscreen redesign
Speaking ring:
- Switch useActiveSpeakers from LiveKit's smoothed isSpeaking / server-
  batched ActiveSpeakersChanged to Web Audio API AnalyserNode on each
  participant's raw audio MediaStreamTrack. Poll 50ms, RMS threshold 0.03,
  250ms hold. Feels real-time vs the old ~500ms lag
- Defensive syncProbe on every tick so probes catch up if TrackPublished
  missed (local mic publish race on join)
- Universal speaking overlay on tile (3px emerald border + inset glow,
  z-10) so video mode shows the ring too, not just audio mode

Screen sharing:
- Separate "screen" tile per sharer so the sharer's avatar tile stays
  intact with its speaking ring. Tile.id is kind-prefixed (user:xxx /
  screen:xxx) so focus tracking distinguishes them
- New ScreenShareDialog (quality preset + fps override + displaySurface
  hint) opens on the share button. startScreenShare / stopScreenShare
  actions in CallContext replace the one-shot toggle
- ScreenShareViewer: plain CSS-only fullscreen overlay (Tauri WKWebView
  doesn't implement requestFullscreen), always `h-full w-full
  object-contain`, Esc exits

Camera:
- toggleCamera action in CallContext tracks isCameraEnabled
- VideoStub renders real <video> srcObject for the participant's camera
  MediaStreamTrack; local preview is mirrored
- Tile video track resolves to Track.Source.Camera publications of the
  LocalParticipant / each RemoteParticipant
- Room listens for TrackMuted / TrackUnmuted and re-publishes remote
  state so peers switch to avatar placeholder when a camera is disabled

Deafen:
- New isDeafened state + toggleDeafen action. Sets `muted = true` on all
  attached `<audio[data-livekit-track]>` plus mutes fresh ones on attach
  via module-level flag
- Broadcast state over the LiveKit data channel
  ({type:'presence', deafened}) so peers can render the headphones-off
  badge. Attributes API not used because the self-hosted server may run
  older LiveKit versions
- remoteDeafen: Record<identity, bool> exposed via context, bumped on
  DataReceived and re-broadcast on ParticipantConnected

Incoming video call:
- acceptIncoming takes an optional CallKind override so the receiver can
  answer a video invite with audio only or promote an audio invite to
  video on accept
- IncomingCallPanel shows two accept buttons (audio + video) when the
  invite is a video call

Audio devices:
- audioSettings adds inputDeviceId + outputDeviceId, persisted
- CallContext uses them on setMicrophoneEnabled, plus new
  setAudioInputDevice / setAudioOutputDevice hot-swap actions.
  Output swap applies setSinkId to every attached remote-audio element
  since LiveKit's own switchActiveDevice only tracks elements it
  attached itself
- SettingsPage "Mikrofon" + "Ausgabegerät" selects with devicechange
  listener and a permission-probe button

Fullscreen mode:
- Replaced absolute-positioned speaker + floating thumbnails with a real
  flex layout. Default = even grid of all tiles. Clicking a tile flips
  to big-speaker + horizontal thumbnail strip. Click focused tile =
  back to grid
- Controls overlay pinned bottom; content wrapper has pb-24 so tiles
  never sit behind the toolbar
- Grid now uses explicit grid-rows-* so cells get a defined 1fr height
  (without it, video intrinsic dimensions blew tiles past the container
  bounds on Windows)

UI chips:
- Mic-off badge combines isMuted flag AND
  localParticipant.isMicrophoneEnabled, so a user with no mic / denied
  permission sees the badge + the toolbar button red even though they
  never pressed mute
- Deafen badge on tile chips for local + remote (remote driven by the
  data-channel broadcast)
2026-04-21 00:02:43 +02:00
byGalax a4c9b959a9 fix: editable decrypt + windows realtime wake (v0.7.1)
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Edit message decrypt:
- handleUpdate in useConversationMessages now refetches the canonical row
  via REST after a realtime UPDATE instead of trusting the realtime
  payload's bytea encoding. Same pattern as handleInsert — base64 vs
  `\x…` hex serialisation varies across supabase/postgrest versions and
  was silently producing undecryptable ciphertext for edited messages
  on the receiver side

Windows WebView2 background throttling:
- ConversationsContext, useFriendships and useConversationMessages now
  listen for visibilitychange / focus / online events and trigger both a
  fresh REST refresh and a best-effort channel.subscribe() on wake.
  WebView2 aggressively throttles background WebSockets and was dropping
  realtime events entirely while the window was minimised, so new
  messages and friend acceptances only surfaced after a manual reload

Bump tauri version 0.7.0 -> 0.7.1
2026-04-20 22:15:49 +02:00
byGalax 37becba7e2 feat: audio devices + fullscreen + banner cleanup (v0.7.0)
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Audio device selection:
- audioSettings: persisted inputDeviceId + outputDeviceId
- CallContext: uses stored input deviceId on mic enable, new
  setAudioInputDevice / setAudioOutputDevice actions that hot-swap
  without reconnect. Output swap applies HTMLMediaElement.setSinkId
  to every attached remote-audio element (LiveKit's switchActiveDevice
  only tracks elements it attached itself)
- SettingsPage: new "Mikrofon" + "Ausgabegerät" selects with
  enumerateDevices, devicechange listener, permission-probe button.
  setSinkId-unsupported fallback is messaged but non-blocking

Fullscreen:
- FullscreenCall was absolute inset-0 z-40 which trapped it inside the
  <main> pane — sidebar + chat-list stayed visible. Switched to
  fixed inset-0 z-[60] so the call overlays the whole window
  Discord-style
- ScreenShareViewer fullscreen: CSS-only toggle (native Fullscreen API
  unreliable under Tauri WKWebView), portalled to document.body when
  active so no ancestor stacking context can clip it. Esc exits

ActiveCallBanner:
- cleanup effect returned early when presence was entirely empty,
  leaving the "1 im Raum" fallback stuck after both peers left. Now
  schedules dismissLastCall as soon as othersIn.length === 0, with a
  3s grace window to absorb presence re-sync flicker

Bump tauri version 0.6.0 -> 0.7.0
2026-04-20 21:26:10 +02:00
byGalax eb8f9857ff feat: device backup/restore + quick wins + username casing
Backup / restore flow:
- deviceBackup.ts: v2 format wrapping userId + deviceId + privateKey in
  an encrypted JSON payload so restore can re-seed localStorage, vault,
  and reattach to the existing server-side device row without provisioning
  a new one (conv-key bundles stay valid, no "awaiting key" state)
- shared/auth: restoreDeviceFromServerRecord — verifies session.user.id
  matches the backup's userId, confirms the server device row still
  exists, then writes the private key into the local secret store
- BackupExportDialog — passphrase + confirm, generates portable string,
  copy + download .txt
- DeviceRestore — textarea + passphrase → seeds vault + writes
  deviceId cache, treats this install as the original device
- DevicePage now has tabs "Neu einrichten" / "Backup wiederherstellen"
- BackupPromptBanner — post-registration nudge, reads sessionStorage
  signal from fresh provisions and persists "never-ask-again" in
  localStorage so it stops nagging
- SettingsPage backup section: uses the new dialog; removes the
  dangerous in-place key import (restore now lives in the device flow)

Username casing:
- Migration 20260420000002 drops lower() from the handle_new_user trigger
  and widens the regex to [A-Za-z0-9_]. profiles.username is citext so
  uniqueness + lookups stay case-insensitive regardless of stored casing
- Shared auth: trim() only, no toLowerCase on signup/lookups/search.
  ilike handles CI anyway and citext makes client normalisation redundant
- AuthPage regex + input preserve case, FriendsPage search preserves case
- i18n (de+en): updated username_hint / username_invalid / ERR_USERNAME_INVALID
  to reflect the new rule

Quick wins:
- React Router v7 future flags (v7_startTransition + v7_relativeSplatPath)
  set on BrowserRouter — silences the upgrade warning
- appUpdates.checkForUpdate: swallow benign network/fetch/"could not
  fetch valid release JSON" cases silently instead of console spam
- osNotify: persist an "asked" marker in localStorage so the permission
  prompt only fires once per install (OS already persists the answer,
  but the plugin re-queries loudly otherwise)
2026-04-20 19:07:31 +02:00
byGalax de431386ea feat: reply + search + forward + archive/mute + error boundary
Messages:
- Reply-to: hover action, composer chip with cancel, quote bubble inside
  the replying message with tap-to-jump + amber highlight ring
- Search: header search button toggles in-conversation search bar with
  prev/next + match counter, auto-jump to active match
- Forward: multi-select conversation picker. Attachments are now carried
  over: download + decrypt source, re-encrypt under each target conv-key,
  re-upload with fresh per-attachment keys, insert new attachment rows

Conversations:
- Archive + mute per member. New migration 20260420000001 adds `archived`
  + `muted_until` on conversation_members. Shared helpers:
  setConversationArchived / setConversationMutedUntil / isConversationMuted
- ChatsPage: archive toggle in header with unread badge for archived
  bucket, split active/archived lists, muted indicator (BellOff icon,
  dimmed unread badge)
- ConversationRowMenu via createPortal (escapes sidebar overflow clip),
  forwardRef-based MenuItem so submenu positioning refs survive React 18
- ConversationsContext: suppresses notification sound + OS notif when
  target conversation is muted
- Refresh on `profiles UPDATE` realtime so peer avatar / displayName
  changes flow to conversation.members without manual refresh

Resilience:
- ErrorBoundary (Discord-style): centred spinner + escalating copy, no
  manual reload button. Backoff retry schedule [2s, 4s, 8s, 15s, 30s].
  Uses a keyed Fragment (not a div wrapper) so flex h-full chains survive
- App wrapped root + per-route RouteBoundary, conversation-level boundary
- AuthContext: flip `ready` immediately on cached session read; validate
  getUser in background so a stalled/offline Supabase doesn't freeze the
  app on the loading spinner

Crypto:
- Swap libsodium-wrappers -> libsodium-wrappers-sumo (compact build was
  missing crypto_pwhash so Argon2id vault KDF threw, falling back to
  plaintext localStorage on every launch)
- Shim d.ts for sumo types (sumo is API superset, no official types ship)
- vite optimizeDeps includes sumo with the "require" condition
- secureFileStore: exists(dir) check before mkdir; surface genuine
  permission errors instead of silent catch

Tauri:
- fs scope adds `$APPLOCALDATA` direct entry (not just /**) so the app
  data directory itself can be mkdir'd on first launch

Chat layout:
- Skip call_event messages when computing avatar run boundaries so a
  regular bubble followed by a call event from the same sender still
  shows its avatar
2026-04-20 15:42:49 +02:00
byGalax 1fab2edc57 fix(messages): peer avatar visibility in chat bubbles
- ConversationsContext: subscribe to profiles UPDATE realtime so
  conversation members[].profile picks up peer avatar / displayName changes
  without a manual refresh
- ConversationPage: skip call_event messages when computing run boundaries.
  Previously a peer bubble followed by a call event from the same sender
  was treated as mid-run -> avatar slot collapsed to a placeholder
- DM peer-profile fallback already added in previous commit covers transient
  member-lookup misses
2026-04-20 00:49:21 +02:00
byGalax 4db65993d5 feat: redesign + avatars + theme + call presence fixes (v0.6.0)
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Visual:
- Light/dark theme via ThemeContext + CSS vars
- New design tokens (surface/fg/accent/line/etc.) across all pages
- Reusable Avatar component (img + letter fallback) wired into UserBar,
  ConversationHeader, ChatsPage, FriendsPage, GroupInfoPanel, IncomingCallPanel

Calls:
- Split call UI: IncomingCallPanel, InCallPanel, CallControls,
  CallParticipantTile, ScreenShareViewer
- Active speaker hook (useActiveSpeakers)
- Fix: ActiveCallBanner stayed hidden after hangup while peers in room.
  - useCallPresence: bind presence callbacks only when we own subscribe
    (Supabase forbids .on() after .subscribe() on shared dedup'd channels)
  - useCallPresence: never removeChannel — channel is shared with CallContext
    so tearing it down on ConversationHeader unmount killed live tracking
  - ActiveCallBanner: lastCallConversationId fallback so banner shows
    instantly after hangup, auto-dismiss when room confirmed empty
- Drop unused useAnyActiveCall

Bump tauri version 0.5.0 -> 0.6.0
2026-04-20 00:26:19 +02:00
byGalax 0ca29952ba feat: profile avatar upload + share_conv_keys rpc + favicon + smtp tweaks
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 23:04:03 +02:00
byGalax ff5ea274b9 fix(keysync): swallow expected supabase errors by code/status, not just message
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 22:08:54 +02:00
byGalax b0f9f1dada fix(keysync): silence expected RLS rejections during best-effort backfill
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 22:03:49 +02:00
byGalax 961ac2dde5 fix: idempotent conv-key upsert + guard tauri-only notification calls
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 22:00:21 +02:00
byGalax 7efbcf7e39 fix(vault): per-user filename so multi-account on same machine works
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 21:29:03 +02:00
byGalax 49c64cc5d9 feat(crypto): self-rolled encrypted file vault, replaces flaky stronghold
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 21:21:40 +02:00
byGalax d20c7e210b fix(senderkey): backfill missing key bundles on mount + refresh on incoming bundle
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 21:07:47 +02:00
byGalax 58fa9487e3 fix(auth): replace navigator.locks with in-process serial lock to avoid 'lock stolen' aborts
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:38:42 +02:00
byGalax d9b08592da fix(messages): refetch row via REST on realtime insert + optimistic sender update
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:35:00 +02:00
byGalax 4a80bf1c0e fix(realtime): handle base64 bytea payloads from postgres_changes
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:19:40 +02:00
byGalax 05c962d46f fix(auth): scope signOut to local session only
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:11:08 +02:00
byGalax cf3fef6936 fix(secretStore): fall back to localStorage when stronghold init fails
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:05:37 +02:00
byGalax 8c878b3718 fix(capabilities): allow stronghold operations
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:02:44 +02:00
byGalax 389f00e85c build: enable devtools in release for easier debugging
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:00:33 +02:00
byGalax 75618637e2 feat(crypto): sender-key per-conversation multi-device E2EE
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 19:27:24 +02:00
byGalax e57f81c9c3 ci: trim matrix to macos-14 universal + windows
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 18:55:06 +02:00
byGalax 0a5811cc68 feat(ui): netralax wordmark lockup in sidebar 2026-04-19 18:46:14 +02:00
byGalax 0d94b684bf feat(crypto): stronghold persistence + passphrase device-key backup/restore
Release desktop app / build (, ubuntu-22.04) (push) Has been cancelled
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target aarch64-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Release desktop app / build (--target x86_64-apple-darwin --bundles app,updater, macos-13) (push) Has been cancelled
2026-04-19 18:41:35 +02:00
byGalax 3c2579b3ed fix(updater): mount UpdateToast outside auth gates
Release desktop app / build (, ubuntu-22.04) (push) Has been cancelled
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target aarch64-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Release desktop app / build (--target x86_64-apple-darwin --bundles app,updater, macos-13) (push) Has been cancelled
2026-04-19 14:55:06 +02:00
byGalax c413ee1cf6 feat: netralax split-hex logo + app icons
Release desktop app / build (, ubuntu-22.04) (push) Has been cancelled
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target aarch64-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Release desktop app / build (--target x86_64-apple-darwin --bundles app,updater, macos-13) (push) Has been cancelled
2026-04-19 14:40:14 +02:00
188 changed files with 14894 additions and 1460 deletions
+3 -13
View File
@@ -21,12 +21,8 @@ jobs:
fail-fast: false
matrix:
include:
- platform: macos-14 # apple silicon
args: "--target aarch64-apple-darwin --bundles app,updater"
- platform: macos-13 # intel
args: "--target x86_64-apple-darwin --bundles app,updater"
- platform: ubuntu-22.04
args: ""
- platform: macos-14 # universal binary covers both Intel + Apple Silicon
args: "--target universal-apple-darwin --bundles app,updater"
- platform: windows-latest
args: ""
@@ -47,13 +43,7 @@ jobs:
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin' || matrix.platform == 'macos-13' && 'x86_64-apple-darwin' || '' }}
- name: Install Linux build deps
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt update
sudo apt install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libgtk-3-dev
targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Install JS deps
run: pnpm install --frozen-lockfile
+1 -1
View File
@@ -1 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDg0Q0Y0N0I1Q0U2MjBEMzcKUldRM0RXTE90VWZQaERVWnBGNTVKUVZ2MWZyRktaaDFJaXVWc3NGNUZVb08yVHNxaVp2c2dOL2oK
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ5N0U0RDcxOTU2OEQ0QUUKUldTdTFHaVZjVTErU1ZuMk1lWXBUbEcyS1RHYzJQN3k4VDdiUGRvRnVJYVJKR3BxWG1xcENpdlYK
+16 -2
View File
@@ -4,9 +4,23 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>ChatApp</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Netralax</title>
<script>
// Apply persisted / system theme before paint to avoid FOUC.
(function () {
try {
var stored = localStorage.getItem('netralax.theme');
var dark =
stored === 'dark' ||
(stored !== 'light' &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) document.documentElement.classList.add('dark');
} catch (_) {}
})();
</script>
</head>
<body class="bg-[#0b0b0f] text-white antialiased">
<body class="bg-surface text-fg antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@chat-app/desktop",
"version": "0.1.0",
"version": "0.8.0",
"private": true,
"description": "Tauri v2 desktop client (Windows / macOS / Linux)",
"type": "module",
@@ -23,13 +23,14 @@
"@livekit/components-react": "^2.9.0",
"@supabase/supabase-js": "^2.46.0",
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-fs": "^2.5.0",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-notification": "^2.0.1",
"@tauri-apps/plugin-sql": "^2.0.1",
"@tauri-apps/plugin-stronghold": "^2.0.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"i18next": "^23.16.4",
"libsodium-wrappers": "0.7.15",
"libsodium-wrappers-sumo": "0.7.15",
"livekit-client": "^2.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -40,6 +41,7 @@
"devDependencies": {
"@tauri-apps/cli": "^2.1.0",
"@types/libsodium-wrappers": "^0.7.14",
"@types/libsodium-wrappers-sumo": "^0.8.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
+14
View File
@@ -0,0 +1,14 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="cp02">
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"/>
</clipPath>
</defs>
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065"/>
<g clip-path="url(#cp02)">
<path d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z" fill="#7c4dff"/>
<path d="M-4 32 Q 16 18 32 32 T 68 32" stroke="#a78bfa" stroke-width="2" fill="none"/>
</g>
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"
fill="none" stroke="#a78bfa" stroke-width="1.5" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 667 B

+67
View File
@@ -0,0 +1,67 @@
// Web Push service worker.
//
// Handles browser-delivered push events when the app tab is closed or in the
// background. Tauri desktop does not install service workers; native OS
// notifications are routed through the Tauri notification plugin instead
// (see src/lib/osNotify.ts).
//
// Payload contract — server sends JSON of shape:
// { title: string, body?: string, conversationId?: string, kind?: 'message' | 'call' }
// Body is intentionally generic; message ciphertext is never included.
self.addEventListener('install', (event) => {
// Activate immediately so updates apply on next page load.
event.waitUntil(self.skipWaiting());
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('push', (event) => {
let data = { title: 'Neue Nachricht', body: '' };
try {
if (event.data) {
data = { ...data, ...event.data.json() };
}
} catch (_err) {
/* malformed payload — fall back to defaults */
}
const opts = {
body: data.body || '',
icon: '/favicon.svg',
badge: '/favicon.svg',
tag: data.conversationId || 'default',
renotify: true,
data: {
conversationId: data.conversationId,
kind: data.kind,
},
};
event.waitUntil(self.registration.showNotification(data.title, opts));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const conversationId = event.notification.data && event.notification.data.conversationId;
const target = conversationId ? '/chats/' + conversationId : '/';
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
for (const client of clientList) {
if ('focus' in client) {
client.postMessage({ type: 'navigate', to: target });
return client.focus();
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(target);
}
return undefined;
}),
);
});
+26 -1
View File
@@ -678,12 +678,13 @@ dependencies = [
[[package]]
name = "chat-app-desktop"
version = "0.1.0"
version = "0.8.0"
dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-fs",
"tauri-plugin-global-shortcut",
"tauri-plugin-notification",
"tauri-plugin-sql",
@@ -5483,6 +5484,30 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 0.9.12+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-global-shortcut"
version = "2.3.1"
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "chat-app-desktop"
version = "0.1.0"
version = "0.8.0"
description = "ChatApp desktop client"
authors = ["Dennis"]
edition = "2021"
@@ -14,10 +14,11 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["devtools"] }
tauri-plugin-notification = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-stronghold = "2"
tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -15,6 +15,28 @@
"updater:allow-check",
"updater:allow-download",
"updater:allow-install",
"updater:allow-download-and-install"
"updater:allow-download-and-install",
"stronghold:default",
"stronghold:allow-initialize",
"stronghold:allow-load-client",
"stronghold:allow-create-client",
"stronghold:allow-save",
"stronghold:allow-get-store-record",
"stronghold:allow-save-store-record",
"stronghold:allow-remove-store-record",
"fs:default",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-mkdir",
"fs:allow-exists",
"fs:allow-rename",
"fs:allow-remove",
{
"identifier": "fs:scope",
"allow": [
{ "path": "$APPLOCALDATA" },
{ "path": "$APPLOCALDATA/**" }
]
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="cp02">
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"/>
</clipPath>
</defs>
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065"/>
<g clip-path="url(#cp02)">
<path d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z" fill="#7c4dff"/>
<path d="M-4 32 Q 16 18 32 32 T 68 32" stroke="#a78bfa" stroke-width="2" fill="none"/>
</g>
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"
fill="none" stroke="#a78bfa" stroke-width="1.5" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 667 B

+1
View File
@@ -3,6 +3,7 @@ pub fn run() {
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_sql::Builder::default().build())
.plugin(tauri_plugin_fs::init())
.plugin(
tauri_plugin_stronghold::Builder::new(|password| {
// TODO: derive stronghold key from password using argon2 / blake2b.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ChatApp",
"version": "0.1.0",
"version": "0.8.0",
"identifier": "com.meinname.chatapp",
"build": {
"beforeDevCommand": "pnpm vite:dev",
+83 -34
View File
@@ -1,11 +1,14 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
import { AppShell } from './components/AppShell';
import { ErrorBoundary } from './components/ErrorBoundary';
import { UpdateToast } from './components/UpdateToast';
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
import { AuthProvider } from './context/AuthContext';
import { CallProvider } from './context/CallContext';
import { ConversationsProvider } from './context/ConversationsContext';
import { FriendshipsProvider } from './context/FriendshipsContext';
import { ThemeProvider } from './context/ThemeContext';
import { AdminPage } from './pages/AdminPage';
import { AuthCallbackPage } from './pages/AuthCallbackPage';
import { AuthPage } from './pages/AuthPage';
@@ -15,39 +18,85 @@ import { DevicePage } from './pages/DevicePage';
import { FriendsPage } from './pages/FriendsPage';
import { SettingsPage } from './pages/SettingsPage';
export function App() {
// Isolates each top-level route so a crash in one page doesn't take the whole
// shell down. The boundary auto-retries via `ErrorBoundary`'s backoff schedule.
function RouteBoundary({ scope }: { scope: string }) {
return (
<AuthProvider>
<FriendshipsProvider>
<ConversationsProvider>
<CallProvider>
<BrowserRouter>
<Routes>
<Route path="/auth" element={<AuthPage />} />
<Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route element={<RequireAuth />}>
<Route path="/device" element={<DevicePage />} />
<Route element={<RequireDevice />}>
<Route element={<AppShell />}>
<Route index element={<Navigate to="/chats" replace />} />
<Route path="/chats" element={<ChatsPage />}>
<Route index element={<ChatsEmptyState />} />
<Route path=":id" element={<ConversationPage />} />
</Route>
<Route path="/friends" element={<FriendsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route element={<RequireAdmin />}>
<Route path="/admin" element={<AdminPage />} />
</Route>
</Route>
</Route>
</Route>
<Route path="*" element={<Navigate to="/chats" replace />} />
</Routes>
</BrowserRouter>
</CallProvider>
</ConversationsProvider>
</FriendshipsProvider>
</AuthProvider>
<ErrorBoundary scope={scope}>
<Outlet />
</ErrorBoundary>
);
}
export function App() {
return (
<ErrorBoundary scope="root">
<ThemeProvider>
<AuthProvider>
<FriendshipsProvider>
<ConversationsProvider>
<CallProvider>
<BrowserRouter
future={{
// Opt into v7 behaviour early so the upgrade is a no-op:
// - `v7_startTransition` wraps navigations in startTransition
// so Suspense / concurrent rendering deal with the new tree
// - `v7_relativeSplatPath` matches relative paths inside
// splat routes against the parent splat segment (not the
// full matched path). Our tree has no splat routes today
// but this kills the runtime warning and future-proofs.
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<Routes>
<Route element={<RouteBoundary scope="auth" />}>
<Route path="/auth" element={<AuthPage />} />
<Route path="/auth/callback" element={<AuthCallbackPage />} />
</Route>
<Route element={<RequireAuth />}>
<Route element={<RouteBoundary scope="device" />}>
<Route path="/device" element={<DevicePage />} />
</Route>
<Route element={<RequireDevice />}>
<Route element={<AppShell />}>
<Route index element={<Navigate to="/chats" replace />} />
<Route element={<RouteBoundary scope="chats" />}>
<Route path="/chats" element={<ChatsPage />}>
<Route index element={<ChatsEmptyState />} />
<Route
path=":id"
element={
<ErrorBoundary scope="conversation">
<ConversationPage />
</ErrorBoundary>
}
/>
</Route>
</Route>
<Route element={<RouteBoundary scope="friends" />}>
<Route path="/friends" element={<FriendsPage />} />
</Route>
<Route element={<RouteBoundary scope="settings" />}>
<Route path="/settings" element={<SettingsPage />} />
</Route>
<Route element={<RequireAdmin />}>
<Route element={<RouteBoundary scope="admin" />}>
<Route path="/admin" element={<AdminPage />} />
</Route>
</Route>
</Route>
</Route>
</Route>
<Route path="*" element={<Navigate to="/chats" replace />} />
</Routes>
<UpdateToast />
</BrowserRouter>
</CallProvider>
</ConversationsProvider>
</FriendshipsProvider>
</AuthProvider>
</ThemeProvider>
</ErrorBoundary>
);
}
+14 -6
View File
@@ -1,20 +1,28 @@
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { startConversationKeySync } from '../lib/conversationKeySync';
import { ensureNotificationPermission } from '../lib/osNotify';
import { BackupPromptBanner } from './BackupPromptBanner';
import { CallUI } from './CallUI';
import { Sidebar } from './Sidebar';
import { UpdateToast } from './UpdateToast';
export function AppShell() {
const { session, device } = useAuth();
useEffect(() => {
// Prompt once per authenticated shell mount. Module-level guard prevents
// re-asking if the user already responded this session.
void ensureNotificationPermission();
}, []);
useEffect(() => {
if (!session?.user.id || !device?.id) return;
return startConversationKeySync(session.user.id, device.id);
}, [session?.user.id, device?.id]);
return (
<div className="relative flex min-h-screen overflow-hidden bg-ink-950 text-neutral-100">
<div className="relative flex min-h-screen overflow-hidden bg-surface text-fg">
<ShellBackground />
<div className="relative z-10 flex min-h-screen w-full">
<Sidebar />
@@ -25,16 +33,16 @@ export function AppShell() {
</main>
</div>
<CallUI />
<UpdateToast />
<BackupPromptBanner />
</div>
);
}
// Calmer than the auth-screen background — full-bleed grid + 2 large blobs.
// No animation here so message lists stay readable.
// Subtle ambient background — only renders in dark mode where the app's
// visual language expects depth. Light mode stays clean and flat.
function ShellBackground() {
return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<div aria-hidden="true" className="pointer-events-none absolute inset-0 hidden dark:block">
<div className="bg-grid absolute inset-0 opacity-[0.18]" />
<div className="absolute -left-32 top-1/4 h-[420px] w-[420px] rounded-full bg-brand-500/20 blur-3xl" />
<div className="absolute -right-32 bottom-0 h-[420px] w-[420px] rounded-full bg-fuchsia-500/10 blur-3xl" />
@@ -0,0 +1,239 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useMemo, useRef, useState } from 'react';
import { supabase } from '../lib/supabase';
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
const BAR_COUNT = 48;
// Custom voice-message player with waveform visualisation. Decoded peaks are
// computed once per blob via OfflineAudioContext so playback only carries the
// rendered DOM. Falls back to a rectangular bar if decoding fails (e.g. the
// blob mime is recognised by <audio> but not by AudioContext).
export function AttachmentAudio({ handle }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [arrayBuf, setArrayBuf] = useState<ArrayBuffer | null>(null);
const [error, setError] = useState<string | null>(null);
const [peaks, setPeaks] = useState<number[] | null>(null);
const [duration, setDuration] = useState<number>(0);
const [position, setPosition] = useState<number>(0);
const [playing, setPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
let cancelled = false;
let url: string | null = null;
setError(null);
setBlobUrl(null);
setArrayBuf(null);
downloadAndDecryptAttachment({ client: supabase, handle })
.then(async (blob) => {
if (cancelled) return;
url = URL.createObjectURL(blob);
setBlobUrl(url);
const buf = await blob.arrayBuffer();
if (!cancelled) setArrayBuf(buf);
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
}
});
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
// Compute peaks via OfflineAudioContext. Cheap O(n) scan over PCM samples
// bucketed into BAR_COUNT bars. Done once per attachment.
useEffect(() => {
if (!arrayBuf) return;
let cancelled = false;
const Ctx =
window.AudioContext ||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
const ctx = new Ctx();
ctx
.decodeAudioData(arrayBuf.slice(0))
.then((decoded) => {
if (cancelled) return;
setDuration(decoded.duration);
const channel = decoded.getChannelData(0);
const bucket = Math.max(1, Math.floor(channel.length / BAR_COUNT));
const out = new Array<number>(BAR_COUNT).fill(0);
for (let i = 0; i < BAR_COUNT; i++) {
let max = 0;
const start = i * bucket;
const end = Math.min(channel.length, start + bucket);
for (let j = start; j < end; j++) {
const v = Math.abs(channel[j]!);
if (v > max) max = v;
}
out[i] = max;
}
// Normalize so loudest peak is 1; keeps quiet recordings visible.
const peak = Math.max(...out, 0.001);
setPeaks(out.map((v) => v / peak));
})
.catch(() => {
// Fall through — UI shows a flat bar but playback still works.
})
.finally(() => {
void ctx.close().catch(() => {});
});
return () => {
cancelled = true;
};
}, [arrayBuf]);
const fallbackPeaks = useMemo(
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
[peaks],
);
const visiblePeaks = peaks ?? fallbackPeaks!;
const progress = duration > 0 ? position / duration : 0;
const onTogglePlay = () => {
const el = audioRef.current;
if (!el || !blobUrl) return;
if (el.paused) void el.play();
else el.pause();
};
const onSeek = (e: React.MouseEvent<HTMLDivElement>) => {
const el = audioRef.current;
if (!el || duration === 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
el.currentTime = ratio * duration;
};
if (error) {
return (
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
<AlertIcon className="h-4 w-4" />
<span>{error}</span>
</div>
);
}
return (
<div className="mt-2 flex w-[280px] min-w-[280px] items-center gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2">
<button
type="button"
onClick={onTogglePlay}
disabled={!blobUrl}
aria-label={playing ? 'Pause' : 'Wiedergabe'}
title={playing ? 'Pause' : 'Wiedergabe'}
className="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
>
{!blobUrl ? (
<SpinnerIcon className="h-4 w-4" />
) : playing ? (
<PauseGlyph />
) : (
<PlayGlyph />
)}
</button>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div
role="slider"
aria-label="Position"
aria-valuemin={0}
aria-valuemax={Math.max(1, Math.floor(duration))}
aria-valuenow={Math.floor(position)}
tabIndex={0}
onClick={onSeek}
className="flex h-7 cursor-pointer items-center gap-[2px]"
>
{visiblePeaks.map((v, i) => {
const playedRatio = (i + 0.5) / BAR_COUNT;
const played = playedRatio <= progress;
const h = Math.max(2, Math.round(v * 22));
return (
<span
key={i}
style={{ height: h + 'px' }}
className={
'w-[3px] rounded-full ' +
(played ? 'bg-accent' : 'bg-fg-muted/40')
}
/>
);
})}
</div>
<div className="flex items-center justify-between text-[10px] tabular-nums text-fg-muted">
<span className="inline-flex items-center gap-1">
<MicIcon className="h-3 w-3" />
<span>{formatSec(playing ? position : duration)}</span>
</span>
</div>
</div>
{blobUrl && (
<audio
ref={audioRef}
src={blobUrl}
preload="metadata"
onLoadedMetadata={(e) => {
// Some webm/opus blobs report Infinity until first seek (Chrome
// bug). Force a seek to flush real duration.
const el = e.currentTarget;
if (!Number.isFinite(el.duration)) {
el.currentTime = 1e9;
setTimeout(() => {
el.currentTime = 0;
}, 0);
} else if (duration === 0) {
setDuration(el.duration);
}
}}
onDurationChange={(e) => {
const d = e.currentTarget.duration;
if (Number.isFinite(d) && d > 0) setDuration(d);
}}
onTimeUpdate={(e) => setPosition(e.currentTarget.currentTime)}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => {
setPlaying(false);
setPosition(0);
}}
className="hidden"
>
<track kind="captions" />
</audio>
)}
</div>
);
}
function PlayGlyph() {
return (
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
<path d="M5 3.5l8 4.5-8 4.5z" />
</svg>
);
}
function PauseGlyph() {
return (
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
<rect x="4" y="3" width="3" height="10" rx="1" />
<rect x="9" y="3" width="3" height="10" rx="1" />
</svg>
);
}
function formatSec(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) sec = 0;
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return m + ':' + s.toString().padStart(2, '0');
}
+45
View File
@@ -0,0 +1,45 @@
// Reusable avatar that prefers an uploaded image and falls back to a coloured
// letter circle. Use this everywhere the app needs to render a profile.
interface Props {
url?: string | null | undefined;
displayName?: string | null | undefined;
className?: string;
// Background gradient classes used when no image is set. Defaults to a
// brand-tinted fallback; callers can override (e.g. to colour-by-id).
fallbackClass?: string;
alt?: string;
}
export function Avatar({
url,
displayName,
className = 'h-10 w-10',
fallbackClass = 'bg-accent/20 text-accent',
alt,
}: Props) {
if (url) {
return (
<img
src={url}
alt={alt ?? displayName ?? ''}
className={'shrink-0 rounded-full object-cover ' + className}
draggable={false}
/>
);
}
const letter = (displayName ?? '?').trim().charAt(0).toUpperCase() || '?';
return (
<div
aria-hidden={alt ? undefined : 'true'}
className={
'flex shrink-0 items-center justify-center rounded-full font-semibold ' +
fallbackClass +
' ' +
className
}
>
{letter}
</div>
);
}
@@ -0,0 +1,306 @@
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>
);
}
@@ -0,0 +1,122 @@
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}
/>
)}
</>
);
}
@@ -0,0 +1,194 @@
import { useTranslation } from 'react-i18next';
import {
HeadphonesIcon,
HeadphonesOffIcon,
MicIcon,
MicOffIcon,
MonitorShareIcon,
MonitorStopIcon,
PhoneOffIcon,
UsersIcon,
VideoIcon,
} from './icons';
interface Props {
muted: boolean;
sharing: boolean;
video: boolean;
deafened: boolean;
onToggleMute: () => void;
onToggleShare: () => void;
onToggleVideo?: () => void;
onToggleDeafen: () => void;
onHangup: () => void;
onOpenParticipants?: () => void;
/** Compact variant used inside the docked call (36px buttons). */
compact?: boolean;
/** Glass variant used when controls float on fullscreen cinema mode. */
glass?: boolean;
/** Disable transient interactions while the call isn't fully connected. */
disabledMedia?: boolean;
}
export function CallControls({
muted,
sharing,
video,
deafened,
onToggleMute,
onToggleShare,
onToggleVideo,
onToggleDeafen,
onHangup,
onOpenParticipants,
compact = false,
glass = false,
disabledMedia = false,
}: Props) {
const { t } = useTranslation(['app']);
const wrapBase = glass
? 'glass-pill flex items-center justify-center gap-2 rounded-[18px] p-2.5'
: 'flex items-center justify-center gap-2 border-t border-line bg-surface-3 p-3';
const btnSize = compact ? 'h-9 w-9 rounded-[10px]' : 'h-12 w-12 rounded-[14px]';
const hangupSize = compact ? 'h-9 w-[54px] rounded-[10px]' : 'h-12 w-[72px] rounded-[14px]';
return (
<div className={wrapBase}>
<CallButton
label={muted ? t('app:call.unmute') : t('app:call.mute')}
active={muted}
activeTone="danger"
onClick={onToggleMute}
disabled={disabledMedia}
glass={glass}
className={btnSize}
>
{muted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />}
</CallButton>
<CallButton
label={
deafened
? t('app:call.undeafen', { defaultValue: 'Ton wieder aktiv' })
: t('app:call.deafen', { defaultValue: 'Alle stumm' })
}
active={deafened}
activeTone="danger"
onClick={onToggleDeafen}
glass={glass}
className={btnSize}
>
{deafened ? (
<HeadphonesOffIcon className="h-5 w-5" />
) : (
<HeadphonesIcon className="h-5 w-5" />
)}
</CallButton>
{onToggleVideo && (
<CallButton
label={t('app:call.start_video', { defaultValue: 'Video' })}
active={video}
activeTone="accent"
onClick={onToggleVideo}
disabled={disabledMedia}
glass={glass}
className={btnSize}
>
<VideoIcon className="h-5 w-5" />
</CallButton>
)}
<CallButton
label={
sharing
? t('app:call.stop_share_screen', { defaultValue: 'Screen-Share stoppen' })
: t('app:call.share_screen', { defaultValue: 'Bildschirm teilen' })
}
active={sharing}
activeTone="accent"
onClick={onToggleShare}
disabled={disabledMedia}
glass={glass}
className={btnSize}
>
{sharing ? (
<MonitorStopIcon className="h-5 w-5" />
) : (
<MonitorShareIcon className="h-5 w-5" />
)}
</CallButton>
{onOpenParticipants && (
<CallButton
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
onClick={onOpenParticipants}
glass={glass}
className={btnSize}
>
<UsersIcon className="h-5 w-5" />
</CallButton>
)}
<CallButton
label={t('app:call.hangup')}
onClick={onHangup}
tone="danger"
glass={glass}
className={hangupSize}
>
<PhoneOffIcon className="h-5 w-5" />
</CallButton>
</div>
);
}
interface CallButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
active?: boolean;
activeTone?: 'accent' | 'danger';
tone?: 'default' | 'danger';
glass?: boolean;
className?: string;
children: React.ReactNode;
}
function CallButton({
label,
onClick,
disabled,
active,
activeTone = 'accent',
tone = 'default',
glass = false,
className = '',
children,
}: CallButtonProps) {
const base =
'inline-flex cursor-pointer items-center justify-center border transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-50';
let toneClass: string;
if (tone === 'danger') {
toneClass = 'bg-rose-600 text-white border-rose-600 hover:bg-rose-500';
} else if (active && activeTone === 'danger') {
toneClass = 'bg-rose-600 text-white border-rose-600 hover:bg-rose-500';
} else if (active) {
toneClass = 'bg-accent text-accent-fg border-accent hover:brightness-110';
} else if (glass) {
toneClass =
'border-white/15 bg-white/10 text-white hover:bg-white/15';
} else {
toneClass = 'border-line bg-surface-2 text-fg hover:bg-surface';
}
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
aria-pressed={active}
title={label}
className={`${base} ${toneClass} ${className}`}
>
{children}
</button>
);
}
@@ -0,0 +1,262 @@
import { useEffect, useRef } from 'react';
import { CrownIcon, HeadphonesOffIcon, LockIcon, MicOffIcon } from './icons';
export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal';
const AVATAR_COLORS: Record<AvatarColorKey, { bg: string; fg: string }> = {
// Tailwind classes tuned per spec: violet/amber/rose/teal swatches in both
// light and dark modes. Dark pairs invert to keep contrast readable.
violet: {
bg: 'bg-violet-200 dark:bg-violet-900/60',
fg: 'text-violet-800 dark:text-violet-200',
},
amber: {
bg: 'bg-amber-200 dark:bg-amber-900/60',
fg: 'text-amber-900 dark:text-amber-200',
},
rose: {
bg: 'bg-rose-200 dark:bg-rose-900/60',
fg: 'text-rose-900 dark:text-rose-200',
},
teal: {
bg: 'bg-teal-200 dark:bg-teal-900/60',
fg: 'text-teal-900 dark:text-teal-200',
},
};
const COLOR_KEYS: AvatarColorKey[] = ['violet', 'amber', 'rose', 'teal'];
// Stable per-user color from identity string — keeps avatars visually
// consistent across re-mounts without needing a color field on the profile.
export function colorKeyFor(id: string): AvatarColorKey {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
}
return COLOR_KEYS[hash % COLOR_KEYS.length]!;
}
export interface ParticipantTileProps {
userId: string;
displayName: string;
avatarUrl: string | null;
me: boolean;
muted: boolean;
/** Local-only: true when THIS user has muted everyone else via the deafen
* toggle. Remote deafen state is not propagated, so only the own tile
* ever carries a truthy value. */
deafened: boolean;
speaking: boolean;
video: boolean;
e2ee: boolean;
/** MediaStreamTrack for the participant's active camera, when `video` is
* true. When null the tile falls back to the avatar placeholder. */
videoTrack?: MediaStreamTrack | null;
size?: 'default' | 'small';
focused?: boolean;
onClick?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
}
export function CallParticipantTile(props: ParticipantTileProps) {
const {
displayName,
me,
muted,
deafened,
speaking,
video,
e2ee,
size = 'default',
focused = false,
onClick,
onContextMenu,
} = props;
const small = size === 'small';
const borderClass = speaking
? 'border-emerald-500 shadow-[0_0_0_2px_rgba(22,163,74,0.25)] dark:border-emerald-400'
: focused
? 'border-accent'
: 'border-line hover:border-accent';
return (
<div
onClick={onClick}
onContextMenu={onContextMenu}
className={
'relative flex flex-col overflow-hidden rounded-[14px] border bg-surface-3 transition ' +
(onClick ? 'cursor-pointer ' : '') +
borderClass +
(small ? ' min-w-[140px]' : '')
}
>
{video ? (
<VideoStub {...props} small={small} />
) : (
<AudioContent {...props} small={small} />
)}
{/* Speaking indicator visible regardless of content type (video or
audio). z-10 ensures it sits above the video element. */}
{speaking && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-10 rounded-[14px] border-[3px] border-emerald-400 shadow-[inset_0_0_18px_rgba(34,197,94,0.55)]"
/>
)}
<div className="pointer-events-none absolute inset-x-2 bottom-2 flex items-center justify-between gap-2 rounded-lg glass-chip px-2.5 py-1.5">
<div className="flex min-w-0 items-center gap-1.5 text-xs font-semibold">
{me && <CrownIcon className="h-3 w-3 shrink-0 text-amber-300" />}
<span className="truncate">
{displayName}
{me ? ' (du)' : ''}
</span>
{e2ee && (
<span
aria-label="E2E verschlüsselt"
title="E2E verschlüsselt"
className="flex opacity-70"
>
<LockIcon className="h-3 w-3" />
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{muted && (
<span
aria-label="Mikro stumm"
title="Mikro stumm"
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
>
<MicOffIcon className="h-3 w-3" />
</span>
)}
{deafened && (
<span
aria-label="Ton aus"
title="Ton aus"
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
>
<HeadphonesOffIcon className="h-3 w-3" />
</span>
)}
</div>
</div>
</div>
);
}
function AudioContent({
userId,
displayName,
avatarUrl,
speaking,
small,
}: ParticipantTileProps & { small: boolean }) {
const key = colorKeyFor(userId);
const colors = AVATAR_COLORS[key];
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
return (
<div className="flex min-h-0 flex-1 items-center justify-center p-4">
<div
className={
'relative flex items-center justify-center rounded-full ' +
(small ? 'h-11 w-11' : 'h-[72px] w-[72px]')
}
>
{speaking && (
<span
aria-hidden="true"
className="absolute -inset-1.5 animate-audio-pulse rounded-full border-2 border-emerald-500 dark:border-emerald-400"
/>
)}
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="relative h-full w-full rounded-full object-cover"
/>
) : (
<span
className={
'relative flex h-full w-full items-center justify-center rounded-full font-bold ' +
colors.bg + ' ' + colors.fg + ' ' +
(small ? 'text-lg' : 'text-2xl')
}
>
{letter}
</span>
)}
</div>
</div>
);
}
function VideoStub({
userId,
displayName,
avatarUrl,
videoTrack,
me,
small,
}: ParticipantTileProps & { small: boolean }) {
const videoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
const el = videoRef.current;
if (!el || !videoTrack) return;
el.srcObject = new MediaStream([videoTrack]);
return () => {
if (el.srcObject) {
(el.srcObject as MediaStream).getTracks().forEach((t) => {
// Don't stop the live track — other consumers may still need it.
// Just detach from this element.
void t;
});
el.srcObject = null;
}
};
}, [videoTrack]);
if (videoTrack) {
return (
<div className="relative flex min-h-0 flex-1 items-center justify-center bg-black">
<video
ref={videoRef}
autoPlay
playsInline
muted
className={
'h-full w-full object-cover ' +
(me ? 'scale-x-[-1]' : '') /* mirror local preview */
}
/>
</div>
);
}
// Camera flag true but no track yet (publishing / subscribing race).
const key = colorKeyFor(userId);
const colors = AVATAR_COLORS[key];
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
return (
<div className="flex min-h-0 flex-1 items-center justify-center bg-gradient-to-br from-accent/20 to-surface-2 p-4">
<div
className={
'flex items-center justify-center rounded-full font-bold ' +
colors.bg + ' ' + colors.fg + ' ' +
(small ? 'h-11 w-11 text-lg' : 'h-[72px] w-[72px] text-2xl')
}
>
{avatarUrl ? (
<img src={avatarUrl} alt="" className="h-full w-full rounded-full object-cover" />
) : (
letter
)}
</div>
</div>
);
}
+126 -252
View File
@@ -1,26 +1,20 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { useFriendshipsContext } from '../context/FriendshipsContext';
import { ringtone } from '../lib/ringtone';
import { useAnyActiveCall } from '../lib/useAnyActiveCall';
import { useCallPresence } from '../lib/useCallPresence';
import {
MicIcon,
MicOffIcon,
PhoneIcon,
PhoneOffIcon,
SpinnerIcon,
XIcon,
} from './icons';
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
import { LockIcon, MonitorShareIcon, PhoneIcon, PhoneOffIcon } from './icons';
// Shell-level mount. Handles ringtones + the IncomingCallToast.
// The persistent CallBar (active-call widget) is rendered inside Sidebar so
// users can keep browsing/typing while a call is live.
// Shell-level mount. Responsibilities:
// - Start/stop ringtones by call-state transition.
// - Toast-style incoming-call banner for conversations the user isn't in
// (clicking the toast routes to the docked IncomingCallPanel).
// - PiP widget when an active call is live but the user is looking at a
// different route.
export function CallUI() {
const { state } = useCall();
@@ -34,19 +28,37 @@ export function CallUI() {
return () => ringtone.stop();
}, []);
return <IncomingCallToast />;
return (
<>
<IncomingCallToast />
<PipCall />
</>
);
}
// ---------------------------------------------------------------------------
const TOAST_AVATAR: Record<AvatarColorKey, string> = {
violet: 'bg-violet-500/90 text-white',
amber: 'bg-amber-500/90 text-white',
rose: 'bg-rose-500/90 text-white',
teal: 'bg-teal-500/90 text-white',
};
function IncomingCallToast() {
const { t } = useTranslation(['app']);
const { state, acceptIncoming, rejectIncoming } = useCall();
const { friendships } = useFriendshipsContext();
const { conversations } = useConversationsContext();
const navigate = useNavigate();
const location = useLocation();
if (state.kind !== 'incoming') return null;
// When the user is already looking at the target conversation, the docked
// IncomingCallPanel handles the UI; hide the toast to avoid double chrome.
if (location.pathname === '/chats/' + state.conversationId) return null;
const conv = conversations.find((c) => c.id === state.conversationId) ?? null;
const callerName =
conv?.members.find((m) => m.userId === state.fromUserId)?.profile?.displayName ??
@@ -54,44 +66,55 @@ function IncomingCallToast() {
'?';
const isGroup = conv?.type === 'group';
const groupName = isGroup ? (conv?.name ?? t('app:chats.new_group')) : null;
const letter = (isGroup ? groupName ?? callerName : callerName)
.trim()
.charAt(0)
.toUpperCase() || '?';
const title = isGroup ? groupName ?? callerName : callerName;
const letter = title.trim().charAt(0).toUpperCase() || '?';
const color = colorKeyFor(state.fromUserId);
return (
<div
role="dialog"
aria-modal="false"
aria-label={t('app:call.incoming_title')}
className="fixed bottom-6 right-6 z-50 w-80 animate-slide-up rounded-2xl border border-white/10 bg-ink-900/95 p-5 shadow-2xl backdrop-blur-xl"
className="fixed bottom-6 right-6 z-50 w-80 animate-slide-up overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-call-card-dark"
>
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">
{t('app:call.incoming_title')}
</p>
<div className="mt-3 flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-base font-semibold text-white ring-1 ring-brand-400/30">
{letter}
<button
type="button"
onClick={() => navigate('/chats/' + state.conversationId)}
className="group block w-full cursor-pointer p-5 text-left transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
{t('app:call.incoming_title')}
</p>
<div className="mt-2 flex items-center gap-3">
<div
className={
'flex h-11 w-11 shrink-0 items-center justify-center rounded-full font-semibold ' +
TOAST_AVATAR[color]
}
>
{letter}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
<p className="flex items-center gap-1.5 truncate text-xs text-fg-muted">
<LockIcon className="h-3 w-3" />
<span>
{isGroup
? t('app:call.incoming_group_from', {
name: callerName,
defaultValue: callerName + ' ruft Gruppe',
})
: t('app:call.incoming_from', { name: callerName })}
</span>
</p>
</div>
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-display text-base font-semibold text-white">
{isGroup ? groupName : callerName}
</p>
<p className="truncate text-xs text-neutral-400">
{isGroup
? t('app:call.incoming_group_from', {
name: callerName,
defaultValue: callerName + ' ruft Gruppe',
})
: t('app:call.incoming_from', { name: callerName })}
</p>
</div>
</div>
<div className="mt-4 flex gap-2">
</button>
<div className="flex gap-2 px-5 pb-5">
<button
type="button"
onClick={rejectIncoming}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm font-semibold text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40"
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg border border-rose-500/40 bg-transparent px-3 py-2 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/40 dark:text-rose-400"
>
<PhoneOffIcon className="h-4 w-4" />
<span>{t('app:call.decline')}</span>
@@ -99,7 +122,7 @@ function IncomingCallToast() {
<button
type="button"
onClick={() => void acceptIncoming()}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-500/90 px-3 py-2 text-sm font-semibold text-white transition hover:bg-emerald-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-600 px-3 py-2 text-sm font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
>
<PhoneIcon className="h-4 w-4" />
<span>{t('app:call.accept')}</span>
@@ -110,226 +133,77 @@ function IncomingCallToast() {
}
// ---------------------------------------------------------------------------
// CallBar — persistent widget inside Sidebar. Lets the user keep browsing
// the app while a call is active / connecting / ringing.
// PiP widget — shown when the user has an active call but is browsing
// somewhere else. Clicking expands back to the call's conversation.
// ---------------------------------------------------------------------------
export function CallBar() {
const { state, lastCallConversationId } = useCall();
const { conversations } = useConversationsContext();
const { session } = useAuth();
const myId = session?.user.id ?? null;
// Aggregate observer across every conversation the user is in so a
// "call is live, rejoin" affordance appears in the sidebar whenever ANY
// peer is in a call — not just calls I previously joined.
const convIds = conversations.map((c) => c.id);
const anyActive = useAnyActiveCall(convIds, myId);
if (state.kind === 'connected' || state.kind === 'connecting' || state.kind === 'outgoing') {
return <ActiveCallBar />;
}
// Prefer the conversation I just left, fall back to any other live call.
const rejoinId = lastCallConversationId ?? anyActive?.conversationId ?? null;
if (rejoinId) {
return <RejoinCallBar conversationId={rejoinId} />;
}
return null;
}
function ActiveCallBar() {
function PipCall() {
const { t } = useTranslation(['app']);
const { state, isMuted, hangup, toggleMute } = useCall();
const {
state,
remoteParticipants,
remoteScreenShares,
hangup,
} = useCall();
const navigate = useNavigate();
const location = useLocation();
const { conversations } = useConversationsContext();
const [, forceTick] = useState(0);
useEffect(() => {
if (state.kind !== 'connected') return;
const id = window.setInterval(() => forceTick((v) => v + 1), 1000);
return () => window.clearInterval(id);
}, [state.kind]);
const active =
state.kind === 'connected' ||
state.kind === 'connecting' ||
state.kind === 'outgoing';
if (!active) return null;
if (state.kind !== 'connected' && state.kind !== 'connecting' && state.kind !== 'outgoing') {
return null;
}
// Only render when the user is NOT currently viewing the call's
// conversation. Inside that conversation the full dock is visible already.
if (location.pathname === '/chats/' + state.conversationId) return null;
const conv = conversations.find((c) => c.id === state.conversationId) ?? null;
const title =
conv?.type === 'group'
? conv.name ?? '—'
? conv?.name ?? t('app:chats.new_group')
: conv?.peer?.displayName ?? '—';
const statusLabel =
state.kind === 'outgoing'
? t('app:call.outgoing_ringing')
: state.kind === 'connecting'
? t('app:call.connecting')
: t('app:call.voice_connected', { defaultValue: 'Sprachchat verbunden' });
const elapsed =
state.kind === 'connected'
? formatElapsed(Date.now() - new Date(state.startedAt).getTime())
: null;
const participantCount = 1 + remoteParticipants.length;
const someoneSharing = remoteScreenShares.length > 0;
return (
<div className="mb-2 overflow-hidden rounded-xl border border-emerald-500/20 bg-ink-900/80">
<Link
to={'/chats/' + state.conversationId}
className="flex items-center gap-2.5 px-3 pt-2.5 pb-2 transition hover:bg-white/5 focus:outline-none"
aria-label={statusLabel}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/15 text-emerald-300">
{state.kind === 'connecting' ? (
<SpinnerIcon className="h-4 w-4" />
) : (
<SignalIcon className="h-4 w-4" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-emerald-300">{statusLabel}</p>
<p className="truncate text-xs text-neutral-400">
{title}
{elapsed && <span className="ml-1.5 font-mono text-neutral-500">{elapsed}</span>}
</p>
</div>
</Link>
<div className="grid grid-cols-2 gap-1 border-t border-white/5 bg-ink-950/40 p-1.5">
<IconTile
onClick={toggleMute}
disabled={state.kind !== 'connected'}
label={isMuted ? t('app:call.unmute') : t('app:call.mute')}
tone={isMuted ? 'amber' : 'neutral'}
>
{isMuted ? <MicOffIcon className="h-4 w-4" /> : <MicIcon className="h-4 w-4" />}
</IconTile>
<IconTile onClick={() => void hangup()} label={t('app:call.hangup')} tone="rose">
<PhoneOffIcon className="h-4 w-4" />
</IconTile>
</div>
</div>
);
}
function RejoinCallBar({ conversationId }: { conversationId: string }) {
const { t } = useTranslation(['app', 'common']);
const { joinActiveCall, dismissLastCall } = useCall();
const { conversations } = useConversationsContext();
const { session } = useAuth();
const active = useCallPresence(conversationId);
const myId = session?.user.id;
const others = active.filter((u) => u !== myId);
// Presence polling returns [] on the first tick before it syncs, so we can't
// treat an initial empty list as "room is empty". Only dismiss after we've
// actually seen peers and then watched them leave — and even then confirm
// the empty state for a grace period, since presence_diff events can
// arrive slightly out of order.
const [visible, setVisible] = useState(false);
useEffect(() => {
if (others.length > 0) {
setVisible(true);
return;
}
if (!visible) return;
const id = window.setTimeout(() => {
setVisible(false);
dismissLastCall();
}, 2500);
return () => window.clearTimeout(id);
}, [others.length, visible, dismissLastCall]);
if (!visible) return null;
const conv = conversations.find((c) => c.id === conversationId) ?? null;
const title =
conv?.type === 'group'
? conv.name ?? '—'
: conv?.peer?.displayName ?? '—';
return (
<div className="mb-2 overflow-hidden rounded-xl border border-emerald-500/20 bg-ink-900/80">
<Link
to={'/chats/' + conversationId}
className="flex items-center gap-2.5 px-3 pt-2.5 pb-2 transition hover:bg-white/5 focus:outline-none"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/15 text-emerald-300">
<SignalIcon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-emerald-300">
{t('app:call.still_live', { defaultValue: 'Anruf läuft noch' })}
</p>
<p className="truncate text-xs text-neutral-400">
{title} · {others.length}
</p>
</div>
</Link>
<div className="grid grid-cols-2 gap-1 border-t border-white/5 bg-ink-950/40 p-1.5">
<IconTile
onClick={() => void joinActiveCall(conversationId, 'audio')}
label={t('app:call.join')}
tone="emerald"
>
<PhoneIcon className="h-4 w-4" />
</IconTile>
<IconTile onClick={dismissLastCall} label={t('common:close', { defaultValue: 'Schließen' })} tone="neutral">
<XIcon className="h-4 w-4" />
</IconTile>
</div>
</div>
);
}
interface IconTileProps {
onClick: () => void;
label: string;
tone: 'neutral' | 'amber' | 'rose' | 'emerald';
disabled?: boolean;
children: React.ReactNode;
}
function IconTile({ onClick, label, tone, disabled, children }: IconTileProps) {
const toneClass =
tone === 'rose'
? 'bg-white/5 text-rose-300 hover:bg-rose-500/20 focus-visible:ring-rose-400/40'
: tone === 'amber'
? 'bg-amber-500/20 text-amber-200 hover:bg-amber-500/30 focus-visible:ring-amber-400/40'
: tone === 'emerald'
? 'bg-emerald-500/20 text-emerald-200 hover:bg-emerald-500/30 focus-visible:ring-emerald-400/40'
: 'bg-white/5 text-neutral-200 hover:bg-white/10 focus-visible:ring-brand-400/40';
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className={
'inline-flex h-9 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 ' +
toneClass
}
<div
role="dialog"
aria-label={t('app:call.active_in_conv', { defaultValue: 'Aktiver Anruf' })}
onClick={() => navigate('/chats/' + state.conversationId)}
className="fixed bottom-5 right-5 z-40 flex w-[260px] animate-slide-in-call cursor-pointer items-center gap-2.5 rounded-[14px] border border-accent bg-surface-3 p-2.5 shadow-pip-call transition hover:-translate-y-0.5"
>
{children}
</button>
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-surface-2">
{someoneSharing ? (
<MonitorShareIcon className="h-5 w-5 text-accent" />
) : (
<PhoneIcon className="h-5 w-5 text-accent" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-semibold text-fg">
{title} · {participantCount}
</p>
<p className="mt-0.5 flex items-center gap-1.5 text-[11px] text-fg-muted">
<span
aria-hidden="true"
className="h-1.5 w-1.5 rounded-full bg-rose-500 animate-live-dot"
/>
<span>Live · {t('app:call.tap_to_open', { defaultValue: 'tippe zum Öffnen' })}</span>
</p>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void hangup();
}}
aria-label={t('app:call.hangup')}
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full bg-rose-600 text-white transition hover:bg-rose-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60"
>
<PhoneOffIcon className="h-4 w-4" />
</button>
</div>
);
}
function SignalIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" {...props}>
<path d="M5 12v0" />
<path d="M9 9v6" />
<path d="M13 6v12" />
<path d="M17 9v6" />
</svg>
);
}
function formatElapsed(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const mm = Math.floor(total / 60).toString().padStart(2, '0');
const ss = (total % 60).toString().padStart(2, '0');
return mm + ':' + ss;
}
@@ -1,31 +1,39 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import type { PresenceState } from '@chat-app/shared/supabase';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useCallPresence } from '../lib/useCallPresence';
import { InfoIcon, PhoneIcon, SpinnerIcon, UsersIcon } from './icons';
import { Avatar } from './Avatar';
import {
InfoIcon,
PhoneIcon,
SearchIcon,
SpinnerIcon,
UsersIcon,
VideoIcon,
} from './icons';
const PRESENCE_DOT: Record<PresenceState, string> = {
online: 'bg-emerald-400',
online: 'bg-emerald-500',
idle: 'bg-amber-400',
dnd: 'bg-rose-500',
invisible: 'bg-neutral-500',
offline: 'bg-neutral-600',
offline: 'bg-neutral-400 dark:bg-neutral-600',
};
interface Props {
conversation: ConversationSummary | null;
peerPresence: PresenceState | null;
onInfoClick?: () => void;
onSearchClick?: () => void;
}
export function ConversationHeader({ conversation, peerPresence, onInfoClick }: Props) {
export function ConversationHeader({ conversation, peerPresence, onInfoClick, onSearchClick }: Props) {
if (!conversation) {
return (
<header className="h-[57px] border-b border-white/5 px-6 py-3" aria-busy="true" />
);
return <header className="h-[57px] border-b border-line px-6 py-3" aria-busy="true" />;
}
return (
@@ -34,6 +42,7 @@ export function ConversationHeader({ conversation, peerPresence, onInfoClick }:
conversation={conversation}
peerPresence={peerPresence}
{...(onInfoClick ? { onInfoClick } : {})}
{...(onSearchClick ? { onSearchClick } : {})}
/>
<ActiveCallBanner conversationId={conversation.id} />
</>
@@ -44,43 +53,52 @@ interface HeaderBarProps {
conversation: ConversationSummary;
peerPresence: PresenceState | null;
onInfoClick?: () => void;
onSearchClick?: () => void;
}
function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps) {
function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: HeaderBarProps) {
const { t } = useTranslation(['app']);
const isDm = conversation.type === 'dm';
const title = isDm ? (conversation.peer?.displayName ?? '?') : (conversation.name ?? '?');
const handle = isDm ? '@' + (conversation.peer?.username ?? '?') : '';
const letter = title.trim().charAt(0).toUpperCase() || '?';
const peerAvatar = isDm ? (conversation.peer?.avatarUrl ?? null) : (conversation.avatarUrl ?? null);
// Hide presence when peer chose invisible — reciprocal privacy.
const showPresence = isDm && peerPresence && peerPresence !== 'invisible';
const presenceLabel = peerPresence ? t('app:presence.' + peerPresence) : '';
return (
<header className="flex items-center gap-3 border-b border-white/5 px-6 py-3">
<div className="relative flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-base font-semibold text-white ring-1 ring-brand-400/30">
{isDm ? letter : <UsersIcon className="h-5 w-5" />}
<header className="flex items-center gap-3 border-b border-line bg-surface-3 px-6 py-3">
<div className="relative">
{isDm ? (
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
) : peerAvatar ? (
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/20 text-accent">
<UsersIcon className="h-5 w-5" />
</div>
)}
{showPresence && peerPresence && (
<span
aria-hidden="true"
className={
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-ink-950 ' +
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-3 ' +
PRESENCE_DOT[peerPresence]
}
/>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-display text-base font-semibold text-white">{title}</p>
<p className="truncate text-xs text-neutral-500">
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
<p className="truncate text-xs text-fg-muted">
{isDm ? (
<>
<span>{handle}</span>
{showPresence && (
<>
<span className="mx-1.5 text-neutral-700">·</span>
<span className="mx-1.5 text-fg-muted/50">·</span>
<span>{presenceLabel}</span>
</>
)}
@@ -93,75 +111,133 @@ function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps)
</p>
</div>
<CallHeaderButton conversationId={conversation.id} />
<HeaderActionButton
label={t('app:chats.search', { defaultValue: 'Suche' })}
icon={SearchIcon}
{...(onSearchClick ? { onClick: onSearchClick } : {})}
/>
<CallHeaderButton conversationId={conversation.id} kind="audio" />
<CallHeaderButton conversationId={conversation.id} kind="video" />
{!isDm && onInfoClick && (
<button
type="button"
<HeaderActionButton
label={t('app:group.info_title')}
icon={InfoIcon}
onClick={onInfoClick}
aria-label={t('app:group.info_title')}
title={t('app:group.info_title')}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<InfoIcon className="h-4 w-4" />
</button>
/>
)}
</header>
);
}
function CallHeaderButton({ conversationId }: { conversationId: string }) {
const { t } = useTranslation(['app']);
const { state, startCall } = useCall();
const busy = state.kind !== 'idle';
interface HeaderActionButtonProps {
label: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
onClick?: () => void;
disabled?: boolean;
tone?: 'default' | 'accent';
}
function HeaderActionButton({
label,
icon: Icon,
onClick,
disabled,
tone = 'default',
}: HeaderActionButtonProps) {
const toneClass =
tone === 'accent'
? 'text-accent hover:bg-accent/10'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg';
return (
<button
type="button"
onClick={() => void startCall(conversationId, 'audio')}
disabled={busy}
aria-label={t('app:call.start_audio')}
title={busy ? t('app:call.busy') : t('app:call.start_audio')}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-brand-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:cursor-not-allowed disabled:opacity-50"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className={
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 ' +
toneClass
}
>
<PhoneIcon className="h-4 w-4" />
<Icon className="h-4 w-4" />
</button>
);
}
function CallHeaderButton({
conversationId,
kind,
}: {
conversationId: string;
kind: 'audio' | 'video';
}) {
const { t } = useTranslation(['app']);
const { state, startCall } = useCall();
const busy = state.kind !== 'idle';
const label =
kind === 'video'
? t('app:call.start_video', { defaultValue: 'Video-Anruf' })
: t('app:call.start_audio');
const Icon = kind === 'video' ? VideoIcon : PhoneIcon;
return (
<HeaderActionButton
label={busy ? t('app:call.busy') : label}
icon={Icon}
onClick={() => void startCall(conversationId, kind)}
disabled={busy}
/>
);
}
function ActiveCallBanner({ conversationId }: { conversationId: string }) {
const { t } = useTranslation(['app']);
const { session } = useAuth();
const { state, joinActiveCall } = useCall();
const { state, joinActiveCall, lastCallConversationId, dismissLastCall } = useCall();
const active = useCallPresence(conversationId);
const myId = session?.user.id;
// Source of truth for "am I in this call" is the CallContext state, not
// presence — my own presence entry can lag behind the room join, which
// would otherwise make the banner flash while I'm already connected.
const iAmIn =
(state.kind === 'connected' ||
state.kind === 'connecting' ||
state.kind === 'outgoing') &&
state.conversationId === conversationId;
// Only show banner when other people are in it and I'm not.
const othersIn = active.filter((u) => u !== myId);
if (iAmIn || othersIn.length === 0) return null;
// Fallback signal: I just left this conv with peers still inside. Covers the
// brief window after hangup where presence may not have re-synced yet (the
// realtime channel can churn while ConversationHeader remounts).
const justLeft = state.kind === 'idle' && lastCallConversationId === conversationId;
// Once presence confirms the room is empty, drop the "just left" hint so the
// banner hides cleanly instead of sticking forever. Grace window handles the
// brief gap between hangup and presence re-sync so we don't flicker.
useEffect(() => {
if (!justLeft) return;
if (othersIn.length > 0) return; // still live — keep banner
const id = window.setTimeout(() => dismissLastCall(), 3000);
return () => window.clearTimeout(id);
}, [justLeft, othersIn.length, dismissLastCall]);
if (iAmIn) return null;
if (othersIn.length === 0 && !justLeft) return null;
const count = Math.max(othersIn.length, justLeft ? 1 : 0);
const busy = state.kind !== 'idle';
return (
<div className="flex items-center gap-3 border-b border-emerald-500/20 bg-emerald-500/10 px-6 py-2.5 text-sm text-emerald-100">
<PhoneIcon className="h-4 w-4 text-emerald-300" />
<div className="flex items-center gap-3 border-b border-emerald-500/30 bg-emerald-500/10 px-6 py-2.5 text-sm text-emerald-700 dark:text-emerald-100">
<PhoneIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />
<span className="flex-1">
{t('app:call.active_in_conv', {
defaultValue: 'Active call · {{count}} in room',
count: othersIn.length,
count,
})}
</span>
<button
type="button"
onClick={() => void joinActiveCall(conversationId, 'audio')}
disabled={busy}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-emerald-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
className="inline-flex 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" />
@@ -0,0 +1,264 @@
import {
muteDurationToIso,
setConversationArchived,
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 { supabase } from '../lib/supabase';
import { ArchiveIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
interface Props {
conversationId: string;
archived: boolean;
mutedUntil: string | null;
}
interface MuteOption {
key: string;
labelKey: string;
labelDefault: string;
minutes: number | null;
}
// Muted-forever sentinel ≈ 100 years. UI treats any future timestamp as muted
// until that moment; 100y is indistinguishable from "forever" at the UX level
// without requiring a dedicated `bool muted_forever` column.
const FOREVER_MINUTES = 100 * 365 * 24 * 60;
const MUTE_OPTIONS: MuteOption[] = [
{ key: '1h', labelKey: 'app:chats.mute_1h', labelDefault: '1 Stunde', minutes: 60 },
{ key: '8h', labelKey: 'app:chats.mute_8h', labelDefault: '8 Stunden', minutes: 8 * 60 },
{ key: '24h', labelKey: 'app:chats.mute_24h', labelDefault: '24 Stunden', minutes: 24 * 60 },
{ key: '1w', labelKey: 'app:chats.mute_1w', labelDefault: '1 Woche', minutes: 7 * 24 * 60 },
{
key: 'forever',
labelKey: 'app:chats.mute_forever',
labelDefault: 'Bis auf Weiteres',
minutes: FOREVER_MINUTES,
},
];
interface MenuPos {
top: number;
left: number;
}
// Per-conversation row context-menu. Renders via portal so the submenu can
// 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) {
const { t } = useTranslation(['app']);
const [open, setOpen] = useState(false);
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
const [menuPos, setMenuPos] = useState<MenuPos | null>(null);
const [submenuPos, setSubmenuPos] = useState<MenuPos | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const muteItemRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
function onDocClick(e: MouseEvent) {
const target = e.target as Node;
if (triggerRef.current?.contains(target)) return;
if (menuRef.current?.contains(target)) return;
setOpen(false);
setSubmenuOpen(null);
}
function onEsc(e: KeyboardEvent) {
if (e.key === 'Escape') {
setOpen(false);
setSubmenuOpen(null);
}
}
document.addEventListener('mousedown', onDocClick);
document.addEventListener('keydown', onEsc);
return () => {
document.removeEventListener('mousedown', onDocClick);
document.removeEventListener('keydown', onEsc);
};
}, [open]);
useEffect(() => {
if (!open) {
setMenuPos(null);
setSubmenuPos(null);
return;
}
const rect = triggerRef.current?.getBoundingClientRect();
if (!rect) return;
// Anchor: right edge aligns with trigger's right edge, menu hangs below.
const menuWidth = 208;
setMenuPos({
top: rect.bottom + 4,
left: Math.max(8, rect.right - menuWidth),
});
}, [open]);
useEffect(() => {
if (submenuOpen !== 'mute') {
setSubmenuPos(null);
return;
}
const rect = muteItemRef.current?.getBoundingClientRect();
if (!rect) return;
const submenuWidth = 192;
const viewportWidth = window.innerWidth;
// Prefer right of the item. Flip to left when it would overflow viewport.
const wantLeft = rect.right + 4;
const flip = wantLeft + submenuWidth > viewportWidth - 8;
setSubmenuPos({
top: rect.top,
left: flip ? rect.left - submenuWidth - 4 : wantLeft,
});
}, [submenuOpen]);
const isMuted =
mutedUntil !== null && new Date(mutedUntil).getTime() > Date.now();
const handleArchive = useCallback(
async (next: boolean) => {
setOpen(false);
try {
await setConversationArchived(supabase, conversationId, next);
} catch (err: unknown) {
console.error('archive toggle failed', err);
}
},
[conversationId],
);
const handleMute = useCallback(
async (minutes: number | null) => {
setOpen(false);
setSubmenuOpen(null);
try {
await setConversationMutedUntil(
supabase,
conversationId,
muteDurationToIso(minutes),
);
} catch (err: unknown) {
console.error('mute toggle failed', err);
}
},
[conversationId],
);
return (
<>
<button
ref={triggerRef}
type="button"
aria-label={t('app:chats.row_menu', { defaultValue: 'Aktionen' })}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen((v) => !v);
}}
onMouseDown={(e) => e.stopPropagation()}
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted opacity-0 transition group-hover:opacity-100 hover:bg-surface-3 hover:text-fg focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<MoreVerticalIcon className="h-4 w-4" />
</button>
{open &&
menuPos &&
createPortal(
<div
ref={menuRef}
role="menu"
style={{ top: menuPos.top, left: menuPos.left }}
className="fixed z-50 w-52 rounded-lg border border-line bg-surface-3 p-1 shadow-xl"
>
<MenuItem
icon={<ArchiveIcon className="h-4 w-4" />}
label={
archived
? t('app:chats.unarchive', { defaultValue: 'Entarchivieren' })
: t('app:chats.archive', { defaultValue: 'Archivieren' })
}
onClick={() => void handleArchive(!archived)}
/>
<MenuItem
ref={muteItemRef}
icon={
isMuted ? (
<BellOffIcon className="h-4 w-4" />
) : (
<BellIcon className="h-4 w-4" />
)
}
label={
isMuted
? t('app:chats.unmute', { defaultValue: 'Stummschaltung aufheben' })
: t('app:chats.mute', { defaultValue: 'Stummschalten' })
}
onClick={() => {
if (isMuted) void handleMute(null);
else setSubmenuOpen((v) => (v === 'mute' ? null : 'mute'));
}}
hasSubmenu={!isMuted}
/>
</div>,
document.body,
)}
{open &&
submenuOpen === 'mute' &&
submenuPos &&
createPortal(
<div
role="menu"
style={{ top: submenuPos.top, left: submenuPos.left }}
className="fixed z-50 w-48 rounded-lg border border-line bg-surface-3 p-1 shadow-xl"
>
{MUTE_OPTIONS.map((opt) => (
<MenuItem
key={opt.key}
label={t(opt.labelKey, { defaultValue: opt.labelDefault })}
onClick={() => void handleMute(opt.minutes)}
/>
))}
</div>,
document.body,
)}
</>
);
}
interface MenuItemProps {
icon?: React.ReactNode;
label: string;
onClick: () => void;
hasSubmenu?: boolean;
}
// React 18 requires forwardRef for function components to receive refs —
// without it the `ref` prop is stripped before reaching the component and
// measurement-dependent submenus never position.
const MenuItem = forwardRef<HTMLButtonElement, MenuItemProps>(
({ icon, label, onClick, hasSubmenu }, ref) => (
<button
ref={ref}
type="button"
role="menuitem"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClick();
}}
onMouseDown={(e) => e.stopPropagation()}
className="flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
{icon && <span className="shrink-0 text-fg-muted">{icon}</span>}
<span className="min-w-0 flex-1 truncate">{label}</span>
{hasSubmenu && <span className="shrink-0 text-xs text-fg-muted"></span>}
</button>
),
);
MenuItem.displayName = 'MenuItem';
@@ -0,0 +1,190 @@
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,133 @@
import { Component, type ErrorInfo, Fragment, type ReactNode } from 'react';
import { SpinnerIcon } from './icons';
interface Props {
children: ReactNode;
/**
* Optional scope label shown in logs / devtools. Defaults to `root` — set
* per boundary (e.g. `route`, `conversation`) so multiple boundaries can be
* distinguished at a glance.
*/
scope?: string;
/**
* If the retry count exceeds this, the boundary stops auto-retrying and
* shows a more helpful message (still without a button — Discord-style,
* the app keeps trying but hints the user to hold on or check network).
*/
maxAutoRetries?: number;
}
interface State {
error: Error | null;
retryKey: number;
attempt: number;
}
const RETRY_DELAYS_MS = [2000, 4000, 8000, 15000, 30000];
// Discord-style error boundary.
// - Catches render-time errors in its subtree.
// - Shows a centred spinner + status text. Never renders a manual "Reload"
// button; the boundary remounts its children on an exponential-backoff
// schedule so the UI self-heals once the underlying issue clears (typical
// causes: a realtime reconnect, a transient network blip, or a race that
// only fires once).
// - Escalates the label after each failed retry so the user sees that the
// app is trying, rather than silent infinite spinning.
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null, retryKey: 0, attempt: 0 };
private retryTimer: number | null = null;
static getDerivedStateFromError(error: Error): Partial<State> {
return { error };
}
override componentDidCatch(error: Error, info: ErrorInfo): void {
const scope = this.props.scope ?? 'root';
// We explicitly log here — the boundary itself swallows the error from
// React, so without this the failure would be invisible in production.
console.error('[ErrorBoundary:' + scope + '] caught render error', error, info);
}
override componentDidUpdate(_prev: Props, prevState: State): void {
if (this.state.error && !prevState.error) {
this.scheduleRetry();
}
}
override componentWillUnmount(): void {
if (this.retryTimer !== null) {
window.clearTimeout(this.retryTimer);
this.retryTimer = null;
}
}
private scheduleRetry(): void {
if (this.retryTimer !== null) return;
const attempt = this.state.attempt;
const delay =
RETRY_DELAYS_MS[Math.min(attempt, RETRY_DELAYS_MS.length - 1)] ??
RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1] ??
30000;
this.retryTimer = window.setTimeout(() => {
this.retryTimer = null;
this.setState((prev) => ({
error: null,
retryKey: prev.retryKey + 1,
attempt: prev.attempt + 1,
}));
}, delay);
}
override render(): ReactNode {
if (this.state.error) {
const max = this.props.maxAutoRetries ?? RETRY_DELAYS_MS.length;
const escalated = this.state.attempt >= max;
return <RetryingScreen escalated={escalated} attempt={this.state.attempt} />;
}
// `retryKey` forces a remount of the subtree so hooks re-run cleanly after
// an error (otherwise stale state from the crashed tree can immediately
// re-throw). Use a keyed Fragment so the boundary doesn't inject an extra
// wrapper div — that would break `flex h-full` chains (e.g. AppShell →
// Outlet → page column).
return <Fragment key={this.state.retryKey}>{this.props.children}</Fragment>;
}
}
function RetryingScreen({ escalated, attempt }: { escalated: boolean; attempt: number }) {
const primary = escalated
? 'Verbindungsprobleme…'
: attempt === 0
? 'Einen Moment bitte'
: 'Versuche erneut zu laden…';
const secondary = escalated
? 'Prüfe deine Internetverbindung. Wir versuchen es weiter.'
: 'Die App lädt sich gleich selbst neu.';
return (
<div
role="status"
aria-live="polite"
className="flex min-h-screen w-full items-center justify-center bg-surface-3 px-6"
>
<div className="flex flex-col items-center gap-4 text-center">
<div className="relative flex h-16 w-16 items-center justify-center">
<span
aria-hidden="true"
className="absolute inset-0 rounded-full border-2 border-accent/20"
/>
<span
aria-hidden="true"
className="absolute inset-0 rounded-full border-2 border-accent border-r-transparent border-b-transparent animate-spin"
/>
<SpinnerIcon className="hidden" />
</div>
<div className="max-w-sm space-y-1.5">
<p className="font-display text-lg font-semibold text-fg">{primary}</p>
<p className="text-sm text-fg-muted">{secondary}</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,315 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import {
type AttachmentHandle,
type DecryptedMessage,
downloadAndDecryptAttachment,
encryptAndUploadAttachment,
insertAttachmentRow,
parseMessagePayload,
sendEncryptedMessage,
} from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { bytesToPgHex } from '@chat-app/shared/supabase';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import { Avatar } from './Avatar';
import { ForwardIcon, SpinnerIcon, UsersIcon, XIcon } from './icons';
interface Props {
open: boolean;
message: DecryptedMessage | null;
currentConversationId: string | null;
onClose: () => void;
}
// Forwards a message's plaintext to one or more conversations. Attachments are
// NOT carried over yet (would require re-uploading + re-encrypting under the
// new conversation key); only the text payload is forwarded for now and the
// preview hints at the dropped attachment.
export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) {
const { t } = useTranslation(['app', 'errors']);
const { session, device } = useAuth();
const { conversations } = useConversationsContext();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
useEffect(() => {
if (!open) return;
setSelected(new Set());
setError(null);
setDone(false);
}, [open, message?.id]);
const targets = useMemo(() => {
return conversations
.filter((c) => c.id !== currentConversationId && c.acceptedByMe)
.sort((a, b) => {
const ta = a.lastMessageAt ?? a.createdAt;
const tb = b.lastMessageAt ?? b.createdAt;
return tb.localeCompare(ta);
});
}, [conversations, currentConversationId]);
const preview = useMemo(() => {
if (!message?.plaintext) return '';
const p = parseMessagePayload(message.plaintext);
if (p.kind !== 'text') return '';
return p.text.length > 140 ? p.text.slice(0, 140) + '…' : p.text;
}, [message]);
const sourceAttachments = useMemo<AttachmentHandle[]>(() => {
if (!message?.plaintext) return [];
const p = parseMessagePayload(message.plaintext);
return p.kind === 'text' ? p.attachments : [];
}, [message]);
if (!open || !message) return null;
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function handleSend() {
if (!session?.user.id || !device?.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 hasAttachments = sourceAttachments.length > 0;
const text = preview || (hasAttachments ? '' : '');
if (!text && !hasAttachments) throw new Error('nothing to forward');
// Download+decrypt source attachments ONCE (same plaintext goes to every
// target). For each target conv we re-encrypt under fresh per-attachment
// keys and re-upload under the target conv's storage folder — source and
// target conv-keys differ, so the bytes must actually move.
const decryptedBlobs: { mime: string; size: number; width?: number; height?: number; blob: Blob }[] =
[];
for (const h of sourceAttachments) {
const blob = await downloadAndDecryptAttachment({ client: supabase, handle: h });
const entry: {
mime: string;
size: number;
width?: number;
height?: number;
blob: Blob;
} = { mime: h.mimeType, size: h.sizeBytes, blob };
if (h.width !== undefined) entry.width = h.width;
if (h.height !== undefined) entry.height = h.height;
decryptedBlobs.push(entry);
}
for (const convId of selected) {
const newHandles: AttachmentHandle[] = [];
const blobNonceHex = new Map<string, string>();
for (const src of decryptedBlobs) {
const res = await encryptAndUploadAttachment({
client: supabase,
conversationId: convId,
file: src.blob,
mimeType: src.mime,
sizeBytes: src.size,
...(src.width !== undefined ? { width: src.width } : {}),
...(src.height !== undefined ? { height: src.height } : {}),
});
newHandles.push(res.handle);
blobNonceHex.set(res.handle.id, bytesToPgHex(res.nonce));
}
const msg = await sendEncryptedMessage({
client: supabase,
conversationId: convId,
plaintext: text,
senderUserId: session.user.id,
senderDeviceId: device.id,
senderPrivateKey: priv,
...(newHandles.length > 0 ? { attachmentHandles: newHandles } : {}),
});
for (const h of newHandles) {
const bn = blobNonceHex.get(h.id) ?? '\\x';
await insertAttachmentRow(supabase, msg.id, h, bn);
}
}
setDone(true);
window.setTimeout(onClose, 700);
} catch (err: unknown) {
const code = extractErrorCode(err);
setError(
code
? t('errors:' + code, { defaultValue: t('errors:generic') })
: err instanceof Error
? err.message
: t('errors:generic'),
);
} finally {
setBusy(false);
}
}
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="flex max-h-[80vh] w-full max-w-md 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">
<ForwardIcon className="h-4 w-4 text-accent" />
<h3 className="font-display text-sm font-semibold text-fg">
{t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
</h3>
</div>
<button
type="button"
onClick={onClose}
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="border-b border-line bg-surface-2 px-5 py-3">
<p className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t('app:chats.forward_preview', { defaultValue: 'Vorschau' })}
</p>
<p className="mt-1 line-clamp-3 break-words text-sm text-fg">
{preview || (sourceAttachments.length > 0 ? '📎' : '…')}
</p>
{sourceAttachments.length > 0 && (
<p className="mt-1 text-[11px] text-fg-muted">
📎{' '}
{t('app:chats.forward_attachments_count', {
count: sourceAttachments.length,
defaultValue: '{{count}} Anhang wird mit weitergeleitet',
})}
</p>
)}
</div>
<div className="flex-1 overflow-y-auto p-2">
{targets.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-fg-muted">
{t('app:chats.forward_no_targets', {
defaultValue: 'Keine anderen Unterhaltungen verfügbar.',
})}
</p>
) : (
<ul className="space-y-0.5">
{targets.map((c) => {
const isGroup = c.type === 'group';
const title = isGroup ? c.name ?? '?' : c.peer?.displayName ?? '?';
const avatarUrl = isGroup ? c.avatarUrl ?? null : c.peer?.avatarUrl ?? null;
const checked = selected.has(c.id);
return (
<li key={c.id}>
<button
type="button"
onClick={() => toggle(c.id)}
className={
'flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(checked ? 'bg-accent/15' : 'hover:bg-surface-2')
}
>
{avatarUrl ? (
<Avatar
url={avatarUrl}
displayName={title}
className="h-9 w-9 text-sm"
/>
) : isGroup ? (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-accent">
<UsersIcon className="h-4 w-4" />
</div>
) : (
<Avatar
url={null}
displayName={title}
className="h-9 w-9 text-sm"
/>
)}
<span className="min-w-0 flex-1 truncate text-sm font-medium text-fg">
{title}
</span>
<span
aria-hidden="true"
className={
'flex h-5 w-5 shrink-0 items-center justify-center rounded border ' +
(checked ? 'border-accent bg-accent text-accent-fg' : 'border-line bg-surface-2')
}
>
{checked && (
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
{error && (
<p
role="alert"
className="border-t border-rose-500/30 bg-rose-500/10 px-5 py-2 text-xs text-rose-700 dark:text-rose-200"
>
{error}
</p>
)}
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
<button
type="button"
onClick={onClose}
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"
disabled={busy || selected.size === 0 || done}
onClick={() => void handleSend()}
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>
{done
? t('app:chats.forward_done', { defaultValue: 'Gesendet' })
: t('app:chats.forward_send', {
count: selected.size,
defaultValue: 'An {{count}} senden',
})}
</span>
</button>
</footer>
</div>
</div>
);
}
+25 -6
View File
@@ -123,15 +123,25 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
{conversation.members.map((m) => {
const name = m.profile?.displayName ?? '?';
const handle = m.profile?.username ? '@' + m.profile.username : '';
const avatarUrl = m.profile?.avatarUrl ?? null;
const letter = name.trim().charAt(0).toUpperCase() || '?';
return (
<li
key={m.userId}
className="flex items-center gap-3 rounded-lg px-2 py-1.5"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
{letter}
</div>
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-8 w-8 shrink-0 rounded-full object-cover"
draggable={false}
/>
) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
{letter}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-white">
{name}
@@ -187,9 +197,18 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
onClick={() => void handleAdd(f.userId)}
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:opacity-60"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
{letter}
</div>
{f.avatarUrl ? (
<img
src={f.avatarUrl}
alt=""
className="h-8 w-8 shrink-0 rounded-full object-cover"
draggable={false}
/>
) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
{letter}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-white">{f.displayName}</p>
<p className="truncate text-xs text-neutral-500">@{f.username}</p>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,137 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useTranslation } from 'react-i18next';
import { useCall } from '../context/CallContext';
import { useFriendshipsContext } from '../context/FriendshipsContext';
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
import { LockIcon, PhoneIcon, PhoneOffIcon, VideoIcon } from './icons';
interface Props {
conversation: ConversationSummary;
}
const PULSE_BG: Record<AvatarColorKey, string> = {
violet: 'bg-violet-200 text-violet-800 dark:bg-violet-900/60 dark:text-violet-200',
amber: 'bg-amber-200 text-amber-900 dark:bg-amber-900/60 dark:text-amber-200',
rose: 'bg-rose-200 text-rose-900 dark:bg-rose-900/60 dark:text-rose-200',
teal: 'bg-teal-200 text-teal-900 dark:bg-teal-900/60 dark:text-teal-200',
};
// Docked incoming-call panel: replaces the chat header, chat + composer stay
// visible and usable below (per user decision). Full-bleed overlay lives in
// the shell-level toast for cross-conversation ring notifications.
export function IncomingCallPanel({ conversation }: Props) {
const { t } = useTranslation(['app']);
const { state, acceptIncoming, rejectIncoming } = useCall();
const { friendships } = useFriendshipsContext();
if (state.kind !== 'incoming' || state.conversationId !== conversation.id) return null;
const isGroup = conversation.type === 'group';
const callerProfile = conversation.members.find((m) => m.userId === state.fromUserId)?.profile ?? null;
const callerName =
callerProfile?.displayName ??
friendships.find((f) => f.peer.userId === state.fromUserId)?.peer.displayName ??
'?';
const groupName = isGroup ? (conversation.name ?? t('app:chats.new_group')) : null;
const title = isGroup ? groupName ?? callerName : callerName;
const letter = title.trim().charAt(0).toUpperCase() || '?';
const avatarUrl = isGroup
? (conversation.avatarUrl ?? callerProfile?.avatarUrl ?? null)
: callerProfile?.avatarUrl ?? null;
const colorKey = colorKeyFor(state.fromUserId);
const avatarTone = PULSE_BG[colorKey];
const mediaLabel =
state.mediaKind === 'video' ? 'Video Call' : 'Voice Call';
return (
<section
role="region"
aria-label={t('app:call.incoming_title')}
className="relative overflow-hidden border-b border-line bg-surface-3"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(79,70,229,0.10),transparent_60%),radial-gradient(circle_at_70%_80%,rgba(109,115,255,0.08),transparent_55%)]" />
<div className="relative mx-auto flex max-w-[480px] flex-col items-center px-6 py-8 text-center">
<p className="text-[12px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
{t('app:call.incoming_title')}
</p>
<div className="relative my-4 flex h-[112px] w-[112px] items-center justify-center">
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-full border-2 border-accent animate-pulse-ring"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-full border-2 border-accent animate-pulse-ring"
style={{ animationDelay: '1s' }}
/>
{avatarUrl ? (
<img
src={avatarUrl}
alt={title}
className="h-24 w-24 rounded-full border-[3px] border-surface-3 object-cover"
draggable={false}
/>
) : (
<div
className={
'flex h-24 w-24 items-center justify-center rounded-full border-[3px] border-surface-3 font-display text-4xl font-bold ' +
avatarTone
}
>
{letter}
</div>
)}
</div>
<p className="font-display text-2xl font-bold tracking-tight text-fg">{title}</p>
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-fg-muted">
<LockIcon className="h-3.5 w-3.5" />
<span>E2E verschlüsselt · {mediaLabel}</span>
{isGroup && (
<>
<span className="text-fg-muted/50" aria-hidden="true">·</span>
<span>
{t('app:call.incoming_group_from', {
name: callerName,
defaultValue: callerName + ' ruft an',
})}
</span>
</>
)}
</p>
<div className="mt-5 flex w-full max-w-[440px] flex-wrap gap-3">
<button
type="button"
onClick={rejectIncoming}
className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] border border-rose-500/40 bg-transparent px-4 py-3.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/40 dark:text-rose-400"
>
<PhoneOffIcon className="h-4 w-4" />
<span>{t('app:call.decline')}</span>
</button>
<button
type="button"
onClick={() => void acceptIncoming('audio')}
className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-emerald-600 px-4 py-3.5 text-sm font-semibold text-white shadow-accept-btn transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
>
<PhoneIcon className="h-4 w-4" />
<span>
{state.mediaKind === 'video'
? t('app:call.accept_audio', { defaultValue: 'Nur Audio' })
: t('app:call.accept')}
</span>
</button>
{state.mediaKind === 'video' && (
<button
type="button"
onClick={() => void acceptIncoming('video')}
className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-accent px-4 py-3.5 text-sm font-semibold text-accent-fg shadow-accept-btn transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
<VideoIcon className="h-4 w-4" />
<span>{t('app:call.accept_video', { defaultValue: 'Mit Video' })}</span>
</button>
)}
</div>
</div>
</section>
);
}
+196 -35
View File
@@ -13,30 +13,65 @@ import { useAuth } from '../context/AuthContext';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import type { AggregatedReaction } from '../lib/useMessageReactions';
import { AttachmentAudio } from './AttachmentAudio';
import { AttachmentImage } from './AttachmentImage';
import { PencilIcon, PhoneIcon, PhoneOffIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
import { Avatar } from './Avatar';
import { ForwardIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
const EMOJI_CHOICES = ['👍', '❤️', '😂', '🎉', '🔥', '😮', '😢', '🙏'];
const EDIT_WINDOW_MS = 24 * 60 * 60 * 1000;
export interface QuotedRef {
id: string;
senderName: string;
snippet: string;
isAttachment: boolean;
deleted: boolean;
}
interface Props {
message: DecryptedMessage;
mine: boolean;
groupedWithPrev: boolean;
/** Last message of a run from this sender — anchor avatar on this row. */
isLastOfRun?: boolean;
senderDisplayName?: string | null | undefined;
senderAvatarUrl?: string | null | undefined;
conversationId: string;
reactions: AggregatedReaction[];
onToggleReaction: (emoji: string) => Promise<void>;
showSeen?: boolean;
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
deliveryState?: 'sent' | 'delivered' | 'read';
/** Resolved quoted message info (parent does the lookup). */
quoted?: QuotedRef | null;
/** Tap-to-jump on quote bubble. Receives the quoted message's id. */
onJumpToMessage?: (id: string) => void;
/** Hover action: parent receives current message to start a reply. */
onReply?: (m: DecryptedMessage) => void;
/** Hover action: parent opens forward dialog for current message. */
onForward?: (m: DecryptedMessage) => void;
/** Highlighted state — set briefly after a jump. */
highlighted?: boolean;
}
export function MessageBubble({
message,
mine,
groupedWithPrev,
isLastOfRun = false,
senderDisplayName,
senderAvatarUrl,
conversationId,
reactions,
onToggleReaction,
showSeen = false,
deliveryState,
quoted = null,
onJumpToMessage,
onReply,
onForward,
highlighted = false,
}: Props) {
const { t } = useTranslation(['app']);
const { session, device } = useAuth();
@@ -98,6 +133,8 @@ export function MessageBubble({
messageId: message.id,
conversationId,
newPlaintext: trimmed,
senderUserId: session.user.id,
senderDeviceId: device.id,
senderPrivateKey: priv,
});
setEditing(false);
@@ -141,8 +178,13 @@ export function MessageBubble({
if (message.deletedAt) {
return (
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
<div className="max-w-[70%] rounded-2xl border border-white/5 bg-white/5 px-3.5 py-1.5 text-xs italic text-neutral-500">
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
<AvatarSlot
show={isLastOfRun}
url={senderAvatarUrl ?? null}
displayName={senderDisplayName ?? null}
/>
<div className="max-w-[calc(70%-2.5rem)] rounded-2xl border border-line bg-surface-2 px-3.5 py-1.5 text-xs italic text-fg-muted">
{t('app:chats.deleted')}
</div>
</div>
@@ -154,14 +196,19 @@ export function MessageBubble({
}
return (
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
<AvatarSlot
show={isLastOfRun}
url={senderAvatarUrl ?? null}
displayName={senderDisplayName ?? null}
/>
<div
className={
'group relative max-w-[70%] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2')
'group relative max-w-[calc(70%-2.5rem)] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2')
}
>
{editing ? (
<div className="rounded-2xl border border-brand-400/40 bg-ink-900/80 p-2 backdrop-blur-xl">
<div className="rounded-2xl border border-accent/40 bg-surface-2 p-2 shadow-sm">
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
@@ -177,10 +224,10 @@ export function MessageBubble({
}}
rows={2}
autoFocus
className="w-full resize-none rounded-md bg-ink-800 px-3 py-2 text-sm text-white outline-none focus:ring-2 focus:ring-brand-400/60"
className="w-full resize-none rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg outline-none focus:ring-2 focus:ring-accent/50"
/>
{editError && (
<p className="mt-1.5 break-words rounded-md border border-rose-500/20 bg-rose-500/10 px-2 py-1 text-[11px] text-rose-200">
<p className="mt-1.5 break-words rounded-md border border-rose-500/30 bg-rose-500/10 px-2 py-1 text-[11px] text-rose-600 dark:text-rose-200">
{editError}
</p>
)}
@@ -191,7 +238,7 @@ export function MessageBubble({
setEditing(false);
setEditError(null);
}}
className="cursor-pointer rounded-md border border-white/10 bg-white/5 px-3 py-1 text-xs text-neutral-200 hover:bg-white/10"
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1 text-xs text-fg hover:bg-surface-2"
>
{t('app:friends.action_cancel')}
</button>
@@ -199,7 +246,7 @@ export function MessageBubble({
type="button"
disabled={busy}
onClick={() => void handleEditSave()}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1 text-xs font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1 text-xs font-semibold text-accent-fg hover:brightness-110 disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>{t('common:save', { defaultValue: 'Save' })}</span>
@@ -208,27 +255,64 @@ export function MessageBubble({
</div>
) : (
<div
data-message-id={message.id}
className={
'break-words rounded-2xl px-3.5 py-2 text-sm ' +
'break-words px-3.5 py-2 text-sm transition ' +
(highlighted ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-surface-3 ' : '') +
(mine
? 'bg-brand-500/85 text-white'
: 'border border-white/5 bg-ink-900/70 text-neutral-100')
? 'rounded-[18px_18px_4px_18px] bg-accent text-accent-fg'
: 'rounded-[18px_18px_18px_4px] border border-line text-fg')
}
>
{quoted && (
<button
type="button"
onClick={() => onJumpToMessage?.(quoted.id)}
className={
'mb-1.5 flex w-full cursor-pointer items-stretch gap-2 rounded-md px-2 py-1.5 text-left text-xs transition hover:opacity-90 ' +
(mine
? 'bg-white/15 text-accent-fg/90'
: 'bg-surface-2 text-fg-muted')
}
>
<span
aria-hidden="true"
className={
'w-0.5 shrink-0 rounded-full ' + (mine ? 'bg-white/50' : 'bg-accent')
}
/>
<span className="min-w-0 flex-1">
<span className={'block truncate font-semibold ' + (mine ? '' : 'text-fg')}>
{quoted.senderName}
</span>
<span className="block truncate italic opacity-90">
{quoted.deleted
? t('app:chats.deleted')
: quoted.isAttachment && !quoted.snippet
? '📎 ' + t('app:chats.attachment', { defaultValue: 'Anhang' })
: quoted.snippet}
</span>
</span>
</button>
)}
{message.plaintext === null ? (
<span className="italic text-neutral-400">cannot decrypt</span>
<span className="italic opacity-70">cannot decrypt</span>
) : (
<>
{bodyText.length > 0 && <div>{bodyText}</div>}
{attachments.map((a) => (
<AttachmentImage key={a.id} handle={a} />
))}
{attachments.map((a) =>
a.mimeType.startsWith('audio/') ? (
<AttachmentAudio key={a.id} handle={a} />
) : (
<AttachmentImage key={a.id} handle={a} />
),
)}
</>
)}
<div
className={
'mt-1 flex items-center gap-1.5 text-[10px] ' +
(mine ? 'text-brand-100/70' : 'text-neutral-500')
(mine ? 'text-accent-fg/75' : 'text-fg-muted')
}
>
<span>{time}</span>
@@ -239,24 +323,27 @@ export function MessageBubble({
</div>
)}
{showSeen && mine && !editing && !message.deletedAt && (
<p className="mt-0.5 text-right text-[10px] text-neutral-500">
{t('app:chats.seen')}
</p>
{mine && !editing && !message.deletedAt && deliveryState && (
<div className="mt-0.5 flex items-center justify-end gap-1 text-[10px] text-fg-muted">
<DeliveryTicks state={deliveryState} />
{showSeen && <span>{t('app:chats.seen')}</span>}
</div>
)}
{reactions.length > 0 && !editing && (
<div className={'mt-1 flex flex-wrap gap-1 ' + (mine ? 'justify-end' : 'justify-start')}>
{reactions.map((r) => (
<button
key={r.emoji}
// Keying by emoji+count makes React remount the chip when the
// count flips, replaying the pop animation. Cheap visual cue.
key={r.emoji + ':' + r.count}
type="button"
onClick={() => void onToggleReaction(r.emoji)}
className={
'inline-flex 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-brand-400/40 ' +
'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
? 'border-brand-400/40 bg-brand-500/20 text-brand-100'
: 'border-white/10 bg-white/5 text-neutral-200 hover:bg-white/10')
? 'border-accent/40 bg-accent/20 text-accent'
: 'border-line bg-surface-2 text-fg hover:bg-surface-3')
}
>
<span>{r.emoji}</span>
@@ -273,12 +360,26 @@ export function MessageBubble({
(mine ? 'right-full pr-2' : 'left-full pl-2')
}
>
<div className="flex items-center gap-0.5 rounded-lg border border-white/10 bg-ink-900/90 p-1 shadow-lg backdrop-blur-xl">
<div className="flex items-center gap-0.5 rounded-lg border border-line bg-surface-3 p-1 shadow-lg">
<ActionButton
label={t('app:friends.action_accept', { defaultValue: 'React' })}
onClick={() => setPickerOpen((v) => !v)}
icon={<SmileIcon className="h-4 w-4" />}
/>
{onReply && !message.deletedAt && (
<ActionButton
label={t('app:chats.reply', { defaultValue: 'Antworten' })}
onClick={() => onReply(message)}
icon={<ReplyIcon className="h-4 w-4" />}
/>
)}
{onForward && !message.deletedAt && (
<ActionButton
label={t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
onClick={() => onForward(message)}
icon={<ForwardIcon className="h-4 w-4" />}
/>
)}
{canEdit && (
<ActionButton
label="Edit"
@@ -304,7 +405,7 @@ export function MessageBubble({
ref={pickerRef}
role="menu"
className={
'absolute z-30 mt-1 flex gap-1 rounded-lg border border-white/10 bg-ink-900/95 p-1.5 shadow-xl backdrop-blur-xl ' +
'absolute z-30 mt-1 flex gap-1 rounded-lg border border-line bg-surface-3 p-1.5 shadow-xl ' +
(mine ? 'right-0' : 'left-0')
}
>
@@ -313,7 +414,7 @@ export function MessageBubble({
key={e}
type="button"
onClick={() => void handlePickEmoji(e)}
className="cursor-pointer rounded-md px-2 py-1 text-lg transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
className="cursor-pointer rounded-md px-2 py-1 text-lg transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
{e}
</button>
@@ -321,7 +422,7 @@ export function MessageBubble({
<button
type="button"
onClick={() => setPickerOpen(false)}
className="cursor-pointer rounded-md px-1.5 py-1 text-neutral-500 transition hover:bg-white/10"
className="cursor-pointer rounded-md px-1.5 py-1 text-fg-muted transition hover:bg-surface-2"
>
<XIcon className="h-4 w-4" />
</button>
@@ -334,6 +435,27 @@ export function MessageBubble({
);
}
function AvatarSlot({
show,
url,
displayName,
}: {
show: boolean;
url: string | null;
displayName: string | null;
}) {
if (!show) {
return <div aria-hidden="true" className="h-8 w-8 shrink-0" />;
}
return (
<Avatar
url={url}
displayName={displayName ?? ''}
className="h-8 w-8 text-xs"
/>
);
}
function CallEventRow({
parsed,
mine,
@@ -349,8 +471,8 @@ function CallEventRow({
const Icon = isMissed ? PhoneOffIcon : PhoneIcon;
const tone = isMissed
? 'border-rose-500/20 bg-rose-500/10 text-rose-200'
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-200';
? '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';
const label =
status === 'ended'
@@ -388,6 +510,45 @@ function formatDuration(totalSec: number): string {
return m + ':' + s.toString().padStart(2, '0');
}
function DeliveryTicks({ state }: { state: 'sent' | 'delivered' | 'read' }) {
// One checkmark for sent, two for delivered/read. Read shifts color to the
// accent to match WhatsApp/Telegram blue-tick convention.
const color =
state === 'read'
? 'text-sky-500 dark:text-sky-400'
: 'text-fg-muted';
return (
<span
aria-label={
state === 'read'
? 'Gelesen'
: state === 'delivered'
? 'Zugestellt'
: 'Gesendet'
}
title={
state === 'read'
? 'Gelesen'
: state === 'delivered'
? 'Zugestellt'
: 'Gesendet'
}
className={'flex items-center ' + color}
>
<svg viewBox="0 0 16 12" width="14" height="10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{state === 'sent' ? (
<polyline points="2 7 6 11 14 1" />
) : (
<>
<polyline points="1 7 5 11 11 2" />
<polyline points="6 11 10 11 14 1" />
</>
)}
</svg>
</span>
);
}
function ActionButton({
label,
onClick,
@@ -406,10 +567,10 @@ function ActionButton({
title={label}
onClick={onClick}
className={
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(tone === 'danger'
? 'text-neutral-400 hover:bg-rose-500/20 hover:text-rose-200'
: 'text-neutral-400 hover:bg-white/10 hover:text-neutral-100')
? 'text-fg-muted hover:bg-rose-500/20 hover:text-rose-500 dark:hover:text-rose-200'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg')
}
>
{icon}
+5 -5
View File
@@ -35,22 +35,22 @@ export function Modal({ open, title, onClose, children, size = 'md' }: Props) {
aria-modal="true"
aria-label={title}
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/80 p-6 backdrop-blur-sm animate-fade-in"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6 backdrop-blur-sm animate-fade-in"
>
<div
onClick={(e) => e.stopPropagation()}
className={
'relative w-full animate-slide-up rounded-2xl border border-white/10 bg-ink-900/95 shadow-xl backdrop-blur-xl ' +
'relative w-full animate-slide-up rounded-2xl border border-line bg-surface-3 shadow-xl ' +
width
}
>
<header className="flex items-center justify-between border-b border-white/5 px-6 py-4">
<h2 className="font-display text-lg font-semibold text-white">{title}</h2>
<header className="flex items-center justify-between border-b border-line px-6 py-4">
<h2 className="font-display text-lg font-semibold text-fg">{title}</h2>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
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 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<XIcon className="h-4 w-4" />
</button>
@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import {
getParticipantVolume,
setParticipantVolume,
subscribeParticipantVolumes,
} from '../lib/participantVolumes';
interface Props {
userId: string;
displayName: string;
x: number;
y: number;
onClose: () => void;
}
const MENU_W = 240;
const MENU_H = 84;
export function ParticipantVolumeMenu({
userId,
displayName,
x,
y,
onClose,
}: Props) {
const [volume, setVolume] = useState<number>(() => getParticipantVolume(userId));
// Re-sync from store in case another menu instance changed the same user.
useEffect(() => subscribeParticipantVolumes(() => {
setVolume(getParticipantVolume(userId));
}), [userId]);
// Outside click + Esc to close.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const onDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (target?.closest('[data-volume-menu]')) return;
onClose();
};
window.addEventListener('keydown', onKey);
window.addEventListener('mousedown', onDown);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onDown);
};
}, [onClose]);
const left = Math.min(Math.max(8, x), window.innerWidth - MENU_W - 8);
const top = Math.min(Math.max(8, y), window.innerHeight - MENU_H - 8);
return createPortal(
<div
data-volume-menu
role="dialog"
aria-label={'Lautstärke ' + displayName}
style={{ left, top, width: MENU_W }}
className="fixed z-[80] rounded-xl border border-line bg-surface-2/95 p-3 shadow-xl backdrop-blur-md"
>
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
<span className="truncate font-semibold text-fg">{displayName}</span>
<span className="tabular-nums text-fg-muted">
{Math.round(volume * 100)}%
</span>
</div>
<input
type="range"
min={0}
max={1}
step={0.01}
value={volume}
onChange={(e) => {
const v = Number(e.target.value);
setVolume(v);
setParticipantVolume(userId, v);
}}
aria-label={'Lautstärke ' + displayName}
className="w-full accent-accent"
/>
</div>,
document.body,
);
}
@@ -0,0 +1,233 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
type DisplaySurfaceHint,
getPresetParams,
getScreenShareSettings,
PRESET_ORDER,
type ScreenSharePreset,
} from '../lib/screenShareSettings';
import {
MonitorShareIcon,
SpinnerIcon,
XIcon,
} from './icons';
interface Props {
open: boolean;
onClose: () => void;
onStart: (opts: {
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
framerate: number | null;
}) => Promise<void>;
}
const FRAMERATE_OPTIONS: ReadonlyArray<{ value: number | null; label: string }> = [
{ value: null, label: 'Preset-Standard' },
{ value: 15, label: '15 fps' },
{ value: 30, label: '30 fps' },
{ value: 60, label: '60 fps' },
];
// Discord-style pre-share dialog. The OS still owns the final source picker
// (browser/OS limitation — only Chrome/Edge plus a native plugin can enumerate
// windows from JS), but we pre-filter with the `displaySurface` hint and lock
// in quality + framerate up-front so the user doesn't have to re-open the
// system picker to adjust them mid-call.
export function ScreenShareDialog({ open, onClose, onStart }: Props) {
const { t } = useTranslation(['app']);
const initial = getScreenShareSettings();
const [surface, setSurface] = useState<DisplaySurfaceHint>(initial.displaySurface);
const [preset, setPreset] = useState<ScreenSharePreset>(initial.preset);
const [framerate, setFramerate] = useState<number | null>(initial.framerateOverride);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!open) return null;
async function handleStart() {
setBusy(true);
setError(null);
try {
await onStart({ preset, displaySurface: surface, framerate });
onClose();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'screenshare failed');
} finally {
setBusy(false);
}
}
const presetParams = getPresetParams(preset);
const effectiveFps = framerate ?? presetParams.framerate;
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="flex 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">
<MonitorShareIcon className="h-4 w-4 text-accent" />
<h3 className="font-display text-sm font-semibold text-fg">
{t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
</h3>
</div>
<button
type="button"
onClick={onClose}
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="space-y-5 p-5">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t('app:call.share_surface', { defaultValue: 'Quelle' })}
</p>
<div className="grid grid-cols-3 gap-2">
<SurfaceOption
active={surface === null}
onClick={() => setSurface(null)}
label={t('app:call.share_any', { defaultValue: 'Alle anzeigen' })}
sub={t('app:call.share_any_sub', { defaultValue: 'Bildschirm + Fenster' })}
/>
<SurfaceOption
active={surface === 'monitor'}
onClick={() => setSurface('monitor')}
label={t('app:call.share_monitor', { defaultValue: 'Bildschirm' })}
sub={t('app:call.share_monitor_sub', { defaultValue: 'Ganzer Monitor' })}
/>
<SurfaceOption
active={surface === 'window'}
onClick={() => setSurface('window')}
label={t('app:call.share_window', { defaultValue: 'Fenster' })}
sub={t('app:call.share_window_sub', { defaultValue: 'Einzelnes Fenster' })}
/>
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t('app:call.share_quality', { defaultValue: 'Qualität' })}
</p>
<select
value={preset}
onChange={(e) => setPreset(e.target.value as ScreenSharePreset)}
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
>
{PRESET_ORDER.map((p) => (
<option key={p} value={p}>
{getPresetParams(p).label} · {getPresetParams(p).bitrateKbps} kbps
</option>
))}
</select>
</div>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t('app:call.share_fps', { defaultValue: 'Bildrate' })}
</p>
<div className="flex flex-wrap gap-2">
{FRAMERATE_OPTIONS.map((opt) => (
<button
key={String(opt.value)}
type="button"
onClick={() => setFramerate(opt.value)}
className={
'cursor-pointer rounded-lg border px-3 py-1.5 text-xs font-medium transition ' +
(framerate === opt.value
? 'border-accent bg-accent/15 text-accent'
: 'border-line bg-surface-2 text-fg hover:bg-surface')
}
>
{opt.label}
</button>
))}
</div>
<p className="mt-2 text-[11px] text-fg-muted">
{t('app:call.share_fps_effective', {
defaultValue: 'Effektiv: {{fps}} fps',
fps: effectiveFps,
})}
</p>
</div>
{error && (
<p role="alert" className="text-sm text-rose-600 dark:text-rose-300">
{error}
</p>
)}
<p className="text-[11px] text-fg-muted">
{t('app:call.share_hint', {
defaultValue:
'Nach "Teilen starten" öffnet das Betriebssystem den Quellen-Picker. Qualität + Bildrate werden bereits angewendet.',
})}
</p>
</div>
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
<button
type="button"
onClick={onClose}
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 handleStart()}
disabled={busy}
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:call.share_start', { defaultValue: 'Teilen starten' })}
</span>
</button>
</footer>
</div>
</div>
);
}
function SurfaceOption({
active,
onClick,
label,
sub,
}: {
active: boolean;
onClick: () => void;
label: string;
sub: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={
'flex flex-col items-start gap-0.5 rounded-lg border p-2.5 text-left transition ' +
(active
? 'border-accent bg-accent/10 text-fg'
: 'border-line bg-surface-2 text-fg hover:bg-surface')
}
>
<span className="text-xs font-semibold">{label}</span>
<span className="text-[10px] text-fg-muted">{sub}</span>
</button>
);
}
@@ -0,0 +1,180 @@
import type { RemoteTrack } from 'livekit-client';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { RemoteScreenShare } from '../context/CallContext';
import { MonitorShareIcon } from './icons';
interface ScreenShareViewerProps {
share: RemoteScreenShare;
avatarUrl: string | null;
displayName: string;
}
// Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
// Lifted out of the old InCallPanel so the new CallDock stays lean.
export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShareViewerProps) {
const { t } = useTranslation(['app']);
const videoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [watching, setWatching] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
useEffect(() => {
if (!watching) return;
const el = videoRef.current;
if (!el) return;
const track: RemoteTrack = share.track;
track.attach(el);
return () => {
track.detach(el);
};
}, [share.track, watching]);
// Esc exits CSS fullscreen.
useEffect(() => {
if (!isFullscreen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsFullscreen(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isFullscreen]);
// CSS-only "app fullscreen" — Discord-style: overlay the whole window
// including the left sidebar + chat list. Native Fullscreen API is
// unreliable in Tauri's WKWebView and doesn't add useful chrome-hiding
// beyond what `fixed inset-0 z-[60]` already gives us.
const toggleFullscreen = () => {
setIsFullscreen((v) => !v);
};
return (
<div
ref={containerRef}
className={
isFullscreen
? 'fixed inset-0 z-[60] flex h-screen w-screen flex-col overflow-hidden border-0 bg-black'
: 'flex h-full w-full flex-col overflow-hidden rounded-xl border border-emerald-500/30 bg-black'
}
>
<div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/20 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-700 dark:text-emerald-200">
<MonitorShareIcon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">
{t('app:call.is_sharing_screen', {
name: displayName,
defaultValue: displayName + ' teilt den Bildschirm',
})}
</span>
{watching && (
<>
<button
type="button"
onClick={toggleFullscreen}
aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
>
<FullscreenIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => {
if (document.fullscreenElement === containerRef.current) {
void document.exitFullscreen();
}
setWatching(false);
}}
aria-label={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
title={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
>
<span aria-hidden="true" className="text-[14px] leading-none">×</span>
</button>
</>
)}
</div>
{watching ? (
<video
ref={videoRef}
autoPlay
playsInline
muted
onDoubleClick={toggleFullscreen}
className="block h-full w-full flex-1 cursor-zoom-in bg-black object-contain"
/>
) : (
<button
type="button"
onClick={() => setWatching(true)}
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
style={{ aspectRatio: '16 / 9' }}
>
<BlurredTile avatarUrl={avatarUrl} letter={letter} />
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40">
<div className="flex flex-col items-center gap-2">
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500 text-white shadow-lg transition group-hover:scale-105">
<PlayIcon className="ml-0.5 h-6 w-6" />
</span>
<span className="text-xs font-medium text-white/90">
{t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
</span>
</div>
</div>
</button>
)}
</div>
);
}
function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {
if (avatarUrl) {
return (
<>
<img
src={avatarUrl}
alt=""
className="absolute inset-0 h-full w-full scale-110 object-cover blur-2xl"
/>
<div className="absolute inset-0 bg-gradient-to-br from-accent/20 to-emerald-500/20" />
</>
);
}
return (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-accent/40 via-fuchsia-500/20 to-emerald-500/30">
<span
aria-hidden="true"
className="text-[120px] font-display font-bold text-white/20 blur-[2px]"
>
{letter}
</span>
</div>
);
}
function PlayIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M8 5v14l11-7z" />
</svg>
);
}
function FullscreenIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
</svg>
);
}
+114 -48
View File
@@ -4,110 +4,176 @@ import { NavLink } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { useFriendshipsContext } from '../context/FriendshipsContext';
import { CallBar } from './CallUI';
import { useTheme } from '../context/ThemeContext';
import {
ChatBubbleIcon,
GearIcon,
LogoMark,
MoonIcon,
ShieldIcon,
SignOutIcon,
SunIcon,
UsersIcon,
} from './icons';
import { UserBar } from './UserBar';
interface NavItem {
to: string;
labelKey: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
badge?: 'friends' | 'chats';
}
const BASE_NAV_ITEMS: NavItem[] = [
{ to: '/chats', labelKey: 'app:nav.chats', icon: ChatBubbleIcon },
{ to: '/friends', labelKey: 'app:nav.friends', icon: UsersIcon },
{ to: '/settings', labelKey: 'app:nav.settings', icon: GearIcon },
const PRIMARY_NAV: NavItem[] = [
{ to: '/chats', labelKey: 'app:nav.chats', icon: ChatBubbleIcon, badge: 'chats' },
{ to: '/friends', labelKey: 'app:nav.friends', icon: UsersIcon, badge: 'friends' },
];
const ADMIN_NAV_ITEM: NavItem = {
const ADMIN_NAV: NavItem = {
to: '/admin',
labelKey: 'app:nav.admin',
icon: ShieldIcon,
};
// Clean-Rail 72px icon rail. Brand logo top → primary tabs → spacer →
// settings + theme toggle + sign-out at bottom. Active tab shows a pill
// indicator anchored to the left edge of the rail.
export function Sidebar() {
const { t } = useTranslation(['app', 'common']);
const { t } = useTranslation(['app']);
const { signOut, profile } = useAuth();
const { incomingCount } = useFriendshipsContext();
const { totalUnread } = useConversationsContext();
const { theme, toggle } = useTheme();
const navItems = profile?.isAdmin ? [...BASE_NAV_ITEMS, ADMIN_NAV_ITEM] : BASE_NAV_ITEMS;
const navItems: NavItem[] = profile?.isAdmin ? [...PRIMARY_NAV, ADMIN_NAV] : PRIMARY_NAV;
return (
<aside
aria-label="Primary navigation"
className="flex h-screen w-72 shrink-0 flex-col border-r border-white/5 bg-ink-900/70 backdrop-blur-xl"
className="flex h-screen w-[72px] shrink-0 flex-col items-center border-r border-line bg-surface-2/80 py-4 backdrop-blur-xl"
>
<div className="flex items-center gap-2.5 px-5 pb-3 pt-5">
<LogoMark className="h-7 w-7" />
<span className="font-display text-base font-semibold tracking-tight text-white">
{t('common:app_name')}
</span>
<div className="flex h-10 w-10 items-center justify-center" title="Netralax">
<LogoMark className="h-8 w-8" />
</div>
<nav className="mt-2 flex flex-col gap-0.5 px-3">
<div className="my-3 h-px w-8 bg-line" aria-hidden="true" />
<nav className="flex flex-col items-center gap-2">
{navItems.map((item) => {
const badge =
item.to === '/friends'
const badgeCount =
item.badge === 'friends'
? incomingCount
: item.to === '/chats'
: item.badge === 'chats'
? totalUnread
: 0;
const ariaLabel = badge > 0 ? `${t(item.labelKey)} (${badge})` : undefined;
return (
<NavLink
<RailNavLink
key={item.to}
to={item.to}
aria-label={ariaLabel}
className={({ isActive }) =>
[
'group flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/50',
isActive
? 'bg-brand-500/15 text-white ring-1 ring-brand-400/30'
: 'text-neutral-400 hover:bg-white/5 hover:text-neutral-100',
].join(' ')
}
>
<item.icon style={{ width: '18px', height: '18px' }} className="transition" />
<span className="flex-1">{t(item.labelKey)}</span>
{badge > 0 && <NavBadge count={badge} />}
</NavLink>
label={t(item.labelKey)}
icon={item.icon}
badge={badgeCount}
/>
);
})}
</nav>
<div className="flex-1" />
<div className="border-t border-white/5 p-3">
<CallBar />
<UserBar />
<button
type="button"
<div className="flex flex-col items-center gap-2">
<RailNavLink
to="/settings"
label={t('app:nav.settings')}
icon={GearIcon}
/>
<RailIconButton
label={theme === 'dark' ? t('app:theme.light', { defaultValue: 'Light mode' }) : t('app:theme.dark', { defaultValue: 'Dark mode' })}
onClick={toggle}
icon={theme === 'dark' ? SunIcon : MoonIcon}
/>
<RailIconButton
label={t('app:sidebar.sign_out')}
onClick={() => void signOut()}
className="mt-2 flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-neutral-400 transition hover:bg-rose-500/10 hover:text-rose-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40"
>
<SignOutIcon className="h-4 w-4" />
<span>{t('app:sidebar.sign_out')}</span>
</button>
icon={SignOutIcon}
tone="danger"
/>
</div>
</aside>
);
}
function NavBadge({ count }: { count: number }) {
interface RailNavLinkProps {
to: string;
label: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
badge?: number;
}
function RailNavLink({ to, label, icon: Icon, badge = 0 }: RailNavLinkProps) {
const ariaLabel = badge > 0 ? `${label} (${badge})` : label;
return (
<NavLink
to={to}
aria-label={ariaLabel}
title={label}
className={({ isActive }) =>
[
'group relative flex h-11 w-11 cursor-pointer items-center justify-center rounded-xl transition',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
isActive
? 'bg-accent/15 text-accent'
: 'text-fg-muted hover:bg-surface-3/60 hover:text-fg',
].join(' ')
}
>
{({ isActive }) => (
<>
{isActive && (
<span
aria-hidden="true"
className="absolute -left-4 h-6 w-1 rounded-r-full bg-accent"
/>
)}
<Icon style={{ width: '20px', height: '20px' }} />
{badge > 0 && <RailBadge count={badge} />}
</>
)}
</NavLink>
);
}
interface RailIconButtonProps {
label: string;
onClick: () => void;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
tone?: 'default' | 'danger';
}
function RailIconButton({ label, onClick, icon: Icon, tone = 'default' }: RailIconButtonProps) {
const toneClass =
tone === 'danger'
? 'text-fg-muted hover:bg-rose-500/10 hover:text-rose-400 focus-visible:ring-rose-400/40'
: 'text-fg-muted hover:bg-surface-3/60 hover:text-fg focus-visible:ring-accent/50';
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={
'flex h-11 w-11 cursor-pointer items-center justify-center rounded-xl transition focus:outline-none focus-visible:ring-2 ' +
toneClass
}
>
<Icon style={{ width: '20px', height: '20px' }} />
</button>
);
}
function RailBadge({ count }: { count: number }) {
const display = count > 99 ? '99+' : String(count);
return (
<span
aria-hidden="true"
className="inline-flex min-w-[20px] items-center justify-center rounded-full bg-rose-500 px-1.5 text-[10px] font-bold leading-tight text-white shadow-[0_0_0_2px_rgba(15,15,24,1)]"
className="absolute -right-1 -top-1 inline-flex min-w-[18px] items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold leading-tight text-white ring-2 ring-surface-2"
>
{display}
</span>
@@ -20,7 +20,7 @@ export function TypingIndicator({ typingUserIds, members }: Props) {
: t('app:chats.typing_many', { count: typingUserIds.length });
return (
<div className="px-6 pb-1 pt-0 text-xs text-neutral-400">
<div className="bg-surface-3 px-6 pb-1 pt-0 text-xs text-fg-muted">
<span className="inline-flex items-center gap-2">
<TypingDots />
<span>{text}</span>
@@ -32,9 +32,9 @@ export function TypingIndicator({ typingUserIds, members }: Props) {
function TypingDots() {
return (
<span aria-hidden="true" className="inline-flex items-center gap-0.5">
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite]" />
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.15s]" />
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.3s]" />
<span className="h-1 w-1 rounded-full bg-fg-muted [animation:pulse_1.2s_ease-in-out_infinite]" />
<span className="h-1 w-1 rounded-full bg-fg-muted [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.15s]" />
<span className="h-1 w-1 rounded-full bg-fg-muted [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.3s]" />
</span>
);
}
+7 -7
View File
@@ -57,22 +57,22 @@ export function UpdateToast() {
<div
role="status"
aria-live="polite"
className="fixed bottom-6 left-6 z-50 w-80 overflow-hidden rounded-2xl border border-white/10 bg-ink-900/95 p-4 shadow-2xl backdrop-blur-xl"
className="fixed bottom-6 left-6 z-50 w-80 overflow-hidden rounded-2xl border border-line bg-surface-3 p-4 shadow-2xl"
>
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/25 text-brand-200 ring-1 ring-brand-400/30">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-accent">
<SparklesIcon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white">
<p className="text-sm font-semibold text-fg">
{t('app:update.available', { defaultValue: 'Update verfügbar' })}
{state.version ? ' · v' + state.version : ''}
</p>
{state.notes && (
<p className="mt-0.5 line-clamp-3 text-xs text-neutral-400">{state.notes}</p>
<p className="mt-0.5 line-clamp-3 text-xs text-fg-muted">{state.notes}</p>
)}
{state.error && (
<p className="mt-1 text-xs text-rose-300">{state.error}</p>
<p className="mt-1 text-xs text-rose-500 dark:text-rose-300">{state.error}</p>
)}
</div>
{!installing && (
@@ -80,7 +80,7 @@ export function UpdateToast() {
type="button"
onClick={() => setDismissed(true)}
aria-label={t('common:close', { defaultValue: 'Schließen' })}
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100"
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-2 hover:text-fg"
>
<XIcon className="h-3.5 w-3.5" />
</button>
@@ -90,7 +90,7 @@ export function UpdateToast() {
type="button"
onClick={() => void handleInstall()}
disabled={installing}
className="mt-3 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-3 py-2 text-sm font-semibold text-white transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/60 disabled:cursor-not-allowed disabled:opacity-60"
className="mt-3 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-not-allowed disabled:opacity-60"
>
{installing ? (
<>
+18 -17
View File
@@ -5,22 +5,19 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { supabase } from '../lib/supabase';
import { Avatar } from './Avatar';
import { ChevronDownIcon } from './icons';
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
const PRESENCE_DOT: Record<PresenceState, string> = {
online: 'bg-emerald-400',
online: 'bg-emerald-500',
idle: 'bg-amber-400',
dnd: 'bg-rose-500',
invisible: 'bg-neutral-500',
offline: 'bg-neutral-600',
offline: 'bg-neutral-400 dark:bg-neutral-600',
};
function avatarLetter(input: string | undefined): string {
return (input ?? '?').trim().charAt(0).toUpperCase() || '?';
}
export function UserBar() {
const { t } = useTranslation(['app']);
const { profile, refreshProfile } = useAuth();
@@ -53,34 +50,38 @@ export function UserBar() {
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<div className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30">
{avatarLetter(profile?.displayName ?? profile?.username)}
<div className="relative">
<Avatar
url={profile?.avatarUrl ?? null}
displayName={profile?.displayName ?? profile?.username}
className="h-9 w-9 text-sm"
/>
<span
className={
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-ink-900 ' +
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-2 ' +
PRESENCE_DOT[presence]
}
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">
<p className="truncate text-sm font-medium text-fg">
{profile?.displayName ?? '—'}
</p>
<p className="truncate text-xs text-neutral-400">
{profile?.username ? '@' + profile.username : '—'}
<p className="truncate text-xs text-fg-muted">
{t('app:presence.' + presence)}
</p>
</div>
<ChevronDownIcon
className={'h-4 w-4 text-neutral-500 transition ' + (open ? 'rotate-180' : '')}
className={'h-4 w-4 text-fg-muted transition ' + (open ? 'rotate-180' : '')}
/>
</button>
{open && (
<div
role="menu"
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-white/10 bg-ink-900/95 shadow-xl backdrop-blur-xl"
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-line bg-surface-3 shadow-xl"
>
{PRESENCE_OPTIONS.map((opt) => (
<button
@@ -90,12 +91,12 @@ export function UserBar() {
aria-checked={opt === presence}
onClick={() => void changePresence(opt)}
disabled={busy}
className="flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm text-neutral-200 transition hover:bg-white/5 disabled:opacity-50"
className="flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm text-fg transition hover:bg-surface-2 disabled:opacity-50"
>
<span className={'h-2.5 w-2.5 rounded-full ' + PRESENCE_DOT[opt]} />
<span className="flex-1">{t('app:presence.' + opt)}</span>
{opt === presence && (
<span className="text-xs text-brand-300"></span>
<span className="text-xs text-accent"></span>
)}
</button>
))}
@@ -0,0 +1,252 @@
import { useEffect, useRef, useState } from 'react';
import { MicIcon, SpinnerIcon, XIcon } from './icons';
interface Props {
/** Called with the recorded audio file when the user confirms. The button
* ships as a single-attachment message, so the parent can feed it into
* the normal send flow. */
onComplete: (file: File) => Promise<void> | void;
disabled?: boolean;
}
// Single-button voice recorder. Click to start; shows an inline pill with
// elapsed time + stop + cancel while recording. On stop, hands a File to the
// parent. No in-place preview yet — we lean on the optimistic message bubble
// to appear once the parent sends.
const MAX_RECORD_SEC = 60;
export function VoiceRecorder({ onComplete, disabled = false }: Props) {
const [state, setState] = useState<'idle' | 'recording' | 'finalizing'>('idle');
const [elapsedSec, setElapsedSec] = useState(0);
const [level, setLevel] = useState(0);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const startedAtRef = useRef<number>(0);
const cancelledRef = useRef(false);
const audioCtxRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const rafRef = useRef<number>(0);
// Tick elapsed + auto-stop at MAX_RECORD_SEC.
useEffect(() => {
if (state !== 'recording') return;
const id = window.setInterval(() => {
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000);
setElapsedSec(sec);
if (sec >= MAX_RECORD_SEC) {
const rec = recorderRef.current;
if (rec && rec.state !== 'inactive') rec.stop();
}
}, 200);
return () => {
window.clearInterval(id);
};
}, [state]);
// Live mic level meter via Web Audio AnalyserNode. RMS on 0..1, smoothed.
useEffect(() => {
if (state !== 'recording') return;
const analyser = analyserRef.current;
if (!analyser) return;
const buf = new Uint8Array(analyser.frequencyBinCount);
let cancelled = false;
const tick = () => {
if (cancelled) return;
analyser.getByteFrequencyData(buf as Uint8Array<ArrayBuffer>);
let sum = 0;
for (let i = 0; i < buf.length; i++) sum += buf[i]!;
setLevel(sum / (buf.length * 255));
rafRef.current = window.requestAnimationFrame(tick);
};
rafRef.current = window.requestAnimationFrame(tick);
return () => {
cancelled = true;
window.cancelAnimationFrame(rafRef.current);
};
}, [state]);
const stopStream = () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
if (audioCtxRef.current) {
void audioCtxRef.current.close().catch(() => {});
audioCtxRef.current = null;
}
analyserRef.current = null;
setLevel(0);
};
const start = async () => {
if (disabled) return;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
try {
const ctx = new (window.AudioContext ||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.4;
source.connect(analyser);
audioCtxRef.current = ctx;
analyserRef.current = analyser;
} catch {
/* Analyser is best-effort; recording still works without level meter. */
}
const mime = pickMime();
const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
recorderRef.current = rec;
chunksRef.current = [];
cancelledRef.current = false;
rec.ondataavailable = (ev: BlobEvent) => {
if (ev.data && ev.data.size > 0) chunksRef.current.push(ev.data);
};
rec.onstop = () => {
const blob = new Blob(chunksRef.current, { type: rec.mimeType || 'audio/webm' });
chunksRef.current = [];
stopStream();
if (cancelledRef.current || blob.size === 0) {
setState('idle');
setElapsedSec(0);
return;
}
setState('finalizing');
const ext = extFor(blob.type);
const filename = 'voice-' + new Date().toISOString().replace(/[:.]/g, '-') + '.' + ext;
const file = new File([blob], filename, { type: blob.type });
void Promise.resolve(onComplete(file)).finally(() => {
setState('idle');
setElapsedSec(0);
});
};
rec.start();
startedAtRef.current = Date.now();
setElapsedSec(0);
setState('recording');
} catch (err: unknown) {
stopStream();
// Surface device-permission denials or capture failures via console;
// the composer doesn't have space for inline errors here and the
// browser already shows a system-level permission prompt.
console.error('VoiceRecorder.start failed', err);
}
};
const confirm = () => {
const rec = recorderRef.current;
if (!rec) return;
if (rec.state !== 'inactive') rec.stop();
};
const cancel = () => {
cancelledRef.current = true;
const rec = recorderRef.current;
if (rec && rec.state !== 'inactive') rec.stop();
else {
stopStream();
setState('idle');
setElapsedSec(0);
}
};
if (state === 'idle') {
return (
<button
type="button"
onClick={() => void start()}
disabled={disabled}
aria-label="Sprachnachricht aufnehmen"
title="Sprachnachricht aufnehmen"
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-surface-2 text-fg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
>
<MicIcon className="h-4 w-4" />
</button>
);
}
if (state === 'finalizing') {
return (
<div className="inline-flex h-11 items-center gap-2 rounded-lg bg-surface-2 px-3 text-xs text-fg-muted">
<SpinnerIcon className="h-4 w-4" />
<span>Wird gesendet</span>
</div>
);
}
// Visual level: scale from 0..1 → 0..100% width. Floor at 4% so the bar
// stays visible when silent.
const levelPct = Math.max(4, Math.min(100, Math.round(level * 180)));
const remaining = Math.max(0, MAX_RECORD_SEC - elapsedSec);
return (
<div className="inline-flex h-11 items-center gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 text-xs font-semibold text-rose-700 dark:text-rose-200">
<span className="h-2 w-2 animate-pulse rounded-full bg-rose-500" />
<span className="tabular-nums">{formatTime(elapsedSec)}</span>
<div
aria-hidden="true"
className="relative h-1.5 w-20 overflow-hidden rounded-full bg-rose-500/20"
>
<span
className="absolute inset-y-0 left-0 rounded-full bg-rose-500 transition-[width] duration-75"
style={{ width: levelPct + '%' }}
/>
</div>
<span className="tabular-nums text-[10px] text-rose-700/70 dark:text-rose-200/70">
{remaining}s
</span>
<button
type="button"
onClick={cancel}
aria-label="Aufnahme abbrechen"
title="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>
<button
type="button"
onClick={confirm}
aria-label="Aufnahme senden"
title="Senden"
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md bg-accent text-accent-fg transition hover:brightness-110"
>
<MicIcon className="h-3.5 w-3.5" />
</button>
</div>
);
}
function pickMime(): string | null {
const candidates = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
'audio/mp4',
];
for (const c of candidates) {
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(c)) return c;
}
return null;
}
function extFor(mime: string): string {
if (mime.includes('webm')) return 'webm';
if (mime.includes('ogg')) return 'ogg';
if (mime.includes('mp4')) return 'm4a';
return 'bin';
}
function formatTime(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return m + ':' + s.toString().padStart(2, '0');
}
+253 -14
View File
@@ -336,32 +336,271 @@ export function MonitorStopIcon(props: IconProps) {
);
}
export function SunIcon(props: IconProps) {
return (
<Base {...props}>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</Base>
);
}
export function MoonIcon(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 function GridIcon(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</Base>
);
}
export function FocusIcon(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<rect x="8" y="8" width="8" height="8" rx="1" />
</Base>
);
}
export function MaximizeIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M4 9V5a1 1 0 0 1 1-1h4" />
<path d="M20 9V5a1 1 0 0 0-1-1h-4" />
<path d="M4 15v4a1 1 0 0 0 1 1h4" />
<path d="M20 15v4a1 1 0 0 1-1 1h-4" />
</Base>
);
}
export function VideoIcon(props: IconProps) {
return (
<Base {...props}>
<rect x="2" y="6" width="15" height="12" rx="2" />
<path d="m22 8-5 4 5 4V8Z" />
</Base>
);
}
export function CrownIcon(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 function SendIcon(props: IconProps) {
return (
<Base {...props}>
<path d="m3 11 18-8-8 18-2-8-8-2Z" />
</Base>
);
}
export function ArchiveIcon(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="4" width="18" height="5" rx="1" />
<path d="M5 9v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9" />
<path d="M10 13h4" />
</Base>
);
}
export function BellIcon(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" />
<path d="M9.5 18a2.5 2.5 0 0 0 5 0" />
</Base>
);
}
export function BellOffIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M9.5 18a2.5 2.5 0 0 0 5 0" />
<path d="M13.73 4A2 2 0 0 0 10 4" />
<path d="M18 8c0 1-.1 1.9-.2 2.7" />
<path d="M4 4 20 20" />
<path d="M6 8a6 6 0 0 1 .2-1.7" />
<path d="M4 18h13.5L18 17.4c.5-1 2-2 2-6" />
</Base>
);
}
export function MoreVerticalIcon(props: IconProps) {
return (
<Base {...props}>
<circle cx="12" cy="5" r="1.5" />
<circle cx="12" cy="12" r="1.5" />
<circle cx="12" cy="19" r="1.5" />
</Base>
);
}
export function HeadphonesIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M3 14v-2a9 9 0 0 1 18 0v2" />
<path d="M3 14h4v7H5a2 2 0 0 1-2-2Z" />
<path d="M21 14h-4v7h2a2 2 0 0 0 2-2Z" />
</Base>
);
}
export function HeadphonesOffIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M3 14v-2a9 9 0 0 1 11.3-8.7" />
<path d="M21 14v-2a9 9 0 0 0-1.5-5" />
<path d="M3 14h4v7H5a2 2 0 0 1-2-2Z" />
<path d="M21 14h-4v7h2a2 2 0 0 0 2-2Z" />
<path d="M3 3 21 21" />
</Base>
);
}
export function ReplyIcon(props: IconProps) {
return (
<Base {...props}>
<polyline points="9 17 4 12 9 7" />
<path d="M20 18v-2a4 4 0 0 0-4-4H4" />
</Base>
);
}
export function ForwardIcon(props: IconProps) {
return (
<Base {...props}>
<polyline points="15 17 20 12 15 7" />
<path d="M4 18v-2a4 4 0 0 1 4-4h12" />
</Base>
);
}
export function ChevronUpIcon(props: IconProps) {
return (
<Base {...props}>
<polyline points="18 15 12 9 6 15" />
</Base>
);
}
export function AddUserIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M19 8v6M22 11h-6" />
</Base>
);
}
export function LogoMark(props: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
viewBox="0 0 64 64"
fill="none"
aria-hidden="true"
{...props}
>
<defs>
<linearGradient id="logo-grad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#818CF8" />
<stop offset="1" stopColor="#4F46E5" />
</linearGradient>
<clipPath id="logo-hex-clip">
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" />
</clipPath>
</defs>
<path
d="M6 9a5 5 0 0 1 5-5h10a5 5 0 0 1 5 5v8a5 5 0 0 1-5 5h-5.5L9 27v-5H11a5 5 0 0 1-5-5V9Z"
fill="url(#logo-grad)"
<polygon
points="32,5 57,19 57,45 32,59 7,45 7,19"
fill="#2e1065"
/>
<path
d="M12 14h8M12 10h8"
stroke="#0A0A0F"
strokeWidth="1.75"
strokeLinecap="round"
strokeOpacity="0.5"
<g clipPath="url(#logo-hex-clip)">
<path
d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z"
fill="#7c4dff"
/>
<path
d="M-4 32 Q 16 18 32 32 T 68 32"
stroke="#a78bfa"
strokeWidth="2"
fill="none"
/>
</g>
<polygon
points="32,5 57,19 57,45 32,59 7,45 7,19"
fill="none"
stroke="#a78bfa"
strokeWidth="1.5"
opacity="0.4"
/>
</svg>
);
}
// Full lockup: hex icon + "Netralax" wordmark. `tone` decides text colour:
// "dark" = white text (use on dark background), "light" = black text.
export function LogoLockup({
tone = 'dark',
...props
}: IconProps & { tone?: 'dark' | 'light' }) {
const textFill = tone === 'dark' ? '#ffffff' : '#0F172A';
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 260 64"
fill="none"
aria-hidden="true"
{...props}
>
<defs>
<clipPath id="logo-lockup-hex-clip">
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" />
</clipPath>
</defs>
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065" />
<g clipPath="url(#logo-lockup-hex-clip)">
<path
d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z"
fill="#7c4dff"
/>
<path
d="M-4 32 Q 16 18 32 32 T 68 32"
stroke="#a78bfa"
strokeWidth="2"
fill="none"
/>
</g>
<polygon
points="32,5 57,19 57,45 32,59 7,45 7,19"
fill="none"
stroke="#a78bfa"
strokeWidth="1.5"
opacity="0.4"
/>
<text
x="78"
y="42"
fontFamily="'Space Grotesk', system-ui, sans-serif"
fontSize="30"
fontWeight="600"
letterSpacing="-0.6"
fill={textFill}
>
Netralax
</text>
</svg>
);
}
+37 -23
View File
@@ -18,7 +18,9 @@ import {
import { useTranslation } from 'react-i18next';
import { findExistingDevice } from '../lib/device';
import { setSecretStoreUser } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import { registerWebPush } from '../lib/webPush';
interface AuthContextValue {
session: Session | null;
@@ -53,34 +55,39 @@ export function AuthProvider({ children }: { children: ReactNode }) {
(async () => {
const { data: sessionRes } = await supabase.auth.getSession();
if (cancelled) return;
if (sessionRes.session) {
const { error } = await supabase.auth.getUser();
if (cancelled) return;
if (error) {
const status = (error as { status?: number }).status;
if (status === 401 || status === 403) {
// Token genuinely invalid — wipe.
await supabase.auth.signOut().catch(() => {
/* ignore */
});
setSession(null);
} else {
// Network / server unreachable — keep cached session, let reads
// fail gracefully and recover when the stack is back.
console.warn('auth.getUser failed, keeping cached session:', error);
setSession(sessionRes.session);
}
} else {
setSession(sessionRes.session);
}
} else {
setSession(null);
}
// Flip `ready` immediately on cached session read so the UI unblocks even
// if the network is slow/down. Validate the token in the background and
// only wipe on an unambiguous 401/403 — a stalled getUser (Tauri WebView
// with no network, server unreachable) must not keep the app on the
// loading spinner forever.
setSession(sessionRes.session ?? null);
setReady(true);
if (sessionRes.session) {
supabase.auth
.getUser()
.then(({ error }) => {
if (cancelled || !error) return;
const status = (error as { status?: number }).status;
if (status === 401 || status === 403) {
void supabase.auth.signOut({ scope: 'local' }).catch(() => {
/* ignore */
});
setSession(null);
} else {
// Network / server unreachable — keep cached session.
console.warn('auth.getUser failed, keeping cached session:', error);
}
})
.catch((err: unknown) => {
console.warn('auth.getUser rejected, keeping cached session:', err);
});
}
})();
const { data: sub } = supabase.auth.onAuthStateChange((_event, s) => {
setSession(s);
setReady(true);
void setSecretStoreUser(s?.user.id ?? null);
});
return () => {
cancelled = true;
@@ -129,6 +136,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
});
}, [session, refreshProfile, refreshDevice]);
// 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.
useEffect(() => {
if (!device?.id) return;
void registerWebPush(device.id);
}, [device?.id]);
const signOut = useCallback(async () => {
await supabaseSignOut(supabase);
}, []);
+361 -41
View File
@@ -40,15 +40,20 @@ import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
import {
getAudioQualityParams,
getAudioSettings,
updateAudioSettings,
} from '../lib/audioSettings';
import {
createCallE2EE,
getCallE2EESettings,
isE2EESupported,
} from '../lib/callE2EE';
import { getParticipantVolume } from '../lib/participantVolumes';
import {
type DisplaySurfaceHint,
getPresetParams,
getScreenShareSettings,
type ScreenSharePreset,
updateScreenShareSettings,
} from '../lib/screenShareSettings';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
@@ -90,6 +95,10 @@ export interface RemoteScreenShare {
participantName: string;
}
// Visual call modes (Discord-style): grid shows all tiles equally, focus pins
// one speaker with others in a strip, fullscreen is cinema mode.
export type CallMode = 'grid' | 'focus' | 'fullscreen';
interface CallContextValue {
state: CallState;
room: Room | null;
@@ -97,20 +106,45 @@ interface CallContextValue {
isMuted: boolean;
isE2EEActive: boolean;
isScreenSharing: boolean;
isCameraEnabled: boolean;
isDeafened: boolean;
/** identity -> their deafen state, received via data channel. */
remoteDeafen: Record<string, boolean>;
// Zero or more remote screen shares (LiveKit supports multiple simultaneous).
remoteScreenShares: RemoteScreenShare[];
// Remembers the conversation of the last call we left so a sidebar widget
// can show "still live — rejoin" while peers stay in the room.
lastCallConversationId: string | null;
// Clean-Rail UI state — display mode + focused participant id.
callMode: CallMode;
focusedId: string | null;
// Actions:
startCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
acceptIncoming: () => Promise<void>;
acceptIncoming: (override?: CallKind) => Promise<void>;
rejectIncoming: () => void;
hangup: () => Promise<void>;
toggleMute: () => void;
toggleScreenShare: () => Promise<void>;
startScreenShare: (
overrides?: Partial<{
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
framerate: number | null;
}>,
) => Promise<void>;
stopScreenShare: () => Promise<void>;
toggleCamera: () => Promise<void>;
toggleDeafen: () => void;
dismissLastCall: () => void;
setCallMode: (mode: CallMode) => void;
setFocusedId: (id: string | null) => void;
// Runtime mic switch. Persists choice so subsequent calls reuse it, and
// hot-swaps the input on an active call without a reconnect.
setAudioInputDevice: (deviceId: string | null) => Promise<void>;
// Runtime speaker/headphone switch. Persists + applies setSinkId to all
// currently-attached remote-audio elements.
setAudioOutputDevice: (deviceId: string | null) => Promise<void>;
}
const CallContext = createContext<CallContextValue | null>(null);
@@ -133,8 +167,12 @@ export function CallProvider({ children }: { children: ReactNode }) {
const [isMuted, setIsMuted] = useState(false);
const [isE2EEActive, setIsE2EEActive] = useState(false);
const [isScreenSharing, setIsScreenSharing] = useState(false);
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
const [isDeafened, setIsDeafened] = useState(false);
const [remoteScreenShares, setRemoteScreenShares] = useState<RemoteScreenShare[]>([]);
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
const [callMode, setCallModeState] = useState<CallMode>('grid');
const [focusedId, setFocusedIdState] = useState<string | null>(null);
const signalChannelRef = useRef<RealtimeChannel | null>(null);
const presenceChannelRef = useRef<RealtimeChannel | null>(null);
@@ -149,6 +187,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
// For 1:1 calls the set has one entry so behaviour is unchanged; for
// groups, a single reject doesn't terminate the call while others ring.
const pendingPeersRef = useRef<Set<string>>(new Set());
// identity -> their current deafen state. Populated via LiveKit data
// channel messages ({ type: 'presence', deafened: bool }). Exposed as
// state so consumer components re-render on change.
const [remoteDeafen, setRemoteDeafen] = useState<Record<string, boolean>>({});
const stateRef = useRef<CallState>(state);
stateRef.current = state;
// Keep latest conversations accessible from signal-channel closures without
@@ -225,6 +267,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
setRemoteParticipants([]);
setRemoteScreenShares([]);
setIsScreenSharing(false);
setIsCameraEnabled(false);
setIsDeafened(false);
deafenedActive = false;
setRemoteDeafen({});
setIsE2EEActive(false);
const pres = presenceChannelRef.current;
@@ -445,6 +491,45 @@ export function CallProvider({ children }: { children: ReactNode }) {
}
});
// Mute/unmute a camera doesn't publish or unpublish — the publication
// stays, just its `muted` flag flips. Without this listener, remote
// participants who toggle video mid-call appear as a frozen last frame
// or (worse) a black tile on every other client. Bumping the
// remoteParticipants state reference forces buildTiles to re-read
// `isCameraEnabled` and swap to the avatar placeholder.
const bumpParticipants = () => {
setRemoteParticipants(Array.from(r.remoteParticipants.values()));
};
r.on(RoomEvent.TrackMuted, bumpParticipants);
r.on(RoomEvent.TrackUnmuted, bumpParticipants);
// Remote deafen state is broadcast via the LiveKit data channel. We
// store incoming states in `remoteDeafenMapRef` and bump participants
// so buildTiles re-reads it.
r.on(
RoomEvent.DataReceived,
(payload: Uint8Array, participant?: RemoteParticipant | undefined) => {
if (!participant?.identity) return;
try {
const text = new TextDecoder().decode(payload);
const msg = JSON.parse(text) as { type?: string; deafened?: boolean };
if (msg.type === 'presence' && typeof msg.deafened === 'boolean') {
const id: string = participant.identity;
const deafened: boolean = msg.deafened;
setRemoteDeafen((prev) => {
if (prev[id] === deafened) return prev;
return { ...prev, [id]: deafened };
});
}
} catch {
/* ignore malformed */
}
},
);
// When someone joins, re-send our current deafen state so they know.
r.on(RoomEvent.ParticipantConnected, () => {
void broadcastPresence(r, deafenedActive);
});
// Track my own screen-share state via LocalTrack events so the toggle
// stays in sync if the user stops sharing via the browser's native UI.
r.on(RoomEvent.LocalTrackPublished, (publication) => {
@@ -471,12 +556,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
setIsE2EEActive(false);
}
try {
const inputId = getAudioSettings().inputDeviceId;
await r.localParticipant.setMicrophoneEnabled(true, {
echoCancellation: aParams.echoCancellation,
noiseSuppression: aParams.noiseSuppression,
autoGainControl: aParams.autoGainControl,
channelCount: aParams.stereo ? 2 : 1,
sampleRate: aParams.sampleRateHz,
// Plain string maps to `ideal` — if the device is gone we fall back
// to OS default instead of throwing NotFoundError.
...(inputId ? { deviceId: inputId } : {}),
});
} catch (micErr: unknown) {
console.error('setMicrophoneEnabled failed', micErr);
@@ -484,6 +573,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
if (mediaKind === 'video') {
try {
await r.localParticipant.setCameraEnabled(true);
setIsCameraEnabled(true);
} catch (camErr: unknown) {
console.error('setCameraEnabled failed', camErr);
}
@@ -628,23 +718,31 @@ export function CallProvider({ children }: { children: ReactNode }) {
[myId, joinRoom, disconnectRoom],
);
const acceptIncoming = useCallback(async () => {
const s = stateRef.current;
if (s.kind !== 'incoming' || !myId) return;
const { callId, conversationId, mediaKind } = s;
everConnectedRef.current = false;
setLastCallConversationId(null);
setState({ kind: 'connecting', callId, conversationId, mediaKind });
try {
await joinRoom(conversationId, mediaKind, callId);
} catch (err: unknown) {
setState({
kind: 'error',
message: err instanceof Error ? err.message : 'join failed',
});
await disconnectRoom();
}
}, [myId, joinRoom, disconnectRoom]);
const acceptIncoming = useCallback(
async (override?: CallKind) => {
const s = stateRef.current;
if (s.kind !== 'incoming' || !myId) return;
const { callId, conversationId } = s;
// Caller's `mediaKind` is the INVITE kind (what they started with). The
// receiver can accept with audio even if the caller rang as video, or
// upgrade an audio invite to video on accept. `override` picks the
// receiver's choice.
const mediaKind: CallKind = override ?? s.mediaKind;
everConnectedRef.current = false;
setLastCallConversationId(null);
setState({ kind: 'connecting', callId, conversationId, mediaKind });
try {
await joinRoom(conversationId, mediaKind, callId);
} catch (err: unknown) {
setState({
kind: 'error',
message: err instanceof Error ? err.message : 'join failed',
});
await disconnectRoom();
}
},
[myId, joinRoom, disconnectRoom],
);
const rejectIncoming = useCallback(() => {
const s = stateRef.current;
@@ -718,33 +816,129 @@ export function CallProvider({ children }: { children: ReactNode }) {
});
}, []);
const toggleScreenShare = useCallback(async () => {
const startScreenShare = useCallback(
async (
overrides?: Partial<{
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
framerate: number | null;
}>,
) => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
if (lp.isScreenShareEnabled) return;
// Persist the user's choice so subsequent shares use the same config
// without re-opening the picker unless they want to change something.
const settings = getScreenShareSettings();
const preset = overrides?.preset ?? settings.preset;
const displaySurface =
overrides?.displaySurface !== undefined
? overrides.displaySurface
: settings.displaySurface;
const framerateOverride =
overrides?.framerate !== undefined
? overrides.framerate
: settings.framerateOverride;
updateScreenShareSettings({ preset, displaySurface, framerateOverride });
const ssParams = getPresetParams(preset);
const fps = framerateOverride ?? ssParams.framerate;
try {
await lp.setScreenShareEnabled(true, {
audio: false,
...(ssParams.dims
? {
resolution: {
width: ssParams.dims.width,
height: ssParams.dims.height,
frameRate: fps,
},
}
: {
resolution: {
width: 3840,
height: 2160,
frameRate: fps,
},
}),
// Hints the OS picker to pre-filter by source kind. `null` = no
// filter (show both). Cast because TS lib.dom doesn't know the
// field yet on all branches.
...(displaySurface
? ({ displaySurface } as { displaySurface: DisplaySurfaceHint })
: {}),
contentHint: 'detail',
});
setIsScreenSharing(true);
} catch (err: unknown) {
console.error('setScreenShareEnabled failed', err);
}
},
[],
);
const stopScreenShare = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
const nextOn = !lp.isScreenShareEnabled;
if (!lp.isScreenShareEnabled) return;
try {
const ssParams = getPresetParams(getScreenShareSettings().preset);
await lp.setScreenShareEnabled(nextOn, {
audio: false,
// Omitting `resolution` lets the browser return native source size —
// best possible input quality. Fixed presets pass explicit dims so
// the encoder has a predictable target.
...(ssParams.dims
? {
resolution: {
width: ssParams.dims.width,
height: ssParams.dims.height,
frameRate: ssParams.framerate,
},
}
: {}),
contentHint: 'detail',
});
setIsScreenSharing(nextOn);
await lp.setScreenShareEnabled(false);
setIsScreenSharing(false);
} catch (err: unknown) {
console.error('setScreenShareEnabled failed', err);
// User cancelled or permission denied — leave state as-is.
console.error('stopScreenShare failed', err);
}
}, []);
// Legacy toggle kept for convenience elsewhere — opens/closes with the
// last-persisted settings and no picker UI.
const toggleScreenShare = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
if (r.localParticipant.isScreenShareEnabled) {
await stopScreenShare();
} else {
await startScreenShare();
}
}, [startScreenShare, stopScreenShare]);
const toggleDeafen = useCallback(() => {
setIsDeafened((prev) => {
const next = !prev;
deafenedActive = next;
// Apply to every currently-attached remote-audio element. Fresh tracks
// that attach during a deafened session are muted in attachTrack above.
const els = document.querySelectorAll<HTMLAudioElement>(
'audio[data-livekit-track]',
);
els.forEach((el) => {
el.muted = next;
});
// Broadcast via LiveKit data channel so peers' UIs can show the
// headphones-off badge. Data channel works on any LiveKit server
// version, unlike `setAttributes` which requires a newer server.
const r = roomRef.current;
if (r) void broadcastPresence(r, next);
return next;
});
}, []);
const toggleCamera = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
const nextOn = !lp.isCameraEnabled;
try {
await lp.setCameraEnabled(nextOn);
setIsCameraEnabled(nextOn);
} catch (err: unknown) {
console.error('setCameraEnabled failed', err);
// Permission denied / no camera — keep state in sync with actual
// publication state so the button doesn't lie.
setIsCameraEnabled(lp.isCameraEnabled);
}
}, []);
@@ -954,6 +1148,76 @@ export function CallProvider({ children }: { children: ReactNode }) {
}
}, [myId, clearRingTimer, clearSoloTimer, disconnectRoom, sendSignal, emitCallEvent]);
const setCallMode = useCallback((mode: CallMode) => {
setCallModeState(mode);
}, []);
const setFocusedId = useCallback((id: string | null) => {
setFocusedIdState(id);
}, []);
const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
updateAudioSettings({ inputDeviceId: deviceId });
const r = roomRef.current;
if (!r) return;
try {
// LiveKit API: `switchActiveDevice(kind, deviceId)` hot-swaps without a
// reconnect. Pass empty string or `default` to revert to OS default.
await r.switchActiveDevice('audioinput', deviceId ?? 'default');
} catch (err: unknown) {
console.error('switchActiveDevice(audioinput) failed', err);
}
}, []);
const setAudioOutputDevice = useCallback(async (deviceId: string | null) => {
updateAudioSettings({ outputDeviceId: deviceId });
const sinkId = deviceId ?? '';
// Apply to every <audio> element we've attached to the body. LiveKit's
// switchActiveDevice only tracks elements it attached itself; our custom
// appendChild path bypasses that, so we iterate and setSinkId manually.
const els = document.querySelectorAll<HTMLAudioElement>(
'audio[data-livekit-track]',
);
for (const el of Array.from(els)) {
if (typeof el.setSinkId !== 'function') continue;
try {
await el.setSinkId(sinkId);
} catch (err: unknown) {
console.warn('setSinkId on audio element failed', err);
}
}
const r = roomRef.current;
if (r) {
try {
await r.switchActiveDevice('audiooutput', sinkId || 'default');
} catch (err: unknown) {
console.warn('switchActiveDevice(audiooutput) failed', err);
}
}
}, []);
// Reset UI call-mode state when the call leaves any active phase so the next
// call starts fresh at grid/unfocused.
useEffect(() => {
if (state.kind === 'idle' || state.kind === 'error') {
setCallModeState('grid');
setFocusedIdState(null);
}
}, [state.kind]);
// Global Esc: drop out of fullscreen cinema back to grid while in an active
// call. Doesn't hangup and doesn't fire when other dialogs would consume it.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
if (callMode !== 'fullscreen') return;
if (state.kind !== 'connected' && state.kind !== 'connecting') return;
setCallModeState('grid');
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [callMode, state.kind]);
const value = useMemo<CallContextValue>(
() => ({
state,
@@ -962,8 +1226,13 @@ export function CallProvider({ children }: { children: ReactNode }) {
isMuted,
isE2EEActive,
isScreenSharing,
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteScreenShares,
lastCallConversationId,
callMode,
focusedId,
startCall,
joinActiveCall,
acceptIncoming,
@@ -971,7 +1240,15 @@ export function CallProvider({ children }: { children: ReactNode }) {
hangup,
toggleMute,
toggleScreenShare,
startScreenShare,
stopScreenShare,
toggleCamera,
toggleDeafen,
dismissLastCall,
setCallMode,
setFocusedId,
setAudioInputDevice,
setAudioOutputDevice,
}),
[
state,
@@ -980,8 +1257,13 @@ export function CallProvider({ children }: { children: ReactNode }) {
isMuted,
isE2EEActive,
isScreenSharing,
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteScreenShares,
lastCallConversationId,
callMode,
focusedId,
startCall,
joinActiveCall,
acceptIncoming,
@@ -989,7 +1271,15 @@ export function CallProvider({ children }: { children: ReactNode }) {
hangup,
toggleMute,
toggleScreenShare,
startScreenShare,
stopScreenShare,
toggleCamera,
toggleDeafen,
dismissLastCall,
setCallMode,
setFocusedId,
setAudioInputDevice,
setAudioOutputDevice,
],
);
@@ -1002,10 +1292,26 @@ export function useCall(): CallContextValue {
return ctx;
}
// Shared flag so attachTrack (called from LiveKit event listeners, outside the
// React component) can apply the current deafen state to freshly-attached
// audio elements. Toggled by toggleDeafen in sync with the React state.
let deafenedActive = false;
async function broadcastPresence(room: Room, deafened: boolean): Promise<void> {
try {
const payload = new TextEncoder().encode(
JSON.stringify({ type: 'presence', deafened }),
);
await room.localParticipant.publishData(payload, { reliable: true });
} catch (err: unknown) {
console.warn('broadcastPresence failed', err);
}
}
function attachTrack(
track: RemoteTrack,
_publication: RemoteTrackPublication,
_participant: RemoteParticipant,
participant: RemoteParticipant,
): void {
if (track.kind === Track.Kind.Audio) {
const audio = track.attach();
@@ -1013,7 +1319,21 @@ function attachTrack(
audio.autoplay = true;
audio.setAttribute('playsinline', 'true');
audio.setAttribute('data-livekit-track', track.sid ?? '');
if (participant.identity) {
audio.setAttribute('data-participant', participant.identity);
audio.volume = getParticipantVolume(participant.identity);
}
if (deafenedActive) audio.muted = true;
document.body.appendChild(audio);
// Apply persisted sinkId so the element routes to the user's chosen
// speaker/headphone from the start (LiveKit's own `switchActiveDevice`
// doesn't track custom-appended elements).
const sinkId = getAudioSettings().outputDeviceId;
if (sinkId && typeof audio.setSinkId === 'function') {
void audio.setSinkId(sinkId).catch((err: unknown) => {
console.warn('setSinkId on attach failed', err);
});
}
}
}
// Video is handled later in M2.6/M3 by a dedicated <video> element.
@@ -1,4 +1,8 @@
import { type ConversationSummary, listConversations } from '@chat-app/shared/chat';
import {
type ConversationSummary,
isConversationMuted,
listConversations,
} from '@chat-app/shared/chat';
import {
createContext,
type ReactNode,
@@ -95,7 +99,9 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
return;
}
try {
setLoading(true);
// Only flag loading on initial fetch so background re-syncs (visibility
// change, online event) don't blank the list each time.
setLoading((prev) => (conversationsRef.current.length === 0 ? true : prev));
const convs = await listConversations(supabase);
setConversations(convs);
const entries = await Promise.all(
@@ -151,6 +157,15 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
.on('postgres_changes', { event: '*', schema: 'public', table: 'conversations' }, () => {
void refresh();
})
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'profiles' },
() => {
// Peer updated their profile (e.g. uploaded avatar / changed name).
// Re-pull conversations so members[].profile picks up the new data.
void refresh();
},
)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'messages' },
@@ -168,10 +183,15 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
...prev,
[row.conversation_id]: (prev[row.conversation_id] ?? 0) + 1,
}));
// Notification sound + OS notification — respect DND. Body stays
// empty because message content is E2E-encrypted and only
// decryptable in the conversation view (not at this hook level).
if (presenceRef.current !== 'dnd') {
// Notification sound + OS notification — respect DND and
// per-conversation mute. Body stays empty because message
// content is E2E-encrypted and only decryptable in the
// conversation view (not at this hook level).
const convForMute = conversationsRef.current.find(
(c) => c.id === row.conversation_id,
);
const muted = isConversationMuted(convForMute?.mutedUntil ?? null);
if (presenceRef.current !== 'dnd' && !muted) {
playNotificationTone();
const conv = conversationsRef.current.find(
(c) => c.id === row.conversation_id,
@@ -195,7 +215,34 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
)
.subscribe();
// Windows WebView2 aggressively throttles background WebSockets and
// sometimes drops events entirely while the window is minimised. Force a
// refresh + realtime reconnect on visibility/focus regain so we never
// leave stale conversation lists on a Windows client after the user
// returns to the app.
let lastAwakeRefresh = 0;
const AWAKE_THROTTLE_MS = 30_000;
const onAwake = () => {
if (document.visibilityState !== 'visible') return;
const now = Date.now();
if (now - lastAwakeRefresh < AWAKE_THROTTLE_MS) return;
lastAwakeRefresh = now;
void refresh();
try {
// If the socket got wedged during background throttle, a no-op
// unsubscribe+resubscribe brings it back. `subscribe()` on an already
// joined channel is a no-op so this is safe.
channel.subscribe();
} catch {
/* ignore — already live */
}
};
document.addEventListener('visibilitychange', onAwake);
window.addEventListener('online', onAwake);
return () => {
document.removeEventListener('visibilitychange', onAwake);
window.removeEventListener('online', onAwake);
void supabase.removeChannel(channel);
};
}, [myId, refresh, markRead]);
+53
View File
@@ -0,0 +1,53 @@
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { applyTheme, getInitialTheme, setThemePersisted, type Theme } from '../lib/theme';
interface ThemeContextValue {
theme: Theme;
toggle: () => void;
setTheme: (t: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => getInitialTheme());
useEffect(() => {
applyTheme(theme);
}, [theme]);
const setTheme = useCallback((next: Theme) => {
setThemePersisted(next);
setThemeState(next);
}, []);
const toggle = useCallback(() => {
setThemeState((prev) => {
const next: Theme = prev === 'dark' ? 'light' : 'dark';
setThemePersisted(next);
return next;
});
}, []);
const value = useMemo<ThemeContextValue>(
() => ({ theme, toggle, setTheme }),
[theme, toggle, setTheme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}
+14 -2
View File
@@ -44,10 +44,22 @@ export async function checkForUpdate(): Promise<UpdateState> {
error: null,
};
} catch (err: unknown) {
console.warn('checkForUpdate failed', err);
// Swallow "no release on GitHub yet" / network-unreachable cases silently.
// The updater endpoint serves `latest.json` from GitHub releases; a fresh
// repo or offline machine produces a generic "Could not fetch a valid
// release JSON" error that has no actionable information for the user —
// logging it on every launch just pollutes the console.
const msg = err instanceof Error ? err.message : String(err);
const benign =
/could not fetch a valid release json|network|timed? out|failed to fetch|connection/i.test(
msg,
);
if (!benign) {
console.warn('checkForUpdate failed', err);
}
return {
...IDLE_UPDATE_STATE,
error: err instanceof Error ? err.message : 'update check failed',
error: benign ? null : msg,
};
}
}
+17
View File
@@ -9,10 +9,19 @@ export type AudioQuality = 'voice' | 'hifi';
export interface AudioSettings {
quality: AudioQuality;
// Preferred input deviceId from enumerateDevices. null = use browser default
// (whatever the OS points at). Persisted across sessions, so "grandma's
// mic is default" stays even after browser picks the wrong device.
inputDeviceId: string | null;
// Preferred output (speaker/headphone) deviceId. null = system default.
// Applied to every <audio> element we attach via HTMLMediaElement.setSinkId.
outputDeviceId: string | null;
}
const DEFAULTS: AudioSettings = {
quality: 'voice',
inputDeviceId: null,
outputDeviceId: null,
};
export interface AudioQualityParams {
@@ -73,6 +82,14 @@ function read(): AudioSettings {
const parsed = JSON.parse(raw) as Partial<AudioSettings>;
cached = {
quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality,
inputDeviceId:
typeof parsed.inputDeviceId === 'string' && parsed.inputDeviceId.length > 0
? parsed.inputDeviceId
: DEFAULTS.inputDeviceId,
outputDeviceId:
typeof parsed.outputDeviceId === 'string' && parsed.outputDeviceId.length > 0
? parsed.outputDeviceId
: DEFAULTS.outputDeviceId,
};
return cached;
} catch {
+77
View File
@@ -0,0 +1,77 @@
import { supabase } from './supabase';
const BUCKET = 'profile-avatars';
const MAX_DIM = 512;
const QUALITY = 0.85;
// Resizes the source image to a centred-cropped square ≤ MAX_DIM and
// re-encodes as WebP. Falls back to JPEG if WebP isn't supported (rare).
async function resizeToSquare(file: File): Promise<Blob> {
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;
});
const minSide = Math.min(img.naturalWidth, img.naturalHeight);
const sx = (img.naturalWidth - minSide) / 2;
const sy = (img.naturalHeight - minSide) / 2;
const target = Math.min(MAX_DIM, minSide);
const canvas = document.createElement('canvas');
canvas.width = target;
canvas.height = target;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas context unavailable');
ctx.drawImage(img, sx, sy, minSide, minSide, 0, 0, target, target);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/webp', QUALITY),
);
if (blob) return blob;
const jpeg = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/jpeg', QUALITY),
);
if (!jpeg) throw new Error('canvas toBlob returned null');
return jpeg;
} finally {
URL.revokeObjectURL(url);
}
}
export async function uploadAvatar(userId: string, file: File): Promise<string> {
if (!file.type.startsWith('image/')) {
throw new Error('only image files are accepted');
}
const blob = await resizeToSquare(file);
const ext = blob.type === 'image/webp' ? 'webp' : '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.
const name =
userId + '/' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8) + '.' + ext;
const { error: upErr } = await supabase.storage.from(BUCKET).upload(name, blob, {
contentType: blob.type,
cacheControl: '604800',
upsert: false,
});
if (upErr) throw upErr;
const { data: pub } = supabase.storage.from(BUCKET).getPublicUrl(name);
return pub.publicUrl;
}
export async function deleteAvatarObject(publicUrl: string): Promise<void> {
// Public URLs look like
// https://<host>/storage/v1/object/public/profile-avatars/<path>
// Extract <path> and remove.
const marker = '/object/public/' + BUCKET + '/';
const idx = publicUrl.indexOf(marker);
if (idx === -1) return;
const path = publicUrl.slice(idx + marker.length);
const { error } = await supabase.storage.from(BUCKET).remove([path]);
if (error) throw error;
}
+236
View File
@@ -0,0 +1,236 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { type OwnDeviceCtx, shareConvKeyToDevice } 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.
//
// This fixes the "cannot decrypt" cliff for devices that registered while
// no other participant device was online to share the key with them.
interface SyncCtx {
myUserId: string;
myDeviceId: string;
priv: Uint8Array;
}
// db-types is stale for `conversation_keys`/`active_key_version`; bypass.
function rawFrom(table: string) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (supabase as unknown as { from: (t: string) => any }).from(table);
}
// Module-level flag — gap-fill runs once per (user, device) combo per
// process lifetime. Page reloads / route changes don't re-trigger it.
const backfilledKey = new Set<string>();
export function startConversationKeySync(
ownUserId: string,
ownDeviceId: string,
): () => void {
let cancelled = false;
let priv: Uint8Array | null = null;
const dedupeKey = ownUserId + ':' + ownDeviceId;
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then(async (pk) => {
if (cancelled) return;
priv = pk;
if (!priv) return;
if (backfilledKey.has(dedupeKey)) return;
backfilledKey.add(dedupeKey);
await syncAllExistingGaps({ myUserId: ownUserId, myDeviceId: ownDeviceId, priv });
});
const channel = supabase
.channel('device-key-sync:' + ownDeviceId)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'devices' },
(payload: { new: { id?: string; user_id?: string; public_key?: string } }) => {
if (cancelled) return;
const row = payload.new;
if (!row?.id || !row.user_id || !row.public_key) return;
if (row.user_id === ownUserId && row.id === ownDeviceId) return;
if (!priv) return; // backfill on mount will catch it later
void wrapForOneDevice(
{ myUserId: ownUserId, myDeviceId: ownDeviceId, priv },
row.id,
row.user_id,
row.public_key,
);
},
)
.subscribe();
return () => {
cancelled = true;
void supabase.removeChannel(channel);
};
}
async function listMyConversationIds(myUserId: string): Promise<string[]> {
const { data, error } = await supabase
.from('conversation_members')
.select('conversation_id')
.eq('user_id', myUserId)
.eq('accepted', true);
if (error) {
console.warn('keySync: own-member lookup failed', error);
return [];
}
return (data ?? []).map((r) => r.conversation_id as string);
}
async function listConversationDevices(
conversationId: string,
): Promise<{ id: string; user_id: string; public_key: string }[]> {
const { data: members, error: mErr } = await supabase
.from('conversation_members')
.select('user_id')
.eq('conversation_id', conversationId)
.eq('accepted', true);
if (mErr) {
console.warn('keySync: members lookup failed', mErr);
return [];
}
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')
.in('user_id', userIds);
if (dErr) {
console.warn('keySync: devices lookup failed', dErr);
return [];
}
return (devices ?? []) as { id: string; user_id: string; public_key: string }[];
}
async function listExistingKeyRecipients(
conversationId: string,
keyVersion: number,
): Promise<Set<string>> {
const { data, error } = await rawFrom('conversation_keys')
.select('recipient_device_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));
}
async function getActiveKeyVersion(conversationId: string): Promise<number> {
const { data, error } = await rawFrom('conversations')
.select('active_key_version')
.eq('id', conversationId)
.single();
if (error) {
console.warn('keySync: active key version lookup failed', error);
return 1;
}
return (data as { active_key_version: number }).active_key_version;
}
async function syncAllExistingGaps(ctx: SyncCtx): Promise<void> {
const convs = await listMyConversationIds(ctx.myUserId);
for (const convId of convs) {
try {
await syncOneConversationGaps(ctx, convId);
} catch (err: unknown) {
console.warn('keySync: conv gap sync failed', { convId, err });
}
}
}
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 recipients = await listExistingKeyRecipients(convId, version);
const ownCtx: OwnDeviceCtx = {
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
// sharing (or don't need it if we ourselves haven't been wrapped yet).
if (dev.id === ctx.myDeviceId) continue;
try {
await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.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
// 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', {
convId,
recipient: dev.id,
err,
});
}
}
}
function isExpectedShareFailure(err: unknown): boolean {
if (!err || typeof err !== 'object') return false;
const e = err as { message?: string; code?: string; details?: string; status?: number };
const code = (e.code ?? '').toString();
const status = e.status;
const haystack = (e.message ?? '') + ' ' + (e.details ?? '');
return (
status === 403 ||
code === '42501' || // postgres: insufficient_privilege (RLS)
code === '23505' || // unique_violation
haystack.includes('row-level security') ||
haystack.includes('does not have it yet') ||
haystack.includes('Forbidden')
);
}
async function wrapForOneDevice(
ctx: SyncCtx,
newDeviceId: string,
newDeviceUserId: string,
newDevicePubHex: string,
): Promise<void> {
const myConvs = new Set(await listMyConversationIds(ctx.myUserId));
const { data: peerMember, error: pErr } = await supabase
.from('conversation_members')
.select('conversation_id')
.eq('user_id', newDeviceUserId);
if (pErr) {
console.warn('keySync: peer-member lookup failed', pErr);
return;
}
const sharedConvs = (peerMember ?? [])
.map((r) => r.conversation_id as string)
.filter((id) => myConvs.has(id));
if (sharedConvs.length === 0) return;
const newPub = pgHexToBytes(newDevicePubHex);
const ownCtx: OwnDeviceCtx = {
userId: ctx.myUserId,
deviceId: ctx.myDeviceId,
privateKey: ctx.priv,
};
for (const convId of sharedConvs) {
try {
await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx);
} catch (err: unknown) {
console.warn('keySync: shareConvKeyToDevice failed', { convId, err });
}
}
}

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