diff --git a/src/components/Call/Call.js b/src/components/Call/Call.js index 339ef66..a8c01b1 100644 --- a/src/components/Call/Call.js +++ b/src/components/Call/Call.js @@ -1,123 +1,211 @@ -import React, { useEffect, useContext, useReducer, useCallback } from 'react'; +import React, { + useEffect, + useContext, + useState, + useCallback, + useMemo, +} from 'react'; import './Call.css'; import Tile from '../Tile/Tile'; import CallObjectContext from '../../CallObjectContext'; import CallMessage from '../CallMessage/CallMessage'; -import { - initialCallState, - CLICK_ALLOW_TIMEOUT, - PARTICIPANTS_CHANGE, - CAM_OR_MIC_ERROR, - FATAL_ERROR, - callReducer, - isLocal, - isScreenShare, - containsScreenShare, - getMessage, -} from './callState'; import { logDailyEvent } from '../../logUtils'; export default function Call() { const callObject = useContext(CallObjectContext); - const [callState, dispatch] = useReducer(callReducer, initialCallState); - - /** - * Start listening for participant changes, when the callObject is set. - */ - useEffect(() => { - if (!callObject) return; + const [participantUpdated, setParticipantUpdated] = useState(null); + const [participants, setParticipants] = useState([]); + + const handleTrackStarted = useCallback((e) => { + logDailyEvent(e); + setParticipantUpdated( + `track-started-${e?.participant?.user_id}-${Date.now()}` + ); + }, []); - const events = [ - 'participant-joined', - 'participant-updated', - 'participant-left', - ]; + const handleTrackStopped = useCallback((e) => { + logDailyEvent(e); + setParticipantUpdated( + `track-stopped-${e?.participant?.user_id}-${Date.now()}` + ); + }, []); - function handleNewParticipantsState(event) { - event && logDailyEvent(event); - dispatch({ - type: PARTICIPANTS_CHANGE, - participants: callObject.participants(), - }); - } + const handleParticipantUpdate = useCallback((e) => { + logDailyEvent(e); + setParticipantUpdated( + `participant-updated-${e?.participant?.user_id}-${Date.now()}` + ); + }, []); - // Use initial state - handleNewParticipantsState(); + const handleErrorEvent = useCallback((e) => { + logDailyEvent(e); + getMessage(e); + }, []); - // Listen for changes in state - for (const event of events) { - callObject.on(event, handleNewParticipantsState); - } + const getMessage = (e) => { + let header = null; + let detail = null; + let isError = false; - // Stop listening for changes in state - return function cleanup() { - for (const event of events) { - callObject.off(event, handleNewParticipantsState); + if (!e) { + if (participants.length <= 1) { + header = "Copy and share this page's URL to invite others"; + detail = window.location.href; } - }; - }, [callObject]); - - /** - * Start listening for call errors, when the callObject is set. - */ - useEffect(() => { - if (!callObject) return; - - function handleCameraErrorEvent(event) { - logDailyEvent(event); - dispatch({ - type: CAM_OR_MIC_ERROR, - message: - (event && event.errorMsg && event.errorMsg.errorMsg) || 'Unknown', - }); + } else if (e.action === 'error') { + header = `Fatal error ${(e && e.errorMsg) || 'Unknown'}`; + } else if (e.action === 'camera-error') { + header = `Camera or mic access error: ${ + (e && e.errorMsg && e.errorMsg.errorMsg) || 'Unknown' + }`; + detail = + 'See https://help.daily.co/en/articles/2528184-unblock-camera-mic-access-on-a-computer to troubleshoot.'; + isError = true; } - - // We're making an assumption here: there is no camera error when callObject - // is first assigned. - - callObject.on('camera-error', handleCameraErrorEvent); - - return function cleanup() { - callObject.off('camera-error', handleCameraErrorEvent); - }; - }, [callObject]); + return header || detail ? { header, detail, isError } : null; + }; /** - * Start listening for fatal errors, when the callObject is set. + * When the call object is set, listen and respond to events */ useEffect(() => { if (!callObject) return; - - function handleErrorEvent(e) { - logDailyEvent(e); - dispatch({ - type: FATAL_ERROR, - message: (e && e.errorMsg) || 'Unknown', - }); - } - - // We're making an assumption here: there is no error when callObject is - // first assigned. - + callObject.on('track-started', handleTrackStarted); + callObject.on('track-stopped', handleTrackStopped); + callObject.on('participant-updated', handleParticipantUpdate); callObject.on('error', handleErrorEvent); + callObject.on('camera-error', handleErrorEvent); - return function cleanup() { + return () => { + callObject.off('track-started', handleTrackStarted); + callObject.off('track-stopped', handleTrackStopped); + callObject.off('participant-updated', handleParticipantUpdate); callObject.off('error', handleErrorEvent); + callObject.off('camera-error', handleErrorEvent); }; - }, [callObject]); + }, [ + callObject, + participants, + handleTrackStarted, + handleParticipantUpdate, + handleTrackStopped, + handleErrorEvent, + ]); /** - * Start a timer to show the "click allow" message, when the component mounts. + * Update participants for any event that happens to keep local participant list up to date. + * We grab the whole participant list to make sure everyone's status is most up to date. */ useEffect(() => { - const t = setTimeout(() => { - dispatch({ type: CLICK_ALLOW_TIMEOUT }); - }, 2500); + if (participantUpdated) { + const list = Object.values(callObject?.participants()); + setParticipants(list); + } + }, [participantUpdated, callObject]); - return function cleanup() { - clearTimeout(t); - }; - }, []); + const isScreenShare = useMemo(() => { + return participants?.some((p) => p?.tracks?.screenVideo?.track); + }, [participants, callObject]); + + const displayLargeTiles = useMemo(() => { + const isLarge = true; + if (isScreenShare) { + const screenShare = participants?.find( + (p) => p?.tracks?.screenVideo?.track + ); + return ( +
+ { + { + sendHello(screenShare.id); + } + } + /> + } +
+ ); + } else { + const tiles = participants?.filter((p) => !p.local); + return ( +
+ {tiles?.map((t, i) => ( + { + sendHello(t.id); + } + } + /> + ))} +
+ ); + } + }, [participants, isScreenShare]); + + const displaySmallTiles = useMemo(() => { + const isLarge = false; + if (isScreenShare) { + return ( +
+ {participants?.map((p, i) => ( + { + sendHello(p.id); + } + } + /> + ))} +
+ ); + } else { + const tiles = participants?.filter((p) => p.local); + return ( +
+ {tiles?.map((t, i) => ( + { + sendHello(t.id); + } + } + /> + ))} +
+ ); + } + }, [participants, isScreenShare]); /** * Send an app message to the remote participant whose tile was clicked on. @@ -130,51 +218,11 @@ export default function Call() { [callObject] ); - function getTiles() { - let largeTiles = []; - let smallTiles = []; - Object.entries(callState.callItems).forEach(([id, callItem]) => { - const isLarge = - isScreenShare(id) || - (!isLocal(id) && !containsScreenShare(callState.callItems)); - const tile = ( - { - sendHello(id); - } - } - /> - ); - if (isLarge) { - largeTiles.push(tile); - } else { - smallTiles.push(tile); - } - }); - return [largeTiles, smallTiles]; - } - - const [largeTiles, smallTiles] = getTiles(); - const message = getMessage(callState); + const message = getMessage(); return (
-
- { - !message - ? largeTiles - : null /* Avoid showing large tiles to make room for the message */ - } -
-
{smallTiles}
+ {displayLargeTiles} + {displaySmallTiles} {message && ( -screen" for each shared screen - */ -const initialCallState = { - callItems: { - local: { - videoTrackState: null, - audioTrackState: null, - }, - }, - clickAllowTimeoutFired: false, - camOrMicError: null, - fatalError: null, -}; - -// --- Actions --- - -/** - * CLICK_ALLOW_TIMEOUT action structure: - * - type: string - */ -const CLICK_ALLOW_TIMEOUT = 'CLICK_ALLOW_TIMEOUT'; - -/** - * PARTICIPANTS_CHANGE action structure: - * - type: string - * - participants: Object (from Daily callObject.participants()) - */ -const PARTICIPANTS_CHANGE = 'PARTICIPANTS_CHANGE'; - -/** - * CAM_OR_MIC_ERROR action structure: - * - type: string - * - message: string - */ -const CAM_OR_MIC_ERROR = 'CAM_OR_MIC_ERROR'; - -/** - * CAM_OR_MIC_ERROR action structure: - * - type: string - * - message: string - */ -const FATAL_ERROR = 'FATAL_ERROR'; - -// --- Reducer and helpers -- - -function callReducer(callState, action) { - switch (action.type) { - case CLICK_ALLOW_TIMEOUT: - return { - ...callState, - clickAllowTimeoutFired: true, - }; - case PARTICIPANTS_CHANGE: - const callItems = getCallItems(action.participants); - return { - ...callState, - callItems, - }; - case CAM_OR_MIC_ERROR: - return { ...callState, camOrMicError: action.message }; - case FATAL_ERROR: - return { ...callState, fatalError: action.message }; - default: - throw new Error(); - } -} - -function getLocalCallItem(callItems) { - return callItems['local']; -} - -function getCallItems(participants) { - let callItems = { ...initialCallState.callItems }; // Ensure we *always* have a local participant - for (const [id, participant] of Object.entries(participants)) { - callItems[id] = { - videoTrackState: participant.tracks.video, - audioTrackState: participant.tracks.audio, - }; - if (shouldIncludeScreenCallItem(participant)) { - callItems[id + '-screen'] = { - videoTrackState: participant.tracks.screenVideo, - audioTrackState: participant.tracks.screenAudio, - }; - } - } - return callItems; -} - -function shouldIncludeScreenCallItem(participant) { - const trackStatesForInclusion = ['loading', 'playable', 'interrupted']; - return ( - trackStatesForInclusion.includes(participant.tracks.screenVideo.state) || - trackStatesForInclusion.includes(participant.tracks.screenAudio.state) - ); -} - -// --- Derived data --- - -// True if id corresponds to local participant (*not* their screen share) -function isLocal(id) { - return id === 'local'; -} - -function isScreenShare(id) { - return id.endsWith('-screen'); -} - -function containsScreenShare(callItems) { - return Object.keys(callItems).some((id) => isScreenShare(id)); -} - -function getMessage(callState) { - function shouldShowClickAllow() { - const localCallItem = getLocalCallItem(callState.callItems); - const hasLoaded = localCallItem && !localCallItem.isLoading; - return !hasLoaded && callState.clickAllowTimeoutFired; - } - - let header = null; - let detail = null; - let isError = false; - if (callState.fatalError) { - header = `Fatal error: ${callState.fatalError}`; - isError = true; - } else if (callState.camOrMicError) { - header = `Camera or mic access error: ${callState.camOrMicError}`; - detail = - 'See https://help.daily.co/en/articles/2528184-unblock-camera-mic-access-on-a-computer to troubleshoot.'; - isError = true; - } else if (shouldShowClickAllow()) { - header = 'Click "Allow" to enable camera and mic access'; - } else if (Object.keys(callState.callItems).length === 1) { - header = "Copy and share this page's URL to invite others"; - detail = window.location.href; - } - return header || detail ? { header, detail, isError } : null; -} - -export { - initialCallState, - CLICK_ALLOW_TIMEOUT, - PARTICIPANTS_CHANGE, - CAM_OR_MIC_ERROR, - FATAL_ERROR, - callReducer, - isLocal, - isScreenShare, - containsScreenShare, - getMessage, -}; diff --git a/src/components/Tile/Tile.js b/src/components/Tile/Tile.js index a6997da..031f32e 100644 --- a/src/components/Tile/Tile.js +++ b/src/components/Tile/Tile.js @@ -10,12 +10,14 @@ function getTrackUnavailableMessage(kind, trackState) { } else if (trackState.blocked.byDeviceMissing) { return `${kind} device missing`; } + return `${kind} blocked`; case 'off': if (trackState.off.byUser) { return `${kind} muted`; } else if (trackState.off.byBandwidth) { return `${kind} muted to save bandwidth`; } + return `${kind} off`; case 'sendable': return `${kind} not subscribed`; case 'loading':