Calling Reactions

This commit is contained in:
ayumi-signal 2023-11-16 11:55:35 -08:00 committed by GitHub
parent ab187ab265
commit 4603832258
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 942 additions and 35 deletions

View file

@ -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"

View file

@ -291,3 +291,4 @@ $NavTabs__width: 80px;
$NavTabs__Item__blockPadding: 2px;
$NavTabs__Toggle__blockPadding: 8px;
$NavTabs__ItemButton__blockPadding: 10px;
$CallControls__height: 80px;

View file

@ -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);
}

View file

@ -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 {

View file

@ -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;
}

View file

@ -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 {

View file

@ -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';

View file

@ -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> = {}): 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> = {}): PropsType => ({
openSystemPreferencesAction: action('open-system-preferences-action'),
playRingtone: action('play-ringtone'),
renderDeviceSelection: () => <div />,
renderEmojiPicker: () => <>EmojiPicker</>,
renderReactionPicker: () => <div />,
renderSafetyNumberViewer: (_: SafetyNumberProps) => <div />,
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<number, ConversationType>(),
deviceCount: 0,
joinState: GroupCallJoinState.Joined,
localDemuxId: 1,
maxDevices: 5,
groupMembers: [],
isConversationTooBigToRing: false,
@ -230,8 +239,10 @@ export function GroupCallSafetyNumberChanged(): JSX.Element {
}),
},
],
conversationsByDemuxId: new Map<number, ConversationType>(),
deviceCount: 0,
joinState: GroupCallJoinState.Joined,
localDemuxId: 1,
maxDevices: 5,
groupMembers: [],
isConversationTooBigToRing: false,

View file

@ -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<typeof SmartReactionPicker>
) => 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<ReactionPickerProps, 'renderEmojiPicker'>;
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}

View file

@ -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<number, ConversationType>(
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: () => <div />,
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 <CallScreen {...props} activeCall={activeCall} />;
}
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 <CallScreen {...props} activeCall={activeCall} />;
}
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 <CallScreen {...props} />;
}
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;
}

View file

