Compare commits

...

20 Commits

Author SHA1 Message Date
byGalax 6301ebb392 chore(desktop): release v0.10.2 2026-04-22 19:24:14 +02:00
byGalax 31d21dd2c2 fix(release): tauri v2 ships .exe + .exe.sig, not .nsis.zip
v1 used to wrap the installer in a .nsis.zip and sign that wrapper.
v2 signs the .exe directly, so the updater url points at the .exe and
the .sig file sits next to it. Script was still looking for the
legacy .nsis.zip path and aborting after a successful build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:22:17 +02:00
byGalax 9add0a4d61 fix(release): call tauri binary directly, not via desktop build script
Node execSync runs via cmd.exe on Windows, which preserves the `--`
separator pnpm injects between the script name and forwarded args.
Tauri CLI then forwards that `--` to cargo, which rejects `--bundles`
with "unexpected argument". Invoking `pnpm exec tauri build --bundles nsis`
skips the script indirection so no `--` is emitted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:19:42 +02:00
byGalax 500f1c4bc2 chore(release): self-hosted updater on update.netralax.cloud
Switches the Tauri updater endpoint from GitHub Releases to a static
host. New Ed25519 pubkey (old private key was lost); existing 0.10.x
installs need one manual reinstall to pick up the new updater identity.

