// Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ReactNode } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react'; import { isEqual, noop } from 'lodash'; import classNames from 'classnames'; import type { VideoFrameSource } from '@signalapp/ringrtc'; import type { ActiveCallStateType, BatchUserActionPayloadType, PendingUserActionPayloadType, SendGroupCallRaiseHandType, SendGroupCallReactionType, SetLocalAudioType, SetLocalVideoType, SetRendererCanvasType, } from '../state/ducks/calling'; import { Avatar, AvatarSize } from './Avatar'; import { CallingHeader, getCallViewIconClassname } from './CallingHeader'; import { CallingPreCallInfo, RingMode } from './CallingPreCallInfo'; import { CallingButton, CallingButtonType } from './CallingButton'; import { Button, ButtonVariant } from './Button'; import { TooltipPlacement } from './Tooltip'; import { CallBackgroundBlur } from './CallBackgroundBlur'; import type { ActiveCallType, ActiveCallReactionsType, ConversationsByDemuxIdType, GroupCallVideoRequest, } from '../types/Calling'; import { CALLING_REACTIONS_LIFETIME, CallViewMode, CallState, GroupCallConnectionState, GroupCallJoinState, } from '../types/Calling'; import { CallMode } from '../types/CallDisposition'; import type { ServiceIdString } from '../types/ServiceId'; import { AvatarColors } from '../types/Colors'; import type { ConversationType } from '../state/ducks/conversations'; import { CallingButtonToastsContainer, useScreenSharingStoppedToast, } from './CallingToastManager'; import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant'; import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants'; import { CallParticipantCount } from './CallParticipantCount'; import type { LocalizerType } from '../types/Util'; import { NeedsScreenRecordingPermissionsModal } from './NeedsScreenRecordingPermissionsModal'; import { missingCaseError } from '../util/missingCaseError'; import * as KeyboardLayout from '../services/keyboardLayout'; import { usePresenter, useActivateSpeakerViewOnPresenting, } from '../hooks/useActivateSpeakerViewOnPresenting'; import { CallingAudioIndicator, SPEAKING_LINGER_MS, } from './CallingAudioIndicator'; import { useActiveCallShortcuts, useKeyboardShortcuts, } from '../hooks/useKeyboardShortcuts'; import { useValueAtFixedRate } from '../hooks/useValueAtFixedRate'; import { isReconnecting as callingIsReconnecting } from '../util/callingIsReconnecting'; import { usePrevious } from '../hooks/usePrevious'; import { CallingToastProvider, PersistentCallingToast, useCallingToasts, } from './CallingToast'; import { handleOutsideClick } from '../util/handleOutsideClick'; import { Spinner } from './Spinner'; import type { Props as ReactionPickerProps } from './conversation/ReactionPicker'; import type { SmartReactionPicker } from '../state/smart/ReactionPicker'; import { Emoji } from './emoji/Emoji'; import { CallingRaisedHandsList, CallingRaisedHandsListButton, } from './CallingRaisedHandsList'; import type { CallReactionBurstType } from './CallReactionBurst'; import { CallReactionBurstProvider, useCallReactionBursts, } from './CallReactionBurst'; import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall'; import { assertDev } from '../util/assert'; import { emojiToData } from './emoji/lib'; import { CallingPendingParticipants } from './CallingPendingParticipants'; import type { CallingImageDataCache } from './CallManager'; export type PropsType = { activeCall: ActiveCallType; approveUser: (payload: PendingUserActionPayloadType) => void; batchUserAction: (payload: BatchUserActionPayloadType) => void; cancelPresenting: () => void; denyUser: (payload: PendingUserActionPayloadType) => void; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; getPresentingSources: () => void; groupMembers?: Array>; hangUpActiveCall: (reason: string) => void; i18n: LocalizerType; imageDataCache: React.RefObject; isCallLinkAdmin: boolean; me: ConversationType; openSystemPreferencesAction: () => unknown; renderReactionPicker: ( props: React.ComponentProps ) => JSX.Element; sendGroupCallRaiseHand: (payload: SendGroupCallRaiseHandType) => void; sendGroupCallReaction: (payload: SendGroupCallReactionType) => void; setGroupCallVideoRequest: ( _: Array, speakerHeight: number ) => void; setLocalAudio: (_: SetLocalAudioType) => void; setLocalVideo: (_: SetLocalVideoType) => void; setLocalPreviewContainer: (container: HTMLDivElement | null) => void; setRendererCanvas: (_: SetRendererCanvasType) => void; stickyControls: boolean; switchToPresentationView: () => void; switchFromPresentationView: () => void; toggleCallLinkPendingParticipantModal: (contactId: string) => void; toggleParticipants: () => void; togglePip: () => void; toggleScreenRecordingPermissionsDialog: () => unknown; toggleSettings: () => void; changeCallView: (mode: CallViewMode) => void; } & Pick; export const isInSpeakerView = ( call: Pick | undefined ): boolean => { return Boolean( call?.viewMode === CallViewMode.Presentation || call?.viewMode === CallViewMode.Speaker ); }; const REACTIONS_TOASTS_TRANSITION_FROM = { opacity: 0, }; // How many reactions of the same emoji must occur before a burst. const REACTIONS_BURST_THRESHOLD = 3; // Timeframe in which multiple of the same emoji must occur before a burst. const REACTIONS_BURST_WINDOW = 4000; // Timeframe after a burst where new reactions of the same emoji are ignored for // bursting. They are considered part of the recent burst. const REACTIONS_BURST_TRAILING_WINDOW = 2000; // Max number of bursts in a short timeframe to avoid overwhelming the user. const REACTIONS_BURST_MAX_IN_SHORT_WINDOW = 3; const REACTIONS_BURST_SHORT_WINDOW = 4000; function CallDuration({ joinedAt, }: { joinedAt: number | null; }): JSX.Element | null { const [acceptedDuration, setAcceptedDuration] = useState< number | undefined >(); useEffect(() => { if (joinedAt == null) { return noop; } // It's really jumpy with a value of 500ms. const interval = setInterval(() => { setAcceptedDuration(Date.now() - joinedAt); }, 100); return clearInterval.bind(null, interval); }, [joinedAt]); if (acceptedDuration) { return <>{renderDuration(acceptedDuration)}; } return null; } export function CallScreen({ activeCall, approveUser, batchUserAction, cancelPresenting, changeCallView, denyUser, getGroupCallVideoFrameSource, getPresentingSources, groupMembers, hangUpActiveCall, i18n, imageDataCache, isCallLinkAdmin, me, openSystemPreferencesAction, renderEmojiPicker, renderReactionPicker, setGroupCallVideoRequest, sendGroupCallRaiseHand, sendGroupCallReaction, setLocalAudio, setLocalVideo, setLocalPreviewContainer, setRendererCanvas, stickyControls, switchToPresentationView, switchFromPresentationView, toggleCallLinkPendingParticipantModal, toggleParticipants, togglePip, toggleScreenRecordingPermissionsDialog, toggleSettings, }: PropsType): JSX.Element { const { conversation, hasLocalAudio, hasLocalVideo, localAudioLevel, presentingSource, remoteParticipants, showNeedsScreenRecordingPermissionsWarning, reactions, } = activeCall; const isSpeaking = useValueAtFixedRate( localAudioLevel > 0, SPEAKING_LINGER_MS ); useActivateSpeakerViewOnPresenting({ remoteParticipants, switchToPresentationView, switchFromPresentationView, }); const activeCallShortcuts = useActiveCallShortcuts(hangUpActiveCall); useKeyboardShortcuts(activeCallShortcuts); const toggleAudio = useCallback(() => { setLocalAudio({ enabled: !hasLocalAudio, }); }, [setLocalAudio, hasLocalAudio]); const toggleVideo = useCallback(() => { setLocalVideo({ enabled: !hasLocalVideo, }); }, [setLocalVideo, hasLocalVideo]); const togglePresenting = useCallback(() => { if (presentingSource) { cancelPresenting(); } else { getPresentingSources(); } }, [getPresentingSources, presentingSource, cancelPresenting]); const hangUp = useCallback(() => { hangUpActiveCall('button click'); }, [hangUpActiveCall]); const reactButtonRef = React.useRef(null); const reactionPickerRef = React.useRef(null); const reactionPickerContainerRef = React.useRef(null); const [showReactionPicker, setShowReactionPicker] = useState(false); const toggleReactionPicker = useCallback(() => { setShowReactionPicker(prevValue => !prevValue); }, []); const [showRaisedHandsList, setShowRaisedHandsList] = useState(false); const toggleRaisedHandsList = useCallback(() => { setShowRaisedHandsList(prevValue => !prevValue); }, []); const [controlsHover, setControlsHover] = useState(false); const onControlsMouseEnter = useCallback(() => { setControlsHover(true); }, [setControlsHover]); const onControlsMouseLeave = useCallback(() => { setControlsHover(false); }, [setControlsHover]); const [showControls, setShowControls] = useState(true); useEffect(() => { if ( !showControls || showReactionPicker || stickyControls || controlsHover ) { return noop; } const timer = setTimeout(() => { setShowControls(false); }, 5000); return clearTimeout.bind(null, timer); }, [showControls, showReactionPicker, stickyControls, controlsHover]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent): void => { let eventHandled = false; const key = KeyboardLayout.lookup(event); if (event.shiftKey && (key === 'V' || key === 'v')) { toggleVideo(); eventHandled = true; } else if (event.shiftKey && (key === 'M' || key === 'm')) { toggleAudio(); eventHandled = true; } if (eventHandled) { event.preventDefault(); event.stopPropagation(); setShowControls(true); } }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [toggleAudio, toggleVideo]); useEffect(() => { if (!showReactionPicker) { return noop; } return handleOutsideClick( () => { setShowReactionPicker(false); return true; }, { containerElements: [reactButtonRef, reactionPickerContainerRef], name: 'CallScreen.reactionPicker', } ); }, [showReactionPicker]); useScreenSharingStoppedToast({ activeCall, i18n }); useViewModeChangedToast({ activeCall, i18n }); const currentPresenter = remoteParticipants.find( participant => participant.presenting ); const hasRemoteVideo = remoteParticipants.some( remoteParticipant => remoteParticipant.hasRemoteVideo ); const isSendingVideo = hasLocalVideo || presentingSource; const isReconnecting: boolean = callingIsReconnecting(activeCall); let isRinging: boolean; let hasCallStarted: boolean; let isConnected: boolean; let participantCount: number; let conversationsByDemuxId: ConversationsByDemuxIdType; let localDemuxId: number | undefined; switch (activeCall.callMode) { case CallMode.Direct: { isRinging = activeCall.callState === CallState.Prering || activeCall.callState === CallState.Ringing; hasCallStarted = !isRinging; isConnected = activeCall.callState === CallState.Accepted; participantCount = isConnected ? 2 : 0; conversationsByDemuxId = new Map(); break; } case CallMode.Group: case CallMode.Adhoc: isRinging = activeCall.outgoingRing && !activeCall.remoteParticipants.length && !(groupMembers?.length === 1 && groupMembers[0].id === me.id); hasCallStarted = activeCall.joinState !== GroupCallJoinState.NotJoined; participantCount = activeCall.remoteParticipants.length + 1; conversationsByDemuxId = activeCall.conversationsByDemuxId; localDemuxId = activeCall.localDemuxId; isConnected = activeCall.connectionState === GroupCallConnectionState.Connected; break; default: throw missingCaseError(activeCall); } const pendingParticipants = activeCall.callMode === CallMode.Adhoc && isCallLinkAdmin ? activeCall.pendingParticipants : []; let lonelyInCallNode: ReactNode; let localPreviewNode: ReactNode; const isLonelyInCall = !activeCall.remoteParticipants.length; if (isLonelyInCall) { lonelyInCallNode = (
{isSendingVideo ? (
) : (
{i18n('icu:calling__your-video-is-off')}
)}
); } else { localPreviewNode = isSendingVideo ? (
) : ( ); } let videoButtonType: CallingButtonType; if (presentingSource) { videoButtonType = CallingButtonType.VIDEO_DISABLED; } else if (hasLocalVideo) { videoButtonType = CallingButtonType.VIDEO_ON; } else { videoButtonType = CallingButtonType.VIDEO_OFF; } const audioButtonType = hasLocalAudio ? CallingButtonType.AUDIO_ON : CallingButtonType.AUDIO_OFF; const isAudioOnly = !hasLocalVideo && !hasRemoteVideo; const controlsFadedOut = !showControls && !isAudioOnly && isConnected; const controlsFadeClass = classNames({ 'module-ongoing-call__controls': true, 'module-ongoing-call__controls--fadeIn': (showControls || isAudioOnly) && !isConnected, 'module-ongoing-call__controls--fadeOut': controlsFadedOut, }); const isGroupCall = isGroupOrAdhocActiveCall(activeCall); let presentingButtonType: CallingButtonType; if (presentingSource) { presentingButtonType = CallingButtonType.PRESENTING_ON; } else if (currentPresenter) { presentingButtonType = CallingButtonType.PRESENTING_DISABLED; } else { presentingButtonType = CallingButtonType.PRESENTING_OFF; } const raisedHands = isGroupOrAdhocActiveCall(activeCall) ? activeCall.raisedHands : undefined; // This is the value of our hand raised as seen by remote clients. We should prefer // to use it in UI so the user understands what remote clients see. const syncedLocalHandRaised = isHandRaised(raisedHands, localDemuxId); // Don't call setLocalHandRaised because it only sets local state. Instead call // toggleRaiseHand() which will set ringrtc state and call setLocalHandRaised. const [localHandRaised, setLocalHandRaised] = useState( syncedLocalHandRaised ); const previousLocalHandRaised = usePrevious(localHandRaised, localHandRaised); const toggleRaiseHand = useCallback( (raise?: boolean) => { const nextValue = raise ?? !localHandRaised; if (nextValue === previousLocalHandRaised) { return; } setLocalHandRaised(nextValue); // It's possible that the ringrtc call can fail due to flaky network connection. // In that case, local and remote state (localHandRaised and raisedHands) can // get out of sync. The user might need to manually toggle raise hand to get to // a coherent state. It would be nice if this returned a Promise (but it doesn't) sendGroupCallRaiseHand({ conversationId: conversation.id, raise: nextValue, }); }, [ localHandRaised, previousLocalHandRaised, conversation.id, sendGroupCallRaiseHand, ] ); let raiseHandButtonType: CallingButtonType | undefined; let reactButtonType: CallingButtonType | undefined; if (isGroupCall) { raiseHandButtonType = localHandRaised ? CallingButtonType.RAISE_HAND_ON : CallingButtonType.RAISE_HAND_OFF; reactButtonType = showReactionPicker ? CallingButtonType.REACT_ON : CallingButtonType.REACT_OFF; } const renderRaisedHandsToast = React.useCallback( (demuxIds: Array) => { const names: Array = []; let isYourHandRaised = false; for (const demuxId of demuxIds) { if (demuxId === localDemuxId) { isYourHandRaised = true; continue; } const handConversation = conversationsByDemuxId.get(demuxId); if (!handConversation) { continue; } names.push(handConversation.title); } const count = names.length; const name = names[0] ?? ''; const otherName = names[1] ?? ''; let message: string; let buttonOverride: JSX.Element | undefined; switch (count) { case 0: return undefined; case 1: if (isYourHandRaised) { message = i18n('icu:CallControls__RaiseHandsToast--you'); buttonOverride = ( ); } else { message = i18n('icu:CallControls__RaiseHandsToast--one', { name, }); } break; case 2: if (isYourHandRaised) { message = i18n('icu:CallControls__RaiseHandsToast--you-and-one', { otherName, }); } else { message = i18n('icu:CallControls__RaiseHandsToast--two', { name, otherName, }); } break; default: { const overflowCount = count - 2; if (isYourHandRaised) { message = i18n('icu:CallControls__RaiseHandsToast--you-and-more', { otherName, overflowCount, }); } else { message = i18n('icu:CallControls__RaiseHandsToast--more', { name: names[0] ?? '', otherName, overflowCount, }); } } } return (
{message} {buttonOverride || ( )}
); }, [i18n, localDemuxId, conversationsByDemuxId, toggleRaiseHand] ); const raisedHandsCount: number = raisedHands?.size ?? 0; const callStatus: ReactNode | string = React.useMemo(() => { if (isRinging) { return i18n('icu:outgoingCallRinging'); } if (isReconnecting) { return i18n('icu:callReconnecting'); } if (isGroupCall) { return ( ); } if (isConnected && activeCall.callMode === CallMode.Direct) { return ; } if (hasLocalVideo) { return i18n('icu:ContactListItem__menu__video-call'); } if (hasLocalAudio) { return i18n('icu:CallControls__InfoDisplay--audio-call'); } return null; }, [ i18n, isRinging, isConnected, activeCall.callMode, activeCall.joinedAt, isReconnecting, isGroupCall, participantCount, hasLocalVideo, hasLocalAudio, toggleParticipants, ]); let remoteParticipantsElement: ReactNode; switch (activeCall.callMode) { case CallMode.Direct: { assertDev( conversation.type === 'direct', 'direct call must have direct conversation' ); remoteParticipantsElement = hasCallStarted ? ( ) : (
); break; } case CallMode.Group: case CallMode.Adhoc: remoteParticipantsElement = ( 0 ? () => setShowRaisedHandsList(true) : undefined } /> ); break; default: throw missingCaseError(activeCall); } return (
{ setShowControls(true); }} onMouseMove={() => { setShowControls(true); }} role="group" > {isReconnecting ? ( {i18n('icu:callReconnecting')} ) : null} {isLonelyInCall && !isRinging ? ( {i18n('icu:calling__in-this-call--zero')} ) : null} {currentPresenter ? ( {i18n('icu:calling__presenting--person-ongoing', { name: currentPresenter.title, })} ) : null} {showNeedsScreenRecordingPermissionsWarning ? ( ) : null}
{isRinging && ( <>
)} {remoteParticipantsElement} {lonelyInCallNode} {raisedHands && ( <> {showRaisedHandsList && raisedHandsCount > 0 && ( setShowRaisedHandsList(false)} onLowerMyHand={() => { toggleRaiseHand(false); setShowRaisedHandsList(false); }} localDemuxId={localDemuxId} conversationsByDemuxId={conversationsByDemuxId} raisedHands={raisedHands} localHandRaised={syncedLocalHandRaised} /> )} )} {isCallLinkAdmin ? ( ) : null} {/* We render the local preview first and set the footer flex direction to row-reverse to ensure the preview is visible at low viewport widths. */}
{localPreviewNode ? (
{localPreviewNode} {!isSendingVideo && (
)} {syncedLocalHandRaised && (
)}
) : (
)}
{conversation.title}
{callStatus}
{showReactionPicker && (
{renderReactionPicker({ ref: reactionPickerRef, onClose: () => setShowReactionPicker(false), onPick: emoji => { setShowReactionPicker(false); sendGroupCallReaction({ callMode: activeCall.callMode, conversationId: conversation.id, value: emoji, }); }, renderEmojiPicker, })}
)}
{raiseHandButtonType && ( toggleRaiseHand()} tooltipDirection={TooltipPlacement.Top} /> )} {reactButtonType && (
)}
); } function getCallModeClassSuffix( callMode: CallMode.Direct | CallMode.Group | CallMode.Adhoc ): string { switch (callMode) { case CallMode.Direct: return 'direct'; case CallMode.Group: case CallMode.Adhoc: return 'group'; default: throw missingCaseError(callMode); } } function renderDuration(ms: number): string { const secs = Math.floor((ms / 1000) % 60) .toString() .padStart(2, '0'); const mins = Math.floor((ms / 60000) % 60) .toString() .padStart(2, '0'); const hours = Math.floor(ms / 3600000); if (hours > 0) { return `${hours}:${mins}:${secs}`; } return `${mins}:${secs}`; } function useViewModeChangedToast({ activeCall, i18n, }: { activeCall: ActiveCallType; i18n: LocalizerType; }): void { const { viewMode } = activeCall; const previousViewMode = usePrevious(viewMode, viewMode); const presenterAci = usePresenter(activeCall.remoteParticipants); const VIEW_MODE_CHANGED_TOAST_KEY = 'view-mode-changed'; const { showToast, hideToast } = useCallingToasts(); useEffect(() => { if (viewMode !== previousViewMode) { if ( // If this is an automated change to presentation mode, don't show toast viewMode === CallViewMode.Presentation || // if this is an automated change away from presentation mode, don't show toast (previousViewMode === CallViewMode.Presentation && !presenterAci) ) { return; } hideToast(VIEW_MODE_CHANGED_TOAST_KEY); showToast({ key: VIEW_MODE_CHANGED_TOAST_KEY, content: (
{i18n('icu:calling__view_mode--updated')}
), autoClose: true, }); } }, [ showToast, hideToast, i18n, activeCall, viewMode, previousViewMode, presenterAci, ]); } type CallingReactionsToastsType = { reactions: ActiveCallReactionsType | undefined; conversationsByDemuxId: Map; localDemuxId: number | undefined; i18n: LocalizerType; }; type UseReactionsToastType = CallingReactionsToastsType & { showBurst: (toast: CallReactionBurstType) => string; }; function useReactionsToast(props: UseReactionsToastType): void { const { reactions, conversationsByDemuxId, localDemuxId, i18n, showBurst } = props; const ourServiceId: ServiceIdString | undefined = localDemuxId ? conversationsByDemuxId.get(localDemuxId)?.serviceId : undefined; const [previousReactions, setPreviousReactions] = React.useState< ActiveCallReactionsType | undefined >(undefined); const reactionsShown = useRef< Map< string, { value: string; originalValue: string; isBursted: boolean; expireAt: number; demuxId: number; } > >(new Map()); const burstsShown = useRef>(new Map()); const { showToast } = useCallingToasts(); useEffect(() => { setPreviousReactions(reactions); }, [reactions]); useEffect(() => { if (!reactions || isEqual(reactions, previousReactions)) { return; } const time = Date.now(); let anyReactionWasShown = false; reactions.forEach(({ timestamp, demuxId, value }) => { const conversation = conversationsByDemuxId.get(demuxId); const key = `reactions-${timestamp}-${demuxId}`; showToast({ key, onlyShowOnce: true, autoClose: true, content: ( {demuxId === localDemuxId || (ourServiceId && conversation?.serviceId === ourServiceId) ? i18n('icu:CallingReactions--me') : conversation?.title} ), }); // Track shown reactions for burst purposes. Skip if it's already tracked. if (reactionsShown.current.has(key)) { return; } // If there's a recent burst for this emoji, treat it as part of that burst. const recentBurstTime = burstsShown.current.get(value); const isBursted = !!( recentBurstTime && recentBurstTime + REACTIONS_BURST_TRAILING_WINDOW > time ); // Normalize skin tone emoji to calculate burst threshold, but save original // value to show in the burst animation const emojiData = emojiToData(value); const normalizedValue = emojiData?.unified ?? value; reactionsShown.current.set(key, { value: normalizedValue, originalValue: value, isBursted, expireAt: timestamp + REACTIONS_BURST_WINDOW, demuxId, }); anyReactionWasShown = true; }); if (!anyReactionWasShown) { return; } const unburstedEmojis = new Map>(); const unburstedEmojisReactorIds = new Map< string, Set >(); reactionsShown.current.forEach( ({ value, isBursted, expireAt, demuxId }, key) => { if (expireAt < time) { reactionsShown.current.delete(key); return; } if (isBursted) { return; } const reactionKeys = unburstedEmojis.get(value) ?? new Set(); reactionKeys.add(key); unburstedEmojis.set(value, reactionKeys); // Only burst when enough unique people react. const conversation = conversationsByDemuxId.get(demuxId); const reactorId = conversation?.serviceId || demuxId; const reactorIdSet = unburstedEmojisReactorIds.get(value) ?? new Set(); reactorIdSet.add(reactorId); unburstedEmojisReactorIds.set(value, reactorIdSet); } ); burstsShown.current.forEach((timestamp, value) => { if (timestamp < time - REACTIONS_BURST_SHORT_WINDOW) { burstsShown.current.delete(value); } }); if (burstsShown.current.size >= REACTIONS_BURST_MAX_IN_SHORT_WINDOW) { return; } for (const [value, reactorIds] of unburstedEmojisReactorIds.entries()) { if (reactorIds.size < REACTIONS_BURST_THRESHOLD) { continue; } const reactionKeys = unburstedEmojis.get(value); if (!reactionKeys) { unburstedEmojisReactorIds.delete(value); continue; } burstsShown.current.set(value, time); const values: Array = []; reactionKeys.forEach(key => { const reactionShown = reactionsShown.current.get(key); if (!reactionShown) { return; } reactionShown.isBursted = true; values.push(reactionShown.originalValue); }); showBurst({ values }); if (burstsShown.current.size >= REACTIONS_BURST_MAX_IN_SHORT_WINDOW) { break; } } }, [ reactions, previousReactions, showBurst, showToast, conversationsByDemuxId, localDemuxId, i18n, ourServiceId, ]); } function CallingReactionsToastsContainer( props: CallingReactionsToastsType ): JSX.Element { const { i18n } = props; const toastRegionRef = useRef(null); const burstRegionRef = useRef(null); return (
); } function CallingReactionsToasts(props: CallingReactionsToastsType) { const { showBurst } = useCallReactionBursts(); useReactionsToast({ ...props, showBurst }); return null; } function isHandRaised( raisedHands: Set | undefined, demuxId: number | undefined ): boolean { if (raisedHands === undefined || demuxId === undefined) { return false; } return raisedHands.has(demuxId); }