Compare commits

...

13 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
107 changed files with 4615 additions and 347 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.9.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

+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.9.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.9.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"
}
+72 -10
View File
@@ -1,8 +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';
@@ -10,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.
@@ -53,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 />}>
@@ -76,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>
@@ -1,6 +1,7 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useMemo, useRef, useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
import { supabase } from '../lib/supabase';
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
@@ -31,19 +32,30 @@ export function AttachmentAudio({ handle }: Props) {
setBlobUrl(null);
setArrayBuf(null);
downloadAndDecryptAttachment({ client: supabase, handle })
.then(async (blob) => {
void (async () => {
const cached = await getCachedAttachment(handle.id);
if (cached) {
if (cancelled) return;
url = URL.createObjectURL(cached);
setBlobUrl(url);
const buf = await cached.arrayBuffer();
if (!cancelled) setArrayBuf(buf);
return;
}
try {
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
if (cancelled) return;
url = URL.createObjectURL(blob);
setBlobUrl(url);
const buf = await blob.arrayBuffer();
if (!cancelled) setArrayBuf(buf);
})
.catch((err: unknown) => {
void putCachedAttachment(handle.id, blob);
} catch (err: unknown) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
}
});
}
})();
return () => {
cancelled = true;
@@ -1,6 +1,7 @@
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';
@@ -20,7 +21,11 @@ export function AttachmentGeneric({ handle }: Props) {
setBusy(true);
setError(null);
try {
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
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;
+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)} />}
</>
);
}
+16 -5
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 } from './icons';
@@ -22,19 +23,29 @@ export function AttachmentPdf({ handle }: Props) {
setError(null);
setBlobUrl(null);
downloadAndDecryptAttachment({ client: supabase, handle })
.then((blob) => {
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);
})
.catch((err: unknown) => {
void putCachedAttachment(handle.id, blob);
} catch (err: unknown) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
}
});
}
})();
return () => {
cancelled = true;
@@ -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 } from './icons';
@@ -20,17 +21,26 @@ export function AttachmentVideo({ handle }: Props) {
setError(null);
setBlobUrl(null);
downloadAndDecryptAttachment({ client: supabase, handle })
.then((blob) => {
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);
})
.catch((err: unknown) => {
void putCachedAttachment(handle.id, blob);
} catch (err: unknown) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
}
});
}
})();
return () => {
cancelled = true;
+5 -2
View File
@@ -1,6 +1,8 @@
// Reusable avatar that prefers an uploaded image and falls back to a coloured
// letter circle. Use this everywhere the app needs to render a profile.
import { useCachedAvatarUrl } from '../lib/avatarCache';
interface Props {
url?: string | null | undefined;
displayName?: string | null | undefined;
@@ -18,10 +20,11 @@ export function Avatar({
fallbackClass = 'bg-accent/20 text-accent',
alt,
}: Props) {
if (url) {
const effectiveUrl = useCachedAvatarUrl(url);
if (effectiveUrl) {
return (
<img
src={url}
src={effectiveUrl}
alt={alt ?? displayName ?? ''}
className={'shrink-0 rounded-full object-cover ' + className}
draggable={false}
@@ -0,0 +1,86 @@
import { useEffect } from 'react';
import { DeviceRestore } from './DeviceRestore';
import { AlertIcon, ShieldIcon, XIcon } from './icons';
interface Props {
open: boolean;
userId: string;
onClose: () => void;
}
// Modal wrapper around DeviceRestore for the already-signed-in case. A
// successful restore swaps the local device-identity for the one embedded
// in the backup string — the app then hard-reloads so every hook
// re-initialises against the restored keys (simpler than invalidating
// supabase-realtime subscriptions, stronghold caches, livekit rooms, etc.
// individually).
export function BackupRestoreDialog({ open, userId, onClose }: Props) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', onKey);
document.body.style.overflow = prev;
};
}, [open, onClose]);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="Backup wiederherstellen"
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-6 backdrop-blur-sm"
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-md flex-col gap-4"
>
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-ink-900/70 p-3 backdrop-blur-xl">
<div className="flex items-center gap-2">
<ShieldIcon className="h-4 w-4 text-brand-300" />
<h3 className="text-sm font-semibold text-white">
Gerät aus Backup wiederherstellen
</h3>
</div>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-white"
>
<XIcon className="h-4 w-4" />
</button>
</div>
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-100">
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
<p className="min-w-0 flex-1">
Restore ersetzt das aktuelle Gerät durch das aus dem Backup.
Die App lädt danach neu. Nachrichten, die auf diesem Gerät seit
dem Backup eingegangen sind, sind erst wieder lesbar, nachdem
Peer-Geräte den Conversation-Key erneut für die wiederhergestellte
Device-ID wrappen.
</p>
</div>
<DeviceRestore
userId={userId}
onRestored={() => {
// Hard reload — cleanest way to reset every hook, supabase
// realtime channel, stronghold handle, and cached state.
window.location.reload();
}}
/>
</div>
</div>
);
}
+89 -9
View File
@@ -56,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;
}
@@ -76,6 +78,7 @@ export function MessageBubble({
onJumpToMessage,
onReply,
onForward,
onAvatarClick,
highlighted = false,
}: Props) {
const { t } = useTranslation(['app']);
@@ -97,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 &&
@@ -181,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')}
@@ -274,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
@@ -337,6 +391,17 @@ export function MessageBubble({
{message.editedAt && !message.deletedAt && (
<span className="italic">· {t('app:chats.edited')}</span>
)}
{msLeft !== null && msLeft > 0 && (
<span
className={
'inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider ' +
(mine ? 'bg-white/20' : 'bg-rose-500/15 text-rose-600 dark:text-rose-300')
}
title="Selbstzerstörung"
>
{Math.ceil(msLeft / 1000)}s
</span>
)}
</div>
</div>
)}
@@ -457,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}
@@ -82,7 +82,7 @@ export function RingtoneSettings({ disabled = false }: Props) {
if (code === 'ringtone_too_large') {
setError(
t('app:settings.ringtone_error_too_large', {
defaultValue: 'Datei zu groß (max 2 MB).',
defaultValue: 'Datei zu groß (max {{max}} MB).',
max: MAX_RINGTONE_BYTES / BYTES_PER_MB,
}),
);
@@ -236,7 +236,7 @@ export function RingtoneSettings({ disabled = false }: Props) {
<p className="text-[11px] text-fg-muted">
{t('app:settings.ringtone_hint', {
defaultValue:
'MP3, WAV, OGG oder M4A bis 2 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.',
'MP3, WAV, OGG oder M4A bis 8 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.',
})}
</p>
</div>
@@ -7,6 +7,7 @@ import {
getScreenShareSettings,
PRESET_ORDER,
type ScreenSharePreset,
updateScreenShareSettings,
} from '../lib/screenShareSettings';
import {
MonitorShareIcon,
@@ -42,6 +43,7 @@ export function ScreenShareDialog({ open, onClose, onStart }: Props) {
const [surface, setSurface] = useState<DisplaySurfaceHint>(initial.displaySurface);
const [preset, setPreset] = useState<ScreenSharePreset>(initial.preset);
const [framerate, setFramerate] = useState<number | null>(initial.framerateOverride);
const [includeAudio, setIncludeAudio] = useState<boolean>(initial.includeSystemAudio);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -51,6 +53,9 @@ export function ScreenShareDialog({ open, onClose, onStart }: Props) {
setBusy(true);
setError(null);
try {
// Persist the audio choice alongside the other picker prefs so the
// upstream startScreenShare picks it up on its settings read.
updateScreenShareSettings({ includeSystemAudio: includeAudio });
await onStart({ preset, displaySurface: surface, framerate });
onClose();
} catch (err: unknown) {
@@ -165,6 +170,30 @@ export function ScreenShareDialog({ open, onClose, onStart }: Props) {
</p>
</div>
<div>
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2 text-xs hover:bg-surface">
<input
type="checkbox"
checked={includeAudio}
onChange={(e) => setIncludeAudio(e.target.checked)}
className="mt-0.5 accent-accent"
/>
<span className="min-w-0 flex-1">
<span className="block font-semibold text-fg">
{t('app:call.share_system_audio', {
defaultValue: 'System-Sound mit übertragen',
})}
</span>
<span className="mt-0.5 block text-[11px] text-fg-muted">
{t('app:call.share_system_audio_hint', {
defaultValue:
'"Go Live" — Systemsound wird mitgesendet. Auf macOS braucht das extra Berechtigungen; wird sonst stumm geteilt.',
})}
</span>
</span>
</label>
</div>
{error && (
<p role="alert" className="text-sm text-rose-600 dark:text-rose-300">
{error}
@@ -0,0 +1,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,
);
}
+21 -54
View File
@@ -519,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"
+53 -7
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,8 +41,13 @@ import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
import {
getAudioQualityParams,
getAudioSettings,
subscribeAudioSettings,
updateAudioSettings,
} from '../lib/audioSettings';
import {
applyBackgroundBlurToLocal,
removeBackgroundBlurFromLocal,
} from '../lib/videoBlur';
import {
createCallE2EE,
getCallE2EESettings,
@@ -492,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' &&
@@ -627,7 +633,11 @@ export function CallProvider({ children }: { children: ReactNode }) {
setIsE2EEActive(false);
}
try {
const inputId = getAudioSettings().inputDeviceId;
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
@@ -636,7 +646,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
const rawStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: aParams.echoCancellation,
noiseSuppression: aParams.noiseSuppression,
noiseSuppression: nsEffective,
autoGainControl: aParams.autoGainControl,
channelCount: aParams.stereo ? 2 : 1,
sampleRate: aParams.sampleRateHz,
@@ -947,7 +957,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
try {
await lp.setScreenShareEnabled(true, {
audio: false,
// "Go live" mode — capture system audio alongside the screen when
// the user opted in. On hosts that can't fulfil the request the
// browser quietly drops it; peers just get video-only, no error.
audio: settings.includeSystemAudio,
...(ssParams.dims
? {
resolution: {
@@ -1033,6 +1046,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
try {
await lp.setCameraEnabled(nextOn);
setIsCameraEnabled(nextOn);
if (nextOn && getAudioSettings().videoBackgroundBlur) {
void applyBackgroundBlurToLocal(lp);
}
} catch (err: unknown) {
console.error('setCameraEnabled failed', err);
// Permission denied / no camera — keep state in sync with actual
@@ -1041,6 +1057,23 @@ export function CallProvider({ children }: { children: ReactNode }) {
}
}, []);
// Live-toggle the background-blur processor when the settings flag flips.
// Acquiring the MediaPipe model is deferred until first activation to
// avoid the 1.5MB download on users who never enable blur.
useEffect(() => {
return subscribeAudioSettings((s) => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
if (!lp.isCameraEnabled) return;
if (s.videoBackgroundBlur) {
void applyBackgroundBlurToLocal(lp);
} else {
void removeBackgroundBlurFromLocal(lp);
}
});
}, []);
// --- Push-to-talk ------------------------------------------------------
// While PTT is active + we're in a connected call, the mic is held off
// except while the configured key is pressed. Under Tauri we also register
@@ -1337,11 +1370,13 @@ export function CallProvider({ children }: { children: ReactNode }) {
// 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 aParams = getAudioQualityParams(getAudioSettings().quality);
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: aParams.noiseSuppression,
noiseSuppression: nsEffective,
autoGainControl: aParams.autoGainControl,
channelCount: aParams.stereo ? 2 : 1,
sampleRate: aParams.sampleRateHz,
@@ -1393,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(() => {
@@ -17,6 +17,7 @@ import {
import { playNotificationTone } from '../lib/notificationSound';
import { notify } from '../lib/osNotify';
import { supabase } from '../lib/supabase';
import { updateTrayUnread } from '../lib/trayBadge';
import { useAuth } from './AuthContext';
const LAST_READ_STORAGE_KEY = 'chatapp.conv_last_read';
@@ -253,6 +254,12 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
return s;
}, [unread]);
// Mirror unread count into the tray tooltip + dock badge. Runs in Tauri
// only; no-op in browser-preview.
useEffect(() => {
void updateTrayUnread(totalUnread);
}, [totalUnread]);
const value = useMemo<ConversationsContextValue>(
() => ({
conversations,
+102
View File
@@ -0,0 +1,102 @@
// Blob cache for decrypted attachments. Stores the Blob body in OPFS
// (Origin Private File System — always sandbox-scoped to the origin, no
// user prompt) keyed by attachment id. Avoids re-downloading + re-decrypting
// the same blob on every scroll-into-view.
//
// Tauri WebKit + WebView2 both support OPFS as of the versions targeted by
// tauri 2. On unsupported hosts the cache degrades to a no-op and callers
// fall back to the fetch path.
const DIR_NAME = 'attachments';
const DEFAULT_TTL_MS = 7 * 24 * 3600 * 1000;
interface OpfsRoot {
getDirectoryHandle: (
name: string,
opts?: { create?: boolean },
) => Promise<OpfsDir>;
}
interface OpfsDir {
getFileHandle: (
name: string,
opts?: { create?: boolean },
) => Promise<OpfsFile>;
removeEntry: (name: string, opts?: { recursive?: boolean }) => Promise<void>;
entries?: () => AsyncIterableIterator<[string, OpfsFile]>;
}
interface OpfsFile {
getFile: () => Promise<File>;
createWritable: () => Promise<{
write: (data: ArrayBuffer | Blob) => Promise<void>;
close: () => Promise<void>;
}>;
}
let dirPromise: Promise<OpfsDir | null> | null = null;
async function getDir(): Promise<OpfsDir | null> {
if (!dirPromise) {
dirPromise = (async () => {
const storage = (navigator as unknown as { storage?: { getDirectory?: () => Promise<OpfsRoot> } }).storage;
const getDirectory = storage?.getDirectory;
if (!storage || !getDirectory) return null;
try {
const root = await getDirectory.call(storage);
return await root.getDirectoryHandle(DIR_NAME, { create: true });
} catch (err: unknown) {
console.warn('opfs attachment cache init failed', err);
return null;
}
})();
}
return dirPromise;
}
function safeName(id: string): string {
// OPFS file names can't contain slashes; attachment ids are UUIDs so this
// is mostly defensive.
return id.replace(/[^A-Za-z0-9_.-]/g, '_') + '.bin';
}
export async function getCachedAttachment(id: string): Promise<Blob | null> {
const dir = await getDir();
if (!dir) return null;
try {
const handle = await dir.getFileHandle(safeName(id), { create: false });
const file = await handle.getFile();
// Evict stale entries lazily — if older than TTL, drop and miss.
if (Date.now() - file.lastModified > DEFAULT_TTL_MS) {
await dir.removeEntry(safeName(id)).catch(() => undefined);
return null;
}
return file;
} catch {
// File doesn't exist → cache miss.
return null;
}
}
export async function putCachedAttachment(id: string, blob: Blob): Promise<void> {
const dir = await getDir();
if (!dir) return;
try {
const handle = await dir.getFileHandle(safeName(id), { create: true });
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
} catch (err: unknown) {
console.warn('putCachedAttachment failed', err);
}
}
export async function evictCachedAttachment(id: string): Promise<void> {
const dir = await getDir();
if (!dir) return;
try {
await dir.removeEntry(safeName(id));
} catch {
/* ignore — probably never existed */
}
}
+28
View File
@@ -16,12 +16,27 @@ export interface AudioSettings {
// Preferred output (speaker/headphone) deviceId. null = system default.
// Applied to every <audio> element we attach via HTMLMediaElement.setSinkId.
outputDeviceId: string | null;
// RMS threshold (0..1) for the "is this participant talking" ring. Lower =
// more sensitive. Default 0.03 catches soft speech without lighting up on
// keyboard noise. Users on quiet mics can lower; users in noisy rooms bump up.
voiceThreshold: number;
// Per-publish DSP toggle. Off lets hifi-style music go through unmodified;
// on cleans up voice when the quality preset doesn't already imply it.
// Defaults to "follow the quality preset".
noiseSuppression: boolean;
// Background blur on the local camera track. Lazy — requires
// @livekit/track-processors + its MediaPipe selfie-segmentation model
// (~1.5MB) which downloads on first activation.
videoBackgroundBlur: boolean;
}
const DEFAULTS: AudioSettings = {
quality: 'voice',
inputDeviceId: null,
outputDeviceId: null,
voiceThreshold: 0.03,
noiseSuppression: true,
videoBackgroundBlur: false,
};
export interface AudioQualityParams {
@@ -80,6 +95,7 @@ function read(): AudioSettings {
return cached;
}
const parsed = JSON.parse(raw) as Partial<AudioSettings>;
const rawThreshold = typeof parsed.voiceThreshold === 'number' ? parsed.voiceThreshold : NaN;
cached = {
quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality,
inputDeviceId:
@@ -90,6 +106,18 @@ function read(): AudioSettings {
typeof parsed.outputDeviceId === 'string' && parsed.outputDeviceId.length > 0
? parsed.outputDeviceId
: DEFAULTS.outputDeviceId,
voiceThreshold:
Number.isFinite(rawThreshold) && rawThreshold >= 0.005 && rawThreshold <= 0.2
? rawThreshold
: DEFAULTS.voiceThreshold,
noiseSuppression:
typeof parsed.noiseSuppression === 'boolean'
? parsed.noiseSuppression
: DEFAULTS.noiseSuppression,
videoBackgroundBlur:
typeof parsed.videoBackgroundBlur === 'boolean'
? parsed.videoBackgroundBlur
: DEFAULTS.videoBackgroundBlur,
};
return cached;
} catch {
+41
View File
@@ -0,0 +1,41 @@
// Session-level avatar cache. Avatars are small (<100KB), referenced from
// many places (chat list, bubbles, dialogs), and rarely change — so keeping
// one blob-URL per remote URL avoids repeated decodes + network 304s across
// remounts. OPFS is optional; the in-memory map is enough for daily use.
const blobUrls = new Map<string, string>(); // remote URL → blob URL
const inflight = new Map<string, Promise<string | null>>();
async function fetchAsBlobUrl(url: string): Promise<string | null> {
try {
const res = await fetch(url, { cache: 'force-cache' });
if (!res.ok) return null;
const blob = await res.blob();
return URL.createObjectURL(blob);
} catch {
return null;
}
}
// Returns a cached blob URL for the avatar. Falls back to the original URL
// if caching fails so rendering never breaks.
export function useCachedAvatarUrl(url: string | null | undefined): string | null | undefined {
if (!url) return url;
return blobUrls.get(url) ?? url;
}
// Eagerly populate the cache for a list of URLs (e.g. conversation members)
// so subsequent renders hit the Map directly.
export function warmAvatarCache(urls: Array<string | null | undefined>): void {
for (const url of urls) {
if (!url) continue;
if (blobUrls.has(url)) continue;
if (inflight.has(url)) continue;
const p = fetchAsBlobUrl(url).then((blobUrl) => {
if (blobUrl) blobUrls.set(url, blobUrl);
inflight.delete(url);
return blobUrl;
});
inflight.set(url, p);
}
}
+99
View File
@@ -0,0 +1,99 @@
// Main-thread wrapper around the decrypt Web Worker.
//
// Spawns a single worker lazily on first use. Uses request-id correlation so
// concurrent batches (e.g. a backfill arriving while a realtime insert
// dispatches) can't mix up their results. Falls back to inline decrypt if
// the Worker constructor isn't available (non-browser environments, some
// strict CSPs).
import sodium from 'libsodium-wrappers-sumo';
export interface DecryptItem {
id: string;
ciphertext: Uint8Array;
nonce: Uint8Array;
key: Uint8Array;
}
interface Pending {
resolve: (results: DecryptResult[]) => void;
reject: (err: unknown) => void;
}
interface DecryptResult {
id: string;
plaintext: string | null;
}
let worker: Worker | null = null;
let workerBroken = false;
const pending = new Map<string, Pending>();
function spawn(): Worker | null {
if (workerBroken) return null;
if (worker) return worker;
try {
worker = new Worker(new URL('../workers/decrypt.worker.ts', import.meta.url), {
type: 'module',
});
worker.addEventListener('message', (ev: MessageEvent) => {
const data = ev.data as { id?: string; results?: DecryptResult[] };
if (!data.id) return;
const entry = pending.get(data.id);
if (!entry) return;
pending.delete(data.id);
entry.resolve(data.results ?? []);
});
worker.addEventListener('error', (ev) => {
console.warn('decrypt worker error, falling back to inline', ev.message);
workerBroken = true;
worker?.terminate();
worker = null;
for (const entry of pending.values()) {
entry.reject(new Error('decrypt worker crashed'));
}
pending.clear();
});
return worker;
} catch (err: unknown) {
console.warn('decrypt worker spawn failed, falling back to inline', err);
workerBroken = true;
return null;
}
}
export async function decryptBatch(items: DecryptItem[]): Promise<DecryptResult[]> {
if (items.length === 0) return [];
const w = spawn();
if (!w) return inlineDecrypt(items);
return new Promise<DecryptResult[]>((resolve, reject) => {
const id = crypto.randomUUID();
pending.set(id, { resolve, reject });
try {
w.postMessage({ id, items });
} catch (err: unknown) {
pending.delete(id);
// postMessage can fail if the Uint8Array view is detached — retry inline.
console.warn('decrypt worker postMessage failed, using inline', err);
inlineDecrypt(items).then(resolve, reject);
}
});
}
// Fallback path — same semantics as the worker but on the main thread. Used
// when the worker failed to spawn or crashed mid-session.
async function inlineDecrypt(items: DecryptItem[]): Promise<DecryptResult[]> {
await sodium.ready;
return items.map((item) => {
try {
const plain = sodium.crypto_secretbox_open_easy(item.ciphertext, item.nonce, item.key);
return {
id: item.id,
plaintext: new TextDecoder('utf-8', { fatal: false }).decode(plain),
};
} catch {
return { id: item.id, plaintext: null };
}
});
}
+8 -8
View File
@@ -1,6 +1,8 @@
import { getCryptoBackend } from '@chat-app/shared/crypto';
import sodium from 'libsodium-wrappers-sumo';
import { pwhashArgon2id } from './nativeCryptoOps';
// Encrypts/decrypts the device private key with a user-provided passphrase
// so the backup string can be safely written down or stored in a password
// manager. Uses Argon2id (libsodium crypto_pwhash) for the KDF and
@@ -36,15 +38,13 @@ function unb64url(s: string): Uint8Array {
return out;
}
async function deriveKey(passphrase: string, salt: Uint8Array, sodiumLib: typeof sodium): Promise<Uint8Array> {
return sodiumLib.crypto_pwhash(
KEY_LEN,
passphrase,
async function deriveKey(passphrase: string, salt: Uint8Array, _sodiumLib: typeof sodium): Promise<Uint8Array> {
return pwhashArgon2id({
password: passphrase,
salt,
sodiumLib.crypto_pwhash_OPSLIMIT_MODERATE,
sodiumLib.crypto_pwhash_MEMLIMIT_MODERATE,
sodiumLib.crypto_pwhash_ALG_ARGON2ID13,
);
outLen: KEY_LEN,
preset: 'moderate',
});
}
export async function exportDeviceKey(
+57
View File
@@ -0,0 +1,57 @@
// Client-side image compression before upload. Downscales huge photos
// (phone cameras regularly emit 4000×3000+ at 5MB+) to a size that
// actually makes sense for chat display. Preserves aspect ratio.
//
// Skips animated formats (gif/apng/webp) to keep motion, and skips
// already-small files to avoid useless re-encode overhead.
const MAX_DIM = 2048;
const TARGET_QUALITY = 0.85;
const SKIP_BELOW_BYTES = 512 * 1024; // 512KB — not worth re-encoding
const ANIMATED_MIME = /^image\/(gif|apng|webp)$/;
export async function compressImage(file: File): Promise<File> {
if (!file.type.startsWith('image/')) return file;
if (ANIMATED_MIME.test(file.type)) return file;
if (file.size < SKIP_BELOW_BYTES) return file;
if (typeof createImageBitmap !== 'function') return file;
if (typeof OffscreenCanvas !== 'function') return file;
try {
const bitmap = await createImageBitmap(file);
const largest = Math.max(bitmap.width, bitmap.height);
const scale = largest > MAX_DIM ? MAX_DIM / largest : 1;
const w = Math.max(1, Math.round(bitmap.width * scale));
const h = Math.max(1, Math.round(bitmap.height * scale));
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext('2d');
if (!ctx) {
bitmap.close();
return file;
}
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
const blob = await canvas.convertToBlob({
type: 'image/webp',
quality: TARGET_QUALITY,
});
// If the re-encoded blob is actually larger (small PNGs can expand as
// WebP), keep the original.
if (blob.size >= file.size) return file;
const name = renameToWebp(file.name);
return new File([blob], name, { type: 'image/webp', lastModified: file.lastModified });
} catch (err: unknown) {
console.warn('compressImage failed — keeping original', err);
return file;
}
}
function renameToWebp(original: string): string {
const dot = original.lastIndexOf('.');
const base = dot > 0 ? original.slice(0, dot) : original;
return base + '.webp';
}
export async function compressImages(files: File[]): Promise<File[]> {
return Promise.all(files.map((f) => compressImage(f)));
}
+268
View File
@@ -0,0 +1,268 @@
// Local SQLite message cache. Persists decrypted messages so startup +
// conversation-switch hydrate from disk instantly instead of waiting on a
// network round-trip + batch decrypt.
//
// The cache is per-device-local (app-local-data dir, same trust boundary as
// the device private key). Plaintext is stored because the threat model
// already assumes local-disk access means compromise — same as the existing
// outbox + secret store. Ciphertext + nonce are kept alongside so a future
// key-rotation migration can re-derive plaintext when a new bundle arrives.
import type { DecryptedMessage } from '@chat-app/shared/chat';
import { isTauriRuntime } from './globalShortcut';
interface Database {
execute: (sql: string, values?: unknown[]) => Promise<{ rowsAffected?: number }>;
select: <T>(sql: string, values?: unknown[]) => Promise<T[]>;
close: () => Promise<boolean>;
}
interface DatabaseStatic {
load: (path: string) => Promise<Database>;
}
const DB_NAME = 'chatapp-cache.db';
let dbPromise: Promise<Database | null> | null = null;
async function loadDb(): Promise<Database | null> {
if (!isTauriRuntime()) return null;
try {
// Dynamic import so browser-preview builds don't choke on the tauri
// plugin module. Vite will statically analyse this + split it into a
// chunk that only loads inside Tauri.
const mod = (await import('@tauri-apps/plugin-sql')) as {
default: DatabaseStatic;
Database?: DatabaseStatic;
};
const DatabaseCtor: DatabaseStatic = mod.default ?? (mod as { Database: DatabaseStatic }).Database;
const db = await DatabaseCtor.load('sqlite:' + DB_NAME);
await db.execute(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
sender_id TEXT NOT NULL,
sender_device_id TEXT,
reply_to_id TEXT,
edited_at TEXT,
deleted_at TEXT,
created_at TEXT NOT NULL,
plaintext TEXT
);
`);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_msgs_conv_created ON messages(conversation_id, created_at);',
);
// FTS5 virtual table for instant full-text search across the cache.
// Keeps only the searchable text columns (plaintext + sender), keyed
// by the message id so we can join back to the main row. Triggers
// mirror inserts/updates/deletes so the index never drifts.
//
// Falls back gracefully on FTS5-less builds: the IF NOT EXISTS keeps
// the call idempotent, and the surrounding try/catch already handles
// a CREATE failure by skipping the whole cache.
try {
await db.execute(
`CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
message_id UNINDEXED,
conversation_id UNINDEXED,
plaintext,
tokenize = 'unicode61 remove_diacritics 2'
);`,
);
await db.execute(
`CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts (message_id, conversation_id, plaintext)
VALUES (new.id, new.conversation_id, COALESCE(new.plaintext, ''));
END;`,
);
await db.execute(
`CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
DELETE FROM messages_fts WHERE message_id = old.id;
END;`,
);
await db.execute(
`CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
DELETE FROM messages_fts WHERE message_id = old.id;
INSERT INTO messages_fts (message_id, conversation_id, plaintext)
VALUES (new.id, new.conversation_id, COALESCE(new.plaintext, ''));
END;`,
);
} catch (err: unknown) {
console.warn('FTS5 init failed — search falls back to in-memory scan', err);
}
return db;
} catch (err: unknown) {
console.warn('messageCache init failed — falling back to memory-only', err);
return null;
}
}
function getDb(): Promise<Database | null> {
if (!dbPromise) dbPromise = loadDb();
return dbPromise;
}
// ---------------------------------------------------------------------------
// Public API — all calls are no-ops when the cache isn't available (browser
// preview, init error, etc.), so callers never need to guard.
// ---------------------------------------------------------------------------
interface Row {
id: string;
conversation_id: string;
sender_id: string;
sender_device_id: string | null;
reply_to_id: string | null;
edited_at: string | null;
deleted_at: string | null;
created_at: string;
plaintext: string | null;
}
export async function loadCachedMessages(
conversationId: string,
limit = 500,
): Promise<DecryptedMessage[]> {
const db = await getDb();
if (!db) return [];
try {
const rows = await db.select<Row>(
'SELECT * FROM messages WHERE conversation_id = $1 ORDER BY created_at DESC LIMIT $2',
[conversationId, limit],
);
return rows
.reverse()
.map((r) => ({
id: r.id,
conversationId: r.conversation_id,
senderId: r.sender_id,
senderDeviceId: r.sender_device_id,
replyToId: r.reply_to_id,
editedAt: r.edited_at,
deletedAt: r.deleted_at,
createdAt: r.created_at,
plaintext: r.plaintext,
}));
} catch (err: unknown) {
console.warn('loadCachedMessages failed', err);
return [];
}
}
export async function persistMessages(
conversationId: string,
messages: DecryptedMessage[],
): Promise<void> {
const db = await getDb();
if (!db || messages.length === 0) return;
try {
// Replace strategy: each message is keyed by id so upsert is "INSERT OR
// REPLACE". Attachments + forward chains are part of `plaintext` JSON so
// nothing lives outside this table.
for (const m of messages) {
await db.execute(
`INSERT OR REPLACE INTO messages
(id, conversation_id, sender_id, sender_device_id, reply_to_id,
edited_at, deleted_at, created_at, plaintext)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
m.id,
conversationId,
m.senderId,
m.senderDeviceId,
m.replyToId,
m.editedAt,
m.deletedAt,
m.createdAt,
m.plaintext,
],
);
}
} catch (err: unknown) {
console.warn('persistMessages failed', err);
}
}
export async function deleteCachedMessage(id: string): Promise<void> {
const db = await getDb();
if (!db) return;
try {
await db.execute('DELETE FROM messages WHERE id = $1', [id]);
} catch (err: unknown) {
console.warn('deleteCachedMessage failed', err);
}
}
// Full-text search across cached messages. Returns rows in newest-first
// order; caller maps to DecryptedMessage. Empty result on cache-miss or
// FTS5 not available — caller should fall back to in-memory regex.
export async function searchCachedMessages(
conversationId: string,
query: string,
limit = 200,
): Promise<DecryptedMessage[]> {
const db = await getDb();
if (!db) return [];
const trimmed = query.trim();
if (trimmed.length === 0) return [];
// Sanitize for FTS5 MATCH syntax. Strip quotes + control chars; wrap in
// an OR over each token with a trailing wildcard so partial words match.
// Hyphens + colons are FTS5 operators so we drop them.
const tokens = trimmed
.replace(/["'\u0000-\u001f]/g, ' ')
.replace(/[-:^]/g, ' ')
.split(/\s+/)
.filter(Boolean);
if (tokens.length === 0) return [];
const matchExpr = tokens.map((t) => '"' + t.replace(/"/g, '') + '"*').join(' AND ');
try {
const rows = await db.select<Row>(
`SELECT m.* FROM messages m
JOIN messages_fts f ON f.message_id = m.id
WHERE f.conversation_id = $1 AND messages_fts MATCH $2
ORDER BY m.created_at DESC
LIMIT $3`,
[conversationId, matchExpr, limit],
);
return rows.map((r) => ({
id: r.id,
conversationId: r.conversation_id,
senderId: r.sender_id,
senderDeviceId: r.sender_device_id,
replyToId: r.reply_to_id,
editedAt: r.edited_at,
deletedAt: r.deleted_at,
createdAt: r.created_at,
plaintext: r.plaintext,
}));
} catch (err: unknown) {
console.warn('searchCachedMessages failed', err);
return [];
}
}
// Housekeeping — run once per session to bound the cache size. Keeps the
// latest KEEP_PER_CONV messages per conversation.
const KEEP_PER_CONV = 1000;
export async function pruneCache(): Promise<void> {
const db = await getDb();
if (!db) return;
try {
await db.execute(
`DELETE FROM messages WHERE id IN (
SELECT id FROM messages m
WHERE (
SELECT COUNT(*) FROM messages m2
WHERE m2.conversation_id = m.conversation_id
AND m2.created_at > m.created_at
) >= $1
)`,
[KEEP_PER_CONV],
);
} catch (err: unknown) {
console.warn('pruneCache failed', err);
}
}
+149
View File
@@ -0,0 +1,149 @@
// Native accelerators for the crypto ops whose JS/WASM runtime is slow
// enough to matter. The sync `CryptoBackend` interface stays on WASM because
// migrating every call-site to async would break most of the shared crypto
// code for no runtime win — per-message AEAD already runs in a worker and
// its 15µs-vs-80µs delta is lost in IPC overhead (~40µs round-trip).
//
// The two ops we actually accelerate:
// * Argon2id password hashing (vault unlock, backup import/export)
// * Randomness (fast enough inline, but we expose it so future Rust-only
// flows don't need to route through the WASM backend).
//
// All fall back to WASM when the Tauri runtime isn't present (browser
// preview, dev server) so the same code paths keep working.
import { invoke } from '@tauri-apps/api/core';
import _sodium from 'libsodium-wrappers-sumo';
import { isTauriRuntime } from './globalShortcut';
function bytesToB64(bytes: Uint8Array): string {
let s = '';
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
}
function b64ToBytes(s: string): Uint8Array {
const bin = atob(s);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
// Environment flag. Default ON in Tauri, OFF in browser-preview. Can be
// force-disabled by setting VITE_USE_NATIVE_CRYPTO=false for debugging
// a regression against the WASM baseline.
const flagEnabled = (() => {
const raw = (import.meta as unknown as { env?: { VITE_USE_NATIVE_CRYPTO?: string } })
.env?.VITE_USE_NATIVE_CRYPTO;
if (raw === 'false' || raw === '0') return false;
return true;
})();
function nativeAvailable(): boolean {
return flagEnabled && isTauriRuntime();
}
// ---------------------------------------------------------------------------
// Argon2id password hashing
// ---------------------------------------------------------------------------
export interface PwhashOpts {
password: string;
salt: Uint8Array;
outLen: number;
preset?: 'interactive' | 'moderate' | 'sensitive';
}
export async function pwhashArgon2id(opts: PwhashOpts): Promise<Uint8Array> {
if (nativeAvailable()) {
try {
const b64 = await invoke<string>('crypto_pwhash', {
args: {
password: opts.password,
saltB64: bytesToB64(opts.salt),
outLen: opts.outLen,
preset: opts.preset ?? 'moderate',
},
});
return b64ToBytes(b64);
} catch (err: unknown) {
// Tauri command not registered (e.g. older build) or unexpected failure.
// Fall through to WASM so the app stays functional.
console.warn('[nativeCryptoOps] pwhash native failed, falling back', err);
}
}
await _sodium.ready;
const s = _sodium;
const presetPair = (() => {
switch (opts.preset ?? 'moderate') {
case 'interactive':
return [s.crypto_pwhash_OPSLIMIT_INTERACTIVE, s.crypto_pwhash_MEMLIMIT_INTERACTIVE];
case 'sensitive':
return [s.crypto_pwhash_OPSLIMIT_SENSITIVE, s.crypto_pwhash_MEMLIMIT_SENSITIVE];
default:
return [s.crypto_pwhash_OPSLIMIT_MODERATE, s.crypto_pwhash_MEMLIMIT_MODERATE];
}
})();
return s.crypto_pwhash(
opts.outLen,
opts.password,
opts.salt,
presetPair[0]!,
presetPair[1]!,
s.crypto_pwhash_ALG_ARGON2ID13,
);
}
// ---------------------------------------------------------------------------
// Randombytes — native-first, WASM fallback. Used for non-hot-path nonces
// and tokens; inline callers that need sync random should stay on the
// backend interface.
// ---------------------------------------------------------------------------
export async function randomBytesAsync(len: number): Promise<Uint8Array> {
if (nativeAvailable()) {
try {
const b64 = await invoke<string>('crypto_random_bytes', { len });
return b64ToBytes(b64);
} catch (err: unknown) {
console.warn('[nativeCryptoOps] random native failed, falling back', err);
}
}
await _sodium.ready;
return _sodium.randombytes_buf(len);
}
// ---------------------------------------------------------------------------
// Secretbox + box accelerators — exposed but not auto-adopted. Call-sites
// can migrate individually if they measure a win. Round-trip IPC is ~40µs
// so per-op wins under 100µs usually lose to the WASM path.
// ---------------------------------------------------------------------------
export async function secretboxEncryptNative(
plaintext: Uint8Array,
nonce: Uint8Array,
key: Uint8Array,
): Promise<Uint8Array> {
if (!nativeAvailable()) throw new Error('native crypto unavailable');
const b64 = await invoke<string>('crypto_secretbox_encrypt', {
plaintextB64: bytesToB64(plaintext),
nonceB64: bytesToB64(nonce),
keyB64: bytesToB64(key),
});
return b64ToBytes(b64);
}
export async function secretboxDecryptNative(
ciphertext: Uint8Array,
nonce: Uint8Array,
key: Uint8Array,
): Promise<Uint8Array> {
if (!nativeAvailable()) throw new Error('native crypto unavailable');
const b64 = await invoke<string>('crypto_secretbox_decrypt', {
ciphertextB64: bytesToB64(ciphertext),
nonceB64: bytesToB64(nonce),
keyB64: bytesToB64(key),
});
return b64ToBytes(b64);
}
+132
View File
@@ -0,0 +1,132 @@
// Thin JS wrapper around the Rust LiveKit bridge commands + events.
// Mirrors the subset of `livekit-client` that CallContext actually uses
// so the adapter can be swapped behind the `VITE_USE_RUST_LIVEKIT` flag.
//
// Phase B.1 — only connect/disconnect/data-channel/state events wired.
// Mic, camera, screen-share, active-speakers, video rendering ship in
// later phases. Components that call missing methods get a typed
// "not implemented" error so regressions surface immediately.
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { isTauriRuntime } from './globalShortcut';
export const rustLivekitFlag = (() => {
const raw = (import.meta as unknown as { env?: { VITE_USE_RUST_LIVEKIT?: string } })
.env?.VITE_USE_RUST_LIVEKIT;
return raw === 'true' || raw === '1';
})();
export function isRustLivekitAvailable(): boolean {
return rustLivekitFlag && isTauriRuntime();
}
export type NativeRoomState =
| { state: 'connecting' }
| { state: 'connected' }
| { state: 'disconnected' };
export interface NativeParticipantEvent {
identity: string;
name?: string | undefined;
}
export interface NativeDataEvent {
identity: string;
payloadB64: string;
reliable: boolean;
}
type Listener<T> = (payload: T) => void;
export class NativeRoom {
private unlistens: UnlistenFn[] = [];
private stateListeners = new Set<Listener<NativeRoomState>>();
private joinListeners = new Set<Listener<NativeParticipantEvent>>();
private leaveListeners = new Set<Listener<NativeParticipantEvent>>();
private dataListeners = new Set<Listener<NativeDataEvent>>();
async connect(url: string, token: string): Promise<void> {
if (!isRustLivekitAvailable()) {
throw new Error('Rust LiveKit backend not available');
}
await this.subscribeEvents();
try {
await invoke('livekit_connect', { args: { url, token } });
} catch (err: unknown) {
await this.teardown();
throw err;
}
}
async disconnect(): Promise<void> {
try {
await invoke('livekit_disconnect');
} finally {
await this.teardown();
}
}
async sendData(payload: Uint8Array, reliable: boolean): Promise<void> {
await invoke('livekit_send_data', {
payloadB64: bytesToB64(payload),
reliable,
});
}
onRoomState(fn: Listener<NativeRoomState>): () => void {
this.stateListeners.add(fn);
return () => this.stateListeners.delete(fn);
}
onParticipantJoined(fn: Listener<NativeParticipantEvent>): () => void {
this.joinListeners.add(fn);
return () => this.joinListeners.delete(fn);
}
onParticipantLeft(fn: Listener<NativeParticipantEvent>): () => void {
this.leaveListeners.add(fn);
return () => this.leaveListeners.delete(fn);
}
onDataReceived(fn: Listener<NativeDataEvent>): () => void {
this.dataListeners.add(fn);
return () => this.dataListeners.delete(fn);
}
private async subscribeEvents(): Promise<void> {
// Room state — connected / disconnected.
const uState = await listen<NativeRoomState>('livekit:room_state', (evt) => {
for (const fn of this.stateListeners) fn(evt.payload);
});
const uJoined = await listen<NativeParticipantEvent>('livekit:participant_joined', (evt) => {
for (const fn of this.joinListeners) fn(evt.payload);
});
const uLeft = await listen<NativeParticipantEvent>('livekit:participant_left', (evt) => {
for (const fn of this.leaveListeners) fn(evt.payload);
});
const uData = await listen<NativeDataEvent>('livekit:data_received', (evt) => {
for (const fn of this.dataListeners) fn(evt.payload);
});
this.unlistens.push(uState, uJoined, uLeft, uData);
}
private async teardown(): Promise<void> {
for (const unlisten of this.unlistens) {
try {
unlisten();
} catch {
/* unlistens become no-op after first call */
}
}
this.unlistens = [];
this.stateListeners.clear();
this.joinListeners.clear();
this.leaveListeners.clear();
this.dataListeners.clear();
}
}
function bytesToB64(bytes: Uint8Array): string {
let s = '';
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
}
+1 -1
View File
@@ -16,7 +16,7 @@ export interface StoredRingtone {
updatedAt: number;
}
export const MAX_RINGTONE_BYTES = 2 * 1024 * 1024; // 2 MB cap
export const MAX_RINGTONE_BYTES = 8 * 1024 * 1024; // 8 MB cap
export const SUPPORTED_RINGTONE_MIMES = [
'audio/mpeg',
@@ -24,12 +24,18 @@ export interface ScreenShareSettings {
displaySurface: DisplaySurfaceHint;
// User-chosen framerate. `null` falls back to preset's default.
framerateOverride: number | null;
// Include system audio ("go live" style). On some hosts getDisplayMedia
// can't capture system audio (macOS without special entitlements, some
// Linux setups). If the browser ignores the `audio: true` request we
// silently fall through to a video-only share.
includeSystemAudio: boolean;
}
const DEFAULTS: ScreenShareSettings = {
preset: 'auto',
displaySurface: null,
framerateOverride: null,
includeSystemAudio: false,
};
export interface PresetParams {
@@ -121,6 +127,10 @@ function read(): ScreenShareSettings {
typeof parsed.framerateOverride === 'number' && parsed.framerateOverride > 0
? parsed.framerateOverride
: DEFAULTS.framerateOverride,
includeSystemAudio:
typeof parsed.includeSystemAudio === 'boolean'
? parsed.includeSystemAudio
: DEFAULTS.includeSystemAudio,
};
return cached;
} catch {
+8 -8
View File
@@ -5,6 +5,8 @@ import { appLocalDataDir } from '@tauri-apps/api/path';
// is the compact build without Argon2 — vault KDF would error otherwise.
import sodium from 'libsodium-wrappers-sumo';
import { pwhashArgon2id } from './nativeCryptoOps';
// Encrypted single-file SecretStore for Tauri. Replaces the flaky
// tauri-plugin-stronghold implementation.
//
@@ -76,16 +78,14 @@ function unb64url(s: string): Uint8Array {
return out;
}
async function deriveKey(userId: string, salt: Uint8Array, s: typeof sodium): Promise<Uint8Array> {
async function deriveKey(userId: string, salt: Uint8Array, _s: typeof sodium): Promise<Uint8Array> {
const passphrase = 'chatapp-vault-v1:' + userId;
return s.crypto_pwhash(
KEY_LEN,
passphrase,
return pwhashArgon2id({
password: passphrase,
salt,
s.crypto_pwhash_OPSLIMIT_MODERATE,
s.crypto_pwhash_MEMLIMIT_MODERATE,
s.crypto_pwhash_ALG_ARGON2ID13,
);
outLen: KEY_LEN,
preset: 'moderate',
});
}
async function loadOrCreateVault(userId: string): Promise<VaultState> {
+15
View File
@@ -0,0 +1,15 @@
import { emit } from '@tauri-apps/api/event';
import { isTauriRuntime } from './globalShortcut';
// Pushes the current aggregate unread count to the Rust-side tray listener.
// Rust mirrors it into the tray tooltip + macOS dock badge. No-op in the
// browser/dev preview where the Tauri runtime isn't present.
export async function updateTrayUnread(count: number): Promise<void> {
if (!isTauriRuntime()) return;
try {
await emit('tray-unread-update', { count: Math.max(0, Math.floor(count)) });
} catch (err: unknown) {
console.warn('updateTrayUnread failed', err);
}
}
+16 -3
View File
@@ -1,6 +1,8 @@
import type { AudioTrack, Participant, Room } from 'livekit-client';
import { ParticipantEvent, RoomEvent, Track } from 'livekit-client';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { getAudioSettings, subscribeAudioSettings } from './audioSettings';
// Real-time speaking ring driven by Web Audio API AnalyserNodes directly on
// each participant's audio MediaStreamTrack. LiveKit's own `audioLevel` +
@@ -9,9 +11,10 @@ import { useEffect, useState } from 'react';
// lights up within one animation frame of actual speech.
//
// Hold time of 250ms prevents flicker between words / short pauses.
// RMS threshold is user-tunable via audioSettings.voiceThreshold so soft
// speakers / noisy rooms can dial in their own sensitivity.
const POLL_MS = 50;
const HOLD_MS = 250;
const THRESHOLD = 0.03; // RMS on 0..1 — tuned against soft speech
const FFT_SIZE = 256;
interface Probe {
@@ -80,6 +83,15 @@ function firstAudioTrack(p: Participant): AudioTrack | null {
export function useActiveSpeakers(room: Room | null): Set<string> {
const [ids, setIds] = useState<Set<string>>(() => new Set());
const thresholdRef = useRef<number>(getAudioSettings().voiceThreshold);
// Live-subscribe so the slider in settings takes effect without a call
// restart. Reading via ref keeps the tick-loop branch-free.
useEffect(() => {
return subscribeAudioSettings((s) => {
thresholdRef.current = s.voiceThreshold;
});
}, []);
useEffect(() => {
if (!room) {
@@ -142,8 +154,9 @@ export function useActiveSpeakers(room: Room | null): Set<string> {
room.remoteParticipants.forEach(syncProbe);
const now = Date.now();
const threshold = thresholdRef.current;
for (const [id, probe] of probes) {
if (sampleRms(probe) > THRESHOLD) lastActive.set(id, now);
if (sampleRms(probe) > threshold) lastActive.set(id, now);
}
const next = new Set<string>();
for (const [id, t] of lastActive) {
+102 -1
View File
@@ -13,6 +13,13 @@ import {
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { decryptBatch as decryptBatchWorker } from './decryptWorker';
import {
loadCachedMessages,
persistMessages,
pruneCache,
deleteCachedMessage,
} from './messageCache';
import {
enqueueOutbox,
getOutbox,
@@ -103,6 +110,10 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
messages,
ownDeviceId: deviceId,
ownPrivateKey: priv,
// Offload the symmetric decrypt + utf-8 decode to a Web Worker so
// the main thread stays responsive during bulk operations (initial
// fetch, backfill after sleep).
aeadBatchDelegate: decryptBatchWorker,
});
},
[deviceId],
@@ -120,6 +131,10 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
const rows = await fetchConversationMessages(supabase, conversationId);
const decrypted = await decryptBatch(rows);
setState({ messages: decrypted, loading: false, error: null });
// Persist the fresh batch to the local cache so next conversation
// switch / app start can hydrate instantly. Fire-and-forget — cache
// write failure is never user-visible.
void persistMessages(conversationId, decrypted);
} catch (err: unknown) {
setState((prev) => ({
...prev,
@@ -129,6 +144,31 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
}
}, [conversationId, decryptBatch]);
// Hydrate from the local SQLite cache the moment the conversation id
// changes. Runs in parallel with the network fetch — whichever resolves
// first populates the UI, and `refresh` will replace stale cache data
// when the server response lands. On cache-miss this is a ~5ms no-op.
useEffect(() => {
if (!conversationId) return;
let cancelled = false;
void loadCachedMessages(conversationId).then((cached) => {
if (cancelled || cached.length === 0) return;
setState((prev) => {
// Don't clobber a fresh server response that already landed.
if (prev.messages.length > 0) return prev;
return { messages: cached, loading: false, error: null };
});
});
return () => {
cancelled = true;
};
}, [conversationId]);
// Prune cache once per app session.
useEffect(() => {
void pruneCache();
}, []);
// Realtime INSERT handler — refetches the row via REST so we get the
// canonical bytea encoding (postgres_changes payloads serialize bytea
// differently and decoding them inline is brittle). Then decrypt + append.
@@ -296,12 +336,43 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
...prev,
messages: prev.messages.filter((m) => m.id !== id),
}));
void deleteCachedMessage(id);
}, []);
useEffect(() => {
if (!conversationId || !userId || !deviceId) return;
void refresh();
// Batch INSERT bursts so a paste / backfill doesn't fire N parallel
// refetches + decrypts. If more than BATCH_BURST_THRESHOLD ids arrive
// within BATCH_WINDOW_MS, collapse to a single refresh() which pulls
// the last 100 in one query — cheaper and keeps order stable. For
// lone inserts the per-id path stays so latency is unchanged.
const BATCH_WINDOW_MS = 250;
const BATCH_BURST_THRESHOLD = 3;
let burstBuffer: Array<Record<string, unknown>> = [];
let burstTimer: number | null = null;
const flushBurst = () => {
const buf = burstBuffer;
burstBuffer = [];
if (burstTimer !== null) {
window.clearTimeout(burstTimer);
burstTimer = null;
}
if (buf.length === 0) return;
if (buf.length > BATCH_BURST_THRESHOLD) {
void refresh();
} else {
for (const row of buf) void handleInsert(row);
}
};
const queueInsert = (row: Record<string, unknown>) => {
burstBuffer.push(row);
if (burstTimer === null) {
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
}
};
const channel = supabase
.channel('conv:' + conversationId)
.on(
@@ -314,7 +385,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
},
(payload: MessageChangePayload) => {
if (payload.eventType === 'INSERT') {
void handleInsert(payload.new);
queueInsert(payload.new);
} else if (payload.eventType === 'UPDATE') {
void handleUpdate(payload.new);
} else if (payload.eventType === 'DELETE') {
@@ -363,6 +434,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
window.addEventListener('online', onAwake);
return () => {
if (burstTimer !== null) window.clearTimeout(burstTimer);
document.removeEventListener('visibilitychange', onAwake);
window.removeEventListener('online', onAwake);
void supabase.removeChannel(channel);
@@ -401,6 +473,35 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
const priv = privateKeyRef.current;
if (!priv) throw new Error('private key not loaded');
// Slash-command: /tempmsg <seconds> <text> sends an ephemeral message
// that the sender auto-deletes after the window elapses. Both peers
// see the countdown via the expireMs field embedded in the plaintext
// payload — no server support required.
const tempMatch = /^\/tempmsg\s+(\d+)\s+([\s\S]+)$/i.exec(trimmed);
if (tempMatch && images.length === 0) {
const seconds = Math.min(3600, Math.max(5, parseInt(tempMatch[1]!, 10)));
const body = tempMatch[2]!.trim();
const payload = JSON.stringify({
v: 1,
type: 'text',
text: body,
attachments: [],
expireMs: seconds * 1000,
});
try {
await sendText(conversationId, userId, deviceId, priv, payload, replyToId);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'send failed';
enqueueOutbox({
conversationId,
text: payload,
replyToId,
error: msg,
});
}
return;
}
// Text-only path is retryable — if the network is down or the server
// rejects transiently, stash in the outbox and keep the UI optimistic.
// Attachments can't be deferred (large payloads, uploaded separately),
+67
View File
@@ -0,0 +1,67 @@
// Background-blur processor wrapper. LiveKit's `@livekit/track-processors`
// ships a MediaPipe-based selfie-segmentation pipeline that keeps the
// foreground sharp and blurs the background. The model (~1.5MB) downloads
// lazily on first activation, so users who never enable blur don't pay
// for it.
//
// attach/detach hide behind a guard so repeated toggles don't create a
// stack of processors — `setProcessor(null)` tears down the WebGL context
// and frees the GPU surface.
import type { LocalParticipant, LocalVideoTrack } from 'livekit-client';
import { Track } from 'livekit-client';
let cachedProcessor: unknown = null;
async function getProcessor(): Promise<unknown> {
if (cachedProcessor) return cachedProcessor;
const mod = (await import('@livekit/track-processors')) as {
BackgroundBlur?: (radius?: number) => unknown;
};
if (!mod.BackgroundBlur) {
throw new Error('BackgroundBlur not exported by @livekit/track-processors');
}
cachedProcessor = mod.BackgroundBlur(12);
return cachedProcessor;
}
function getCameraTrack(lp: LocalParticipant): LocalVideoTrack | null {
const pub = lp.getTrackPublication(Track.Source.Camera);
const track = pub?.track;
if (!track) return null;
return track as LocalVideoTrack;
}
export async function applyBackgroundBlurToLocal(lp: LocalParticipant): Promise<void> {
try {
const track = getCameraTrack(lp);
if (!track) return;
const processor = await getProcessor();
// `setProcessor` is declared on LocalVideoTrack; cast because the
// processor type lives in a separate module we don't want to strongly
// couple to here.
await (track as unknown as {
setProcessor: (p: unknown) => Promise<void>;
}).setProcessor(processor);
} catch (err: unknown) {
console.warn('applyBackgroundBlur failed', err);
}
}
export async function removeBackgroundBlurFromLocal(lp: LocalParticipant): Promise<void> {
try {
const track = getCameraTrack(lp);
if (!track) return;
const setter = (track as unknown as {
setProcessor?: (p: unknown) => Promise<void>;
stopProcessor?: () => Promise<void>;
});
if (setter.stopProcessor) {
await setter.stopProcessor();
} else if (setter.setProcessor) {
await setter.setProcessor(null);
}
} catch (err: unknown) {
console.warn('removeBackgroundBlur failed', err);
}
}
+77
View File
@@ -0,0 +1,77 @@
// Screen wake-lock for active calls. WebKit + WebView2 both ship the
// Screen Wake Lock API (tauri 2.x). Browsers release the sentinel when
// the page becomes hidden, so we re-acquire on visibilitychange while a
// call is active.
interface WakeLockSentinelLike {
release: () => Promise<void>;
addEventListener: (event: string, fn: () => void) => void;
}
interface WakeLockNavigator {
wakeLock?: {
request: (type: 'screen') => Promise<WakeLockSentinelLike>;
};
}
let sentinel: WakeLockSentinelLike | null = null;
let active = false;
let visibilityBound = false;
function hasWakeLock(): boolean {
return typeof navigator !== 'undefined' && !!(navigator as unknown as WakeLockNavigator).wakeLock;
}
async function acquire(): Promise<void> {
if (sentinel || !hasWakeLock()) return;
try {
const s = await (navigator as unknown as WakeLockNavigator).wakeLock!.request('screen');
sentinel = s;
s.addEventListener('release', () => {
sentinel = null;
// If still active (released by the browser because we went hidden),
// wait for visibility and re-request.
});
} catch (err: unknown) {
// Permission denied, document not visible, etc. Harmless — the call
// still works, the user's screen may dim. Log once.
console.warn('wakeLock request failed', err);
}
}
async function release(): Promise<void> {
if (!sentinel) return;
try {
await sentinel.release();
} catch {
/* already released */
}
sentinel = null;
}
function onVisibility(): void {
if (!active) return;
if (document.visibilityState === 'visible') {
void acquire();
}
}
// Called from CallContext when the call enters a state that should keep
// the screen awake (connected / connecting). Toggle off again when the
// call ends.
export async function setCallWakeLock(on: boolean): Promise<void> {
active = on;
if (on) {
if (!visibilityBound) {
document.addEventListener('visibilitychange', onVisibility);
visibilityBound = true;
}
await acquire();
} else {
if (visibilityBound) {
document.removeEventListener('visibilitychange', onVisibility);
visibilityBound = false;
}
await release();
}
}
+73 -13
View File
@@ -4,7 +4,7 @@ import {
isConversationMuted,
} from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, Outlet, useParams } from 'react-router-dom';
@@ -227,18 +227,12 @@ function ConversationList({
</p>
</div>
) : (
<ul className="flex-1 overflow-y-auto px-2 pb-2">
{items.map((c) => (
<li key={c.id}>
<ConversationRow
item={c}
active={c.id === activeId}
unreadCount={unread[c.id] ?? 0}
onAccept={onAccept}
/>
</li>
))}
</ul>
<VirtualConversationList
items={items}
activeId={activeId}
unread={unread}
onAccept={onAccept}
/>
)}
<div className="shrink-0 border-t border-line bg-surface-2 p-2">
@@ -248,6 +242,72 @@ function ConversationList({
);
}
// Windowed list: renders the first N rows and expands by N whenever a
// bottom sentinel scrolls into view. Under the threshold we skip the
// machinery entirely because rendering 50 rows costs less than the
// overhead of observers + state updates.
const VLIST_INITIAL = 40;
const VLIST_STEP = 40;
function VirtualConversationList({
items,
activeId,
unread,
onAccept,
}: {
items: ConversationSummary[];
activeId: string | undefined;
unread: Record<string, number>;
onAccept: (id: string) => void;
}) {
const [visible, setVisible] = useState<number>(VLIST_INITIAL);
const scrollRef = useRef<HTMLUListElement | null>(null);
const sentinelRef = useRef<HTMLLIElement | null>(null);
useEffect(() => {
setVisible(VLIST_INITIAL);
}, [items.length]);
useEffect(() => {
if (items.length <= visible) return;
const node = sentinelRef.current;
if (!node) return;
const obs = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setVisible((n) => Math.min(items.length, n + VLIST_STEP));
}
},
{ root: scrollRef.current, rootMargin: '200px 0px' },
);
obs.observe(node);
return () => obs.disconnect();
}, [items.length, visible]);
const slice = items.length <= VLIST_INITIAL ? items : items.slice(0, visible);
const hasMore = items.length > slice.length;
return (
<ul ref={scrollRef} className="flex-1 overflow-y-auto px-2 pb-2">
{slice.map((c) => (
<li key={c.id}>
<ConversationRow
item={c}
active={c.id === activeId}
unreadCount={unread[c.id] ?? 0}
onAccept={onAccept}
/>
</li>
))}
{hasMore && (
<li ref={sentinelRef} className="py-2 text-center text-xs text-fg-muted">
</li>
)}
</ul>
);
}
function ConversationRow({
item,
active,
+130 -8
View File
@@ -24,12 +24,15 @@ import { InCallPanel } from '../components/InCallPanel';
import { IncomingCallPanel } from '../components/IncomingCallPanel';
import { MentionAutocomplete } from '../components/MentionAutocomplete';
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
import { UserProfilePopover } from '../components/UserProfilePopover';
import { TypingIndicator } from '../components/TypingIndicator';
import { VoiceRecorder } from '../components/VoiceRecorder';
import type { DecryptedMessage } from '@chat-app/shared/chat';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { compressImages } from '../lib/imageCompress';
import { searchCachedMessages } from '../lib/messageCache';
import type { OutboxItem } from '../lib/messageOutbox';
import { useConversationMessages } from '../lib/useConversationMessages';
import { useMessageReactions } from '../lib/useMessageReactions';
@@ -45,7 +48,7 @@ export function ConversationPage() {
const { t } = useTranslation(['app', 'errors']);
const { id } = useParams<{ id: string }>();
const { session, device } = useAuth();
const { conversations, setActiveConversation, markRead } = useConversationsContext();
const { conversations, setActiveConversation, markRead, unread } = useConversationsContext();
const conversation = useMemo(
() => conversations.find((c) => c.id === id) ?? null,
@@ -145,6 +148,14 @@ export function ConversationPage() {
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const [displayCount, setDisplayCount] = useState<number>(150);
const [isDraggingFile, setIsDraggingFile] = useState(false);
// Snapshot of the "first-unread-message" id captured once the very first
// render of this conversation lands. Stays fixed until the user switches
// away so the divider doesn't jump around while new messages arrive.
const firstUnreadRef = useRef<string | null>(null);
const firstUnreadComputedRef = useRef<boolean>(false);
const [profilePopover, setProfilePopover] = useState<
{ userId: string; x: number; y: number } | null
>(null);
const [mentionState, setMentionState] = useState<
{ query: string; start: number } | null
>(null);
@@ -161,8 +172,27 @@ export function ConversationPage() {
setSearchOpen(false);
setSearchQuery('');
setDisplayCount(150);
firstUnreadRef.current = null;
firstUnreadComputedRef.current = false;
}, [id]);
// On first message-list populate for this conversation, pin the divider
// above the oldest-unread message. We only compute once — subsequent
// inserts push the divider "further back" visually, which matches
// Discord's behaviour.
useEffect(() => {
if (firstUnreadComputedRef.current) return;
if (!id || messages.length === 0) return;
const count = unread[id] ?? 0;
firstUnreadComputedRef.current = true;
if (count === 0 || count > messages.length) {
firstUnreadRef.current = null;
return;
}
const boundary = messages[messages.length - count];
firstUnreadRef.current = boundary ? boundary.id : null;
}, [id, messages, unread]);
// Expand window when the "load older" sentinel scrolls into view. Doubles
// effective window on each trigger so scrolling up quickly converges to
// rendering everything.
@@ -257,6 +287,32 @@ export function ConversationPage() {
searchDateTo !== '',
[searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo],
);
// FTS5-backed supplementary results: covers cached messages that aren't in
// the currently-loaded window (`messages`). Runs only when there's a text
// query — filters alone stay in-memory because they depend on already-
// decrypted payload state.
const [ftsExtras, setFtsExtras] = useState<DecryptedMessage[]>([]);
useEffect(() => {
if (!id) {
setFtsExtras([]);
return;
}
const q = searchQuery.trim();
if (q.length < 2) {
setFtsExtras([]);
return;
}
let cancelled = false;
void searchCachedMessages(id, q, 200).then((rows) => {
if (cancelled) return;
setFtsExtras(rows);
});
return () => {
cancelled = true;
};
}, [id, searchQuery]);
const searchMatches = useMemo(() => {
if (!searchActive) return [] as DecryptedMessage[];
const q = searchQuery.trim().toLowerCase();
@@ -265,7 +321,26 @@ export function ConversationPage() {
const toTs = searchDateTo
? new Date(searchDateTo).getTime() + 24 * 3600 * 1000 - 1
: null;
return messages.filter((m) => {
// Union the live `messages` array with any FTS5-only rows not yet
// loaded into memory, keyed by id so we don't double-count.
const seen = new Set<string>();
const pool: DecryptedMessage[] = [];
for (const m of messages) {
if (!seen.has(m.id)) {
seen.add(m.id);
pool.push(m);
}
}
for (const m of ftsExtras) {
if (!seen.has(m.id)) {
seen.add(m.id);
pool.push(m);
}
}
pool.sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
return pool.filter((m) => {
if (searchSenderId && m.senderId !== searchSenderId) return false;
const created = new Date(m.createdAt).getTime();
if (fromTs !== null && created < fromTs) return false;
@@ -277,7 +352,16 @@ export function ConversationPage() {
if (q && !parsed.text.toLowerCase().includes(q)) return false;
return true;
});
}, [messages, searchActive, searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo]);
}, [
messages,
ftsExtras,
searchActive,
searchQuery,
searchSenderId,
searchAttachmentsOnly,
searchDateFrom,
searchDateTo,
]);
// Reset/clamp the active match index when the match set changes.
useEffect(() => {
@@ -353,9 +437,13 @@ export function ConversationPage() {
}
}
function ingestFiles(files: File[]) {
async function ingestFiles(files: File[]) {
// Pre-compression so heavy phone photos (typically 4-8MB) don't bust the
// 10MB limit and don't waste storage/bandwidth. Non-image + animated
// files are passed through unchanged.
const compressed = await compressImages(files);
const next: File[] = [];
for (const f of files) {
for (const f of compressed) {
if (f.size > 10 * 1024 * 1024) {
setSendError('Datei zu groß (max 10 MB)');
continue;
@@ -367,7 +455,7 @@ export function ConversationPage() {
function handleFilesChosen(list: FileList | null) {
if (!list) return;
ingestFiles(Array.from(list));
void ingestFiles(Array.from(list));
}
const { state: callState } = useCall();
@@ -405,7 +493,7 @@ export function ConversationPage() {
if (!e.dataTransfer?.files?.length) return;
e.preventDefault();
setIsDraggingFile(false);
ingestFiles(Array.from(e.dataTransfer.files));
void ingestFiles(Array.from(e.dataTransfer.files));
}}
>
{!callHereActive && (
@@ -512,6 +600,18 @@ export function ConversationPage() {
(m.senderId !== myId ? (conversation?.peer ?? null) : null);
return (
<li key={m.id}>
{firstUnreadRef.current === m.id && (
<div
aria-label="Neue Nachrichten"
className="my-2 flex items-center gap-3 px-2"
>
<span className="h-px flex-1 bg-rose-500/60" />
<span className="rounded-full bg-rose-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-rose-500">
Neue Nachrichten
</span>
<span className="h-px flex-1 bg-rose-500/60" />
</div>
)}
<MessageBubble
message={m}
mine={m.senderId === myId}
@@ -540,6 +640,14 @@ export function ConversationPage() {
onJumpToMessage={jumpToMessage}
onReply={handleReply}
onForward={handleForward}
onAvatarClick={(uid, ev) => {
ev.stopPropagation();
setProfilePopover({
userId: uid,
x: ev.clientX,
y: ev.clientY,
});
}}
highlighted={highlightedId === m.id}
/>
</li>
@@ -754,7 +862,7 @@ export function ConversationPage() {
}
if (pics.length > 0) {
e.preventDefault();
ingestFiles(pics);
void ingestFiles(pics);
}
}}
rows={1}
@@ -778,6 +886,20 @@ export function ConversationPage() {
currentConversationId={id ?? null}
onClose={() => setForwardTarget(null)}
/>
{profilePopover && (
<UserProfilePopover
userId={profilePopover.userId}
profile={
conversation?.members.find((m) => m.userId === profilePopover.userId)?.profile ??
conversation?.peer ??
null
}
x={profilePopover.x}
y={profilePopover.y}
onClose={() => setProfilePopover(null)}
/>
)}
</div>
);
}
+117 -8
View File
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
import { Avatar } from '../components/Avatar';
import { BackupExportDialog } from '../components/BackupExportDialog';
import { BackupRestoreDialog } from '../components/BackupRestoreDialog';
import { RingtoneSettings } from '../components/RingtoneSettings';
import { SoundboardSettings } from '../components/SoundboardSettings';
import { LockIcon } from '../components/icons';
@@ -347,10 +348,99 @@ function AudioQualityControls() {
'Mono 48 kbps mit Noise-Suppression, Echo-Cancellation und Auto-Gain. Optimiert für Sprache im Raum.',
})}
</p>
<SettingRow
label={t('app:settings.noise_suppression', { defaultValue: 'Noise Suppression' })}
>
<InlineToggle
checked={cfg.noiseSuppression}
onChange={(v) => updateAudioSettings({ noiseSuppression: v })}
/>
</SettingRow>
<p className="text-xs text-fg-muted">
{t('app:settings.noise_suppression_hint', {
defaultValue:
'Unterdrückt Hintergrundgeräusche (Tastatur, Lüfter, Café-Lärm). Ausschalten nur bei Musik/Instrumenten.',
})}
</p>
<SettingRow
label={t('app:settings.video_blur', {
defaultValue: 'Video-Hintergrund unscharf',
})}
>
<InlineToggle
checked={cfg.videoBackgroundBlur}
onChange={(v) => updateAudioSettings({ videoBackgroundBlur: v })}
/>
</SettingRow>
<p className="text-xs text-fg-muted">
{t('app:settings.video_blur_hint', {
defaultValue:
'Blendet den Hintergrund hinter dir aus. Braucht etwas GPU-Leistung und lädt beim ersten Aktivieren ~1,5 MB Modell nach.',
})}
</p>
<SettingRow
label={t('app:settings.voice_threshold', {
defaultValue: 'Sprach-Erkennungs-Schwelle',
})}
>
<div className="flex w-48 items-center gap-2">
<input
type="range"
min={0.005}
max={0.1}
step={0.005}
value={cfg.voiceThreshold}
onChange={(e) =>
updateAudioSettings({ voiceThreshold: Number(e.target.value) })
}
className="flex-1 accent-accent"
/>
<span className="w-10 tabular-nums text-right text-[11px] text-fg-muted">
{(cfg.voiceThreshold * 100).toFixed(1)}
</span>
</div>
</SettingRow>
<p className="text-xs text-fg-muted">
{t('app:settings.voice_threshold_hint', {
defaultValue:
'Wann der grüne Sprech-Ring aufleuchtet. Niedriger = empfindlicher (leise Stimme erfassen), höher = tolerant gegen Raumlärm.',
})}
</p>
</>
);
}
function InlineToggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (next: boolean) => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(checked ? 'bg-accent' : 'bg-surface')
}
>
<span
className={
'absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition ' +
(checked ? 'translate-x-5' : '')
}
/>
</button>
);
}
interface AvatarControlsProps {
patchProfile: (patch: Parameters<typeof updateOwnProfile>[1]) => Promise<void>;
busy: boolean;
@@ -464,6 +554,7 @@ function DeviceKeyBackupControls() {
const { t } = useTranslation(['app']);
const { profile, device } = useAuth();
const [open, setOpen] = useState(false);
const [restoreOpen, setRestoreOpen] = useState(false);
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
const [err, setErr] = useState<string | null>(null);
const canRun = !!profile?.userId && !!device?.id;
@@ -502,14 +593,24 @@ function DeviceKeyBackupControls() {
'Backup deines Geräteschlüssels. Ohne Backup kein Zugriff auf alte Nachrichten wenn Gerät oder Browser-Storage verloren geht. Wiederherstellung passiert im Gerät-Einrichtungsbildschirm.',
})}
</p>
<button
type="button"
disabled={!canRun}
onClick={() => void handleOpen()}
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('app:backup.export_open', { defaultValue: 'Backup erstellen' })}
</button>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={!canRun}
onClick={() => void handleOpen()}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('app:backup.export_open', { defaultValue: 'Backup erstellen' })}
</button>
<button
type="button"
disabled={!profile?.userId}
onClick={() => setRestoreOpen(true)}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-semibold text-fg transition hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('app:backup.restore_open', { defaultValue: 'Backup wiederherstellen' })}
</button>
</div>
{err && (
<p className="mt-2 text-xs text-rose-600 dark:text-rose-300">{err}</p>
)}
@@ -523,6 +624,14 @@ function DeviceKeyBackupControls() {
onClose={handleClose}
/>
)}
{profile && (
<BackupRestoreDialog
open={restoreOpen}
userId={profile.userId}
onClose={() => setRestoreOpen(false)}
/>
)}
</div>
);
}
@@ -0,0 +1,62 @@
// Web Worker — runs XSalsa20-Poly1305 decrypt + utf8 decode off the main
// thread. The conv-key lookup (which is network-bound) stays in the main
// thread; only the CPU-heavy AEAD + UTF-8 step runs here.
//
// Message protocol:
// request: { id: string, items: Array<{ id, ciphertext, nonce, key }> }
// response: { id: string, results: Array<{ id, plaintext: string | null }> }
/// <reference lib="webworker" />
import sodium from 'libsodium-wrappers-sumo';
interface DecryptItem {
id: string;
ciphertext: Uint8Array;
nonce: Uint8Array;
key: Uint8Array;
}
interface DecryptRequest {
id: string;
items: DecryptItem[];
}
interface DecryptResult {
id: string;
plaintext: string | null;
}
interface DecryptResponse {
id: string;
results: DecryptResult[];
}
let sodiumReady: Promise<typeof sodium> | null = null;
async function ensureSodium(): Promise<typeof sodium> {
if (!sodiumReady) sodiumReady = sodium.ready.then(() => sodium);
return sodiumReady;
}
function decodeOne(s: typeof sodium, item: DecryptItem): string | null {
try {
const plain = s.crypto_secretbox_open_easy(item.ciphertext, item.nonce, item.key);
return new TextDecoder('utf-8', { fatal: false }).decode(plain);
} catch {
return null;
}
}
self.addEventListener('message', (ev: MessageEvent<DecryptRequest>) => {
const req = ev.data;
void (async () => {
const s = await ensureSodium();
const results: DecryptResult[] = req.items.map((item) => ({
id: item.id,
plaintext: decodeOne(s, item),
}));
const resp: DecryptResponse = { id: req.id, results };
(self as unknown as Worker).postMessage(resp);
})();
});
+19
View File
@@ -25,6 +25,25 @@ export default defineConfig({
conditions: ['require', 'node', 'default'],
},
},
build: {
rollupOptions: {
output: {
// Split heavy vendor modules into their own chunks so they cache
// independently from the app shell. LiveKit + libsodium change
// rarely, so this keeps the hot-path app chunk small on rebuilds
// and lets the browser cache the big binaries across app updates.
manualChunks: (id) => {
if (id.includes('node_modules/livekit-client')) return 'vendor-livekit';
if (id.includes('node_modules/libsodium-wrappers-sumo')) return 'vendor-sodium';
if (id.includes('node_modules/@supabase')) return 'vendor-supabase';
if (id.includes('node_modules/react-router') || id.includes('node_modules/react-dom') || id.includes('node_modules/react/')) {
return 'vendor-react';
}
return undefined;
},
},
},
},
clearScreen: false,
server: {
port: 1420,

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