(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) {
+
+
+ setIncludeAudio(e.target.checked)}
+ className="mt-0.5 accent-accent"
+ />
+
+
+ {t('app:call.share_system_audio', {
+ defaultValue: 'System-Sound mit übertragen',
+ })}
+
+
+ {t('app:call.share_system_audio_hint', {
+ defaultValue:
+ '"Go Live" — Systemsound wird mitgesendet. Auf macOS braucht das extra Berechtigungen; wird sonst stumm geteilt.',
+ })}
+
+
+
+
+
{error && (
{error}
diff --git a/apps/desktop/src/components/UserProfilePopover.tsx b/apps/desktop/src/components/UserProfilePopover.tsx
new file mode 100644
index 0000000..7ed5828
--- /dev/null
+++ b/apps/desktop/src/components/UserProfilePopover.tsx
@@ -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 = {
+ 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(
+
+
+
+
+ {showPresence && (
+
+ )}
+
+
+
+ {displayName}
+
+ {username && (
+
@{username}
+ )}
+
+
+
+ {statusMessage && state !== 'offline' && (
+
+ {statusMessage}
+
+ )}
+
+ {onStartDm && (
+
{
+ 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
+
+ )}
+
,
+ document.body,
+ );
+}
diff --git a/apps/desktop/src/components/icons.tsx b/apps/desktop/src/components/icons.tsx
index cf8793f..c51296e 100644
--- a/apps/desktop/src/components/icons.tsx
+++ b/apps/desktop/src/components/icons.tsx
@@ -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 (
-
-
-
-
-
-
-
-
-
-
-
+
);
}
-// 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 (
-
-
-
-
-
-
-
-
-
-
-
+
{
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(() => {
diff --git a/apps/desktop/src/lib/audioSettings.ts b/apps/desktop/src/lib/audioSettings.ts
index 02cb3ec..712e7f5 100644
--- a/apps/desktop/src/lib/audioSettings.ts
+++ b/apps/desktop/src/lib/audioSettings.ts
@@ -16,12 +16,27 @@ export interface AudioSettings {
// Preferred output (speaker/headphone) deviceId. null = system default.
// Applied to every 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;
+ 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 {
diff --git a/apps/desktop/src/lib/imageCompress.ts b/apps/desktop/src/lib/imageCompress.ts
new file mode 100644
index 0000000..4b8ef43
--- /dev/null
+++ b/apps/desktop/src/lib/imageCompress.ts
@@ -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 {
+ 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 {
+ return Promise.all(files.map((f) => compressImage(f)));
+}
diff --git a/apps/desktop/src/lib/messageCache.ts b/apps/desktop/src/lib/messageCache.ts
index 25c37f6..93408ca 100644
--- a/apps/desktop/src/lib/messageCache.ts
+++ b/apps/desktop/src/lib/messageCache.ts
@@ -54,6 +54,44 @@ async function loadDb(): Promise {
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 {
}
}
+// 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 {
+ 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(
+ `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;
diff --git a/apps/desktop/src/lib/screenShareSettings.ts b/apps/desktop/src/lib/screenShareSettings.ts
index 6cf60ca..6fb3cfe 100644
--- a/apps/desktop/src/lib/screenShareSettings.ts
+++ b/apps/desktop/src/lib/screenShareSettings.ts
@@ -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 {
diff --git a/apps/desktop/src/lib/useActiveSpeakers.ts b/apps/desktop/src/lib/useActiveSpeakers.ts
index b1cab2d..da7b294 100644
--- a/apps/desktop/src/lib/useActiveSpeakers.ts
+++ b/apps/desktop/src/lib/useActiveSpeakers.ts
@@ -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 {
const [ids, setIds] = useState>(() => new Set());
+ const thresholdRef = useRef(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 {
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();
for (const [id, t] of lastActive) {
diff --git a/apps/desktop/src/lib/useConversationMessages.ts b/apps/desktop/src/lib/useConversationMessages.ts
index d8b801e..79bf879 100644
--- a/apps/desktop/src/lib/useConversationMessages.ts
+++ b/apps/desktop/src/lib/useConversationMessages.ts
@@ -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 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),
diff --git a/apps/desktop/src/lib/videoBlur.ts b/apps/desktop/src/lib/videoBlur.ts
new file mode 100644
index 0000000..cbe242b
--- /dev/null
+++ b/apps/desktop/src/lib/videoBlur.ts
@@ -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 {
+ 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 {
+ 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;
+ }).setProcessor(processor);
+ } catch (err: unknown) {
+ console.warn('applyBackgroundBlur failed', err);
+ }
+}
+
+export async function removeBackgroundBlurFromLocal(lp: LocalParticipant): Promise {
+ try {
+ const track = getCameraTrack(lp);
+ if (!track) return;
+ const setter = (track as unknown as {
+ setProcessor?: (p: unknown) => Promise;
+ stopProcessor?: () => Promise;
+ });
+ if (setter.stopProcessor) {
+ await setter.stopProcessor();
+ } else if (setter.setProcessor) {
+ await setter.setProcessor(null);
+ }
+ } catch (err: unknown) {
+ console.warn('removeBackgroundBlur failed', err);
+ }
+}
diff --git a/apps/desktop/src/lib/wakeLock.ts b/apps/desktop/src/lib/wakeLock.ts
new file mode 100644
index 0000000..1bb1796
--- /dev/null
+++ b/apps/desktop/src/lib/wakeLock.ts
@@ -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;
+ addEventListener: (event: string, fn: () => void) => void;
+}
+
+interface WakeLockNavigator {
+ wakeLock?: {
+ request: (type: 'screen') => Promise;
+ };
+}
+
+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 {
+ 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 {
+ 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 {
+ 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();
+ }
+}
diff --git a/apps/desktop/src/pages/ConversationPage.tsx b/apps/desktop/src/pages/ConversationPage.tsx
index 0060c05..258a74f 100644
--- a/apps/desktop/src/pages/ConversationPage.tsx
+++ b/apps/desktop/src/pages/ConversationPage.tsx
@@ -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(null);
const [displayCount, setDisplayCount] = useState(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(null);
+ const firstUnreadComputedRef = useRef(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([]);
+ 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();
+ 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 (
+ {firstUnreadRef.current === m.id && (
+
+
+
+ Neue Nachrichten
+
+
+
+ )}
{
+ ev.stopPropagation();
+ setProfilePopover({
+ userId: uid,
+ x: ev.clientX,
+ y: ev.clientY,
+ });
+ }}
highlighted={highlightedId === m.id}
/>
@@ -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 && (
+ m.userId === profilePopover.userId)?.profile ??
+ conversation?.peer ??
+ null
+ }
+ x={profilePopover.x}
+ y={profilePopover.y}
+ onClose={() => setProfilePopover(null)}
+ />
+ )}
);
}
diff --git a/apps/desktop/src/pages/SettingsPage.tsx b/apps/desktop/src/pages/SettingsPage.tsx
index 7396f5c..71517cb 100644
--- a/apps/desktop/src/pages/SettingsPage.tsx
+++ b/apps/desktop/src/pages/SettingsPage.tsx
@@ -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.',
})}
+
+
+ updateAudioSettings({ noiseSuppression: v })}
+ />
+
+
+ {t('app:settings.noise_suppression_hint', {
+ defaultValue:
+ 'Unterdrückt Hintergrundgeräusche (Tastatur, Lüfter, Café-Lärm). Ausschalten nur bei Musik/Instrumenten.',
+ })}
+
+
+
+ updateAudioSettings({ videoBackgroundBlur: v })}
+ />
+
+
+ {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.',
+ })}
+
+
+
+
+
+ updateAudioSettings({ voiceThreshold: Number(e.target.value) })
+ }
+ className="flex-1 accent-accent"
+ />
+
+ {(cfg.voiceThreshold * 100).toFixed(1)}
+
+
+
+
+ {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.',
+ })}
+
>
);
}
+function InlineToggle({
+ checked,
+ onChange,
+}: {
+ checked: boolean;
+ onChange: (next: boolean) => void;
+}) {
+ return (
+ 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')
+ }
+ >
+
+
+ );
+}
+
interface AvatarControlsProps {
patchProfile: (patch: Parameters[1]) => Promise;
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(null);
const [err, setErr] = useState(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.',
})}
- 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' })}
-
+
+ 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' })}
+
+ 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' })}
+
+
{err && (
{err}
)}
@@ -523,6 +624,14 @@ function DeviceKeyBackupControls() {
onClose={handleClose}
/>
)}
+
+ {profile && (
+ setRestoreOpen(false)}
+ />
+ )}
);
}
diff --git a/packages/shared/src/chat/attachments.ts b/packages/shared/src/chat/attachments.ts
index c3433b3..2746365 100644
--- a/packages/shared/src/chat/attachments.ts
+++ b/packages/shared/src/chat/attachments.ts
@@ -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 {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4153a5b..58ce991 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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':