import { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { CallService } from './CallService'; import { ActiveCall, CallEvent, CallEventType, CallState, CallType } from './types'; import { fetchWellKnownWithRTC, getLiveKitFocus, getLiveKitHomeserverPriority } from './useRtcConfig'; interface CallContextValue { callService: CallService | null; activeCall: ActiveCall | null; callSupported: boolean; callSupportLoading: boolean; startCall: (roomId: string, callType: CallType) => Promise; endCall: () => Promise; toggleMute: () => boolean; toggleDeafen: () => boolean; toggleVideo: () => boolean; toggleScreenShare: () => Promise; } const CallContext = createContext(null); /** * Global reference to CallService for use outside React components * (e.g., from Electron IPC handlers) */ let globalCallServiceInstance: CallService | null = null; /** * Get the global CallService instance * @returns The CallService instance or null if not initialized */ export function getCallService(): CallService | null { return globalCallServiceInstance; } /** * Set the global CallService instance * @param service - The CallService instance */ function setGlobalCallService(service: CallService | null): void { globalCallServiceInstance = service; } /** * Hook to access the call context * @returns The call context value */ export function useCall(): CallContextValue { const context = useContext(CallContext); if (!context) { throw new Error('useCall must be used within a CallProvider'); } return context; } export const CallProvider = CallContext.Provider; /** * Hook to initialize and manage the call service * Should be used at the app level to provide call context */ export function useCallService(): CallContextValue { const mx = useMatrixClient(); const [callService, setCallService] = useState(null); const [activeCall, setActiveCall] = useState(null); const [callSupported, setCallSupported] = useState(false); const [callSupportLoading, setCallSupportLoading] = useState(true); useEffect(() => { let unsubscribe: (() => void) | undefined; let service: CallService | undefined; const initCallService = async () => { const baseUrl = mx.getHomeserverUrl(); const accessToken = mx.getAccessToken(); const userId = mx.getSafeUserId(); const deviceId = mx.getDeviceId(); if (!accessToken || !deviceId) { setCallSupportLoading(false); return; } try { // Initialize CallService without checking user's homeserver // Call support will be determined per-room based on available homeservers console.log('Call service init - Initializing (call support determined per-room)'); service = new CallService( { homeserverBaseUrl: baseUrl, accessToken, userId, deviceId, }, mx ); setCallService(service); setGlobalCallService(service); // Make available globally for Paarrot API setCallSupported(true); // Global flag - actual support checked per-room setCallSupportLoading(false); // eslint-disable-next-line no-console console.log('Call service init - Success!'); unsubscribe = service.on((event: CallEvent) => { // Update state for any event that might change the call state if (event.type === CallEventType.StateChanged || event.type === CallEventType.ScreenShareStarted || event.type === CallEventType.ScreenShareStopped || event.type === CallEventType.RemoteStreamAdded || event.type === CallEventType.RemoteStreamRemoved || event.type === CallEventType.ParticipantJoined || event.type === CallEventType.ParticipantLeft || event.type === CallEventType.SpeakersChanged) { const newCall = service?.getActiveCall(); if (newCall) { // Deep copy Maps and Sets to ensure React detects changes setActiveCall({ ...newCall, remoteStreams: new Map(newCall.remoteStreams), remoteScreenTracks: new Map(newCall.remoteScreenTracks), remoteScreenStreams: new Map(newCall.remoteScreenStreams), activeSpeakers: new Set(newCall.activeSpeakers), }); } else { setActiveCall(null); } } }); } catch (error) { // eslint-disable-next-line no-console console.error('Call service init - Error:', error); setCallSupported(false); setCallSupportLoading(false); } }; initCallService(); return () => { unsubscribe?.(); service?.dispose(); setGlobalCallService(null); // Clear global reference }; }, [mx]); const startCall = useCallback( async (roomId: string, callType: CallType) => { if (!callService) { throw new Error('Call service not initialized'); } await callService.startCall(roomId, callType); }, [callService] ); const endCall = useCallback(async () => { if (!callService) { return; } await callService.endCall(); }, [callService]); const toggleMute = useCallback(() => { if (!callService) { return false; } const newState = callService.toggleMute(); const call = callService.getActiveCall(); if (call) { setActiveCall({ ...call }); } return newState; }, [callService]); const toggleDeafen = useCallback(() => { if (!callService) { return false; } const newState = callService.toggleDeafen(); const call = callService.getActiveCall(); if (call) { setActiveCall({ ...call }); } return newState; }, [callService]); const toggleVideo = useCallback(() => { if (!callService) { return false; } const newState = callService.toggleVideo(); const call = callService.getActiveCall(); if (call) { setActiveCall({ ...call }); } return newState; }, [callService]); const toggleScreenShare = useCallback(async () => { if (!callService) { return false; } const newState = await callService.toggleScreenShare(); const call = callService.getActiveCall(); if (call) { setActiveCall({ ...call }); } return newState; }, [callService]); return useMemo( () => ({ callService, activeCall, callSupported, callSupportLoading, startCall, endCall, toggleMute, toggleDeafen, toggleVideo, toggleScreenShare, }), [callService, activeCall, callSupported, callSupportLoading, startCall, endCall, toggleMute, toggleDeafen, toggleVideo, toggleScreenShare] ); } /** * Hook to check if a call is active in a specific room */ /** Persists across keep-alive remounts so room switches don't re-hit well-known. */ const roomCallSupportCache = new Map(); export function useRoomCall(roomId: string) { const { activeCall, startCall, endCall, callSupported: globalCallSupported, callSupportLoading: globalCallSupportLoading, callService } = useCall(); const mx = useMatrixClient(); const [roomCallSupported, setRoomCallSupported] = useState( () => roomCallSupportCache.get(roomId) ?? false ); const [roomCallSupportLoading, setRoomCallSupportLoading] = useState( () => !roomCallSupportCache.has(roomId) ); // Check if calls are supported for this specific room by checking all priority homeservers useEffect(() => { const cached = roomCallSupportCache.get(roomId); if (cached !== undefined) { setRoomCallSupported(cached); setRoomCallSupportLoading(false); return undefined; } let cancelled = false; const checkRoomCallSupport = async () => { setRoomCallSupportLoading(true); const room = mx.getRoom(roomId); if (!room) { if (!cancelled) { setRoomCallSupported(false); setRoomCallSupportLoading(false); } return; } const homeserverPriority = getLiveKitHomeserverPriority( room, mx.getHomeserverUrl(), mx.getSafeUserId() ); for (const homeserver of homeserverPriority) { try { const wellKnown = await fetchWellKnownWithRTC(homeserver); const livekitFocus = getLiveKitFocus(wellKnown); if (livekitFocus) { roomCallSupportCache.set(roomId, true); if (!cancelled) { setRoomCallSupported(true); setRoomCallSupportLoading(false); } return; } } catch { // Continue to next homeserver } } roomCallSupportCache.set(roomId, false); if (!cancelled) { setRoomCallSupported(false); setRoomCallSupportLoading(false); } }; checkRoomCallSupport(); return () => { cancelled = true; }; }, [roomId, mx]); const isInCall = activeCall?.roomId === roomId; const callState = isInCall ? activeCall.state : CallState.Idle; const isUsingRemoteHomeserver = isInCall ? callService?.isUsingRemoteHomeserver() ?? false : false; // Check if a remote homeserver WOULD be used (pre-call check) const wouldUseRemoteHomeserver = useMemo(() => { if (isInCall) return isUsingRemoteHomeserver; const room = mx.getRoom(roomId); if (!room) return false; const homeserverPriority = getLiveKitHomeserverPriority( room, mx.getHomeserverUrl(), mx.getSafeUserId() ); // Check if first priority is different from user's homeserver const userHomeserverDomain = mx.getHomeserverUrl() .replace('https://', '') .replace('http://', ''); const firstPriorityDomain = homeserverPriority[0] ?.replace('https://', '') .replace('http://', ''); return firstPriorityDomain !== userHomeserverDomain; }, [isInCall, isUsingRemoteHomeserver, mx, roomId]); const startVoiceCall = useCallback(() => startCall(roomId, CallType.Voice), [roomId, startCall]); const startVideoCall = useCallback(() => startCall(roomId, CallType.Video), [roomId, startCall]); return { isInCall, callState, activeCall: isInCall ? activeCall : null, isUsingRemoteHomeserver: wouldUseRemoteHomeserver, startVoiceCall, startVideoCall, endCall, callSupported: roomCallSupported, callSupportLoading: roomCallSupportLoading, }; }