initial
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import type { RemoteTrack } from 'livekit-client';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { type RemoteScreenShare, useCall } from '../context/CallContext';
|
||||
import {
|
||||
getPttSettings,
|
||||
type PttSettings,
|
||||
subscribePttSettings,
|
||||
} from '../lib/pttSettings';
|
||||
import {
|
||||
LockIcon,
|
||||
MicIcon,
|
||||
MicOffIcon,
|
||||
MonitorShareIcon,
|
||||
MonitorStopIcon,
|
||||
PhoneOffIcon,
|
||||
SpinnerIcon,
|
||||
} from './icons';
|
||||
|
||||
interface Props {
|
||||
conversation: ConversationSummary;
|
||||
}
|
||||
|
||||
// Discord-style call widget rendered above the message list when the user is
|
||||
// in the current conversation's call. Shows participant avatars + controls.
|
||||
export function InCallPanel({ conversation }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const {
|
||||
state,
|
||||
remoteParticipants,
|
||||
isMuted,
|
||||
isE2EEActive,
|
||||
toggleMute,
|
||||
hangup,
|
||||
isScreenSharing,
|
||||
remoteScreenShares,
|
||||
toggleScreenShare,
|
||||
} = useCall();
|
||||
const { session } = useAuth();
|
||||
const myId = session?.user.id ?? null;
|
||||
|
||||
const active =
|
||||
(state.kind === 'connected' ||
|
||||
state.kind === 'connecting' ||
|
||||
state.kind === 'outgoing') &&
|
||||
state.conversationId === conversation.id;
|
||||
if (!active) return null;
|
||||
|
||||
const remoteIds = new Set<string>(
|
||||
remoteParticipants.map((p) => p.identity).filter((s): s is string => Boolean(s)),
|
||||
);
|
||||
const tiles: ParticipantTileData[] = [];
|
||||
if (myId) {
|
||||
const me = conversation.members.find((m) => m.userId === myId) ?? null;
|
||||
tiles.push({
|
||||
userId: myId,
|
||||
displayName: me?.profile?.displayName ?? '?',
|
||||
avatarUrl: me?.profile?.avatarUrl ?? null,
|
||||
self: true,
|
||||
speaking: false,
|
||||
muted: isMuted,
|
||||
});
|
||||
}
|
||||
for (const m of conversation.members) {
|
||||
if (m.userId === myId) continue;
|
||||
if (!remoteIds.has(m.userId)) continue;
|
||||
tiles.push({
|
||||
userId: m.userId,
|
||||
displayName: m.profile?.displayName ?? '?',
|
||||
avatarUrl: m.profile?.avatarUrl ?? null,
|
||||
self: false,
|
||||
speaking: false,
|
||||
muted: false,
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })}
|
||||
className="border-b border-white/5 bg-gradient-to-b from-ink-900/80 to-ink-950/40 px-6 py-5"
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-2 text-xs font-medium text-emerald-300">
|
||||
{state.kind === 'connecting' ? (
|
||||
<SpinnerIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400/60" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
)}
|
||||
<span className="uppercase tracking-wide">{statusLabel}</span>
|
||||
{isE2EEActive && (
|
||||
<span
|
||||
className="ml-2 inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-200 ring-1 ring-emerald-400/30"
|
||||
title={t('app:call.e2ee_active_hint', {
|
||||
defaultValue: 'Audio + Video sind Ende-zu-Ende-verschlüsselt',
|
||||
})}
|
||||
>
|
||||
<LockIcon className="h-3 w-3" />
|
||||
E2EE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
{tiles.map((p) => (
|
||||
<ParticipantTile key={p.userId} {...p} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{remoteScreenShares.length > 0 && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{remoteScreenShares.map((s) => {
|
||||
const member = conversation.members.find((m) => m.userId === s.participantId);
|
||||
return (
|
||||
<ScreenShareViewer
|
||||
key={s.track.sid ?? s.participantId}
|
||||
share={s}
|
||||
avatarUrl={member?.profile?.avatarUrl ?? null}
|
||||
displayName={member?.profile?.displayName ?? s.participantName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex items-center justify-center gap-2">
|
||||
<ControlButton
|
||||
onClick={toggleMute}
|
||||
disabled={state.kind !== 'connected'}
|
||||
active={isMuted}
|
||||
label={isMuted ? t('app:call.unmute') : t('app:call.mute')}
|
||||
tone={isMuted ? 'amber' : 'neutral'}
|
||||
>
|
||||
{isMuted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />}
|
||||
</ControlButton>
|
||||
<ControlButton
|
||||
onClick={() => void toggleScreenShare()}
|
||||
disabled={state.kind !== 'connected'}
|
||||
active={isScreenSharing}
|
||||
label={
|
||||
isScreenSharing
|
||||
? t('app:call.stop_share_screen', { defaultValue: 'Screen-Share stoppen' })
|
||||
: t('app:call.share_screen', { defaultValue: 'Bildschirm teilen' })
|
||||
}
|
||||
tone={isScreenSharing ? 'emerald' : 'neutral'}
|
||||
>
|
||||
{isScreenSharing ? (
|
||||
<MonitorStopIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<MonitorShareIcon className="h-5 w-5" />
|
||||
)}
|
||||
</ControlButton>
|
||||
<ControlButton
|
||||
onClick={() => void hangup()}
|
||||
label={t('app:call.hangup')}
|
||||
tone="rose"
|
||||
>
|
||||
<PhoneOffIcon className="h-5 w-5" />
|
||||
</ControlButton>
|
||||
</div>
|
||||
|
||||
<PttHint />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScreenShareViewerProps {
|
||||
share: RemoteScreenShare;
|
||||
avatarUrl: string | null;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShareViewerProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [watching, setWatching] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||
|
||||
useEffect(() => {
|
||||
if (!watching) return;
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
const track: RemoteTrack = share.track;
|
||||
track.attach(el);
|
||||
return () => {
|
||||
track.detach(el);
|
||||
};
|
||||
}, [share.track, watching]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => {
|
||||
setIsFullscreen(document.fullscreenElement === containerRef.current);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', onChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (document.fullscreenElement === el) {
|
||||
void document.exitFullscreen();
|
||||
} else {
|
||||
void el.requestFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={
|
||||
'overflow-hidden rounded-xl border border-emerald-500/20 bg-black ' +
|
||||
(isFullscreen ? 'flex h-screen w-screen flex-col rounded-none' : '')
|
||||
}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/10 bg-emerald-500/10 px-3 py-1.5 text-xs text-emerald-200">
|
||||
<MonitorShareIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate flex-1">
|
||||
{t('app:call.is_sharing_screen', {
|
||||
name: displayName,
|
||||
defaultValue: displayName + ' teilt den Bildschirm',
|
||||
})}
|
||||
</span>
|
||||
{watching && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFullscreen}
|
||||
aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
||||
title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
||||
>
|
||||
<FullscreenIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (document.fullscreenElement === containerRef.current) {
|
||||
void document.exitFullscreen();
|
||||
}
|
||||
setWatching(false);
|
||||
}}
|
||||
aria-label={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
||||
title={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
||||
>
|
||||
<span aria-hidden="true" className="text-[14px] leading-none">×</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{watching ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
onDoubleClick={toggleFullscreen}
|
||||
className={
|
||||
'block cursor-zoom-in bg-black ' +
|
||||
(isFullscreen ? 'h-full w-full flex-1 object-contain' : 'w-full')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWatching(true)}
|
||||
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
||||
style={{ aspectRatio: '16 / 9' }}
|
||||
>
|
||||
<BlurredTile avatarUrl={avatarUrl} letter={letter} />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/90 text-white shadow-lg transition group-hover:scale-105">
|
||||
<PlayIcon className="ml-0.5 h-6 w-6" />
|
||||
</span>
|
||||
<span className="text-xs font-medium text-white/90">
|
||||
{t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {
|
||||
if (avatarUrl) {
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full scale-110 object-cover blur-2xl"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-brand-500/20 to-emerald-500/20" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-brand-500/40 via-fuchsia-500/20 to-emerald-500/30">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-[120px] font-display font-bold text-white/20 blur-[2px]"
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FullscreenIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PttHint() {
|
||||
const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
|
||||
useEffect(() => subscribePttSettings(setPtt), []);
|
||||
if (!ptt.enabled) return null;
|
||||
return (
|
||||
<p className="mt-3 text-center text-[11px] text-neutral-500">
|
||||
Push-to-Talk:
|
||||
<kbd className="rounded border border-white/10 bg-white/5 px-1.5 py-0.5 font-mono text-[10px] text-neutral-300">
|
||||
{ptt.keyLabel}
|
||||
</kbd>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
interface ParticipantTileData {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
self: boolean;
|
||||
speaking: boolean;
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
function ParticipantTile(p: ParticipantTileData) {
|
||||
const letter = p.displayName.trim().charAt(0).toUpperCase() || '?';
|
||||
return (
|
||||
<div className="flex w-28 flex-col items-center gap-2">
|
||||
<div
|
||||
className={
|
||||
'relative flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-500/40 to-brand-700/40 text-2xl font-semibold text-white ring-2 transition ' +
|
||||
(p.speaking
|
||||
? 'ring-emerald-400 shadow-[0_0_24px_rgba(52,211,153,0.35)]'
|
||||
: 'ring-white/10')
|
||||
}
|
||||
>
|
||||
{p.avatarUrl ? (
|
||||
<img
|
||||
src={p.avatarUrl}
|
||||
alt=""
|
||||
className="h-full w-full rounded-2xl object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden="true">{letter}</span>
|
||||
)}
|
||||
{p.muted && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-amber-500 ring-2 ring-ink-950"
|
||||
>
|
||||
<MicOffIcon className="h-3 w-3 text-ink-950" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="max-w-full truncate text-xs font-medium text-neutral-200">
|
||||
{p.displayName}
|
||||
{p.self && (
|
||||
<span className="ml-1 text-neutral-500">· Du</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ControlButtonProps {
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
tone: 'neutral' | 'amber' | 'rose' | 'emerald';
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function ControlButton({ onClick, label, tone, disabled, children }: ControlButtonProps) {
|
||||
const toneClass =
|
||||
tone === 'rose'
|
||||
? 'bg-rose-500 text-white hover:bg-rose-400 focus-visible:ring-rose-400/50'
|
||||
: tone === 'amber'
|
||||
? 'bg-amber-500/90 text-ink-950 hover:bg-amber-400 focus-visible:ring-amber-400/50'
|
||||
: tone === 'emerald'
|
||||
? 'bg-emerald-500/85 text-white hover:bg-emerald-400 focus-visible:ring-emerald-400/50'
|
||||
: 'bg-white/10 text-neutral-100 hover:bg-white/20 focus-visible:ring-brand-400/40';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={
|
||||
'inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-full transition focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 ' +
|
||||
toneClass
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user