672c8738c7
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
928 lines
30 KiB
TypeScript
928 lines
30 KiB
TypeScript
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:
|
||
<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>
|
||
);
|
||
}
|