feat: backup/restore, user profile popover, image compress, video blur, wake lock

- backup/restore dialog + user profile popover components
- image compression, video blur, wake lock utilities
- message cache + conversation messages hook refinements
- call context, active speakers, screen share dialog tweaks
- audio + screen share settings persistence
- refreshed app icons (smaller sizes) across all platforms
This commit is contained in:
2026-04-21 12:11:09 +02:00
parent 48ac9d2922
commit 1303c8e26f
71 changed files with 1077 additions and 114 deletions
+28
View File
@@ -16,12 +16,27 @@ export interface AudioSettings {
// Preferred output (speaker/headphone) deviceId. null = system default.
// Applied to every <audio> element we attach via HTMLMediaElement.setSinkId.
outputDeviceId: string | null;
// RMS threshold (0..1) for the "is this participant talking" ring. Lower =
// more sensitive. Default 0.03 catches soft speech without lighting up on
// keyboard noise. Users on quiet mics can lower; users in noisy rooms bump up.
voiceThreshold: number;
// Per-publish DSP toggle. Off lets hifi-style music go through unmodified;
// on cleans up voice when the quality preset doesn't already imply it.
// Defaults to "follow the quality preset".
noiseSuppression: boolean;
// Background blur on the local camera track. Lazy — requires
// @livekit/track-processors + its MediaPipe selfie-segmentation model
// (~1.5MB) which downloads on first activation.
videoBackgroundBlur: boolean;
}
const DEFAULTS: AudioSettings = {
quality: 'voice',
inputDeviceId: null,
outputDeviceId: null,
voiceThreshold: 0.03,
noiseSuppression: true,
videoBackgroundBlur: false,
};
export interface AudioQualityParams {
@@ -80,6 +95,7 @@ function read(): AudioSettings {
return cached;
}
const parsed = JSON.parse(raw) as Partial<AudioSettings>;
const rawThreshold = typeof parsed.voiceThreshold === 'number' ? parsed.voiceThreshold : NaN;
cached = {
quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality,
inputDeviceId:
@@ -90,6 +106,18 @@ function read(): AudioSettings {
typeof parsed.outputDeviceId === 'string' && parsed.outputDeviceId.length > 0
? parsed.outputDeviceId
: DEFAULTS.outputDeviceId,
voiceThreshold:
Number.isFinite(rawThreshold) && rawThreshold >= 0.005 && rawThreshold <= 0.2
? rawThreshold
: DEFAULTS.voiceThreshold,
noiseSuppression:
typeof parsed.noiseSuppression === 'boolean'
? parsed.noiseSuppression
: DEFAULTS.noiseSuppression,
videoBackgroundBlur:
typeof parsed.videoBackgroundBlur === 'boolean'
? parsed.videoBackgroundBlur
: DEFAULTS.videoBackgroundBlur,
};
return cached;
} catch {
+57
View File
@@ -0,0 +1,57 @@
// Client-side image compression before upload. Downscales huge photos
// (phone cameras regularly emit 4000×3000+ at 5MB+) to a size that
// actually makes sense for chat display. Preserves aspect ratio.
//
// Skips animated formats (gif/apng/webp) to keep motion, and skips
// already-small files to avoid useless re-encode overhead.
const MAX_DIM = 2048;
const TARGET_QUALITY = 0.85;
const SKIP_BELOW_BYTES = 512 * 1024; // 512KB — not worth re-encoding
const ANIMATED_MIME = /^image\/(gif|apng|webp)$/;
export async function compressImage(file: File): Promise<File> {
if (!file.type.startsWith('image/')) return file;
if (ANIMATED_MIME.test(file.type)) return file;
if (file.size < SKIP_BELOW_BYTES) return file;
if (typeof createImageBitmap !== 'function') return file;
if (typeof OffscreenCanvas !== 'function') return file;
try {
const bitmap = await createImageBitmap(file);
const largest = Math.max(bitmap.width, bitmap.height);
const scale = largest > MAX_DIM ? MAX_DIM / largest : 1;
const w = Math.max(1, Math.round(bitmap.width * scale));
const h = Math.max(1, Math.round(bitmap.height * scale));
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext('2d');
if (!ctx) {
bitmap.close();
return file;
}
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
const blob = await canvas.convertToBlob({
type: 'image/webp',
quality: TARGET_QUALITY,
});
// If the re-encoded blob is actually larger (small PNGs can expand as
// WebP), keep the original.
if (blob.size >= file.size) return file;
const name = renameToWebp(file.name);
return new File([blob], name, { type: 'image/webp', lastModified: file.lastModified });
} catch (err: unknown) {
console.warn('compressImage failed — keeping original', err);
return file;
}
}
function renameToWebp(original: string): string {
const dot = original.lastIndexOf('.');
const base = dot > 0 ? original.slice(0, dot) : original;
return base + '.webp';
}
export async function compressImages(files: File[]): Promise<File[]> {
return Promise.all(files.map((f) => compressImage(f)));
}
+86
View File
@@ -54,6 +54,44 @@ async function loadDb(): Promise<Database | null> {
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);
@@ -157,6 +195,54 @@ export async function deleteCachedMessage(id: string): Promise<void> {
}
}
// 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;
@@ -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 {
+16 -3
View File
@@ -1,6 +1,8 @@
import type { AudioTrack, Participant, Room } from 'livekit-client';
import { ParticipantEvent, RoomEvent, Track } from 'livekit-client';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { getAudioSettings, subscribeAudioSettings } from './audioSettings';
// Real-time speaking ring driven by Web Audio API AnalyserNodes directly on
// each participant's audio MediaStreamTrack. LiveKit's own `audioLevel` +
@@ -9,9 +11,10 @@ import { useEffect, useState } from 'react';
// lights up within one animation frame of actual speech.
//
// Hold time of 250ms prevents flicker between words / short pauses.
// RMS threshold is user-tunable via audioSettings.voiceThreshold so soft
// speakers / noisy rooms can dial in their own sensitivity.
const POLL_MS = 50;
const HOLD_MS = 250;
const THRESHOLD = 0.03; // RMS on 0..1 — tuned against soft speech
const FFT_SIZE = 256;
interface Probe {
@@ -80,6 +83,15 @@ function firstAudioTrack(p: Participant): AudioTrack | null {
export function useActiveSpeakers(room: Room | null): Set<string> {
const [ids, setIds] = useState<Set<string>>(() => new Set());
const thresholdRef = useRef<number>(getAudioSettings().voiceThreshold);
// Live-subscribe so the slider in settings takes effect without a call
// restart. Reading via ref keeps the tick-loop branch-free.
useEffect(() => {
return subscribeAudioSettings((s) => {
thresholdRef.current = s.voiceThreshold;
});
}, []);
useEffect(() => {
if (!room) {
@@ -142,8 +154,9 @@ export function useActiveSpeakers(room: Room | null): Set<string> {
room.remoteParticipants.forEach(syncProbe);
const now = Date.now();
const threshold = thresholdRef.current;
for (const [id, probe] of probes) {
if (sampleRms(probe) > THRESHOLD) lastActive.set(id, now);
if (sampleRms(probe) > threshold) lastActive.set(id, now);
}
const next = new Set<string>();
for (const [id, t] of lastActive) {
@@ -473,6 +473,35 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
const priv = privateKeyRef.current;
if (!priv) throw new Error('private key not loaded');
// Slash-command: /tempmsg <seconds> <text> sends an ephemeral message
// that the sender auto-deletes after the window elapses. Both peers
// see the countdown via the expireMs field embedded in the plaintext
// payload — no server support required.
const tempMatch = /^\/tempmsg\s+(\d+)\s+([\s\S]+)$/i.exec(trimmed);
if (tempMatch && images.length === 0) {
const seconds = Math.min(3600, Math.max(5, parseInt(tempMatch[1]!, 10)));
const body = tempMatch[2]!.trim();
const payload = JSON.stringify({
v: 1,
type: 'text',
text: body,
attachments: [],
expireMs: seconds * 1000,
});
try {
await sendText(conversationId, userId, deviceId, priv, payload, replyToId);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'send failed';
enqueueOutbox({
conversationId,
text: payload,
replyToId,
error: msg,
});
}
return;
}
// Text-only path is retryable — if the network is down or the server
// rejects transiently, stash in the outbox and keep the UI optimistic.
// Attachments can't be deferred (large payloads, uploaded separately),
+67
View File
@@ -0,0 +1,67 @@
// Background-blur processor wrapper. LiveKit's `@livekit/track-processors`
// ships a MediaPipe-based selfie-segmentation pipeline that keeps the
// foreground sharp and blurs the background. The model (~1.5MB) downloads
// lazily on first activation, so users who never enable blur don't pay
// for it.
//
// attach/detach hide behind a guard so repeated toggles don't create a
// stack of processors — `setProcessor(null)` tears down the WebGL context
// and frees the GPU surface.
import type { LocalParticipant, LocalVideoTrack } from 'livekit-client';
import { Track } from 'livekit-client';
let cachedProcessor: unknown = null;
async function getProcessor(): Promise<unknown> {
if (cachedProcessor) return cachedProcessor;
const mod = (await import('@livekit/track-processors')) as {
BackgroundBlur?: (radius?: number) => unknown;
};
if (!mod.BackgroundBlur) {
throw new Error('BackgroundBlur not exported by @livekit/track-processors');
}
cachedProcessor = mod.BackgroundBlur(12);
return cachedProcessor;
}
function getCameraTrack(lp: LocalParticipant): LocalVideoTrack | null {
const pub = lp.getTrackPublication(Track.Source.Camera);
const track = pub?.track;
if (!track) return null;
return track as LocalVideoTrack;
}
export async function applyBackgroundBlurToLocal(lp: LocalParticipant): Promise<void> {
try {
const track = getCameraTrack(lp);
if (!track) return;
const processor = await getProcessor();
// `setProcessor` is declared on LocalVideoTrack; cast because the
// processor type lives in a separate module we don't want to strongly
// couple to here.
await (track as unknown as {
setProcessor: (p: unknown) => Promise<void>;
}).setProcessor(processor);
} catch (err: unknown) {
console.warn('applyBackgroundBlur failed', err);
}
}
export async function removeBackgroundBlurFromLocal(lp: LocalParticipant): Promise<void> {
try {
const track = getCameraTrack(lp);
if (!track) return;
const setter = (track as unknown as {
setProcessor?: (p: unknown) => Promise<void>;
stopProcessor?: () => Promise<void>;
});
if (setter.stopProcessor) {
await setter.stopProcessor();
} else if (setter.setProcessor) {
await setter.setProcessor(null);
}
} catch (err: unknown) {
console.warn('removeBackgroundBlur failed', err);
}
}
+77
View File
@@ -0,0 +1,77 @@
// Screen wake-lock for active calls. WebKit + WebView2 both ship the
// Screen Wake Lock API (tauri 2.x). Browsers release the sentinel when
// the page becomes hidden, so we re-acquire on visibilitychange while a
// call is active.
interface WakeLockSentinelLike {
release: () => Promise<void>;
addEventListener: (event: string, fn: () => void) => void;
}
interface WakeLockNavigator {
wakeLock?: {
request: (type: 'screen') => Promise<WakeLockSentinelLike>;
};
}
let sentinel: WakeLockSentinelLike | null = null;
let active = false;
let visibilityBound = false;
function hasWakeLock(): boolean {
return typeof navigator !== 'undefined' && !!(navigator as unknown as WakeLockNavigator).wakeLock;
}
async function acquire(): Promise<void> {
if (sentinel || !hasWakeLock()) return;
try {
const s = await (navigator as unknown as WakeLockNavigator).wakeLock!.request('screen');
sentinel = s;
s.addEventListener('release', () => {
sentinel = null;
// If still active (released by the browser because we went hidden),
// wait for visibility and re-request.
});
} catch (err: unknown) {
// Permission denied, document not visible, etc. Harmless — the call
// still works, the user's screen may dim. Log once.
console.warn('wakeLock request failed', err);
}
}
async function release(): Promise<void> {
if (!sentinel) return;
try {
await sentinel.release();
} catch {
/* already released */
}
sentinel = null;
}
function onVisibility(): void {
if (!active) return;
if (document.visibilityState === 'visible') {
void acquire();
}
}
// Called from CallContext when the call enters a state that should keep
// the screen awake (connected / connecting). Toggle off again when the
// call ends.
export async function setCallWakeLock(on: boolean): Promise<void> {
active = on;
if (on) {
if (!visibilityBound) {
document.addEventListener('visibilitychange', onVisibility);
visibilityBound = true;
}
await acquire();
} else {
if (visibilityBound) {
document.removeEventListener('visibilitychange', onVisibility);
visibilityBound = false;
}
await release();
}
}