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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user