Files
ChatApp/apps/desktop/src/components/InCallPanel.tsx
T
byGalax 672c8738c7 feat: file variety, user status, DND gate, drag-drop, mentions, link preview, emoji picker, soundboard, ringtone
Attachments
- Video: native <video controls>, decrypted blob, metadata preload
- PDF: collapsible <object> viewer with download + preview toggle
- Generic: file card with lazy decrypt, mime-to-extension map
- MessageBubble dispatch: audio/image/video/pdf/generic by mime
- Composer accept widened from image/* to any; AttachmentPreview renders
  non-images as file cards

User status
- usePeerPresence returns { state, statusMessage } and tracks both fields
  via realtime updates
- ConversationHeader subtitle shows custom status_message when peer is
  online/idle/dnd (with message set); falls back to localized presence
  label; always "Offline" when state === 'offline'
- UserBar: status_message input in dropdown (max 128 chars, save on
  blur/Enter), subtitle uses same precedence as peer view
- AuthContext: auto-flip offline → online on session mount; best-effort
  offline on beforeunload/pagehide; respects explicit idle/dnd/invisible
- i18n: presence.status_placeholder (DE + EN)

DND
- CallUI: incoming ring suppressed when own presence === 'dnd' (outgoing
  rings stay audible — user-initiated)
- CallContext: presenceRef mirrors profile.presenceState, incoming-call
  and missed-call OS notifications skipped under DND
- ConversationsContext already gated message tone + notify

Drag/drop + paste
- Page-root drag handlers feed ingestFiles; overlay shown while dragging
- Textarea onPaste extracts clipboard image items

@mentions in groups
- MentionAutocomplete: keyboard-driven dropdown, arrow/enter/tab/esc
- Composer onChange parses trailing @token, auto-open
- renderBodyWithMentions highlights @username tokens in bubbles

Link previews
- Migration 20260421000003_link_previews.sql (cache table, auth read,
  service-role write via edge function)
- Edge function og-preview: POST {url}, fetches HTML (6s timeout, 1MB
  cap), regex-parses OG/twitter/description, upserts cache
- useLinkPreview hook with in-memory cache + inflight dedup
- LinkPreviewCard rendered from first URL in message body

Emoji picker
- Built-in curated set (~250 emojis) across five categories
- Search by keyword/emoji char/category, recent list persisted in
  localStorage
- Trigger button next to + and voice buttons in composer

Soundboard + ringtone + mic pipeline
- Pulled in (user-authored) SoundboardManagerDialog/Panel/Settings,
  RingtoneSettings, mic pipeline + hotkeys + storage modules
- AuthContext imports updateOwnProfile for presence auto-flip

Fixes
- AttachmentAudio min-width so bubbles don't collapse to 0px on peer
  side
- Focus flicker: visibility/online wake refresh throttled to 30s,
  focus listener dropped, loading flag only on first fetch
2026-04-21 09:13:30 +02:00

928 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ConversationSummary } from '@chat-app/shared/chat';
import type { RemoteParticipant, Room } from 'livekit-client';
import { Track } from 'livekit-client';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { type CallMode, useCall } from '../context/CallContext';
import {
getPttSettings,
type PttSettings,
subscribePttSettings,
} from '../lib/pttSettings';
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';
import { SoundboardPanel } from './SoundboardPanel';
// Discord-style in-call dock rendered above the message list. Renders three
// visual modes driven by CallContext.callMode: grid, focus, fullscreen.
// The fullscreen variant absolute-positions itself over the conversation
// container so the rail+chat-list remain visible on the left.
interface Props {
conversation: ConversationSummary;
}
interface Tile {
// `user` = person with avatar/video/mute info. Speaking ring applies here.
// `screen` = a separate screen-share window from a user. No speaking
// indicator, no mute, no avatar — just the stream.
kind: 'user' | 'screen';
// Stable id used for focus tracking + React keys. `user:${userId}` or
// `screen:${userId}`.
id: string;
userId: string;
displayName: string;
avatarUrl: string | null;
self: boolean;
muted: boolean;
deafened: boolean;
video: boolean;
videoTrack: MediaStreamTrack | null;
// True iff this is the current user's own screen-share tile.
sharing: boolean;
// True iff this is a remote user's screen-share tile.
remoteSharing: boolean;
}
export function InCallPanel({ conversation }: Props) {
const { t } = useTranslation(['app']);
const {
state,
room,
remoteParticipants,
isMuted,
isE2EEActive,
isScreenSharing,
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteMute,
remoteScreenShares,
callMode,
focusedId,
toggleMute,
startScreenShare,
stopScreenShare,
toggleCamera,
toggleDeafen,
hangup,
setCallMode,
setFocusedId,
} = useCall();
const { session } = useAuth();
const myId = session?.user.id ?? null;
const activeSpeakers = useActiveSpeakers(room);
const [shareDialogOpen, setShareDialogOpen] = useState(false);
const [soundboardOpen, setSoundboardOpen] = 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' ||
state.kind === 'connecting' ||
state.kind === 'outgoing') &&
state.conversationId === conversation.id;
if (!active) return null;
const tiles = buildTiles({
conversation,
myId,
room,
remoteParticipants,
isMuted,
isDeafened,
remoteDeafen,
remoteMute,
isScreenSharing,
isCameraEnabled,
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
});
const duration =
state.kind === 'connected'
? <LiveDuration startedAt={state.startedAt} />
: null;
const statusLabel =
state.kind === 'outgoing'
? t('app:call.outgoing_ringing')
: state.kind === 'connecting'
? t('app:call.connecting')
: remoteParticipants.length === 0
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
: t('app:call.connected');
// A screen-share tile becomes the auto-focus target when no one explicitly
// picked a tile yet. Focus ids now use Tile.id (kind-prefixed) so we can
// distinguish a user's own avatar tile from their screen tile.
const screenTile = tiles.find((p) => p.kind === 'screen');
const effectiveFocusedId = focusedId ?? screenTile?.id ?? tiles[0]?.id ?? null;
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
const controls = (
<CallControls
muted={isMuted || !(room?.localParticipant?.isMicrophoneEnabled ?? false)}
sharing={isScreenSharing}
video={isCameraEnabled}
deafened={isDeafened}
onToggleMute={toggleMute}
onToggleShare={() => {
if (isScreenSharing) {
void stopScreenShare();
} else {
setShareDialogOpen(true);
}
}}
onToggleVideo={() => void toggleCamera()}
onToggleDeafen={toggleDeafen}
onToggleSoundboard={() => setSoundboardOpen((v) => !v)}
soundboardOpen={soundboardOpen}
onHangup={() => void hangup()}
compact={callMode !== 'fullscreen'}
glass={callMode === 'fullscreen'}
disabledMedia={state.kind !== 'connected'}
/>
);
if (callMode === 'fullscreen') {
// 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={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)}
/>
)}
<SoundboardPopover
open={soundboardOpen}
onClose={() => setSoundboardOpen(false)}
/>
</>
);
}
const title =
conversation.type === 'group'
? conversation.name ?? t('app:chats.new_group')
: conversation.peer?.displayName ?? '—';
// Focus mode dedicates the entire call-panel vertical slot to the speaker so
// the tile can grow in height (grid mode's 420px cap leaves it squashed).
const sectionClass =
callMode === 'focus'
? 'flex min-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2'
: 'flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2';
const sectionHeight = callMode === 'focus' ? '75%' : '50%';
return (
<section
aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })}
className={sectionClass}
style={{ height: sectionHeight }}
>
<div className="flex items-center justify-between border-b border-line bg-surface-3 px-4 py-2">
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex items-center gap-2 text-sm font-semibold text-fg">
<UsersIcon className="h-4 w-4 shrink-0 text-fg-muted" />
<span className="truncate">{title}</span>
<span className="text-fg-muted/50" aria-hidden="true">·</span>
<span className="tabular-nums text-fg-muted">
{duration ?? statusLabel}
{state.kind !== 'connected' && (
<SpinnerIcon className="ml-1 inline h-3 w-3" />
)}
</span>
</div>
{isE2EEActive && (
<div className="flex items-center gap-1 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
<LockIcon className="h-3 w-3" />
<span>{t('app:call.e2ee_active', { defaultValue: 'E2E verschlüsselt' })}</span>
</div>
)}
</div>
<ModeToggles mode={callMode} onChange={setCallMode} />
</div>
<CallStage
tiles={tiles}
speaker={speaker}
mode={callMode}
activeSpeakers={activeSpeakers}
e2ee={isE2EEActive}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members}
onFocusTile={(id) => {
setFocusedId(id);
if (callMode === 'grid') setCallMode('focus');
}}
onTileContextMenu={openVolumeMenu}
compact
/>
{controls}
<PttHint />
<ScreenShareDialog
open={shareDialogOpen}
onClose={() => setShareDialogOpen(false)}
onStart={async (opts) => {
await startScreenShare(opts);
}}
/>
{volumeMenu && (
<ParticipantVolumeMenu
userId={volumeMenu.userId}
displayName={volumeMenu.displayName}
x={volumeMenu.x}
y={volumeMenu.y}
onClose={() => setVolumeMenu(null)}
/>
)}
<SoundboardPopover
open={soundboardOpen}
onClose={() => setSoundboardOpen(false)}
/>
</section>
);
}
// Fixed-position overlay so the popover sits above both docked + fullscreen
// call modes without needing a portal or parent-relative anchoring.
function SoundboardPopover({ open, onClose }: { open: boolean; onClose: () => void }) {
if (!open) return null;
return (
<div className="pointer-events-none fixed inset-x-0 bottom-24 z-50 flex justify-center px-4">
<div className="pointer-events-auto">
<SoundboardPanel onClose={onClose} />
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface BuildArgs {
conversation: ConversationSummary;
myId: string | null;
room: Room | null;
remoteParticipants: RemoteParticipant[];
isMuted: boolean;
isDeafened: boolean;
remoteDeafen: Record<string, boolean>;
remoteMute: Record<string, boolean>;
isScreenSharing: boolean;
isCameraEnabled: boolean;
remoteSharerIds: Set<string>;
}
function cameraTrackFor(
participant: { videoTrackPublications: Map<string, { source: Track.Source; track?: { mediaStreamTrack: MediaStreamTrack } | undefined }> } | null,
): MediaStreamTrack | null {
if (!participant) return null;
for (const pub of participant.videoTrackPublications.values()) {
if (pub.source === Track.Source.Camera && pub.track) {
return pub.track.mediaStreamTrack ?? null;
}
}
return null;
}
function buildTiles({
conversation,
myId,
room,
remoteParticipants,
isMuted,
isDeafened,
remoteDeafen,
remoteMute,
isScreenSharing,
isCameraEnabled,
remoteSharerIds,
}: BuildArgs): Tile[] {
const remoteById = new Map<string, RemoteParticipant>();
for (const p of remoteParticipants) {
if (p.identity) remoteById.set(p.identity, p);
}
const out: Tile[] = [];
if (myId) {
const me = conversation.members.find((m) => m.userId === myId) ?? null;
// Source-of-truth for own mic: the LocalParticipant publication state.
// `isMuted` reflects the toggle button, but if mic never published (no
// device / permission denied) the toggle stays false while the real
// state is "muted". Combine both so the badge always matches reality.
const micLive = room?.localParticipant?.isMicrophoneEnabled ?? false;
out.push({
kind: 'user',
id: 'user:' + myId,
userId: myId,
displayName: me?.profile?.displayName ?? '?',
avatarUrl: me?.profile?.avatarUrl ?? null,
self: true,
muted: isMuted || !micLive,
deafened: isDeafened,
video: isCameraEnabled,
videoTrack: cameraTrackFor(room?.localParticipant ?? null),
sharing: false,
remoteSharing: false,
});
if (isScreenSharing) {
out.push({
kind: 'screen',
id: 'screen:' + myId,
userId: myId,
displayName: (me?.profile?.displayName ?? '?') + ' · Bildschirm',
avatarUrl: me?.profile?.avatarUrl ?? null,
self: true,
muted: false,
deafened: false,
video: false,
videoTrack: null,
sharing: true,
remoteSharing: false,
});
}
}
for (const m of conversation.members) {
if (m.userId === myId) continue;
const rp = remoteById.get(m.userId);
if (!rp) continue;
out.push({
kind: 'user',
id: 'user:' + m.userId,
userId: m.userId,
displayName: m.profile?.displayName ?? '?',
avatarUrl: m.profile?.avatarUrl ?? null,
self: false,
// Peer's self-reported mute state via data channel. LiveKit's own
// `isMicrophoneEnabled` no longer flips on mute since the pipeline
// output track stays published. See remoteMute broadcast in CallContext.
muted: remoteMute[m.userId] ?? false,
// Deafen state arrives via LiveKit data channel; see CallContext.
deafened: remoteDeafen[m.userId] ?? false,
video: rp.isCameraEnabled,
videoTrack: cameraTrackFor(rp),
sharing: false,
remoteSharing: false,
});
if (remoteSharerIds.has(m.userId)) {
out.push({
kind: 'screen',
id: 'screen:' + m.userId,
userId: m.userId,
displayName: (m.profile?.displayName ?? '?') + ' · Bildschirm',
avatarUrl: m.profile?.avatarUrl ?? null,
self: false,
muted: false,
deafened: false,
video: false,
videoTrack: null,
sharing: false,
remoteSharing: true,
});
}
}
return out;
}
function LiveDuration({ startedAt }: { startedAt: string }) {
const [, tick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => tick((v) => v + 1), 1000);
return () => window.clearInterval(id);
}, []);
return <>{formatElapsed(Date.now() - new Date(startedAt).getTime())}</>;
}
function formatElapsed(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const hh = Math.floor(total / 3600);
const mm = Math.floor((total % 3600) / 60);
const ss = total % 60;
const pad = (n: number) => n.toString().padStart(2, '0');
return `${pad(hh)}:${pad(mm)}:${pad(ss)}`;
}
function ModeToggles({
mode,
onChange,
}: {
mode: CallMode;
onChange: (mode: CallMode) => void;
}) {
return (
<div className="flex items-center gap-1">
<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
<GridIcon className="h-4 w-4" />
</ModeButton>
<ModeButton active={mode === 'focus'} onClick={() => onChange('focus')} label="Fokus">
<FocusIcon className="h-4 w-4" />
</ModeButton>
<ModeButton
active={mode === 'fullscreen'}
onClick={() => onChange('fullscreen')}
label="Vollbild"
>
<MaximizeIcon className="h-4 w-4" />
</ModeButton>
</div>
);
}
function ModeButton({
active,
onClick,
label,
children,
}: {
active: boolean;
onClick: () => void;
label: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
aria-pressed={active}
title={label}
className={
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
(active
? 'border-accent bg-accent text-accent-fg'
: 'border-line bg-surface-2 text-fg-muted hover:bg-surface-3 hover:text-fg')
}
>
{children}
</button>
);
}
interface StageProps {
tiles: Tile[];
speaker: Tile | undefined;
mode: CallMode;
activeSpeakers: Set<string>;
e2ee: boolean;
remoteScreenShares: {
track: import('livekit-client').RemoteTrack;
participantId: string;
participantName: string;
}[];
conversationMembers: ConversationSummary['members'];
onFocusTile: (id: string) => void;
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
compact?: boolean;
}
// Dispatches a Tile to the right renderer. Screen-tiles show the screenshare
// stream directly (no avatar, no speaking ring, no mic indicator); user-tiles
// render via CallParticipantTile with all its chrome.
function TileRender({
tile,
activeSpeakers,
e2ee,
remoteScreenShares,
conversationMembers,
size,
focused,
onClick,
onContextMenu,
}: {
tile: Tile;
activeSpeakers: Set<string>;
e2ee: boolean;
remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers'];
size?: 'default' | 'small';
focused?: boolean;
onClick?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
}): JSX.Element {
if (tile.kind === 'screen') {
if (tile.self) {
// Local screenshare preview — we don't mirror a copy of the outgoing
// stream. Render a labelled placeholder card so the user knows their
// share is live without double-encoding the stream.
return (
<div
onClick={onClick}
className={
'relative flex h-full w-full items-center justify-center overflow-hidden rounded-[14px] border border-emerald-500/40 bg-emerald-500/5 text-emerald-700 dark:text-emerald-200 ' +
(onClick ? 'cursor-pointer' : '')
}
>
<div className="flex flex-col items-center gap-2 p-4 text-center">
<div className="text-xs font-semibold uppercase tracking-wider">
Live
</div>
<div className="text-sm">{tile.displayName}</div>
</div>
</div>
);
}
const share = remoteScreenShares.find((s) => s.participantId === tile.userId);
const member = conversationMembers.find((m) => m.userId === tile.userId);
if (!share) return <div className="h-full w-full" />;
return (
<div
onClick={onClick}
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
>
<ScreenShareViewer
share={share}
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
displayName={member?.profile?.displayName ?? tile.displayName}
/>
</div>
);
}
return (
<CallParticipantTile
userId={tile.userId}
displayName={tile.displayName}
avatarUrl={tile.avatarUrl}
me={tile.self}
muted={tile.muted}
deafened={tile.deafened}
speaking={activeSpeakers.has(tile.userId)}
video={tile.video}
videoTrack={tile.videoTrack}
e2ee={e2ee}
{...(size ? { size } : {})}
{...(focused ? { focused } : {})}
{...(onClick ? { onClick } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
/>
);
}
function CallStage({
tiles,
speaker,
mode,
activeSpeakers,
e2ee,
remoteScreenShares,
conversationMembers,
onFocusTile,
onTileContextMenu,
compact = false,
}: StageProps) {
if (mode === 'focus' && speaker) {
const others = tiles.filter((p) => p.id !== speaker.id);
return (
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
<div className="min-h-0 flex-1">
<FocusedTile
tile={speaker}
e2ee={e2ee}
activeSpeakers={activeSpeakers}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
/>
</div>
{others.length > 0 && (
<div className="flex h-[180px] gap-2.5 overflow-x-auto">
{others.map((p) => (
<div
key={p.id}
className="h-full w-[240px] shrink-0 [&>div]:h-full"
>
<TileRender
tile={p}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
size="small"
onClick={() => onFocusTile(p.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/>
</div>
))}
</div>
)}
</div>
);
}
// Grid
const gridClass = gridColsFor(tiles.length);
return (
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
<div className={'grid h-full gap-2 ' + gridClass}>
{tiles.map((p) => (
<div key={p.id} className="[&>div]:h-full [&>div]:w-full">
<TileRender
tile={p}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(p.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/>
</div>
))}
</div>
</div>
);
}
function FocusedTile({
tile,
e2ee,
activeSpeakers,
remoteScreenShares,
conversationMembers,
}: {
tile: Tile;
e2ee: boolean;
activeSpeakers: Set<string>;
remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers'];
}) {
return (
<div className="h-full [&>div]:h-full">
<TileRender
tile={tile}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
focused
/>
</div>
);
}
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
// content, and a video element's intrinsic size blows the tile past the
// container bounds (overlapping the toolbar below).
if (n <= 1) return 'grid-cols-1 grid-rows-1';
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';
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));
}
// ---------------------------------------------------------------------------
// Fullscreen cinema mode
// ---------------------------------------------------------------------------
interface FullscreenProps {
tiles: Tile[];
speaker: Tile | undefined;
remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers'];
activeSpeakers: Set<string>;
e2ee: boolean;
onExit: () => void;
onFocusTile: (id: string) => void;
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
controls: React.ReactNode;
}
function FullscreenCall({
tiles,
speaker,
remoteScreenShares,
conversationMembers,
activeSpeakers,
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);
}, []);
const hasFocus = speaker !== undefined;
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
// 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">
{/* Content area. pb-24 reserves ~96px space at the bottom for the
floating controls bar so tiles never sit behind it. */}
<div className="relative flex min-h-0 flex-1 flex-col pb-24">
{hasFocus ? (
<>
<div
className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
onClick={() => onFocusTile(speaker!.id)}
title="Zurück zur Übersicht"
>
<TileRender
tile={speaker!}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
focused
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker!, e) }
: {})}
/>
</div>
{others.length > 0 && (
<div className="flex h-[160px] shrink-0 gap-2.5 overflow-x-auto px-4 pb-2">
{others.map((p) => (
<div
key={p.id}
className="h-full w-[220px] shrink-0 [&>div]:h-full [&>div]:w-full"
>
<TileRender
tile={p}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
size="small"
onClick={() => onFocusTile(p.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/>
</div>
))}
</div>
)}
</>
) : (
<div className="min-h-0 flex-1 p-4">
<div className={'grid h-full gap-2 ' + gridClass}>
{visibleTiles.map((p) => (
<div key={p.id} className="min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full">
<TileRender
tile={p}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
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>
{!hintGone && (
<div
aria-hidden="true"
className="pointer-events-none absolute left-1/2 top-5 z-10 animate-fs-hint rounded-lg border border-white/10 bg-black/60 px-3.5 py-1.5 text-[11px] font-medium tracking-wide text-white/70 backdrop-blur-md"
>
Esc zum Verlassen
</div>
)}
<div className="pointer-events-auto absolute bottom-4 left-1/2 -translate-x-1/2">
{controls}
</div>
</div>
);
}
function PttHint() {
const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
useEffect(() => subscribePttSettings(setPtt), []);
if (!ptt.enabled) return null;
return (
<p className="border-t border-line bg-surface-3 py-2 text-center text-[11px] text-fg-muted">
Push-to-Talk:&nbsp;
<kbd className="rounded border border-line bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] text-fg">
{ptt.keyLabel}
</kbd>
</p>
);
}