// Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useEffect, useMemo, useRef } from 'react'; import type { ActiveCallType } from '../types/Calling'; import { CallMode } from '../types/Calling'; import type { ConversationType } from '../state/ducks/conversations'; import type { LocalizerType } from '../types/Util'; import { CallingToastProvider, useCallingToasts } from './CallingToast'; import { usePrevious } from '../hooks/usePrevious'; import { difference as setDifference } from '../util/setUtil'; import { isMoreRecentThan } from '../util/timestamp'; import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall'; type PropsType = { activeCall: ActiveCallType; i18n: LocalizerType; }; const ME = Symbol('me'); function getCurrentPresenter( activeCall: Readonly ): ConversationType | { id: typeof ME } | undefined { if (activeCall.presentingSource) { return { id: ME }; } if ( activeCall.callMode === CallMode.Direct && activeCall.conversation.type === 'direct' ) { const isOtherPersonPresenting = activeCall.remoteParticipants.some( participant => participant.presenting ); return isOtherPersonPresenting ? activeCall.conversation : undefined; } if (isGroupOrAdhocActiveCall(activeCall)) { return activeCall.remoteParticipants.find( participant => participant.presenting ); } return undefined; } export function useScreenSharingStoppedToast({ activeCall, i18n, }: PropsType): void { const { showToast, hideToast } = useCallingToasts(); const SOMEONE_STOPPED_PRESENTING_TOAST_KEY = 'someone_stopped_presenting'; const currentPresenter = useMemo( () => getCurrentPresenter(activeCall), [activeCall] ); const previousPresenter = usePrevious(currentPresenter, currentPresenter); useEffect(() => { if (previousPresenter && !currentPresenter) { hideToast(SOMEONE_STOPPED_PRESENTING_TOAST_KEY); showToast({ key: SOMEONE_STOPPED_PRESENTING_TOAST_KEY, content: previousPresenter.id === ME ? i18n('icu:calling__presenting--you-stopped') : i18n('icu:calling__presenting--person-stopped', { name: previousPresenter.title, }), autoClose: true, }); } }, [ activeCall, hideToast, currentPresenter, previousPresenter, showToast, i18n, ]); } function useMutedToast({ hasLocalAudio, i18n, }: { hasLocalAudio: boolean; i18n: LocalizerType; }): void { const previousHasLocalAudio = usePrevious(hasLocalAudio, hasLocalAudio); const { showToast, hideToast } = useCallingToasts(); const MUTED_TOAST_KEY = 'muted'; useEffect(() => { if ( previousHasLocalAudio !== undefined && hasLocalAudio !== previousHasLocalAudio ) { hideToast(MUTED_TOAST_KEY); showToast({ key: MUTED_TOAST_KEY, content: hasLocalAudio ? i18n('icu:CallControls__MutedToast--unmuted') : i18n('icu:CallControls__MutedToast--muted'), autoClose: true, dismissable: true, }); } }, [hasLocalAudio, previousHasLocalAudio, hideToast, showToast, i18n]); } function useOutgoingRingToast({ outgoingRing, i18n, }: { outgoingRing?: boolean; i18n: LocalizerType; }): void { const { showToast, hideToast } = useCallingToasts(); const previousOutgoingRing = usePrevious(outgoingRing, outgoingRing); const RINGING_TOAST_KEY = 'ringing'; React.useEffect(() => { if (outgoingRing === undefined) { return; } if ( previousOutgoingRing !== undefined && outgoingRing !== previousOutgoingRing ) { hideToast(RINGING_TOAST_KEY); showToast({ key: RINGING_TOAST_KEY, content: outgoingRing ? i18n('icu:CallControls__RingingToast--ringing-on') : i18n('icu:CallControls__RingingToast--ringing-off'), autoClose: true, dismissable: true, }); } }, [outgoingRing, previousOutgoingRing, hideToast, showToast, i18n]); } function useRaisedHandsToast({ raisedHands, renderRaisedHandsToast, }: { raisedHands?: Set; renderRaisedHandsToast?: ( hands: Array ) => JSX.Element | string | undefined; }): void { const RAISED_HANDS_TOAST_KEY = 'raised-hands'; const RAISED_HANDS_TOAST_LIFETIME = 4000; const LOAD_DELAY = 2000; const { showToast, hideToast } = useCallingToasts(); // Hand state is updated after a delay upon joining a call, so it can appear that // hands were raised immediately when you join a call. To avoid spurious toasts, add // an initial delay before showing toasts. const [isLoaded, setIsLoaded] = React.useState(false); React.useEffect(() => { const timeout = setTimeout(() => { setIsLoaded(true); }, LOAD_DELAY); return () => clearTimeout(timeout); }, []); const previousRaisedHands = usePrevious(raisedHands, raisedHands); const [newHands, loweredHands]: [Set, Set] = isLoaded ? [ setDifference( raisedHands ?? new Set(), previousRaisedHands ?? new Set() ), setDifference( previousRaisedHands ?? new Set(), raisedHands ?? new Set() ), ] : [new Set(), new Set()]; const toastLastShownAt = useRef(0); const handsForLastShownToast = useRef>(new Set()); React.useEffect(() => { if (raisedHands?.size === 0) { hideToast(RAISED_HANDS_TOAST_KEY); } if ( (newHands.size === 0 && loweredHands.size === 0) || !renderRaisedHandsToast ) { return; } // If there's an existing raised hand toast (it hasn't faded out yet), then // group the newly raised and lowered hands into the existing toast. let handsForToast: Array; if ( isMoreRecentThan(toastLastShownAt.current, RAISED_HANDS_TOAST_LIFETIME) ) { handsForToast = [ ...setDifference(handsForLastShownToast.current, loweredHands), ...newHands, ]; // If someone lowered a hand which isn't present in the existing toast, // we can ignore it. if ( newHands.size === 0 && loweredHands.size > 0 && handsForToast.length && handsForToast.length === handsForLastShownToast.current.size ) { return; } } else { handsForToast = [...newHands]; } handsForLastShownToast.current = new Set([...handsForToast]); hideToast(RAISED_HANDS_TOAST_KEY); const content = renderRaisedHandsToast(handsForToast.reverse()); if (!content) { return; } // Note: Don't set { dismissable: true } or else the links (Lower or View Queue) // will cause nested buttons (dismissable toasts are