Receive rings for group calls

This commit is contained in:
Evan Hahn 2021-08-20 11:06:15 -05:00 committed by GitHub
parent fe040a2873
commit 79c976668b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 2112 additions and 359 deletions

View file

@ -53,20 +53,12 @@ const getCommonActiveCallData = () => ({
showParticipantsList: boolean('showParticipantsList', false),
});
const getIncomingCallState = (extraProps = {}) => ({
...extraProps,
callMode: CallMode.Direct as CallMode.Direct,
conversationId: '3051234567',
callState: CallState.Ringing,
isIncoming: true,
isVideoCall: boolean('isVideoCall', true),
hasRemoteVideo: true,
});
const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
...storyProps,
availableCameras: [],
acceptCall: action('accept-call'),
bounceAppIconStart: action('bounce-app-icon-start'),
bounceAppIconStop: action('bounce-app-icon-stop'),
cancelCall: action('cancel-call'),
closeNeedPermissionScreen: action('close-need-permission-screen'),
declineCall: action('decline-call'),
@ -87,7 +79,9 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
}),
uuid: 'cb0dd0c8-7393-41e9-a0aa-d631c4109541',
},
notifyForCall: action('notify-for-call'),
openSystemPreferencesAction: action('open-system-preferences-action'),
playRingtone: action('play-ringtone'),
renderDeviceSelection: () => <div />,
renderSafetyNumberViewer: (_: SafetyNumberViewerProps) => <div />,
setGroupCallVideoRequest: action('set-group-call-video-request'),
@ -97,6 +91,7 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
setPresenting: action('toggle-presenting'),
setRendererCanvas: action('set-renderer-canvas'),
startCall: action('start-call'),
stopRingtone: action('stop-ringtone'),
toggleParticipants: action('toggle-participants'),
togglePip: action('toggle-pip'),
toggleScreenRecordingPermissionsDialog: action(
@ -145,12 +140,33 @@ story.add('Ongoing Group Call', () => (
/>
));
story.add('Ringing', () => (
story.add('Ringing (direct call)', () => (
<CallManager
{...createProps({
incomingCall: {
call: getIncomingCallState(),
callMode: CallMode.Direct as const,
conversation: getConversation(),
isVideoCall: true,
},
})}
/>
));
story.add('Ringing (group call)', () => (
<CallManager
{...createProps({
incomingCall: {
callMode: CallMode.Group as const,
conversation: {
...getConversation(),
type: 'group',
title: 'Tahoe Trip',
},
otherMembersRung: [
{ firstName: 'Morty', title: 'Morty Smith' },
{ firstName: 'Summer', title: 'Summer Smith' },
],
ringer: { firstName: 'Rick', title: 'Rick Sanchez' },
},
})}
/>

View file

@ -1,7 +1,8 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback } from 'react';
import React, { useCallback, useEffect } from 'react';
import { noop } from 'lodash';
import { CallNeedPermissionScreen } from './CallNeedPermissionScreen';
import { CallScreen } from './CallScreen';
import { CallingLobby } from './CallingLobby';
@ -28,7 +29,6 @@ import {
AcceptCallType,
CancelCallType,
DeclineCallType,
DirectCallStateType,
HangUpType,
KeyChangeOkType,
SetGroupCallVideoRequestType,
@ -55,26 +55,39 @@ export type PropsType = {
demuxId: number
) => VideoFrameSource;
getPresentingSources: () => void;
incomingCall?: {
call: DirectCallStateType;
conversation: ConversationType;
};
incomingCall?:
| {
callMode: CallMode.Direct;
conversation: ConversationType;
isVideoCall: boolean;
}
| {
callMode: CallMode.Group;
conversation: ConversationType;
otherMembersRung: Array<Pick<ConversationType, 'firstName' | 'title'>>;
ringer: Pick<ConversationType, 'firstName' | 'title'>;
};
keyChangeOk: (_: KeyChangeOkType) => void;
renderDeviceSelection: () => JSX.Element;
renderSafetyNumberViewer: (props: SafetyNumberProps) => JSX.Element;
startCall: (payload: StartCallType) => void;
toggleParticipants: () => void;
acceptCall: (_: AcceptCallType) => void;
bounceAppIconStart: () => unknown;
bounceAppIconStop: () => unknown;
declineCall: (_: DeclineCallType) => void;
i18n: LocalizerType;
me: MeType;
notifyForCall: (title: string, isVideoCall: boolean) => unknown;
openSystemPreferencesAction: () => unknown;
playRingtone: () => unknown;
setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void;
setLocalAudio: (_: SetLocalAudioType) => void;
setLocalVideo: (_: SetLocalVideoType) => void;
setLocalPreview: (_: SetLocalPreviewType) => void;
setPresenting: (_?: PresentedSource) => void;
setRendererCanvas: (_: SetRendererCanvasType) => void;
stopRingtone: () => unknown;
hangUp: (_: HangUpType) => void;
togglePip: () => void;
toggleScreenRecordingPermissionsDialog: () => unknown;
@ -330,7 +343,31 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
};
export const CallManager: React.FC<PropsType> = props => {
const { activeCall, incomingCall, acceptCall, declineCall, i18n } = props;
const {
acceptCall,
activeCall,
bounceAppIconStart,
bounceAppIconStop,
declineCall,
i18n,
incomingCall,
notifyForCall,
playRingtone,
stopRingtone,
} = props;
const shouldRing = getShouldRing(props);
useEffect(() => {
if (shouldRing) {
playRingtone();
return () => {
stopRingtone();
};
}
stopRingtone();
return noop;
}, [shouldRing, playRingtone, stopRingtone]);
if (activeCall) {
// `props` should logically have an `activeCall` at this point, but TypeScript can't
@ -343,13 +380,40 @@ export const CallManager: React.FC<PropsType> = props => {
return (
<IncomingCallBar
acceptCall={acceptCall}
bounceAppIconStart={bounceAppIconStart}
bounceAppIconStop={bounceAppIconStop}
declineCall={declineCall}
i18n={i18n}
call={incomingCall.call}
conversation={incomingCall.conversation}
notifyForCall={notifyForCall}
{...incomingCall}
/>
);
}
return null;
};
function getShouldRing({
activeCall,
incomingCall,
}: Readonly<Pick<PropsType, 'activeCall' | 'incomingCall'>>): boolean {
if (incomingCall) {
return !activeCall;
}
if (!activeCall) {
return false;
}
switch (activeCall.callMode) {
case CallMode.Direct:
return (
activeCall.callState === CallState.Prering ||
activeCall.callState === CallState.Ringing
);
case CallMode.Group:
return false;
default:
throw missingCaseError(activeCall);
}
}

View file

@ -6,6 +6,7 @@ import type { ConversationType } from '../state/ducks/conversations';
import type { LocalizerType } from '../types/Util';
import { Avatar, AvatarSize } from './Avatar';
import { Emojify } from './conversation/Emojify';
import { getParticipantName } from '../util/callingGetParticipantName';
import { missingCaseError } from '../util/missingCaseError';
type PropsType = {
@ -151,9 +152,3 @@ export const CallingPreCallInfo: FunctionComponent<PropsType> = ({
</div>
);
};
function getParticipantName(
participant: Readonly<Pick<ConversationType, 'firstName' | 'title'>>
): string {
return participant.firstName || participant.title;
}

View file

@ -3,20 +3,20 @@
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { boolean, select, text } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { IncomingCallBar } from './IncomingCallBar';
import { AvatarColors } from '../types/Colors';
import { CallMode } from '../types/Calling';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { getRandomColor } from '../test-both/helpers/getRandomColor';
const i18n = setupI18n('en', enMessages);
const defaultProps = {
const commonProps = {
acceptCall: action('accept-call'),
bounceAppIconStart: action('bounceAppIconStart'),
bounceAppIconStop: action('bounceAppIconStop'),
call: {
conversationId: 'fake-conversation-id',
callId: 0,
@ -33,36 +33,96 @@ const defaultProps = {
}),
declineCall: action('decline-call'),
i18n,
notifyForCall: action('notify-for-call'),
};
storiesOf('Components/IncomingCallBar', module)
.add('Knobs Playground', () => {
const color = select('color', AvatarColors, getRandomColor());
const isVideoCall = boolean('isVideoCall', false);
const name = text(
'name',
'Rick Sanchez Foo Bar Baz Spool Cool Mango Fango Wand Mars Venus Jupiter Spark Mirage Water Loop Branch Zeus Element Sail Bananas Cars Horticulture Turtle Lion Zebra Micro Music Garage Iguana Ohio Retro Joy Entertainment Logo Understanding Diary'
);
const directConversation = getDefaultConversation({
id: '3051234567',
avatarPath: undefined,
name: 'Rick Sanchez',
phoneNumber: '3051234567',
profileName: 'Rick Sanchez',
title: 'Rick Sanchez',
});
return (
<IncomingCallBar
{...defaultProps}
call={{
...defaultProps.call,
isVideoCall,
}}
conversation={{
...defaultProps.conversation,
color,
name,
}}
/>
);
})
.add('Incoming Call Bar (video)', () => <IncomingCallBar {...defaultProps} />)
.add('Incoming Call Bar (audio)', () => (
const groupConversation = getDefaultConversation({
avatarPath: undefined,
name: 'Tahoe Trip',
title: 'Tahoe Trip',
type: 'group',
});
storiesOf('Components/IncomingCallBar', module)
.add('Incoming direct call (video)', () => (
<IncomingCallBar
{...defaultProps}
call={{ ...defaultProps.call, isVideoCall: false }}
{...commonProps}
conversation={directConversation}
callMode={CallMode.Direct}
isVideoCall
/>
))
.add('Incoming direct call (audio)', () => (
<IncomingCallBar
{...commonProps}
conversation={directConversation}
callMode={CallMode.Direct}
isVideoCall={false}
/>
))
.add('Incoming group call (only calling you)', () => (
<IncomingCallBar
{...commonProps}
conversation={groupConversation}
callMode={CallMode.Group}
otherMembersRung={[]}
ringer={{ firstName: 'Rick', title: 'Rick Sanchez' }}
/>
))
.add('Incoming group call (calling you and 1 other)', () => (
<IncomingCallBar
{...commonProps}
conversation={groupConversation}
callMode={CallMode.Group}
otherMembersRung={[{ firstName: 'Morty', title: 'Morty Smith' }]}
ringer={{ firstName: 'Rick', title: 'Rick Sanchez' }}
/>
))
.add('Incoming group call (calling you and 2 others)', () => (
<IncomingCallBar
{...commonProps}
conversation={groupConversation}
callMode={CallMode.Group}
otherMembersRung={[
{ firstName: 'Morty', title: 'Morty Smith' },
{ firstName: 'Summer', title: 'Summer Smith' },
]}
ringer={{ firstName: 'Rick', title: 'Rick Sanchez' }}
/>
))
.add('Incoming group call (calling you and 3 others)', () => (
<IncomingCallBar
{...commonProps}
conversation={groupConversation}
callMode={CallMode.Group}
otherMembersRung={[
{ firstName: 'Morty', title: 'Morty Smith' },
{ firstName: 'Summer', title: 'Summer Smith' },
{ firstName: 'Beth', title: 'Beth Smith' },
]}
ringer={{ firstName: 'Rick', title: 'Rick Sanchez' }}
/>
))
.add('Incoming group call (calling you and 4 others)', () => (
<IncomingCallBar
{...commonProps}
conversation={groupConversation}
callMode={CallMode.Group}
otherMembersRung={[
{ firstName: 'Morty', title: 'Morty Smith' },
{ firstName: 'Summer', title: 'Summer Smith' },
{ firstName: 'Beth', title: 'Beth Sanchez' },
{ firstName: 'Jerry', title: 'Beth Smith' },
]}
ringer={{ firstName: 'Rick', title: 'Rick Sanchez' }}
/>
));

View file

@ -1,23 +1,25 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { useEffect, useRef, ReactChild } from 'react';
import { Avatar } from './Avatar';
import { Tooltip } from './Tooltip';
import { Intl } from './Intl';
import { Theme } from '../util/theme';
import { getParticipantName } from '../util/callingGetParticipantName';
import { ContactName } from './conversation/ContactName';
import { Emojify } from './conversation/Emojify';
import { LocalizerType } from '../types/Util';
import { AvatarColors } from '../types/Colors';
import { CallMode } from '../types/Calling';
import { ConversationType } from '../state/ducks/conversations';
import { AcceptCallType, DeclineCallType } from '../state/ducks/calling';
import { missingCaseError } from '../util/missingCaseError';
export type PropsType = {
acceptCall: (_: AcceptCallType) => void;
declineCall: (_: DeclineCallType) => void;
i18n: LocalizerType;
call: {
isVideoCall: boolean;
};
conversation: Pick<
ConversationType,
| 'acceptedMessageRequest'
@ -30,8 +32,22 @@ export type PropsType = {
| 'profileName'
| 'sharedGroupNames'
| 'title'
| 'type'
>;
};
bounceAppIconStart(): unknown;
bounceAppIconStop(): unknown;
notifyForCall(conversationTitle: string, isVideoCall: boolean): unknown;
} & (
| {
callMode: CallMode.Direct;
isVideoCall: boolean;
}
| {
callMode: CallMode.Group;
otherMembersRung: Array<Pick<ConversationType, 'firstName' | 'title'>>;
ringer: Pick<ConversationType, 'firstName' | 'title'>;
}
);
type CallButtonProps = {
classSuffix: string;
@ -61,14 +77,93 @@ const CallButton = ({
);
};
export const IncomingCallBar = ({
acceptCall,
declineCall,
const GroupCallMessage = ({
i18n,
call,
conversation,
}: PropsType): JSX.Element | null => {
const { isVideoCall } = call;
otherMembersRung,
ringer,
}: Readonly<{
i18n: LocalizerType;
otherMembersRung: Array<Pick<ConversationType, 'firstName' | 'title'>>;
ringer: Pick<ConversationType, 'firstName' | 'title'>;
}>): JSX.Element => {
// As an optimization, we only process the first two names.
const [first, second] = otherMembersRung
.slice(0, 2)
.map(member => <Emojify text={getParticipantName(member)} />);
const ringerNode = <Emojify text={getParticipantName(ringer)} />;
switch (otherMembersRung.length) {
case 0:
return (
<Intl
id="incomingGroupCall__ringing-you"
i18n={i18n}
components={{ ringer: ringerNode }}
/>
);
case 1:
return (
<Intl
id="incomingGroupCall__ringing-1-other"
i18n={i18n}
components={{
ringer: ringerNode,
otherMember: first,
}}
/>
);
case 2:
return (
<Intl
id="incomingGroupCall__ringing-2-others"
i18n={i18n}
components={{
ringer: ringerNode,
first,
second,
}}
/>
);
break;
case 3:
return (
<Intl
id="incomingGroupCall__ringing-3-others"
i18n={i18n}
components={{
ringer: ringerNode,
first,
second,
}}
/>
);
break;
default:
return (
<Intl
id="incomingGroupCall__ringing-many"
i18n={i18n}
components={{
ringer: ringerNode,
first,
second,
remaining: String(otherMembersRung.length - 2),
}}
/>
);
}
};
export const IncomingCallBar = (props: PropsType): JSX.Element | null => {
const {
acceptCall,
bounceAppIconStart,
bounceAppIconStop,
conversation,
declineCall,
i18n,
notifyForCall,
} = props;
const {
id: conversationId,
acceptedMessageRequest,
@ -80,19 +175,71 @@ export const IncomingCallBar = ({
profileName,
sharedGroupNames,
title,
type: conversationType,
} = conversation;
let isVideoCall: boolean;
let headerNode: ReactChild;
let messageNode: ReactChild;
switch (props.callMode) {
case CallMode.Direct:
({ isVideoCall } = props);
headerNode = (
<ContactName
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
i18n={i18n}
/>
);
messageNode = i18n(
isVideoCall ? 'incomingVideoCall' : 'incomingAudioCall'
);
break;
case CallMode.Group: {
const { otherMembersRung, ringer } = props;
isVideoCall = true;
headerNode = <Emojify text={title} />;
messageNode = (
<GroupCallMessage
i18n={i18n}
otherMembersRung={otherMembersRung}
ringer={ringer}
/>
);
break;
}
default:
throw missingCaseError(props);
}
// We don't want to re-notify if the title changes.
const initialTitleRef = useRef<string>(title);
useEffect(() => {
const initialTitle = initialTitleRef.current;
notifyForCall(initialTitle, isVideoCall);
}, [isVideoCall, notifyForCall]);
useEffect(() => {
bounceAppIconStart();
return () => {
bounceAppIconStop();
};
}, [bounceAppIconStart, bounceAppIconStop]);
return (
<div className="IncomingCallBar__container">
<div className="IncomingCallBar__bar">
<div className="IncomingCallBar__contact">
<div className="IncomingCallBar__contact--avatar">
<div className="IncomingCallBar__conversation">
<div className="IncomingCallBar__conversation--avatar">
<Avatar
acceptedMessageRequest={acceptedMessageRequest}
avatarPath={avatarPath}
color={color || AvatarColors[0]}
noteToSelf={false}
conversationType="direct"
conversationType={conversationType}
i18n={i18n}
isMe={isMe}
name={name}
@ -103,18 +250,15 @@ export const IncomingCallBar = ({
size={52}
/>
</div>
<div className="IncomingCallBar__contact--name">
<div className="IncomingCallBar__contact--name-header">
<ContactName
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
i18n={i18n}
/>
<div className="IncomingCallBar__conversation--name">
<div className="IncomingCallBar__conversation--name-header">
{headerNode}
</div>
<div dir="auto" className="IncomingCallBar__contact--message-text">
{i18n(isVideoCall ? 'incomingVideoCall' : 'incomingAudioCall')}
<div
dir="auto"
className="IncomingCallBar__conversation--message-text"
>
{messageNode}
</div>
</div>
</div>