From 46038322584e68654df3c9cf90f2ddc66ead32a3 Mon Sep 17 00:00:00 2001 From: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> Date: Thu, 16 Nov 2023 11:55:35 -0800 Subject: [PATCH] Calling Reactions --- _locales/en/messages.json | 8 + stylesheets/_variables.scss | 1 + stylesheets/components/CallControls.scss | 22 +- stylesheets/components/CallingButton.scss | 4 + .../components/CallingReactionsToasts.scss | 65 ++++++ stylesheets/components/CallingToast.scss | 3 +- stylesheets/manifest.scss | 1 + ts/components/CallManager.stories.tsx | 13 +- ts/components/CallManager.tsx | 18 +- ts/components/CallScreen.stories.tsx | 118 ++++++++++- ts/components/CallScreen.tsx | 198 +++++++++++++++++- ts/components/CallingButton.tsx | 4 + ts/components/CallingPip.stories.tsx | 2 + ts/components/CallingToast.tsx | 15 +- ts/components/conversation/ReactionPicker.tsx | 7 +- ts/services/calling.ts | 22 +- ts/state/ducks/calling.ts | 192 +++++++++++++++++ ts/state/smart/CallManager.tsx | 15 ++ ts/state/smart/ReactionPicker.tsx | 21 +- ts/test-electron/state/ducks/calling_test.ts | 173 +++++++++++++++ .../state/selectors/calling_test.ts | 1 + ts/types/Calling.ts | 20 +- ts/util/isGroupCallReactionsEnabled.ts | 8 + ts/util/lint/exceptions.json | 46 +++- 24 files changed, 942 insertions(+), 35 deletions(-) create mode 100644 stylesheets/components/CallingReactionsToasts.scss create mode 100644 ts/util/isGroupCallReactionsEnabled.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 41aded33a6fa..7c0c56421be7 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1780,6 +1780,14 @@ "messageformat": "Turn on ringing", "description": "Button tooltip label for turning ringing on" }, + "icu:CallingButton--more-options": { + "messageformat": "More options", + "description": "Tooltip label for button in the calling screen that opens a menu with other call actions such as React or Raise Hand." + }, + "icu:CallingReactions--me": { + "messageformat": "You", + "description": "Label next to in-call reactions to indicate that the current user sent that reaction." + }, "icu:calling__your-video-is-off": { "messageformat": "Your camera is off", "description": "Label in the calling lobby indicating that your camera is off" diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index b5a466f1f1d6..d7a89a915d9a 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -291,3 +291,4 @@ $NavTabs__width: 80px; $NavTabs__Item__blockPadding: 2px; $NavTabs__Toggle__blockPadding: 8px; $NavTabs__ItemButton__blockPadding: 10px; +$CallControls__height: 80px; diff --git a/stylesheets/components/CallControls.scss b/stylesheets/components/CallControls.scss index 3b8a86e5fd9f..d1ac36f53d86 100644 --- a/stylesheets/components/CallControls.scss +++ b/stylesheets/components/CallControls.scss @@ -11,7 +11,7 @@ align-items: center; justify-content: space-between; max-width: 600px; - height: 80px; + height: $CallControls__height; background-color: $color-gray-78; box-shadow: 0px 4px 14px 0px $color-black-alpha-40; border-radius: 18px; @@ -115,3 +115,23 @@ $local-preview-width: 108px; flex-basis: calc($local-preview-width + 16px); } + +.CallControls__MoreOptionsContainer { + position: absolute; + inset-inline-start: min(48%, 40vw); + inset-block-end: 70px; + z-index: $z-index-calling; +} + +.CallControls__MoreOptionsMenu { + display: flex; + flex-direction: column; + max-height: calc(var(--window-height) - 155px); + filter: drop-shadow(0px 4px 3px $color-black-alpha-20); + pointer-events: auto; +} + +.CallControls__MoreOptionsMenu .module-emoji-picker { + margin-bottom: auto; + max-width: calc(var(--window-width) / 2 + 20px); +} diff --git a/stylesheets/components/CallingButton.scss b/stylesheets/components/CallingButton.scss index 2208d0db678b..fdfc7710129d 100644 --- a/stylesheets/components/CallingButton.scss +++ b/stylesheets/components/CallingButton.scss @@ -115,6 +115,10 @@ @include calling-button-icon-disabled($icon); } } + + &--more-options { + @include calling-button-icon-regular('../images/icons/v3/more/more.svg'); + } } &__button-container { diff --git a/stylesheets/components/CallingReactionsToasts.scss b/stylesheets/components/CallingReactionsToasts.scss new file mode 100644 index 000000000000..408bb2e99e38 --- /dev/null +++ b/stylesheets/components/CallingReactionsToasts.scss @@ -0,0 +1,65 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.CallingReactionsToasts { + position: absolute; + inset-block-end: calc($CallControls__height + 32px); + inset-inline-start: 65px; + width: 100%; +} + +.module-ongoing-call__container--controls-faded-out .CallingReactionsToasts { + inset-block-end: 16px; +} + +// Normally the newest toasts are appended on top, like this: +// | Second | +// | First | -> | First | +// +// For Reactions we need the newest toasts to come in at the bottom: +// | First | +// | First | -> | Second | +// +// To achieve this, rotate the outer container 180deg so everything is upside-down, +// then rotate each toast 180deg to reset the rotation of the actual toasts to 0deg. +.CallingReactionsToasts .CallingToasts { + position: absolute; + inset-block-start: 0; + transform: translateY(-100%) rotate(180deg); +} + +.CallingReactionsToasts .CallingToast { + @include font-body-1; + padding-inline: 16px; + color: $color-gray-20; + font-size: 15px; + line-height: 20px; + transform: rotate(-180deg); +} + +.CallingReactionsToasts .CallingToasts__inner { + width: 100%; + align-items: flex-end; + justify-content: flex-end; + pointer-events: none; + gap: 12px; +} + +.CallingReactionsToasts .CallingToasts__inner div:nth-child(4) .CallingToast { + opacity: 0.9; +} + +.CallingReactionsToasts .CallingToasts__inner div:nth-child(5) .CallingToast { + opacity: 0.7; +} + +.CallingReactionsToasts__reaction { + position: relative; +} + +.CallingReactionsToasts__reaction .module-emoji { + position: absolute; + // Float the emoji outside of the toast bubble + inset-inline-start: -60px; + inset-block-start: -5px; +} diff --git a/stylesheets/components/CallingToast.scss b/stylesheets/components/CallingToast.scss index 279e759b8fea..24159f4c0bce 100644 --- a/stylesheets/components/CallingToast.scss +++ b/stylesheets/components/CallingToast.scss @@ -44,8 +44,7 @@ position: absolute; top: -20px; transform: translateY(-100%); - /* stylelint-disable-next-line liberty/use-logical-spec */ - left: 0; + inset-inline-start: 0; } .CallingToast__viewChanged { diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 8f3664cff4c6..ee21965275c1 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -46,6 +46,7 @@ @import './components/CallingScreenSharingController.scss'; @import './components/CallingSelectPresentingSourcesModal.scss'; @import './components/CallingToast.scss'; +@import './components/CallingReactionsToasts.scss'; @import './components/ChatColorPicker.scss'; @import './components/Checkbox.scss'; @import './components/CircleCheckbox.scss'; diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index 72cd9fcc95bc..0ec9919fe31b 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -14,7 +14,10 @@ import { GroupCallConnectionState, GroupCallJoinState, } from '../types/Calling'; -import type { ConversationTypeType } from '../state/ducks/conversations'; +import type { + ConversationType, + ConversationTypeType, +} from '../state/ducks/conversations'; import { AvatarColors } from '../types/Colors'; import { generateAci } from '../types/ServiceId'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; @@ -71,6 +74,7 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ hangUpActiveCall: action('hang-up-active-call'), i18n, isGroupCallOutboundRingEnabled: true, + isGroupCallReactionsEnabled: true, keyChangeOk: action('key-change-ok'), me: { ...getDefaultConversation({ @@ -83,7 +87,10 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ openSystemPreferencesAction: action('open-system-preferences-action'), playRingtone: action('play-ringtone'), renderDeviceSelection: () =>
, + renderEmojiPicker: () => <>EmojiPicker, + renderReactionPicker: () =>
, renderSafetyNumberViewer: (_: SafetyNumberProps) =>
, + sendGroupCallReaction: action('send-group-call-reaction'), setGroupCallVideoRequest: action('set-group-call-video-request'), setIsCallActive: action('set-is-call-active'), setLocalAudio: action('set-local-audio'), @@ -144,8 +151,10 @@ export function OngoingGroupCall(): JSX.Element { callMode: CallMode.Group, connectionState: GroupCallConnectionState.Connected, conversationsWithSafetyNumberChanges: [], + conversationsByDemuxId: new Map(), deviceCount: 0, joinState: GroupCallJoinState.Joined, + localDemuxId: 1, maxDevices: 5, groupMembers: [], isConversationTooBigToRing: false, @@ -230,8 +239,10 @@ export function GroupCallSafetyNumberChanged(): JSX.Element { }), }, ], + conversationsByDemuxId: new Map(), deviceCount: 0, joinState: GroupCallJoinState.Joined, + localDemuxId: 1, maxDevices: 5, groupMembers: [], isConversationTooBigToRing: false, diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index bc2485afef70..244ccf3f7ef5 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -33,6 +33,7 @@ import type { CancelCallType, DeclineCallType, KeyChangeOkType, + SendGroupCallReactionType, SetGroupCallVideoRequestType, SetLocalAudioType, SetLocalPreviewType, @@ -43,6 +44,8 @@ import type { import type { LocalizerType, ThemeType } from '../types/Util'; import { missingCaseError } from '../util/missingCaseError'; import { CallingToastProvider } from './CallingToast'; +import type { SmartReactionPicker } from '../state/smart/ReactionPicker'; +import type { Props as ReactionPickerProps } from './conversation/ReactionPicker'; const GROUP_CALL_RING_DURATION = 60 * 1000; @@ -72,6 +75,9 @@ export type PropsType = { }; keyChangeOk: (_: KeyChangeOkType) => void; renderDeviceSelection: () => JSX.Element; + renderReactionPicker: ( + props: React.ComponentProps + ) => JSX.Element; renderSafetyNumberViewer: (props: SafetyNumberProps) => JSX.Element; startCall: (payload: StartCallType) => void; toggleParticipants: () => void; @@ -81,6 +87,7 @@ export type PropsType = { declineCall: (_: DeclineCallType) => void; i18n: LocalizerType; isGroupCallOutboundRingEnabled: boolean; + isGroupCallReactionsEnabled: boolean; me: ConversationType; notifyForCall: ( conversationId: string, @@ -89,6 +96,7 @@ export type PropsType = { ) => unknown; openSystemPreferencesAction: () => unknown; playRingtone: () => unknown; + sendGroupCallReaction: (payload: SendGroupCallReactionType) => void; setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void; setIsCallActive: (_: boolean) => void; setLocalAudio: (_: SetLocalAudioType) => void; @@ -107,7 +115,7 @@ export type PropsType = { toggleSettings: () => void; isConversationTooBigToRing: boolean; pauseVoiceNotePlayer: () => void; -}; +} & Pick; type ActiveCallManagerPropsType = PropsType & { activeCall: ActiveCallType; @@ -122,6 +130,7 @@ function ActiveCallManager({ hangUpActiveCall, i18n, isGroupCallOutboundRingEnabled, + isGroupCallReactionsEnabled, keyChangeOk, getGroupCallVideoFrameSource, getPreferredBadge, @@ -129,7 +138,10 @@ function ActiveCallManager({ me, openSystemPreferencesAction, renderDeviceSelection, + renderEmojiPicker, + renderReactionPicker, renderSafetyNumberViewer, + sendGroupCallReaction, setGroupCallVideoRequest, setLocalAudio, setLocalPreview, @@ -329,8 +341,12 @@ function ActiveCallManager({ groupMembers={groupMembers} hangUpActiveCall={hangUpActiveCall} i18n={i18n} + isGroupCallReactionsEnabled={isGroupCallReactionsEnabled} me={me} openSystemPreferencesAction={openSystemPreferencesAction} + renderEmojiPicker={renderEmojiPicker} + renderReactionPicker={renderReactionPicker} + sendGroupCallReaction={sendGroupCallReaction} setGroupCallVideoRequest={setGroupCallVideoRequestForConversation} setLocalPreview={setLocalPreview} setRendererCanvas={setRendererCanvas} diff --git a/ts/components/CallScreen.stories.tsx b/ts/components/CallScreen.stories.tsx index 5b81c2676576..000526819d27 100644 --- a/ts/components/CallScreen.stories.tsx +++ b/ts/components/CallScreen.stories.tsx @@ -2,11 +2,12 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; -import { times } from 'lodash'; +import { sample, times } from 'lodash'; import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/react'; import type { + ActiveCallReactionsType, ActiveGroupCallType, GroupCallRemoteParticipantType, } from '../types/Calling'; @@ -21,7 +22,10 @@ import { generateAci } from '../types/ServiceId'; import type { ConversationType } from '../state/ducks/conversations'; import { AvatarColors } from '../types/Colors'; import type { PropsType } from './CallScreen'; -import { CallScreen as UnwrappedCallScreen } from './CallScreen'; +import { + CALL_REACTION_EMOJI, + CallScreen as UnwrappedCallScreen, +} from './CallScreen'; import { setupI18n } from '../util/setupI18n'; import { missingCaseError } from '../util/missingCaseError'; import { @@ -51,6 +55,7 @@ type OverridePropsBase = { hasLocalVideo?: boolean; localAudioLevel?: number; viewMode?: CallViewMode; + reactions?: ActiveCallReactionsType; }; type DirectCallOverrideProps = OverridePropsBase & { @@ -94,7 +99,17 @@ const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({ connectionState: overrideProps.connectionState || GroupCallConnectionState.Connected, conversationsWithSafetyNumberChanges: [], + conversationsByDemuxId: new Map( + overrideProps.remoteParticipants?.map((participant, index) => [ + participant.demuxId, + getDefaultConversationWithServiceId({ + isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1, + title: `Participant ${index + 1}`, + }), + ]) + ), joinState: GroupCallJoinState.Joined, + localDemuxId: 1, maxDevices: 5, deviceCount: (overrideProps.remoteParticipants || []).length, groupMembers: overrideProps.remoteParticipants || [], @@ -110,6 +125,7 @@ const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({ overrideProps.remoteAudioLevel ?? 0, ]) ), + reactions: overrideProps.reactions || [], }); const createActiveCallProp = ( @@ -149,6 +165,7 @@ const createProps = ( getPresentingSources: action('get-presenting-sources'), hangUpActiveCall: action('hang-up'), i18n, + isGroupCallReactionsEnabled: true, me: getDefaultConversation({ color: AvatarColors[1], id: '6146087e-f7ef-457e-9a8d-47df1fdd6b25', @@ -158,6 +175,9 @@ const createProps = ( serviceId: generateAci(), }), openSystemPreferencesAction: action('open-system-preferences-action'), + renderEmojiPicker: () => <>EmojiPicker, + renderReactionPicker: () =>
, + sendGroupCallReaction: action('send-group-call-reaction'), setGroupCallVideoRequest: action('set-group-call-video-request'), setLocalAudio: action('set-local-audio'), setLocalPreview: action('set-local-preview'), @@ -551,3 +571,97 @@ function useMakeEveryoneTalk( }, [frequency, call]); return call; } + +export function GroupCallReactions(): JSX.Element { + const remoteParticipants = allRemoteParticipants.slice(0, 5); + const [props] = React.useState( + createProps({ + callMode: CallMode.Group, + remoteParticipants, + viewMode: CallViewMode.Overflow, + }) + ); + + const activeCall = useReactionsEmitter( + props.activeCall as ActiveGroupCallType + ); + + return ; +} + +export function GroupCallReactionsSpam(): JSX.Element { + const remoteParticipants = allRemoteParticipants.slice(0, 5); + const [props] = React.useState( + createProps({ + callMode: CallMode.Group, + remoteParticipants, + viewMode: CallViewMode.Overflow, + }) + ); + + const activeCall = useReactionsEmitter( + props.activeCall as ActiveGroupCallType, + 250 + ); + + return ; +} + +export function GroupCallReactionsBurstInOrder(): JSX.Element { + const timestamp = Date.now(); + const remoteParticipants = allRemoteParticipants.slice(0, 5); + const reactions = remoteParticipants.map((participant, i) => { + const { demuxId } = participant; + const value = CALL_REACTION_EMOJI[i % CALL_REACTION_EMOJI.length]; + return { timestamp, demuxId, value }; + }); + const [props] = React.useState( + createProps({ + callMode: CallMode.Group, + remoteParticipants, + viewMode: CallViewMode.Overflow, + reactions, + }) + ); + + return ; +} + +function useReactionsEmitter( + activeCall: ActiveGroupCallType, + frequency = 2000, + removeAfter = 5000 +) { + const [call, setCall] = React.useState(activeCall); + React.useEffect(() => { + const interval = setInterval(() => { + setCall(state => { + const timeNow = Date.now(); + const expireAt = timeNow - removeAfter; + + const participantIndex = Math.floor( + Math.random() * call.remoteParticipants.length + ); + const { demuxId } = call.remoteParticipants[participantIndex]; + + const reactions: ActiveCallReactionsType = [ + ...(state.reactions ?? []).filter( + ({ timestamp }) => timestamp > expireAt + ), + { + timestamp: timeNow, + demuxId, + value: sample(CALL_REACTION_EMOJI) as string, + }, + ]; + + return { + ...state, + reactions, + }; + }); + }, frequency); + return () => clearInterval(interval); + }, [frequency, removeAfter, call]); + return call; +} diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index 57f355087128..65289c6d4e13 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -3,11 +3,12 @@ import type { ReactNode } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react'; -import { noop } from 'lodash'; +import { isEqual, noop } from 'lodash'; import classNames from 'classnames'; import type { VideoFrameSource } from '@signalapp/ringrtc'; import type { ActiveCallStateType, + SendGroupCallReactionType, SetLocalAudioType, SetLocalPreviewType, SetLocalVideoType, @@ -22,10 +23,13 @@ import { TooltipPlacement } from './Tooltip'; import { CallBackgroundBlur } from './CallBackgroundBlur'; import type { ActiveCallType, + ActiveCallReactionsType, + ConversationsByDemuxIdType, GroupCallVideoRequest, PresentedSource, } from '../types/Calling'; import { + CALLING_REACTIONS_LIFETIME, CallMode, CallViewMode, CallState, @@ -60,8 +64,16 @@ import { import { useValueAtFixedRate } from '../hooks/useValueAtFixedRate'; import { isReconnecting as callingIsReconnecting } from '../util/callingIsReconnecting'; import { usePrevious } from '../hooks/usePrevious'; -import { PersistentCallingToast, useCallingToasts } from './CallingToast'; +import { + CallingToastProvider, + PersistentCallingToast, + useCallingToasts, +} from './CallingToast'; import { Spinner } from './Spinner'; +import { handleOutsideClick } from '../util/handleOutsideClick'; +import type { Props as ReactionPickerProps } from './conversation/ReactionPicker'; +import type { SmartReactionPicker } from '../state/smart/ReactionPicker'; +import { Emoji } from './emoji/Emoji'; export type PropsType = { activeCall: ActiveCallType; @@ -70,8 +82,13 @@ export type PropsType = { groupMembers?: Array>; hangUpActiveCall: (reason: string) => void; i18n: LocalizerType; + isGroupCallReactionsEnabled: boolean; me: ConversationType; openSystemPreferencesAction: () => unknown; + renderReactionPicker: ( + props: React.ComponentProps + ) => JSX.Element; + sendGroupCallReaction: (payload: SendGroupCallReactionType) => void; setGroupCallVideoRequest: ( _: Array, speakerHeight: number @@ -89,7 +106,7 @@ export type PropsType = { toggleScreenRecordingPermissionsDialog: () => unknown; toggleSettings: () => void; changeCallView: (mode: CallViewMode) => void; -}; +} & Pick; export const isInSpeakerView = ( call: Pick | undefined @@ -100,6 +117,19 @@ export const isInSpeakerView = ( ); }; +export const CALL_REACTION_EMOJI = [ + '❤️', + '👍', + '👋', + '👏', + '🎉', + '😂', +] as const; + +const REACTIONS_TOASTS_TRANSITION_FROM = { + opacity: 0, +}; + function CallDuration({ joinedAt, }: { @@ -134,9 +164,13 @@ export function CallScreen({ groupMembers, hangUpActiveCall, i18n, + isGroupCallReactionsEnabled, me, openSystemPreferencesAction, + renderEmojiPicker, + renderReactionPicker, setGroupCallVideoRequest, + sendGroupCallReaction, setLocalAudio, setLocalVideo, setLocalPreview, @@ -158,6 +192,7 @@ export function CallScreen({ presentingSource, remoteParticipants, showNeedsScreenRecordingPermissionsWarning, + reactions, } = activeCall; const isSpeaking = useValueAtFixedRate( @@ -198,6 +233,14 @@ export function CallScreen({ hangUpActiveCall('button click'); }, [hangUpActiveCall]); + const moreOptionsMenuRef = React.useRef(null); + const moreOptionsButtonRef = React.useRef(null); + const reactionPickerRef = React.useRef(null); + const [showMoreOptions, setShowMoreOptions] = useState(false); + const toggleMoreOptions = useCallback(() => { + setShowMoreOptions(prevValue => !prevValue); + }, []); + const [controlsHover, setControlsHover] = useState(false); const onControlsMouseEnter = useCallback(() => { @@ -256,6 +299,22 @@ export function CallScreen({ }; }, [toggleAudio, toggleVideo]); + useEffect(() => { + if (!showMoreOptions) { + return noop; + } + return handleOutsideClick( + () => { + setShowMoreOptions(false); + return true; + }, + { + containerElements: [moreOptionsButtonRef, moreOptionsMenuRef], + name: 'CallScreen.moreOptions', + } + ); + }, [showMoreOptions]); + useScreenSharingStoppedToast({ activeCall, i18n }); useViewModeChangedToast({ activeCall, i18n }); @@ -275,6 +334,8 @@ export function CallScreen({ let isConnected: boolean; let participantCount: number; let remoteParticipantsElement: ReactNode; + let conversationsByDemuxId: ConversationsByDemuxIdType; + let localDemuxId: number | undefined; switch (activeCall.callMode) { case CallMode.Direct: { @@ -284,6 +345,7 @@ export function CallScreen({ hasCallStarted = !isRinging; isConnected = activeCall.callState === CallState.Accepted; participantCount = isConnected ? 2 : 0; + conversationsByDemuxId = new Map(); remoteParticipantsElement = hasCallStarted ? ( { setShowControls(true); @@ -532,6 +601,12 @@ export function CallScreen({ )} {remoteParticipantsElement} {lonelyInCallNode} +
+ {showMoreOptions && ( +
+
+ {renderReactionPicker({ + ref: reactionPickerRef, + onClose: () => setShowMoreOptions(false), + onPick: emoji => { + setShowMoreOptions(false); + sendGroupCallReaction({ + conversationId: conversation.id, + value: emoji, + }); + }, + isCustomizePreferredReactionsHidden: true, + preferredReactionEmoji: CALL_REACTION_EMOJI, + renderEmojiPicker, + })} +
+
+ )} +
+ {isMoreOptionsButtonEnabled && ( +
+ +
+ )}
; + localDemuxId: number | undefined; + i18n: LocalizerType; +}; + +function useReactionsToast(props: CallingReactionsToastsType): void { + const { reactions, conversationsByDemuxId, localDemuxId, i18n } = props; + const [previousReactions, setPreviousReactions] = React.useState< + ActiveCallReactionsType | undefined + >(undefined); + const { showToast } = useCallingToasts(); + + useEffect(() => { + setPreviousReactions(reactions); + }, [reactions]); + + useEffect(() => { + if (!reactions || isEqual(reactions, previousReactions)) { + return; + } + + reactions.forEach(({ timestamp, demuxId, value }) => { + showToast({ + key: `reactions-${timestamp}-${demuxId}`, + onlyShowOnce: true, + autoClose: true, + content: ( + + + {demuxId === localDemuxId + ? i18n('icu:CallingReactions--me') + : conversationsByDemuxId.get(demuxId)?.title} + + ), + }); + }); + }, [ + reactions, + previousReactions, + showToast, + conversationsByDemuxId, + localDemuxId, + i18n, + ]); +} + +function CallingReactionsToastsContainer( + props: CallingReactionsToastsType +): JSX.Element { + const { i18n } = props; + const toastRegionRef = useRef(null); + return ( + +
+ + + ); +} + +function CallingReactionsToasts(props: CallingReactionsToastsType) { + useReactionsToast(props); + return null; +} diff --git a/ts/components/CallingButton.tsx b/ts/components/CallingButton.tsx index d07ec980ac6e..3ea68d4354c6 100644 --- a/ts/components/CallingButton.tsx +++ b/ts/components/CallingButton.tsx @@ -22,6 +22,7 @@ export enum CallingButtonType { VIDEO_DISABLED = 'VIDEO_DISABLED', VIDEO_OFF = 'VIDEO_OFF', VIDEO_ON = 'VIDEO_ON', + MORE_OPTIONS = 'MORE_OPTIONS', } export type PropsType = { @@ -90,6 +91,9 @@ export function CallingButton({ } else if (buttonType === CallingButtonType.PRESENTING_OFF) { classNameSuffix = 'presenting--off'; tooltipContent = i18n('icu:calling__button--presenting-on'); + } else if (buttonType === CallingButtonType.MORE_OPTIONS) { + classNameSuffix = 'more-options'; + tooltipContent = i18n('icu:CallingButton--more-options'); } return ( diff --git a/ts/components/CallingPip.stories.tsx b/ts/components/CallingPip.stories.tsx index 06049489b7ad..dca7e31ebe78 100644 --- a/ts/components/CallingPip.stories.tsx +++ b/ts/components/CallingPip.stories.tsx @@ -132,9 +132,11 @@ export function GroupCall(args: PropsType): JSX.Element { callMode: CallMode.Group as CallMode.Group, connectionState: GroupCallConnectionState.Connected, conversationsWithSafetyNumberChanges: [], + conversationsByDemuxId: new Map(), groupMembers: times(3, () => getDefaultConversation()), isConversationTooBigToRing: false, joinState: GroupCallJoinState.Joined, + localDemuxId: 1, maxDevices: 5, deviceCount: 0, peekedParticipants: [], diff --git a/ts/components/CallingToast.tsx b/ts/components/CallingToast.tsx index 07878281c7ea..22f0c3b3883f 100644 --- a/ts/components/CallingToast.tsx +++ b/ts/components/CallingToast.tsx @@ -20,6 +20,10 @@ import { usePrevious } from '../hooks/usePrevious'; import { difference } from '../util/setUtil'; const DEFAULT_LIFETIME = 5000; +const DEFAULT_TRANSITION_FROM = { + opacity: 0, + scale: 0.85, +}; export type CallingToastType = { // If key is provided, calls to showToast will be idempotent; otherwise an @@ -59,11 +63,15 @@ export function CallingToastProvider({ children, region, maxNonPersistentToasts = 5, + lifetime = DEFAULT_LIFETIME, + transitionFrom = DEFAULT_TRANSITION_FROM, }: { i18n: LocalizerType; children: React.ReactNode; region?: React.RefObject; maxNonPersistentToasts?: number; + lifetime?: number; + transitionFrom?: object; }): JSX.Element { const [toasts, setToasts] = React.useState>([]); const previousToasts = usePrevious([], toasts); @@ -153,7 +161,7 @@ export function CallingToastProvider({ } if (toast.autoClose) { - startTimer(key, DEFAULT_LIFETIME); + startTimer(key, lifetime); nonPersistentToasts.unshift({ ...toast, key }); } else { persistentToasts.unshift({ ...toast, key }); @@ -166,7 +174,7 @@ export function CallingToastProvider({ return key; }, - [startTimer, clearToastTimeout, maxNonPersistentToasts] + [startTimer, clearToastTimeout, maxNonPersistentToasts, lifetime] ); const pauseAll = useCallback(() => { @@ -225,9 +233,8 @@ export function CallingToastProvider({ previousToasts[enteringItemIndex] ); return { - opacity: 0, + ...transitionFrom, zIndex: item.autoClose ? 1 : 2, - scale: 0.85, marginTop: // If this toast is replacing an existing one, don't slide-down, just fade-in // Note: this just refers to toasts added / removed within one render cycle; diff --git a/ts/components/conversation/ReactionPicker.tsx b/ts/components/conversation/ReactionPicker.tsx index 006028c2bf71..99c475d97b75 100644 --- a/ts/components/conversation/ReactionPicker.tsx +++ b/ts/components/conversation/ReactionPicker.tsx @@ -27,6 +27,7 @@ export type OwnProps = { onClose?: () => unknown; onPick: (emoji: string) => unknown; onSetSkinTone: (tone: number) => unknown; + isCustomizePreferredReactionsHidden?: boolean; openCustomizePreferredReactionsModal?: () => unknown; preferredReactionEmoji: ReadonlyArray; renderEmojiPicker: (props: RenderEmojiPickerProps) => React.ReactElement; @@ -41,6 +42,7 @@ export const ReactionPicker = React.forwardRef( onClose, onPick, onSetSkinTone, + isCustomizePreferredReactionsHidden, openCustomizePreferredReactionsModal, preferredReactionEmoji, renderEmojiPicker, @@ -78,8 +80,11 @@ export const ReactionPicker = React.forwardRef( const [focusRef] = useDelayedRestoreFocus(); if (pickingOther) { + const onClickSettings = isCustomizePreferredReactionsHidden + ? undefined + : openCustomizePreferredReactionsModal; return renderEmojiPicker({ - onClickSettings: openCustomizePreferredReactionsModal, + onClickSettings, onClose, onPickEmoji, onSetSkinTone, diff --git a/ts/services/calling.ts b/ts/services/calling.ts index d67023a22c30..484b34acef08 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -165,6 +165,7 @@ type CallingReduxInterface = Pick< | 'groupCallAudioLevelsChange' | 'groupCallStateChange' | 'outgoingCall' + | 'receiveGroupCallReactions' | 'receiveIncomingDirectCall' | 'receiveIncomingGroupCall' | 'refreshIODevices' @@ -835,8 +836,16 @@ export class CallingClass { onLowBandwidthForVideo: (_groupCall, _recovered) => { // TODO: Implement handling of "low outgoing bandwidth for video" notification. }, - onReactions: (_groupCall, _reactions) => { - // TODO: Implement handling of reactions. + + /** + * @param reactions A list of reactions received by the client ordered + * from oldest to newest. + */ + onReactions: (_groupCall, reactions) => { + this.reduxInterface?.receiveGroupCallReactions({ + conversationId, + reactions, + }); }, onRaisedHands: (_groupCall, _raisedHands) => { // TODO: Implement handling of raised hands. @@ -1093,6 +1102,7 @@ export class CallingClass { joinState, hasLocalAudio: !localDeviceState.audioMuted, hasLocalVideo: !localDeviceState.videoMuted, + localDemuxId: localDeviceState.demuxId, peekInfo: peekInfo ? this.formatGroupCallPeekInfoForRedux(peekInfo) : undefined, @@ -1143,6 +1153,14 @@ export class CallingClass { groupCall.resendMediaKeys(); } + public sendGroupCallReaction(conversationId: string, value: string): void { + const groupCall = this.getGroupCall(conversationId); + if (!groupCall) { + throw new Error('Could not find matching call'); + } + groupCall.react(value); + } + private syncGroupCallToRedux( conversationId: string, groupCall: GroupCall diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index e0b0aba9cdc0..59004fe3c2dd 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -9,6 +9,7 @@ import { } from 'mac-screen-capture-permissions'; import { has, omit } from 'lodash'; import type { ReadonlyDeep } from 'type-fest'; +import type { Reaction as CallReaction } from '@signalapp/ringrtc'; import { getOwn } from '../../util/getOwn'; import * as Errors from '../../types/errors'; import { getPlatform } from '../selectors/user'; @@ -18,6 +19,8 @@ import { calling } from '../../services/calling'; import { truncateAudioLevel } from '../../calling/truncateAudioLevel'; import type { StateType as RootStateType } from '../reducer'; import type { + ActiveCallReaction, + ActiveCallReactionsType, ChangeIODevicePayloadType, GroupCallVideoRequest, MediaDeviceSettings, @@ -25,6 +28,8 @@ import type { PresentableSource, } from '../../types/Calling'; import { + CALLING_REACTIONS_LIFETIME, + MAX_CALLING_REACTIONS, CallEndedReason, CallingDeviceType, CallMode, @@ -108,6 +113,7 @@ export type GroupCallStateType = { callMode: CallMode.Group; conversationId: string; connectionState: GroupCallConnectionState; + localDemuxId: number | undefined; joinState: GroupCallJoinState; peekInfo?: GroupCallPeekInfoType; remoteParticipants: Array; @@ -131,6 +137,7 @@ export type ActiveCallStateType = { settingsDialogOpen: boolean; showNeedsScreenRecordingPermissionsWarning?: boolean; showParticipantsList: boolean; + reactions?: ActiveCallReactionsType; }; // eslint-disable-next-line local-rules/type-alias-readonlydeep @@ -176,10 +183,16 @@ type GroupCallStateChangeArgumentType = { hasLocalAudio: boolean; hasLocalVideo: boolean; joinState: GroupCallJoinState; + localDemuxId: number | undefined; peekInfo?: GroupCallPeekInfoType; remoteParticipants: Array; }; +type GroupCallReactionsReceivedArgumentType = ReadonlyDeep<{ + conversationId: string; + reactions: Array; +}>; + // eslint-disable-next-line local-rules/type-alias-readonlydeep type GroupCallStateChangeActionPayloadType = GroupCallStateChangeArgumentType & { @@ -209,6 +222,17 @@ type IncomingGroupCallType = ReadonlyDeep<{ ringerAci: AciString; }>; +export type SendGroupCallReactionType = ReadonlyDeep<{ + conversationId: string; + value: string; +}>; + +type SendGroupCallReactionLocalCopyType = ReadonlyDeep<{ + conversationId: string; + value: string; + timestamp: number; +}>; + type PeekNotConnectedGroupCallType = ReadonlyDeep<{ conversationId: string; }>; @@ -422,6 +446,8 @@ const CLOSE_NEED_PERMISSION_SCREEN = 'calling/CLOSE_NEED_PERMISSION_SCREEN'; const DECLINE_DIRECT_CALL = 'calling/DECLINE_DIRECT_CALL'; const GROUP_CALL_AUDIO_LEVELS_CHANGE = 'calling/GROUP_CALL_AUDIO_LEVELS_CHANGE'; const GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE'; +const GROUP_CALL_REACTIONS_RECEIVED = 'calling/GROUP_CALL_REACTIONS_RECEIVED'; +const GROUP_CALL_REACTIONS_EXPIRED = 'calling/GROUP_CALL_REACTIONS_EXPIRED'; const HANG_UP = 'calling/HANG_UP'; const INCOMING_DIRECT_CALL = 'calling/INCOMING_DIRECT_CALL'; const INCOMING_GROUP_CALL = 'calling/INCOMING_GROUP_CALL'; @@ -433,6 +459,7 @@ const REFRESH_IO_DEVICES = 'calling/REFRESH_IO_DEVICES'; const REMOTE_SHARING_SCREEN_CHANGE = 'calling/REMOTE_SHARING_SCREEN_CHANGE'; const REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE'; const RETURN_TO_ACTIVE_CALL = 'calling/RETURN_TO_ACTIVE_CALL'; +const SEND_GROUP_CALL_REACTION = 'calling/SEND_GROUP_CALL_REACTION'; const SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_FULFILLED'; const SET_LOCAL_VIDEO_FULFILLED = 'calling/SET_LOCAL_VIDEO_FULFILLED'; const SET_OUTGOING_RING = 'calling/SET_OUTGOING_RING'; @@ -504,6 +531,27 @@ export type GroupCallStateChangeActionType = { payload: GroupCallStateChangeActionPayloadType; }; +type GroupCallReactionsReceivedActionPayloadType = ReadonlyDeep<{ + conversationId: string; + reactions: Array; + timestamp: number; +}>; + +type GroupCallReactionsExpiredActionPayloadType = ReadonlyDeep<{ + conversationId: string; + timestamp: number; +}>; + +export type GroupCallReactionsReceivedActionType = ReadonlyDeep<{ + type: 'calling/GROUP_CALL_REACTIONS_RECEIVED'; + payload: GroupCallReactionsReceivedActionPayloadType; +}>; + +type GroupCallReactionsExpiredActionType = ReadonlyDeep<{ + type: 'calling/GROUP_CALL_REACTIONS_EXPIRED'; + payload: GroupCallReactionsExpiredActionPayloadType; +}>; + type HangUpActionType = ReadonlyDeep<{ type: 'calling/HANG_UP'; payload: HangUpActionPayloadType; @@ -532,6 +580,11 @@ type KeyChangeOkActionType = ReadonlyDeep<{ payload: null; }>; +export type SendGroupCallReactionActionType = ReadonlyDeep<{ + type: 'calling/SEND_GROUP_CALL_REACTION'; + payload: SendGroupCallReactionLocalCopyType; +}>; + type OutgoingCallActionType = ReadonlyDeep<{ type: 'calling/OUTGOING_CALL'; payload: StartDirectCallType; @@ -640,6 +693,8 @@ export type CallingActionType = | DeclineCallActionType | GroupCallAudioLevelsChangeActionType | GroupCallStateChangeActionType + | GroupCallReactionsReceivedActionType + | GroupCallReactionsExpiredActionType | HangUpActionType | IncomingDirectCallActionType | IncomingGroupCallActionType @@ -651,6 +706,7 @@ export type CallingActionType = | RemoteSharingScreenChangeActionType | RemoteVideoChangeActionType | ReturnToActiveCallActionType + | SendGroupCallReactionActionType | SetLocalAudioActionType | SetLocalVideoFulfilledActionType | SetPresentingSourcesActionType @@ -869,6 +925,31 @@ function groupCallAudioLevelsChange( return { type: GROUP_CALL_AUDIO_LEVELS_CHANGE, payload }; } +function receiveGroupCallReactions( + payload: GroupCallReactionsReceivedArgumentType +): ThunkAction< + void, + RootStateType, + unknown, + GroupCallReactionsReceivedActionType | GroupCallReactionsExpiredActionType +> { + return async dispatch => { + const { conversationId } = payload; + const timestamp = Date.now(); + + dispatch({ + type: GROUP_CALL_REACTIONS_RECEIVED, + payload: { ...payload, timestamp }, + }); + await sleep(CALLING_REACTIONS_LIFETIME); + + dispatch({ + type: GROUP_CALL_REACTIONS_EXPIRED, + payload: { conversationId, timestamp }, + }); + }; +} + function groupCallStateChange( payload: GroupCallStateChangeArgumentType ): ThunkAction { @@ -994,6 +1075,32 @@ function keyChangeOk( }; } +function sendGroupCallReaction( + payload: SendGroupCallReactionType +): ThunkAction< + void, + RootStateType, + unknown, + SendGroupCallReactionActionType | GroupCallReactionsExpiredActionType +> { + return async dispatch => { + const { conversationId } = payload; + const timestamp = Date.now(); + + calling.sendGroupCallReaction(payload.conversationId, payload.value); + dispatch({ + type: SEND_GROUP_CALL_REACTION, + payload: { ...payload, timestamp }, + }); + await sleep(CALLING_REACTIONS_LIFETIME); + + dispatch({ + type: GROUP_CALL_REACTIONS_EXPIRED, + payload: { conversationId, timestamp }, + }); + }; +} + function receiveIncomingDirectCall( payload: IncomingDirectCallType ): ThunkAction { @@ -1516,12 +1623,14 @@ export const actions = { peekGroupCallForTheFirstTime, peekGroupCallIfItHasMembers, peekNotConnectedGroupCall, + receiveGroupCallReactions, receiveIncomingDirectCall, receiveIncomingGroupCall, refreshIODevices, remoteSharingScreenChange, remoteVideoChange, returnToActiveCall, + sendGroupCallReaction, setGroupCallVideoRequest, setIsCallActive, setLocalAudio, @@ -1613,6 +1722,7 @@ export function reducer( conversationId, connectionState: action.payload.connectionState, joinState: action.payload.joinState, + localDemuxId: undefined, peekInfo: action.payload.peekInfo || existingCall?.peekInfo || { acis: action.payload.remoteParticipants.map(({ aci }) => aci), @@ -1815,6 +1925,7 @@ export function reducer( conversationId, connectionState: GroupCallConnectionState.NotConnected, joinState: GroupCallJoinState.NotJoined, + localDemuxId: undefined, peekInfo: { acis: [], maxDevices: Infinity, @@ -1964,6 +2075,7 @@ export function reducer( conversationId, hasLocalAudio, hasLocalVideo, + localDemuxId, joinState, ourAci, peekInfo, @@ -2022,6 +2134,7 @@ export function reducer( conversationId, connectionState, joinState, + localDemuxId, peekInfo: newPeekInfo, remoteParticipants, ...newRingState, @@ -2042,6 +2155,7 @@ export function reducer( conversationId, connectionState: GroupCallConnectionState.NotConnected, joinState: GroupCallJoinState.NotJoined, + localDemuxId: undefined, peekInfo: { acis: [], maxDevices: Infinity, @@ -2075,6 +2189,84 @@ export function reducer( }; } + if ( + action.type === SEND_GROUP_CALL_REACTION || + action.type === GROUP_CALL_REACTIONS_RECEIVED + ) { + const { conversationId, timestamp } = action.payload; + if (state.activeCallState?.conversationId !== conversationId) { + return state; + } + + let recentReactions: Array = []; + if (action.type === GROUP_CALL_REACTIONS_RECEIVED) { + recentReactions = action.payload.reactions.map(({ demuxId, value }) => { + return { timestamp, demuxId, value }; + }); + } else { + // When sending reactions, ringrtc doesn't automatically receive back a copy of + // the reaction you just sent. We handle it here and add a local copy to state. + const existingGroupCall = getGroupCall(conversationId, state); + if (!existingGroupCall) { + log.warn( + 'Unable to update group call reactions after send reaction because existing group call is missing.' + ); + return state; + } + + // This should never happen -- localDemuxId is set when a call enters the + // Joining state, and Reactions are only usable from the CallScreen which is + // shown when the call is in the Joined state (after Joining). + if (!existingGroupCall.localDemuxId) { + log.warn( + 'Unable to update group call reactions after send reaction because localDemuxId is missing.' + ); + return state; + } + + recentReactions = [ + { + timestamp, + demuxId: existingGroupCall.localDemuxId, + value: action.payload.value, + }, + ]; + } + + return { + ...state, + activeCallState: { + ...state.activeCallState, + reactions: [ + ...(state.activeCallState.reactions ?? []), + ...recentReactions, + ].slice(-MAX_CALLING_REACTIONS), + }, + }; + } + + if (action.type === GROUP_CALL_REACTIONS_EXPIRED) { + const { conversationId, timestamp: receivedAt } = action.payload; + if ( + state.activeCallState?.conversationId !== conversationId || + !state.activeCallState?.reactions + ) { + return state; + } + + const expireAt = receivedAt + CALLING_REACTIONS_LIFETIME; + + return { + ...state, + activeCallState: { + ...state.activeCallState, + reactions: state.activeCallState.reactions.filter(({ timestamp }) => { + return timestamp > expireAt; + }), + }, + }; + } + if (action.type === REMOTE_SHARING_SCREEN_CHANGE) { const { conversationId, isSharingScreen } = action.payload; const call = getOwn(state.callsByConversation, conversationId); diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index d20448974d08..caadc879dc8c 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -13,11 +13,13 @@ import { getActiveCall } from '../ducks/calling'; import type { ConversationType } from '../ducks/conversations'; import { getIncomingCall } from '../selectors/calling'; import { isGroupCallOutboundRingEnabled } from '../../util/isGroupCallOutboundRingEnabled'; +import { isGroupCallReactionsEnabled } from '../../util/isGroupCallReactionsEnabled'; import type { ActiveCallBaseType, ActiveCallType, ActiveDirectCallType, ActiveGroupCallType, + ConversationsByDemuxIdType, GroupCallRemoteParticipantType, } from '../../types/Calling'; import { isAciString } from '../../util/isAciString'; @@ -43,6 +45,8 @@ import * as log from '../../logging/log'; import { getPreferredBadgeSelector } from '../selectors/badges'; import { isConversationTooBigToRing } from '../../conversations/isConversationTooBigToRing'; import { strictAssert } from '../../util/assert'; +import { renderEmojiPicker } from './renderEmojiPicker'; +import { renderReactionPicker } from './renderReactionPicker'; function renderDeviceSelection(): JSX.Element { return ; @@ -158,6 +162,7 @@ const mapStateToActiveCallProp = ( activeCallState.showNeedsScreenRecordingPermissionsWarning ), showParticipantsList: activeCallState.showParticipantsList, + reactions: activeCallState.reactions, }; switch (call.callMode) { @@ -195,6 +200,7 @@ const mapStateToActiveCallProp = ( const groupMembers: Array = []; const remoteParticipants: Array = []; const peekedParticipants: Array = []; + const conversationsByDemuxId: ConversationsByDemuxIdType = new Map(); const { memberships = [] } = conversation; @@ -242,6 +248,10 @@ const mapStateToActiveCallProp = ( speakerTime: remoteParticipant.speakerTime, videoAspectRatio: remoteParticipant.videoAspectRatio, }); + conversationsByDemuxId.set( + remoteParticipant.demuxId, + remoteConversation + ); } for ( @@ -278,10 +288,12 @@ const mapStateToActiveCallProp = ( callMode: CallMode.Group, connectionState: call.connectionState, conversationsWithSafetyNumberChanges, + conversationsByDemuxId, deviceCount: peekInfo.deviceCount, groupMembers, isConversationTooBigToRing: isConversationTooBigToRing(conversation), joinState: call.joinState, + localDemuxId: call.localDemuxId, maxDevices: peekInfo.maxDevices, peekedParticipants, remoteParticipants, @@ -348,11 +360,14 @@ const mapStateToProps = (state: StateType) => { getPreferredBadge: getPreferredBadgeSelector(state), i18n: getIntl(state), isGroupCallOutboundRingEnabled: isGroupCallOutboundRingEnabled(), + isGroupCallReactionsEnabled: isGroupCallReactionsEnabled(), incomingCall, me: getMe(state), notifyForCall, playRingtone, stopRingtone, + renderEmojiPicker, + renderReactionPicker, renderDeviceSelection, renderSafetyNumberViewer, theme: getTheme(state), diff --git a/ts/state/smart/ReactionPicker.tsx b/ts/state/smart/ReactionPicker.tsx index c2dce45a3c1a..ed9c43107d5e 100644 --- a/ts/state/smart/ReactionPicker.tsx +++ b/ts/state/smart/ReactionPicker.tsx @@ -11,32 +11,37 @@ import { getIntl } from '../selectors/user'; import { getPreferredReactionEmoji } from '../selectors/items'; import type { LocalizerType } from '../../types/Util'; -import type { Props } from '../../components/conversation/ReactionPicker'; +import type { Props as InternalProps } from '../../components/conversation/ReactionPicker'; import { ReactionPicker } from '../../components/conversation/ReactionPicker'; type ExternalProps = Omit< - Props, + InternalProps, | 'i18n' | 'onSetSkinTone' | 'openCustomizePreferredReactionsModal' | 'preferredReactionEmoji' | 'selectionStyle' | 'skinTone' ->; +> & { + preferredReactionEmoji?: ReadonlyArray; +}; export const SmartReactionPicker = React.forwardRef< HTMLDivElement, ExternalProps >(function SmartReactionPickerInner(props, ref) { + const { preferredReactionEmoji } = props; const { openCustomizePreferredReactionsModal } = usePreferredReactionsActions(); + const { onSetSkinTone } = useItemsActions(); const i18n = useSelector(getIntl); - const preferredReactionEmoji = useSelector>( - getPreferredReactionEmoji - ); + const statePreferredReactionEmoji = useSelector< + StateType, + ReadonlyArray + >(getPreferredReactionEmoji); return ( ); }); diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index 873e27baa3bb..b7ead4b0fe38 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -12,8 +12,10 @@ import type { ActiveCallStateType, CallingStateType, DirectCallStateType, + GroupCallReactionsReceivedActionType, GroupCallStateChangeActionType, GroupCallStateType, + SendGroupCallReactionActionType, } from '../../../state/ducks/calling'; import { actions, @@ -36,6 +38,7 @@ import { getDefaultConversation } from '../../../test-both/helpers/getDefaultCon import type { UnwrapPromise } from '../../../types/Util'; const ACI_1 = generateAci(); +const NOW = new Date('2020-01-23T04:56:00.000'); type CallingStateTypeWithActiveCall = CallingStateType & { activeCallState: ActiveCallStateType; @@ -101,6 +104,7 @@ describe('calling duck', () => { conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.NotJoined, + localDemuxId: 1, peekInfo: { acis: [creatorAci], creatorAci, @@ -167,6 +171,19 @@ describe('calling duck', () => { }; }; + function useFakeTimers() { + beforeEach(function (this: Mocha.Context) { + this.sandbox = sinon.createSandbox(); + this.clock = this.sandbox.useFakeTimers({ + now: NOW, + }); + }); + + afterEach(function (this: Mocha.Context) { + this.sandbox.restore(); + }); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any let oldEvents: any; beforeEach(function (this: Mocha.Context) { @@ -873,6 +890,7 @@ describe('calling duck', () => { conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joining, + localDemuxId: 1, hasLocalAudio: true, hasLocalVideo: false, peekInfo: { @@ -903,6 +921,7 @@ describe('calling duck', () => { conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joining, + localDemuxId: 1, peekInfo: { acis: [creatorAci], creatorAci, @@ -932,6 +951,7 @@ describe('calling duck', () => { conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, + localDemuxId: 1, hasLocalAudio: true, hasLocalVideo: false, peekInfo: { @@ -960,6 +980,7 @@ describe('calling duck', () => { conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, + localDemuxId: 1, peekInfo: { acis: [ACI_1], maxDevices: 16, @@ -1000,6 +1021,7 @@ describe('calling duck', () => { conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.NotJoined, + localDemuxId: 1, hasLocalAudio: true, hasLocalVideo: false, peekInfo: { @@ -1050,6 +1072,7 @@ describe('calling duck', () => { getAction({ conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, + localDemuxId: 1, joinState: GroupCallJoinState.Joined, hasLocalAudio: true, hasLocalVideo: false, @@ -1089,6 +1112,7 @@ describe('calling duck', () => { conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, + localDemuxId: 1, hasLocalAudio: true, hasLocalVideo: false, peekInfo: { @@ -1120,6 +1144,7 @@ describe('calling duck', () => { conversationId: 'another-fake-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, + localDemuxId: 1, hasLocalAudio: true, hasLocalVideo: true, peekInfo: { @@ -1163,6 +1188,7 @@ describe('calling duck', () => { conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, + localDemuxId: 1, hasLocalAudio: true, hasLocalVideo: true, peekInfo: { @@ -1206,6 +1232,7 @@ describe('calling duck', () => { conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, + localDemuxId: 1, hasLocalAudio: true, hasLocalVideo: true, peekInfo: { @@ -1234,6 +1261,7 @@ describe('calling duck', () => { conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, + localDemuxId: 1, hasLocalAudio: true, hasLocalVideo: true, peekInfo: { @@ -1389,6 +1417,7 @@ describe('calling duck', () => { conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.NotConnected, joinState: GroupCallJoinState.NotJoined, + localDemuxId: undefined, peekInfo: { acis: [], maxDevices: Infinity, @@ -1419,6 +1448,149 @@ describe('calling duck', () => { }); }); + describe('receiveGroupCallReactions', () => { + useFakeTimers(); + + const { receiveGroupCallReactions } = actions; + + const getState = (): RootStateType => ({ + ...getEmptyRootState(), + calling: { + ...stateWithActiveGroupCall, + }, + }); + + function getAction( + ...args: Parameters + ): GroupCallReactionsReceivedActionType { + const dispatch = sinon.spy(); + + receiveGroupCallReactions(...args)(dispatch, getState, null); + + return dispatch.getCall(0).args[0]; + } + + it('adds reactions by timestamp', function (this: Mocha.Context) { + const firstAction = getAction({ + conversationId: 'fake-group-call-conversation-id', + reactions: [ + { + demuxId: 123, + value: '❤️', + }, + ], + }); + const firstResult = reducer(getState().calling, firstAction); + assert.deepEqual(firstResult.activeCallState?.reactions, [ + { + timestamp: NOW.getTime(), + demuxId: 123, + value: '❤️', + }, + ]); + + const secondDate = new Date(NOW.getTime() + 1234); + this.sandbox.useFakeTimers({ now: secondDate }); + const secondAction = getAction({ + conversationId: 'fake-group-call-conversation-id', + reactions: [ + { + demuxId: 456, + value: '🎉', + }, + ], + }); + const secondResult = reducer(firstResult, secondAction); + assert.deepEqual(secondResult.activeCallState?.reactions, [ + { + timestamp: NOW.getTime(), + demuxId: 123, + value: '❤️', + }, + { + timestamp: secondDate.getTime(), + demuxId: 456, + value: '🎉', + }, + ]); + }); + + it('sets multiple reactions with the same timestamp', () => { + const action = getAction({ + conversationId: 'fake-group-call-conversation-id', + reactions: [ + { + demuxId: 123, + value: '❤️', + }, + { + demuxId: 456, + value: '🎉', + }, + ], + }); + const result = reducer(getState().calling, action); + assert.deepEqual(result.activeCallState?.reactions, [ + { + timestamp: NOW.getTime(), + demuxId: 123, + value: '❤️', + }, + { + timestamp: NOW.getTime(), + demuxId: 456, + value: '🎉', + }, + ]); + }); + }); + + describe('sendGroupCallReactions', () => { + useFakeTimers(); + + beforeEach(function (this: Mocha.Context) { + this.callingServiceSendGroupCallReaction = this.sandbox.stub( + callingService, + 'sendGroupCallReaction' + ); + }); + + const { sendGroupCallReaction } = actions; + + const getState = (): RootStateType => ({ + ...getEmptyRootState(), + calling: { + ...stateWithActiveGroupCall, + }, + }); + + function getAction( + ...args: Parameters + ): SendGroupCallReactionActionType { + const dispatch = sinon.spy(); + + sendGroupCallReaction(...args)(dispatch, getState, null); + + return dispatch.getCall(0).args[0]; + } + + it('adds a local copy', () => { + const action = getAction({ + conversationId: 'fake-group-call-conversation-id', + value: '❤️', + }); + const result = reducer(getState().calling, action); + + assert.deepEqual(result.activeCallState?.reactions, [ + { + timestamp: NOW.getTime(), + demuxId: 1, + value: '❤️', + }, + ]); + }); + }); + describe('setLocalAudio', () => { const { setLocalAudio } = actions; @@ -1720,6 +1892,7 @@ describe('calling duck', () => { conversationId: 'fake-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.NotJoined, + localDemuxId: undefined, peekInfo: { acis: [creatorAci], creatorAci, diff --git a/ts/test-electron/state/selectors/calling_test.ts b/ts/test-electron/state/selectors/calling_test.ts index 865a986ea60c..0d3751f22f8e 100644 --- a/ts/test-electron/state/selectors/calling_test.ts +++ b/ts/test-electron/state/selectors/calling_test.ts @@ -97,6 +97,7 @@ describe('state/selectors/calling', () => { conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.NotConnected, joinState: GroupCallJoinState.NotJoined, + localDemuxId: undefined, peekInfo: { acis: [ACI_1], creatorAci: ACI_1, diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index 1ff1c5eebac9..1cb236243b9a 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -1,10 +1,13 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { AudioDevice } from '@signalapp/ringrtc'; +import type { AudioDevice, Reaction as CallReaction } from '@signalapp/ringrtc'; import type { ConversationType } from '../state/ducks/conversations'; import type { AciString, ServiceIdString } from './ServiceId'; +export const MAX_CALLING_REACTIONS = 5; +export const CALLING_REACTIONS_LIFETIME = 4000; + // These are strings (1) for the database (2) for Storybook. export enum CallMode { Direct = 'Direct', @@ -34,6 +37,16 @@ export type PresentedSource = { name: string; }; +// export type ActiveCallReactionsType = { +// [timestamp: number]: ReadonlyArray; +// }; + +export type ActiveCallReaction = { + timestamp: number; +} & CallReaction; + +export type ActiveCallReactionsType = ReadonlyArray; + export type ActiveCallBaseType = { conversation: ConversationType; hasLocalAudio: boolean; @@ -50,6 +63,7 @@ export type ActiveCallBaseType = { settingsDialogOpen: boolean; showNeedsScreenRecordingPermissionsWarning?: boolean; showParticipantsList: boolean; + reactions?: ActiveCallReactionsType; }; export type ActiveDirectCallType = ActiveCallBaseType & { @@ -73,8 +87,10 @@ export type ActiveDirectCallType = ActiveCallBaseType & { export type ActiveGroupCallType = ActiveCallBaseType & { callMode: CallMode.Group; connectionState: GroupCallConnectionState; + conversationsByDemuxId: ConversationsByDemuxIdType; conversationsWithSafetyNumberChanges: Array; joinState: GroupCallJoinState; + localDemuxId: number | undefined; maxDevices: number; deviceCount: number; groupMembers: Array>; @@ -148,6 +164,8 @@ export type GroupCallRemoteParticipantType = ConversationType & { videoAspectRatio: number; }; +export type ConversationsByDemuxIdType = Map; + // Similar to RingRTC's `VideoRequest` but without the `framerate` property. export type GroupCallVideoRequest = { demuxId: number; diff --git a/ts/util/isGroupCallReactionsEnabled.ts b/ts/util/isGroupCallReactionsEnabled.ts new file mode 100644 index 000000000000..982232c300c9 --- /dev/null +++ b/ts/util/isGroupCallReactionsEnabled.ts @@ -0,0 +1,8 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as RemoteConfig from '../RemoteConfig'; + +export function isGroupCallReactionsEnabled(): boolean { + return Boolean(RemoteConfig.isEnabled('desktop.internalUser')); +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 941cbf6a2056..13b63c56373b 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -2969,6 +2969,38 @@ "reasonCategory": "usageTrusted", "updated": "2021-12-01T01:31:12.757Z" }, + { + "rule": "React-useRef", + "path": "ts/components/CallScreen.tsx", + "line": " const toastRegionRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2023-11-14T23:29:51.425Z", + "reasonDetail": "For calling reactions toasts" + }, + { + "rule": "React-useRef", + "path": "ts/components/CallScreen.tsx", + "line": " const reactionPickerRef = React.useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2023-11-14T23:29:51.425Z", + "reasonDetail": "To render the reaction picker in the CallScreen" + }, + { + "rule": "React-useRef", + "path": "ts/components/CallScreen.tsx", + "line": " const moreOptionsMenuRef = React.useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2023-11-14T23:29:51.425Z", + "reasonDetail": "Used to detect clicks outside of the Calling More Options button menu" + }, + { + "rule": "React-useRef", + "path": "ts/components/CallScreen.tsx", + "line": " const moreOptionsButtonRef = React.useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2023-11-14T23:29:51.425Z", + "reasonDetail": "Used to detect clicks outside of the Calling More Options button menu and ensures clicking the button does not re-open the menu." + }, { "rule": "React-useRef", "path": "ts/components/CallingLobby.tsx", @@ -3019,13 +3051,6 @@ "reasonCategory": "usageTrusted", "updated": "2023-10-10T17:05:02.468Z" }, - { - "rule": "React-useRef", - "path": "ts/components/CallingToastManager.tsx", - "line": " const toastRegionRef = useRef(null);", - "reasonCategory": "usageTrusted", - "updated": "2023-10-26T13:57:41.860Z" - }, { "rule": "React-useRef", "path": "ts/components/CallingToast.tsx", @@ -3033,6 +3058,13 @@ "reasonCategory": "usageTrusted", "updated": "2023-11-14T16:52:45.342Z" }, + { + "rule": "React-useRef", + "path": "ts/components/CallingToastManager.tsx", + "line": " const toastRegionRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2023-10-26T13:57:41.860Z" + }, { "rule": "React-useRef", "path": "ts/components/CallsList.tsx",