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,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');
|
||||
}
|
||||
Reference in New Issue
Block a user