From f2d4f669fefe99b909feaff4c01199e431ecc742 Mon Sep 17 00:00:00 2001 From: trevor-signal <131492920+trevor-signal@users.noreply.github.com> Date: Fri, 20 Dec 2024 13:35:11 -0500 Subject: [PATCH] Implement lower hand suggestion in group calls --- _locales/en/messages.json | 8 +++ ts/components/CallManager.stories.tsx | 2 + ts/components/CallScreen.stories.tsx | 33 ++++++++++ ts/components/CallScreen.tsx | 7 +++ ts/components/CallingPip.stories.tsx | 1 + ts/components/CallingToastManager.tsx | 82 +++++++++++++++++++++++++ ts/services/calling.ts | 23 ++++++- ts/state/ducks/calling.ts | 35 +++++++++++ ts/state/smart/CallManager.tsx | 1 + ts/types/Calling.ts | 1 + ts/util/isLowerHandSuggestionEnabled.ts | 16 +++++ 11 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 ts/util/isLowerHandSuggestionEnabled.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 6f0ecce8d..f56e7117c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3699,6 +3699,14 @@ "messageformat": "Mic on", "description": "Shown in a call when the user is muted and then unmutes their audio input using the Mute toggle button." }, + "icu:CallControls__LowerHandSuggestionToast": { + "messageformat": "Lower your hand?", + "description": "Shown in a call when the user has their hand raised but has been talking, next to a button to lower their hand." + }, + "icu:CallControls__LowerHandSuggestionToast--button": { + "messageformat": "Lower", + "description": "Text of button that, when clicked, will lower your raised hand after you've been speaking" + }, "icu:CallControls__RingingToast--ringing-on": { "messageformat": "Ringing on", "description": "Shown in a group call lobby when call ringing is disabled, then the user enables ringing using the Ringing toggle button." diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index d9eec759c..dfe10cec2 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -181,6 +181,7 @@ const getActiveCallForCallLink = ( pendingParticipants: overrideProps.pendingParticipants ?? [], raisedHands: new Set(), remoteAudioLevels: new Map(), + suggestLowerHand: false, }; }; @@ -232,6 +233,7 @@ export function OngoingGroupCall(): JSX.Element { raisedHands: new Set(), remoteParticipants: [], remoteAudioLevels: new Map(), + suggestLowerHand: false, }, })} /> diff --git a/ts/components/CallScreen.stories.tsx b/ts/components/CallScreen.stories.tsx index 760dbc142..5dfd66a46 100644 --- a/ts/components/CallScreen.stories.tsx +++ b/ts/components/CallScreen.stories.tsx @@ -74,6 +74,7 @@ type GroupCallOverrideProps = OverridePropsBase & { raisedHands?: Set; remoteParticipants?: Array; remoteAudioLevel?: number; + suggestLowerHand?: boolean; }; const createActiveDirectCallProp = ( @@ -153,6 +154,7 @@ const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({ ]) ), reactions: overrideProps.reactions || [], + suggestLowerHand: overrideProps.suggestLowerHand ?? false, }); const createActiveCallProp = ( @@ -799,6 +801,37 @@ export function GroupCallHandRaising(): JSX.Element { return ; } +export function GroupCallSuggestLowerHand(): JSX.Element { + const remoteParticipants = allRemoteParticipants.slice(0, 10); + + const [props, setProps] = React.useState( + createProps({ + callMode: CallMode.Group, + remoteParticipants, + raisedHands: new Set([LOCAL_DEMUX_ID]), + viewMode: CallViewMode.Sidebar, + suggestLowerHand: false, + }) + ); + + React.useEffect(() => { + setTimeout( + () => + setProps( + createProps({ + callMode: CallMode.Group, + remoteParticipants, + viewMode: CallViewMode.Sidebar, + suggestLowerHand: true, + }) + ), + 200 + ); + }, [remoteParticipants]); + + return ; +} + // Every [frequency] ms, all hands are lowered and [random min to max] random hands // are raised function useHandRaiser( diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index e38dc935d..335dc5858 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -855,6 +855,13 @@ export function CallScreen({ outgoingRing={undefined} raisedHands={raisedHands} renderRaisedHandsToast={renderRaisedHandsToast} + handleLowerHand={() => toggleRaiseHand(false)} + suggestLowerHand={ + isGroupOrAdhocActiveCall(activeCall) + ? activeCall.suggestLowerHand + : false + } + isHandRaised={localHandRaised} i18n={i18n} /> {isCallLinkAdmin ? ( diff --git a/ts/components/CallingPip.stories.tsx b/ts/components/CallingPip.stories.tsx index 28a26d053..c8dc7fa75 100644 --- a/ts/components/CallingPip.stories.tsx +++ b/ts/components/CallingPip.stories.tsx @@ -144,6 +144,7 @@ export function GroupCall(args: PropsType): JSX.Element { raisedHands: new Set(), remoteParticipants: [], remoteAudioLevels: new Map(), + suggestLowerHand: false, }} /> ); diff --git a/ts/components/CallingToastManager.tsx b/ts/components/CallingToastManager.tsx index 69abf6d63..799480ab8 100644 --- a/ts/components/CallingToastManager.tsx +++ b/ts/components/CallingToastManager.tsx @@ -11,6 +11,7 @@ import { usePrevious } from '../hooks/usePrevious'; import { difference as setDifference } from '../util/setUtil'; import { isMoreRecentThan } from '../util/timestamp'; import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall'; +import { SECOND } from '../util/durations'; type PropsType = { activeCall: ActiveCallType; @@ -251,6 +252,75 @@ function useRaisedHandsToast({ ]); } +function useLowerHandSuggestionToast({ + suggestLowerHand, + handleLowerHand, + i18n, + isHandRaised, +}: { + suggestLowerHand: boolean | undefined; + i18n: LocalizerType; + handleLowerHand: (() => void) | undefined; + isHandRaised: boolean | undefined; +}): void { + const previousSuggestLowerHand = usePrevious( + suggestLowerHand, + suggestLowerHand + ); + const { showToast, hideToast } = useCallingToasts(); + const SUGGEST_LOWER_HAND_TOAST_KEY = 'SUGGEST_LOWER_HAND_TOAST_KEY'; + + useEffect(() => { + if (!handleLowerHand) { + return; + } + if ( + previousSuggestLowerHand !== undefined && + suggestLowerHand !== previousSuggestLowerHand + ) { + if (suggestLowerHand && isHandRaised) { + showToast({ + key: SUGGEST_LOWER_HAND_TOAST_KEY, + content: ( +
+ + {i18n('icu:CallControls__LowerHandSuggestionToast')} + +
+ ), + dismissable: false, + autoClose: true, + lifetime: 10 * SECOND, + }); + } + } + }, [ + suggestLowerHand, + handleLowerHand, + previousSuggestLowerHand, + hideToast, + showToast, + SUGGEST_LOWER_HAND_TOAST_KEY, + isHandRaised, + i18n, + ]); + + useEffect(() => { + if (!isHandRaised) { + hideToast(SUGGEST_LOWER_HAND_TOAST_KEY); + } + }, [isHandRaised, hideToast]); +} + type CallingButtonToastsType = { hasLocalAudio: boolean; outgoingRing: boolean | undefined; @@ -258,6 +328,9 @@ type CallingButtonToastsType = { renderRaisedHandsToast?: ( hands: Array ) => JSX.Element | string | undefined; + suggestLowerHand?: boolean; + isHandRaised?: boolean; + handleLowerHand?: () => void; i18n: LocalizerType; }; @@ -284,11 +357,20 @@ function CallingButtonToasts({ outgoingRing, raisedHands, renderRaisedHandsToast, + suggestLowerHand, + handleLowerHand, + isHandRaised, i18n, }: CallingButtonToastsType) { useMutedToast({ hasLocalAudio, i18n }); useOutgoingRingToast({ outgoingRing, i18n }); useRaisedHandsToast({ raisedHands, renderRaisedHandsToast }); + useLowerHandSuggestionToast({ + suggestLowerHand, + i18n, + handleLowerHand, + isHandRaised, + }); return null; } diff --git a/ts/services/calling.ts b/ts/services/calling.ts index 541156479..440e270fd 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -7,7 +7,6 @@ import type { CallId, DeviceId, GroupCallObserver, - SpeechEvent, PeekInfo, UserId, VideoFrameSource, @@ -39,6 +38,7 @@ import { RingRTC, RingUpdate, GroupCallKind, + SpeechEvent, } from '@signalapp/ringrtc'; import { uniqBy, noop, compact } from 'lodash'; @@ -158,6 +158,7 @@ import { createIdenticon } from '../util/createIdenticon'; import { getColorForCallLink } from '../util/getColorForCallLink'; import { getUseRingrtcAdm } from '../util/ringrtc/ringrtcAdm'; import OS from '../util/os/osMain'; +import { isLowerHandSuggestionEnabled } from '../util/isLowerHandSuggestionEnabled'; const { wasGroupCallRingPreviouslyCanceled } = DataReader; const { @@ -207,10 +208,12 @@ type CallingReduxInterface = Pick< | 'refreshIODevices' | 'remoteSharingScreenChange' | 'remoteVideoChange' + | 'sendGroupCallRaiseHand' | 'startCallingLobby' | 'startCallLinkLobby' | 'startCallLinkLobbyByRoomId' | 'peekNotConnectedGroupCall' + | 'setSuggestLowerHand' > & { areAnyCallsActiveOrRinging(): boolean; }; @@ -1479,8 +1482,22 @@ export class CallingClass { endedReason, }); }, - onSpeechEvent: (_groupCall: GroupCall, _event: SpeechEvent) => { - // Implementation to come later + onSpeechEvent: (_groupCall: GroupCall, event: SpeechEvent) => { + if (!isLowerHandSuggestionEnabled()) { + return; + } + log.info('GroupCall#onSpeechEvent', event); + if (event === SpeechEvent.LowerHandSuggestion) { + this.reduxInterface?.setSuggestLowerHand(true); + } else if (event === SpeechEvent.StoppedSpeaking) { + this.reduxInterface?.setSuggestLowerHand(false); + } else { + log.error( + 'GroupCall#onSpeechEvent, unknown speechEvent', + SpeechEvent, + Errors.toLogFormat(missingCaseError(event)) + ); + } }, }; } diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index 12511f062..813077242 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -193,6 +193,7 @@ export type ActiveCallStateType = { settingsDialogOpen: boolean; showNeedsScreenRecordingPermissionsWarning?: boolean; showParticipantsList: boolean; + suggestLowerHand?: boolean; reactions?: ActiveCallReactionsType; }; export type WaitingCallStateType = ReadonlyDeep<{ @@ -650,6 +651,7 @@ const SET_OUTGOING_RING = 'calling/SET_OUTGOING_RING'; const SET_PRESENTING = 'calling/SET_PRESENTING'; const SET_PRESENTING_SOURCES = 'calling/SET_PRESENTING_SOURCES'; const SET_CAPTURER_BATON = 'calling/SET_CAPTURER_BATON'; +const SUGGEST_LOWER_HAND = 'calling/SUGGEST_LOWER_HAND'; const TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS = 'calling/TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS'; const START_DIRECT_CALL = 'calling/START_DIRECT_CALL'; @@ -915,6 +917,10 @@ type StartDirectCallActionType = ReadonlyDeep<{ type: 'calling/START_DIRECT_CALL'; payload: StartDirectCallType; }>; +type SuggestLowerHandActionType = ReadonlyDeep<{ + type: 'calling/SUGGEST_LOWER_HAND'; + payload: { suggestLowerHand: boolean }; +}>; type ToggleNeedsScreenRecordingPermissionsActionType = ReadonlyDeep<{ type: 'calling/TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS'; @@ -993,6 +999,7 @@ export type CallingActionType = | TogglePipActionType | SetPresentingFulfilledActionType | ToggleSettingsActionType + | SuggestLowerHandActionType | SwitchToPresentationViewActionType | SwitchFromPresentationViewActionType | WaitingForCallingLobbyActionType @@ -1622,6 +1629,15 @@ function hangUpActiveCall( }; } +function setSuggestLowerHand( + suggestLowerHand: boolean +): SuggestLowerHandActionType { + return { + type: SUGGEST_LOWER_HAND, + payload: { suggestLowerHand }, + }; +} + function sendGroupCallRaiseHand( payload: SendGroupCallRaiseHandType ): ThunkAction { @@ -2696,6 +2712,7 @@ export const actions = { setLocalVideo, setOutgoingRing, setRendererCanvas, + setSuggestLowerHand, startCall, startCallLinkLobby, startCallLinkLobbyByRoomId, @@ -4035,5 +4052,23 @@ export function reducer( }; } + if (action.type === SUGGEST_LOWER_HAND) { + const { suggestLowerHand } = action.payload; + const { activeCallState } = state; + + if (activeCallState?.state !== 'Active') { + log.warn('Cannot suggest lower hand when there is no active call'); + return state; + } + + return { + ...state, + activeCallState: { + ...activeCallState, + suggestLowerHand, + }, + }; + } + return state; } diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index 3d1fcc930..794addce7 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -333,6 +333,7 @@ const mapStateToActiveCallProp = ( raisedHands, remoteParticipants, remoteAudioLevels: call.remoteAudioLevels || new Map(), + suggestLowerHand: Boolean(activeCallState.suggestLowerHand), } satisfies ActiveGroupCallType; } default: diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index 28e3694f2..acd247def 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -95,6 +95,7 @@ export type ActiveGroupCallType = ActiveCallBaseType & { raisedHands: Set; remoteParticipants: Array; remoteAudioLevels: Map; + suggestLowerHand: boolean; }; export type ActiveCallType = ActiveDirectCallType | ActiveGroupCallType; diff --git a/ts/util/isLowerHandSuggestionEnabled.ts b/ts/util/isLowerHandSuggestionEnabled.ts new file mode 100644 index 000000000..b41463fbe --- /dev/null +++ b/ts/util/isLowerHandSuggestionEnabled.ts @@ -0,0 +1,16 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as RemoteConfig from '../RemoteConfig'; +import { isProduction } from './version'; + +export function isLowerHandSuggestionEnabled(): boolean { + if ( + isProduction(window.getVersion()) || + !RemoteConfig.isEnabled('desktop.internalUser') + ) { + return false; + } + + return true; +}