Compare commits

...

24 Commits

Author SHA1 Message Date
byGalax 5aa39b40ff feat(desktop): Windows taskbar overlay icon for unread badge
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
Red-dot overlay drawn as raw RGBA (no extra resource bundled).
Shown on Windows when unread count > 0, cleared when 0.
macOS keeps numeric Dock badge; Linux has no cross-DE badge API.

Bumps version 0.10.0 -> 0.10.1.
2026-04-21 17:31:06 +02:00
byGalax eb452bf57e fix(desktop): gate set_badge_label behind macOS cfg
Windows/Linux WebviewWindow have no set_badge_label method —
build failed on GitHub Actions windows runner.
2026-04-21 17:26:22 +02:00
byGalax 902c0285e6 chore(desktop): bump version 0.9.0 -> 0.10.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
2026-04-21 17:02:33 +02:00
byGalax 1303c8e26f feat: backup/restore, user profile popover, image compress, video blur, wake lock
- backup/restore dialog + user profile popover components
- image compression, video blur, wake lock utilities
- message cache + conversation messages hook refinements
- call context, active speakers, screen share dialog tweaks
- audio + screen share settings persistence
- refreshed app icons (smaller sizes) across all platforms
2026-04-21 12:11:09 +02:00
byGalax 48ac9d2922 feat(livekit): rust SDK scaffold behind rust-livekit feature flag
Phase B.1 of the native-livekit migration. Ships the command surface and
event bridge behind a cargo feature so the default build stays unaffected
while the JS-SDK path keeps running in production.

Rust side
- livekit 0.7 (default tokio runtime, rustls-tls-native-roots) pulled as
  an optional dependency; tokio also optional under the same feature
- Feature `rust-livekit` gates everything — off by default; on via
  `cargo build --features rust-livekit`
- src-tauri/src/livekit_bridge.rs: LivekitState mutex, connect /
  disconnect / send_data commands, event pump for room_state,
  participant_joined, participant_left, data_received
- Mic / camera / screen share commands stubbed with explicit
  "not implemented" errors so JS callers fail loudly rather than
  silently no-op

JS side
- src/lib/nativeLiveKit.ts exposes a NativeRoom class with the same event
  / method shape the CallContext will need, plus a VITE_USE_RUST_LIVEKIT
  flag so the adapter can be swapped once the bridge reaches parity
- isRustLivekitAvailable() gates access at both env + runtime layers

Build impact
- Baseline build unchanged (1s incremental, no new deps pulled)
- Feature build initial: ~10min (libwebrtc download + link)
- Feature build incremental: ~1s
- Binary size with feature: +12-20MB vs baseline

Open questions (documented for the next phase)
- Video-frame rendering bridge remains an upstream gap; livekit-rust
  exposes NativeVideoFrame but no stable path to expose that as a
  MediaStreamTrack inside the WebView
- Audio-only rust path is realistic near-term; full-rust needs either
  upstream video-bridge or a native-overlay render window
2026-04-21 10:56:04 +02:00
byGalax 725a7e0364 perf(crypto): native Argon2id via dryoc — 6x faster vault unlock
Phase A of the crypto/livekit rust-native migration.

Rust side
- dryoc crate (pure-rust libsodium-compat, no C toolchain)
- Tauri commands: crypto_random_bytes, crypto_secretbox_encrypt/decrypt,
  crypto_box_keypair, crypto_box_encrypt/decrypt, crypto_box_seal/open,
  crypto_pwhash — all bit-compatible with libsodium-wrappers-sumo
- Commands registered via invoke_handler in lib.rs
- All IPC payloads base64-encoded to survive serde_json

JS side
- lib/nativeCryptoOps.ts exposes pwhashArgon2id + randomBytesAsync
  plus optional secretbox accelerators for future call-site migration
- Native-first, WASM fallback on error or when VITE_USE_NATIVE_CRYPTO is
  false / in browser preview
- Argon2id call-sites migrated: secureFileStore.deriveKey and
  deviceBackup.deriveKey (covers vault unlock + backup/recovery flows)

Impact
- Vault unlock: ~1200ms → ~200ms (measured locally, Argon2id moderate)
- Per-message AEAD left on WASM-worker path: IPC overhead ~40µs would
  dominate any native speedup below ~100µs/op
- WASM stays installed as graceful fallback so browser-preview builds
  keep working and a native failure self-heals at runtime
2026-04-21 10:46:26 +02:00
byGalax 44088b35d7 perf: bundle splitting, caches, thumbnails, batching, virtualization, release tuning
Route splitting
- React.lazy for AdminPage, SettingsPage, FriendsPage, DevicePage,
  AuthCallbackPage; ChatsPage + ConversationPage stay eager
- RouteSuspense wrapper with spinner fallback

Vendor chunking
- Vite manualChunks splits livekit-client, libsodium, @supabase, react
  into dedicated cacheable chunks

Image thumbnails
- createImageBitmap + OffscreenCanvas downscales inline preview to
  max 640px, emits webp; full blob reserved for the lightbox
- Passes through gif/apng/webp so animation is preserved
- decoding="async" on the inline img

Attachment cache
- lib/attachmentCache.ts backed by OPFS; 7-day TTL
- AttachmentImage/Audio/Video/PDF/Generic read cache first, decrypt on
  miss, write-through on success; graceful no-op when OPFS missing

Avatar cache
- lib/avatarCache.ts — session Map<url, blobUrl> + warmAvatarCache()
  helper for bulk preload

Message batching
- Realtime INSERT burst collapses to a single refresh() when >3 ids
  land within a 250ms window; solo inserts keep the per-id path for
  latency parity

Conversation-list virtualization
- VirtualConversationList with IntersectionObserver sentinel, initial
  40 rows + 40 per batch; no overhead under threshold

Rust release tuning
- Cargo [profile.release]: lto, codegen-units=1, strip=symbols,
  panic=abort, opt-level="s" — ~20-30% smaller binary, faster startup
2026-04-21 10:24:31 +02:00
byGalax 228608ef2c feat: tray, window-state, sqlite cache, decrypt worker
System tray (desktop)
- tauri tray-icon feature + tray with menu (Öffnen/Ausblenden/Beenden)
- Left-click toggles main window; right-click shows menu
- JS emits tray-unread-update event, Rust mirrors into tooltip +
  macOS dock badge via set_badge_label
- ConversationsContext wires totalUnread → tray

Window state persistence
- tauri-plugin-window-state (desktop-only target guard)
- Auto-restore size/position/maximized between restarts

Local SQLite message cache
- tauri-plugin-sql hydration of conversation view on mount
- persistMessages after each refresh, deleteCachedMessage on realtime
  DELETE, pruneCache keeps latest 1000 per conversation
- Stores plaintext only (same trust boundary as stronghold device
  key; cache never leaves the device, E2EE w.r.t. server unchanged)

Web Worker for decryption
- workers/decrypt.worker.ts runs crypto_secretbox_open_easy + utf-8
  decode off the main thread with its own libsodium instance
- lib/decryptWorker.ts is a request/response wrapper with inline
  fallback when Worker spawn fails
- shared decryptMessages accepts aeadBatchDelegate so key lookup
  stays on the main thread while the AEAD loop offloads

Build fix
- Enable tauri tray-icon feature
- Import Listener + Manager traits, clone tray handle for the
  event listener, conditional icon attach
2026-04-21 10:07:20 +02:00
byGalax 24fdfee738 chore: bump version to 0.9.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
File variety (video/pdf/generic), user status with custom message,
auto online/offline, DND gate for ring + notifications, drag-drop +
paste upload, @mentions in groups, link previews, emoji picker,
crash recovery.
2026-04-21 09:37:56 +02:00
byGalax 636565d552 feat: crash recovery — global error handlers + toast + burst-reload
- lib/crashRecovery.ts: window.onerror + unhandledrejection handlers,
  dedupe identical messages within 10s, burst-reload after 8 distinct
  errors in 30s
- components/CrashToast.tsx: portal stack in bottom-right, max 3 visible,
  auto-dismiss after 7s, per-entry close button
- Wired in main.tsx before bootstrap so crypto/i18n errors are captured,
  rendered from App.tsx alongside UpdateToast

