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:
@@ -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,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)));
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user