Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5aa39b40ff | |||
| eb452bf57e | |||
| 902c0285e6 | |||
| 1303c8e26f | |||
| 48ac9d2922 | |||
| 725a7e0364 | |||
| 44088b35d7 | |||
| 228608ef2c | |||
| 24fdfee738 | |||
| 636565d552 | |||
| 672c8738c7 | |||
| b89ec90813 | |||
| a04ecf7a19 | |||
| da85f0ba54 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chat-app/desktop",
|
"name": "@chat-app/desktop",
|
||||||
"version": "0.1.0",
|
"version": "0.10.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Tauri v2 desktop client (Windows / macOS / Linux)",
|
"description": "Tauri v2 desktop client (Windows / macOS / Linux)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"@chat-app/shared": "workspace:*",
|
"@chat-app/shared": "workspace:*",
|
||||||
"@chat-app/ui-web": "workspace:*",
|
"@chat-app/ui-web": "workspace:*",
|
||||||
"@livekit/components-react": "^2.9.0",
|
"@livekit/components-react": "^2.9.0",
|
||||||
|
"@livekit/track-processors": "^0.7.2",
|
||||||
"@supabase/supabase-js": "^2.46.0",
|
"@supabase/supabase-js": "^2.46.0",
|
||||||
"@tauri-apps/api": "^2.1.1",
|
"@tauri-apps/api": "^2.1.1",
|
||||||
"@tauri-apps/plugin-fs": "^2.5.0",
|
"@tauri-apps/plugin-fs": "^2.5.0",
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa"/>
|
||||||
<clipPath id="cp02">
|
<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"
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"/>
|
fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0"/>
|
||||||
</clipPath>
|
<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"
|
||||||
</defs>
|
fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065"/>
|
|
||||||
<g clip-path="url(#cp02)">
|
|
||||||
<path d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z" fill="#7c4dff"/>
|
|
||||||
<path d="M-4 32 Q 16 18 32 32 T 68 32" stroke="#a78bfa" stroke-width="2" fill="none"/>
|
|
||||||
</g>
|
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"
|
|
||||||
fill="none" stroke="#a78bfa" stroke-width="1.5" opacity="0.4"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 667 B After Width: | Height: | Size: 563 B |
@@ -0,0 +1,67 @@
|
|||||||
|
// Web Push service worker.
|
||||||
|
//
|
||||||
|
// Handles browser-delivered push events when the app tab is closed or in the
|
||||||
|
// background. Tauri desktop does not install service workers; native OS
|
||||||
|
// notifications are routed through the Tauri notification plugin instead
|
||||||
|
// (see src/lib/osNotify.ts).
|
||||||
|
//
|
||||||
|
// Payload contract — server sends JSON of shape:
|
||||||
|
// { title: string, body?: string, conversationId?: string, kind?: 'message' | 'call' }
|
||||||
|
// Body is intentionally generic; message ciphertext is never included.
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
// Activate immediately so updates apply on next page load.
|
||||||
|
event.waitUntil(self.skipWaiting());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
let data = { title: 'Neue Nachricht', body: '' };
|
||||||
|
try {
|
||||||
|
if (event.data) {
|
||||||
|
data = { ...data, ...event.data.json() };
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
/* malformed payload — fall back to defaults */
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
body: data.body || '',
|
||||||
|
icon: '/favicon.svg',
|
||||||
|
badge: '/favicon.svg',
|
||||||
|
tag: data.conversationId || 'default',
|
||||||
|
renotify: true,
|
||||||
|
data: {
|
||||||
|
conversationId: data.conversationId,
|
||||||
|
kind: data.kind,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(self.registration.showNotification(data.title, opts));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const conversationId = event.notification.data && event.notification.data.conversationId;
|
||||||
|
const target = conversationId ? '/chats/' + conversationId : '/';
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients
|
||||||
|
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||||
|
.then((clientList) => {
|
||||||
|
for (const client of clientList) {
|
||||||
|
if ('focus' in client) {
|
||||||
|
client.postMessage({ type: 'navigate', to: target });
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (self.clients.openWindow) {
|
||||||
|
return self.clients.openWindow(target);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "chat-app-desktop"
|
name = "chat-app-desktop"
|
||||||
version = "0.1.0"
|
version = "0.10.1"
|
||||||
description = "ChatApp desktop client"
|
description = "ChatApp desktop client"
|
||||||
authors = ["Dennis"]
|
authors = ["Dennis"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -14,7 +14,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = ["devtools"] }
|
tauri = { version = "2", features = ["devtools", "tray-icon"] }
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||||
tauri-plugin-stronghold = "2"
|
tauri-plugin-stronghold = "2"
|
||||||
@@ -22,11 +22,43 @@ tauri-plugin-fs = "2"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
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]
|
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
||||||
tauri-plugin-global-shortcut = "2"
|
tauri-plugin-global-shortcut = "2"
|
||||||
tauri-plugin-updater = "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]
|
[features]
|
||||||
# This feature is used for production builds or when `devPath` points to the filesystem
|
# This feature is used for production builds or when `devPath` points to the filesystem
|
||||||
# and disables specific features relevant to the dev build.
|
# and disables specific features relevant to the dev build.
|
||||||
custom-protocol = ["tauri/custom-protocol"]
|
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-notify",
|
||||||
"notification:allow-is-permission-granted",
|
"notification:allow-is-permission-granted",
|
||||||
"notification:allow-request-permission",
|
"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-register",
|
||||||
"global-shortcut:allow-unregister",
|
"global-shortcut:allow-unregister",
|
||||||
"global-shortcut:allow-is-registered",
|
"global-shortcut:allow-is-registered",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 948 B After Width: | Height: | Size: 709 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 928 B After Width: | Height: | Size: 680 B |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 934 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1021 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 611 B After Width: | Height: | Size: 471 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 864 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 864 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 858 B After Width: | Height: | Size: 669 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 864 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.9 KiB |
@@ -1,14 +1,7 @@
|
|||||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa"/>
|
||||||
<clipPath id="cp02">
|
<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"
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"/>
|
fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0"/>
|
||||||
</clipPath>
|
<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"
|
||||||
</defs>
|
fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065"/>
|
|
||||||
<g clip-path="url(#cp02)">
|
|
||||||
<path d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z" fill="#7c4dff"/>
|
|
||||||
<path d="M-4 32 Q 16 18 32 32 T 68 32" stroke="#a78bfa" stroke-width="2" fill="none"/>
|
|
||||||
</g>
|
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"
|
|
||||||
fill="none" stroke="#a78bfa" stroke-width="1.5" opacity="0.4"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 667 B After Width: | Height: | Size: 563 B |
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
#[cfg(not(feature = "rust-livekit"))]
|
||||||
let mut builder = tauri::Builder::default()
|
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_sql::Builder::default().build())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(
|
.plugin(
|
||||||
@@ -13,12 +127,105 @@ pub fn run() {
|
|||||||
.build(),
|
.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")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
{
|
{
|
||||||
builder = builder
|
builder = builder
|
||||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
.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
|
builder
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// Rust LiveKit bridge — command/event glue between the JS CallContext and
|
||||||
|
// the native livekit client. Feature-gated behind `rust-livekit` so the
|
||||||
|
// baseline build doesn't pay the libwebrtc download / link cost while the
|
||||||
|
// bridge is still evolving.
|
||||||
|
//
|
||||||
|
// Design contract (matches `lib/nativeLiveKit.ts` on the JS side):
|
||||||
|
// command: livekit_connect { url, token, e2ee_key_b64? }
|
||||||
|
// command: livekit_disconnect
|
||||||
|
// command: livekit_set_mic { enabled }
|
||||||
|
// command: livekit_set_camera { enabled }
|
||||||
|
// command: livekit_start_share {}
|
||||||
|
// command: livekit_stop_share {}
|
||||||
|
// command: livekit_send_data { payload_b64, reliable }
|
||||||
|
// event: livekit:room_state { state }
|
||||||
|
// event: livekit:participant_joined { identity, name? }
|
||||||
|
// event: livekit:participant_left { identity }
|
||||||
|
// event: livekit:track_published { identity, sid, kind, source }
|
||||||
|
// event: livekit:track_unpublished { identity, sid }
|
||||||
|
// event: livekit:audio_level { identity, level }
|
||||||
|
// event: livekit:data_received { identity, payload_b64 }
|
||||||
|
// event: livekit:error { message }
|
||||||
|
//
|
||||||
|
// Phase B.1 (this file) only wires connect/disconnect + room-state events
|
||||||
|
// so the JS side can prove round-trip; mic/camera/video come later.
|
||||||
|
|
||||||
|
#![cfg(feature = "rust-livekit")]
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use livekit::{
|
||||||
|
id::ParticipantIdentity, DataPacketKind, Room, RoomEvent, RoomOptions,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
use tokio::sync::{mpsc, Mutex};
|
||||||
|
|
||||||
|
pub struct LivekitState {
|
||||||
|
room: Mutex<Option<Arc<Room>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LivekitState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
room: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ConnectArgs {
|
||||||
|
pub url: String,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
struct RoomStatePayload {
|
||||||
|
state: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
struct ParticipantPayload {
|
||||||
|
identity: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
struct DataPayload {
|
||||||
|
identity: String,
|
||||||
|
payload_b64: String,
|
||||||
|
reliable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn livekit_connect(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, LivekitState>,
|
||||||
|
args: ConnectArgs,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut guard = state.room.lock().await;
|
||||||
|
if guard.is_some() {
|
||||||
|
return Err("already connected".into());
|
||||||
|
}
|
||||||
|
let options = RoomOptions::default();
|
||||||
|
let (room, mut events) = Room::connect(&args.url, &args.token, options)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("livekit connect failed: {}", e))?;
|
||||||
|
let room = Arc::new(room);
|
||||||
|
*guard = Some(room.clone());
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"livekit:room_state",
|
||||||
|
RoomStatePayload { state: "connected" },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Spawn the event pump. Lives for the duration of the room connection;
|
||||||
|
// stops naturally when the channel closes (disconnect or crash).
|
||||||
|
let app_for_events = app.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(event) = events.recv().await {
|
||||||
|
pump_event(&app_for_events, event);
|
||||||
|
}
|
||||||
|
let _ = app_for_events.emit(
|
||||||
|
"livekit:room_state",
|
||||||
|
RoomStatePayload {
|
||||||
|
state: "disconnected",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pump_event(app: &AppHandle, event: RoomEvent) {
|
||||||
|
match event {
|
||||||
|
RoomEvent::ParticipantConnected(p) => {
|
||||||
|
let _ = app.emit(
|
||||||
|
"livekit:participant_joined",
|
||||||
|
ParticipantPayload {
|
||||||
|
identity: identity_string(p.identity()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
RoomEvent::ParticipantDisconnected(p) => {
|
||||||
|
let _ = app.emit(
|
||||||
|
"livekit:participant_left",
|
||||||
|
ParticipantPayload {
|
||||||
|
identity: identity_string(p.identity()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
RoomEvent::DataReceived {
|
||||||
|
payload,
|
||||||
|
kind,
|
||||||
|
participant,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use base64::Engine;
|
||||||
|
let identity = participant
|
||||||
|
.map(|p| identity_string(p.identity()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let _ = app.emit(
|
||||||
|
"livekit:data_received",
|
||||||
|
DataPayload {
|
||||||
|
identity,
|
||||||
|
payload_b64: STANDARD.encode(payload.as_ref()),
|
||||||
|
reliable: matches!(kind, DataPacketKind::Reliable),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
RoomEvent::Disconnected { .. } => {
|
||||||
|
let _ = app.emit(
|
||||||
|
"livekit:room_state",
|
||||||
|
RoomStatePayload {
|
||||||
|
state: "disconnected",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Remaining events (TrackPublished, TrackSubscribed, etc.) land
|
||||||
|
// in later phases. Ignoring silently keeps the prototype small.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn identity_string(id: ParticipantIdentity) -> String {
|
||||||
|
// ParticipantIdentity is a newtype around String in the livekit crate.
|
||||||
|
id.0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn livekit_disconnect(state: State<'_, LivekitState>) -> Result<(), String> {
|
||||||
|
let mut guard = state.room.lock().await;
|
||||||
|
if let Some(room) = guard.take() {
|
||||||
|
let _ = room.close().await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn livekit_send_data(
|
||||||
|
state: State<'_, LivekitState>,
|
||||||
|
payload_b64: String,
|
||||||
|
reliable: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use base64::Engine;
|
||||||
|
let room = state.room.lock().await;
|
||||||
|
let room = room.as_ref().ok_or_else(|| "not connected".to_string())?;
|
||||||
|
let payload = STANDARD
|
||||||
|
.decode(&payload_b64)
|
||||||
|
.map_err(|e| format!("bad base64: {}", e))?;
|
||||||
|
let kind = if reliable {
|
||||||
|
DataPacketKind::Reliable
|
||||||
|
} else {
|
||||||
|
DataPacketKind::Lossy
|
||||||
|
};
|
||||||
|
room.local_participant()
|
||||||
|
.publish_data(livekit::prelude::DataPacket {
|
||||||
|
payload,
|
||||||
|
topic: None,
|
||||||
|
reliable: matches!(kind, DataPacketKind::Reliable),
|
||||||
|
destination_identities: Vec::new(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("publish_data failed: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder — Phase B.2 will fill these in.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn livekit_set_mic(_enabled: bool) -> Result<(), String> {
|
||||||
|
Err("livekit_set_mic not implemented — Phase B.2".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn livekit_set_camera(_enabled: bool) -> Result<(), String> {
|
||||||
|
Err("livekit_set_camera not implemented — Phase B.2".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unused-send bridge so `mpsc` doesn't get unused-import-warned when the
|
||||||
|
// feature gate is off.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn _mpsc_anchor() -> mpsc::Sender<()> {
|
||||||
|
let (tx, _rx) = mpsc::channel::<()>(1);
|
||||||
|
tx
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ChatApp",
|
"productName": "ChatApp",
|
||||||
"version": "0.7.1",
|
"version": "0.10.1",
|
||||||
"identifier": "com.meinname.chatapp",
|
"identifier": "com.meinname.chatapp",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm vite:dev",
|
"beforeDevCommand": "pnpm vite:dev",
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { lazy, Suspense } from 'react';
|
||||||
import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
import { AppShell } from './components/AppShell';
|
import { AppShell } from './components/AppShell';
|
||||||
|
import { CrashToast } from './components/CrashToast';
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
|
import { SpinnerIcon } from './components/icons';
|
||||||
import { UpdateToast } from './components/UpdateToast';
|
import { UpdateToast } from './components/UpdateToast';
|
||||||
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
|
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
|
||||||
import { AuthProvider } from './context/AuthContext';
|
import { AuthProvider } from './context/AuthContext';
|
||||||
@@ -9,14 +12,39 @@ import { CallProvider } from './context/CallContext';
|
|||||||
import { ConversationsProvider } from './context/ConversationsContext';
|
import { ConversationsProvider } from './context/ConversationsContext';
|
||||||
import { FriendshipsProvider } from './context/FriendshipsContext';
|
import { FriendshipsProvider } from './context/FriendshipsContext';
|
||||||
import { ThemeProvider } from './context/ThemeContext';
|
import { ThemeProvider } from './context/ThemeContext';
|
||||||
import { AdminPage } from './pages/AdminPage';
|
|
||||||
import { AuthCallbackPage } from './pages/AuthCallbackPage';
|
|
||||||
import { AuthPage } from './pages/AuthPage';
|
import { AuthPage } from './pages/AuthPage';
|
||||||
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
|
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
|
||||||
import { ConversationPage } from './pages/ConversationPage';
|
import { ConversationPage } from './pages/ConversationPage';
|
||||||
import { DevicePage } from './pages/DevicePage';
|
|
||||||
import { FriendsPage } from './pages/FriendsPage';
|
// Routes rarely visited on first render are pulled out of the initial bundle.
|
||||||
import { SettingsPage } from './pages/SettingsPage';
|
// 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
|
// 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.
|
// shell down. The boundary auto-retries via `ErrorBoundary`'s backoff schedule.
|
||||||
@@ -52,11 +80,25 @@ export function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<RouteBoundary scope="auth" />}>
|
<Route element={<RouteBoundary scope="auth" />}>
|
||||||
<Route path="/auth" element={<AuthPage />} />
|
<Route path="/auth" element={<AuthPage />} />
|
||||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
<Route
|
||||||
|
path="/auth/callback"
|
||||||
|
element={
|
||||||
|
<RouteSuspense>
|
||||||
|
<AuthCallbackPage />
|
||||||
|
</RouteSuspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<RequireAuth />}>
|
<Route element={<RequireAuth />}>
|
||||||
<Route element={<RouteBoundary scope="device" />}>
|
<Route element={<RouteBoundary scope="device" />}>
|
||||||
<Route path="/device" element={<DevicePage />} />
|
<Route
|
||||||
|
path="/device"
|
||||||
|
element={
|
||||||
|
<RouteSuspense>
|
||||||
|
<DevicePage />
|
||||||
|
</RouteSuspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<RequireDevice />}>
|
<Route element={<RequireDevice />}>
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
@@ -75,14 +117,35 @@ export function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<RouteBoundary scope="friends" />}>
|
<Route element={<RouteBoundary scope="friends" />}>
|
||||||
<Route path="/friends" element={<FriendsPage />} />
|
<Route
|
||||||
|
path="/friends"
|
||||||
|
element={
|
||||||
|
<RouteSuspense>
|
||||||
|
<FriendsPage />
|
||||||
|
</RouteSuspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<RouteBoundary scope="settings" />}>
|
<Route element={<RouteBoundary scope="settings" />}>
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<RouteSuspense>
|
||||||
|
<SettingsPage />
|
||||||
|
</RouteSuspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<RequireAdmin />}>
|
<Route element={<RequireAdmin />}>
|
||||||
<Route element={<RouteBoundary scope="admin" />}>
|
<Route element={<RouteBoundary scope="admin" />}>
|
||||||
<Route path="/admin" element={<AdminPage />} />
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<RouteSuspense>
|
||||||
|
<AdminPage />
|
||||||
|
</RouteSuspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
@@ -91,6 +154,7 @@ export function App() {
|
|||||||
<Route path="*" element={<Navigate to="/chats" replace />} />
|
<Route path="*" element={<Navigate to="/chats" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<UpdateToast />
|
<UpdateToast />
|
||||||
|
<CrashToast />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</CallProvider>
|
</CallProvider>
|
||||||
</ConversationsProvider>
|
</ConversationsProvider>
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
handle: AttachmentHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BAR_COUNT = 48;
|
||||||
|
|
||||||
|
// Custom voice-message player with waveform visualisation. Decoded peaks are
|
||||||
|
// computed once per blob via OfflineAudioContext so playback only carries the
|
||||||
|
// rendered DOM. Falls back to a rectangular bar if decoding fails (e.g. the
|
||||||
|
// blob mime is recognised by <audio> but not by AudioContext).
|
||||||
|
export function AttachmentAudio({ handle }: Props) {
|
||||||
|
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||||
|
const [arrayBuf, setArrayBuf] = useState<ArrayBuffer | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [peaks, setPeaks] = useState<number[] | null>(null);
|
||||||
|
const [duration, setDuration] = useState<number>(0);
|
||||||
|
const [position, setPosition] = useState<number>(0);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let url: string | null = null;
|
||||||
|
setError(null);
|
||||||
|
setBlobUrl(null);
|
||||||
|
setArrayBuf(null);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const cached = await getCachedAttachment(handle.id);
|
||||||
|
if (cached) {
|
||||||
|
if (cancelled) return;
|
||||||
|
url = URL.createObjectURL(cached);
|
||||||
|
setBlobUrl(url);
|
||||||
|
const buf = await cached.arrayBuffer();
|
||||||
|
if (!cancelled) setArrayBuf(buf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||||
|
if (cancelled) return;
|
||||||
|
url = URL.createObjectURL(blob);
|
||||||
|
setBlobUrl(url);
|
||||||
|
const buf = await blob.arrayBuffer();
|
||||||
|
if (!cancelled) setArrayBuf(buf);
|
||||||
|
void putCachedAttachment(handle.id, blob);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : 'download failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (url) URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
||||||
|
|
||||||
|
// Compute peaks via OfflineAudioContext. Cheap O(n) scan over PCM samples
|
||||||
|
// bucketed into BAR_COUNT bars. Done once per attachment.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!arrayBuf) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const Ctx =
|
||||||
|
window.AudioContext ||
|
||||||
|
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||||
|
const ctx = new Ctx();
|
||||||
|
ctx
|
||||||
|
.decodeAudioData(arrayBuf.slice(0))
|
||||||
|
.then((decoded) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setDuration(decoded.duration);
|
||||||
|
const channel = decoded.getChannelData(0);
|
||||||
|
const bucket = Math.max(1, Math.floor(channel.length / BAR_COUNT));
|
||||||
|
const out = new Array<number>(BAR_COUNT).fill(0);
|
||||||
|
for (let i = 0; i < BAR_COUNT; i++) {
|
||||||
|
let max = 0;
|
||||||
|
const start = i * bucket;
|
||||||
|
const end = Math.min(channel.length, start + bucket);
|
||||||
|
for (let j = start; j < end; j++) {
|
||||||
|
const v = Math.abs(channel[j]!);
|
||||||
|
if (v > max) max = v;
|
||||||
|
}
|
||||||
|
out[i] = max;
|
||||||
|
}
|
||||||
|
// Normalize so loudest peak is 1; keeps quiet recordings visible.
|
||||||
|
const peak = Math.max(...out, 0.001);
|
||||||
|
setPeaks(out.map((v) => v / peak));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Fall through — UI shows a flat bar but playback still works.
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
void ctx.close().catch(() => {});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [arrayBuf]);
|
||||||
|
|
||||||
|
const fallbackPeaks = useMemo(
|
||||||
|
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
|
||||||
|
[peaks],
|
||||||
|
);
|
||||||
|
const visiblePeaks = peaks ?? fallbackPeaks!;
|
||||||
|
const progress = duration > 0 ? position / duration : 0;
|
||||||
|
|
||||||
|
const onTogglePlay = () => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el || !blobUrl) return;
|
||||||
|
if (el.paused) void el.play();
|
||||||
|
else el.pause();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el || duration === 0) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||||
|
el.currentTime = ratio * duration;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
|
||||||
|
<AlertIcon className="h-4 w-4" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex w-[280px] min-w-[280px] items-center gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTogglePlay}
|
||||||
|
disabled={!blobUrl}
|
||||||
|
aria-label={playing ? 'Pause' : 'Wiedergabe'}
|
||||||
|
title={playing ? 'Pause' : 'Wiedergabe'}
|
||||||
|
className="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{!blobUrl ? (
|
||||||
|
<SpinnerIcon className="h-4 w-4" />
|
||||||
|
) : playing ? (
|
||||||
|
<PauseGlyph />
|
||||||
|
) : (
|
||||||
|
<PlayGlyph />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<div
|
||||||
|
role="slider"
|
||||||
|
aria-label="Position"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={Math.max(1, Math.floor(duration))}
|
||||||
|
aria-valuenow={Math.floor(position)}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onSeek}
|
||||||
|
className="flex h-7 cursor-pointer items-center gap-[2px]"
|
||||||
|
>
|
||||||
|
{visiblePeaks.map((v, i) => {
|
||||||
|
const playedRatio = (i + 0.5) / BAR_COUNT;
|
||||||
|
const played = playedRatio <= progress;
|
||||||
|
const h = Math.max(2, Math.round(v * 22));
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
style={{ height: h + 'px' }}
|
||||||
|
className={
|
||||||
|
'w-[3px] rounded-full ' +
|
||||||
|
(played ? 'bg-accent' : 'bg-fg-muted/40')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[10px] tabular-nums text-fg-muted">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<MicIcon className="h-3 w-3" />
|
||||||
|
<span>{formatSec(playing ? position : duration)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{blobUrl && (
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={blobUrl}
|
||||||
|
preload="metadata"
|
||||||
|
onLoadedMetadata={(e) => {
|
||||||
|
// Some webm/opus blobs report Infinity until first seek (Chrome
|
||||||
|
// bug). Force a seek to flush real duration.
|
||||||
|
const el = e.currentTarget;
|
||||||
|
if (!Number.isFinite(el.duration)) {
|
||||||
|
el.currentTime = 1e9;
|
||||||
|
setTimeout(() => {
|
||||||
|
el.currentTime = 0;
|
||||||
|
}, 0);
|
||||||
|
} else if (duration === 0) {
|
||||||
|
setDuration(el.duration);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDurationChange={(e) => {
|
||||||
|
const d = e.currentTarget.duration;
|
||||||
|
if (Number.isFinite(d) && d > 0) setDuration(d);
|
||||||
|
}}
|
||||||
|
onTimeUpdate={(e) => setPosition(e.currentTarget.currentTime)}
|
||||||
|
onPlay={() => setPlaying(true)}
|
||||||
|
onPause={() => setPlaying(false)}
|
||||||
|
onEnded={() => {
|
||||||
|
setPlaying(false);
|
||||||
|
setPosition(0);
|
||||||
|
}}
|
||||||
|
className="hidden"
|
||||||
|
>
|
||||||
|
<track kind="captions" />
|
||||||
|
</audio>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayGlyph() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M5 3.5l8 4.5-8 4.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PauseGlyph() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||||
|
<rect x="4" y="3" width="3" height="10" rx="1" />
|
||||||
|
<rect x="9" y="3" width="3" height="10" rx="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSec(sec: number): string {
|
||||||
|
if (!Number.isFinite(sec) || sec < 0) sec = 0;
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = Math.floor(sec % 60);
|
||||||
|
return m + ':' + s.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { AlertIcon, SpinnerIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
handle: AttachmentHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catch-all card for attachments without a richer renderer (zip, docx,
|
||||||
|
// txt, etc). Decrypt is deferred to first download click — these can be
|
||||||
|
// large and there's no inline preview to justify auto-fetching them.
|
||||||
|
export function AttachmentGeneric({ handle }: Props) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const download = async () => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
let blob = await getCachedAttachment(handle.id);
|
||||||
|
if (!blob) {
|
||||||
|
blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||||
|
void putCachedAttachment(handle.id, blob);
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filenameFor(handle);
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
// Defer revoke so Safari has a chance to start the download.
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'download failed');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 inline-flex w-[280px] items-center gap-2.5 rounded-lg border border-line bg-surface-2 p-2.5">
|
||||||
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-accent/10 text-accent">
|
||||||
|
<FileGlyph />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-xs font-semibold text-fg">
|
||||||
|
{prettyMime(handle.mimeType)}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-[10px] text-fg-muted">
|
||||||
|
{formatSize(handle.sizeBytes)}
|
||||||
|
</p>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-0.5 inline-flex items-center gap-1 text-[10px] text-rose-500">
|
||||||
|
<AlertIcon className="h-3 w-3" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void download()}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label="Download"
|
||||||
|
title="Download"
|
||||||
|
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-line bg-surface-3 text-fg transition hover:brightness-95 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy ? <SpinnerIcon className="h-4 w-4" /> : <DownloadGlyph />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileGlyph() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M3 2a1 1 0 011-1h6l3 3v10a1 1 0 01-1 1H4a1 1 0 01-1-1V2zm7 0v3h3l-3-3z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadGlyph() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M8 2v8M4 7l4 4 4-4M3 13h10" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filenameFor(handle: AttachmentHandle): string {
|
||||||
|
const ext = extFor(handle.mimeType);
|
||||||
|
return 'attachment-' + handle.id.slice(0, 8) + (ext ? '.' + ext : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extFor(mime: string): string | null {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'application/zip': 'zip',
|
||||||
|
'application/x-zip-compressed': 'zip',
|
||||||
|
'application/x-7z-compressed': '7z',
|
||||||
|
'application/x-tar': 'tar',
|
||||||
|
'application/gzip': 'gz',
|
||||||
|
'application/json': 'json',
|
||||||
|
'application/xml': 'xml',
|
||||||
|
'text/plain': 'txt',
|
||||||
|
'text/markdown': 'md',
|
||||||
|
'text/csv': 'csv',
|
||||||
|
'application/msword': 'doc',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
||||||
|
'application/vnd.ms-excel': 'xls',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
||||||
|
'application/vnd.ms-powerpoint': 'ppt',
|
||||||
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
|
||||||
|
};
|
||||||
|
return map[mime] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyMime(mime: string): string {
|
||||||
|
const ext = extFor(mime);
|
||||||
|
if (ext) return ext.toUpperCase() + '-Datei';
|
||||||
|
if (mime.startsWith('text/')) return 'Textdatei';
|
||||||
|
return mime || 'Datei';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
|
||||||
|
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { AlertIcon, SpinnerIcon, XIcon } from './icons';
|
import { AlertIcon, SpinnerIcon, XIcon } from './icons';
|
||||||
|
|
||||||
@@ -8,35 +9,95 @@ interface Props {
|
|||||||
handle: AttachmentHandle;
|
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) {
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let url: string | null = null;
|
const created: string[] = [];
|
||||||
setError(null);
|
setError(null);
|
||||||
setBlobUrl(null);
|
setFullUrl(null);
|
||||||
|
setThumbUrl(null);
|
||||||
|
|
||||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
const take = (blob: Blob): string => {
|
||||||
.then((blob) => {
|
const u = URL.createObjectURL(blob);
|
||||||
if (cancelled) return;
|
created.push(u);
|
||||||
url = URL.createObjectURL(blob);
|
return u;
|
||||||
setBlobUrl(url);
|
};
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
// OPFS cache → decrypt → generate thumbnail for inline display.
|
||||||
if (!cancelled) {
|
// Lightbox swaps to the full blob when opened.
|
||||||
setError(err instanceof Error ? err.message : 'download failed');
|
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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (url) URL.revokeObjectURL(url);
|
for (const u of created) URL.revokeObjectURL(u);
|
||||||
};
|
};
|
||||||
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
||||||
|
|
||||||
|
const blobUrl = thumbUrl ?? fullUrl;
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
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">
|
<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}
|
src={blobUrl}
|
||||||
alt="attachment"
|
alt="attachment"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
className="block h-auto max-h-80 w-auto max-w-full object-contain"
|
className="block h-auto max-h-80 w-auto max-w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{lightboxOpen && <Lightbox url={blobUrl} onClose={() => setLightboxOpen(false)} />}
|
{lightboxOpen && fullUrl && <Lightbox url={fullUrl} onClose={() => setLightboxOpen(false)} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { AlertIcon, SpinnerIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
handle: AttachmentHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDF preview rendered via the browser's built-in PDF viewer (Chromium /
|
||||||
|
// Safari both ship one). Embedding via <object> with a fallback link keeps
|
||||||
|
// the implementation tiny — no pdf.js dependency.
|
||||||
|
export function AttachmentPdf({ handle }: Props) {
|
||||||
|
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let url: string | null = null;
|
||||||
|
setError(null);
|
||||||
|
setBlobUrl(null);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const cached = await getCachedAttachment(handle.id);
|
||||||
|
if (cached) {
|
||||||
|
if (cancelled) return;
|
||||||
|
const typed = new Blob([cached], { type: 'application/pdf' });
|
||||||
|
url = URL.createObjectURL(typed);
|
||||||
|
setBlobUrl(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||||
|
if (cancelled) return;
|
||||||
|
// Force the application/pdf type so the browser plugin engages.
|
||||||
|
const typed = new Blob([blob], { type: 'application/pdf' });
|
||||||
|
url = URL.createObjectURL(typed);
|
||||||
|
setBlobUrl(url);
|
||||||
|
void putCachedAttachment(handle.id, blob);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : 'download failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (url) URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
|
||||||
|
<AlertIcon className="h-4 w-4" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!blobUrl) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex h-24 w-[320px] items-center justify-center rounded-lg border border-line bg-surface-2 text-fg-muted">
|
||||||
|
<SpinnerIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 w-full max-w-[420px] overflow-hidden rounded-lg border border-line bg-surface-2">
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-line bg-surface-3 px-3 py-2 text-xs">
|
||||||
|
<span className="flex items-center gap-2 truncate text-fg">
|
||||||
|
<PdfGlyph />
|
||||||
|
<span className="truncate">PDF · {formatSize(handle.sizeBytes)}</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-0.5 text-[11px] text-fg hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
{expanded ? 'Einklappen' : 'Vorschau'}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={blobUrl}
|
||||||
|
download={'attachment-' + handle.id.slice(0, 8) + '.pdf'}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-0.5 text-[11px] text-fg no-underline hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<object data={blobUrl} type="application/pdf" className="block h-[420px] w-full">
|
||||||
|
<p className="p-4 text-xs text-fg-muted">
|
||||||
|
Vorschau nicht verfügbar — bitte herunterladen.
|
||||||
|
</p>
|
||||||
|
</object>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PdfGlyph() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M3 2a1 1 0 011-1h6l3 3v10a1 1 0 01-1 1H4a1 1 0 01-1-1V2zm7 0v3h3l-3-3zM5 9h6v1H5V9zm0 2h6v1H5v-1zm0-4h2v1H5V7z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
|
||||||
|
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { AlertIcon, SpinnerIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
handle: AttachmentHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the blob, render a native <video controls>. Loads on demand —
|
||||||
|
// metadata-only preload so we don't burn bandwidth until the user hits play.
|
||||||
|
export function AttachmentVideo({ handle }: Props) {
|
||||||
|
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let url: string | null = null;
|
||||||
|
setError(null);
|
||||||
|
setBlobUrl(null);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const cached = await getCachedAttachment(handle.id);
|
||||||
|
if (cached) {
|
||||||
|
if (cancelled) return;
|
||||||
|
url = URL.createObjectURL(cached);
|
||||||
|
setBlobUrl(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||||
|
if (cancelled) return;
|
||||||
|
url = URL.createObjectURL(blob);
|
||||||
|
setBlobUrl(url);
|
||||||
|
void putCachedAttachment(handle.id, blob);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : 'download failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (url) URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
|
||||||
|
<AlertIcon className="h-4 w-4" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!blobUrl) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex h-32 w-[320px] items-center justify-center rounded-lg border border-line bg-surface-2 text-fg-muted">
|
||||||
|
<SpinnerIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
src={blobUrl}
|
||||||
|
className="mt-2 block max-h-80 w-full max-w-[420px] rounded-lg border border-line bg-black"
|
||||||
|
>
|
||||||
|
<track kind="captions" />
|
||||||
|
</video>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// Reusable avatar that prefers an uploaded image and falls back to a coloured
|
// 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.
|
// letter circle. Use this everywhere the app needs to render a profile.
|
||||||
|
|
||||||
|
import { useCachedAvatarUrl } from '../lib/avatarCache';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url?: string | null | undefined;
|
url?: string | null | undefined;
|
||||||
displayName?: string | null | undefined;
|
displayName?: string | null | undefined;
|
||||||
@@ -18,10 +20,11 @@ export function Avatar({
|
|||||||
fallbackClass = 'bg-accent/20 text-accent',
|
fallbackClass = 'bg-accent/20 text-accent',
|
||||||
alt,
|
alt,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
if (url) {
|
const effectiveUrl = useCachedAvatarUrl(url);
|
||||||
|
if (effectiveUrl) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={effectiveUrl}
|
||||||
alt={alt ?? displayName ?? ''}
|
alt={alt ?? displayName ?? ''}
|
||||||
className={'shrink-0 rounded-full object-cover ' + className}
|
className={'shrink-0 rounded-full object-cover ' + className}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { exportDeviceBackup } from '../lib/deviceBackup';
|
import { type BackupBundle, exportDeviceBackupWithRecovery } from '../lib/deviceBackup';
|
||||||
import { AlertIcon, CopyIcon, LockIcon, ShieldIcon, SpinnerIcon, XIcon } from './icons';
|
import { AlertIcon, CopyIcon, LockIcon, ShieldIcon, SpinnerIcon, XIcon } from './icons';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -22,8 +22,9 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
|
|||||||
const [confirm, setConfirm] = useState('');
|
const [confirm, setConfirm] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [backup, setBackup] = useState<string | null>(null);
|
const [bundle, setBundle] = useState<BackupBundle | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [copiedRecovery, setCopiedRecovery] = useState(false);
|
||||||
|
|
||||||
const canGenerate = useMemo(() => {
|
const canGenerate = useMemo(() => {
|
||||||
return passphrase.length >= 8 && passphrase === confirm && !busy;
|
return passphrase.length >= 8 && passphrase === confirm && !busy;
|
||||||
@@ -32,9 +33,10 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
|
|||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
setPassphrase('');
|
setPassphrase('');
|
||||||
setConfirm('');
|
setConfirm('');
|
||||||
setBackup(null);
|
setBundle(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
|
setCopiedRecovery(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
@@ -47,8 +49,13 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const str = await exportDeviceBackup({ userId, deviceId, privateKey, passphrase });
|
const b = await exportDeviceBackupWithRecovery({
|
||||||
setBackup(str);
|
userId,
|
||||||
|
deviceId,
|
||||||
|
privateKey,
|
||||||
|
passphrase,
|
||||||
|
});
|
||||||
|
setBundle(b);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -56,27 +63,39 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
|
|||||||
}
|
}
|
||||||
}, [canGenerate, userId, deviceId, privateKey, passphrase]);
|
}, [canGenerate, userId, deviceId, privateKey, passphrase]);
|
||||||
|
|
||||||
const handleCopy = useCallback(async () => {
|
const copyText = async (text: string, marker: 'main' | 'recovery'): Promise<void> => {
|
||||||
if (!backup) return;
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(backup);
|
await navigator.clipboard.writeText(text);
|
||||||
setCopied(true);
|
if (marker === 'main') {
|
||||||
window.setTimeout(() => setCopied(false), 1500);
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1500);
|
||||||
|
} else {
|
||||||
|
setCopiedRecovery(true);
|
||||||
|
window.setTimeout(() => setCopiedRecovery(false), 1500);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* fall back — user can select manually */
|
/* fall back — user can select manually */
|
||||||
}
|
}
|
||||||
}, [backup]);
|
};
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
const handleDownload = useCallback(() => {
|
||||||
if (!backup) return;
|
if (!bundle) return;
|
||||||
const blob = new Blob([backup], { type: 'text/plain;charset=utf-8' });
|
const text =
|
||||||
|
'=== Passphrase backup ===\n' +
|
||||||
|
bundle.passphraseBackup +
|
||||||
|
'\n\n=== Recovery code ===\n' +
|
||||||
|
bundle.recoveryCode +
|
||||||
|
'\n\n=== Recovery backup (use with the recovery code) ===\n' +
|
||||||
|
bundle.recoveryBackup +
|
||||||
|
'\n';
|
||||||
|
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `chatapp-device-backup-${deviceId.slice(0, 8)}.txt`;
|
a.download = `chatapp-device-backup-${deviceId.slice(0, 8)}.txt`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}, [backup, deviceId]);
|
}, [bundle, deviceId]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@@ -110,7 +129,7 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-5">
|
<div className="flex-1 overflow-y-auto p-5">
|
||||||
{!backup ? (
|
{!bundle ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-fg-muted">
|
<p className="text-sm text-fg-muted">
|
||||||
{t('app:backup.export_explainer', {
|
{t('app:backup.export_explainer', {
|
||||||
@@ -183,40 +202,76 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||||
|
Backup-String
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
readOnly
|
readOnly
|
||||||
value={backup}
|
value={bundle.passphraseBackup}
|
||||||
rows={8}
|
rows={6}
|
||||||
onFocus={(e) => e.currentTarget.select()}
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
className="mt-3 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
|
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
|
||||||
/>
|
/>
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-2 flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleCopy()}
|
onClick={() => void copyText(bundle.passphraseBackup, 'main')}
|
||||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
||||||
>
|
>
|
||||||
<CopyIcon className="h-4 w-4" />
|
<CopyIcon className="h-4 w-4" />
|
||||||
<span>
|
<span>{copied ? 'Kopiert!' : 'Kopieren'}</span>
|
||||||
{copied
|
|
||||||
? t('app:backup.copied', { defaultValue: 'Kopiert!' })
|
|
||||||
: t('app:backup.copy', { defaultValue: 'Kopieren' })}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
||||||
>
|
>
|
||||||
{t('app:backup.download', { defaultValue: 'Als Datei speichern' })}
|
Als Datei speichern (alles)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<ShieldIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-semibold text-amber-700 dark:text-amber-200">
|
||||||
|
Recovery-Code (Passphrase vergessen?)
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-amber-700/80 dark:text-amber-200/80">
|
||||||
|
Code separat aufbewahren. Mit dem Recovery-Backup unten lässt sich der Schlüssel
|
||||||
|
ohne Passphrase wiederherstellen.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest text-fg">
|
||||||
|
{bundle.recoveryCode}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||||
|
Recovery-Backup-String
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={bundle.recoveryBackup}
|
||||||
|
rows={6}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyText(bundle.recoveryBackup, 'recovery')}
|
||||||
|
className="mt-2 inline-flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
<CopyIcon className="h-4 w-4" />
|
||||||
|
<span>{copiedRecovery ? 'Kopiert!' : 'Recovery-Backup kopieren'}</span>
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||||
{!backup ? (
|
{!bundle ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { DeviceRestore } from './DeviceRestore';
|
||||||
|
import { AlertIcon, ShieldIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
userId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal wrapper around DeviceRestore for the already-signed-in case. A
|
||||||
|
// successful restore swaps the local device-identity for the one embedded
|
||||||
|
// in the backup string — the app then hard-reloads so every hook
|
||||||
|
// re-initialises against the restored keys (simpler than invalidating
|
||||||
|
// supabase-realtime subscriptions, stronghold caches, livekit rooms, etc.
|
||||||
|
// individually).
|
||||||
|
export function BackupRestoreDialog({ open, userId, onClose }: Props) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Backup wiederherstellen"
|
||||||
|
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-6 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex w-full max-w-md flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-ink-900/70 p-3 backdrop-blur-xl">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldIcon className="h-4 w-4 text-brand-300" />
|
||||||
|
<h3 className="text-sm font-semibold text-white">
|
||||||
|
Gerät aus Backup wiederherstellen
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Schließen"
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-100">
|
||||||
|
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
|
||||||
|
<p className="min-w-0 flex-1">
|
||||||
|
Restore ersetzt das aktuelle Gerät durch das aus dem Backup.
|
||||||
|
Die App lädt danach neu. Nachrichten, die auf diesem Gerät seit
|
||||||
|
dem Backup eingegangen sind, sind erst wieder lesbar, nachdem
|
||||||
|
Peer-Geräte den Conversation-Key erneut für die wiederhergestellte
|
||||||
|
Device-ID wrappen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeviceRestore
|
||||||
|
userId={userId}
|
||||||
|
onRestored={() => {
|
||||||
|
// Hard reload — cleanest way to reset every hook, supabase
|
||||||
|
// realtime channel, stronghold handle, and cached state.
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
HeadphonesIcon,
|
||||||
|
HeadphonesOffIcon,
|
||||||
MicIcon,
|
MicIcon,
|
||||||
MicOffIcon,
|
MicOffIcon,
|
||||||
MonitorShareIcon,
|
MonitorShareIcon,
|
||||||
MonitorStopIcon,
|
MonitorStopIcon,
|
||||||
|
MusicIcon,
|
||||||
PhoneOffIcon,
|
PhoneOffIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
VideoIcon,
|
VideoIcon,
|
||||||
@@ -14,11 +17,16 @@ interface Props {
|
|||||||
muted: boolean;
|
muted: boolean;
|
||||||
sharing: boolean;
|
sharing: boolean;
|
||||||
video: boolean;
|
video: boolean;
|
||||||
|
deafened: boolean;
|
||||||
onToggleMute: () => void;
|
onToggleMute: () => void;
|
||||||
onToggleShare: () => void;
|
onToggleShare: () => void;
|
||||||
onToggleVideo?: () => void;
|
onToggleVideo?: () => void;
|
||||||
|
onToggleDeafen: () => void;
|
||||||
onHangup: () => void;
|
onHangup: () => void;
|
||||||
onOpenParticipants?: () => void;
|
onOpenParticipants?: () => void;
|
||||||
|
/** Toggle the in-call soundboard popover. Active = panel currently open. */
|
||||||
|
onToggleSoundboard?: () => void;
|
||||||
|
soundboardOpen?: boolean;
|
||||||
/** Compact variant used inside the docked call (36px buttons). */
|
/** Compact variant used inside the docked call (36px buttons). */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
/** Glass variant used when controls float on fullscreen cinema mode. */
|
/** Glass variant used when controls float on fullscreen cinema mode. */
|
||||||
@@ -31,11 +39,15 @@ export function CallControls({
|
|||||||
muted,
|
muted,
|
||||||
sharing,
|
sharing,
|
||||||
video,
|
video,
|
||||||
|
deafened,
|
||||||
onToggleMute,
|
onToggleMute,
|
||||||
onToggleShare,
|
onToggleShare,
|
||||||
onToggleVideo,
|
onToggleVideo,
|
||||||
|
onToggleDeafen,
|
||||||
onHangup,
|
onHangup,
|
||||||
onOpenParticipants,
|
onOpenParticipants,
|
||||||
|
onToggleSoundboard,
|
||||||
|
soundboardOpen = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
glass = false,
|
glass = false,
|
||||||
disabledMedia = false,
|
disabledMedia = false,
|
||||||
@@ -59,6 +71,24 @@ export function CallControls({
|
|||||||
>
|
>
|
||||||
{muted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />}
|
{muted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />}
|
||||||
</CallButton>
|
</CallButton>
|
||||||
|
<CallButton
|
||||||
|
label={
|
||||||
|
deafened
|
||||||
|
? t('app:call.undeafen', { defaultValue: 'Ton wieder aktiv' })
|
||||||
|
: t('app:call.deafen', { defaultValue: 'Alle stumm' })
|
||||||
|
}
|
||||||
|
active={deafened}
|
||||||
|
activeTone="danger"
|
||||||
|
onClick={onToggleDeafen}
|
||||||
|
glass={glass}
|
||||||
|
className={btnSize}
|
||||||
|
>
|
||||||
|
{deafened ? (
|
||||||
|
<HeadphonesOffIcon className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<HeadphonesIcon className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</CallButton>
|
||||||
{onToggleVideo && (
|
{onToggleVideo && (
|
||||||
<CallButton
|
<CallButton
|
||||||
label={t('app:call.start_video', { defaultValue: 'Video' })}
|
label={t('app:call.start_video', { defaultValue: 'Video' })}
|
||||||
@@ -91,6 +121,18 @@ export function CallControls({
|
|||||||
<MonitorShareIcon className="h-5 w-5" />
|
<MonitorShareIcon className="h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
</CallButton>
|
</CallButton>
|
||||||
|
{onToggleSoundboard && (
|
||||||
|
<CallButton
|
||||||
|
label={t('app:soundboard.toggle', { defaultValue: 'Soundboard' })}
|
||||||
|
active={soundboardOpen}
|
||||||
|
activeTone="accent"
|
||||||
|
onClick={onToggleSoundboard}
|
||||||
|
glass={glass}
|
||||||
|
className={btnSize}
|
||||||
|
>
|
||||||
|
<MusicIcon className="h-5 w-5" />
|
||||||
|
</CallButton>
|
||||||
|
)}
|
||||||
{onOpenParticipants && (
|
{onOpenParticipants && (
|
||||||
<CallButton
|
<CallButton
|
||||||
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { CrownIcon, LockIcon, MicOffIcon, MonitorShareIcon } from './icons';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { CrownIcon, HeadphonesOffIcon, LockIcon, MicOffIcon } from './icons';
|
||||||
|
|
||||||
export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal';
|
export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal';
|
||||||
|
|
||||||
@@ -41,14 +43,20 @@ export interface ParticipantTileProps {
|
|||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
me: boolean;
|
me: boolean;
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
|
/** Local-only: true when THIS user has muted everyone else via the deafen
|
||||||
|
* toggle. Remote deafen state is not propagated, so only the own tile
|
||||||
|
* ever carries a truthy value. */
|
||||||
|
deafened: boolean;
|
||||||
speaking: boolean;
|
speaking: boolean;
|
||||||
sharing: boolean;
|
|
||||||
video: boolean;
|
video: boolean;
|
||||||
e2ee: boolean;
|
e2ee: boolean;
|
||||||
|
/** MediaStreamTrack for the participant's active camera, when `video` is
|
||||||
|
* true. When null the tile falls back to the avatar placeholder. */
|
||||||
|
videoTrack?: MediaStreamTrack | null;
|
||||||
size?: 'default' | 'small';
|
size?: 'default' | 'small';
|
||||||
focused?: boolean;
|
focused?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
onOpenScreenShare?: () => void;
|
onContextMenu?: (e: React.MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CallParticipantTile(props: ParticipantTileProps) {
|
export function CallParticipantTile(props: ParticipantTileProps) {
|
||||||
@@ -56,14 +64,14 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
|||||||
displayName,
|
displayName,
|
||||||
me,
|
me,
|
||||||
muted,
|
muted,
|
||||||
|
deafened,
|
||||||
speaking,
|
speaking,
|
||||||
sharing,
|
|
||||||
video,
|
video,
|
||||||
e2ee,
|
e2ee,
|
||||||
size = 'default',
|
size = 'default',
|
||||||
focused = false,
|
focused = false,
|
||||||
onClick,
|
onClick,
|
||||||
onOpenScreenShare,
|
onContextMenu,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const small = size === 'small';
|
const small = size === 'small';
|
||||||
@@ -76,6 +84,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
className={
|
className={
|
||||||
'relative flex flex-col overflow-hidden rounded-[14px] border bg-surface-3 transition ' +
|
'relative flex flex-col overflow-hidden rounded-[14px] border bg-surface-3 transition ' +
|
||||||
(onClick ? 'cursor-pointer ' : '') +
|
(onClick ? 'cursor-pointer ' : '') +
|
||||||
@@ -83,18 +92,21 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
|||||||
(small ? ' min-w-[140px]' : '')
|
(small ? ' min-w-[140px]' : '')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{sharing ? (
|
{video ? (
|
||||||
<ScreenshareStub
|
|
||||||
displayName={displayName}
|
|
||||||
small={small}
|
|
||||||
onOpen={onOpenScreenShare}
|
|
||||||
/>
|
|
||||||
) : video ? (
|
|
||||||
<VideoStub {...props} small={small} />
|
<VideoStub {...props} small={small} />
|
||||||
) : (
|
) : (
|
||||||
<AudioContent {...props} small={small} />
|
<AudioContent {...props} small={small} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Speaking indicator visible regardless of content type (video or
|
||||||
|
audio). z-10 ensures it sits above the video element. */}
|
||||||
|
{speaking && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 z-10 rounded-[14px] border-[3px] border-emerald-400 shadow-[inset_0_0_18px_rgba(34,197,94,0.55)]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="pointer-events-none absolute inset-x-2 bottom-2 flex items-center justify-between gap-2 rounded-lg glass-chip px-2.5 py-1.5">
|
<div className="pointer-events-none absolute inset-x-2 bottom-2 flex items-center justify-between gap-2 rounded-lg glass-chip px-2.5 py-1.5">
|
||||||
<div className="flex min-w-0 items-center gap-1.5 text-xs font-semibold">
|
<div className="flex min-w-0 items-center gap-1.5 text-xs font-semibold">
|
||||||
{me && <CrownIcon className="h-3 w-3 shrink-0 text-amber-300" />}
|
{me && <CrownIcon className="h-3 w-3 shrink-0 text-amber-300" />}
|
||||||
@@ -114,13 +126,21 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
{muted && (
|
{muted && (
|
||||||
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white">
|
<span
|
||||||
|
aria-label="Mikro stumm"
|
||||||
|
title="Mikro stumm"
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
|
||||||
|
>
|
||||||
<MicOffIcon className="h-3 w-3" />
|
<MicOffIcon className="h-3 w-3" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{sharing && (
|
{deafened && (
|
||||||
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-emerald-500/80 text-white">
|
<span
|
||||||
<MonitorShareIcon className="h-3 w-3" />
|
aria-label="Ton aus"
|
||||||
|
title="Ton aus"
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
|
||||||
|
>
|
||||||
|
<HeadphonesOffIcon className="h-3 w-3" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -179,11 +199,45 @@ function VideoStub({
|
|||||||
userId,
|
userId,
|
||||||
displayName,
|
displayName,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
|
videoTrack,
|
||||||
|
me,
|
||||||
small,
|
small,
|
||||||
}: ParticipantTileProps & { small: boolean }) {
|
}: ParticipantTileProps & { small: boolean }) {
|
||||||
// Video capture playback is out of scope for this UI iteration — show the
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
// audio-avatar gradient background as a placeholder so the layout is stable
|
useEffect(() => {
|
||||||
// when a participant enables video.
|
const el = videoRef.current;
|
||||||
|
if (!el || !videoTrack) return;
|
||||||
|
el.srcObject = new MediaStream([videoTrack]);
|
||||||
|
return () => {
|
||||||
|
if (el.srcObject) {
|
||||||
|
(el.srcObject as MediaStream).getTracks().forEach((t) => {
|
||||||
|
// Don't stop the live track — other consumers may still need it.
|
||||||
|
// Just detach from this element.
|
||||||
|
void t;
|
||||||
|
});
|
||||||
|
el.srcObject = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [videoTrack]);
|
||||||
|
|
||||||
|
if (videoTrack) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-0 flex-1 items-center justify-center bg-black">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
className={
|
||||||
|
'h-full w-full object-cover ' +
|
||||||
|
(me ? 'scale-x-[-1]' : '') /* mirror local preview */
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera flag true but no track yet (publishing / subscribing race).
|
||||||
const key = colorKeyFor(userId);
|
const key = colorKeyFor(userId);
|
||||||
const colors = AVATAR_COLORS[key];
|
const colors = AVATAR_COLORS[key];
|
||||||
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||||
@@ -206,49 +260,3 @@ function VideoStub({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScreenshareStubProps {
|
|
||||||
displayName: string;
|
|
||||||
small: boolean;
|
|
||||||
onOpen?: (() => void) | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fake browser window placeholder — click to open the real <video> viewer.
|
|
||||||
// Matches the design spec's "screenshare-stub" look.
|
|
||||||
function ScreenshareStub({ displayName, small, onOpen }: ScreenshareStubProps) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={
|
|
||||||
onOpen
|
|
||||||
? (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onOpen();
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
aria-label={`${displayName} teilt Bildschirm`}
|
|
||||||
className="relative flex min-h-0 flex-1 cursor-pointer items-center justify-center bg-ink-900 p-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
|
||||||
>
|
|
||||||
<div className="h-[85%] w-[90%] overflow-hidden rounded-lg border border-white/10 bg-ink-700">
|
|
||||||
<div className="flex h-[20px] items-center gap-1 bg-ink-600 px-2">
|
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
|
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
|
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
|
|
||||||
</div>
|
|
||||||
<div className={'flex flex-col gap-2 ' + (small ? 'gap-[3px] p-1.5' : 'p-3.5')}>
|
|
||||||
<div className={'w-[60%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
|
|
||||||
<div className={'w-[80%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
|
|
||||||
<div className={'w-[40%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
|
|
||||||
<div className={'my-1 rounded ' + (small ? 'h-[14px]' : 'h-10') + ' bg-accent/30'} />
|
|
||||||
<div className={'w-[70%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!small && (
|
|
||||||
<div className="absolute left-3 top-3 flex items-center gap-1.5 glass-chip rounded-lg px-2.5 py-1 text-[10px] font-medium">
|
|
||||||
<MonitorShareIcon className="h-3 w-3" />
|
|
||||||
<span>{displayName} teilt Bildschirm</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useCall } from '../context/CallContext';
|
import { useCall } from '../context/CallContext';
|
||||||
import { useConversationsContext } from '../context/ConversationsContext';
|
import { useConversationsContext } from '../context/ConversationsContext';
|
||||||
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
||||||
@@ -17,12 +18,17 @@ import { LockIcon, MonitorShareIcon, PhoneIcon, PhoneOffIcon } from './icons';
|
|||||||
// different route.
|
// different route.
|
||||||
export function CallUI() {
|
export function CallUI() {
|
||||||
const { state } = useCall();
|
const { state } = useCall();
|
||||||
|
const { profile } = useAuth();
|
||||||
|
const dnd = profile?.presenceState === 'dnd';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// DND silences only the *incoming* ring — outgoing stays audible because
|
||||||
|
// the user initiated that call themselves. The incoming-call panel still
|
||||||
|
// appears visually; only the audible ring is suppressed.
|
||||||
if (state.kind === 'outgoing') ringtone.start('outgoing');
|
if (state.kind === 'outgoing') ringtone.start('outgoing');
|
||||||
else if (state.kind === 'incoming') ringtone.start('incoming');
|
else if (state.kind === 'incoming' && !dnd) ringtone.start('incoming');
|
||||||
else ringtone.stop();
|
else ringtone.stop();
|
||||||
}, [state.kind]);
|
}, [state.kind, dnd]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => ringtone.stop();
|
return () => ringtone.stop();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useCall } from '../context/CallContext';
|
import { useCall } from '../context/CallContext';
|
||||||
import { useCallPresence } from '../lib/useCallPresence';
|
import { useCallPresence } from '../lib/useCallPresence';
|
||||||
|
import type { PeerPresence } from '../lib/usePeerPresence';
|
||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
import {
|
import {
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
@@ -26,7 +27,7 @@ const PRESENCE_DOT: Record<PresenceState, string> = {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
conversation: ConversationSummary | null;
|
conversation: ConversationSummary | null;
|
||||||
peerPresence: PresenceState | null;
|
peerPresence: PeerPresence | null;
|
||||||
onInfoClick?: () => void;
|
onInfoClick?: () => void;
|
||||||
onSearchClick?: () => void;
|
onSearchClick?: () => void;
|
||||||
}
|
}
|
||||||
@@ -51,7 +52,7 @@ export function ConversationHeader({ conversation, peerPresence, onInfoClick, on
|
|||||||
|
|
||||||
interface HeaderBarProps {
|
interface HeaderBarProps {
|
||||||
conversation: ConversationSummary;
|
conversation: ConversationSummary;
|
||||||
peerPresence: PresenceState | null;
|
peerPresence: PeerPresence | null;
|
||||||
onInfoClick?: () => void;
|
onInfoClick?: () => void;
|
||||||
onSearchClick?: () => void;
|
onSearchClick?: () => void;
|
||||||
}
|
}
|
||||||
@@ -65,8 +66,18 @@ function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: H
|
|||||||
const peerAvatar = isDm ? (conversation.peer?.avatarUrl ?? null) : (conversation.avatarUrl ?? null);
|
const peerAvatar = isDm ? (conversation.peer?.avatarUrl ?? null) : (conversation.avatarUrl ?? null);
|
||||||
|
|
||||||
// Hide presence when peer chose invisible — reciprocal privacy.
|
// Hide presence when peer chose invisible — reciprocal privacy.
|
||||||
const showPresence = isDm && peerPresence && peerPresence !== 'invisible';
|
const peerState = peerPresence?.state ?? null;
|
||||||
const presenceLabel = peerPresence ? t('app:presence.' + peerPresence) : '';
|
const showPresence = isDm && peerState && peerState !== 'invisible';
|
||||||
|
// Subtitle priority: custom status_message when online (or idle/dnd), else
|
||||||
|
// the localized presence label. Offline always wins → just "Offline".
|
||||||
|
const presenceLabel = (() => {
|
||||||
|
if (!peerState) return '';
|
||||||
|
if (peerState === 'offline') return t('app:presence.offline');
|
||||||
|
if (peerPresence?.statusMessage && peerPresence.statusMessage.trim().length > 0) {
|
||||||
|
return peerPresence.statusMessage.trim();
|
||||||
|
}
|
||||||
|
return t('app:presence.' + peerState);
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex items-center gap-3 border-b border-line bg-surface-3 px-6 py-3">
|
<header className="flex items-center gap-3 border-b border-line bg-surface-3 px-6 py-3">
|
||||||
@@ -80,12 +91,12 @@ function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: H
|
|||||||
<UsersIcon className="h-5 w-5" />
|
<UsersIcon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showPresence && peerPresence && (
|
{showPresence && peerState && (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={
|
className={
|
||||||
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-3 ' +
|
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-3 ' +
|
||||||
PRESENCE_DOT[peerPresence]
|
PRESENCE_DOT[peerState]
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { type CrashEntry, subscribeCrashes } from '../lib/crashRecovery';
|
||||||
|
import { AlertIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
const VISIBLE_MS = 7_000;
|
||||||
|
const MAX_STACK = 3;
|
||||||
|
|
||||||
|
// Bottom-right stack of toasts for uncaught errors. Auto-dismisses each
|
||||||
|
// entry after VISIBLE_MS. The user can X-out earlier.
|
||||||
|
export function CrashToast() {
|
||||||
|
const [entries, setEntries] = useState<CrashEntry[]>([]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
subscribeCrashes((entry) => {
|
||||||
|
setEntries((prev) => [...prev.slice(-(MAX_STACK - 1)), entry]);
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
const latest = entries[entries.length - 1]!;
|
||||||
|
const id = window.setTimeout(() => {
|
||||||
|
setEntries((prev) => prev.filter((e) => e.id !== latest.id));
|
||||||
|
}, VISIBLE_MS);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="Fehler"
|
||||||
|
className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
role="alert"
|
||||||
|
className="pointer-events-auto flex max-w-sm items-start gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-700 shadow-xl backdrop-blur-sm dark:text-rose-100"
|
||||||
|
>
|
||||||
|
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider">
|
||||||
|
{entry.source === 'promise' ? 'Promise-Fehler' : 'Unerwarteter Fehler'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 break-words text-xs">{entry.message}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Schließen"
|
||||||
|
onClick={() =>
|
||||||
|
setEntries((prev) => prev.filter((e) => e.id !== entry.id))
|
||||||
|
}
|
||||||
|
className="shrink-0 cursor-pointer rounded-md p-1 text-rose-700 transition hover:bg-rose-500/20 dark:text-rose-100"
|
||||||
|
>
|
||||||
|
<XIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import {
|
import {
|
||||||
decodePrivateKeyFromBackup,
|
decodePrivateKeyFromBackup,
|
||||||
importDeviceBackup,
|
importDeviceBackup,
|
||||||
|
normalizeRecoveryCode,
|
||||||
} from '../lib/deviceBackup';
|
} from '../lib/deviceBackup';
|
||||||
import { devLocalSecretStore } from '../lib/secretStore';
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
@@ -28,6 +29,7 @@ export function DeviceRestore({ userId, onRestored }: Props) {
|
|||||||
const { t } = useTranslation(['app', 'errors']);
|
const { t } = useTranslation(['app', 'errors']);
|
||||||
const [backup, setBackup] = useState('');
|
const [backup, setBackup] = useState('');
|
||||||
const [passphrase, setPassphrase] = useState('');
|
const [passphrase, setPassphrase] = useState('');
|
||||||
|
const [mode, setMode] = useState<'passphrase' | 'recovery'>('passphrase');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -39,7 +41,9 @@ export function DeviceRestore({ userId, onRestored }: Props) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
let privateKey: Uint8Array | null = null;
|
let privateKey: Uint8Array | null = null;
|
||||||
try {
|
try {
|
||||||
const payload = await importDeviceBackup(backup.trim(), passphrase);
|
const secret =
|
||||||
|
mode === 'recovery' ? normalizeRecoveryCode(passphrase) : passphrase;
|
||||||
|
const payload = await importDeviceBackup(backup.trim(), secret);
|
||||||
privateKey = decodePrivateKeyFromBackup(payload);
|
privateKey = decodePrivateKeyFromBackup(payload);
|
||||||
|
|
||||||
const device = await restoreDeviceFromServerRecord({
|
const device = await restoreDeviceFromServerRecord({
|
||||||
@@ -63,7 +67,7 @@ export function DeviceRestore({ userId, onRestored }: Props) {
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[backup, passphrase, userId, onRestored, busy],
|
[backup, passphrase, mode, userId, onRestored, busy],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -102,16 +106,42 @@ export function DeviceRestore({ userId, onRestored }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
<div className="flex items-center justify-between">
|
||||||
{t('app:backup.passphrase', { defaultValue: 'Passphrase' })}
|
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
</label>
|
{mode === 'passphrase' ? 'Passphrase' : 'Recovery-Code'}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMode((m) => (m === 'passphrase' ? 'recovery' : 'passphrase'));
|
||||||
|
setPassphrase('');
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer text-[11px] font-semibold text-brand-300 hover:underline"
|
||||||
|
>
|
||||||
|
{mode === 'passphrase'
|
||||||
|
? 'Passphrase vergessen? Recovery-Code nutzen'
|
||||||
|
: 'Stattdessen Passphrase eingeben'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={mode === 'passphrase' ? 'password' : 'text'}
|
||||||
required
|
required
|
||||||
value={passphrase}
|
value={passphrase}
|
||||||
onChange={(e) => setPassphrase(e.target.value)}
|
onChange={(e) => setPassphrase(e.target.value)}
|
||||||
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
placeholder={mode === 'recovery' ? 'XXXXXX-XXXXXX-XXXXXX-XXXXXX' : ''}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete={mode === 'recovery' ? 'off' : 'current-password'}
|
||||||
|
className={
|
||||||
|
'w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40 ' +
|
||||||
|
(mode === 'recovery' ? 'font-mono tracking-widest' : '')
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
{mode === 'recovery' && (
|
||||||
|
<p className="text-[11px] text-neutral-500">
|
||||||
|
Stattdessen den Recovery-Backup-String oben einfügen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface EmojiEntry {
|
||||||
|
e: string;
|
||||||
|
k: string[]; // search keywords (incl. name)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
label: string;
|
||||||
|
entries: EmojiEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Curated emoji set — small enough to stay fast without a dependency, wide
|
||||||
|
// enough to cover everyday messaging. Keywords are the primary search
|
||||||
|
// surface; the emoji character itself is also matched so a user typing ❤️
|
||||||
|
// literally finds it.
|
||||||
|
const CATEGORIES: Category[] = [
|
||||||
|
{
|
||||||
|
label: 'Smileys',
|
||||||
|
entries: [
|
||||||
|
{ e: '😀', k: ['grin', 'smile', 'happy'] },
|
||||||
|
{ e: '😃', k: ['smile', 'happy'] },
|
||||||
|
{ e: '😄', k: ['smile', 'laugh'] },
|
||||||
|
{ e: '😁', k: ['grin', 'smile'] },
|
||||||
|
{ e: '😆', k: ['laugh', 'lol'] },
|
||||||
|
{ e: '😅', k: ['sweat', 'nervous', 'laugh'] },
|
||||||
|
{ e: '🤣', k: ['lol', 'rofl', 'laugh'] },
|
||||||
|
{ e: '😂', k: ['joy', 'laugh', 'tears'] },
|
||||||
|
{ e: '🙂', k: ['smile', 'slight'] },
|
||||||
|
{ e: '🙃', k: ['upside', 'irony'] },
|
||||||
|
{ e: '😉', k: ['wink'] },
|
||||||
|
{ e: '😊', k: ['blush', 'smile'] },
|
||||||
|
{ e: '😇', k: ['angel', 'innocent'] },
|
||||||
|
{ e: '🥰', k: ['love', 'hearts'] },
|
||||||
|
{ e: '😍', k: ['love', 'heart eyes'] },
|
||||||
|
{ e: '🤩', k: ['star', 'excited'] },
|
||||||
|
{ e: '😘', k: ['kiss'] },
|
||||||
|
{ e: '😗', k: ['kiss'] },
|
||||||
|
{ e: '😚', k: ['kiss'] },
|
||||||
|
{ e: '😙', k: ['kiss'] },
|
||||||
|
{ e: '🥲', k: ['tear', 'smile'] },
|
||||||
|
{ e: '😋', k: ['yum', 'tasty'] },
|
||||||
|
{ e: '😛', k: ['tongue'] },
|
||||||
|
{ e: '😜', k: ['tongue', 'wink'] },
|
||||||
|
{ e: '🤪', k: ['zany', 'silly'] },
|
||||||
|
{ e: '😝', k: ['tongue'] },
|
||||||
|
{ e: '🤑', k: ['money'] },
|
||||||
|
{ e: '🤗', k: ['hug'] },
|
||||||
|
{ e: '🤭', k: ['giggle', 'shy'] },
|
||||||
|
{ e: '🤫', k: ['shush', 'quiet'] },
|
||||||
|
{ e: '🤔', k: ['think'] },
|
||||||
|
{ e: '🤐', k: ['zip', 'quiet'] },
|
||||||
|
{ e: '🤨', k: ['raise brow'] },
|
||||||
|
{ e: '😐', k: ['neutral'] },
|
||||||
|
{ e: '😑', k: ['expressionless'] },
|
||||||
|
{ e: '😶', k: ['speechless'] },
|
||||||
|
{ e: '😏', k: ['smirk'] },
|
||||||
|
{ e: '😒', k: ['unamused'] },
|
||||||
|
{ e: '🙄', k: ['eye roll'] },
|
||||||
|
{ e: '😬', k: ['grimace', 'awkward'] },
|
||||||
|
{ e: '🤥', k: ['lying'] },
|
||||||
|
{ e: '😔', k: ['sad', 'pensive'] },
|
||||||
|
{ e: '😪', k: ['sleepy'] },
|
||||||
|
{ e: '😴', k: ['sleep'] },
|
||||||
|
{ e: '😷', k: ['mask', 'sick'] },
|
||||||
|
{ e: '🤒', k: ['sick', 'fever'] },
|
||||||
|
{ e: '🤕', k: ['injured'] },
|
||||||
|
{ e: '🤢', k: ['nauseated'] },
|
||||||
|
{ e: '🤮', k: ['vomit'] },
|
||||||
|
{ e: '🤧', k: ['sneeze'] },
|
||||||
|
{ e: '🥵', k: ['hot'] },
|
||||||
|
{ e: '🥶', k: ['cold'] },
|
||||||
|
{ e: '🥴', k: ['dizzy', 'woozy'] },
|
||||||
|
{ e: '😵', k: ['dizzy'] },
|
||||||
|
{ e: '🤯', k: ['mind blown'] },
|
||||||
|
{ e: '🤠', k: ['cowboy'] },
|
||||||
|
{ e: '🥳', k: ['party'] },
|
||||||
|
{ e: '😎', k: ['cool', 'sunglasses'] },
|
||||||
|
{ e: '🤓', k: ['nerd'] },
|
||||||
|
{ e: '🧐', k: ['monocle'] },
|
||||||
|
{ e: '😕', k: ['confused'] },
|
||||||
|
{ e: '😟', k: ['worried'] },
|
||||||
|
{ e: '🙁', k: ['frown'] },
|
||||||
|
{ e: '☹️', k: ['frown'] },
|
||||||
|
{ e: '😮', k: ['open mouth'] },
|
||||||
|
{ e: '😯', k: ['hushed'] },
|
||||||
|
{ e: '😲', k: ['astonished'] },
|
||||||
|
{ e: '😳', k: ['flushed'] },
|
||||||
|
{ e: '🥺', k: ['pleading'] },
|
||||||
|
{ e: '😦', k: ['frowning'] },
|
||||||
|
{ e: '😧', k: ['anguished'] },
|
||||||
|
{ e: '😨', k: ['fear'] },
|
||||||
|
{ e: '😰', k: ['anxious', 'sweat'] },
|
||||||
|
{ e: '😥', k: ['sad', 'relieved'] },
|
||||||
|
{ e: '😢', k: ['cry'] },
|
||||||
|
{ e: '😭', k: ['cry', 'loud'] },
|
||||||
|
{ e: '😱', k: ['scream', 'scared'] },
|
||||||
|
{ e: '😖', k: ['confounded'] },
|
||||||
|
{ e: '😣', k: ['persevere'] },
|
||||||
|
{ e: '😞', k: ['disappointed'] },
|
||||||
|
{ e: '😓', k: ['sweat'] },
|
||||||
|
{ e: '😩', k: ['weary'] },
|
||||||
|
{ e: '😫', k: ['tired'] },
|
||||||
|
{ e: '🥱', k: ['yawn'] },
|
||||||
|
{ e: '😤', k: ['triumph'] },
|
||||||
|
{ e: '😡', k: ['angry', 'rage'] },
|
||||||
|
{ e: '😠', k: ['angry'] },
|
||||||
|
{ e: '🤬', k: ['swear', 'curse'] },
|
||||||
|
{ e: '😈', k: ['devil'] },
|
||||||
|
{ e: '👿', k: ['imp'] },
|
||||||
|
{ e: '💀', k: ['skull', 'dead'] },
|
||||||
|
{ e: '🤡', k: ['clown'] },
|
||||||
|
{ e: '👻', k: ['ghost'] },
|
||||||
|
{ e: '👽', k: ['alien'] },
|
||||||
|
{ e: '🤖', k: ['robot'] },
|
||||||
|
{ e: '💩', k: ['poop', 'shit'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Gestures',
|
||||||
|
entries: [
|
||||||
|
{ e: '👋', k: ['wave', 'hi'] },
|
||||||
|
{ e: '🤚', k: ['hand'] },
|
||||||
|
{ e: '🖐️', k: ['hand'] },
|
||||||
|
{ e: '✋', k: ['stop', 'high five'] },
|
||||||
|
{ e: '🖖', k: ['spock'] },
|
||||||
|
{ e: '👌', k: ['ok'] },
|
||||||
|
{ e: '🤌', k: ['pinch'] },
|
||||||
|
{ e: '🤏', k: ['small'] },
|
||||||
|
{ e: '✌️', k: ['peace', 'victory'] },
|
||||||
|
{ e: '🤞', k: ['crossed fingers'] },
|
||||||
|
{ e: '🤟', k: ['love you'] },
|
||||||
|
{ e: '🤘', k: ['rock'] },
|
||||||
|
{ e: '🤙', k: ['call me'] },
|
||||||
|
{ e: '👈', k: ['point left'] },
|
||||||
|
{ e: '👉', k: ['point right'] },
|
||||||
|
{ e: '👆', k: ['point up'] },
|
||||||
|
{ e: '🖕', k: ['middle finger', 'fuck'] },
|
||||||
|
{ e: '👇', k: ['point down'] },
|
||||||
|
{ e: '☝️', k: ['point up'] },
|
||||||
|
{ e: '👍', k: ['thumbs up', 'like'] },
|
||||||
|
{ e: '👎', k: ['thumbs down', 'dislike'] },
|
||||||
|
{ e: '✊', k: ['fist'] },
|
||||||
|
{ e: '👊', k: ['punch'] },
|
||||||
|
{ e: '🤛', k: ['fist left'] },
|
||||||
|
{ e: '🤜', k: ['fist right'] },
|
||||||
|
{ e: '👏', k: ['clap'] },
|
||||||
|
{ e: '🙌', k: ['raised hands'] },
|
||||||
|
{ e: '👐', k: ['open hands'] },
|
||||||
|
{ e: '🤲', k: ['palms'] },
|
||||||
|
{ e: '🙏', k: ['pray', 'thanks'] },
|
||||||
|
{ e: '✍️', k: ['write'] },
|
||||||
|
{ e: '💪', k: ['flex', 'strong'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Hearts',
|
||||||
|
entries: [
|
||||||
|
{ e: '❤️', k: ['heart', 'love'] },
|
||||||
|
{ e: '🧡', k: ['orange heart'] },
|
||||||
|
{ e: '💛', k: ['yellow heart'] },
|
||||||
|
{ e: '💚', k: ['green heart'] },
|
||||||
|
{ e: '💙', k: ['blue heart'] },
|
||||||
|
{ e: '💜', k: ['purple heart'] },
|
||||||
|
{ e: '🖤', k: ['black heart'] },
|
||||||
|
{ e: '🤍', k: ['white heart'] },
|
||||||
|
{ e: '🤎', k: ['brown heart'] },
|
||||||
|
{ e: '💔', k: ['broken heart'] },
|
||||||
|
{ e: '❣️', k: ['heart exclamation'] },
|
||||||
|
{ e: '💕', k: ['hearts'] },
|
||||||
|
{ e: '💞', k: ['revolving hearts'] },
|
||||||
|
{ e: '💓', k: ['beating heart'] },
|
||||||
|
{ e: '💗', k: ['growing heart'] },
|
||||||
|
{ e: '💖', k: ['sparkle heart'] },
|
||||||
|
{ e: '💘', k: ['cupid'] },
|
||||||
|
{ e: '💝', k: ['heart gift'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Animals & Food',
|
||||||
|
entries: [
|
||||||
|
{ e: '🐶', k: ['dog'] },
|
||||||
|
{ e: '🐱', k: ['cat'] },
|
||||||
|
{ e: '🐭', k: ['mouse'] },
|
||||||
|
{ e: '🐹', k: ['hamster'] },
|
||||||
|
{ e: '🐰', k: ['rabbit'] },
|
||||||
|
{ e: '🦊', k: ['fox'] },
|
||||||
|
{ e: '🐻', k: ['bear'] },
|
||||||
|
{ e: '🐼', k: ['panda'] },
|
||||||
|
{ e: '🐨', k: ['koala'] },
|
||||||
|
{ e: '🐯', k: ['tiger'] },
|
||||||
|
{ e: '🦁', k: ['lion'] },
|
||||||
|
{ e: '🐸', k: ['frog'] },
|
||||||
|
{ e: '🐵', k: ['monkey'] },
|
||||||
|
{ e: '🐔', k: ['chicken'] },
|
||||||
|
{ e: '🐧', k: ['penguin'] },
|
||||||
|
{ e: '🐦', k: ['bird'] },
|
||||||
|
{ e: '🦆', k: ['duck'] },
|
||||||
|
{ e: '🍎', k: ['apple'] },
|
||||||
|
{ e: '🍌', k: ['banana'] },
|
||||||
|
{ e: '🍕', k: ['pizza'] },
|
||||||
|
{ e: '🍔', k: ['burger'] },
|
||||||
|
{ e: '🍟', k: ['fries'] },
|
||||||
|
{ e: '🌭', k: ['hotdog'] },
|
||||||
|
{ e: '🍿', k: ['popcorn'] },
|
||||||
|
{ e: '🍣', k: ['sushi'] },
|
||||||
|
{ e: '🍩', k: ['donut'] },
|
||||||
|
{ e: '🍪', k: ['cookie'] },
|
||||||
|
{ e: '🎂', k: ['cake', 'birthday'] },
|
||||||
|
{ e: '🍰', k: ['cake'] },
|
||||||
|
{ e: '🍫', k: ['chocolate'] },
|
||||||
|
{ e: '🍺', k: ['beer'] },
|
||||||
|
{ e: '🍷', k: ['wine'] },
|
||||||
|
{ e: '🥂', k: ['cheers'] },
|
||||||
|
{ e: '☕', k: ['coffee'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Objects & Symbols',
|
||||||
|
entries: [
|
||||||
|
{ e: '🔥', k: ['fire', 'lit'] },
|
||||||
|
{ e: '✨', k: ['sparkle'] },
|
||||||
|
{ e: '⭐', k: ['star'] },
|
||||||
|
{ e: '🌟', k: ['star glowing'] },
|
||||||
|
{ e: '💫', k: ['dizzy'] },
|
||||||
|
{ e: '💥', k: ['boom', 'explosion'] },
|
||||||
|
{ e: '⚡', k: ['lightning'] },
|
||||||
|
{ e: '☀️', k: ['sun'] },
|
||||||
|
{ e: '🌈', k: ['rainbow'] },
|
||||||
|
{ e: '☁️', k: ['cloud'] },
|
||||||
|
{ e: '🌧️', k: ['rain'] },
|
||||||
|
{ e: '❄️', k: ['snow'] },
|
||||||
|
{ e: '🎉', k: ['party', 'tada'] },
|
||||||
|
{ e: '🎊', k: ['confetti'] },
|
||||||
|
{ e: '🎁', k: ['gift'] },
|
||||||
|
{ e: '🎈', k: ['balloon'] },
|
||||||
|
{ e: '💯', k: ['100', 'perfect'] },
|
||||||
|
{ e: '✅', k: ['check'] },
|
||||||
|
{ e: '❌', k: ['x', 'no'] },
|
||||||
|
{ e: '⚠️', k: ['warning'] },
|
||||||
|
{ e: '❓', k: ['question'] },
|
||||||
|
{ e: '❗', k: ['exclamation'] },
|
||||||
|
{ e: '💬', k: ['speech'] },
|
||||||
|
{ e: '💭', k: ['thought'] },
|
||||||
|
{ e: '👀', k: ['eyes'] },
|
||||||
|
{ e: '🚀', k: ['rocket'] },
|
||||||
|
{ e: '🎵', k: ['music'] },
|
||||||
|
{ e: '🎶', k: ['music'] },
|
||||||
|
{ e: '🔔', k: ['bell'] },
|
||||||
|
{ e: '💡', k: ['idea', 'bulb'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const RECENT_KEY = 'chat.emoji.recents.v1';
|
||||||
|
const RECENT_MAX = 24;
|
||||||
|
|
||||||
|
function loadRecents(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(RECENT_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.filter((x): x is string => typeof x === 'string').slice(0, RECENT_MAX);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRecents(list: string[]): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX)));
|
||||||
|
} catch {
|
||||||
|
/* ignore quota */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onPick: (emoji: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmojiPicker({ open, onPick, onClose }: Props) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [recents, setRecents] = useState<string[]>(() => loadRecents());
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
const t = e.target as HTMLElement | null;
|
||||||
|
if (rootRef.current && t && !rootRef.current.contains(t) && !t.closest('[data-emoji-trigger]')) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
window.addEventListener('mousedown', onDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
window.removeEventListener('mousedown', onDown);
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setQuery('');
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return CATEGORIES;
|
||||||
|
return CATEGORIES.map((cat) => ({
|
||||||
|
label: cat.label,
|
||||||
|
entries: cat.entries.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.e.includes(q) ||
|
||||||
|
entry.k.some((k) => k.includes(q)) ||
|
||||||
|
cat.label.toLowerCase().includes(q),
|
||||||
|
),
|
||||||
|
})).filter((cat) => cat.entries.length > 0);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const handlePick = (emoji: string) => {
|
||||||
|
onPick(emoji);
|
||||||
|
const next = [emoji, ...recents.filter((e) => e !== emoji)].slice(0, RECENT_MAX);
|
||||||
|
setRecents(next);
|
||||||
|
saveRecents(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Emoji auswählen"
|
||||||
|
className="absolute bottom-full right-0 z-30 mb-2 flex w-[320px] flex-col rounded-xl border border-line bg-surface-2 shadow-xl"
|
||||||
|
>
|
||||||
|
<div className="border-b border-line p-2">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Suchen…"
|
||||||
|
className="w-full rounded-md border border-line bg-surface-3 px-2.5 py-1.5 text-sm text-fg placeholder-fg-muted outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[320px] overflow-y-auto p-2">
|
||||||
|
{recents.length > 0 && !query && (
|
||||||
|
<CategoryBlock
|
||||||
|
label="Zuletzt"
|
||||||
|
entries={recents.map((e) => ({ e, k: [] }))}
|
||||||
|
onPick={handlePick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{filtered.map((cat) => (
|
||||||
|
<CategoryBlock
|
||||||
|
key={cat.label}
|
||||||
|
label={cat.label}
|
||||||
|
entries={cat.entries}
|
||||||
|
onPick={handlePick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<p className="py-4 text-center text-xs text-fg-muted">Keine Treffer</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryBlock({
|
||||||
|
label,
|
||||||
|
entries,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
entries: EmojiEntry[];
|
||||||
|
onPick: (emoji: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-2">
|
||||||
|
<p className="mb-1 px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-8 gap-0.5">
|
||||||
|
{entries.map((entry, idx) => (
|
||||||
|
<button
|
||||||
|
key={entry.e + ':' + idx}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick(entry.e)}
|
||||||
|
aria-label={entry.k[0] ?? entry.e}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-lg transition hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
{entry.e}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||||
import { useEffect, useState } from 'react';
|
import type { RemoteParticipant, Room } from 'livekit-client';
|
||||||
|
import { Track } from 'livekit-client';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
@@ -13,7 +15,10 @@ import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
|||||||
import { CallControls } from './CallControls';
|
import { CallControls } from './CallControls';
|
||||||
import { CallParticipantTile } from './CallParticipantTile';
|
import { CallParticipantTile } from './CallParticipantTile';
|
||||||
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||||
|
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
|
||||||
|
import { ScreenShareDialog } from './ScreenShareDialog';
|
||||||
import { ScreenShareViewer } from './ScreenShareViewer';
|
import { ScreenShareViewer } from './ScreenShareViewer';
|
||||||
|
import { SoundboardPanel } from './SoundboardPanel';
|
||||||
|
|
||||||
// Discord-style in-call dock rendered above the message list. Renders three
|
// Discord-style in-call dock rendered above the message list. Renders three
|
||||||
// visual modes driven by CallContext.callMode: grid, focus, fullscreen.
|
// visual modes driven by CallContext.callMode: grid, focus, fullscreen.
|
||||||
@@ -24,13 +29,24 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Tile {
|
interface Tile {
|
||||||
|
// `user` = person with avatar/video/mute info. Speaking ring applies here.
|
||||||
|
// `screen` = a separate screen-share window from a user. No speaking
|
||||||
|
// indicator, no mute, no avatar — just the stream.
|
||||||
|
kind: 'user' | 'screen';
|
||||||
|
// Stable id used for focus tracking + React keys. `user:${userId}` or
|
||||||
|
// `screen:${userId}`.
|
||||||
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
self: boolean;
|
self: boolean;
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
|
deafened: boolean;
|
||||||
video: boolean;
|
video: boolean;
|
||||||
|
videoTrack: MediaStreamTrack | null;
|
||||||
|
// True iff this is the current user's own screen-share tile.
|
||||||
sharing: boolean;
|
sharing: boolean;
|
||||||
|
// True iff this is a remote user's screen-share tile.
|
||||||
remoteSharing: boolean;
|
remoteSharing: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,11 +59,18 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
isMuted,
|
isMuted,
|
||||||
isE2EEActive,
|
isE2EEActive,
|
||||||
isScreenSharing,
|
isScreenSharing,
|
||||||
|
isCameraEnabled,
|
||||||
|
isDeafened,
|
||||||
|
remoteDeafen,
|
||||||
|
remoteMute,
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
callMode,
|
callMode,
|
||||||
focusedId,
|
focusedId,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
toggleScreenShare,
|
startScreenShare,
|
||||||
|
stopScreenShare,
|
||||||
|
toggleCamera,
|
||||||
|
toggleDeafen,
|
||||||
hangup,
|
hangup,
|
||||||
setCallMode,
|
setCallMode,
|
||||||
setFocusedId,
|
setFocusedId,
|
||||||
@@ -55,6 +78,23 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
const { session } = useAuth();
|
const { session } = useAuth();
|
||||||
const myId = session?.user.id ?? null;
|
const myId = session?.user.id ?? null;
|
||||||
const activeSpeakers = useActiveSpeakers(room);
|
const activeSpeakers = useActiveSpeakers(room);
|
||||||
|
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
||||||
|
const [soundboardOpen, setSoundboardOpen] = useState(false);
|
||||||
|
const [volumeMenu, setVolumeMenu] = useState<
|
||||||
|
{ userId: string; displayName: string; x: number; y: number } | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const openVolumeMenu = (tile: Tile, e: React.MouseEvent) => {
|
||||||
|
if (tile.self) return;
|
||||||
|
if (tile.kind !== 'user') return;
|
||||||
|
e.preventDefault();
|
||||||
|
setVolumeMenu({
|
||||||
|
userId: tile.userId,
|
||||||
|
displayName: tile.displayName,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const active =
|
const active =
|
||||||
(state.kind === 'connected' ||
|
(state.kind === 'connected' ||
|
||||||
@@ -66,9 +106,14 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
const tiles = buildTiles({
|
const tiles = buildTiles({
|
||||||
conversation,
|
conversation,
|
||||||
myId,
|
myId,
|
||||||
remoteIdentities: remoteParticipants.map((p) => p.identity),
|
room,
|
||||||
|
remoteParticipants,
|
||||||
isMuted,
|
isMuted,
|
||||||
|
isDeafened,
|
||||||
|
remoteDeafen,
|
||||||
|
remoteMute,
|
||||||
isScreenSharing,
|
isScreenSharing,
|
||||||
|
isCameraEnabled,
|
||||||
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
|
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,17 +131,31 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
|
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
|
||||||
: t('app:call.connected');
|
: t('app:call.connected');
|
||||||
|
|
||||||
const sharingTile = tiles.find((p) => p.sharing);
|
// A screen-share tile becomes the auto-focus target when no one explicitly
|
||||||
const effectiveFocusedId = focusedId ?? sharingTile?.userId ?? tiles[0]?.userId ?? null;
|
// picked a tile yet. Focus ids now use Tile.id (kind-prefixed) so we can
|
||||||
const speaker = tiles.find((p) => p.userId === effectiveFocusedId) ?? tiles[0];
|
// distinguish a user's own avatar tile from their screen tile.
|
||||||
|
const screenTile = tiles.find((p) => p.kind === 'screen');
|
||||||
|
const effectiveFocusedId = focusedId ?? screenTile?.id ?? tiles[0]?.id ?? null;
|
||||||
|
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
|
||||||
|
|
||||||
const controls = (
|
const controls = (
|
||||||
<CallControls
|
<CallControls
|
||||||
muted={isMuted}
|
muted={isMuted || !(room?.localParticipant?.isMicrophoneEnabled ?? false)}
|
||||||
sharing={isScreenSharing}
|
sharing={isScreenSharing}
|
||||||
video={false}
|
video={isCameraEnabled}
|
||||||
|
deafened={isDeafened}
|
||||||
onToggleMute={toggleMute}
|
onToggleMute={toggleMute}
|
||||||
onToggleShare={() => void toggleScreenShare()}
|
onToggleShare={() => {
|
||||||
|
if (isScreenSharing) {
|
||||||
|
void stopScreenShare();
|
||||||
|
} else {
|
||||||
|
setShareDialogOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onToggleVideo={() => void toggleCamera()}
|
||||||
|
onToggleDeafen={toggleDeafen}
|
||||||
|
onToggleSoundboard={() => setSoundboardOpen((v) => !v)}
|
||||||
|
soundboardOpen={soundboardOpen}
|
||||||
onHangup={() => void hangup()}
|
onHangup={() => void hangup()}
|
||||||
compact={callMode !== 'fullscreen'}
|
compact={callMode !== 'fullscreen'}
|
||||||
glass={callMode === 'fullscreen'}
|
glass={callMode === 'fullscreen'}
|
||||||
@@ -105,20 +164,50 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (callMode === 'fullscreen') {
|
if (callMode === 'fullscreen') {
|
||||||
|
// In fullscreen, a "manual focus" = user explicitly picked someone, OR
|
||||||
|
// someone is sharing a screen, OR exactly one non-self speaker is talking
|
||||||
|
// (auto-promote). Without that we show an even grid of all participants
|
||||||
|
// (Discord default). Clicking a tile switches to the big-speaker layout.
|
||||||
|
const speakingNonSelf = tiles.filter(
|
||||||
|
(t: Tile) => activeSpeakers.has(t.userId) && !t.self && t.kind === 'user',
|
||||||
|
);
|
||||||
|
const autoSpeaker =
|
||||||
|
focusedId === null && screenTile === undefined && speakingNonSelf.length === 1
|
||||||
|
? speakingNonSelf[0]
|
||||||
|
: undefined;
|
||||||
|
const hasFocus = focusedId !== null || screenTile !== undefined || autoSpeaker !== undefined;
|
||||||
|
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
|
||||||
return (
|
return (
|
||||||
<FullscreenCall
|
<>
|
||||||
tiles={tiles}
|
<FullscreenCall
|
||||||
speaker={speaker}
|
tiles={tiles}
|
||||||
remoteScreenShares={remoteScreenShares}
|
speaker={effectiveSpeaker}
|
||||||
conversationMembers={conversation.members}
|
remoteScreenShares={remoteScreenShares}
|
||||||
activeSpeakers={activeSpeakers}
|
conversationMembers={conversation.members}
|
||||||
e2ee={isE2EEActive}
|
activeSpeakers={activeSpeakers}
|
||||||
onExit={() => setCallMode('grid')}
|
e2ee={isE2EEActive}
|
||||||
onFocusTile={(id) => {
|
onExit={() => setCallMode('grid')}
|
||||||
setFocusedId(id);
|
onFocusTile={(id) => {
|
||||||
}}
|
// Toggle: click the already-focused tile to return to grid.
|
||||||
controls={controls}
|
setFocusedId(focusedId === id ? null : id);
|
||||||
/>
|
}}
|
||||||
|
onTileContextMenu={openVolumeMenu}
|
||||||
|
controls={controls}
|
||||||
|
/>
|
||||||
|
{volumeMenu && (
|
||||||
|
<ParticipantVolumeMenu
|
||||||
|
userId={volumeMenu.userId}
|
||||||
|
displayName={volumeMenu.displayName}
|
||||||
|
x={volumeMenu.x}
|
||||||
|
y={volumeMenu.y}
|
||||||
|
onClose={() => setVolumeMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SoundboardPopover
|
||||||
|
open={soundboardOpen}
|
||||||
|
onClose={() => setSoundboardOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,11 +216,19 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
? conversation.name ?? t('app:chats.new_group')
|
? conversation.name ?? t('app:chats.new_group')
|
||||||
: conversation.peer?.displayName ?? '—';
|
: conversation.peer?.displayName ?? '—';
|
||||||
|
|
||||||
|
// Focus mode dedicates the entire call-panel vertical slot to the speaker so
|
||||||
|
// the tile can grow in height (grid mode's 420px cap leaves it squashed).
|
||||||
|
const sectionClass =
|
||||||
|
callMode === 'focus'
|
||||||
|
? 'flex min-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2'
|
||||||
|
: 'flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2';
|
||||||
|
const sectionHeight = callMode === 'focus' ? '75%' : '50%';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })}
|
aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })}
|
||||||
className="flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2"
|
className={sectionClass}
|
||||||
style={{ height: '50%' }}
|
style={{ height: sectionHeight }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-line bg-surface-3 px-4 py-2">
|
<div className="flex items-center justify-between border-b border-line bg-surface-3 px-4 py-2">
|
||||||
<div className="flex min-w-0 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
@@ -168,16 +265,53 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
setFocusedId(id);
|
setFocusedId(id);
|
||||||
if (callMode === 'grid') setCallMode('focus');
|
if (callMode === 'grid') setCallMode('focus');
|
||||||
}}
|
}}
|
||||||
|
onTileContextMenu={openVolumeMenu}
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{controls}
|
{controls}
|
||||||
|
|
||||||
<PttHint />
|
<PttHint />
|
||||||
|
|
||||||
|
<ScreenShareDialog
|
||||||
|
open={shareDialogOpen}
|
||||||
|
onClose={() => setShareDialogOpen(false)}
|
||||||
|
onStart={async (opts) => {
|
||||||
|
await startScreenShare(opts);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{volumeMenu && (
|
||||||
|
<ParticipantVolumeMenu
|
||||||
|
userId={volumeMenu.userId}
|
||||||
|
displayName={volumeMenu.displayName}
|
||||||
|
x={volumeMenu.x}
|
||||||
|
y={volumeMenu.y}
|
||||||
|
onClose={() => setVolumeMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SoundboardPopover
|
||||||
|
open={soundboardOpen}
|
||||||
|
onClose={() => setSoundboardOpen(false)}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fixed-position overlay so the popover sits above both docked + fullscreen
|
||||||
|
// call modes without needing a portal or parent-relative anchoring.
|
||||||
|
function SoundboardPopover({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed inset-x-0 bottom-24 z-50 flex justify-center px-4">
|
||||||
|
<div className="pointer-events-auto">
|
||||||
|
<SoundboardPanel onClose={onClose} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -185,49 +319,123 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
interface BuildArgs {
|
interface BuildArgs {
|
||||||
conversation: ConversationSummary;
|
conversation: ConversationSummary;
|
||||||
myId: string | null;
|
myId: string | null;
|
||||||
remoteIdentities: string[];
|
room: Room | null;
|
||||||
|
remoteParticipants: RemoteParticipant[];
|
||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
|
isDeafened: boolean;
|
||||||
|
remoteDeafen: Record<string, boolean>;
|
||||||
|
remoteMute: Record<string, boolean>;
|
||||||
isScreenSharing: boolean;
|
isScreenSharing: boolean;
|
||||||
|
isCameraEnabled: boolean;
|
||||||
remoteSharerIds: Set<string>;
|
remoteSharerIds: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cameraTrackFor(
|
||||||
|
participant: { videoTrackPublications: Map<string, { source: Track.Source; track?: { mediaStreamTrack: MediaStreamTrack } | undefined }> } | null,
|
||||||
|
): MediaStreamTrack | null {
|
||||||
|
if (!participant) return null;
|
||||||
|
for (const pub of participant.videoTrackPublications.values()) {
|
||||||
|
if (pub.source === Track.Source.Camera && pub.track) {
|
||||||
|
return pub.track.mediaStreamTrack ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function buildTiles({
|
function buildTiles({
|
||||||
conversation,
|
conversation,
|
||||||
myId,
|
myId,
|
||||||
remoteIdentities,
|
room,
|
||||||
|
remoteParticipants,
|
||||||
isMuted,
|
isMuted,
|
||||||
|
isDeafened,
|
||||||
|
remoteDeafen,
|
||||||
|
remoteMute,
|
||||||
isScreenSharing,
|
isScreenSharing,
|
||||||
|
isCameraEnabled,
|
||||||
remoteSharerIds,
|
remoteSharerIds,
|
||||||
}: BuildArgs): Tile[] {
|
}: BuildArgs): Tile[] {
|
||||||
const remoteSet = new Set(remoteIdentities);
|
const remoteById = new Map<string, RemoteParticipant>();
|
||||||
|
for (const p of remoteParticipants) {
|
||||||
|
if (p.identity) remoteById.set(p.identity, p);
|
||||||
|
}
|
||||||
const out: Tile[] = [];
|
const out: Tile[] = [];
|
||||||
if (myId) {
|
if (myId) {
|
||||||
const me = conversation.members.find((m) => m.userId === myId) ?? null;
|
const me = conversation.members.find((m) => m.userId === myId) ?? null;
|
||||||
|
// Source-of-truth for own mic: the LocalParticipant publication state.
|
||||||
|
// `isMuted` reflects the toggle button, but if mic never published (no
|
||||||
|
// device / permission denied) the toggle stays false while the real
|
||||||
|
// state is "muted". Combine both so the badge always matches reality.
|
||||||
|
const micLive = room?.localParticipant?.isMicrophoneEnabled ?? false;
|
||||||
out.push({
|
out.push({
|
||||||
|
kind: 'user',
|
||||||
|
id: 'user:' + myId,
|
||||||
userId: myId,
|
userId: myId,
|
||||||
displayName: me?.profile?.displayName ?? '?',
|
displayName: me?.profile?.displayName ?? '?',
|
||||||
avatarUrl: me?.profile?.avatarUrl ?? null,
|
avatarUrl: me?.profile?.avatarUrl ?? null,
|
||||||
self: true,
|
self: true,
|
||||||
muted: isMuted,
|
muted: isMuted || !micLive,
|
||||||
video: false,
|
deafened: isDeafened,
|
||||||
sharing: isScreenSharing,
|
video: isCameraEnabled,
|
||||||
|
videoTrack: cameraTrackFor(room?.localParticipant ?? null),
|
||||||
|
sharing: false,
|
||||||
remoteSharing: false,
|
remoteSharing: false,
|
||||||
});
|
});
|
||||||
|
if (isScreenSharing) {
|
||||||
|
out.push({
|
||||||
|
kind: 'screen',
|
||||||
|
id: 'screen:' + myId,
|
||||||
|
userId: myId,
|
||||||
|
displayName: (me?.profile?.displayName ?? '?') + ' · Bildschirm',
|
||||||
|
avatarUrl: me?.profile?.avatarUrl ?? null,
|
||||||
|
self: true,
|
||||||
|
muted: false,
|
||||||
|
deafened: false,
|
||||||
|
video: false,
|
||||||
|
videoTrack: null,
|
||||||
|
sharing: true,
|
||||||
|
remoteSharing: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const m of conversation.members) {
|
for (const m of conversation.members) {
|
||||||
if (m.userId === myId) continue;
|
if (m.userId === myId) continue;
|
||||||
if (!remoteSet.has(m.userId)) continue;
|
const rp = remoteById.get(m.userId);
|
||||||
const sharing = remoteSharerIds.has(m.userId);
|
if (!rp) continue;
|
||||||
out.push({
|
out.push({
|
||||||
|
kind: 'user',
|
||||||
|
id: 'user:' + m.userId,
|
||||||
userId: m.userId,
|
userId: m.userId,
|
||||||
displayName: m.profile?.displayName ?? '?',
|
displayName: m.profile?.displayName ?? '?',
|
||||||
avatarUrl: m.profile?.avatarUrl ?? null,
|
avatarUrl: m.profile?.avatarUrl ?? null,
|
||||||
self: false,
|
self: false,
|
||||||
muted: false,
|
// Peer's self-reported mute state via data channel. LiveKit's own
|
||||||
video: false,
|
// `isMicrophoneEnabled` no longer flips on mute since the pipeline
|
||||||
sharing,
|
// output track stays published. See remoteMute broadcast in CallContext.
|
||||||
remoteSharing: sharing,
|
muted: remoteMute[m.userId] ?? false,
|
||||||
|
// Deafen state arrives via LiveKit data channel; see CallContext.
|
||||||
|
deafened: remoteDeafen[m.userId] ?? false,
|
||||||
|
video: rp.isCameraEnabled,
|
||||||
|
videoTrack: cameraTrackFor(rp),
|
||||||
|
sharing: false,
|
||||||
|
remoteSharing: false,
|
||||||
});
|
});
|
||||||
|
if (remoteSharerIds.has(m.userId)) {
|
||||||
|
out.push({
|
||||||
|
kind: 'screen',
|
||||||
|
id: 'screen:' + m.userId,
|
||||||
|
userId: m.userId,
|
||||||
|
displayName: (m.profile?.displayName ?? '?') + ' · Bildschirm',
|
||||||
|
avatarUrl: m.profile?.avatarUrl ?? null,
|
||||||
|
self: false,
|
||||||
|
muted: false,
|
||||||
|
deafened: false,
|
||||||
|
video: false,
|
||||||
|
videoTrack: null,
|
||||||
|
sharing: false,
|
||||||
|
remoteSharing: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -319,9 +527,92 @@ interface StageProps {
|
|||||||
}[];
|
}[];
|
||||||
conversationMembers: ConversationSummary['members'];
|
conversationMembers: ConversationSummary['members'];
|
||||||
onFocusTile: (id: string) => void;
|
onFocusTile: (id: string) => void;
|
||||||
|
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispatches a Tile to the right renderer. Screen-tiles show the screenshare
|
||||||
|
// stream directly (no avatar, no speaking ring, no mic indicator); user-tiles
|
||||||
|
// render via CallParticipantTile with all its chrome.
|
||||||
|
function TileRender({
|
||||||
|
tile,
|
||||||
|
activeSpeakers,
|
||||||
|
e2ee,
|
||||||
|
remoteScreenShares,
|
||||||
|
conversationMembers,
|
||||||
|
size,
|
||||||
|
focused,
|
||||||
|
onClick,
|
||||||
|
onContextMenu,
|
||||||
|
}: {
|
||||||
|
tile: Tile;
|
||||||
|
activeSpeakers: Set<string>;
|
||||||
|
e2ee: boolean;
|
||||||
|
remoteScreenShares: StageProps['remoteScreenShares'];
|
||||||
|
conversationMembers: StageProps['conversationMembers'];
|
||||||
|
size?: 'default' | 'small';
|
||||||
|
focused?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent) => void;
|
||||||
|
}): JSX.Element {
|
||||||
|
if (tile.kind === 'screen') {
|
||||||
|
if (tile.self) {
|
||||||
|
// Local screenshare preview — we don't mirror a copy of the outgoing
|
||||||
|
// stream. Render a labelled placeholder card so the user knows their
|
||||||
|
// share is live without double-encoding the stream.
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={
|
||||||
|
'relative flex h-full w-full items-center justify-center overflow-hidden rounded-[14px] border border-emerald-500/40 bg-emerald-500/5 text-emerald-700 dark:text-emerald-200 ' +
|
||||||
|
(onClick ? 'cursor-pointer' : '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-2 p-4 text-center">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wider">
|
||||||
|
Live
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">{tile.displayName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const share = remoteScreenShares.find((s) => s.participantId === tile.userId);
|
||||||
|
const member = conversationMembers.find((m) => m.userId === tile.userId);
|
||||||
|
if (!share) return <div className="h-full w-full" />;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
|
||||||
|
>
|
||||||
|
<ScreenShareViewer
|
||||||
|
share={share}
|
||||||
|
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
|
||||||
|
displayName={member?.profile?.displayName ?? tile.displayName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<CallParticipantTile
|
||||||
|
userId={tile.userId}
|
||||||
|
displayName={tile.displayName}
|
||||||
|
avatarUrl={tile.avatarUrl}
|
||||||
|
me={tile.self}
|
||||||
|
muted={tile.muted}
|
||||||
|
deafened={tile.deafened}
|
||||||
|
speaking={activeSpeakers.has(tile.userId)}
|
||||||
|
video={tile.video}
|
||||||
|
videoTrack={tile.videoTrack}
|
||||||
|
e2ee={e2ee}
|
||||||
|
{...(size ? { size } : {})}
|
||||||
|
{...(focused ? { focused } : {})}
|
||||||
|
{...(onClick ? { onClick } : {})}
|
||||||
|
{...(onContextMenu ? { onContextMenu } : {})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CallStage({
|
function CallStage({
|
||||||
tiles,
|
tiles,
|
||||||
speaker,
|
speaker,
|
||||||
@@ -331,37 +622,40 @@ function CallStage({
|
|||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
conversationMembers,
|
conversationMembers,
|
||||||
onFocusTile,
|
onFocusTile,
|
||||||
|
onTileContextMenu,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: StageProps) {
|
}: StageProps) {
|
||||||
if (mode === 'focus' && speaker) {
|
if (mode === 'focus' && speaker) {
|
||||||
const others = tiles.filter((p) => p.userId !== speaker.userId);
|
const others = tiles.filter((p) => p.id !== speaker.id);
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
|
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<FocusedTile
|
<FocusedTile
|
||||||
tile={speaker}
|
tile={speaker}
|
||||||
e2ee={e2ee}
|
e2ee={e2ee}
|
||||||
speaking={activeSpeakers.has(speaker.userId)}
|
activeSpeakers={activeSpeakers}
|
||||||
remoteScreenShares={remoteScreenShares}
|
remoteScreenShares={remoteScreenShares}
|
||||||
conversationMembers={conversationMembers}
|
conversationMembers={conversationMembers}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{others.length > 0 && (
|
{others.length > 0 && (
|
||||||
<div className="flex h-[110px] gap-2.5 overflow-x-auto">
|
<div className="flex h-[180px] gap-2.5 overflow-x-auto">
|
||||||
{others.map((p) => (
|
{others.map((p) => (
|
||||||
<div key={p.userId} className="h-full min-w-[160px]">
|
<div
|
||||||
<CallParticipantTile
|
key={p.id}
|
||||||
userId={p.userId}
|
className="h-full w-[240px] shrink-0 [&>div]:h-full"
|
||||||
displayName={p.displayName}
|
>
|
||||||
avatarUrl={p.avatarUrl}
|
<TileRender
|
||||||
me={p.self}
|
tile={p}
|
||||||
muted={p.muted}
|
activeSpeakers={activeSpeakers}
|
||||||
speaking={activeSpeakers.has(p.userId)}
|
|
||||||
sharing={p.sharing}
|
|
||||||
video={p.video}
|
|
||||||
e2ee={e2ee}
|
e2ee={e2ee}
|
||||||
|
remoteScreenShares={remoteScreenShares}
|
||||||
|
conversationMembers={conversationMembers}
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => onFocusTile(p.userId)}
|
onClick={() => onFocusTile(p.id)}
|
||||||
|
{...(onTileContextMenu
|
||||||
|
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||||
|
: {})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -377,19 +671,19 @@ function CallStage({
|
|||||||
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
|
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
|
||||||
<div className={'grid h-full gap-2 ' + gridClass}>
|
<div className={'grid h-full gap-2 ' + gridClass}>
|
||||||
{tiles.map((p) => (
|
{tiles.map((p) => (
|
||||||
<CallParticipantTile
|
<div key={p.id} className="[&>div]:h-full [&>div]:w-full">
|
||||||
key={p.userId}
|
<TileRender
|
||||||
userId={p.userId}
|
tile={p}
|
||||||
displayName={p.displayName}
|
activeSpeakers={activeSpeakers}
|
||||||
avatarUrl={p.avatarUrl}
|
e2ee={e2ee}
|
||||||
me={p.self}
|
remoteScreenShares={remoteScreenShares}
|
||||||
muted={p.muted}
|
conversationMembers={conversationMembers}
|
||||||
speaking={activeSpeakers.has(p.userId)}
|
onClick={() => onFocusTile(p.id)}
|
||||||
sharing={p.sharing}
|
{...(onTileContextMenu
|
||||||
video={p.video}
|
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||||
e2ee={e2ee}
|
: {})}
|
||||||
onClick={() => onFocusTile(p.userId)}
|
/>
|
||||||
/>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -399,57 +693,57 @@ function CallStage({
|
|||||||
function FocusedTile({
|
function FocusedTile({
|
||||||
tile,
|
tile,
|
||||||
e2ee,
|
e2ee,
|
||||||
speaking,
|
activeSpeakers,
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
conversationMembers,
|
conversationMembers,
|
||||||
}: {
|
}: {
|
||||||
tile: Tile;
|
tile: Tile;
|
||||||
e2ee: boolean;
|
e2ee: boolean;
|
||||||
speaking: boolean;
|
activeSpeakers: Set<string>;
|
||||||
remoteScreenShares: StageProps['remoteScreenShares'];
|
remoteScreenShares: StageProps['remoteScreenShares'];
|
||||||
conversationMembers: StageProps['conversationMembers'];
|
conversationMembers: StageProps['conversationMembers'];
|
||||||
}) {
|
}) {
|
||||||
// When the focused participant is remotely sharing their screen, embed the
|
|
||||||
// real video stream rather than the fake-window placeholder.
|
|
||||||
if (tile.remoteSharing) {
|
|
||||||
const share = remoteScreenShares.find((s) => s.participantId === tile.userId);
|
|
||||||
const member = conversationMembers.find((m) => m.userId === tile.userId);
|
|
||||||
if (share) {
|
|
||||||
return (
|
|
||||||
<div className="h-full">
|
|
||||||
<ScreenShareViewer
|
|
||||||
share={share}
|
|
||||||
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
|
|
||||||
displayName={member?.profile?.displayName ?? tile.displayName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full">
|
<div className="h-full [&>div]:h-full">
|
||||||
<CallParticipantTile
|
<TileRender
|
||||||
userId={tile.userId}
|
tile={tile}
|
||||||
displayName={tile.displayName}
|
activeSpeakers={activeSpeakers}
|
||||||
avatarUrl={tile.avatarUrl}
|
|
||||||
me={tile.self}
|
|
||||||
muted={tile.muted}
|
|
||||||
speaking={speaking}
|
|
||||||
sharing={tile.sharing}
|
|
||||||
video={tile.video}
|
|
||||||
e2ee={e2ee}
|
e2ee={e2ee}
|
||||||
|
remoteScreenShares={remoteScreenShares}
|
||||||
|
conversationMembers={conversationMembers}
|
||||||
focused
|
focused
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GRID_PAGE_SIZE = 12;
|
||||||
|
|
||||||
function gridColsFor(n: number): string {
|
function gridColsFor(n: number): string {
|
||||||
if (n <= 1) return 'grid-cols-1';
|
// Explicit `grid-rows-*` so cells get a defined height (1fr of available
|
||||||
if (n === 2) return 'grid-cols-2';
|
// space). Without this, implicit rows default to auto → they size to
|
||||||
if (n === 3) return 'grid-cols-3';
|
// content, and a video element's intrinsic size blows the tile past the
|
||||||
|
// container bounds (overlapping the toolbar below).
|
||||||
|
if (n <= 1) return 'grid-cols-1 grid-rows-1';
|
||||||
|
if (n === 2) return 'grid-cols-2 grid-rows-1';
|
||||||
|
if (n === 3) return 'grid-cols-3 grid-rows-1';
|
||||||
if (n === 4) return 'grid-cols-2 grid-rows-2';
|
if (n === 4) return 'grid-cols-2 grid-rows-2';
|
||||||
return 'grid-cols-3 grid-rows-2';
|
if (n <= 6) return 'grid-cols-3 grid-rows-2';
|
||||||
|
if (n <= 9) return 'grid-cols-3 grid-rows-3';
|
||||||
|
return 'grid-cols-4 grid-rows-3';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promote self + active speakers to the front of the tile list. Stable
|
||||||
|
// otherwise. Used by both pagination (so page 1 always carries the most
|
||||||
|
// "useful" tiles) and active-speaker promotion in fullscreen.
|
||||||
|
function prioritizeTiles(tiles: Tile[], activeSpeakers: Set<string>): Tile[] {
|
||||||
|
const score = (t: Tile): number => {
|
||||||
|
if (t.self) return 3;
|
||||||
|
if (activeSpeakers.has(t.userId)) return 2;
|
||||||
|
if (t.kind === 'screen') return 1;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
return [...tiles].sort((a, b) => score(b) - score(a));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -465,6 +759,7 @@ interface FullscreenProps {
|
|||||||
e2ee: boolean;
|
e2ee: boolean;
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
onFocusTile: (id: string) => void;
|
onFocusTile: (id: string) => void;
|
||||||
|
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
|
||||||
controls: React.ReactNode;
|
controls: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,99 +772,142 @@ function FullscreenCall({
|
|||||||
e2ee,
|
e2ee,
|
||||||
onExit: _onExit,
|
onExit: _onExit,
|
||||||
onFocusTile,
|
onFocusTile,
|
||||||
|
onTileContextMenu,
|
||||||
controls,
|
controls,
|
||||||
}: FullscreenProps) {
|
}: FullscreenProps) {
|
||||||
const others = speaker ? tiles.filter((p) => p.userId !== speaker.userId) : tiles;
|
|
||||||
const [hintGone, setHintGone] = useState(false);
|
const [hintGone, setHintGone] = useState(false);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = window.setTimeout(() => setHintGone(true), 3500);
|
const id = window.setTimeout(() => setHintGone(true), 3500);
|
||||||
return () => window.clearTimeout(id);
|
return () => window.clearTimeout(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const hasFocus = speaker !== undefined;
|
||||||
|
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
|
||||||
|
|
||||||
|
// Active-speaker reorder + paginate. When more than GRID_PAGE_SIZE tiles
|
||||||
|
// exist, slice them into pages. Reset to page 0 if the page count drops
|
||||||
|
// below the current page (someone left).
|
||||||
|
const sortedGridTiles = useMemo(
|
||||||
|
() => prioritizeTiles(tiles, activeSpeakers),
|
||||||
|
[tiles, activeSpeakers],
|
||||||
|
);
|
||||||
|
const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE));
|
||||||
|
useEffect(() => {
|
||||||
|
if (page >= pageCount) setPage(0);
|
||||||
|
}, [pageCount, page]);
|
||||||
|
const visibleTiles = sortedGridTiles.slice(
|
||||||
|
page * GRID_PAGE_SIZE,
|
||||||
|
(page + 1) * GRID_PAGE_SIZE,
|
||||||
|
);
|
||||||
|
const gridClass = gridColsFor(visibleTiles.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
|
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
|
||||||
<div className="relative min-h-0 flex-1">
|
{/* Content area. pb-24 reserves ~96px space at the bottom for the
|
||||||
{speaker && (
|
floating controls bar so tiles never sit behind it. */}
|
||||||
<div className="absolute inset-0">
|
<div className="relative flex min-h-0 flex-1 flex-col pb-24">
|
||||||
{speaker.remoteSharing ? (
|
{hasFocus ? (
|
||||||
<FullscreenShare speaker={speaker} remoteScreenShares={remoteScreenShares} conversationMembers={conversationMembers} />
|
<>
|
||||||
) : (
|
<div
|
||||||
<div className="h-full w-full [&>div]:rounded-none [&>div]:border-0">
|
className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
|
||||||
<CallParticipantTile
|
onClick={() => onFocusTile(speaker!.id)}
|
||||||
userId={speaker.userId}
|
title="Zurück zur Übersicht"
|
||||||
displayName={speaker.displayName}
|
>
|
||||||
avatarUrl={speaker.avatarUrl}
|
<TileRender
|
||||||
me={speaker.self}
|
tile={speaker!}
|
||||||
muted={speaker.muted}
|
activeSpeakers={activeSpeakers}
|
||||||
speaking={activeSpeakers.has(speaker.userId)}
|
e2ee={e2ee}
|
||||||
sharing={speaker.sharing}
|
remoteScreenShares={remoteScreenShares}
|
||||||
video={speaker.video}
|
conversationMembers={conversationMembers}
|
||||||
e2ee={e2ee}
|
focused
|
||||||
focused
|
{...(onTileContextMenu
|
||||||
/>
|
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker!, e) }
|
||||||
|
: {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{others.length > 0 && (
|
||||||
|
<div className="flex h-[160px] shrink-0 gap-2.5 overflow-x-auto px-4 pb-2">
|
||||||
|
{others.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="h-full w-[220px] shrink-0 [&>div]:h-full [&>div]:w-full"
|
||||||
|
>
|
||||||
|
<TileRender
|
||||||
|
tile={p}
|
||||||
|
activeSpeakers={activeSpeakers}
|
||||||
|
e2ee={e2ee}
|
||||||
|
remoteScreenShares={remoteScreenShares}
|
||||||
|
conversationMembers={conversationMembers}
|
||||||
|
size="small"
|
||||||
|
onClick={() => onFocusTile(p.id)}
|
||||||
|
{...(onTileContextMenu
|
||||||
|
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||||
|
: {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="min-h-0 flex-1 p-4">
|
||||||
|
<div className={'grid h-full gap-2 ' + gridClass}>
|
||||||
|
{visibleTiles.map((p) => (
|
||||||
|
<div key={p.id} className="min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full">
|
||||||
|
<TileRender
|
||||||
|
tile={p}
|
||||||
|
activeSpeakers={activeSpeakers}
|
||||||
|
e2ee={e2ee}
|
||||||
|
remoteScreenShares={remoteScreenShares}
|
||||||
|
conversationMembers={conversationMembers}
|
||||||
|
onClick={() => onFocusTile(p.id)}
|
||||||
|
{...(onTileContextMenu
|
||||||
|
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||||
|
: {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{pageCount > 1 && (
|
||||||
|
<div className="mt-2 flex items-center justify-center gap-3 text-xs text-fg-muted">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage((p) => (p === 0 ? pageCount - 1 : p - 1))}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-1 hover:bg-surface-3"
|
||||||
|
aria-label="Vorherige Seite"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{page + 1} / {pageCount}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage((p) => (p + 1) % pageCount)}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-1 hover:bg-surface-3"
|
||||||
|
aria-label="Nächste Seite"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{others.length > 0 && (
|
|
||||||
<div className="absolute bottom-24 right-4 flex w-[180px] flex-col gap-2">
|
|
||||||
{others.slice(0, 4).map((p) => (
|
|
||||||
<div key={p.userId} className="h-[100px] backdrop-blur-xl">
|
|
||||||
<CallParticipantTile
|
|
||||||
userId={p.userId}
|
|
||||||
displayName={p.displayName}
|
|
||||||
avatarUrl={p.avatarUrl}
|
|
||||||
me={p.self}
|
|
||||||
muted={p.muted}
|
|
||||||
speaking={activeSpeakers.has(p.userId)}
|
|
||||||
sharing={p.sharing}
|
|
||||||
video={p.video}
|
|
||||||
e2ee={e2ee}
|
|
||||||
size="small"
|
|
||||||
onClick={() => onFocusTile(p.userId)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hintGone && (
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute left-1/2 top-5 z-10 animate-fs-hint rounded-lg border border-white/10 bg-black/60 px-3.5 py-1.5 text-[11px] font-medium tracking-wide text-white/70 backdrop-blur-md"
|
|
||||||
>
|
|
||||||
Esc zum Verlassen
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="pointer-events-auto absolute bottom-4 left-1/2 -translate-x-1/2">
|
|
||||||
{controls}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FullscreenShare({
|
{!hintGone && (
|
||||||
speaker,
|
<div
|
||||||
remoteScreenShares,
|
aria-hidden="true"
|
||||||
conversationMembers,
|
className="pointer-events-none absolute left-1/2 top-5 z-10 animate-fs-hint rounded-lg border border-white/10 bg-black/60 px-3.5 py-1.5 text-[11px] font-medium tracking-wide text-white/70 backdrop-blur-md"
|
||||||
}: {
|
>
|
||||||
speaker: Tile;
|
Esc zum Verlassen
|
||||||
remoteScreenShares: StageProps['remoteScreenShares'];
|
</div>
|
||||||
conversationMembers: StageProps['conversationMembers'];
|
)}
|
||||||
}) {
|
|
||||||
const share = remoteScreenShares.find((s) => s.participantId === speaker.userId);
|
<div className="pointer-events-auto absolute bottom-4 left-1/2 -translate-x-1/2">
|
||||||
const member = conversationMembers.find((m) => m.userId === speaker.userId);
|
{controls}
|
||||||
if (!share) return null;
|
</div>
|
||||||
return (
|
|
||||||
<div className="h-full w-full">
|
|
||||||
<ScreenShareViewer
|
|
||||||
share={share}
|
|
||||||
avatarUrl={member?.profile?.avatarUrl ?? speaker.avatarUrl}
|
|
||||||
displayName={member?.profile?.displayName ?? speaker.displayName}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useCall } from '../context/CallContext';
|
import { useCall } from '../context/CallContext';
|
||||||
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
||||||
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
|
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
|
||||||
import { LockIcon, PhoneIcon, PhoneOffIcon } from './icons';
|
import { LockIcon, PhoneIcon, PhoneOffIcon, VideoIcon } from './icons';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
conversation: ConversationSummary;
|
conversation: ConversationSummary;
|
||||||
@@ -99,23 +99,37 @@ export function IncomingCallPanel({ conversation }: Props) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-5 flex w-full max-w-[360px] gap-3">
|
<div className="mt-5 flex w-full max-w-[440px] flex-wrap gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={rejectIncoming}
|
onClick={rejectIncoming}
|
||||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-[14px] border border-rose-500/40 bg-transparent px-5 py-3.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/40 dark:text-rose-400"
|
className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] border border-rose-500/40 bg-transparent px-4 py-3.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/40 dark:text-rose-400"
|
||||||
>
|
>
|
||||||
<PhoneOffIcon className="h-4 w-4" />
|
<PhoneOffIcon className="h-4 w-4" />
|
||||||
<span>{t('app:call.decline')}</span>
|
<span>{t('app:call.decline')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void acceptIncoming()}
|
onClick={() => void acceptIncoming('audio')}
|
||||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-emerald-600 px-5 py-3.5 text-sm font-semibold text-white shadow-accept-btn transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-emerald-600 px-4 py-3.5 text-sm font-semibold text-white shadow-accept-btn transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||||
>
|
>
|
||||||
<PhoneIcon className="h-4 w-4" />
|
<PhoneIcon className="h-4 w-4" />
|
||||||
<span>{t('app:call.accept')}</span>
|
<span>
|
||||||
|
{state.mediaKind === 'video'
|
||||||
|
? t('app:call.accept_audio', { defaultValue: 'Nur Audio' })
|
||||||
|
: t('app:call.accept')}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
{state.mediaKind === 'video' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void acceptIncoming('video')}
|
||||||
|
className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-accent px-4 py-3.5 text-sm font-semibold text-accent-fg shadow-accept-btn transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||||
|
>
|
||||||
|
<VideoIcon className="h-4 w-4" />
|
||||||
|
<span>{t('app:call.accept_video', { defaultValue: 'Mit Video' })}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useLinkPreview } from '../lib/useLinkPreview';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders a compact OpenGraph preview card under a message bubble. Fetches
|
||||||
|
// lazily through the edge function; silent when the URL returned no meta.
|
||||||
|
export function LinkPreviewCard({ url }: Props) {
|
||||||
|
const preview = useLinkPreview(url);
|
||||||
|
if (!preview || !preview.ok) return null;
|
||||||
|
if (!preview.title && !preview.description && !preview.imageUrl) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className="mt-2 flex max-w-[320px] overflow-hidden rounded-lg border border-line bg-surface-2 text-sm no-underline transition hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
{preview.imageUrl && (
|
||||||
|
<img
|
||||||
|
src={preview.imageUrl}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="h-20 w-20 shrink-0 object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5 p-2.5">
|
||||||
|
{preview.siteName && (
|
||||||
|
<p className="truncate text-[10px] uppercase tracking-wider text-fg-muted">
|
||||||
|
{preview.siteName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{preview.title && (
|
||||||
|
<p className="line-clamp-2 text-sm font-semibold text-fg">
|
||||||
|
{preview.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{preview.description && (
|
||||||
|
<p className="line-clamp-2 text-xs text-fg-muted">{preview.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Avatar } from './Avatar';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
members: ConversationSummary['members'];
|
||||||
|
query: string;
|
||||||
|
excludeUserId: string | undefined;
|
||||||
|
onSelect: (username: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropdown shown above the composer when the user has typed `@` followed
|
||||||
|
// by the start of a member name. Keyboard-first — arrow keys move through,
|
||||||
|
// enter/tab commits, escape cancels.
|
||||||
|
export function MentionAutocomplete({
|
||||||
|
members,
|
||||||
|
query,
|
||||||
|
excludeUserId,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const matches = members
|
||||||
|
.filter((m) => m.userId !== excludeUserId)
|
||||||
|
.filter((m) => {
|
||||||
|
if (!q) return true;
|
||||||
|
const name = (m.profile?.displayName ?? '').toLowerCase();
|
||||||
|
const handle = (m.profile?.username ?? '').toLowerCase();
|
||||||
|
return name.includes(q) || handle.includes(q);
|
||||||
|
})
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
setActive(0);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (matches.length === 0) return;
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActive((i) => (i + 1) % matches.length);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActive((i) => (i - 1 + matches.length) % matches.length);
|
||||||
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
const pick = matches[active];
|
||||||
|
if (pick?.profile?.username) onSelect(pick.profile.username);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey, true);
|
||||||
|
};
|
||||||
|
}, [matches, active, onSelect, onClose]);
|
||||||
|
|
||||||
|
if (matches.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Mitglieder"
|
||||||
|
className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-64 overflow-y-auto rounded-xl border border-line bg-surface-2 p-1 shadow-xl"
|
||||||
|
>
|
||||||
|
{matches.map((m, idx) => {
|
||||||
|
const name = m.profile?.displayName ?? m.profile?.username ?? '?';
|
||||||
|
const handle = m.profile?.username ?? '';
|
||||||
|
const isActive = idx === active;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.userId}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isActive}
|
||||||
|
onMouseEnter={() => setActive(idx)}
|
||||||
|
onClick={() => {
|
||||||
|
if (m.profile?.username) onSelect(m.profile.username);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
|
||||||
|
(isActive ? 'bg-accent/20 text-fg' : 'text-fg-muted hover:bg-surface-3')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
displayName={name}
|
||||||
|
url={m.profile?.avatarUrl ?? null}
|
||||||
|
className="h-6 w-6 text-[10px]"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
|
||||||
|
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,13 @@ import { useAuth } from '../context/AuthContext';
|
|||||||
import { devLocalSecretStore } from '../lib/secretStore';
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
||||||
|
import { extractFirstUrl } from '../lib/useLinkPreview';
|
||||||
|
import { AttachmentAudio } from './AttachmentAudio';
|
||||||
|
import { AttachmentGeneric } from './AttachmentGeneric';
|
||||||
import { AttachmentImage } from './AttachmentImage';
|
import { AttachmentImage } from './AttachmentImage';
|
||||||
|
import { AttachmentPdf } from './AttachmentPdf';
|
||||||
|
import { AttachmentVideo } from './AttachmentVideo';
|
||||||
|
import { LinkPreviewCard } from './LinkPreviewCard';
|
||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
import { ForwardIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
|
import { ForwardIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
|
||||||
|
|
||||||
@@ -40,6 +46,8 @@ interface Props {
|
|||||||
reactions: AggregatedReaction[];
|
reactions: AggregatedReaction[];
|
||||||
onToggleReaction: (emoji: string) => Promise<void>;
|
onToggleReaction: (emoji: string) => Promise<void>;
|
||||||
showSeen?: boolean;
|
showSeen?: boolean;
|
||||||
|
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
|
||||||
|
deliveryState?: 'sent' | 'delivered' | 'read';
|
||||||
/** Resolved quoted message info (parent does the lookup). */
|
/** Resolved quoted message info (parent does the lookup). */
|
||||||
quoted?: QuotedRef | null;
|
quoted?: QuotedRef | null;
|
||||||
/** Tap-to-jump on quote bubble. Receives the quoted message's id. */
|
/** Tap-to-jump on quote bubble. Receives the quoted message's id. */
|
||||||
@@ -48,6 +56,8 @@ interface Props {
|
|||||||
onReply?: (m: DecryptedMessage) => void;
|
onReply?: (m: DecryptedMessage) => void;
|
||||||
/** Hover action: parent opens forward dialog for current message. */
|
/** Hover action: parent opens forward dialog for current message. */
|
||||||
onForward?: (m: DecryptedMessage) => void;
|
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 state — set briefly after a jump. */
|
||||||
highlighted?: boolean;
|
highlighted?: boolean;
|
||||||
}
|
}
|
||||||
@@ -63,10 +73,12 @@ export function MessageBubble({
|
|||||||
reactions,
|
reactions,
|
||||||
onToggleReaction,
|
onToggleReaction,
|
||||||
showSeen = false,
|
showSeen = false,
|
||||||
|
deliveryState,
|
||||||
quoted = null,
|
quoted = null,
|
||||||
onJumpToMessage,
|
onJumpToMessage,
|
||||||
onReply,
|
onReply,
|
||||||
onForward,
|
onForward,
|
||||||
|
onAvatarClick,
|
||||||
highlighted = false,
|
highlighted = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
@@ -88,6 +100,47 @@ export function MessageBubble({
|
|||||||
const withinEditWindow = age < EDIT_WINDOW_MS;
|
const withinEditWindow = age < EDIT_WINDOW_MS;
|
||||||
const bodyText = initialText;
|
const bodyText = initialText;
|
||||||
const attachments = initialAttachments;
|
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 =
|
const canEdit =
|
||||||
parsed.kind === 'text' &&
|
parsed.kind === 'text' &&
|
||||||
mine &&
|
mine &&
|
||||||
@@ -172,13 +225,16 @@ export function MessageBubble({
|
|||||||
[onToggleReaction],
|
[onToggleReaction],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (message.deletedAt) {
|
if (message.deletedAt || localExpired) {
|
||||||
return (
|
return (
|
||||||
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
|
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
|
||||||
<AvatarSlot
|
<AvatarSlot
|
||||||
show={isLastOfRun}
|
show={isLastOfRun}
|
||||||
url={senderAvatarUrl ?? null}
|
url={senderAvatarUrl ?? null}
|
||||||
displayName={senderDisplayName ?? 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">
|
<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')}
|
{t('app:chats.deleted')}
|
||||||
@@ -265,23 +321,30 @@ export function MessageBubble({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onJumpToMessage?.(quoted.id)}
|
onClick={() => onJumpToMessage?.(quoted.id)}
|
||||||
className={
|
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
|
(mine
|
||||||
? 'bg-white/15 text-accent-fg/90'
|
? 'bg-white/10 text-accent-fg/90'
|
||||||
: 'bg-surface-2 text-fg-muted')
|
: 'bg-surface-2/80 text-fg-muted ring-1 ring-inset ring-line')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={
|
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="min-w-0 flex-1 pl-1">
|
||||||
<span className={'block truncate font-semibold ' + (mine ? '' : 'text-fg')}>
|
<span
|
||||||
{quoted.senderName}
|
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>
|
||||||
<span className="block truncate italic opacity-90">
|
<span className="mt-0.5 block truncate opacity-80">
|
||||||
{quoted.deleted
|
{quoted.deleted
|
||||||
? t('app:chats.deleted')
|
? t('app:chats.deleted')
|
||||||
: quoted.isAttachment && !quoted.snippet
|
: quoted.isAttachment && !quoted.snippet
|
||||||
@@ -295,10 +358,27 @@ export function MessageBubble({
|
|||||||
<span className="italic opacity-70">…cannot decrypt</span>
|
<span className="italic opacity-70">…cannot decrypt</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{bodyText.length > 0 && <div>{bodyText}</div>}
|
{bodyText.length > 0 && <div>{renderBodyWithMentions(bodyText)}</div>}
|
||||||
{attachments.map((a) => (
|
{bodyText.length > 0 &&
|
||||||
<AttachmentImage key={a.id} handle={a} />
|
(() => {
|
||||||
))}
|
const url = extractFirstUrl(bodyText);
|
||||||
|
return url ? <LinkPreviewCard url={url} /> : null;
|
||||||
|
})()}
|
||||||
|
{attachments.map((a) => {
|
||||||
|
if (a.mimeType.startsWith('audio/')) {
|
||||||
|
return <AttachmentAudio key={a.id} handle={a} />;
|
||||||
|
}
|
||||||
|
if (a.mimeType.startsWith('image/')) {
|
||||||
|
return <AttachmentImage key={a.id} handle={a} />;
|
||||||
|
}
|
||||||
|
if (a.mimeType.startsWith('video/')) {
|
||||||
|
return <AttachmentVideo key={a.id} handle={a} />;
|
||||||
|
}
|
||||||
|
if (a.mimeType === 'application/pdf') {
|
||||||
|
return <AttachmentPdf key={a.id} handle={a} />;
|
||||||
|
}
|
||||||
|
return <AttachmentGeneric key={a.id} handle={a} />;
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
@@ -311,25 +391,39 @@ export function MessageBubble({
|
|||||||
{message.editedAt && !message.deletedAt && (
|
{message.editedAt && !message.deletedAt && (
|
||||||
<span className="italic">· {t('app:chats.edited')}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showSeen && mine && !editing && !message.deletedAt && (
|
{mine && !editing && !message.deletedAt && deliveryState && (
|
||||||
<p className="mt-0.5 text-right text-[10px] text-fg-muted">
|
<div className="mt-0.5 flex items-center justify-end gap-1 text-[10px] text-fg-muted">
|
||||||
{t('app:chats.seen')}
|
<DeliveryTicks state={deliveryState} />
|
||||||
</p>
|
{showSeen && <span>{t('app:chats.seen')}</span>}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{reactions.length > 0 && !editing && (
|
{reactions.length > 0 && !editing && (
|
||||||
<div className={'mt-1 flex flex-wrap gap-1 ' + (mine ? 'justify-end' : 'justify-start')}>
|
<div className={'mt-1 flex flex-wrap gap-1 ' + (mine ? 'justify-end' : 'justify-start')}>
|
||||||
{reactions.map((r) => (
|
{reactions.map((r) => (
|
||||||
<button
|
<button
|
||||||
key={r.emoji}
|
// Keying by emoji+count makes React remount the chip when the
|
||||||
|
// count flips, replaying the pop animation. Cheap visual cue.
|
||||||
|
key={r.emoji + ':' + r.count}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void onToggleReaction(r.emoji)}
|
onClick={() => void onToggleReaction(r.emoji)}
|
||||||
className={
|
className={
|
||||||
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
'inline-flex origin-bottom animate-reaction-pop cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||||
(r.mine
|
(r.mine
|
||||||
? 'border-accent/40 bg-accent/20 text-accent'
|
? 'border-accent/40 bg-accent/20 text-accent'
|
||||||
: 'border-line bg-surface-2 text-fg hover:bg-surface-3')
|
: 'border-line bg-surface-2 text-fg hover:bg-surface-3')
|
||||||
@@ -428,14 +522,29 @@ function AvatarSlot({
|
|||||||
show,
|
show,
|
||||||
url,
|
url,
|
||||||
displayName,
|
displayName,
|
||||||
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
|
onClick?: (ev: React.MouseEvent) => void;
|
||||||
}) {
|
}) {
|
||||||
if (!show) {
|
if (!show) {
|
||||||
return <div aria-hidden="true" className="h-8 w-8 shrink-0" />;
|
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 (
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
url={url}
|
url={url}
|
||||||
@@ -499,6 +608,72 @@ function formatDuration(totalSec: number): string {
|
|||||||
return m + ':' + s.toString().padStart(2, '0');
|
return m + ':' + s.toString().padStart(2, '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Splits body text on `@username` tokens, rendering matches as highlighted
|
||||||
|
// pills. Username alphabet matches Supabase citext usernames: alphanumerics
|
||||||
|
// + underscores, length 1..32 (we don't bound here — regex is permissive
|
||||||
|
// and keys off a leading `@` with an alnum/underscore follow).
|
||||||
|
const MENTION_RE = /@([A-Za-z0-9_]{1,32})/g;
|
||||||
|
|
||||||
|
function renderBodyWithMentions(text: string): React.ReactNode[] {
|
||||||
|
const out: React.ReactNode[] = [];
|
||||||
|
let lastIdx = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
MENTION_RE.lastIndex = 0;
|
||||||
|
while ((m = MENTION_RE.exec(text)) !== null) {
|
||||||
|
if (m.index > lastIdx) out.push(text.slice(lastIdx, m.index));
|
||||||
|
out.push(
|
||||||
|
<span
|
||||||
|
key={m.index + ':' + m[1]}
|
||||||
|
className="rounded bg-accent/20 px-1 text-accent"
|
||||||
|
>
|
||||||
|
{m[0]}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
lastIdx = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (lastIdx < text.length) out.push(text.slice(lastIdx));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeliveryTicks({ state }: { state: 'sent' | 'delivered' | 'read' }) {
|
||||||
|
// One checkmark for sent, two for delivered/read. Read shifts color to the
|
||||||
|
// accent to match WhatsApp/Telegram blue-tick convention.
|
||||||
|
const color =
|
||||||
|
state === 'read'
|
||||||
|
? 'text-sky-500 dark:text-sky-400'
|
||||||
|
: 'text-fg-muted';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label={
|
||||||
|
state === 'read'
|
||||||
|
? 'Gelesen'
|
||||||
|
: state === 'delivered'
|
||||||
|
? 'Zugestellt'
|
||||||
|
: 'Gesendet'
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
state === 'read'
|
||||||
|
? 'Gelesen'
|
||||||
|
: state === 'delivered'
|
||||||
|
? 'Zugestellt'
|
||||||
|
: 'Gesendet'
|
||||||
|
}
|
||||||
|
className={'flex items-center ' + color}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 12" width="14" height="10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
{state === 'sent' ? (
|
||||||
|
<polyline points="2 7 6 11 14 1" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<polyline points="1 7 5 11 11 2" />
|
||||||
|
<polyline points="6 11 10 11 14 1" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ActionButton({
|
function ActionButton({
|
||||||
label,
|
label,
|
||||||
onClick,
|
onClick,
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getParticipantVolume,
|
||||||
|
setParticipantVolume,
|
||||||
|
subscribeParticipantVolumes,
|
||||||
|
} from '../lib/participantVolumes';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MENU_W = 240;
|
||||||
|
const MENU_H = 84;
|
||||||
|
|
||||||
|
export function ParticipantVolumeMenu({
|
||||||
|
userId,
|
||||||
|
displayName,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const [volume, setVolume] = useState<number>(() => getParticipantVolume(userId));
|
||||||
|
|
||||||
|
// Re-sync from store in case another menu instance changed the same user.
|
||||||
|
useEffect(() => subscribeParticipantVolumes(() => {
|
||||||
|
setVolume(getParticipantVolume(userId));
|
||||||
|
}), [userId]);
|
||||||
|
|
||||||
|
// Outside click + Esc to close.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('[data-volume-menu]')) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
window.addEventListener('mousedown', onDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
window.removeEventListener('mousedown', onDown);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const left = Math.min(Math.max(8, x), window.innerWidth - MENU_W - 8);
|
||||||
|
const top = Math.min(Math.max(8, y), window.innerHeight - MENU_H - 8);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
data-volume-menu
|
||||||
|
role="dialog"
|
||||||
|
aria-label={'Lautstärke ' + displayName}
|
||||||
|
style={{ left, top, width: MENU_W }}
|
||||||
|
className="fixed z-[80] rounded-xl border border-line bg-surface-2/95 p-3 shadow-xl backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
|
||||||
|
<span className="truncate font-semibold text-fg">{displayName}</span>
|
||||||
|
<span className="tabular-nums text-fg-muted">
|
||||||
|
{Math.round(volume * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
setVolume(v);
|
||||||
|
setParticipantVolume(userId, v);
|
||||||
|
}}
|
||||||
|
aria-label={'Lautstärke ' + displayName}
|
||||||
|
className="w-full accent-accent"
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearIncomingRingtone,
|
||||||
|
getIncomingRingtone,
|
||||||
|
MAX_RINGTONE_BYTES,
|
||||||
|
saveIncomingRingtone,
|
||||||
|
type StoredRingtone,
|
||||||
|
} from '../lib/ringtoneStorage';
|
||||||
|
import { PhoneIcon, SpinnerIcon, TrashIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Disable interactions while a parent action is in flight. */
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BYTES_PER_MB = 1024 * 1024;
|
||||||
|
|
||||||
|
// UI for the custom incoming-call ringtone. Single file slot. Upload
|
||||||
|
// validates size + mime and surfaces errors inline. Preview button plays
|
||||||
|
// the stored blob through a local <audio> element without touching the
|
||||||
|
// shared ringtone singleton so we don't interfere with a live call.
|
||||||
|
export function RingtoneSettings({ disabled = false }: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const previewRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const previewUrlRef = useRef<string | null>(null);
|
||||||
|
const [current, setCurrent] = useState<StoredRingtone | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const cur = await getIncomingRingtone();
|
||||||
|
setCurrent(cur);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('getIncomingRingtone failed', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Revoke any preview blob URL when the component unmounts so long-lived
|
||||||
|
// pages don't leak memory.
|
||||||
|
return () => {
|
||||||
|
stopPreview();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function stopPreview(): void {
|
||||||
|
const el = previewRef.current;
|
||||||
|
if (el) {
|
||||||
|
try {
|
||||||
|
el.pause();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
el.src = '';
|
||||||
|
}
|
||||||
|
previewRef.current = null;
|
||||||
|
if (previewUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(previewUrlRef.current);
|
||||||
|
previewUrlRef.current = null;
|
||||||
|
}
|
||||||
|
setPlaying(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFile(file: File): Promise<void> {
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await saveIncomingRingtone(file);
|
||||||
|
await refresh();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const code = err instanceof Error ? err.message : 'upload_failed';
|
||||||
|
if (code === 'ringtone_too_large') {
|
||||||
|
setError(
|
||||||
|
t('app:settings.ringtone_error_too_large', {
|
||||||
|
defaultValue: 'Datei zu groß (max 2 MB).',
|
||||||
|
max: MAX_RINGTONE_BYTES / BYTES_PER_MB,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (code === 'ringtone_not_audio') {
|
||||||
|
setError(
|
||||||
|
t('app:settings.ringtone_error_not_audio', {
|
||||||
|
defaultValue: 'Nur Audio-Dateien werden unterstützt.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError(
|
||||||
|
t('app:settings.ringtone_error_generic', {
|
||||||
|
defaultValue: 'Ringtone konnte nicht gespeichert werden.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
if (inputRef.current) inputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReset(): Promise<void> {
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
stopPreview();
|
||||||
|
try {
|
||||||
|
await clearIncomingRingtone();
|
||||||
|
await refresh();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('clearIncomingRingtone failed', err);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePreview(): void {
|
||||||
|
if (!current) return;
|
||||||
|
if (playing) {
|
||||||
|
stopPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(current.blob);
|
||||||
|
const el = new Audio(url);
|
||||||
|
el.loop = false;
|
||||||
|
el.volume = 0.85;
|
||||||
|
el.onended = () => stopPreview();
|
||||||
|
el.onerror = () => {
|
||||||
|
setError(
|
||||||
|
t('app:settings.ringtone_error_play', {
|
||||||
|
defaultValue: 'Ringtone konnte nicht abgespielt werden.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
stopPreview();
|
||||||
|
};
|
||||||
|
el.play().catch(() => {
|
||||||
|
setError(
|
||||||
|
t('app:settings.ringtone_error_play', {
|
||||||
|
defaultValue: 'Ringtone konnte nicht abgespielt werden.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
stopPreview();
|
||||||
|
});
|
||||||
|
previewRef.current = el;
|
||||||
|
previewUrlRef.current = url;
|
||||||
|
setPlaying(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCustom = current !== null;
|
||||||
|
const sizeMb = current ? (current.blob.size / BYTES_PER_MB).toFixed(2) : null;
|
||||||
|
const interactionsDisabled = disabled || busy;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-fg">
|
||||||
|
<PhoneIcon className="h-4 w-4 text-fg-muted" />
|
||||||
|
{t('app:settings.ringtone_incoming', { defaultValue: 'Eingehender Anruf' })}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-fg-muted">
|
||||||
|
{hasCustom && current
|
||||||
|
? t('app:settings.ringtone_custom_active', {
|
||||||
|
defaultValue: '{{name}} · {{size}} MB',
|
||||||
|
name: current.filename,
|
||||||
|
size: sizeMb,
|
||||||
|
})
|
||||||
|
: t('app:settings.ringtone_default_active', {
|
||||||
|
defaultValue: 'Standard-Klingelton (Doppelton)',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{hasCustom && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePreview}
|
||||||
|
disabled={interactionsDisabled}
|
||||||
|
className="cursor-pointer rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-xs font-semibold text-fg transition hover:brightness-95 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{playing
|
||||||
|
? t('app:settings.ringtone_stop', { defaultValue: 'Stop' })
|
||||||
|
: t('app:settings.ringtone_preview', { defaultValue: 'Vorhören' })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="audio/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void handleFile(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
disabled={interactionsDisabled}
|
||||||
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
||||||
|
<span>
|
||||||
|
{hasCustom
|
||||||
|
? t('app:settings.ringtone_replace', { defaultValue: 'Ersetzen' })
|
||||||
|
: t('app:settings.ringtone_upload', { defaultValue: 'Hochladen' })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{hasCustom && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleReset()}
|
||||||
|
disabled={interactionsDisabled}
|
||||||
|
aria-label={t('app:settings.ringtone_reset', { defaultValue: 'Zurücksetzen' })}
|
||||||
|
title={t('app:settings.ringtone_reset', { defaultValue: 'Zurücksetzen' })}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-rose-500/40 bg-rose-500/10 text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-300">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-[11px] text-fg-muted">
|
||||||
|
{t('app:settings.ringtone_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'MP3, WAV, OGG oder M4A bis 2 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type DisplaySurfaceHint,
|
||||||
|
getPresetParams,
|
||||||
|
getScreenShareSettings,
|
||||||
|
PRESET_ORDER,
|
||||||
|
type ScreenSharePreset,
|
||||||
|
updateScreenShareSettings,
|
||||||
|
} from '../lib/screenShareSettings';
|
||||||
|
import {
|
||||||
|
MonitorShareIcon,
|
||||||
|
SpinnerIcon,
|
||||||
|
XIcon,
|
||||||
|
} from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onStart: (opts: {
|
||||||
|
preset: ScreenSharePreset;
|
||||||
|
displaySurface: DisplaySurfaceHint;
|
||||||
|
framerate: number | null;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FRAMERATE_OPTIONS: ReadonlyArray<{ value: number | null; label: string }> = [
|
||||||
|
{ value: null, label: 'Preset-Standard' },
|
||||||
|
{ value: 15, label: '15 fps' },
|
||||||
|
{ value: 30, label: '30 fps' },
|
||||||
|
{ value: 60, label: '60 fps' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Discord-style pre-share dialog. The OS still owns the final source picker
|
||||||
|
// (browser/OS limitation — only Chrome/Edge plus a native plugin can enumerate
|
||||||
|
// windows from JS), but we pre-filter with the `displaySurface` hint and lock
|
||||||
|
// in quality + framerate up-front so the user doesn't have to re-open the
|
||||||
|
// system picker to adjust them mid-call.
|
||||||
|
export function ScreenShareDialog({ open, onClose, onStart }: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const initial = getScreenShareSettings();
|
||||||
|
const [surface, setSurface] = useState<DisplaySurfaceHint>(initial.displaySurface);
|
||||||
|
const [preset, setPreset] = useState<ScreenSharePreset>(initial.preset);
|
||||||
|
const [framerate, setFramerate] = useState<number | null>(initial.framerateOverride);
|
||||||
|
const [includeAudio, setIncludeAudio] = useState<boolean>(initial.includeSystemAudio);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
async function handleStart() {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Persist the audio choice alongside the other picker prefs so the
|
||||||
|
// upstream startScreenShare picks it up on its settings read.
|
||||||
|
updateScreenShareSettings({ includeSystemAudio: includeAudio });
|
||||||
|
await onStart({ preset, displaySurface: surface, framerate });
|
||||||
|
onClose();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'screenshare failed');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const presetParams = getPresetParams(preset);
|
||||||
|
const effectiveFps = framerate ?? presetParams.framerate;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MonitorShareIcon className="h-4 w-4 text-accent" />
|
||||||
|
<h3 className="font-display text-sm font-semibold text-fg">
|
||||||
|
{t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="space-y-5 p-5">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||||
|
{t('app:call.share_surface', { defaultValue: 'Quelle' })}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<SurfaceOption
|
||||||
|
active={surface === null}
|
||||||
|
onClick={() => setSurface(null)}
|
||||||
|
label={t('app:call.share_any', { defaultValue: 'Alle anzeigen' })}
|
||||||
|
sub={t('app:call.share_any_sub', { defaultValue: 'Bildschirm + Fenster' })}
|
||||||
|
/>
|
||||||
|
<SurfaceOption
|
||||||
|
active={surface === 'monitor'}
|
||||||
|
onClick={() => setSurface('monitor')}
|
||||||
|
label={t('app:call.share_monitor', { defaultValue: 'Bildschirm' })}
|
||||||
|
sub={t('app:call.share_monitor_sub', { defaultValue: 'Ganzer Monitor' })}
|
||||||
|
/>
|
||||||
|
<SurfaceOption
|
||||||
|
active={surface === 'window'}
|
||||||
|
onClick={() => setSurface('window')}
|
||||||
|
label={t('app:call.share_window', { defaultValue: 'Fenster' })}
|
||||||
|
sub={t('app:call.share_window_sub', { defaultValue: 'Einzelnes Fenster' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||||
|
{t('app:call.share_quality', { defaultValue: 'Qualität' })}
|
||||||
|
</p>
|
||||||
|
<select
|
||||||
|
value={preset}
|
||||||
|
onChange={(e) => setPreset(e.target.value as ScreenSharePreset)}
|
||||||
|
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||||
|
>
|
||||||
|
{PRESET_ORDER.map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{getPresetParams(p).label} · ≤ {getPresetParams(p).bitrateKbps} kbps
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||||
|
{t('app:call.share_fps', { defaultValue: 'Bildrate' })}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{FRAMERATE_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={String(opt.value)}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFramerate(opt.value)}
|
||||||
|
className={
|
||||||
|
'cursor-pointer rounded-lg border px-3 py-1.5 text-xs font-medium transition ' +
|
||||||
|
(framerate === opt.value
|
||||||
|
? 'border-accent bg-accent/15 text-accent'
|
||||||
|
: 'border-line bg-surface-2 text-fg hover:bg-surface')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[11px] text-fg-muted">
|
||||||
|
{t('app:call.share_fps_effective', {
|
||||||
|
defaultValue: 'Effektiv: {{fps}} fps',
|
||||||
|
fps: effectiveFps,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2 text-xs hover:bg-surface">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeAudio}
|
||||||
|
onChange={(e) => setIncludeAudio(e.target.checked)}
|
||||||
|
className="mt-0.5 accent-accent"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block font-semibold text-fg">
|
||||||
|
{t('app:call.share_system_audio', {
|
||||||
|
defaultValue: 'System-Sound mit übertragen',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block text-[11px] text-fg-muted">
|
||||||
|
{t('app:call.share_system_audio_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'"Go Live" — Systemsound wird mitgesendet. Auf macOS braucht das extra Berechtigungen; wird sonst stumm geteilt.',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm text-rose-600 dark:text-rose-300">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-[11px] text-fg-muted">
|
||||||
|
{t('app:call.share_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'Nach "Teilen starten" öffnet das Betriebssystem den Quellen-Picker. Qualität + Bildrate werden bereits angewendet.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleStart()}
|
||||||
|
disabled={busy}
|
||||||
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||||
|
<span>
|
||||||
|
{t('app:call.share_start', { defaultValue: 'Teilen starten' })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SurfaceOption({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
sub,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
|
sub: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={
|
||||||
|
'flex flex-col items-start gap-0.5 rounded-lg border p-2.5 text-left transition ' +
|
||||||
|
(active
|
||||||
|
? 'border-accent bg-accent/10 text-fg'
|
||||||
|
: 'border-line bg-surface-2 text-fg hover:bg-surface')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-semibold">{label}</span>
|
||||||
|
<span className="text-[10px] text-fg-muted">{sub}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { RemoteTrack } from 'livekit-client';
|
import type { RemoteTrack } from 'livekit-client';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import type { RemoteScreenShare } from '../context/CallContext';
|
import type { RemoteScreenShare } from '../context/CallContext';
|
||||||
@@ -51,13 +50,13 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
|
|||||||
setIsFullscreen((v) => !v);
|
setIsFullscreen((v) => !v);
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewerNode = (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={
|
className={
|
||||||
isFullscreen
|
isFullscreen
|
||||||
? 'fixed inset-0 z-[60] flex h-screen w-screen flex-col overflow-hidden border-0 bg-black'
|
? 'fixed inset-0 z-[60] flex h-screen w-screen flex-col overflow-hidden border-0 bg-black'
|
||||||
: 'overflow-hidden rounded-xl border border-emerald-500/30 bg-black'
|
: 'flex h-full w-full flex-col overflow-hidden rounded-xl border border-emerald-500/30 bg-black'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/20 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-700 dark:text-emerald-200">
|
<div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/20 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-700 dark:text-emerald-200">
|
||||||
@@ -104,10 +103,7 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
|
|||||||
playsInline
|
playsInline
|
||||||
muted
|
muted
|
||||||
onDoubleClick={toggleFullscreen}
|
onDoubleClick={toggleFullscreen}
|
||||||
className={
|
className="block h-full w-full flex-1 cursor-zoom-in bg-black object-contain"
|
||||||
'block cursor-zoom-in bg-black ' +
|
|
||||||
(isFullscreen ? 'h-full w-full flex-1 object-contain' : 'w-full')
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -132,14 +128,6 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// When in fullscreen, portal out of the call-panel subtree into <body> so
|
|
||||||
// no ancestor can clip or stack below us. Sidebar/chat-list are siblings of
|
|
||||||
// AppShell's root — portalled node sits above them via z-[60].
|
|
||||||
if (isFullscreen) {
|
|
||||||
return createPortal(viewerNode, document.body);
|
|
||||||
}
|
|
||||||
return viewerNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {
|
function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {
|
||||||
|
|||||||
@@ -0,0 +1,625 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { getPttSettings } from '../lib/pttSettings';
|
||||||
|
import { codeToShortcut } from '../lib/globalShortcut';
|
||||||
|
import { invalidate as invalidateSoundCache, preload } from '../lib/soundboardPlayback';
|
||||||
|
import {
|
||||||
|
addSound,
|
||||||
|
deleteSound,
|
||||||
|
getSoundBlob,
|
||||||
|
listSounds,
|
||||||
|
MAX_SOUND_BYTES,
|
||||||
|
reorderCategory,
|
||||||
|
type SoundboardEntry,
|
||||||
|
subscribeSoundboardChanges,
|
||||||
|
updateSound,
|
||||||
|
} from '../lib/soundboardStorage';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
import {
|
||||||
|
AlertIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
PencilIcon,
|
||||||
|
PlusIcon,
|
||||||
|
SpinnerIcon,
|
||||||
|
TrashIcon,
|
||||||
|
} from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BYTES_PER_MB = 1024 * 1024;
|
||||||
|
const CATEGORY_LIST_ID = 'sb-category-list';
|
||||||
|
|
||||||
|
// Admin UI for the soundboard. Users add, rename, categorise, reorder,
|
||||||
|
// assign hotkeys, adjust per-sound volume, preview and delete clips here.
|
||||||
|
// In-call panel only reads the resulting manifest.
|
||||||
|
export function SoundboardManagerDialog({ open, onClose }: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const previewRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const previewUrlRef = useRef<string | null>(null);
|
||||||
|
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setEntries(await listSounds());
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('listSounds failed', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
void refresh();
|
||||||
|
// External mutations (hotkey fires, multi-tab edits) should reflect
|
||||||
|
// immediately while the dialog is open.
|
||||||
|
const unsub = subscribeSoundboardChanges(() => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unsub();
|
||||||
|
stopPreview();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => stopPreview();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const e of entries) if (e.category) set.add(e.category);
|
||||||
|
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, SoundboardEntry[]>();
|
||||||
|
for (const e of entries) {
|
||||||
|
const key = e.category ?? '';
|
||||||
|
const arr = map.get(key);
|
||||||
|
if (arr) arr.push(e);
|
||||||
|
else map.set(key, [e]);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
|
function stopPreview(): void {
|
||||||
|
const el = previewRef.current;
|
||||||
|
if (el) {
|
||||||
|
try {
|
||||||
|
el.pause();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
el.src = '';
|
||||||
|
}
|
||||||
|
previewRef.current = null;
|
||||||
|
if (previewUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(previewUrlRef.current);
|
||||||
|
previewUrlRef.current = null;
|
||||||
|
}
|
||||||
|
setPreviewingId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdd(file: File): Promise<void> {
|
||||||
|
setError(null);
|
||||||
|
setBusyId('__add');
|
||||||
|
try {
|
||||||
|
await addSound({ file });
|
||||||
|
await refresh();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const code = err instanceof Error ? err.message : 'add_failed';
|
||||||
|
if (code === 'sound_too_large') {
|
||||||
|
setError(
|
||||||
|
t('app:soundboard.error_too_large', {
|
||||||
|
defaultValue: 'Datei zu groß (max {{max}} MB).',
|
||||||
|
max: MAX_SOUND_BYTES / BYTES_PER_MB,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (code === 'sound_not_audio') {
|
||||||
|
setError(
|
||||||
|
t('app:soundboard.error_not_audio', {
|
||||||
|
defaultValue: 'Nur Audio-Dateien werden unterstützt.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError(
|
||||||
|
t('app:soundboard.error_generic', {
|
||||||
|
defaultValue: 'Sound konnte nicht gespeichert werden.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
if (fileRef.current) fileRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePatch(
|
||||||
|
id: string,
|
||||||
|
patch: Parameters<typeof updateSound>[1],
|
||||||
|
): Promise<void> {
|
||||||
|
setBusyId(id);
|
||||||
|
try {
|
||||||
|
await updateSound(id, patch);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('updateSound failed', err);
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string): Promise<void> {
|
||||||
|
if (!window.confirm(t('app:soundboard.delete_confirm', { defaultValue: 'Sound löschen?' }))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusyId(id);
|
||||||
|
try {
|
||||||
|
await deleteSound(id);
|
||||||
|
invalidateSoundCache(id);
|
||||||
|
if (previewingId === id) stopPreview();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('deleteSound failed', err);
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePreview(entry: SoundboardEntry): Promise<void> {
|
||||||
|
if (previewingId === entry.id) {
|
||||||
|
stopPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopPreview();
|
||||||
|
const blob = await getSoundBlob(entry.id);
|
||||||
|
if (!blob) return;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const el = new Audio(url);
|
||||||
|
el.volume = entry.gain;
|
||||||
|
el.onended = () => stopPreview();
|
||||||
|
el.onerror = () => stopPreview();
|
||||||
|
el.play().catch(() => stopPreview());
|
||||||
|
previewRef.current = el;
|
||||||
|
previewUrlRef.current = url;
|
||||||
|
setPreviewingId(entry.id);
|
||||||
|
// Opportunistic warm-up of the AudioBuffer cache so the first in-call
|
||||||
|
// playback doesn't pause on decode.
|
||||||
|
void preload(entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReorder(
|
||||||
|
category: string | null,
|
||||||
|
idx: number,
|
||||||
|
dir: -1 | 1,
|
||||||
|
): Promise<void> {
|
||||||
|
const bucket = grouped.get(category ?? '') ?? [];
|
||||||
|
const next = idx + dir;
|
||||||
|
if (next < 0 || next >= bucket.length) return;
|
||||||
|
const reordered = bucket.slice();
|
||||||
|
const tmp = reordered[idx]!;
|
||||||
|
reordered[idx] = reordered[next]!;
|
||||||
|
reordered[next] = tmp;
|
||||||
|
setBusyId('__reorder:' + (category ?? ''));
|
||||||
|
try {
|
||||||
|
await reorderCategory(
|
||||||
|
category,
|
||||||
|
reordered.map((e) => e.id),
|
||||||
|
);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('reorderCategory failed', err);
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t('app:soundboard.manager_title', { defaultValue: 'Soundboard verwalten' })}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-xs text-fg-muted">
|
||||||
|
{t('app:soundboard.manager_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'Beliebig viele Sounds, kein Hotkey nötig. Hotkeys feuern nur während eines Anrufs.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="audio/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void handleAdd(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={busyId !== null}
|
||||||
|
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busyId === '__add' ? (
|
||||||
|
<SpinnerIcon className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<PlusIcon className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
<span>{t('app:soundboard.add', { defaultValue: 'Sound hinzufügen' })}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-300">
|
||||||
|
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<p className="min-w-0 flex-1 break-words">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<datalist id={CATEGORY_LIST_ID}>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c} value={c} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
||||||
|
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
||||||
|
</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-line bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
|
||||||
|
{t('app:soundboard.empty', {
|
||||||
|
defaultValue: 'Noch keine Sounds. Lade oben welche hoch.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{Array.from(grouped.entries()).map(([categoryKey, bucket]) => {
|
||||||
|
const category = categoryKey === '' ? null : categoryKey;
|
||||||
|
return (
|
||||||
|
<SoundboardCategoryGroup
|
||||||
|
key={categoryKey || '__uncat'}
|
||||||
|
category={category}
|
||||||
|
entries={bucket}
|
||||||
|
entriesTotal={entries}
|
||||||
|
busyId={busyId}
|
||||||
|
previewingId={previewingId}
|
||||||
|
onPatch={handlePatch}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onPreview={handlePreview}
|
||||||
|
onReorder={handleReorder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface GroupProps {
|
||||||
|
category: string | null;
|
||||||
|
entries: SoundboardEntry[];
|
||||||
|
entriesTotal: SoundboardEntry[];
|
||||||
|
busyId: string | null;
|
||||||
|
previewingId: string | null;
|
||||||
|
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
||||||
|
onDelete: (id: string) => Promise<void>;
|
||||||
|
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
||||||
|
onReorder: (category: string | null, idx: number, dir: -1 | 1) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SoundboardCategoryGroup({
|
||||||
|
category,
|
||||||
|
entries,
|
||||||
|
entriesTotal,
|
||||||
|
busyId,
|
||||||
|
previewingId,
|
||||||
|
onPatch,
|
||||||
|
onDelete,
|
||||||
|
onPreview,
|
||||||
|
onReorder,
|
||||||
|
}: GroupProps) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
const label =
|
||||||
|
category ??
|
||||||
|
t('app:soundboard.category_uncategorized', { defaultValue: '(Ohne Kategorie)' });
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-line bg-surface-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5 text-left"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
||||||
|
{label} · {entries.length}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={'h-4 w-4 text-fg-muted transition ' + (open ? '' : '-rotate-90')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<ul className="flex flex-col gap-2 border-t border-line p-3">
|
||||||
|
{entries.map((entry, idx) => (
|
||||||
|
<SoundboardRow
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
entriesTotal={entriesTotal}
|
||||||
|
isFirst={idx === 0}
|
||||||
|
isLast={idx === entries.length - 1}
|
||||||
|
busy={busyId === entry.id}
|
||||||
|
previewing={previewingId === entry.id}
|
||||||
|
onPatch={onPatch}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onPreview={onPreview}
|
||||||
|
onReorderUp={() => onReorder(category, idx, -1)}
|
||||||
|
onReorderDown={() => onReorder(category, idx, 1)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface RowProps {
|
||||||
|
entry: SoundboardEntry;
|
||||||
|
entriesTotal: SoundboardEntry[];
|
||||||
|
isFirst: boolean;
|
||||||
|
isLast: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
previewing: boolean;
|
||||||
|
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
||||||
|
onDelete: (id: string) => Promise<void>;
|
||||||
|
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
||||||
|
onReorderUp: () => Promise<void>;
|
||||||
|
onReorderDown: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SoundboardRow({
|
||||||
|
entry,
|
||||||
|
entriesTotal,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
busy,
|
||||||
|
previewing,
|
||||||
|
onPatch,
|
||||||
|
onDelete,
|
||||||
|
onPreview,
|
||||||
|
onReorderUp,
|
||||||
|
onReorderDown,
|
||||||
|
}: RowProps) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const [editingName, setEditingName] = useState(false);
|
||||||
|
const [nameDraft, setNameDraft] = useState(entry.name);
|
||||||
|
const [categoryDraft, setCategoryDraft] = useState(entry.category ?? '');
|
||||||
|
const [capturingHotkey, setCapturingHotkey] = useState(false);
|
||||||
|
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNameDraft(entry.name);
|
||||||
|
setCategoryDraft(entry.category ?? '');
|
||||||
|
}, [entry.name, entry.category]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!capturingHotkey) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.code === 'Escape') {
|
||||||
|
setCapturingHotkey(false);
|
||||||
|
setHotkeyError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Conflict checks: PTT + other soundboard entries with this code.
|
||||||
|
const ptt = getPttSettings();
|
||||||
|
if (ptt.enabled && ptt.key === e.code) {
|
||||||
|
setHotkeyError(
|
||||||
|
t('app:soundboard.hotkey_conflict_ptt', {
|
||||||
|
defaultValue: 'Konflikt mit Push-to-Talk.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const taken = entriesTotal.find((s) => s.id !== entry.id && s.hotkey === e.code);
|
||||||
|
if (taken) {
|
||||||
|
setHotkeyError(
|
||||||
|
t('app:soundboard.hotkey_conflict_sound', {
|
||||||
|
defaultValue: 'Bereits von "{{name}}" belegt.',
|
||||||
|
name: taken.name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHotkeyError(null);
|
||||||
|
setCapturingHotkey(false);
|
||||||
|
void onPatch(entry.id, { hotkey: e.code });
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey, { capture: true });
|
||||||
|
return () => window.removeEventListener('keydown', onKey, { capture: true });
|
||||||
|
}, [capturingHotkey, entriesTotal, entry.id, onPatch, t]);
|
||||||
|
|
||||||
|
const hotkeyLabel = entry.hotkey ? codeToShortcut(entry.hotkey) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="flex flex-wrap items-center gap-3 rounded-md border border-line bg-surface-3 px-3 py-2">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
{editingName ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={nameDraft}
|
||||||
|
onChange={(e) => setNameDraft(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
setEditingName(false);
|
||||||
|
if (nameDraft.trim() && nameDraft !== entry.name) {
|
||||||
|
void onPatch(entry.id, { name: nameDraft });
|
||||||
|
} else {
|
||||||
|
setNameDraft(entry.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
(e.target as HTMLInputElement).blur();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setNameDraft(entry.name);
|
||||||
|
setEditingName(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full rounded border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingName(true)}
|
||||||
|
className="flex cursor-pointer items-center gap-1.5 self-start text-sm font-semibold text-fg hover:text-accent"
|
||||||
|
>
|
||||||
|
{entry.name}
|
||||||
|
<PencilIcon className="h-3 w-3 opacity-50" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-fg-muted">
|
||||||
|
{(entry.size / BYTES_PER_MB).toFixed(2)} MB · {entry.mime}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={categoryDraft}
|
||||||
|
onChange={(e) => setCategoryDraft(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const next = categoryDraft.trim() || null;
|
||||||
|
if (next !== (entry.category ?? null)) {
|
||||||
|
void onPatch(entry.id, { category: next });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
list={CATEGORY_LIST_ID}
|
||||||
|
placeholder={t('app:soundboard.category_placeholder', {
|
||||||
|
defaultValue: 'Kategorie…',
|
||||||
|
})}
|
||||||
|
className="w-32 shrink-0 rounded border border-line bg-surface-2 px-2 py-1 text-xs text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setHotkeyError(null);
|
||||||
|
setCapturingHotkey((v) => !v);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
'inline-flex min-w-[5rem] cursor-pointer items-center justify-center rounded border px-2 py-1 text-[11px] font-mono font-semibold transition ' +
|
||||||
|
(capturingHotkey
|
||||||
|
? 'animate-pulse border-accent bg-accent/20 text-fg'
|
||||||
|
: hotkeyLabel
|
||||||
|
? 'border-accent/40 bg-accent/10 text-accent'
|
||||||
|
: 'border-line bg-surface-2 text-fg-muted hover:bg-surface')
|
||||||
|
}
|
||||||
|
title={t('app:soundboard.hotkey_capture', {
|
||||||
|
defaultValue: 'Hotkey binden (Esc = abbrechen)',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{capturingHotkey
|
||||||
|
? t('app:soundboard.hotkey_press', { defaultValue: 'Drücke…' })
|
||||||
|
: hotkeyLabel ??
|
||||||
|
t('app:soundboard.hotkey_none', { defaultValue: 'Kein Hotkey' })}
|
||||||
|
</button>
|
||||||
|
{entry.hotkey && !capturingHotkey && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onPatch(entry.id, { hotkey: null })}
|
||||||
|
aria-label={t('app:soundboard.hotkey_clear', { defaultValue: 'Hotkey entfernen' })}
|
||||||
|
title={t('app:soundboard.hotkey_clear', { defaultValue: 'Hotkey entfernen' })}
|
||||||
|
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={entry.gain}
|
||||||
|
onChange={(e) => void onPatch(entry.id, { gain: Number(e.target.value) })}
|
||||||
|
className="accent-accent w-20"
|
||||||
|
title={t('app:soundboard.gain_title', {
|
||||||
|
defaultValue: 'Lautstärke',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onPreview(entry)}
|
||||||
|
disabled={busy}
|
||||||
|
className="inline-flex cursor-pointer items-center gap-1 rounded border border-line bg-surface-2 px-2 py-1 text-[11px] font-medium text-fg transition hover:bg-surface disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{previewing
|
||||||
|
? t('app:soundboard.preview_stop', { defaultValue: 'Stop' })
|
||||||
|
: t('app:soundboard.preview', { defaultValue: 'Vorhören' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onReorderUp}
|
||||||
|
disabled={busy || isFirst}
|
||||||
|
aria-label={t('app:soundboard.move_up', { defaultValue: 'Nach oben' })}
|
||||||
|
title={t('app:soundboard.move_up', { defaultValue: 'Nach oben' })}
|
||||||
|
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg disabled:opacity-40"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onReorderDown}
|
||||||
|
disabled={busy || isLast}
|
||||||
|
aria-label={t('app:soundboard.move_down', { defaultValue: 'Nach unten' })}
|
||||||
|
title={t('app:soundboard.move_down', { defaultValue: 'Nach unten' })}
|
||||||
|
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg disabled:opacity-40"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onDelete(entry.id)}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={t('app:soundboard.delete', { defaultValue: 'Löschen' })}
|
||||||
|
title={t('app:soundboard.delete', { defaultValue: 'Löschen' })}
|
||||||
|
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded border border-rose-500/30 bg-rose-500/10 text-rose-600 hover:bg-rose-500/20 disabled:opacity-50 dark:text-rose-300"
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hotkeyError && capturingHotkey && (
|
||||||
|
<p className="basis-full text-[11px] text-rose-600 dark:text-rose-300">
|
||||||
|
{hotkeyError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useCall } from '../context/CallContext';
|
||||||
|
import { codeToShortcut } from '../lib/globalShortcut';
|
||||||
|
import {
|
||||||
|
DEFAULT_PREFS,
|
||||||
|
getPrefs,
|
||||||
|
listSounds,
|
||||||
|
type SoundboardEntry,
|
||||||
|
type SoundboardPrefs,
|
||||||
|
subscribeSoundboardChanges,
|
||||||
|
} from '../lib/soundboardStorage';
|
||||||
|
import { ChevronDownIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-call popover that lists every stored sound grouped by category. Click a
|
||||||
|
// pad to play through the active pipeline. Hotkey-badge shows the bound
|
||||||
|
// accelerator (if any). Master + monitor sliders adjust the pipeline gains.
|
||||||
|
export function SoundboardPanel({ onClose }: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const {
|
||||||
|
playSoundboard,
|
||||||
|
stopSoundboard,
|
||||||
|
activeSoundboardIds,
|
||||||
|
setSoundboardMasterGain,
|
||||||
|
setSoundboardMonitorGain,
|
||||||
|
} = useCall();
|
||||||
|
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
|
||||||
|
const [prefs, setPrefs] = useState<SoundboardPrefs>(() => ({ ...DEFAULT_PREFS }));
|
||||||
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const closeRef = useRef(onClose);
|
||||||
|
closeRef.current = onClose;
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [all, p] = await Promise.all([listSounds(), getPrefs()]);
|
||||||
|
setEntries(all);
|
||||||
|
setPrefs(p);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('soundboard panel refresh failed', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
const unsub = subscribeSoundboardChanges(() => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
// Close on Esc — tapping outside is handled by the trigger's parent.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') closeRef.current();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return entries;
|
||||||
|
return entries.filter(
|
||||||
|
(e) =>
|
||||||
|
e.name.toLowerCase().includes(q) ||
|
||||||
|
(e.category ?? '').toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [entries, query]);
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, SoundboardEntry[]>();
|
||||||
|
for (const e of filtered) {
|
||||||
|
const key = e.category ?? '';
|
||||||
|
const arr = map.get(key);
|
||||||
|
if (arr) arr.push(e);
|
||||||
|
else map.set(key, [e]);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [filtered]);
|
||||||
|
|
||||||
|
function toggleCategory(key: string): void {
|
||||||
|
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label={t('app:soundboard.panel_title', { defaultValue: 'Soundboard' })}
|
||||||
|
className="flex w-[360px] max-h-[70vh] flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-2xl"
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between border-b border-line px-4 py-2.5">
|
||||||
|
<h3 className="font-display text-sm font-semibold text-fg">
|
||||||
|
{t('app:soundboard.panel_title', { defaultValue: 'Soundboard' })}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('app:soundboard.panel_close', { defaultValue: 'Schließen' })}
|
||||||
|
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{entries.length > 0 && (
|
||||||
|
<div className="border-b border-line px-3 pb-2 pt-2">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={t('app:soundboard.panel_search', { defaultValue: 'Suche…' })}
|
||||||
|
className="w-full rounded-md border border-line bg-surface-2 px-3 py-1.5 text-xs text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-xs text-fg-muted">
|
||||||
|
{t('app:soundboard.panel_empty', {
|
||||||
|
defaultValue:
|
||||||
|
'Keine Sounds gespeichert. Füge welche in den Einstellungen hinzu.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-xs text-fg-muted">
|
||||||
|
{t('app:soundboard.panel_no_matches', {
|
||||||
|
defaultValue: 'Keine Treffer.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{Array.from(grouped.entries()).map(([key, bucket]) => (
|
||||||
|
<CategorySection
|
||||||
|
key={key || '__uncat'}
|
||||||
|
categoryKey={key}
|
||||||
|
entries={bucket}
|
||||||
|
collapsed={collapsed[key] ?? false}
|
||||||
|
activeIds={activeSoundboardIds}
|
||||||
|
onToggle={() => toggleCategory(key)}
|
||||||
|
onActivate={(id) => {
|
||||||
|
if (activeSoundboardIds.has(id)) {
|
||||||
|
stopSoundboard(id);
|
||||||
|
} else {
|
||||||
|
void playSoundboard(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="flex flex-col gap-2 border-t border-line bg-surface-2 px-4 py-3">
|
||||||
|
<VolumeSlider
|
||||||
|
label={t('app:soundboard.panel_master', { defaultValue: 'Master' })}
|
||||||
|
value={prefs.masterGain}
|
||||||
|
onChange={(v) => {
|
||||||
|
setPrefs((p) => ({ ...p, masterGain: v }));
|
||||||
|
void setSoundboardMasterGain(v);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<VolumeSlider
|
||||||
|
label={t('app:soundboard.panel_monitor', { defaultValue: 'Mithören' })}
|
||||||
|
value={prefs.monitorGain}
|
||||||
|
onChange={(v) => {
|
||||||
|
setPrefs((p) => ({ ...p, monitorGain: v }));
|
||||||
|
void setSoundboardMonitorGain(v);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => stopSoundboard()}
|
||||||
|
className="mt-1 inline-flex cursor-pointer items-center justify-center rounded-md border border-line bg-surface-3 px-3 py-1.5 text-[11px] font-semibold text-fg transition hover:brightness-95"
|
||||||
|
>
|
||||||
|
{t('app:soundboard.panel_stop_all', { defaultValue: 'Alle stoppen' })}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategorySectionProps {
|
||||||
|
categoryKey: string;
|
||||||
|
entries: SoundboardEntry[];
|
||||||
|
collapsed: boolean;
|
||||||
|
activeIds: ReadonlySet<string>;
|
||||||
|
onToggle: () => void;
|
||||||
|
onActivate: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategorySection({
|
||||||
|
categoryKey,
|
||||||
|
entries,
|
||||||
|
collapsed,
|
||||||
|
activeIds,
|
||||||
|
onToggle,
|
||||||
|
onActivate,
|
||||||
|
}: CategorySectionProps) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const label =
|
||||||
|
categoryKey === ''
|
||||||
|
? t('app:soundboard.category_uncategorized', { defaultValue: '(Ohne Kategorie)' })
|
||||||
|
: categoryKey;
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="mb-1.5 flex w-full cursor-pointer items-center justify-between gap-2 text-left text-[10px] font-semibold uppercase tracking-[0.1em] text-fg-muted"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{label} · {entries.length}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={'h-3 w-3 transition ' + (collapsed ? '-rotate-90' : '')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<SoundPad
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
active={activeIds.has(entry.id)}
|
||||||
|
onActivate={() => onActivate(entry.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PadProps {
|
||||||
|
entry: SoundboardEntry;
|
||||||
|
active: boolean;
|
||||||
|
onActivate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SoundPad({ entry, active, onActivate }: PadProps) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const hotkey = entry.hotkey ? codeToShortcut(entry.hotkey) : null;
|
||||||
|
const base =
|
||||||
|
'group relative flex min-h-[54px] cursor-pointer flex-col justify-center gap-0.5 rounded-md border px-2.5 py-2 text-left text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40';
|
||||||
|
const toneClass = active
|
||||||
|
? 'border-rose-500 bg-rose-500/20 text-fg hover:brightness-110'
|
||||||
|
: 'border-line bg-surface-2 text-fg hover:border-accent hover:bg-surface-3';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onActivate}
|
||||||
|
className={`${base} ${toneClass}`}
|
||||||
|
title={
|
||||||
|
active
|
||||||
|
? t('app:soundboard.pad_stop', { defaultValue: 'Stoppen' })
|
||||||
|
: entry.name
|
||||||
|
}
|
||||||
|
aria-pressed={active}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5 truncate">
|
||||||
|
{active && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-2 w-2 shrink-0 rounded-full bg-rose-500 animate-live-dot"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="truncate">{entry.name}</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center justify-between gap-1.5">
|
||||||
|
{hotkey ? (
|
||||||
|
<span className="inline-flex w-fit items-center rounded border border-line bg-surface-3 px-1 py-0.5 font-mono text-[9px] text-fg-muted">
|
||||||
|
{hotkey}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{active && (
|
||||||
|
<span className="text-[9px] font-semibold uppercase tracking-[0.08em] text-rose-500 dark:text-rose-300">
|
||||||
|
{t('app:soundboard.pad_stop', { defaultValue: 'Stoppen' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VolumeSlider({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
onChange: (v: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-3 text-[11px] text-fg">
|
||||||
|
<span className="w-16 shrink-0 text-fg-muted">{label}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.02}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
className="accent-accent flex-1"
|
||||||
|
/>
|
||||||
|
<span className="w-9 shrink-0 text-right tabular-nums text-fg-muted">
|
||||||
|
{Math.round(value * 100)}%
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
listSounds,
|
||||||
|
subscribeSoundboardChanges,
|
||||||
|
type SoundboardEntry,
|
||||||
|
} from '../lib/soundboardStorage';
|
||||||
|
import { SoundboardManagerDialog } from './SoundboardManagerDialog';
|
||||||
|
import { ArrowRightIcon } from './icons';
|
||||||
|
|
||||||
|
// Entry point into the soundboard manager from the settings page. Shows a
|
||||||
|
// tiny summary (count, category count) and opens the big dialog on click.
|
||||||
|
export function SoundboardSettings() {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const refresh = async () => {
|
||||||
|
try {
|
||||||
|
const all = await listSounds();
|
||||||
|
if (!cancelled) setEntries(all);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('listSounds failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void refresh();
|
||||||
|
const unsub = subscribeSoundboardChanges(() => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unsub();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const categoryCount = new Set(entries.map((e) => e.category ?? '__uncat')).size;
|
||||||
|
const withHotkey = entries.filter((e) => e.hotkey !== null).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-fg">
|
||||||
|
{t('app:soundboard.summary_title', { defaultValue: 'Deine Sounds' })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-fg-muted">
|
||||||
|
{entries.length === 0
|
||||||
|
? t('app:soundboard.summary_empty', {
|
||||||
|
defaultValue: 'Noch keine Sounds vorhanden.',
|
||||||
|
})
|
||||||
|
: t('app:soundboard.summary_counts', {
|
||||||
|
defaultValue:
|
||||||
|
'{{sounds}} Sounds · {{categories}} Kategorien · {{hotkeys}} mit Hotkey',
|
||||||
|
sounds: entries.length,
|
||||||
|
categories: categoryCount,
|
||||||
|
hotkeys: withHotkey,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{t('app:soundboard.manage', { defaultValue: 'Verwalten' })}
|
||||||
|
</span>
|
||||||
|
<ArrowRightIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-fg-muted">
|
||||||
|
{t('app:soundboard.settings_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'Hotkeys sind optional. Sounds lassen sich auch während eines Anrufs direkt im UI abspielen.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<SoundboardManagerDialog open={open} onClose={() => setOpen(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { updateOwnProfile } from '@chat-app/shared/auth';
|
import { updateOwnProfile } from '@chat-app/shared/auth';
|
||||||
import type { PresenceState } from '@chat-app/shared/supabase';
|
import type { PresenceState } from '@chat-app/shared/supabase';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
@@ -8,6 +8,8 @@ import { supabase } from '../lib/supabase';
|
|||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
import { ChevronDownIcon } from './icons';
|
import { ChevronDownIcon } from './icons';
|
||||||
|
|
||||||
|
const STATUS_MAX = 128;
|
||||||
|
|
||||||
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
|
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
|
||||||
|
|
||||||
const PRESENCE_DOT: Record<PresenceState, string> = {
|
const PRESENCE_DOT: Record<PresenceState, string> = {
|
||||||
@@ -25,6 +27,23 @@ export function UserBar() {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const presence = profile?.presenceState ?? 'offline';
|
const presence = profile?.presenceState ?? 'offline';
|
||||||
|
const persistedStatus = profile?.statusMessage ?? '';
|
||||||
|
const [statusDraft, setStatusDraft] = useState(persistedStatus);
|
||||||
|
|
||||||
|
// Re-sync local draft with the server when the profile refreshes (e.g. after
|
||||||
|
// a successful save) — without this the input would forget any incoming
|
||||||
|
// updates from another device.
|
||||||
|
useEffect(() => {
|
||||||
|
setStatusDraft(persistedStatus);
|
||||||
|
}, [persistedStatus]);
|
||||||
|
|
||||||
|
// Subtitle priority: custom status when online and set, else label, else "Offline".
|
||||||
|
const subtitle =
|
||||||
|
presence === 'offline'
|
||||||
|
? t('app:presence.offline')
|
||||||
|
: persistedStatus.trim().length > 0
|
||||||
|
? persistedStatus.trim()
|
||||||
|
: t('app:presence.' + presence);
|
||||||
|
|
||||||
async function changePresence(next: PresenceState) {
|
async function changePresence(next: PresenceState) {
|
||||||
if (busy || next === presence) {
|
if (busy || next === presence) {
|
||||||
@@ -43,6 +62,20 @@ export function UserBar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveStatus() {
|
||||||
|
const next = statusDraft.trim().slice(0, STATUS_MAX);
|
||||||
|
if (next === persistedStatus) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await updateOwnProfile(supabase, { statusMessage: next.length === 0 ? null : next });
|
||||||
|
await refreshProfile();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('updateStatusMessage failed', err);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
@@ -69,9 +102,7 @@ export function UserBar() {
|
|||||||
<p className="truncate text-sm font-medium text-fg">
|
<p className="truncate text-sm font-medium text-fg">
|
||||||
{profile?.displayName ?? '—'}
|
{profile?.displayName ?? '—'}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-xs text-fg-muted">
|
<p className="truncate text-xs text-fg-muted">{subtitle}</p>
|
||||||
{t('app:presence.' + presence)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
className={'h-4 w-4 text-fg-muted transition ' + (open ? 'rotate-180' : '')}
|
className={'h-4 w-4 text-fg-muted transition ' + (open ? 'rotate-180' : '')}
|
||||||
@@ -83,6 +114,30 @@ export function UserBar() {
|
|||||||
role="menu"
|
role="menu"
|
||||||
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-line bg-surface-3 shadow-xl"
|
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-line bg-surface-3 shadow-xl"
|
||||||
>
|
>
|
||||||
|
<div className="border-b border-line p-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={statusDraft}
|
||||||
|
onChange={(e) => setStatusDraft(e.target.value.slice(0, STATUS_MAX))}
|
||||||
|
onBlur={() => void saveStatus()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
void saveStatus();
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setStatusDraft(persistedStatus);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('app:presence.status_placeholder', {
|
||||||
|
defaultValue: 'Status setzen…',
|
||||||
|
})}
|
||||||
|
maxLength={STATUS_MAX}
|
||||||
|
className="w-full rounded-md border border-line bg-surface-2 px-2 py-1.5 text-sm text-fg placeholder-fg-muted outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{PRESENCE_OPTIONS.map((opt) => (
|
{PRESENCE_OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt}
|
key={opt}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import type { ProfileBrief } from '@chat-app/shared/friends';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { usePeerPresence } from '../lib/usePeerPresence';
|
||||||
|
import { Avatar } from './Avatar';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userId: string;
|
||||||
|
profile: ProfileBrief | null;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onStartDm?: (userId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRESENCE_DOT: Record<string, string> = {
|
||||||
|
online: 'bg-emerald-500',
|
||||||
|
idle: 'bg-amber-400',
|
||||||
|
dnd: 'bg-rose-500',
|
||||||
|
invisible: 'bg-neutral-500',
|
||||||
|
offline: 'bg-neutral-400 dark:bg-neutral-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CARD_W = 280;
|
||||||
|
const CARD_H = 180;
|
||||||
|
|
||||||
|
// Hover/click card surfaced from avatars around the app. Shows display name,
|
||||||
|
// @handle, presence state + status message, plus a DM-start button when
|
||||||
|
// the clicked profile isn't the caller.
|
||||||
|
export function UserProfilePopover({
|
||||||
|
userId,
|
||||||
|
profile,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
onClose,
|
||||||
|
onStartDm,
|
||||||
|
}: Props) {
|
||||||
|
// Subscribe to the same presence feed that the conversation header uses,
|
||||||
|
// so status updates flow in live.
|
||||||
|
const presence = usePeerPresence(userId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
const t = e.target as HTMLElement | null;
|
||||||
|
if (t?.closest('[data-user-popover]')) return;
|
||||||
|
if (t?.closest('[data-user-popover-trigger]')) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
window.addEventListener('mousedown', onDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
window.removeEventListener('mousedown', onDown);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const left = Math.min(Math.max(8, x), window.innerWidth - CARD_W - 8);
|
||||||
|
const top = Math.min(Math.max(8, y), window.innerHeight - CARD_H - 8);
|
||||||
|
|
||||||
|
const displayName = profile?.displayName ?? '?';
|
||||||
|
const username = profile?.username ?? '';
|
||||||
|
const state = presence?.state ?? 'offline';
|
||||||
|
const showPresence = state !== 'invisible';
|
||||||
|
const statusMessage = presence?.statusMessage?.trim() ?? '';
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
data-user-popover
|
||||||
|
role="dialog"
|
||||||
|
aria-label={displayName}
|
||||||
|
style={{ left, top, width: CARD_W }}
|
||||||
|
className="fixed z-[80] flex flex-col gap-3 rounded-xl border border-line bg-surface-2/95 p-4 shadow-xl backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar
|
||||||
|
url={profile?.avatarUrl ?? null}
|
||||||
|
displayName={displayName}
|
||||||
|
className="h-14 w-14 text-lg"
|
||||||
|
/>
|
||||||
|
{showPresence && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={
|
||||||
|
'absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full ring-2 ring-surface-2 ' +
|
||||||
|
(PRESENCE_DOT[state] ?? PRESENCE_DOT.offline)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-display text-base font-semibold text-fg">
|
||||||
|
{displayName}
|
||||||
|
</p>
|
||||||
|
{username && (
|
||||||
|
<p className="truncate text-xs text-fg-muted">@{username}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{statusMessage && state !== 'offline' && (
|
||||||
|
<p className="rounded-md bg-surface-3 px-2.5 py-1.5 text-xs italic text-fg-muted">
|
||||||
|
{statusMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onStartDm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onStartDm(userId);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="inline-flex cursor-pointer items-center justify-center rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110"
|
||||||
|
>
|
||||||
|
Nachricht senden
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { MicIcon, SpinnerIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Called with the recorded audio file when the user confirms. The button
|
||||||
|
* ships as a single-attachment message, so the parent can feed it into
|
||||||
|
* the normal send flow. */
|
||||||
|
onComplete: (file: File) => Promise<void> | void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-button voice recorder. Click to start; shows an inline pill with
|
||||||
|
// elapsed time + stop + cancel while recording. On stop, hands a File to the
|
||||||
|
// parent. No in-place preview yet — we lean on the optimistic message bubble
|
||||||
|
// to appear once the parent sends.
|
||||||
|
const MAX_RECORD_SEC = 60;
|
||||||
|
|
||||||
|
export function VoiceRecorder({ onComplete, disabled = false }: Props) {
|
||||||
|
const [state, setState] = useState<'idle' | 'recording' | 'finalizing'>('idle');
|
||||||
|
const [elapsedSec, setElapsedSec] = useState(0);
|
||||||
|
const [level, setLevel] = useState(0);
|
||||||
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const startedAtRef = useRef<number>(0);
|
||||||
|
const cancelledRef = useRef(false);
|
||||||
|
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||||
|
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||||
|
const rafRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// Tick elapsed + auto-stop at MAX_RECORD_SEC.
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== 'recording') return;
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000);
|
||||||
|
setElapsedSec(sec);
|
||||||
|
if (sec >= MAX_RECORD_SEC) {
|
||||||
|
const rec = recorderRef.current;
|
||||||
|
if (rec && rec.state !== 'inactive') rec.stop();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
// Live mic level meter via Web Audio AnalyserNode. RMS on 0..1, smoothed.
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== 'recording') return;
|
||||||
|
const analyser = analyserRef.current;
|
||||||
|
if (!analyser) return;
|
||||||
|
const buf = new Uint8Array(analyser.frequencyBinCount);
|
||||||
|
let cancelled = false;
|
||||||
|
const tick = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
analyser.getByteFrequencyData(buf as Uint8Array<ArrayBuffer>);
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < buf.length; i++) sum += buf[i]!;
|
||||||
|
setLevel(sum / (buf.length * 255));
|
||||||
|
rafRef.current = window.requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
rafRef.current = window.requestAnimationFrame(tick);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.cancelAnimationFrame(rafRef.current);
|
||||||
|
};
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const stopStream = () => {
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.getTracks().forEach((t) => t.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
}
|
||||||
|
if (audioCtxRef.current) {
|
||||||
|
void audioCtxRef.current.close().catch(() => {});
|
||||||
|
audioCtxRef.current = null;
|
||||||
|
}
|
||||||
|
analyserRef.current = null;
|
||||||
|
setLevel(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
if (disabled) return;
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
streamRef.current = stream;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ctx = new (window.AudioContext ||
|
||||||
|
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
|
||||||
|
const source = ctx.createMediaStreamSource(stream);
|
||||||
|
const analyser = ctx.createAnalyser();
|
||||||
|
analyser.fftSize = 256;
|
||||||
|
analyser.smoothingTimeConstant = 0.4;
|
||||||
|
source.connect(analyser);
|
||||||
|
audioCtxRef.current = ctx;
|
||||||
|
analyserRef.current = analyser;
|
||||||
|
} catch {
|
||||||
|
/* Analyser is best-effort; recording still works without level meter. */
|
||||||
|
}
|
||||||
|
|
||||||
|
const mime = pickMime();
|
||||||
|
const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
|
||||||
|
recorderRef.current = rec;
|
||||||
|
chunksRef.current = [];
|
||||||
|
cancelledRef.current = false;
|
||||||
|
|
||||||
|
rec.ondataavailable = (ev: BlobEvent) => {
|
||||||
|
if (ev.data && ev.data.size > 0) chunksRef.current.push(ev.data);
|
||||||
|
};
|
||||||
|
rec.onstop = () => {
|
||||||
|
const blob = new Blob(chunksRef.current, { type: rec.mimeType || 'audio/webm' });
|
||||||
|
chunksRef.current = [];
|
||||||
|
stopStream();
|
||||||
|
if (cancelledRef.current || blob.size === 0) {
|
||||||
|
setState('idle');
|
||||||
|
setElapsedSec(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState('finalizing');
|
||||||
|
const ext = extFor(blob.type);
|
||||||
|
const filename = 'voice-' + new Date().toISOString().replace(/[:.]/g, '-') + '.' + ext;
|
||||||
|
const file = new File([blob], filename, { type: blob.type });
|
||||||
|
void Promise.resolve(onComplete(file)).finally(() => {
|
||||||
|
setState('idle');
|
||||||
|
setElapsedSec(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
rec.start();
|
||||||
|
startedAtRef.current = Date.now();
|
||||||
|
setElapsedSec(0);
|
||||||
|
setState('recording');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
stopStream();
|
||||||
|
// Surface device-permission denials or capture failures via console;
|
||||||
|
// the composer doesn't have space for inline errors here and the
|
||||||
|
// browser already shows a system-level permission prompt.
|
||||||
|
console.error('VoiceRecorder.start failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
const rec = recorderRef.current;
|
||||||
|
if (!rec) return;
|
||||||
|
if (rec.state !== 'inactive') rec.stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
cancelledRef.current = true;
|
||||||
|
const rec = recorderRef.current;
|
||||||
|
if (rec && rec.state !== 'inactive') rec.stop();
|
||||||
|
else {
|
||||||
|
stopStream();
|
||||||
|
setState('idle');
|
||||||
|
setElapsedSec(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state === 'idle') {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void start()}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Sprachnachricht aufnehmen"
|
||||||
|
title="Sprachnachricht aufnehmen"
|
||||||
|
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-surface-2 text-fg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<MicIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'finalizing') {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex h-11 items-center gap-2 rounded-lg bg-surface-2 px-3 text-xs text-fg-muted">
|
||||||
|
<SpinnerIcon className="h-4 w-4" />
|
||||||
|
<span>Wird gesendet…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visual level: scale from 0..1 → 0..100% width. Floor at 4% so the bar
|
||||||
|
// stays visible when silent.
|
||||||
|
const levelPct = Math.max(4, Math.min(100, Math.round(level * 180)));
|
||||||
|
const remaining = Math.max(0, MAX_RECORD_SEC - elapsedSec);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex h-11 items-center gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 text-xs font-semibold text-rose-700 dark:text-rose-200">
|
||||||
|
<span className="h-2 w-2 animate-pulse rounded-full bg-rose-500" />
|
||||||
|
<span className="tabular-nums">{formatTime(elapsedSec)}</span>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="relative h-1.5 w-20 overflow-hidden rounded-full bg-rose-500/20"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute inset-y-0 left-0 rounded-full bg-rose-500 transition-[width] duration-75"
|
||||||
|
style={{ width: levelPct + '%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="tabular-nums text-[10px] text-rose-700/70 dark:text-rose-200/70">
|
||||||
|
−{remaining}s
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancel}
|
||||||
|
aria-label="Aufnahme abbrechen"
|
||||||
|
title="Abbrechen"
|
||||||
|
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||||
|
>
|
||||||
|
<XIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={confirm}
|
||||||
|
aria-label="Aufnahme senden"
|
||||||
|
title="Senden"
|
||||||
|
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md bg-accent text-accent-fg transition hover:brightness-110"
|
||||||
|
>
|
||||||
|
<MicIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickMime(): string | null {
|
||||||
|
const candidates = [
|
||||||
|
'audio/webm;codecs=opus',
|
||||||
|
'audio/webm',
|
||||||
|
'audio/ogg;codecs=opus',
|
||||||
|
'audio/mp4',
|
||||||
|
];
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(c)) return c;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extFor(mime: string): string {
|
||||||
|
if (mime.includes('webm')) return 'webm';
|
||||||
|
if (mime.includes('ogg')) return 'ogg';
|
||||||
|
if (mime.includes('mp4')) return 'm4a';
|
||||||
|
return 'bin';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
return m + ':' + s.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
@@ -401,6 +401,16 @@ export function CrownIcon(props: IconProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function MusicIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<path d="M9 18V5l12-2v13" />
|
||||||
|
<circle cx="6" cy="18" r="3" />
|
||||||
|
<circle cx="18" cy="16" r="3" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SendIcon(props: IconProps) {
|
export function SendIcon(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
@@ -451,6 +461,28 @@ export function MoreVerticalIcon(props: IconProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function HeadphonesIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<path d="M3 14v-2a9 9 0 0 1 18 0v2" />
|
||||||
|
<path d="M3 14h4v7H5a2 2 0 0 1-2-2Z" />
|
||||||
|
<path d="M21 14h-4v7h2a2 2 0 0 0 2-2Z" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeadphonesOffIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<path d="M3 14v-2a9 9 0 0 1 11.3-8.7" />
|
||||||
|
<path d="M21 14v-2a9 9 0 0 0-1.5-5" />
|
||||||
|
<path d="M3 14h4v7H5a2 2 0 0 1-2-2Z" />
|
||||||
|
<path d="M21 14h-4v7h2a2 2 0 0 0 2-2Z" />
|
||||||
|
<path d="M3 3 21 21" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ReplyIcon(props: IconProps) {
|
export function ReplyIcon(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
@@ -487,86 +519,53 @@ export function AddUserIcon(props: IconProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Soft-N mark: rounded-square violet tile with a stylised "N" glyph. Used
|
||||||
|
// wherever the app needs a standalone icon (sidebar rail, auth screen,
|
||||||
|
// favicon). Colour decisions sit inside the SVG so consumers just size the
|
||||||
|
// element via `className`.
|
||||||
export function LogoMark(props: IconProps) {
|
export function LogoMark(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 64 64"
|
viewBox="0 0 64 64"
|
||||||
fill="none"
|
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<defs>
|
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa" />
|
||||||
<clipPath id="logo-hex-clip">
|
<path
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" />
|
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"
|
||||||
</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"
|
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#a78bfa"
|
stroke="#fff"
|
||||||
strokeWidth="1.5"
|
strokeWidth="5"
|
||||||
opacity="0.4"
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full lockup: hex icon + "Netralax" wordmark. `tone` decides text colour:
|
// Full lockup: soft-N mark + "Netralax" wordmark. `tone` decides text colour:
|
||||||
// "dark" = white text (use on dark background), "light" = black text.
|
// "dark" = white text (use on dark background), "light" = near-black.
|
||||||
export function LogoLockup({
|
export function LogoLockup({
|
||||||
tone = 'dark',
|
tone = 'dark',
|
||||||
...props
|
...props
|
||||||
}: IconProps & { tone?: 'dark' | 'light' }) {
|
}: IconProps & { tone?: 'dark' | 'light' }) {
|
||||||
const textFill = tone === 'dark' ? '#ffffff' : '#0F172A';
|
const textFill = tone === 'dark' ? '#ffffff' : '#14121c';
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 260 64"
|
viewBox="0 0 260 64"
|
||||||
fill="none"
|
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<defs>
|
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa" />
|
||||||
<clipPath id="logo-lockup-hex-clip">
|
<path
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" />
|
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"
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065" />
|
|
||||||
<g clipPath="url(#logo-lockup-hex-clip)">
|
|
||||||
<path
|
|
||||||
d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z"
|
|
||||||
fill="#7c4dff"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M-4 32 Q 16 18 32 32 T 68 32"
|
|
||||||
stroke="#a78bfa"
|
|
||||||
strokeWidth="2"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<polygon
|
|
||||||
points="32,5 57,19 57,45 32,59 7,45 7,19"
|
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#a78bfa"
|
stroke="#fff"
|
||||||
strokeWidth="1.5"
|
strokeWidth="5"
|
||||||
opacity="0.4"
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
x="78"
|
x="78"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
getOwnProfile,
|
getOwnProfile,
|
||||||
signOut as supabaseSignOut,
|
signOut as supabaseSignOut,
|
||||||
type Profile,
|
type Profile,
|
||||||
|
updateOwnProfile,
|
||||||
} from '@chat-app/shared/auth';
|
} from '@chat-app/shared/auth';
|
||||||
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
|
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
|
||||||
import type { Session } from '@supabase/supabase-js';
|
import type { Session } from '@supabase/supabase-js';
|
||||||
@@ -20,6 +21,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { findExistingDevice } from '../lib/device';
|
import { findExistingDevice } from '../lib/device';
|
||||||
import { setSecretStoreUser } from '../lib/secretStore';
|
import { setSecretStoreUser } from '../lib/secretStore';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { registerWebPush } from '../lib/webPush';
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
@@ -135,6 +137,50 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
}, [session, refreshProfile, refreshDevice]);
|
}, [session, refreshProfile, refreshDevice]);
|
||||||
|
|
||||||
|
// Best-effort web-push registration once we know the device id. No-op on
|
||||||
|
// Tauri (uses native notifications) or when VITE_VAPID_PUBLIC_KEY is unset.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!device?.id) return;
|
||||||
|
void registerWebPush(device.id);
|
||||||
|
}, [device?.id]);
|
||||||
|
|
||||||
|
// Auto online/offline transition.
|
||||||
|
//
|
||||||
|
// - On mount with a session whose last persisted state is `offline`, flip
|
||||||
|
// to `online`. We never override an explicit `idle`, `dnd`, or
|
||||||
|
// `invisible` choice — those are user intent.
|
||||||
|
// - On `pagehide` / `beforeunload`, fire a best-effort update to
|
||||||
|
// `offline`. Browsers don't guarantee delivery during unload, but the
|
||||||
|
// request usually slips through; the next page load corrects state if it
|
||||||
|
// didn't.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session || !profile) return;
|
||||||
|
if (profile.presenceState === 'offline') {
|
||||||
|
void updateOwnProfile(supabase, { presenceState: 'online' })
|
||||||
|
.then(() => refreshProfile())
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.warn('auto online flip failed', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const onLeave = () => {
|
||||||
|
// Skip if user explicitly chose a non-online state — they probably
|
||||||
|
// want to look unavailable on next reconnect too.
|
||||||
|
if (
|
||||||
|
profile.presenceState !== 'online' &&
|
||||||
|
profile.presenceState !== 'offline'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void updateOwnProfile(supabase, { presenceState: 'offline' }).catch(() => {});
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', onLeave);
|
||||||
|
window.addEventListener('pagehide', onLeave);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', onLeave);
|
||||||
|
window.removeEventListener('pagehide', onLeave);
|
||||||
|
};
|
||||||
|
}, [session, profile, refreshProfile]);
|
||||||
|
|
||||||
const signOut = useCallback(async () => {
|
const signOut = useCallback(async () => {
|
||||||
await supabaseSignOut(supabase);
|
await supabaseSignOut(supabase);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
import { useAuth } from './AuthContext';
|
import { useAuth } from './AuthContext';
|
||||||
import { useConversationsContext } from './ConversationsContext';
|
import { useConversationsContext } from './ConversationsContext';
|
||||||
import { playEndBeep, playJoinBeep, playLeaveBeep } from '../lib/callSounds';
|
import { playEndBeep, playJoinBeep, playLeaveBeep } from '../lib/callSounds';
|
||||||
|
import { setCallWakeLock } from '../lib/wakeLock';
|
||||||
import { notify } from '../lib/osNotify';
|
import { notify } from '../lib/osNotify';
|
||||||
import {
|
import {
|
||||||
isTauriRuntime,
|
isTauriRuntime,
|
||||||
@@ -40,16 +41,33 @@ import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
|
|||||||
import {
|
import {
|
||||||
getAudioQualityParams,
|
getAudioQualityParams,
|
||||||
getAudioSettings,
|
getAudioSettings,
|
||||||
|
subscribeAudioSettings,
|
||||||
updateAudioSettings,
|
updateAudioSettings,
|
||||||
} from '../lib/audioSettings';
|
} from '../lib/audioSettings';
|
||||||
|
import {
|
||||||
|
applyBackgroundBlurToLocal,
|
||||||
|
removeBackgroundBlurFromLocal,
|
||||||
|
} from '../lib/videoBlur';
|
||||||
import {
|
import {
|
||||||
createCallE2EE,
|
createCallE2EE,
|
||||||
getCallE2EESettings,
|
getCallE2EESettings,
|
||||||
isE2EESupported,
|
isE2EESupported,
|
||||||
} from '../lib/callE2EE';
|
} from '../lib/callE2EE';
|
||||||
|
import { createMicPipeline, type MicPipeline } from '../lib/micPipeline';
|
||||||
|
import { getParticipantVolume } from '../lib/participantVolumes';
|
||||||
|
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
|
||||||
|
import { playEntry } from '../lib/soundboardPlayback';
|
||||||
import {
|
import {
|
||||||
|
getPrefs as getSoundboardPrefs,
|
||||||
|
listSounds as listSoundboard,
|
||||||
|
updatePrefs as updateSoundboardPrefs,
|
||||||
|
} from '../lib/soundboardStorage';
|
||||||
|
import {
|
||||||
|
type DisplaySurfaceHint,
|
||||||
getPresetParams,
|
getPresetParams,
|
||||||
getScreenShareSettings,
|
getScreenShareSettings,
|
||||||
|
type ScreenSharePreset,
|
||||||
|
updateScreenShareSettings,
|
||||||
} from '../lib/screenShareSettings';
|
} from '../lib/screenShareSettings';
|
||||||
import { devLocalSecretStore } from '../lib/secretStore';
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
@@ -102,6 +120,15 @@ interface CallContextValue {
|
|||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
isE2EEActive: boolean;
|
isE2EEActive: boolean;
|
||||||
isScreenSharing: boolean;
|
isScreenSharing: boolean;
|
||||||
|
isCameraEnabled: boolean;
|
||||||
|
isDeafened: boolean;
|
||||||
|
/** identity -> their deafen state, received via data channel. */
|
||||||
|
remoteDeafen: Record<string, boolean>;
|
||||||
|
/** identity -> mute state. Broadcast from peer whenever mic-gain flips.
|
||||||
|
* Needed because we can't rely on LiveKit's native isMicrophoneEnabled —
|
||||||
|
* the mic pipeline keeps the track published with sound flowing even
|
||||||
|
* while the mic path is gain-silenced, so LK never sees "muted". */
|
||||||
|
remoteMute: Record<string, boolean>;
|
||||||
// Zero or more remote screen shares (LiveKit supports multiple simultaneous).
|
// Zero or more remote screen shares (LiveKit supports multiple simultaneous).
|
||||||
remoteScreenShares: RemoteScreenShare[];
|
remoteScreenShares: RemoteScreenShare[];
|
||||||
// Remembers the conversation of the last call we left so a sidebar widget
|
// Remembers the conversation of the last call we left so a sidebar widget
|
||||||
@@ -113,14 +140,37 @@ interface CallContextValue {
|
|||||||
// Actions:
|
// Actions:
|
||||||
startCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
|
startCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
|
||||||
joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
|
joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
|
||||||
acceptIncoming: () => Promise<void>;
|
acceptIncoming: (override?: CallKind) => Promise<void>;
|
||||||
rejectIncoming: () => void;
|
rejectIncoming: () => void;
|
||||||
hangup: () => Promise<void>;
|
hangup: () => Promise<void>;
|
||||||
toggleMute: () => void;
|
toggleMute: () => void;
|
||||||
toggleScreenShare: () => Promise<void>;
|
toggleScreenShare: () => Promise<void>;
|
||||||
|
startScreenShare: (
|
||||||
|
overrides?: Partial<{
|
||||||
|
preset: ScreenSharePreset;
|
||||||
|
displaySurface: DisplaySurfaceHint;
|
||||||
|
framerate: number | null;
|
||||||
|
}>,
|
||||||
|
) => Promise<void>;
|
||||||
|
stopScreenShare: () => Promise<void>;
|
||||||
|
toggleCamera: () => Promise<void>;
|
||||||
|
toggleDeafen: () => void;
|
||||||
dismissLastCall: () => void;
|
dismissLastCall: () => void;
|
||||||
setCallMode: (mode: CallMode) => void;
|
setCallMode: (mode: CallMode) => void;
|
||||||
setFocusedId: (id: string | null) => void;
|
setFocusedId: (id: string | null) => void;
|
||||||
|
/** Play a soundboard entry through the active call's mic pipeline.
|
||||||
|
* No-op when not connected. Default single-fire per id (spamming the
|
||||||
|
* hotkey cuts the previous instance); set overlap=true to layer. */
|
||||||
|
playSoundboard: (id: string, opts?: { overlap?: boolean }) => Promise<void>;
|
||||||
|
/** Stop every active sb source, or just the one matching `id` if given. */
|
||||||
|
stopSoundboard: (id?: string) => void;
|
||||||
|
/** Ids of soundboard entries currently emitting audio. Updated live so
|
||||||
|
* the in-call panel can show a stop icon on active pads. */
|
||||||
|
activeSoundboardIds: ReadonlySet<string>;
|
||||||
|
/** Apply new sb master/monitor gains to the live pipeline. Persists via
|
||||||
|
* updatePrefs in the storage module. */
|
||||||
|
setSoundboardMasterGain: (value: number) => Promise<void>;
|
||||||
|
setSoundboardMonitorGain: (value: number) => Promise<void>;
|
||||||
// Runtime mic switch. Persists choice so subsequent calls reuse it, and
|
// Runtime mic switch. Persists choice so subsequent calls reuse it, and
|
||||||
// hot-swaps the input on an active call without a reconnect.
|
// hot-swaps the input on an active call without a reconnect.
|
||||||
setAudioInputDevice: (deviceId: string | null) => Promise<void>;
|
setAudioInputDevice: (deviceId: string | null) => Promise<void>;
|
||||||
@@ -139,7 +189,7 @@ function newCallId(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CallProvider({ children }: { children: ReactNode }) {
|
export function CallProvider({ children }: { children: ReactNode }) {
|
||||||
const { session, device } = useAuth();
|
const { session, device, profile } = useAuth();
|
||||||
const { conversations } = useConversationsContext();
|
const { conversations } = useConversationsContext();
|
||||||
const myId = session?.user.id;
|
const myId = session?.user.id;
|
||||||
|
|
||||||
@@ -149,16 +199,24 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
const [isE2EEActive, setIsE2EEActive] = useState(false);
|
const [isE2EEActive, setIsE2EEActive] = useState(false);
|
||||||
const [isScreenSharing, setIsScreenSharing] = useState(false);
|
const [isScreenSharing, setIsScreenSharing] = useState(false);
|
||||||
|
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
|
||||||
|
const [isDeafened, setIsDeafened] = useState(false);
|
||||||
const [remoteScreenShares, setRemoteScreenShares] = useState<RemoteScreenShare[]>([]);
|
const [remoteScreenShares, setRemoteScreenShares] = useState<RemoteScreenShare[]>([]);
|
||||||
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
|
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
|
||||||
const [callMode, setCallModeState] = useState<CallMode>('grid');
|
const [callMode, setCallModeState] = useState<CallMode>('grid');
|
||||||
const [focusedId, setFocusedIdState] = useState<string | null>(null);
|
const [focusedId, setFocusedIdState] = useState<string | null>(null);
|
||||||
|
const [activeSoundboardIds, setActiveSoundboardIds] = useState<ReadonlySet<string>>(
|
||||||
|
() => new Set<string>(),
|
||||||
|
);
|
||||||
|
|
||||||
const signalChannelRef = useRef<RealtimeChannel | null>(null);
|
const signalChannelRef = useRef<RealtimeChannel | null>(null);
|
||||||
const presenceChannelRef = useRef<RealtimeChannel | null>(null);
|
const presenceChannelRef = useRef<RealtimeChannel | null>(null);
|
||||||
const ringTimerRef = useRef<number | null>(null);
|
const ringTimerRef = useRef<number | null>(null);
|
||||||
const soloTimerRef = useRef<number | null>(null);
|
const soloTimerRef = useRef<number | null>(null);
|
||||||
const roomRef = useRef<Room | null>(null);
|
const roomRef = useRef<Room | null>(null);
|
||||||
|
// Web Audio graph that mixes live mic + soundboard sources into a single
|
||||||
|
// published track. Created per call in joinRoom, destroyed in disconnectRoom.
|
||||||
|
const pipelineRef = useRef<MicPipeline | null>(null);
|
||||||
// Tracks whether the current call was ever in the connected state — needed
|
// Tracks whether the current call was ever in the connected state — needed
|
||||||
// so hangup/solo-timeout can emit a real duration message vs. "missed".
|
// so hangup/solo-timeout can emit a real duration message vs. "missed".
|
||||||
const everConnectedRef = useRef<boolean>(false);
|
const everConnectedRef = useRef<boolean>(false);
|
||||||
@@ -167,12 +225,25 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
// For 1:1 calls the set has one entry so behaviour is unchanged; for
|
// For 1:1 calls the set has one entry so behaviour is unchanged; for
|
||||||
// groups, a single reject doesn't terminate the call while others ring.
|
// groups, a single reject doesn't terminate the call while others ring.
|
||||||
const pendingPeersRef = useRef<Set<string>>(new Set());
|
const pendingPeersRef = useRef<Set<string>>(new Set());
|
||||||
|
// identity -> their current deafen state. Populated via LiveKit data
|
||||||
|
// channel messages ({ type: 'presence', deafened: bool }). Exposed as
|
||||||
|
// state so consumer components re-render on change.
|
||||||
|
const [remoteDeafen, setRemoteDeafen] = useState<Record<string, boolean>>({});
|
||||||
|
const [remoteMute, setRemoteMute] = useState<Record<string, boolean>>({});
|
||||||
|
// Mirror of isMuted for use inside LiveKit-event callbacks that run outside
|
||||||
|
// the React component (ParticipantConnected rebroadcast etc).
|
||||||
|
const mutedRef = useRef<boolean>(false);
|
||||||
const stateRef = useRef<CallState>(state);
|
const stateRef = useRef<CallState>(state);
|
||||||
stateRef.current = state;
|
stateRef.current = state;
|
||||||
// Keep latest conversations accessible from signal-channel closures without
|
// Keep latest conversations accessible from signal-channel closures without
|
||||||
// re-subscribing the channel on every conversations update.
|
// re-subscribing the channel on every conversations update.
|
||||||
const conversationsRef = useRef(conversations);
|
const conversationsRef = useRef(conversations);
|
||||||
conversationsRef.current = conversations;
|
conversationsRef.current = conversations;
|
||||||
|
// Mirror own presence state for use inside signal-channel callbacks. DND
|
||||||
|
// suppresses incoming-call OS notifications (ringtone is handled in CallUI
|
||||||
|
// which has direct access to the auth profile).
|
||||||
|
const presenceRef = useRef(profile?.presenceState ?? 'offline');
|
||||||
|
presenceRef.current = profile?.presenceState ?? 'offline';
|
||||||
|
|
||||||
// --- Helpers -----------------------------------------------------------
|
// --- Helpers -----------------------------------------------------------
|
||||||
|
|
||||||
@@ -243,8 +314,29 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
setRemoteParticipants([]);
|
setRemoteParticipants([]);
|
||||||
setRemoteScreenShares([]);
|
setRemoteScreenShares([]);
|
||||||
setIsScreenSharing(false);
|
setIsScreenSharing(false);
|
||||||
|
setIsCameraEnabled(false);
|
||||||
|
setIsDeafened(false);
|
||||||
|
deafenedActive = false;
|
||||||
|
setRemoteDeafen({});
|
||||||
|
setRemoteMute({});
|
||||||
|
setIsMuted(false);
|
||||||
|
mutedRef.current = false;
|
||||||
setIsE2EEActive(false);
|
setIsE2EEActive(false);
|
||||||
|
|
||||||
|
// Tear down the mic pipeline AFTER LiveKit disconnects so the published
|
||||||
|
// track is unpublished cleanly first; then close AudioContext + stop
|
||||||
|
// raw mic + output tracks we own.
|
||||||
|
const pipeline = pipelineRef.current;
|
||||||
|
if (pipeline) {
|
||||||
|
try {
|
||||||
|
pipeline.destroy();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
pipelineRef.current = null;
|
||||||
|
}
|
||||||
|
setActiveSoundboardIds(new Set<string>());
|
||||||
|
|
||||||
const pres = presenceChannelRef.current;
|
const pres = presenceChannelRef.current;
|
||||||
if (pres) {
|
if (pres) {
|
||||||
try {
|
try {
|
||||||
@@ -406,14 +498,14 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
r.on(RoomEvent.ParticipantConnected, () => {
|
r.on(RoomEvent.ParticipantConnected, () => {
|
||||||
setRemoteParticipants(Array.from(r.remoteParticipants.values()));
|
setRemoteParticipants(Array.from(r.remoteParticipants.values()));
|
||||||
void playJoinBeep();
|
if (presenceRef.current !== 'dnd') void playJoinBeep();
|
||||||
markConnectedIfReady(r, conversationId, mediaKind, callId);
|
markConnectedIfReady(r, conversationId, mediaKind, callId);
|
||||||
});
|
});
|
||||||
|
|
||||||
r.on(RoomEvent.ParticipantDisconnected, () => {
|
r.on(RoomEvent.ParticipantDisconnected, () => {
|
||||||
const remaining = Array.from(r.remoteParticipants.values());
|
const remaining = Array.from(r.remoteParticipants.values());
|
||||||
setRemoteParticipants(remaining);
|
setRemoteParticipants(remaining);
|
||||||
void playLeaveBeep();
|
if (presenceRef.current !== 'dnd') void playLeaveBeep();
|
||||||
// Alone in the room while connected — start the solo-timeout.
|
// Alone in the room while connected — start the solo-timeout.
|
||||||
if (
|
if (
|
||||||
stateRef.current.kind === 'connected' &&
|
stateRef.current.kind === 'connected' &&
|
||||||
@@ -463,6 +555,58 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mute/unmute a camera doesn't publish or unpublish — the publication
|
||||||
|
// stays, just its `muted` flag flips. Without this listener, remote
|
||||||
|
// participants who toggle video mid-call appear as a frozen last frame
|
||||||
|
// or (worse) a black tile on every other client. Bumping the
|
||||||
|
// remoteParticipants state reference forces buildTiles to re-read
|
||||||
|
// `isCameraEnabled` and swap to the avatar placeholder.
|
||||||
|
const bumpParticipants = () => {
|
||||||
|
setRemoteParticipants(Array.from(r.remoteParticipants.values()));
|
||||||
|
};
|
||||||
|
r.on(RoomEvent.TrackMuted, bumpParticipants);
|
||||||
|
r.on(RoomEvent.TrackUnmuted, bumpParticipants);
|
||||||
|
// Remote deafen state is broadcast via the LiveKit data channel. We
|
||||||
|
// store incoming states in `remoteDeafenMapRef` and bump participants
|
||||||
|
// so buildTiles re-reads it.
|
||||||
|
r.on(
|
||||||
|
RoomEvent.DataReceived,
|
||||||
|
(payload: Uint8Array, participant?: RemoteParticipant | undefined) => {
|
||||||
|
if (!participant?.identity) return;
|
||||||
|
try {
|
||||||
|
const text = new TextDecoder().decode(payload);
|
||||||
|
const msg = JSON.parse(text) as {
|
||||||
|
type?: string;
|
||||||
|
deafened?: boolean;
|
||||||
|
muted?: boolean;
|
||||||
|
};
|
||||||
|
if (msg.type !== 'presence') return;
|
||||||
|
const id: string = participant.identity;
|
||||||
|
if (typeof msg.deafened === 'boolean') {
|
||||||
|
const deafened: boolean = msg.deafened;
|
||||||
|
setRemoteDeafen((prev) => {
|
||||||
|
if (prev[id] === deafened) return prev;
|
||||||
|
return { ...prev, [id]: deafened };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof msg.muted === 'boolean') {
|
||||||
|
const muted: boolean = msg.muted;
|
||||||
|
setRemoteMute((prev) => {
|
||||||
|
if (prev[id] === muted) return prev;
|
||||||
|
return { ...prev, [id]: muted };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// When someone joins, re-send our current presence (deafen + mute) so
|
||||||
|
// they know immediately instead of waiting for the next toggle.
|
||||||
|
r.on(RoomEvent.ParticipantConnected, () => {
|
||||||
|
void broadcastPresence(r, deafenedActive, mutedRef.current);
|
||||||
|
});
|
||||||
|
|
||||||
// Track my own screen-share state via LocalTrack events so the toggle
|
// Track my own screen-share state via LocalTrack events so the toggle
|
||||||
// stays in sync if the user stops sharing via the browser's native UI.
|
// stays in sync if the user stops sharing via the browser's native UI.
|
||||||
r.on(RoomEvent.LocalTrackPublished, (publication) => {
|
r.on(RoomEvent.LocalTrackPublished, (publication) => {
|
||||||
@@ -489,23 +633,53 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsE2EEActive(false);
|
setIsE2EEActive(false);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const inputId = getAudioSettings().inputDeviceId;
|
const audioPrefs = getAudioSettings();
|
||||||
await r.localParticipant.setMicrophoneEnabled(true, {
|
const inputId = audioPrefs.inputDeviceId;
|
||||||
echoCancellation: aParams.echoCancellation,
|
// Noise suppression: user-preference wins over the quality preset so
|
||||||
noiseSuppression: aParams.noiseSuppression,
|
// hifi-mode users can still enable NS when they need to cut room hum.
|
||||||
autoGainControl: aParams.autoGainControl,
|
const nsEffective = audioPrefs.noiseSuppression && aParams.noiseSuppression;
|
||||||
channelCount: aParams.stereo ? 2 : 1,
|
// Grab the raw mic ourselves instead of going through LiveKit's
|
||||||
sampleRate: aParams.sampleRateHz,
|
// setMicrophoneEnabled. The resulting MediaStreamTrack is routed
|
||||||
// Plain string maps to `ideal` — if the device is gone we fall back
|
// through createMicPipeline, which mixes in soundboard buffers and
|
||||||
// to OS default instead of throwing NotFoundError.
|
// exposes a single output track we hand to publishTrack. Mute / PTT
|
||||||
...(inputId ? { deviceId: inputId } : {}),
|
// are gain-based from here on, never track.enabled or device stop.
|
||||||
|
const rawStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
echoCancellation: aParams.echoCancellation,
|
||||||
|
noiseSuppression: nsEffective,
|
||||||
|
autoGainControl: aParams.autoGainControl,
|
||||||
|
channelCount: aParams.stereo ? 2 : 1,
|
||||||
|
sampleRate: aParams.sampleRateHz,
|
||||||
|
...(inputId ? { deviceId: { ideal: inputId } } : {}),
|
||||||
|
},
|
||||||
|
video: false,
|
||||||
|
});
|
||||||
|
const rawTrack = rawStream.getAudioTracks()[0];
|
||||||
|
if (!rawTrack) throw new Error('no audio track from getUserMedia');
|
||||||
|
const pipeline = createMicPipeline(rawTrack);
|
||||||
|
pipelineRef.current = pipeline;
|
||||||
|
// Pull the user's last-saved soundboard gains onto the live pipeline
|
||||||
|
// before the first sound ever plays so nothing blasts at 100%.
|
||||||
|
try {
|
||||||
|
const prefs = await getSoundboardPrefs();
|
||||||
|
pipeline.setSoundboardGain(prefs.masterGain);
|
||||||
|
pipeline.setMonitorGain(prefs.monitorGain);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('getSoundboardPrefs failed', err);
|
||||||
|
}
|
||||||
|
await r.localParticipant.publishTrack(pipeline.outputTrack, {
|
||||||
|
source: Track.Source.Microphone,
|
||||||
|
red: true,
|
||||||
|
dtx: aParams.stereo ? false : true,
|
||||||
|
forceStereo: aParams.stereo,
|
||||||
});
|
});
|
||||||
} catch (micErr: unknown) {
|
} catch (micErr: unknown) {
|
||||||
console.error('setMicrophoneEnabled failed', micErr);
|
console.error('mic pipeline setup failed', micErr);
|
||||||
}
|
}
|
||||||
if (mediaKind === 'video') {
|
if (mediaKind === 'video') {
|
||||||
try {
|
try {
|
||||||
await r.localParticipant.setCameraEnabled(true);
|
await r.localParticipant.setCameraEnabled(true);
|
||||||
|
setIsCameraEnabled(true);
|
||||||
} catch (camErr: unknown) {
|
} catch (camErr: unknown) {
|
||||||
console.error('setCameraEnabled failed', camErr);
|
console.error('setCameraEnabled failed', camErr);
|
||||||
}
|
}
|
||||||
@@ -650,23 +824,31 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
[myId, joinRoom, disconnectRoom],
|
[myId, joinRoom, disconnectRoom],
|
||||||
);
|
);
|
||||||
|
|
||||||
const acceptIncoming = useCallback(async () => {
|
const acceptIncoming = useCallback(
|
||||||
const s = stateRef.current;
|
async (override?: CallKind) => {
|
||||||
if (s.kind !== 'incoming' || !myId) return;
|
const s = stateRef.current;
|
||||||
const { callId, conversationId, mediaKind } = s;
|
if (s.kind !== 'incoming' || !myId) return;
|
||||||
everConnectedRef.current = false;
|
const { callId, conversationId } = s;
|
||||||
setLastCallConversationId(null);
|
// Caller's `mediaKind` is the INVITE kind (what they started with). The
|
||||||
setState({ kind: 'connecting', callId, conversationId, mediaKind });
|
// receiver can accept with audio even if the caller rang as video, or
|
||||||
try {
|
// upgrade an audio invite to video on accept. `override` picks the
|
||||||
await joinRoom(conversationId, mediaKind, callId);
|
// receiver's choice.
|
||||||
} catch (err: unknown) {
|
const mediaKind: CallKind = override ?? s.mediaKind;
|
||||||
setState({
|
everConnectedRef.current = false;
|
||||||
kind: 'error',
|
setLastCallConversationId(null);
|
||||||
message: err instanceof Error ? err.message : 'join failed',
|
setState({ kind: 'connecting', callId, conversationId, mediaKind });
|
||||||
});
|
try {
|
||||||
await disconnectRoom();
|
await joinRoom(conversationId, mediaKind, callId);
|
||||||
}
|
} catch (err: unknown) {
|
||||||
}, [myId, joinRoom, disconnectRoom]);
|
setState({
|
||||||
|
kind: 'error',
|
||||||
|
message: err instanceof Error ? err.message : 'join failed',
|
||||||
|
});
|
||||||
|
await disconnectRoom();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[myId, joinRoom, disconnectRoom],
|
||||||
|
);
|
||||||
|
|
||||||
const rejectIncoming = useCallback(() => {
|
const rejectIncoming = useCallback(() => {
|
||||||
const s = stateRef.current;
|
const s = stateRef.current;
|
||||||
@@ -731,45 +913,167 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleMute = useCallback(() => {
|
const toggleMute = useCallback(() => {
|
||||||
const r = roomRef.current;
|
const pipeline = pipelineRef.current;
|
||||||
if (!r) return;
|
if (!pipeline) return;
|
||||||
const lp = r.localParticipant;
|
setIsMuted((prev) => {
|
||||||
const shouldEnable = !lp.isMicrophoneEnabled;
|
const nextMuted = !prev;
|
||||||
void lp.setMicrophoneEnabled(shouldEnable).then(() => {
|
pipeline.setMicGain(nextMuted ? 0 : 1);
|
||||||
setIsMuted(!shouldEnable);
|
mutedRef.current = nextMuted;
|
||||||
|
const r = roomRef.current;
|
||||||
|
if (r) void broadcastPresence(r, deafenedActive, nextMuted);
|
||||||
|
return nextMuted;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleScreenShare = useCallback(async () => {
|
const startScreenShare = useCallback(
|
||||||
|
async (
|
||||||
|
overrides?: Partial<{
|
||||||
|
preset: ScreenSharePreset;
|
||||||
|
displaySurface: DisplaySurfaceHint;
|
||||||
|
framerate: number | null;
|
||||||
|
}>,
|
||||||
|
) => {
|
||||||
|
const r = roomRef.current;
|
||||||
|
if (!r) return;
|
||||||
|
const lp = r.localParticipant;
|
||||||
|
if (lp.isScreenShareEnabled) return;
|
||||||
|
|
||||||
|
// Persist the user's choice so subsequent shares use the same config
|
||||||
|
// without re-opening the picker unless they want to change something.
|
||||||
|
const settings = getScreenShareSettings();
|
||||||
|
const preset = overrides?.preset ?? settings.preset;
|
||||||
|
const displaySurface =
|
||||||
|
overrides?.displaySurface !== undefined
|
||||||
|
? overrides.displaySurface
|
||||||
|
: settings.displaySurface;
|
||||||
|
const framerateOverride =
|
||||||
|
overrides?.framerate !== undefined
|
||||||
|
? overrides.framerate
|
||||||
|
: settings.framerateOverride;
|
||||||
|
updateScreenShareSettings({ preset, displaySurface, framerateOverride });
|
||||||
|
|
||||||
|
const ssParams = getPresetParams(preset);
|
||||||
|
const fps = framerateOverride ?? ssParams.framerate;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await lp.setScreenShareEnabled(true, {
|
||||||
|
// "Go live" mode — capture system audio alongside the screen when
|
||||||
|
// the user opted in. On hosts that can't fulfil the request the
|
||||||
|
// browser quietly drops it; peers just get video-only, no error.
|
||||||
|
audio: settings.includeSystemAudio,
|
||||||
|
...(ssParams.dims
|
||||||
|
? {
|
||||||
|
resolution: {
|
||||||
|
width: ssParams.dims.width,
|
||||||
|
height: ssParams.dims.height,
|
||||||
|
frameRate: fps,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
resolution: {
|
||||||
|
width: 3840,
|
||||||
|
height: 2160,
|
||||||
|
frameRate: fps,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// Hints the OS picker to pre-filter by source kind. `null` = no
|
||||||
|
// filter (show both). Cast because TS lib.dom doesn't know the
|
||||||
|
// field yet on all branches.
|
||||||
|
...(displaySurface
|
||||||
|
? ({ displaySurface } as { displaySurface: DisplaySurfaceHint })
|
||||||
|
: {}),
|
||||||
|
contentHint: 'detail',
|
||||||
|
});
|
||||||
|
setIsScreenSharing(true);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('setScreenShareEnabled failed', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopScreenShare = useCallback(async () => {
|
||||||
const r = roomRef.current;
|
const r = roomRef.current;
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
const lp = r.localParticipant;
|
const lp = r.localParticipant;
|
||||||
const nextOn = !lp.isScreenShareEnabled;
|
if (!lp.isScreenShareEnabled) return;
|
||||||
try {
|
try {
|
||||||
const ssParams = getPresetParams(getScreenShareSettings().preset);
|
await lp.setScreenShareEnabled(false);
|
||||||
await lp.setScreenShareEnabled(nextOn, {
|
setIsScreenSharing(false);
|
||||||
audio: false,
|
|
||||||
// Omitting `resolution` lets the browser return native source size —
|
|
||||||
// best possible input quality. Fixed presets pass explicit dims so
|
|
||||||
// the encoder has a predictable target.
|
|
||||||
...(ssParams.dims
|
|
||||||
? {
|
|
||||||
resolution: {
|
|
||||||
width: ssParams.dims.width,
|
|
||||||
height: ssParams.dims.height,
|
|
||||||
frameRate: ssParams.framerate,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
contentHint: 'detail',
|
|
||||||
});
|
|
||||||
setIsScreenSharing(nextOn);
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('setScreenShareEnabled failed', err);
|
console.error('stopScreenShare failed', err);
|
||||||
// User cancelled or permission denied — leave state as-is.
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Legacy toggle kept for convenience elsewhere — opens/closes with the
|
||||||
|
// last-persisted settings and no picker UI.
|
||||||
|
const toggleScreenShare = useCallback(async () => {
|
||||||
|
const r = roomRef.current;
|
||||||
|
if (!r) return;
|
||||||
|
if (r.localParticipant.isScreenShareEnabled) {
|
||||||
|
await stopScreenShare();
|
||||||
|
} else {
|
||||||
|
await startScreenShare();
|
||||||
|
}
|
||||||
|
}, [startScreenShare, stopScreenShare]);
|
||||||
|
|
||||||
|
const toggleDeafen = useCallback(() => {
|
||||||
|
setIsDeafened((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
deafenedActive = next;
|
||||||
|
// Apply to every currently-attached remote-audio element. Fresh tracks
|
||||||
|
// that attach during a deafened session are muted in attachTrack above.
|
||||||
|
const els = document.querySelectorAll<HTMLAudioElement>(
|
||||||
|
'audio[data-livekit-track]',
|
||||||
|
);
|
||||||
|
els.forEach((el) => {
|
||||||
|
el.muted = next;
|
||||||
|
});
|
||||||
|
// Broadcast via LiveKit data channel so peers' UIs can show the
|
||||||
|
// headphones-off badge. Data channel works on any LiveKit server
|
||||||
|
// version, unlike `setAttributes` which requires a newer server.
|
||||||
|
const r = roomRef.current;
|
||||||
|
if (r) void broadcastPresence(r, next, mutedRef.current);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleCamera = useCallback(async () => {
|
||||||
|
const r = roomRef.current;
|
||||||
|
if (!r) return;
|
||||||
|
const lp = r.localParticipant;
|
||||||
|
const nextOn = !lp.isCameraEnabled;
|
||||||
|
try {
|
||||||
|
await lp.setCameraEnabled(nextOn);
|
||||||
|
setIsCameraEnabled(nextOn);
|
||||||
|
if (nextOn && getAudioSettings().videoBackgroundBlur) {
|
||||||
|
void applyBackgroundBlurToLocal(lp);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('setCameraEnabled failed', err);
|
||||||
|
// Permission denied / no camera — keep state in sync with actual
|
||||||
|
// publication state so the button doesn't lie.
|
||||||
|
setIsCameraEnabled(lp.isCameraEnabled);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Live-toggle the background-blur processor when the settings flag flips.
|
||||||
|
// Acquiring the MediaPipe model is deferred until first activation to
|
||||||
|
// avoid the 1.5MB download on users who never enable blur.
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribeAudioSettings((s) => {
|
||||||
|
const r = roomRef.current;
|
||||||
|
if (!r) return;
|
||||||
|
const lp = r.localParticipant;
|
||||||
|
if (!lp.isCameraEnabled) return;
|
||||||
|
if (s.videoBackgroundBlur) {
|
||||||
|
void applyBackgroundBlurToLocal(lp);
|
||||||
|
} else {
|
||||||
|
void removeBackgroundBlurFromLocal(lp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// --- Push-to-talk ------------------------------------------------------
|
// --- Push-to-talk ------------------------------------------------------
|
||||||
// While PTT is active + we're in a connected call, the mic is held off
|
// 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
|
// except while the configured key is pressed. Under Tauri we also register
|
||||||
@@ -785,9 +1089,14 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
let globalRegisteredFor: string | null = null;
|
let globalRegisteredFor: string | null = null;
|
||||||
|
|
||||||
const setMic = (on: boolean) => {
|
const setMic = (on: boolean) => {
|
||||||
|
const pipeline = pipelineRef.current;
|
||||||
|
if (!pipeline) return;
|
||||||
|
pipeline.setMicGain(on ? 1 : 0);
|
||||||
|
const nextMuted = !on;
|
||||||
|
mutedRef.current = nextMuted;
|
||||||
|
setIsMuted(nextMuted);
|
||||||
const r = roomRef.current;
|
const r = roomRef.current;
|
||||||
if (!r) return;
|
if (r) void broadcastPresence(r, deafenedActive, nextMuted);
|
||||||
void r.localParticipant.setMicrophoneEnabled(on).then(() => setIsMuted(!on));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const pressPtt = () => {
|
const pressPtt = () => {
|
||||||
@@ -916,14 +1225,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
conv?.peer?.displayName ??
|
conv?.peer?.displayName ??
|
||||||
'…';
|
'…';
|
||||||
const isGroup = conv?.type === 'group';
|
const isGroup = conv?.type === 'group';
|
||||||
void notify({
|
if (presenceRef.current !== 'dnd') {
|
||||||
title: isGroup
|
void notify({
|
||||||
? (conv?.name ?? 'Gruppenanruf')
|
title: isGroup
|
||||||
: 'Eingehender Anruf',
|
? (conv?.name ?? 'Gruppenanruf')
|
||||||
body: isGroup
|
: 'Eingehender Anruf',
|
||||||
? callerName + ' ruft die Gruppe'
|
body: isGroup
|
||||||
: callerName + ' ruft dich an',
|
? callerName + ' ruft die Gruppe'
|
||||||
});
|
: callerName + ' ruft dich an',
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'accept':
|
case 'accept':
|
||||||
@@ -964,10 +1275,12 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
conv?.members.find((m) => m.userId === cur.fromUserId)?.profile?.displayName ??
|
conv?.members.find((m) => m.userId === cur.fromUserId)?.profile?.displayName ??
|
||||||
conv?.peer?.displayName ??
|
conv?.peer?.displayName ??
|
||||||
'…';
|
'…';
|
||||||
void notify({
|
if (presenceRef.current !== 'dnd') {
|
||||||
title: 'Verpasster Anruf',
|
void notify({
|
||||||
body: callerName + ' hat aufgelegt',
|
title: 'Verpasster Anruf',
|
||||||
});
|
body: callerName + ' hat aufgelegt',
|
||||||
|
});
|
||||||
|
}
|
||||||
setState({ kind: 'idle' });
|
setState({ kind: 'idle' });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -984,16 +1297,98 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
setFocusedIdState(id);
|
setFocusedIdState(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const markActive = useCallback((id: string, on: boolean) => {
|
||||||
|
setActiveSoundboardIds((prev) => {
|
||||||
|
const has = prev.has(id);
|
||||||
|
if (on && has) return prev;
|
||||||
|
if (!on && !has) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (on) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const playSoundboard = useCallback(
|
||||||
|
async (id: string, opts?: { overlap?: boolean }) => {
|
||||||
|
const pipeline = pipelineRef.current;
|
||||||
|
if (!pipeline) return;
|
||||||
|
const entries = await listSoundboard();
|
||||||
|
const entry = entries.find((e) => e.id === id);
|
||||||
|
if (!entry) return;
|
||||||
|
const handle = await playEntry(pipeline, entry, {
|
||||||
|
...(opts?.overlap !== undefined ? { overlap: opts.overlap } : {}),
|
||||||
|
onEnded: () => markActive(id, false),
|
||||||
|
});
|
||||||
|
if (handle) markActive(id, true);
|
||||||
|
},
|
||||||
|
[markActive],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopSoundboard = useCallback(
|
||||||
|
(id?: string) => {
|
||||||
|
const pipeline = pipelineRef.current;
|
||||||
|
if (!pipeline) return;
|
||||||
|
pipeline.stopAll(id);
|
||||||
|
if (id) {
|
||||||
|
markActive(id, false);
|
||||||
|
} else {
|
||||||
|
setActiveSoundboardIds(new Set<string>());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[markActive],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setSoundboardMasterGain = useCallback(async (value: number) => {
|
||||||
|
const prefs = await updateSoundboardPrefs({ masterGain: value });
|
||||||
|
pipelineRef.current?.setSoundboardGain(prefs.masterGain);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSoundboardMonitorGain = useCallback(async (value: number) => {
|
||||||
|
const prefs = await updateSoundboardPrefs({ monitorGain: value });
|
||||||
|
pipelineRef.current?.setMonitorGain(prefs.monitorGain);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Global soundboard hotkey registration — runs only while connected so the
|
||||||
|
// OS-level shortcuts don't fire when the user is outside of a call.
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.kind !== 'connected') return;
|
||||||
|
const teardown = startSoundboardHotkeys((id) => {
|
||||||
|
void playSoundboard(id);
|
||||||
|
});
|
||||||
|
return teardown;
|
||||||
|
}, [state.kind, playSoundboard]);
|
||||||
|
|
||||||
const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
|
const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
|
||||||
updateAudioSettings({ inputDeviceId: deviceId });
|
updateAudioSettings({ inputDeviceId: deviceId });
|
||||||
const r = roomRef.current;
|
const pipeline = pipelineRef.current;
|
||||||
if (!r) return;
|
if (!pipeline) return;
|
||||||
try {
|
try {
|
||||||
// LiveKit API: `switchActiveDevice(kind, deviceId)` hot-swaps without a
|
// We own the mic track (see joinRoom pipeline setup), so LiveKit's
|
||||||
// reconnect. Pass empty string or `default` to revert to OS default.
|
// switchActiveDevice no longer applies. Fetch a new raw track with the
|
||||||
await r.switchActiveDevice('audioinput', deviceId ?? 'default');
|
// updated deviceId + the same quality constraints, then hand ownership
|
||||||
|
// to the pipeline. It disconnects the old source, stops the old track,
|
||||||
|
// and rewires micGain onto the new source — the published track stays
|
||||||
|
// stable so peers don't see a republish.
|
||||||
|
const audioPrefs = getAudioSettings();
|
||||||
|
const aParams = getAudioQualityParams(audioPrefs.quality);
|
||||||
|
const nsEffective = audioPrefs.noiseSuppression && aParams.noiseSuppression;
|
||||||
|
const newStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
echoCancellation: aParams.echoCancellation,
|
||||||
|
noiseSuppression: nsEffective,
|
||||||
|
autoGainControl: aParams.autoGainControl,
|
||||||
|
channelCount: aParams.stereo ? 2 : 1,
|
||||||
|
sampleRate: aParams.sampleRateHz,
|
||||||
|
...(deviceId ? { deviceId: { ideal: deviceId } } : {}),
|
||||||
|
},
|
||||||
|
video: false,
|
||||||
|
});
|
||||||
|
const newTrack = newStream.getAudioTracks()[0];
|
||||||
|
if (!newTrack) throw new Error('no audio track for device');
|
||||||
|
pipeline.replaceMicTrack(newTrack);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('switchActiveDevice(audioinput) failed', err);
|
console.error('setAudioInputDevice failed', err);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1033,6 +1428,17 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [state.kind]);
|
}, [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
|
// 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.
|
// call. Doesn't hangup and doesn't fire when other dialogs would consume it.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1054,6 +1460,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
isMuted,
|
isMuted,
|
||||||
isE2EEActive,
|
isE2EEActive,
|
||||||
isScreenSharing,
|
isScreenSharing,
|
||||||
|
isCameraEnabled,
|
||||||
|
isDeafened,
|
||||||
|
remoteDeafen,
|
||||||
|
remoteMute,
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
lastCallConversationId,
|
lastCallConversationId,
|
||||||
callMode,
|
callMode,
|
||||||
@@ -1065,11 +1475,20 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
hangup,
|
hangup,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
toggleScreenShare,
|
toggleScreenShare,
|
||||||
|
startScreenShare,
|
||||||
|
stopScreenShare,
|
||||||
|
toggleCamera,
|
||||||
|
toggleDeafen,
|
||||||
dismissLastCall,
|
dismissLastCall,
|
||||||
setCallMode,
|
setCallMode,
|
||||||
setFocusedId,
|
setFocusedId,
|
||||||
setAudioInputDevice,
|
setAudioInputDevice,
|
||||||
setAudioOutputDevice,
|
setAudioOutputDevice,
|
||||||
|
playSoundboard,
|
||||||
|
stopSoundboard,
|
||||||
|
activeSoundboardIds,
|
||||||
|
setSoundboardMasterGain,
|
||||||
|
setSoundboardMonitorGain,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
state,
|
state,
|
||||||
@@ -1078,6 +1497,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
isMuted,
|
isMuted,
|
||||||
isE2EEActive,
|
isE2EEActive,
|
||||||
isScreenSharing,
|
isScreenSharing,
|
||||||
|
isCameraEnabled,
|
||||||
|
isDeafened,
|
||||||
|
remoteDeafen,
|
||||||
|
remoteMute,
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
lastCallConversationId,
|
lastCallConversationId,
|
||||||
callMode,
|
callMode,
|
||||||
@@ -1089,11 +1512,20 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
hangup,
|
hangup,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
toggleScreenShare,
|
toggleScreenShare,
|
||||||
|
startScreenShare,
|
||||||
|
stopScreenShare,
|
||||||
|
toggleCamera,
|
||||||
|
toggleDeafen,
|
||||||
dismissLastCall,
|
dismissLastCall,
|
||||||
setCallMode,
|
setCallMode,
|
||||||
setFocusedId,
|
setFocusedId,
|
||||||
setAudioInputDevice,
|
setAudioInputDevice,
|
||||||
setAudioOutputDevice,
|
setAudioOutputDevice,
|
||||||
|
playSoundboard,
|
||||||
|
stopSoundboard,
|
||||||
|
activeSoundboardIds,
|
||||||
|
setSoundboardMasterGain,
|
||||||
|
setSoundboardMonitorGain,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1106,10 +1538,30 @@ export function useCall(): CallContextValue {
|
|||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared flag so attachTrack (called from LiveKit event listeners, outside the
|
||||||
|
// React component) can apply the current deafen state to freshly-attached
|
||||||
|
// audio elements. Toggled by toggleDeafen in sync with the React state.
|
||||||
|
let deafenedActive = false;
|
||||||
|
|
||||||
|
async function broadcastPresence(
|
||||||
|
room: Room,
|
||||||
|
deafened: boolean,
|
||||||
|
muted: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payload = new TextEncoder().encode(
|
||||||
|
JSON.stringify({ type: 'presence', deafened, muted }),
|
||||||
|
);
|
||||||
|
await room.localParticipant.publishData(payload, { reliable: true });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('broadcastPresence failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function attachTrack(
|
function attachTrack(
|
||||||
track: RemoteTrack,
|
track: RemoteTrack,
|
||||||
_publication: RemoteTrackPublication,
|
_publication: RemoteTrackPublication,
|
||||||
_participant: RemoteParticipant,
|
participant: RemoteParticipant,
|
||||||
): void {
|
): void {
|
||||||
if (track.kind === Track.Kind.Audio) {
|
if (track.kind === Track.Kind.Audio) {
|
||||||
const audio = track.attach();
|
const audio = track.attach();
|
||||||
@@ -1117,6 +1569,11 @@ function attachTrack(
|
|||||||
audio.autoplay = true;
|
audio.autoplay = true;
|
||||||
audio.setAttribute('playsinline', 'true');
|
audio.setAttribute('playsinline', 'true');
|
||||||
audio.setAttribute('data-livekit-track', track.sid ?? '');
|
audio.setAttribute('data-livekit-track', track.sid ?? '');
|
||||||
|
if (participant.identity) {
|
||||||
|
audio.setAttribute('data-participant', participant.identity);
|
||||||
|
audio.volume = getParticipantVolume(participant.identity);
|
||||||
|
}
|
||||||
|
if (deafenedActive) audio.muted = true;
|
||||||
document.body.appendChild(audio);
|
document.body.appendChild(audio);
|
||||||
// Apply persisted sinkId so the element routes to the user's chosen
|
// Apply persisted sinkId so the element routes to the user's chosen
|
||||||
// speaker/headphone from the start (LiveKit's own `switchActiveDevice`
|
// speaker/headphone from the start (LiveKit's own `switchActiveDevice`
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { playNotificationTone } from '../lib/notificationSound';
|
import { playNotificationTone } from '../lib/notificationSound';
|
||||||
import { notify } from '../lib/osNotify';
|
import { notify } from '../lib/osNotify';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { updateTrayUnread } from '../lib/trayBadge';
|
||||||
import { useAuth } from './AuthContext';
|
import { useAuth } from './AuthContext';
|
||||||
|
|
||||||
const LAST_READ_STORAGE_KEY = 'chatapp.conv_last_read';
|
const LAST_READ_STORAGE_KEY = 'chatapp.conv_last_read';
|
||||||
@@ -99,7 +100,9 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
// Only flag loading on initial fetch so background re-syncs (visibility
|
||||||
|
// change, online event) don't blank the list each time.
|
||||||
|
setLoading((prev) => (conversationsRef.current.length === 0 ? true : prev));
|
||||||
const convs = await listConversations(supabase);
|
const convs = await listConversations(supabase);
|
||||||
setConversations(convs);
|
setConversations(convs);
|
||||||
const entries = await Promise.all(
|
const entries = await Promise.all(
|
||||||
@@ -218,8 +221,13 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
|||||||
// refresh + realtime reconnect on visibility/focus regain so we never
|
// refresh + realtime reconnect on visibility/focus regain so we never
|
||||||
// leave stale conversation lists on a Windows client after the user
|
// leave stale conversation lists on a Windows client after the user
|
||||||
// returns to the app.
|
// returns to the app.
|
||||||
|
let lastAwakeRefresh = 0;
|
||||||
|
const AWAKE_THROTTLE_MS = 30_000;
|
||||||
const onAwake = () => {
|
const onAwake = () => {
|
||||||
if (document.visibilityState !== 'visible') return;
|
if (document.visibilityState !== 'visible') return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastAwakeRefresh < AWAKE_THROTTLE_MS) return;
|
||||||
|
lastAwakeRefresh = now;
|
||||||
void refresh();
|
void refresh();
|
||||||
try {
|
try {
|
||||||
// If the socket got wedged during background throttle, a no-op
|
// If the socket got wedged during background throttle, a no-op
|
||||||
@@ -231,12 +239,10 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('visibilitychange', onAwake);
|
document.addEventListener('visibilitychange', onAwake);
|
||||||
window.addEventListener('focus', onAwake);
|
|
||||||
window.addEventListener('online', onAwake);
|
window.addEventListener('online', onAwake);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('visibilitychange', onAwake);
|
document.removeEventListener('visibilitychange', onAwake);
|
||||||
window.removeEventListener('focus', onAwake);
|
|
||||||
window.removeEventListener('online', onAwake);
|
window.removeEventListener('online', onAwake);
|
||||||
void supabase.removeChannel(channel);
|
void supabase.removeChannel(channel);
|
||||||
};
|
};
|
||||||
@@ -248,6 +254,12 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
|||||||
return s;
|
return s;
|
||||||
}, [unread]);
|
}, [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>(
|
const value = useMemo<ConversationsContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
conversations,
|
conversations,
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,12 +16,27 @@ export interface AudioSettings {
|
|||||||
// Preferred output (speaker/headphone) deviceId. null = system default.
|
// Preferred output (speaker/headphone) deviceId. null = system default.
|
||||||
// Applied to every <audio> element we attach via HTMLMediaElement.setSinkId.
|
// Applied to every <audio> element we attach via HTMLMediaElement.setSinkId.
|
||||||
outputDeviceId: string | null;
|
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 = {
|
const DEFAULTS: AudioSettings = {
|
||||||
quality: 'voice',
|
quality: 'voice',
|
||||||
inputDeviceId: null,
|
inputDeviceId: null,
|
||||||
outputDeviceId: null,
|
outputDeviceId: null,
|
||||||
|
voiceThreshold: 0.03,
|
||||||
|
noiseSuppression: true,
|
||||||
|
videoBackgroundBlur: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AudioQualityParams {
|
export interface AudioQualityParams {
|
||||||
@@ -80,6 +95,7 @@ function read(): AudioSettings {
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
const parsed = JSON.parse(raw) as Partial<AudioSettings>;
|
const parsed = JSON.parse(raw) as Partial<AudioSettings>;
|
||||||
|
const rawThreshold = typeof parsed.voiceThreshold === 'number' ? parsed.voiceThreshold : NaN;
|
||||||
cached = {
|
cached = {
|
||||||
quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality,
|
quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality,
|
||||||
inputDeviceId:
|
inputDeviceId:
|
||||||
@@ -90,6 +106,18 @@ function read(): AudioSettings {
|
|||||||
typeof parsed.outputDeviceId === 'string' && parsed.outputDeviceId.length > 0
|
typeof parsed.outputDeviceId === 'string' && parsed.outputDeviceId.length > 0
|
||||||
? parsed.outputDeviceId
|
? parsed.outputDeviceId
|
||||||
: DEFAULTS.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;
|
return cached;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Crash / uncaught-error recovery.
|
||||||
|
//
|
||||||
|
// The React <ErrorBoundary> already catches render errors. This module covers
|
||||||
|
// the OTHER half: async code outside React (realtime handlers, Promises
|
||||||
|
// that never hit a .catch, setTimeout callbacks, LiveKit event listeners,
|
||||||
|
// etc.). Those bubble to window.onerror / onunhandledrejection and would
|
||||||
|
// otherwise disappear silently in production.
|
||||||
|
//
|
||||||
|
// Behaviour:
|
||||||
|
// - First error: emit a toast + log.
|
||||||
|
// - Repeated identical errors within DEDUPE_MS: swallow (no spam).
|
||||||
|
// - More than BURST_LIMIT distinct errors within BURST_WINDOW_MS: force a
|
||||||
|
// full page reload. Something is systematically broken and the UI
|
||||||
|
// probably can't recover in-place.
|
||||||
|
|
||||||
|
export interface CrashEntry {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
stack: string | null;
|
||||||
|
source: 'window' | 'promise';
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Listener = (entry: CrashEntry) => void;
|
||||||
|
|
||||||
|
const DEDUPE_MS = 10_000;
|
||||||
|
const BURST_WINDOW_MS = 30_000;
|
||||||
|
const BURST_LIMIT = 8;
|
||||||
|
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
const recent = new Map<string, number>(); // message hash → last seen
|
||||||
|
let burst: number[] = [];
|
||||||
|
let installed = false;
|
||||||
|
|
||||||
|
function hash(message: string): string {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < message.length; i++) {
|
||||||
|
h = ((h << 5) - h + message.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return h.toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(entry: CrashEntry): void {
|
||||||
|
// Dedupe on the message hash so a handler that fires every animation
|
||||||
|
// frame doesn't drown the UI in toasts.
|
||||||
|
const key = hash(entry.message);
|
||||||
|
const now = entry.at;
|
||||||
|
const last = recent.get(key);
|
||||||
|
if (last && now - last < DEDUPE_MS) return;
|
||||||
|
recent.set(key, now);
|
||||||
|
// Purge stale dedupe entries to keep the map bounded.
|
||||||
|
for (const [k, ts] of recent) {
|
||||||
|
if (now - ts > DEDUPE_MS * 3) recent.delete(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Burst detection: drop samples older than window, count what's left.
|
||||||
|
burst = burst.filter((t) => now - t < BURST_WINDOW_MS);
|
||||||
|
burst.push(now);
|
||||||
|
if (burst.length >= BURST_LIMIT) {
|
||||||
|
console.error('[crash-recovery] burst threshold reached — reloading');
|
||||||
|
// Let existing toast handlers see the final entry first, then reload.
|
||||||
|
// Delay lets the log flush + any pending realtime ack go through.
|
||||||
|
window.setTimeout(() => window.location.reload(), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fn of listeners) {
|
||||||
|
try {
|
||||||
|
fn(entry);
|
||||||
|
} catch (err) {
|
||||||
|
// Listener itself threw — log but don't recurse.
|
||||||
|
console.error('[crash-recovery] listener threw', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEntry(
|
||||||
|
err: unknown,
|
||||||
|
source: CrashEntry['source'],
|
||||||
|
): CrashEntry {
|
||||||
|
const message =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: typeof err === 'string'
|
||||||
|
? err
|
||||||
|
: (() => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(err);
|
||||||
|
} catch {
|
||||||
|
return String(err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const stack = err instanceof Error ? err.stack ?? null : null;
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
message: message || 'unknown error',
|
||||||
|
stack,
|
||||||
|
source,
|
||||||
|
at: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installCrashHandlers(): void {
|
||||||
|
if (installed) return;
|
||||||
|
installed = true;
|
||||||
|
|
||||||
|
window.addEventListener('error', (ev: ErrorEvent) => {
|
||||||
|
// Some events (ResizeObserver loop, benign extension injections) arrive
|
||||||
|
// as errors without a real message. Skip those to avoid spam.
|
||||||
|
if (!ev.message && !ev.error) return;
|
||||||
|
emit(toEntry(ev.error ?? ev.message, 'window'));
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (ev: PromiseRejectionEvent) => {
|
||||||
|
emit(toEntry(ev.reason, 'promise'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeCrashes(fn: Listener): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test / escape hatch — lets the reload threshold get checked in dev.
|
||||||
|
export function reportManualCrash(err: unknown): void {
|
||||||
|
emit(toEntry(err, 'window'));
|
||||||
|
}
|
||||||