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,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,
);
}