import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { useCall } from '../context/CallContext'; import { useConversationsContext } from '../context/ConversationsContext'; import { useFriendshipsContext } from '../context/FriendshipsContext'; import { ringtone } from '../lib/ringtone'; import { useAnyActiveCall } from '../lib/useAnyActiveCall'; import { useCallPresence } from '../lib/useCallPresence'; import { MicIcon, MicOffIcon, PhoneIcon, PhoneOffIcon, SpinnerIcon, XIcon, } from './icons'; // Shell-level mount. Handles ringtones + the IncomingCallToast. // The persistent CallBar (active-call widget) is rendered inside Sidebar so // users can keep browsing/typing while a call is live. export function CallUI() { const { state } = useCall(); useEffect(() => { if (state.kind === 'outgoing') ringtone.start('outgoing'); else if (state.kind === 'incoming') ringtone.start('incoming'); else ringtone.stop(); }, [state.kind]); useEffect(() => { return () => ringtone.stop(); }, []); return ; } // --------------------------------------------------------------------------- function IncomingCallToast() { const { t } = useTranslation(['app']); const { state, acceptIncoming, rejectIncoming } = useCall(); const { friendships } = useFriendshipsContext(); const { conversations } = useConversationsContext(); if (state.kind !== 'incoming') return null; const conv = conversations.find((c) => c.id === state.conversationId) ?? null; const callerName = conv?.members.find((m) => m.userId === state.fromUserId)?.profile?.displayName ?? friendships.find((f) => f.peer.userId === state.fromUserId)?.peer.displayName ?? '?'; const isGroup = conv?.type === 'group'; const groupName = isGroup ? (conv?.name ?? t('app:chats.new_group')) : null; const letter = (isGroup ? groupName ?? callerName : callerName) .trim() .charAt(0) .toUpperCase() || '?'; return (

{t('app:call.incoming_title')}

{letter}

{isGroup ? groupName : callerName}

{isGroup ? t('app:call.incoming_group_from', { name: callerName, defaultValue: callerName + ' ruft Gruppe', }) : t('app:call.incoming_from', { name: callerName })}

); } // --------------------------------------------------------------------------- // CallBar — persistent widget inside Sidebar. Lets the user keep browsing // the app while a call is active / connecting / ringing. // --------------------------------------------------------------------------- export function CallBar() { const { state, lastCallConversationId } = useCall(); const { conversations } = useConversationsContext(); const { session } = useAuth(); const myId = session?.user.id ?? null; // Aggregate observer across every conversation the user is in so a // "call is live, rejoin" affordance appears in the sidebar whenever ANY // peer is in a call — not just calls I previously joined. const convIds = conversations.map((c) => c.id); const anyActive = useAnyActiveCall(convIds, myId); if (state.kind === 'connected' || state.kind === 'connecting' || state.kind === 'outgoing') { return ; } // Prefer the conversation I just left, fall back to any other live call. const rejoinId = lastCallConversationId ?? anyActive?.conversationId ?? null; if (rejoinId) { return ; } return null; } function ActiveCallBar() { const { t } = useTranslation(['app']); const { state, isMuted, hangup, toggleMute } = useCall(); const { conversations } = useConversationsContext(); const [, forceTick] = useState(0); useEffect(() => { if (state.kind !== 'connected') return; const id = window.setInterval(() => forceTick((v) => v + 1), 1000); return () => window.clearInterval(id); }, [state.kind]); if (state.kind !== 'connected' && state.kind !== 'connecting' && state.kind !== 'outgoing') { return null; } const conv = conversations.find((c) => c.id === state.conversationId) ?? null; const title = conv?.type === 'group' ? conv.name ?? '—' : conv?.peer?.displayName ?? '—'; const statusLabel = state.kind === 'outgoing' ? t('app:call.outgoing_ringing') : state.kind === 'connecting' ? t('app:call.connecting') : t('app:call.voice_connected', { defaultValue: 'Sprachchat verbunden' }); const elapsed = state.kind === 'connected' ? formatElapsed(Date.now() - new Date(state.startedAt).getTime()) : null; return (
{state.kind === 'connecting' ? ( ) : ( )}

{statusLabel}

{title} {elapsed && {elapsed}}

{isMuted ? : } void hangup()} label={t('app:call.hangup')} tone="rose">
); } function RejoinCallBar({ conversationId }: { conversationId: string }) { const { t } = useTranslation(['app', 'common']); const { joinActiveCall, dismissLastCall } = useCall(); const { conversations } = useConversationsContext(); const { session } = useAuth(); const active = useCallPresence(conversationId); const myId = session?.user.id; const others = active.filter((u) => u !== myId); // Presence polling returns [] on the first tick before it syncs, so we can't // treat an initial empty list as "room is empty". Only dismiss after we've // actually seen peers and then watched them leave — and even then confirm // the empty state for a grace period, since presence_diff events can // arrive slightly out of order. const [visible, setVisible] = useState(false); useEffect(() => { if (others.length > 0) { setVisible(true); return; } if (!visible) return; const id = window.setTimeout(() => { setVisible(false); dismissLastCall(); }, 2500); return () => window.clearTimeout(id); }, [others.length, visible, dismissLastCall]); if (!visible) return null; const conv = conversations.find((c) => c.id === conversationId) ?? null; const title = conv?.type === 'group' ? conv.name ?? '—' : conv?.peer?.displayName ?? '—'; return (

{t('app:call.still_live', { defaultValue: 'Anruf läuft noch' })}

{title} · {others.length}

void joinActiveCall(conversationId, 'audio')} label={t('app:call.join')} tone="emerald" >
); } interface IconTileProps { onClick: () => void; label: string; tone: 'neutral' | 'amber' | 'rose' | 'emerald'; disabled?: boolean; children: React.ReactNode; } function IconTile({ onClick, label, tone, disabled, children }: IconTileProps) { const toneClass = tone === 'rose' ? 'bg-white/5 text-rose-300 hover:bg-rose-500/20 focus-visible:ring-rose-400/40' : tone === 'amber' ? 'bg-amber-500/20 text-amber-200 hover:bg-amber-500/30 focus-visible:ring-amber-400/40' : tone === 'emerald' ? 'bg-emerald-500/20 text-emerald-200 hover:bg-emerald-500/30 focus-visible:ring-emerald-400/40' : 'bg-white/5 text-neutral-200 hover:bg-white/10 focus-visible:ring-brand-400/40'; return ( ); } function SignalIcon(props: React.SVGProps) { return ( ); } function formatElapsed(ms: number): string { const total = Math.max(0, Math.floor(ms / 1000)); const mm = Math.floor(total / 60).toString().padStart(2, '0'); const ss = (total % 60).toString().padStart(2, '0'); return mm + ':' + ss; }