feat: voice messages, offline queue, delivery ticks, volume slider, admin + scaling
- Voice messages: MediaRecorder → encrypted attachment, custom waveform player via OfflineAudioContext, 60s limit + live mic-level meter - Offline message queue: localStorage outbox, exponential backoff retries, optimistic pending bubble with retry/discard - Delivery indicator: message_deliveries table + RLS (reciprocal receipts), ✓ / ✓✓ / ✓✓-blue tick states, group-aware (all members must ack) - Per-participant volume slider in calls via right-click tile menu, persisted to localStorage, applied to attached audio elements - Group call scaling: grid up to 12 tiles with pagination, active-speaker auto-promotion in fullscreen - Push notifications scaffolding: service worker, VAPID subscription registration, notify-push edge function skeleton - Backup recovery code: 24-char base32 code (~120 bits entropy) as alternative decrypt path, restore UI with mode toggle - Admin panel: conversations list, audit log (admin_audit_log table + admin_log_action RPC), audit entry on user flag toggle - Search v2: sender filter, attachment-only toggle, date range - Reactions pop animation (scale 0.4→1.15→1 on count change) - Message list windowing (150 default, expand via IntersectionObserver) - Stub cleanup: removed dead ScreenshareStub from CallParticipantTile Fixes: - Focus-triggered flicker: dropped window.focus listeners in three spots, throttled visibilitychange/online wake-refreshes to 30s, keep existing data visible during background re-syncs (no more spinner on every click) - Voice attachment audio element collapsed to 0px on peer side — now forces 280px min-width on bubble Migrations (push required): 20260421000001_message_deliveries.sql 20260421000002_admin_audit_log.sql Server TODO: VAPID keys + notify-push edge function deploy
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
handle: AttachmentHandle;
|
||||
}
|
||||
|
||||
const BAR_COUNT = 48;
|
||||
|
||||
// Custom voice-message player with waveform visualisation. Decoded peaks are
|
||||
// computed once per blob via OfflineAudioContext so playback only carries the
|
||||
// rendered DOM. Falls back to a rectangular bar if decoding fails (e.g. the
|
||||
// blob mime is recognised by <audio> but not by AudioContext).
|
||||
export function AttachmentAudio({ handle }: Props) {
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
const [arrayBuf, setArrayBuf] = useState<ArrayBuffer | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [peaks, setPeaks] = useState<number[] | null>(null);
|
||||
const [duration, setDuration] = useState<number>(0);
|
||||
const [position, setPosition] = useState<number>(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let url: string | null = null;
|
||||
setError(null);
|
||||
setBlobUrl(null);
|
||||
setArrayBuf(null);
|
||||
|
||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
||||
.then(async (blob) => {
|
||||
if (cancelled) return;
|
||||
url = URL.createObjectURL(blob);
|
||||
setBlobUrl(url);
|
||||
const buf = await blob.arrayBuffer();
|
||||
if (!cancelled) setArrayBuf(buf);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'download failed');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
||||
|
||||
// Compute peaks via OfflineAudioContext. Cheap O(n) scan over PCM samples
|
||||
// bucketed into BAR_COUNT bars. Done once per attachment.
|
||||
useEffect(() => {
|
||||
if (!arrayBuf) return;
|
||||
let cancelled = false;
|
||||
const Ctx =
|
||||
window.AudioContext ||
|
||||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
const ctx = new Ctx();
|
||||
ctx
|
||||
.decodeAudioData(arrayBuf.slice(0))
|
||||
.then((decoded) => {
|
||||
if (cancelled) return;
|
||||
setDuration(decoded.duration);
|
||||
const channel = decoded.getChannelData(0);
|
||||
const bucket = Math.max(1, Math.floor(channel.length / BAR_COUNT));
|
||||
const out = new Array<number>(BAR_COUNT).fill(0);
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
let max = 0;
|
||||
const start = i * bucket;
|
||||
const end = Math.min(channel.length, start + bucket);
|
||||
for (let j = start; j < end; j++) {
|
||||
const v = Math.abs(channel[j]!);
|
||||
if (v > max) max = v;
|
||||
}
|
||||
out[i] = max;
|
||||
}
|
||||
// Normalize so loudest peak is 1; keeps quiet recordings visible.
|
||||
const peak = Math.max(...out, 0.001);
|
||||
setPeaks(out.map((v) => v / peak));
|
||||
})
|
||||
.catch(() => {
|
||||
// Fall through — UI shows a flat bar but playback still works.
|
||||
})
|
||||
.finally(() => {
|
||||
void ctx.close().catch(() => {});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [arrayBuf]);
|
||||
|
||||
const fallbackPeaks = useMemo(
|
||||
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
|
||||
[peaks],
|
||||
);
|
||||
const visiblePeaks = peaks ?? fallbackPeaks!;
|
||||
const progress = duration > 0 ? position / duration : 0;
|
||||
|
||||
const onTogglePlay = () => {
|
||||
const el = audioRef.current;
|
||||
if (!el || !blobUrl) return;
|
||||
if (el.paused) void el.play();
|
||||
else el.pause();
|
||||
};
|
||||
|
||||
const onSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const el = audioRef.current;
|
||||
if (!el || duration === 0) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||
el.currentTime = ratio * duration;
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
|
||||
<AlertIcon className="h-4 w-4" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex w-[280px] min-w-[280px] items-center gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePlay}
|
||||
disabled={!blobUrl}
|
||||
aria-label={playing ? 'Pause' : 'Wiedergabe'}
|
||||
title={playing ? 'Pause' : 'Wiedergabe'}
|
||||
className="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{!blobUrl ? (
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
) : playing ? (
|
||||
<PauseGlyph />
|
||||
) : (
|
||||
<PlayGlyph />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div
|
||||
role="slider"
|
||||
aria-label="Position"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={Math.max(1, Math.floor(duration))}
|
||||
aria-valuenow={Math.floor(position)}
|
||||
tabIndex={0}
|
||||
onClick={onSeek}
|
||||
className="flex h-7 cursor-pointer items-center gap-[2px]"
|
||||
>
|
||||
{visiblePeaks.map((v, i) => {
|
||||
const playedRatio = (i + 0.5) / BAR_COUNT;
|
||||
const played = playedRatio <= progress;
|
||||
const h = Math.max(2, Math.round(v * 22));
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{ height: h + 'px' }}
|
||||
className={
|
||||
'w-[3px] rounded-full ' +
|
||||
(played ? 'bg-accent' : 'bg-fg-muted/40')
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] tabular-nums text-fg-muted">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MicIcon className="h-3 w-3" />
|
||||
<span>{formatSec(playing ? position : duration)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{blobUrl && (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={blobUrl}
|
||||
preload="metadata"
|
||||
onLoadedMetadata={(e) => {
|
||||
// Some webm/opus blobs report Infinity until first seek (Chrome
|
||||
// bug). Force a seek to flush real duration.
|
||||
const el = e.currentTarget;
|
||||
if (!Number.isFinite(el.duration)) {
|
||||
el.currentTime = 1e9;
|
||||
setTimeout(() => {
|
||||
el.currentTime = 0;
|
||||
}, 0);
|
||||
} else if (duration === 0) {
|
||||
setDuration(el.duration);
|
||||
}
|
||||
}}
|
||||
onDurationChange={(e) => {
|
||||
const d = e.currentTarget.duration;
|
||||
if (Number.isFinite(d) && d > 0) setDuration(d);
|
||||
}}
|
||||
onTimeUpdate={(e) => setPosition(e.currentTarget.currentTime)}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => {
|
||||
setPlaying(false);
|
||||
setPosition(0);
|
||||
}}
|
||||
className="hidden"
|
||||
>
|
||||
<track kind="captions" />
|
||||
</audio>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayGlyph() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||
<path d="M5 3.5l8 4.5-8 4.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PauseGlyph() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||
<rect x="4" y="3" width="3" height="10" rx="1" />
|
||||
<rect x="9" y="3" width="3" height="10" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSec(sec: number): string {
|
||||
if (!Number.isFinite(sec) || sec < 0) sec = 0;
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return m + ':' + s.toString().padStart(2, '0');
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { exportDeviceBackup } from '../lib/deviceBackup';
|
||||
import { type BackupBundle, exportDeviceBackupWithRecovery } from '../lib/deviceBackup';
|
||||
import { AlertIcon, CopyIcon, LockIcon, ShieldIcon, SpinnerIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
@@ -22,8 +22,9 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [backup, setBackup] = useState<string | null>(null);
|
||||
const [bundle, setBundle] = useState<BackupBundle | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedRecovery, setCopiedRecovery] = useState(false);
|
||||
|
||||
const canGenerate = useMemo(() => {
|
||||
return passphrase.length >= 8 && passphrase === confirm && !busy;
|
||||
@@ -32,9 +33,10 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
|
||||
const reset = useCallback(() => {
|
||||
setPassphrase('');
|
||||
setConfirm('');
|
||||
setBackup(null);
|
||||
setBundle(null);
|
||||
setError(null);
|
||||
setCopied(false);
|
||||
setCopiedRecovery(false);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
@@ -47,8 +49,13 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const str = await exportDeviceBackup({ userId, deviceId, privateKey, passphrase });
|
||||
setBackup(str);
|
||||
const b = await exportDeviceBackupWithRecovery({
|
||||
userId,
|
||||
deviceId,
|
||||
privateKey,
|
||||
passphrase,
|
||||
});
|
||||
setBundle(b);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -56,27 +63,39 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
|
||||
}
|
||||
}, [canGenerate, userId, deviceId, privateKey, passphrase]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!backup) return;
|
||||
const copyText = async (text: string, marker: 'main' | 'recovery'): Promise<void> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(backup);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (marker === 'main') {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} else {
|
||||
setCopiedRecovery(true);
|
||||
window.setTimeout(() => setCopiedRecovery(false), 1500);
|
||||
}
|
||||
} catch {
|
||||
/* fall back — user can select manually */
|
||||
}
|
||||
}, [backup]);
|
||||
};
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!backup) return;
|
||||
const blob = new Blob([backup], { type: 'text/plain;charset=utf-8' });
|
||||
if (!bundle) return;
|
||||
const text =
|
||||
'=== Passphrase backup ===\n' +
|
||||
bundle.passphraseBackup +
|
||||
'\n\n=== Recovery code ===\n' +
|
||||
bundle.recoveryCode +
|
||||
'\n\n=== Recovery backup (use with the recovery code) ===\n' +
|
||||
bundle.recoveryBackup +
|
||||
'\n';
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `chatapp-device-backup-${deviceId.slice(0, 8)}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [backup, deviceId]);
|
||||
}, [bundle, deviceId]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -110,7 +129,7 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{!backup ? (
|
||||
{!bundle ? (
|
||||
<>
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('app:backup.export_explainer', {
|
||||
@@ -183,40 +202,76 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
Backup-String
|
||||
</label>
|
||||
<textarea
|
||||
readOnly
|
||||
value={backup}
|
||||
rows={8}
|
||||
value={bundle.passphraseBackup}
|
||||
rows={6}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="mt-3 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
|
||||
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
|
||||
/>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopy()}
|
||||
onClick={() => void copyText(bundle.passphraseBackup, 'main')}
|
||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
<span>
|
||||
{copied
|
||||
? t('app:backup.copied', { defaultValue: 'Kopiert!' })
|
||||
: t('app:backup.copy', { defaultValue: 'Kopieren' })}
|
||||
</span>
|
||||
<span>{copied ? 'Kopiert!' : 'Kopieren'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
||||
>
|
||||
{t('app:backup.download', { defaultValue: 'Als Datei speichern' })}
|
||||
Als Datei speichern (alles)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-semibold text-amber-700 dark:text-amber-200">
|
||||
Recovery-Code (Passphrase vergessen?)
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] text-amber-700/80 dark:text-amber-200/80">
|
||||
Code separat aufbewahren. Mit dem Recovery-Backup unten lässt sich der Schlüssel
|
||||
ohne Passphrase wiederherstellen.
|
||||
</p>
|
||||
<p className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest text-fg">
|
||||
{bundle.recoveryCode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
Recovery-Backup-String
|
||||
</label>
|
||||
<textarea
|
||||
readOnly
|
||||
value={bundle.recoveryBackup}
|
||||
rows={6}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(bundle.recoveryBackup, 'recovery')}
|
||||
className="mt-2 inline-flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
<span>{copiedRecovery ? 'Kopiert!' : 'Recovery-Backup kopieren'}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||
{!backup ? (
|
||||
{!bundle ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { CrownIcon, HeadphonesOffIcon, LockIcon, MicOffIcon, MonitorShareIcon } from './icons';
|
||||
import { CrownIcon, HeadphonesOffIcon, LockIcon, MicOffIcon } from './icons';
|
||||
|
||||
export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal';
|
||||
|
||||
@@ -48,7 +48,6 @@ export interface ParticipantTileProps {
|
||||
* ever carries a truthy value. */
|
||||
deafened: boolean;
|
||||
speaking: boolean;
|
||||
sharing: boolean;
|
||||
video: boolean;
|
||||
e2ee: boolean;
|
||||
/** MediaStreamTrack for the participant's active camera, when `video` is
|
||||
@@ -57,7 +56,7 @@ export interface ParticipantTileProps {
|
||||
size?: 'default' | 'small';
|
||||
focused?: boolean;
|
||||
onClick?: () => void;
|
||||
onOpenScreenShare?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
@@ -67,13 +66,12 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
muted,
|
||||
deafened,
|
||||
speaking,
|
||||
sharing,
|
||||
video,
|
||||
e2ee,
|
||||
size = 'default',
|
||||
focused = false,
|
||||
onClick,
|
||||
onOpenScreenShare,
|
||||
onContextMenu,
|
||||
} = props;
|
||||
|
||||
const small = size === 'small';
|
||||
@@ -86,6 +84,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
className={
|
||||
'relative flex flex-col overflow-hidden rounded-[14px] border bg-surface-3 transition ' +
|
||||
(onClick ? 'cursor-pointer ' : '') +
|
||||
@@ -93,13 +92,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
(small ? ' min-w-[140px]' : '')
|
||||
}
|
||||
>
|
||||
{sharing ? (
|
||||
<ScreenshareStub
|
||||
displayName={displayName}
|
||||
small={small}
|
||||
onOpen={onOpenScreenShare}
|
||||
/>
|
||||
) : video ? (
|
||||
{video ? (
|
||||
<VideoStub {...props} small={small} />
|
||||
) : (
|
||||
<AudioContent {...props} small={small} />
|
||||
@@ -150,15 +143,6 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
<HeadphonesOffIcon className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
{sharing && (
|
||||
<span
|
||||
aria-label="Teilt Bildschirm"
|
||||
title="Teilt Bildschirm"
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md bg-emerald-500/80 text-white"
|
||||
>
|
||||
<MonitorShareIcon className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -276,49 +260,3 @@ function VideoStub({
|
||||
);
|
||||
}
|
||||
|
||||
interface ScreenshareStubProps {
|
||||
displayName: string;
|
||||
small: boolean;
|
||||
onOpen?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
// Fake browser window placeholder — click to open the real <video> viewer.
|
||||
// Matches the design spec's "screenshare-stub" look.
|
||||
function ScreenshareStub({ displayName, small, onOpen }: ScreenshareStubProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={
|
||||
onOpen
|
||||
? (e) => {
|
||||
e.stopPropagation();
|
||||
onOpen();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
aria-label={`${displayName} teilt Bildschirm`}
|
||||
className="relative flex min-h-0 flex-1 cursor-pointer items-center justify-center bg-ink-900 p-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<div className="h-[85%] w-[90%] overflow-hidden rounded-lg border border-white/10 bg-ink-700">
|
||||
<div className="flex h-[20px] items-center gap-1 bg-ink-600 px-2">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
|
||||
</div>
|
||||
<div className={'flex flex-col gap-2 ' + (small ? 'gap-[3px] p-1.5' : 'p-3.5')}>
|
||||
<div className={'w-[60%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
|
||||
<div className={'w-[80%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
|
||||
<div className={'w-[40%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
|
||||
<div className={'my-1 rounded ' + (small ? 'h-[14px]' : 'h-10') + ' bg-accent/30'} />
|
||||
<div className={'w-[70%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
|
||||
</div>
|
||||
</div>
|
||||
{!small && (
|
||||
<div className="absolute left-3 top-3 flex items-center gap-1.5 glass-chip rounded-lg px-2.5 py-1 text-[10px] font-medium">
|
||||
<MonitorShareIcon className="h-3 w-3" />
|
||||
<span>{displayName} teilt Bildschirm</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
decodePrivateKeyFromBackup,
|
||||
importDeviceBackup,
|
||||
normalizeRecoveryCode,
|
||||
} from '../lib/deviceBackup';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
@@ -28,6 +29,7 @@ export function DeviceRestore({ userId, onRestored }: Props) {
|
||||
const { t } = useTranslation(['app', 'errors']);
|
||||
const [backup, setBackup] = useState('');
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [mode, setMode] = useState<'passphrase' | 'recovery'>('passphrase');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -39,7 +41,9 @@ export function DeviceRestore({ userId, onRestored }: Props) {
|
||||
setError(null);
|
||||
let privateKey: Uint8Array | null = null;
|
||||
try {
|
||||
const payload = await importDeviceBackup(backup.trim(), passphrase);
|
||||
const secret =
|
||||
mode === 'recovery' ? normalizeRecoveryCode(passphrase) : passphrase;
|
||||
const payload = await importDeviceBackup(backup.trim(), secret);
|
||||
privateKey = decodePrivateKeyFromBackup(payload);
|
||||
|
||||
const device = await restoreDeviceFromServerRecord({
|
||||
@@ -63,7 +67,7 @@ export function DeviceRestore({ userId, onRestored }: Props) {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[backup, passphrase, userId, onRestored, busy],
|
||||
[backup, passphrase, mode, userId, onRestored, busy],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -102,16 +106,42 @@ export function DeviceRestore({ userId, onRestored }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
{t('app:backup.passphrase', { defaultValue: 'Passphrase' })}
|
||||
</label>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
{mode === 'passphrase' ? 'Passphrase' : 'Recovery-Code'}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode((m) => (m === 'passphrase' ? 'recovery' : 'passphrase'));
|
||||
setPassphrase('');
|
||||
setError(null);
|
||||
}}
|
||||
className="cursor-pointer text-[11px] font-semibold text-brand-300 hover:underline"
|
||||
>
|
||||
{mode === 'passphrase'
|
||||
? 'Passphrase vergessen? Recovery-Code nutzen'
|
||||
: 'Stattdessen Passphrase eingeben'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
type={mode === 'passphrase' ? 'password' : 'text'}
|
||||
required
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
||||
placeholder={mode === 'recovery' ? 'XXXXXX-XXXXXX-XXXXXX-XXXXXX' : ''}
|
||||
spellCheck={false}
|
||||
autoComplete={mode === 'recovery' ? 'off' : 'current-password'}
|
||||
className={
|
||||
'w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40 ' +
|
||||
(mode === 'recovery' ? 'font-mono tracking-widest' : '')
|
||||
}
|
||||
/>
|
||||
{mode === 'recovery' && (
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
Stattdessen den Recovery-Backup-String oben einfügen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import type { RemoteParticipant, Room } from 'livekit-client';
|
||||
import { Track } from 'livekit-client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
@@ -15,6 +15,7 @@ import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
||||
import { CallControls } from './CallControls';
|
||||
import { CallParticipantTile } from './CallParticipantTile';
|
||||
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
|
||||
import { ScreenShareDialog } from './ScreenShareDialog';
|
||||
import { ScreenShareViewer } from './ScreenShareViewer';
|
||||
|
||||
@@ -76,6 +77,21 @@ export function InCallPanel({ conversation }: Props) {
|
||||
const myId = session?.user.id ?? null;
|
||||
const activeSpeakers = useActiveSpeakers(room);
|
||||
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
||||
const [volumeMenu, setVolumeMenu] = useState<
|
||||
{ userId: string; displayName: string; x: number; y: number } | null
|
||||
>(null);
|
||||
|
||||
const openVolumeMenu = (tile: Tile, e: React.MouseEvent) => {
|
||||
if (tile.self) return;
|
||||
if (tile.kind !== 'user') return;
|
||||
e.preventDefault();
|
||||
setVolumeMenu({
|
||||
userId: tile.userId,
|
||||
displayName: tile.displayName,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
};
|
||||
|
||||
const active =
|
||||
(state.kind === 'connected' ||
|
||||
@@ -142,26 +158,46 @@ export function InCallPanel({ conversation }: Props) {
|
||||
);
|
||||
|
||||
if (callMode === 'fullscreen') {
|
||||
// In fullscreen, a "manual focus" = user explicitly picked someone OR
|
||||
// someone is sharing their screen. Without that we show an even grid of
|
||||
// all participants (Discord default). Clicking a tile switches to the
|
||||
// big-speaker + thumbnail-strip layout.
|
||||
const hasFocus = focusedId !== null || screenTile !== undefined;
|
||||
// In fullscreen, a "manual focus" = user explicitly picked someone, OR
|
||||
// someone is sharing a screen, OR exactly one non-self speaker is talking
|
||||
// (auto-promote). Without that we show an even grid of all participants
|
||||
// (Discord default). Clicking a tile switches to the big-speaker layout.
|
||||
const speakingNonSelf = tiles.filter(
|
||||
(t: Tile) => activeSpeakers.has(t.userId) && !t.self && t.kind === 'user',
|
||||
);
|
||||
const autoSpeaker =
|
||||
focusedId === null && screenTile === undefined && speakingNonSelf.length === 1
|
||||
? speakingNonSelf[0]
|
||||
: undefined;
|
||||
const hasFocus = focusedId !== null || screenTile !== undefined || autoSpeaker !== undefined;
|
||||
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
|
||||
return (
|
||||
<FullscreenCall
|
||||
tiles={tiles}
|
||||
speaker={hasFocus ? speaker : undefined}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversation.members}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={isE2EEActive}
|
||||
onExit={() => setCallMode('grid')}
|
||||
onFocusTile={(id) => {
|
||||
// Toggle: click the already-focused tile to return to grid.
|
||||
setFocusedId(focusedId === id ? null : id);
|
||||
}}
|
||||
controls={controls}
|
||||
/>
|
||||
<>
|
||||
<FullscreenCall
|
||||
tiles={tiles}
|
||||
speaker={effectiveSpeaker}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversation.members}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={isE2EEActive}
|
||||
onExit={() => setCallMode('grid')}
|
||||
onFocusTile={(id) => {
|
||||
// Toggle: click the already-focused tile to return to grid.
|
||||
setFocusedId(focusedId === id ? null : id);
|
||||
}}
|
||||
onTileContextMenu={openVolumeMenu}
|
||||
controls={controls}
|
||||
/>
|
||||
{volumeMenu && (
|
||||
<ParticipantVolumeMenu
|
||||
userId={volumeMenu.userId}
|
||||
displayName={volumeMenu.displayName}
|
||||
x={volumeMenu.x}
|
||||
y={volumeMenu.y}
|
||||
onClose={() => setVolumeMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,6 +255,7 @@ export function InCallPanel({ conversation }: Props) {
|
||||
setFocusedId(id);
|
||||
if (callMode === 'grid') setCallMode('focus');
|
||||
}}
|
||||
onTileContextMenu={openVolumeMenu}
|
||||
compact
|
||||
/>
|
||||
|
||||
@@ -233,6 +270,16 @@ export function InCallPanel({ conversation }: Props) {
|
||||
await startScreenShare(opts);
|
||||
}}
|
||||
/>
|
||||
|
||||
{volumeMenu && (
|
||||
<ParticipantVolumeMenu
|
||||
userId={volumeMenu.userId}
|
||||
displayName={volumeMenu.displayName}
|
||||
x={volumeMenu.x}
|
||||
y={volumeMenu.y}
|
||||
onClose={() => setVolumeMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -447,6 +494,7 @@ interface StageProps {
|
||||
}[];
|
||||
conversationMembers: ConversationSummary['members'];
|
||||
onFocusTile: (id: string) => void;
|
||||
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
@@ -462,6 +510,7 @@ function TileRender({
|
||||
size,
|
||||
focused,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
}: {
|
||||
tile: Tile;
|
||||
activeSpeakers: Set<string>;
|
||||
@@ -471,6 +520,7 @@ function TileRender({
|
||||
size?: 'default' | 'small';
|
||||
focused?: boolean;
|
||||
onClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
}): JSX.Element {
|
||||
if (tile.kind === 'screen') {
|
||||
if (tile.self) {
|
||||
@@ -519,13 +569,13 @@ function TileRender({
|
||||
muted={tile.muted}
|
||||
deafened={tile.deafened}
|
||||
speaking={activeSpeakers.has(tile.userId)}
|
||||
sharing={false}
|
||||
video={tile.video}
|
||||
videoTrack={tile.videoTrack}
|
||||
e2ee={e2ee}
|
||||
{...(size ? { size } : {})}
|
||||
{...(focused ? { focused } : {})}
|
||||
{...(onClick ? { onClick } : {})}
|
||||
{...(onContextMenu ? { onContextMenu } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -539,6 +589,7 @@ function CallStage({
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
onFocusTile,
|
||||
onTileContextMenu,
|
||||
compact = false,
|
||||
}: StageProps) {
|
||||
if (mode === 'focus' && speaker) {
|
||||
@@ -569,6 +620,9 @@ function CallStage({
|
||||
conversationMembers={conversationMembers}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -592,6 +646,9 @@ function CallStage({
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -627,6 +684,8 @@ function FocusedTile({
|
||||
);
|
||||
}
|
||||
|
||||
const GRID_PAGE_SIZE = 12;
|
||||
|
||||
function gridColsFor(n: number): string {
|
||||
// Explicit `grid-rows-*` so cells get a defined height (1fr of available
|
||||
// space). Without this, implicit rows default to auto → they size to
|
||||
@@ -636,7 +695,22 @@ function gridColsFor(n: number): string {
|
||||
if (n === 2) return 'grid-cols-2 grid-rows-1';
|
||||
if (n === 3) return 'grid-cols-3 grid-rows-1';
|
||||
if (n === 4) return 'grid-cols-2 grid-rows-2';
|
||||
return 'grid-cols-3 grid-rows-2';
|
||||
if (n <= 6) return 'grid-cols-3 grid-rows-2';
|
||||
if (n <= 9) return 'grid-cols-3 grid-rows-3';
|
||||
return 'grid-cols-4 grid-rows-3';
|
||||
}
|
||||
|
||||
// Promote self + active speakers to the front of the tile list. Stable
|
||||
// otherwise. Used by both pagination (so page 1 always carries the most
|
||||
// "useful" tiles) and active-speaker promotion in fullscreen.
|
||||
function prioritizeTiles(tiles: Tile[], activeSpeakers: Set<string>): Tile[] {
|
||||
const score = (t: Tile): number => {
|
||||
if (t.self) return 3;
|
||||
if (activeSpeakers.has(t.userId)) return 2;
|
||||
if (t.kind === 'screen') return 1;
|
||||
return 0;
|
||||
};
|
||||
return [...tiles].sort((a, b) => score(b) - score(a));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -652,6 +726,7 @@ interface FullscreenProps {
|
||||
e2ee: boolean;
|
||||
onExit: () => void;
|
||||
onFocusTile: (id: string) => void;
|
||||
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
|
||||
controls: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -664,9 +739,11 @@ function FullscreenCall({
|
||||
e2ee,
|
||||
onExit: _onExit,
|
||||
onFocusTile,
|
||||
onTileContextMenu,
|
||||
controls,
|
||||
}: FullscreenProps) {
|
||||
const [hintGone, setHintGone] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setHintGone(true), 3500);
|
||||
return () => window.clearTimeout(id);
|
||||
@@ -674,7 +751,23 @@ function FullscreenCall({
|
||||
|
||||
const hasFocus = speaker !== undefined;
|
||||
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
|
||||
const gridClass = gridColsFor(tiles.length);
|
||||
|
||||
// Active-speaker reorder + paginate. When more than GRID_PAGE_SIZE tiles
|
||||
// exist, slice them into pages. Reset to page 0 if the page count drops
|
||||
// below the current page (someone left).
|
||||
const sortedGridTiles = useMemo(
|
||||
() => prioritizeTiles(tiles, activeSpeakers),
|
||||
[tiles, activeSpeakers],
|
||||
);
|
||||
const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE));
|
||||
useEffect(() => {
|
||||
if (page >= pageCount) setPage(0);
|
||||
}, [pageCount, page]);
|
||||
const visibleTiles = sortedGridTiles.slice(
|
||||
page * GRID_PAGE_SIZE,
|
||||
(page + 1) * GRID_PAGE_SIZE,
|
||||
);
|
||||
const gridClass = gridColsFor(visibleTiles.length);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
|
||||
@@ -695,6 +788,9 @@ function FullscreenCall({
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
focused
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker!, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
{others.length > 0 && (
|
||||
@@ -712,6 +808,9 @@ function FullscreenCall({
|
||||
conversationMembers={conversationMembers}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -721,7 +820,7 @@ function FullscreenCall({
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 p-4">
|
||||
<div className={'grid h-full gap-2 ' + gridClass}>
|
||||
{tiles.map((p) => (
|
||||
{visibleTiles.map((p) => (
|
||||
<div key={p.id} className="min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full">
|
||||
<TileRender
|
||||
tile={p}
|
||||
@@ -730,10 +829,36 @@ function FullscreenCall({
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className="mt-2 flex items-center justify-center gap-3 text-xs text-fg-muted">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => (p === 0 ? pageCount - 1 : p - 1))}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-1 hover:bg-surface-3"
|
||||
aria-label="Vorherige Seite"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span className="tabular-nums">
|
||||
{page + 1} / {pageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => (p + 1) % pageCount)}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-1 hover:bg-surface-3"
|
||||
aria-label="Nächste Seite"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useAuth } from '../context/AuthContext';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
||||
import { AttachmentAudio } from './AttachmentAudio';
|
||||
import { AttachmentImage } from './AttachmentImage';
|
||||
import { Avatar } from './Avatar';
|
||||
import { ForwardIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
|
||||
@@ -40,6 +41,8 @@ interface Props {
|
||||
reactions: AggregatedReaction[];
|
||||
onToggleReaction: (emoji: string) => Promise<void>;
|
||||
showSeen?: boolean;
|
||||
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
|
||||
deliveryState?: 'sent' | 'delivered' | 'read';
|
||||
/** Resolved quoted message info (parent does the lookup). */
|
||||
quoted?: QuotedRef | null;
|
||||
/** Tap-to-jump on quote bubble. Receives the quoted message's id. */
|
||||
@@ -63,6 +66,7 @@ export function MessageBubble({
|
||||
reactions,
|
||||
onToggleReaction,
|
||||
showSeen = false,
|
||||
deliveryState,
|
||||
quoted = null,
|
||||
onJumpToMessage,
|
||||
onReply,
|
||||
@@ -296,9 +300,13 @@ export function MessageBubble({
|
||||
) : (
|
||||
<>
|
||||
{bodyText.length > 0 && <div>{bodyText}</div>}
|
||||
{attachments.map((a) => (
|
||||
<AttachmentImage key={a.id} handle={a} />
|
||||
))}
|
||||
{attachments.map((a) =>
|
||||
a.mimeType.startsWith('audio/') ? (
|
||||
<AttachmentAudio key={a.id} handle={a} />
|
||||
) : (
|
||||
<AttachmentImage key={a.id} handle={a} />
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
@@ -315,21 +323,24 @@ export function MessageBubble({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSeen && mine && !editing && !message.deletedAt && (
|
||||
<p className="mt-0.5 text-right text-[10px] text-fg-muted">
|
||||
{t('app:chats.seen')}
|
||||
</p>
|
||||
{mine && !editing && !message.deletedAt && deliveryState && (
|
||||
<div className="mt-0.5 flex items-center justify-end gap-1 text-[10px] text-fg-muted">
|
||||
<DeliveryTicks state={deliveryState} />
|
||||
{showSeen && <span>{t('app:chats.seen')}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reactions.length > 0 && !editing && (
|
||||
<div className={'mt-1 flex flex-wrap gap-1 ' + (mine ? 'justify-end' : 'justify-start')}>
|
||||
{reactions.map((r) => (
|
||||
<button
|
||||
key={r.emoji}
|
||||
// Keying by emoji+count makes React remount the chip when the
|
||||
// count flips, replaying the pop animation. Cheap visual cue.
|
||||
key={r.emoji + ':' + r.count}
|
||||
type="button"
|
||||
onClick={() => void onToggleReaction(r.emoji)}
|
||||
className={
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
'inline-flex origin-bottom animate-reaction-pop cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(r.mine
|
||||
? 'border-accent/40 bg-accent/20 text-accent'
|
||||
: 'border-line bg-surface-2 text-fg hover:bg-surface-3')
|
||||
@@ -499,6 +510,45 @@ function formatDuration(totalSec: number): string {
|
||||
return m + ':' + s.toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
function DeliveryTicks({ state }: { state: 'sent' | 'delivered' | 'read' }) {
|
||||
// One checkmark for sent, two for delivered/read. Read shifts color to the
|
||||
// accent to match WhatsApp/Telegram blue-tick convention.
|
||||
const color =
|
||||
state === 'read'
|
||||
? 'text-sky-500 dark:text-sky-400'
|
||||
: 'text-fg-muted';
|
||||
return (
|
||||
<span
|
||||
aria-label={
|
||||
state === 'read'
|
||||
? 'Gelesen'
|
||||
: state === 'delivered'
|
||||
? 'Zugestellt'
|
||||
: 'Gesendet'
|
||||
}
|
||||
title={
|
||||
state === 'read'
|
||||
? 'Gelesen'
|
||||
: state === 'delivered'
|
||||
? 'Zugestellt'
|
||||
: 'Gesendet'
|
||||
}
|
||||
className={'flex items-center ' + color}
|
||||
>
|
||||
<svg viewBox="0 0 16 12" width="14" height="10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
{state === 'sent' ? (
|
||||
<polyline points="2 7 6 11 14 1" />
|
||||
) : (
|
||||
<>
|
||||
<polyline points="1 7 5 11 11 2" />
|
||||
<polyline points="6 11 10 11 14 1" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
onClick,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
getParticipantVolume,
|
||||
setParticipantVolume,
|
||||
subscribeParticipantVolumes,
|
||||
} from '../lib/participantVolumes';
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
x: number;
|
||||
y: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MENU_W = 240;
|
||||
const MENU_H = 84;
|
||||
|
||||
export function ParticipantVolumeMenu({
|
||||
userId,
|
||||
displayName,
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [volume, setVolume] = useState<number>(() => getParticipantVolume(userId));
|
||||
|
||||
// Re-sync from store in case another menu instance changed the same user.
|
||||
useEffect(() => subscribeParticipantVolumes(() => {
|
||||
setVolume(getParticipantVolume(userId));
|
||||
}), [userId]);
|
||||
|
||||
// Outside click + Esc to close.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('[data-volume-menu]')) 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 - MENU_W - 8);
|
||||
const top = Math.min(Math.max(8, y), window.innerHeight - MENU_H - 8);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
data-volume-menu
|
||||
role="dialog"
|
||||
aria-label={'Lautstärke ' + displayName}
|
||||
style={{ left, top, width: MENU_W }}
|
||||
className="fixed z-[80] rounded-xl border border-line bg-surface-2/95 p-3 shadow-xl backdrop-blur-md"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
|
||||
<span className="truncate font-semibold text-fg">{displayName}</span>
|
||||
<span className="tabular-nums text-fg-muted">
|
||||
{Math.round(volume * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setVolume(v);
|
||||
setParticipantVolume(userId, v);
|
||||
}}
|
||||
aria-label={'Lautstärke ' + displayName}
|
||||
className="w-full accent-accent"
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { MicIcon, SpinnerIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
/** Called with the recorded audio file when the user confirms. The button
|
||||
* ships as a single-attachment message, so the parent can feed it into
|
||||
* the normal send flow. */
|
||||
onComplete: (file: File) => Promise<void> | void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Single-button voice recorder. Click to start; shows an inline pill with
|
||||
// elapsed time + stop + cancel while recording. On stop, hands a File to the
|
||||
// parent. No in-place preview yet — we lean on the optimistic message bubble
|
||||
// to appear once the parent sends.
|
||||
const MAX_RECORD_SEC = 60;
|
||||
|
||||
export function VoiceRecorder({ onComplete, disabled = false }: Props) {
|
||||
const [state, setState] = useState<'idle' | 'recording' | 'finalizing'>('idle');
|
||||
const [elapsedSec, setElapsedSec] = useState(0);
|
||||
const [level, setLevel] = useState(0);
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const startedAtRef = useRef<number>(0);
|
||||
const cancelledRef = useRef(false);
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
// Tick elapsed + auto-stop at MAX_RECORD_SEC.
|
||||
useEffect(() => {
|
||||
if (state !== 'recording') return;
|
||||
const id = window.setInterval(() => {
|
||||
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000);
|
||||
setElapsedSec(sec);
|
||||
if (sec >= MAX_RECORD_SEC) {
|
||||
const rec = recorderRef.current;
|
||||
if (rec && rec.state !== 'inactive') rec.stop();
|
||||
}
|
||||
}, 200);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
// Live mic level meter via Web Audio AnalyserNode. RMS on 0..1, smoothed.
|
||||
useEffect(() => {
|
||||
if (state !== 'recording') return;
|
||||
const analyser = analyserRef.current;
|
||||
if (!analyser) return;
|
||||
const buf = new Uint8Array(analyser.frequencyBinCount);
|
||||
let cancelled = false;
|
||||
const tick = () => {
|
||||
if (cancelled) return;
|
||||
analyser.getByteFrequencyData(buf as Uint8Array<ArrayBuffer>);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < buf.length; i++) sum += buf[i]!;
|
||||
setLevel(sum / (buf.length * 255));
|
||||
rafRef.current = window.requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = window.requestAnimationFrame(tick);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
const stopStream = () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
if (audioCtxRef.current) {
|
||||
void audioCtxRef.current.close().catch(() => {});
|
||||
audioCtxRef.current = null;
|
||||
}
|
||||
analyserRef.current = null;
|
||||
setLevel(0);
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
if (disabled) return;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
|
||||
try {
|
||||
const ctx = new (window.AudioContext ||
|
||||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
analyser.smoothingTimeConstant = 0.4;
|
||||
source.connect(analyser);
|
||||
audioCtxRef.current = ctx;
|
||||
analyserRef.current = analyser;
|
||||
} catch {
|
||||
/* Analyser is best-effort; recording still works without level meter. */
|
||||
}
|
||||
|
||||
const mime = pickMime();
|
||||
const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
|
||||
recorderRef.current = rec;
|
||||
chunksRef.current = [];
|
||||
cancelledRef.current = false;
|
||||
|
||||
rec.ondataavailable = (ev: BlobEvent) => {
|
||||
if (ev.data && ev.data.size > 0) chunksRef.current.push(ev.data);
|
||||
};
|
||||
rec.onstop = () => {
|
||||
const blob = new Blob(chunksRef.current, { type: rec.mimeType || 'audio/webm' });
|
||||
chunksRef.current = [];
|
||||
stopStream();
|
||||
if (cancelledRef.current || blob.size === 0) {
|
||||
setState('idle');
|
||||
setElapsedSec(0);
|
||||
return;
|
||||
}
|
||||
setState('finalizing');
|
||||
const ext = extFor(blob.type);
|
||||
const filename = 'voice-' + new Date().toISOString().replace(/[:.]/g, '-') + '.' + ext;
|
||||
const file = new File([blob], filename, { type: blob.type });
|
||||
void Promise.resolve(onComplete(file)).finally(() => {
|
||||
setState('idle');
|
||||
setElapsedSec(0);
|
||||
});
|
||||
};
|
||||
|
||||
rec.start();
|
||||
startedAtRef.current = Date.now();
|
||||
setElapsedSec(0);
|
||||
setState('recording');
|
||||
} catch (err: unknown) {
|
||||
stopStream();
|
||||
// Surface device-permission denials or capture failures via console;
|
||||
// the composer doesn't have space for inline errors here and the
|
||||
// browser already shows a system-level permission prompt.
|
||||
console.error('VoiceRecorder.start failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
const rec = recorderRef.current;
|
||||
if (!rec) return;
|
||||
if (rec.state !== 'inactive') rec.stop();
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
cancelledRef.current = true;
|
||||
const rec = recorderRef.current;
|
||||
if (rec && rec.state !== 'inactive') rec.stop();
|
||||
else {
|
||||
stopStream();
|
||||
setState('idle');
|
||||
setElapsedSec(0);
|
||||
}
|
||||
};
|
||||
|
||||
if (state === 'idle') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void start()}
|
||||
disabled={disabled}
|
||||
aria-label="Sprachnachricht aufnehmen"
|
||||
title="Sprachnachricht aufnehmen"
|
||||
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-surface-2 text-fg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<MicIcon className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'finalizing') {
|
||||
return (
|
||||
<div className="inline-flex h-11 items-center gap-2 rounded-lg bg-surface-2 px-3 text-xs text-fg-muted">
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
<span>Wird gesendet…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Visual level: scale from 0..1 → 0..100% width. Floor at 4% so the bar
|
||||
// stays visible when silent.
|
||||
const levelPct = Math.max(4, Math.min(100, Math.round(level * 180)));
|
||||
const remaining = Math.max(0, MAX_RECORD_SEC - elapsedSec);
|
||||
|
||||
return (
|
||||
<div className="inline-flex h-11 items-center gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 text-xs font-semibold text-rose-700 dark:text-rose-200">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-rose-500" />
|
||||
<span className="tabular-nums">{formatTime(elapsedSec)}</span>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="relative h-1.5 w-20 overflow-hidden rounded-full bg-rose-500/20"
|
||||
>
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-rose-500 transition-[width] duration-75"
|
||||
style={{ width: levelPct + '%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="tabular-nums text-[10px] text-rose-700/70 dark:text-rose-200/70">
|
||||
−{remaining}s
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancel}
|
||||
aria-label="Aufnahme abbrechen"
|
||||
title="Abbrechen"
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirm}
|
||||
aria-label="Aufnahme senden"
|
||||
title="Senden"
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md bg-accent text-accent-fg transition hover:brightness-110"
|
||||
>
|
||||
<MicIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pickMime(): string | null {
|
||||
const candidates = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/ogg;codecs=opus',
|
||||
'audio/mp4',
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(c)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extFor(mime: string): string {
|
||||
if (mime.includes('webm')) return 'webm';
|
||||
if (mime.includes('ogg')) return 'ogg';
|
||||
if (mime.includes('mp4')) return 'm4a';
|
||||
return 'bin';
|
||||
}
|
||||
|
||||
function formatTime(sec: number): string {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return m + ':' + s.toString().padStart(2, '0');
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { findExistingDevice } from '../lib/device';
|
||||
import { setSecretStoreUser } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { registerWebPush } from '../lib/webPush';
|
||||
|
||||
interface AuthContextValue {
|
||||
session: Session | null;
|
||||
@@ -135,6 +136,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
}, [session, refreshProfile, refreshDevice]);
|
||||
|
||||
// Best-effort web-push registration once we know the device id. No-op on
|
||||
// Tauri (uses native notifications) or when VITE_VAPID_PUBLIC_KEY is unset.
|
||||
useEffect(() => {
|
||||
if (!device?.id) return;
|
||||
void registerWebPush(device.id);
|
||||
}, [device?.id]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await supabaseSignOut(supabase);
|
||||
}, []);
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
getCallE2EESettings,
|
||||
isE2EESupported,
|
||||
} from '../lib/callE2EE';
|
||||
import { getParticipantVolume } from '../lib/participantVolumes';
|
||||
import {
|
||||
type DisplaySurfaceHint,
|
||||
getPresetParams,
|
||||
@@ -1310,7 +1311,7 @@ async function broadcastPresence(room: Room, deafened: boolean): Promise<void> {
|
||||
function attachTrack(
|
||||
track: RemoteTrack,
|
||||
_publication: RemoteTrackPublication,
|
||||
_participant: RemoteParticipant,
|
||||
participant: RemoteParticipant,
|
||||
): void {
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
const audio = track.attach();
|
||||
@@ -1318,6 +1319,10 @@ function attachTrack(
|
||||
audio.autoplay = true;
|
||||
audio.setAttribute('playsinline', 'true');
|
||||
audio.setAttribute('data-livekit-track', track.sid ?? '');
|
||||
if (participant.identity) {
|
||||
audio.setAttribute('data-participant', participant.identity);
|
||||
audio.volume = getParticipantVolume(participant.identity);
|
||||
}
|
||||
if (deafenedActive) audio.muted = true;
|
||||
document.body.appendChild(audio);
|
||||
// Apply persisted sinkId so the element routes to the user's chosen
|
||||
|
||||
@@ -99,7 +99,9 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
// Only flag loading on initial fetch so background re-syncs (visibility
|
||||
// change, online event) don't blank the list each time.
|
||||
setLoading((prev) => (conversationsRef.current.length === 0 ? true : prev));
|
||||
const convs = await listConversations(supabase);
|
||||
setConversations(convs);
|
||||
const entries = await Promise.all(
|
||||
@@ -218,8 +220,13 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
// refresh + realtime reconnect on visibility/focus regain so we never
|
||||
// leave stale conversation lists on a Windows client after the user
|
||||
// returns to the app.
|
||||
let lastAwakeRefresh = 0;
|
||||
const AWAKE_THROTTLE_MS = 30_000;
|
||||
const onAwake = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
const now = Date.now();
|
||||
if (now - lastAwakeRefresh < AWAKE_THROTTLE_MS) return;
|
||||
lastAwakeRefresh = now;
|
||||
void refresh();
|
||||
try {
|
||||
// If the socket got wedged during background throttle, a no-op
|
||||
@@ -231,12 +238,10 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onAwake);
|
||||
window.addEventListener('focus', onAwake);
|
||||
window.addEventListener('online', onAwake);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onAwake);
|
||||
window.removeEventListener('focus', onAwake);
|
||||
window.removeEventListener('online', onAwake);
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
|
||||
@@ -140,3 +140,59 @@ export async function importDeviceBackup(
|
||||
export function decodePrivateKeyFromBackup(payload: DeviceBackupPayload): Uint8Array {
|
||||
return unb64url(payload.privateKeyB64);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recovery code
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Generates a high-entropy code shown to the user once at backup time. The
|
||||
// same payload is encrypted twice — once with the user's passphrase, once
|
||||
// with the recovery code — so either string can decrypt the device key.
|
||||
//
|
||||
// The recovery code is 24 chars from a 32-symbol alphabet (no ambiguous
|
||||
// characters), grouped as 4×6. ~120 bits of entropy.
|
||||
|
||||
const RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
|
||||
export interface BackupBundle {
|
||||
passphraseBackup: string;
|
||||
recoveryBackup: string;
|
||||
recoveryCode: string;
|
||||
}
|
||||
|
||||
export async function exportDeviceBackupWithRecovery(params: {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
privateKey: Uint8Array;
|
||||
passphrase: string;
|
||||
}): Promise<BackupBundle> {
|
||||
const recoveryCode = await generateRecoveryCode();
|
||||
const [passphraseBackup, recoveryBackup] = await Promise.all([
|
||||
exportDeviceBackup(params),
|
||||
exportDeviceBackup({ ...params, passphrase: recoveryCode }),
|
||||
]);
|
||||
return { passphraseBackup, recoveryBackup, recoveryCode };
|
||||
}
|
||||
|
||||
async function generateRecoveryCode(): Promise<string> {
|
||||
const s = await ensureSodium();
|
||||
const raw = s.randombytes_buf(24);
|
||||
let out = '';
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
out += RECOVERY_ALPHABET[raw[i]! % RECOVERY_ALPHABET.length];
|
||||
if ((i + 1) % 6 === 0 && i !== raw.length - 1) out += '-';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Normalizes a user-typed recovery code: strips dashes/spaces, uppercases,
|
||||
// maps look-alike characters. Lets users enter the code with imperfect
|
||||
// spacing without rejecting valid input.
|
||||
export function normalizeRecoveryCode(input: string): string {
|
||||
return input
|
||||
.toUpperCase()
|
||||
.replace(/[\s-]/g, '')
|
||||
.split('')
|
||||
.filter((c) => RECOVERY_ALPHABET.includes(c))
|
||||
.join('');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// Offline text-message outbox.
|
||||
//
|
||||
// When `sendEncryptedMessage` fails (network loss, server 5xx, transient
|
||||
// realtime hiccups) we stash the plaintext in localStorage keyed by
|
||||
// conversation. The UI shows it inline with a "sending…" indicator; the
|
||||
// drain loop retries with exponential backoff until it lands.
|
||||
//
|
||||
// Attachments are intentionally NOT queued — they're too large to persist
|
||||
// and require server-side upload that can't be deferred reliably. The
|
||||
// composer surfaces an immediate error for those.
|
||||
|
||||
const STORAGE_KEY = 'chat.outbox.v1';
|
||||
const MAX_ATTEMPTS = 8;
|
||||
|
||||
export interface OutboxItem {
|
||||
/** Local-only id (never collides with server UUIDs). */
|
||||
id: string;
|
||||
conversationId: string;
|
||||
text: string;
|
||||
replyToId: string | null;
|
||||
createdAt: string;
|
||||
attempts: number;
|
||||
/** Timestamp of the next permitted send attempt. */
|
||||
nextAttemptAt: string;
|
||||
/** Last error message for surface in the UI. */
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
type Store = Record<string, OutboxItem[]>; // keyed by conversationId
|
||||
type Listener = (byConv: Store) => void;
|
||||
|
||||
function load(): Store {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return {};
|
||||
const out: Store = {};
|
||||
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (Array.isArray(v)) {
|
||||
out[k] = v.filter((x): x is OutboxItem => isItem(x));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function isItem(x: unknown): x is OutboxItem {
|
||||
if (!x || typeof x !== 'object') return false;
|
||||
const o = x as Record<string, unknown>;
|
||||
return (
|
||||
typeof o.id === 'string' &&
|
||||
typeof o.conversationId === 'string' &&
|
||||
typeof o.text === 'string' &&
|
||||
typeof o.createdAt === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
let store: Store = load();
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function persist(): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||
} catch {
|
||||
/* quota exceeded — ignore; in-memory queue still retries */
|
||||
}
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
for (const fn of listeners) fn(store);
|
||||
}
|
||||
|
||||
export function subscribeOutbox(fn: Listener): () => void {
|
||||
listeners.add(fn);
|
||||
fn(store);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
export function getOutbox(conversationId: string): OutboxItem[] {
|
||||
return store[conversationId] ?? [];
|
||||
}
|
||||
|
||||
export function enqueueOutbox(args: {
|
||||
conversationId: string;
|
||||
text: string;
|
||||
replyToId: string | null;
|
||||
error: string | null;
|
||||
}): OutboxItem {
|
||||
const item: OutboxItem = {
|
||||
id: 'local-' + crypto.randomUUID(),
|
||||
conversationId: args.conversationId,
|
||||
text: args.text,
|
||||
replyToId: args.replyToId,
|
||||
createdAt: new Date().toISOString(),
|
||||
attempts: 0,
|
||||
nextAttemptAt: new Date().toISOString(),
|
||||
lastError: args.error,
|
||||
};
|
||||
const bucket = store[args.conversationId] ?? [];
|
||||
store = { ...store, [args.conversationId]: [...bucket, item] };
|
||||
persist();
|
||||
notify();
|
||||
return item;
|
||||
}
|
||||
|
||||
export function removeOutbox(conversationId: string, id: string): void {
|
||||
const bucket = store[conversationId];
|
||||
if (!bucket) return;
|
||||
const next = bucket.filter((x) => x.id !== id);
|
||||
if (next.length === bucket.length) return;
|
||||
if (next.length === 0) {
|
||||
const copy = { ...store };
|
||||
delete copy[conversationId];
|
||||
store = copy;
|
||||
} else {
|
||||
store = { ...store, [conversationId]: next };
|
||||
}
|
||||
persist();
|
||||
notify();
|
||||
}
|
||||
|
||||
export function markAttempt(
|
||||
conversationId: string,
|
||||
id: string,
|
||||
error: string | null,
|
||||
): OutboxItem | null {
|
||||
const bucket = store[conversationId];
|
||||
if (!bucket) return null;
|
||||
const idx = bucket.findIndex((x) => x.id === id);
|
||||
if (idx < 0) return null;
|
||||
const prev = bucket[idx]!;
|
||||
const attempts = prev.attempts + 1;
|
||||
// Exponential backoff: 2^n seconds, capped at 60s.
|
||||
const delaySec = Math.min(60, Math.pow(2, attempts));
|
||||
const next: OutboxItem = {
|
||||
...prev,
|
||||
attempts,
|
||||
lastError: error,
|
||||
nextAttemptAt: new Date(Date.now() + delaySec * 1000).toISOString(),
|
||||
};
|
||||
const nextBucket = [...bucket];
|
||||
nextBucket[idx] = next;
|
||||
store = { ...store, [conversationId]: nextBucket };
|
||||
persist();
|
||||
notify();
|
||||
return next;
|
||||
}
|
||||
|
||||
export function shouldGiveUp(item: OutboxItem): boolean {
|
||||
return item.attempts >= MAX_ATTEMPTS;
|
||||
}
|
||||
|
||||
export function isDue(item: OutboxItem): boolean {
|
||||
return new Date(item.nextAttemptAt).getTime() <= Date.now();
|
||||
}
|
||||
|
||||
export function allConversationsWithItems(): string[] {
|
||||
return Object.keys(store);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Per-participant output volume overrides. Values are 0..1 (HTMLMediaElement
|
||||
// scale). Persisted so the user's mixing choices survive reconnects.
|
||||
//
|
||||
// The store is intentionally tiny — a flat Record keyed by LiveKit identity
|
||||
// (our `userId`). `attachTrack` reads from here when a remote audio track
|
||||
// first lands; live changes are pushed to any already-attached audio
|
||||
// elements via the `data-participant` attribute selector.
|
||||
|
||||
const STORAGE_KEY = 'call.participantVolumes.v1';
|
||||
const DEFAULT_VOLUME = 1;
|
||||
|
||||
type VolumeMap = Record<string, number>;
|
||||
type Listener = (map: VolumeMap) => void;
|
||||
|
||||
function load(): VolumeMap {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return {};
|
||||
const out: VolumeMap = {};
|
||||
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
out[k] = clamp(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(v: number): number {
|
||||
if (v < 0) return 0;
|
||||
if (v > 1) return 1;
|
||||
return v;
|
||||
}
|
||||
|
||||
let current: VolumeMap = load();
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function persist(): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(current));
|
||||
} catch {
|
||||
/* ignore quota */
|
||||
}
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
for (const fn of listeners) fn(current);
|
||||
}
|
||||
|
||||
export function getParticipantVolume(userId: string): number {
|
||||
return current[userId] ?? DEFAULT_VOLUME;
|
||||
}
|
||||
|
||||
export function setParticipantVolume(userId: string, volume: number): void {
|
||||
const next = clamp(volume);
|
||||
if (next === (current[userId] ?? DEFAULT_VOLUME)) return;
|
||||
current = { ...current, [userId]: next };
|
||||
persist();
|
||||
applyToAttachedElements(userId, next);
|
||||
notify();
|
||||
}
|
||||
|
||||
export function subscribeParticipantVolumes(fn: Listener): () => void {
|
||||
listeners.add(fn);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
// Apply a volume to any audio elements already attached for this user.
|
||||
// Attached elements are tagged with `data-participant` in attachTrack.
|
||||
function applyToAttachedElements(userId: string, volume: number): void {
|
||||
const nodes = document.querySelectorAll<HTMLAudioElement>(
|
||||
'audio[data-participant="' + cssEscape(userId) + '"]',
|
||||
);
|
||||
nodes.forEach((el) => {
|
||||
el.volume = volume;
|
||||
});
|
||||
}
|
||||
|
||||
function cssEscape(v: string): string {
|
||||
if (typeof (globalThis as { CSS?: { escape?: (s: string) => string } }).CSS
|
||||
?.escape === 'function') {
|
||||
return (globalThis as { CSS: { escape: (s: string) => string } }).CSS.escape(v);
|
||||
}
|
||||
return v.replace(/"/g, '\\"');
|
||||
}
|
||||
@@ -13,6 +13,16 @@ import {
|
||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
enqueueOutbox,
|
||||
getOutbox,
|
||||
isDue,
|
||||
markAttempt,
|
||||
type OutboxItem,
|
||||
removeOutbox,
|
||||
shouldGiveUp,
|
||||
subscribeOutbox,
|
||||
} from './messageOutbox';
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
@@ -53,10 +63,26 @@ function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
|
||||
export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & {
|
||||
send: (text: string, images?: File[], replyToId?: string | null) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
pending: OutboxItem[];
|
||||
retryPending: (id: string) => void;
|
||||
cancelPending: (id: string) => void;
|
||||
} {
|
||||
const [state, setState] = useState<State>({ messages: [], loading: true, error: null });
|
||||
const [pending, setPending] = useState<OutboxItem[]>(() =>
|
||||
conversationId ? getOutbox(conversationId) : [],
|
||||
);
|
||||
const privateKeyRef = useRef<Uint8Array | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId) {
|
||||
setPending([]);
|
||||
return;
|
||||
}
|
||||
return subscribeOutbox((byConv) => {
|
||||
setPending(byConv[conversationId] ?? []);
|
||||
});
|
||||
}, [conversationId]);
|
||||
|
||||
// Load own private key once per (user, device).
|
||||
useEffect(() => {
|
||||
privateKeyRef.current = null;
|
||||
@@ -85,7 +111,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
const refresh = useCallback(async () => {
|
||||
if (!conversationId) return;
|
||||
try {
|
||||
setState((prev) => ({ ...prev, loading: true }));
|
||||
// Only show the loading spinner on the *initial* fetch — subsequent
|
||||
// refreshes (focus/visibility) replace messages in-place to avoid
|
||||
// flickering an empty state on every wake.
|
||||
setState((prev) =>
|
||||
prev.messages.length === 0 ? { ...prev, loading: true } : prev,
|
||||
);
|
||||
const rows = await fetchConversationMessages(supabase, conversationId);
|
||||
const decrypted = await decryptBatch(rows);
|
||||
setState({ messages: decrypted, loading: false, error: null });
|
||||
@@ -311,9 +342,16 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
|
||||
// Refresh + reconnect on wake from background throttle (mostly Windows
|
||||
// WebView2). Without this, messages inserted while the window is
|
||||
// minimised never arrive until the user explicitly reloads.
|
||||
// minimised never arrive until the user explicitly reloads. Throttled to
|
||||
// at most once per AWAKE_THROTTLE_MS so the inevitable cluster of
|
||||
// visibility/online events on focus does not cause a flicker storm.
|
||||
let lastAwakeRefresh = 0;
|
||||
const AWAKE_THROTTLE_MS = 30_000;
|
||||
const onAwake = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
const now = Date.now();
|
||||
if (now - lastAwakeRefresh < AWAKE_THROTTLE_MS) return;
|
||||
lastAwakeRefresh = now;
|
||||
void refresh();
|
||||
try {
|
||||
channel.subscribe();
|
||||
@@ -322,17 +360,40 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onAwake);
|
||||
window.addEventListener('focus', onAwake);
|
||||
window.addEventListener('online', onAwake);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onAwake);
|
||||
window.removeEventListener('focus', onAwake);
|
||||
window.removeEventListener('online', onAwake);
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [conversationId, userId, deviceId, refresh, handleInsert, handleUpdate, handleDelete]);
|
||||
|
||||
const sendText = useCallback(
|
||||
async (convId: string, uid: string, did: string, priv: Uint8Array, text: string, replyToId: string | null): Promise<void> => {
|
||||
const msg = await sendEncryptedMessage({
|
||||
client: supabase,
|
||||
conversationId: convId,
|
||||
plaintext: text,
|
||||
senderUserId: uid,
|
||||
senderDeviceId: did,
|
||||
senderPrivateKey: priv,
|
||||
...(replyToId ? { replyToId } : {}),
|
||||
});
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
||||
return {
|
||||
...prev,
|
||||
messages: [
|
||||
...prev.messages,
|
||||
{ ...msg, plaintext: text } as DecryptedMessage,
|
||||
],
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string, images: File[] = [], replyToId: string | null = null) => {
|
||||
const trimmed = text.trim();
|
||||
@@ -340,6 +401,25 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
const priv = privateKeyRef.current;
|
||||
if (!priv) throw new Error('private key not loaded');
|
||||
|
||||
// Text-only path is retryable — if the network is down or the server
|
||||
// rejects transiently, stash in the outbox and keep the UI optimistic.
|
||||
// Attachments can't be deferred (large payloads, uploaded separately),
|
||||
// so those still surface the error immediately.
|
||||
if (images.length === 0) {
|
||||
try {
|
||||
await sendText(conversationId, userId, deviceId, priv, trimmed, replyToId);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'send failed';
|
||||
enqueueOutbox({
|
||||
conversationId,
|
||||
text: trimmed,
|
||||
replyToId,
|
||||
error: msg,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Upload + encrypt each image. Collect handles + raw blob nonces
|
||||
// (so the public attachment row can reference the blob-level nonce).
|
||||
const handles: AttachmentHandle[] = [];
|
||||
@@ -401,10 +481,104 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
await insertAttachmentRow(supabase, msg.id, h, blobNonce);
|
||||
}
|
||||
},
|
||||
[conversationId, userId, deviceId],
|
||||
[conversationId, userId, deviceId, sendText],
|
||||
);
|
||||
|
||||
return useMemo(() => ({ ...state, send, refresh }), [state, send, refresh]);
|
||||
// Drain outbox: retry due items, remove on success, record attempt on fail.
|
||||
// Runs on online event, window focus, and a 10s interval.
|
||||
useEffect(() => {
|
||||
if (!conversationId || !userId || !deviceId) return;
|
||||
|
||||
let draining = false;
|
||||
const drain = async (): Promise<void> => {
|
||||
if (draining) return;
|
||||
const priv = privateKeyRef.current;
|
||||
if (!priv) return;
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) return;
|
||||
draining = true;
|
||||
try {
|
||||
const items = getOutbox(conversationId).filter(isDue);
|
||||
for (const item of items) {
|
||||
if (shouldGiveUp(item)) continue;
|
||||
try {
|
||||
await sendText(
|
||||
conversationId,
|
||||
userId,
|
||||
deviceId,
|
||||
priv,
|
||||
item.text,
|
||||
item.replyToId,
|
||||
);
|
||||
removeOutbox(conversationId, item.id);
|
||||
} catch (err: unknown) {
|
||||
markAttempt(
|
||||
conversationId,
|
||||
item.id,
|
||||
err instanceof Error ? err.message : 'send failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
draining = false;
|
||||
}
|
||||
};
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
void drain();
|
||||
}, 10_000);
|
||||
const onOnline = () => void drain();
|
||||
window.addEventListener('online', onOnline);
|
||||
window.addEventListener('focus', onOnline);
|
||||
// Kick once immediately on mount for stale queued items.
|
||||
void drain();
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener('online', onOnline);
|
||||
window.removeEventListener('focus', onOnline);
|
||||
};
|
||||
}, [conversationId, userId, deviceId, sendText]);
|
||||
|
||||
const retryPending = useCallback(
|
||||
(id: string) => {
|
||||
if (!conversationId) return;
|
||||
// Force due now; the drain loop will pick it up on next tick.
|
||||
markAttempt(conversationId, id, null);
|
||||
// Manual kick: mutate nextAttemptAt by re-enqueuing? Simpler — just
|
||||
// trigger a drain-ish by queueing a microtask. The interval picks up
|
||||
// due items within 10s, but for UX we also eagerly try here.
|
||||
const priv = privateKeyRef.current;
|
||||
if (!priv || !userId || !deviceId) return;
|
||||
const item = getOutbox(conversationId).find((x) => x.id === id);
|
||||
if (!item) return;
|
||||
void (async () => {
|
||||
try {
|
||||
await sendText(conversationId, userId, deviceId, priv, item.text, item.replyToId);
|
||||
removeOutbox(conversationId, id);
|
||||
} catch (err: unknown) {
|
||||
markAttempt(
|
||||
conversationId,
|
||||
id,
|
||||
err instanceof Error ? err.message : 'send failed',
|
||||
);
|
||||
}
|
||||
})();
|
||||
},
|
||||
[conversationId, userId, deviceId, sendText],
|
||||
);
|
||||
|
||||
const cancelPending = useCallback(
|
||||
(id: string) => {
|
||||
if (!conversationId) return;
|
||||
removeOutbox(conversationId, id);
|
||||
},
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({ ...state, send, refresh, pending, retryPending, cancelPending }),
|
||||
[state, send, refresh, pending, retryPending, cancelPending],
|
||||
);
|
||||
}
|
||||
|
||||
// Best-effort image dimension probe. Falls back silently on non-images.
|
||||
|
||||
@@ -45,8 +45,15 @@ export function useFriendships(userId: string | undefined): FriendshipsState & {
|
||||
.subscribe();
|
||||
|
||||
// Windows WebView2 throttles background sockets — refresh on wake.
|
||||
// Throttled + visibility-only so a normal click into the window does not
|
||||
// re-fetch on every focus.
|
||||
let lastAwakeRefresh = 0;
|
||||
const AWAKE_THROTTLE_MS = 30_000;
|
||||
const onAwake = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
const now = Date.now();
|
||||
if (now - lastAwakeRefresh < AWAKE_THROTTLE_MS) return;
|
||||
lastAwakeRefresh = now;
|
||||
void refresh();
|
||||
try {
|
||||
channel.subscribe();
|
||||
@@ -55,12 +62,10 @@ export function useFriendships(userId: string | undefined): FriendshipsState & {
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onAwake);
|
||||
window.addEventListener('focus', onAwake);
|
||||
window.addEventListener('online', onAwake);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onAwake);
|
||||
window.removeEventListener('focus', onAwake);
|
||||
window.removeEventListener('online', onAwake);
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
listGroupDeliveriesForMessages,
|
||||
listGroupReadsForMessages,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { supabase } from './supabase';
|
||||
|
||||
export interface GroupReceiptState {
|
||||
// message_id → set of user ids who have delivered/read.
|
||||
deliveredByMessage: Map<string, Set<string>>;
|
||||
readByMessage: Map<string, Set<string>>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Aggregated delivery + read receipts for group conversations. Returns the
|
||||
// set of user ids per message; consumers join with conversation members to
|
||||
// figure out who has not yet acknowledged.
|
||||
export function useGroupReceipts(
|
||||
messageIds: string[],
|
||||
selfUserId: string | undefined,
|
||||
active: boolean,
|
||||
): GroupReceiptState {
|
||||
const idsKey = useMemo(() => messageIds.join(','), [messageIds]);
|
||||
const [deliveredByMessage, setDelivered] = useState<Map<string, Set<string>>>(new Map());
|
||||
const [readByMessage, setRead] = useState<Map<string, Set<string>>>(new Map());
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!active || !selfUserId || messageIds.length === 0) {
|
||||
setDelivered(new Map());
|
||||
setRead(new Map());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [delivered, read] = await Promise.all([
|
||||
listGroupDeliveriesForMessages(supabase, messageIds, selfUserId),
|
||||
listGroupReadsForMessages(supabase, messageIds, selfUserId),
|
||||
]);
|
||||
setDelivered(toIdSets(delivered));
|
||||
setRead(toIdSets(read));
|
||||
} catch (err: unknown) {
|
||||
console.warn('useGroupReceipts refresh failed', err);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active, selfUserId, idsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
if (!active || !selfUserId) return;
|
||||
|
||||
// Listen on both tables. RLS already enforces visibility.
|
||||
const reads = supabase
|
||||
.channel('group-reads:' + selfUserId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'INSERT', schema: 'public', table: 'message_reads' },
|
||||
() => {
|
||||
void refresh();
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
const deliveries = supabase
|
||||
.channel('group-deliv:' + selfUserId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'INSERT', schema: 'public', table: 'message_deliveries' },
|
||||
() => {
|
||||
void refresh();
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
void supabase.removeChannel(reads);
|
||||
void supabase.removeChannel(deliveries);
|
||||
};
|
||||
}, [active, selfUserId, refresh]);
|
||||
|
||||
return { deliveredByMessage, readByMessage, refresh };
|
||||
}
|
||||
|
||||
function toIdSets(input: Map<string, Map<string, string>>): Map<string, Set<string>> {
|
||||
const out = new Map<string, Set<string>>();
|
||||
for (const [mid, inner] of input) {
|
||||
out.set(mid, new Set(inner.keys()));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
listPeerDeliveriesForMessages,
|
||||
markMessagesDelivered,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { supabase } from './supabase';
|
||||
|
||||
// Tracks peer delivery acknowledgements for our own messages. Single peer
|
||||
// (DM). For groups this would need a per-user map; keep parity with
|
||||
// `useMessageReads` for now.
|
||||
export function useMessageDeliveries(
|
||||
messageIds: string[],
|
||||
peerUserId: string | undefined,
|
||||
): { peerDeliveredSet: Set<string>; refresh: () => Promise<void> } {
|
||||
const idsKey = useMemo(() => messageIds.join(','), [messageIds]);
|
||||
const [peerDeliveredSet, setPeerDeliveredSet] = useState<Set<string>>(new Set());
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!peerUserId || messageIds.length === 0) {
|
||||
setPeerDeliveredSet(new Set());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const s = await listPeerDeliveriesForMessages(supabase, messageIds, peerUserId);
|
||||
setPeerDeliveredSet(s);
|
||||
} catch (err: unknown) {
|
||||
console.error('listPeerDeliveriesForMessages failed', err);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [peerUserId, idsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
if (!peerUserId) return;
|
||||
const channel = supabase
|
||||
.channel('deliveries:' + peerUserId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'message_deliveries',
|
||||
filter: 'user_id=eq.' + peerUserId,
|
||||
},
|
||||
() => {
|
||||
void refresh();
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [peerUserId, refresh]);
|
||||
|
||||
return { peerDeliveredSet, refresh };
|
||||
}
|
||||
|
||||
export async function markDelivered(messageIds: string[]): Promise<void> {
|
||||
await markMessagesDelivered(supabase, messageIds);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Web Push subscription registration. Browser-only; Tauri WebView2 / WebKit
|
||||
// do not install service workers. The Tauri build relies on native OS
|
||||
// notifications via osNotify.ts instead.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Register /sw.js if not already.
|
||||
// 2. Subscribe with the VAPID public key (Vite injects it via env).
|
||||
// 3. Persist {endpoint, keys} JSON-encoded into push_tokens.token for the
|
||||
// current device. Server-side fan-out reads this row to send pushes.
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
const VAPID_PUBLIC_KEY: string | undefined =
|
||||
(import.meta as unknown as { env?: { VITE_VAPID_PUBLIC_KEY?: string } }).env
|
||||
?.VITE_VAPID_PUBLIC_KEY;
|
||||
|
||||
export async function registerWebPush(deviceId: string): Promise<void> {
|
||||
// Tauri uses native notifications — no service worker.
|
||||
if (isTauriRuntime()) return;
|
||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
||||
if (!('PushManager' in window)) return;
|
||||
if (!VAPID_PUBLIC_KEY) {
|
||||
console.warn('VITE_VAPID_PUBLIC_KEY not set — push disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register('/sw.js');
|
||||
await navigator.serviceWorker.ready;
|
||||
let sub = await reg.pushManager.getSubscription();
|
||||
if (!sub) {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') return;
|
||||
sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY) as BufferSource,
|
||||
});
|
||||
}
|
||||
|
||||
const token = JSON.stringify({
|
||||
endpoint: sub.endpoint,
|
||||
keys: sub.toJSON().keys ?? {},
|
||||
});
|
||||
|
||||
const { error } = await supabase
|
||||
.from('push_tokens')
|
||||
.upsert(
|
||||
{ device_id: deviceId, platform: 'web' as never, token },
|
||||
{ onConflict: 'device_id' },
|
||||
);
|
||||
if (error) {
|
||||
console.warn('push_tokens upsert failed', error);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('registerWebPush failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterWebPush(deviceId: string): Promise<void> {
|
||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
if (reg) {
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
if (sub) await sub.unsubscribe();
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
await supabase.from('push_tokens').delete().eq('device_id', deviceId);
|
||||
} catch (err: unknown) {
|
||||
console.warn('push_tokens delete failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
// VAPID public key arrives as URL-safe base64; PushManager expects a raw byte
|
||||
// array. Standard conversion.
|
||||
function urlBase64ToUint8Array(base64: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
|
||||
const padded = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(padded);
|
||||
const out = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
type AdminAuditEntry,
|
||||
type AdminConversationRow,
|
||||
type AdminProfileFlag,
|
||||
type AdminProfileRow,
|
||||
type AdminSetting,
|
||||
@@ -6,8 +8,11 @@ import {
|
||||
deleteInvite,
|
||||
type InviteRecord,
|
||||
listAdminSettings,
|
||||
listAllConversations,
|
||||
listAllProfiles,
|
||||
listAuditLog,
|
||||
listInvites,
|
||||
logAdminAction,
|
||||
setInviteDisabled,
|
||||
setUserFlag,
|
||||
updateAdminSetting,
|
||||
@@ -31,20 +36,26 @@ export function AdminPage() {
|
||||
const [settings, setSettings] = useState<AdminSetting[]>([]);
|
||||
const [invites, setInvites] = useState<InviteRecord[]>([]);
|
||||
const [users, setUsers] = useState<AdminProfileRow[]>([]);
|
||||
const [conversations, setConversations] = useState<AdminConversationRow[]>([]);
|
||||
const [audit, setAudit] = useState<AdminAuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [s, i, u] = await Promise.all([
|
||||
const [s, i, u, c, a] = await Promise.all([
|
||||
listAdminSettings(supabase),
|
||||
listInvites(supabase),
|
||||
listAllProfiles(supabase),
|
||||
listAllConversations(supabase),
|
||||
listAuditLog(supabase, 50),
|
||||
]);
|
||||
setSettings(s);
|
||||
setInvites(i);
|
||||
setUsers(u);
|
||||
setConversations(c);
|
||||
setAudit(a);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
const code = extractErrorCode(err);
|
||||
@@ -92,6 +103,8 @@ export function AdminPage() {
|
||||
<SettingsSection settings={settings} onRefresh={refresh} />
|
||||
<InvitesSection invites={invites} onRefresh={refresh} />
|
||||
<UsersSection users={users} onRefresh={refresh} />
|
||||
<ConversationsSection conversations={conversations} />
|
||||
<AuditSection entries={audit} users={users} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -295,6 +308,13 @@ function UsersSection({ users, onRefresh }: { users: AdminProfileRow[]; onRefres
|
||||
async function toggle(userId: string, flag: AdminProfileFlag, value: boolean) {
|
||||
try {
|
||||
await setUserFlag(supabase, userId, flag, value);
|
||||
// Best-effort audit; don't fail the UI if the log write fails.
|
||||
void logAdminAction(
|
||||
supabase,
|
||||
'user.set_flag',
|
||||
{ type: 'user', id: userId },
|
||||
{ flag, value },
|
||||
).catch(() => undefined);
|
||||
await onRefresh();
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
@@ -345,6 +365,106 @@ function UsersSection({ users, onRefresh }: { users: AdminProfileRow[]; onRefres
|
||||
);
|
||||
}
|
||||
|
||||
// --- Conversations ---------------------------------------------------------
|
||||
|
||||
function ConversationsSection({ conversations }: { conversations: AdminConversationRow[] }) {
|
||||
if (conversations.length === 0) {
|
||||
return (
|
||||
<Section title="Unterhaltungen">
|
||||
<p className="text-sm text-fg-muted">Keine Unterhaltungen.</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Section title={'Unterhaltungen (' + conversations.length + ')'}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-[10px] uppercase tracking-[0.1em] text-fg-muted">
|
||||
<tr>
|
||||
<th className="py-2 pr-3">Name/ID</th>
|
||||
<th className="py-2 pr-3">Typ</th>
|
||||
<th className="py-2 pr-3">Members</th>
|
||||
<th className="py-2 pr-3">Letzte Nachricht</th>
|
||||
<th className="py-2 pr-3">Erstellt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{conversations.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td className="py-2 pr-3 font-mono text-[11px] text-fg">
|
||||
{c.name ?? c.id.slice(0, 12)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-xs text-fg-muted">{c.type}</td>
|
||||
<td className="py-2 pr-3 text-xs text-fg">{c.memberCount}</td>
|
||||
<td className="py-2 pr-3 text-xs text-fg-muted">
|
||||
{c.lastMessageAt
|
||||
? new Date(c.lastMessageAt).toLocaleString()
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-xs text-fg-muted">
|
||||
{new Date(c.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Audit log -------------------------------------------------------------
|
||||
|
||||
function AuditSection({
|
||||
entries,
|
||||
users,
|
||||
}: {
|
||||
entries: AdminAuditEntry[];
|
||||
users: AdminProfileRow[];
|
||||
}) {
|
||||
const nameByUserId = new Map(users.map((u) => [u.userId, u.displayName]));
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<Section title="Audit-Log">
|
||||
<p className="text-sm text-fg-muted">Keine Einträge.</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Section title={'Audit-Log (letzte ' + entries.length + ')'}>
|
||||
<ul className="divide-y divide-line text-sm">
|
||||
{entries.map((e) => (
|
||||
<li key={e.id} className="flex items-start gap-3 py-2">
|
||||
<span className="w-32 shrink-0 font-mono text-[10px] text-fg-muted">
|
||||
{new Date(e.createdAt).toLocaleString()}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs">
|
||||
<span className="font-semibold text-fg">
|
||||
{e.actorId ? nameByUserId.get(e.actorId) ?? e.actorId.slice(0, 8) : '—'}
|
||||
</span>{' '}
|
||||
<span className="text-accent">{e.action}</span>
|
||||
{e.targetId && (
|
||||
<span className="text-fg-muted">
|
||||
{' '}
|
||||
on {e.targetType}:{e.targetId.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{Object.keys(e.metadata).length > 0 && (
|
||||
<p className="mt-0.5 font-mono text-[10px] text-fg-muted">
|
||||
{JSON.stringify(e.metadata)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Bits ------------------------------------------------------------------
|
||||
|
||||
function Toggle({
|
||||
|
||||
@@ -22,12 +22,16 @@ import { InCallPanel } from '../components/InCallPanel';
|
||||
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
||||
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
|
||||
import { TypingIndicator } from '../components/TypingIndicator';
|
||||
import { VoiceRecorder } from '../components/VoiceRecorder';
|
||||
import type { DecryptedMessage } from '@chat-app/shared/chat';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import type { OutboxItem } from '../lib/messageOutbox';
|
||||
import { useConversationMessages } from '../lib/useConversationMessages';
|
||||
import { useMessageReactions } from '../lib/useMessageReactions';
|
||||
import { useGroupReceipts } from '../lib/useGroupReceipts';
|
||||
import { markDelivered, useMessageDeliveries } from '../lib/useMessageDeliveries';
|
||||
import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useMessageReads';
|
||||
import { usePeerPresence } from '../lib/usePeerPresence';
|
||||
import { useTypingChannel } from '../lib/useTypingChannel';
|
||||
@@ -47,11 +51,12 @@ export function ConversationPage() {
|
||||
const peerId = conversation?.peer?.userId;
|
||||
const peerPresence = usePeerPresence(peerId);
|
||||
|
||||
const { messages, loading, error, send } = useConversationMessages({
|
||||
conversationId: id,
|
||||
userId: session?.user.id,
|
||||
deviceId: device?.id,
|
||||
});
|
||||
const { messages, loading, error, send, pending, retryPending, cancelPending } =
|
||||
useConversationMessages({
|
||||
conversationId: id,
|
||||
userId: session?.user.id,
|
||||
deviceId: device?.id,
|
||||
});
|
||||
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
|
||||
const { byMessage: reactionsByMessage, toggle: toggleReaction } = useMessageReactions(
|
||||
messageIds,
|
||||
@@ -66,6 +71,36 @@ export function ConversationPage() {
|
||||
[messages, myId],
|
||||
);
|
||||
const { peerReadSet } = useMessageReads(ownMessageIds, peerId);
|
||||
const { peerDeliveredSet } = useMessageDeliveries(ownMessageIds, peerId);
|
||||
|
||||
// Group receipts: only meaningful when conversation is a group. We feed it
|
||||
// ownMessageIds since we only render delivery state on the sender side.
|
||||
const isGroup = conversation?.type === 'group';
|
||||
const { deliveredByMessage: groupDelivered, readByMessage: groupRead } =
|
||||
useGroupReceipts(ownMessageIds, myId, !!isGroup);
|
||||
const groupRecipientCount = useMemo(() => {
|
||||
if (!isGroup || !conversation) return 0;
|
||||
return conversation.members.filter((m) => m.userId !== myId).length;
|
||||
}, [isGroup, conversation, myId]);
|
||||
|
||||
// Mark every peer-authored message as delivered on our side. Idempotent,
|
||||
// so rerunning for already-acknowledged ids is a no-op server-side.
|
||||
const deliveredTrackedRef = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
if (!myId || messages.length === 0) return;
|
||||
const toMark: string[] = [];
|
||||
for (const m of messages) {
|
||||
if (m.senderId === myId) continue;
|
||||
if (deliveredTrackedRef.current.has(m.id)) continue;
|
||||
deliveredTrackedRef.current.add(m.id);
|
||||
toMark.push(m.id);
|
||||
}
|
||||
if (toMark.length > 0) {
|
||||
void markDelivered(toMark).catch((err: unknown) => {
|
||||
console.warn('markDelivered failed', err);
|
||||
});
|
||||
}
|
||||
}, [messages, myId]);
|
||||
|
||||
const lastSeenMessageId = useMemo(() => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
@@ -100,8 +135,14 @@ export function ConversationPage() {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchIdx, setSearchIdx] = useState(0);
|
||||
const [searchSenderId, setSearchSenderId] = useState<string>('');
|
||||
const [searchAttachmentsOnly, setSearchAttachmentsOnly] = useState(false);
|
||||
const [searchDateFrom, setSearchDateFrom] = useState<string>('');
|
||||
const [searchDateTo, setSearchDateTo] = useState<string>('');
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const [displayCount, setDisplayCount] = useState<number>(150);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -111,8 +152,28 @@ export function ConversationPage() {
|
||||
setForwardTarget(null);
|
||||
setSearchOpen(false);
|
||||
setSearchQuery('');
|
||||
setDisplayCount(150);
|
||||
}, [id]);
|
||||
|
||||
// Expand window when the "load older" sentinel scrolls into view. Doubles
|
||||
// effective window on each trigger so scrolling up quickly converges to
|
||||
// rendering everything.
|
||||
useEffect(() => {
|
||||
const el = loadMoreSentinelRef.current;
|
||||
if (!el) return;
|
||||
if (displayCount >= messages.length) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
setDisplayCount((n) => Math.min(messages.length, n * 2));
|
||||
}
|
||||
},
|
||||
{ root: scrollRef.current, rootMargin: '200px 0px' },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [displayCount, messages.length]);
|
||||
|
||||
const messageById = useMemo(() => {
|
||||
const m = new Map<string, DecryptedMessage>();
|
||||
for (const msg of messages) m.set(msg.id, msg);
|
||||
@@ -176,17 +237,39 @@ export function ConversationPage() {
|
||||
setForwardTarget(m);
|
||||
}, []);
|
||||
|
||||
// Search matches: messages whose decrypted text includes the query.
|
||||
// Search matches: messages matching query + filters. Empty query is allowed
|
||||
// when filters are active, so users can e.g. show "all attachments from
|
||||
// alice in the last week" without a text query.
|
||||
const searchActive = useMemo(
|
||||
() =>
|
||||
searchQuery.trim().length > 0 ||
|
||||
searchSenderId !== '' ||
|
||||
searchAttachmentsOnly ||
|
||||
searchDateFrom !== '' ||
|
||||
searchDateTo !== '',
|
||||
[searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo],
|
||||
);
|
||||
const searchMatches = useMemo(() => {
|
||||
if (!searchActive) return [] as DecryptedMessage[];
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (!q) return [] as DecryptedMessage[];
|
||||
const fromTs = searchDateFrom ? new Date(searchDateFrom).getTime() : null;
|
||||
// Date inputs cover whole days — bump 'to' to end-of-day.
|
||||
const toTs = searchDateTo
|
||||
? new Date(searchDateTo).getTime() + 24 * 3600 * 1000 - 1
|
||||
: null;
|
||||
return messages.filter((m) => {
|
||||
if (searchSenderId && m.senderId !== searchSenderId) return false;
|
||||
const created = new Date(m.createdAt).getTime();
|
||||
if (fromTs !== null && created < fromTs) return false;
|
||||
if (toTs !== null && created > toTs) return false;
|
||||
if (!m.plaintext) return false;
|
||||
const parsed = parseMessagePayload(m.plaintext);
|
||||
if (parsed.kind !== 'text') return false;
|
||||
return parsed.text.toLowerCase().includes(q);
|
||||
if (searchAttachmentsOnly && parsed.attachments.length === 0) return false;
|
||||
if (q && !parsed.text.toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [messages, searchQuery]);
|
||||
}, [messages, searchActive, searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo]);
|
||||
|
||||
// Reset/clamp the active match index when the match set changes.
|
||||
useEffect(() => {
|
||||
@@ -278,7 +361,6 @@ export function ConversationPage() {
|
||||
setAttachments((prev) => [...prev, ...next].slice(0, 4));
|
||||
}
|
||||
|
||||
const isGroup = conversation?.type === 'group';
|
||||
const { state: callState } = useCall();
|
||||
// Hide the chat header while this conversation hosts an active call — the
|
||||
// call topbar inside the dock already shows the channel name + duration,
|
||||
@@ -308,6 +390,15 @@ export function ConversationPage() {
|
||||
onQueryChange={setSearchQuery}
|
||||
matches={searchMatches.length}
|
||||
activeIdx={searchIdx}
|
||||
senderId={searchSenderId}
|
||||
onSenderChange={setSearchSenderId}
|
||||
attachmentsOnly={searchAttachmentsOnly}
|
||||
onAttachmentsOnlyChange={setSearchAttachmentsOnly}
|
||||
dateFrom={searchDateFrom}
|
||||
onDateFromChange={setSearchDateFrom}
|
||||
dateTo={searchDateTo}
|
||||
onDateToChange={setSearchDateTo}
|
||||
members={conversation?.members ?? []}
|
||||
onPrev={() =>
|
||||
setSearchIdx((cur) =>
|
||||
searchMatches.length === 0 ? 0 : (cur - 1 + searchMatches.length) % searchMatches.length,
|
||||
@@ -319,6 +410,10 @@ export function ConversationPage() {
|
||||
onClose={() => {
|
||||
setSearchOpen(false);
|
||||
setSearchQuery('');
|
||||
setSearchSenderId('');
|
||||
setSearchAttachmentsOnly(false);
|
||||
setSearchDateFrom('');
|
||||
setSearchDateTo('');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -345,7 +440,18 @@ export function ConversationPage() {
|
||||
<p className="text-center text-sm text-fg-muted">…</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{messages.map((m, idx) => {
|
||||
{displayCount < messages.length && (
|
||||
<li>
|
||||
<div
|
||||
ref={loadMoreSentinelRef}
|
||||
className="flex items-center justify-center py-2 text-xs text-fg-muted"
|
||||
>
|
||||
Lade ältere Nachrichten…
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
{messages.slice(Math.max(0, messages.length - displayCount)).map((m, sliceIdx) => {
|
||||
const idx = Math.max(0, messages.length - displayCount) + sliceIdx;
|
||||
// A "run" is consecutive bubbles from the same sender with
|
||||
// nothing between them. Call-event separators break the run —
|
||||
// a bubble whose immediate next neighbour is a call_event must
|
||||
@@ -384,6 +490,19 @@ export function ConversationPage() {
|
||||
reactions={reactionsByMessage.get(m.id) ?? []}
|
||||
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
|
||||
showSeen={m.id === lastSeenMessageId}
|
||||
{...(m.senderId === myId
|
||||
? {
|
||||
deliveryState: computeDeliveryState({
|
||||
messageId: m.id,
|
||||
isGroup: !!isGroup,
|
||||
recipientCount: groupRecipientCount,
|
||||
peerReadSet,
|
||||
peerDeliveredSet,
|
||||
groupRead,
|
||||
groupDelivered,
|
||||
}),
|
||||
}
|
||||
: {})}
|
||||
quoted={buildQuoted(m.replyToId)}
|
||||
onJumpToMessage={jumpToMessage}
|
||||
onReply={handleReply}
|
||||
@@ -393,6 +512,15 @@ export function ConversationPage() {
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{pending.map((p) => (
|
||||
<li key={p.id}>
|
||||
<PendingBubble
|
||||
item={p}
|
||||
onRetry={() => retryPending(p.id)}
|
||||
onCancel={() => cancelPending(p.id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
@@ -473,6 +601,18 @@ export function ConversationPage() {
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<VoiceRecorder
|
||||
disabled={sending}
|
||||
onComplete={async (file) => {
|
||||
try {
|
||||
await send('', [file], replyTo?.id ?? null);
|
||||
setReplyTo(null);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'send failed';
|
||||
setSendError(msg);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
ref={composerRef}
|
||||
value={text}
|
||||
@@ -516,65 +656,134 @@ interface SearchBarProps {
|
||||
onQueryChange: (q: string) => void;
|
||||
matches: number;
|
||||
activeIdx: number;
|
||||
senderId: string;
|
||||
onSenderChange: (id: string) => void;
|
||||
attachmentsOnly: boolean;
|
||||
onAttachmentsOnlyChange: (v: boolean) => void;
|
||||
dateFrom: string;
|
||||
onDateFromChange: (v: string) => void;
|
||||
dateTo: string;
|
||||
onDateToChange: (v: string) => void;
|
||||
members: { userId: string; profile: { displayName?: string | null } | null }[];
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function SearchBar({ query, onQueryChange, matches, activeIdx, onPrev, onNext, onClose }: SearchBarProps) {
|
||||
function SearchBar({
|
||||
query,
|
||||
onQueryChange,
|
||||
matches,
|
||||
activeIdx,
|
||||
senderId,
|
||||
onSenderChange,
|
||||
attachmentsOnly,
|
||||
onAttachmentsOnlyChange,
|
||||
dateFrom,
|
||||
onDateFromChange,
|
||||
dateTo,
|
||||
onDateToChange,
|
||||
members,
|
||||
onPrev,
|
||||
onNext,
|
||||
onClose,
|
||||
}: SearchBarProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b border-line bg-surface-2 px-4 py-2">
|
||||
<SearchIcon className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||
<input
|
||||
type="search"
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) onPrev();
|
||||
else onNext();
|
||||
}
|
||||
}}
|
||||
placeholder={t('app:chats.search_in_conv', { defaultValue: 'In Unterhaltung suchen…' })}
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-fg placeholder-fg-muted outline-none"
|
||||
/>
|
||||
<span className="shrink-0 text-xs tabular-nums text-fg-muted">
|
||||
{matches === 0
|
||||
? query.trim().length > 0
|
||||
? t('app:chats.search_none', { defaultValue: 'Keine Treffer' })
|
||||
: ''
|
||||
: activeIdx + 1 + ' / ' + matches}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrev}
|
||||
disabled={matches === 0}
|
||||
aria-label="Previous"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={matches === 0}
|
||||
aria-label="Next"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex flex-col gap-2 border-b border-line bg-surface-2 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<SearchIcon className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||
<input
|
||||
type="search"
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) onPrev();
|
||||
else onNext();
|
||||
}
|
||||
}}
|
||||
placeholder={t('app:chats.search_in_conv', { defaultValue: 'In Unterhaltung suchen…' })}
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-fg placeholder-fg-muted outline-none"
|
||||
/>
|
||||
<span className="shrink-0 text-xs tabular-nums text-fg-muted">
|
||||
{matches === 0
|
||||
? query.trim().length > 0
|
||||
? t('app:chats.search_none', { defaultValue: 'Keine Treffer' })
|
||||
: ''
|
||||
: activeIdx + 1 + ' / ' + matches}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrev}
|
||||
disabled={matches === 0}
|
||||
aria-label="Previous"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={matches === 0}
|
||||
aria-label="Next"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-fg-muted">
|
||||
<select
|
||||
value={senderId}
|
||||
onChange={(e) => onSenderChange(e.target.value)}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">Alle Sender</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.userId} value={m.userId}>
|
||||
{m.profile?.displayName ?? m.userId.slice(0, 6)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="flex cursor-pointer items-center gap-1 rounded-md border border-line bg-surface-3 px-2 py-1 text-fg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={attachmentsOnly}
|
||||
onChange={(e) => onAttachmentsOnlyChange(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
<span>Nur Anhänge</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<span>Von</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => onDateFromChange(e.target.value)}
|
||||
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<span>Bis</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => onDateToChange(e.target.value)}
|
||||
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -583,6 +792,86 @@ function SearchBar({ query, onQueryChange, matches, activeIdx, onPrev, onNext, o
|
||||
// regular bubble is found or the array boundary is reached. Used to decide
|
||||
// run-grouping for avatar placement so call separators don't bleed into
|
||||
// sender continuity.
|
||||
function computeDeliveryState(args: {
|
||||
messageId: string;
|
||||
isGroup: boolean;
|
||||
recipientCount: number;
|
||||
peerReadSet: Set<string>;
|
||||
peerDeliveredSet: Set<string>;
|
||||
groupRead: Map<string, Set<string>>;
|
||||
groupDelivered: Map<string, Set<string>>;
|
||||
}): 'sent' | 'delivered' | 'read' {
|
||||
// DM: single peer ack flips state.
|
||||
if (!args.isGroup) {
|
||||
if (args.peerReadSet.has(args.messageId)) return 'read';
|
||||
if (args.peerDeliveredSet.has(args.messageId)) return 'delivered';
|
||||
return 'sent';
|
||||
}
|
||||
// Group: state advances only when ALL recipients have acknowledged. With
|
||||
// 0 recipients (admin-only group), we keep 'sent' so we don't show
|
||||
// misleading completed ticks.
|
||||
if (args.recipientCount === 0) return 'sent';
|
||||
const reads = args.groupRead.get(args.messageId);
|
||||
if (reads && reads.size >= args.recipientCount) return 'read';
|
||||
const delivered = args.groupDelivered.get(args.messageId);
|
||||
if (delivered && delivered.size >= args.recipientCount) return 'delivered';
|
||||
return 'sent';
|
||||
}
|
||||
|
||||
function PendingBubble({
|
||||
item,
|
||||
onRetry,
|
||||
onCancel,
|
||||
}: {
|
||||
item: OutboxItem;
|
||||
onRetry: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const failed = item.attempts >= 8;
|
||||
return (
|
||||
<div className="flex justify-end py-0.5">
|
||||
<div
|
||||
className={
|
||||
'max-w-[72%] rounded-[18px] px-3.5 py-2 text-sm ' +
|
||||
(failed
|
||||
? 'border border-rose-500/40 bg-rose-500/10 text-rose-700 dark:text-rose-100'
|
||||
: 'border border-dashed border-accent/50 bg-accent/10 text-fg opacity-80')
|
||||
}
|
||||
>
|
||||
<p className="whitespace-pre-wrap break-words">{item.text}</p>
|
||||
<div className="mt-1 flex items-center justify-end gap-2 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
{failed ? (
|
||||
<>
|
||||
<span>{item.lastError ?? 'Senden fehlgeschlagen'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="cursor-pointer rounded px-1.5 py-0.5 font-semibold text-accent hover:underline"
|
||||
>
|
||||
Erneut
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="cursor-pointer rounded px-1.5 py-0.5 font-semibold text-rose-600 hover:underline dark:text-rose-300"
|
||||
>
|
||||
Verwerfen
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SpinnerIcon className="h-3 w-3 animate-spin" />
|
||||
<span>
|
||||
{item.attempts === 0 ? 'Senden…' : 'Wiederhole (' + item.attempts + ')'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Banner({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user