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

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