Files
ChatApp/apps/desktop/src/components/CallParticipantTile.tsx
T
byGalax 6d0e4fb1f0 perf(P6A.T5): pre-warm Supabase + avatar loading hints
Fire a no-await profiles query in AuthContext on session establish to absorb
cold-connection latency before the first user-triggered request. Add
loading='lazy' default to the central Avatar component so all off-screen
avatars (chat list, friends list, popovers, message senders) skip eager
Supabase Storage fetches; set loading='eager' on ConversationHeader (active
conv header) and CallParticipantTile inline imgs (both AudioContent and
VideoStub) which are always above-the-fold when visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:43:04 +02:00

379 lines
12 KiB
TypeScript

import { useEffect, useRef } from 'react';
import { useNickname } from '../lib/friendNicknames';
import {
CrownIcon,
HeadphonesOffIcon,
LockIcon,
MicOffIcon,
PinIcon,
WifiLowIcon,
WifiOffIcon,
} from './icons';
/** Discord-style connection-quality badge state. Matches LiveKit's
* ConnectionQuality enum values so consumers can pass the value through
* without conversion. `unknown` and `excellent`/`good` render nothing — the
* badge only surfaces when the user actually has degraded media. */
export type TileConnectionQuality = 'excellent' | 'good' | 'poor' | 'lost' | 'unknown';
export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal';
const AVATAR_COLORS: Record<AvatarColorKey, { bg: string; fg: string }> = {
// Tailwind classes tuned per spec: violet/amber/rose/teal swatches in both
// light and dark modes. Dark pairs invert to keep contrast readable.
violet: {
bg: 'bg-violet-200 dark:bg-violet-900/60',
fg: 'text-violet-800 dark:text-violet-200',
},
amber: {
bg: 'bg-amber-200 dark:bg-amber-900/60',
fg: 'text-amber-900 dark:text-amber-200',
},
rose: {
bg: 'bg-rose-200 dark:bg-rose-900/60',
fg: 'text-rose-900 dark:text-rose-200',
},
teal: {
bg: 'bg-teal-200 dark:bg-teal-900/60',
fg: 'text-teal-900 dark:text-teal-200',
},
};
const COLOR_KEYS: AvatarColorKey[] = ['violet', 'amber', 'rose', 'teal'];
// Stable per-user color from identity string — keeps avatars visually
// consistent across re-mounts without needing a color field on the profile.
export function colorKeyFor(id: string): AvatarColorKey {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
}
return COLOR_KEYS[hash % COLOR_KEYS.length]!;
}
export interface ParticipantTileProps {
userId: string;
displayName: string;
avatarUrl: string | null;
me: boolean;
muted: boolean;
/** True when the participant has deafened. Propagated from peers via the
* LiveKit data channel, so remote tiles surface the headphones-off badge
* the same way the local tile does. */
deafened: boolean;
/** Discord-style: true if this participant is the call host (initiator).
* Drives the crown badge. Hidden in 1:1 calls — set false there. */
isHost?: boolean;
/** True iff the user has pinned this tile via right-click → "Anpinnen".
* Renders a small pin badge top-left and locks the focus mode to this
* tile regardless of who is currently speaking. */
pinned?: boolean;
/** Discord-style "LIVE" pill — true iff this participant is currently
* publishing a screen share. Shown on the user tile so observers know
* the share-tile next to it belongs to this person. */
streaming?: boolean;
speaking: boolean;
video: boolean;
e2ee: boolean;
/** MediaStreamTrack for the participant's active camera, when `video` is
* true. When null the tile falls back to the avatar placeholder. */
videoTrack?: MediaStreamTrack | null;
/** SFU-measured connection quality. Only `poor` and `lost` show a badge. */
connectionQuality?: TileConnectionQuality;
size?: 'default' | 'small';
focused?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
}
export function CallParticipantTile(props: ParticipantTileProps) {
const {
userId,
displayName,
me,
muted,
deafened,
speaking,
video,
e2ee,
connectionQuality,
isHost = false,
pinned = false,
streaming = false,
size = 'default',
focused = false,
onClick,
onDoubleClick,
onContextMenu,
} = props;
// Apply the per-viewer nickname override once at the top — each tile is
// already per-participant, so a single hook call is fine. The resolved
// name is forwarded into the avatar sub-components below so their initial
// letter respects the nickname too.
const resolvedName = useNickname(userId, displayName);
const tileProps = { ...props, displayName: resolvedName };
const small = size === 'small';
// Discord-style: full tile border switches to emerald the whole time the
// user is speaking. Same treatment for audio + video tiles so the visual
// is consistent regardless of camera state. The inner-glow span on top of
// it adds a subtle inset highlight that reads even when the underlying
// tile is bright (e.g. a video stream).
const borderClass = speaking
? 'border-emerald-500 shadow-[0_0_0_2px_rgba(22,163,74,0.35)] dark:border-emerald-400'
: focused
? 'border-accent'
: 'border-line hover:border-accent';
return (
<div
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
className={
'relative flex flex-col overflow-hidden rounded-[14px] border-[2px] bg-surface-3 transition-colors duration-150 ' +
(onClick ? 'cursor-pointer ' : '') +
borderClass +
(small ? ' min-w-[140px]' : '')
}
>
{video ? (
<VideoStub {...tileProps} small={small} fit={focused ? 'contain' : 'cover'} />
) : (
<AudioContent {...tileProps} small={small} />
)}
{speaking && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-10 rounded-[12px] shadow-[inset_0_0_18px_rgba(34,197,94,0.45)]"
/>
)}
<ConnectionQualityBadge quality={connectionQuality} small={small} />
{pinned && (
<span
aria-label="Angepinnt"
title="Angepinnt"
className={
'pointer-events-none absolute left-2 top-2 z-20 flex items-center justify-center rounded-md bg-accent text-accent-fg ' +
(small ? 'h-5 w-5' : 'h-6 w-6')
}
>
<PinIcon className={small ? 'h-3 w-3' : 'h-3.5 w-3.5'} />
</span>
)}
{streaming && (
<span
aria-label="Streamt gerade"
title="Streamt gerade"
className={
'pointer-events-none absolute z-20 flex items-center gap-1 rounded-md bg-rose-600 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white shadow ' +
(pinned ? 'left-9 top-2' : 'left-2 top-2')
}
>
<span className="h-1.5 w-1.5 rounded-full bg-white motion-safe:animate-live-dot" />
LIVE
</span>
)}
{/* Reserve enough vertical space so the chip's height stays constant
whether or not the muted/deafened badges are present. Without
`min-h-5` on the right slot the chip grows by ~4px the moment a
badge appears, which makes the dock visibly jiggle when somebody
mutes mid-call. */}
<div className="pointer-events-none absolute inset-x-2 bottom-2 flex min-h-[32px] items-center justify-between gap-2 rounded-lg glass-chip px-2.5 py-1.5">
<div className="flex min-w-0 items-center gap-1.5 text-xs font-semibold">
{isHost && (
<CrownIcon
aria-label="Anrufgründer"
className="h-3 w-3 shrink-0 text-amber-300"
/>
)}
<span className="truncate">
{resolvedName}
{me ? ' (du)' : ''}
</span>
{e2ee && (
<span
aria-label="E2E verschlüsselt"
title="E2E verschlüsselt"
className="flex opacity-70"
>
<LockIcon className="h-3 w-3" />
</span>
)}
</div>
<div className="flex min-h-5 shrink-0 items-center gap-1">
{muted && (
<span
aria-label="Mikro stumm"
title="Mikro stumm"
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
>
<MicOffIcon className="h-3 w-3" />
</span>
)}
{deafened && (
<span
aria-label="Ton aus"
title="Ton aus"
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
>
<HeadphonesOffIcon className="h-3 w-3" />
</span>
)}
</div>
</div>
</div>
);
}
/** Discord-style: hidden when excellent/good/unknown, amber when poor,
* red with a struck-through Wifi when lost. Anchored top-right above the
* speaking-glow so it stays readable on a bright video stream. */
function ConnectionQualityBadge({
quality,
small,
}: {
quality: TileConnectionQuality | undefined;
small: boolean;
}) {
if (!quality || quality === 'excellent' || quality === 'good' || quality === 'unknown') {
return null;
}
const isLost = quality === 'lost';
const Icon = isLost ? WifiOffIcon : WifiLowIcon;
const label = isLost ? 'Verbindung verloren' : 'Schlechte Verbindung';
const tone = isLost
? 'bg-rose-500/85 text-white'
: 'bg-amber-400/90 text-amber-950 dark:text-amber-950';
const sz = small ? 'h-5 w-5' : 'h-6 w-6';
const iconSz = small ? 'h-3 w-3' : 'h-3.5 w-3.5';
return (
<span
aria-label={label}
title={label}
className={
'pointer-events-none absolute right-2 top-2 z-20 flex items-center justify-center rounded-md ' +
sz + ' ' + tone
}
>
<Icon className={iconSz} />
</span>
);
}
function AudioContent({
userId,
displayName,
avatarUrl,
small,
}: ParticipantTileProps & { small: boolean }) {
const key = colorKeyFor(userId);
const colors = AVATAR_COLORS[key];
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
return (
<div className="flex min-h-0 flex-1 items-center justify-center p-4">
<div
className={
'relative flex items-center justify-center rounded-full ' +
(small ? 'h-11 w-11' : 'h-[72px] w-[72px]')
}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="relative h-full w-full rounded-full object-cover"
loading="eager"
/>
) : (
<span
className={
'relative flex h-full w-full items-center justify-center rounded-full font-bold ' +
colors.bg + ' ' + colors.fg + ' ' +
(small ? 'text-lg' : 'text-2xl')
}
>
{letter}
</span>
)}
</div>
</div>
);
}
function VideoStub({
userId,
displayName,
avatarUrl,
videoTrack,
me,
small,
fit,
}: ParticipantTileProps & { small: boolean; fit: 'cover' | 'contain' }) {
const videoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
const el = videoRef.current;
if (!el || !videoTrack) return;
el.srcObject = new MediaStream([videoTrack]);
return () => {
if (el.srcObject) {
(el.srcObject as MediaStream).getTracks().forEach((t) => {
// Don't stop the live track — other consumers may still need it.
// Just detach from this element.
void t;
});
el.srcObject = null;
}
};
}, [videoTrack]);
if (videoTrack) {
return (
<div className="relative flex min-h-0 flex-1 items-center justify-center bg-black">
<video
ref={videoRef}
autoPlay
playsInline
muted
className={
'h-full w-full ' +
(fit === 'contain' ? 'object-contain ' : 'object-cover ') +
(me ? 'scale-x-[-1]' : '') /* mirror local preview */
}
/>
</div>
);
}
// Camera flag true but no track yet (publishing / subscribing race).
const key = colorKeyFor(userId);
const colors = AVATAR_COLORS[key];
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
return (
<div className="flex min-h-0 flex-1 items-center justify-center bg-gradient-to-br from-accent/20 to-surface-2 p-4">
<div
className={
'flex items-center justify-center rounded-full font-bold ' +
colors.bg + ' ' + colors.fg + ' ' +
(small ? 'h-11 w-11 text-lg' : 'h-[72px] w-[72px] text-2xl')
}
>
{avatarUrl ? (
<img src={avatarUrl} alt="" className="h-full w-full rounded-full object-cover" loading="eager" />
) : (
letter
)}
</div>
</div>
);
}