Compare commits

...

11 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
127 changed files with 8984 additions and 393 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chat-app/desktop",
"version": "0.8.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",
+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

+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.8.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",
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.8.0",
"version": "0.10.1",
"identifier": "com.meinname.chatapp",
"build": {
"beforeDevCommand": "pnpm vite:dev",
+74 -10
View File
@@ -1,7 +1,10 @@
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';
@@ -9,14 +12,39 @@ import { CallProvider } from './context/CallContext';
import { ConversationsProvider } from './context/ConversationsContext';
import { FriendshipsProvider } from './context/FriendshipsContext';
import { ThemeProvider } from './context/ThemeContext';
import { AdminPage } from './pages/AdminPage';
import { AuthCallbackPage } from './pages/AuthCallbackPage';
import { AuthPage } from './pages/AuthPage';
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.
@@ -52,11 +80,25 @@ export function App() {
<Routes>
<Route element={<RouteBoundary scope="auth" />}>
<Route path="/auth" element={<AuthPage />} />
<Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route
path="/auth/callback"
element={
<RouteSuspense>
<AuthCallbackPage />
</RouteSuspense>
}
/>
</Route>
<Route element={<RequireAuth />}>
<Route element={<RouteBoundary scope="device" />}>
<Route path="/device" element={<DevicePage />} />
<Route
path="/device"
element={
<RouteSuspense>
<DevicePage />
</RouteSuspense>
}
/>
</Route>
<Route element={<RequireDevice />}>
<Route element={<AppShell />}>
@@ -75,14 +117,35 @@ export function App() {
</Route>
</Route>
<Route element={<RouteBoundary scope="friends" />}>
<Route path="/friends" element={<FriendsPage />} />
<Route
path="/friends"
element={
<RouteSuspense>
<FriendsPage />
</RouteSuspense>
}
/>
</Route>
<Route element={<RouteBoundary scope="settings" />}>
<Route path="/settings" element={<SettingsPage />} />
<Route
path="/settings"
element={
<RouteSuspense>
<SettingsPage />
</RouteSuspense>
}
/>
</Route>
<Route element={<RequireAdmin />}>
<Route element={<RouteBoundary scope="admin" />}>
<Route path="/admin" element={<AdminPage />} />
<Route
path="/admin"
element={
<RouteSuspense>
<AdminPage />
</RouteSuspense>
}
/>
</Route>
</Route>
</Route>
@@ -91,6 +154,7 @@ export function App() {
<Route path="*" element={<Navigate to="/chats" replace />} />
</Routes>
<UpdateToast />
<CrashToast />
</BrowserRouter>
</CallProvider>
</ConversationsProvider>
@@ -1,6 +1,7 @@
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';
@@ -31,19 +32,30 @@ export function AttachmentAudio({ handle }: Props) {
setBlobUrl(null);
setArrayBuf(null);
downloadAndDecryptAttachment({ client: supabase, handle })
.then(async (blob) => {
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);
})
.catch((err: unknown) => {
void putCachedAttachment(handle.id, blob);
} catch (err: unknown) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
}
});
}
})();
return () => {
cancelled = true;
@@ -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';
}
+75 -13
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) => {
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>
);
}
+5 -2
View File
@@ -1,6 +1,8 @@
// 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;
@@ -18,10 +20,11 @@ export function Avatar({
fallbackClass = 'bg-accent/20 text-accent',
alt,
}: Props) {
if (url) {
const effectiveUrl = useCachedAvatarUrl(url);
if (effectiveUrl) {
return (
<img
src={url}
src={effectiveUrl}
alt={alt ?? displayName ?? ''}
className={'shrink-0 rounded-full object-cover ' + className}
draggable={false}
@@ -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>
);
}
@@ -7,6 +7,7 @@ import {
MicOffIcon,
MonitorShareIcon,
MonitorStopIcon,
MusicIcon,
PhoneOffIcon,
UsersIcon,
VideoIcon,
@@ -23,6 +24,9 @@ interface Props {
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. */
@@ -42,6 +46,8 @@ export function CallControls({
onToggleDeafen,
onHangup,
onOpenParticipants,
onToggleSoundboard,
soundboardOpen = false,
compact = false,
glass = false,
disabledMedia = false,
@@ -115,6 +121,18 @@ export function CallControls({
<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' })}
+8 -2
View File
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
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';
@@ -17,12 +18,17 @@ import { LockIcon, MonitorShareIcon, PhoneIcon, PhoneOffIcon } from './icons';
// 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();
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useCallPresence } from '../lib/useCallPresence';
import type { PeerPresence } from '../lib/usePeerPresence';
import { Avatar } from './Avatar';
import {
InfoIcon,
@@ -26,7 +27,7 @@ const PRESENCE_DOT: Record<PresenceState, string> = {
interface Props {
conversation: ConversationSummary | null;
peerPresence: PresenceState | null;
peerPresence: PeerPresence | null;
onInfoClick?: () => void;
onSearchClick?: () => void;
}
@@ -51,7 +52,7 @@ export function ConversationHeader({ conversation, peerPresence, onInfoClick, on
interface HeaderBarProps {
conversation: ConversationSummary;
peerPresence: PresenceState | null;
peerPresence: PeerPresence | null;
onInfoClick?: () => void;
onSearchClick?: () => void;
}
@@ -65,8 +66,18 @@ function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: H
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-line bg-surface-3 px-6 py-3">
@@ -80,12 +91,12 @@ function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: H
<UsersIcon className="h-5 w-5" />
</div>
)}
{showPresence && peerPresence && (
{showPresence && peerState && (
<span
aria-hidden="true"
className={
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-3 ' +
PRESENCE_DOT[peerPresence]
PRESENCE_DOT[peerState]
}
/>
)}
@@ -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,
);
}
+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>
);
}
+34 -1
View File
@@ -18,6 +18,7 @@ import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } f
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
import { ScreenShareDialog } from './ScreenShareDialog';
import { ScreenShareViewer } from './ScreenShareViewer';
import { SoundboardPanel } from './SoundboardPanel';
// Discord-style in-call dock rendered above the message list. Renders three
// visual modes driven by CallContext.callMode: grid, focus, fullscreen.
@@ -61,6 +62,7 @@ export function InCallPanel({ conversation }: Props) {
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteMute,
remoteScreenShares,
callMode,
focusedId,
@@ -77,6 +79,7 @@ export function InCallPanel({ conversation }: Props) {
const myId = session?.user.id ?? null;
const activeSpeakers = useActiveSpeakers(room);
const [shareDialogOpen, setShareDialogOpen] = useState(false);
const [soundboardOpen, setSoundboardOpen] = useState(false);
const [volumeMenu, setVolumeMenu] = useState<
{ userId: string; displayName: string; x: number; y: number } | null
>(null);
@@ -108,6 +111,7 @@ export function InCallPanel({ conversation }: Props) {
isMuted,
isDeafened,
remoteDeafen,
remoteMute,
isScreenSharing,
isCameraEnabled,
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
@@ -150,6 +154,8 @@ export function InCallPanel({ conversation }: Props) {
}}
onToggleVideo={() => void toggleCamera()}
onToggleDeafen={toggleDeafen}
onToggleSoundboard={() => setSoundboardOpen((v) => !v)}
soundboardOpen={soundboardOpen}
onHangup={() => void hangup()}
compact={callMode !== 'fullscreen'}
glass={callMode === 'fullscreen'}
@@ -197,6 +203,10 @@ export function InCallPanel({ conversation }: Props) {
onClose={() => setVolumeMenu(null)}
/>
)}
<SoundboardPopover
open={soundboardOpen}
onClose={() => setSoundboardOpen(false)}
/>
</>
);
}
@@ -280,10 +290,28 @@ export function InCallPanel({ conversation }: Props) {
onClose={() => setVolumeMenu(null)}
/>
)}
<SoundboardPopover
open={soundboardOpen}
onClose={() => setSoundboardOpen(false)}
/>
</section>
);
}
// Fixed-position overlay so the popover sits above both docked + fullscreen
// call modes without needing a portal or parent-relative anchoring.
function SoundboardPopover({ open, onClose }: { open: boolean; onClose: () => void }) {
if (!open) return null;
return (
<div className="pointer-events-none fixed inset-x-0 bottom-24 z-50 flex justify-center px-4">
<div className="pointer-events-auto">
<SoundboardPanel onClose={onClose} />
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -296,6 +324,7 @@ interface BuildArgs {
isMuted: boolean;
isDeafened: boolean;
remoteDeafen: Record<string, boolean>;
remoteMute: Record<string, boolean>;
isScreenSharing: boolean;
isCameraEnabled: boolean;
remoteSharerIds: Set<string>;
@@ -321,6 +350,7 @@ function buildTiles({
isMuted,
isDeafened,
remoteDeafen,
remoteMute,
isScreenSharing,
isCameraEnabled,
remoteSharerIds,
@@ -379,7 +409,10 @@ function buildTiles({
displayName: m.profile?.displayName ?? '?',
avatarUrl: m.profile?.avatarUrl ?? null,
self: false,
muted: !rp.isMicrophoneEnabled,
// Peer's self-reported mute state via data channel. LiveKit's own
// `isMicrophoneEnabled` no longer flips on mute since the pipeline
// output track stays published. See remoteMute broadcast in CallContext.
muted: remoteMute[m.userId] ?? false,
// Deafen state arrives via LiveKit data channel; see CallContext.
deafened: remoteDeafen[m.userId] ?? false,
video: rp.isCameraEnabled,
@@ -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>
);
}
+142 -17
View File
@@ -13,8 +13,13 @@ 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 { 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';
@@ -51,6 +56,8 @@ interface Props {
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;
}
@@ -71,6 +78,7 @@ export function MessageBubble({
onJumpToMessage,
onReply,
onForward,
onAvatarClick,
highlighted = false,
}: Props) {
const { t } = useTranslation(['app']);
@@ -92,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 &&
@@ -176,13 +225,16 @@ export function MessageBubble({
[onToggleReaction],
);
if (message.deletedAt) {
if (message.deletedAt || localExpired) {
return (
<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')}
@@ -269,23 +321,30 @@ export function MessageBubble({
type="button"
onClick={() => onJumpToMessage?.(quoted.id)}
className={
'mb-1.5 flex w-full cursor-pointer items-stretch gap-2 rounded-md px-2 py-1.5 text-left text-xs transition hover:opacity-90 ' +
'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/15 text-accent-fg/90'
: 'bg-surface-2 text-fg-muted')
? '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={
'w-0.5 shrink-0 rounded-full ' + (mine ? 'bg-white/50' : 'bg-accent')
'-ml-1 w-1 shrink-0 rounded-full ' +
(mine ? 'bg-white/70' : 'bg-accent')
}
/>
<span className="min-w-0 flex-1">
<span className={'block truncate font-semibold ' + (mine ? '' : 'text-fg')}>
{quoted.senderName}
<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="block truncate italic opacity-90">
<span className="mt-0.5 block truncate opacity-80">
{quoted.deleted
? t('app:chats.deleted')
: quoted.isAttachment && !quoted.snippet
@@ -299,14 +358,27 @@ export function MessageBubble({
<span className="italic opacity-70">cannot decrypt</span>
) : (
<>
{bodyText.length > 0 && <div>{bodyText}</div>}
{attachments.map((a) =>
a.mimeType.startsWith('audio/') ? (
<AttachmentAudio key={a.id} handle={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
@@ -319,6 +391,17 @@ export function MessageBubble({
{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>
)}
@@ -439,14 +522,29 @@ 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}
@@ -510,6 +608,33 @@ 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.
@@ -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>
);
}
@@ -7,6 +7,7 @@ import {
getScreenShareSettings,
PRESET_ORDER,
type ScreenSharePreset,
updateScreenShareSettings,
} from '../lib/screenShareSettings';
import {
MonitorShareIcon,
@@ -42,6 +43,7 @@ export function ScreenShareDialog({ open, onClose, onStart }: Props) {
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);
@@ -51,6 +53,9 @@ export function ScreenShareDialog({ open, onClose, onStart }: Props) {
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) {
@@ -165,6 +170,30 @@ export function ScreenShareDialog({ open, onClose, onStart }: Props) {
</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}
@@ -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)} />
</>
);
}
+59 -4
View File
@@ -1,6 +1,6 @@
import { updateOwnProfile } from '@chat-app/shared/auth';
import type { PresenceState } from '@chat-app/shared/supabase';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
@@ -8,6 +8,8 @@ import { supabase } from '../lib/supabase';
import { Avatar } from './Avatar';
import { ChevronDownIcon } from './icons';
const STATUS_MAX = 128;
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
const PRESENCE_DOT: Record<PresenceState, string> = {
@@ -25,6 +27,23 @@ export function UserBar() {
const [busy, setBusy] = useState(false);
const presence = profile?.presenceState ?? 'offline';
const persistedStatus = profile?.statusMessage ?? '';
const [statusDraft, setStatusDraft] = useState(persistedStatus);
// Re-sync local draft with the server when the profile refreshes (e.g. after
// a successful save) — without this the input would forget any incoming
// updates from another device.
useEffect(() => {
setStatusDraft(persistedStatus);
}, [persistedStatus]);
// Subtitle priority: custom status when online and set, else label, else "Offline".
const subtitle =
presence === 'offline'
? t('app:presence.offline')
: persistedStatus.trim().length > 0
? persistedStatus.trim()
: t('app:presence.' + presence);
async function changePresence(next: PresenceState) {
if (busy || next === presence) {
@@ -43,6 +62,20 @@ export function UserBar() {
}
}
async function saveStatus() {
const next = statusDraft.trim().slice(0, STATUS_MAX);
if (next === persistedStatus) return;
setBusy(true);
try {
await updateOwnProfile(supabase, { statusMessage: next.length === 0 ? null : next });
await refreshProfile();
} catch (err: unknown) {
console.error('updateStatusMessage failed', err);
} finally {
setBusy(false);
}
}
return (
<div className="relative">
<button
@@ -69,9 +102,7 @@ export function UserBar() {
<p className="truncate text-sm font-medium text-fg">
{profile?.displayName ?? '—'}
</p>
<p className="truncate text-xs text-fg-muted">
{t('app:presence.' + presence)}
</p>
<p className="truncate text-xs text-fg-muted">{subtitle}</p>
</div>
<ChevronDownIcon
className={'h-4 w-4 text-fg-muted transition ' + (open ? 'rotate-180' : '')}
@@ -83,6 +114,30 @@ export function UserBar() {
role="menu"
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-line bg-surface-3 shadow-xl"
>
<div className="border-b border-line p-2">
<input
type="text"
value={statusDraft}
onChange={(e) => setStatusDraft(e.target.value.slice(0, STATUS_MAX))}
onBlur={() => void saveStatus()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void saveStatus();
setOpen(false);
}
if (e.key === 'Escape') {
setStatusDraft(persistedStatus);
setOpen(false);
}
}}
placeholder={t('app:presence.status_placeholder', {
defaultValue: 'Status setzen…',
})}
maxLength={STATUS_MAX}
className="w-full rounded-md border border-line bg-surface-2 px-2 py-1.5 text-sm text-fg placeholder-fg-muted outline-none focus:border-accent"
/>
</div>
{PRESENCE_OPTIONS.map((opt) => (
<button
key={opt}
@@ -0,0 +1,126 @@
import type { ProfileBrief } from '@chat-app/shared/friends';
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { usePeerPresence } from '../lib/usePeerPresence';
import { Avatar } from './Avatar';
interface Props {
userId: string;
profile: ProfileBrief | null;
x: number;
y: number;
onClose: () => void;
onStartDm?: (userId: string) => void;
}
const PRESENCE_DOT: Record<string, string> = {
online: 'bg-emerald-500',
idle: 'bg-amber-400',
dnd: 'bg-rose-500',
invisible: 'bg-neutral-500',
offline: 'bg-neutral-400 dark:bg-neutral-600',
};
const CARD_W = 280;
const CARD_H = 180;
// Hover/click card surfaced from avatars around the app. Shows display name,
// @handle, presence state + status message, plus a DM-start button when
// the clicked profile isn't the caller.
export function UserProfilePopover({
userId,
profile,
x,
y,
onClose,
onStartDm,
}: Props) {
// Subscribe to the same presence feed that the conversation header uses,
// so status updates flow in live.
const presence = usePeerPresence(userId);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (t?.closest('[data-user-popover]')) return;
if (t?.closest('[data-user-popover-trigger]')) 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 - CARD_W - 8);
const top = Math.min(Math.max(8, y), window.innerHeight - CARD_H - 8);
const displayName = profile?.displayName ?? '?';
const username = profile?.username ?? '';
const state = presence?.state ?? 'offline';
const showPresence = state !== 'invisible';
const statusMessage = presence?.statusMessage?.trim() ?? '';
return createPortal(
<div
data-user-popover
role="dialog"
aria-label={displayName}
style={{ left, top, width: CARD_W }}
className="fixed z-[80] flex flex-col gap-3 rounded-xl border border-line bg-surface-2/95 p-4 shadow-xl backdrop-blur-md"
>
<div className="flex items-center gap-3">
<div className="relative">
<Avatar
url={profile?.avatarUrl ?? null}
displayName={displayName}
className="h-14 w-14 text-lg"
/>
{showPresence && (
<span
aria-hidden="true"
className={
'absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full ring-2 ring-surface-2 ' +
(PRESENCE_DOT[state] ?? PRESENCE_DOT.offline)
}
/>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-display text-base font-semibold text-fg">
{displayName}
</p>
{username && (
<p className="truncate text-xs text-fg-muted">@{username}</p>
)}
</div>
</div>
{statusMessage && state !== 'offline' && (
<p className="rounded-md bg-surface-3 px-2.5 py-1.5 text-xs italic text-fg-muted">
{statusMessage}
</p>
)}
{onStartDm && (
<button
type="button"
onClick={() => {
onStartDm(userId);
onClose();
}}
className="inline-flex cursor-pointer items-center justify-center rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110"
>
Nachricht senden
</button>
)}
</div>,
document.body,
);
}
+29 -52
View File
@@ -401,6 +401,16 @@ export function CrownIcon(props: IconProps) {
);
}
export function MusicIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</Base>
);
}
export function SendIcon(props: IconProps) {
return (
<Base {...props}>
@@ -509,86 +519,53 @@ export function AddUserIcon(props: IconProps) {
);
}
// Soft-N mark: rounded-square violet tile with a stylised "N" glyph. Used
// wherever the app needs a standalone icon (sidebar rail, auth screen,
// favicon). Colour decisions sit inside the SVG so consumers just size the
// element via `className`.
export function LogoMark(props: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 64 64"
fill="none"
aria-hidden="true"
{...props}
>
<defs>
<clipPath id="logo-hex-clip">
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" />
</clipPath>
</defs>
<polygon
points="32,5 57,19 57,45 32,59 7,45 7,19"
fill="#2e1065"
/>
<g clipPath="url(#logo-hex-clip)">
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa" />
<path
d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z"
fill="#7c4dff"
/>
<path
d="M-4 32 Q 16 18 32 32 T 68 32"
stroke="#a78bfa"
strokeWidth="2"
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"
/>
</g>
<polygon
points="32,5 57,19 57,45 32,59 7,45 7,19"
fill="none"
stroke="#a78bfa"
strokeWidth="1.5"
opacity="0.4"
stroke="#fff"
strokeWidth="5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
// Full lockup: hex icon + "Netralax" wordmark. `tone` decides text colour:
// "dark" = white text (use on dark background), "light" = black text.
// Full lockup: soft-N mark + "Netralax" wordmark. `tone` decides text colour:
// "dark" = white text (use on dark background), "light" = near-black.
export function LogoLockup({
tone = 'dark',
...props
}: IconProps & { tone?: 'dark' | 'light' }) {
const textFill = tone === 'dark' ? '#ffffff' : '#0F172A';
const textFill = tone === 'dark' ? '#ffffff' : '#14121c';
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 260 64"
fill="none"
aria-hidden="true"
{...props}
>
<defs>
<clipPath id="logo-lockup-hex-clip">
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" />
</clipPath>
</defs>
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065" />
<g clipPath="url(#logo-lockup-hex-clip)">
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa" />
<path
d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z"
fill="#7c4dff"
/>
<path
d="M-4 32 Q 16 18 32 32 T 68 32"
stroke="#a78bfa"
strokeWidth="2"
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"
/>
</g>
<polygon
points="32,5 57,19 57,45 32,59 7,45 7,19"
fill="none"
stroke="#a78bfa"
strokeWidth="1.5"
opacity="0.4"
stroke="#fff"
strokeWidth="5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<text
x="78"
+38
View File
@@ -3,6 +3,7 @@ import {
getOwnProfile,
signOut as supabaseSignOut,
type Profile,
updateOwnProfile,
} from '@chat-app/shared/auth';
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
import type { Session } from '@supabase/supabase-js';
@@ -143,6 +144,43 @@ export function AuthProvider({ children }: { children: ReactNode }) {
void registerWebPush(device.id);
}, [device?.id]);
// Auto online/offline transition.
//
// - On mount with a session whose last persisted state is `offline`, flip
// to `online`. We never override an explicit `idle`, `dnd`, or
// `invisible` choice — those are user intent.
// - On `pagehide` / `beforeunload`, fire a best-effort update to
// `offline`. Browsers don't guarantee delivery during unload, but the
// request usually slips through; the next page load corrects state if it
// didn't.
useEffect(() => {
if (!session || !profile) return;
if (profile.presenceState === 'offline') {
void updateOwnProfile(supabase, { presenceState: 'online' })
.then(() => refreshProfile())
.catch((err: unknown) => {
console.warn('auto online flip failed', err);
});
}
const onLeave = () => {
// Skip if user explicitly chose a non-online state — they probably
// want to look unavailable on next reconnect too.
if (
profile.presenceState !== 'online' &&
profile.presenceState !== 'offline'
) {
return;
}
void updateOwnProfile(supabase, { presenceState: 'offline' }).catch(() => {});
};
window.addEventListener('beforeunload', onLeave);
window.addEventListener('pagehide', onLeave);
return () => {
window.removeEventListener('beforeunload', onLeave);
window.removeEventListener('pagehide', onLeave);
};
}, [session, profile, refreshProfile]);
const signOut = useCallback(async () => {
await supabaseSignOut(supabase);
}, []);
+281 -31
View File
@@ -30,6 +30,7 @@ import {
import { useAuth } from './AuthContext';
import { useConversationsContext } from './ConversationsContext';
import { playEndBeep, playJoinBeep, playLeaveBeep } from '../lib/callSounds';
import { setCallWakeLock } from '../lib/wakeLock';
import { notify } from '../lib/osNotify';
import {
isTauriRuntime,
@@ -40,14 +41,27 @@ import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
import {
getAudioQualityParams,
getAudioSettings,
subscribeAudioSettings,
updateAudioSettings,
} from '../lib/audioSettings';
import {
applyBackgroundBlurToLocal,
removeBackgroundBlurFromLocal,
} from '../lib/videoBlur';
import {
createCallE2EE,
getCallE2EESettings,
isE2EESupported,
} from '../lib/callE2EE';
import { createMicPipeline, type MicPipeline } from '../lib/micPipeline';
import { getParticipantVolume } from '../lib/participantVolumes';
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
import { playEntry } from '../lib/soundboardPlayback';
import {
getPrefs as getSoundboardPrefs,
listSounds as listSoundboard,
updatePrefs as updateSoundboardPrefs,
} from '../lib/soundboardStorage';
import {
type DisplaySurfaceHint,
getPresetParams,
@@ -110,6 +124,11 @@ interface CallContextValue {
isDeafened: boolean;
/** identity -> their deafen state, received via data channel. */
remoteDeafen: Record<string, boolean>;
/** identity -> mute state. Broadcast from peer whenever mic-gain flips.
* Needed because we can't rely on LiveKit's native isMicrophoneEnabled —
* the mic pipeline keeps the track published with sound flowing even
* while the mic path is gain-silenced, so LK never sees "muted". */
remoteMute: Record<string, boolean>;
// Zero or more remote screen shares (LiveKit supports multiple simultaneous).
remoteScreenShares: RemoteScreenShare[];
// Remembers the conversation of the last call we left so a sidebar widget
@@ -139,6 +158,19 @@ interface CallContextValue {
dismissLastCall: () => void;
setCallMode: (mode: CallMode) => void;
setFocusedId: (id: string | null) => void;
/** Play a soundboard entry through the active call's mic pipeline.
* No-op when not connected. Default single-fire per id (spamming the
* hotkey cuts the previous instance); set overlap=true to layer. */
playSoundboard: (id: string, opts?: { overlap?: boolean }) => Promise<void>;
/** Stop every active sb source, or just the one matching `id` if given. */
stopSoundboard: (id?: string) => void;
/** Ids of soundboard entries currently emitting audio. Updated live so
* the in-call panel can show a stop icon on active pads. */
activeSoundboardIds: ReadonlySet<string>;
/** Apply new sb master/monitor gains to the live pipeline. Persists via
* updatePrefs in the storage module. */
setSoundboardMasterGain: (value: number) => Promise<void>;
setSoundboardMonitorGain: (value: number) => Promise<void>;
// Runtime mic switch. Persists choice so subsequent calls reuse it, and
// hot-swaps the input on an active call without a reconnect.
setAudioInputDevice: (deviceId: string | null) => Promise<void>;
@@ -157,7 +189,7 @@ function newCallId(): string {
}
export function CallProvider({ children }: { children: ReactNode }) {
const { session, device } = useAuth();
const { session, device, profile } = useAuth();
const { conversations } = useConversationsContext();
const myId = session?.user.id;
@@ -173,12 +205,18 @@ export function CallProvider({ children }: { children: ReactNode }) {
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
const [callMode, setCallModeState] = useState<CallMode>('grid');
const [focusedId, setFocusedIdState] = useState<string | null>(null);
const [activeSoundboardIds, setActiveSoundboardIds] = useState<ReadonlySet<string>>(
() => new Set<string>(),
);
const signalChannelRef = useRef<RealtimeChannel | null>(null);
const presenceChannelRef = useRef<RealtimeChannel | null>(null);
const ringTimerRef = useRef<number | null>(null);
const soloTimerRef = useRef<number | null>(null);
const roomRef = useRef<Room | null>(null);
// Web Audio graph that mixes live mic + soundboard sources into a single
// published track. Created per call in joinRoom, destroyed in disconnectRoom.
const pipelineRef = useRef<MicPipeline | null>(null);
// Tracks whether the current call was ever in the connected state — needed
// so hangup/solo-timeout can emit a real duration message vs. "missed".
const everConnectedRef = useRef<boolean>(false);
@@ -191,12 +229,21 @@ export function CallProvider({ children }: { children: ReactNode }) {
// channel messages ({ type: 'presence', deafened: bool }). Exposed as
// state so consumer components re-render on change.
const [remoteDeafen, setRemoteDeafen] = useState<Record<string, boolean>>({});
const [remoteMute, setRemoteMute] = useState<Record<string, boolean>>({});
// Mirror of isMuted for use inside LiveKit-event callbacks that run outside
// the React component (ParticipantConnected rebroadcast etc).
const mutedRef = useRef<boolean>(false);
const stateRef = useRef<CallState>(state);
stateRef.current = state;
// Keep latest conversations accessible from signal-channel closures without
// re-subscribing the channel on every conversations update.
const conversationsRef = useRef(conversations);
conversationsRef.current = conversations;
// Mirror own presence state for use inside signal-channel callbacks. DND
// suppresses incoming-call OS notifications (ringtone is handled in CallUI
// which has direct access to the auth profile).
const presenceRef = useRef(profile?.presenceState ?? 'offline');
presenceRef.current = profile?.presenceState ?? 'offline';
// --- Helpers -----------------------------------------------------------
@@ -271,8 +318,25 @@ export function CallProvider({ children }: { children: ReactNode }) {
setIsDeafened(false);
deafenedActive = false;
setRemoteDeafen({});
setRemoteMute({});
setIsMuted(false);
mutedRef.current = false;
setIsE2EEActive(false);
// Tear down the mic pipeline AFTER LiveKit disconnects so the published
// track is unpublished cleanly first; then close AudioContext + stop
// raw mic + output tracks we own.
const pipeline = pipelineRef.current;
if (pipeline) {
try {
pipeline.destroy();
} catch {
/* ignore */
}
pipelineRef.current = null;
}
setActiveSoundboardIds(new Set<string>());
const pres = presenceChannelRef.current;
if (pres) {
try {
@@ -434,14 +498,14 @@ export function CallProvider({ children }: { children: ReactNode }) {
r.on(RoomEvent.ParticipantConnected, () => {
setRemoteParticipants(Array.from(r.remoteParticipants.values()));
void playJoinBeep();
if (presenceRef.current !== 'dnd') void playJoinBeep();
markConnectedIfReady(r, conversationId, mediaKind, callId);
});
r.on(RoomEvent.ParticipantDisconnected, () => {
const remaining = Array.from(r.remoteParticipants.values());
setRemoteParticipants(remaining);
void playLeaveBeep();
if (presenceRef.current !== 'dnd') void playLeaveBeep();
// Alone in the room while connected — start the solo-timeout.
if (
stateRef.current.kind === 'connected' &&
@@ -511,23 +575,36 @@ export function CallProvider({ children }: { children: ReactNode }) {
if (!participant?.identity) return;
try {
const text = new TextDecoder().decode(payload);
const msg = JSON.parse(text) as { type?: string; deafened?: boolean };
if (msg.type === 'presence' && typeof msg.deafened === 'boolean') {
const msg = JSON.parse(text) as {
type?: string;
deafened?: boolean;
muted?: boolean;
};
if (msg.type !== 'presence') return;
const id: string = participant.identity;
if (typeof msg.deafened === 'boolean') {
const deafened: boolean = msg.deafened;
setRemoteDeafen((prev) => {
if (prev[id] === deafened) return prev;
return { ...prev, [id]: deafened };
});
}
if (typeof msg.muted === 'boolean') {
const muted: boolean = msg.muted;
setRemoteMute((prev) => {
if (prev[id] === muted) return prev;
return { ...prev, [id]: muted };
});
}
} catch {
/* ignore malformed */
}
},
);
// When someone joins, re-send our current deafen state so they know.
// When someone joins, re-send our current presence (deafen + mute) so
// they know immediately instead of waiting for the next toggle.
r.on(RoomEvent.ParticipantConnected, () => {
void broadcastPresence(r, deafenedActive);
void broadcastPresence(r, deafenedActive, mutedRef.current);
});
// Track my own screen-share state via LocalTrack events so the toggle
@@ -556,19 +633,48 @@ export function CallProvider({ children }: { children: ReactNode }) {
setIsE2EEActive(false);
}
try {
const inputId = getAudioSettings().inputDeviceId;
await r.localParticipant.setMicrophoneEnabled(true, {
const audioPrefs = getAudioSettings();
const inputId = audioPrefs.inputDeviceId;
// Noise suppression: user-preference wins over the quality preset so
// hifi-mode users can still enable NS when they need to cut room hum.
const nsEffective = audioPrefs.noiseSuppression && aParams.noiseSuppression;
// Grab the raw mic ourselves instead of going through LiveKit's
// setMicrophoneEnabled. The resulting MediaStreamTrack is routed
// through createMicPipeline, which mixes in soundboard buffers and
// exposes a single output track we hand to publishTrack. Mute / PTT
// are gain-based from here on, never track.enabled or device stop.
const rawStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: aParams.echoCancellation,
noiseSuppression: aParams.noiseSuppression,
noiseSuppression: nsEffective,
autoGainControl: aParams.autoGainControl,
channelCount: aParams.stereo ? 2 : 1,
sampleRate: aParams.sampleRateHz,
// Plain string maps to `ideal` — if the device is gone we fall back
// to OS default instead of throwing NotFoundError.
...(inputId ? { deviceId: inputId } : {}),
...(inputId ? { deviceId: { ideal: inputId } } : {}),
},
video: false,
});
const rawTrack = rawStream.getAudioTracks()[0];
if (!rawTrack) throw new Error('no audio track from getUserMedia');
const pipeline = createMicPipeline(rawTrack);
pipelineRef.current = pipeline;
// Pull the user's last-saved soundboard gains onto the live pipeline
// before the first sound ever plays so nothing blasts at 100%.
try {
const prefs = await getSoundboardPrefs();
pipeline.setSoundboardGain(prefs.masterGain);
pipeline.setMonitorGain(prefs.monitorGain);
} catch (err: unknown) {
console.warn('getSoundboardPrefs failed', err);
}
await r.localParticipant.publishTrack(pipeline.outputTrack, {
source: Track.Source.Microphone,
red: true,
dtx: aParams.stereo ? false : true,
forceStereo: aParams.stereo,
});
} catch (micErr: unknown) {
console.error('setMicrophoneEnabled failed', micErr);
console.error('mic pipeline setup failed', micErr);
}
if (mediaKind === 'video') {
try {
@@ -807,12 +913,15 @@ export function CallProvider({ children }: { children: ReactNode }) {
}, []);
const toggleMute = useCallback(() => {
const pipeline = pipelineRef.current;
if (!pipeline) return;
setIsMuted((prev) => {
const nextMuted = !prev;
pipeline.setMicGain(nextMuted ? 0 : 1);
mutedRef.current = nextMuted;
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
const shouldEnable = !lp.isMicrophoneEnabled;
void lp.setMicrophoneEnabled(shouldEnable).then(() => {
setIsMuted(!shouldEnable);
if (r) void broadcastPresence(r, deafenedActive, nextMuted);
return nextMuted;
});
}, []);
@@ -848,7 +957,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
try {
await lp.setScreenShareEnabled(true, {
audio: false,
// "Go live" mode — capture system audio alongside the screen when
// the user opted in. On hosts that can't fulfil the request the
// browser quietly drops it; peers just get video-only, no error.
audio: settings.includeSystemAudio,
...(ssParams.dims
? {
resolution: {
@@ -921,7 +1033,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
// headphones-off badge. Data channel works on any LiveKit server
// version, unlike `setAttributes` which requires a newer server.
const r = roomRef.current;
if (r) void broadcastPresence(r, next);
if (r) void broadcastPresence(r, next, mutedRef.current);
return next;
});
}, []);
@@ -934,6 +1046,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
try {
await lp.setCameraEnabled(nextOn);
setIsCameraEnabled(nextOn);
if (nextOn && getAudioSettings().videoBackgroundBlur) {
void applyBackgroundBlurToLocal(lp);
}
} catch (err: unknown) {
console.error('setCameraEnabled failed', err);
// Permission denied / no camera — keep state in sync with actual
@@ -942,6 +1057,23 @@ export function CallProvider({ children }: { children: ReactNode }) {
}
}, []);
// Live-toggle the background-blur processor when the settings flag flips.
// Acquiring the MediaPipe model is deferred until first activation to
// avoid the 1.5MB download on users who never enable blur.
useEffect(() => {
return subscribeAudioSettings((s) => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
if (!lp.isCameraEnabled) return;
if (s.videoBackgroundBlur) {
void applyBackgroundBlurToLocal(lp);
} else {
void removeBackgroundBlurFromLocal(lp);
}
});
}, []);
// --- Push-to-talk ------------------------------------------------------
// While PTT is active + we're in a connected call, the mic is held off
// except while the configured key is pressed. Under Tauri we also register
@@ -957,9 +1089,14 @@ export function CallProvider({ children }: { children: ReactNode }) {
let globalRegisteredFor: string | null = null;
const setMic = (on: boolean) => {
const pipeline = pipelineRef.current;
if (!pipeline) return;
pipeline.setMicGain(on ? 1 : 0);
const nextMuted = !on;
mutedRef.current = nextMuted;
setIsMuted(nextMuted);
const r = roomRef.current;
if (!r) return;
void r.localParticipant.setMicrophoneEnabled(on).then(() => setIsMuted(!on));
if (r) void broadcastPresence(r, deafenedActive, nextMuted);
};
const pressPtt = () => {
@@ -1088,6 +1225,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
conv?.peer?.displayName ??
'…';
const isGroup = conv?.type === 'group';
if (presenceRef.current !== 'dnd') {
void notify({
title: isGroup
? (conv?.name ?? 'Gruppenanruf')
@@ -1096,6 +1234,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
? callerName + ' ruft die Gruppe'
: callerName + ' ruft dich an',
});
}
break;
}
case 'accept':
@@ -1136,10 +1275,12 @@ export function CallProvider({ children }: { children: ReactNode }) {
conv?.members.find((m) => m.userId === cur.fromUserId)?.profile?.displayName ??
conv?.peer?.displayName ??
'…';
if (presenceRef.current !== 'dnd') {
void notify({
title: 'Verpasster Anruf',
body: callerName + ' hat aufgelegt',
});
}
setState({ kind: 'idle' });
}
break;
@@ -1156,16 +1297,98 @@ export function CallProvider({ children }: { children: ReactNode }) {
setFocusedIdState(id);
}, []);
const markActive = useCallback((id: string, on: boolean) => {
setActiveSoundboardIds((prev) => {
const has = prev.has(id);
if (on && has) return prev;
if (!on && !has) return prev;
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
}, []);
const playSoundboard = useCallback(
async (id: string, opts?: { overlap?: boolean }) => {
const pipeline = pipelineRef.current;
if (!pipeline) return;
const entries = await listSoundboard();
const entry = entries.find((e) => e.id === id);
if (!entry) return;
const handle = await playEntry(pipeline, entry, {
...(opts?.overlap !== undefined ? { overlap: opts.overlap } : {}),
onEnded: () => markActive(id, false),
});
if (handle) markActive(id, true);
},
[markActive],
);
const stopSoundboard = useCallback(
(id?: string) => {
const pipeline = pipelineRef.current;
if (!pipeline) return;
pipeline.stopAll(id);
if (id) {
markActive(id, false);
} else {
setActiveSoundboardIds(new Set<string>());
}
},
[markActive],
);
const setSoundboardMasterGain = useCallback(async (value: number) => {
const prefs = await updateSoundboardPrefs({ masterGain: value });
pipelineRef.current?.setSoundboardGain(prefs.masterGain);
}, []);
const setSoundboardMonitorGain = useCallback(async (value: number) => {
const prefs = await updateSoundboardPrefs({ monitorGain: value });
pipelineRef.current?.setMonitorGain(prefs.monitorGain);
}, []);
// Global soundboard hotkey registration — runs only while connected so the
// OS-level shortcuts don't fire when the user is outside of a call.
useEffect(() => {
if (state.kind !== 'connected') return;
const teardown = startSoundboardHotkeys((id) => {
void playSoundboard(id);
});
return teardown;
}, [state.kind, playSoundboard]);
const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
updateAudioSettings({ inputDeviceId: deviceId });
const r = roomRef.current;
if (!r) return;
const pipeline = pipelineRef.current;
if (!pipeline) return;
try {
// LiveKit API: `switchActiveDevice(kind, deviceId)` hot-swaps without a
// reconnect. Pass empty string or `default` to revert to OS default.
await r.switchActiveDevice('audioinput', deviceId ?? 'default');
// We own the mic track (see joinRoom pipeline setup), so LiveKit's
// switchActiveDevice no longer applies. Fetch a new raw track with the
// updated deviceId + the same quality constraints, then hand ownership
// to the pipeline. It disconnects the old source, stops the old track,
// and rewires micGain onto the new source — the published track stays
// stable so peers don't see a republish.
const audioPrefs = getAudioSettings();
const aParams = getAudioQualityParams(audioPrefs.quality);
const nsEffective = audioPrefs.noiseSuppression && aParams.noiseSuppression;
const newStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: aParams.echoCancellation,
noiseSuppression: nsEffective,
autoGainControl: aParams.autoGainControl,
channelCount: aParams.stereo ? 2 : 1,
sampleRate: aParams.sampleRateHz,
...(deviceId ? { deviceId: { ideal: deviceId } } : {}),
},
video: false,
});
const newTrack = newStream.getAudioTracks()[0];
if (!newTrack) throw new Error('no audio track for device');
pipeline.replaceMicTrack(newTrack);
} catch (err: unknown) {
console.error('switchActiveDevice(audioinput) failed', err);
console.error('setAudioInputDevice failed', err);
}
}, []);
@@ -1205,6 +1428,17 @@ export function CallProvider({ children }: { children: ReactNode }) {
}
}, [state.kind]);
// Hold the screen awake while a call is live so long sessions don't get
// dropped by display-sleep / OS power-save. Released the moment the call
// ends or errors out.
useEffect(() => {
const callActive =
state.kind === 'connected' ||
state.kind === 'connecting' ||
state.kind === 'outgoing';
void setCallWakeLock(callActive);
}, [state.kind]);
// Global Esc: drop out of fullscreen cinema back to grid while in an active
// call. Doesn't hangup and doesn't fire when other dialogs would consume it.
useEffect(() => {
@@ -1229,6 +1463,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteMute,
remoteScreenShares,
lastCallConversationId,
callMode,
@@ -1249,6 +1484,11 @@ export function CallProvider({ children }: { children: ReactNode }) {
setFocusedId,
setAudioInputDevice,
setAudioOutputDevice,
playSoundboard,
stopSoundboard,
activeSoundboardIds,
setSoundboardMasterGain,
setSoundboardMonitorGain,
}),
[
state,
@@ -1260,6 +1500,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteMute,
remoteScreenShares,
lastCallConversationId,
callMode,
@@ -1280,6 +1521,11 @@ export function CallProvider({ children }: { children: ReactNode }) {
setFocusedId,
setAudioInputDevice,
setAudioOutputDevice,
playSoundboard,
stopSoundboard,
activeSoundboardIds,
setSoundboardMasterGain,
setSoundboardMonitorGain,
],
);
@@ -1297,10 +1543,14 @@ export function useCall(): CallContextValue {
// audio elements. Toggled by toggleDeafen in sync with the React state.
let deafenedActive = false;
async function broadcastPresence(room: Room, deafened: boolean): Promise<void> {
async function broadcastPresence(
room: Room,
deafened: boolean,
muted: boolean,
): Promise<void> {
try {
const payload = new TextEncoder().encode(
JSON.stringify({ type: 'presence', deafened }),
JSON.stringify({ type: 'presence', deafened, muted }),
);
await room.localParticipant.publishData(payload, { reliable: true });
} catch (err: unknown) {
@@ -17,6 +17,7 @@ import {
import { playNotificationTone } from '../lib/notificationSound';
import { notify } from '../lib/osNotify';
import { supabase } from '../lib/supabase';
import { updateTrayUnread } from '../lib/trayBadge';
import { useAuth } from './AuthContext';
const LAST_READ_STORAGE_KEY = 'chatapp.conv_last_read';
@@ -253,6 +254,12 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
return s;
}, [unread]);
// Mirror unread count into the tray tooltip + dock badge. Runs in Tauri
// only; no-op in browser-preview.
useEffect(() => {
void updateTrayUnread(totalUnread);
}, [totalUnread]);
const value = useMemo<ConversationsContextValue>(
() => ({
conversations,
+102
View File
@@ -0,0 +1,102 @@
// Blob cache for decrypted attachments. Stores the Blob body in OPFS
// (Origin Private File System — always sandbox-scoped to the origin, no
// user prompt) keyed by attachment id. Avoids re-downloading + re-decrypting
// the same blob on every scroll-into-view.
//
// Tauri WebKit + WebView2 both support OPFS as of the versions targeted by
// tauri 2. On unsupported hosts the cache degrades to a no-op and callers
// fall back to the fetch path.
const DIR_NAME = 'attachments';
const DEFAULT_TTL_MS = 7 * 24 * 3600 * 1000;
interface OpfsRoot {
getDirectoryHandle: (
name: string,
opts?: { create?: boolean },
) => Promise<OpfsDir>;
}
interface OpfsDir {
getFileHandle: (
name: string,
opts?: { create?: boolean },
) => Promise<OpfsFile>;
removeEntry: (name: string, opts?: { recursive?: boolean }) => Promise<void>;
entries?: () => AsyncIterableIterator<[string, OpfsFile]>;
}
interface OpfsFile {
getFile: () => Promise<File>;
createWritable: () => Promise<{
write: (data: ArrayBuffer | Blob) => Promise<void>;
close: () => Promise<void>;
}>;
}
let dirPromise: Promise<OpfsDir | null> | null = null;
async function getDir(): Promise<OpfsDir | null> {
if (!dirPromise) {
dirPromise = (async () => {
const storage = (navigator as unknown as { storage?: { getDirectory?: () => Promise<OpfsRoot> } }).storage;
const getDirectory = storage?.getDirectory;
if (!storage || !getDirectory) return null;
try {
const root = await getDirectory.call(storage);
return await root.getDirectoryHandle(DIR_NAME, { create: true });
} catch (err: unknown) {
console.warn('opfs attachment cache init failed', err);
return null;
}
})();
}
return dirPromise;
}
function safeName(id: string): string {
// OPFS file names can't contain slashes; attachment ids are UUIDs so this
// is mostly defensive.
return id.replace(/[^A-Za-z0-9_.-]/g, '_') + '.bin';
}
export async function getCachedAttachment(id: string): Promise<Blob | null> {
const dir = await getDir();
if (!dir) return null;
try {
const handle = await dir.getFileHandle(safeName(id), { create: false });
const file = await handle.getFile();
// Evict stale entries lazily — if older than TTL, drop and miss.
if (Date.now() - file.lastModified > DEFAULT_TTL_MS) {
await dir.removeEntry(safeName(id)).catch(() => undefined);
return null;
}
return file;
} catch {
// File doesn't exist → cache miss.
return null;
}
}
export async function putCachedAttachment(id: string, blob: Blob): Promise<void> {
const dir = await getDir();
if (!dir) return;
try {
const handle = await dir.getFileHandle(safeName(id), { create: true });
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
} catch (err: unknown) {
console.warn('putCachedAttachment failed', err);
}
}
export async function evictCachedAttachment(id: string): Promise<void> {
const dir = await getDir();
if (!dir) return;
try {
await dir.removeEntry(safeName(id));
} catch {
/* ignore — probably never existed */
}
}
+28
View File
@@ -16,12 +16,27 @@ export interface AudioSettings {
// Preferred output (speaker/headphone) deviceId. null = system default.
// Applied to every <audio> element we attach via HTMLMediaElement.setSinkId.
outputDeviceId: string | null;
// RMS threshold (0..1) for the "is this participant talking" ring. Lower =
// more sensitive. Default 0.03 catches soft speech without lighting up on
// keyboard noise. Users on quiet mics can lower; users in noisy rooms bump up.
voiceThreshold: number;
// Per-publish DSP toggle. Off lets hifi-style music go through unmodified;
// on cleans up voice when the quality preset doesn't already imply it.
// Defaults to "follow the quality preset".
noiseSuppression: boolean;
// Background blur on the local camera track. Lazy — requires
// @livekit/track-processors + its MediaPipe selfie-segmentation model
// (~1.5MB) which downloads on first activation.
videoBackgroundBlur: boolean;
}
const DEFAULTS: AudioSettings = {
quality: 'voice',
inputDeviceId: null,
outputDeviceId: null,
voiceThreshold: 0.03,
noiseSuppression: true,
videoBackgroundBlur: false,
};
export interface AudioQualityParams {
@@ -80,6 +95,7 @@ function read(): AudioSettings {
return cached;
}
const parsed = JSON.parse(raw) as Partial<AudioSettings>;
const rawThreshold = typeof parsed.voiceThreshold === 'number' ? parsed.voiceThreshold : NaN;
cached = {
quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality,
inputDeviceId:
@@ -90,6 +106,18 @@ function read(): AudioSettings {
typeof parsed.outputDeviceId === 'string' && parsed.outputDeviceId.length > 0
? parsed.outputDeviceId
: DEFAULTS.outputDeviceId,
voiceThreshold:
Number.isFinite(rawThreshold) && rawThreshold >= 0.005 && rawThreshold <= 0.2
? rawThreshold
: DEFAULTS.voiceThreshold,
noiseSuppression:
typeof parsed.noiseSuppression === 'boolean'
? parsed.noiseSuppression
: DEFAULTS.noiseSuppression,
videoBackgroundBlur:
typeof parsed.videoBackgroundBlur === 'boolean'
? parsed.videoBackgroundBlur
: DEFAULTS.videoBackgroundBlur,
};
return cached;
} catch {
+41
View File
@@ -0,0 +1,41 @@
// Session-level avatar cache. Avatars are small (<100KB), referenced from
// many places (chat list, bubbles, dialogs), and rarely change — so keeping
// one blob-URL per remote URL avoids repeated decodes + network 304s across
// remounts. OPFS is optional; the in-memory map is enough for daily use.
const blobUrls = new Map<string, string>(); // remote URL → blob URL
const inflight = new Map<string, Promise<string | null>>();
async function fetchAsBlobUrl(url: string): Promise<string | null> {
try {
const res = await fetch(url, { cache: 'force-cache' });
if (!res.ok) return null;
const blob = await res.blob();
return URL.createObjectURL(blob);
} catch {
return null;
}
}
// Returns a cached blob URL for the avatar. Falls back to the original URL
// if caching fails so rendering never breaks.
export function useCachedAvatarUrl(url: string | null | undefined): string | null | undefined {
if (!url) return url;
return blobUrls.get(url) ?? url;
}
// Eagerly populate the cache for a list of URLs (e.g. conversation members)
// so subsequent renders hit the Map directly.
export function warmAvatarCache(urls: Array<string | null | undefined>): void {
for (const url of urls) {
if (!url) continue;
if (blobUrls.has(url)) continue;
if (inflight.has(url)) continue;
const p = fetchAsBlobUrl(url).then((blobUrl) => {
if (blobUrl) blobUrls.set(url, blobUrl);
inflight.delete(url);
return blobUrl;
});
inflight.set(url, p);
}
}
+128
View File
@@ -0,0 +1,128 @@
// Crash / uncaught-error recovery.
//
// The React <ErrorBoundary> already catches render errors. This module covers
// the OTHER half: async code outside React (realtime handlers, Promises
// that never hit a .catch, setTimeout callbacks, LiveKit event listeners,
// etc.). Those bubble to window.onerror / onunhandledrejection and would
// otherwise disappear silently in production.
//
// Behaviour:
// - First error: emit a toast + log.
// - Repeated identical errors within DEDUPE_MS: swallow (no spam).
// - More than BURST_LIMIT distinct errors within BURST_WINDOW_MS: force a
// full page reload. Something is systematically broken and the UI
// probably can't recover in-place.
export interface CrashEntry {
id: string;
message: string;
stack: string | null;
source: 'window' | 'promise';
at: number;
}
type Listener = (entry: CrashEntry) => void;
const DEDUPE_MS = 10_000;
const BURST_WINDOW_MS = 30_000;
const BURST_LIMIT = 8;
const listeners = new Set<Listener>();
const recent = new Map<string, number>(); // message hash → last seen
let burst: number[] = [];
let installed = false;
function hash(message: string): string {
let h = 0;
for (let i = 0; i < message.length; i++) {
h = ((h << 5) - h + message.charCodeAt(i)) | 0;
}
return h.toString(36);
}
function emit(entry: CrashEntry): void {
// Dedupe on the message hash so a handler that fires every animation
// frame doesn't drown the UI in toasts.
const key = hash(entry.message);
const now = entry.at;
const last = recent.get(key);
if (last && now - last < DEDUPE_MS) return;
recent.set(key, now);
// Purge stale dedupe entries to keep the map bounded.
for (const [k, ts] of recent) {
if (now - ts > DEDUPE_MS * 3) recent.delete(k);
}
// Burst detection: drop samples older than window, count what's left.
burst = burst.filter((t) => now - t < BURST_WINDOW_MS);
burst.push(now);
if (burst.length >= BURST_LIMIT) {
console.error('[crash-recovery] burst threshold reached — reloading');
// Let existing toast handlers see the final entry first, then reload.
// Delay lets the log flush + any pending realtime ack go through.
window.setTimeout(() => window.location.reload(), 500);
}
for (const fn of listeners) {
try {
fn(entry);
} catch (err) {
// Listener itself threw — log but don't recurse.
console.error('[crash-recovery] listener threw', err);
}
}
}
function toEntry(
err: unknown,
source: CrashEntry['source'],
): CrashEntry {
const message =
err instanceof Error
? err.message
: typeof err === 'string'
? err
: (() => {
try {
return JSON.stringify(err);
} catch {
return String(err);
}
})();
const stack = err instanceof Error ? err.stack ?? null : null;
return {
id: crypto.randomUUID(),
message: message || 'unknown error',
stack,
source,
at: Date.now(),
};
}
export function installCrashHandlers(): void {
if (installed) return;
installed = true;
window.addEventListener('error', (ev: ErrorEvent) => {
// Some events (ResizeObserver loop, benign extension injections) arrive
// as errors without a real message. Skip those to avoid spam.
if (!ev.message && !ev.error) return;
emit(toEntry(ev.error ?? ev.message, 'window'));
});
window.addEventListener('unhandledrejection', (ev: PromiseRejectionEvent) => {
emit(toEntry(ev.reason, 'promise'));
});
}
export function subscribeCrashes(fn: Listener): () => void {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
}
// Test / escape hatch — lets the reload threshold get checked in dev.
export function reportManualCrash(err: unknown): void {
emit(toEntry(err, 'window'));
}
+99
View File
@@ -0,0 +1,99 @@
// Main-thread wrapper around the decrypt Web Worker.
//
// Spawns a single worker lazily on first use. Uses request-id correlation so
// concurrent batches (e.g. a backfill arriving while a realtime insert
// dispatches) can't mix up their results. Falls back to inline decrypt if
// the Worker constructor isn't available (non-browser environments, some
// strict CSPs).
import sodium from 'libsodium-wrappers-sumo';
export interface DecryptItem {
id: string;
ciphertext: Uint8Array;
nonce: Uint8Array;
key: Uint8Array;
}
interface Pending {
resolve: (results: DecryptResult[]) => void;
reject: (err: unknown) => void;
}
interface DecryptResult {
id: string;
plaintext: string | null;
}
let worker: Worker | null = null;
let workerBroken = false;
const pending = new Map<string, Pending>();
function spawn(): Worker | null {
if (workerBroken) return null;
if (worker) return worker;
try {
worker = new Worker(new URL('../workers/decrypt.worker.ts', import.meta.url), {
type: 'module',
});
worker.addEventListener('message', (ev: MessageEvent) => {
const data = ev.data as { id?: string; results?: DecryptResult[] };
if (!data.id) return;
const entry = pending.get(data.id);
if (!entry) return;
pending.delete(data.id);
entry.resolve(data.results ?? []);
});
worker.addEventListener('error', (ev) => {
console.warn('decrypt worker error, falling back to inline', ev.message);
workerBroken = true;
worker?.terminate();
worker = null;
for (const entry of pending.values()) {
entry.reject(new Error('decrypt worker crashed'));
}
pending.clear();
});
return worker;
} catch (err: unknown) {
console.warn('decrypt worker spawn failed, falling back to inline', err);
workerBroken = true;
return null;
}
}
export async function decryptBatch(items: DecryptItem[]): Promise<DecryptResult[]> {
if (items.length === 0) return [];
const w = spawn();
if (!w) return inlineDecrypt(items);
return new Promise<DecryptResult[]>((resolve, reject) => {
const id = crypto.randomUUID();
pending.set(id, { resolve, reject });
try {
w.postMessage({ id, items });
} catch (err: unknown) {
pending.delete(id);
// postMessage can fail if the Uint8Array view is detached — retry inline.
console.warn('decrypt worker postMessage failed, using inline', err);
inlineDecrypt(items).then(resolve, reject);
}
});
}
// Fallback path — same semantics as the worker but on the main thread. Used
// when the worker failed to spawn or crashed mid-session.
async function inlineDecrypt(items: DecryptItem[]): Promise<DecryptResult[]> {
await sodium.ready;
return items.map((item) => {
try {
const plain = sodium.crypto_secretbox_open_easy(item.ciphertext, item.nonce, item.key);
return {
id: item.id,
plaintext: new TextDecoder('utf-8', { fatal: false }).decode(plain),
};
} catch {
return { id: item.id, plaintext: null };
}
});
}
+8 -8
View File
@@ -1,6 +1,8 @@
import { getCryptoBackend } from '@chat-app/shared/crypto';
import sodium from 'libsodium-wrappers-sumo';
import { pwhashArgon2id } from './nativeCryptoOps';
// Encrypts/decrypts the device private key with a user-provided passphrase
// so the backup string can be safely written down or stored in a password
// manager. Uses Argon2id (libsodium crypto_pwhash) for the KDF and
@@ -36,15 +38,13 @@ function unb64url(s: string): Uint8Array {
return out;
}
async function deriveKey(passphrase: string, salt: Uint8Array, sodiumLib: typeof sodium): Promise<Uint8Array> {
return sodiumLib.crypto_pwhash(
KEY_LEN,
passphrase,
async function deriveKey(passphrase: string, salt: Uint8Array, _sodiumLib: typeof sodium): Promise<Uint8Array> {
return pwhashArgon2id({
password: passphrase,
salt,
sodiumLib.crypto_pwhash_OPSLIMIT_MODERATE,
sodiumLib.crypto_pwhash_MEMLIMIT_MODERATE,
sodiumLib.crypto_pwhash_ALG_ARGON2ID13,
);
outLen: KEY_LEN,
preset: 'moderate',
});
}
export async function exportDeviceKey(
+69
View File
@@ -55,3 +55,72 @@ export async function unregisterPttShortcut(code: string): Promise<void> {
export function isTauriRuntime(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
}
// --- Soundboard shortcuts --------------------------------------------------
//
// Separate from the single PTT shortcut: the soundboard needs to register
// many fire-and-forget press bindings at once, keep track of which ids own
// which accelerators so we can unregister just one, and expose conflict
// detection for the settings UI.
interface SoundShortcutRegistration {
shortcut: string;
onPress: () => void;
}
// Map of logical id (sound uuid) -> registration.
const soundRegistry = new Map<string, SoundShortcutRegistration>();
export async function registerSoundShortcut(
id: string,
code: string,
onPress: () => void,
): Promise<boolean> {
if (!isTauriRuntime()) return false;
const shortcut = codeToShortcut(code);
// Unregister any previous binding for this id first — caller may be
// re-registering after the user changed the hotkey for the same sound.
await unregisterSoundShortcut(id);
try {
if (await isRegistered(shortcut)) {
await unregister(shortcut);
}
await register(shortcut, (event: ShortcutEvent) => {
if (event.state === 'Pressed') onPress();
});
soundRegistry.set(id, { shortcut, onPress });
return true;
} catch (err: unknown) {
console.warn('registerSoundShortcut failed', { id, code, err });
return false;
}
}
export async function unregisterSoundShortcut(id: string): Promise<void> {
const reg = soundRegistry.get(id);
if (!reg) return;
soundRegistry.delete(id);
if (!isTauriRuntime()) return;
try {
if (await isRegistered(reg.shortcut)) {
await unregister(reg.shortcut);
}
} catch (err: unknown) {
console.warn('unregisterSoundShortcut failed', { id, err });
}
}
export async function unregisterAllSoundShortcuts(): Promise<void> {
const ids = Array.from(soundRegistry.keys());
await Promise.all(ids.map((id) => unregisterSoundShortcut(id)));
}
// Resolve a DOM code to the registry's current owner (if any). Used by the
// settings UI to surface conflicts before saving a new hotkey.
export function soundShortcutOwnerFor(code: string): string | null {
const shortcut = codeToShortcut(code);
for (const [id, reg] of soundRegistry) {
if (reg.shortcut === shortcut) return id;
}
return null;
}
+57
View File
@@ -0,0 +1,57 @@
// Client-side image compression before upload. Downscales huge photos
// (phone cameras regularly emit 4000×3000+ at 5MB+) to a size that
// actually makes sense for chat display. Preserves aspect ratio.
//
// Skips animated formats (gif/apng/webp) to keep motion, and skips
// already-small files to avoid useless re-encode overhead.
const MAX_DIM = 2048;
const TARGET_QUALITY = 0.85;
const SKIP_BELOW_BYTES = 512 * 1024; // 512KB — not worth re-encoding
const ANIMATED_MIME = /^image\/(gif|apng|webp)$/;
export async function compressImage(file: File): Promise<File> {
if (!file.type.startsWith('image/')) return file;
if (ANIMATED_MIME.test(file.type)) return file;
if (file.size < SKIP_BELOW_BYTES) return file;
if (typeof createImageBitmap !== 'function') return file;
if (typeof OffscreenCanvas !== 'function') return file;
try {
const bitmap = await createImageBitmap(file);
const largest = Math.max(bitmap.width, bitmap.height);
const scale = largest > MAX_DIM ? MAX_DIM / largest : 1;
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 file;
}
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
const blob = await canvas.convertToBlob({
type: 'image/webp',
quality: TARGET_QUALITY,
});
// If the re-encoded blob is actually larger (small PNGs can expand as
// WebP), keep the original.
if (blob.size >= file.size) return file;
const name = renameToWebp(file.name);
return new File([blob], name, { type: 'image/webp', lastModified: file.lastModified });
} catch (err: unknown) {
console.warn('compressImage failed — keeping original', err);
return file;
}
}
function renameToWebp(original: string): string {
const dot = original.lastIndexOf('.');
const base = dot > 0 ? original.slice(0, dot) : original;
return base + '.webp';
}
export async function compressImages(files: File[]): Promise<File[]> {
return Promise.all(files.map((f) => compressImage(f)));
}
+268
View File
@@ -0,0 +1,268 @@
// Local SQLite message cache. Persists decrypted messages so startup +
// conversation-switch hydrate from disk instantly instead of waiting on a
// network round-trip + batch decrypt.
//
// The cache is per-device-local (app-local-data dir, same trust boundary as
// the device private key). Plaintext is stored because the threat model
// already assumes local-disk access means compromise — same as the existing
// outbox + secret store. Ciphertext + nonce are kept alongside so a future
// key-rotation migration can re-derive plaintext when a new bundle arrives.
import type { DecryptedMessage } from '@chat-app/shared/chat';
import { isTauriRuntime } from './globalShortcut';
interface Database {
execute: (sql: string, values?: unknown[]) => Promise<{ rowsAffected?: number }>;
select: <T>(sql: string, values?: unknown[]) => Promise<T[]>;
close: () => Promise<boolean>;
}
interface DatabaseStatic {
load: (path: string) => Promise<Database>;
}
const DB_NAME = 'chatapp-cache.db';
let dbPromise: Promise<Database | null> | null = null;
async function loadDb(): Promise<Database | null> {
if (!isTauriRuntime()) return null;
try {
// Dynamic import so browser-preview builds don't choke on the tauri
// plugin module. Vite will statically analyse this + split it into a
// chunk that only loads inside Tauri.
const mod = (await import('@tauri-apps/plugin-sql')) as {
default: DatabaseStatic;
Database?: DatabaseStatic;
};
const DatabaseCtor: DatabaseStatic = mod.default ?? (mod as { Database: DatabaseStatic }).Database;
const db = await DatabaseCtor.load('sqlite:' + DB_NAME);
await db.execute(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
sender_id TEXT NOT NULL,
sender_device_id TEXT,
reply_to_id TEXT,
edited_at TEXT,
deleted_at TEXT,
created_at TEXT NOT NULL,
plaintext TEXT
);
`);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_msgs_conv_created ON messages(conversation_id, created_at);',
);
// FTS5 virtual table for instant full-text search across the cache.
// Keeps only the searchable text columns (plaintext + sender), keyed
// by the message id so we can join back to the main row. Triggers
// mirror inserts/updates/deletes so the index never drifts.
//
// Falls back gracefully on FTS5-less builds: the IF NOT EXISTS keeps
// the call idempotent, and the surrounding try/catch already handles
// a CREATE failure by skipping the whole cache.
try {
await db.execute(
`CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
message_id UNINDEXED,
conversation_id UNINDEXED,
plaintext,
tokenize = 'unicode61 remove_diacritics 2'
);`,
);
await db.execute(
`CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts (message_id, conversation_id, plaintext)
VALUES (new.id, new.conversation_id, COALESCE(new.plaintext, ''));
END;`,
);
await db.execute(
`CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
DELETE FROM messages_fts WHERE message_id = old.id;
END;`,
);
await db.execute(
`CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
DELETE FROM messages_fts WHERE message_id = old.id;
INSERT INTO messages_fts (message_id, conversation_id, plaintext)
VALUES (new.id, new.conversation_id, COALESCE(new.plaintext, ''));
END;`,
);
} catch (err: unknown) {
console.warn('FTS5 init failed — search falls back to in-memory scan', err);
}
return db;
} catch (err: unknown) {
console.warn('messageCache init failed — falling back to memory-only', err);
return null;
}
}
function getDb(): Promise<Database | null> {
if (!dbPromise) dbPromise = loadDb();
return dbPromise;
}
// ---------------------------------------------------------------------------
// Public API — all calls are no-ops when the cache isn't available (browser
// preview, init error, etc.), so callers never need to guard.
// ---------------------------------------------------------------------------
interface Row {
id: string;
conversation_id: string;
sender_id: string;
sender_device_id: string | null;
reply_to_id: string | null;
edited_at: string | null;
deleted_at: string | null;
created_at: string;
plaintext: string | null;
}
export async function loadCachedMessages(
conversationId: string,
limit = 500,
): Promise<DecryptedMessage[]> {
const db = await getDb();
if (!db) return [];
try {
const rows = await db.select<Row>(
'SELECT * FROM messages WHERE conversation_id = $1 ORDER BY created_at DESC LIMIT $2',
[conversationId, limit],
);
return rows
.reverse()
.map((r) => ({
id: r.id,
conversationId: r.conversation_id,
senderId: r.sender_id,
senderDeviceId: r.sender_device_id,
replyToId: r.reply_to_id,
editedAt: r.edited_at,
deletedAt: r.deleted_at,
createdAt: r.created_at,
plaintext: r.plaintext,
}));
} catch (err: unknown) {
console.warn('loadCachedMessages failed', err);
return [];
}
}
export async function persistMessages(
conversationId: string,
messages: DecryptedMessage[],
): Promise<void> {
const db = await getDb();
if (!db || messages.length === 0) return;
try {
// Replace strategy: each message is keyed by id so upsert is "INSERT OR
// REPLACE". Attachments + forward chains are part of `plaintext` JSON so
// nothing lives outside this table.
for (const m of messages) {
await db.execute(
`INSERT OR REPLACE INTO messages
(id, conversation_id, sender_id, sender_device_id, reply_to_id,
edited_at, deleted_at, created_at, plaintext)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
m.id,
conversationId,
m.senderId,
m.senderDeviceId,
m.replyToId,
m.editedAt,
m.deletedAt,
m.createdAt,
m.plaintext,
],
);
}
} catch (err: unknown) {
console.warn('persistMessages failed', err);
}
}
export async function deleteCachedMessage(id: string): Promise<void> {
const db = await getDb();
if (!db) return;
try {
await db.execute('DELETE FROM messages WHERE id = $1', [id]);
} catch (err: unknown) {
console.warn('deleteCachedMessage failed', err);
}
}
// Full-text search across cached messages. Returns rows in newest-first
// order; caller maps to DecryptedMessage. Empty result on cache-miss or
// FTS5 not available — caller should fall back to in-memory regex.
export async function searchCachedMessages(
conversationId: string,
query: string,
limit = 200,
): Promise<DecryptedMessage[]> {
const db = await getDb();
if (!db) return [];
const trimmed = query.trim();
if (trimmed.length === 0) return [];
// Sanitize for FTS5 MATCH syntax. Strip quotes + control chars; wrap in
// an OR over each token with a trailing wildcard so partial words match.
// Hyphens + colons are FTS5 operators so we drop them.
const tokens = trimmed
.replace(/["'\u0000-\u001f]/g, ' ')
.replace(/[-:^]/g, ' ')
.split(/\s+/)
.filter(Boolean);
if (tokens.length === 0) return [];
const matchExpr = tokens.map((t) => '"' + t.replace(/"/g, '') + '"*').join(' AND ');
try {
const rows = await db.select<Row>(
`SELECT m.* FROM messages m
JOIN messages_fts f ON f.message_id = m.id
WHERE f.conversation_id = $1 AND messages_fts MATCH $2
ORDER BY m.created_at DESC
LIMIT $3`,
[conversationId, matchExpr, limit],
);
return rows.map((r) => ({
id: r.id,
conversationId: r.conversation_id,
senderId: r.sender_id,
senderDeviceId: r.sender_device_id,
replyToId: r.reply_to_id,
editedAt: r.edited_at,
deletedAt: r.deleted_at,
createdAt: r.created_at,
plaintext: r.plaintext,
}));
} catch (err: unknown) {
console.warn('searchCachedMessages failed', err);
return [];
}
}
// Housekeeping — run once per session to bound the cache size. Keeps the
// latest KEEP_PER_CONV messages per conversation.
const KEEP_PER_CONV = 1000;
export async function pruneCache(): Promise<void> {
const db = await getDb();
if (!db) return;
try {
await db.execute(
`DELETE FROM messages WHERE id IN (
SELECT id FROM messages m
WHERE (
SELECT COUNT(*) FROM messages m2
WHERE m2.conversation_id = m.conversation_id
AND m2.created_at > m.created_at
) >= $1
)`,
[KEEP_PER_CONV],
);
} catch (err: unknown) {
console.warn('pruneCache failed', err);
}
}
+275
View File
@@ -0,0 +1,275 @@
// Shared Web Audio graph that sits between the raw microphone MediaStream
// and LiveKit's published track. Mixes live mic with on-demand soundboard
// buffers so both paths reach the peer through a single published track,
// and lets us locally monitor soundboard output without feedback from the
// mic path.
//
// Graph:
// rawMicSource ──► micGain ──┐
// ├─► destinationNode ─► publishedTrack
// sbBufferSources ─► sbGain ─┤
// └─► monitorGain ─► ctx.destination (local hear,
// soundboard only)
//
// Lifetime:
// createMicPipeline(rawTrack) — builds graph + AudioContext
// pipeline.outputTrack — pass to `localParticipant.publishTrack`
// pipeline.setMicGain(0..1) — mute / PTT
// pipeline.setSoundboardGain(..) / setMonitorGain(..) — sb master + local hear
// pipeline.playBuffer(buffer, opts) — returns a handle so callers can stop
// pipeline.stopAll(buffers?) — kill every active sb source (or only one id)
// pipeline.replaceMicTrack(newTrack) — hot-swap on device change
// pipeline.destroy() — close ctx, stop owned tracks
export interface PlayBufferOpts {
/** Per-source gain 0..1, multiplied by sb master. */
gain?: number;
/** Stable id — calling playBuffer with the same id stops the previous one
* first (single-fire mode). Omit for overlap mode. */
id?: string;
/** Fired when the buffer ends naturally (not when stopped manually). */
onEnded?: () => void;
}
export interface PlayHandle {
id: string | null;
stop(): void;
}
export interface MicPipeline {
readonly outputTrack: MediaStreamTrack;
setMicGain(value: number): void;
setSoundboardGain(value: number): void;
setMonitorGain(value: number): void;
replaceMicTrack(newTrack: MediaStreamTrack): void;
playBuffer(buffer: AudioBuffer, opts?: PlayBufferOpts): PlayHandle;
stopAll(id?: string): void;
destroy(): void;
}
interface ActiveSource {
id: string | null;
node: AudioBufferSourceNode;
gain: GainNode;
}
// Clamp helper — avoid letting callers pass NaN or out-of-range values.
function clamp01(v: number): number {
if (!Number.isFinite(v)) return 0;
if (v < 0) return 0;
if (v > 1) return 1;
return v;
}
export function createMicPipeline(rawTrack: MediaStreamTrack): MicPipeline {
const AudioCtx =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioCtx) {
throw new Error('AudioContext unavailable');
}
const ctx = new AudioCtx();
const micGain = ctx.createGain();
micGain.gain.value = 1;
const sbGain = ctx.createGain();
sbGain.gain.value = 1;
const monitorGain = ctx.createGain();
monitorGain.gain.value = 1;
const dest = ctx.createMediaStreamDestination();
// Mic path → published only.
micGain.connect(dest);
// Soundboard path → published + local monitor.
sbGain.connect(dest);
sbGain.connect(monitorGain);
monitorGain.connect(ctx.destination);
let currentRawTrack: MediaStreamTrack = rawTrack;
let micSource: MediaStreamAudioSourceNode = buildMicSource(ctx, rawTrack, micGain);
const active = new Set<ActiveSource>();
let destroyed = false;
function buildMicSource(
c: AudioContext,
t: MediaStreamTrack,
target: AudioNode,
): MediaStreamAudioSourceNode {
const stream = new MediaStream([t]);
const node = c.createMediaStreamSource(stream);
node.connect(target);
return node;
}
const outputTrack = dest.stream.getAudioTracks()[0];
if (!outputTrack) {
throw new Error('MediaStreamAudioDestinationNode produced no audio track');
}
return {
outputTrack,
setMicGain(value: number) {
if (destroyed) return;
const v = clamp01(value);
micGain.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
},
setSoundboardGain(value: number) {
if (destroyed) return;
const v = clamp01(value);
sbGain.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
},
setMonitorGain(value: number) {
if (destroyed) return;
const v = clamp01(value);
monitorGain.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
},
replaceMicTrack(newTrack: MediaStreamTrack) {
if (destroyed) return;
// Tear down the old MediaStreamSourceNode and stop the raw track we
// owned. Caller passes ownership of `newTrack` to the pipeline.
try {
micSource.disconnect();
} catch {
/* ignore */
}
if (currentRawTrack !== newTrack) {
try {
currentRawTrack.stop();
} catch {
/* ignore */
}
}
currentRawTrack = newTrack;
micSource = buildMicSource(ctx, newTrack, micGain);
},
playBuffer(buffer: AudioBuffer, opts: PlayBufferOpts = {}): PlayHandle {
if (destroyed) {
return { id: opts.id ?? null, stop: () => undefined };
}
// Single-fire: stop previous instance of the same id so holding a
// hotkey doesn't stack a dozen overlapping plays.
if (opts.id) {
for (const entry of active) {
if (entry.id === opts.id) stopActive(entry);
}
}
const node = ctx.createBufferSource();
node.buffer = buffer;
const g = ctx.createGain();
g.gain.value = clamp01(opts.gain ?? 1);
node.connect(g);
g.connect(sbGain);
const entry: ActiveSource = { id: opts.id ?? null, node, gain: g };
active.add(entry);
node.onended = () => {
if (!active.has(entry)) return;
try {
node.disconnect();
} catch {
/* ignore */
}
try {
g.disconnect();
} catch {
/* ignore */
}
active.delete(entry);
opts.onEnded?.();
};
try {
node.start();
} catch {
active.delete(entry);
}
return {
id: entry.id,
stop: () => stopActive(entry),
};
},
stopAll(id?: string) {
for (const entry of Array.from(active)) {
if (id !== undefined && entry.id !== id) continue;
stopActive(entry);
}
},
destroy() {
if (destroyed) return;
destroyed = true;
for (const entry of Array.from(active)) stopActive(entry);
try {
micSource.disconnect();
} catch {
/* ignore */
}
try {
micGain.disconnect();
} catch {
/* ignore */
}
try {
sbGain.disconnect();
} catch {
/* ignore */
}
try {
monitorGain.disconnect();
} catch {
/* ignore */
}
try {
currentRawTrack.stop();
} catch {
/* ignore */
}
try {
outputTrack.stop();
} catch {
/* ignore */
}
void ctx.close().catch(() => {
/* ignore */
});
},
};
function stopActive(entry: ActiveSource): void {
try {
entry.node.onended = null;
entry.node.stop();
} catch {
/* already ended */
}
try {
entry.node.disconnect();
} catch {
/* ignore */
}
try {
entry.gain.disconnect();
} catch {
/* ignore */
}
active.delete(entry);
}
}
+149
View File
@@ -0,0 +1,149 @@
// Native accelerators for the crypto ops whose JS/WASM runtime is slow
// enough to matter. The sync `CryptoBackend` interface stays on WASM because
// migrating every call-site to async would break most of the shared crypto
// code for no runtime win — per-message AEAD already runs in a worker and
// its 15µs-vs-80µs delta is lost in IPC overhead (~40µs round-trip).
//
// The two ops we actually accelerate:
// * Argon2id password hashing (vault unlock, backup import/export)
// * Randomness (fast enough inline, but we expose it so future Rust-only
// flows don't need to route through the WASM backend).
//
// All fall back to WASM when the Tauri runtime isn't present (browser
// preview, dev server) so the same code paths keep working.
import { invoke } from '@tauri-apps/api/core';
import _sodium from 'libsodium-wrappers-sumo';
import { isTauriRuntime } from './globalShortcut';
function bytesToB64(bytes: Uint8Array): string {
let s = '';
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
}
function b64ToBytes(s: string): Uint8Array {
const bin = atob(s);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
// Environment flag. Default ON in Tauri, OFF in browser-preview. Can be
// force-disabled by setting VITE_USE_NATIVE_CRYPTO=false for debugging
// a regression against the WASM baseline.
const flagEnabled = (() => {
const raw = (import.meta as unknown as { env?: { VITE_USE_NATIVE_CRYPTO?: string } })
.env?.VITE_USE_NATIVE_CRYPTO;
if (raw === 'false' || raw === '0') return false;
return true;
})();
function nativeAvailable(): boolean {
return flagEnabled && isTauriRuntime();
}
// ---------------------------------------------------------------------------
// Argon2id password hashing
// ---------------------------------------------------------------------------
export interface PwhashOpts {
password: string;
salt: Uint8Array;
outLen: number;
preset?: 'interactive' | 'moderate' | 'sensitive';
}
export async function pwhashArgon2id(opts: PwhashOpts): Promise<Uint8Array> {
if (nativeAvailable()) {
try {
const b64 = await invoke<string>('crypto_pwhash', {
args: {
password: opts.password,
saltB64: bytesToB64(opts.salt),
outLen: opts.outLen,
preset: opts.preset ?? 'moderate',
},
});
return b64ToBytes(b64);
} catch (err: unknown) {
// Tauri command not registered (e.g. older build) or unexpected failure.
// Fall through to WASM so the app stays functional.
console.warn('[nativeCryptoOps] pwhash native failed, falling back', err);
}
}
await _sodium.ready;
const s = _sodium;
const presetPair = (() => {
switch (opts.preset ?? 'moderate') {
case 'interactive':
return [s.crypto_pwhash_OPSLIMIT_INTERACTIVE, s.crypto_pwhash_MEMLIMIT_INTERACTIVE];
case 'sensitive':
return [s.crypto_pwhash_OPSLIMIT_SENSITIVE, s.crypto_pwhash_MEMLIMIT_SENSITIVE];
default:
return [s.crypto_pwhash_OPSLIMIT_MODERATE, s.crypto_pwhash_MEMLIMIT_MODERATE];
}
})();
return s.crypto_pwhash(
opts.outLen,
opts.password,
opts.salt,
presetPair[0]!,
presetPair[1]!,
s.crypto_pwhash_ALG_ARGON2ID13,
);
}
// ---------------------------------------------------------------------------
// Randombytes — native-first, WASM fallback. Used for non-hot-path nonces
// and tokens; inline callers that need sync random should stay on the
// backend interface.
// ---------------------------------------------------------------------------
export async function randomBytesAsync(len: number): Promise<Uint8Array> {
if (nativeAvailable()) {
try {
const b64 = await invoke<string>('crypto_random_bytes', { len });
return b64ToBytes(b64);
} catch (err: unknown) {
console.warn('[nativeCryptoOps] random native failed, falling back', err);
}
}
await _sodium.ready;
return _sodium.randombytes_buf(len);
}
// ---------------------------------------------------------------------------
// Secretbox + box accelerators — exposed but not auto-adopted. Call-sites
// can migrate individually if they measure a win. Round-trip IPC is ~40µs
// so per-op wins under 100µs usually lose to the WASM path.
// ---------------------------------------------------------------------------
export async function secretboxEncryptNative(
plaintext: Uint8Array,
nonce: Uint8Array,
key: Uint8Array,
): Promise<Uint8Array> {
if (!nativeAvailable()) throw new Error('native crypto unavailable');
const b64 = await invoke<string>('crypto_secretbox_encrypt', {
plaintextB64: bytesToB64(plaintext),
nonceB64: bytesToB64(nonce),
keyB64: bytesToB64(key),
});
return b64ToBytes(b64);
}
export async function secretboxDecryptNative(
ciphertext: Uint8Array,
nonce: Uint8Array,
key: Uint8Array,
): Promise<Uint8Array> {
if (!nativeAvailable()) throw new Error('native crypto unavailable');
const b64 = await invoke<string>('crypto_secretbox_decrypt', {
ciphertextB64: bytesToB64(ciphertext),
nonceB64: bytesToB64(nonce),
keyB64: bytesToB64(key),
});
return b64ToBytes(b64);
}
+132
View File
@@ -0,0 +1,132 @@
// Thin JS wrapper around the Rust LiveKit bridge commands + events.
// Mirrors the subset of `livekit-client` that CallContext actually uses
// so the adapter can be swapped behind the `VITE_USE_RUST_LIVEKIT` flag.
//
// Phase B.1 — only connect/disconnect/data-channel/state events wired.
// Mic, camera, screen-share, active-speakers, video rendering ship in
// later phases. Components that call missing methods get a typed
// "not implemented" error so regressions surface immediately.
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { isTauriRuntime } from './globalShortcut';
export const rustLivekitFlag = (() => {
const raw = (import.meta as unknown as { env?: { VITE_USE_RUST_LIVEKIT?: string } })
.env?.VITE_USE_RUST_LIVEKIT;
return raw === 'true' || raw === '1';
})();
export function isRustLivekitAvailable(): boolean {
return rustLivekitFlag && isTauriRuntime();
}
export type NativeRoomState =
| { state: 'connecting' }
| { state: 'connected' }
| { state: 'disconnected' };
export interface NativeParticipantEvent {
identity: string;
name?: string | undefined;
}
export interface NativeDataEvent {
identity: string;
payloadB64: string;
reliable: boolean;
}
type Listener<T> = (payload: T) => void;
export class NativeRoom {
private unlistens: UnlistenFn[] = [];
private stateListeners = new Set<Listener<NativeRoomState>>();
private joinListeners = new Set<Listener<NativeParticipantEvent>>();
private leaveListeners = new Set<Listener<NativeParticipantEvent>>();
private dataListeners = new Set<Listener<NativeDataEvent>>();
async connect(url: string, token: string): Promise<void> {
if (!isRustLivekitAvailable()) {
throw new Error('Rust LiveKit backend not available');
}
await this.subscribeEvents();
try {
await invoke('livekit_connect', { args: { url, token } });
} catch (err: unknown) {
await this.teardown();
throw err;
}
}
async disconnect(): Promise<void> {
try {
await invoke('livekit_disconnect');
} finally {
await this.teardown();
}
}
async sendData(payload: Uint8Array, reliable: boolean): Promise<void> {
await invoke('livekit_send_data', {
payloadB64: bytesToB64(payload),
reliable,
});
}
onRoomState(fn: Listener<NativeRoomState>): () => void {
this.stateListeners.add(fn);
return () => this.stateListeners.delete(fn);
}
onParticipantJoined(fn: Listener<NativeParticipantEvent>): () => void {
this.joinListeners.add(fn);
return () => this.joinListeners.delete(fn);
}
onParticipantLeft(fn: Listener<NativeParticipantEvent>): () => void {
this.leaveListeners.add(fn);
return () => this.leaveListeners.delete(fn);
}
onDataReceived(fn: Listener<NativeDataEvent>): () => void {
this.dataListeners.add(fn);
return () => this.dataListeners.delete(fn);
}
private async subscribeEvents(): Promise<void> {
// Room state — connected / disconnected.
const uState = await listen<NativeRoomState>('livekit:room_state', (evt) => {
for (const fn of this.stateListeners) fn(evt.payload);
});
const uJoined = await listen<NativeParticipantEvent>('livekit:participant_joined', (evt) => {
for (const fn of this.joinListeners) fn(evt.payload);
});
const uLeft = await listen<NativeParticipantEvent>('livekit:participant_left', (evt) => {
for (const fn of this.leaveListeners) fn(evt.payload);
});
const uData = await listen<NativeDataEvent>('livekit:data_received', (evt) => {
for (const fn of this.dataListeners) fn(evt.payload);
});
this.unlistens.push(uState, uJoined, uLeft, uData);
}
private async teardown(): Promise<void> {
for (const unlisten of this.unlistens) {
try {
unlisten();
} catch {
/* unlistens become no-op after first call */
}
}
this.unlistens = [];
this.stateListeners.clear();
this.joinListeners.clear();
this.leaveListeners.clear();
this.dataListeners.clear();
}
}
function bytesToB64(bytes: Uint8Array): string {
let s = '';
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
}

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