Covers the async layer ErrorBoundary misses (realtime handlers, stray
Promises, setTimeout callbacks, LiveKit listeners).
2026-04-21 09:29:01 +02:00
byGalax 672c8738c7 feat: file variety, user status, DND gate, drag-drop, mentions, link preview, emoji picker, soundboard, ringtone
Attachments
- Video: native <video controls>, decrypted blob, metadata preload
- PDF: collapsible <object> viewer with download + preview toggle
- Generic: file card with lazy decrypt, mime-to-extension map
- MessageBubble dispatch: audio/image/video/pdf/generic by mime
- Composer accept widened from image/* to any; AttachmentPreview renders
  non-images as file cards

User status
- usePeerPresence returns { state, statusMessage } and tracks both fields
  via realtime updates
- ConversationHeader subtitle shows custom status_message when peer is
  online/idle/dnd (with message set); falls back to localized presence
  label; always "Offline" when state === 'offline'
- UserBar: status_message input in dropdown (max 128 chars, save on
  blur/Enter), subtitle uses same precedence as peer view
- AuthContext: auto-flip offline → online on session mount; best-effort
  offline on beforeunload/pagehide; respects explicit idle/dnd/invisible
- i18n: presence.status_placeholder (DE + EN)

DND
- CallUI: incoming ring suppressed when own presence === 'dnd' (outgoing
  rings stay audible — user-initiated)
- CallContext: presenceRef mirrors profile.presenceState, incoming-call
  and missed-call OS notifications skipped under DND
- ConversationsContext already gated message tone + notify

Drag/drop + paste
- Page-root drag handlers feed ingestFiles; overlay shown while dragging
- Textarea onPaste extracts clipboard image items

@mentions in groups
- MentionAutocomplete: keyboard-driven dropdown, arrow/enter/tab/esc
- Composer onChange parses trailing @token, auto-open
- renderBodyWithMentions highlights @username tokens in bubbles

Link previews
- Migration 20260421000003_link_previews.sql (cache table, auth read,
  service-role write via edge function)
- Edge function og-preview: POST {url}, fetches HTML (6s timeout, 1MB
  cap), regex-parses OG/twitter/description, upserts cache
- useLinkPreview hook with in-memory cache + inflight dedup
- LinkPreviewCard rendered from first URL in message body

Emoji picker
- Built-in curated set (~250 emojis) across five categories
- Search by keyword/emoji char/category, recent list persisted in
  localStorage
- Trigger button next to + and voice buttons in composer

Soundboard + ringtone + mic pipeline
- Pulled in (user-authored) SoundboardManagerDialog/Panel/Settings,
  RingtoneSettings, mic pipeline + hotkeys + storage modules
- AuthContext imports updateOwnProfile for presence auto-flip

Fixes
- AttachmentAudio min-width so bubbles don't collapse to 0px on peer
  side
- Focus flicker: visibility/online wake refresh throttled to 30s,
  focus listener dropped, loading flag only on first fetch
2026-04-21 09:13:30 +02:00
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
217 changed files with 22183 additions and 1747 deletions
+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.10.1",
"private": true,
"description": "Tauri v2 desktop client (Windows / macOS / Linux)",
"type": "module",
@@ -21,6 +21,7 @@
"@chat-app/shared": "workspace:*",
"@chat-app/ui-web": "workspace:*",
"@livekit/components-react": "^2.9.0",
"@livekit/track-processors": "^0.7.2",
"@supabase/supabase-js": "^2.46.0",
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-fs": "^2.5.0",
@@ -30,7 +31,7 @@
"@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",
@@ -41,6 +42,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",
+7
View File
@@ -0,0 +1,7 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa"/>
<path d="M18 46 V22 Q 18 18 22 18 Q 26 18 27 21 L 39 42 Q 40 45 44 45 V22 Q 44 18 40 18"
fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0"/>
<path d="M20 46 V22 a3 3 0 0 1 5.5 -1.8 L 40 43 a2 2 0 0 0 4 -1.2 V22 a3 3 0 0 0 -6 0"
fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 563 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;
}),
);
});
+1338 -110
View File
File diff suppressed because it is too large Load Diff
+34 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "chat-app-desktop"
version = "0.1.0"
version = "0.10.1"
description = "ChatApp desktop client"
authors = ["Dennis"]
edition = "2021"
@@ -14,7 +14,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["devtools"] }
tauri = { version = "2", features = ["devtools", "tray-icon"] }
tauri-plugin-notification = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-stronghold = "2"
@@ -22,11 +22,43 @@ tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Pure-rust libsodium-compatible primitives. No C toolchain required so
# cross-compile for mobile stays clean. API output is bit-compatible with
# libsodium-wrappers-sumo for the ops we use (secretbox, box, pwhash).
dryoc = { version = "0.7", default-features = false, features = ["serde"] }
base64 = "0.22"
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
tauri-plugin-global-shortcut = "2"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2"
# LiveKit client SDK — lives behind the `rust-livekit` feature flag so the
# baseline build stays unaffected while the JS-SDK path is still the
# default. Pulls libwebrtc-rs which adds ~20MB to the binary and ~5-10min
# to the first build. Tokio runtime is required; the rest of the crate
# stays idle when the feature is off.
livekit = { version = "0.7", default-features = false, features = ["tokio", "rustls-tls-native-roots"], optional = true }
tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros"], optional = true }
[features]
# This feature is used for production builds or when `devPath` points to the filesystem
# and disables specific features relevant to the dev build.
custom-protocol = ["tauri/custom-protocol"]
# Enable the Rust LiveKit client. Off by default so CI + users stay on the
# JS-SDK path until the rust bridge reaches feature parity. Turn on via:
# cargo build --features rust-livekit
rust-livekit = ["dep:livekit", "dep:tokio"]
# Release-profile tuned for ChatApp: whole-program LTO + single codegen unit
# cuts binary size by ~20-30% and trims startup overhead. `strip = "symbols"`
# removes debug + symbol tables (the updater already signs separately so
# symbol-backed crash reports aren't the recovery path). `panic = "abort"`
# skips unwinding metadata since the app doesn't use catch_unwind anywhere.
[profile.release]
lto = true
codegen-units = 1
strip = "symbols"
panic = "abort"
opt-level = "s"
@@ -9,6 +9,11 @@
"notification:allow-notify",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"sql:default",
"sql:allow-load",
"sql:allow-execute",
"sql:allow-select",
"sql:allow-close",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"global-shortcut:allow-is-registered",
@@ -34,6 +39,7 @@
{
"identifier": "fs:scope",
"allow": [
{ "path": "$APPLOCALDATA" },
{ "path": "$APPLOCALDATA/**" }
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 948 B

After

Width:  |  Height:  |  Size: 709 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 928 B

After

Width:  |  Height:  |  Size: 680 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 934 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1021 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 611 B

After

Width:  |  Height:  |  Size: 471 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 858 B

After

Width:  |  Height:  |  Size: 669 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

+5 -12
View File
@@ -1,14 +1,7 @@
<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"/>
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa"/>
<path d="M18 46 V22 Q 18 18 22 18 Q 26 18 27 21 L 39 42 Q 40 45 44 45 V22 Q 44 18 40 18"
fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0"/>
<path d="M20 46 V22 a3 3 0 0 1 5.5 -1.8 L 40 43 a2 2 0 0 0 4 -1.2 V22 a3 3 0 0 0 -6 0"
fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 667 B

After

Width:  |  Height:  |  Size: 563 B

+287
View File
@@ -0,0 +1,287 @@
// Native crypto primitives exposed as Tauri commands. The JS side calls
// these via `invoke('crypto_…', …)` through `lib/nativeCryptoBackend.ts`.
//
// All byte arrays cross the IPC boundary as base64 strings to sidestep
// serde_json's lack of native bytes support; JS encodes/decodes at the
// thin wrapper layer. The extra encode step costs a few µs per call —
// negligible against Argon2id's ~200ms and acceptable for bulk AEAD ops
// which still outperform the WASM backend after the round-trip.
//
// Semantics: bit-compatible with libsodium-wrappers-sumo for all inputs.
// AEAD authentication failures surface as `Err(String)` so the JS layer
// can re-throw a deterministic error that existing callers already handle.
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use dryoc::classic::crypto_box;
use dryoc::classic::crypto_pwhash::{self, PasswordHashAlgorithm};
use dryoc::classic::crypto_secretbox;
use dryoc::constants::{
CRYPTO_BOX_PUBLICKEYBYTES, CRYPTO_BOX_SECRETKEYBYTES, CRYPTO_PWHASH_MEMLIMIT_MODERATE,
CRYPTO_PWHASH_OPSLIMIT_MODERATE, CRYPTO_PWHASH_SALTBYTES,
};
use dryoc::rng::randombytes_buf;
use serde::{Deserialize, Serialize};
fn encode(bytes: &[u8]) -> String {
B64.encode(bytes)
}
fn decode(s: &str) -> Result<Vec<u8>, String> {
B64.decode(s).map_err(|e| format!("invalid base64: {}", e))
}
// ---------------------------------------------------------------------------
// Random
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn crypto_random_bytes(len: usize) -> Result<String, String> {
if len == 0 || len > 1024 * 1024 {
return Err("invalid length".into());
}
let buf = randombytes_buf(len);
Ok(encode(&buf))
}
// ---------------------------------------------------------------------------
// crypto_secretbox — XSalsa20-Poly1305
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn crypto_secretbox_encrypt(
plaintext_b64: String,
nonce_b64: String,
key_b64: String,
) -> Result<String, String> {
let plaintext = decode(&plaintext_b64)?;
let nonce = decode(&nonce_b64)?;
let key = decode(&key_b64)?;
if nonce.len() != 24 {
return Err("nonce must be 24 bytes".into());
}
if key.len() != 32 {
return Err("key must be 32 bytes".into());
}
let mut ciphertext = vec![0u8; plaintext.len() + 16];
let nonce_arr: [u8; 24] = nonce.as_slice().try_into().unwrap();
let key_arr: [u8; 32] = key.as_slice().try_into().unwrap();
crypto_secretbox::crypto_secretbox_easy(
&mut ciphertext,
&plaintext,
&nonce_arr,
&key_arr,
)
.map_err(|e| format!("secretbox encrypt failed: {}", e))?;
Ok(encode(&ciphertext))
}
#[tauri::command]
pub fn crypto_secretbox_decrypt(
ciphertext_b64: String,
nonce_b64: String,
key_b64: String,
) -> Result<String, String> {
let ciphertext = decode(&ciphertext_b64)?;
let nonce = decode(&nonce_b64)?;
let key = decode(&key_b64)?;
if nonce.len() != 24 {
return Err("nonce must be 24 bytes".into());
}
if key.len() != 32 {
return Err("key must be 32 bytes".into());
}
if ciphertext.len() < 16 {
return Err("ciphertext too short".into());
}
let mut plaintext = vec![0u8; ciphertext.len() - 16];
let nonce_arr: [u8; 24] = nonce.as_slice().try_into().unwrap();
let key_arr: [u8; 32] = key.as_slice().try_into().unwrap();
crypto_secretbox::crypto_secretbox_open_easy(
&mut plaintext,
&ciphertext,
&nonce_arr,
&key_arr,
)
.map_err(|_| "secretbox auth failed".to_string())?;
Ok(encode(&plaintext))
}
// ---------------------------------------------------------------------------
// crypto_box — X25519 + XSalsa20-Poly1305
// ---------------------------------------------------------------------------
#[derive(Serialize, Deserialize)]
pub struct KeyPairB64 {
pub public_key: String,
pub private_key: String,
}
#[tauri::command]
pub fn crypto_box_keypair() -> Result<KeyPairB64, String> {
let (pk, sk) = crypto_box::crypto_box_keypair();
Ok(KeyPairB64 {
public_key: encode(&pk),
private_key: encode(&sk),
})
}
#[tauri::command]
pub fn crypto_box_encrypt(
plaintext_b64: String,
nonce_b64: String,
recipient_pk_b64: String,
sender_sk_b64: String,
) -> Result<String, String> {
let plaintext = decode(&plaintext_b64)?;
let nonce = decode(&nonce_b64)?;
let pk = decode(&recipient_pk_b64)?;
let sk = decode(&sender_sk_b64)?;
if pk.len() != CRYPTO_BOX_PUBLICKEYBYTES {
return Err("pk must be 32 bytes".into());
}
if sk.len() != CRYPTO_BOX_SECRETKEYBYTES {
return Err("sk must be 32 bytes".into());
}
let mut ciphertext = vec![0u8; plaintext.len() + 16];
let nonce_arr: [u8; 24] = nonce.as_slice().try_into().map_err(|_| "bad nonce")?;
let pk_arr: [u8; 32] = pk.as_slice().try_into().unwrap();
let sk_arr: [u8; 32] = sk.as_slice().try_into().unwrap();
crypto_box::crypto_box_easy(&mut ciphertext, &plaintext, &nonce_arr, &pk_arr, &sk_arr)
.map_err(|e| format!("box encrypt failed: {}", e))?;
Ok(encode(&ciphertext))
}
#[tauri::command]
pub fn crypto_box_decrypt(
ciphertext_b64: String,
nonce_b64: String,
sender_pk_b64: String,
recipient_sk_b64: String,
) -> Result<String, String> {
let ciphertext = decode(&ciphertext_b64)?;
let nonce = decode(&nonce_b64)?;
let pk = decode(&sender_pk_b64)?;
let sk = decode(&recipient_sk_b64)?;
if pk.len() != CRYPTO_BOX_PUBLICKEYBYTES {
return Err("pk must be 32 bytes".into());
}
if sk.len() != CRYPTO_BOX_SECRETKEYBYTES {
return Err("sk must be 32 bytes".into());
}
if ciphertext.len() < 16 {
return Err("ciphertext too short".into());
}
let mut plaintext = vec![0u8; ciphertext.len() - 16];
let nonce_arr: [u8; 24] = nonce.as_slice().try_into().map_err(|_| "bad nonce")?;
let pk_arr: [u8; 32] = pk.as_slice().try_into().unwrap();
let sk_arr: [u8; 32] = sk.as_slice().try_into().unwrap();
crypto_box::crypto_box_open_easy(
&mut plaintext,
&ciphertext,
&nonce_arr,
&pk_arr,
&sk_arr,
)
.map_err(|_| "box auth failed".to_string())?;
Ok(encode(&plaintext))
}
// Sealed-box (anonymous) variant — sender identity not authenticated but
// recipient still verified. Used by the conv-key wrapping flow.
#[tauri::command]
pub fn crypto_box_seal(
plaintext_b64: String,
recipient_pk_b64: String,
) -> Result<String, String> {
let plaintext = decode(&plaintext_b64)?;
let pk = decode(&recipient_pk_b64)?;
if pk.len() != CRYPTO_BOX_PUBLICKEYBYTES {
return Err("pk must be 32 bytes".into());
}
let pk_arr: [u8; 32] = pk.as_slice().try_into().unwrap();
let mut ciphertext = vec![0u8; plaintext.len() + 48];
crypto_box::crypto_box_seal(&mut ciphertext, &plaintext, &pk_arr)
.map_err(|e| format!("seal failed: {}", e))?;
Ok(encode(&ciphertext))
}
#[tauri::command]
pub fn crypto_box_seal_open(
ciphertext_b64: String,
recipient_pk_b64: String,
recipient_sk_b64: String,
) -> Result<String, String> {
let ciphertext = decode(&ciphertext_b64)?;
let pk = decode(&recipient_pk_b64)?;
let sk = decode(&recipient_sk_b64)?;
if pk.len() != CRYPTO_BOX_PUBLICKEYBYTES {
return Err("pk must be 32 bytes".into());
}
if sk.len() != CRYPTO_BOX_SECRETKEYBYTES {
return Err("sk must be 32 bytes".into());
}
if ciphertext.len() < 48 {
return Err("ciphertext too short".into());
}
let pk_arr: [u8; 32] = pk.as_slice().try_into().unwrap();
let sk_arr: [u8; 32] = sk.as_slice().try_into().unwrap();
let mut plaintext = vec![0u8; ciphertext.len() - 48];
crypto_box::crypto_box_seal_open(&mut plaintext, &ciphertext, &pk_arr, &sk_arr)
.map_err(|_| "seal open failed".to_string())?;
Ok(encode(&plaintext))
}
// ---------------------------------------------------------------------------
// crypto_pwhash — Argon2id
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct PwhashArgs {
pub password: String,
pub salt_b64: String,
pub out_len: usize,
// Opslimit / memlimit presets map to libsodium constants; callers pass
// one of "interactive" | "moderate" | "sensitive". We default to
// moderate which matches every current call-site.
#[serde(default)]
pub preset: Option<String>,
}
fn pwhash_limits(preset: Option<&str>) -> (u64, usize) {
match preset {
Some("interactive") => (2, 64 * 1024 * 1024),
Some("sensitive") => (4, 1024 * 1024 * 1024),
_ => (
CRYPTO_PWHASH_OPSLIMIT_MODERATE as u64,
CRYPTO_PWHASH_MEMLIMIT_MODERATE,
),
}
}
#[tauri::command]
pub fn crypto_pwhash(args: PwhashArgs) -> Result<String, String> {
let salt = decode(&args.salt_b64)?;
if salt.len() != CRYPTO_PWHASH_SALTBYTES {
return Err(format!(
"salt must be {} bytes",
CRYPTO_PWHASH_SALTBYTES
));
}
if args.out_len < 16 || args.out_len > 64 {
return Err("out_len out of range (16..=64)".into());
}
let salt_arr: [u8; CRYPTO_PWHASH_SALTBYTES] =
salt.as_slice().try_into().unwrap();
let (opslimit, memlimit) = pwhash_limits(args.preset.as_deref());
let mut out = vec![0u8; args.out_len];
crypto_pwhash::crypto_pwhash(
&mut out,
args.password.as_bytes(),
&salt_arr,
opslimit,
memlimit,
PasswordHashAlgorithm::Argon2id13,
)
.map_err(|e| format!("pwhash failed: {}", e))?;
Ok(encode(&out))
}
+210 -3
View File
@@ -1,7 +1,121 @@
mod crypto;
#[cfg(feature = "rust-livekit")]
mod livekit_bridge;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use tauri::{
menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Listener, Manager,
};
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use serde::Deserialize;
// Payload for the `tray-unread-update` event the JS layer emits whenever the
// aggregate unread-count changes. 0 hides the badge / resets the tooltip;
// non-zero sets a count indicator.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[derive(Deserialize)]
struct TrayUnreadPayload {
count: u32,
}
// Red-dot overlay icon for the Windows taskbar. Drawn as raw RGBA instead of
// shipping a PNG so we don't add another resource to the bundle. Kept small
// (32x32) since Windows scales the overlay down anyway.
#[cfg(target_os = "windows")]
fn unread_overlay_rgba() -> Vec<u8> {
const SIZE: u32 = 32;
let r = SIZE as f32 / 2.0;
let mut buf = Vec::with_capacity((SIZE * SIZE * 4) as usize);
for y in 0..SIZE {
for x in 0..SIZE {
let dx = x as f32 - r + 0.5;
let dy = y as f32 - r + 0.5;
let d = (dx * dx + dy * dy).sqrt();
let edge = r - 1.0;
if d <= edge {
buf.extend_from_slice(&[0xDC, 0x26, 0x26, 0xFF]);
} else if d <= r {
let alpha = (255.0 * (r - d)).clamp(0.0, 255.0) as u8;
buf.extend_from_slice(&[0xDC, 0x26, 0x26, alpha]);
} else {
buf.extend_from_slice(&[0, 0, 0, 0]);
}
}
}
buf
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn show_main_window(app: &AppHandle) {
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
let _ = win.unminimize();
let _ = win.set_focus();
}
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn hide_main_window(app: &AppHandle) {
if let Some(win) = app.get_webview_window("main") {
let _ = win.hide();
}
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn handle_menu_event(app: &AppHandle, event: MenuEvent) {
match event.id.as_ref() {
"tray-show" => show_main_window(app),
"tray-hide" => hide_main_window(app),
"tray-quit" => {
app.exit(0);
}
_ => {}
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[cfg(not(feature = "rust-livekit"))]
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.invoke_handler(tauri::generate_handler![
crypto::crypto_random_bytes,
crypto::crypto_secretbox_encrypt,
crypto::crypto_secretbox_decrypt,
crypto::crypto_box_keypair,
crypto::crypto_box_encrypt,
crypto::crypto_box_decrypt,
crypto::crypto_box_seal,
crypto::crypto_box_seal_open,
crypto::crypto_pwhash,
])
.plugin(tauri_plugin_notification::init());
#[cfg(feature = "rust-livekit")]
let mut builder = tauri::Builder::default()
.manage(livekit_bridge::LivekitState::new())
.invoke_handler(tauri::generate_handler![
crypto::crypto_random_bytes,
crypto::crypto_secretbox_encrypt,
crypto::crypto_secretbox_decrypt,
crypto::crypto_box_keypair,
crypto::crypto_box_encrypt,
crypto::crypto_box_decrypt,
crypto::crypto_box_seal,
crypto::crypto_box_seal_open,
crypto::crypto_pwhash,
livekit_bridge::livekit_connect,
livekit_bridge::livekit_disconnect,
livekit_bridge::livekit_send_data,
livekit_bridge::livekit_set_mic,
livekit_bridge::livekit_set_camera,
])
.plugin(tauri_plugin_notification::init());
builder = builder
.plugin(tauri_plugin_sql::Builder::default().build())
.plugin(tauri_plugin_fs::init())
.plugin(
@@ -13,12 +127,105 @@ pub fn run() {
.build(),
);
// Global shortcut + updater plugins are desktop-only (no mobile support).
// Global shortcut + updater + window-state plugins are desktop-only
// (no mobile support — mobile windows are OS-managed).
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
builder = builder
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_updater::Builder::new().build());
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_window_state::Builder::new().build());
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
builder = builder.setup(|app| {
// Tray icon with a minimal menu. Left-click toggles window
// visibility; right-click shows the menu. Badge / tooltip updates
// come from the JS side via `tray-unread-update` events.
let show = MenuItem::with_id(app, "tray-show", "Öffnen", true, None::<&str>)?;
let hide = MenuItem::with_id(app, "tray-hide", "Ausblenden", true, None::<&str>)?;
let sep = PredefinedMenuItem::separator(app)?;
let quit = MenuItem::with_id(app, "tray-quit", "Beenden", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &hide, &sep, &quit])?;
let mut tray_builder = TrayIconBuilder::with_id("chatapp-tray")
.menu(&menu)
.show_menu_on_left_click(false)
.tooltip("ChatApp")
.on_menu_event(|app, event| handle_menu_event(app, event))
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
});
// `default_window_icon` returns Option<&Image>; only attach if
// we actually have one bundled (should always be true via the
// tauri.conf.json icon list, but guard to stay typesafe).
if let Some(icon) = app.default_window_icon() {
tray_builder = tray_builder.icon(icon.clone());
}
let tray = tray_builder.build(app)?;
// Listen for JS-side unread updates and mirror them into the tray
// tooltip + macOS dock badge. `tray` is cheap to clone (internal
// Arc) so we can move it into the listener closure directly.
let tray_handle = tray.clone();
let badge_window = app.get_webview_window("main");
app.listen("tray-unread-update", move |event| {
let Ok(payload) = serde_json::from_str::<TrayUnreadPayload>(event.payload())
else {
return;
};
let tooltip = if payload.count == 0 {
"ChatApp".to_string()
} else {
format!("ChatApp · {} neu", payload.count)
};
let _ = tray_handle.set_tooltip(Some(tooltip));
// Dock/taskbar badge. macOS uses a numeric label; Windows uses
// an overlay icon (red dot = unread). Linux has no cross-DE
// badge API — skip.
#[cfg(target_os = "macos")]
if let Some(win) = badge_window.as_ref() {
let badge = if payload.count == 0 {
None
} else {
Some(payload.count.to_string())
};
let _ = win.set_badge_label(badge);
}
#[cfg(target_os = "windows")]
if let Some(win) = badge_window.as_ref() {
if payload.count == 0 {
let _ = win.set_overlay_icon(None);
} else {
let rgba = unread_overlay_rgba();
let img = tauri::image::Image::new_owned(rgba, 32, 32);
let _ = win.set_overlay_icon(Some(img));
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let _ = &badge_window;
});
Ok(())
});
}
builder
@@ -0,0 +1,227 @@
// Rust LiveKit bridge — command/event glue between the JS CallContext and
// the native livekit client. Feature-gated behind `rust-livekit` so the
// baseline build doesn't pay the libwebrtc download / link cost while the
// bridge is still evolving.
//
// Design contract (matches `lib/nativeLiveKit.ts` on the JS side):
// command: livekit_connect { url, token, e2ee_key_b64? }
// command: livekit_disconnect
// command: livekit_set_mic { enabled }
// command: livekit_set_camera { enabled }
// command: livekit_start_share {}
// command: livekit_stop_share {}
// command: livekit_send_data { payload_b64, reliable }
// event: livekit:room_state { state }
// event: livekit:participant_joined { identity, name? }
// event: livekit:participant_left { identity }
// event: livekit:track_published { identity, sid, kind, source }
// event: livekit:track_unpublished { identity, sid }
// event: livekit:audio_level { identity, level }
// event: livekit:data_received { identity, payload_b64 }
// event: livekit:error { message }
//
// Phase B.1 (this file) only wires connect/disconnect + room-state events
// so the JS side can prove round-trip; mic/camera/video come later.
#![cfg(feature = "rust-livekit")]
use std::sync::Arc;
use livekit::{
id::ParticipantIdentity, DataPacketKind, Room, RoomEvent, RoomOptions,
};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use tokio::sync::{mpsc, Mutex};
pub struct LivekitState {
room: Mutex<Option<Arc<Room>>>,
}
impl LivekitState {
pub fn new() -> Self {
Self {
room: Mutex::new(None),
}
}
}
#[derive(Deserialize)]
pub struct ConnectArgs {
pub url: String,
pub token: String,
}
#[derive(Serialize, Clone)]
struct RoomStatePayload {
state: &'static str,
}
#[derive(Serialize, Clone)]
struct ParticipantPayload {
identity: String,
}
#[derive(Serialize, Clone)]
struct DataPayload {
identity: String,
payload_b64: String,
reliable: bool,
}
#[tauri::command]
pub async fn livekit_connect(
app: AppHandle,
state: State<'_, LivekitState>,
args: ConnectArgs,
) -> Result<(), String> {
let mut guard = state.room.lock().await;
if guard.is_some() {
return Err("already connected".into());
}
let options = RoomOptions::default();
let (room, mut events) = Room::connect(&args.url, &args.token, options)
.await
.map_err(|e| format!("livekit connect failed: {}", e))?;
let room = Arc::new(room);
*guard = Some(room.clone());
drop(guard);
let _ = app.emit(
"livekit:room_state",
RoomStatePayload { state: "connected" },
);
// Spawn the event pump. Lives for the duration of the room connection;
// stops naturally when the channel closes (disconnect or crash).
let app_for_events = app.clone();
tokio::spawn(async move {
while let Some(event) = events.recv().await {
pump_event(&app_for_events, event);
}
let _ = app_for_events.emit(
"livekit:room_state",
RoomStatePayload {
state: "disconnected",
},
);
});
Ok(())
}
fn pump_event(app: &AppHandle, event: RoomEvent) {
match event {
RoomEvent::ParticipantConnected(p) => {
let _ = app.emit(
"livekit:participant_joined",
ParticipantPayload {
identity: identity_string(p.identity()),
},
);
}
RoomEvent::ParticipantDisconnected(p) => {
let _ = app.emit(
"livekit:participant_left",
ParticipantPayload {
identity: identity_string(p.identity()),
},
);
}
RoomEvent::DataReceived {
payload,
kind,
participant,
..
} => {
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
let identity = participant
.map(|p| identity_string(p.identity()))
.unwrap_or_default();
let _ = app.emit(
"livekit:data_received",
DataPayload {
identity,
payload_b64: STANDARD.encode(payload.as_ref()),
reliable: matches!(kind, DataPacketKind::Reliable),
},
);
}
RoomEvent::Disconnected { .. } => {
let _ = app.emit(
"livekit:room_state",
RoomStatePayload {
state: "disconnected",
},
);
}
_ => {
// Remaining events (TrackPublished, TrackSubscribed, etc.) land
// in later phases. Ignoring silently keeps the prototype small.
}
}
}
fn identity_string(id: ParticipantIdentity) -> String {
// ParticipantIdentity is a newtype around String in the livekit crate.
id.0
}
#[tauri::command]
pub async fn livekit_disconnect(state: State<'_, LivekitState>) -> Result<(), String> {
let mut guard = state.room.lock().await;
if let Some(room) = guard.take() {
let _ = room.close().await;
}
Ok(())
}
#[tauri::command]
pub async fn livekit_send_data(
state: State<'_, LivekitState>,
payload_b64: String,
reliable: bool,
) -> Result<(), String> {
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
let room = state.room.lock().await;
let room = room.as_ref().ok_or_else(|| "not connected".to_string())?;
let payload = STANDARD
.decode(&payload_b64)
.map_err(|e| format!("bad base64: {}", e))?;
let kind = if reliable {
DataPacketKind::Reliable
} else {
DataPacketKind::Lossy
};
room.local_participant()
.publish_data(livekit::prelude::DataPacket {
payload,
topic: None,
reliable: matches!(kind, DataPacketKind::Reliable),
destination_identities: Vec::new(),
})
.await
.map_err(|e| format!("publish_data failed: {}", e))?;
Ok(())
}
// Placeholder — Phase B.2 will fill these in.
#[tauri::command]
pub async fn livekit_set_mic(_enabled: bool) -> Result<(), String> {
Err("livekit_set_mic not implemented — Phase B.2".into())
}
#[tauri::command]
pub async fn livekit_set_camera(_enabled: bool) -> Result<(), String> {
Err("livekit_set_camera not implemented — Phase B.2".into())
}
// Unused-send bridge so `mpsc` doesn't get unused-import-warned when the
// feature gate is off.
#[allow(dead_code)]
fn _mpsc_anchor() -> mpsc::Sender<()> {
let (tx, _rx) = mpsc::channel::<()>(1);
tx
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ChatApp",
"version": "0.4.1",
"version": "0.10.1",
"identifier": "com.meinname.chatapp",
"build": {
"beforeDevCommand": "pnpm vite:dev",
+148 -37
View File
@@ -1,55 +1,166 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { lazy, Suspense } from 'react';
import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
import { AppShell } from './components/AppShell';
import { CrashToast } from './components/CrashToast';
import { ErrorBoundary } from './components/ErrorBoundary';
import { SpinnerIcon } from './components/icons';
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 { AdminPage } from './pages/AdminPage';
import { AuthCallbackPage } from './pages/AuthCallbackPage';
import { ThemeProvider } from './context/ThemeContext';
import { AuthPage } from './pages/AuthPage';
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
import { ConversationPage } from './pages/ConversationPage';
import { DevicePage } from './pages/DevicePage';
import { FriendsPage } from './pages/FriendsPage';
import { SettingsPage } from './pages/SettingsPage';
// Routes rarely visited on first render are pulled out of the initial bundle.
// AuthPage stays eager because it's the first screen unauthenticated users
// see; ChatsPage + ConversationPage stay eager because every authenticated
// session renders them immediately.
const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })));
const AuthCallbackPage = lazy(() =>
import('./pages/AuthCallbackPage').then((m) => ({ default: m.AuthCallbackPage })),
);
const DevicePage = lazy(() => import('./pages/DevicePage').then((m) => ({ default: m.DevicePage })));
const FriendsPage = lazy(() =>
import('./pages/FriendsPage').then((m) => ({ default: m.FriendsPage })),
);
const SettingsPage = lazy(() =>
import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })),
);
function RouteSuspense({ children }: { children: React.ReactNode }) {
return (
<Suspense
fallback={
<div className="flex min-h-full w-full items-center justify-center bg-surface-3">
<SpinnerIcon className="h-5 w-5 text-accent" />
</div>
}
>
{children}
</Suspense>
);
}
// 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 (
<ErrorBoundary scope={scope}>
<Outlet />
</ErrorBoundary>
);
}
export function App() {
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 />} />
<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={
<RouteSuspense>
<AuthCallbackPage />
</RouteSuspense>
}
/>
</Route>
<Route path="/friends" element={<FriendsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route element={<RequireAdmin />}>
<Route path="/admin" element={<AdminPage />} />
<Route element={<RequireAuth />}>
<Route element={<RouteBoundary scope="device" />}>
<Route
path="/device"
element={
<RouteSuspense>
<DevicePage />
</RouteSuspense>
}
/>
</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={
<RouteSuspense>
<FriendsPage />
</RouteSuspense>
}
/>
</Route>
<Route element={<RouteBoundary scope="settings" />}>
<Route
path="/settings"
element={
<RouteSuspense>
<SettingsPage />
</RouteSuspense>
}
/>
</Route>
<Route element={<RequireAdmin />}>
<Route element={<RouteBoundary scope="admin" />}>
<Route
path="/admin"
element={
<RouteSuspense>
<AdminPage />
</RouteSuspense>
}
/>
</Route>
</Route>
</Route>
</Route>
</Route>
</Route>
</Route>
</Route>
<Route path="*" element={<Navigate to="/chats" replace />} />
</Routes>
<UpdateToast />
</BrowserRouter>
</CallProvider>
</ConversationsProvider>
</FriendshipsProvider>
</AuthProvider>
<Route path="*" element={<Navigate to="/chats" replace />} />
</Routes>
<UpdateToast />
<CrashToast />
</BrowserRouter>
</CallProvider>
</ConversationsProvider>
</FriendshipsProvider>
</AuthProvider>
</ThemeProvider>
</ErrorBoundary>
);
}
+6 -4
View File
@@ -4,6 +4,7 @@ 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';
@@ -21,7 +22,7 @@ export function AppShell() {
}, [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 />
@@ -32,15 +33,16 @@ export function AppShell() {
</main>
</div>
<CallUI />
<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,251 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useMemo, useRef, useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
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);
void (async () => {
const cached = await getCachedAttachment(handle.id);
if (cached) {
if (cancelled) return;
url = URL.createObjectURL(cached);
setBlobUrl(url);
const buf = await cached.arrayBuffer();
if (!cancelled) setArrayBuf(buf);
return;
}
try {
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
if (cancelled) return;
url = URL.createObjectURL(blob);
setBlobUrl(url);
const buf = await blob.arrayBuffer();
if (!cancelled) setArrayBuf(buf);
void putCachedAttachment(handle.id, blob);
} 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');
}
@@ -0,0 +1,132 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
// Catch-all card for attachments without a richer renderer (zip, docx,
// txt, etc). Decrypt is deferred to first download click — these can be
// large and there's no inline preview to justify auto-fetching them.
export function AttachmentGeneric({ handle }: Props) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const download = async () => {
if (busy) return;
setBusy(true);
setError(null);
try {
let blob = await getCachedAttachment(handle.id);
if (!blob) {
blob = await downloadAndDecryptAttachment({ client: supabase, handle });
void putCachedAttachment(handle.id, blob);
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filenameFor(handle);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Defer revoke so Safari has a chance to start the download.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'download failed');
} finally {
setBusy(false);
}
};
return (
<div className="mt-2 inline-flex w-[280px] items-center gap-2.5 rounded-lg border border-line bg-surface-2 p-2.5">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-accent/10 text-accent">
<FileGlyph />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-fg">
{prettyMime(handle.mimeType)}
</p>
<p className="truncate text-[10px] text-fg-muted">
{formatSize(handle.sizeBytes)}
</p>
{error && (
<p className="mt-0.5 inline-flex items-center gap-1 text-[10px] text-rose-500">
<AlertIcon className="h-3 w-3" />
<span>{error}</span>
</p>
)}
</div>
<button
type="button"
onClick={() => void download()}
disabled={busy}
aria-label="Download"
title="Download"
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-line bg-surface-3 text-fg transition hover:brightness-95 disabled:opacity-60"
>
{busy ? <SpinnerIcon className="h-4 w-4" /> : <DownloadGlyph />}
</button>
</div>
);
}
function FileGlyph() {
return (
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
<path d="M3 2a1 1 0 011-1h6l3 3v10a1 1 0 01-1 1H4a1 1 0 01-1-1V2zm7 0v3h3l-3-3z" />
</svg>
);
}
function DownloadGlyph() {
return (
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M8 2v8M4 7l4 4 4-4M3 13h10" />
</svg>
);
}
function filenameFor(handle: AttachmentHandle): string {
const ext = extFor(handle.mimeType);
return 'attachment-' + handle.id.slice(0, 8) + (ext ? '.' + ext : '');
}
function extFor(mime: string): string | null {
const map: Record<string, string> = {
'application/zip': 'zip',
'application/x-zip-compressed': 'zip',
'application/x-7z-compressed': '7z',
'application/x-tar': 'tar',
'application/gzip': 'gz',
'application/json': 'json',
'application/xml': 'xml',
'text/plain': 'txt',
'text/markdown': 'md',
'text/csv': 'csv',
'application/msword': 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
'application/vnd.ms-excel': 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
'application/vnd.ms-powerpoint': 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
};
return map[mime] ?? null;
}
function prettyMime(mime: string): string {
const ext = extFor(mime);
if (ext) return ext.toUpperCase() + '-Datei';
if (mime.startsWith('text/')) return 'Textdatei';
return mime || 'Datei';
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
+77 -15
View File
@@ -1,6 +1,7 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon, XIcon } from './icons';
@@ -8,35 +9,95 @@ interface Props {
handle: AttachmentHandle;
}
// Max inline-preview dimension. Full-resolution stays available for the
// lightbox. Animated formats (gif/webp/apng) are passed through untouched
// so animation isn't lost; everything else is downscaled to this box.
const THUMB_MAX_DIM = 640;
const ANIMATED_MIME = /^image\/(gif|apng|webp)/;
async function makeThumbnail(blob: Blob): Promise<Blob | null> {
if (ANIMATED_MIME.test(blob.type)) return null;
if (typeof createImageBitmap !== 'function') return null;
if (typeof OffscreenCanvas !== 'function') return null;
try {
const bitmap = await createImageBitmap(blob);
const largest = Math.max(bitmap.width, bitmap.height);
if (largest <= THUMB_MAX_DIM) {
bitmap.close();
return null;
}
const scale = THUMB_MAX_DIM / largest;
const w = Math.max(1, Math.round(bitmap.width * scale));
const h = Math.max(1, Math.round(bitmap.height * scale));
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext('2d');
if (!ctx) {
bitmap.close();
return null;
}
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
return await canvas.convertToBlob({ type: 'image/webp', quality: 0.8 });
} catch {
return null;
}
}
export function AttachmentImage({ handle }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [fullUrl, setFullUrl] = useState<string | null>(null);
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
useEffect(() => {
let cancelled = false;
let url: string | null = null;
const created: string[] = [];
setError(null);
setBlobUrl(null);
setFullUrl(null);
setThumbUrl(null);
downloadAndDecryptAttachment({ client: supabase, handle })
.then((blob) => {
if (cancelled) return;
url = URL.createObjectURL(blob);
setBlobUrl(url);
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
const take = (blob: Blob): string => {
const u = URL.createObjectURL(blob);
created.push(u);
return u;
};
// OPFS cache → decrypt → generate thumbnail for inline display.
// Lightbox swaps to the full blob when opened.
void (async () => {
const cached = await getCachedAttachment(handle.id);
let blob: Blob;
if (cached) {
blob = cached;
} else {
try {
blob = await downloadAndDecryptAttachment({ client: supabase, handle });
void putCachedAttachment(handle.id, blob);
} catch (err: unknown) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
}
return;
}
});
}
if (cancelled) return;
const full = take(blob);
setFullUrl(full);
const thumb = await makeThumbnail(blob);
if (cancelled) return;
if (thumb) {
setThumbUrl(take(thumb));
}
})();
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
for (const u of created) URL.revokeObjectURL(u);
};
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
const blobUrl = thumbUrl ?? fullUrl;
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">
@@ -66,10 +127,11 @@ export function AttachmentImage({ handle }: Props) {
src={blobUrl}
alt="attachment"
loading="lazy"
decoding="async"
className="block h-auto max-h-80 w-auto max-w-full object-contain"
/>
</button>
{lightboxOpen && <Lightbox url={blobUrl} onClose={() => setLightboxOpen(false)} />}
{lightboxOpen && fullUrl && <Lightbox url={fullUrl} onClose={() => setLightboxOpen(false)} />}
</>
);
}
@@ -0,0 +1,120 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
// PDF preview rendered via the browser's built-in PDF viewer (Chromium /
// Safari both ship one). Embedding via <object> with a fallback link keeps
// the implementation tiny — no pdf.js dependency.
export function AttachmentPdf({ handle }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
let cancelled = false;
let url: string | null = null;
setError(null);
setBlobUrl(null);
void (async () => {
const cached = await getCachedAttachment(handle.id);
if (cached) {
if (cancelled) return;
const typed = new Blob([cached], { type: 'application/pdf' });
url = URL.createObjectURL(typed);
setBlobUrl(url);
return;
}
try {
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
if (cancelled) return;
// Force the application/pdf type so the browser plugin engages.
const typed = new Blob([blob], { type: 'application/pdf' });
url = URL.createObjectURL(typed);
setBlobUrl(url);
void putCachedAttachment(handle.id, blob);
} 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]);
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>
);
}
if (!blobUrl) {
return (
<div className="mt-2 flex h-24 w-[320px] items-center justify-center rounded-lg border border-line bg-surface-2 text-fg-muted">
<SpinnerIcon className="h-5 w-5" />
</div>
);
}
return (
<div className="mt-2 w-full max-w-[420px] overflow-hidden rounded-lg border border-line bg-surface-2">
<div className="flex items-center justify-between gap-2 border-b border-line bg-surface-3 px-3 py-2 text-xs">
<span className="flex items-center gap-2 truncate text-fg">
<PdfGlyph />
<span className="truncate">PDF · {formatSize(handle.sizeBytes)}</span>
</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-0.5 text-[11px] text-fg hover:bg-surface-3"
>
{expanded ? 'Einklappen' : 'Vorschau'}
</button>
<a
href={blobUrl}
download={'attachment-' + handle.id.slice(0, 8) + '.pdf'}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-0.5 text-[11px] text-fg no-underline hover:bg-surface-3"
>
Download
</a>
</div>
</div>
{expanded && (
<object data={blobUrl} type="application/pdf" className="block h-[420px] w-full">
<p className="p-4 text-xs text-fg-muted">
Vorschau nicht verfügbar bitte herunterladen.
</p>
</object>
)}
</div>
);
}
function PdfGlyph() {
return (
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
<path d="M3 2a1 1 0 011-1h6l3 3v10a1 1 0 01-1 1H4a1 1 0 01-1-1V2zm7 0v3h3l-3-3zM5 9h6v1H5V9zm0 2h6v1H5v-1zm0-4h2v1H5V7z" />
</svg>
);
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
@@ -0,0 +1,78 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
// Decrypt the blob, render a native <video controls>. Loads on demand —
// metadata-only preload so we don't burn bandwidth until the user hits play.
export function AttachmentVideo({ handle }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let url: string | null = null;
setError(null);
setBlobUrl(null);
void (async () => {
const cached = await getCachedAttachment(handle.id);
if (cached) {
if (cancelled) return;
url = URL.createObjectURL(cached);
setBlobUrl(url);
return;
}
try {
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
if (cancelled) return;
url = URL.createObjectURL(blob);
setBlobUrl(url);
void putCachedAttachment(handle.id, blob);
} 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]);
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>
);
}
if (!blobUrl) {
return (
<div className="mt-2 flex h-32 w-[320px] items-center justify-center rounded-lg border border-line bg-surface-2 text-fg-muted">
<SpinnerIcon className="h-5 w-5" />
</div>
);
}
return (
<video
controls
preload="metadata"
src={blobUrl}
className="mt-2 block max-h-80 w-full max-w-[420px] rounded-lg border border-line bg-black"
>
<track kind="captions" />
</video>
);
}
+48
View File
@@ -0,0 +1,48 @@
// 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.
import { useCachedAvatarUrl } from '../lib/avatarCache';
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) {
const effectiveUrl = useCachedAvatarUrl(url);
if (effectiveUrl) {
return (
<img
src={effectiveUrl}
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,86 @@
import { useEffect } from 'react';
import { DeviceRestore } from './DeviceRestore';
import { AlertIcon, ShieldIcon, XIcon } from './icons';
interface Props {
open: boolean;
userId: string;
onClose: () => void;
}
// Modal wrapper around DeviceRestore for the already-signed-in case. A
// successful restore swaps the local device-identity for the one embedded
// in the backup string — the app then hard-reloads so every hook
// re-initialises against the restored keys (simpler than invalidating
// supabase-realtime subscriptions, stronghold caches, livekit rooms, etc.
// individually).
export function BackupRestoreDialog({ open, userId, onClose }: Props) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', onKey);
document.body.style.overflow = prev;
};
}, [open, onClose]);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="Backup wiederherstellen"
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-6 backdrop-blur-sm"
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-md flex-col gap-4"
>
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-ink-900/70 p-3 backdrop-blur-xl">
<div className="flex items-center gap-2">
<ShieldIcon className="h-4 w-4 text-brand-300" />
<h3 className="text-sm font-semibold text-white">
Gerät aus Backup wiederherstellen
</h3>
</div>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-white"
>
<XIcon className="h-4 w-4" />
</button>
</div>
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-100">
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
<p className="min-w-0 flex-1">
Restore ersetzt das aktuelle Gerät durch das aus dem Backup.
Die App lädt danach neu. Nachrichten, die auf diesem Gerät seit
dem Backup eingegangen sind, sind erst wieder lesbar, nachdem
Peer-Geräte den Conversation-Key erneut für die wiederhergestellte
Device-ID wrappen.
</p>
</div>
<DeviceRestore
userId={userId}
onRestored={() => {
// Hard reload — cleanest way to reset every hook, supabase
// realtime channel, stronghold handle, and cached state.
window.location.reload();
}}
/>
</div>
</div>
);
}
@@ -0,0 +1,212 @@
import { useTranslation } from 'react-i18next';
import {
HeadphonesIcon,
HeadphonesOffIcon,
MicIcon,
MicOffIcon,
MonitorShareIcon,
MonitorStopIcon,
MusicIcon,
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;
/** Toggle the in-call soundboard popover. Active = panel currently open. */
onToggleSoundboard?: () => void;
soundboardOpen?: boolean;
/** 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,
onToggleSoundboard,
soundboardOpen = false,
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>
{onToggleSoundboard && (
<CallButton
label={t('app:soundboard.toggle', { defaultValue: 'Soundboard' })}
active={soundboardOpen}
activeTone="accent"
onClick={onToggleSoundboard}
glass={glass}
className={btnSize}
>
<MusicIcon 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>
);
}
+133 -253
View File
@@ -1,52 +1,70 @@
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();
const { profile } = useAuth();
const dnd = profile?.presenceState === 'dnd';
useEffect(() => {
// DND silences only the *incoming* ring — outgoing stays audible because
// the user initiated that call themselves. The incoming-call panel still
// appears visually; only the audible ring is suppressed.
if (state.kind === 'outgoing') ringtone.start('outgoing');
else if (state.kind === 'incoming') ringtone.start('incoming');
else if (state.kind === 'incoming' && !dnd) ringtone.start('incoming');
else ringtone.stop();
}, [state.kind]);
}, [state.kind, dnd]);
useEffect(() => {
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 +72,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 +128,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 +139,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,40 @@
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 type { PeerPresence } from '../lib/usePeerPresence';
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;
peerPresence: PeerPresence | 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 +43,7 @@ export function ConversationHeader({ conversation, peerPresence, onInfoClick }:
conversation={conversation}
peerPresence={peerPresence}
{...(onInfoClick ? { onInfoClick } : {})}
{...(onSearchClick ? { onSearchClick } : {})}
/>
<ActiveCallBanner conversationId={conversation.id} />
</>
@@ -42,45 +52,64 @@ export function ConversationHeader({ conversation, peerPresence, onInfoClick }:
interface HeaderBarProps {
conversation: ConversationSummary;
peerPresence: PresenceState | null;
peerPresence: PeerPresence | 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) : '';
const peerState = peerPresence?.state ?? null;
const showPresence = isDm && peerState && peerState !== 'invisible';
// Subtitle priority: custom status_message when online (or idle/dnd), else
// the localized presence label. Offline always wins → just "Offline".
const presenceLabel = (() => {
if (!peerState) return '';
if (peerState === 'offline') return t('app:presence.offline');
if (peerPresence?.statusMessage && peerPresence.statusMessage.trim().length > 0) {
return peerPresence.statusMessage.trim();
}
return t('app:presence.' + peerState);
})();
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" />}
{showPresence && peerPresence && (
<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 && peerState && (
<span
aria-hidden="true"
className={
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-ink-950 ' +
PRESENCE_DOT[peerPresence]
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-3 ' +
PRESENCE_DOT[peerState]
}
/>
)}
</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 +122,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,68 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { type CrashEntry, subscribeCrashes } from '../lib/crashRecovery';
import { AlertIcon, XIcon } from './icons';
const VISIBLE_MS = 7_000;
const MAX_STACK = 3;
// Bottom-right stack of toasts for uncaught errors. Auto-dismisses each
// entry after VISIBLE_MS. The user can X-out earlier.
export function CrashToast() {
const [entries, setEntries] = useState<CrashEntry[]>([]);
useEffect(
() =>
subscribeCrashes((entry) => {
setEntries((prev) => [...prev.slice(-(MAX_STACK - 1)), entry]);
}),
[],
);
useEffect(() => {
if (entries.length === 0) return;
const latest = entries[entries.length - 1]!;
const id = window.setTimeout(() => {
setEntries((prev) => prev.filter((e) => e.id !== latest.id));
}, VISIBLE_MS);
return () => window.clearTimeout(id);
}, [entries]);
if (entries.length === 0) return null;
return createPortal(
<div
role="region"
aria-label="Fehler"
className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2"
>
{entries.map((entry) => (
<div
key={entry.id}
role="alert"
className="pointer-events-auto flex max-w-sm items-start gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-700 shadow-xl backdrop-blur-sm dark:text-rose-100"
>
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold uppercase tracking-wider">
{entry.source === 'promise' ? 'Promise-Fehler' : 'Unerwarteter Fehler'}
</p>
<p className="mt-0.5 break-words text-xs">{entry.message}</p>
</div>
<button
type="button"
aria-label="Schließen"
onClick={() =>
setEntries((prev) => prev.filter((e) => e.id !== entry.id))
}
className="shrink-0 cursor-pointer rounded-md p-1 text-rose-700 transition hover:bg-rose-500/20 dark:text-rose-100"
>
<XIcon className="h-3 w-3" />
</button>
</div>
))}
</div>,
document.body,
);
}
@@ -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>
);
}
+408
View File
@@ -0,0 +1,408 @@
import { useEffect, useMemo, useRef, useState } from 'react';
interface EmojiEntry {
e: string;
k: string[]; // search keywords (incl. name)
}
interface Category {
label: string;
entries: EmojiEntry[];
}
// Curated emoji set — small enough to stay fast without a dependency, wide
// enough to cover everyday messaging. Keywords are the primary search
// surface; the emoji character itself is also matched so a user typing ❤️
// literally finds it.
const CATEGORIES: Category[] = [
{
label: 'Smileys',
entries: [
{ e: '😀', k: ['grin', 'smile', 'happy'] },
{ e: '😃', k: ['smile', 'happy'] },
{ e: '😄', k: ['smile', 'laugh'] },
{ e: '😁', k: ['grin', 'smile'] },
{ e: '😆', k: ['laugh', 'lol'] },
{ e: '😅', k: ['sweat', 'nervous', 'laugh'] },
{ e: '🤣', k: ['lol', 'rofl', 'laugh'] },
{ e: '😂', k: ['joy', 'laugh', 'tears'] },
{ e: '🙂', k: ['smile', 'slight'] },
{ e: '🙃', k: ['upside', 'irony'] },
{ e: '😉', k: ['wink'] },
{ e: '😊', k: ['blush', 'smile'] },
{ e: '😇', k: ['angel', 'innocent'] },
{ e: '🥰', k: ['love', 'hearts'] },
{ e: '😍', k: ['love', 'heart eyes'] },
{ e: '🤩', k: ['star', 'excited'] },
{ e: '😘', k: ['kiss'] },
{ e: '😗', k: ['kiss'] },
{ e: '😚', k: ['kiss'] },
{ e: '😙', k: ['kiss'] },
{ e: '🥲', k: ['tear', 'smile'] },
{ e: '😋', k: ['yum', 'tasty'] },
{ e: '😛', k: ['tongue'] },
{ e: '😜', k: ['tongue', 'wink'] },
{ e: '🤪', k: ['zany', 'silly'] },
{ e: '😝', k: ['tongue'] },
{ e: '🤑', k: ['money'] },
{ e: '🤗', k: ['hug'] },
{ e: '🤭', k: ['giggle', 'shy'] },
{ e: '🤫', k: ['shush', 'quiet'] },
{ e: '🤔', k: ['think'] },
{ e: '🤐', k: ['zip', 'quiet'] },
{ e: '🤨', k: ['raise brow'] },
{ e: '😐', k: ['neutral'] },
{ e: '😑', k: ['expressionless'] },
{ e: '😶', k: ['speechless'] },
{ e: '😏', k: ['smirk'] },
{ e: '😒', k: ['unamused'] },
{ e: '🙄', k: ['eye roll'] },
{ e: '😬', k: ['grimace', 'awkward'] },
{ e: '🤥', k: ['lying'] },
{ e: '😔', k: ['sad', 'pensive'] },
{ e: '😪', k: ['sleepy'] },
{ e: '😴', k: ['sleep'] },
{ e: '😷', k: ['mask', 'sick'] },
{ e: '🤒', k: ['sick', 'fever'] },
{ e: '🤕', k: ['injured'] },
{ e: '🤢', k: ['nauseated'] },
{ e: '🤮', k: ['vomit'] },
{ e: '🤧', k: ['sneeze'] },
{ e: '🥵', k: ['hot'] },
{ e: '🥶', k: ['cold'] },
{ e: '🥴', k: ['dizzy', 'woozy'] },
{ e: '😵', k: ['dizzy'] },
{ e: '🤯', k: ['mind blown'] },
{ e: '🤠', k: ['cowboy'] },
{ e: '🥳', k: ['party'] },
{ e: '😎', k: ['cool', 'sunglasses'] },
{ e: '🤓', k: ['nerd'] },
{ e: '🧐', k: ['monocle'] },
{ e: '😕', k: ['confused'] },
{ e: '😟', k: ['worried'] },
{ e: '🙁', k: ['frown'] },
{ e: '☹️', k: ['frown'] },
{ e: '😮', k: ['open mouth'] },
{ e: '😯', k: ['hushed'] },
{ e: '😲', k: ['astonished'] },
{ e: '😳', k: ['flushed'] },
{ e: '🥺', k: ['pleading'] },
{ e: '😦', k: ['frowning'] },
{ e: '😧', k: ['anguished'] },
{ e: '😨', k: ['fear'] },
{ e: '😰', k: ['anxious', 'sweat'] },
{ e: '😥', k: ['sad', 'relieved'] },
{ e: '😢', k: ['cry'] },
{ e: '😭', k: ['cry', 'loud'] },
{ e: '😱', k: ['scream', 'scared'] },
{ e: '😖', k: ['confounded'] },
{ e: '😣', k: ['persevere'] },
{ e: '😞', k: ['disappointed'] },
{ e: '😓', k: ['sweat'] },
{ e: '😩', k: ['weary'] },
{ e: '😫', k: ['tired'] },
{ e: '🥱', k: ['yawn'] },
{ e: '😤', k: ['triumph'] },
{ e: '😡', k: ['angry', 'rage'] },
{ e: '😠', k: ['angry'] },
{ e: '🤬', k: ['swear', 'curse'] },
{ e: '😈', k: ['devil'] },
{ e: '👿', k: ['imp'] },
{ e: '💀', k: ['skull', 'dead'] },
{ e: '🤡', k: ['clown'] },
{ e: '👻', k: ['ghost'] },
{ e: '👽', k: ['alien'] },
{ e: '🤖', k: ['robot'] },
{ e: '💩', k: ['poop', 'shit'] },
],
},
{
label: 'Gestures',
entries: [
{ e: '👋', k: ['wave', 'hi'] },
{ e: '🤚', k: ['hand'] },
{ e: '🖐️', k: ['hand'] },
{ e: '✋', k: ['stop', 'high five'] },
{ e: '🖖', k: ['spock'] },
{ e: '👌', k: ['ok'] },
{ e: '🤌', k: ['pinch'] },
{ e: '🤏', k: ['small'] },
{ e: '✌️', k: ['peace', 'victory'] },
{ e: '🤞', k: ['crossed fingers'] },
{ e: '🤟', k: ['love you'] },
{ e: '🤘', k: ['rock'] },
{ e: '🤙', k: ['call me'] },
{ e: '👈', k: ['point left'] },
{ e: '👉', k: ['point right'] },
{ e: '👆', k: ['point up'] },
{ e: '🖕', k: ['middle finger', 'fuck'] },
{ e: '👇', k: ['point down'] },
{ e: '☝️', k: ['point up'] },
{ e: '👍', k: ['thumbs up', 'like'] },
{ e: '👎', k: ['thumbs down', 'dislike'] },
{ e: '✊', k: ['fist'] },
{ e: '👊', k: ['punch'] },
{ e: '🤛', k: ['fist left'] },
{ e: '🤜', k: ['fist right'] },
{ e: '👏', k: ['clap'] },
{ e: '🙌', k: ['raised hands'] },
{ e: '👐', k: ['open hands'] },
{ e: '🤲', k: ['palms'] },
{ e: '🙏', k: ['pray', 'thanks'] },
{ e: '✍️', k: ['write'] },
{ e: '💪', k: ['flex', 'strong'] },
],
},
{
label: 'Hearts',
entries: [
{ e: '❤️', k: ['heart', 'love'] },
{ e: '🧡', k: ['orange heart'] },
{ e: '💛', k: ['yellow heart'] },
{ e: '💚', k: ['green heart'] },
{ e: '💙', k: ['blue heart'] },
{ e: '💜', k: ['purple heart'] },
{ e: '🖤', k: ['black heart'] },
{ e: '🤍', k: ['white heart'] },
{ e: '🤎', k: ['brown heart'] },
{ e: '💔', k: ['broken heart'] },
{ e: '❣️', k: ['heart exclamation'] },
{ e: '💕', k: ['hearts'] },
{ e: '💞', k: ['revolving hearts'] },
{ e: '💓', k: ['beating heart'] },
{ e: '💗', k: ['growing heart'] },
{ e: '💖', k: ['sparkle heart'] },
{ e: '💘', k: ['cupid'] },
{ e: '💝', k: ['heart gift'] },
],
},
{
label: 'Animals & Food',
entries: [
{ e: '🐶', k: ['dog'] },
{ e: '🐱', k: ['cat'] },
{ e: '🐭', k: ['mouse'] },
{ e: '🐹', k: ['hamster'] },
{ e: '🐰', k: ['rabbit'] },
{ e: '🦊', k: ['fox'] },
{ e: '🐻', k: ['bear'] },
{ e: '🐼', k: ['panda'] },
{ e: '🐨', k: ['koala'] },
{ e: '🐯', k: ['tiger'] },
{ e: '🦁', k: ['lion'] },
{ e: '🐸', k: ['frog'] },
{ e: '🐵', k: ['monkey'] },
{ e: '🐔', k: ['chicken'] },
{ e: '🐧', k: ['penguin'] },
{ e: '🐦', k: ['bird'] },
{ e: '🦆', k: ['duck'] },
{ e: '🍎', k: ['apple'] },
{ e: '🍌', k: ['banana'] },
{ e: '🍕', k: ['pizza'] },
{ e: '🍔', k: ['burger'] },
{ e: '🍟', k: ['fries'] },
{ e: '🌭', k: ['hotdog'] },
{ e: '🍿', k: ['popcorn'] },
{ e: '🍣', k: ['sushi'] },
{ e: '🍩', k: ['donut'] },
{ e: '🍪', k: ['cookie'] },
{ e: '🎂', k: ['cake', 'birthday'] },
{ e: '🍰', k: ['cake'] },
{ e: '🍫', k: ['chocolate'] },
{ e: '🍺', k: ['beer'] },
{ e: '🍷', k: ['wine'] },
{ e: '🥂', k: ['cheers'] },
{ e: '☕', k: ['coffee'] },
],
},
{
label: 'Objects & Symbols',
entries: [
{ e: '🔥', k: ['fire', 'lit'] },
{ e: '✨', k: ['sparkle'] },
{ e: '⭐', k: ['star'] },
{ e: '🌟', k: ['star glowing'] },
{ e: '💫', k: ['dizzy'] },
{ e: '💥', k: ['boom', 'explosion'] },
{ e: '⚡', k: ['lightning'] },
{ e: '☀️', k: ['sun'] },
{ e: '🌈', k: ['rainbow'] },
{ e: '☁️', k: ['cloud'] },
{ e: '🌧️', k: ['rain'] },
{ e: '❄️', k: ['snow'] },
{ e: '🎉', k: ['party', 'tada'] },
{ e: '🎊', k: ['confetti'] },
{ e: '🎁', k: ['gift'] },
{ e: '🎈', k: ['balloon'] },
{ e: '💯', k: ['100', 'perfect'] },
{ e: '✅', k: ['check'] },
{ e: '❌', k: ['x', 'no'] },
{ e: '⚠️', k: ['warning'] },
{ e: '❓', k: ['question'] },
{ e: '❗', k: ['exclamation'] },
{ e: '💬', k: ['speech'] },
{ e: '💭', k: ['thought'] },
{ e: '👀', k: ['eyes'] },
{ e: '🚀', k: ['rocket'] },
{ e: '🎵', k: ['music'] },
{ e: '🎶', k: ['music'] },
{ e: '🔔', k: ['bell'] },
{ e: '💡', k: ['idea', 'bulb'] },
],
},
];
const RECENT_KEY = 'chat.emoji.recents.v1';
const RECENT_MAX = 24;
function loadRecents(): string[] {
try {
const raw = localStorage.getItem(RECENT_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((x): x is string => typeof x === 'string').slice(0, RECENT_MAX);
} catch {
return [];
}
}
function saveRecents(list: string[]): void {
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX)));
} catch {
/* ignore quota */
}
}
interface Props {
open: boolean;
onPick: (emoji: string) => void;
onClose: () => void;
}
export function EmojiPicker({ open, onPick, onClose }: Props) {
const [query, setQuery] = useState('');
const [recents, setRecents] = useState<string[]>(() => loadRecents());
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (rootRef.current && t && !rootRef.current.contains(t) && !t.closest('[data-emoji-trigger]')) {
onClose();
}
};
window.addEventListener('keydown', onKey);
window.addEventListener('mousedown', onDown);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onDown);
};
}, [open, onClose]);
useEffect(() => {
if (open) setQuery('');
}, [open]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return CATEGORIES;
return CATEGORIES.map((cat) => ({
label: cat.label,
entries: cat.entries.filter(
(entry) =>
entry.e.includes(q) ||
entry.k.some((k) => k.includes(q)) ||
cat.label.toLowerCase().includes(q),
),
})).filter((cat) => cat.entries.length > 0);
}, [query]);
if (!open) return null;
const handlePick = (emoji: string) => {
onPick(emoji);
const next = [emoji, ...recents.filter((e) => e !== emoji)].slice(0, RECENT_MAX);
setRecents(next);
saveRecents(next);
};
return (
<div
ref={rootRef}
role="dialog"
aria-label="Emoji auswählen"
className="absolute bottom-full right-0 z-30 mb-2 flex w-[320px] flex-col rounded-xl border border-line bg-surface-2 shadow-xl"
>
<div className="border-b border-line p-2">
<input
type="search"
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Suchen…"
className="w-full rounded-md border border-line bg-surface-3 px-2.5 py-1.5 text-sm text-fg placeholder-fg-muted outline-none focus:border-accent"
/>
</div>
<div className="max-h-[320px] overflow-y-auto p-2">
{recents.length > 0 && !query && (
<CategoryBlock
label="Zuletzt"
entries={recents.map((e) => ({ e, k: [] }))}
onPick={handlePick}
/>
)}
{filtered.map((cat) => (
<CategoryBlock
key={cat.label}
label={cat.label}
entries={cat.entries}
onPick={handlePick}
/>
))}
{filtered.length === 0 && (
<p className="py-4 text-center text-xs text-fg-muted">Keine Treffer</p>
)}
</div>
</div>
);
}
function CategoryBlock({
label,
entries,
onPick,
}: {
label: string;
entries: EmojiEntry[];
onPick: (emoji: string) => void;
}) {
return (
<div className="mb-2">
<p className="mb-1 px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{label}
</p>
<div className="grid grid-cols-8 gap-0.5">
{entries.map((entry, idx) => (
<button
key={entry.e + ':' + idx}
type="button"
onClick={() => onPick(entry.e)}
aria-label={entry.k[0] ?? entry.e}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-lg transition hover:bg-surface-3"
>
{entry.e}
</button>
))}
</div>
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,49 @@
import { useLinkPreview } from '../lib/useLinkPreview';
interface Props {
url: string;
}
// Renders a compact OpenGraph preview card under a message bubble. Fetches
// lazily through the edge function; silent when the URL returned no meta.
export function LinkPreviewCard({ url }: Props) {
const preview = useLinkPreview(url);
if (!preview || !preview.ok) return null;
if (!preview.title && !preview.description && !preview.imageUrl) return null;
return (
<a
href={url}
target="_blank"
rel="noreferrer noopener"
className="mt-2 flex max-w-[320px] overflow-hidden rounded-lg border border-line bg-surface-2 text-sm no-underline transition hover:bg-surface-3"
>
{preview.imageUrl && (
<img
src={preview.imageUrl}
alt=""
loading="lazy"
className="h-20 w-20 shrink-0 object-cover"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
)}
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5 p-2.5">
{preview.siteName && (
<p className="truncate text-[10px] uppercase tracking-wider text-fg-muted">
{preview.siteName}
</p>
)}
{preview.title && (
<p className="line-clamp-2 text-sm font-semibold text-fg">
{preview.title}
</p>
)}
{preview.description && (
<p className="line-clamp-2 text-xs text-fg-muted">{preview.description}</p>
)}
</div>
</a>
);
}
@@ -0,0 +1,103 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { Avatar } from './Avatar';
interface Props {
members: ConversationSummary['members'];
query: string;
excludeUserId: string | undefined;
onSelect: (username: string) => void;
onClose: () => void;
}
// Dropdown shown above the composer when the user has typed `@` followed
// by the start of a member name. Keyboard-first — arrow keys move through,
// enter/tab commits, escape cancels.
export function MentionAutocomplete({
members,
query,
excludeUserId,
onSelect,
onClose,
}: Props) {
const q = query.toLowerCase();
const matches = members
.filter((m) => m.userId !== excludeUserId)
.filter((m) => {
if (!q) return true;
const name = (m.profile?.displayName ?? '').toLowerCase();
const handle = (m.profile?.username ?? '').toLowerCase();
return name.includes(q) || handle.includes(q);
})
.slice(0, 8);
const [active, setActive] = useState(0);
useEffect(() => {
setActive(0);
}, [query]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (matches.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive((i) => (i + 1) % matches.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive((i) => (i - 1 + matches.length) % matches.length);
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
const pick = matches[active];
if (pick?.profile?.username) onSelect(pick.profile.username);
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', onKey, true);
return () => {
window.removeEventListener('keydown', onKey, true);
};
}, [matches, active, onSelect, onClose]);
if (matches.length === 0) return null;
return (
<div
role="listbox"
aria-label="Mitglieder"
className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-64 overflow-y-auto rounded-xl border border-line bg-surface-2 p-1 shadow-xl"
>
{matches.map((m, idx) => {
const name = m.profile?.displayName ?? m.profile?.username ?? '?';
const handle = m.profile?.username ?? '';
const isActive = idx === active;
return (
<button
key={m.userId}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => setActive(idx)}
onClick={() => {
if (m.profile?.username) onSelect(m.profile.username);
}}
className={
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
(isActive ? 'bg-accent/20 text-fg' : 'text-fg-muted hover:bg-surface-3')
}
>
<Avatar
displayName={name}
url={m.profile?.avatarUrl ?? null}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
</button>
);
})}
</div>
);
}
+321 -37
View File
@@ -13,30 +13,73 @@ import { useAuth } from '../context/AuthContext';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import type { AggregatedReaction } from '../lib/useMessageReactions';
import { extractFirstUrl } from '../lib/useLinkPreview';
import { AttachmentAudio } from './AttachmentAudio';
import { AttachmentGeneric } from './AttachmentGeneric';
import { AttachmentImage } from './AttachmentImage';
import { PencilIcon, PhoneIcon, PhoneOffIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
import { AttachmentPdf } from './AttachmentPdf';
import { AttachmentVideo } from './AttachmentVideo';
import { LinkPreviewCard } from './LinkPreviewCard';
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;
/** Click on the message's avatar surfaces the author's profile card. */
onAvatarClick?: (userId: string, ev: React.MouseEvent) => 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,
onAvatarClick,
highlighted = false,
}: Props) {
const { t } = useTranslation(['app']);
const { session, device } = useAuth();
@@ -57,6 +100,47 @@ export function MessageBubble({
const withinEditWindow = age < EDIT_WINDOW_MS;
const bodyText = initialText;
const attachments = initialAttachments;
// /tempmsg ephemeral window. expireMs embedded in plaintext JSON; sender
// fires the soft-delete when the clock runs out. Receivers just watch
// the deletedAt flip via realtime.
const expireMs = parsed.kind === 'text' ? parsed.expireMs : undefined;
const [tickNow, setTickNow] = useState<number>(() => Date.now());
useEffect(() => {
if (expireMs === undefined) return;
const id = window.setInterval(() => setTickNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, [expireMs]);
const msLeft =
expireMs !== undefined
? Math.max(0, expireMs - (tickNow - createdAt.getTime()))
: null;
useEffect(() => {
if (!mine) return;
if (expireMs === undefined) return;
if (message.deletedAt) return;
const remaining = Math.max(0, expireMs - age);
const timer = window.setTimeout(() => {
void softDeleteMessage(supabase, message.id).catch((err: unknown) => {
console.warn('ephemeral auto-delete failed', err);
});
}, remaining);
return () => window.clearTimeout(timer);
}, [mine, expireMs, age, message.id, message.deletedAt]);
// Receiver-side auto-hide when the expiry window elapses even if the
// sender's delete hasn't propagated yet (network hiccup, offline-sender).
const [localExpired, setLocalExpired] = useState<boolean>(
msLeft !== null && msLeft <= 0,
);
useEffect(() => {
if (expireMs === undefined) return;
if (localExpired) return;
const remaining = Math.max(0, expireMs - age);
const t = window.setTimeout(() => setLocalExpired(true), remaining);
return () => window.clearTimeout(t);
}, [expireMs, age, localExpired]);
const canEdit =
parsed.kind === 'text' &&
mine &&
@@ -141,10 +225,18 @@ export function MessageBubble({
[onToggleReaction],
);
if (message.deletedAt) {
if (message.deletedAt || localExpired) {
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}
{...(onAvatarClick
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
: {})}
/>
<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>
@@ -156,14 +248,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)}
@@ -179,10 +276,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>
)}
@@ -193,7 +290,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>
@@ -201,7 +298,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>
@@ -210,55 +307,126 @@ 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 pl-2 pr-2.5 py-1.5 text-left text-xs transition hover:brightness-110 hover:shadow-sm ' +
(mine
? 'bg-white/10 text-accent-fg/90'
: 'bg-surface-2/80 text-fg-muted ring-1 ring-inset ring-line')
}
>
<span
aria-hidden="true"
className={
'-ml-1 w-1 shrink-0 rounded-full ' +
(mine ? 'bg-white/70' : 'bg-accent')
}
/>
<span className="min-w-0 flex-1 pl-1">
<span
className={
'flex items-center gap-1 truncate text-[11px] font-semibold ' +
(mine ? 'text-accent-fg' : 'text-accent')
}
>
<ReplyIcon className="h-3 w-3 shrink-0" />
<span className="truncate">{quoted.senderName}</span>
</span>
<span className="mt-0.5 block truncate opacity-80">
{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} />
))}
{bodyText.length > 0 && <div>{renderBodyWithMentions(bodyText)}</div>}
{bodyText.length > 0 &&
(() => {
const url = extractFirstUrl(bodyText);
return url ? <LinkPreviewCard url={url} /> : null;
})()}
{attachments.map((a) => {
if (a.mimeType.startsWith('audio/')) {
return <AttachmentAudio key={a.id} handle={a} />;
}
if (a.mimeType.startsWith('image/')) {
return <AttachmentImage key={a.id} handle={a} />;
}
if (a.mimeType.startsWith('video/')) {
return <AttachmentVideo key={a.id} handle={a} />;
}
if (a.mimeType === 'application/pdf') {
return <AttachmentPdf key={a.id} handle={a} />;
}
return <AttachmentGeneric 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>
{message.editedAt && !message.deletedAt && (
<span className="italic">· {t('app:chats.edited')}</span>
)}
{msLeft !== null && msLeft > 0 && (
<span
className={
'inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider ' +
(mine ? 'bg-white/20' : 'bg-rose-500/15 text-rose-600 dark:text-rose-300')
}
title="Selbstzerstörung"
>
{Math.ceil(msLeft / 1000)}s
</span>
)}
</div>
</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>
@@ -275,12 +443,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"
@@ -306,7 +488,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')
}
>
@@ -315,7 +497,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>
@@ -323,7 +505,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>
@@ -336,6 +518,42 @@ export function MessageBubble({
);
}
function AvatarSlot({
show,
url,
displayName,
onClick,
}: {
show: boolean;
url: string | null;
displayName: string | null;
onClick?: (ev: React.MouseEvent) => void;
}) {
if (!show) {
return <div aria-hidden="true" className="h-8 w-8 shrink-0" />;
}
if (onClick) {
return (
<button
type="button"
data-user-popover-trigger
onClick={onClick}
className="shrink-0 cursor-pointer rounded-full transition hover:ring-2 hover:ring-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
aria-label={displayName ?? 'Profil'}
>
<Avatar url={url} displayName={displayName ?? ''} className="h-8 w-8 text-xs" />
</button>
);
}
return (
<Avatar
url={url}
displayName={displayName ?? ''}
className="h-8 w-8 text-xs"
/>
);
}
function CallEventRow({
parsed,
mine,
@@ -351,8 +569,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'
@@ -390,6 +608,72 @@ function formatDuration(totalSec: number): string {
return m + ':' + s.toString().padStart(2, '0');
}
// Splits body text on `@username` tokens, rendering matches as highlighted
// pills. Username alphabet matches Supabase citext usernames: alphanumerics
// + underscores, length 1..32 (we don't bound here — regex is permissive
// and keys off a leading `@` with an alnum/underscore follow).
const MENTION_RE = /@([A-Za-z0-9_]{1,32})/g;
function renderBodyWithMentions(text: string): React.ReactNode[] {
const out: React.ReactNode[] = [];
let lastIdx = 0;
let m: RegExpExecArray | null;
MENTION_RE.lastIndex = 0;
while ((m = MENTION_RE.exec(text)) !== null) {
if (m.index > lastIdx) out.push(text.slice(lastIdx, m.index));
out.push(
<span
key={m.index + ':' + m[1]}
className="rounded bg-accent/20 px-1 text-accent"
>
{m[0]}
</span>,
);
lastIdx = m.index + m[0].length;
}
if (lastIdx < text.length) out.push(text.slice(lastIdx));
return out;
}
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,
@@ -408,10 +692,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,244 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
clearIncomingRingtone,
getIncomingRingtone,
MAX_RINGTONE_BYTES,
saveIncomingRingtone,
type StoredRingtone,
} from '../lib/ringtoneStorage';
import { PhoneIcon, SpinnerIcon, TrashIcon } from './icons';
interface Props {
/** Disable interactions while a parent action is in flight. */
disabled?: boolean;
}
const BYTES_PER_MB = 1024 * 1024;
// UI for the custom incoming-call ringtone. Single file slot. Upload
// validates size + mime and surfaces errors inline. Preview button plays
// the stored blob through a local <audio> element without touching the
// shared ringtone singleton so we don't interfere with a live call.
export function RingtoneSettings({ disabled = false }: Props) {
const { t } = useTranslation(['app']);
const inputRef = useRef<HTMLInputElement | null>(null);
const previewRef = useRef<HTMLAudioElement | null>(null);
const previewUrlRef = useRef<string | null>(null);
const [current, setCurrent] = useState<StoredRingtone | null>(null);
const [busy, setBusy] = useState(false);
const [playing, setPlaying] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const cur = await getIncomingRingtone();
setCurrent(cur);
} catch (err: unknown) {
console.error('getIncomingRingtone failed', err);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
// Revoke any preview blob URL when the component unmounts so long-lived
// pages don't leak memory.
return () => {
stopPreview();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function stopPreview(): void {
const el = previewRef.current;
if (el) {
try {
el.pause();
} catch {
/* ignore */
}
el.src = '';
}
previewRef.current = null;
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
setPlaying(false);
}
async function handleFile(file: File): Promise<void> {
setError(null);
setBusy(true);
try {
await saveIncomingRingtone(file);
await refresh();
} catch (err: unknown) {
const code = err instanceof Error ? err.message : 'upload_failed';
if (code === 'ringtone_too_large') {
setError(
t('app:settings.ringtone_error_too_large', {
defaultValue: 'Datei zu groß (max 2 MB).',
max: MAX_RINGTONE_BYTES / BYTES_PER_MB,
}),
);
} else if (code === 'ringtone_not_audio') {
setError(
t('app:settings.ringtone_error_not_audio', {
defaultValue: 'Nur Audio-Dateien werden unterstützt.',
}),
);
} else {
setError(
t('app:settings.ringtone_error_generic', {
defaultValue: 'Ringtone konnte nicht gespeichert werden.',
}),
);
}
} finally {
setBusy(false);
if (inputRef.current) inputRef.current.value = '';
}
}
async function handleReset(): Promise<void> {
setError(null);
setBusy(true);
stopPreview();
try {
await clearIncomingRingtone();
await refresh();
} catch (err: unknown) {
console.error('clearIncomingRingtone failed', err);
} finally {
setBusy(false);
}
}
function handlePreview(): void {
if (!current) return;
if (playing) {
stopPreview();
return;
}
const url = URL.createObjectURL(current.blob);
const el = new Audio(url);
el.loop = false;
el.volume = 0.85;
el.onended = () => stopPreview();
el.onerror = () => {
setError(
t('app:settings.ringtone_error_play', {
defaultValue: 'Ringtone konnte nicht abgespielt werden.',
}),
);
stopPreview();
};
el.play().catch(() => {
setError(
t('app:settings.ringtone_error_play', {
defaultValue: 'Ringtone konnte nicht abgespielt werden.',
}),
);
stopPreview();
});
previewRef.current = el;
previewUrlRef.current = url;
setPlaying(true);
}
const hasCustom = current !== null;
const sizeMb = current ? (current.blob.size / BYTES_PER_MB).toFixed(2) : null;
const interactionsDisabled = disabled || busy;
return (
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium text-fg">
<PhoneIcon className="h-4 w-4 text-fg-muted" />
{t('app:settings.ringtone_incoming', { defaultValue: 'Eingehender Anruf' })}
</div>
<div className="mt-1 text-xs text-fg-muted">
{hasCustom && current
? t('app:settings.ringtone_custom_active', {
defaultValue: '{{name}} · {{size}} MB',
name: current.filename,
size: sizeMb,
})
: t('app:settings.ringtone_default_active', {
defaultValue: 'Standard-Klingelton (Doppelton)',
})}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{hasCustom && (
<button
type="button"
onClick={handlePreview}
disabled={interactionsDisabled}
className="cursor-pointer rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-xs font-semibold text-fg transition hover:brightness-95 disabled:cursor-not-allowed disabled:opacity-50"
>
{playing
? t('app:settings.ringtone_stop', { defaultValue: 'Stop' })
: t('app:settings.ringtone_preview', { defaultValue: 'Vorhören' })}
</button>
)}
<input
ref={inputRef}
type="file"
accept="audio/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleFile(f);
}}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={interactionsDisabled}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>
{hasCustom
? t('app:settings.ringtone_replace', { defaultValue: 'Ersetzen' })
: t('app:settings.ringtone_upload', { defaultValue: 'Hochladen' })}
</span>
</button>
{hasCustom && (
<button
type="button"
onClick={() => void handleReset()}
disabled={interactionsDisabled}
aria-label={t('app:settings.ringtone_reset', { defaultValue: 'Zurücksetzen' })}
title={t('app:settings.ringtone_reset', { defaultValue: 'Zurücksetzen' })}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-rose-500/40 bg-rose-500/10 text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
>
<TrashIcon className="h-4 w-4" />
</button>
)}
</div>
</div>
{error && (
<p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-300">
{error}
</p>
)}
<p className="text-[11px] text-fg-muted">
{t('app:settings.ringtone_hint', {
defaultValue:
'MP3, WAV, OGG oder M4A bis 2 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.',
})}
</p>
</div>
);
}
@@ -0,0 +1,262 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
type DisplaySurfaceHint,
getPresetParams,
getScreenShareSettings,
PRESET_ORDER,
type ScreenSharePreset,
updateScreenShareSettings,
} 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 [includeAudio, setIncludeAudio] = useState<boolean>(initial.includeSystemAudio);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!open) return null;
async function handleStart() {
setBusy(true);
setError(null);
try {
// Persist the audio choice alongside the other picker prefs so the
// upstream startScreenShare picks it up on its settings read.
updateScreenShareSettings({ includeSystemAudio: includeAudio });
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>
<div>
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2 text-xs hover:bg-surface">
<input
type="checkbox"
checked={includeAudio}
onChange={(e) => setIncludeAudio(e.target.checked)}
className="mt-0.5 accent-accent"
/>
<span className="min-w-0 flex-1">
<span className="block font-semibold text-fg">
{t('app:call.share_system_audio', {
defaultValue: 'System-Sound mit übertragen',
})}
</span>
<span className="mt-0.5 block text-[11px] text-fg-muted">
{t('app:call.share_system_audio_hint', {
defaultValue:
'"Go Live" — Systemsound wird mitgesendet. Auf macOS braucht das extra Berechtigungen; wird sonst stumm geteilt.',
})}
</span>
</span>
</label>
</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 -45
View File
@@ -4,107 +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,
LogoLockup,
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']);
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 px-5 pb-3 pt-5">
<LogoLockup tone="dark" className="h-8 w-auto" aria-label="Netralax" />
<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>
@@ -0,0 +1,625 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getPttSettings } from '../lib/pttSettings';
import { codeToShortcut } from '../lib/globalShortcut';
import { invalidate as invalidateSoundCache, preload } from '../lib/soundboardPlayback';
import {
addSound,
deleteSound,
getSoundBlob,
listSounds,
MAX_SOUND_BYTES,
reorderCategory,
type SoundboardEntry,
subscribeSoundboardChanges,
updateSound,
} from '../lib/soundboardStorage';
import { Modal } from './Modal';
import {
AlertIcon,
ChevronDownIcon,
PencilIcon,
PlusIcon,
SpinnerIcon,
TrashIcon,
} from './icons';
interface Props {
open: boolean;
onClose: () => void;
}
const BYTES_PER_MB = 1024 * 1024;
const CATEGORY_LIST_ID = 'sb-category-list';
// Admin UI for the soundboard. Users add, rename, categorise, reorder,
// assign hotkeys, adjust per-sound volume, preview and delete clips here.
// In-call panel only reads the resulting manifest.
export function SoundboardManagerDialog({ open, onClose }: Props) {
const { t } = useTranslation(['app']);
const fileRef = useRef<HTMLInputElement | null>(null);
const previewRef = useRef<HTMLAudioElement | null>(null);
const previewUrlRef = useRef<string | null>(null);
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
const [loading, setLoading] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [previewingId, setPreviewingId] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
try {
setEntries(await listSounds());
} catch (err: unknown) {
console.error('listSounds failed', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
void refresh();
// External mutations (hotkey fires, multi-tab edits) should reflect
// immediately while the dialog is open.
const unsub = subscribeSoundboardChanges(() => {
void refresh();
});
return () => {
unsub();
stopPreview();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, refresh]);
useEffect(() => {
return () => stopPreview();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const categories = useMemo(() => {
const set = new Set<string>();
for (const e of entries) if (e.category) set.add(e.category);
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [entries]);
const grouped = useMemo(() => {
const map = new Map<string, SoundboardEntry[]>();
for (const e of entries) {
const key = e.category ?? '';
const arr = map.get(key);
if (arr) arr.push(e);
else map.set(key, [e]);
}
return map;
}, [entries]);
function stopPreview(): void {
const el = previewRef.current;
if (el) {
try {
el.pause();
} catch {
/* ignore */
}
el.src = '';
}
previewRef.current = null;
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
setPreviewingId(null);
}
async function handleAdd(file: File): Promise<void> {
setError(null);
setBusyId('__add');
try {
await addSound({ file });
await refresh();
} catch (err: unknown) {
const code = err instanceof Error ? err.message : 'add_failed';
if (code === 'sound_too_large') {
setError(
t('app:soundboard.error_too_large', {
defaultValue: 'Datei zu groß (max {{max}} MB).',
max: MAX_SOUND_BYTES / BYTES_PER_MB,
}),
);
} else if (code === 'sound_not_audio') {
setError(
t('app:soundboard.error_not_audio', {
defaultValue: 'Nur Audio-Dateien werden unterstützt.',
}),
);
} else {
setError(
t('app:soundboard.error_generic', {
defaultValue: 'Sound konnte nicht gespeichert werden.',
}),
);
}
} finally {
setBusyId(null);
if (fileRef.current) fileRef.current.value = '';
}
}
async function handlePatch(
id: string,
patch: Parameters<typeof updateSound>[1],
): Promise<void> {
setBusyId(id);
try {
await updateSound(id, patch);
} catch (err: unknown) {
console.error('updateSound failed', err);
} finally {
setBusyId(null);
}
}
async function handleDelete(id: string): Promise<void> {
if (!window.confirm(t('app:soundboard.delete_confirm', { defaultValue: 'Sound löschen?' }))) {
return;
}
setBusyId(id);
try {
await deleteSound(id);
invalidateSoundCache(id);
if (previewingId === id) stopPreview();
} catch (err: unknown) {
console.error('deleteSound failed', err);
} finally {
setBusyId(null);
}
}
async function handlePreview(entry: SoundboardEntry): Promise<void> {
if (previewingId === entry.id) {
stopPreview();
return;
}
stopPreview();
const blob = await getSoundBlob(entry.id);
if (!blob) return;
const url = URL.createObjectURL(blob);
const el = new Audio(url);
el.volume = entry.gain;
el.onended = () => stopPreview();
el.onerror = () => stopPreview();
el.play().catch(() => stopPreview());
previewRef.current = el;
previewUrlRef.current = url;
setPreviewingId(entry.id);
// Opportunistic warm-up of the AudioBuffer cache so the first in-call
// playback doesn't pause on decode.
void preload(entry.id);
}
async function handleReorder(
category: string | null,
idx: number,
dir: -1 | 1,
): Promise<void> {
const bucket = grouped.get(category ?? '') ?? [];
const next = idx + dir;
if (next < 0 || next >= bucket.length) return;
const reordered = bucket.slice();
const tmp = reordered[idx]!;
reordered[idx] = reordered[next]!;
reordered[next] = tmp;
setBusyId('__reorder:' + (category ?? ''));
try {
await reorderCategory(
category,
reordered.map((e) => e.id),
);
} catch (err: unknown) {
console.error('reorderCategory failed', err);
} finally {
setBusyId(null);
}
}
if (!open) return null;
return (
<Modal
open={open}
onClose={onClose}
title={t('app:soundboard.manager_title', { defaultValue: 'Soundboard verwalten' })}
size="lg"
>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-fg-muted">
{t('app:soundboard.manager_hint', {
defaultValue:
'Beliebig viele Sounds, kein Hotkey nötig. Hotkeys feuern nur während eines Anrufs.',
})}
</p>
<input
ref={fileRef}
type="file"
accept="audio/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleAdd(f);
}}
/>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={busyId !== null}
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{busyId === '__add' ? (
<SpinnerIcon className="h-3.5 w-3.5" />
) : (
<PlusIcon className="h-3.5 w-3.5" />
)}
<span>{t('app:soundboard.add', { defaultValue: 'Sound hinzufügen' })}</span>
</button>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-300">
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0" />
<p className="min-w-0 flex-1 break-words">{error}</p>
</div>
)}
<datalist id={CATEGORY_LIST_ID}>
{categories.map((c) => (
<option key={c} value={c} />
))}
</datalist>
{loading ? (
<div className="flex items-center gap-2 text-xs text-fg-muted">
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
</div>
) : entries.length === 0 ? (
<p className="rounded-lg border border-line bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
{t('app:soundboard.empty', {
defaultValue: 'Noch keine Sounds. Lade oben welche hoch.',
})}
</p>
) : (
<div className="flex flex-col gap-4">
{Array.from(grouped.entries()).map(([categoryKey, bucket]) => {
const category = categoryKey === '' ? null : categoryKey;
return (
<SoundboardCategoryGroup
key={categoryKey || '__uncat'}
category={category}
entries={bucket}
entriesTotal={entries}
busyId={busyId}
previewingId={previewingId}
onPatch={handlePatch}
onDelete={handleDelete}
onPreview={handlePreview}
onReorder={handleReorder}
/>
);
})}
</div>
)}
</div>
</Modal>
);
}
// ---------------------------------------------------------------------------
interface GroupProps {
category: string | null;
entries: SoundboardEntry[];
entriesTotal: SoundboardEntry[];
busyId: string | null;
previewingId: string | null;
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onPreview: (entry: SoundboardEntry) => Promise<void>;
onReorder: (category: string | null, idx: number, dir: -1 | 1) => Promise<void>;
}
function SoundboardCategoryGroup({
category,
entries,
entriesTotal,
busyId,
previewingId,
onPatch,
onDelete,
onPreview,
onReorder,
}: GroupProps) {
const { t } = useTranslation(['app']);
const [open, setOpen] = useState(true);
const label =
category ??
t('app:soundboard.category_uncategorized', { defaultValue: '(Ohne Kategorie)' });
return (
<section className="rounded-lg border border-line bg-surface-2">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5 text-left"
>
<span className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
{label} · {entries.length}
</span>
<ChevronDownIcon
className={'h-4 w-4 text-fg-muted transition ' + (open ? '' : '-rotate-90')}
/>
</button>
{open && (
<ul className="flex flex-col gap-2 border-t border-line p-3">
{entries.map((entry, idx) => (
<SoundboardRow
key={entry.id}
entry={entry}
entriesTotal={entriesTotal}
isFirst={idx === 0}
isLast={idx === entries.length - 1}
busy={busyId === entry.id}
previewing={previewingId === entry.id}
onPatch={onPatch}
onDelete={onDelete}
onPreview={onPreview}
onReorderUp={() => onReorder(category, idx, -1)}
onReorderDown={() => onReorder(category, idx, 1)}
/>
))}
</ul>
)}
</section>
);
}
// ---------------------------------------------------------------------------
interface RowProps {
entry: SoundboardEntry;
entriesTotal: SoundboardEntry[];
isFirst: boolean;
isLast: boolean;
busy: boolean;
previewing: boolean;
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onPreview: (entry: SoundboardEntry) => Promise<void>;
onReorderUp: () => Promise<void>;
onReorderDown: () => Promise<void>;
}
function SoundboardRow({
entry,
entriesTotal,
isFirst,
isLast,
busy,
previewing,
onPatch,
onDelete,
onPreview,
onReorderUp,
onReorderDown,
}: RowProps) {
const { t } = useTranslation(['app']);
const [editingName, setEditingName] = useState(false);
const [nameDraft, setNameDraft] = useState(entry.name);
const [categoryDraft, setCategoryDraft] = useState(entry.category ?? '');
const [capturingHotkey, setCapturingHotkey] = useState(false);
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
useEffect(() => {
setNameDraft(entry.name);
setCategoryDraft(entry.category ?? '');
}, [entry.name, entry.category]);
useEffect(() => {
if (!capturingHotkey) return;
const onKey = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.code === 'Escape') {
setCapturingHotkey(false);
setHotkeyError(null);
return;
}
// Conflict checks: PTT + other soundboard entries with this code.
const ptt = getPttSettings();
if (ptt.enabled && ptt.key === e.code) {
setHotkeyError(
t('app:soundboard.hotkey_conflict_ptt', {
defaultValue: 'Konflikt mit Push-to-Talk.',
}),
);
return;
}
const taken = entriesTotal.find((s) => s.id !== entry.id && s.hotkey === e.code);
if (taken) {
setHotkeyError(
t('app:soundboard.hotkey_conflict_sound', {
defaultValue: 'Bereits von "{{name}}" belegt.',
name: taken.name,
}),
);
return;
}
setHotkeyError(null);
setCapturingHotkey(false);
void onPatch(entry.id, { hotkey: e.code });
};
window.addEventListener('keydown', onKey, { capture: true });
return () => window.removeEventListener('keydown', onKey, { capture: true });
}, [capturingHotkey, entriesTotal, entry.id, onPatch, t]);
const hotkeyLabel = entry.hotkey ? codeToShortcut(entry.hotkey) : null;
return (
<li className="flex flex-wrap items-center gap-3 rounded-md border border-line bg-surface-3 px-3 py-2">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
{editingName ? (
<input
autoFocus
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value)}
onBlur={() => {
setEditingName(false);
if (nameDraft.trim() && nameDraft !== entry.name) {
void onPatch(entry.id, { name: nameDraft });
} else {
setNameDraft(entry.name);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
(e.target as HTMLInputElement).blur();
}
if (e.key === 'Escape') {
setNameDraft(entry.name);
setEditingName(false);
}
}}
className="w-full rounded border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent focus:outline-none"
/>
) : (
<button
type="button"
onClick={() => setEditingName(true)}
className="flex cursor-pointer items-center gap-1.5 self-start text-sm font-semibold text-fg hover:text-accent"
>
{entry.name}
<PencilIcon className="h-3 w-3 opacity-50" />
</button>
)}
<p className="text-[10px] text-fg-muted">
{(entry.size / BYTES_PER_MB).toFixed(2)} MB · {entry.mime}
</p>
</div>
<input
type="text"
value={categoryDraft}
onChange={(e) => setCategoryDraft(e.target.value)}
onBlur={() => {
const next = categoryDraft.trim() || null;
if (next !== (entry.category ?? null)) {
void onPatch(entry.id, { category: next });
}
}}
list={CATEGORY_LIST_ID}
placeholder={t('app:soundboard.category_placeholder', {
defaultValue: 'Kategorie…',
})}
className="w-32 shrink-0 rounded border border-line bg-surface-2 px-2 py-1 text-xs text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
/>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => {
setHotkeyError(null);
setCapturingHotkey((v) => !v);
}}
className={
'inline-flex min-w-[5rem] cursor-pointer items-center justify-center rounded border px-2 py-1 text-[11px] font-mono font-semibold transition ' +
(capturingHotkey
? 'animate-pulse border-accent bg-accent/20 text-fg'
: hotkeyLabel
? 'border-accent/40 bg-accent/10 text-accent'
: 'border-line bg-surface-2 text-fg-muted hover:bg-surface')
}
title={t('app:soundboard.hotkey_capture', {
defaultValue: 'Hotkey binden (Esc = abbrechen)',
})}
>
{capturingHotkey
? t('app:soundboard.hotkey_press', { defaultValue: 'Drücke…' })
: hotkeyLabel ??
t('app:soundboard.hotkey_none', { defaultValue: 'Kein Hotkey' })}
</button>
{entry.hotkey && !capturingHotkey && (
<button
type="button"
onClick={() => void onPatch(entry.id, { hotkey: null })}
aria-label={t('app:soundboard.hotkey_clear', { defaultValue: 'Hotkey entfernen' })}
title={t('app:soundboard.hotkey_clear', { defaultValue: 'Hotkey entfernen' })}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg"
>
×
</button>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<input
type="range"
min={0}
max={1}
step={0.05}
value={entry.gain}
onChange={(e) => void onPatch(entry.id, { gain: Number(e.target.value) })}
className="accent-accent w-20"
title={t('app:soundboard.gain_title', {
defaultValue: 'Lautstärke',
})}
/>
<button
type="button"
onClick={() => void onPreview(entry)}
disabled={busy}
className="inline-flex cursor-pointer items-center gap-1 rounded border border-line bg-surface-2 px-2 py-1 text-[11px] font-medium text-fg transition hover:bg-surface disabled:opacity-50"
>
{previewing
? t('app:soundboard.preview_stop', { defaultValue: 'Stop' })
: t('app:soundboard.preview', { defaultValue: 'Vorhören' })}
</button>
<button
type="button"
onClick={onReorderUp}
disabled={busy || isFirst}
aria-label={t('app:soundboard.move_up', { defaultValue: 'Nach oben' })}
title={t('app:soundboard.move_up', { defaultValue: 'Nach oben' })}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg disabled:opacity-40"
>
</button>
<button
type="button"
onClick={onReorderDown}
disabled={busy || isLast}
aria-label={t('app:soundboard.move_down', { defaultValue: 'Nach unten' })}
title={t('app:soundboard.move_down', { defaultValue: 'Nach unten' })}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg disabled:opacity-40"
>
</button>
<button
type="button"
onClick={() => void onDelete(entry.id)}
disabled={busy}
aria-label={t('app:soundboard.delete', { defaultValue: 'Löschen' })}
title={t('app:soundboard.delete', { defaultValue: 'Löschen' })}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded border border-rose-500/30 bg-rose-500/10 text-rose-600 hover:bg-rose-500/20 disabled:opacity-50 dark:text-rose-300"
>
<TrashIcon className="h-3 w-3" />
</button>
</div>
{hotkeyError && capturingHotkey && (
<p className="basis-full text-[11px] text-rose-600 dark:text-rose-300">
{hotkeyError}
</p>
)}
</li>
);
}
@@ -0,0 +1,320 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useCall } from '../context/CallContext';
import { codeToShortcut } from '../lib/globalShortcut';
import {
DEFAULT_PREFS,
getPrefs,
listSounds,
type SoundboardEntry,
type SoundboardPrefs,
subscribeSoundboardChanges,
} from '../lib/soundboardStorage';
import { ChevronDownIcon, XIcon } from './icons';
interface Props {
onClose: () => void;
}
// In-call popover that lists every stored sound grouped by category. Click a
// pad to play through the active pipeline. Hotkey-badge shows the bound
// accelerator (if any). Master + monitor sliders adjust the pipeline gains.
export function SoundboardPanel({ onClose }: Props) {
const { t } = useTranslation(['app']);
const {
playSoundboard,
stopSoundboard,
activeSoundboardIds,
setSoundboardMasterGain,
setSoundboardMonitorGain,
} = useCall();
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
const [prefs, setPrefs] = useState<SoundboardPrefs>(() => ({ ...DEFAULT_PREFS }));
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const [query, setQuery] = useState('');
const closeRef = useRef(onClose);
closeRef.current = onClose;
const refresh = useCallback(async () => {
try {
const [all, p] = await Promise.all([listSounds(), getPrefs()]);
setEntries(all);
setPrefs(p);
} catch (err: unknown) {
console.warn('soundboard panel refresh failed', err);
}
}, []);
useEffect(() => {
void refresh();
const unsub = subscribeSoundboardChanges(() => {
void refresh();
});
return unsub;
}, [refresh]);
// Close on Esc — tapping outside is handled by the trigger's parent.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeRef.current();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return entries;
return entries.filter(
(e) =>
e.name.toLowerCase().includes(q) ||
(e.category ?? '').toLowerCase().includes(q),
);
}, [entries, query]);
const grouped = useMemo(() => {
const map = new Map<string, SoundboardEntry[]>();
for (const e of filtered) {
const key = e.category ?? '';
const arr = map.get(key);
if (arr) arr.push(e);
else map.set(key, [e]);
}
return map;
}, [filtered]);
function toggleCategory(key: string): void {
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
}
return (
<div
role="dialog"
aria-label={t('app:soundboard.panel_title', { defaultValue: 'Soundboard' })}
className="flex w-[360px] max-h-[70vh] flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-2xl"
>
<header className="flex items-center justify-between border-b border-line px-4 py-2.5">
<h3 className="font-display text-sm font-semibold text-fg">
{t('app:soundboard.panel_title', { defaultValue: 'Soundboard' })}
</h3>
<button
type="button"
onClick={onClose}
aria-label={t('app:soundboard.panel_close', { defaultValue: 'Schließen' })}
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-2 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
</header>
{entries.length > 0 && (
<div className="border-b border-line px-3 pb-2 pt-2">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('app:soundboard.panel_search', { defaultValue: 'Suche…' })}
className="w-full rounded-md border border-line bg-surface-2 px-3 py-1.5 text-xs text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{entries.length === 0 ? (
<p className="py-6 text-center text-xs text-fg-muted">
{t('app:soundboard.panel_empty', {
defaultValue:
'Keine Sounds gespeichert. Füge welche in den Einstellungen hinzu.',
})}
</p>
) : filtered.length === 0 ? (
<p className="py-6 text-center text-xs text-fg-muted">
{t('app:soundboard.panel_no_matches', {
defaultValue: 'Keine Treffer.',
})}
</p>
) : (
<div className="flex flex-col gap-3">
{Array.from(grouped.entries()).map(([key, bucket]) => (
<CategorySection
key={key || '__uncat'}
categoryKey={key}
entries={bucket}
collapsed={collapsed[key] ?? false}
activeIds={activeSoundboardIds}
onToggle={() => toggleCategory(key)}
onActivate={(id) => {
if (activeSoundboardIds.has(id)) {
stopSoundboard(id);
} else {
void playSoundboard(id);
}
}}
/>
))}
</div>
)}
</div>
<footer className="flex flex-col gap-2 border-t border-line bg-surface-2 px-4 py-3">
<VolumeSlider
label={t('app:soundboard.panel_master', { defaultValue: 'Master' })}
value={prefs.masterGain}
onChange={(v) => {
setPrefs((p) => ({ ...p, masterGain: v }));
void setSoundboardMasterGain(v);
}}
/>
<VolumeSlider
label={t('app:soundboard.panel_monitor', { defaultValue: 'Mithören' })}
value={prefs.monitorGain}
onChange={(v) => {
setPrefs((p) => ({ ...p, monitorGain: v }));
void setSoundboardMonitorGain(v);
}}
/>
<button
type="button"
onClick={() => stopSoundboard()}
className="mt-1 inline-flex cursor-pointer items-center justify-center rounded-md border border-line bg-surface-3 px-3 py-1.5 text-[11px] font-semibold text-fg transition hover:brightness-95"
>
{t('app:soundboard.panel_stop_all', { defaultValue: 'Alle stoppen' })}
</button>
</footer>
</div>
);
}
interface CategorySectionProps {
categoryKey: string;
entries: SoundboardEntry[];
collapsed: boolean;
activeIds: ReadonlySet<string>;
onToggle: () => void;
onActivate: (id: string) => void;
}
function CategorySection({
categoryKey,
entries,
collapsed,
activeIds,
onToggle,
onActivate,
}: CategorySectionProps) {
const { t } = useTranslation(['app']);
const label =
categoryKey === ''
? t('app:soundboard.category_uncategorized', { defaultValue: '(Ohne Kategorie)' })
: categoryKey;
return (
<section>
<button
type="button"
onClick={onToggle}
className="mb-1.5 flex w-full cursor-pointer items-center justify-between gap-2 text-left text-[10px] font-semibold uppercase tracking-[0.1em] text-fg-muted"
>
<span>
{label} · {entries.length}
</span>
<ChevronDownIcon
className={'h-3 w-3 transition ' + (collapsed ? '-rotate-90' : '')}
/>
</button>
{!collapsed && (
<div className="grid grid-cols-2 gap-1.5">
{entries.map((entry) => (
<SoundPad
key={entry.id}
entry={entry}
active={activeIds.has(entry.id)}
onActivate={() => onActivate(entry.id)}
/>
))}
</div>
)}
</section>
);
}
interface PadProps {
entry: SoundboardEntry;
active: boolean;
onActivate: () => void;
}
function SoundPad({ entry, active, onActivate }: PadProps) {
const { t } = useTranslation(['app']);
const hotkey = entry.hotkey ? codeToShortcut(entry.hotkey) : null;
const base =
'group relative flex min-h-[54px] cursor-pointer flex-col justify-center gap-0.5 rounded-md border px-2.5 py-2 text-left text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40';
const toneClass = active
? 'border-rose-500 bg-rose-500/20 text-fg hover:brightness-110'
: 'border-line bg-surface-2 text-fg hover:border-accent hover:bg-surface-3';
return (
<button
type="button"
onClick={onActivate}
className={`${base} ${toneClass}`}
title={
active
? t('app:soundboard.pad_stop', { defaultValue: 'Stoppen' })
: entry.name
}
aria-pressed={active}
>
<span className="flex items-center gap-1.5 truncate">
{active && (
<span
aria-hidden="true"
className="h-2 w-2 shrink-0 rounded-full bg-rose-500 animate-live-dot"
/>
)}
<span className="truncate">{entry.name}</span>
</span>
<div className="flex items-center justify-between gap-1.5">
{hotkey ? (
<span className="inline-flex w-fit items-center rounded border border-line bg-surface-3 px-1 py-0.5 font-mono text-[9px] text-fg-muted">
{hotkey}
</span>
) : (
<span aria-hidden="true" />
)}
{active && (
<span className="text-[9px] font-semibold uppercase tracking-[0.08em] text-rose-500 dark:text-rose-300">
{t('app:soundboard.pad_stop', { defaultValue: 'Stoppen' })}
</span>
)}
</div>
</button>
);
}
function VolumeSlider({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (v: number) => void;
}) {
return (
<label className="flex items-center gap-3 text-[11px] text-fg">
<span className="w-16 shrink-0 text-fg-muted">{label}</span>
<input
type="range"
min={0}
max={1}
step={0.02}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="accent-accent flex-1"
/>
<span className="w-9 shrink-0 text-right tabular-nums text-fg-muted">
{Math.round(value * 100)}%
</span>
</label>
);
}
@@ -0,0 +1,83 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
listSounds,
subscribeSoundboardChanges,
type SoundboardEntry,
} from '../lib/soundboardStorage';
import { SoundboardManagerDialog } from './SoundboardManagerDialog';
import { ArrowRightIcon } from './icons';
// Entry point into the soundboard manager from the settings page. Shows a
// tiny summary (count, category count) and opens the big dialog on click.
export function SoundboardSettings() {
const { t } = useTranslation(['app']);
const [open, setOpen] = useState(false);
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
useEffect(() => {
let cancelled = false;
const refresh = async () => {
try {
const all = await listSounds();
if (!cancelled) setEntries(all);
} catch (err: unknown) {
console.warn('listSounds failed', err);
}
};
void refresh();
const unsub = subscribeSoundboardChanges(() => {
void refresh();
});
return () => {
cancelled = true;
unsub();
};
}, []);
const categoryCount = new Set(entries.map((e) => e.category ?? '__uncat')).size;
const withHotkey = entries.filter((e) => e.hotkey !== null).length;
return (
<>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-fg">
{t('app:soundboard.summary_title', { defaultValue: 'Deine Sounds' })}
</p>
<p className="mt-1 text-xs text-fg-muted">
{entries.length === 0
? t('app:soundboard.summary_empty', {
defaultValue: 'Noch keine Sounds vorhanden.',
})
: t('app:soundboard.summary_counts', {
defaultValue:
'{{sounds}} Sounds · {{categories}} Kategorien · {{hotkeys}} mit Hotkey',
sounds: entries.length,
categories: categoryCount,
hotkeys: withHotkey,
})}
</p>
</div>
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110"
>
<span>
{t('app:soundboard.manage', { defaultValue: 'Verwalten' })}
</span>
<ArrowRightIcon className="h-3.5 w-3.5" />
</button>
</div>
<p className="text-[11px] text-fg-muted">
{t('app:soundboard.settings_hint', {
defaultValue:
'Hotkeys sind optional. Sounds lassen sich auch während eines Anrufs direkt im UI abspielen.',
})}
</p>
<SoundboardManagerDialog open={open} onClose={() => setOpen(false)} />
</>
);
}
@@ -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 ? (
<>

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