import React, { createContext, useState, useContext, useMemo, useCallback, ReactNode, useEffect, } from 'react'; import { WidgetApiToWidgetAction, WidgetApiAction, ClientWidgetApi, IWidgetApiRequestData, } from 'matrix-widget-api'; import { useParams } from 'react-router-dom'; import { SmallWidget } from '../../../features/call/SmallWidget'; 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'; const WIDGET_TILE_UPDATE = 'io.element.tile_layout'; interface CallContextState { activeCallRoomId: string | null; setActiveCallRoomId: (roomId: string | null) => void; viewedCallRoomId: string | null; setViewedCallRoomId: (roomId: string | null) => void; hangUp: () => void; activeClientWidgetApi: ClientWidgetApi | null; activeClientWidget: SmallWidget | null; registerActiveClientWidgetApi: ( roomId: string | null, clientWidgetApi: ClientWidgetApi | null, clientWidget: SmallWidget, activeClientIframeRef: HTMLIFrameElement ) => void; sendWidgetAction: ( action: WidgetApiToWidgetAction | string, data: T ) => Promise; isAudioEnabled: boolean; isVideoEnabled: boolean; isChatOpen: boolean; isActiveCallReady: boolean; toggleAudio: () => Promise; toggleVideo: () => Promise; toggleChat: () => Promise; } const CallContext = createContext(undefined); interface CallProviderProps { children: ReactNode; } const DEFAULT_AUDIO_ENABLED = true; const DEFAULT_VIDEO_ENABLED = false; const DEFAULT_CHAT_OPENED = false; export function CallProvider({ children }: CallProviderProps) { const [activeCallRoomId, setActiveCallRoomIdState] = useState(null); const [viewedCallRoomId, setViewedCallRoomIdState] = useState(null); const [activeClientWidgetApi, setActiveClientWidgetApiState] = useState( null ); const [activeClientWidget, setActiveClientWidget] = useState(null); const [activeClientWidgetApiRoomId, setActiveClientWidgetApiRoomId] = useState( null ); const [activeClientWidgetIframeRef, setActiveClientWidgetIframeRef] = useState(null); const [isAudioEnabled, setIsAudioEnabledState] = useState(DEFAULT_AUDIO_ENABLED); const [isVideoEnabled, setIsVideoEnabledState] = useState(DEFAULT_VIDEO_ENABLED); const [isChatOpen, setIsChatOpenState] = useState(DEFAULT_CHAT_OPENED); const [isActiveCallReady, setIsActiveCallReady] = useState(false); const { roomIdOrAlias: viewedRoomId } = useParams<{ roomIdOrAlias: string }>(); const setActiveCallRoomId = useCallback((roomId: string | null) => { setActiveCallRoomIdState(roomId); }, []); const setViewedCallRoomId = useCallback( (roomId: string | null) => { setViewedCallRoomIdState(roomId); }, [setViewedCallRoomIdState] ); const setActiveClientWidgetApi = useCallback( ( clientWidgetApi: ClientWidgetApi | null, clientWidget: SmallWidget | null, roomId: string | null, clientWidgetIframeRef: HTMLIFrameElement | null ) => { setActiveClientWidgetApiState(clientWidgetApi); setActiveClientWidget(clientWidget); setActiveClientWidgetApiRoomId(roomId); setActiveClientWidgetIframeRef(clientWidgetIframeRef); }, [] ); const registerActiveClientWidgetApi = useCallback( ( roomId: string | null, clientWidgetApi: ClientWidgetApi | null, clientWidget: SmallWidget | null, clientWidgetIframeRef: HTMLIFrameElement | null ) => { if (roomId && clientWidgetApi) { setActiveClientWidgetApi(clientWidgetApi, clientWidget, roomId, clientWidgetIframeRef); } else if (roomId === activeClientWidgetApiRoomId || roomId === null) { setActiveClientWidgetApi(null, null, null, null); } }, [activeClientWidgetApiRoomId, setActiveClientWidgetApi] ); const hangUp = useCallback(() => { setActiveClientWidgetApi(null, null, null, null); setActiveCallRoomIdState(null); activeClientWidgetApi?.transport.send(`${WIDGET_HANGUP_ACTION}`, {}); setIsActiveCallReady(false); }, [activeClientWidgetApi?.transport, setActiveClientWidgetApi]); const sendWidgetAction = useCallback( async ( action: WidgetApiToWidgetAction | string, data: T ): Promise => { if (!activeClientWidgetApi) { return Promise.reject(new Error('No active call clientWidgetApi')); } if (!activeClientWidgetApiRoomId || activeClientWidgetApiRoomId !== activeCallRoomId) { return Promise.reject(new Error('Mismatched active call clientWidgetApi')); } await activeClientWidgetApi.transport.send(action as WidgetApiAction, data); return Promise.resolve(); }, [activeClientWidgetApi, activeCallRoomId, activeClientWidgetApiRoomId] ); const toggleAudio = useCallback(async () => { const newState = !isAudioEnabled; setIsAudioEnabledState(newState); if (isActiveCallReady) { try { await sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, { audio_enabled: newState, video_enabled: isVideoEnabled, }); } catch (error) { setIsAudioEnabledState(!newState); throw error; } } }, [isAudioEnabled, isVideoEnabled, sendWidgetAction, isActiveCallReady]); const toggleVideo = useCallback(async () => { const newState = !isVideoEnabled; setIsVideoEnabledState(newState); if (isActiveCallReady) { try { await sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, { audio_enabled: isAudioEnabled, video_enabled: newState, }); } catch (error) { setIsVideoEnabledState(!newState); throw error; } } }, [isVideoEnabled, isAudioEnabled, sendWidgetAction, isActiveCallReady]); useEffect(() => { if (!activeCallRoomId && !viewedCallRoomId) { return; } if (!activeClientWidgetApi) { return; } const handleHangup = (ev: CustomEvent) => { ev.preventDefault(); if (isActiveCallReady && ev.detail.widgetId === activeClientWidgetApi.widget.id) { activeClientWidgetApi.transport.reply(ev.detail, {}); } }; const handleMediaStateUpdate = (ev: CustomEvent) => { if (!isActiveCallReady) return; ev.preventDefault(); /* eslint-disable camelcase */ const { audio_enabled, video_enabled } = ev.detail.data ?? {}; if (typeof audio_enabled === 'boolean' && audio_enabled !== isAudioEnabled) { setIsAudioEnabledState(audio_enabled); } if (typeof video_enabled === 'boolean' && video_enabled !== isVideoEnabled) { setIsVideoEnabledState(video_enabled); } /* eslint-enable camelcase */ }; const handleOnScreenStateUpdate = (ev: CustomEvent) => { ev.preventDefault(); activeClientWidgetApi.transport.reply(ev.detail, {}); }; const handleOnTileLayout = (ev: CustomEvent) => { ev.preventDefault(); activeClientWidgetApi.transport.reply(ev.detail, {}); }; const handleJoin = (ev: CustomEvent) => { ev.preventDefault(); activeClientWidgetApi.transport.reply(ev.detail, {}); const iframeDoc = activeClientWidgetIframeRef?.contentWindow?.document || activeClientWidgetIframeRef?.contentDocument; if (iframeDoc) { const observer = new MutationObserver(() => { const button = iframeDoc.querySelector('[data-testid="incall_leave"]'); if (button) { button.addEventListener('click', () => { hangUp(); }); } observer.disconnect(); }); observer.observe(iframeDoc, { childList: true, subtree: true }); } setIsActiveCallReady(true); }; void sendWidgetAction(WIDGET_MEDIA_STATE_UPDATE_ACTION, { audio_enabled: isAudioEnabled, video_enabled: isVideoEnabled, }).catch(() => { // Widget transport may reject while call/session setup is still in progress. }); activeClientWidgetApi.on(`action:${WIDGET_HANGUP_ACTION}`, handleHangup); activeClientWidgetApi.on(`action:${WIDGET_MEDIA_STATE_UPDATE_ACTION}`, handleMediaStateUpdate); activeClientWidgetApi.on(`action:${WIDGET_TILE_UPDATE}`, handleOnTileLayout); activeClientWidgetApi.on(`action:${WIDGET_ON_SCREEN_ACTION}`, handleOnScreenStateUpdate); activeClientWidgetApi.on(`action:${WIDGET_JOIN_ACTION}`, handleJoin); }, [ activeClientWidgetIframeRef, activeClientWidgetApi, activeCallRoomId, activeClientWidgetApiRoomId, hangUp, isChatOpen, isAudioEnabled, isVideoEnabled, isActiveCallReady, viewedRoomId, viewedCallRoomId, setViewedCallRoomId, activeClientWidget?.iframe?.contentDocument, activeClientWidget?.iframe?.contentWindow?.document, sendWidgetAction, ]); const toggleChat = useCallback(async () => { const newState = !isChatOpen; setIsChatOpenState(newState); }, [isChatOpen]); const contextValue = useMemo( () => ({ activeCallRoomId, setActiveCallRoomId, viewedCallRoomId, setViewedCallRoomId, hangUp, activeClientWidgetApi, registerActiveClientWidgetApi, activeClientWidget, sendWidgetAction, isChatOpen, isAudioEnabled, isVideoEnabled, isActiveCallReady, toggleAudio, toggleVideo, toggleChat, }), [ activeCallRoomId, setActiveCallRoomId, viewedCallRoomId, setViewedCallRoomId, hangUp, activeClientWidgetApi, registerActiveClientWidgetApi, activeClientWidget, sendWidgetAction, isChatOpen, isAudioEnabled, isVideoEnabled, isActiveCallReady, toggleAudio, toggleVideo, toggleChat, ] ); 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; }