Release flow is now pnpm release <version> <notes> which bumps,
builds + signs locally, scps artifacts to the server, commits, tags.
GitHub workflow stays as workflow_dispatch backup (Windows only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:17:58 +02:00
byGalax a38e2f96c0 feat(desktop): raise ringtone cap to 8 MB
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:17:48 +02:00
byGalax 5aa39b40ff feat(desktop): Windows taskbar overlay icon for unread badge
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Red-dot overlay drawn as raw RGBA (no extra resource bundled).
Shown on Windows when unread count > 0, cleared when 0.
macOS keeps numeric Dock badge; Linux has no cross-DE badge API.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Bump tauri version 0.7.0 -> 0.7.1
2026-04-20 22:15:49 +02:00
164 changed files with 14667 additions and 1047 deletions
+21
View File
@@ -0,0 +1,21 @@
# Copy to .env.release (gitignored) and fill in.
# Consumed by scripts/release.mjs.
# Absolute path to the private key file produced by `tauri signer generate`.
TAURI_SIGNING_PRIVATE_KEY_PATH=C:/Users/denni/.tauri/chatapp.key
# Password set when generating the key. Leave empty if none.
TAURI_SIGNING_PRIVATE_KEY_PASSWORD=
# Host serving latest.json + installer artifacts over HTTPS.
UPDATE_HOST=update.netralax.cloud
# SSH user on UPDATE_HOST with write access to UPDATE_REMOTE_PATH.
UPDATE_SSH_USER=chatapp-deploy
# Optional: path to the SSH private key. Omit to fall back on ssh-agent or the
# default id_rsa.
UPDATE_SSH_KEY=
# Absolute path on the server where windows/ artifacts + latest.json live.
UPDATE_REMOTE_PATH=/var/www/updates/windows
+16 -28
View File
@@ -1,35 +1,27 @@
name: Release desktop app
name: Release desktop app (manual backup)
# Tag a version to trigger a release:
# git tag v0.1.0 && git push --tags
#
# Produces signed Tauri bundles for macOS (arm + intel), Windows, and Linux,
# uploads them to a GitHub Release, and publishes `latest.json` for the
# updater plugin to discover.
# Self-hosted updates run from scripts/release.mjs on Dennis's Windows box.
# This workflow is kept as a manual backup — trigger it from the Actions tab
# if the local build host is unavailable.
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Tag to build (e.g. v0.10.2) — must already exist"
required: true
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: macos-14 # universal binary covers both Intel + Apple Silicon
args: "--target universal-apple-darwin --bundles app,updater"
- platform: windows-latest
args: ""
runs-on: ${{ matrix.platform }}
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -42,8 +34,6 @@ jobs:
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Install JS deps
run: pnpm install --frozen-lockfile
@@ -54,18 +44,16 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Client-side env vars baked into the bundle — paste your prod values
# into the repo's Actions → Secrets so releases point at prod.
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
VITE_AUTH_REDIRECT_URL: ${{ secrets.VITE_AUTH_REDIRECT_URL }}
VITE_LIVEKIT_URL: ${{ secrets.VITE_LIVEKIT_URL }}
with:
projectPath: apps/desktop
tagName: ${{ github.ref_name }}
releaseName: "ChatApp ${{ github.ref_name }}"
releaseBody: "See the assets below to download this version."
tagName: ${{ inputs.tag }}
releaseName: "ChatApp ${{ inputs.tag }}"
releaseBody: "Manual build — copy .nsis.zip/.sig/latest.json to the update host."
releaseDraft: true
prerelease: false
tauriScript: pnpm exec tauri
args: ${{ matrix.args }}
args: "--bundles nsis"
+2
View File
@@ -15,7 +15,9 @@ out/
.env
.env.local
.env.*.local
.env.release
!.env.example
!.env.release.example
# Expo
.expo/
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chat-app/desktop",
"version": "0.1.0",
"version": "0.10.2",
"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

+67
View File
@@ -0,0 +1,67 @@
// Web Push service worker.
//
// Handles browser-delivered push events when the app tab is closed or in the
// background. Tauri desktop does not install service workers; native OS
// notifications are routed through the Tauri notification plugin instead
// (see src/lib/osNotify.ts).
//
// Payload contract — server sends JSON of shape:
// { title: string, body?: string, conversationId?: string, kind?: 'message' | 'call' }
// Body is intentionally generic; message ciphertext is never included.
self.addEventListener('install', (event) => {
// Activate immediately so updates apply on next page load.
event.waitUntil(self.skipWaiting());
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('push', (event) => {
let data = { title: 'Neue Nachricht', body: '' };
try {
if (event.data) {
data = { ...data, ...event.data.json() };
}
} catch (_err) {
/* malformed payload — fall back to defaults */
}
const opts = {
body: data.body || '',
icon: '/favicon.svg',
badge: '/favicon.svg',
tag: data.conversationId || 'default',
renotify: true,
data: {
conversationId: data.conversationId,
kind: data.kind,
},
};
event.waitUntil(self.registration.showNotification(data.title, opts));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const conversationId = event.notification.data && event.notification.data.conversationId;
const target = conversationId ? '/chats/' + conversationId : '/';
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
for (const client of clientList) {
if ('focus' in client) {
client.postMessage({ type: 'navigate', to: target });
return client.focus();
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(target);
}
return undefined;
}),
);
});
+1338 -110
View File
File diff suppressed because it is too large Load Diff
+34 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "chat-app-desktop"
version = "0.1.0"
version = "0.10.2"
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
}
+5 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ChatApp",
"version": "0.7.0",
"version": "0.10.2",
"identifier": "com.meinname.chatapp",
"build": {
"beforeDevCommand": "pnpm vite:dev",
@@ -42,8 +42,10 @@
},
"plugins": {
"updater": {
"endpoints": ["https://github.com/byGalax/chat-app/releases/latest/download/latest.json"],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ5N0U0RDcxOTU2OEQ0QUUKUldTdTFHaVZjVTErU1ZuMk1lWXBUbEcyS1RHYzJQN3k4VDdiUGRvRnVJYVJKR3BxWG1xcENpdlYK",
"endpoints": [
"https://update.netralax.cloud/windows/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEI1Mzc0QjVBQUZEQTA3RUIKUldUckI5cXZXa3MzdGM3QkE4WWFPd3NnVzRZeXdpcUM0eUtjRDlGN09ySEdzNXhLNlo3azBPajYK",
"windows": {
"installMode": "passive"
}
+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>
@@ -0,0 +1,251 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useMemo, useRef, useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
import { supabase } from '../lib/supabase';
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
const BAR_COUNT = 48;
// Custom voice-message player with waveform visualisation. Decoded peaks are
// computed once per blob via OfflineAudioContext so playback only carries the
// rendered DOM. Falls back to a rectangular bar if decoding fails (e.g. the
// blob mime is recognised by <audio> but not by AudioContext).
export function AttachmentAudio({ handle }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [arrayBuf, setArrayBuf] = useState<ArrayBuffer | null>(null);
const [error, setError] = useState<string | null>(null);
const [peaks, setPeaks] = useState<number[] | null>(null);
const [duration, setDuration] = useState<number>(0);
const [position, setPosition] = useState<number>(0);
const [playing, setPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
let cancelled = false;
let url: string | null = null;
setError(null);
setBlobUrl(null);
setArrayBuf(null);
void (async () => {
const cached = await getCachedAttachment(handle.id);
if (cached) {
if (cancelled) return;
url = URL.createObjectURL(cached);
setBlobUrl(url);
const buf = await cached.arrayBuffer();
if (!cancelled) setArrayBuf(buf);
return;
}
try {
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
if (cancelled) return;
url = URL.createObjectURL(blob);
setBlobUrl(url);
const buf = await blob.arrayBuffer();
if (!cancelled) setArrayBuf(buf);
void putCachedAttachment(handle.id, blob);
} catch (err: unknown) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
}
}
})();
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
// Compute peaks via OfflineAudioContext. Cheap O(n) scan over PCM samples
// bucketed into BAR_COUNT bars. Done once per attachment.
useEffect(() => {
if (!arrayBuf) return;
let cancelled = false;
const Ctx =
window.AudioContext ||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
const ctx = new Ctx();
ctx
.decodeAudioData(arrayBuf.slice(0))
.then((decoded) => {
if (cancelled) return;
setDuration(decoded.duration);
const channel = decoded.getChannelData(0);
const bucket = Math.max(1, Math.floor(channel.length / BAR_COUNT));
const out = new Array<number>(BAR_COUNT).fill(0);
for (let i = 0; i < BAR_COUNT; i++) {
let max = 0;
const start = i * bucket;
const end = Math.min(channel.length, start + bucket);
for (let j = start; j < end; j++) {
const v = Math.abs(channel[j]!);
if (v > max) max = v;
}
out[i] = max;
}
// Normalize so loudest peak is 1; keeps quiet recordings visible.
const peak = Math.max(...out, 0.001);
setPeaks(out.map((v) => v / peak));
})
.catch(() => {
// Fall through — UI shows a flat bar but playback still works.
})
.finally(() => {
void ctx.close().catch(() => {});
});
return () => {
cancelled = true;
};
}, [arrayBuf]);
const fallbackPeaks = useMemo(
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
[peaks],
);
const visiblePeaks = peaks ?? fallbackPeaks!;
const progress = duration > 0 ? position / duration : 0;
const onTogglePlay = () => {
const el = audioRef.current;
if (!el || !blobUrl) return;
if (el.paused) void el.play();
else el.pause();
};
const onSeek = (e: React.MouseEvent<HTMLDivElement>) => {
const el = audioRef.current;
if (!el || duration === 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
el.currentTime = ratio * duration;
};
if (error) {
return (
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
<AlertIcon className="h-4 w-4" />
<span>{error}</span>
</div>
);
}
return (
<div className="mt-2 flex w-[280px] min-w-[280px] items-center gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2">
<button
type="button"
onClick={onTogglePlay}
disabled={!blobUrl}
aria-label={playing ? 'Pause' : 'Wiedergabe'}
title={playing ? 'Pause' : 'Wiedergabe'}
className="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
>
{!blobUrl ? (
<SpinnerIcon className="h-4 w-4" />
) : playing ? (
<PauseGlyph />
) : (
<PlayGlyph />
)}
</button>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div
role="slider"
aria-label="Position"
aria-valuemin={0}
aria-valuemax={Math.max(1, Math.floor(duration))}
aria-valuenow={Math.floor(position)}
tabIndex={0}
onClick={onSeek}
className="flex h-7 cursor-pointer items-center gap-[2px]"
>
{visiblePeaks.map((v, i) => {
const playedRatio = (i + 0.5) / BAR_COUNT;
const played = playedRatio <= progress;
const h = Math.max(2, Math.round(v * 22));
return (
<span
key={i}
style={{ height: h + 'px' }}
className={
'w-[3px] rounded-full ' +
(played ? 'bg-accent' : 'bg-fg-muted/40')
}
/>
);
})}
</div>
<div className="flex items-center justify-between text-[10px] tabular-nums text-fg-muted">
<span className="inline-flex items-center gap-1">
<MicIcon className="h-3 w-3" />
<span>{formatSec(playing ? position : duration)}</span>
</span>
</div>
</div>
{blobUrl && (
<audio
ref={audioRef}
src={blobUrl}
preload="metadata"
onLoadedMetadata={(e) => {
// Some webm/opus blobs report Infinity until first seek (Chrome
// bug). Force a seek to flush real duration.
const el = e.currentTarget;
if (!Number.isFinite(el.duration)) {
el.currentTime = 1e9;
setTimeout(() => {
el.currentTime = 0;
}, 0);
} else if (duration === 0) {
setDuration(el.duration);
}
}}
onDurationChange={(e) => {
const d = e.currentTarget.duration;
if (Number.isFinite(d) && d > 0) setDuration(d);
}}
onTimeUpdate={(e) => setPosition(e.currentTarget.currentTime)}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => {
setPlaying(false);
setPosition(0);
}}
className="hidden"
>
<track kind="captions" />
</audio>
)}
</div>
);
}
function PlayGlyph() {
return (
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
<path d="M5 3.5l8 4.5-8 4.5z" />
</svg>
);
}
function PauseGlyph() {
return (
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
<rect x="4" y="3" width="3" height="10" rx="1" />
<rect x="9" y="3" width="3" height="10" rx="1" />
</svg>
);
}
function formatSec(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) sec = 0;
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return m + ':' + s.toString().padStart(2, '0');
}
@@ -0,0 +1,132 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
// Catch-all card for attachments without a richer renderer (zip, docx,
// txt, etc). Decrypt is deferred to first download click — these can be
// large and there's no inline preview to justify auto-fetching them.
export function AttachmentGeneric({ handle }: Props) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const download = async () => {
if (busy) return;
setBusy(true);
setError(null);
try {
let blob = await getCachedAttachment(handle.id);
if (!blob) {
blob = await downloadAndDecryptAttachment({ client: supabase, handle });
void putCachedAttachment(handle.id, blob);
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filenameFor(handle);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Defer revoke so Safari has a chance to start the download.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'download failed');
} finally {
setBusy(false);
}
};
return (
<div className="mt-2 inline-flex w-[280px] items-center gap-2.5 rounded-lg border border-line bg-surface-2 p-2.5">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-accent/10 text-accent">
<FileGlyph />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-fg">
{prettyMime(handle.mimeType)}
</p>
<p className="truncate text-[10px] text-fg-muted">
{formatSize(handle.sizeBytes)}
</p>
{error && (
<p className="mt-0.5 inline-flex items-center gap-1 text-[10px] text-rose-500">
<AlertIcon className="h-3 w-3" />
<span>{error}</span>
</p>
)}
</div>
<button
type="button"
onClick={() => void download()}
disabled={busy}
aria-label="Download"
title="Download"
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-line bg-surface-3 text-fg transition hover:brightness-95 disabled:opacity-60"
>
{busy ? <SpinnerIcon className="h-4 w-4" /> : <DownloadGlyph />}
</button>
</div>
);
}
function FileGlyph() {
return (
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
<path d="M3 2a1 1 0 011-1h6l3 3v10a1 1 0 01-1 1H4a1 1 0 01-1-1V2zm7 0v3h3l-3-3z" />
</svg>
);
}
function DownloadGlyph() {
return (
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M8 2v8M4 7l4 4 4-4M3 13h10" />
</svg>
);
}
function filenameFor(handle: AttachmentHandle): string {
const ext = extFor(handle.mimeType);
return 'attachment-' + handle.id.slice(0, 8) + (ext ? '.' + ext : '');
}
function extFor(mime: string): string | null {
const map: Record<string, string> = {
'application/zip': 'zip',
'application/x-zip-compressed': 'zip',
'application/x-7z-compressed': '7z',
'application/x-tar': 'tar',
'application/gzip': 'gz',
'application/json': 'json',
'application/xml': 'xml',
'text/plain': 'txt',
'text/markdown': 'md',
'text/csv': 'csv',
'application/msword': 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
'application/vnd.ms-excel': 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
'application/vnd.ms-powerpoint': 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
};
return map[mime] ?? null;
}
function prettyMime(mime: string): string {
const ext = extFor(mime);
if (ext) return ext.toUpperCase() + '-Datei';
if (mime.startsWith('text/')) return 'Textdatei';
return mime || 'Datei';
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
+77 -15
View File
@@ -1,6 +1,7 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon, XIcon } from './icons';
@@ -8,35 +9,95 @@ interface Props {
handle: AttachmentHandle;
}
// Max inline-preview dimension. Full-resolution stays available for the
// lightbox. Animated formats (gif/webp/apng) are passed through untouched
// so animation isn't lost; everything else is downscaled to this box.
const THUMB_MAX_DIM = 640;
const ANIMATED_MIME = /^image\/(gif|apng|webp)/;
async function makeThumbnail(blob: Blob): Promise<Blob | null> {
if (ANIMATED_MIME.test(blob.type)) return null;
if (typeof createImageBitmap !== 'function') return null;
if (typeof OffscreenCanvas !== 'function') return null;
try {
const bitmap = await createImageBitmap(blob);
const largest = Math.max(bitmap.width, bitmap.height);
if (largest <= THUMB_MAX_DIM) {
bitmap.close();
return null;
}
const scale = THUMB_MAX_DIM / largest;
const w = Math.max(1, Math.round(bitmap.width * scale));
const h = Math.max(1, Math.round(bitmap.height * scale));
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext('2d');
if (!ctx) {
bitmap.close();
return null;
}
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
return await canvas.convertToBlob({ type: 'image/webp', quality: 0.8 });
} catch {
return null;
}
}
export function AttachmentImage({ handle }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [fullUrl, setFullUrl] = useState<string | null>(null);
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
useEffect(() => {
let cancelled = false;
let url: string | null = null;
const created: string[] = [];
setError(null);
setBlobUrl(null);
setFullUrl(null);
setThumbUrl(null);
downloadAndDecryptAttachment({ client: supabase, handle })
.then((blob) => {
if (cancelled) return;
url = URL.createObjectURL(blob);
setBlobUrl(url);
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
const take = (blob: Blob): string => {
const u = URL.createObjectURL(blob);
created.push(u);
return u;
};
// OPFS cache → decrypt → generate thumbnail for inline display.
// Lightbox swaps to the full blob when opened.
void (async () => {
const cached = await getCachedAttachment(handle.id);
let blob: Blob;
if (cached) {
blob = cached;
} else {
try {
blob = await downloadAndDecryptAttachment({ client: supabase, handle });
void putCachedAttachment(handle.id, blob);
} catch (err: unknown) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
}
return;
}
});
}
if (cancelled) return;
const full = take(blob);
setFullUrl(full);
const thumb = await makeThumbnail(blob);
if (cancelled) return;
if (thumb) {
setThumbUrl(take(thumb));
}
})();
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
for (const u of created) URL.revokeObjectURL(u);
};
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
const blobUrl = thumbUrl ?? fullUrl;
if (error) {
return (
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
@@ -66,10 +127,11 @@ export function AttachmentImage({ handle }: Props) {
src={blobUrl}
alt="attachment"
loading="lazy"
decoding="async"
className="block h-auto max-h-80 w-auto max-w-full object-contain"
/>
</button>
{lightboxOpen && <Lightbox url={blobUrl} onClose={() => setLightboxOpen(false)} />}
{lightboxOpen && fullUrl && <Lightbox url={fullUrl} onClose={() => setLightboxOpen(false)} />}
</>
);
}
@@ -0,0 +1,120 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
// PDF preview rendered via the browser's built-in PDF viewer (Chromium /
// Safari both ship one). Embedding via <object> with a fallback link keeps
// the implementation tiny — no pdf.js dependency.
export function AttachmentPdf({ handle }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
let cancelled = false;
let url: string | null = null;
setError(null);
setBlobUrl(null);
void (async () => {
const cached = await getCachedAttachment(handle.id);
if (cached) {
if (cancelled) return;
const typed = new Blob([cached], { type: 'application/pdf' });
url = URL.createObjectURL(typed);
setBlobUrl(url);
return;
}
try {
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
if (cancelled) return;
// Force the application/pdf type so the browser plugin engages.
const typed = new Blob([blob], { type: 'application/pdf' });
url = URL.createObjectURL(typed);
setBlobUrl(url);
void putCachedAttachment(handle.id, blob);
} catch (err: unknown) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
}
}
})();
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
if (error) {
return (
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
<AlertIcon className="h-4 w-4" />
<span>{error}</span>
</div>
);
}
if (!blobUrl) {
return (
<div className="mt-2 flex h-24 w-[320px] items-center justify-center rounded-lg border border-line bg-surface-2 text-fg-muted">
<SpinnerIcon className="h-5 w-5" />
</div>
);
}
return (
<div className="mt-2 w-full max-w-[420px] overflow-hidden rounded-lg border border-line bg-surface-2">
<div className="flex items-center justify-between gap-2 border-b border-line bg-surface-3 px-3 py-2 text-xs">
<span className="flex items-center gap-2 truncate text-fg">
<PdfGlyph />
<span className="truncate">PDF · {formatSize(handle.sizeBytes)}</span>
</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-0.5 text-[11px] text-fg hover:bg-surface-3"
>
{expanded ? 'Einklappen' : 'Vorschau'}
</button>
<a
href={blobUrl}
download={'attachment-' + handle.id.slice(0, 8) + '.pdf'}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-0.5 text-[11px] text-fg no-underline hover:bg-surface-3"
>
Download
</a>
</div>
</div>
{expanded && (
<object data={blobUrl} type="application/pdf" className="block h-[420px] w-full">
<p className="p-4 text-xs text-fg-muted">
Vorschau nicht verfügbar bitte herunterladen.
</p>
</object>
)}
</div>
);
}
function PdfGlyph() {
return (
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
<path d="M3 2a1 1 0 011-1h6l3 3v10a1 1 0 01-1 1H4a1 1 0 01-1-1V2zm7 0v3h3l-3-3zM5 9h6v1H5V9zm0 2h6v1H5v-1zm0-4h2v1H5V7z" />
</svg>
);
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
@@ -0,0 +1,78 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
// Decrypt the blob, render a native <video controls>. Loads on demand —
// metadata-only preload so we don't burn bandwidth until the user hits play.
export function AttachmentVideo({ handle }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let url: string | null = null;
setError(null);
setBlobUrl(null);
void (async () => {
const cached = await getCachedAttachment(handle.id);
if (cached) {
if (cancelled) return;
url = URL.createObjectURL(cached);
setBlobUrl(url);
return;
}
try {
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
if (cancelled) return;
url = URL.createObjectURL(blob);
setBlobUrl(url);
void putCachedAttachment(handle.id, blob);
} catch (err: unknown) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
}
}
})();
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
if (error) {
return (
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
<AlertIcon className="h-4 w-4" />
<span>{error}</span>
</div>
);
}
if (!blobUrl) {
return (
<div className="mt-2 flex h-32 w-[320px] items-center justify-center rounded-lg border border-line bg-surface-2 text-fg-muted">
<SpinnerIcon className="h-5 w-5" />
</div>
);
}
return (
<video
controls
preload="metadata"
src={blobUrl}
className="mt-2 block max-h-80 w-full max-w-[420px] rounded-lg border border-line bg-black"
>
<track kind="captions" />
</video>
);
}
+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}
@@ -1,7 +1,7 @@
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { exportDeviceBackup } from '../lib/deviceBackup';
import { type BackupBundle, exportDeviceBackupWithRecovery } from '../lib/deviceBackup';
import { AlertIcon, CopyIcon, LockIcon, ShieldIcon, SpinnerIcon, XIcon } from './icons';
interface Props {
@@ -22,8 +22,9 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
const [confirm, setConfirm] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [backup, setBackup] = useState<string | null>(null);
const [bundle, setBundle] = useState<BackupBundle | null>(null);
const [copied, setCopied] = useState(false);
const [copiedRecovery, setCopiedRecovery] = useState(false);
const canGenerate = useMemo(() => {
return passphrase.length >= 8 && passphrase === confirm && !busy;
@@ -32,9 +33,10 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
const reset = useCallback(() => {
setPassphrase('');
setConfirm('');
setBackup(null);
setBundle(null);
setError(null);
setCopied(false);
setCopiedRecovery(false);
}, []);
const handleClose = useCallback(() => {
@@ -47,8 +49,13 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
setBusy(true);
setError(null);
try {
const str = await exportDeviceBackup({ userId, deviceId, privateKey, passphrase });
setBackup(str);
const b = await exportDeviceBackupWithRecovery({
userId,
deviceId,
privateKey,
passphrase,
});
setBundle(b);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -56,27 +63,39 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
}
}, [canGenerate, userId, deviceId, privateKey, passphrase]);
const handleCopy = useCallback(async () => {
if (!backup) return;
const copyText = async (text: string, marker: 'main' | 'recovery'): Promise<void> => {
try {
await navigator.clipboard.writeText(backup);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
await navigator.clipboard.writeText(text);
if (marker === 'main') {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} else {
setCopiedRecovery(true);
window.setTimeout(() => setCopiedRecovery(false), 1500);
}
} catch {
/* fall back — user can select manually */
}
}, [backup]);
};
const handleDownload = useCallback(() => {
if (!backup) return;
const blob = new Blob([backup], { type: 'text/plain;charset=utf-8' });
if (!bundle) return;
const text =
'=== Passphrase backup ===\n' +
bundle.passphraseBackup +
'\n\n=== Recovery code ===\n' +
bundle.recoveryCode +
'\n\n=== Recovery backup (use with the recovery code) ===\n' +
bundle.recoveryBackup +
'\n';
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chatapp-device-backup-${deviceId.slice(0, 8)}.txt`;
a.click();
URL.revokeObjectURL(url);
}, [backup, deviceId]);
}, [bundle, deviceId]);
if (!open) return null;
@@ -110,7 +129,7 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
</header>
<div className="flex-1 overflow-y-auto p-5">
{!backup ? (
{!bundle ? (
<>
<p className="text-sm text-fg-muted">
{t('app:backup.export_explainer', {
@@ -183,40 +202,76 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
</span>
</div>
</div>
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
Backup-String
</label>
<textarea
readOnly
value={backup}
rows={8}
value={bundle.passphraseBackup}
rows={6}
onFocus={(e) => e.currentTarget.select()}
className="mt-3 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
/>
<div className="mt-3 flex gap-2">
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={() => void handleCopy()}
onClick={() => void copyText(bundle.passphraseBackup, 'main')}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
>
<CopyIcon className="h-4 w-4" />
<span>
{copied
? t('app:backup.copied', { defaultValue: 'Kopiert!' })
: t('app:backup.copy', { defaultValue: 'Kopieren' })}
</span>
<span>{copied ? 'Kopiert!' : 'Kopieren'}</span>
</button>
<button
type="button"
onClick={handleDownload}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
>
{t('app:backup.download', { defaultValue: 'Als Datei speichern' })}
Als Datei speichern (alles)
</button>
</div>
<div className="mt-5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
<div className="flex items-start gap-2">
<ShieldIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-amber-700 dark:text-amber-200">
Recovery-Code (Passphrase vergessen?)
</p>
<p className="mt-0.5 text-[11px] text-amber-700/80 dark:text-amber-200/80">
Code separat aufbewahren. Mit dem Recovery-Backup unten lässt sich der Schlüssel
ohne Passphrase wiederherstellen.
</p>
<p className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest text-fg">
{bundle.recoveryCode}
</p>
</div>
</div>
</div>
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
Recovery-Backup-String
</label>
<textarea
readOnly
value={bundle.recoveryBackup}
rows={6}
onFocus={(e) => e.currentTarget.select()}
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
/>
<button
type="button"
onClick={() => void copyText(bundle.recoveryBackup, 'recovery')}
className="mt-2 inline-flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
>
<CopyIcon className="h-4 w-4" />
<span>{copiedRecovery ? 'Kopiert!' : 'Recovery-Backup kopieren'}</span>
</button>
</>
)}
</div>
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
{!backup ? (
{!bundle ? (
<>
<button
type="button"
@@ -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>
);
}
@@ -1,10 +1,13 @@
import { useTranslation } from 'react-i18next';
import {
HeadphonesIcon,
HeadphonesOffIcon,
MicIcon,
MicOffIcon,
MonitorShareIcon,
MonitorStopIcon,
MusicIcon,
PhoneOffIcon,
UsersIcon,
VideoIcon,
@@ -14,11 +17,16 @@ interface Props {
muted: boolean;
sharing: boolean;
video: boolean;
deafened: boolean;
onToggleMute: () => void;
onToggleShare: () => void;
onToggleVideo?: () => void;
onToggleDeafen: () => void;
onHangup: () => void;
onOpenParticipants?: () => void;
/** Toggle the in-call soundboard popover. Active = panel currently open. */
onToggleSoundboard?: () => void;
soundboardOpen?: boolean;
/** Compact variant used inside the docked call (36px buttons). */
compact?: boolean;
/** Glass variant used when controls float on fullscreen cinema mode. */
@@ -31,11 +39,15 @@ export function CallControls({
muted,
sharing,
video,
deafened,
onToggleMute,
onToggleShare,
onToggleVideo,
onToggleDeafen,
onHangup,
onOpenParticipants,
onToggleSoundboard,
soundboardOpen = false,
compact = false,
glass = false,
disabledMedia = false,
@@ -59,6 +71,24 @@ export function CallControls({
>
{muted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />}
</CallButton>
<CallButton
label={
deafened
? t('app:call.undeafen', { defaultValue: 'Ton wieder aktiv' })
: t('app:call.deafen', { defaultValue: 'Alle stumm' })
}
active={deafened}
activeTone="danger"
onClick={onToggleDeafen}
glass={glass}
className={btnSize}
>
{deafened ? (
<HeadphonesOffIcon className="h-5 w-5" />
) : (
<HeadphonesIcon className="h-5 w-5" />
)}
</CallButton>
{onToggleVideo && (
<CallButton
label={t('app:call.start_video', { defaultValue: 'Video' })}
@@ -91,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' })}
@@ -1,4 +1,6 @@
import { CrownIcon, LockIcon, MicOffIcon, MonitorShareIcon } from './icons';
import { useEffect, useRef } from 'react';
import { CrownIcon, HeadphonesOffIcon, LockIcon, MicOffIcon } from './icons';
export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal';
@@ -41,14 +43,20 @@ export interface ParticipantTileProps {
avatarUrl: string | null;
me: boolean;
muted: boolean;
/** Local-only: true when THIS user has muted everyone else via the deafen
* toggle. Remote deafen state is not propagated, so only the own tile
* ever carries a truthy value. */
deafened: boolean;
speaking: boolean;
sharing: boolean;
video: boolean;
e2ee: boolean;
/** MediaStreamTrack for the participant's active camera, when `video` is
* true. When null the tile falls back to the avatar placeholder. */
videoTrack?: MediaStreamTrack | null;
size?: 'default' | 'small';
focused?: boolean;
onClick?: () => void;
onOpenScreenShare?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
}
export function CallParticipantTile(props: ParticipantTileProps) {
@@ -56,14 +64,14 @@ export function CallParticipantTile(props: ParticipantTileProps) {
displayName,
me,
muted,
deafened,
speaking,
sharing,
video,
e2ee,
size = 'default',
focused = false,
onClick,
onOpenScreenShare,
onContextMenu,
} = props;
const small = size === 'small';
@@ -76,6 +84,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
return (
<div
onClick={onClick}
onContextMenu={onContextMenu}
className={
'relative flex flex-col overflow-hidden rounded-[14px] border bg-surface-3 transition ' +
(onClick ? 'cursor-pointer ' : '') +
@@ -83,18 +92,21 @@ export function CallParticipantTile(props: ParticipantTileProps) {
(small ? ' min-w-[140px]' : '')
}
>
{sharing ? (
<ScreenshareStub
displayName={displayName}
small={small}
onOpen={onOpenScreenShare}
/>
) : video ? (
{video ? (
<VideoStub {...props} small={small} />
) : (
<AudioContent {...props} small={small} />
)}
{/* Speaking indicator visible regardless of content type (video or
audio). z-10 ensures it sits above the video element. */}
{speaking && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-10 rounded-[14px] border-[3px] border-emerald-400 shadow-[inset_0_0_18px_rgba(34,197,94,0.55)]"
/>
)}
<div className="pointer-events-none absolute inset-x-2 bottom-2 flex items-center justify-between gap-2 rounded-lg glass-chip px-2.5 py-1.5">
<div className="flex min-w-0 items-center gap-1.5 text-xs font-semibold">
{me && <CrownIcon className="h-3 w-3 shrink-0 text-amber-300" />}
@@ -114,13 +126,21 @@ export function CallParticipantTile(props: ParticipantTileProps) {
</div>
<div className="flex shrink-0 items-center gap-1">
{muted && (
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white">
<span
aria-label="Mikro stumm"
title="Mikro stumm"
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
>
<MicOffIcon className="h-3 w-3" />
</span>
)}
{sharing && (
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-emerald-500/80 text-white">
<MonitorShareIcon className="h-3 w-3" />
{deafened && (
<span
aria-label="Ton aus"
title="Ton aus"
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
>
<HeadphonesOffIcon className="h-3 w-3" />
</span>
)}
</div>
@@ -179,11 +199,45 @@ function VideoStub({
userId,
displayName,
avatarUrl,
videoTrack,
me,
small,
}: ParticipantTileProps & { small: boolean }) {
// Video capture playback is out of scope for this UI iteration — show the
// audio-avatar gradient background as a placeholder so the layout is stable
// when a participant enables video.
const videoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
const el = videoRef.current;
if (!el || !videoTrack) return;
el.srcObject = new MediaStream([videoTrack]);
return () => {
if (el.srcObject) {
(el.srcObject as MediaStream).getTracks().forEach((t) => {
// Don't stop the live track — other consumers may still need it.
// Just detach from this element.
void t;
});
el.srcObject = null;
}
};
}, [videoTrack]);
if (videoTrack) {
return (
<div className="relative flex min-h-0 flex-1 items-center justify-center bg-black">
<video
ref={videoRef}
autoPlay
playsInline
muted
className={
'h-full w-full object-cover ' +
(me ? 'scale-x-[-1]' : '') /* mirror local preview */
}
/>
</div>
);
}
// Camera flag true but no track yet (publishing / subscribing race).
const key = colorKeyFor(userId);
const colors = AVATAR_COLORS[key];
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
@@ -206,49 +260,3 @@ function VideoStub({
);
}
interface ScreenshareStubProps {
displayName: string;
small: boolean;
onOpen?: (() => void) | undefined;
}
// Fake browser window placeholder — click to open the real <video> viewer.
// Matches the design spec's "screenshare-stub" look.
function ScreenshareStub({ displayName, small, onOpen }: ScreenshareStubProps) {
return (
<button
type="button"
onClick={
onOpen
? (e) => {
e.stopPropagation();
onOpen();
}
: undefined
}
aria-label={`${displayName} teilt Bildschirm`}
className="relative flex min-h-0 flex-1 cursor-pointer items-center justify-center bg-ink-900 p-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<div className="h-[85%] w-[90%] overflow-hidden rounded-lg border border-white/10 bg-ink-700">
<div className="flex h-[20px] items-center gap-1 bg-ink-600 px-2">
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
</div>
<div className={'flex flex-col gap-2 ' + (small ? 'gap-[3px] p-1.5' : 'p-3.5')}>
<div className={'w-[60%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
<div className={'w-[80%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
<div className={'w-[40%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
<div className={'my-1 rounded ' + (small ? 'h-[14px]' : 'h-10') + ' bg-accent/30'} />
<div className={'w-[70%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
</div>
</div>
{!small && (
<div className="absolute left-3 top-3 flex items-center gap-1.5 glass-chip rounded-lg px-2.5 py-1 text-[10px] font-medium">
<MonitorShareIcon className="h-3 w-3" />
<span>{displayName} teilt Bildschirm</span>
</div>
)}
</button>
);
}
+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,
);
}
+37 -7
View File
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
import {
decodePrivateKeyFromBackup,
importDeviceBackup,
normalizeRecoveryCode,
} from '../lib/deviceBackup';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
@@ -28,6 +29,7 @@ export function DeviceRestore({ userId, onRestored }: Props) {
const { t } = useTranslation(['app', 'errors']);
const [backup, setBackup] = useState('');
const [passphrase, setPassphrase] = useState('');
const [mode, setMode] = useState<'passphrase' | 'recovery'>('passphrase');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -39,7 +41,9 @@ export function DeviceRestore({ userId, onRestored }: Props) {
setError(null);
let privateKey: Uint8Array | null = null;
try {
const payload = await importDeviceBackup(backup.trim(), passphrase);
const secret =
mode === 'recovery' ? normalizeRecoveryCode(passphrase) : passphrase;
const payload = await importDeviceBackup(backup.trim(), secret);
privateKey = decodePrivateKeyFromBackup(payload);
const device = await restoreDeviceFromServerRecord({
@@ -63,7 +67,7 @@ export function DeviceRestore({ userId, onRestored }: Props) {
setBusy(false);
}
},
[backup, passphrase, userId, onRestored, busy],
[backup, passphrase, mode, userId, onRestored, busy],
);
return (
@@ -102,16 +106,42 @@ export function DeviceRestore({ userId, onRestored }: Props) {
/>
</div>
<div className="space-y-1">
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
{t('app:backup.passphrase', { defaultValue: 'Passphrase' })}
</label>
<div className="flex items-center justify-between">
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
{mode === 'passphrase' ? 'Passphrase' : 'Recovery-Code'}
</label>
<button
type="button"
onClick={() => {
setMode((m) => (m === 'passphrase' ? 'recovery' : 'passphrase'));
setPassphrase('');
setError(null);
}}
className="cursor-pointer text-[11px] font-semibold text-brand-300 hover:underline"
>
{mode === 'passphrase'
? 'Passphrase vergessen? Recovery-Code nutzen'
: 'Stattdessen Passphrase eingeben'}
</button>
</div>
<input
type="password"
type={mode === 'passphrase' ? 'password' : 'text'}
required
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
placeholder={mode === 'recovery' ? 'XXXXXX-XXXXXX-XXXXXX-XXXXXX' : ''}
spellCheck={false}
autoComplete={mode === 'recovery' ? 'off' : 'current-password'}
className={
'w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40 ' +
(mode === 'recovery' ? 'font-mono tracking-widest' : '')
}
/>
{mode === 'recovery' && (
<p className="text-[11px] text-neutral-500">
Stattdessen den Recovery-Backup-String oben einfügen.
</p>
)}
</div>
</div>
+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>
);
}
+511 -173
View File
@@ -1,5 +1,7 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import type { RemoteParticipant, Room } from 'livekit-client';
import { Track } from 'livekit-client';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
@@ -13,7 +15,10 @@ import { useActiveSpeakers } from '../lib/useActiveSpeakers';
import { CallControls } from './CallControls';
import { CallParticipantTile } from './CallParticipantTile';
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
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.
@@ -24,13 +29,24 @@ interface Props {
}
interface Tile {
// `user` = person with avatar/video/mute info. Speaking ring applies here.
// `screen` = a separate screen-share window from a user. No speaking
// indicator, no mute, no avatar — just the stream.
kind: 'user' | 'screen';
// Stable id used for focus tracking + React keys. `user:${userId}` or
// `screen:${userId}`.
id: string;
userId: string;
displayName: string;
avatarUrl: string | null;
self: boolean;
muted: boolean;
deafened: boolean;
video: boolean;
videoTrack: MediaStreamTrack | null;
// True iff this is the current user's own screen-share tile.
sharing: boolean;
// True iff this is a remote user's screen-share tile.
remoteSharing: boolean;
}
@@ -43,11 +59,18 @@ export function InCallPanel({ conversation }: Props) {
isMuted,
isE2EEActive,
isScreenSharing,
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteMute,
remoteScreenShares,
callMode,
focusedId,
toggleMute,
toggleScreenShare,
startScreenShare,
stopScreenShare,
toggleCamera,
toggleDeafen,
hangup,
setCallMode,
setFocusedId,
@@ -55,6 +78,23 @@ export function InCallPanel({ conversation }: Props) {
const { session } = useAuth();
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);
const openVolumeMenu = (tile: Tile, e: React.MouseEvent) => {
if (tile.self) return;
if (tile.kind !== 'user') return;
e.preventDefault();
setVolumeMenu({
userId: tile.userId,
displayName: tile.displayName,
x: e.clientX,
y: e.clientY,
});
};
const active =
(state.kind === 'connected' ||
@@ -66,9 +106,14 @@ export function InCallPanel({ conversation }: Props) {
const tiles = buildTiles({
conversation,
myId,
remoteIdentities: remoteParticipants.map((p) => p.identity),
room,
remoteParticipants,
isMuted,
isDeafened,
remoteDeafen,
remoteMute,
isScreenSharing,
isCameraEnabled,
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
});
@@ -86,17 +131,31 @@ export function InCallPanel({ conversation }: Props) {
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
: t('app:call.connected');
const sharingTile = tiles.find((p) => p.sharing);
const effectiveFocusedId = focusedId ?? sharingTile?.userId ?? tiles[0]?.userId ?? null;
const speaker = tiles.find((p) => p.userId === effectiveFocusedId) ?? tiles[0];
// A screen-share tile becomes the auto-focus target when no one explicitly
// picked a tile yet. Focus ids now use Tile.id (kind-prefixed) so we can
// distinguish a user's own avatar tile from their screen tile.
const screenTile = tiles.find((p) => p.kind === 'screen');
const effectiveFocusedId = focusedId ?? screenTile?.id ?? tiles[0]?.id ?? null;
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
const controls = (
<CallControls
muted={isMuted}
muted={isMuted || !(room?.localParticipant?.isMicrophoneEnabled ?? false)}
sharing={isScreenSharing}
video={false}
video={isCameraEnabled}
deafened={isDeafened}
onToggleMute={toggleMute}
onToggleShare={() => void toggleScreenShare()}
onToggleShare={() => {
if (isScreenSharing) {
void stopScreenShare();
} else {
setShareDialogOpen(true);
}
}}
onToggleVideo={() => void toggleCamera()}
onToggleDeafen={toggleDeafen}
onToggleSoundboard={() => setSoundboardOpen((v) => !v)}
soundboardOpen={soundboardOpen}
onHangup={() => void hangup()}
compact={callMode !== 'fullscreen'}
glass={callMode === 'fullscreen'}
@@ -105,20 +164,50 @@ export function InCallPanel({ conversation }: Props) {
);
if (callMode === 'fullscreen') {
// In fullscreen, a "manual focus" = user explicitly picked someone, OR
// someone is sharing a screen, OR exactly one non-self speaker is talking
// (auto-promote). Without that we show an even grid of all participants
// (Discord default). Clicking a tile switches to the big-speaker layout.
const speakingNonSelf = tiles.filter(
(t: Tile) => activeSpeakers.has(t.userId) && !t.self && t.kind === 'user',
);
const autoSpeaker =
focusedId === null && screenTile === undefined && speakingNonSelf.length === 1
? speakingNonSelf[0]
: undefined;
const hasFocus = focusedId !== null || screenTile !== undefined || autoSpeaker !== undefined;
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
return (
<FullscreenCall
tiles={tiles}
speaker={speaker}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members}
activeSpeakers={activeSpeakers}
e2ee={isE2EEActive}
onExit={() => setCallMode('grid')}
onFocusTile={(id) => {
setFocusedId(id);
}}
controls={controls}
/>
<>
<FullscreenCall
tiles={tiles}
speaker={effectiveSpeaker}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members}
activeSpeakers={activeSpeakers}
e2ee={isE2EEActive}
onExit={() => setCallMode('grid')}
onFocusTile={(id) => {
// Toggle: click the already-focused tile to return to grid.
setFocusedId(focusedId === id ? null : id);
}}
onTileContextMenu={openVolumeMenu}
controls={controls}
/>
{volumeMenu && (
<ParticipantVolumeMenu
userId={volumeMenu.userId}
displayName={volumeMenu.displayName}
x={volumeMenu.x}
y={volumeMenu.y}
onClose={() => setVolumeMenu(null)}
/>
)}
<SoundboardPopover
open={soundboardOpen}
onClose={() => setSoundboardOpen(false)}
/>
</>
);
}
@@ -127,11 +216,19 @@ export function InCallPanel({ conversation }: Props) {
? conversation.name ?? t('app:chats.new_group')
: conversation.peer?.displayName ?? '—';
// Focus mode dedicates the entire call-panel vertical slot to the speaker so
// the tile can grow in height (grid mode's 420px cap leaves it squashed).
const sectionClass =
callMode === 'focus'
? 'flex min-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2'
: 'flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2';
const sectionHeight = callMode === 'focus' ? '75%' : '50%';
return (
<section
aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })}
className="flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2"
style={{ height: '50%' }}
className={sectionClass}
style={{ height: sectionHeight }}
>
<div className="flex items-center justify-between border-b border-line bg-surface-3 px-4 py-2">
<div className="flex min-w-0 flex-col gap-0.5">
@@ -168,16 +265,53 @@ export function InCallPanel({ conversation }: Props) {
setFocusedId(id);
if (callMode === 'grid') setCallMode('focus');
}}
onTileContextMenu={openVolumeMenu}
compact
/>
{controls}
<PttHint />
<ScreenShareDialog
open={shareDialogOpen}
onClose={() => setShareDialogOpen(false)}
onStart={async (opts) => {
await startScreenShare(opts);
}}
/>
{volumeMenu && (
<ParticipantVolumeMenu
userId={volumeMenu.userId}
displayName={volumeMenu.displayName}
x={volumeMenu.x}
y={volumeMenu.y}
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
// ---------------------------------------------------------------------------
@@ -185,49 +319,123 @@ export function InCallPanel({ conversation }: Props) {
interface BuildArgs {
conversation: ConversationSummary;
myId: string | null;
remoteIdentities: string[];
room: Room | null;
remoteParticipants: RemoteParticipant[];
isMuted: boolean;
isDeafened: boolean;
remoteDeafen: Record<string, boolean>;
remoteMute: Record<string, boolean>;
isScreenSharing: boolean;
isCameraEnabled: boolean;
remoteSharerIds: Set<string>;
}
function cameraTrackFor(
participant: { videoTrackPublications: Map<string, { source: Track.Source; track?: { mediaStreamTrack: MediaStreamTrack } | undefined }> } | null,
): MediaStreamTrack | null {
if (!participant) return null;
for (const pub of participant.videoTrackPublications.values()) {
if (pub.source === Track.Source.Camera && pub.track) {
return pub.track.mediaStreamTrack ?? null;
}
}
return null;
}
function buildTiles({
conversation,
myId,
remoteIdentities,
room,
remoteParticipants,
isMuted,
isDeafened,
remoteDeafen,
remoteMute,
isScreenSharing,
isCameraEnabled,
remoteSharerIds,
}: BuildArgs): Tile[] {
const remoteSet = new Set(remoteIdentities);
const remoteById = new Map<string, RemoteParticipant>();
for (const p of remoteParticipants) {
if (p.identity) remoteById.set(p.identity, p);
}
const out: Tile[] = [];
if (myId) {
const me = conversation.members.find((m) => m.userId === myId) ?? null;
// Source-of-truth for own mic: the LocalParticipant publication state.
// `isMuted` reflects the toggle button, but if mic never published (no
// device / permission denied) the toggle stays false while the real
// state is "muted". Combine both so the badge always matches reality.
const micLive = room?.localParticipant?.isMicrophoneEnabled ?? false;
out.push({
kind: 'user',
id: 'user:' + myId,
userId: myId,
displayName: me?.profile?.displayName ?? '?',
avatarUrl: me?.profile?.avatarUrl ?? null,
self: true,
muted: isMuted,
video: false,
sharing: isScreenSharing,
muted: isMuted || !micLive,
deafened: isDeafened,
video: isCameraEnabled,
videoTrack: cameraTrackFor(room?.localParticipant ?? null),
sharing: false,
remoteSharing: false,
});
if (isScreenSharing) {
out.push({
kind: 'screen',
id: 'screen:' + myId,
userId: myId,
displayName: (me?.profile?.displayName ?? '?') + ' · Bildschirm',
avatarUrl: me?.profile?.avatarUrl ?? null,
self: true,
muted: false,
deafened: false,
video: false,
videoTrack: null,
sharing: true,
remoteSharing: false,
});
}
}
for (const m of conversation.members) {
if (m.userId === myId) continue;
if (!remoteSet.has(m.userId)) continue;
const sharing = remoteSharerIds.has(m.userId);
const rp = remoteById.get(m.userId);
if (!rp) continue;
out.push({
kind: 'user',
id: 'user:' + m.userId,
userId: m.userId,
displayName: m.profile?.displayName ?? '?',
avatarUrl: m.profile?.avatarUrl ?? null,
self: false,
muted: false,
video: false,
sharing,
remoteSharing: sharing,
// 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,
videoTrack: cameraTrackFor(rp),
sharing: false,
remoteSharing: false,
});
if (remoteSharerIds.has(m.userId)) {
out.push({
kind: 'screen',
id: 'screen:' + m.userId,
userId: m.userId,
displayName: (m.profile?.displayName ?? '?') + ' · Bildschirm',
avatarUrl: m.profile?.avatarUrl ?? null,
self: false,
muted: false,
deafened: false,
video: false,
videoTrack: null,
sharing: false,
remoteSharing: true,
});
}
}
return out;
}
@@ -319,9 +527,92 @@ interface StageProps {
}[];
conversationMembers: ConversationSummary['members'];
onFocusTile: (id: string) => void;
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
compact?: boolean;
}
// Dispatches a Tile to the right renderer. Screen-tiles show the screenshare
// stream directly (no avatar, no speaking ring, no mic indicator); user-tiles
// render via CallParticipantTile with all its chrome.
function TileRender({
tile,
activeSpeakers,
e2ee,
remoteScreenShares,
conversationMembers,
size,
focused,
onClick,
onContextMenu,
}: {
tile: Tile;
activeSpeakers: Set<string>;
e2ee: boolean;
remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers'];
size?: 'default' | 'small';
focused?: boolean;
onClick?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
}): JSX.Element {
if (tile.kind === 'screen') {
if (tile.self) {
// Local screenshare preview — we don't mirror a copy of the outgoing
// stream. Render a labelled placeholder card so the user knows their
// share is live without double-encoding the stream.
return (
<div
onClick={onClick}
className={
'relative flex h-full w-full items-center justify-center overflow-hidden rounded-[14px] border border-emerald-500/40 bg-emerald-500/5 text-emerald-700 dark:text-emerald-200 ' +
(onClick ? 'cursor-pointer' : '')
}
>
<div className="flex flex-col items-center gap-2 p-4 text-center">
<div className="text-xs font-semibold uppercase tracking-wider">
Live
</div>
<div className="text-sm">{tile.displayName}</div>
</div>
</div>
);
}
const share = remoteScreenShares.find((s) => s.participantId === tile.userId);
const member = conversationMembers.find((m) => m.userId === tile.userId);
if (!share) return <div className="h-full w-full" />;
return (
<div
onClick={onClick}
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
>
<ScreenShareViewer
share={share}
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
displayName={member?.profile?.displayName ?? tile.displayName}
/>
</div>
);
}
return (
<CallParticipantTile
userId={tile.userId}
displayName={tile.displayName}
avatarUrl={tile.avatarUrl}
me={tile.self}
muted={tile.muted}
deafened={tile.deafened}
speaking={activeSpeakers.has(tile.userId)}
video={tile.video}
videoTrack={tile.videoTrack}
e2ee={e2ee}
{...(size ? { size } : {})}
{...(focused ? { focused } : {})}
{...(onClick ? { onClick } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
/>
);
}
function CallStage({
tiles,
speaker,
@@ -331,37 +622,40 @@ function CallStage({
remoteScreenShares,
conversationMembers,
onFocusTile,
onTileContextMenu,
compact = false,
}: StageProps) {
if (mode === 'focus' && speaker) {
const others = tiles.filter((p) => p.userId !== speaker.userId);
const others = tiles.filter((p) => p.id !== speaker.id);
return (
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
<div className="min-h-0 flex-1">
<FocusedTile
tile={speaker}
e2ee={e2ee}
speaking={activeSpeakers.has(speaker.userId)}
activeSpeakers={activeSpeakers}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
/>
</div>
{others.length > 0 && (
<div className="flex h-[110px] gap-2.5 overflow-x-auto">
<div className="flex h-[180px] gap-2.5 overflow-x-auto">
{others.map((p) => (
<div key={p.userId} className="h-full min-w-[160px]">
<CallParticipantTile
userId={p.userId}
displayName={p.displayName}
avatarUrl={p.avatarUrl}
me={p.self}
muted={p.muted}
speaking={activeSpeakers.has(p.userId)}
sharing={p.sharing}
video={p.video}
<div
key={p.id}
className="h-full w-[240px] shrink-0 [&>div]:h-full"
>
<TileRender
tile={p}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
size="small"
onClick={() => onFocusTile(p.userId)}
onClick={() => onFocusTile(p.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/>
</div>
))}
@@ -377,19 +671,19 @@ function CallStage({
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
<div className={'grid h-full gap-2 ' + gridClass}>
{tiles.map((p) => (
<CallParticipantTile
key={p.userId}
userId={p.userId}
displayName={p.displayName}
avatarUrl={p.avatarUrl}
me={p.self}
muted={p.muted}
speaking={activeSpeakers.has(p.userId)}
sharing={p.sharing}
video={p.video}
e2ee={e2ee}
onClick={() => onFocusTile(p.userId)}
/>
<div key={p.id} className="[&>div]:h-full [&>div]:w-full">
<TileRender
tile={p}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(p.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/>
</div>
))}
</div>
</div>
@@ -399,57 +693,57 @@ function CallStage({
function FocusedTile({
tile,
e2ee,
speaking,
activeSpeakers,
remoteScreenShares,
conversationMembers,
}: {
tile: Tile;
e2ee: boolean;
speaking: boolean;
activeSpeakers: Set<string>;
remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers'];
}) {
// When the focused participant is remotely sharing their screen, embed the
// real video stream rather than the fake-window placeholder.
if (tile.remoteSharing) {
const share = remoteScreenShares.find((s) => s.participantId === tile.userId);
const member = conversationMembers.find((m) => m.userId === tile.userId);
if (share) {
return (
<div className="h-full">
<ScreenShareViewer
share={share}
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
displayName={member?.profile?.displayName ?? tile.displayName}
/>
</div>
);
}
}
return (
<div className="h-full">
<CallParticipantTile
userId={tile.userId}
displayName={tile.displayName}
avatarUrl={tile.avatarUrl}
me={tile.self}
muted={tile.muted}
speaking={speaking}
sharing={tile.sharing}
video={tile.video}
<div className="h-full [&>div]:h-full">
<TileRender
tile={tile}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
focused
/>
</div>
);
}
const GRID_PAGE_SIZE = 12;
function gridColsFor(n: number): string {
if (n <= 1) return 'grid-cols-1';
if (n === 2) return 'grid-cols-2';
if (n === 3) return 'grid-cols-3';
// Explicit `grid-rows-*` so cells get a defined height (1fr of available
// space). Without this, implicit rows default to auto → they size to
// content, and a video element's intrinsic size blows the tile past the
// container bounds (overlapping the toolbar below).
if (n <= 1) return 'grid-cols-1 grid-rows-1';
if (n === 2) return 'grid-cols-2 grid-rows-1';
if (n === 3) return 'grid-cols-3 grid-rows-1';
if (n === 4) return 'grid-cols-2 grid-rows-2';
return 'grid-cols-3 grid-rows-2';
if (n <= 6) return 'grid-cols-3 grid-rows-2';
if (n <= 9) return 'grid-cols-3 grid-rows-3';
return 'grid-cols-4 grid-rows-3';
}
// Promote self + active speakers to the front of the tile list. Stable
// otherwise. Used by both pagination (so page 1 always carries the most
// "useful" tiles) and active-speaker promotion in fullscreen.
function prioritizeTiles(tiles: Tile[], activeSpeakers: Set<string>): Tile[] {
const score = (t: Tile): number => {
if (t.self) return 3;
if (activeSpeakers.has(t.userId)) return 2;
if (t.kind === 'screen') return 1;
return 0;
};
return [...tiles].sort((a, b) => score(b) - score(a));
}
// ---------------------------------------------------------------------------
@@ -465,6 +759,7 @@ interface FullscreenProps {
e2ee: boolean;
onExit: () => void;
onFocusTile: (id: string) => void;
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
controls: React.ReactNode;
}
@@ -477,99 +772,142 @@ function FullscreenCall({
e2ee,
onExit: _onExit,
onFocusTile,
onTileContextMenu,
controls,
}: FullscreenProps) {
const others = speaker ? tiles.filter((p) => p.userId !== speaker.userId) : tiles;
const [hintGone, setHintGone] = useState(false);
const [page, setPage] = useState(0);
useEffect(() => {
const id = window.setTimeout(() => setHintGone(true), 3500);
return () => window.clearTimeout(id);
}, []);
const hasFocus = speaker !== undefined;
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
// Active-speaker reorder + paginate. When more than GRID_PAGE_SIZE tiles
// exist, slice them into pages. Reset to page 0 if the page count drops
// below the current page (someone left).
const sortedGridTiles = useMemo(
() => prioritizeTiles(tiles, activeSpeakers),
[tiles, activeSpeakers],
);
const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE));
useEffect(() => {
if (page >= pageCount) setPage(0);
}, [pageCount, page]);
const visibleTiles = sortedGridTiles.slice(
page * GRID_PAGE_SIZE,
(page + 1) * GRID_PAGE_SIZE,
);
const gridClass = gridColsFor(visibleTiles.length);
return (
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
<div className="relative min-h-0 flex-1">
{speaker && (
<div className="absolute inset-0">
{speaker.remoteSharing ? (
<FullscreenShare speaker={speaker} remoteScreenShares={remoteScreenShares} conversationMembers={conversationMembers} />
) : (
<div className="h-full w-full [&>div]:rounded-none [&>div]:border-0">
<CallParticipantTile
userId={speaker.userId}
displayName={speaker.displayName}
avatarUrl={speaker.avatarUrl}
me={speaker.self}
muted={speaker.muted}
speaking={activeSpeakers.has(speaker.userId)}
sharing={speaker.sharing}
video={speaker.video}
e2ee={e2ee}
focused
/>
{/* Content area. pb-24 reserves ~96px space at the bottom for the
floating controls bar so tiles never sit behind it. */}
<div className="relative flex min-h-0 flex-1 flex-col pb-24">
{hasFocus ? (
<>
<div
className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
onClick={() => onFocusTile(speaker!.id)}
title="Zurück zur Übersicht"
>
<TileRender
tile={speaker!}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
focused
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker!, e) }
: {})}
/>
</div>
{others.length > 0 && (
<div className="flex h-[160px] shrink-0 gap-2.5 overflow-x-auto px-4 pb-2">
{others.map((p) => (
<div
key={p.id}
className="h-full w-[220px] shrink-0 [&>div]:h-full [&>div]:w-full"
>
<TileRender
tile={p}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
size="small"
onClick={() => onFocusTile(p.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/>
</div>
))}
</div>
)}
</>
) : (
<div className="min-h-0 flex-1 p-4">
<div className={'grid h-full gap-2 ' + gridClass}>
{visibleTiles.map((p) => (
<div key={p.id} className="min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full">
<TileRender
tile={p}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(p.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/>
</div>
))}
</div>
{pageCount > 1 && (
<div className="mt-2 flex items-center justify-center gap-3 text-xs text-fg-muted">
<button
type="button"
onClick={() => setPage((p) => (p === 0 ? pageCount - 1 : p - 1))}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-1 hover:bg-surface-3"
aria-label="Vorherige Seite"
>
</button>
<span className="tabular-nums">
{page + 1} / {pageCount}
</span>
<button
type="button"
onClick={() => setPage((p) => (p + 1) % pageCount)}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-1 hover:bg-surface-3"
aria-label="Nächste Seite"
>
</button>
</div>
)}
</div>
)}
{others.length > 0 && (
<div className="absolute bottom-24 right-4 flex w-[180px] flex-col gap-2">
{others.slice(0, 4).map((p) => (
<div key={p.userId} className="h-[100px] backdrop-blur-xl">
<CallParticipantTile
userId={p.userId}
displayName={p.displayName}
avatarUrl={p.avatarUrl}
me={p.self}
muted={p.muted}
speaking={activeSpeakers.has(p.userId)}
sharing={p.sharing}
video={p.video}
e2ee={e2ee}
size="small"
onClick={() => onFocusTile(p.userId)}
/>
</div>
))}
</div>
)}
{!hintGone && (
<div
aria-hidden="true"
className="pointer-events-none absolute left-1/2 top-5 z-10 animate-fs-hint rounded-lg border border-white/10 bg-black/60 px-3.5 py-1.5 text-[11px] font-medium tracking-wide text-white/70 backdrop-blur-md"
>
Esc zum Verlassen
</div>
)}
<div className="pointer-events-auto absolute bottom-4 left-1/2 -translate-x-1/2">
{controls}
</div>
</div>
</div>
);
}
function FullscreenShare({
speaker,
remoteScreenShares,
conversationMembers,
}: {
speaker: Tile;
remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers'];
}) {
const share = remoteScreenShares.find((s) => s.participantId === speaker.userId);
const member = conversationMembers.find((m) => m.userId === speaker.userId);
if (!share) return null;
return (
<div className="h-full w-full">
<ScreenShareViewer
share={share}
avatarUrl={member?.profile?.avatarUrl ?? speaker.avatarUrl}
displayName={member?.profile?.displayName ?? speaker.displayName}
/>
{!hintGone && (
<div
aria-hidden="true"
className="pointer-events-none absolute left-1/2 top-5 z-10 animate-fs-hint rounded-lg border border-white/10 bg-black/60 px-3.5 py-1.5 text-[11px] font-medium tracking-wide text-white/70 backdrop-blur-md"
>
Esc zum Verlassen
</div>
)}
<div className="pointer-events-auto absolute bottom-4 left-1/2 -translate-x-1/2">
{controls}
</div>
</div>
);
}
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useCall } from '../context/CallContext';
import { useFriendshipsContext } from '../context/FriendshipsContext';
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
import { LockIcon, PhoneIcon, PhoneOffIcon } from './icons';
import { LockIcon, PhoneIcon, PhoneOffIcon, VideoIcon } from './icons';
interface Props {
conversation: ConversationSummary;
@@ -99,23 +99,37 @@ export function IncomingCallPanel({ conversation }: Props) {
</>
)}
</p>
<div className="mt-5 flex w-full max-w-[360px] gap-3">
<div className="mt-5 flex w-full max-w-[440px] flex-wrap gap-3">
<button
type="button"
onClick={rejectIncoming}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-[14px] border border-rose-500/40 bg-transparent px-5 py-3.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/40 dark:text-rose-400"
className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] border border-rose-500/40 bg-transparent px-4 py-3.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/40 dark:text-rose-400"
>
<PhoneOffIcon className="h-4 w-4" />
<span>{t('app:call.decline')}</span>
</button>
<button
type="button"
onClick={() => void acceptIncoming()}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-emerald-600 px-5 py-3.5 text-sm font-semibold text-white shadow-accept-btn transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
onClick={() => void acceptIncoming('audio')}
className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-emerald-600 px-4 py-3.5 text-sm font-semibold text-white shadow-accept-btn transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
>
<PhoneIcon className="h-4 w-4" />
<span>{t('app:call.accept')}</span>
<span>
{state.mediaKind === 'video'
? t('app:call.accept_audio', { defaultValue: 'Nur Audio' })
: t('app:call.accept')}
</span>
</button>
{state.mediaKind === 'video' && (
<button
type="button"
onClick={() => void acceptIncoming('video')}
className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-accent px-4 py-3.5 text-sm font-semibold text-accent-fg shadow-accept-btn transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
<VideoIcon className="h-4 w-4" />
<span>{t('app:call.accept_video', { defaultValue: 'Mit Video' })}</span>
</button>
)}
</div>
</div>
</section>
@@ -0,0 +1,49 @@
import { useLinkPreview } from '../lib/useLinkPreview';
interface Props {
url: string;
}
// Renders a compact OpenGraph preview card under a message bubble. Fetches
// lazily through the edge function; silent when the URL returned no meta.
export function LinkPreviewCard({ url }: Props) {
const preview = useLinkPreview(url);
if (!preview || !preview.ok) return null;
if (!preview.title && !preview.description && !preview.imageUrl) return null;
return (
<a
href={url}
target="_blank"
rel="noreferrer noopener"
className="mt-2 flex max-w-[320px] overflow-hidden rounded-lg border border-line bg-surface-2 text-sm no-underline transition hover:bg-surface-3"
>
{preview.imageUrl && (
<img
src={preview.imageUrl}
alt=""
loading="lazy"
className="h-20 w-20 shrink-0 object-cover"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
)}
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5 p-2.5">
{preview.siteName && (
<p className="truncate text-[10px] uppercase tracking-wider text-fg-muted">
{preview.siteName}
</p>
)}
{preview.title && (
<p className="line-clamp-2 text-sm font-semibold text-fg">
{preview.title}
</p>
)}
{preview.description && (
<p className="line-clamp-2 text-xs text-fg-muted">{preview.description}</p>
)}
</div>
</a>
);
}
@@ -0,0 +1,103 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { Avatar } from './Avatar';
interface Props {
members: ConversationSummary['members'];
query: string;
excludeUserId: string | undefined;
onSelect: (username: string) => void;
onClose: () => void;
}
// Dropdown shown above the composer when the user has typed `@` followed
// by the start of a member name. Keyboard-first — arrow keys move through,
// enter/tab commits, escape cancels.
export function MentionAutocomplete({
members,
query,
excludeUserId,
onSelect,
onClose,
}: Props) {
const q = query.toLowerCase();
const matches = members
.filter((m) => m.userId !== excludeUserId)
.filter((m) => {
if (!q) return true;
const name = (m.profile?.displayName ?? '').toLowerCase();
const handle = (m.profile?.username ?? '').toLowerCase();
return name.includes(q) || handle.includes(q);
})
.slice(0, 8);
const [active, setActive] = useState(0);
useEffect(() => {
setActive(0);
}, [query]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (matches.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive((i) => (i + 1) % matches.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive((i) => (i - 1 + matches.length) % matches.length);
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
const pick = matches[active];
if (pick?.profile?.username) onSelect(pick.profile.username);
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', onKey, true);
return () => {
window.removeEventListener('keydown', onKey, true);
};
}, [matches, active, onSelect, onClose]);
if (matches.length === 0) return null;
return (
<div
role="listbox"
aria-label="Mitglieder"
className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-64 overflow-y-auto rounded-xl border border-line bg-surface-2 p-1 shadow-xl"
>
{matches.map((m, idx) => {
const name = m.profile?.displayName ?? m.profile?.username ?? '?';
const handle = m.profile?.username ?? '';
const isActive = idx === active;
return (
<button
key={m.userId}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => setActive(idx)}
onClick={() => {
if (m.profile?.username) onSelect(m.profile.username);
}}
className={
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
(isActive ? 'bg-accent/20 text-fg' : 'text-fg-muted hover:bg-surface-3')
}
>
<Avatar
displayName={name}
url={m.profile?.avatarUrl ?? null}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
</button>
);
})}
</div>
);
}
+194 -19
View File
@@ -13,7 +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';
@@ -40,6 +46,8 @@ interface Props {
reactions: AggregatedReaction[];
onToggleReaction: (emoji: string) => Promise<void>;
showSeen?: boolean;
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
deliveryState?: 'sent' | 'delivered' | 'read';
/** Resolved quoted message info (parent does the lookup). */
quoted?: QuotedRef | null;
/** Tap-to-jump on quote bubble. Receives the quoted message's id. */
@@ -48,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;
}
@@ -63,10 +73,12 @@ export function MessageBubble({
reactions,
onToggleReaction,
showSeen = false,
deliveryState,
quoted = null,
onJumpToMessage,
onReply,
onForward,
onAvatarClick,
highlighted = false,
}: Props) {
const { t } = useTranslation(['app']);
@@ -88,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 &&
@@ -172,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')}
@@ -265,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
@@ -295,10 +358,27 @@ export function MessageBubble({
<span className="italic opacity-70">cannot decrypt</span>
) : (
<>
{bodyText.length > 0 && <div>{bodyText}</div>}
{attachments.map((a) => (
<AttachmentImage key={a.id} handle={a} />
))}
{bodyText.length > 0 && <div>{renderBodyWithMentions(bodyText)}</div>}
{bodyText.length > 0 &&
(() => {
const url = extractFirstUrl(bodyText);
return url ? <LinkPreviewCard url={url} /> : null;
})()}
{attachments.map((a) => {
if (a.mimeType.startsWith('audio/')) {
return <AttachmentAudio key={a.id} handle={a} />;
}
if (a.mimeType.startsWith('image/')) {
return <AttachmentImage key={a.id} handle={a} />;
}
if (a.mimeType.startsWith('video/')) {
return <AttachmentVideo key={a.id} handle={a} />;
}
if (a.mimeType === 'application/pdf') {
return <AttachmentPdf key={a.id} handle={a} />;
}
return <AttachmentGeneric key={a.id} handle={a} />;
})}
</>
)}
<div
@@ -311,25 +391,39 @@ 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>
)}
{showSeen && mine && !editing && !message.deletedAt && (
<p className="mt-0.5 text-right text-[10px] text-fg-muted">
{t('app:chats.seen')}
</p>
{mine && !editing && !message.deletedAt && deliveryState && (
<div className="mt-0.5 flex items-center justify-end gap-1 text-[10px] text-fg-muted">
<DeliveryTicks state={deliveryState} />
{showSeen && <span>{t('app:chats.seen')}</span>}
</div>
)}
{reactions.length > 0 && !editing && (
<div className={'mt-1 flex flex-wrap gap-1 ' + (mine ? 'justify-end' : 'justify-start')}>
{reactions.map((r) => (
<button
key={r.emoji}
// Keying by emoji+count makes React remount the chip when the
// count flips, replaying the pop animation. Cheap visual cue.
key={r.emoji + ':' + r.count}
type="button"
onClick={() => void onToggleReaction(r.emoji)}
className={
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
'inline-flex origin-bottom animate-reaction-pop cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(r.mine
? 'border-accent/40 bg-accent/20 text-accent'
: 'border-line bg-surface-2 text-fg hover:bg-surface-3')
@@ -428,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}
@@ -499,6 +608,72 @@ function formatDuration(totalSec: number): string {
return m + ':' + s.toString().padStart(2, '0');
}
// Splits body text on `@username` tokens, rendering matches as highlighted
// pills. Username alphabet matches Supabase citext usernames: alphanumerics
// + underscores, length 1..32 (we don't bound here — regex is permissive
// and keys off a leading `@` with an alnum/underscore follow).
const MENTION_RE = /@([A-Za-z0-9_]{1,32})/g;
function renderBodyWithMentions(text: string): React.ReactNode[] {
const out: React.ReactNode[] = [];
let lastIdx = 0;
let m: RegExpExecArray | null;
MENTION_RE.lastIndex = 0;
while ((m = MENTION_RE.exec(text)) !== null) {
if (m.index > lastIdx) out.push(text.slice(lastIdx, m.index));
out.push(
<span
key={m.index + ':' + m[1]}
className="rounded bg-accent/20 px-1 text-accent"
>
{m[0]}
</span>,
);
lastIdx = m.index + m[0].length;
}
if (lastIdx < text.length) out.push(text.slice(lastIdx));
return out;
}
function DeliveryTicks({ state }: { state: 'sent' | 'delivered' | 'read' }) {
// One checkmark for sent, two for delivered/read. Read shifts color to the
// accent to match WhatsApp/Telegram blue-tick convention.
const color =
state === 'read'
? 'text-sky-500 dark:text-sky-400'
: 'text-fg-muted';
return (
<span
aria-label={
state === 'read'
? 'Gelesen'
: state === 'delivered'
? 'Zugestellt'
: 'Gesendet'
}
title={
state === 'read'
? 'Gelesen'
: state === 'delivered'
? 'Zugestellt'
: 'Gesendet'
}
className={'flex items-center ' + color}
>
<svg viewBox="0 0 16 12" width="14" height="10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{state === 'sent' ? (
<polyline points="2 7 6 11 14 1" />
) : (
<>
<polyline points="1 7 5 11 11 2" />
<polyline points="6 11 10 11 14 1" />
</>
)}
</svg>
</span>
);
}
function ActionButton({
label,
onClick,
@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import {
getParticipantVolume,
setParticipantVolume,
subscribeParticipantVolumes,
} from '../lib/participantVolumes';
interface Props {
userId: string;
displayName: string;
x: number;
y: number;
onClose: () => void;
}
const MENU_W = 240;
const MENU_H = 84;
export function ParticipantVolumeMenu({
userId,
displayName,
x,
y,
onClose,
}: Props) {
const [volume, setVolume] = useState<number>(() => getParticipantVolume(userId));
// Re-sync from store in case another menu instance changed the same user.
useEffect(() => subscribeParticipantVolumes(() => {
setVolume(getParticipantVolume(userId));
}), [userId]);
// Outside click + Esc to close.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const onDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (target?.closest('[data-volume-menu]')) return;
onClose();
};
window.addEventListener('keydown', onKey);
window.addEventListener('mousedown', onDown);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onDown);
};
}, [onClose]);
const left = Math.min(Math.max(8, x), window.innerWidth - MENU_W - 8);
const top = Math.min(Math.max(8, y), window.innerHeight - MENU_H - 8);
return createPortal(
<div
data-volume-menu
role="dialog"
aria-label={'Lautstärke ' + displayName}
style={{ left, top, width: MENU_W }}
className="fixed z-[80] rounded-xl border border-line bg-surface-2/95 p-3 shadow-xl backdrop-blur-md"
>
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
<span className="truncate font-semibold text-fg">{displayName}</span>
<span className="tabular-nums text-fg-muted">
{Math.round(volume * 100)}%
</span>
</div>
<input
type="range"
min={0}
max={1}
step={0.01}
value={volume}
onChange={(e) => {
const v = Number(e.target.value);
setVolume(v);
setParticipantVolume(userId, v);
}}
aria-label={'Lautstärke ' + displayName}
className="w-full accent-accent"
/>
</div>,
document.body,
);
}
@@ -0,0 +1,244 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
clearIncomingRingtone,
getIncomingRingtone,
MAX_RINGTONE_BYTES,
saveIncomingRingtone,
type StoredRingtone,
} from '../lib/ringtoneStorage';
import { PhoneIcon, SpinnerIcon, TrashIcon } from './icons';
interface Props {
/** Disable interactions while a parent action is in flight. */
disabled?: boolean;
}
const BYTES_PER_MB = 1024 * 1024;
// UI for the custom incoming-call ringtone. Single file slot. Upload
// validates size + mime and surfaces errors inline. Preview button plays
// the stored blob through a local <audio> element without touching the
// shared ringtone singleton so we don't interfere with a live call.
export function RingtoneSettings({ disabled = false }: Props) {
const { t } = useTranslation(['app']);
const inputRef = useRef<HTMLInputElement | null>(null);
const previewRef = useRef<HTMLAudioElement | null>(null);
const previewUrlRef = useRef<string | null>(null);
const [current, setCurrent] = useState<StoredRingtone | null>(null);
const [busy, setBusy] = useState(false);
const [playing, setPlaying] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const cur = await getIncomingRingtone();
setCurrent(cur);
} catch (err: unknown) {
console.error('getIncomingRingtone failed', err);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
// Revoke any preview blob URL when the component unmounts so long-lived
// pages don't leak memory.
return () => {
stopPreview();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function stopPreview(): void {
const el = previewRef.current;
if (el) {
try {
el.pause();
} catch {
/* ignore */
}
el.src = '';
}
previewRef.current = null;
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
setPlaying(false);
}
async function handleFile(file: File): Promise<void> {
setError(null);
setBusy(true);
try {
await saveIncomingRingtone(file);
await refresh();
} catch (err: unknown) {
const code = err instanceof Error ? err.message : 'upload_failed';
if (code === 'ringtone_too_large') {
setError(
t('app:settings.ringtone_error_too_large', {
defaultValue: 'Datei zu groß (max {{max}} 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 8 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.',
})}
</p>
</div>
);
}
@@ -0,0 +1,262 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
type DisplaySurfaceHint,
getPresetParams,
getScreenShareSettings,
PRESET_ORDER,
type ScreenSharePreset,
updateScreenShareSettings,
} from '../lib/screenShareSettings';
import {
MonitorShareIcon,
SpinnerIcon,
XIcon,
} from './icons';
interface Props {
open: boolean;
onClose: () => void;
onStart: (opts: {
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
framerate: number | null;
}) => Promise<void>;
}
const FRAMERATE_OPTIONS: ReadonlyArray<{ value: number | null; label: string }> = [
{ value: null, label: 'Preset-Standard' },
{ value: 15, label: '15 fps' },
{ value: 30, label: '30 fps' },
{ value: 60, label: '60 fps' },
];
// Discord-style pre-share dialog. The OS still owns the final source picker
// (browser/OS limitation — only Chrome/Edge plus a native plugin can enumerate
// windows from JS), but we pre-filter with the `displaySurface` hint and lock
// in quality + framerate up-front so the user doesn't have to re-open the
// system picker to adjust them mid-call.
export function ScreenShareDialog({ open, onClose, onStart }: Props) {
const { t } = useTranslation(['app']);
const initial = getScreenShareSettings();
const [surface, setSurface] = useState<DisplaySurfaceHint>(initial.displaySurface);
const [preset, setPreset] = useState<ScreenSharePreset>(initial.preset);
const [framerate, setFramerate] = useState<number | null>(initial.framerateOverride);
const [includeAudio, setIncludeAudio] = useState<boolean>(initial.includeSystemAudio);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!open) return null;
async function handleStart() {
setBusy(true);
setError(null);
try {
// Persist the audio choice alongside the other picker prefs so the
// upstream startScreenShare picks it up on its settings read.
updateScreenShareSettings({ includeSystemAudio: includeAudio });
await onStart({ preset, displaySurface: surface, framerate });
onClose();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'screenshare failed');
} finally {
setBusy(false);
}
}
const presetParams = getPresetParams(preset);
const effectiveFps = framerate ?? presetParams.framerate;
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
>
<header className="flex items-center justify-between border-b border-line px-5 py-3">
<div className="flex items-center gap-2">
<MonitorShareIcon className="h-4 w-4 text-accent" />
<h3 className="font-display text-sm font-semibold text-fg">
{t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
</h3>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
</header>
<div className="space-y-5 p-5">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t('app:call.share_surface', { defaultValue: 'Quelle' })}
</p>
<div className="grid grid-cols-3 gap-2">
<SurfaceOption
active={surface === null}
onClick={() => setSurface(null)}
label={t('app:call.share_any', { defaultValue: 'Alle anzeigen' })}
sub={t('app:call.share_any_sub', { defaultValue: 'Bildschirm + Fenster' })}
/>
<SurfaceOption
active={surface === 'monitor'}
onClick={() => setSurface('monitor')}
label={t('app:call.share_monitor', { defaultValue: 'Bildschirm' })}
sub={t('app:call.share_monitor_sub', { defaultValue: 'Ganzer Monitor' })}
/>
<SurfaceOption
active={surface === 'window'}
onClick={() => setSurface('window')}
label={t('app:call.share_window', { defaultValue: 'Fenster' })}
sub={t('app:call.share_window_sub', { defaultValue: 'Einzelnes Fenster' })}
/>
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t('app:call.share_quality', { defaultValue: 'Qualität' })}
</p>
<select
value={preset}
onChange={(e) => setPreset(e.target.value as ScreenSharePreset)}
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
>
{PRESET_ORDER.map((p) => (
<option key={p} value={p}>
{getPresetParams(p).label} · {getPresetParams(p).bitrateKbps} kbps
</option>
))}
</select>
</div>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t('app:call.share_fps', { defaultValue: 'Bildrate' })}
</p>
<div className="flex flex-wrap gap-2">
{FRAMERATE_OPTIONS.map((opt) => (
<button
key={String(opt.value)}
type="button"
onClick={() => setFramerate(opt.value)}
className={
'cursor-pointer rounded-lg border px-3 py-1.5 text-xs font-medium transition ' +
(framerate === opt.value
? 'border-accent bg-accent/15 text-accent'
: 'border-line bg-surface-2 text-fg hover:bg-surface')
}
>
{opt.label}
</button>
))}
</div>
<p className="mt-2 text-[11px] text-fg-muted">
{t('app:call.share_fps_effective', {
defaultValue: 'Effektiv: {{fps}} fps',
fps: effectiveFps,
})}
</p>
</div>
<div>
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2 text-xs hover:bg-surface">
<input
type="checkbox"
checked={includeAudio}
onChange={(e) => setIncludeAudio(e.target.checked)}
className="mt-0.5 accent-accent"
/>
<span className="min-w-0 flex-1">
<span className="block font-semibold text-fg">
{t('app:call.share_system_audio', {
defaultValue: 'System-Sound mit übertragen',
})}
</span>
<span className="mt-0.5 block text-[11px] text-fg-muted">
{t('app:call.share_system_audio_hint', {
defaultValue:
'"Go Live" — Systemsound wird mitgesendet. Auf macOS braucht das extra Berechtigungen; wird sonst stumm geteilt.',
})}
</span>
</span>
</label>
</div>
{error && (
<p role="alert" className="text-sm text-rose-600 dark:text-rose-300">
{error}
</p>
)}
<p className="text-[11px] text-fg-muted">
{t('app:call.share_hint', {
defaultValue:
'Nach "Teilen starten" öffnet das Betriebssystem den Quellen-Picker. Qualität + Bildrate werden bereits angewendet.',
})}
</p>
</div>
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
<button
type="button"
onClick={onClose}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
>
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
</button>
<button
type="button"
onClick={() => void handleStart()}
disabled={busy}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
<span>
{t('app:call.share_start', { defaultValue: 'Teilen starten' })}
</span>
</button>
</footer>
</div>
</div>
);
}
function SurfaceOption({
active,
onClick,
label,
sub,
}: {
active: boolean;
onClick: () => void;
label: string;
sub: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={
'flex flex-col items-start gap-0.5 rounded-lg border p-2.5 text-left transition ' +
(active
? 'border-accent bg-accent/10 text-fg'
: 'border-line bg-surface-2 text-fg hover:bg-surface')
}
>
<span className="text-xs font-semibold">{label}</span>
<span className="text-[10px] text-fg-muted">{sub}</span>
</button>
);
}
@@ -1,6 +1,5 @@
import type { RemoteTrack } from 'livekit-client';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { RemoteScreenShare } from '../context/CallContext';
@@ -51,13 +50,13 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
setIsFullscreen((v) => !v);
};
const viewerNode = (
return (
<div
ref={containerRef}
className={
isFullscreen
? 'fixed inset-0 z-[60] flex h-screen w-screen flex-col overflow-hidden border-0 bg-black'
: 'overflow-hidden rounded-xl border border-emerald-500/30 bg-black'
: 'flex h-full w-full flex-col overflow-hidden rounded-xl border border-emerald-500/30 bg-black'
}
>
<div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/20 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-700 dark:text-emerald-200">
@@ -104,10 +103,7 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
playsInline
muted
onDoubleClick={toggleFullscreen}
className={
'block cursor-zoom-in bg-black ' +
(isFullscreen ? 'h-full w-full flex-1 object-contain' : 'w-full')
}
className="block h-full w-full flex-1 cursor-zoom-in bg-black object-contain"
/>
) : (
<button
@@ -132,14 +128,6 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
)}
</div>
);
// When in fullscreen, portal out of the call-panel subtree into <body> so
// no ancestor can clip or stack below us. Sidebar/chat-list are siblings of
// AppShell's root — portalled node sits above them via z-[60].
if (isFullscreen) {
return createPortal(viewerNode, document.body);
}
return viewerNode;
}
function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {
@@ -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,
);
}
@@ -0,0 +1,252 @@
import { useEffect, useRef, useState } from 'react';
import { MicIcon, SpinnerIcon, XIcon } from './icons';
interface Props {
/** Called with the recorded audio file when the user confirms. The button
* ships as a single-attachment message, so the parent can feed it into
* the normal send flow. */
onComplete: (file: File) => Promise<void> | void;
disabled?: boolean;
}
// Single-button voice recorder. Click to start; shows an inline pill with
// elapsed time + stop + cancel while recording. On stop, hands a File to the
// parent. No in-place preview yet — we lean on the optimistic message bubble
// to appear once the parent sends.
const MAX_RECORD_SEC = 60;
export function VoiceRecorder({ onComplete, disabled = false }: Props) {
const [state, setState] = useState<'idle' | 'recording' | 'finalizing'>('idle');
const [elapsedSec, setElapsedSec] = useState(0);
const [level, setLevel] = useState(0);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const startedAtRef = useRef<number>(0);
const cancelledRef = useRef(false);
const audioCtxRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const rafRef = useRef<number>(0);
// Tick elapsed + auto-stop at MAX_RECORD_SEC.
useEffect(() => {
if (state !== 'recording') return;
const id = window.setInterval(() => {
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000);
setElapsedSec(sec);
if (sec >= MAX_RECORD_SEC) {
const rec = recorderRef.current;
if (rec && rec.state !== 'inactive') rec.stop();
}
}, 200);
return () => {
window.clearInterval(id);
};
}, [state]);
// Live mic level meter via Web Audio AnalyserNode. RMS on 0..1, smoothed.
useEffect(() => {
if (state !== 'recording') return;
const analyser = analyserRef.current;
if (!analyser) return;
const buf = new Uint8Array(analyser.frequencyBinCount);
let cancelled = false;
const tick = () => {
if (cancelled) return;
analyser.getByteFrequencyData(buf as Uint8Array<ArrayBuffer>);
let sum = 0;
for (let i = 0; i < buf.length; i++) sum += buf[i]!;
setLevel(sum / (buf.length * 255));
rafRef.current = window.requestAnimationFrame(tick);
};
rafRef.current = window.requestAnimationFrame(tick);
return () => {
cancelled = true;
window.cancelAnimationFrame(rafRef.current);
};
}, [state]);
const stopStream = () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
if (audioCtxRef.current) {
void audioCtxRef.current.close().catch(() => {});
audioCtxRef.current = null;
}
analyserRef.current = null;
setLevel(0);
};
const start = async () => {
if (disabled) return;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
try {
const ctx = new (window.AudioContext ||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.4;
source.connect(analyser);
audioCtxRef.current = ctx;
analyserRef.current = analyser;
} catch {
/* Analyser is best-effort; recording still works without level meter. */
}
const mime = pickMime();
const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
recorderRef.current = rec;
chunksRef.current = [];
cancelledRef.current = false;
rec.ondataavailable = (ev: BlobEvent) => {
if (ev.data && ev.data.size > 0) chunksRef.current.push(ev.data);
};
rec.onstop = () => {
const blob = new Blob(chunksRef.current, { type: rec.mimeType || 'audio/webm' });
chunksRef.current = [];
stopStream();
if (cancelledRef.current || blob.size === 0) {
setState('idle');
setElapsedSec(0);
return;
}
setState('finalizing');
const ext = extFor(blob.type);
const filename = 'voice-' + new Date().toISOString().replace(/[:.]/g, '-') + '.' + ext;
const file = new File([blob], filename, { type: blob.type });
void Promise.resolve(onComplete(file)).finally(() => {
setState('idle');
setElapsedSec(0);
});
};
rec.start();
startedAtRef.current = Date.now();
setElapsedSec(0);
setState('recording');
} catch (err: unknown) {
stopStream();
// Surface device-permission denials or capture failures via console;
// the composer doesn't have space for inline errors here and the
// browser already shows a system-level permission prompt.
console.error('VoiceRecorder.start failed', err);
}
};
const confirm = () => {
const rec = recorderRef.current;
if (!rec) return;
if (rec.state !== 'inactive') rec.stop();
};
const cancel = () => {
cancelledRef.current = true;
const rec = recorderRef.current;
if (rec && rec.state !== 'inactive') rec.stop();
else {
stopStream();
setState('idle');
setElapsedSec(0);
}
};
if (state === 'idle') {
return (
<button
type="button"
onClick={() => void start()}
disabled={disabled}
aria-label="Sprachnachricht aufnehmen"
title="Sprachnachricht aufnehmen"
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-surface-2 text-fg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
>
<MicIcon className="h-4 w-4" />
</button>
);
}
if (state === 'finalizing') {
return (
<div className="inline-flex h-11 items-center gap-2 rounded-lg bg-surface-2 px-3 text-xs text-fg-muted">
<SpinnerIcon className="h-4 w-4" />
<span>Wird gesendet</span>
</div>
);
}
// Visual level: scale from 0..1 → 0..100% width. Floor at 4% so the bar
// stays visible when silent.
const levelPct = Math.max(4, Math.min(100, Math.round(level * 180)));
const remaining = Math.max(0, MAX_RECORD_SEC - elapsedSec);
return (
<div className="inline-flex h-11 items-center gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 text-xs font-semibold text-rose-700 dark:text-rose-200">
<span className="h-2 w-2 animate-pulse rounded-full bg-rose-500" />
<span className="tabular-nums">{formatTime(elapsedSec)}</span>
<div
aria-hidden="true"
className="relative h-1.5 w-20 overflow-hidden rounded-full bg-rose-500/20"
>
<span
className="absolute inset-y-0 left-0 rounded-full bg-rose-500 transition-[width] duration-75"
style={{ width: levelPct + '%' }}
/>
</div>
<span className="tabular-nums text-[10px] text-rose-700/70 dark:text-rose-200/70">
{remaining}s
</span>
<button
type="button"
onClick={cancel}
aria-label="Aufnahme abbrechen"
title="Abbrechen"
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
>
<XIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={confirm}
aria-label="Aufnahme senden"
title="Senden"
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md bg-accent text-accent-fg transition hover:brightness-110"
>
<MicIcon className="h-3.5 w-3.5" />
</button>
</div>
);
}
function pickMime(): string | null {
const candidates = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
'audio/mp4',
];
for (const c of candidates) {
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(c)) return c;
}
return null;
}
function extFor(mime: string): string {
if (mime.includes('webm')) return 'webm';
if (mime.includes('ogg')) return 'ogg';
if (mime.includes('mp4')) return 'm4a';
return 'bin';
}
function formatTime(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return m + ':' + s.toString().padStart(2, '0');
}
+53 -54
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}>
@@ -451,6 +461,28 @@ export function MoreVerticalIcon(props: IconProps) {
);
}
export function HeadphonesIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M3 14v-2a9 9 0 0 1 18 0v2" />
<path d="M3 14h4v7H5a2 2 0 0 1-2-2Z" />
<path d="M21 14h-4v7h2a2 2 0 0 0 2-2Z" />
</Base>
);
}
export function HeadphonesOffIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M3 14v-2a9 9 0 0 1 11.3-8.7" />
<path d="M21 14v-2a9 9 0 0 0-1.5-5" />
<path d="M3 14h4v7H5a2 2 0 0 1-2-2Z" />
<path d="M21 14h-4v7h2a2 2 0 0 0 2-2Z" />
<path d="M3 3 21 21" />
</Base>
);
}
export function ReplyIcon(props: IconProps) {
return (
<Base {...props}>
@@ -487,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)">
<path
d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z"
fill="#7c4dff"
/>
<path
d="M-4 32 Q 16 18 32 32 T 68 32"
stroke="#a78bfa"
strokeWidth="2"
fill="none"
/>
</g>
<polygon
points="32,5 57,19 57,45 32,59 7,45 7,19"
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa" />
<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="#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)">
<path
d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z"
fill="#7c4dff"
/>
<path
d="M-4 32 Q 16 18 32 32 T 68 32"
stroke="#a78bfa"
strokeWidth="2"
fill="none"
/>
</g>
<polygon
points="32,5 57,19 57,45 32,59 7,45 7,19"
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa" />
<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="#a78bfa"
strokeWidth="1.5"
opacity="0.4"
stroke="#fff"
strokeWidth="5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<text
x="78"
+46
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';
@@ -20,6 +21,7 @@ import { useTranslation } from 'react-i18next';
import { findExistingDevice } from '../lib/device';
import { setSecretStoreUser } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import { registerWebPush } from '../lib/webPush';
interface AuthContextValue {
session: Session | null;
@@ -135,6 +137,50 @@ export function AuthProvider({ children }: { children: ReactNode }) {
});
}, [session, refreshProfile, refreshDevice]);
// Best-effort web-push registration once we know the device id. No-op on
// Tauri (uses native notifications) or when VITE_VAPID_PUBLIC_KEY is unset.
useEffect(() => {
if (!device?.id) return;
void registerWebPush(device.id);
}, [device?.id]);
// 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);
}, []);
+538 -81
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,16 +41,33 @@ 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,
getScreenShareSettings,
type ScreenSharePreset,
updateScreenShareSettings,
} from '../lib/screenShareSettings';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
@@ -102,6 +120,15 @@ interface CallContextValue {
isMuted: boolean;
isE2EEActive: boolean;
isScreenSharing: boolean;
isCameraEnabled: boolean;
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
@@ -113,14 +140,37 @@ interface CallContextValue {
// Actions:
startCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
acceptIncoming: () => Promise<void>;
acceptIncoming: (override?: CallKind) => Promise<void>;
rejectIncoming: () => void;
hangup: () => Promise<void>;
toggleMute: () => void;
toggleScreenShare: () => Promise<void>;
startScreenShare: (
overrides?: Partial<{
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
framerate: number | null;
}>,
) => Promise<void>;
stopScreenShare: () => Promise<void>;
toggleCamera: () => Promise<void>;
toggleDeafen: () => void;
dismissLastCall: () => void;
setCallMode: (mode: CallMode) => void;
setFocusedId: (id: string | null) => void;
/** 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>;
@@ -139,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;
@@ -149,16 +199,24 @@ export function CallProvider({ children }: { children: ReactNode }) {
const [isMuted, setIsMuted] = useState(false);
const [isE2EEActive, setIsE2EEActive] = useState(false);
const [isScreenSharing, setIsScreenSharing] = useState(false);
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
const [isDeafened, setIsDeafened] = useState(false);
const [remoteScreenShares, setRemoteScreenShares] = useState<RemoteScreenShare[]>([]);
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
const [callMode, setCallModeState] = useState<CallMode>('grid');
const [focusedId, setFocusedIdState] = useState<string | null>(null);
const [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);
@@ -167,12 +225,25 @@ export function CallProvider({ children }: { children: ReactNode }) {
// For 1:1 calls the set has one entry so behaviour is unchanged; for
// groups, a single reject doesn't terminate the call while others ring.
const pendingPeersRef = useRef<Set<string>>(new Set());
// identity -> their current deafen state. Populated via LiveKit data
// channel messages ({ type: 'presence', deafened: bool }). Exposed as
// state so consumer components re-render on change.
const [remoteDeafen, setRemoteDeafen] = useState<Record<string, boolean>>({});
const [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 -----------------------------------------------------------
@@ -243,8 +314,29 @@ export function CallProvider({ children }: { children: ReactNode }) {
setRemoteParticipants([]);
setRemoteScreenShares([]);
setIsScreenSharing(false);
setIsCameraEnabled(false);
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 {
@@ -406,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' &&
@@ -463,6 +555,58 @@ export function CallProvider({ children }: { children: ReactNode }) {
}
});
// Mute/unmute a camera doesn't publish or unpublish — the publication
// stays, just its `muted` flag flips. Without this listener, remote
// participants who toggle video mid-call appear as a frozen last frame
// or (worse) a black tile on every other client. Bumping the
// remoteParticipants state reference forces buildTiles to re-read
// `isCameraEnabled` and swap to the avatar placeholder.
const bumpParticipants = () => {
setRemoteParticipants(Array.from(r.remoteParticipants.values()));
};
r.on(RoomEvent.TrackMuted, bumpParticipants);
r.on(RoomEvent.TrackUnmuted, bumpParticipants);
// Remote deafen state is broadcast via the LiveKit data channel. We
// store incoming states in `remoteDeafenMapRef` and bump participants
// so buildTiles re-reads it.
r.on(
RoomEvent.DataReceived,
(payload: Uint8Array, participant?: RemoteParticipant | undefined) => {
if (!participant?.identity) return;
try {
const text = new TextDecoder().decode(payload);
const msg = JSON.parse(text) as {
type?: string;
deafened?: boolean;
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 presence (deafen + mute) so
// they know immediately instead of waiting for the next toggle.
r.on(RoomEvent.ParticipantConnected, () => {
void broadcastPresence(r, deafenedActive, mutedRef.current);
});
// Track my own screen-share state via LocalTrack events so the toggle
// stays in sync if the user stops sharing via the browser's native UI.
r.on(RoomEvent.LocalTrackPublished, (publication) => {
@@ -489,23 +633,53 @@ export function CallProvider({ children }: { children: ReactNode }) {
setIsE2EEActive(false);
}
try {
const inputId = getAudioSettings().inputDeviceId;
await r.localParticipant.setMicrophoneEnabled(true, {
echoCancellation: aParams.echoCancellation,
noiseSuppression: aParams.noiseSuppression,
autoGainControl: aParams.autoGainControl,
channelCount: aParams.stereo ? 2 : 1,
sampleRate: aParams.sampleRateHz,
// Plain string maps to `ideal` — if the device is gone we fall back
// to OS default instead of throwing NotFoundError.
...(inputId ? { deviceId: inputId } : {}),
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: nsEffective,
autoGainControl: aParams.autoGainControl,
channelCount: aParams.stereo ? 2 : 1,
sampleRate: aParams.sampleRateHz,
...(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 {
await r.localParticipant.setCameraEnabled(true);
setIsCameraEnabled(true);
} catch (camErr: unknown) {
console.error('setCameraEnabled failed', camErr);
}
@@ -650,23 +824,31 @@ export function CallProvider({ children }: { children: ReactNode }) {
[myId, joinRoom, disconnectRoom],
);
const acceptIncoming = useCallback(async () => {
const s = stateRef.current;
if (s.kind !== 'incoming' || !myId) return;
const { callId, conversationId, mediaKind } = s;
everConnectedRef.current = false;
setLastCallConversationId(null);
setState({ kind: 'connecting', callId, conversationId, mediaKind });
try {
await joinRoom(conversationId, mediaKind, callId);
} catch (err: unknown) {
setState({
kind: 'error',
message: err instanceof Error ? err.message : 'join failed',
});
await disconnectRoom();
}
}, [myId, joinRoom, disconnectRoom]);
const acceptIncoming = useCallback(
async (override?: CallKind) => {
const s = stateRef.current;
if (s.kind !== 'incoming' || !myId) return;
const { callId, conversationId } = s;
// Caller's `mediaKind` is the INVITE kind (what they started with). The
// receiver can accept with audio even if the caller rang as video, or
// upgrade an audio invite to video on accept. `override` picks the
// receiver's choice.
const mediaKind: CallKind = override ?? s.mediaKind;
everConnectedRef.current = false;
setLastCallConversationId(null);
setState({ kind: 'connecting', callId, conversationId, mediaKind });
try {
await joinRoom(conversationId, mediaKind, callId);
} catch (err: unknown) {
setState({
kind: 'error',
message: err instanceof Error ? err.message : 'join failed',
});
await disconnectRoom();
}
},
[myId, joinRoom, disconnectRoom],
);
const rejectIncoming = useCallback(() => {
const s = stateRef.current;
@@ -731,45 +913,167 @@ export function CallProvider({ children }: { children: ReactNode }) {
}, []);
const toggleMute = useCallback(() => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
const shouldEnable = !lp.isMicrophoneEnabled;
void lp.setMicrophoneEnabled(shouldEnable).then(() => {
setIsMuted(!shouldEnable);
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) void broadcastPresence(r, deafenedActive, nextMuted);
return nextMuted;
});
}, []);
const toggleScreenShare = useCallback(async () => {
const startScreenShare = useCallback(
async (
overrides?: Partial<{
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
framerate: number | null;
}>,
) => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
if (lp.isScreenShareEnabled) return;
// Persist the user's choice so subsequent shares use the same config
// without re-opening the picker unless they want to change something.
const settings = getScreenShareSettings();
const preset = overrides?.preset ?? settings.preset;
const displaySurface =
overrides?.displaySurface !== undefined
? overrides.displaySurface
: settings.displaySurface;
const framerateOverride =
overrides?.framerate !== undefined
? overrides.framerate
: settings.framerateOverride;
updateScreenShareSettings({ preset, displaySurface, framerateOverride });
const ssParams = getPresetParams(preset);
const fps = framerateOverride ?? ssParams.framerate;
try {
await lp.setScreenShareEnabled(true, {
// "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: {
width: ssParams.dims.width,
height: ssParams.dims.height,
frameRate: fps,
},
}
: {
resolution: {
width: 3840,
height: 2160,
frameRate: fps,
},
}),
// Hints the OS picker to pre-filter by source kind. `null` = no
// filter (show both). Cast because TS lib.dom doesn't know the
// field yet on all branches.
...(displaySurface
? ({ displaySurface } as { displaySurface: DisplaySurfaceHint })
: {}),
contentHint: 'detail',
});
setIsScreenSharing(true);
} catch (err: unknown) {
console.error('setScreenShareEnabled failed', err);
}
},
[],
);
const stopScreenShare = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
const nextOn = !lp.isScreenShareEnabled;
if (!lp.isScreenShareEnabled) return;
try {
const ssParams = getPresetParams(getScreenShareSettings().preset);
await lp.setScreenShareEnabled(nextOn, {
audio: false,
// Omitting `resolution` lets the browser return native source size —
// best possible input quality. Fixed presets pass explicit dims so
// the encoder has a predictable target.
...(ssParams.dims
? {
resolution: {
width: ssParams.dims.width,
height: ssParams.dims.height,
frameRate: ssParams.framerate,
},
}
: {}),
contentHint: 'detail',
});
setIsScreenSharing(nextOn);
await lp.setScreenShareEnabled(false);
setIsScreenSharing(false);
} catch (err: unknown) {
console.error('setScreenShareEnabled failed', err);
// User cancelled or permission denied — leave state as-is.
console.error('stopScreenShare failed', err);
}
}, []);
// Legacy toggle kept for convenience elsewhere — opens/closes with the
// last-persisted settings and no picker UI.
const toggleScreenShare = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
if (r.localParticipant.isScreenShareEnabled) {
await stopScreenShare();
} else {
await startScreenShare();
}
}, [startScreenShare, stopScreenShare]);
const toggleDeafen = useCallback(() => {
setIsDeafened((prev) => {
const next = !prev;
deafenedActive = next;
// Apply to every currently-attached remote-audio element. Fresh tracks
// that attach during a deafened session are muted in attachTrack above.
const els = document.querySelectorAll<HTMLAudioElement>(
'audio[data-livekit-track]',
);
els.forEach((el) => {
el.muted = next;
});
// Broadcast via LiveKit data channel so peers' UIs can show the
// headphones-off badge. Data channel works on any LiveKit server
// version, unlike `setAttributes` which requires a newer server.
const r = roomRef.current;
if (r) void broadcastPresence(r, next, mutedRef.current);
return next;
});
}, []);
const toggleCamera = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
const nextOn = !lp.isCameraEnabled;
try {
await lp.setCameraEnabled(nextOn);
setIsCameraEnabled(nextOn);
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
// publication state so the button doesn't lie.
setIsCameraEnabled(lp.isCameraEnabled);
}
}, []);
// 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
@@ -785,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 = () => {
@@ -916,14 +1225,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
conv?.peer?.displayName ??
'…';
const isGroup = conv?.type === 'group';
void notify({
title: isGroup
? (conv?.name ?? 'Gruppenanruf')
: 'Eingehender Anruf',
body: isGroup
? callerName + ' ruft die Gruppe'
: callerName + ' ruft dich an',
});
if (presenceRef.current !== 'dnd') {
void notify({
title: isGroup
? (conv?.name ?? 'Gruppenanruf')
: 'Eingehender Anruf',
body: isGroup
? callerName + ' ruft die Gruppe'
: callerName + ' ruft dich an',
});
}
break;
}
case 'accept':
@@ -964,10 +1275,12 @@ export function CallProvider({ children }: { children: ReactNode }) {
conv?.members.find((m) => m.userId === cur.fromUserId)?.profile?.displayName ??
conv?.peer?.displayName ??
'…';
void notify({
title: 'Verpasster Anruf',
body: callerName + ' hat aufgelegt',
});
if (presenceRef.current !== 'dnd') {
void notify({
title: 'Verpasster Anruf',
body: callerName + ' hat aufgelegt',
});
}
setState({ kind: 'idle' });
}
break;
@@ -984,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);
}
}, []);
@@ -1033,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(() => {
@@ -1054,6 +1460,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
isMuted,
isE2EEActive,
isScreenSharing,
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteMute,
remoteScreenShares,
lastCallConversationId,
callMode,
@@ -1065,11 +1475,20 @@ export function CallProvider({ children }: { children: ReactNode }) {
hangup,
toggleMute,
toggleScreenShare,
startScreenShare,
stopScreenShare,
toggleCamera,
toggleDeafen,
dismissLastCall,
setCallMode,
setFocusedId,
setAudioInputDevice,
setAudioOutputDevice,
playSoundboard,
stopSoundboard,
activeSoundboardIds,
setSoundboardMasterGain,
setSoundboardMonitorGain,
}),
[
state,
@@ -1078,6 +1497,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
isMuted,
isE2EEActive,
isScreenSharing,
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteMute,
remoteScreenShares,
lastCallConversationId,
callMode,
@@ -1089,11 +1512,20 @@ export function CallProvider({ children }: { children: ReactNode }) {
hangup,
toggleMute,
toggleScreenShare,
startScreenShare,
stopScreenShare,
toggleCamera,
toggleDeafen,
dismissLastCall,
setCallMode,
setFocusedId,
setAudioInputDevice,
setAudioOutputDevice,
playSoundboard,
stopSoundboard,
activeSoundboardIds,
setSoundboardMasterGain,
setSoundboardMonitorGain,
],
);
@@ -1106,10 +1538,30 @@ export function useCall(): CallContextValue {
return ctx;
}
// Shared flag so attachTrack (called from LiveKit event listeners, outside the
// React component) can apply the current deafen state to freshly-attached
// audio elements. Toggled by toggleDeafen in sync with the React state.
let deafenedActive = false;
async function broadcastPresence(
room: Room,
deafened: boolean,
muted: boolean,
): Promise<void> {
try {
const payload = new TextEncoder().encode(
JSON.stringify({ type: 'presence', deafened, muted }),
);
await room.localParticipant.publishData(payload, { reliable: true });
} catch (err: unknown) {
console.warn('broadcastPresence failed', err);
}
}
function attachTrack(
track: RemoteTrack,
_publication: RemoteTrackPublication,
_participant: RemoteParticipant,
participant: RemoteParticipant,
): void {
if (track.kind === Track.Kind.Audio) {
const audio = track.attach();
@@ -1117,6 +1569,11 @@ function attachTrack(
audio.autoplay = true;
audio.setAttribute('playsinline', 'true');
audio.setAttribute('data-livekit-track', track.sid ?? '');
if (participant.identity) {
audio.setAttribute('data-participant', participant.identity);
audio.volume = getParticipantVolume(participant.identity);
}
if (deafenedActive) audio.muted = true;
document.body.appendChild(audio);
// Apply persisted sinkId so the element routes to the user's chosen
// speaker/headphone from the start (LiveKit's own `switchActiveDevice`
@@ -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';
@@ -99,7 +100,9 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
return;
}
try {
setLoading(true);
// Only flag loading on initial fetch so background re-syncs (visibility
// change, online event) don't blank the list each time.
setLoading((prev) => (conversationsRef.current.length === 0 ? true : prev));
const convs = await listConversations(supabase);
setConversations(convs);
const entries = await Promise.all(
@@ -213,7 +216,34 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
)
.subscribe();
// Windows WebView2 aggressively throttles background WebSockets and
// sometimes drops events entirely while the window is minimised. Force a
// refresh + realtime reconnect on visibility/focus regain so we never
// leave stale conversation lists on a Windows client after the user
// returns to the app.
let lastAwakeRefresh = 0;
const AWAKE_THROTTLE_MS = 30_000;
const onAwake = () => {
if (document.visibilityState !== 'visible') return;
const now = Date.now();
if (now - lastAwakeRefresh < AWAKE_THROTTLE_MS) return;
lastAwakeRefresh = now;
void refresh();
try {
// If the socket got wedged during background throttle, a no-op
// unsubscribe+resubscribe brings it back. `subscribe()` on an already
// joined channel is a no-op so this is safe.
channel.subscribe();
} catch {
/* ignore — already live */
}
};
document.addEventListener('visibilitychange', onAwake);
window.addEventListener('online', onAwake);
return () => {
document.removeEventListener('visibilitychange', onAwake);
window.removeEventListener('online', onAwake);
void supabase.removeChannel(channel);
};
}, [myId, refresh, markRead]);
@@ -224,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 */
}
}

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