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 { 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. export function CallUI() { const { state } = 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. if (state.kind === 'outgoing') ringtone.start('outgoing'); else if (state.kind === 'incoming' && !dnd) ringtone.start('incoming'); else ringtone.stop(); }, [state.kind, 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 (
); } // --------------------------------------------------------------------------- // 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, remoteParticipants, remoteScreenShares, hangup, } = useCall(); const navigate = useNavigate(); const location = useLocation(); const { conversations } = useConversationsContext(); 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 participantCount = 1 + remoteParticipants.length; const someoneSharing = remoteScreenShares.length > 0; // 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-[260px] animate-slide-in-call cursor-pointer items-center gap-2.5 rounded-[14px] border border-accent bg-surface-3 p-2.5 shadow-pip-call transition hover:-translate-y-0.5" >
{someoneSharing ? ( ) : ( )}

{title} · {participantCount}

); } // 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)}`}; }