Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 902c0285e6 | |||
| 1303c8e26f | |||
| 48ac9d2922 | |||
| 725a7e0364 | |||
| 44088b35d7 | |||
| 228608ef2c |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chat-app/desktop",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"private": true,
|
||||
"description": "Tauri v2 desktop client (Windows / macOS / Linux)",
|
||||
"type": "module",
|
||||
@@ -21,6 +21,7 @@
|
||||
"@chat-app/shared": "workspace:*",
|
||||
"@chat-app/ui-web": "workspace:*",
|
||||
"@livekit/components-react": "^2.9.0",
|
||||
"@livekit/track-processors": "^0.7.2",
|
||||
"@supabase/supabase-js": "^2.46.0",
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.0",
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<clipPath id="cp02">
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065"/>
|
||||
<g clip-path="url(#cp02)">
|
||||
<path d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z" fill="#7c4dff"/>
|
||||
<path d="M-4 32 Q 16 18 32 32 T 68 32" stroke="#a78bfa" stroke-width="2" fill="none"/>
|
||||
</g>
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"
|
||||
fill="none" stroke="#a78bfa" stroke-width="1.5" opacity="0.4"/>
|
||||
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa"/>
|
||||
<path d="M18 46 V22 Q 18 18 22 18 Q 26 18 27 21 L 39 42 Q 40 45 44 45 V22 Q 44 18 40 18"
|
||||
fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0"/>
|
||||
<path d="M20 46 V22 a3 3 0 0 1 5.5 -1.8 L 40 43 a2 2 0 0 0 4 -1.2 V22 a3 3 0 0 0 -6 0"
|
||||
fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 667 B After Width: | Height: | Size: 563 B |
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "chat-app-desktop"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
description = "ChatApp desktop client"
|
||||
authors = ["Dennis"]
|
||||
edition = "2021"
|
||||
@@ -14,7 +14,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
tauri = { version = "2", features = ["devtools", "tray-icon"] }
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||
tauri-plugin-stronghold = "2"
|
||||
@@ -22,11 +22,43 @@ tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Pure-rust libsodium-compatible primitives. No C toolchain required so
|
||||
# cross-compile for mobile stays clean. API output is bit-compatible with
|
||||
# libsodium-wrappers-sumo for the ops we use (secretbox, box, pwhash).
|
||||
dryoc = { version = "0.7", default-features = false, features = ["serde"] }
|
||||
base64 = "0.22"
|
||||
|
||||
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
|
||||
# LiveKit client SDK — lives behind the `rust-livekit` feature flag so the
|
||||
# baseline build stays unaffected while the JS-SDK path is still the
|
||||
# default. Pulls libwebrtc-rs which adds ~20MB to the binary and ~5-10min
|
||||
# to the first build. Tokio runtime is required; the rest of the crate
|
||||
# stays idle when the feature is off.
|
||||
livekit = { version = "0.7", default-features = false, features = ["tokio", "rustls-tls-native-roots"], optional = true }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros"], optional = true }
|
||||
|
||||
[features]
|
||||
# This feature is used for production builds or when `devPath` points to the filesystem
|
||||
# and disables specific features relevant to the dev build.
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
# Enable the Rust LiveKit client. Off by default so CI + users stay on the
|
||||
# JS-SDK path until the rust bridge reaches feature parity. Turn on via:
|
||||
# cargo build --features rust-livekit
|
||||
rust-livekit = ["dep:livekit", "dep:tokio"]
|
||||
|
||||
# Release-profile tuned for ChatApp: whole-program LTO + single codegen unit
|
||||
# cuts binary size by ~20-30% and trims startup overhead. `strip = "symbols"`
|
||||
# removes debug + symbol tables (the updater already signs separately so
|
||||
# symbol-backed crash reports aren't the recovery path). `panic = "abort"`
|
||||
# skips unwinding metadata since the app doesn't use catch_unwind anywhere.
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = "symbols"
|
||||
panic = "abort"
|
||||
opt-level = "s"
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
"notification:allow-notify",
|
||||
"notification:allow-is-permission-granted",
|
||||
"notification:allow-request-permission",
|
||||
"sql:default",
|
||||
"sql:allow-load",
|
||||
"sql:allow-execute",
|
||||
"sql:allow-select",
|
||||
"sql:allow-close",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister",
|
||||
"global-shortcut:allow-is-registered",
|
||||
|
||||
|
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">
|
||||
<defs>
|
||||
<clipPath id="cp02">
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065"/>
|
||||
<g clip-path="url(#cp02)">
|
||||
<path d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z" fill="#7c4dff"/>
|
||||
<path d="M-4 32 Q 16 18 32 32 T 68 32" stroke="#a78bfa" stroke-width="2" fill="none"/>
|
||||
</g>
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"
|
||||
fill="none" stroke="#a78bfa" stroke-width="1.5" opacity="0.4"/>
|
||||
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa"/>
|
||||
<path d="M18 46 V22 Q 18 18 22 18 Q 26 18 27 21 L 39 42 Q 40 45 44 45 V22 Q 44 18 40 18"
|
||||
fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0"/>
|
||||
<path d="M20 46 V22 a3 3 0 0 1 5.5 -1.8 L 40 43 a2 2 0 0 0 4 -1.2 V22 a3 3 0 0 0 -6 0"
|
||||
fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 667 B After Width: | Height: | Size: 563 B |
@@ -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,94 @@
|
||||
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,
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
fn show_main_window(app: &AppHandle) {
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
let _ = win.show();
|
||||
let _ = win.unminimize();
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
fn hide_main_window(app: &AppHandle) {
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
let _ = win.hide();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
fn handle_menu_event(app: &AppHandle, event: MenuEvent) {
|
||||
match event.id.as_ref() {
|
||||
"tray-show" => show_main_window(app),
|
||||
"tray-hide" => hide_main_window(app),
|
||||
"tray-quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
#[cfg(not(feature = "rust-livekit"))]
|
||||
let mut builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
crypto::crypto_random_bytes,
|
||||
crypto::crypto_secretbox_encrypt,
|
||||
crypto::crypto_secretbox_decrypt,
|
||||
crypto::crypto_box_keypair,
|
||||
crypto::crypto_box_encrypt,
|
||||
crypto::crypto_box_decrypt,
|
||||
crypto::crypto_box_seal,
|
||||
crypto::crypto_box_seal_open,
|
||||
crypto::crypto_pwhash,
|
||||
])
|
||||
.plugin(tauri_plugin_notification::init());
|
||||
|
||||
#[cfg(feature = "rust-livekit")]
|
||||
let mut builder = tauri::Builder::default()
|
||||
.manage(livekit_bridge::LivekitState::new())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
crypto::crypto_random_bytes,
|
||||
crypto::crypto_secretbox_encrypt,
|
||||
crypto::crypto_secretbox_decrypt,
|
||||
crypto::crypto_box_keypair,
|
||||
crypto::crypto_box_encrypt,
|
||||
crypto::crypto_box_decrypt,
|
||||
crypto::crypto_box_seal,
|
||||
crypto::crypto_box_seal_open,
|
||||
crypto::crypto_pwhash,
|
||||
livekit_bridge::livekit_connect,
|
||||
livekit_bridge::livekit_disconnect,
|
||||
livekit_bridge::livekit_send_data,
|
||||
livekit_bridge::livekit_set_mic,
|
||||
livekit_bridge::livekit_set_camera,
|
||||
])
|
||||
.plugin(tauri_plugin_notification::init());
|
||||
|
||||
builder = builder
|
||||
.plugin(tauri_plugin_sql::Builder::default().build())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(
|
||||
@@ -13,12 +100,91 @@ pub fn run() {
|
||||
.build(),
|
||||
);
|
||||
|
||||
// Global shortcut + updater plugins are desktop-only (no mobile support).
|
||||
// Global shortcut + updater + window-state plugins are desktop-only
|
||||
// (no mobile support — mobile windows are OS-managed).
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
{
|
||||
builder = builder
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_window_state::Builder::new().build());
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
{
|
||||
builder = builder.setup(|app| {
|
||||
// Tray icon with a minimal menu. Left-click toggles window
|
||||
// visibility; right-click shows the menu. Badge / tooltip updates
|
||||
// come from the JS side via `tray-unread-update` events.
|
||||
let show = MenuItem::with_id(app, "tray-show", "Öffnen", true, None::<&str>)?;
|
||||
let hide = MenuItem::with_id(app, "tray-hide", "Ausblenden", true, None::<&str>)?;
|
||||
let sep = PredefinedMenuItem::separator(app)?;
|
||||
let quit = MenuItem::with_id(app, "tray-quit", "Beenden", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&show, &hide, &sep, &quit])?;
|
||||
|
||||
let mut tray_builder = TrayIconBuilder::with_id("chatapp-tray")
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.tooltip("ChatApp")
|
||||
.on_menu_event(|app, event| handle_menu_event(app, event))
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
if win.is_visible().unwrap_or(false) {
|
||||
let _ = win.hide();
|
||||
} else {
|
||||
let _ = win.show();
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// `default_window_icon` returns Option<&Image>; only attach if
|
||||
// we actually have one bundled (should always be true via the
|
||||
// tauri.conf.json icon list, but guard to stay typesafe).
|
||||
if let Some(icon) = app.default_window_icon() {
|
||||
tray_builder = tray_builder.icon(icon.clone());
|
||||
}
|
||||
|
||||
let tray = tray_builder.build(app)?;
|
||||
|
||||
// Listen for JS-side unread updates and mirror them into the tray
|
||||
// tooltip + macOS dock badge. `tray` is cheap to clone (internal
|
||||
// Arc) so we can move it into the listener closure directly.
|
||||
let tray_handle = tray.clone();
|
||||
let badge_window = app.get_webview_window("main");
|
||||
app.listen("tray-unread-update", move |event| {
|
||||
let Ok(payload) = serde_json::from_str::<TrayUnreadPayload>(event.payload())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let tooltip = if payload.count == 0 {
|
||||
"ChatApp".to_string()
|
||||
} else {
|
||||
format!("ChatApp · {} neu", payload.count)
|
||||
};
|
||||
let _ = tray_handle.set_tooltip(Some(tooltip));
|
||||
// macOS dock badge. `set_badge_label` is macOS-only but the
|
||||
// call is a no-op on other platforms so we don't need a cfg.
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
builder
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// Rust LiveKit bridge — command/event glue between the JS CallContext and
|
||||
// the native livekit client. Feature-gated behind `rust-livekit` so the
|
||||
// baseline build doesn't pay the libwebrtc download / link cost while the
|
||||
// bridge is still evolving.
|
||||
//
|
||||
// Design contract (matches `lib/nativeLiveKit.ts` on the JS side):
|
||||
// command: livekit_connect { url, token, e2ee_key_b64? }
|
||||
// command: livekit_disconnect
|
||||
// command: livekit_set_mic { enabled }
|
||||
// command: livekit_set_camera { enabled }
|
||||
// command: livekit_start_share {}
|
||||
// command: livekit_stop_share {}
|
||||
// command: livekit_send_data { payload_b64, reliable }
|
||||
// event: livekit:room_state { state }
|
||||
// event: livekit:participant_joined { identity, name? }
|
||||
// event: livekit:participant_left { identity }
|
||||
// event: livekit:track_published { identity, sid, kind, source }
|
||||
// event: livekit:track_unpublished { identity, sid }
|
||||
// event: livekit:audio_level { identity, level }
|
||||
// event: livekit:data_received { identity, payload_b64 }
|
||||
// event: livekit:error { message }
|
||||
//
|
||||
// Phase B.1 (this file) only wires connect/disconnect + room-state events
|
||||
// so the JS side can prove round-trip; mic/camera/video come later.
|
||||
|
||||
#![cfg(feature = "rust-livekit")]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use livekit::{
|
||||
id::ParticipantIdentity, DataPacketKind, Room, RoomEvent, RoomOptions,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
pub struct LivekitState {
|
||||
room: Mutex<Option<Arc<Room>>>,
|
||||
}
|
||||
|
||||
impl LivekitState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
room: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ConnectArgs {
|
||||
pub url: String,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct RoomStatePayload {
|
||||
state: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct ParticipantPayload {
|
||||
identity: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct DataPayload {
|
||||
identity: String,
|
||||
payload_b64: String,
|
||||
reliable: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn livekit_connect(
|
||||
app: AppHandle,
|
||||
state: State<'_, LivekitState>,
|
||||
args: ConnectArgs,
|
||||
) -> Result<(), String> {
|
||||
let mut guard = state.room.lock().await;
|
||||
if guard.is_some() {
|
||||
return Err("already connected".into());
|
||||
}
|
||||
let options = RoomOptions::default();
|
||||
let (room, mut events) = Room::connect(&args.url, &args.token, options)
|
||||
.await
|
||||
.map_err(|e| format!("livekit connect failed: {}", e))?;
|
||||
let room = Arc::new(room);
|
||||
*guard = Some(room.clone());
|
||||
drop(guard);
|
||||
|
||||
let _ = app.emit(
|
||||
"livekit:room_state",
|
||||
RoomStatePayload { state: "connected" },
|
||||
);
|
||||
|
||||
// Spawn the event pump. Lives for the duration of the room connection;
|
||||
// stops naturally when the channel closes (disconnect or crash).
|
||||
let app_for_events = app.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(event) = events.recv().await {
|
||||
pump_event(&app_for_events, event);
|
||||
}
|
||||
let _ = app_for_events.emit(
|
||||
"livekit:room_state",
|
||||
RoomStatePayload {
|
||||
state: "disconnected",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pump_event(app: &AppHandle, event: RoomEvent) {
|
||||
match event {
|
||||
RoomEvent::ParticipantConnected(p) => {
|
||||
let _ = app.emit(
|
||||
"livekit:participant_joined",
|
||||
ParticipantPayload {
|
||||
identity: identity_string(p.identity()),
|
||||
},
|
||||
);
|
||||
}
|
||||
RoomEvent::ParticipantDisconnected(p) => {
|
||||
let _ = app.emit(
|
||||
"livekit:participant_left",
|
||||
ParticipantPayload {
|
||||
identity: identity_string(p.identity()),
|
||||
},
|
||||
);
|
||||
}
|
||||
RoomEvent::DataReceived {
|
||||
payload,
|
||||
kind,
|
||||
participant,
|
||||
..
|
||||
} => {
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
let identity = participant
|
||||
.map(|p| identity_string(p.identity()))
|
||||
.unwrap_or_default();
|
||||
let _ = app.emit(
|
||||
"livekit:data_received",
|
||||
DataPayload {
|
||||
identity,
|
||||
payload_b64: STANDARD.encode(payload.as_ref()),
|
||||
reliable: matches!(kind, DataPacketKind::Reliable),
|
||||
},
|
||||
);
|
||||
}
|
||||
RoomEvent::Disconnected { .. } => {
|
||||
let _ = app.emit(
|
||||
"livekit:room_state",
|
||||
RoomStatePayload {
|
||||
state: "disconnected",
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
// Remaining events (TrackPublished, TrackSubscribed, etc.) land
|
||||
// in later phases. Ignoring silently keeps the prototype small.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn identity_string(id: ParticipantIdentity) -> String {
|
||||
// ParticipantIdentity is a newtype around String in the livekit crate.
|
||||
id.0
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn livekit_disconnect(state: State<'_, LivekitState>) -> Result<(), String> {
|
||||
let mut guard = state.room.lock().await;
|
||||
if let Some(room) = guard.take() {
|
||||
let _ = room.close().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn livekit_send_data(
|
||||
state: State<'_, LivekitState>,
|
||||
payload_b64: String,
|
||||
reliable: bool,
|
||||
) -> Result<(), String> {
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
let room = state.room.lock().await;
|
||||
let room = room.as_ref().ok_or_else(|| "not connected".to_string())?;
|
||||
let payload = STANDARD
|
||||
.decode(&payload_b64)
|
||||
.map_err(|e| format!("bad base64: {}", e))?;
|
||||
let kind = if reliable {
|
||||
DataPacketKind::Reliable
|
||||
} else {
|
||||
DataPacketKind::Lossy
|
||||
};
|
||||
room.local_participant()
|
||||
.publish_data(livekit::prelude::DataPacket {
|
||||
payload,
|
||||
topic: None,
|
||||
reliable: matches!(kind, DataPacketKind::Reliable),
|
||||
destination_identities: Vec::new(),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("publish_data failed: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Placeholder — Phase B.2 will fill these in.
|
||||
#[tauri::command]
|
||||
pub async fn livekit_set_mic(_enabled: bool) -> Result<(), String> {
|
||||
Err("livekit_set_mic not implemented — Phase B.2".into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn livekit_set_camera(_enabled: bool) -> Result<(), String> {
|
||||
Err("livekit_set_camera not implemented — Phase B.2".into())
|
||||
}
|
||||
|
||||
// Unused-send bridge so `mpsc` doesn't get unused-import-warned when the
|
||||
// feature gate is off.
|
||||
#[allow(dead_code)]
|
||||
fn _mpsc_anchor() -> mpsc::Sender<()> {
|
||||
let (tx, _rx) = mpsc::channel::<()>(1);
|
||||
tx
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ChatApp",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"identifier": "com.meinname.chatapp",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm vite:dev",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { AppShell } from './components/AppShell';
|
||||
import { CrashToast } from './components/CrashToast';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { SpinnerIcon } from './components/icons';
|
||||
import { UpdateToast } from './components/UpdateToast';
|
||||
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
@@ -10,14 +12,39 @@ import { CallProvider } from './context/CallContext';
|
||||
import { ConversationsProvider } from './context/ConversationsContext';
|
||||
import { FriendshipsProvider } from './context/FriendshipsContext';
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
import { AdminPage } from './pages/AdminPage';
|
||||
import { AuthCallbackPage } from './pages/AuthCallbackPage';
|
||||
import { AuthPage } from './pages/AuthPage';
|
||||
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
|
||||
import { ConversationPage } from './pages/ConversationPage';
|
||||
import { DevicePage } from './pages/DevicePage';
|
||||
import { FriendsPage } from './pages/FriendsPage';
|
||||
import { SettingsPage } from './pages/SettingsPage';
|
||||
|
||||
// Routes rarely visited on first render are pulled out of the initial bundle.
|
||||
// AuthPage stays eager because it's the first screen unauthenticated users
|
||||
// see; ChatsPage + ConversationPage stay eager because every authenticated
|
||||
// session renders them immediately.
|
||||
const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })));
|
||||
const AuthCallbackPage = lazy(() =>
|
||||
import('./pages/AuthCallbackPage').then((m) => ({ default: m.AuthCallbackPage })),
|
||||
);
|
||||
const DevicePage = lazy(() => import('./pages/DevicePage').then((m) => ({ default: m.DevicePage })));
|
||||
const FriendsPage = lazy(() =>
|
||||
import('./pages/FriendsPage').then((m) => ({ default: m.FriendsPage })),
|
||||
);
|
||||
const SettingsPage = lazy(() =>
|
||||
import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })),
|
||||
);
|
||||
|
||||
function RouteSuspense({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-full w-full items-center justify-center bg-surface-3">
|
||||
<SpinnerIcon className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// Isolates each top-level route so a crash in one page doesn't take the whole
|
||||
// shell down. The boundary auto-retries via `ErrorBoundary`'s backoff schedule.
|
||||
@@ -53,11 +80,25 @@ export function App() {
|
||||
<Routes>
|
||||
<Route element={<RouteBoundary scope="auth" />}>
|
||||
<Route path="/auth" element={<AuthPage />} />
|
||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
||||
<Route
|
||||
path="/auth/callback"
|
||||
element={
|
||||
<RouteSuspense>
|
||||
<AuthCallbackPage />
|
||||
</RouteSuspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<RouteBoundary scope="device" />}>
|
||||
<Route path="/device" element={<DevicePage />} />
|
||||
<Route
|
||||
path="/device"
|
||||
element={
|
||||
<RouteSuspense>
|
||||
<DevicePage />
|
||||
</RouteSuspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route element={<RequireDevice />}>
|
||||
<Route element={<AppShell />}>
|
||||
@@ -76,14 +117,35 @@ export function App() {
|
||||
</Route>
|
||||
</Route>
|
||||
<Route element={<RouteBoundary scope="friends" />}>
|
||||
<Route path="/friends" element={<FriendsPage />} />
|
||||
<Route
|
||||
path="/friends"
|
||||
element={
|
||||
<RouteSuspense>
|
||||
<FriendsPage />
|
||||
</RouteSuspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route element={<RouteBoundary scope="settings" />}>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<RouteSuspense>
|
||||
<SettingsPage />
|
||||
</RouteSuspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route element={<RequireAdmin />}>
|
||||
<Route element={<RouteBoundary scope="admin" />}>
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<RouteSuspense>
|
||||
<AdminPage />
|
||||
</RouteSuspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
|
||||
|
||||
@@ -31,19 +32,30 @@ export function AttachmentAudio({ handle }: Props) {
|
||||
setBlobUrl(null);
|
||||
setArrayBuf(null);
|
||||
|
||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
||||
.then(async (blob) => {
|
||||
void (async () => {
|
||||
const cached = await getCachedAttachment(handle.id);
|
||||
if (cached) {
|
||||
if (cancelled) return;
|
||||
url = URL.createObjectURL(cached);
|
||||
setBlobUrl(url);
|
||||
const buf = await cached.arrayBuffer();
|
||||
if (!cancelled) setArrayBuf(buf);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||
if (cancelled) return;
|
||||
url = URL.createObjectURL(blob);
|
||||
setBlobUrl(url);
|
||||
const buf = await blob.arrayBuffer();
|
||||
if (!cancelled) setArrayBuf(buf);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
void putCachedAttachment(handle.id, blob);
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'download failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, SpinnerIcon } from './icons';
|
||||
|
||||
@@ -20,7 +21,11 @@ export function AttachmentGeneric({ handle }: Props) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||
let blob = await getCachedAttachment(handle.id);
|
||||
if (!blob) {
|
||||
blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||
void putCachedAttachment(handle.id, blob);
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, SpinnerIcon, XIcon } from './icons';
|
||||
|
||||
@@ -8,35 +9,95 @@ interface Props {
|
||||
handle: AttachmentHandle;
|
||||
}
|
||||
|
||||
// Max inline-preview dimension. Full-resolution stays available for the
|
||||
// lightbox. Animated formats (gif/webp/apng) are passed through untouched
|
||||
// so animation isn't lost; everything else is downscaled to this box.
|
||||
const THUMB_MAX_DIM = 640;
|
||||
const ANIMATED_MIME = /^image\/(gif|apng|webp)/;
|
||||
|
||||
async function makeThumbnail(blob: Blob): Promise<Blob | null> {
|
||||
if (ANIMATED_MIME.test(blob.type)) return null;
|
||||
if (typeof createImageBitmap !== 'function') return null;
|
||||
if (typeof OffscreenCanvas !== 'function') return null;
|
||||
try {
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const largest = Math.max(bitmap.width, bitmap.height);
|
||||
if (largest <= THUMB_MAX_DIM) {
|
||||
bitmap.close();
|
||||
return null;
|
||||
}
|
||||
const scale = THUMB_MAX_DIM / largest;
|
||||
const w = Math.max(1, Math.round(bitmap.width * scale));
|
||||
const h = Math.max(1, Math.round(bitmap.height * scale));
|
||||
const canvas = new OffscreenCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
bitmap.close();
|
||||
return null;
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0, w, h);
|
||||
bitmap.close();
|
||||
return await canvas.convertToBlob({ type: 'image/webp', quality: 0.8 });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function AttachmentImage({ handle }: Props) {
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
const [fullUrl, setFullUrl] = useState<string | null>(null);
|
||||
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let url: string | null = null;
|
||||
const created: string[] = [];
|
||||
setError(null);
|
||||
setBlobUrl(null);
|
||||
setFullUrl(null);
|
||||
setThumbUrl(null);
|
||||
|
||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
||||
.then((blob) => {
|
||||
if (cancelled) return;
|
||||
url = URL.createObjectURL(blob);
|
||||
setBlobUrl(url);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'download failed');
|
||||
const take = (blob: Blob): string => {
|
||||
const u = URL.createObjectURL(blob);
|
||||
created.push(u);
|
||||
return u;
|
||||
};
|
||||
|
||||
// OPFS cache → decrypt → generate thumbnail for inline display.
|
||||
// Lightbox swaps to the full blob when opened.
|
||||
void (async () => {
|
||||
const cached = await getCachedAttachment(handle.id);
|
||||
let blob: Blob;
|
||||
if (cached) {
|
||||
blob = cached;
|
||||
} else {
|
||||
try {
|
||||
blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||
void putCachedAttachment(handle.id, blob);
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'download failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (cancelled) return;
|
||||
const full = take(blob);
|
||||
setFullUrl(full);
|
||||
const thumb = await makeThumbnail(blob);
|
||||
if (cancelled) return;
|
||||
if (thumb) {
|
||||
setThumbUrl(take(thumb));
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
for (const u of created) URL.revokeObjectURL(u);
|
||||
};
|
||||
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
||||
|
||||
const blobUrl = thumbUrl ?? fullUrl;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
|
||||
@@ -66,10 +127,11 @@ export function AttachmentImage({ handle }: Props) {
|
||||
src={blobUrl}
|
||||
alt="attachment"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="block h-auto max-h-80 w-auto max-w-full object-contain"
|
||||
/>
|
||||
</button>
|
||||
{lightboxOpen && <Lightbox url={blobUrl} onClose={() => setLightboxOpen(false)} />}
|
||||
{lightboxOpen && fullUrl && <Lightbox url={fullUrl} onClose={() => setLightboxOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, SpinnerIcon } from './icons';
|
||||
|
||||
@@ -22,19 +23,29 @@ export function AttachmentPdf({ handle }: Props) {
|
||||
setError(null);
|
||||
setBlobUrl(null);
|
||||
|
||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
||||
.then((blob) => {
|
||||
void (async () => {
|
||||
const cached = await getCachedAttachment(handle.id);
|
||||
if (cached) {
|
||||
if (cancelled) return;
|
||||
const typed = new Blob([cached], { type: 'application/pdf' });
|
||||
url = URL.createObjectURL(typed);
|
||||
setBlobUrl(url);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||
if (cancelled) return;
|
||||
// Force the application/pdf type so the browser plugin engages.
|
||||
const typed = new Blob([blob], { type: 'application/pdf' });
|
||||
url = URL.createObjectURL(typed);
|
||||
setBlobUrl(url);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
void putCachedAttachment(handle.id, blob);
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'download failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, SpinnerIcon } from './icons';
|
||||
|
||||
@@ -20,17 +21,26 @@ export function AttachmentVideo({ handle }: Props) {
|
||||
setError(null);
|
||||
setBlobUrl(null);
|
||||
|
||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
||||
.then((blob) => {
|
||||
void (async () => {
|
||||
const cached = await getCachedAttachment(handle.id);
|
||||
if (cached) {
|
||||
if (cancelled) return;
|
||||
url = URL.createObjectURL(cached);
|
||||
setBlobUrl(url);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||
if (cancelled) return;
|
||||
url = URL.createObjectURL(blob);
|
||||
setBlobUrl(url);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
void putCachedAttachment(handle.id, blob);
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'download failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Reusable avatar that prefers an uploaded image and falls back to a coloured
|
||||
// letter circle. Use this everywhere the app needs to render a profile.
|
||||
|
||||
import { useCachedAvatarUrl } from '../lib/avatarCache';
|
||||
|
||||
interface Props {
|
||||
url?: string | null | undefined;
|
||||
displayName?: string | null | undefined;
|
||||
@@ -18,10 +20,11 @@ export function Avatar({
|
||||
fallbackClass = 'bg-accent/20 text-accent',
|
||||
alt,
|
||||
}: Props) {
|
||||
if (url) {
|
||||
const effectiveUrl = useCachedAvatarUrl(url);
|
||||
if (effectiveUrl) {
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
src={effectiveUrl}
|
||||
alt={alt ?? displayName ?? ''}
|
||||
className={'shrink-0 rounded-full object-cover ' + className}
|
||||
draggable={false}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { DeviceRestore } from './DeviceRestore';
|
||||
import { AlertIcon, ShieldIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
userId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Modal wrapper around DeviceRestore for the already-signed-in case. A
|
||||
// successful restore swaps the local device-identity for the one embedded
|
||||
// in the backup string — the app then hard-reloads so every hook
|
||||
// re-initialises against the restored keys (simpler than invalidating
|
||||
// supabase-realtime subscriptions, stronghold caches, livekit rooms, etc.
|
||||
// individually).
|
||||
export function BackupRestoreDialog({ open, userId, onClose }: Props) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Backup wiederherstellen"
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-6 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-md flex-col gap-4"
|
||||
>
|
||||
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-ink-900/70 p-3 backdrop-blur-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldIcon className="h-4 w-4 text-brand-300" />
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
Gerät aus Backup wiederherstellen
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-100">
|
||||
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
|
||||
<p className="min-w-0 flex-1">
|
||||
Restore ersetzt das aktuelle Gerät durch das aus dem Backup.
|
||||
Die App lädt danach neu. Nachrichten, die auf diesem Gerät seit
|
||||
dem Backup eingegangen sind, sind erst wieder lesbar, nachdem
|
||||
Peer-Geräte den Conversation-Key erneut für die wiederhergestellte
|
||||
Device-ID wrappen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DeviceRestore
|
||||
userId={userId}
|
||||
onRestored={() => {
|
||||
// Hard reload — cleanest way to reset every hook, supabase
|
||||
// realtime channel, stronghold handle, and cached state.
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,8 @@ interface Props {
|
||||
onReply?: (m: DecryptedMessage) => void;
|
||||
/** Hover action: parent opens forward dialog for current message. */
|
||||
onForward?: (m: DecryptedMessage) => void;
|
||||
/** Click on the message's avatar surfaces the author's profile card. */
|
||||
onAvatarClick?: (userId: string, ev: React.MouseEvent) => void;
|
||||
/** Highlighted state — set briefly after a jump. */
|
||||
highlighted?: boolean;
|
||||
}
|
||||
@@ -76,6 +78,7 @@ export function MessageBubble({
|
||||
onJumpToMessage,
|
||||
onReply,
|
||||
onForward,
|
||||
onAvatarClick,
|
||||
highlighted = false,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
@@ -97,6 +100,47 @@ export function MessageBubble({
|
||||
const withinEditWindow = age < EDIT_WINDOW_MS;
|
||||
const bodyText = initialText;
|
||||
const attachments = initialAttachments;
|
||||
|
||||
// /tempmsg ephemeral window. expireMs embedded in plaintext JSON; sender
|
||||
// fires the soft-delete when the clock runs out. Receivers just watch
|
||||
// the deletedAt flip via realtime.
|
||||
const expireMs = parsed.kind === 'text' ? parsed.expireMs : undefined;
|
||||
const [tickNow, setTickNow] = useState<number>(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (expireMs === undefined) return;
|
||||
const id = window.setInterval(() => setTickNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [expireMs]);
|
||||
const msLeft =
|
||||
expireMs !== undefined
|
||||
? Math.max(0, expireMs - (tickNow - createdAt.getTime()))
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!mine) return;
|
||||
if (expireMs === undefined) return;
|
||||
if (message.deletedAt) return;
|
||||
const remaining = Math.max(0, expireMs - age);
|
||||
const timer = window.setTimeout(() => {
|
||||
void softDeleteMessage(supabase, message.id).catch((err: unknown) => {
|
||||
console.warn('ephemeral auto-delete failed', err);
|
||||
});
|
||||
}, remaining);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [mine, expireMs, age, message.id, message.deletedAt]);
|
||||
|
||||
// Receiver-side auto-hide when the expiry window elapses even if the
|
||||
// sender's delete hasn't propagated yet (network hiccup, offline-sender).
|
||||
const [localExpired, setLocalExpired] = useState<boolean>(
|
||||
msLeft !== null && msLeft <= 0,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (expireMs === undefined) return;
|
||||
if (localExpired) return;
|
||||
const remaining = Math.max(0, expireMs - age);
|
||||
const t = window.setTimeout(() => setLocalExpired(true), remaining);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [expireMs, age, localExpired]);
|
||||
const canEdit =
|
||||
parsed.kind === 'text' &&
|
||||
mine &&
|
||||
@@ -181,13 +225,16 @@ export function MessageBubble({
|
||||
[onToggleReaction],
|
||||
);
|
||||
|
||||
if (message.deletedAt) {
|
||||
if (message.deletedAt || localExpired) {
|
||||
return (
|
||||
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
|
||||
<AvatarSlot
|
||||
show={isLastOfRun}
|
||||
url={senderAvatarUrl ?? null}
|
||||
displayName={senderDisplayName ?? null}
|
||||
{...(onAvatarClick
|
||||
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
|
||||
: {})}
|
||||
/>
|
||||
<div className="max-w-[calc(70%-2.5rem)] rounded-2xl border border-line bg-surface-2 px-3.5 py-1.5 text-xs italic text-fg-muted">
|
||||
{t('app:chats.deleted')}
|
||||
@@ -274,23 +321,30 @@ export function MessageBubble({
|
||||
type="button"
|
||||
onClick={() => onJumpToMessage?.(quoted.id)}
|
||||
className={
|
||||
'mb-1.5 flex w-full cursor-pointer items-stretch gap-2 rounded-md px-2 py-1.5 text-left text-xs transition hover:opacity-90 ' +
|
||||
'mb-1.5 flex w-full cursor-pointer items-stretch gap-2 rounded-md pl-2 pr-2.5 py-1.5 text-left text-xs transition hover:brightness-110 hover:shadow-sm ' +
|
||||
(mine
|
||||
? 'bg-white/15 text-accent-fg/90'
|
||||
: 'bg-surface-2 text-fg-muted')
|
||||
? 'bg-white/10 text-accent-fg/90'
|
||||
: 'bg-surface-2/80 text-fg-muted ring-1 ring-inset ring-line')
|
||||
}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
'w-0.5 shrink-0 rounded-full ' + (mine ? 'bg-white/50' : 'bg-accent')
|
||||
'-ml-1 w-1 shrink-0 rounded-full ' +
|
||||
(mine ? 'bg-white/70' : 'bg-accent')
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={'block truncate font-semibold ' + (mine ? '' : 'text-fg')}>
|
||||
{quoted.senderName}
|
||||
<span className="min-w-0 flex-1 pl-1">
|
||||
<span
|
||||
className={
|
||||
'flex items-center gap-1 truncate text-[11px] font-semibold ' +
|
||||
(mine ? 'text-accent-fg' : 'text-accent')
|
||||
}
|
||||
>
|
||||
<ReplyIcon className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{quoted.senderName}</span>
|
||||
</span>
|
||||
<span className="block truncate italic opacity-90">
|
||||
<span className="mt-0.5 block truncate opacity-80">
|
||||
{quoted.deleted
|
||||
? t('app:chats.deleted')
|
||||
: quoted.isAttachment && !quoted.snippet
|
||||
@@ -337,6 +391,17 @@ export function MessageBubble({
|
||||
{message.editedAt && !message.deletedAt && (
|
||||
<span className="italic">· {t('app:chats.edited')}</span>
|
||||
)}
|
||||
{msLeft !== null && msLeft > 0 && (
|
||||
<span
|
||||
className={
|
||||
'inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider ' +
|
||||
(mine ? 'bg-white/20' : 'bg-rose-500/15 text-rose-600 dark:text-rose-300')
|
||||
}
|
||||
title="Selbstzerstörung"
|
||||
>
|
||||
⏱ {Math.ceil(msLeft / 1000)}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -457,14 +522,29 @@ function AvatarSlot({
|
||||
show,
|
||||
url,
|
||||
displayName,
|
||||
onClick,
|
||||
}: {
|
||||
show: boolean;
|
||||
url: string | null;
|
||||
displayName: string | null;
|
||||
onClick?: (ev: React.MouseEvent) => void;
|
||||
}) {
|
||||
if (!show) {
|
||||
return <div aria-hidden="true" className="h-8 w-8 shrink-0" />;
|
||||
}
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-user-popover-trigger
|
||||
onClick={onClick}
|
||||
className="shrink-0 cursor-pointer rounded-full transition hover:ring-2 hover:ring-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||
aria-label={displayName ?? 'Profil'}
|
||||
>
|
||||
<Avatar url={url} displayName={displayName ?? ''} className="h-8 w-8 text-xs" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Avatar
|
||||
url={url}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getScreenShareSettings,
|
||||
PRESET_ORDER,
|
||||
type ScreenSharePreset,
|
||||
updateScreenShareSettings,
|
||||
} from '../lib/screenShareSettings';
|
||||
import {
|
||||
MonitorShareIcon,
|
||||
@@ -42,6 +43,7 @@ export function ScreenShareDialog({ open, onClose, onStart }: Props) {
|
||||
const [surface, setSurface] = useState<DisplaySurfaceHint>(initial.displaySurface);
|
||||
const [preset, setPreset] = useState<ScreenSharePreset>(initial.preset);
|
||||
const [framerate, setFramerate] = useState<number | null>(initial.framerateOverride);
|
||||
const [includeAudio, setIncludeAudio] = useState<boolean>(initial.includeSystemAudio);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -51,6 +53,9 @@ export function ScreenShareDialog({ open, onClose, onStart }: Props) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Persist the audio choice alongside the other picker prefs so the
|
||||
// upstream startScreenShare picks it up on its settings read.
|
||||
updateScreenShareSettings({ includeSystemAudio: includeAudio });
|
||||
await onStart({ preset, displaySurface: surface, framerate });
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
@@ -165,6 +170,30 @@ export function ScreenShareDialog({ open, onClose, onStart }: Props) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2 text-xs hover:bg-surface">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeAudio}
|
||||
onChange={(e) => setIncludeAudio(e.target.checked)}
|
||||
className="mt-0.5 accent-accent"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-semibold text-fg">
|
||||
{t('app:call.share_system_audio', {
|
||||
defaultValue: 'System-Sound mit übertragen',
|
||||
})}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] text-fg-muted">
|
||||
{t('app:call.share_system_audio_hint', {
|
||||
defaultValue:
|
||||
'"Go Live" — Systemsound wird mitgesendet. Auf macOS braucht das extra Berechtigungen; wird sonst stumm geteilt.',
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-rose-600 dark:text-rose-300">
|
||||
{error}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { ProfileBrief } from '@chat-app/shared/friends';
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { usePeerPresence } from '../lib/usePeerPresence';
|
||||
import { Avatar } from './Avatar';
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
profile: ProfileBrief | null;
|
||||
x: number;
|
||||
y: number;
|
||||
onClose: () => void;
|
||||
onStartDm?: (userId: string) => void;
|
||||
}
|
||||
|
||||
const PRESENCE_DOT: Record<string, string> = {
|
||||
online: 'bg-emerald-500',
|
||||
idle: 'bg-amber-400',
|
||||
dnd: 'bg-rose-500',
|
||||
invisible: 'bg-neutral-500',
|
||||
offline: 'bg-neutral-400 dark:bg-neutral-600',
|
||||
};
|
||||
|
||||
const CARD_W = 280;
|
||||
const CARD_H = 180;
|
||||
|
||||
// Hover/click card surfaced from avatars around the app. Shows display name,
|
||||
// @handle, presence state + status message, plus a DM-start button when
|
||||
// the clicked profile isn't the caller.
|
||||
export function UserProfilePopover({
|
||||
userId,
|
||||
profile,
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
onStartDm,
|
||||
}: Props) {
|
||||
// Subscribe to the same presence feed that the conversation header uses,
|
||||
// so status updates flow in live.
|
||||
const presence = usePeerPresence(userId);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (t?.closest('[data-user-popover]')) return;
|
||||
if (t?.closest('[data-user-popover-trigger]')) return;
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
window.addEventListener('mousedown', onDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('mousedown', onDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const left = Math.min(Math.max(8, x), window.innerWidth - CARD_W - 8);
|
||||
const top = Math.min(Math.max(8, y), window.innerHeight - CARD_H - 8);
|
||||
|
||||
const displayName = profile?.displayName ?? '?';
|
||||
const username = profile?.username ?? '';
|
||||
const state = presence?.state ?? 'offline';
|
||||
const showPresence = state !== 'invisible';
|
||||
const statusMessage = presence?.statusMessage?.trim() ?? '';
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
data-user-popover
|
||||
role="dialog"
|
||||
aria-label={displayName}
|
||||
style={{ left, top, width: CARD_W }}
|
||||
className="fixed z-[80] flex flex-col gap-3 rounded-xl border border-line bg-surface-2/95 p-4 shadow-xl backdrop-blur-md"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
url={profile?.avatarUrl ?? null}
|
||||
displayName={displayName}
|
||||
className="h-14 w-14 text-lg"
|
||||
/>
|
||||
{showPresence && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
'absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full ring-2 ring-surface-2 ' +
|
||||
(PRESENCE_DOT[state] ?? PRESENCE_DOT.offline)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-display text-base font-semibold text-fg">
|
||||
{displayName}
|
||||
</p>
|
||||
{username && (
|
||||
<p className="truncate text-xs text-fg-muted">@{username}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{statusMessage && state !== 'offline' && (
|
||||
<p className="rounded-md bg-surface-3 px-2.5 py-1.5 text-xs italic text-fg-muted">
|
||||
{statusMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{onStartDm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onStartDm(userId);
|
||||
onClose();
|
||||
}}
|
||||
className="inline-flex cursor-pointer items-center justify-center rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110"
|
||||
>
|
||||
Nachricht senden
|
||||
</button>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -519,86 +519,53 @@ export function AddUserIcon(props: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Soft-N mark: rounded-square violet tile with a stylised "N" glyph. Used
|
||||
// wherever the app needs a standalone icon (sidebar rail, auth screen,
|
||||
// favicon). Colour decisions sit inside the SVG so consumers just size the
|
||||
// element via `className`.
|
||||
export function LogoMark(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="logo-hex-clip">
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<polygon
|
||||
points="32,5 57,19 57,45 32,59 7,45 7,19"
|
||||
fill="#2e1065"
|
||||
/>
|
||||
<g clipPath="url(#logo-hex-clip)">
|
||||
<path
|
||||
d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z"
|
||||
fill="#7c4dff"
|
||||
/>
|
||||
<path
|
||||
d="M-4 32 Q 16 18 32 32 T 68 32"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
</g>
|
||||
<polygon
|
||||
points="32,5 57,19 57,45 32,59 7,45 7,19"
|
||||
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa" />
|
||||
<path
|
||||
d="M20 46 V22 a3 3 0 0 1 5.5 -1.8 L 40 43 a2 2 0 0 0 4 -1.2 V22 a3 3 0 0 0 -6 0"
|
||||
fill="none"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth="1.5"
|
||||
opacity="0.4"
|
||||
stroke="#fff"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Full lockup: hex icon + "Netralax" wordmark. `tone` decides text colour:
|
||||
// "dark" = white text (use on dark background), "light" = black text.
|
||||
// Full lockup: soft-N mark + "Netralax" wordmark. `tone` decides text colour:
|
||||
// "dark" = white text (use on dark background), "light" = near-black.
|
||||
export function LogoLockup({
|
||||
tone = 'dark',
|
||||
...props
|
||||
}: IconProps & { tone?: 'dark' | 'light' }) {
|
||||
const textFill = tone === 'dark' ? '#ffffff' : '#0F172A';
|
||||
const textFill = tone === 'dark' ? '#ffffff' : '#14121c';
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 260 64"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="logo-lockup-hex-clip">
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065" />
|
||||
<g clipPath="url(#logo-lockup-hex-clip)">
|
||||
<path
|
||||
d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z"
|
||||
fill="#7c4dff"
|
||||
/>
|
||||
<path
|
||||
d="M-4 32 Q 16 18 32 32 T 68 32"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
</g>
|
||||
<polygon
|
||||
points="32,5 57,19 57,45 32,59 7,45 7,19"
|
||||
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa" />
|
||||
<path
|
||||
d="M20 46 V22 a3 3 0 0 1 5.5 -1.8 L 40 43 a2 2 0 0 0 4 -1.2 V22 a3 3 0 0 0 -6 0"
|
||||
fill="none"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth="1.5"
|
||||
opacity="0.4"
|
||||
stroke="#fff"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<text
|
||||
x="78"
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { useAuth } from './AuthContext';
|
||||
import { useConversationsContext } from './ConversationsContext';
|
||||
import { playEndBeep, playJoinBeep, playLeaveBeep } from '../lib/callSounds';
|
||||
import { setCallWakeLock } from '../lib/wakeLock';
|
||||
import { notify } from '../lib/osNotify';
|
||||
import {
|
||||
isTauriRuntime,
|
||||
@@ -40,8 +41,13 @@ import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
|
||||
import {
|
||||
getAudioQualityParams,
|
||||
getAudioSettings,
|
||||
subscribeAudioSettings,
|
||||
updateAudioSettings,
|
||||
} from '../lib/audioSettings';
|
||||
import {
|
||||
applyBackgroundBlurToLocal,
|
||||
removeBackgroundBlurFromLocal,
|
||||
} from '../lib/videoBlur';
|
||||
import {
|
||||
createCallE2EE,
|
||||
getCallE2EESettings,
|
||||
@@ -492,14 +498,14 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
r.on(RoomEvent.ParticipantConnected, () => {
|
||||
setRemoteParticipants(Array.from(r.remoteParticipants.values()));
|
||||
void playJoinBeep();
|
||||
if (presenceRef.current !== 'dnd') void playJoinBeep();
|
||||
markConnectedIfReady(r, conversationId, mediaKind, callId);
|
||||
});
|
||||
|
||||
r.on(RoomEvent.ParticipantDisconnected, () => {
|
||||
const remaining = Array.from(r.remoteParticipants.values());
|
||||
setRemoteParticipants(remaining);
|
||||
void playLeaveBeep();
|
||||
if (presenceRef.current !== 'dnd') void playLeaveBeep();
|
||||
// Alone in the room while connected — start the solo-timeout.
|
||||
if (
|
||||
stateRef.current.kind === 'connected' &&
|
||||
@@ -627,7 +633,11 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
setIsE2EEActive(false);
|
||||
}
|
||||
try {
|
||||
const inputId = getAudioSettings().inputDeviceId;
|
||||
const audioPrefs = getAudioSettings();
|
||||
const inputId = audioPrefs.inputDeviceId;
|
||||
// Noise suppression: user-preference wins over the quality preset so
|
||||
// hifi-mode users can still enable NS when they need to cut room hum.
|
||||
const nsEffective = audioPrefs.noiseSuppression && aParams.noiseSuppression;
|
||||
// Grab the raw mic ourselves instead of going through LiveKit's
|
||||
// setMicrophoneEnabled. The resulting MediaStreamTrack is routed
|
||||
// through createMicPipeline, which mixes in soundboard buffers and
|
||||
@@ -636,7 +646,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
const rawStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: aParams.echoCancellation,
|
||||
noiseSuppression: aParams.noiseSuppression,
|
||||
noiseSuppression: nsEffective,
|
||||
autoGainControl: aParams.autoGainControl,
|
||||
channelCount: aParams.stereo ? 2 : 1,
|
||||
sampleRate: aParams.sampleRateHz,
|
||||
@@ -947,7 +957,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
try {
|
||||
await lp.setScreenShareEnabled(true, {
|
||||
audio: false,
|
||||
// "Go live" mode — capture system audio alongside the screen when
|
||||
// the user opted in. On hosts that can't fulfil the request the
|
||||
// browser quietly drops it; peers just get video-only, no error.
|
||||
audio: settings.includeSystemAudio,
|
||||
...(ssParams.dims
|
||||
? {
|
||||
resolution: {
|
||||
@@ -1033,6 +1046,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
await lp.setCameraEnabled(nextOn);
|
||||
setIsCameraEnabled(nextOn);
|
||||
if (nextOn && getAudioSettings().videoBackgroundBlur) {
|
||||
void applyBackgroundBlurToLocal(lp);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('setCameraEnabled failed', err);
|
||||
// Permission denied / no camera — keep state in sync with actual
|
||||
@@ -1041,6 +1057,23 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Live-toggle the background-blur processor when the settings flag flips.
|
||||
// Acquiring the MediaPipe model is deferred until first activation to
|
||||
// avoid the 1.5MB download on users who never enable blur.
|
||||
useEffect(() => {
|
||||
return subscribeAudioSettings((s) => {
|
||||
const r = roomRef.current;
|
||||
if (!r) return;
|
||||
const lp = r.localParticipant;
|
||||
if (!lp.isCameraEnabled) return;
|
||||
if (s.videoBackgroundBlur) {
|
||||
void applyBackgroundBlurToLocal(lp);
|
||||
} else {
|
||||
void removeBackgroundBlurFromLocal(lp);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// --- Push-to-talk ------------------------------------------------------
|
||||
// While PTT is active + we're in a connected call, the mic is held off
|
||||
// except while the configured key is pressed. Under Tauri we also register
|
||||
@@ -1337,11 +1370,13 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
// to the pipeline. It disconnects the old source, stops the old track,
|
||||
// and rewires micGain onto the new source — the published track stays
|
||||
// stable so peers don't see a republish.
|
||||
const aParams = getAudioQualityParams(getAudioSettings().quality);
|
||||
const audioPrefs = getAudioSettings();
|
||||
const aParams = getAudioQualityParams(audioPrefs.quality);
|
||||
const nsEffective = audioPrefs.noiseSuppression && aParams.noiseSuppression;
|
||||
const newStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: aParams.echoCancellation,
|
||||
noiseSuppression: aParams.noiseSuppression,
|
||||
noiseSuppression: nsEffective,
|
||||
autoGainControl: aParams.autoGainControl,
|
||||
channelCount: aParams.stereo ? 2 : 1,
|
||||
sampleRate: aParams.sampleRateHz,
|
||||
@@ -1393,6 +1428,17 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [state.kind]);
|
||||
|
||||
// Hold the screen awake while a call is live so long sessions don't get
|
||||
// dropped by display-sleep / OS power-save. Released the moment the call
|
||||
// ends or errors out.
|
||||
useEffect(() => {
|
||||
const callActive =
|
||||
state.kind === 'connected' ||
|
||||
state.kind === 'connecting' ||
|
||||
state.kind === 'outgoing';
|
||||
void setCallWakeLock(callActive);
|
||||
}, [state.kind]);
|
||||
|
||||
// Global Esc: drop out of fullscreen cinema back to grid while in an active
|
||||
// call. Doesn't hangup and doesn't fire when other dialogs would consume it.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { playNotificationTone } from '../lib/notificationSound';
|
||||
import { notify } from '../lib/osNotify';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { updateTrayUnread } from '../lib/trayBadge';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
const LAST_READ_STORAGE_KEY = 'chatapp.conv_last_read';
|
||||
@@ -253,6 +254,12 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
return s;
|
||||
}, [unread]);
|
||||
|
||||
// Mirror unread count into the tray tooltip + dock badge. Runs in Tauri
|
||||
// only; no-op in browser-preview.
|
||||
useEffect(() => {
|
||||
void updateTrayUnread(totalUnread);
|
||||
}, [totalUnread]);
|
||||
|
||||
const value = useMemo<ConversationsContextValue>(
|
||||
() => ({
|
||||
conversations,
|
||||
|
||||
@@ -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.
|
||||
// Applied to every <audio> element we attach via HTMLMediaElement.setSinkId.
|
||||
outputDeviceId: string | null;
|
||||
// RMS threshold (0..1) for the "is this participant talking" ring. Lower =
|
||||
// more sensitive. Default 0.03 catches soft speech without lighting up on
|
||||
// keyboard noise. Users on quiet mics can lower; users in noisy rooms bump up.
|
||||
voiceThreshold: number;
|
||||
// Per-publish DSP toggle. Off lets hifi-style music go through unmodified;
|
||||
// on cleans up voice when the quality preset doesn't already imply it.
|
||||
// Defaults to "follow the quality preset".
|
||||
noiseSuppression: boolean;
|
||||
// Background blur on the local camera track. Lazy — requires
|
||||
// @livekit/track-processors + its MediaPipe selfie-segmentation model
|
||||
// (~1.5MB) which downloads on first activation.
|
||||
videoBackgroundBlur: boolean;
|
||||
}
|
||||
|
||||
const DEFAULTS: AudioSettings = {
|
||||
quality: 'voice',
|
||||
inputDeviceId: null,
|
||||
outputDeviceId: null,
|
||||
voiceThreshold: 0.03,
|
||||
noiseSuppression: true,
|
||||
videoBackgroundBlur: false,
|
||||
};
|
||||
|
||||
export interface AudioQualityParams {
|
||||
@@ -80,6 +95,7 @@ function read(): AudioSettings {
|
||||
return cached;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<AudioSettings>;
|
||||
const rawThreshold = typeof parsed.voiceThreshold === 'number' ? parsed.voiceThreshold : NaN;
|
||||
cached = {
|
||||
quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality,
|
||||
inputDeviceId:
|
||||
@@ -90,6 +106,18 @@ function read(): AudioSettings {
|
||||
typeof parsed.outputDeviceId === 'string' && parsed.outputDeviceId.length > 0
|
||||
? parsed.outputDeviceId
|
||||
: DEFAULTS.outputDeviceId,
|
||||
voiceThreshold:
|
||||
Number.isFinite(rawThreshold) && rawThreshold >= 0.005 && rawThreshold <= 0.2
|
||||
? rawThreshold
|
||||
: DEFAULTS.voiceThreshold,
|
||||
noiseSuppression:
|
||||
typeof parsed.noiseSuppression === 'boolean'
|
||||
? parsed.noiseSuppression
|
||||
: DEFAULTS.noiseSuppression,
|
||||
videoBackgroundBlur:
|
||||
typeof parsed.videoBackgroundBlur === 'boolean'
|
||||
? parsed.videoBackgroundBlur
|
||||
: DEFAULTS.videoBackgroundBlur,
|
||||
};
|
||||
return cached;
|
||||
} catch {
|
||||
|
||||
@@ -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,99 @@
|
||||
// Main-thread wrapper around the decrypt Web Worker.
|
||||
//
|
||||
// Spawns a single worker lazily on first use. Uses request-id correlation so
|
||||
// concurrent batches (e.g. a backfill arriving while a realtime insert
|
||||
// dispatches) can't mix up their results. Falls back to inline decrypt if
|
||||
// the Worker constructor isn't available (non-browser environments, some
|
||||
// strict CSPs).
|
||||
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
export interface DecryptItem {
|
||||
id: string;
|
||||
ciphertext: Uint8Array;
|
||||
nonce: Uint8Array;
|
||||
key: Uint8Array;
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
resolve: (results: DecryptResult[]) => void;
|
||||
reject: (err: unknown) => void;
|
||||
}
|
||||
|
||||
interface DecryptResult {
|
||||
id: string;
|
||||
plaintext: string | null;
|
||||
}
|
||||
|
||||
let worker: Worker | null = null;
|
||||
let workerBroken = false;
|
||||
const pending = new Map<string, Pending>();
|
||||
|
||||
function spawn(): Worker | null {
|
||||
if (workerBroken) return null;
|
||||
if (worker) return worker;
|
||||
try {
|
||||
worker = new Worker(new URL('../workers/decrypt.worker.ts', import.meta.url), {
|
||||
type: 'module',
|
||||
});
|
||||
worker.addEventListener('message', (ev: MessageEvent) => {
|
||||
const data = ev.data as { id?: string; results?: DecryptResult[] };
|
||||
if (!data.id) return;
|
||||
const entry = pending.get(data.id);
|
||||
if (!entry) return;
|
||||
pending.delete(data.id);
|
||||
entry.resolve(data.results ?? []);
|
||||
});
|
||||
worker.addEventListener('error', (ev) => {
|
||||
console.warn('decrypt worker error, falling back to inline', ev.message);
|
||||
workerBroken = true;
|
||||
worker?.terminate();
|
||||
worker = null;
|
||||
for (const entry of pending.values()) {
|
||||
entry.reject(new Error('decrypt worker crashed'));
|
||||
}
|
||||
pending.clear();
|
||||
});
|
||||
return worker;
|
||||
} catch (err: unknown) {
|
||||
console.warn('decrypt worker spawn failed, falling back to inline', err);
|
||||
workerBroken = true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function decryptBatch(items: DecryptItem[]): Promise<DecryptResult[]> {
|
||||
if (items.length === 0) return [];
|
||||
const w = spawn();
|
||||
if (!w) return inlineDecrypt(items);
|
||||
|
||||
return new Promise<DecryptResult[]>((resolve, reject) => {
|
||||
const id = crypto.randomUUID();
|
||||
pending.set(id, { resolve, reject });
|
||||
try {
|
||||
w.postMessage({ id, items });
|
||||
} catch (err: unknown) {
|
||||
pending.delete(id);
|
||||
// postMessage can fail if the Uint8Array view is detached — retry inline.
|
||||
console.warn('decrypt worker postMessage failed, using inline', err);
|
||||
inlineDecrypt(items).then(resolve, reject);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback path — same semantics as the worker but on the main thread. Used
|
||||
// when the worker failed to spawn or crashed mid-session.
|
||||
async function inlineDecrypt(items: DecryptItem[]): Promise<DecryptResult[]> {
|
||||
await sodium.ready;
|
||||
return items.map((item) => {
|
||||
try {
|
||||
const plain = sodium.crypto_secretbox_open_easy(item.ciphertext, item.nonce, item.key);
|
||||
return {
|
||||
id: item.id,
|
||||
plaintext: new TextDecoder('utf-8', { fatal: false }).decode(plain),
|
||||
};
|
||||
} catch {
|
||||
return { id: item.id, plaintext: null };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { getCryptoBackend } from '@chat-app/shared/crypto';
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
import { pwhashArgon2id } from './nativeCryptoOps';
|
||||
|
||||
// Encrypts/decrypts the device private key with a user-provided passphrase
|
||||
// so the backup string can be safely written down or stored in a password
|
||||
// manager. Uses Argon2id (libsodium crypto_pwhash) for the KDF and
|
||||
@@ -36,15 +38,13 @@ function unb64url(s: string): Uint8Array {
|
||||
return out;
|
||||
}
|
||||
|
||||
async function deriveKey(passphrase: string, salt: Uint8Array, sodiumLib: typeof sodium): Promise<Uint8Array> {
|
||||
return sodiumLib.crypto_pwhash(
|
||||
KEY_LEN,
|
||||
passphrase,
|
||||
async function deriveKey(passphrase: string, salt: Uint8Array, _sodiumLib: typeof sodium): Promise<Uint8Array> {
|
||||
return pwhashArgon2id({
|
||||
password: passphrase,
|
||||
salt,
|
||||
sodiumLib.crypto_pwhash_OPSLIMIT_MODERATE,
|
||||
sodiumLib.crypto_pwhash_MEMLIMIT_MODERATE,
|
||||
sodiumLib.crypto_pwhash_ALG_ARGON2ID13,
|
||||
);
|
||||
outLen: KEY_LEN,
|
||||
preset: 'moderate',
|
||||
});
|
||||
}
|
||||
|
||||
export async function exportDeviceKey(
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Client-side image compression before upload. Downscales huge photos
|
||||
// (phone cameras regularly emit 4000×3000+ at 5MB+) to a size that
|
||||
// actually makes sense for chat display. Preserves aspect ratio.
|
||||
//
|
||||
// Skips animated formats (gif/apng/webp) to keep motion, and skips
|
||||
// already-small files to avoid useless re-encode overhead.
|
||||
|
||||
const MAX_DIM = 2048;
|
||||
const TARGET_QUALITY = 0.85;
|
||||
const SKIP_BELOW_BYTES = 512 * 1024; // 512KB — not worth re-encoding
|
||||
const ANIMATED_MIME = /^image\/(gif|apng|webp)$/;
|
||||
|
||||
export async function compressImage(file: File): Promise<File> {
|
||||
if (!file.type.startsWith('image/')) return file;
|
||||
if (ANIMATED_MIME.test(file.type)) return file;
|
||||
if (file.size < SKIP_BELOW_BYTES) return file;
|
||||
if (typeof createImageBitmap !== 'function') return file;
|
||||
if (typeof OffscreenCanvas !== 'function') return file;
|
||||
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const largest = Math.max(bitmap.width, bitmap.height);
|
||||
const scale = largest > MAX_DIM ? MAX_DIM / largest : 1;
|
||||
const w = Math.max(1, Math.round(bitmap.width * scale));
|
||||
const h = Math.max(1, Math.round(bitmap.height * scale));
|
||||
const canvas = new OffscreenCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
bitmap.close();
|
||||
return file;
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0, w, h);
|
||||
bitmap.close();
|
||||
const blob = await canvas.convertToBlob({
|
||||
type: 'image/webp',
|
||||
quality: TARGET_QUALITY,
|
||||
});
|
||||
// If the re-encoded blob is actually larger (small PNGs can expand as
|
||||
// WebP), keep the original.
|
||||
if (blob.size >= file.size) return file;
|
||||
const name = renameToWebp(file.name);
|
||||
return new File([blob], name, { type: 'image/webp', lastModified: file.lastModified });
|
||||
} catch (err: unknown) {
|
||||
console.warn('compressImage failed — keeping original', err);
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
function renameToWebp(original: string): string {
|
||||
const dot = original.lastIndexOf('.');
|
||||
const base = dot > 0 ? original.slice(0, dot) : original;
|
||||
return base + '.webp';
|
||||
}
|
||||
|
||||
export async function compressImages(files: File[]): Promise<File[]> {
|
||||
return Promise.all(files.map((f) => compressImage(f)));
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Local SQLite message cache. Persists decrypted messages so startup +
|
||||
// conversation-switch hydrate from disk instantly instead of waiting on a
|
||||
// network round-trip + batch decrypt.
|
||||
//
|
||||
// The cache is per-device-local (app-local-data dir, same trust boundary as
|
||||
// the device private key). Plaintext is stored because the threat model
|
||||
// already assumes local-disk access means compromise — same as the existing
|
||||
// outbox + secret store. Ciphertext + nonce are kept alongside so a future
|
||||
// key-rotation migration can re-derive plaintext when a new bundle arrives.
|
||||
|
||||
import type { DecryptedMessage } from '@chat-app/shared/chat';
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
interface Database {
|
||||
execute: (sql: string, values?: unknown[]) => Promise<{ rowsAffected?: number }>;
|
||||
select: <T>(sql: string, values?: unknown[]) => Promise<T[]>;
|
||||
close: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface DatabaseStatic {
|
||||
load: (path: string) => Promise<Database>;
|
||||
}
|
||||
|
||||
const DB_NAME = 'chatapp-cache.db';
|
||||
|
||||
let dbPromise: Promise<Database | null> | null = null;
|
||||
|
||||
async function loadDb(): Promise<Database | null> {
|
||||
if (!isTauriRuntime()) return null;
|
||||
try {
|
||||
// Dynamic import so browser-preview builds don't choke on the tauri
|
||||
// plugin module. Vite will statically analyse this + split it into a
|
||||
// chunk that only loads inside Tauri.
|
||||
const mod = (await import('@tauri-apps/plugin-sql')) as {
|
||||
default: DatabaseStatic;
|
||||
Database?: DatabaseStatic;
|
||||
};
|
||||
const DatabaseCtor: DatabaseStatic = mod.default ?? (mod as { Database: DatabaseStatic }).Database;
|
||||
const db = await DatabaseCtor.load('sqlite:' + DB_NAME);
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
sender_id TEXT NOT NULL,
|
||||
sender_device_id TEXT,
|
||||
reply_to_id TEXT,
|
||||
edited_at TEXT,
|
||||
deleted_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
plaintext TEXT
|
||||
);
|
||||
`);
|
||||
await db.execute(
|
||||
'CREATE INDEX IF NOT EXISTS idx_msgs_conv_created ON messages(conversation_id, created_at);',
|
||||
);
|
||||
// FTS5 virtual table for instant full-text search across the cache.
|
||||
// Keeps only the searchable text columns (plaintext + sender), keyed
|
||||
// by the message id so we can join back to the main row. Triggers
|
||||
// mirror inserts/updates/deletes so the index never drifts.
|
||||
//
|
||||
// Falls back gracefully on FTS5-less builds: the IF NOT EXISTS keeps
|
||||
// the call idempotent, and the surrounding try/catch already handles
|
||||
// a CREATE failure by skipping the whole cache.
|
||||
try {
|
||||
await db.execute(
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||
message_id UNINDEXED,
|
||||
conversation_id UNINDEXED,
|
||||
plaintext,
|
||||
tokenize = 'unicode61 remove_diacritics 2'
|
||||
);`,
|
||||
);
|
||||
await db.execute(
|
||||
`CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
|
||||
INSERT INTO messages_fts (message_id, conversation_id, plaintext)
|
||||
VALUES (new.id, new.conversation_id, COALESCE(new.plaintext, ''));
|
||||
END;`,
|
||||
);
|
||||
await db.execute(
|
||||
`CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
|
||||
DELETE FROM messages_fts WHERE message_id = old.id;
|
||||
END;`,
|
||||
);
|
||||
await db.execute(
|
||||
`CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
|
||||
DELETE FROM messages_fts WHERE message_id = old.id;
|
||||
INSERT INTO messages_fts (message_id, conversation_id, plaintext)
|
||||
VALUES (new.id, new.conversation_id, COALESCE(new.plaintext, ''));
|
||||
END;`,
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
console.warn('FTS5 init failed — search falls back to in-memory scan', err);
|
||||
}
|
||||
return db;
|
||||
} catch (err: unknown) {
|
||||
console.warn('messageCache init failed — falling back to memory-only', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getDb(): Promise<Database | null> {
|
||||
if (!dbPromise) dbPromise = loadDb();
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — all calls are no-ops when the cache isn't available (browser
|
||||
// preview, init error, etc.), so callers never need to guard.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
sender_id: string;
|
||||
sender_device_id: string | null;
|
||||
reply_to_id: string | null;
|
||||
edited_at: string | null;
|
||||
deleted_at: string | null;
|
||||
created_at: string;
|
||||
plaintext: string | null;
|
||||
}
|
||||
|
||||
export async function loadCachedMessages(
|
||||
conversationId: string,
|
||||
limit = 500,
|
||||
): Promise<DecryptedMessage[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
try {
|
||||
const rows = await db.select<Row>(
|
||||
'SELECT * FROM messages WHERE conversation_id = $1 ORDER BY created_at DESC LIMIT $2',
|
||||
[conversationId, limit],
|
||||
);
|
||||
return rows
|
||||
.reverse()
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
conversationId: r.conversation_id,
|
||||
senderId: r.sender_id,
|
||||
senderDeviceId: r.sender_device_id,
|
||||
replyToId: r.reply_to_id,
|
||||
editedAt: r.edited_at,
|
||||
deletedAt: r.deleted_at,
|
||||
createdAt: r.created_at,
|
||||
plaintext: r.plaintext,
|
||||
}));
|
||||
} catch (err: unknown) {
|
||||
console.warn('loadCachedMessages failed', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistMessages(
|
||||
conversationId: string,
|
||||
messages: DecryptedMessage[],
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db || messages.length === 0) return;
|
||||
try {
|
||||
// Replace strategy: each message is keyed by id so upsert is "INSERT OR
|
||||
// REPLACE". Attachments + forward chains are part of `plaintext` JSON so
|
||||
// nothing lives outside this table.
|
||||
for (const m of messages) {
|
||||
await db.execute(
|
||||
`INSERT OR REPLACE INTO messages
|
||||
(id, conversation_id, sender_id, sender_device_id, reply_to_id,
|
||||
edited_at, deleted_at, created_at, plaintext)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
m.id,
|
||||
conversationId,
|
||||
m.senderId,
|
||||
m.senderDeviceId,
|
||||
m.replyToId,
|
||||
m.editedAt,
|
||||
m.deletedAt,
|
||||
m.createdAt,
|
||||
m.plaintext,
|
||||
],
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('persistMessages failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCachedMessage(id: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
try {
|
||||
await db.execute('DELETE FROM messages WHERE id = $1', [id]);
|
||||
} catch (err: unknown) {
|
||||
console.warn('deleteCachedMessage failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Full-text search across cached messages. Returns rows in newest-first
|
||||
// order; caller maps to DecryptedMessage. Empty result on cache-miss or
|
||||
// FTS5 not available — caller should fall back to in-memory regex.
|
||||
export async function searchCachedMessages(
|
||||
conversationId: string,
|
||||
query: string,
|
||||
limit = 200,
|
||||
): Promise<DecryptedMessage[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length === 0) return [];
|
||||
// Sanitize for FTS5 MATCH syntax. Strip quotes + control chars; wrap in
|
||||
// an OR over each token with a trailing wildcard so partial words match.
|
||||
// Hyphens + colons are FTS5 operators so we drop them.
|
||||
const tokens = trimmed
|
||||
.replace(/["'\u0000-\u001f]/g, ' ')
|
||||
.replace(/[-:^]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
if (tokens.length === 0) return [];
|
||||
const matchExpr = tokens.map((t) => '"' + t.replace(/"/g, '') + '"*').join(' AND ');
|
||||
try {
|
||||
const rows = await db.select<Row>(
|
||||
`SELECT m.* FROM messages m
|
||||
JOIN messages_fts f ON f.message_id = m.id
|
||||
WHERE f.conversation_id = $1 AND messages_fts MATCH $2
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $3`,
|
||||
[conversationId, matchExpr, limit],
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
conversationId: r.conversation_id,
|
||||
senderId: r.sender_id,
|
||||
senderDeviceId: r.sender_device_id,
|
||||
replyToId: r.reply_to_id,
|
||||
editedAt: r.edited_at,
|
||||
deletedAt: r.deleted_at,
|
||||
createdAt: r.created_at,
|
||||
plaintext: r.plaintext,
|
||||
}));
|
||||
} catch (err: unknown) {
|
||||
console.warn('searchCachedMessages failed', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Housekeeping — run once per session to bound the cache size. Keeps the
|
||||
// latest KEEP_PER_CONV messages per conversation.
|
||||
const KEEP_PER_CONV = 1000;
|
||||
|
||||
export async function pruneCache(): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
try {
|
||||
await db.execute(
|
||||
`DELETE FROM messages WHERE id IN (
|
||||
SELECT id FROM messages m
|
||||
WHERE (
|
||||
SELECT COUNT(*) FROM messages m2
|
||||
WHERE m2.conversation_id = m.conversation_id
|
||||
AND m2.created_at > m.created_at
|
||||
) >= $1
|
||||
)`,
|
||||
[KEEP_PER_CONV],
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
console.warn('pruneCache failed', err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Native accelerators for the crypto ops whose JS/WASM runtime is slow
|
||||
// enough to matter. The sync `CryptoBackend` interface stays on WASM because
|
||||
// migrating every call-site to async would break most of the shared crypto
|
||||
// code for no runtime win — per-message AEAD already runs in a worker and
|
||||
// its 15µs-vs-80µs delta is lost in IPC overhead (~40µs round-trip).
|
||||
//
|
||||
// The two ops we actually accelerate:
|
||||
// * Argon2id password hashing (vault unlock, backup import/export)
|
||||
// * Randomness (fast enough inline, but we expose it so future Rust-only
|
||||
// flows don't need to route through the WASM backend).
|
||||
//
|
||||
// All fall back to WASM when the Tauri runtime isn't present (browser
|
||||
// preview, dev server) so the same code paths keep working.
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import _sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
function bytesToB64(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s);
|
||||
}
|
||||
|
||||
function b64ToBytes(s: string): Uint8Array {
|
||||
const bin = atob(s);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Environment flag. Default ON in Tauri, OFF in browser-preview. Can be
|
||||
// force-disabled by setting VITE_USE_NATIVE_CRYPTO=false for debugging
|
||||
// a regression against the WASM baseline.
|
||||
const flagEnabled = (() => {
|
||||
const raw = (import.meta as unknown as { env?: { VITE_USE_NATIVE_CRYPTO?: string } })
|
||||
.env?.VITE_USE_NATIVE_CRYPTO;
|
||||
if (raw === 'false' || raw === '0') return false;
|
||||
return true;
|
||||
})();
|
||||
|
||||
function nativeAvailable(): boolean {
|
||||
return flagEnabled && isTauriRuntime();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Argon2id password hashing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PwhashOpts {
|
||||
password: string;
|
||||
salt: Uint8Array;
|
||||
outLen: number;
|
||||
preset?: 'interactive' | 'moderate' | 'sensitive';
|
||||
}
|
||||
|
||||
export async function pwhashArgon2id(opts: PwhashOpts): Promise<Uint8Array> {
|
||||
if (nativeAvailable()) {
|
||||
try {
|
||||
const b64 = await invoke<string>('crypto_pwhash', {
|
||||
args: {
|
||||
password: opts.password,
|
||||
saltB64: bytesToB64(opts.salt),
|
||||
outLen: opts.outLen,
|
||||
preset: opts.preset ?? 'moderate',
|
||||
},
|
||||
});
|
||||
return b64ToBytes(b64);
|
||||
} catch (err: unknown) {
|
||||
// Tauri command not registered (e.g. older build) or unexpected failure.
|
||||
// Fall through to WASM so the app stays functional.
|
||||
console.warn('[nativeCryptoOps] pwhash native failed, falling back', err);
|
||||
}
|
||||
}
|
||||
await _sodium.ready;
|
||||
const s = _sodium;
|
||||
const presetPair = (() => {
|
||||
switch (opts.preset ?? 'moderate') {
|
||||
case 'interactive':
|
||||
return [s.crypto_pwhash_OPSLIMIT_INTERACTIVE, s.crypto_pwhash_MEMLIMIT_INTERACTIVE];
|
||||
case 'sensitive':
|
||||
return [s.crypto_pwhash_OPSLIMIT_SENSITIVE, s.crypto_pwhash_MEMLIMIT_SENSITIVE];
|
||||
default:
|
||||
return [s.crypto_pwhash_OPSLIMIT_MODERATE, s.crypto_pwhash_MEMLIMIT_MODERATE];
|
||||
}
|
||||
})();
|
||||
return s.crypto_pwhash(
|
||||
opts.outLen,
|
||||
opts.password,
|
||||
opts.salt,
|
||||
presetPair[0]!,
|
||||
presetPair[1]!,
|
||||
s.crypto_pwhash_ALG_ARGON2ID13,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Randombytes — native-first, WASM fallback. Used for non-hot-path nonces
|
||||
// and tokens; inline callers that need sync random should stay on the
|
||||
// backend interface.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function randomBytesAsync(len: number): Promise<Uint8Array> {
|
||||
if (nativeAvailable()) {
|
||||
try {
|
||||
const b64 = await invoke<string>('crypto_random_bytes', { len });
|
||||
return b64ToBytes(b64);
|
||||
} catch (err: unknown) {
|
||||
console.warn('[nativeCryptoOps] random native failed, falling back', err);
|
||||
}
|
||||
}
|
||||
await _sodium.ready;
|
||||
return _sodium.randombytes_buf(len);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Secretbox + box accelerators — exposed but not auto-adopted. Call-sites
|
||||
// can migrate individually if they measure a win. Round-trip IPC is ~40µs
|
||||
// so per-op wins under 100µs usually lose to the WASM path.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function secretboxEncryptNative(
|
||||
plaintext: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
key: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
if (!nativeAvailable()) throw new Error('native crypto unavailable');
|
||||
const b64 = await invoke<string>('crypto_secretbox_encrypt', {
|
||||
plaintextB64: bytesToB64(plaintext),
|
||||
nonceB64: bytesToB64(nonce),
|
||||
keyB64: bytesToB64(key),
|
||||
});
|
||||
return b64ToBytes(b64);
|
||||
}
|
||||
|
||||
export async function secretboxDecryptNative(
|
||||
ciphertext: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
key: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
if (!nativeAvailable()) throw new Error('native crypto unavailable');
|
||||
const b64 = await invoke<string>('crypto_secretbox_decrypt', {
|
||||
ciphertextB64: bytesToB64(ciphertext),
|
||||
nonceB64: bytesToB64(nonce),
|
||||
keyB64: bytesToB64(key),
|
||||
});
|
||||
return b64ToBytes(b64);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Thin JS wrapper around the Rust LiveKit bridge commands + events.
|
||||
// Mirrors the subset of `livekit-client` that CallContext actually uses
|
||||
// so the adapter can be swapped behind the `VITE_USE_RUST_LIVEKIT` flag.
|
||||
//
|
||||
// Phase B.1 — only connect/disconnect/data-channel/state events wired.
|
||||
// Mic, camera, screen-share, active-speakers, video rendering ship in
|
||||
// later phases. Components that call missing methods get a typed
|
||||
// "not implemented" error so regressions surface immediately.
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
export const rustLivekitFlag = (() => {
|
||||
const raw = (import.meta as unknown as { env?: { VITE_USE_RUST_LIVEKIT?: string } })
|
||||
.env?.VITE_USE_RUST_LIVEKIT;
|
||||
return raw === 'true' || raw === '1';
|
||||
})();
|
||||
|
||||
export function isRustLivekitAvailable(): boolean {
|
||||
return rustLivekitFlag && isTauriRuntime();
|
||||
}
|
||||
|
||||
export type NativeRoomState =
|
||||
| { state: 'connecting' }
|
||||
| { state: 'connected' }
|
||||
| { state: 'disconnected' };
|
||||
|
||||
export interface NativeParticipantEvent {
|
||||
identity: string;
|
||||
name?: string | undefined;
|
||||
}
|
||||
|
||||
export interface NativeDataEvent {
|
||||
identity: string;
|
||||
payloadB64: string;
|
||||
reliable: boolean;
|
||||
}
|
||||
|
||||
type Listener<T> = (payload: T) => void;
|
||||
|
||||
export class NativeRoom {
|
||||
private unlistens: UnlistenFn[] = [];
|
||||
private stateListeners = new Set<Listener<NativeRoomState>>();
|
||||
private joinListeners = new Set<Listener<NativeParticipantEvent>>();
|
||||
private leaveListeners = new Set<Listener<NativeParticipantEvent>>();
|
||||
private dataListeners = new Set<Listener<NativeDataEvent>>();
|
||||
|
||||
async connect(url: string, token: string): Promise<void> {
|
||||
if (!isRustLivekitAvailable()) {
|
||||
throw new Error('Rust LiveKit backend not available');
|
||||
}
|
||||
await this.subscribeEvents();
|
||||
try {
|
||||
await invoke('livekit_connect', { args: { url, token } });
|
||||
} catch (err: unknown) {
|
||||
await this.teardown();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
try {
|
||||
await invoke('livekit_disconnect');
|
||||
} finally {
|
||||
await this.teardown();
|
||||
}
|
||||
}
|
||||
|
||||
async sendData(payload: Uint8Array, reliable: boolean): Promise<void> {
|
||||
await invoke('livekit_send_data', {
|
||||
payloadB64: bytesToB64(payload),
|
||||
reliable,
|
||||
});
|
||||
}
|
||||
|
||||
onRoomState(fn: Listener<NativeRoomState>): () => void {
|
||||
this.stateListeners.add(fn);
|
||||
return () => this.stateListeners.delete(fn);
|
||||
}
|
||||
onParticipantJoined(fn: Listener<NativeParticipantEvent>): () => void {
|
||||
this.joinListeners.add(fn);
|
||||
return () => this.joinListeners.delete(fn);
|
||||
}
|
||||
onParticipantLeft(fn: Listener<NativeParticipantEvent>): () => void {
|
||||
this.leaveListeners.add(fn);
|
||||
return () => this.leaveListeners.delete(fn);
|
||||
}
|
||||
onDataReceived(fn: Listener<NativeDataEvent>): () => void {
|
||||
this.dataListeners.add(fn);
|
||||
return () => this.dataListeners.delete(fn);
|
||||
}
|
||||
|
||||
private async subscribeEvents(): Promise<void> {
|
||||
// Room state — connected / disconnected.
|
||||
const uState = await listen<NativeRoomState>('livekit:room_state', (evt) => {
|
||||
for (const fn of this.stateListeners) fn(evt.payload);
|
||||
});
|
||||
const uJoined = await listen<NativeParticipantEvent>('livekit:participant_joined', (evt) => {
|
||||
for (const fn of this.joinListeners) fn(evt.payload);
|
||||
});
|
||||
const uLeft = await listen<NativeParticipantEvent>('livekit:participant_left', (evt) => {
|
||||
for (const fn of this.leaveListeners) fn(evt.payload);
|
||||
});
|
||||
const uData = await listen<NativeDataEvent>('livekit:data_received', (evt) => {
|
||||
for (const fn of this.dataListeners) fn(evt.payload);
|
||||
});
|
||||
this.unlistens.push(uState, uJoined, uLeft, uData);
|
||||
}
|
||||
|
||||
private async teardown(): Promise<void> {
|
||||
for (const unlisten of this.unlistens) {
|
||||
try {
|
||||
unlisten();
|
||||
} catch {
|
||||
/* unlistens become no-op after first call */
|
||||
}
|
||||
}
|
||||
this.unlistens = [];
|
||||
this.stateListeners.clear();
|
||||
this.joinListeners.clear();
|
||||
this.leaveListeners.clear();
|
||||
this.dataListeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function bytesToB64(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s);
|
||||
}
|
||||
@@ -24,12 +24,18 @@ export interface ScreenShareSettings {
|
||||
displaySurface: DisplaySurfaceHint;
|
||||
// User-chosen framerate. `null` falls back to preset's default.
|
||||
framerateOverride: number | null;
|
||||
// Include system audio ("go live" style). On some hosts getDisplayMedia
|
||||
// can't capture system audio (macOS without special entitlements, some
|
||||
// Linux setups). If the browser ignores the `audio: true` request we
|
||||
// silently fall through to a video-only share.
|
||||
includeSystemAudio: boolean;
|
||||
}
|
||||
|
||||
const DEFAULTS: ScreenShareSettings = {
|
||||
preset: 'auto',
|
||||
displaySurface: null,
|
||||
framerateOverride: null,
|
||||
includeSystemAudio: false,
|
||||
};
|
||||
|
||||
export interface PresetParams {
|
||||
@@ -121,6 +127,10 @@ function read(): ScreenShareSettings {
|
||||
typeof parsed.framerateOverride === 'number' && parsed.framerateOverride > 0
|
||||
? parsed.framerateOverride
|
||||
: DEFAULTS.framerateOverride,
|
||||
includeSystemAudio:
|
||||
typeof parsed.includeSystemAudio === 'boolean'
|
||||
? parsed.includeSystemAudio
|
||||
: DEFAULTS.includeSystemAudio,
|
||||
};
|
||||
return cached;
|
||||
} catch {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { appLocalDataDir } from '@tauri-apps/api/path';
|
||||
// is the compact build without Argon2 — vault KDF would error otherwise.
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
import { pwhashArgon2id } from './nativeCryptoOps';
|
||||
|
||||
// Encrypted single-file SecretStore for Tauri. Replaces the flaky
|
||||
// tauri-plugin-stronghold implementation.
|
||||
//
|
||||
@@ -76,16 +78,14 @@ function unb64url(s: string): Uint8Array {
|
||||
return out;
|
||||
}
|
||||
|
||||
async function deriveKey(userId: string, salt: Uint8Array, s: typeof sodium): Promise<Uint8Array> {
|
||||
async function deriveKey(userId: string, salt: Uint8Array, _s: typeof sodium): Promise<Uint8Array> {
|
||||
const passphrase = 'chatapp-vault-v1:' + userId;
|
||||
return s.crypto_pwhash(
|
||||
KEY_LEN,
|
||||
passphrase,
|
||||
return pwhashArgon2id({
|
||||
password: passphrase,
|
||||
salt,
|
||||
s.crypto_pwhash_OPSLIMIT_MODERATE,
|
||||
s.crypto_pwhash_MEMLIMIT_MODERATE,
|
||||
s.crypto_pwhash_ALG_ARGON2ID13,
|
||||
);
|
||||
outLen: KEY_LEN,
|
||||
preset: 'moderate',
|
||||
});
|
||||
}
|
||||
|
||||
async function loadOrCreateVault(userId: string): Promise<VaultState> {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
// Pushes the current aggregate unread count to the Rust-side tray listener.
|
||||
// Rust mirrors it into the tray tooltip + macOS dock badge. No-op in the
|
||||
// browser/dev preview where the Tauri runtime isn't present.
|
||||
export async function updateTrayUnread(count: number): Promise<void> {
|
||||
if (!isTauriRuntime()) return;
|
||||
try {
|
||||
await emit('tray-unread-update', { count: Math.max(0, Math.floor(count)) });
|
||||
} catch (err: unknown) {
|
||||
console.warn('updateTrayUnread failed', err);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { AudioTrack, Participant, Room } from 'livekit-client';
|
||||
import { ParticipantEvent, RoomEvent, Track } from 'livekit-client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getAudioSettings, subscribeAudioSettings } from './audioSettings';
|
||||
|
||||
// Real-time speaking ring driven by Web Audio API AnalyserNodes directly on
|
||||
// each participant's audio MediaStreamTrack. LiveKit's own `audioLevel` +
|
||||
@@ -9,9 +11,10 @@ import { useEffect, useState } from 'react';
|
||||
// lights up within one animation frame of actual speech.
|
||||
//
|
||||
// Hold time of 250ms prevents flicker between words / short pauses.
|
||||
// RMS threshold is user-tunable via audioSettings.voiceThreshold so soft
|
||||
// speakers / noisy rooms can dial in their own sensitivity.
|
||||
const POLL_MS = 50;
|
||||
const HOLD_MS = 250;
|
||||
const THRESHOLD = 0.03; // RMS on 0..1 — tuned against soft speech
|
||||
const FFT_SIZE = 256;
|
||||
|
||||
interface Probe {
|
||||
@@ -80,6 +83,15 @@ function firstAudioTrack(p: Participant): AudioTrack | null {
|
||||
|
||||
export function useActiveSpeakers(room: Room | null): Set<string> {
|
||||
const [ids, setIds] = useState<Set<string>>(() => new Set());
|
||||
const thresholdRef = useRef<number>(getAudioSettings().voiceThreshold);
|
||||
|
||||
// Live-subscribe so the slider in settings takes effect without a call
|
||||
// restart. Reading via ref keeps the tick-loop branch-free.
|
||||
useEffect(() => {
|
||||
return subscribeAudioSettings((s) => {
|
||||
thresholdRef.current = s.voiceThreshold;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) {
|
||||
@@ -142,8 +154,9 @@ export function useActiveSpeakers(room: Room | null): Set<string> {
|
||||
room.remoteParticipants.forEach(syncProbe);
|
||||
|
||||
const now = Date.now();
|
||||
const threshold = thresholdRef.current;
|
||||
for (const [id, probe] of probes) {
|
||||
if (sampleRms(probe) > THRESHOLD) lastActive.set(id, now);
|
||||
if (sampleRms(probe) > threshold) lastActive.set(id, now);
|
||||
}
|
||||
const next = new Set<string>();
|
||||
for (const [id, t] of lastActive) {
|
||||
|
||||
@@ -13,6 +13,13 @@ import {
|
||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { decryptBatch as decryptBatchWorker } from './decryptWorker';
|
||||
import {
|
||||
loadCachedMessages,
|
||||
persistMessages,
|
||||
pruneCache,
|
||||
deleteCachedMessage,
|
||||
} from './messageCache';
|
||||
import {
|
||||
enqueueOutbox,
|
||||
getOutbox,
|
||||
@@ -103,6 +110,10 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
messages,
|
||||
ownDeviceId: deviceId,
|
||||
ownPrivateKey: priv,
|
||||
// Offload the symmetric decrypt + utf-8 decode to a Web Worker so
|
||||
// the main thread stays responsive during bulk operations (initial
|
||||
// fetch, backfill after sleep).
|
||||
aeadBatchDelegate: decryptBatchWorker,
|
||||
});
|
||||
},
|
||||
[deviceId],
|
||||
@@ -120,6 +131,10 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
const rows = await fetchConversationMessages(supabase, conversationId);
|
||||
const decrypted = await decryptBatch(rows);
|
||||
setState({ messages: decrypted, loading: false, error: null });
|
||||
// Persist the fresh batch to the local cache so next conversation
|
||||
// switch / app start can hydrate instantly. Fire-and-forget — cache
|
||||
// write failure is never user-visible.
|
||||
void persistMessages(conversationId, decrypted);
|
||||
} catch (err: unknown) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
@@ -129,6 +144,31 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
}
|
||||
}, [conversationId, decryptBatch]);
|
||||
|
||||
// Hydrate from the local SQLite cache the moment the conversation id
|
||||
// changes. Runs in parallel with the network fetch — whichever resolves
|
||||
// first populates the UI, and `refresh` will replace stale cache data
|
||||
// when the server response lands. On cache-miss this is a ~5ms no-op.
|
||||
useEffect(() => {
|
||||
if (!conversationId) return;
|
||||
let cancelled = false;
|
||||
void loadCachedMessages(conversationId).then((cached) => {
|
||||
if (cancelled || cached.length === 0) return;
|
||||
setState((prev) => {
|
||||
// Don't clobber a fresh server response that already landed.
|
||||
if (prev.messages.length > 0) return prev;
|
||||
return { messages: cached, loading: false, error: null };
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [conversationId]);
|
||||
|
||||
// Prune cache once per app session.
|
||||
useEffect(() => {
|
||||
void pruneCache();
|
||||
}, []);
|
||||
|
||||
// Realtime INSERT handler — refetches the row via REST so we get the
|
||||
// canonical bytea encoding (postgres_changes payloads serialize bytea
|
||||
// differently and decoding them inline is brittle). Then decrypt + append.
|
||||
@@ -296,12 +336,43 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
...prev,
|
||||
messages: prev.messages.filter((m) => m.id !== id),
|
||||
}));
|
||||
void deleteCachedMessage(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId || !userId || !deviceId) return;
|
||||
void refresh();
|
||||
|
||||
// Batch INSERT bursts so a paste / backfill doesn't fire N parallel
|
||||
// refetches + decrypts. If more than BATCH_BURST_THRESHOLD ids arrive
|
||||
// within BATCH_WINDOW_MS, collapse to a single refresh() which pulls
|
||||
// the last 100 in one query — cheaper and keeps order stable. For
|
||||
// lone inserts the per-id path stays so latency is unchanged.
|
||||
const BATCH_WINDOW_MS = 250;
|
||||
const BATCH_BURST_THRESHOLD = 3;
|
||||
let burstBuffer: Array<Record<string, unknown>> = [];
|
||||
let burstTimer: number | null = null;
|
||||
const flushBurst = () => {
|
||||
const buf = burstBuffer;
|
||||
burstBuffer = [];
|
||||
if (burstTimer !== null) {
|
||||
window.clearTimeout(burstTimer);
|
||||
burstTimer = null;
|
||||
}
|
||||
if (buf.length === 0) return;
|
||||
if (buf.length > BATCH_BURST_THRESHOLD) {
|
||||
void refresh();
|
||||
} else {
|
||||
for (const row of buf) void handleInsert(row);
|
||||
}
|
||||
};
|
||||
const queueInsert = (row: Record<string, unknown>) => {
|
||||
burstBuffer.push(row);
|
||||
if (burstTimer === null) {
|
||||
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const channel = supabase
|
||||
.channel('conv:' + conversationId)
|
||||
.on(
|
||||
@@ -314,7 +385,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
},
|
||||
(payload: MessageChangePayload) => {
|
||||
if (payload.eventType === 'INSERT') {
|
||||
void handleInsert(payload.new);
|
||||
queueInsert(payload.new);
|
||||
} else if (payload.eventType === 'UPDATE') {
|
||||
void handleUpdate(payload.new);
|
||||
} else if (payload.eventType === 'DELETE') {
|
||||
@@ -363,6 +434,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
window.addEventListener('online', onAwake);
|
||||
|
||||
return () => {
|
||||
if (burstTimer !== null) window.clearTimeout(burstTimer);
|
||||
document.removeEventListener('visibilitychange', onAwake);
|
||||
window.removeEventListener('online', onAwake);
|
||||
void supabase.removeChannel(channel);
|
||||
@@ -401,6 +473,35 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
const priv = privateKeyRef.current;
|
||||
if (!priv) throw new Error('private key not loaded');
|
||||
|
||||
// Slash-command: /tempmsg <seconds> <text> sends an ephemeral message
|
||||
// that the sender auto-deletes after the window elapses. Both peers
|
||||
// see the countdown via the expireMs field embedded in the plaintext
|
||||
// payload — no server support required.
|
||||
const tempMatch = /^\/tempmsg\s+(\d+)\s+([\s\S]+)$/i.exec(trimmed);
|
||||
if (tempMatch && images.length === 0) {
|
||||
const seconds = Math.min(3600, Math.max(5, parseInt(tempMatch[1]!, 10)));
|
||||
const body = tempMatch[2]!.trim();
|
||||
const payload = JSON.stringify({
|
||||
v: 1,
|
||||
type: 'text',
|
||||
text: body,
|
||||
attachments: [],
|
||||
expireMs: seconds * 1000,
|
||||
});
|
||||
try {
|
||||
await sendText(conversationId, userId, deviceId, priv, payload, replyToId);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'send failed';
|
||||
enqueueOutbox({
|
||||
conversationId,
|
||||
text: payload,
|
||||
replyToId,
|
||||
error: msg,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Text-only path is retryable — if the network is down or the server
|
||||
// rejects transiently, stash in the outbox and keep the UI optimistic.
|
||||
// Attachments can't be deferred (large payloads, uploaded separately),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Background-blur processor wrapper. LiveKit's `@livekit/track-processors`
|
||||
// ships a MediaPipe-based selfie-segmentation pipeline that keeps the
|
||||
// foreground sharp and blurs the background. The model (~1.5MB) downloads
|
||||
// lazily on first activation, so users who never enable blur don't pay
|
||||
// for it.
|
||||
//
|
||||
// attach/detach hide behind a guard so repeated toggles don't create a
|
||||
// stack of processors — `setProcessor(null)` tears down the WebGL context
|
||||
// and frees the GPU surface.
|
||||
|
||||
import type { LocalParticipant, LocalVideoTrack } from 'livekit-client';
|
||||
import { Track } from 'livekit-client';
|
||||
|
||||
let cachedProcessor: unknown = null;
|
||||
|
||||
async function getProcessor(): Promise<unknown> {
|
||||
if (cachedProcessor) return cachedProcessor;
|
||||
const mod = (await import('@livekit/track-processors')) as {
|
||||
BackgroundBlur?: (radius?: number) => unknown;
|
||||
};
|
||||
if (!mod.BackgroundBlur) {
|
||||
throw new Error('BackgroundBlur not exported by @livekit/track-processors');
|
||||
}
|
||||
cachedProcessor = mod.BackgroundBlur(12);
|
||||
return cachedProcessor;
|
||||
}
|
||||
|
||||
function getCameraTrack(lp: LocalParticipant): LocalVideoTrack | null {
|
||||
const pub = lp.getTrackPublication(Track.Source.Camera);
|
||||
const track = pub?.track;
|
||||
if (!track) return null;
|
||||
return track as LocalVideoTrack;
|
||||
}
|
||||
|
||||
export async function applyBackgroundBlurToLocal(lp: LocalParticipant): Promise<void> {
|
||||
try {
|
||||
const track = getCameraTrack(lp);
|
||||
if (!track) return;
|
||||
const processor = await getProcessor();
|
||||
// `setProcessor` is declared on LocalVideoTrack; cast because the
|
||||
// processor type lives in a separate module we don't want to strongly
|
||||
// couple to here.
|
||||
await (track as unknown as {
|
||||
setProcessor: (p: unknown) => Promise<void>;
|
||||
}).setProcessor(processor);
|
||||
} catch (err: unknown) {
|
||||
console.warn('applyBackgroundBlur failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeBackgroundBlurFromLocal(lp: LocalParticipant): Promise<void> {
|
||||
try {
|
||||
const track = getCameraTrack(lp);
|
||||
if (!track) return;
|
||||
const setter = (track as unknown as {
|
||||
setProcessor?: (p: unknown) => Promise<void>;
|
||||
stopProcessor?: () => Promise<void>;
|
||||
});
|
||||
if (setter.stopProcessor) {
|
||||
await setter.stopProcessor();
|
||||
} else if (setter.setProcessor) {
|
||||
await setter.setProcessor(null);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('removeBackgroundBlur failed', err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Screen wake-lock for active calls. WebKit + WebView2 both ship the
|
||||
// Screen Wake Lock API (tauri 2.x). Browsers release the sentinel when
|
||||
// the page becomes hidden, so we re-acquire on visibilitychange while a
|
||||
// call is active.
|
||||
|
||||
interface WakeLockSentinelLike {
|
||||
release: () => Promise<void>;
|
||||
addEventListener: (event: string, fn: () => void) => void;
|
||||
}
|
||||
|
||||
interface WakeLockNavigator {
|
||||
wakeLock?: {
|
||||
request: (type: 'screen') => Promise<WakeLockSentinelLike>;
|
||||
};
|
||||
}
|
||||
|
||||
let sentinel: WakeLockSentinelLike | null = null;
|
||||
let active = false;
|
||||
let visibilityBound = false;
|
||||
|
||||
function hasWakeLock(): boolean {
|
||||
return typeof navigator !== 'undefined' && !!(navigator as unknown as WakeLockNavigator).wakeLock;
|
||||
}
|
||||
|
||||
async function acquire(): Promise<void> {
|
||||
if (sentinel || !hasWakeLock()) return;
|
||||
try {
|
||||
const s = await (navigator as unknown as WakeLockNavigator).wakeLock!.request('screen');
|
||||
sentinel = s;
|
||||
s.addEventListener('release', () => {
|
||||
sentinel = null;
|
||||
// If still active (released by the browser because we went hidden),
|
||||
// wait for visibility and re-request.
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
// Permission denied, document not visible, etc. Harmless — the call
|
||||
// still works, the user's screen may dim. Log once.
|
||||
console.warn('wakeLock request failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function release(): Promise<void> {
|
||||
if (!sentinel) return;
|
||||
try {
|
||||
await sentinel.release();
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
sentinel = null;
|
||||
}
|
||||
|
||||
function onVisibility(): void {
|
||||
if (!active) return;
|
||||
if (document.visibilityState === 'visible') {
|
||||
void acquire();
|
||||
}
|
||||
}
|
||||
|
||||
// Called from CallContext when the call enters a state that should keep
|
||||
// the screen awake (connected / connecting). Toggle off again when the
|
||||
// call ends.
|
||||
export async function setCallWakeLock(on: boolean): Promise<void> {
|
||||
active = on;
|
||||
if (on) {
|
||||
if (!visibilityBound) {
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
visibilityBound = true;
|
||||
}
|
||||
await acquire();
|
||||
} else {
|
||||
if (visibilityBound) {
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
visibilityBound = false;
|
||||
}
|
||||
await release();
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
isConversationMuted,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, Outlet, useParams } from 'react-router-dom';
|
||||
|
||||
@@ -227,18 +227,12 @@ function ConversationList({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex-1 overflow-y-auto px-2 pb-2">
|
||||
{items.map((c) => (
|
||||
<li key={c.id}>
|
||||
<ConversationRow
|
||||
item={c}
|
||||
active={c.id === activeId}
|
||||
unreadCount={unread[c.id] ?? 0}
|
||||
onAccept={onAccept}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<VirtualConversationList
|
||||
items={items}
|
||||
activeId={activeId}
|
||||
unread={unread}
|
||||
onAccept={onAccept}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="shrink-0 border-t border-line bg-surface-2 p-2">
|
||||
@@ -248,6 +242,72 @@ function ConversationList({
|
||||
);
|
||||
}
|
||||
|
||||
// Windowed list: renders the first N rows and expands by N whenever a
|
||||
// bottom sentinel scrolls into view. Under the threshold we skip the
|
||||
// machinery entirely because rendering 50 rows costs less than the
|
||||
// overhead of observers + state updates.
|
||||
const VLIST_INITIAL = 40;
|
||||
const VLIST_STEP = 40;
|
||||
|
||||
function VirtualConversationList({
|
||||
items,
|
||||
activeId,
|
||||
unread,
|
||||
onAccept,
|
||||
}: {
|
||||
items: ConversationSummary[];
|
||||
activeId: string | undefined;
|
||||
unread: Record<string, number>;
|
||||
onAccept: (id: string) => void;
|
||||
}) {
|
||||
const [visible, setVisible] = useState<number>(VLIST_INITIAL);
|
||||
const scrollRef = useRef<HTMLUListElement | null>(null);
|
||||
const sentinelRef = useRef<HTMLLIElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(VLIST_INITIAL);
|
||||
}, [items.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length <= visible) return;
|
||||
const node = sentinelRef.current;
|
||||
if (!node) return;
|
||||
const obs = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
setVisible((n) => Math.min(items.length, n + VLIST_STEP));
|
||||
}
|
||||
},
|
||||
{ root: scrollRef.current, rootMargin: '200px 0px' },
|
||||
);
|
||||
obs.observe(node);
|
||||
return () => obs.disconnect();
|
||||
}, [items.length, visible]);
|
||||
|
||||
const slice = items.length <= VLIST_INITIAL ? items : items.slice(0, visible);
|
||||
const hasMore = items.length > slice.length;
|
||||
|
||||
return (
|
||||
<ul ref={scrollRef} className="flex-1 overflow-y-auto px-2 pb-2">
|
||||
{slice.map((c) => (
|
||||
<li key={c.id}>
|
||||
<ConversationRow
|
||||
item={c}
|
||||
active={c.id === activeId}
|
||||
unreadCount={unread[c.id] ?? 0}
|
||||
onAccept={onAccept}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{hasMore && (
|
||||
<li ref={sentinelRef} className="py-2 text-center text-xs text-fg-muted">
|
||||
…
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationRow({
|
||||
item,
|
||||
active,
|
||||
|
||||
@@ -24,12 +24,15 @@ import { InCallPanel } from '../components/InCallPanel';
|
||||
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
||||
import { MentionAutocomplete } from '../components/MentionAutocomplete';
|
||||
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
|
||||
import { UserProfilePopover } from '../components/UserProfilePopover';
|
||||
import { TypingIndicator } from '../components/TypingIndicator';
|
||||
import { VoiceRecorder } from '../components/VoiceRecorder';
|
||||
import type { DecryptedMessage } from '@chat-app/shared/chat';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { compressImages } from '../lib/imageCompress';
|
||||
import { searchCachedMessages } from '../lib/messageCache';
|
||||
import type { OutboxItem } from '../lib/messageOutbox';
|
||||
import { useConversationMessages } from '../lib/useConversationMessages';
|
||||
import { useMessageReactions } from '../lib/useMessageReactions';
|
||||
@@ -45,7 +48,7 @@ export function ConversationPage() {
|
||||
const { t } = useTranslation(['app', 'errors']);
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { session, device } = useAuth();
|
||||
const { conversations, setActiveConversation, markRead } = useConversationsContext();
|
||||
const { conversations, setActiveConversation, markRead, unread } = useConversationsContext();
|
||||
|
||||
const conversation = useMemo(
|
||||
() => conversations.find((c) => c.id === id) ?? null,
|
||||
@@ -145,6 +148,14 @@ export function ConversationPage() {
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const [displayCount, setDisplayCount] = useState<number>(150);
|
||||
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
||||
// Snapshot of the "first-unread-message" id captured once the very first
|
||||
// render of this conversation lands. Stays fixed until the user switches
|
||||
// away so the divider doesn't jump around while new messages arrive.
|
||||
const firstUnreadRef = useRef<string | null>(null);
|
||||
const firstUnreadComputedRef = useRef<boolean>(false);
|
||||
const [profilePopover, setProfilePopover] = useState<
|
||||
{ userId: string; x: number; y: number } | null
|
||||
>(null);
|
||||
const [mentionState, setMentionState] = useState<
|
||||
{ query: string; start: number } | null
|
||||
>(null);
|
||||
@@ -161,8 +172,27 @@ export function ConversationPage() {
|
||||
setSearchOpen(false);
|
||||
setSearchQuery('');
|
||||
setDisplayCount(150);
|
||||
firstUnreadRef.current = null;
|
||||
firstUnreadComputedRef.current = false;
|
||||
}, [id]);
|
||||
|
||||
// On first message-list populate for this conversation, pin the divider
|
||||
// above the oldest-unread message. We only compute once — subsequent
|
||||
// inserts push the divider "further back" visually, which matches
|
||||
// Discord's behaviour.
|
||||
useEffect(() => {
|
||||
if (firstUnreadComputedRef.current) return;
|
||||
if (!id || messages.length === 0) return;
|
||||
const count = unread[id] ?? 0;
|
||||
firstUnreadComputedRef.current = true;
|
||||
if (count === 0 || count > messages.length) {
|
||||
firstUnreadRef.current = null;
|
||||
return;
|
||||
}
|
||||
const boundary = messages[messages.length - count];
|
||||
firstUnreadRef.current = boundary ? boundary.id : null;
|
||||
}, [id, messages, unread]);
|
||||
|
||||
// Expand window when the "load older" sentinel scrolls into view. Doubles
|
||||
// effective window on each trigger so scrolling up quickly converges to
|
||||
// rendering everything.
|
||||
@@ -257,6 +287,32 @@ export function ConversationPage() {
|
||||
searchDateTo !== '',
|
||||
[searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo],
|
||||
);
|
||||
|
||||
// FTS5-backed supplementary results: covers cached messages that aren't in
|
||||
// the currently-loaded window (`messages`). Runs only when there's a text
|
||||
// query — filters alone stay in-memory because they depend on already-
|
||||
// decrypted payload state.
|
||||
const [ftsExtras, setFtsExtras] = useState<DecryptedMessage[]>([]);
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
setFtsExtras([]);
|
||||
return;
|
||||
}
|
||||
const q = searchQuery.trim();
|
||||
if (q.length < 2) {
|
||||
setFtsExtras([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void searchCachedMessages(id, q, 200).then((rows) => {
|
||||
if (cancelled) return;
|
||||
setFtsExtras(rows);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id, searchQuery]);
|
||||
|
||||
const searchMatches = useMemo(() => {
|
||||
if (!searchActive) return [] as DecryptedMessage[];
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
@@ -265,7 +321,26 @@ export function ConversationPage() {
|
||||
const toTs = searchDateTo
|
||||
? new Date(searchDateTo).getTime() + 24 * 3600 * 1000 - 1
|
||||
: null;
|
||||
return messages.filter((m) => {
|
||||
// Union the live `messages` array with any FTS5-only rows not yet
|
||||
// loaded into memory, keyed by id so we don't double-count.
|
||||
const seen = new Set<string>();
|
||||
const pool: DecryptedMessage[] = [];
|
||||
for (const m of messages) {
|
||||
if (!seen.has(m.id)) {
|
||||
seen.add(m.id);
|
||||
pool.push(m);
|
||||
}
|
||||
}
|
||||
for (const m of ftsExtras) {
|
||||
if (!seen.has(m.id)) {
|
||||
seen.add(m.id);
|
||||
pool.push(m);
|
||||
}
|
||||
}
|
||||
pool.sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
);
|
||||
return pool.filter((m) => {
|
||||
if (searchSenderId && m.senderId !== searchSenderId) return false;
|
||||
const created = new Date(m.createdAt).getTime();
|
||||
if (fromTs !== null && created < fromTs) return false;
|
||||
@@ -277,7 +352,16 @@ export function ConversationPage() {
|
||||
if (q && !parsed.text.toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [messages, searchActive, searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo]);
|
||||
}, [
|
||||
messages,
|
||||
ftsExtras,
|
||||
searchActive,
|
||||
searchQuery,
|
||||
searchSenderId,
|
||||
searchAttachmentsOnly,
|
||||
searchDateFrom,
|
||||
searchDateTo,
|
||||
]);
|
||||
|
||||
// Reset/clamp the active match index when the match set changes.
|
||||
useEffect(() => {
|
||||
@@ -353,9 +437,13 @@ export function ConversationPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function ingestFiles(files: File[]) {
|
||||
async function ingestFiles(files: File[]) {
|
||||
// Pre-compression so heavy phone photos (typically 4-8MB) don't bust the
|
||||
// 10MB limit and don't waste storage/bandwidth. Non-image + animated
|
||||
// files are passed through unchanged.
|
||||
const compressed = await compressImages(files);
|
||||
const next: File[] = [];
|
||||
for (const f of files) {
|
||||
for (const f of compressed) {
|
||||
if (f.size > 10 * 1024 * 1024) {
|
||||
setSendError('Datei zu groß (max 10 MB)');
|
||||
continue;
|
||||
@@ -367,7 +455,7 @@ export function ConversationPage() {
|
||||
|
||||
function handleFilesChosen(list: FileList | null) {
|
||||
if (!list) return;
|
||||
ingestFiles(Array.from(list));
|
||||
void ingestFiles(Array.from(list));
|
||||
}
|
||||
|
||||
const { state: callState } = useCall();
|
||||
@@ -405,7 +493,7 @@ export function ConversationPage() {
|
||||
if (!e.dataTransfer?.files?.length) return;
|
||||
e.preventDefault();
|
||||
setIsDraggingFile(false);
|
||||
ingestFiles(Array.from(e.dataTransfer.files));
|
||||
void ingestFiles(Array.from(e.dataTransfer.files));
|
||||
}}
|
||||
>
|
||||
{!callHereActive && (
|
||||
@@ -512,6 +600,18 @@ export function ConversationPage() {
|
||||
(m.senderId !== myId ? (conversation?.peer ?? null) : null);
|
||||
return (
|
||||
<li key={m.id}>
|
||||
{firstUnreadRef.current === m.id && (
|
||||
<div
|
||||
aria-label="Neue Nachrichten"
|
||||
className="my-2 flex items-center gap-3 px-2"
|
||||
>
|
||||
<span className="h-px flex-1 bg-rose-500/60" />
|
||||
<span className="rounded-full bg-rose-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-rose-500">
|
||||
Neue Nachrichten
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-rose-500/60" />
|
||||
</div>
|
||||
)}
|
||||
<MessageBubble
|
||||
message={m}
|
||||
mine={m.senderId === myId}
|
||||
@@ -540,6 +640,14 @@ export function ConversationPage() {
|
||||
onJumpToMessage={jumpToMessage}
|
||||
onReply={handleReply}
|
||||
onForward={handleForward}
|
||||
onAvatarClick={(uid, ev) => {
|
||||
ev.stopPropagation();
|
||||
setProfilePopover({
|
||||
userId: uid,
|
||||
x: ev.clientX,
|
||||
y: ev.clientY,
|
||||
});
|
||||
}}
|
||||
highlighted={highlightedId === m.id}
|
||||
/>
|
||||
</li>
|
||||
@@ -754,7 +862,7 @@ export function ConversationPage() {
|
||||
}
|
||||
if (pics.length > 0) {
|
||||
e.preventDefault();
|
||||
ingestFiles(pics);
|
||||
void ingestFiles(pics);
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
@@ -778,6 +886,20 @@ export function ConversationPage() {
|
||||
currentConversationId={id ?? null}
|
||||
onClose={() => setForwardTarget(null)}
|
||||
/>
|
||||
|
||||
{profilePopover && (
|
||||
<UserProfilePopover
|
||||
userId={profilePopover.userId}
|
||||
profile={
|
||||
conversation?.members.find((m) => m.userId === profilePopover.userId)?.profile ??
|
||||
conversation?.peer ??
|
||||
null
|
||||
}
|
||||
x={profilePopover.x}
|
||||
y={profilePopover.y}
|
||||
onClose={() => setProfilePopover(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Avatar } from '../components/Avatar';
|
||||
import { BackupExportDialog } from '../components/BackupExportDialog';
|
||||
import { BackupRestoreDialog } from '../components/BackupRestoreDialog';
|
||||
import { RingtoneSettings } from '../components/RingtoneSettings';
|
||||
import { SoundboardSettings } from '../components/SoundboardSettings';
|
||||
import { LockIcon } from '../components/icons';
|
||||
@@ -347,10 +348,99 @@ function AudioQualityControls() {
|
||||
'Mono 48 kbps mit Noise-Suppression, Echo-Cancellation und Auto-Gain. Optimiert für Sprache im Raum.',
|
||||
})}
|
||||
</p>
|
||||
|
||||
<SettingRow
|
||||
label={t('app:settings.noise_suppression', { defaultValue: 'Noise Suppression' })}
|
||||
>
|
||||
<InlineToggle
|
||||
checked={cfg.noiseSuppression}
|
||||
onChange={(v) => updateAudioSettings({ noiseSuppression: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('app:settings.noise_suppression_hint', {
|
||||
defaultValue:
|
||||
'Unterdrückt Hintergrundgeräusche (Tastatur, Lüfter, Café-Lärm). Ausschalten nur bei Musik/Instrumenten.',
|
||||
})}
|
||||
</p>
|
||||
|
||||
<SettingRow
|
||||
label={t('app:settings.video_blur', {
|
||||
defaultValue: 'Video-Hintergrund unscharf',
|
||||
})}
|
||||
>
|
||||
<InlineToggle
|
||||
checked={cfg.videoBackgroundBlur}
|
||||
onChange={(v) => updateAudioSettings({ videoBackgroundBlur: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('app:settings.video_blur_hint', {
|
||||
defaultValue:
|
||||
'Blendet den Hintergrund hinter dir aus. Braucht etwas GPU-Leistung und lädt beim ersten Aktivieren ~1,5 MB Modell nach.',
|
||||
})}
|
||||
</p>
|
||||
|
||||
<SettingRow
|
||||
label={t('app:settings.voice_threshold', {
|
||||
defaultValue: 'Sprach-Erkennungs-Schwelle',
|
||||
})}
|
||||
>
|
||||
<div className="flex w-48 items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0.005}
|
||||
max={0.1}
|
||||
step={0.005}
|
||||
value={cfg.voiceThreshold}
|
||||
onChange={(e) =>
|
||||
updateAudioSettings({ voiceThreshold: Number(e.target.value) })
|
||||
}
|
||||
className="flex-1 accent-accent"
|
||||
/>
|
||||
<span className="w-10 tabular-nums text-right text-[11px] text-fg-muted">
|
||||
{(cfg.voiceThreshold * 100).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('app:settings.voice_threshold_hint', {
|
||||
defaultValue:
|
||||
'Wann der grüne Sprech-Ring aufleuchtet. Niedriger = empfindlicher (leise Stimme erfassen), höher = tolerant gegen Raumlärm.',
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineToggle({
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={
|
||||
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(checked ? 'bg-accent' : 'bg-surface')
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
'absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition ' +
|
||||
(checked ? 'translate-x-5' : '')
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface AvatarControlsProps {
|
||||
patchProfile: (patch: Parameters<typeof updateOwnProfile>[1]) => Promise<void>;
|
||||
busy: boolean;
|
||||
@@ -464,6 +554,7 @@ function DeviceKeyBackupControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { profile, device } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [restoreOpen, setRestoreOpen] = useState(false);
|
||||
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const canRun = !!profile?.userId && !!device?.id;
|
||||
@@ -502,14 +593,24 @@ function DeviceKeyBackupControls() {
|
||||
'Backup deines Geräteschlüssels. Ohne Backup kein Zugriff auf alte Nachrichten wenn Gerät oder Browser-Storage verloren geht. Wiederherstellung passiert im Gerät-Einrichtungsbildschirm.',
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canRun}
|
||||
onClick={() => void handleOpen()}
|
||||
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:backup.export_open', { defaultValue: 'Backup erstellen' })}
|
||||
</button>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canRun}
|
||||
onClick={() => void handleOpen()}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:backup.export_open', { defaultValue: 'Backup erstellen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!profile?.userId}
|
||||
onClick={() => setRestoreOpen(true)}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-semibold text-fg transition hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:backup.restore_open', { defaultValue: 'Backup wiederherstellen' })}
|
||||
</button>
|
||||
</div>
|
||||
{err && (
|
||||
<p className="mt-2 text-xs text-rose-600 dark:text-rose-300">{err}</p>
|
||||
)}
|
||||
@@ -523,6 +624,14 @@ function DeviceKeyBackupControls() {
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{profile && (
|
||||
<BackupRestoreDialog
|
||||
open={restoreOpen}
|
||||
userId={profile.userId}
|
||||
onClose={() => setRestoreOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Web Worker — runs XSalsa20-Poly1305 decrypt + utf8 decode off the main
|
||||
// thread. The conv-key lookup (which is network-bound) stays in the main
|
||||
// thread; only the CPU-heavy AEAD + UTF-8 step runs here.
|
||||
//
|
||||
// Message protocol:
|
||||
// request: { id: string, items: Array<{ id, ciphertext, nonce, key }> }
|
||||
// response: { id: string, results: Array<{ id, plaintext: string | null }> }
|
||||
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
interface DecryptItem {
|
||||
id: string;
|
||||
ciphertext: Uint8Array;
|
||||
nonce: Uint8Array;
|
||||
key: Uint8Array;
|
||||
}
|
||||
|
||||
interface DecryptRequest {
|
||||
id: string;
|
||||
items: DecryptItem[];
|
||||
}
|
||||
|
||||
interface DecryptResult {
|
||||
id: string;
|
||||
plaintext: string | null;
|
||||
}
|
||||
|
||||
interface DecryptResponse {
|
||||
id: string;
|
||||
results: DecryptResult[];
|
||||
}
|
||||
|
||||
let sodiumReady: Promise<typeof sodium> | null = null;
|
||||
|
||||
async function ensureSodium(): Promise<typeof sodium> {
|
||||
if (!sodiumReady) sodiumReady = sodium.ready.then(() => sodium);
|
||||
return sodiumReady;
|
||||
}
|
||||
|
||||
function decodeOne(s: typeof sodium, item: DecryptItem): string | null {
|
||||
try {
|
||||
const plain = s.crypto_secretbox_open_easy(item.ciphertext, item.nonce, item.key);
|
||||
return new TextDecoder('utf-8', { fatal: false }).decode(plain);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('message', (ev: MessageEvent<DecryptRequest>) => {
|
||||
const req = ev.data;
|
||||
void (async () => {
|
||||
const s = await ensureSodium();
|
||||
const results: DecryptResult[] = req.items.map((item) => ({
|
||||
id: item.id,
|
||||
plaintext: decodeOne(s, item),
|
||||
}));
|
||||
const resp: DecryptResponse = { id: req.id, results };
|
||||
(self as unknown as Worker).postMessage(resp);
|
||||
})();
|
||||
});
|
||||
@@ -25,6 +25,25 @@ export default defineConfig({
|
||||
conditions: ['require', 'node', 'default'],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Split heavy vendor modules into their own chunks so they cache
|
||||
// independently from the app shell. LiveKit + libsodium change
|
||||
// rarely, so this keeps the hot-path app chunk small on rebuilds
|
||||
// and lets the browser cache the big binaries across app updates.
|
||||
manualChunks: (id) => {
|
||||
if (id.includes('node_modules/livekit-client')) return 'vendor-livekit';
|
||||
if (id.includes('node_modules/libsodium-wrappers-sumo')) return 'vendor-sodium';
|
||||
if (id.includes('node_modules/@supabase')) return 'vendor-supabase';
|
||||
if (id.includes('node_modules/react-router') || id.includes('node_modules/react-dom') || id.includes('node_modules/react/')) {
|
||||
return 'vendor-react';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
|
||||
@@ -39,6 +39,10 @@ export interface TextMessagePayload {
|
||||
type?: 'text';
|
||||
text: string;
|
||||
attachments: AttachmentHandle[];
|
||||
/** When set, the message self-destructs `expireMs` milliseconds after the
|
||||
* server's createdAt. Sender initiates the delete; receivers hide locally
|
||||
* as soon as the window elapses. Added in /tempmsg support. */
|
||||
expireMs?: number;
|
||||
}
|
||||
|
||||
export interface CallEventPayload {
|
||||
@@ -52,7 +56,12 @@ export interface CallEventPayload {
|
||||
export type MessagePayload = TextMessagePayload | CallEventPayload;
|
||||
|
||||
export type ParsedMessagePayload =
|
||||
| { kind: 'text'; text: string; attachments: AttachmentHandle[] }
|
||||
| {
|
||||
kind: 'text';
|
||||
text: string;
|
||||
attachments: AttachmentHandle[];
|
||||
expireMs?: number;
|
||||
}
|
||||
| {
|
||||
kind: 'call_event';
|
||||
status: CallEventStatus;
|
||||
@@ -91,6 +100,9 @@ export function parseMessagePayload(raw: string | null): ParsedMessagePayload {
|
||||
kind: 'text',
|
||||
text: typeof t.text === 'string' ? t.text : '',
|
||||
attachments: Array.isArray(t.attachments) ? t.attachments : [],
|
||||
...(typeof t.expireMs === 'number' && t.expireMs > 0
|
||||
? { expireMs: t.expireMs }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -444,16 +444,34 @@ export interface DecryptParams {
|
||||
messages: MessageWithCipher[];
|
||||
ownDeviceId: string;
|
||||
ownPrivateKey: Uint8Array;
|
||||
/**
|
||||
* Optional delegate that performs the symmetric-decrypt + utf-8 decode
|
||||
* step for a batch of messages. When provided, the main thread only does
|
||||
* conv-key lookup; the CPU-heavy AEAD loop runs inside the delegate (e.g.
|
||||
* a Web Worker). Items arrive with their per-message conv-key attached.
|
||||
*/
|
||||
aeadBatchDelegate?: (
|
||||
items: Array<{
|
||||
id: string;
|
||||
ciphertext: Uint8Array;
|
||||
nonce: Uint8Array;
|
||||
key: Uint8Array;
|
||||
}>,
|
||||
) => Promise<Array<{ id: string; plaintext: string | null }>>;
|
||||
}
|
||||
|
||||
// Decrypts messages using their conv-key (looked up + cached per
|
||||
// keyVersion). Returns null `plaintext` when this device has no key bundle
|
||||
// for that version yet (e.g. brand-new device waiting for share).
|
||||
export async function decryptMessages(opts: DecryptParams): Promise<DecryptedMessage[]> {
|
||||
const out: DecryptedMessage[] = [];
|
||||
// Group versions to avoid redundant lookups.
|
||||
const versions = new Map<string, Map<number, Uint8Array | null>>(); // convId -> version -> key | null
|
||||
|
||||
// First pass: resolve conv-keys for every message. Network-bound, stays on
|
||||
// caller's thread so Supabase client + session remain usable.
|
||||
interface Resolved {
|
||||
message: MessageWithCipher;
|
||||
key: Uint8Array | null;
|
||||
}
|
||||
const resolved: Resolved[] = [];
|
||||
for (const m of opts.messages) {
|
||||
let convCache = versions.get(m.conversationId);
|
||||
if (!convCache) {
|
||||
@@ -474,17 +492,41 @@ export async function decryptMessages(opts: DecryptParams): Promise<DecryptedMes
|
||||
key = handle?.key ?? null;
|
||||
convCache.set(m.keyVersion, key);
|
||||
}
|
||||
resolved.push({ message: m, key });
|
||||
}
|
||||
|
||||
// Second pass: symmetric decrypt. If a delegate is supplied (Web Worker),
|
||||
// batch the CPU-heavy step to it; otherwise fall back to inline.
|
||||
if (opts.aeadBatchDelegate) {
|
||||
const batch = resolved
|
||||
.filter((r): r is Resolved & { key: Uint8Array } => r.key !== null)
|
||||
.map((r) => ({
|
||||
id: r.message.id,
|
||||
ciphertext: r.message.ciphertext,
|
||||
nonce: r.message.nonce,
|
||||
key: r.key,
|
||||
}));
|
||||
const plaintextById = new Map<string, string | null>();
|
||||
if (batch.length > 0) {
|
||||
const results = await opts.aeadBatchDelegate(batch);
|
||||
for (const result of results) plaintextById.set(result.id, result.plaintext);
|
||||
}
|
||||
return resolved.map((r) => ({
|
||||
...r.message,
|
||||
plaintext: r.key ? plaintextById.get(r.message.id) ?? null : null,
|
||||
}));
|
||||
}
|
||||
|
||||
return resolved.map((r) => {
|
||||
let plaintext: string | null = null;
|
||||
if (key) {
|
||||
if (r.key) {
|
||||
try {
|
||||
const decoded = decryptWithConvKey(m.ciphertext, m.nonce, key);
|
||||
const decoded = decryptWithConvKey(r.message.ciphertext, r.message.nonce, r.key);
|
||||
plaintext = bytesToUtf8(decoded);
|
||||
} catch {
|
||||
plaintext = null;
|
||||
}
|
||||
}
|
||||
out.push({ ...m, plaintext });
|
||||
}
|
||||
return out;
|
||||
return { ...r.message, plaintext };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,6 +59,9 @@ importers:
|
||||
'@livekit/components-react':
|
||||
specifier: ^2.9.0
|
||||
version: 2.9.20(livekit-client@2.18.3(@types/dom-mediacapture-record@1.0.22))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tslib@2.8.1)
|
||||
'@livekit/track-processors':
|
||||
specifier: ^0.7.2
|
||||
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.18.3(@types/dom-mediacapture-record@1.0.22))
|
||||
'@supabase/supabase-js':
|
||||
specifier: ^2.46.0
|
||||
version: 2.103.3
|
||||
@@ -1354,6 +1357,15 @@ packages:
|
||||
'@livekit/protocol@1.45.3':
|
||||
resolution: {integrity: sha512-WmMxBTsy4dRBqcrswFwUUlgq3Z0nnhOqKR6tX749Rb/PcB1yBMUtrHxZvcsS6qi3/5+86zHeVG+exmu1sZqfJg==}
|
||||
|
||||
'@livekit/track-processors@0.7.2':
|
||||
resolution: {integrity: sha512-lzARBKTbBwqycdR/SwTu6//N0l20BzfDd7grxCXl07676SwRApNtZAK1GJjL1m3dCM3KBqH1aVxjMpNcbOw5uQ==}
|
||||
peerDependencies:
|
||||
'@types/dom-mediacapture-transform': ^0.1.9
|
||||
livekit-client: ^1.12.0 || ^2.1.0
|
||||
|
||||
'@mediapipe/tasks-vision@0.10.14':
|
||||
resolution: {integrity: sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==}
|
||||
|
||||
'@noble/hashes@1.8.0':
|
||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
@@ -1835,6 +1847,12 @@ packages:
|
||||
'@types/dom-mediacapture-record@1.0.22':
|
||||
resolution: {integrity: sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==}
|
||||
|
||||
'@types/dom-mediacapture-transform@0.1.11':
|
||||
resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==}
|
||||
|
||||
'@types/dom-webcodecs@0.1.18':
|
||||
resolution: {integrity: sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@@ -6906,6 +6924,14 @@ snapshots:
|
||||
dependencies:
|
||||
'@bufbuild/protobuf': 1.10.1
|
||||
|
||||
'@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.18.3(@types/dom-mediacapture-record@1.0.22))':
|
||||
dependencies:
|
||||
'@mediapipe/tasks-vision': 0.10.14
|
||||
'@types/dom-mediacapture-transform': 0.1.11
|
||||
livekit-client: 2.18.3(@types/dom-mediacapture-record@1.0.22)
|
||||
|
||||
'@mediapipe/tasks-vision@0.10.14': {}
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
@@ -7485,6 +7511,12 @@ snapshots:
|
||||
|
||||
'@types/dom-mediacapture-record@1.0.22': {}
|
||||
|
||||
'@types/dom-mediacapture-transform@0.1.11':
|
||||
dependencies:
|
||||
'@types/dom-webcodecs': 0.1.18
|
||||
|
||||
'@types/dom-webcodecs@0.1.18': {}
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/graceful-fs@4.1.9':
|
||||
|
||||