Calling Reactions
This commit is contained in:
parent
ab187ab265
commit
4603832258
24 changed files with 942 additions and 35 deletions
|
@ -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"
|
||||
|
|
|
@ -291,3 +291,4 @@ $NavTabs__width: 80px;
|
|||
$NavTabs__Item__blockPadding: 2px;
|
||||
$NavTabs__Toggle__blockPadding: 8px;
|
||||
$NavTabs__ItemButton__blockPadding: 10px;
|
||||
$CallControls__height: 80px;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
65
stylesheets/components/CallingReactionsToasts.scss
Normal file
65
stylesheets/components/CallingReactionsToasts.scss
Normal 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;
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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: [],
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
8
ts/util/isGroupCallReactionsEnabled.ts
Normal file
8
ts/util/isGroupCallReactionsEnabled.ts
Normal 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'));
|
||||
}
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue