import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } 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 { useActiveSpeakers } from '../lib/useActiveSpeakers'; import { Avatar } from './Avatar'; import { AvatarColorKey, colorKeyFor } from './CallParticipantTile'; import { LockIcon, MonitorShareIcon, PhoneIcon, PhoneOffIcon } from './icons'; // Shell-level mount. Responsibilities: // - Start/stop ringtones by call-state transition. // - Toast-style incoming-call banner for conversations the user isn't in // (clicking the toast routes to the docked IncomingCallPanel). // - PiP widget when an active call is live but the user is looking at a // different route. // - Top-center pending-incoming toast for Discord-style second-call ringer. export function CallUI() { const { state, pendingIncoming } = useCall(); const { profile } = useAuth(); const dnd = profile?.presenceState === 'dnd'; useEffect(() => { // DND silences only the *incoming* ring — outgoing stays audible because // the user initiated that call themselves. The incoming-call panel still // appears visually; only the audible ring is suppressed. The pending // second-call ringer plays the same incoming sound at the same volume: // user explicitly asked for parity with the normal ring. if (state.kind === 'outgoing') ringtone.start('outgoing'); else if (state.kind === 'incoming' && !dnd) ringtone.start('incoming'); else if (pendingIncoming && !dnd) ringtone.start('incoming'); else ringtone.stop(); }, [state.kind, pendingIncoming, dnd]); useEffect(() => { return () => ringtone.stop(); }, []); return ( <> ); } // --------------------------------------------------------------------------- const TOAST_AVATAR: Record = { violet: 'bg-violet-500/90 text-white', amber: 'bg-amber-500/90 text-white', rose: 'bg-rose-500/90 text-white', teal: 'bg-teal-500/90 text-white', }; function IncomingCallToast() { const { t } = useTranslation(['app']); const { state, acceptIncoming, rejectIncoming } = useCall(); const { friendships } = useFriendshipsContext(); const { conversations } = useConversationsContext(); const navigate = useNavigate(); const location = useLocation(); if (state.kind !== 'incoming') return null; // When the user is already looking at the target conversation, the docked // IncomingCallPanel handles the UI; hide the toast to avoid double chrome. if (location.pathname === '/chats/' + state.conversationId) 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 title = isGroup ? groupName ?? callerName : callerName; const letter = title.trim().charAt(0).toUpperCase() || '?'; const color = colorKeyFor(state.fromUserId); return (
); } // --------------------------------------------------------------------------- // Discord-style "second-call" ringer — pops at the top centre while we're // already in another call. Accepting hangs up the active call (handled in // CallContext.acceptPendingIncoming) then routes to the new conversation. // --------------------------------------------------------------------------- function PendingIncomingToast() { const { t } = useTranslation(['app']); const { pendingIncoming, acceptPendingIncoming, rejectPendingIncoming } = useCall(); const { friendships } = useFriendshipsContext(); const { conversations } = useConversationsContext(); const navigate = useNavigate(); if (!pendingIncoming) return null; const conv = conversations.find((c) => c.id === pendingIncoming.conversationId) ?? null; const callerName = conv?.members.find((m) => m.userId === pendingIncoming.fromUserId)?.profile?.displayName ?? friendships.find((f) => f.peer.userId === pendingIncoming.fromUserId)?.peer.displayName ?? '?'; const isGroup = conv?.type === 'group'; const groupName = isGroup ? (conv?.name ?? t('app:chats.new_group')) : null; const title = isGroup ? groupName ?? callerName : callerName; const letter = title.trim().charAt(0).toUpperCase() || '?'; const color = colorKeyFor(pendingIncoming.fromUserId); const targetConversationId = pendingIncoming.conversationId; return (
{letter}

{t('app:call.incoming_while_busy', { defaultValue: 'Anderer Anruf eingehend', })}

{title}

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

); } // --------------------------------------------------------------------------- // PiP widget — shown when the user has an active call but is browsing // somewhere else. Clicking expands back to the call's conversation. // --------------------------------------------------------------------------- function PipCall() { const { t } = useTranslation(['app']); const { state, room, remoteParticipants, remoteScreenShares, hangup, } = useCall(); const navigate = useNavigate(); const location = useLocation(); const { conversations } = useConversationsContext(); const { profile } = useAuth(); // Active-speakers hook drives the green ring on mini-avatars so the user // can spot who's talking from the PiP without expanding back to the call. const activeSpeakers = useActiveSpeakers(room); const active = state.kind === 'connected' || state.kind === 'connecting' || state.kind === 'reconnecting' || state.kind === 'outgoing'; if (!active) return null; // Only render when the user is NOT currently viewing the call's // conversation. Inside that conversation the full dock is visible already. if (location.pathname === '/chats/' + state.conversationId) return null; const conv = conversations.find((c) => c.id === state.conversationId) ?? null; const title = conv?.type === 'group' ? conv?.name ?? t('app:chats.new_group') : conv?.peer?.displayName ?? '—'; const someoneSharing = remoteScreenShares.length > 0; // Discord-style mini-grid: collect actual present participants (self + // joined remotes), match with profile data from the conversation roster // for avatars/displayName. Show up to MAX, plus a "+N" overflow chip. const MAX_TILES = 4; const myId = profile?.userId ?? null; const presentIds: string[] = []; if (myId) presentIds.push(myId); for (const rp of remoteParticipants) { if (rp.identity && !presentIds.includes(rp.identity)) presentIds.push(rp.identity); } const tileIds = presentIds.slice(0, MAX_TILES); const overflow = Math.max(0, presentIds.length - MAX_TILES); // Duration ticks while connected or reconnecting (LiveKit holds the room // across reconnects, so the timer shouldn't reset on a wobble). Absent // on outgoing/connecting where the call hasn't started yet. const startedAt = state.kind === 'connected' || state.kind === 'reconnecting' ? state.startedAt : null; return (
navigate('/chats/' + state.conversationId)} className="fixed bottom-5 right-5 z-40 flex w-[300px] motion-safe:animate-slide-in-call cursor-pointer flex-col gap-2 rounded-[14px] border border-accent bg-surface-3 p-2.5 shadow-pip-call transition hover:-translate-y-0.5" >
{someoneSharing ? ( ) : ( )}

{title}

{tileIds.map((id) => { const member = conv?.members.find((m) => m.userId === id); const isMe = id === myId; const name = isMe ? profile?.displayName ?? '?' : member?.profile?.displayName ?? '?'; const avatarUrl = isMe ? profile?.avatarUrl ?? null : member?.profile?.avatarUrl ?? null; const speaking = activeSpeakers.has(id); return (
); })} {overflow > 0 && ( +{overflow} )}
); } // Live-ticking `mm:ss` / `hh:mm:ss` for the PiP. Duplicated from InCallPanel // deliberately — the two widgets have different typography + tabular // contexts, and extracting a shared component would be heavier than the // 8-line countup it replaces. function PipDuration({ startedAt }: { startedAt: string }) { const [, tick] = useState(0); useEffect(() => { const id = window.setInterval(() => tick((v) => v + 1), 1000); return () => window.clearInterval(id); }, []); const total = Math.max( 0, Math.floor((Date.now() - new Date(startedAt).getTime()) / 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 <>{hh > 0 ? `${hh}:${pad(mm)}:${pad(ss)}` : `${pad(mm)}:${pad(ss)}`}; }