From 58a7e18b84467e9b23a6c25a0136fe8437952bad Mon Sep 17 00:00:00 2001 From: gabriellsh Date: Tue, 28 Jul 2026 20:04:34 -0300 Subject: [PATCH 01/10] refactor: replace useReducer with useSyncExternalStore, control widget visibility and peer selection on instance provider --- .../src/context/MediaCallInstanceContext.ts | 32 ++- .../src/context/MediaCallViewContext.ts | 4 +- packages/ui-voip/src/context/definitions.d.ts | 4 +- .../src/context/useWidgetExternalControls.ts | 10 +- .../providers/MediaCallInstanceProvider.tsx | 33 ++- .../src/providers/MediaCallViewProvider.tsx | 38 +-- .../ui-voip/src/providers/useMediaSession.ts | 261 ------------------ .../useMediaSessionStateSubscription.ts | 202 ++++++++++++++ .../src/providers/usePersistedSessionState.ts | 46 +++ .../views/MediaCallWidget/MediaCallWidget.tsx | 10 +- .../MediaCallWidgetViewRouter.tsx | 6 +- .../src/views/MediaCallWidget/NewCall.tsx | 4 +- 12 files changed, 341 insertions(+), 309 deletions(-) delete mode 100644 packages/ui-voip/src/providers/useMediaSession.ts create mode 100644 packages/ui-voip/src/providers/useMediaSessionStateSubscription.ts create mode 100644 packages/ui-voip/src/providers/usePersistedSessionState.ts diff --git a/packages/ui-voip/src/context/MediaCallInstanceContext.ts b/packages/ui-voip/src/context/MediaCallInstanceContext.ts index 72eced82fa6b7..79ac4d8585daa 100644 --- a/packages/ui-voip/src/context/MediaCallInstanceContext.ts +++ b/packages/ui-voip/src/context/MediaCallInstanceContext.ts @@ -5,6 +5,8 @@ import { createContext, useContext } from 'react'; import type { PeerAutocompleteOptions } from '../components'; import type { PeerInfo } from './definitions'; +import type { MediaSessionStateSubscription } from '../providers/useMediaSessionStateSubscription'; +import { defaultSessionInfo } from '../providers/useMediaSessionStateSubscription'; export type Signals = { toggleWidget: { peerInfo?: PeerInfo }; @@ -15,19 +17,30 @@ export type AvailableViews = 'room' | 'popout' | 'widget'; type RegisterView = (view: AvailableViews) => void; type UnregisterView = (view: AvailableViews) => void; +type WidgetVisibility = 'open' | 'closed'; + type MediaCallInstanceContextValue = { instance: MediaSignalingSession | undefined; signalEmitter: Emitter; audioElement: RefObject | undefined; openRoomId: string | undefined; + currentViews: AvailableViews[]; - setOpenRoomId: (openRoomId: string | undefined) => void; - getAutocompleteOptions: (filter: string) => Promise; registerView: RegisterView; unregisterView: UnregisterView; + + stateSubscription: MediaSessionStateSubscription; + widgetVisibility: WidgetVisibility; + targetPeer: PeerInfo | undefined; + + setOpenRoomId: (openRoomId: string | undefined) => void; + getAutocompleteOptions: (filter: string) => Promise; + openWidget: (peerInfo?: PeerInfo) => void; + closeWidget: () => void; + setTargetPeer: (peerInfo?: PeerInfo) => void; }; -export const MediaCallInstanceContext = createContext({ +export const defaultContextValue = { instance: undefined, signalEmitter: new Emitter(), audioElement: undefined, @@ -37,6 +50,17 @@ export const MediaCallInstanceContext = createContext undefined, unregisterView: () => undefined, -}); + openWidget: () => undefined, + closeWidget: () => undefined, + setTargetPeer: () => undefined, + widgetVisibility: 'closed' as const, + targetPeer: undefined, + stateSubscription: { + subscribe: () => () => undefined, + getSnapshot: () => defaultSessionInfo, + }, +}; + +export const MediaCallInstanceContext = createContext(defaultContextValue); export const useMediaCallInstance = (): MediaCallInstanceContextValue => useContext(MediaCallInstanceContext); diff --git a/packages/ui-voip/src/context/MediaCallViewContext.ts b/packages/ui-voip/src/context/MediaCallViewContext.ts index 29ebe544c1324..fc46d084d673b 100644 --- a/packages/ui-voip/src/context/MediaCallViewContext.ts +++ b/packages/ui-voip/src/context/MediaCallViewContext.ts @@ -12,6 +12,7 @@ export type MediaCallStreams = { type MediaCallViewContextValue = { sessionState: SessionState; + targetPeer?: PeerInfo; onClickDirectMessage?: () => void; onMute: () => void; onHold: () => void; @@ -33,7 +34,7 @@ type MediaCallViewContextValue = { }; const defaultSessionState: SessionState = { - state: 'closed', + state: 'none', connectionState: 'CONNECTED', peerInfo: undefined, transferredBy: undefined, @@ -48,6 +49,7 @@ const defaultSessionState: SessionState = { export const defaultMediaCallContextValue: MediaCallViewContextValue = { sessionState: defaultSessionState, + targetPeer: undefined, onMute: () => undefined, onHold: () => undefined, onDeviceChange: () => undefined, diff --git a/packages/ui-voip/src/context/definitions.d.ts b/packages/ui-voip/src/context/definitions.d.ts index 495bf5cf253fb..8c38e3cd6b350 100644 --- a/packages/ui-voip/src/context/definitions.d.ts +++ b/packages/ui-voip/src/context/definitions.d.ts @@ -18,7 +18,7 @@ export type ConnectionState = 'CONNECTED' | 'CONNECTING' | 'RECONNECTING'; export type PeerInfo = InternalPeerInfo | ExternalPeerInfo; -export type State = 'closed' | 'new' | 'calling' | 'ringing' | 'ongoing'; +export type State = 'none' | 'calling' | 'ringing' | 'ongoing'; interface IBaseSession { state: State; @@ -35,7 +35,7 @@ interface IBaseSession { } interface IEmptySession extends IBaseSession { - state: Extract; + state: Extract; callId: undefined; } diff --git a/packages/ui-voip/src/context/useWidgetExternalControls.ts b/packages/ui-voip/src/context/useWidgetExternalControls.ts index 82aad94b9e650..129b761b4f95b 100644 --- a/packages/ui-voip/src/context/useWidgetExternalControls.ts +++ b/packages/ui-voip/src/context/useWidgetExternalControls.ts @@ -5,13 +5,17 @@ import type { PeerInfo } from './definitions'; import { getEndCall } from '../utils/instanceControlsGetters'; export const useWidgetExternalControls = () => { - const { signalEmitter, instance } = useMediaCallInstance(); + const { instance, openWidget, closeWidget, widgetVisibility } = useMediaCallInstance(); const toggleWidget = useCallback( (peerInfo?: PeerInfo) => { - signalEmitter.emit('toggleWidget', { peerInfo }); + if (widgetVisibility === 'closed') { + openWidget(peerInfo); + return; + } + closeWidget(); }, - [signalEmitter], + [closeWidget, openWidget, widgetVisibility], ); const endCall = useCallback(() => getEndCall(instance)(), [instance]); diff --git a/packages/ui-voip/src/providers/MediaCallInstanceProvider.tsx b/packages/ui-voip/src/providers/MediaCallInstanceProvider.tsx index 0994c077ea818..4023799736494 100644 --- a/packages/ui-voip/src/providers/MediaCallInstanceProvider.tsx +++ b/packages/ui-voip/src/providers/MediaCallInstanceProvider.tsx @@ -7,6 +7,8 @@ import { useAudioStream } from './useAudioStream'; import useAvailableViewTracker from './useAvailableViewTracker'; import { useGetAutocompleteOptions } from './useGetAutocompleteOptions'; import { useMediaSessionInstance } from './useMediaSessionInstance'; +import { useMediaSessionStateSubscription } from './useMediaSessionStateSubscription'; +import { usePersistedSessionState } from './usePersistedSessionState'; import { MediaCallInstanceContext } from '../context/MediaCallInstanceContext'; import type { Signals } from '../context/MediaCallInstanceContext'; @@ -22,6 +24,10 @@ const MediaCallInstanceProvider = ({ children, enabled = true }: MediaCallInstan const instance = useMediaSessionInstance(user?._id, enabled); const [signalEmitter] = useState(() => new Emitter()); + const stateSubscription = useMediaSessionStateSubscription(); + const { openWidget, closeWidget, targetPeer, setTargetPeer, widgetVisibility } = usePersistedSessionState(stateSubscription, instance); + const { subscribe, getSnapshot } = stateSubscription; + const [remoteStreamRefCallback, audioElement] = useAudioStream(instance); const getAutocompleteOptions = useGetAutocompleteOptions(instance); @@ -37,8 +43,33 @@ const MediaCallInstanceProvider = ({ children, enabled = true }: MediaCallInstan currentViews, registerView, unregisterView, + stateSubscription: { + subscribe, + getSnapshot, + }, + openWidget, + closeWidget, + setTargetPeer, + targetPeer, + widgetVisibility, }), - [instance, signalEmitter, audioElement, openRoomId, setOpenRoomId, getAutocompleteOptions, currentViews, registerView, unregisterView], + [ + instance, + signalEmitter, + audioElement, + openRoomId, + getAutocompleteOptions, + currentViews, + registerView, + unregisterView, + subscribe, + getSnapshot, + openWidget, + closeWidget, + setTargetPeer, + targetPeer, + widgetVisibility, + ], ); return ( diff --git a/packages/ui-voip/src/providers/MediaCallViewProvider.tsx b/packages/ui-voip/src/providers/MediaCallViewProvider.tsx index 44e47c28585d3..ba1a1697eaac1 100644 --- a/packages/ui-voip/src/providers/MediaCallViewProvider.tsx +++ b/packages/ui-voip/src/providers/MediaCallViewProvider.tsx @@ -8,15 +8,13 @@ import { useToastMessageDispatch, } from '@rocket.chat/ui-contexts'; import type { ReactNode } from 'react'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useSyncExternalStore } from 'react'; import { useTranslation } from 'react-i18next'; import { useCallSounds } from './useCallSounds'; import { useDesktopNotifications } from './useDesktopNotifications'; -import { useMediaSession } from './useMediaSession'; import { useMediaSessionControls } from './useMediaSessionControls'; import { useScreenShareStreams } from './useScreenShareStreams'; -import { useWidgetExternalControlSignalListener } from './useWidgetExternalControlSignalListener'; import useWidgetPositionTracker from './useWidgetPositionTracker'; import { useMediaCallInstance } from '../context/MediaCallInstanceContext'; import MediaCallViewContext from '../context/MediaCallViewContext'; @@ -35,9 +33,10 @@ const MediaCallViewProvider = ({ children }: MediaCallViewProviderProps) => { const setModal = useSetModal(); - const { instance, audioElement, openRoomId, registerView, unregisterView } = useMediaCallInstance(); + const { instance, audioElement, openRoomId, registerView, unregisterView, stateSubscription, setTargetPeer, targetPeer } = + useMediaCallInstance(); - const { sessionState, toggleWidget, selectPeer } = useMediaSession(instance); + const sessionState = useSyncExternalStore(stateSubscription.subscribe, stateSubscription.getSnapshot); const controls = useMediaSessionControls(instance); useDesktopNotifications(sessionState); @@ -61,7 +60,7 @@ const MediaCallViewProvider = ({ children }: MediaCallViewProviderProps) => { }, [audioInput?.id, controls, sessionState.hidden]); useCallSounds( - sessionState.hidden ? 'closed' : sessionState.state, + sessionState.hidden ? 'none' : sessionState.state, useCallback( (callback) => { if (!instance) { @@ -82,25 +81,23 @@ const MediaCallViewProvider = ({ children }: MediaCallViewProviderProps) => { const onHold = () => controls.toggleHold(); const onCall = async () => { - if (sessionState.state !== 'new') { + if (sessionState.state !== 'none') { console.error('Cannot start call in state', sessionState.state); return; } - const { peerInfo } = sessionState; - - if (!peerInfo) { + if (!targetPeer) { return; } const startCall = (micless: boolean) => { - if ('userId' in peerInfo) { - void controls.startCall(peerInfo.userId, 'user', micless); + if ('userId' in targetPeer) { + void controls.startCall(targetPeer.userId, 'user', micless); return; } - if ('number' in peerInfo) { - void controls.startCall(peerInfo.number, 'sip', micless); + if ('number' in targetPeer) { + void controls.startCall(targetPeer.number, 'sip', micless); return; } @@ -201,7 +198,7 @@ const MediaCallViewProvider = ({ children }: MediaCallViewProviderProps) => { }; const onSelectPeer = (peerInfo: PeerInfo) => { - selectPeer(peerInfo); + setTargetPeer(peerInfo); }; const onToggleScreenSharing = () => { @@ -218,16 +215,6 @@ const MediaCallViewProvider = ({ children }: MediaCallViewProviderProps) => { const streams = useScreenShareStreams(instance); - useWidgetExternalControlSignalListener( - 'toggleWidget', - useCallback( - ({ peerInfo }) => { - toggleWidget(peerInfo); - }, - [toggleWidget], - ), - ); - const { onChangePosition, getRestorePosition } = useWidgetPositionTracker(); useEffect(() => { @@ -238,6 +225,7 @@ const MediaCallViewProvider = ({ children }: MediaCallViewProviderProps) => { const contextValue = { sessionState, + targetPeer, onClickDirectMessage, onMute, onHold, diff --git a/packages/ui-voip/src/providers/useMediaSession.ts b/packages/ui-voip/src/providers/useMediaSession.ts deleted file mode 100644 index fb9f59a109507..0000000000000 --- a/packages/ui-voip/src/providers/useMediaSession.ts +++ /dev/null @@ -1,261 +0,0 @@ -import type { UserStatus } from '@rocket.chat/core-typings'; -import type { MediaSignalingSession, CallState, CallContact } from '@rocket.chat/media-signaling'; -import { useUserAvatarPath, useUserPresence } from '@rocket.chat/ui-contexts'; -import { useEffect, useReducer, useCallback } from 'react'; - -import type { ConnectionState, PeerInfo, SessionState } from '../context/definitions'; -import { derivePeerInfoFromInstanceContact } from '../utils/derivePeerInfoFromInstanceContact'; -import { deriveWidgetStateFromCallState } from '../utils/deriveWidgetStateFromCallState'; - -const defaultSessionInfo: SessionState = { - state: 'closed' as const, - callId: undefined, - connectionState: 'CONNECTING' as const, - peerInfo: undefined, - transferredBy: undefined, - muted: false, - held: false, - remoteMuted: false, - remoteHeld: false, - startedAt: undefined, - hidden: false, - supportedFeatures: ['audio', 'transfer', 'hold'], -}; - -export const getExtensionFromInstanceContact = (contact: CallContact): string | undefined => { - if (contact.type === 'sip') { - return contact.id; - } - - return contact.sipExtension; -}; - -const deriveConnectionStateFromCallState = (callState: CallState): ConnectionState => { - switch (callState) { - case 'renegotiating': - return 'RECONNECTING'; - case 'ringing': - case 'active': - return 'CONNECTED'; - case 'none': - case 'accepted': - default: - return 'CONNECTING'; - } -}; - -const reducer = ( - reducerState: SessionState, - action: - | { - type: 'reset'; - } - | { - type: 'selectPeer'; - payload: { peerInfo?: PeerInfo }; - } - | { - type: 'toggleWidget'; - payload: { peerInfo?: PeerInfo }; - } - | { - type: 'instance_updated'; - payload: SessionState; - } - | { - type: 'status_updated'; - payload?: { status?: UserStatus }; - }, -): SessionState => { - if (action.type === 'toggleWidget') { - if (reducerState.state === 'closed') { - return { ...reducerState, state: 'new', peerInfo: action.payload?.peerInfo }; - } - - if (reducerState.state === 'new') { - return { ...reducerState, state: 'closed' }; - } - } - - if (action.type === 'instance_updated') { - return { ...reducerState, ...action.payload }; - } - - if (action.type === 'selectPeer') { - if (reducerState.state !== 'new') { - return reducerState; - } - - return { ...reducerState, peerInfo: action.payload?.peerInfo }; - } - - if (action.type === 'reset') { - return defaultSessionInfo; - } - - if (action.type === 'status_updated' && reducerState.peerInfo && 'userId' in reducerState.peerInfo) { - return { ...reducerState, peerInfo: { ...reducerState.peerInfo, status: action.payload?.status } }; - } - - return reducerState; -}; - -export type MediaSessionStateWithWidgetControls = { - sessionState: SessionState; - toggleWidget: (peerInfo?: PeerInfo) => void; - selectPeer: (peerInfo: PeerInfo) => void; -}; - -export const useMediaSession = (instance?: MediaSignalingSession): MediaSessionStateWithWidgetControls => { - const [mediaSession, dispatch] = useReducer(reducer, defaultSessionInfo); - - const getAvatarUrl = useUserAvatarPath(); - - useEffect(() => { - if (!instance) { - dispatch({ type: 'reset' }); - return; - } - - const updateSessionState = () => { - const instanceState = instance.getState(); - if (!instanceState) { - dispatch({ type: 'reset' }); - return; - } - - const { - state: callState, - localParticipant: { role, muted, held }, - } = instanceState; - const state = deriveWidgetStateFromCallState(callState, role); - - if (!state) { - dispatch({ type: 'reset' }); - return; - } - - const connectionState = deriveConnectionStateFromCallState(callState); - - if (!instanceState.confirmed) { - dispatch({ - type: 'instance_updated', - payload: { - peerInfo: { - displayName: instanceState.title, - userId: 'unknown', - username: undefined, - callerId: undefined, - }, - transferredBy: undefined, - state, - muted, - held, - connectionState, - hidden: false, - remoteHeld: false, - remoteMuted: false, - callId: instanceState.tempCallId, - startedAt: undefined, - supportedFeatures: [], - }, - }); - return; - } - - const { - hidden, - callId, - activeTimestamp: startedAt, - features: supportedFeatures, - transferredBy: callTransferredBy, - remoteParticipant: { muted: remoteMuted, held: remoteHeld, contact }, - } = instanceState; - - const transferredBy = callTransferredBy?.displayName || callTransferredBy?.username || undefined; - - if (contact.type === 'sip') { - dispatch({ - type: 'instance_updated', - payload: { - peerInfo: derivePeerInfoFromInstanceContact(contact), - transferredBy, - state, - muted, - held, - connectionState, - hidden, - remoteHeld, - remoteMuted, - callId, - startedAt, - supportedFeatures, - }, - }); - return; - } - - const avatarUrl = (() => { - if (contact.username) { - return getAvatarUrl({ username: contact.username }); - } - - if (contact.id) { - return getAvatarUrl({ userId: contact.id }); - } - - return undefined; - })(); - - const peerInfo = { ...derivePeerInfoFromInstanceContact(contact), avatarUrl }; - - dispatch({ - type: 'instance_updated', - payload: { - state, - peerInfo, - transferredBy, - muted, - held, - connectionState, - hidden, - remoteHeld, - remoteMuted, - callId, - startedAt, - supportedFeatures, - }, - }); - }; - - const offCbs = [instance.on('sessionStateChange', updateSessionState), instance.on('hiddenCall', updateSessionState)]; - - updateSessionState(); - - return () => { - offCbs.forEach((offCb) => offCb()); - }; - }, [getAvatarUrl, instance]); - - const toggleWidget = useCallback((peerInfo?: PeerInfo) => { - dispatch({ type: 'toggleWidget', payload: { peerInfo } }); - }, []); - - const selectPeer = useCallback((peerInfo: PeerInfo) => { - dispatch({ type: 'selectPeer', payload: { peerInfo } }); - }, []); - - const status = useUserPresence(mediaSession.peerInfo && 'userId' in mediaSession.peerInfo ? mediaSession.peerInfo.userId : undefined); - - useEffect(() => { - if (status?.status) { - dispatch({ type: 'status_updated', payload: { status: status.status } }); - } - }, [status?.status]); - - return { - sessionState: mediaSession, - toggleWidget, - selectPeer, - }; -}; diff --git a/packages/ui-voip/src/providers/useMediaSessionStateSubscription.ts b/packages/ui-voip/src/providers/useMediaSessionStateSubscription.ts new file mode 100644 index 0000000000000..7c930c81c9eb8 --- /dev/null +++ b/packages/ui-voip/src/providers/useMediaSessionStateSubscription.ts @@ -0,0 +1,202 @@ +import type { UserStatus } from '@rocket.chat/core-typings'; +import type { MediaSignalingSession, CallState, CallContact } from '@rocket.chat/media-signaling'; +import { useUserAvatarPath, useUserPresence } from '@rocket.chat/ui-contexts'; +import { useCallback, useRef } from 'react'; + +import type { ConnectionState, SessionState } from '../context/definitions'; +import { derivePeerInfoFromInstanceContact } from '../utils/derivePeerInfoFromInstanceContact'; +import { deriveWidgetStateFromCallState } from '../utils/deriveWidgetStateFromCallState'; + +export const defaultSessionInfo: SessionState = { + state: 'none' as const, + callId: undefined, + connectionState: 'CONNECTING' as const, + peerInfo: undefined, + transferredBy: undefined, + muted: false, + held: false, + remoteMuted: false, + remoteHeld: false, + startedAt: undefined, + hidden: false, + supportedFeatures: ['audio', 'transfer', 'hold'], +}; + +export const getExtensionFromInstanceContact = (contact: CallContact): string | undefined => { + if (contact.type === 'sip') { + return contact.id; + } + + return contact.sipExtension; +}; + +const deriveConnectionStateFromCallState = (callState: CallState): ConnectionState => { + switch (callState) { + case 'renegotiating': + return 'RECONNECTING'; + case 'ringing': + case 'active': + return 'CONNECTED'; + case 'none': + case 'accepted': + default: + return 'CONNECTING'; + } +}; + +export type MediaSessionStateSubscription = { + subscribe: (onStoreChange: () => void) => () => void; + getSnapshot: () => SessionState; +}; + +type GetStateParams = { + instance: MediaSignalingSession | undefined; + status?: UserStatus; + getAvatarUrl: ReturnType; +}; + +const getState = ({ instance, status, getAvatarUrl }: GetStateParams): SessionState | undefined => { + if (!instance) { + return undefined; + } + + const instanceState = instance.getState(); + if (!instanceState) { + return undefined; + } + + const { + state: callState, + localParticipant: { role, muted, held }, + } = instanceState; + const state = deriveWidgetStateFromCallState(callState, role); + + if (!state) { + return undefined; + } + + const connectionState = deriveConnectionStateFromCallState(callState); + + if (!instanceState.confirmed) { + return { + peerInfo: { + displayName: instanceState.title, + userId: 'unknown', + username: undefined, + callerId: undefined, + }, + transferredBy: undefined, + state, + muted, + held, + connectionState, + hidden: false, + remoteHeld: false, + remoteMuted: false, + callId: instanceState.tempCallId, + startedAt: undefined, + supportedFeatures: [], + }; + } + + const { + hidden, + callId, + activeTimestamp: startedAt, + features: supportedFeatures, + transferredBy: callTransferredBy, + remoteParticipant: { muted: remoteMuted, held: remoteHeld, contact }, + } = instanceState; + + const transferredBy = callTransferredBy?.displayName || callTransferredBy?.username || undefined; + + if (contact.type === 'sip') { + return { + peerInfo: derivePeerInfoFromInstanceContact(contact), + transferredBy, + state, + muted, + held, + connectionState, + hidden, + remoteHeld, + remoteMuted, + callId, + startedAt, + supportedFeatures, + }; + } + + const avatarUrl = (() => { + if (contact.username) { + return getAvatarUrl({ username: contact.username }); + } + + if (contact.id) { + return getAvatarUrl({ userId: contact.id }); + } + + return undefined; + })(); + + const peerInfo = { ...derivePeerInfoFromInstanceContact(contact), avatarUrl, status }; + + return { + state, + peerInfo, + transferredBy, + muted, + held, + connectionState, + hidden, + remoteHeld, + remoteMuted, + callId, + startedAt, + supportedFeatures, + }; +}; + +export const useMediaSessionStateSubscription = (instance?: MediaSignalingSession): MediaSessionStateSubscription => { + const cacheRef = useRef(defaultSessionInfo); + + const getAvatarUrl = useUserAvatarPath(); + + const presence = useUserPresence( + cacheRef.current?.peerInfo && 'userId' in cacheRef.current.peerInfo ? cacheRef.current.peerInfo.userId : undefined, + ); + + const { status } = presence || {}; + + const subscribe = useCallback( + (onStoreChange: () => void): (() => void) => { + if (!instance) { + return () => undefined; + } + const onEvent = () => { + const newState = getState({ instance, status, getAvatarUrl }); + if (!newState) { + cacheRef.current = defaultSessionInfo; + } else { + cacheRef.current = { ...cacheRef.current, ...newState }; + } + if (newState === cacheRef.current) { + return; + } + onStoreChange(); + }; + const offCbs = [instance.on('sessionStateChange', onEvent), instance.on('hiddenCall', onEvent)]; + return () => offCbs.forEach((cb) => cb()); + }, + [getAvatarUrl, instance, status], + ); + + const getSnapshot = useCallback(() => { + return cacheRef.current; + }, []); + + return { + subscribe, + getSnapshot, + }; +}; diff --git a/packages/ui-voip/src/providers/usePersistedSessionState.ts b/packages/ui-voip/src/providers/usePersistedSessionState.ts new file mode 100644 index 0000000000000..de315ac3b5aaf --- /dev/null +++ b/packages/ui-voip/src/providers/usePersistedSessionState.ts @@ -0,0 +1,46 @@ +import type { MediaSignalingSession } from '@rocket.chat/media-signaling'; +import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'; + +import type { PeerInfo } from '../context'; +import type { MediaSessionStateSubscription } from './useMediaSessionStateSubscription'; + +export const usePersistedSessionState = (sessionStateSubscription: MediaSessionStateSubscription, instance?: MediaSignalingSession) => { + const [targetWidgetVisibility, setTargetWidgetVisibility] = useState<'open' | 'closed'>('closed'); + const [targetPeer, setTargetPeer] = useState(undefined); + + const openWidget = useCallback((peerInfo?: PeerInfo) => { + setTargetWidgetVisibility('open'); + setTargetPeer((oldPeerInfo) => { + if (!peerInfo) { + return oldPeerInfo; + } + return peerInfo; + }); + }, []); + + const closeWidget = useCallback(() => { + setTargetWidgetVisibility('closed'); + }, []); + + const { subscribe: subscribeToState, getSnapshot: getStateSnapshot } = sessionStateSubscription; + + const getWidgetVisibility = useCallback(() => { + const { state } = getStateSnapshot(); + if (['calling', 'ringing', 'ongoing'].includes(state)) { + return 'open'; + } + return targetWidgetVisibility; + }, [getStateSnapshot, targetWidgetVisibility]); + + const widgetVisibility = useSyncExternalStore(subscribeToState, getWidgetVisibility); + + // If a call ended the widget should close. + useEffect(() => { + return instance?.on('endedCall', () => { + setTargetWidgetVisibility('closed'); + setTargetPeer(undefined); + }); + }, [instance]); + + return { openWidget, closeWidget, targetPeer, setTargetPeer, widgetVisibility }; +}; diff --git a/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.tsx b/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.tsx index 9709975d968e1..423e921d11e07 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.tsx @@ -1,19 +1,17 @@ import MediaCallWidgetViewRouter from './MediaCallWidgetViewRouter'; import WidgetDraggableProvider from '../../components/Widget/WidgetDraggableProvider'; +import { useMediaCallInstance } from '../../context'; import { useMediaCallView } from '../../context/MediaCallViewContext'; import useRegisterView from '../../context/useRegisterView'; const MediaCallWidget = () => { + const { widgetVisibility } = useMediaCallInstance(); const currentViews = useRegisterView('widget'); const { - sessionState: { state, hidden }, + sessionState: { hidden }, } = useMediaCallView(); - if (hidden || !currentViews.includes('widget')) { - return null; - } - - if (state === 'closed') { + if (hidden || !currentViews.includes('widget') || widgetVisibility === 'closed') { return null; } diff --git a/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidgetViewRouter.tsx b/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidgetViewRouter.tsx index 2c0a9f3d00782..be3ce7363cb00 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidgetViewRouter.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidgetViewRouter.tsx @@ -13,8 +13,6 @@ const MediaCallWidgetViewRouter = () => { return ; } return ; - case 'new': - return ; case 'ringing': if (transferredBy) { return ; @@ -25,9 +23,9 @@ const MediaCallWidgetViewRouter = () => { return ; } return ; - case 'closed': + case 'none': default: - return null; + return ; } }; diff --git a/packages/ui-voip/src/views/MediaCallWidget/NewCall.tsx b/packages/ui-voip/src/views/MediaCallWidget/NewCall.tsx index 79b60461bc7c3..b31f8a800b340 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/NewCall.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/NewCall.tsx @@ -19,11 +19,11 @@ import { useWidgetExternalControls } from '../../context/useWidgetExternalContro const NewCall = () => { const { t } = useTranslation(); - const { sessionState, onCall, onSelectPeer } = useMediaCallView(); + const { sessionState, onCall, onSelectPeer, targetPeer } = useMediaCallView(); const { peerInfo } = sessionState; const { toggleWidget } = useWidgetExternalControls(); - const autocomplete = usePeerAutocomplete(onSelectPeer, peerInfo); + const autocomplete = usePeerAutocomplete(onSelectPeer, targetPeer); return ( From 65dd8f5d82bf55965da5612d596046a29c718dab Mon Sep 17 00:00:00 2001 From: gabriellsh Date: Thu, 30 Jul 2026 19:20:12 -0300 Subject: [PATCH 02/10] fix: useGetAutocomplete imports --- packages/ui-voip/src/providers/useGetAutocompleteOptions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui-voip/src/providers/useGetAutocompleteOptions.ts b/packages/ui-voip/src/providers/useGetAutocompleteOptions.ts index 1761c7121b1fb..94f2e3e24294a 100644 --- a/packages/ui-voip/src/providers/useGetAutocompleteOptions.ts +++ b/packages/ui-voip/src/providers/useGetAutocompleteOptions.ts @@ -2,7 +2,7 @@ import type { MediaSignalingSession } from '@rocket.chat/media-signaling'; import { useEndpoint, useSetting, useUser, useUserAvatarPath } from '@rocket.chat/ui-contexts'; import { useCallback } from 'react'; -import { getExtensionFromInstanceContact } from './useMediaSession'; +import { getExtensionFromInstanceContact } from './useMediaSessionStateSubscription'; export const useGetAutocompleteOptions = (instance: MediaSignalingSession | undefined) => { const user = useUser(); From 2d9cf2be07d05aca20de8e413f80934eef78ab79 Mon Sep 17 00:00:00 2001 From: gabriellsh Date: Thu, 30 Jul 2026 19:22:15 -0300 Subject: [PATCH 03/10] fix: tests and stories --- .../src/context/MediaCallInstanceContext.ts | 2 +- .../usePeekMediaSessionPeerInfo.spec.tsx | 12 +-- .../context/usePeekMediaSessionState.spec.tsx | 19 +---- .../src/context/usePeerAutocomplete.spec.tsx | 22 +---- .../useMediaCallOpenRoomTracker.spec.tsx | 20 +---- .../src/providers/MockedInstanceProvider.tsx | 81 +++++++++++++++++++ .../src/providers/MockedMediaCallProvider.tsx | 73 +++++------------ .../CallHistoryActions.stories.tsx | 6 +- .../MediaCallWidget/MediaCallWidget.spec.tsx | 1 + .../MediaCallWidget.stories.tsx | 16 ++-- 10 files changed, 122 insertions(+), 130 deletions(-) create mode 100644 packages/ui-voip/src/providers/MockedInstanceProvider.tsx diff --git a/packages/ui-voip/src/context/MediaCallInstanceContext.ts b/packages/ui-voip/src/context/MediaCallInstanceContext.ts index 79ac4d8585daa..c480d07b05df5 100644 --- a/packages/ui-voip/src/context/MediaCallInstanceContext.ts +++ b/packages/ui-voip/src/context/MediaCallInstanceContext.ts @@ -19,7 +19,7 @@ type UnregisterView = (view: AvailableViews) => void; type WidgetVisibility = 'open' | 'closed'; -type MediaCallInstanceContextValue = { +export type MediaCallInstanceContextValue = { instance: MediaSignalingSession | undefined; signalEmitter: Emitter; audioElement: RefObject | undefined; diff --git a/packages/ui-voip/src/context/usePeekMediaSessionPeerInfo.spec.tsx b/packages/ui-voip/src/context/usePeekMediaSessionPeerInfo.spec.tsx index 7b2eab5e020e0..b6bed51c4b50a 100644 --- a/packages/ui-voip/src/context/usePeekMediaSessionPeerInfo.spec.tsx +++ b/packages/ui-voip/src/context/usePeekMediaSessionPeerInfo.spec.tsx @@ -3,8 +3,7 @@ import type { CallContact } from '@rocket.chat/media-signaling'; import { renderHook, act } from '@testing-library/react'; import type { ReactNode } from 'react'; -import type { Signals } from './MediaCallInstanceContext'; -import { MediaCallInstanceContext } from './MediaCallInstanceContext'; +import { MediaCallInstanceContext, defaultContextValue } from './MediaCallInstanceContext'; import { usePeekMediaSessionPeerInfo } from './usePeekMediaSessionPeerInfo'; type MockInstance = { @@ -16,15 +15,8 @@ const createWrapper = (instance: MockInstance | undefined) => { const wrapper = ({ children }: { children?: ReactNode }) => ( undefined, - unregisterView: () => undefined, + ...defaultContextValue, instance: instance as any, - signalEmitter: new Emitter(), - audioElement: undefined, - openRoomId: undefined, - setOpenRoomId: () => undefined, - getAutocompleteOptions: () => Promise.resolve([]), }} > {children} diff --git a/packages/ui-voip/src/context/usePeekMediaSessionState.spec.tsx b/packages/ui-voip/src/context/usePeekMediaSessionState.spec.tsx index a6d31f6392948..5223ee4cb223f 100644 --- a/packages/ui-voip/src/context/usePeekMediaSessionState.spec.tsx +++ b/packages/ui-voip/src/context/usePeekMediaSessionState.spec.tsx @@ -3,9 +3,8 @@ import type { CallRole, CallState } from '@rocket.chat/media-signaling'; import { renderHook, act } from '@testing-library/react'; import type { ReactNode } from 'react'; -import type { Signals } from './MediaCallInstanceContext'; -import { MediaCallInstanceContext } from './MediaCallInstanceContext'; import { usePeekMediaSessionState } from './usePeekMediaSessionState'; +import MockedInstanceProvider from '../providers/MockedInstanceProvider'; type MockInstance = { getState: () => { state: CallState; localParticipant: { role: CallRole } } | null; @@ -14,21 +13,7 @@ type MockInstance = { const createWrapper = (instance: MockInstance | undefined) => { const wrapper = ({ children }: { children?: ReactNode }) => ( - undefined, - unregisterView: () => undefined, - instance: instance as any, - signalEmitter: new Emitter(), - audioElement: undefined, - openRoomId: undefined, - setOpenRoomId: () => undefined, - getAutocompleteOptions: () => Promise.resolve([]), - }} - > - {children} - + {children} ); return wrapper; }; diff --git a/packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx b/packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx index 1382b150ae007..87d4eb0e79399 100644 --- a/packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx +++ b/packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx @@ -1,13 +1,11 @@ import { UserStatus } from '@rocket.chat/core-typings'; -import { Emitter } from '@rocket.chat/emitter'; import { mockAppRoot } from '@rocket.chat/mock-providers'; import { renderHook, waitFor, act } from '@testing-library/react'; import type { PeerInfo } from './definitions'; import type { PeerAutocompleteOptions } from '../components'; -import type { Signals } from './MediaCallInstanceContext'; -import { MediaCallInstanceContext } from './MediaCallInstanceContext'; import { usePeerAutocomplete, isFirstPeerAutocompleteOption } from './usePeerAutocomplete'; +import MockedInstanceProvider from '../providers/MockedInstanceProvider'; jest.mock('@rocket.chat/ui-contexts', () => ({ ...jest.requireActual('@rocket.chat/ui-contexts'), @@ -21,23 +19,7 @@ const mockOnSelectPeer = jest.fn(); const appRoot = () => mockAppRoot() - .wrap((children) => ( - undefined, - unregisterView: () => undefined, - instance: undefined, - signalEmitter: new Emitter(), - audioElement: undefined, - openRoomId: undefined, - setOpenRoomId: () => undefined, - getAutocompleteOptions: mockGetAutocompleteOptions, - }} - > - {children} - - )) + .wrap((children) => {children}) .build(); beforeEach(() => { diff --git a/packages/ui-voip/src/hooks/useMediaCallOpenRoomTracker.spec.tsx b/packages/ui-voip/src/hooks/useMediaCallOpenRoomTracker.spec.tsx index 543d43e4dc345..76fd731d2e437 100644 --- a/packages/ui-voip/src/hooks/useMediaCallOpenRoomTracker.spec.tsx +++ b/packages/ui-voip/src/hooks/useMediaCallOpenRoomTracker.spec.tsx @@ -1,30 +1,14 @@ -import { Emitter } from '@rocket.chat/emitter'; import { renderHook } from '@testing-library/react'; import type { ReactNode } from 'react'; import { useMediaCallOpenRoomTracker } from './useMediaCallOpenRoomTracker'; -import type { Signals } from '../context/MediaCallInstanceContext'; -import { MediaCallInstanceContext } from '../context/MediaCallInstanceContext'; +import MockedInstanceProvider from '../providers/MockedInstanceProvider'; const setOpenRoomIdMock = jest.fn(); const createWrapper = () => { const wrapper = ({ children }: { children?: ReactNode }) => ( - undefined, - unregisterView: () => undefined, - instance: undefined, - signalEmitter: new Emitter(), - audioElement: undefined, - openRoomId: undefined, - setOpenRoomId: setOpenRoomIdMock, - getAutocompleteOptions: () => Promise.resolve([]), - }} - > - {children} - + {children} ); return wrapper; }; diff --git a/packages/ui-voip/src/providers/MockedInstanceProvider.tsx b/packages/ui-voip/src/providers/MockedInstanceProvider.tsx new file mode 100644 index 0000000000000..2aac61e8d06f8 --- /dev/null +++ b/packages/ui-voip/src/providers/MockedInstanceProvider.tsx @@ -0,0 +1,81 @@ +import { Emitter } from '@rocket.chat/emitter'; +import type { MediaSignalingSession } from '@rocket.chat/media-signaling'; +import type { ReactNode } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; + +import useAvailableViewTracker from './useAvailableViewTracker'; +import { defaultSessionInfo } from './useMediaSessionStateSubscription'; +import type { MediaSessionStateSubscription } from './useMediaSessionStateSubscription'; +import { usePersistedSessionState } from './usePersistedSessionState'; +import { MediaCallInstanceContext } from '../context/MediaCallInstanceContext'; +import type { MediaCallInstanceContextValue, Signals } from '../context/MediaCallInstanceContext'; +import type { SessionState } from '../context/definitions'; + +export const avatarUrl = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z`; + +export const mockedPeers = Array.from({ length: 100 }, (_, i) => ({ + value: `user-${i}`, + label: `User ${i}`, + identifier: `000${i}`, + avatarUrl, +})); + +export type MockedInstanceProviderProps = { + children: ReactNode; + sessionState?: SessionState; +} & Partial; + +const MockedInstanceProvider = ({ children, sessionState = defaultSessionInfo, ...props }: MockedInstanceProviderProps) => { + const { currentViews, registerView, unregisterView } = useAvailableViewTracker(); + const [openRoomId, setOpenRoomId] = useState(undefined); + const [signalEmitter] = useState(() => new Emitter()); + const [instance] = useState( + () => + ({ + getState: () => null, + on: () => () => undefined, + once: () => () => undefined, + }) as unknown as MediaSignalingSession, + ); + + const sessionStateRef = useRef(sessionState); + sessionStateRef.current = sessionState; + + const stateSubscription = useMemo( + () => ({ + subscribe: () => () => undefined, + getSnapshot: () => sessionStateRef.current, + }), + [], + ); + + const { openWidget, closeWidget, targetPeer, setTargetPeer, widgetVisibility } = usePersistedSessionState(stateSubscription, instance); + + const getAutocompleteOptions = useCallback( + (filter: string) => Promise.resolve(mockedPeers.filter((peer) => peer.label.toLowerCase().includes(filter.toLowerCase()))), + [], + ); + + const value = { + instance, + signalEmitter, + audioElement: undefined, + openRoomId, + setOpenRoomId, + currentViews, + registerView, + unregisterView, + stateSubscription, + widgetVisibility, + targetPeer, + setTargetPeer, + getAutocompleteOptions, + openWidget, + closeWidget, + ...props, + }; + + return {children}; +}; + +export default MockedInstanceProvider; diff --git a/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx b/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx index 616e68c672888..ce77b544583c9 100644 --- a/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx +++ b/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx @@ -1,17 +1,12 @@ import { UserStatus } from '@rocket.chat/core-typings'; -import { Emitter } from '@rocket.chat/emitter'; -import type { MediaSignalingSession } from '@rocket.chat/media-signaling'; import type { ReactNode } from 'react'; import { useState } from 'react'; -import { MediaCallInstanceContext } from '../context/MediaCallInstanceContext'; -import type { AvailableViews, Signals } from '../context/MediaCallInstanceContext'; +import type { MockedInstanceProviderProps } from './MockedInstanceProvider'; +import MockedInstanceProvider, { avatarUrl, mockedPeers } from './MockedInstanceProvider'; import MediaCallViewContext from '../context/MediaCallViewContext'; import type { State, PeerInfo, SessionState } from '../context/definitions'; -const avatarUrl = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z`; -const myData: any[] = Array.from({ length: 100 }, (_, i) => ({ value: `user-${i}`, label: `User ${i}`, identifier: `000${i}`, avatarUrl })); - export type MockedMediaCallProviderProps = { children: ReactNode; state?: State; @@ -21,17 +16,19 @@ export type MockedMediaCallProviderProps = { muted?: boolean; held?: boolean; onClickDirectMessage?: () => void; + instanceProps?: Partial; }; const MockedMediaCallProvider = ({ children, - state = 'closed', + state = 'none', onClickDirectMessage = undefined, transferredBy = undefined, remoteMuted = false, remoteHeld = false, muted = false, held = false, + instanceProps, }: MockedMediaCallProviderProps) => { const [peerInfo, setPeerInfo] = useState({ displayName: 'John Doe', @@ -60,7 +57,7 @@ const MockedMediaCallProvider = ({ const onForward = () => { console.log('forward'); clearState(); - setWidgetState('closed'); + setWidgetState('none'); }; const onTone = (tone: string) => { @@ -69,25 +66,21 @@ const MockedMediaCallProvider = ({ const onEndCall = () => { clearState(); - setWidgetState('closed'); + setWidgetState('none'); }; - const getAutocompleteOptions = (filter: string) => - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - Promise.resolve(myData.filter((item) => item.label.toLowerCase().includes(filter.toLowerCase()))); - const getPeerInfo = (id: string) => { - const peerInfo = myData.find((item) => item.value === id); - if (!peerInfo) { + const peer = mockedPeers.find((item) => item.value === id); + if (!peer) { return Promise.resolve(undefined); } return Promise.resolve({ - displayName: peerInfo.label, - userId: peerInfo.value, - avatarUrl: peerInfo.avatarUrl, - username: peerInfo.identifier, - callerId: peerInfo.value, + displayName: peer.label, + userId: peer.value, + avatarUrl: peer.avatarUrl, + username: peer.identifier, + callerId: peer.value, }); }; @@ -97,31 +90,17 @@ const MockedMediaCallProvider = ({ } switch (widgetState) { - case 'closed': - setWidgetState('ringing'); - break; case 'ringing': setWidgetState('ongoing'); break; - case 'new': + case 'none': setWidgetState('calling'); setTimeout(() => { setWidgetState('ongoing'); }, 1000); break; case 'calling': - setWidgetState('closed'); - break; - } - }; - - const onToggleWidget = () => { - switch (widgetState) { - case 'closed': - setWidgetState('new'); - break; - case 'new': - setWidgetState('closed'); + setWidgetState('none'); break; } }; @@ -155,7 +134,6 @@ const MockedMediaCallProvider = ({ onEndCall, onCall, onAccept: onCall, - onToggleWidget, onSelectPeer, streams: {}, onToggleScreenSharing: () => undefined, @@ -163,25 +141,10 @@ const MockedMediaCallProvider = ({ onClosePopout: () => undefined, }; - const instanceContextValue = { - instance: { - getState: () => null, - on: () => undefined, - } as unknown as MediaSignalingSession, - currentViews: ['widget'] as AvailableViews[], - registerView: (_view: AvailableViews) => undefined, - unregisterView: (_view: AvailableViews) => undefined, - signalEmitter: new Emitter(), - audioElement: undefined, - openRoomId: undefined, - setOpenRoomId: () => undefined, - getAutocompleteOptions, - }; - return ( - + {children} - + ); }; diff --git a/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryActions.stories.tsx b/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryActions.stories.tsx index 244767d366803..3cc8516068db0 100644 --- a/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryActions.stories.tsx +++ b/packages/ui-voip/src/views/CallHistoryContextualbar/CallHistoryActions.stories.tsx @@ -49,7 +49,7 @@ export const Default: Story = { onClose: noop, actions: getArgs(5), }, - decorators: [getDecorator('closed')], + decorators: [getDecorator('none')], }; export const WithLessActions: Story = { @@ -57,7 +57,7 @@ export const WithLessActions: Story = { onClose: noop, actions: getArgs(3), }, - decorators: [getDecorator('closed')], + decorators: [getDecorator('none')], }; export const WithSingleAction: Story = { @@ -65,7 +65,7 @@ export const WithSingleAction: Story = { onClose: noop, actions: getArgs(1), }, - decorators: [getDecorator('closed')], + decorators: [getDecorator('none')], }; export const WithDisabledVoiceCall: Story = { diff --git a/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.spec.tsx b/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.spec.tsx index 819491709a1e3..171af0244e568 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.spec.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.spec.tsx @@ -9,6 +9,7 @@ const testCases = Object.values(composeStories(stories)).map((Story) => [Story.s test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { const view = render(, { wrapper: mockAppRoot().build() }); + await new Promise((resolve) => setTimeout(resolve, 500)); expect(view.baseElement).toMatchSnapshot(); }); diff --git a/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.stories.tsx b/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.stories.tsx index 19da22cdde8b9..e06c4b4bcad91 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.stories.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.stories.tsx @@ -3,7 +3,7 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; import type { Meta, StoryObj } from '@storybook/react'; import MediaCallWidget from './MediaCallWidget'; -import { useMediaCallView, useWidgetExternalControls } from '../../context'; +import { useMediaCallInstance, useMediaCallView, useWidgetExternalControls } from '../../context'; import MockedMediaCallProvider from '../../providers/MockedMediaCallProvider'; const mockedContexts = mockAppRoot() @@ -21,12 +21,12 @@ const meta = { title: 'Views/MediaCallWidget/Draggable Widget', component: MediaCallWidget, args: { - state: 'closed', + state: 'none', }, decorators: [ mockedContexts, (Story, options) => ( - + ), @@ -37,16 +37,20 @@ export default meta; type Story = StoryObj; export const MediaCallWidgetManualTesting: Story = { + args: { + instanceProps: undefined, + }, render: () => { + const { widgetVisibility } = useMediaCallInstance(); const { sessionState, onCall } = useMediaCallView(); const { toggleWidget } = useWidgetExternalControls(); const { state } = sessionState; return ( <> - - @@ -57,7 +61,7 @@ export const MediaCallWidgetManualTesting: Story = { export const NewCall: Story = { args: { - state: 'new', + state: 'none', }, }; From cf39d3597bd1875a79ca6946357f7439f80ee379 Mon Sep 17 00:00:00 2001 From: gabriellsh Date: Fri, 31 Jul 2026 18:46:46 -0300 Subject: [PATCH 04/10] refactor: go back to old useMediaSession but keep some state on top provider. Also removes some bad patterns no longer needed. --- .../Widget/WidgetDraggableProvider.tsx | 2 +- .../src/context/MediaCallInstanceContext.ts | 22 +- .../src/context/MediaCallViewContext.ts | 7 +- .../ui-voip/src/context/useRegisterView.ts | 2 +- .../src/context/useWidgetExternalControls.ts | 6 +- .../providers/MediaCallInstanceProvider.tsx | 27 +-- .../src/providers/MediaCallViewProvider.tsx | 12 +- .../src/providers/MockedInstanceProvider.tsx | 29 +-- .../src/providers/useAvailableViewTracker.ts | 74 ++----- .../providers/useGetAutocompleteOptions.ts | 2 +- .../ui-voip/src/providers/useInstanceState.ts | 34 +++ .../ui-voip/src/providers/useMediaSession.ts | 203 ++++++++++++++++++ .../useMediaSessionStateSubscription.ts | 202 ----------------- .../src/providers/usePersistedSessionState.ts | 46 ---- .../useWidgetExternalControlSignalListener.ts | 12 -- .../src/providers/useWidgetPositionTracker.ts | 18 +- .../ui-voip/src/views/MediaCallPopout.tsx | 2 +- .../MediaCallRoomSection.tsx | 2 +- .../MediaCallWidget/MediaCallWidget.spec.tsx | 1 - .../MediaCallWidget.stories.tsx | 8 +- .../views/MediaCallWidget/MediaCallWidget.tsx | 8 +- .../src/views/MediaCallWidget/NewCall.tsx | 7 +- .../MediaCallWidget/OngoingCallWithScreen.tsx | 2 +- 23 files changed, 312 insertions(+), 416 deletions(-) create mode 100644 packages/ui-voip/src/providers/useInstanceState.ts create mode 100644 packages/ui-voip/src/providers/useMediaSession.ts delete mode 100644 packages/ui-voip/src/providers/useMediaSessionStateSubscription.ts delete mode 100644 packages/ui-voip/src/providers/usePersistedSessionState.ts delete mode 100644 packages/ui-voip/src/providers/useWidgetExternalControlSignalListener.ts diff --git a/packages/ui-voip/src/components/Widget/WidgetDraggableProvider.tsx b/packages/ui-voip/src/components/Widget/WidgetDraggableProvider.tsx index 336028d521feb..5314c122dd8c2 100644 --- a/packages/ui-voip/src/components/Widget/WidgetDraggableProvider.tsx +++ b/packages/ui-voip/src/components/Widget/WidgetDraggableProvider.tsx @@ -13,7 +13,7 @@ const WidgetDraggableProvider = ({ children }: WidgetDraggableProviderProps) => const { widgetPositionTracker } = useMediaCallView(); const [draggableRef, boundingRef, handleRef] = useDraggable({ onChangePosition: widgetPositionTracker?.onChangePosition, - restorePosition: widgetPositionTracker?.getRestorePosition(), + restorePosition: widgetPositionTracker?.lastKnownPosition, }); useLayoutEffect(() => { diff --git a/packages/ui-voip/src/context/MediaCallInstanceContext.ts b/packages/ui-voip/src/context/MediaCallInstanceContext.ts index c480d07b05df5..82fb2b2a61224 100644 --- a/packages/ui-voip/src/context/MediaCallInstanceContext.ts +++ b/packages/ui-voip/src/context/MediaCallInstanceContext.ts @@ -1,16 +1,9 @@ -import { Emitter } from '@rocket.chat/emitter'; import type { MediaSignalingSession } from '@rocket.chat/media-signaling'; import type { RefObject } from 'react'; import { createContext, useContext } from 'react'; import type { PeerAutocompleteOptions } from '../components'; import type { PeerInfo } from './definitions'; -import type { MediaSessionStateSubscription } from '../providers/useMediaSessionStateSubscription'; -import { defaultSessionInfo } from '../providers/useMediaSessionStateSubscription'; - -export type Signals = { - toggleWidget: { peerInfo?: PeerInfo }; -}; export type AvailableViews = 'room' | 'popout' | 'widget'; @@ -21,16 +14,14 @@ type WidgetVisibility = 'open' | 'closed'; export type MediaCallInstanceContextValue = { instance: MediaSignalingSession | undefined; - signalEmitter: Emitter; audioElement: RefObject | undefined; openRoomId: string | undefined; - currentViews: AvailableViews[]; + currentViews: Set; registerView: RegisterView; unregisterView: UnregisterView; - stateSubscription: MediaSessionStateSubscription; - widgetVisibility: WidgetVisibility; + targetWidgetVisibility: WidgetVisibility; targetPeer: PeerInfo | undefined; setOpenRoomId: (openRoomId: string | undefined) => void; @@ -42,23 +33,18 @@ export type MediaCallInstanceContextValue = { export const defaultContextValue = { instance: undefined, - signalEmitter: new Emitter(), audioElement: undefined, openRoomId: undefined, setOpenRoomId: () => undefined, getAutocompleteOptions: () => Promise.resolve([]), - currentViews: [], + currentViews: new Set(), registerView: () => undefined, unregisterView: () => undefined, openWidget: () => undefined, closeWidget: () => undefined, setTargetPeer: () => undefined, - widgetVisibility: 'closed' as const, + targetWidgetVisibility: 'closed' as const, targetPeer: undefined, - stateSubscription: { - subscribe: () => () => undefined, - getSnapshot: () => defaultSessionInfo, - }, }; export const MediaCallInstanceContext = createContext(defaultContextValue); diff --git a/packages/ui-voip/src/context/MediaCallViewContext.ts b/packages/ui-voip/src/context/MediaCallViewContext.ts index fc46d084d673b..8f32f23769b3e 100644 --- a/packages/ui-voip/src/context/MediaCallViewContext.ts +++ b/packages/ui-voip/src/context/MediaCallViewContext.ts @@ -29,13 +29,13 @@ type MediaCallViewContextValue = { streams: MediaCallStreams; widgetPositionTracker?: { onChangePosition: (position: LastKnownPosition | null) => void; - getRestorePosition: () => LastKnownPosition | null; + lastKnownPosition: LastKnownPosition | null; }; }; -const defaultSessionState: SessionState = { +export const defaultSessionState: SessionState = { state: 'none', - connectionState: 'CONNECTED', + connectionState: 'CONNECTING', peerInfo: undefined, transferredBy: undefined, hidden: false, @@ -44,6 +44,7 @@ const defaultSessionState: SessionState = { remoteMuted: false, remoteHeld: false, callId: undefined, + startedAt: undefined, supportedFeatures: ['audio', 'transfer', 'hold'], }; diff --git a/packages/ui-voip/src/context/useRegisterView.ts b/packages/ui-voip/src/context/useRegisterView.ts index d0059fa1b1c27..a999adc57e8fd 100644 --- a/packages/ui-voip/src/context/useRegisterView.ts +++ b/packages/ui-voip/src/context/useRegisterView.ts @@ -3,7 +3,7 @@ import { useLayoutEffect } from 'react'; import { useMediaCallInstance } from '.'; import type { AvailableViews } from './MediaCallInstanceContext'; -const useRegisterView = (view: AvailableViews): AvailableViews[] => { +const useRegisterView = (view: AvailableViews) => { const { currentViews, registerView, unregisterView } = useMediaCallInstance(); useLayoutEffect(() => { diff --git a/packages/ui-voip/src/context/useWidgetExternalControls.ts b/packages/ui-voip/src/context/useWidgetExternalControls.ts index 129b761b4f95b..a79abb6b8c1b7 100644 --- a/packages/ui-voip/src/context/useWidgetExternalControls.ts +++ b/packages/ui-voip/src/context/useWidgetExternalControls.ts @@ -5,17 +5,17 @@ import type { PeerInfo } from './definitions'; import { getEndCall } from '../utils/instanceControlsGetters'; export const useWidgetExternalControls = () => { - const { instance, openWidget, closeWidget, widgetVisibility } = useMediaCallInstance(); + const { instance, openWidget, closeWidget, targetWidgetVisibility } = useMediaCallInstance(); const toggleWidget = useCallback( (peerInfo?: PeerInfo) => { - if (widgetVisibility === 'closed') { + if (targetWidgetVisibility === 'closed') { openWidget(peerInfo); return; } closeWidget(); }, - [closeWidget, openWidget, widgetVisibility], + [closeWidget, openWidget, targetWidgetVisibility], ); const endCall = useCallback(() => getEndCall(instance)(), [instance]); diff --git a/packages/ui-voip/src/providers/MediaCallInstanceProvider.tsx b/packages/ui-voip/src/providers/MediaCallInstanceProvider.tsx index 4023799736494..8cc816e7f5e73 100644 --- a/packages/ui-voip/src/providers/MediaCallInstanceProvider.tsx +++ b/packages/ui-voip/src/providers/MediaCallInstanceProvider.tsx @@ -1,16 +1,13 @@ -import { Emitter } from '@rocket.chat/emitter'; import { useUser } from '@rocket.chat/ui-contexts'; -import { useMemo, useState, type ReactNode } from 'react'; +import { useMemo, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { useAudioStream } from './useAudioStream'; import useAvailableViewTracker from './useAvailableViewTracker'; import { useGetAutocompleteOptions } from './useGetAutocompleteOptions'; +import { useInstanceState } from './useInstanceState'; import { useMediaSessionInstance } from './useMediaSessionInstance'; -import { useMediaSessionStateSubscription } from './useMediaSessionStateSubscription'; -import { usePersistedSessionState } from './usePersistedSessionState'; import { MediaCallInstanceContext } from '../context/MediaCallInstanceContext'; -import type { Signals } from '../context/MediaCallInstanceContext'; export type MediaCallInstanceProviderProps = { children: ReactNode; @@ -18,15 +15,12 @@ export type MediaCallInstanceProviderProps = { }; const MediaCallInstanceProvider = ({ children, enabled = true }: MediaCallInstanceProviderProps) => { - const [openRoomId, setOpenRoomId] = useState(undefined); const { currentViews, registerView, unregisterView } = useAvailableViewTracker(); const user = useUser(); const instance = useMediaSessionInstance(user?._id, enabled); - const [signalEmitter] = useState(() => new Emitter()); - const stateSubscription = useMediaSessionStateSubscription(); - const { openWidget, closeWidget, targetPeer, setTargetPeer, widgetVisibility } = usePersistedSessionState(stateSubscription, instance); - const { subscribe, getSnapshot } = stateSubscription; + const { openWidget, closeWidget, targetPeer, setTargetPeer, targetWidgetVisibility, openRoomId, setOpenRoomId } = + useInstanceState(instance); const [remoteStreamRefCallback, audioElement] = useAudioStream(instance); @@ -35,7 +29,6 @@ const MediaCallInstanceProvider = ({ children, enabled = true }: MediaCallInstan const value = useMemo( () => ({ instance, - signalEmitter, audioElement, openRoomId, setOpenRoomId, @@ -43,32 +36,26 @@ const MediaCallInstanceProvider = ({ children, enabled = true }: MediaCallInstan currentViews, registerView, unregisterView, - stateSubscription: { - subscribe, - getSnapshot, - }, openWidget, closeWidget, setTargetPeer, targetPeer, - widgetVisibility, + targetWidgetVisibility, }), [ instance, - signalEmitter, audioElement, openRoomId, + setOpenRoomId, getAutocompleteOptions, currentViews, registerView, unregisterView, - subscribe, - getSnapshot, openWidget, closeWidget, setTargetPeer, targetPeer, - widgetVisibility, + targetWidgetVisibility, ], ); diff --git a/packages/ui-voip/src/providers/MediaCallViewProvider.tsx b/packages/ui-voip/src/providers/MediaCallViewProvider.tsx index ba1a1697eaac1..792cddf5b43b8 100644 --- a/packages/ui-voip/src/providers/MediaCallViewProvider.tsx +++ b/packages/ui-voip/src/providers/MediaCallViewProvider.tsx @@ -8,11 +8,12 @@ import { useToastMessageDispatch, } from '@rocket.chat/ui-contexts'; import type { ReactNode } from 'react'; -import { useCallback, useEffect, useSyncExternalStore } from 'react'; +import { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useCallSounds } from './useCallSounds'; import { useDesktopNotifications } from './useDesktopNotifications'; +import { useMediaSession } from './useMediaSession'; import { useMediaSessionControls } from './useMediaSessionControls'; import { useScreenShareStreams } from './useScreenShareStreams'; import useWidgetPositionTracker from './useWidgetPositionTracker'; @@ -33,10 +34,9 @@ const MediaCallViewProvider = ({ children }: MediaCallViewProviderProps) => { const setModal = useSetModal(); - const { instance, audioElement, openRoomId, registerView, unregisterView, stateSubscription, setTargetPeer, targetPeer } = - useMediaCallInstance(); + const { instance, audioElement, openRoomId, registerView, unregisterView, setTargetPeer, targetPeer } = useMediaCallInstance(); - const sessionState = useSyncExternalStore(stateSubscription.subscribe, stateSubscription.getSnapshot); + const sessionState = useMediaSession(instance); const controls = useMediaSessionControls(instance); useDesktopNotifications(sessionState); @@ -215,7 +215,7 @@ const MediaCallViewProvider = ({ children }: MediaCallViewProviderProps) => { const streams = useScreenShareStreams(instance); - const { onChangePosition, getRestorePosition } = useWidgetPositionTracker(); + const { onChangePosition, lastKnownPosition } = useWidgetPositionTracker(); useEffect(() => { return instance?.on('endedCall', () => { @@ -242,7 +242,7 @@ const MediaCallViewProvider = ({ children }: MediaCallViewProviderProps) => { streams, widgetPositionTracker: { onChangePosition, - getRestorePosition, + lastKnownPosition, }, }; diff --git a/packages/ui-voip/src/providers/MockedInstanceProvider.tsx b/packages/ui-voip/src/providers/MockedInstanceProvider.tsx index 2aac61e8d06f8..ff2260813ddb0 100644 --- a/packages/ui-voip/src/providers/MockedInstanceProvider.tsx +++ b/packages/ui-voip/src/providers/MockedInstanceProvider.tsx @@ -1,14 +1,12 @@ -import { Emitter } from '@rocket.chat/emitter'; import type { MediaSignalingSession } from '@rocket.chat/media-signaling'; import type { ReactNode } from 'react'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import useAvailableViewTracker from './useAvailableViewTracker'; -import { defaultSessionInfo } from './useMediaSessionStateSubscription'; -import type { MediaSessionStateSubscription } from './useMediaSessionStateSubscription'; -import { usePersistedSessionState } from './usePersistedSessionState'; +import { useInstanceState } from './useInstanceState'; import { MediaCallInstanceContext } from '../context/MediaCallInstanceContext'; -import type { MediaCallInstanceContextValue, Signals } from '../context/MediaCallInstanceContext'; +import type { MediaCallInstanceContextValue } from '../context/MediaCallInstanceContext'; +import { defaultSessionState } from '../context/MediaCallViewContext'; import type { SessionState } from '../context/definitions'; export const avatarUrl = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAAAAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAgMREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6LxqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVrjbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRkeX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkBSuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlPUH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z`; @@ -25,10 +23,8 @@ export type MockedInstanceProviderProps = { sessionState?: SessionState; } & Partial; -const MockedInstanceProvider = ({ children, sessionState = defaultSessionInfo, ...props }: MockedInstanceProviderProps) => { +const MockedInstanceProvider = ({ children, sessionState = defaultSessionState, ...props }: MockedInstanceProviderProps) => { const { currentViews, registerView, unregisterView } = useAvailableViewTracker(); - const [openRoomId, setOpenRoomId] = useState(undefined); - const [signalEmitter] = useState(() => new Emitter()); const [instance] = useState( () => ({ @@ -41,15 +37,8 @@ const MockedInstanceProvider = ({ children, sessionState = defaultSessionInfo, . const sessionStateRef = useRef(sessionState); sessionStateRef.current = sessionState; - const stateSubscription = useMemo( - () => ({ - subscribe: () => () => undefined, - getSnapshot: () => sessionStateRef.current, - }), - [], - ); - - const { openWidget, closeWidget, targetPeer, setTargetPeer, widgetVisibility } = usePersistedSessionState(stateSubscription, instance); + const { openWidget, closeWidget, targetPeer, setTargetPeer, targetWidgetVisibility, openRoomId, setOpenRoomId } = + useInstanceState(instance); const getAutocompleteOptions = useCallback( (filter: string) => Promise.resolve(mockedPeers.filter((peer) => peer.label.toLowerCase().includes(filter.toLowerCase()))), @@ -58,15 +47,13 @@ const MockedInstanceProvider = ({ children, sessionState = defaultSessionInfo, . const value = { instance, - signalEmitter, audioElement: undefined, openRoomId, setOpenRoomId, currentViews, registerView, unregisterView, - stateSubscription, - widgetVisibility, + targetWidgetVisibility, targetPeer, setTargetPeer, getAutocompleteOptions, diff --git a/packages/ui-voip/src/providers/useAvailableViewTracker.ts b/packages/ui-voip/src/providers/useAvailableViewTracker.ts index cde6b2afd139d..9cfeadaf3d699 100644 --- a/packages/ui-voip/src/providers/useAvailableViewTracker.ts +++ b/packages/ui-voip/src/providers/useAvailableViewTracker.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react'; +import { useCallback, useRef, useState } from 'react'; import type { AvailableViews } from '../context/MediaCallInstanceContext'; @@ -13,68 +13,36 @@ const filter = (view: AvailableViews, _index: number, array: AvailableViews[]) = } }; -const FLUSH_DELAY = 100; - const useAvailableViewTracker = () => { const viewsRef = useRef>(new Set()); - const filteredViewsRef = useRef([]); - - const [registerView, unregisterView, subscribeToViews] = useMemo(() => { - let timeout: NodeJS.Timeout | undefined; + const [currentViews, setCurrentViews] = useState>(new Set()); - let sub: (() => void) | undefined = undefined; - const subscribeToViews = (onStoreChange: () => void) => { - sub = onStoreChange; - return () => { - sub = undefined; - }; - }; + const registerView = useCallback((view: AvailableViews) => { + if (viewsRef.current.has(view)) return; - // TODO maybe we don't need to debounce this - // It is used to prevent an useEffect from unregistering the view too early - // Specially when the view will be re-registered when the effect runs again - // meaning it should not have unregistered at all - const flushDebounced = () => { - if (timeout) { - clearTimeout(timeout); - timeout = undefined; + viewsRef.current.add(view); + const filteredViews = new Set([...viewsRef.current].filter(filter)); + setCurrentViews((prev) => { + if (filteredViews.size === prev.size && filteredViews.isSubsetOf(prev)) { + return prev; } + return filteredViews; + }); + }, []); - timeout = setTimeout(() => { - const viewsArray = [...viewsRef.current].filter(filter); - if (viewsArray.length === filteredViewsRef.current.length && viewsArray.every((view) => filteredViewsRef.current.includes(view))) { - return; - } - filteredViewsRef.current = viewsArray; - return sub?.(); - }, FLUSH_DELAY); - }; + const unregisterView = useCallback((view: AvailableViews) => { + if (!viewsRef.current.has(view)) return; - const unregisterView = (view: AvailableViews) => { - if (!viewsRef.current.has(view)) { - return; + viewsRef.current.delete(view); + const filteredViews = new Set([...viewsRef.current].filter(filter)); + setCurrentViews((prev) => { + if (filteredViews.size === prev.size && filteredViews.isSubsetOf(prev)) { + return prev; } - viewsRef.current.delete(view); - flushDebounced(); - }; - - const registerView = (view: AvailableViews) => { - if (viewsRef.current.has(view)) { - return; - } - viewsRef.current.add(view); - flushDebounced(); - }; - - return [registerView, unregisterView, subscribeToViews]; + return filteredViews; + }); }, []); - const currentViews = useSyncExternalStore( - subscribeToViews, - useCallback(() => filteredViewsRef.current, []), - useCallback(() => filteredViewsRef.current, []), - ); - return { currentViews, registerView, diff --git a/packages/ui-voip/src/providers/useGetAutocompleteOptions.ts b/packages/ui-voip/src/providers/useGetAutocompleteOptions.ts index 94f2e3e24294a..1761c7121b1fb 100644 --- a/packages/ui-voip/src/providers/useGetAutocompleteOptions.ts +++ b/packages/ui-voip/src/providers/useGetAutocompleteOptions.ts @@ -2,7 +2,7 @@ import type { MediaSignalingSession } from '@rocket.chat/media-signaling'; import { useEndpoint, useSetting, useUser, useUserAvatarPath } from '@rocket.chat/ui-contexts'; import { useCallback } from 'react'; -import { getExtensionFromInstanceContact } from './useMediaSessionStateSubscription'; +import { getExtensionFromInstanceContact } from './useMediaSession'; export const useGetAutocompleteOptions = (instance: MediaSignalingSession | undefined) => { const user = useUser(); diff --git a/packages/ui-voip/src/providers/useInstanceState.ts b/packages/ui-voip/src/providers/useInstanceState.ts new file mode 100644 index 0000000000000..f9bda19e6e083 --- /dev/null +++ b/packages/ui-voip/src/providers/useInstanceState.ts @@ -0,0 +1,34 @@ +import type { MediaSignalingSession } from '@rocket.chat/media-signaling'; +import { useCallback, useEffect, useState } from 'react'; + +import type { PeerInfo } from '../context'; + +export const useInstanceState = (instance?: MediaSignalingSession) => { + const [openRoomId, setOpenRoomId] = useState(undefined); + const [targetWidgetVisibility, setTargetWidgetVisibility] = useState<'open' | 'closed'>('closed'); + const [targetPeer, setTargetPeer] = useState(undefined); + + const openWidget = useCallback((peerInfo?: PeerInfo) => { + setTargetWidgetVisibility('open'); + setTargetPeer((oldPeerInfo) => { + if (!peerInfo) { + return oldPeerInfo; + } + return peerInfo; + }); + }, []); + + const closeWidget = useCallback(() => { + setTargetWidgetVisibility('closed'); + }, []); + + // If a call ended the widget should close. + useEffect(() => { + return instance?.on('endedCall', () => { + setTargetWidgetVisibility('closed'); + setTargetPeer(undefined); + }); + }, [instance]); + + return { openWidget, closeWidget, targetPeer, setTargetPeer, targetWidgetVisibility, openRoomId, setOpenRoomId }; +}; diff --git a/packages/ui-voip/src/providers/useMediaSession.ts b/packages/ui-voip/src/providers/useMediaSession.ts new file mode 100644 index 0000000000000..5535ea512fac5 --- /dev/null +++ b/packages/ui-voip/src/providers/useMediaSession.ts @@ -0,0 +1,203 @@ +import type { UserStatus } from '@rocket.chat/core-typings'; +import type { MediaSignalingSession, CallState, CallContact } from '@rocket.chat/media-signaling'; +import { useUserAvatarPath, useUserPresence } from '@rocket.chat/ui-contexts'; +import { useEffect, useReducer } from 'react'; + +import { defaultSessionState } from '../context/MediaCallViewContext'; +import type { ConnectionState, SessionState } from '../context/definitions'; +import { derivePeerInfoFromInstanceContact } from '../utils/derivePeerInfoFromInstanceContact'; +import { deriveWidgetStateFromCallState } from '../utils/deriveWidgetStateFromCallState'; + +export const getExtensionFromInstanceContact = (contact: CallContact): string | undefined => { + if (contact.type === 'sip') { + return contact.id; + } + + return contact.sipExtension; +}; + +const deriveConnectionStateFromCallState = (callState: CallState): ConnectionState => { + switch (callState) { + case 'renegotiating': + return 'RECONNECTING'; + case 'ringing': + case 'active': + return 'CONNECTED'; + case 'none': + case 'accepted': + default: + return 'CONNECTING'; + } +}; + +const reducer = ( + reducerState: SessionState, + action: + | { + type: 'reset'; + } + | { + type: 'instance_updated'; + payload: SessionState; + } + | { + type: 'status_updated'; + payload?: { status?: UserStatus }; + }, +): SessionState => { + if (action.type === 'instance_updated') { + return { ...reducerState, ...action.payload }; + } + + if (action.type === 'reset') { + return defaultSessionState; + } + + if (action.type === 'status_updated' && reducerState.peerInfo && 'userId' in reducerState.peerInfo) { + return { ...reducerState, peerInfo: { ...reducerState.peerInfo, status: action.payload?.status } }; + } + + return reducerState; +}; + +export const useMediaSession = (instance?: MediaSignalingSession): SessionState => { + const [mediaSession, dispatch] = useReducer(reducer, defaultSessionState); + + const getAvatarUrl = useUserAvatarPath(); + + useEffect(() => { + if (!instance) { + dispatch({ type: 'reset' }); + return; + } + + const updateSessionState = () => { + const instanceState = instance.getState(); + if (!instanceState) { + dispatch({ type: 'reset' }); + return; + } + + const { + state: callState, + localParticipant: { role, muted, held }, + } = instanceState; + const state = deriveWidgetStateFromCallState(callState, role); + + if (!state) { + dispatch({ type: 'reset' }); + return; + } + + const connectionState = deriveConnectionStateFromCallState(callState); + + if (!instanceState.confirmed) { + dispatch({ + type: 'instance_updated', + payload: { + peerInfo: { + displayName: instanceState.title, + userId: 'unknown', + username: undefined, + callerId: undefined, + }, + transferredBy: undefined, + state, + muted, + held, + connectionState, + hidden: false, + remoteHeld: false, + remoteMuted: false, + callId: instanceState.tempCallId, + startedAt: undefined, + supportedFeatures: [], + }, + }); + return; + } + + const { + hidden, + callId, + activeTimestamp: startedAt, + features: supportedFeatures, + transferredBy: callTransferredBy, + remoteParticipant: { muted: remoteMuted, held: remoteHeld, contact }, + } = instanceState; + + const transferredBy = callTransferredBy?.displayName || callTransferredBy?.username || undefined; + + if (contact.type === 'sip') { + dispatch({ + type: 'instance_updated', + payload: { + peerInfo: derivePeerInfoFromInstanceContact(contact), + transferredBy, + state, + muted, + held, + connectionState, + hidden, + remoteHeld, + remoteMuted, + callId, + startedAt, + supportedFeatures, + }, + }); + return; + } + + const avatarUrl = (() => { + if (contact.username) { + return getAvatarUrl({ username: contact.username }); + } + + if (contact.id) { + return getAvatarUrl({ userId: contact.id }); + } + + return undefined; + })(); + + const peerInfo = { ...derivePeerInfoFromInstanceContact(contact), avatarUrl }; + + dispatch({ + type: 'instance_updated', + payload: { + state, + peerInfo, + transferredBy, + muted, + held, + connectionState, + hidden, + remoteHeld, + remoteMuted, + callId, + startedAt, + supportedFeatures, + }, + }); + }; + + const offCbs = [instance.on('sessionStateChange', updateSessionState), instance.on('hiddenCall', updateSessionState)]; + + updateSessionState(); + + return () => { + offCbs.forEach((offCb) => offCb()); + }; + }, [getAvatarUrl, instance]); + + const status = useUserPresence(mediaSession.peerInfo && 'userId' in mediaSession.peerInfo ? mediaSession.peerInfo.userId : undefined); + + useEffect(() => { + if (status?.status) { + dispatch({ type: 'status_updated', payload: { status: status.status } }); + } + }, [status?.status]); + + return mediaSession; +}; diff --git a/packages/ui-voip/src/providers/useMediaSessionStateSubscription.ts b/packages/ui-voip/src/providers/useMediaSessionStateSubscription.ts deleted file mode 100644 index 7c930c81c9eb8..0000000000000 --- a/packages/ui-voip/src/providers/useMediaSessionStateSubscription.ts +++ /dev/null @@ -1,202 +0,0 @@ -import type { UserStatus } from '@rocket.chat/core-typings'; -import type { MediaSignalingSession, CallState, CallContact } from '@rocket.chat/media-signaling'; -import { useUserAvatarPath, useUserPresence } from '@rocket.chat/ui-contexts'; -import { useCallback, useRef } from 'react'; - -import type { ConnectionState, SessionState } from '../context/definitions'; -import { derivePeerInfoFromInstanceContact } from '../utils/derivePeerInfoFromInstanceContact'; -import { deriveWidgetStateFromCallState } from '../utils/deriveWidgetStateFromCallState'; - -export const defaultSessionInfo: SessionState = { - state: 'none' as const, - callId: undefined, - connectionState: 'CONNECTING' as const, - peerInfo: undefined, - transferredBy: undefined, - muted: false, - held: false, - remoteMuted: false, - remoteHeld: false, - startedAt: undefined, - hidden: false, - supportedFeatures: ['audio', 'transfer', 'hold'], -}; - -export const getExtensionFromInstanceContact = (contact: CallContact): string | undefined => { - if (contact.type === 'sip') { - return contact.id; - } - - return contact.sipExtension; -}; - -const deriveConnectionStateFromCallState = (callState: CallState): ConnectionState => { - switch (callState) { - case 'renegotiating': - return 'RECONNECTING'; - case 'ringing': - case 'active': - return 'CONNECTED'; - case 'none': - case 'accepted': - default: - return 'CONNECTING'; - } -}; - -export type MediaSessionStateSubscription = { - subscribe: (onStoreChange: () => void) => () => void; - getSnapshot: () => SessionState; -}; - -type GetStateParams = { - instance: MediaSignalingSession | undefined; - status?: UserStatus; - getAvatarUrl: ReturnType; -}; - -const getState = ({ instance, status, getAvatarUrl }: GetStateParams): SessionState | undefined => { - if (!instance) { - return undefined; - } - - const instanceState = instance.getState(); - if (!instanceState) { - return undefined; - } - - const { - state: callState, - localParticipant: { role, muted, held }, - } = instanceState; - const state = deriveWidgetStateFromCallState(callState, role); - - if (!state) { - return undefined; - } - - const connectionState = deriveConnectionStateFromCallState(callState); - - if (!instanceState.confirmed) { - return { - peerInfo: { - displayName: instanceState.title, - userId: 'unknown', - username: undefined, - callerId: undefined, - }, - transferredBy: undefined, - state, - muted, - held, - connectionState, - hidden: false, - remoteHeld: false, - remoteMuted: false, - callId: instanceState.tempCallId, - startedAt: undefined, - supportedFeatures: [], - }; - } - - const { - hidden, - callId, - activeTimestamp: startedAt, - features: supportedFeatures, - transferredBy: callTransferredBy, - remoteParticipant: { muted: remoteMuted, held: remoteHeld, contact }, - } = instanceState; - - const transferredBy = callTransferredBy?.displayName || callTransferredBy?.username || undefined; - - if (contact.type === 'sip') { - return { - peerInfo: derivePeerInfoFromInstanceContact(contact), - transferredBy, - state, - muted, - held, - connectionState, - hidden, - remoteHeld, - remoteMuted, - callId, - startedAt, - supportedFeatures, - }; - } - - const avatarUrl = (() => { - if (contact.username) { - return getAvatarUrl({ username: contact.username }); - } - - if (contact.id) { - return getAvatarUrl({ userId: contact.id }); - } - - return undefined; - })(); - - const peerInfo = { ...derivePeerInfoFromInstanceContact(contact), avatarUrl, status }; - - return { - state, - peerInfo, - transferredBy, - muted, - held, - connectionState, - hidden, - remoteHeld, - remoteMuted, - callId, - startedAt, - supportedFeatures, - }; -}; - -export const useMediaSessionStateSubscription = (instance?: MediaSignalingSession): MediaSessionStateSubscription => { - const cacheRef = useRef(defaultSessionInfo); - - const getAvatarUrl = useUserAvatarPath(); - - const presence = useUserPresence( - cacheRef.current?.peerInfo && 'userId' in cacheRef.current.peerInfo ? cacheRef.current.peerInfo.userId : undefined, - ); - - const { status } = presence || {}; - - const subscribe = useCallback( - (onStoreChange: () => void): (() => void) => { - if (!instance) { - return () => undefined; - } - const onEvent = () => { - const newState = getState({ instance, status, getAvatarUrl }); - if (!newState) { - cacheRef.current = defaultSessionInfo; - } else { - cacheRef.current = { ...cacheRef.current, ...newState }; - } - if (newState === cacheRef.current) { - return; - } - onStoreChange(); - }; - const offCbs = [instance.on('sessionStateChange', onEvent), instance.on('hiddenCall', onEvent)]; - return () => offCbs.forEach((cb) => cb()); - }, - [getAvatarUrl, instance, status], - ); - - const getSnapshot = useCallback(() => { - return cacheRef.current; - }, []); - - return { - subscribe, - getSnapshot, - }; -}; diff --git a/packages/ui-voip/src/providers/usePersistedSessionState.ts b/packages/ui-voip/src/providers/usePersistedSessionState.ts deleted file mode 100644 index de315ac3b5aaf..0000000000000 --- a/packages/ui-voip/src/providers/usePersistedSessionState.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { MediaSignalingSession } from '@rocket.chat/media-signaling'; -import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'; - -import type { PeerInfo } from '../context'; -import type { MediaSessionStateSubscription } from './useMediaSessionStateSubscription'; - -export const usePersistedSessionState = (sessionStateSubscription: MediaSessionStateSubscription, instance?: MediaSignalingSession) => { - const [targetWidgetVisibility, setTargetWidgetVisibility] = useState<'open' | 'closed'>('closed'); - const [targetPeer, setTargetPeer] = useState(undefined); - - const openWidget = useCallback((peerInfo?: PeerInfo) => { - setTargetWidgetVisibility('open'); - setTargetPeer((oldPeerInfo) => { - if (!peerInfo) { - return oldPeerInfo; - } - return peerInfo; - }); - }, []); - - const closeWidget = useCallback(() => { - setTargetWidgetVisibility('closed'); - }, []); - - const { subscribe: subscribeToState, getSnapshot: getStateSnapshot } = sessionStateSubscription; - - const getWidgetVisibility = useCallback(() => { - const { state } = getStateSnapshot(); - if (['calling', 'ringing', 'ongoing'].includes(state)) { - return 'open'; - } - return targetWidgetVisibility; - }, [getStateSnapshot, targetWidgetVisibility]); - - const widgetVisibility = useSyncExternalStore(subscribeToState, getWidgetVisibility); - - // If a call ended the widget should close. - useEffect(() => { - return instance?.on('endedCall', () => { - setTargetWidgetVisibility('closed'); - setTargetPeer(undefined); - }); - }, [instance]); - - return { openWidget, closeWidget, targetPeer, setTargetPeer, widgetVisibility }; -}; diff --git a/packages/ui-voip/src/providers/useWidgetExternalControlSignalListener.ts b/packages/ui-voip/src/providers/useWidgetExternalControlSignalListener.ts deleted file mode 100644 index 92371677d3085..0000000000000 --- a/packages/ui-voip/src/providers/useWidgetExternalControlSignalListener.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { EventHandlerOf } from '@rocket.chat/emitter'; -import { useEffect } from 'react'; - -import { useMediaCallInstance, type Signals } from '../context/MediaCallInstanceContext'; - -export const useWidgetExternalControlSignalListener = (signal: T, callback: EventHandlerOf) => { - const { signalEmitter } = useMediaCallInstance(); - - useEffect(() => { - return signalEmitter.on(signal, callback); - }, [callback, signal, signalEmitter]); -}; diff --git a/packages/ui-voip/src/providers/useWidgetPositionTracker.ts b/packages/ui-voip/src/providers/useWidgetPositionTracker.ts index 703f88642193b..ddc609faa9064 100644 --- a/packages/ui-voip/src/providers/useWidgetPositionTracker.ts +++ b/packages/ui-voip/src/providers/useWidgetPositionTracker.ts @@ -1,5 +1,5 @@ import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; -import { useCallback, useRef } from 'react'; +import { useState } from 'react'; export type LastKnownPosition = { x: number; @@ -9,23 +9,13 @@ export type LastKnownPosition = { }; const useWidgetPositionTracker = () => { - const lastKnownPosition = useRef(null); + const [lastKnownPosition, setLastKnownPosition] = useState(null); - const onChangePosition = useDebouncedCallback( - (position: LastKnownPosition | null) => { - lastKnownPosition.current = position; - }, - 500, - [], - ); - - const getRestorePosition = useCallback(() => { - return lastKnownPosition.current; - }, []); + const onChangePosition = useDebouncedCallback(setLastKnownPosition, 500, []); return { onChangePosition, - getRestorePosition, + lastKnownPosition, }; }; diff --git a/packages/ui-voip/src/views/MediaCallPopout.tsx b/packages/ui-voip/src/views/MediaCallPopout.tsx index ed221ebad7d06..dd3e9a93287c9 100644 --- a/packages/ui-voip/src/views/MediaCallPopout.tsx +++ b/packages/ui-voip/src/views/MediaCallPopout.tsx @@ -31,7 +31,7 @@ const MediaCallPopout = () => { useEffect(() => { queueMicrotask(() => { - if (currentViews.includes('popout') && callId) { + if (currentViews.has('popout') && callId) { void openPopoutWindow(callId); return; } diff --git a/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx b/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx index cc658f5c01c19..71b04cb5d5e62 100644 --- a/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx +++ b/packages/ui-voip/src/views/MediaCallRoomSection/MediaCallRoomSection.tsx @@ -58,7 +58,7 @@ const MediaCallRoomSection = ({ showChat, onToggleChat, user, containerHeight }: } = useMediaCallView(); const { currentViews } = useMediaCallInstance(); - const isPopout = currentViews.includes('popout'); + const isPopout = currentViews.has('popout'); const { muted, held, peerInfo, connectionState, startedAt } = sessionState; diff --git a/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.spec.tsx b/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.spec.tsx index 171af0244e568..819491709a1e3 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.spec.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.spec.tsx @@ -9,7 +9,6 @@ const testCases = Object.values(composeStories(stories)).map((Story) => [Story.s test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { const view = render(, { wrapper: mockAppRoot().build() }); - await new Promise((resolve) => setTimeout(resolve, 500)); expect(view.baseElement).toMatchSnapshot(); }); diff --git a/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.stories.tsx b/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.stories.tsx index e06c4b4bcad91..583862d907973 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.stories.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.stories.tsx @@ -26,7 +26,7 @@ const meta = { decorators: [ mockedContexts, (Story, options) => ( - + ), @@ -38,10 +38,10 @@ type Story = StoryObj; export const MediaCallWidgetManualTesting: Story = { args: { - instanceProps: undefined, + instanceProps: {}, }, render: () => { - const { widgetVisibility } = useMediaCallInstance(); + const { targetWidgetVisibility } = useMediaCallInstance(); const { sessionState, onCall } = useMediaCallView(); const { toggleWidget } = useWidgetExternalControls(); const { state } = sessionState; @@ -50,7 +50,7 @@ export const MediaCallWidgetManualTesting: Story = { - diff --git a/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.tsx b/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.tsx index 423e921d11e07..cfe2977807940 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidget.tsx @@ -5,13 +5,15 @@ import { useMediaCallView } from '../../context/MediaCallViewContext'; import useRegisterView from '../../context/useRegisterView'; const MediaCallWidget = () => { - const { widgetVisibility } = useMediaCallInstance(); + const { targetWidgetVisibility } = useMediaCallInstance(); const currentViews = useRegisterView('widget'); const { - sessionState: { hidden }, + sessionState: { hidden, state }, } = useMediaCallView(); - if (hidden || !currentViews.includes('widget') || widgetVisibility === 'closed') { + const widgetVisible = targetWidgetVisibility === 'open' || state !== 'none'; + + if (hidden || !currentViews.has('widget') || !widgetVisible) { return null; } diff --git a/packages/ui-voip/src/views/MediaCallWidget/NewCall.tsx b/packages/ui-voip/src/views/MediaCallWidget/NewCall.tsx index b31f8a800b340..085c1fc7611c0 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/NewCall.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/NewCall.tsx @@ -19,8 +19,7 @@ import { useWidgetExternalControls } from '../../context/useWidgetExternalContro const NewCall = () => { const { t } = useTranslation(); - const { sessionState, onCall, onSelectPeer, targetPeer } = useMediaCallView(); - const { peerInfo } = sessionState; + const { onCall, onSelectPeer, targetPeer } = useMediaCallView(); const { toggleWidget } = useWidgetExternalControls(); const autocomplete = usePeerAutocomplete(onSelectPeer, targetPeer); @@ -33,9 +32,9 @@ const NewCall = () => { - {peerInfo && ( + {targetPeer && ( - + )} diff --git a/packages/ui-voip/src/views/MediaCallWidget/OngoingCallWithScreen.tsx b/packages/ui-voip/src/views/MediaCallWidget/OngoingCallWithScreen.tsx index a19a59a0f1e25..de413b08340dc 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/OngoingCallWithScreen.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/OngoingCallWithScreen.tsx @@ -38,7 +38,7 @@ const OngoingCall = () => { } = useMediaCallView(); const { muted, held, remoteMuted, remoteHeld, peerInfo, connectionState, startedAt } = sessionState; const { currentViews } = useMediaCallInstance(); - const isPopout = currentViews.includes('popout'); + const isPopout = currentViews.has('popout'); const { localScreen, remoteScreen } = streams; From f2aa1de784c5b83f6ce6ab004437eb01b5beeb87 Mon Sep 17 00:00:00 2001 From: gabriellsh Date: Fri, 31 Jul 2026 18:59:12 -0300 Subject: [PATCH 05/10] fix: Missing mock property --- packages/ui-voip/src/providers/MockedMediaCallProvider.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx b/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx index ce77b544583c9..f0088dc4dd886 100644 --- a/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx +++ b/packages/ui-voip/src/providers/MockedMediaCallProvider.tsx @@ -125,6 +125,7 @@ const MockedMediaCallProvider = ({ const contextValue = { sessionState, + targetPeer: peerInfo, onClickDirectMessage, onMute, onHold, From 6ccc49b1a8aa1fcb04655ecd9ae2d3d9e60920c0 Mon Sep 17 00:00:00 2001 From: gabriellsh Date: Mon, 3 Aug 2026 18:28:06 -0300 Subject: [PATCH 06/10] chore: Verify if call supports screensharing only through supportedFeatures flag. --- .../src/views/MediaCallWidget/MediaCallWidgetViewRouter.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidgetViewRouter.tsx b/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidgetViewRouter.tsx index be3ce7363cb00..54749bd651c92 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidgetViewRouter.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/MediaCallWidgetViewRouter.tsx @@ -4,12 +4,12 @@ import { useMediaCallView } from '../../context/MediaCallViewContext'; const MediaCallWidgetViewRouter = () => { const { - sessionState: { state, transferredBy, peerInfo, supportedFeatures }, + sessionState: { state, transferredBy, supportedFeatures }, } = useMediaCallView(); switch (state) { case 'ongoing': - if ('username' in peerInfo && supportedFeatures.includes('screen-share')) { + if (supportedFeatures.includes('screen-share')) { return ; } return ; From 98033f7bc07e0889a968f3d4ad8c1dea4598e25b Mon Sep 17 00:00:00 2001 From: gabriellsh Date: Tue, 4 Aug 2026 15:08:33 -0300 Subject: [PATCH 07/10] fix: replace `isSubsetOf` with array comparison --- .../src/providers/useAvailableViewTracker.ts | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/ui-voip/src/providers/useAvailableViewTracker.ts b/packages/ui-voip/src/providers/useAvailableViewTracker.ts index 9cfeadaf3d699..e68af56343815 100644 --- a/packages/ui-voip/src/providers/useAvailableViewTracker.ts +++ b/packages/ui-voip/src/providers/useAvailableViewTracker.ts @@ -13,6 +13,15 @@ const filter = (view: AvailableViews, _index: number, array: AvailableViews[]) = } }; +const getViewsSetStateAction = + (filteredViews: AvailableViews[]) => + (prev: Set): Set => { + if (filteredViews.length === prev.size && filteredViews.every((view) => prev.has(view))) { + return prev; + } + return new Set(filteredViews); + }; + const useAvailableViewTracker = () => { const viewsRef = useRef>(new Set()); const [currentViews, setCurrentViews] = useState>(new Set()); @@ -21,26 +30,16 @@ const useAvailableViewTracker = () => { if (viewsRef.current.has(view)) return; viewsRef.current.add(view); - const filteredViews = new Set([...viewsRef.current].filter(filter)); - setCurrentViews((prev) => { - if (filteredViews.size === prev.size && filteredViews.isSubsetOf(prev)) { - return prev; - } - return filteredViews; - }); + const filteredViews = [...viewsRef.current].filter(filter); + setCurrentViews(getViewsSetStateAction(filteredViews)); }, []); const unregisterView = useCallback((view: AvailableViews) => { if (!viewsRef.current.has(view)) return; viewsRef.current.delete(view); - const filteredViews = new Set([...viewsRef.current].filter(filter)); - setCurrentViews((prev) => { - if (filteredViews.size === prev.size && filteredViews.isSubsetOf(prev)) { - return prev; - } - return filteredViews; - }); + const filteredViews = [...viewsRef.current].filter(filter); + setCurrentViews(getViewsSetStateAction(filteredViews)); }, []); return { From bef9397cd9d626779ed3161891e66fe7d5d36a70 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 4 Aug 2026 16:10:42 -0300 Subject: [PATCH 08/10] refactor: simplify view tracking logic and remove unused code --- .../src/providers/useAvailableViewTracker.ts | 49 +++++++++++-------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/packages/ui-voip/src/providers/useAvailableViewTracker.ts b/packages/ui-voip/src/providers/useAvailableViewTracker.ts index e68af56343815..08baff0079c5d 100644 --- a/packages/ui-voip/src/providers/useAvailableViewTracker.ts +++ b/packages/ui-voip/src/providers/useAvailableViewTracker.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useState } from 'react'; import type { AvailableViews } from '../context/MediaCallInstanceContext'; @@ -13,37 +13,44 @@ const filter = (view: AvailableViews, _index: number, array: AvailableViews[]) = } }; -const getViewsSetStateAction = - (filteredViews: AvailableViews[]) => - (prev: Set): Set => { - if (filteredViews.length === prev.size && filteredViews.every((view) => prev.has(view))) { - return prev; - } - return new Set(filteredViews); - }; - const useAvailableViewTracker = () => { - const viewsRef = useRef>(new Set()); - const [currentViews, setCurrentViews] = useState>(new Set()); + // keep in mind views.currentViews is a stable set, so please if you are going to use it in a useEffect, make sure to create a new set from it, otherwise you will not be able to track changes in the set. + const [views, setViews] = useState<{ + currentViews: Set; + filteredViews: Set; + }>({ + currentViews: new Set(), + filteredViews: new Set(), + }); const registerView = useCallback((view: AvailableViews) => { - if (viewsRef.current.has(view)) return; - viewsRef.current.add(view); - const filteredViews = [...viewsRef.current].filter(filter); - setCurrentViews(getViewsSetStateAction(filteredViews)); + setViews((prev) => { + if (prev.currentViews.has(view)) return prev; + + prev.currentViews.add(view); + return { + currentViews: prev.currentViews, + filteredViews: new Set(Array.from(prev.currentViews).filter(filter)), + }; + }); }, []); const unregisterView = useCallback((view: AvailableViews) => { - if (!viewsRef.current.has(view)) return; + setViews((prev) => { + if (!prev.currentViews.has(view)) return prev; + - viewsRef.current.delete(view); - const filteredViews = [...viewsRef.current].filter(filter); - setCurrentViews(getViewsSetStateAction(filteredViews)); + prev.currentViews.delete(view); + return { + currentViews: prev.currentViews, + filteredViews: new Set(Array.from(prev.currentViews).filter(filter)), + }; + }); }, []); return { - currentViews, + currentViews: views.currentViews, registerView, unregisterView, }; From 6628738d1c455b119006a4b15999e81a6aaac4cb Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 4 Aug 2026 18:27:18 -0300 Subject: [PATCH 09/10] chore: fix formatting --- packages/ui-voip/src/providers/useAvailableViewTracker.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/ui-voip/src/providers/useAvailableViewTracker.ts b/packages/ui-voip/src/providers/useAvailableViewTracker.ts index 08baff0079c5d..efd26cc79e957 100644 --- a/packages/ui-voip/src/providers/useAvailableViewTracker.ts +++ b/packages/ui-voip/src/providers/useAvailableViewTracker.ts @@ -24,7 +24,6 @@ const useAvailableViewTracker = () => { }); const registerView = useCallback((view: AvailableViews) => { - setViews((prev) => { if (prev.currentViews.has(view)) return prev; @@ -40,7 +39,6 @@ const useAvailableViewTracker = () => { setViews((prev) => { if (!prev.currentViews.has(view)) return prev; - prev.currentViews.delete(view); return { currentViews: prev.currentViews, From 1e53938b7b41a2ed6fbce1886d1f715ed014af19 Mon Sep 17 00:00:00 2001 From: gabriellsh Date: Tue, 4 Aug 2026 19:31:23 -0300 Subject: [PATCH 10/10] fix: viewTracker returning unfiltered views --- packages/ui-voip/src/providers/useAvailableViewTracker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui-voip/src/providers/useAvailableViewTracker.ts b/packages/ui-voip/src/providers/useAvailableViewTracker.ts index efd26cc79e957..25ce5c9fa3a44 100644 --- a/packages/ui-voip/src/providers/useAvailableViewTracker.ts +++ b/packages/ui-voip/src/providers/useAvailableViewTracker.ts @@ -48,7 +48,7 @@ const useAvailableViewTracker = () => { }, []); return { - currentViews: views.currentViews, + currentViews: views.filteredViews, registerView, unregisterView, };