@ -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<Pick<ConversationType, 'id' | 'firstName' | 'title'>>;
hangUpActiveCall: (reason: string) => void;
i18n: LocalizerType;
isGroupCallReactionsEnabled: boolean;
me: ConversationType;
openSystemPreferencesAction: () => unknown;
renderReactionPicker: (
props: React.ComponentProps<typeof SmartReactionPicker>
) => JSX.Element;
sendGroupCallReaction: (payload: SendGroupCallReactionType) => void;
setGroupCallVideoRequest: (
_: Array<GroupCallVideoRequest>,
speakerHeight: number
@ -89,7 +106,7 @@ export type PropsType = {
toggleScreenRecordingPermissionsDialog: () => unknown;
toggleSettings: () => void;
changeCallView: (mode: CallViewMode) => void;
};
} & Pick<ReactionPickerProps, 'renderEmojiPicker'>;
export const isInSpeakerView = (
call: Pick<ActiveCallStateType, 'viewMode'> | 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 | HTMLDivElement>(null);
const moreOptionsButtonRef = React.useRef<null | HTMLDivElement>(null);
const reactionPickerRef = React.useRef<null | HTMLDivElement>(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 ? (
<DirectCallRemoteParticipant
conversation={conversation}
@ -304,6 +366,8 @@ export function CallScreen({
!(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;
@ -397,14 +461,15 @@ export function CallScreen({
const isAudioOnly = !hasLocalVideo && !hasRemoteVideo;
const controlsFadedOut = !showControls && !isAudioOnly && isConnected;
const controlsFadeClass = classNames({
'module-ongoing-call__controls--fadeIn':
(showControls || isAudioOnly) && !isConnected,
'module-ongoing-call__controls--fadeOut':
!showControls && !isAudioOnly && isConnected,
'module-ongoing-call__controls--fadeOut': controlsFadedOut,
});
const isGroupCall = activeCall.callMode === CallMode.Group;
const isMoreOptionsButtonEnabled = isGroupCall && isGroupCallReactionsEnabled;
let presentingButtonType: CallingButtonType;
if (presentingSource) {
@ -465,7 +530,11 @@ export function CallScreen({
`module-ongoing-call__container--${
hasCallStarted ? 'call-started' : 'call-not-started'
}`,
{ 'module-ongoing-call__container--hide-controls': !showControls }
{ 'module-ongoing-call__container--hide-controls': !showControls },
{
'module-ongoing-call__container--controls-faded-out':
controlsFadedOut,
}
)}
onFocus={() => {
setShowControls(true);
@ -532,6 +601,12 @@ export function CallScreen({
)}
{remoteParticipantsElement}
{lonelyInCallNode}
<CallingReactionsToastsContainer
reactions={reactions}
conversationsByDemuxId={conversationsByDemuxId}
localDemuxId={localDemuxId}
i18n={i18n}
/>
<div className="module-ongoing-call__footer">
<div className="module-calling__spacer CallControls__OuterSpacer" />
<div
@ -552,6 +627,30 @@ export function CallScreen({
i18n={i18n}
/>
{showMoreOptions && (
<div className="CallControls__MoreOptionsContainer">
<div
className="CallControls__MoreOptionsMenu"
ref={moreOptionsMenuRef}
>
{renderReactionPicker({
ref: reactionPickerRef,
onClose: () => setShowMoreOptions(false),
onPick: emoji => {
setShowMoreOptions(false);
sendGroupCallReaction({
conversationId: conversation.id,
value: emoji,
});
},
isCustomizePreferredReactionsHidden: true,
preferredReactionEmoji: CALL_REACTION_EMOJI,
renderEmojiPicker,
})}
</div>
</div>
)}
<div className="CallControls__ButtonContainer">
<CallingButton
buttonType={presentingButtonType}
@ -577,6 +676,21 @@ export function CallScreen({
onClick={toggleAudio}
tooltipDirection={TooltipPlacement.Top}
/>
{isMoreOptionsButtonEnabled && (
<div
className="CallControls__MoreOptionsButtonContainer"
ref={moreOptionsButtonRef}
>
<CallingButton
buttonType={CallingButtonType.MORE_OPTIONS}
i18n={i18n}
onMouseEnter={onControlsMouseEnter}
onMouseLeave={onControlsMouseLeave}
onClick={toggleMoreOptions}
tooltipDirection={TooltipPlacement.Top}
/>
</div>
)}
</div>
<div
className="CallControls__JoinLeaveButtonContainer"
@ -693,3 +807,75 @@ function useViewModeChangedToast({
presenterAci,
]);
}
type CallingReactionsToastsType = {
reactions: ActiveCallReactionsType | undefined;
conversationsByDemuxId: Map<number, ConversationType>;
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: (
<span className="CallingReactionsToasts__reaction">
<Emoji size={28} emoji={value} />
{demuxId === localDemuxId
? i18n('icu:CallingReactions--me')
: conversationsByDemuxId.get(demuxId)?.title}
</span>
),
});
});
}, [
reactions,
previousReactions,
showToast,
conversationsByDemuxId,
localDemuxId,
i18n,
]);
}
function CallingReactionsToastsContainer(
props: CallingReactionsToastsType
): JSX.Element {
const { i18n } = props;
const toastRegionRef = useRef<HTMLDivElement>(null);
return (
<CallingToastProvider
i18n={i18n}
maxNonPersistentToasts={5}
region={toastRegionRef}
lifetime={CALLING_REACTIONS_LIFETIME}
transitionFrom={REACTIONS_TOASTS_TRANSITION_FROM}
>
<div className="CallingReactionsToasts" ref={toastRegionRef} />
<CallingReactionsToasts {...props} />
</CallingToastProvider>
);
}
function CallingReactionsToasts(props: CallingReactionsToastsType) {
useReactionsToast(props);
return null;
}

View file

@ -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 (

View file

@ -132,9 +132,11 @@ export function GroupCall(args: PropsType): JSX.Element {
callMode: CallMode.Group as CallMode.Group,
connectionState: GroupCallConnectionState.Connected,
conversationsWithSafetyNumberChanges: [],
conversationsByDemuxId: new Map<number, ConversationType>(),
groupMembers: times(3, () => getDefaultConversation()),
isConversationTooBigToRing: false,
joinState: GroupCallJoinState.Joined,
localDemuxId: 1,
maxDevices: 5,
deviceCount: 0,
peekedParticipants: [],

View file

@ -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<HTMLElement>;
maxNonPersistentToasts?: number;
lifetime?: number;
transitionFrom?: object;
}): JSX.Element {
const [toasts, setToasts] = React.useState<Array<CallingToastStateType>>([]);
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;

View file

@ -27,6 +27,7 @@ export type OwnProps = {
onClose?: () => unknown;
onPick: (emoji: string) => unknown;
onSetSkinTone: (tone: number) => unknown;
isCustomizePreferredReactionsHidden?: boolean;
openCustomizePreferredReactionsModal?: () => unknown;
preferredReactionEmoji: ReadonlyArray<string>;
renderEmojiPicker: (props: RenderEmojiPickerProps) => React.ReactElement;
@ -41,6 +42,7 @@ export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
onClose,
onPick,
onSetSkinTone,
isCustomizePreferredReactionsHidden,
openCustomizePreferredReactionsModal,
preferredReactionEmoji,
renderEmojiPicker,
@ -78,8 +80,11 @@ export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
const [focusRef] = useDelayedRestoreFocus();
if (pickingOther) {
const onClickSettings = isCustomizePreferredReactionsHidden
? undefined
: openCustomizePreferredReactionsModal;
return renderEmojiPicker({
onClickSettings: openCustomizePreferredReactionsModal,
onClickSettings,
onClose,
onPickEmoji,
onSetSkinTone,

View file

@ -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

View file

@ -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<GroupCallParticipantInfoType>;
@ -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<GroupCallParticipantInfoType>;
};
type GroupCallReactionsReceivedArgumentType = ReadonlyDeep<{
conversationId: string;
reactions: Array<CallReaction>;
}>;
// 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<CallReaction>;
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<void, RootStateType, unknown, GroupCallStateChangeActionType> {
@ -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<void, RootStateType, unknown, IncomingDirectCallActionType> {
@ -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<ActiveCallReaction> = [];
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);

View file

@ -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 <SmartCallingDeviceSelection />;
@ -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<ConversationType> = [];
const remoteParticipants: Array<GroupCallRemoteParticipantType> = [];
const peekedParticipants: Array<ConversationType> = [];
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),

View file

@ -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<string>;
};
export const SmartReactionPicker = React.forwardRef<
HTMLDivElement,
ExternalProps
>(function SmartReactionPickerInner(props, ref) {
const { preferredReactionEmoji } = props;
const { openCustomizePreferredReactionsModal } =
usePreferredReactionsActions();
const { onSetSkinTone } = useItemsActions();
const i18n = useSelector<StateType, LocalizerType>(getIntl);
const preferredReactionEmoji = useSelector<StateType, ReadonlyArray<string>>(
getPreferredReactionEmoji
);
const statePreferredReactionEmoji = useSelector<
StateType,
ReadonlyArray<string>
>(getPreferredReactionEmoji);
return (
<ReactionPicker
@ -45,9 +50,11 @@ export const SmartReactionPicker = React.forwardRef<
openCustomizePreferredReactionsModal={
openCustomizePreferredReactionsModal
}
preferredReactionEmoji={preferredReactionEmoji}
ref={ref}
{...props}
preferredReactionEmoji={
preferredReactionEmoji ?? statePreferredReactionEmoji
}
/>
);
});

View file

@ -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<typeof receiveGroupCallReactions>
): 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<typeof sendGroupCallReaction>
): 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,

View file

@ -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,

View file

@ -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<CallReaction>;
// };
export type ActiveCallReaction = {
timestamp: number;
} & CallReaction;
export type ActiveCallReactionsType = ReadonlyArray<ActiveCallReaction>;
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<ConversationType>;
joinState: GroupCallJoinState;
localDemuxId: number | undefined;
maxDevices: number;
deviceCount: number;
groupMembers: Array<Pick<ConversationType, 'id' | 'firstName' | 'title'>>;
@ -148,6 +164,8 @@ export type GroupCallRemoteParticipantType = ConversationType & {
videoAspectRatio: number;
};
export type ConversationsByDemuxIdType = Map<number, ConversationType>;
// Similar to RingRTC's `VideoRequest` but without the `framerate` property.
export type GroupCallVideoRequest = {
demuxId: number;

View file

@ -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'));
}

View file

@ -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<HTMLDivElement>(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 | HTMLDivElement>(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 | HTMLDivElement>(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 | HTMLDivElement>(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<HTMLDivElement>(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<HTMLDivElement>(null);",
"reasonCategory": "usageTrusted",
"updated": "2023-10-26T13:57:41.860Z"
},
{
"rule": "React-useRef",
"path": "ts/components/CallsList.tsx",