import React, { createContext, useState, useContext, useMemo, useCallback, ReactNode, useEffect, } from 'react'; import { logger } from 'matrix-js-sdk/lib/logger'; import { WidgetApiToWidgetAction, WidgetApiAction, ClientWidgetApi } from 'matrix-widget-api'; import { useParams } from 'react-router-dom'; interface MediaStatePayload { data?: { audio_enabled?: boolean; video_enabled?: boolean; }; } const WIDGET_MEDIA_STATE_UPDATE_ACTION = 'io.element.device_mute'; const WIDGET_HANGUP_ACTION = 'im.vector.hangup'; const WIDGET_ON_SCREEN_ACTION = 'set_always_on_screen'; const WIDGET_JOIN_ACTION = 'io.element.join'; interface CallContextState { activeCallRoomId: string | null; setActiveCallRoomId: (roomId: string | null) => void; viewedCallRoomId: string | null; setViewedCallRoomId: (roomId: string | null) => void; hangUp: () => void; activeClientWidgetApi: ClientWidgetApi | null; registerActiveClientWidgetApi: ( roomId: string | null, clientWidgetApi: ClientWidgetApi | null ) => void; registerViewedClientWidgetApi: ( roomId: string | null, clientWidgetApi: ClientWidgetApi | null ) => void; sendWidgetAction: ( action: WidgetApiToWidgetAction | string, data: T ) => Promise; isAudioEnabled: boolean; isVideoEnabled: boolean; isChatOpen: boolean; isCallActive: boolean; isPrimaryIframe: boolean; toggleAudio: () => Promise; toggleVideo: () => Promise; toggleChat: () => Promise; toggleIframe: () => Promise; } const CallContext = createContext(undefined); interface CallProviderProps { children: ReactNode; } const DEFAULT_AUDIO_ENABLED = true; const DEFAULT_VIDEO_ENABLED = false; const DEFAULT_CHAT_OPENED = false; const DEFAULT_CALL_ACTIVE = false; const DEFAULT_PRIMARY_IFRAME = true; export function CallProvider({ children }: CallProviderProps) { const [activeCallRoomId, setActiveCallRoomIdState] = useState(null); const [viewedCallRoomId, setViewedCallRoomIdState] = useState(null); const [activeClientWidgetApi, setActiveClientWidgetApiState] = useState( null ); const [activeClientWidgetApiRoomId, setActiveClientWidgetApiRoomId] = useState( null ); const [viewedClientWidgetApi, setViewedClientWidgetApiState] = useState( null ); const [viewedClientWidgetApiRoomId, setViewedClientWidgetApiRoomId] = useState( null ); const [isAudioEnabled, setIsAudioEnabledState] = useState(DEFAULT_AUDIO_ENABLED); const [isVideoEnabled, setIsVideoEnabledState] = useState(DEFAULT_VIDEO_ENABLED); const [isChatOpen, setIsChatOpenState] = useState(DEFAULT_CHAT_OPENED); const [isCallActive, setIsCallActive] = useState(DEFAULT_CALL_ACTIVE); const [isPrimaryIframe, setIsPrimaryIframe] = useState(DEFAULT_PRIMARY_IFRAME); const { roomIdOrAlias: viewedRoomId } = useParams<{ roomIdOrAlias: string }>(); const resetMediaState = useCallback(() => { logger.debug('CallContext: Resetting media state to defaults.'); setIsAudioEnabledState(DEFAULT_AUDIO_ENABLED); setIsVideoEnabledState(DEFAULT_VIDEO_ENABLED); }, []); const setActiveCallRoomId = useCallback( (roomId: string | null) => { logger.warn(`CallContext: Setting activeCallRoomId to ${roomId}`); const previousRoomId = activeCallRoomId; setActiveCallRoomIdState(roomId); if (roomId !== previousRoomId) { logger.debug(`CallContext: Active call room changed, resetting media state.`); resetMediaState(); } if (roomId === null || roomId !== activeClientWidgetApiRoomId) { logger.warn( `CallContext: Clearing active clientWidgetApi because active room changed to ${roomId} or was cleared.` ); } }, [activeClientWidgetApiRoomId, resetMediaState, activeCallRoomId] ); const setViewedCallRoomId = useCallback( (roomId: string | null) => { logger.warn(`CallContext: Setting activeCallRoomId to ${roomId}`); setViewedCallRoomIdState(roomId); }, [setViewedCallRoomIdState] ); const hangUp = useCallback(() => { logger.debug(`CallContext: Hang up called.`); activeClientWidgetApi?.transport.send(`${WIDGET_HANGUP_ACTION}`, {}); viewedClientWidgetApi?.transport.send(`${WIDGET_HANGUP_ACTION}`, {}); setActiveCallRoomIdState(null); setIsCallActive(false); }, [activeClientWidgetApi?.transport, viewedClientWidgetApi?.transport]); const setActiveClientWidgetApi = useCallback( (clientWidgetApi: ClientWidgetApi | null, roomId: string | null) => { setActiveClientWidgetApiState(clientWidgetApi); setActiveClientWidgetApiRoomId(roomId); }, [] ); const registerActiveClientWidgetApi = useCallback( (roomId: string | null, clientWidgetApi: ClientWidgetApi | null) => { if (activeClientWidgetApi && activeClientWidgetApi !== clientWidgetApi) { logger.debug(`CallContext: Cleaning up listeners for previous clientWidgetApi instance.`); activeClientWidgetApi.removeAllListeners(); } if (roomId && clientWidgetApi) { logger.debug(`CallContext: Registering active clientWidgetApi for room ${roomId}.`); setActiveClientWidgetApi(clientWidgetApi, roomId); } else if (roomId === activeClientWidgetApiRoomId || roomId === null) { setActiveClientWidgetApi(null, null); resetMediaState(); } }, [activeClientWidgetApi, activeClientWidgetApiRoomId, setActiveClientWidgetApi, resetMediaState] ); const setViewedClientWidgetApi = useCallback( (clientWidgetApi: ClientWidgetApi | null, roomId: string | null) => { setViewedClientWidgetApiState(clientWidgetApi); setViewedClientWidgetApiRoomId(roomId); }, [] ); const registerViewedClientWidgetApi = useCallback( (roomId: string | null, clientWidgetApi: ClientWidgetApi | null) => { if (viewedClientWidgetApi && viewedClientWidgetApi !== clientWidgetApi) { logger.debug(`CallContext: Cleaning up listeners for previous clientWidgetApi instance.`); viewedClientWidgetApi.removeAllListeners(); viewedClientWidgetApi.stop(); } if (roomId && clientWidgetApi) { logger.debug(`CallContext: Registering viewed clientWidgetApi for room ${roomId}.`); setViewedClientWidgetApi(clientWidgetApi, roomId); } else if (roomId === viewedClientWidgetApiRoomId || roomId === null) { logger.debug( `CallContext: Clearing viewed clientWidgetApi for room ${viewedClientWidgetApiRoomId}.` ); setViewedClientWidgetApi(null, null); } }, [viewedClientWidgetApi, viewedClientWidgetApiRoomId, setViewedClientWidgetApi] ); useEffect(() => { if (!activeCallRoomId && !viewedCallRoomId) { return; } const handleHangup = (ev: CustomEvent) => { ev.preventDefault(); if (ev.detail.widgetId === activeClientWidgetApi?.widget.id) { activeClientWidgetApi?.transport.reply(ev.detail, {}); setIsCallActive(false); } else if (ev.detail.widgetId === viewedClientWidgetApi?.widget.id) { viewedClientWidgetApi?.transport.reply(ev.detail, {}); setIsCallActive(false); } logger.warn( `CallContext: Received hangup action from widget in room ${activeCallRoomId}.`, ev ); }; const handleMediaStateUpdate = (ev: CustomEvent) => { ev.preventDefault(); logger.debug( `CallContext: Received media state update from widget in room ${activeCallRoomId}:`, ev.detail ); /* eslint-disable camelcase */ const { audio_enabled, video_enabled } = ev.detail.data ?? {}; if (typeof audio_enabled === 'boolean' && audio_enabled !== isAudioEnabled) { logger.debug(`CallContext: Updating audio enabled state from widget: ${audio_enabled}`); setIsAudioEnabledState(audio_enabled); } if (typeof video_enabled === 'boolean' && video_enabled !== isVideoEnabled) { logger.debug(`CallContext: Updating video enabled state from widget: ${video_enabled}`); setIsVideoEnabledState(video_enabled); } /* eslint-enable camelcase */ }; const handleOnScreenStateUpdate = (ev: CustomEvent) => { ev.preventDefault(); if (isPrimaryIframe) { activeClientWidgetApi?.transport.reply(ev.detail, {}); } else { viewedClientWidgetApi?.transport.reply(ev.detail, {}); } }; const handleJoin = (ev: CustomEvent) => { ev.preventDefault(); activeClientWidgetApi?.transport.reply(ev.detail, {}); if (ev.detail.widgetId === activeClientWidgetApi?.widget.id) { setIsCallActive(true); return; } if (isCallActive && activeClientWidgetApi && viewedClientWidgetApi) { activeClientWidgetApi?.removeAllListeners(); activeClientWidgetApi?.transport.send(WIDGET_HANGUP_ACTION, {}).then(() => { setActiveClientWidgetApi(viewedClientWidgetApi, viewedCallRoomId); setActiveCallRoomIdState(viewedCallRoomId); setViewedClientWidgetApi(null, null); setIsPrimaryIframe(!isPrimaryIframe); setIsCallActive(true); }); } else { setIsCallActive(true); } }; logger.debug( `CallContext: Setting up listeners for clientWidgetApi in room ${activeCallRoomId}` ); activeClientWidgetApi?.on(`action:${WIDGET_HANGUP_ACTION}`, handleHangup); activeClientWidgetApi?.on(`action:${WIDGET_MEDIA_STATE_UPDATE_ACTION}`, handleMediaStateUpdate); activeClientWidgetApi?.on(`action:${WIDGET_ON_SCREEN_ACTION}`, handleOnScreenStateUpdate); activeClientWidgetApi?.on(`action:${WIDGET_JOIN_ACTION}`, handleJoin); viewedClientWidgetApi?.on(`action:${WIDGET_JOIN_ACTION}`, handleJoin); viewedClientWidgetApi?.on(`action:${WIDGET_MEDIA_STATE_UPDATE_ACTION}`, handleMediaStateUpdate); viewedClientWidgetApi?.on(`action:${WIDGET_ON_SCREEN_ACTION}`, handleOnScreenStateUpdate); viewedClientWidgetApi?.on(`action:${WIDGET_HANGUP_ACTION}`, handleHangup); }, [ activeClientWidgetApi, activeCallRoomId, activeClientWidgetApiRoomId, hangUp, isChatOpen, isAudioEnabled, isVideoEnabled, isCallActive, viewedRoomId, viewedClientWidgetApi, isPrimaryIframe, viewedCallRoomId, setViewedClientWidgetApi, setActiveClientWidgetApi, ]); const sendWidgetAction = useCallback( async (action: WidgetApiToWidgetAction | string, data: T): Promise => { if (!activeClientWidgetApi) { logger.warn( `CallContext: Cannot send action '${action}', no active API clientWidgetApi registered.` ); return Promise.reject(new Error('No active call clientWidgetApi')); } if (!activeClientWidgetApiRoomId || activeClientWidgetApiRoomId !== activeCallRoomId) { logger.debug( `CallContext: Cannot send action '${action}', clientWidgetApi room (${activeClientWidgetApiRoomId}) does not match active call room (${activeCallRoomId}). Stale clientWidgetApi?` ); return Promise.reject(new Error('Mismatched active call clientWidgetApi')); } try { logger.debug( `CallContext: Sending action '${action}' via active clientWidgetApi (room: ${activeClientWidgetApiRoomId}) with data:`, data ); await activeClientWidgetApi.transport.send(action as WidgetApiAction, data); } catch (error) { logger.error(`CallContext: Error sending action '${action}':`, error); throw error; } }, [activeClientWidgetApi, activeCallRoomId, activeClientWidgetApiRoomId] ); const toggleAudio = useCallback(async () => { const newState = !isAudioEnabled; logger.debug(`CallContext: Toggling audio. New state: enabled=${newState}`); setIsAudioEnabledState(newState); try { await sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, { audio_enabled: newState, video_enabled: isVideoEnabled, }); logger.debug(`CallContext: Successfully sent audio toggle action.`); } catch (error) { logger.error(`CallContext: Failed to send audio toggle action. Reverting state.`, error); setIsAudioEnabledState(!newState); throw error; } }, [isAudioEnabled, isVideoEnabled, sendWidgetAction]); const toggleVideo = useCallback(async () => { const newState = !isVideoEnabled; logger.debug(`CallContext: Toggling video. New state: enabled=${newState}`); setIsVideoEnabledState(newState); try { await sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, { audio_enabled: isAudioEnabled, video_enabled: newState, }); logger.debug(`CallContext: Successfully sent video toggle action.`); } catch (error) { logger.error(`CallContext: Failed to send video toggle action. Reverting state.`, error); setIsVideoEnabledState(!newState); throw error; } }, [isVideoEnabled, isAudioEnabled, sendWidgetAction]); const toggleChat = useCallback(async () => { const newState = !isChatOpen; setIsChatOpenState(newState); }, [isChatOpen]); const toggleIframe = useCallback(async () => { const newState = !isPrimaryIframe; setIsPrimaryIframe(newState); }, [isPrimaryIframe]); const contextValue = useMemo( () => ({ activeCallRoomId, setActiveCallRoomId, viewedCallRoomId, setViewedCallRoomId, hangUp, activeClientWidgetApi, registerActiveClientWidgetApi, registerViewedClientWidgetApi, sendWidgetAction, isChatOpen, isAudioEnabled, isVideoEnabled, isCallActive, isPrimaryIframe, toggleAudio, toggleVideo, toggleChat, toggleIframe, }), [ activeCallRoomId, setActiveCallRoomId, viewedCallRoomId, setViewedCallRoomId, hangUp, activeClientWidgetApi, registerActiveClientWidgetApi, registerViewedClientWidgetApi, sendWidgetAction, isChatOpen, isAudioEnabled, isVideoEnabled, isCallActive, isPrimaryIframe, toggleAudio, toggleVideo, toggleChat, toggleIframe, ] ); return {children}; } export function useCallState(): CallContextState { const context = useContext(CallContext); if (context === undefined) { throw new Error('useCallState must be used within a CallProvider'); } return context; }