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
@@ -21,6 +21,7 @@
|
|||||||
"@chat-app/shared": "workspace:*",
|
"@chat-app/shared": "workspace:*",
|
||||||
"@chat-app/ui-web": "workspace:*",
|
"@chat-app/ui-web": "workspace:*",
|
||||||
"@livekit/components-react": "^2.9.0",
|
"@livekit/components-react": "^2.9.0",
|
||||||
|
"@livekit/track-processors": "^0.7.2",
|
||||||
"@supabase/supabase-js": "^2.46.0",
|
"@supabase/supabase-js": "^2.46.0",
|
||||||
"@tauri-apps/api": "^2.1.1",
|
"@tauri-apps/api": "^2.1.1",
|
||||||
"@tauri-apps/plugin-fs": "^2.5.0",
|
"@tauri-apps/plugin-fs": "^2.5.0",
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa"/>
|
||||||
<clipPath id="cp02">
|
<path d="M18 46 V22 Q 18 18 22 18 Q 26 18 27 21 L 39 42 Q 40 45 44 45 V22 Q 44 18 40 18"
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"/>
|
fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0"/>
|
||||||
</clipPath>
|
<path d="M20 46 V22 a3 3 0 0 1 5.5 -1.8 L 40 43 a2 2 0 0 0 4 -1.2 V22 a3 3 0 0 0 -6 0"
|
||||||
</defs>
|
fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065"/>
|
|
||||||
<g clip-path="url(#cp02)">
|
|
||||||
<path d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z" fill="#7c4dff"/>
|
|
||||||
<path d="M-4 32 Q 16 18 32 32 T 68 32" stroke="#a78bfa" stroke-width="2" fill="none"/>
|
|
||||||
</g>
|
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"
|
|
||||||
fill="none" stroke="#a78bfa" stroke-width="1.5" opacity="0.4"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 667 B After Width: | Height: | Size: 563 B |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 948 B After Width: | Height: | Size: 709 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 928 B After Width: | Height: | Size: 680 B |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 934 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1021 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 611 B After Width: | Height: | Size: 471 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 864 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 864 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 858 B After Width: | Height: | Size: 669 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 864 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.9 KiB |
@@ -1,14 +1,7 @@
|
|||||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa"/>
|
||||||
<clipPath id="cp02">
|
<path d="M18 46 V22 Q 18 18 22 18 Q 26 18 27 21 L 39 42 Q 40 45 44 45 V22 Q 44 18 40 18"
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"/>
|
fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0"/>
|
||||||
</clipPath>
|
<path d="M20 46 V22 a3 3 0 0 1 5.5 -1.8 L 40 43 a2 2 0 0 0 4 -1.2 V22 a3 3 0 0 0 -6 0"
|
||||||
</defs>
|
fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065"/>
|
|
||||||
<g clip-path="url(#cp02)">
|
|
||||||
<path d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z" fill="#7c4dff"/>
|
|
||||||
<path d="M-4 32 Q 16 18 32 32 T 68 32" stroke="#a78bfa" stroke-width="2" fill="none"/>
|
|
||||||
</g>
|
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"
|
|
||||||
fill="none" stroke="#a78bfa" stroke-width="1.5" opacity="0.4"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 667 B After Width: | Height: | Size: 563 B |
@@ -0,0 +1,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;
|
onReply?: (m: DecryptedMessage) => void;
|
||||||
/** Hover action: parent opens forward dialog for current message. */
|
/** Hover action: parent opens forward dialog for current message. */
|
||||||
onForward?: (m: DecryptedMessage) => void;
|
onForward?: (m: DecryptedMessage) => void;
|
||||||
|
/** Click on the message's avatar surfaces the author's profile card. */
|
||||||
|
onAvatarClick?: (userId: string, ev: React.MouseEvent) => void;
|
||||||
/** Highlighted state — set briefly after a jump. */
|
/** Highlighted state — set briefly after a jump. */
|
||||||
highlighted?: boolean;
|
highlighted?: boolean;
|
||||||
}
|
}
|
||||||
@@ -76,6 +78,7 @@ export function MessageBubble({
|
|||||||
onJumpToMessage,
|
onJumpToMessage,
|
||||||
onReply,
|
onReply,
|
||||||
onForward,
|
onForward,
|
||||||
|
onAvatarClick,
|
||||||
highlighted = false,
|
highlighted = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
@@ -97,6 +100,47 @@ export function MessageBubble({
|
|||||||
const withinEditWindow = age < EDIT_WINDOW_MS;
|
const withinEditWindow = age < EDIT_WINDOW_MS;
|
||||||
const bodyText = initialText;
|
const bodyText = initialText;
|
||||||
const attachments = initialAttachments;
|
const attachments = initialAttachments;
|
||||||
|
|
||||||
|
// /tempmsg ephemeral window. expireMs embedded in plaintext JSON; sender
|
||||||
|
// fires the soft-delete when the clock runs out. Receivers just watch
|
||||||
|
// the deletedAt flip via realtime.
|
||||||
|
const expireMs = parsed.kind === 'text' ? parsed.expireMs : undefined;
|
||||||
|
const [tickNow, setTickNow] = useState<number>(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
if (expireMs === undefined) return;
|
||||||
|
const id = window.setInterval(() => setTickNow(Date.now()), 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [expireMs]);
|
||||||
|
const msLeft =
|
||||||
|
expireMs !== undefined
|
||||||
|
? Math.max(0, expireMs - (tickNow - createdAt.getTime()))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mine) return;
|
||||||
|
if (expireMs === undefined) return;
|
||||||
|
if (message.deletedAt) return;
|
||||||
|
const remaining = Math.max(0, expireMs - age);
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void softDeleteMessage(supabase, message.id).catch((err: unknown) => {
|
||||||
|
console.warn('ephemeral auto-delete failed', err);
|
||||||
|
});
|
||||||
|
}, remaining);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [mine, expireMs, age, message.id, message.deletedAt]);
|
||||||
|
|
||||||
|
// Receiver-side auto-hide when the expiry window elapses even if the
|
||||||
|
// sender's delete hasn't propagated yet (network hiccup, offline-sender).
|
||||||
|
const [localExpired, setLocalExpired] = useState<boolean>(
|
||||||
|
msLeft !== null && msLeft <= 0,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (expireMs === undefined) return;
|
||||||
|
if (localExpired) return;
|
||||||
|
const remaining = Math.max(0, expireMs - age);
|
||||||
|
const t = window.setTimeout(() => setLocalExpired(true), remaining);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [expireMs, age, localExpired]);
|
||||||
const canEdit =
|
const canEdit =
|
||||||
parsed.kind === 'text' &&
|
parsed.kind === 'text' &&
|
||||||
mine &&
|
mine &&
|
||||||
@@ -181,13 +225,16 @@ export function MessageBubble({
|
|||||||
[onToggleReaction],
|
[onToggleReaction],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (message.deletedAt) {
|
if (message.deletedAt || localExpired) {
|
||||||
return (
|
return (
|
||||||
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
|
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
|
||||||
<AvatarSlot
|
<AvatarSlot
|
||||||
show={isLastOfRun}
|
show={isLastOfRun}
|
||||||
url={senderAvatarUrl ?? null}
|
url={senderAvatarUrl ?? null}
|
||||||
displayName={senderDisplayName ?? null}
|
displayName={senderDisplayName ?? null}
|
||||||
|
{...(onAvatarClick
|
||||||
|
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
|
||||||
|
: {})}
|
||||||
/>
|
/>
|
||||||
<div className="max-w-[calc(70%-2.5rem)] rounded-2xl border border-line bg-surface-2 px-3.5 py-1.5 text-xs italic text-fg-muted">
|
<div className="max-w-[calc(70%-2.5rem)] rounded-2xl border border-line bg-surface-2 px-3.5 py-1.5 text-xs italic text-fg-muted">
|
||||||
{t('app:chats.deleted')}
|
{t('app:chats.deleted')}
|
||||||
@@ -274,23 +321,30 @@ export function MessageBubble({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onJumpToMessage?.(quoted.id)}
|
onClick={() => onJumpToMessage?.(quoted.id)}
|
||||||
className={
|
className={
|
||||||
'mb-1.5 flex w-full cursor-pointer items-stretch gap-2 rounded-md px-2 py-1.5 text-left text-xs transition hover:opacity-90 ' +
|
'mb-1.5 flex w-full cursor-pointer items-stretch gap-2 rounded-md pl-2 pr-2.5 py-1.5 text-left text-xs transition hover:brightness-110 hover:shadow-sm ' +
|
||||||
(mine
|
(mine
|
||||||
? 'bg-white/15 text-accent-fg/90'
|
? 'bg-white/10 text-accent-fg/90'
|
||||||
: 'bg-surface-2 text-fg-muted')
|
: 'bg-surface-2/80 text-fg-muted ring-1 ring-inset ring-line')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={
|
className={
|
||||||
'w-0.5 shrink-0 rounded-full ' + (mine ? 'bg-white/50' : 'bg-accent')
|
'-ml-1 w-1 shrink-0 rounded-full ' +
|
||||||
|
(mine ? 'bg-white/70' : 'bg-accent')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1 pl-1">
|
||||||
<span className={'block truncate font-semibold ' + (mine ? '' : 'text-fg')}>
|
<span
|
||||||
{quoted.senderName}
|
className={
|
||||||
|
'flex items-center gap-1 truncate text-[11px] font-semibold ' +
|
||||||
|
(mine ? 'text-accent-fg' : 'text-accent')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ReplyIcon className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="truncate">{quoted.senderName}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="block truncate italic opacity-90">
|
<span className="mt-0.5 block truncate opacity-80">
|
||||||
{quoted.deleted
|
{quoted.deleted
|
||||||
? t('app:chats.deleted')
|
? t('app:chats.deleted')
|
||||||
: quoted.isAttachment && !quoted.snippet
|
: quoted.isAttachment && !quoted.snippet
|
||||||
@@ -337,6 +391,17 @@ export function MessageBubble({
|
|||||||
{message.editedAt && !message.deletedAt && (
|
{message.editedAt && !message.deletedAt && (
|
||||||
<span className="italic">· {t('app:chats.edited')}</span>
|
<span className="italic">· {t('app:chats.edited')}</span>
|
||||||
)}
|
)}
|
||||||
|
{msLeft !== null && msLeft > 0 && (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider ' +
|
||||||
|
(mine ? 'bg-white/20' : 'bg-rose-500/15 text-rose-600 dark:text-rose-300')
|
||||||
|
}
|
||||||
|
title="Selbstzerstörung"
|
||||||
|
>
|
||||||
|
⏱ {Math.ceil(msLeft / 1000)}s
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -457,14 +522,29 @@ function AvatarSlot({
|
|||||||
show,
|
show,
|
||||||
url,
|
url,
|
||||||
displayName,
|
displayName,
|
||||||
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
|
onClick?: (ev: React.MouseEvent) => void;
|
||||||
}) {
|
}) {
|
||||||
if (!show) {
|
if (!show) {
|
||||||
return <div aria-hidden="true" className="h-8 w-8 shrink-0" />;
|
return <div aria-hidden="true" className="h-8 w-8 shrink-0" />;
|
||||||
}
|
}
|
||||||
|
if (onClick) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-user-popover-trigger
|
||||||
|
onClick={onClick}
|
||||||
|
className="shrink-0 cursor-pointer rounded-full transition hover:ring-2 hover:ring-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||||
|
aria-label={displayName ?? 'Profil'}
|
||||||
|
>
|
||||||
|
<Avatar url={url} displayName={displayName ?? ''} className="h-8 w-8 text-xs" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
url={url}
|
url={url}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getScreenShareSettings,
|
getScreenShareSettings,
|
||||||
PRESET_ORDER,
|
PRESET_ORDER,
|
||||||
type ScreenSharePreset,
|
type ScreenSharePreset,
|
||||||
|
updateScreenShareSettings,
|
||||||
} from '../lib/screenShareSettings';
|
} from '../lib/screenShareSettings';
|
||||||
import {
|
import {
|
||||||
MonitorShareIcon,
|
MonitorShareIcon,
|
||||||
@@ -42,6 +43,7 @@ export function ScreenShareDialog({ open, onClose, onStart }: Props) {
|
|||||||
const [surface, setSurface] = useState<DisplaySurfaceHint>(initial.displaySurface);
|
const [surface, setSurface] = useState<DisplaySurfaceHint>(initial.displaySurface);
|
||||||
const [preset, setPreset] = useState<ScreenSharePreset>(initial.preset);
|
const [preset, setPreset] = useState<ScreenSharePreset>(initial.preset);
|
||||||
const [framerate, setFramerate] = useState<number | null>(initial.framerateOverride);
|
const [framerate, setFramerate] = useState<number | null>(initial.framerateOverride);
|
||||||
|
const [includeAudio, setIncludeAudio] = useState<boolean>(initial.includeSystemAudio);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -51,6 +53,9 @@ export function ScreenShareDialog({ open, onClose, onStart }: Props) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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 });
|
await onStart({ preset, displaySurface: surface, framerate });
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -165,6 +170,30 @@ export function ScreenShareDialog({ open, onClose, onStart }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 && (
|
{error && (
|
||||||
<p role="alert" className="text-sm text-rose-600 dark:text-rose-300">
|
<p role="alert" className="text-sm text-rose-600 dark:text-rose-300">
|
||||||
{error}
|
{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) {
|
export function LogoMark(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 64 64"
|
viewBox="0 0 64 64"
|
||||||
fill="none"
|
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<defs>
|
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa" />
|
||||||
<clipPath id="logo-hex-clip">
|
|
||||||
<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
|
<path
|
||||||
d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z"
|
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="#7c4dff"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M-4 32 Q 16 18 32 32 T 68 32"
|
|
||||||
stroke="#a78bfa"
|
|
||||||
strokeWidth="2"
|
|
||||||
fill="none"
|
fill="none"
|
||||||
/>
|
stroke="#fff"
|
||||||
</g>
|
strokeWidth="5"
|
||||||
<polygon
|
strokeLinecap="round"
|
||||||
points="32,5 57,19 57,45 32,59 7,45 7,19"
|
strokeLinejoin="round"
|
||||||
fill="none"
|
|
||||||
stroke="#a78bfa"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
opacity="0.4"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full lockup: hex icon + "Netralax" wordmark. `tone` decides text colour:
|
// Full lockup: soft-N mark + "Netralax" wordmark. `tone` decides text colour:
|
||||||
// "dark" = white text (use on dark background), "light" = black text.
|
// "dark" = white text (use on dark background), "light" = near-black.
|
||||||
export function LogoLockup({
|
export function LogoLockup({
|
||||||
tone = 'dark',
|
tone = 'dark',
|
||||||
...props
|
...props
|
||||||
}: IconProps & { tone?: 'dark' | 'light' }) {
|
}: IconProps & { tone?: 'dark' | 'light' }) {
|
||||||
const textFill = tone === 'dark' ? '#ffffff' : '#0F172A';
|
const textFill = tone === 'dark' ? '#ffffff' : '#14121c';
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 260 64"
|
viewBox="0 0 260 64"
|
||||||
fill="none"
|
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<defs>
|
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa" />
|
||||||
<clipPath id="logo-lockup-hex-clip">
|
|
||||||
<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
|
<path
|
||||||
d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z"
|
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="#7c4dff"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M-4 32 Q 16 18 32 32 T 68 32"
|
|
||||||
stroke="#a78bfa"
|
|
||||||
strokeWidth="2"
|
|
||||||
fill="none"
|
fill="none"
|
||||||
/>
|
stroke="#fff"
|
||||||
</g>
|
strokeWidth="5"
|
||||||
<polygon
|
strokeLinecap="round"
|
||||||
points="32,5 57,19 57,45 32,59 7,45 7,19"
|
strokeLinejoin="round"
|
||||||
fill="none"
|
|
||||||
stroke="#a78bfa"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
opacity="0.4"
|
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
x="78"
|
x="78"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
import { useAuth } from './AuthContext';
|
import { useAuth } from './AuthContext';
|
||||||
import { useConversationsContext } from './ConversationsContext';
|
import { useConversationsContext } from './ConversationsContext';
|
||||||
import { playEndBeep, playJoinBeep, playLeaveBeep } from '../lib/callSounds';
|
import { playEndBeep, playJoinBeep, playLeaveBeep } from '../lib/callSounds';
|
||||||
|
import { setCallWakeLock } from '../lib/wakeLock';
|
||||||
import { notify } from '../lib/osNotify';
|
import { notify } from '../lib/osNotify';
|
||||||
import {
|
import {
|
||||||
isTauriRuntime,
|
isTauriRuntime,
|
||||||
@@ -40,8 +41,13 @@ import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
|
|||||||
import {
|
import {
|
||||||
getAudioQualityParams,
|
getAudioQualityParams,
|
||||||
getAudioSettings,
|
getAudioSettings,
|
||||||
|
subscribeAudioSettings,
|
||||||
updateAudioSettings,
|
updateAudioSettings,
|
||||||
} from '../lib/audioSettings';
|
} from '../lib/audioSettings';
|
||||||
|
import {
|
||||||
|
applyBackgroundBlurToLocal,
|
||||||
|
removeBackgroundBlurFromLocal,
|
||||||
|
} from '../lib/videoBlur';
|
||||||
import {
|
import {
|
||||||
createCallE2EE,
|
createCallE2EE,
|
||||||
getCallE2EESettings,
|
getCallE2EESettings,
|
||||||
@@ -492,14 +498,14 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
r.on(RoomEvent.ParticipantConnected, () => {
|
r.on(RoomEvent.ParticipantConnected, () => {
|
||||||
setRemoteParticipants(Array.from(r.remoteParticipants.values()));
|
setRemoteParticipants(Array.from(r.remoteParticipants.values()));
|
||||||
void playJoinBeep();
|
if (presenceRef.current !== 'dnd') void playJoinBeep();
|
||||||
markConnectedIfReady(r, conversationId, mediaKind, callId);
|
markConnectedIfReady(r, conversationId, mediaKind, callId);
|
||||||
});
|
});
|
||||||
|
|
||||||
r.on(RoomEvent.ParticipantDisconnected, () => {
|
r.on(RoomEvent.ParticipantDisconnected, () => {
|
||||||
const remaining = Array.from(r.remoteParticipants.values());
|
const remaining = Array.from(r.remoteParticipants.values());
|
||||||
setRemoteParticipants(remaining);
|
setRemoteParticipants(remaining);
|
||||||
void playLeaveBeep();
|
if (presenceRef.current !== 'dnd') void playLeaveBeep();
|
||||||
// Alone in the room while connected — start the solo-timeout.
|
// Alone in the room while connected — start the solo-timeout.
|
||||||
if (
|
if (
|
||||||
stateRef.current.kind === 'connected' &&
|
stateRef.current.kind === 'connected' &&
|
||||||
@@ -627,7 +633,11 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsE2EEActive(false);
|
setIsE2EEActive(false);
|
||||||
}
|
}
|
||||||
try {
|
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
|
// Grab the raw mic ourselves instead of going through LiveKit's
|
||||||
// setMicrophoneEnabled. The resulting MediaStreamTrack is routed
|
// setMicrophoneEnabled. The resulting MediaStreamTrack is routed
|
||||||
// through createMicPipeline, which mixes in soundboard buffers and
|
// through createMicPipeline, which mixes in soundboard buffers and
|
||||||
@@ -636,7 +646,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
const rawStream = await navigator.mediaDevices.getUserMedia({
|
const rawStream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: {
|
audio: {
|
||||||
echoCancellation: aParams.echoCancellation,
|
echoCancellation: aParams.echoCancellation,
|
||||||
noiseSuppression: aParams.noiseSuppression,
|
noiseSuppression: nsEffective,
|
||||||
autoGainControl: aParams.autoGainControl,
|
autoGainControl: aParams.autoGainControl,
|
||||||
channelCount: aParams.stereo ? 2 : 1,
|
channelCount: aParams.stereo ? 2 : 1,
|
||||||
sampleRate: aParams.sampleRateHz,
|
sampleRate: aParams.sampleRateHz,
|
||||||
@@ -947,7 +957,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await lp.setScreenShareEnabled(true, {
|
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
|
...(ssParams.dims
|
||||||
? {
|
? {
|
||||||
resolution: {
|
resolution: {
|
||||||
@@ -1033,6 +1046,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
await lp.setCameraEnabled(nextOn);
|
await lp.setCameraEnabled(nextOn);
|
||||||
setIsCameraEnabled(nextOn);
|
setIsCameraEnabled(nextOn);
|
||||||
|
if (nextOn && getAudioSettings().videoBackgroundBlur) {
|
||||||
|
void applyBackgroundBlurToLocal(lp);
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('setCameraEnabled failed', err);
|
console.error('setCameraEnabled failed', err);
|
||||||
// Permission denied / no camera — keep state in sync with actual
|
// 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 ------------------------------------------------------
|
// --- Push-to-talk ------------------------------------------------------
|
||||||
// While PTT is active + we're in a connected call, the mic is held off
|
// While PTT is active + we're in a connected call, the mic is held off
|
||||||
// except while the configured key is pressed. Under Tauri we also register
|
// except while the configured key is pressed. Under Tauri we also register
|
||||||
@@ -1337,11 +1370,13 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
// to the pipeline. It disconnects the old source, stops the old track,
|
// to the pipeline. It disconnects the old source, stops the old track,
|
||||||
// and rewires micGain onto the new source — the published track stays
|
// and rewires micGain onto the new source — the published track stays
|
||||||
// stable so peers don't see a republish.
|
// 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({
|
const newStream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: {
|
audio: {
|
||||||
echoCancellation: aParams.echoCancellation,
|
echoCancellation: aParams.echoCancellation,
|
||||||
noiseSuppression: aParams.noiseSuppression,
|
noiseSuppression: nsEffective,
|
||||||
autoGainControl: aParams.autoGainControl,
|
autoGainControl: aParams.autoGainControl,
|
||||||
channelCount: aParams.stereo ? 2 : 1,
|
channelCount: aParams.stereo ? 2 : 1,
|
||||||
sampleRate: aParams.sampleRateHz,
|
sampleRate: aParams.sampleRateHz,
|
||||||
@@ -1393,6 +1428,17 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [state.kind]);
|
}, [state.kind]);
|
||||||
|
|
||||||
|
// Hold the screen awake while a call is live so long sessions don't get
|
||||||
|
// dropped by display-sleep / OS power-save. Released the moment the call
|
||||||
|
// ends or errors out.
|
||||||
|
useEffect(() => {
|
||||||
|
const callActive =
|
||||||
|
state.kind === 'connected' ||
|
||||||
|
state.kind === 'connecting' ||
|
||||||
|
state.kind === 'outgoing';
|
||||||
|
void setCallWakeLock(callActive);
|
||||||
|
}, [state.kind]);
|
||||||
|
|
||||||
// Global Esc: drop out of fullscreen cinema back to grid while in an active
|
// Global Esc: drop out of fullscreen cinema back to grid while in an active
|
||||||
// call. Doesn't hangup and doesn't fire when other dialogs would consume it.
|
// call. Doesn't hangup and doesn't fire when other dialogs would consume it.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -16,12 +16,27 @@ export interface AudioSettings {
|
|||||||
// Preferred output (speaker/headphone) deviceId. null = system default.
|
// Preferred output (speaker/headphone) deviceId. null = system default.
|
||||||
// Applied to every <audio> element we attach via HTMLMediaElement.setSinkId.
|
// Applied to every <audio> element we attach via HTMLMediaElement.setSinkId.
|
||||||
outputDeviceId: string | null;
|
outputDeviceId: string | null;
|
||||||
|
// RMS threshold (0..1) for the "is this participant talking" ring. Lower =
|
||||||
|
// more sensitive. Default 0.03 catches soft speech without lighting up on
|
||||||
|
// keyboard noise. Users on quiet mics can lower; users in noisy rooms bump up.
|
||||||
|
voiceThreshold: number;
|
||||||
|
// Per-publish DSP toggle. Off lets hifi-style music go through unmodified;
|
||||||
|
// on cleans up voice when the quality preset doesn't already imply it.
|
||||||
|
// Defaults to "follow the quality preset".
|
||||||
|
noiseSuppression: boolean;
|
||||||
|
// Background blur on the local camera track. Lazy — requires
|
||||||
|
// @livekit/track-processors + its MediaPipe selfie-segmentation model
|
||||||
|
// (~1.5MB) which downloads on first activation.
|
||||||
|
videoBackgroundBlur: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS: AudioSettings = {
|
const DEFAULTS: AudioSettings = {
|
||||||
quality: 'voice',
|
quality: 'voice',
|
||||||
inputDeviceId: null,
|
inputDeviceId: null,
|
||||||
outputDeviceId: null,
|
outputDeviceId: null,
|
||||||
|
voiceThreshold: 0.03,
|
||||||
|
noiseSuppression: true,
|
||||||
|
videoBackgroundBlur: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AudioQualityParams {
|
export interface AudioQualityParams {
|
||||||
@@ -80,6 +95,7 @@ function read(): AudioSettings {
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
const parsed = JSON.parse(raw) as Partial<AudioSettings>;
|
const parsed = JSON.parse(raw) as Partial<AudioSettings>;
|
||||||
|
const rawThreshold = typeof parsed.voiceThreshold === 'number' ? parsed.voiceThreshold : NaN;
|
||||||
cached = {
|
cached = {
|
||||||
quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality,
|
quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality,
|
||||||
inputDeviceId:
|
inputDeviceId:
|
||||||
@@ -90,6 +106,18 @@ function read(): AudioSettings {
|
|||||||
typeof parsed.outputDeviceId === 'string' && parsed.outputDeviceId.length > 0
|
typeof parsed.outputDeviceId === 'string' && parsed.outputDeviceId.length > 0
|
||||||
? parsed.outputDeviceId
|
? parsed.outputDeviceId
|
||||||
: DEFAULTS.outputDeviceId,
|
: DEFAULTS.outputDeviceId,
|
||||||
|
voiceThreshold:
|
||||||
|
Number.isFinite(rawThreshold) && rawThreshold >= 0.005 && rawThreshold <= 0.2
|
||||||
|
? rawThreshold
|
||||||
|
: DEFAULTS.voiceThreshold,
|
||||||
|
noiseSuppression:
|
||||||
|
typeof parsed.noiseSuppression === 'boolean'
|
||||||
|
? parsed.noiseSuppression
|
||||||
|
: DEFAULTS.noiseSuppression,
|
||||||
|
videoBackgroundBlur:
|
||||||
|
typeof parsed.videoBackgroundBlur === 'boolean'
|
||||||
|
? parsed.videoBackgroundBlur
|
||||||
|
: DEFAULTS.videoBackgroundBlur,
|
||||||
};
|
};
|
||||||
return cached;
|
return cached;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,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(
|
await db.execute(
|
||||||
'CREATE INDEX IF NOT EXISTS idx_msgs_conv_created ON messages(conversation_id, created_at);',
|
'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;
|
return db;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.warn('messageCache init failed — falling back to memory-only', err);
|
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
|
// Housekeeping — run once per session to bound the cache size. Keeps the
|
||||||
// latest KEEP_PER_CONV messages per conversation.
|
// latest KEEP_PER_CONV messages per conversation.
|
||||||
const KEEP_PER_CONV = 1000;
|
const KEEP_PER_CONV = 1000;
|
||||||
|
|||||||
@@ -24,12 +24,18 @@ export interface ScreenShareSettings {
|
|||||||
displaySurface: DisplaySurfaceHint;
|
displaySurface: DisplaySurfaceHint;
|
||||||
// User-chosen framerate. `null` falls back to preset's default.
|
// User-chosen framerate. `null` falls back to preset's default.
|
||||||
framerateOverride: number | null;
|
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 = {
|
const DEFAULTS: ScreenShareSettings = {
|
||||||
preset: 'auto',
|
preset: 'auto',
|
||||||
displaySurface: null,
|
displaySurface: null,
|
||||||
framerateOverride: null,
|
framerateOverride: null,
|
||||||
|
includeSystemAudio: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface PresetParams {
|
export interface PresetParams {
|
||||||
@@ -121,6 +127,10 @@ function read(): ScreenShareSettings {
|
|||||||
typeof parsed.framerateOverride === 'number' && parsed.framerateOverride > 0
|
typeof parsed.framerateOverride === 'number' && parsed.framerateOverride > 0
|
||||||
? parsed.framerateOverride
|
? parsed.framerateOverride
|
||||||
: DEFAULTS.framerateOverride,
|
: DEFAULTS.framerateOverride,
|
||||||
|
includeSystemAudio:
|
||||||
|
typeof parsed.includeSystemAudio === 'boolean'
|
||||||
|
? parsed.includeSystemAudio
|
||||||
|
: DEFAULTS.includeSystemAudio,
|
||||||
};
|
};
|
||||||
return cached;
|
return cached;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { AudioTrack, Participant, Room } from 'livekit-client';
|
import type { AudioTrack, Participant, Room } from 'livekit-client';
|
||||||
import { ParticipantEvent, RoomEvent, Track } 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
|
// Real-time speaking ring driven by Web Audio API AnalyserNodes directly on
|
||||||
// each participant's audio MediaStreamTrack. LiveKit's own `audioLevel` +
|
// 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.
|
// lights up within one animation frame of actual speech.
|
||||||
//
|
//
|
||||||
// Hold time of 250ms prevents flicker between words / short pauses.
|
// 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 POLL_MS = 50;
|
||||||
const HOLD_MS = 250;
|
const HOLD_MS = 250;
|
||||||
const THRESHOLD = 0.03; // RMS on 0..1 — tuned against soft speech
|
|
||||||
const FFT_SIZE = 256;
|
const FFT_SIZE = 256;
|
||||||
|
|
||||||
interface Probe {
|
interface Probe {
|
||||||
@@ -80,6 +83,15 @@ function firstAudioTrack(p: Participant): AudioTrack | null {
|
|||||||
|
|
||||||
export function useActiveSpeakers(room: Room | null): Set<string> {
|
export function useActiveSpeakers(room: Room | null): Set<string> {
|
||||||
const [ids, setIds] = useState<Set<string>>(() => new Set());
|
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(() => {
|
useEffect(() => {
|
||||||
if (!room) {
|
if (!room) {
|
||||||
@@ -142,8 +154,9 @@ export function useActiveSpeakers(room: Room | null): Set<string> {
|
|||||||
room.remoteParticipants.forEach(syncProbe);
|
room.remoteParticipants.forEach(syncProbe);
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const threshold = thresholdRef.current;
|
||||||
for (const [id, probe] of probes) {
|
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>();
|
const next = new Set<string>();
|
||||||
for (const [id, t] of lastActive) {
|
for (const [id, t] of lastActive) {
|
||||||
|
|||||||
@@ -473,6 +473,35 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
const priv = privateKeyRef.current;
|
const priv = privateKeyRef.current;
|
||||||
if (!priv) throw new Error('private key not loaded');
|
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
|
// Text-only path is retryable — if the network is down or the server
|
||||||
// rejects transiently, stash in the outbox and keep the UI optimistic.
|
// rejects transiently, stash in the outbox and keep the UI optimistic.
|
||||||
// Attachments can't be deferred (large payloads, uploaded separately),
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,12 +24,15 @@ import { InCallPanel } from '../components/InCallPanel';
|
|||||||
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
||||||
import { MentionAutocomplete } from '../components/MentionAutocomplete';
|
import { MentionAutocomplete } from '../components/MentionAutocomplete';
|
||||||
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
|
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
|
||||||
|
import { UserProfilePopover } from '../components/UserProfilePopover';
|
||||||
import { TypingIndicator } from '../components/TypingIndicator';
|
import { TypingIndicator } from '../components/TypingIndicator';
|
||||||
import { VoiceRecorder } from '../components/VoiceRecorder';
|
import { VoiceRecorder } from '../components/VoiceRecorder';
|
||||||
import type { DecryptedMessage } from '@chat-app/shared/chat';
|
import type { DecryptedMessage } from '@chat-app/shared/chat';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useCall } from '../context/CallContext';
|
import { useCall } from '../context/CallContext';
|
||||||
import { useConversationsContext } from '../context/ConversationsContext';
|
import { useConversationsContext } from '../context/ConversationsContext';
|
||||||
|
import { compressImages } from '../lib/imageCompress';
|
||||||
|
import { searchCachedMessages } from '../lib/messageCache';
|
||||||
import type { OutboxItem } from '../lib/messageOutbox';
|
import type { OutboxItem } from '../lib/messageOutbox';
|
||||||
import { useConversationMessages } from '../lib/useConversationMessages';
|
import { useConversationMessages } from '../lib/useConversationMessages';
|
||||||
import { useMessageReactions } from '../lib/useMessageReactions';
|
import { useMessageReactions } from '../lib/useMessageReactions';
|
||||||
@@ -45,7 +48,7 @@ export function ConversationPage() {
|
|||||||
const { t } = useTranslation(['app', 'errors']);
|
const { t } = useTranslation(['app', 'errors']);
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { session, device } = useAuth();
|
const { session, device } = useAuth();
|
||||||
const { conversations, setActiveConversation, markRead } = useConversationsContext();
|
const { conversations, setActiveConversation, markRead, unread } = useConversationsContext();
|
||||||
|
|
||||||
const conversation = useMemo(
|
const conversation = useMemo(
|
||||||
() => conversations.find((c) => c.id === id) ?? null,
|
() => conversations.find((c) => c.id === id) ?? null,
|
||||||
@@ -145,6 +148,14 @@ export function ConversationPage() {
|
|||||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||||
const [displayCount, setDisplayCount] = useState<number>(150);
|
const [displayCount, setDisplayCount] = useState<number>(150);
|
||||||
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
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<
|
const [mentionState, setMentionState] = useState<
|
||||||
{ query: string; start: number } | null
|
{ query: string; start: number } | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -161,8 +172,27 @@ export function ConversationPage() {
|
|||||||
setSearchOpen(false);
|
setSearchOpen(false);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setDisplayCount(150);
|
setDisplayCount(150);
|
||||||
|
firstUnreadRef.current = null;
|
||||||
|
firstUnreadComputedRef.current = false;
|
||||||
}, [id]);
|
}, [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
|
// Expand window when the "load older" sentinel scrolls into view. Doubles
|
||||||
// effective window on each trigger so scrolling up quickly converges to
|
// effective window on each trigger so scrolling up quickly converges to
|
||||||
// rendering everything.
|
// rendering everything.
|
||||||
@@ -257,6 +287,32 @@ export function ConversationPage() {
|
|||||||
searchDateTo !== '',
|
searchDateTo !== '',
|
||||||
[searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, 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(() => {
|
const searchMatches = useMemo(() => {
|
||||||
if (!searchActive) return [] as DecryptedMessage[];
|
if (!searchActive) return [] as DecryptedMessage[];
|
||||||
const q = searchQuery.trim().toLowerCase();
|
const q = searchQuery.trim().toLowerCase();
|
||||||
@@ -265,7 +321,26 @@ export function ConversationPage() {
|
|||||||
const toTs = searchDateTo
|
const toTs = searchDateTo
|
||||||
? new Date(searchDateTo).getTime() + 24 * 3600 * 1000 - 1
|
? new Date(searchDateTo).getTime() + 24 * 3600 * 1000 - 1
|
||||||
: null;
|
: 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;
|
if (searchSenderId && m.senderId !== searchSenderId) return false;
|
||||||
const created = new Date(m.createdAt).getTime();
|
const created = new Date(m.createdAt).getTime();
|
||||||
if (fromTs !== null && created < fromTs) return false;
|
if (fromTs !== null && created < fromTs) return false;
|
||||||
@@ -277,7 +352,16 @@ export function ConversationPage() {
|
|||||||
if (q && !parsed.text.toLowerCase().includes(q)) return false;
|
if (q && !parsed.text.toLowerCase().includes(q)) return false;
|
||||||
return true;
|
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.
|
// Reset/clamp the active match index when the match set changes.
|
||||||
useEffect(() => {
|
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[] = [];
|
const next: File[] = [];
|
||||||
for (const f of files) {
|
for (const f of compressed) {
|
||||||
if (f.size > 10 * 1024 * 1024) {
|
if (f.size > 10 * 1024 * 1024) {
|
||||||
setSendError('Datei zu groß (max 10 MB)');
|
setSendError('Datei zu groß (max 10 MB)');
|
||||||
continue;
|
continue;
|
||||||
@@ -367,7 +455,7 @@ export function ConversationPage() {
|
|||||||
|
|
||||||
function handleFilesChosen(list: FileList | null) {
|
function handleFilesChosen(list: FileList | null) {
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
ingestFiles(Array.from(list));
|
void ingestFiles(Array.from(list));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { state: callState } = useCall();
|
const { state: callState } = useCall();
|
||||||
@@ -405,7 +493,7 @@ export function ConversationPage() {
|
|||||||
if (!e.dataTransfer?.files?.length) return;
|
if (!e.dataTransfer?.files?.length) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDraggingFile(false);
|
setIsDraggingFile(false);
|
||||||
ingestFiles(Array.from(e.dataTransfer.files));
|
void ingestFiles(Array.from(e.dataTransfer.files));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!callHereActive && (
|
{!callHereActive && (
|
||||||
@@ -512,6 +600,18 @@ export function ConversationPage() {
|
|||||||
(m.senderId !== myId ? (conversation?.peer ?? null) : null);
|
(m.senderId !== myId ? (conversation?.peer ?? null) : null);
|
||||||
return (
|
return (
|
||||||
<li key={m.id}>
|
<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
|
<MessageBubble
|
||||||
message={m}
|
message={m}
|
||||||
mine={m.senderId === myId}
|
mine={m.senderId === myId}
|
||||||
@@ -540,6 +640,14 @@ export function ConversationPage() {
|
|||||||
onJumpToMessage={jumpToMessage}
|
onJumpToMessage={jumpToMessage}
|
||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
onForward={handleForward}
|
onForward={handleForward}
|
||||||
|
onAvatarClick={(uid, ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
setProfilePopover({
|
||||||
|
userId: uid,
|
||||||
|
x: ev.clientX,
|
||||||
|
y: ev.clientY,
|
||||||
|
});
|
||||||
|
}}
|
||||||
highlighted={highlightedId === m.id}
|
highlighted={highlightedId === m.id}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
@@ -754,7 +862,7 @@ export function ConversationPage() {
|
|||||||
}
|
}
|
||||||
if (pics.length > 0) {
|
if (pics.length > 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
ingestFiles(pics);
|
void ingestFiles(pics);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
rows={1}
|
rows={1}
|
||||||
@@ -778,6 +886,20 @@ export function ConversationPage() {
|
|||||||
currentConversationId={id ?? null}
|
currentConversationId={id ?? null}
|
||||||
onClose={() => setForwardTarget(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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { Avatar } from '../components/Avatar';
|
import { Avatar } from '../components/Avatar';
|
||||||
import { BackupExportDialog } from '../components/BackupExportDialog';
|
import { BackupExportDialog } from '../components/BackupExportDialog';
|
||||||
|
import { BackupRestoreDialog } from '../components/BackupRestoreDialog';
|
||||||
import { RingtoneSettings } from '../components/RingtoneSettings';
|
import { RingtoneSettings } from '../components/RingtoneSettings';
|
||||||
import { SoundboardSettings } from '../components/SoundboardSettings';
|
import { SoundboardSettings } from '../components/SoundboardSettings';
|
||||||
import { LockIcon } from '../components/icons';
|
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.',
|
'Mono 48 kbps mit Noise-Suppression, Echo-Cancellation und Auto-Gain. Optimiert für Sprache im Raum.',
|
||||||
})}
|
})}
|
||||||
</p>
|
</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 {
|
interface AvatarControlsProps {
|
||||||
patchProfile: (patch: Parameters<typeof updateOwnProfile>[1]) => Promise<void>;
|
patchProfile: (patch: Parameters<typeof updateOwnProfile>[1]) => Promise<void>;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
@@ -464,6 +554,7 @@ function DeviceKeyBackupControls() {
|
|||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
const { profile, device } = useAuth();
|
const { profile, device } = useAuth();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [restoreOpen, setRestoreOpen] = useState(false);
|
||||||
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
|
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const canRun = !!profile?.userId && !!device?.id;
|
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.',
|
'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>
|
</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canRun}
|
disabled={!canRun}
|
||||||
onClick={() => void handleOpen()}
|
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"
|
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' })}
|
{t('app:backup.export_open', { defaultValue: 'Backup erstellen' })}
|
||||||
</button>
|
</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 && (
|
{err && (
|
||||||
<p className="mt-2 text-xs text-rose-600 dark:text-rose-300">{err}</p>
|
<p className="mt-2 text-xs text-rose-600 dark:text-rose-300">{err}</p>
|
||||||
)}
|
)}
|
||||||
@@ -523,6 +624,14 @@ function DeviceKeyBackupControls() {
|
|||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{profile && (
|
||||||
|
<BackupRestoreDialog
|
||||||
|
open={restoreOpen}
|
||||||
|
userId={profile.userId}
|
||||||
|
onClose={() => setRestoreOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ export interface TextMessagePayload {
|
|||||||
type?: 'text';
|
type?: 'text';
|
||||||
text: string;
|
text: string;
|
||||||
attachments: AttachmentHandle[];
|
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 {
|
export interface CallEventPayload {
|
||||||
@@ -52,7 +56,12 @@ export interface CallEventPayload {
|
|||||||
export type MessagePayload = TextMessagePayload | CallEventPayload;
|
export type MessagePayload = TextMessagePayload | CallEventPayload;
|
||||||
|
|
||||||
export type ParsedMessagePayload =
|
export type ParsedMessagePayload =
|
||||||
| { kind: 'text'; text: string; attachments: AttachmentHandle[] }
|
| {
|
||||||
|
kind: 'text';
|
||||||
|
text: string;
|
||||||
|
attachments: AttachmentHandle[];
|
||||||
|
expireMs?: number;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
kind: 'call_event';
|
kind: 'call_event';
|
||||||
status: CallEventStatus;
|
status: CallEventStatus;
|
||||||
@@ -91,6 +100,9 @@ export function parseMessagePayload(raw: string | null): ParsedMessagePayload {
|
|||||||
kind: 'text',
|
kind: 'text',
|
||||||
text: typeof t.text === 'string' ? t.text : '',
|
text: typeof t.text === 'string' ? t.text : '',
|
||||||
attachments: Array.isArray(t.attachments) ? t.attachments : [],
|
attachments: Array.isArray(t.attachments) ? t.attachments : [],
|
||||||
|
...(typeof t.expireMs === 'number' && t.expireMs > 0
|
||||||
|
? { expireMs: t.expireMs }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ importers:
|
|||||||
'@livekit/components-react':
|
'@livekit/components-react':
|
||||||
specifier: ^2.9.0
|
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)
|
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':
|
'@supabase/supabase-js':
|
||||||
specifier: ^2.46.0
|
specifier: ^2.46.0
|
||||||
version: 2.103.3
|
version: 2.103.3
|
||||||
@@ -1354,6 +1357,15 @@ packages:
|
|||||||
'@livekit/protocol@1.45.3':
|
'@livekit/protocol@1.45.3':
|
||||||
resolution: {integrity: sha512-WmMxBTsy4dRBqcrswFwUUlgq3Z0nnhOqKR6tX749Rb/PcB1yBMUtrHxZvcsS6qi3/5+86zHeVG+exmu1sZqfJg==}
|
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':
|
'@noble/hashes@1.8.0':
|
||||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||||
engines: {node: ^14.21.3 || >=16}
|
engines: {node: ^14.21.3 || >=16}
|
||||||
@@ -1835,6 +1847,12 @@ packages:
|
|||||||
'@types/dom-mediacapture-record@1.0.22':
|
'@types/dom-mediacapture-record@1.0.22':
|
||||||
resolution: {integrity: sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==}
|
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':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
@@ -6906,6 +6924,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@bufbuild/protobuf': 1.10.1
|
'@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': {}
|
'@noble/hashes@1.8.0': {}
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
@@ -7485,6 +7511,12 @@ snapshots:
|
|||||||
|
|
||||||
'@types/dom-mediacapture-record@1.0.22': {}
|
'@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/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/graceful-fs@4.1.9':
|
'@types/graceful-fs@4.1.9':
|
||||||
|
|||||||