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