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
+149 -24
View File
@@ -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>