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:
2026-04-21 01:14:16 +02:00
parent da85f0ba54
commit a04ecf7a19
40 changed files with 4286 additions and 430 deletions
@@ -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');
}