Group calling participants refactor

This commit is contained in:
Evan Hahn 2020-12-02 12:14:03 -06:00 committed by GitHub
parent be99bbe87a
commit c85ea814b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 750 additions and 436 deletions

View file

@ -35,8 +35,8 @@ const getConversation = () => ({
lastUpdated: Date.now(),
});
const getCallState = () => ({
conversationId: '3051234567',
const getCommonActiveCallData = () => ({
conversation: getConversation(),
joinedAt: Date.now(),
hasLocalAudio: boolean('hasLocalAudio', true),
hasLocalVideo: boolean('hasLocalVideo', false),
@ -69,6 +69,7 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
hangUp: action('hang-up'),
i18n,
me: {
uuid: 'cb0dd0c8-7393-41e9-a0aa-d631c4109541',
color: select('Caller color', Colors, 'ultramarine' as ColorType),
title: text('Caller Title', 'Morty Smith'),
},
@ -92,19 +93,11 @@ story.add('Ongoing Direct Call', () => (
<CallManager
{...createProps({
activeCall: {
call: {
callMode: CallMode.Direct as CallMode.Direct,
conversationId: '3051234567',
callState: CallState.Accepted,
isIncoming: false,
isVideoCall: true,
hasRemoteVideo: true,
},
activeCallState: getCallState(),
conversation: getConversation(),
isCallFull: false,
groupCallPeekedParticipants: [],
groupCallParticipants: [],
...getCommonActiveCallData(),
callMode: CallMode.Direct,
callState: CallState.Accepted,
peekedParticipants: [],
remoteParticipants: [{ hasRemoteVideo: true }],
},
})}
/>
@ -114,23 +107,14 @@ story.add('Ongoing Group Call', () => (
<CallManager
{...createProps({
activeCall: {
call: {
callMode: CallMode.Group as CallMode.Group,
conversationId: '3051234567',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
peekInfo: {
conversationIds: [],
maxDevices: 16,
deviceCount: 0,
},
remoteParticipants: [],
},
activeCallState: getCallState(),
conversation: getConversation(),
isCallFull: false,
groupCallPeekedParticipants: [],
groupCallParticipants: [],
...getCommonActiveCallData(),
callMode: CallMode.Group,
connectionState: GroupCallConnectionState.Connected,
deviceCount: 0,
joinState: GroupCallJoinState.Joined,
maxDevices: 5,
peekedParticipants: [],
remoteParticipants: [],
},
})}
/>
@ -151,14 +135,12 @@ story.add('Call Request Needed', () => (
<CallManager
{...createProps({
activeCall: {
call: getIncomingCallState({
callEndedReason: CallEndedReason.RemoteHangupNeedPermission,
}),
activeCallState: getCallState(),
conversation: getConversation(),
isCallFull: false,
groupCallPeekedParticipants: [],
groupCallParticipants: [],
...getCommonActiveCallData(),
callEndedReason: CallEndedReason.RemoteHangupNeedPermission,
callMode: CallMode.Direct,
callState: CallState.Accepted,
peekedParticipants: [],
remoteParticipants: [{ hasRemoteVideo: true }],
},
})}
/>

View file

@ -9,6 +9,7 @@ import { CallingParticipantsList } from './CallingParticipantsList';
import { CallingPip } from './CallingPip';
import { IncomingCallBar } from './IncomingCallBar';
import {
ActiveCallType,
CallEndedReason,
CallMode,
CallState,
@ -19,7 +20,6 @@ import {
import { ConversationType } from '../state/ducks/conversations';
import {
AcceptCallType,
ActiveCallType,
CancelCallType,
DeclineCallType,
DirectCallStateType,
@ -61,6 +61,7 @@ export interface PropsType {
phoneNumber?: string;
profileName?: string;
title: string;
uuid: string;
};
setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void;
setLocalAudio: (_: SetLocalAudioType) => void;
@ -97,21 +98,15 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
toggleSettings,
}) => {
const {
call,
activeCallState,
conversation,
groupCallPeekedParticipants,
groupCallParticipants,
isCallFull,
} = activeCall;
const {
hasLocalAudio,
hasLocalVideo,
joinedAt,
peekedParticipants,
pip,
settingsDialogOpen,
showParticipantsList,
} = activeCallState;
} = activeCall;
const cancelActiveCall = useCallback(() => {
cancelCall({ conversationId: conversation.id });
@ -119,12 +114,18 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
const joinActiveCall = useCallback(() => {
startCall({
callMode: call.callMode,
callMode: activeCall.callMode,
conversationId: conversation.id,
hasLocalAudio,
hasLocalVideo,
});
}, [startCall, call.callMode, conversation.id, hasLocalAudio, hasLocalVideo]);
}, [
startCall,
activeCall.callMode,
conversation.id,
hasLocalAudio,
hasLocalVideo,
]);
const getGroupCallVideoFrameSourceForActiveCall = useCallback(
(demuxId: number) => {
@ -143,11 +144,12 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
[setGroupCallVideoRequest, conversation.id]
);
let isCallFull: boolean;
let showCallLobby: boolean;
switch (call.callMode) {
switch (activeCall.callMode) {
case CallMode.Direct: {
const { callState, callEndedReason } = call;
const { callState, callEndedReason } = activeCall;
const ended = callState === CallState.Ended;
if (
ended &&
@ -162,22 +164,19 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
);
}
showCallLobby = !callState;
isCallFull = false;
break;
}
case CallMode.Group: {
showCallLobby = call.joinState === GroupCallJoinState.NotJoined;
showCallLobby = activeCall.joinState === GroupCallJoinState.NotJoined;
isCallFull = activeCall.deviceCount >= activeCall.maxDevices;
break;
}
default:
throw missingCaseError(call);
throw missingCaseError(activeCall);
}
if (showCallLobby) {
const participantNames = groupCallPeekedParticipants.map(participant =>
participant.isSelf
? i18n('you')
: participant.firstName || participant.title
);
return (
<>
<CallingLobby
@ -186,12 +185,12 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
hasLocalAudio={hasLocalAudio}
hasLocalVideo={hasLocalVideo}
i18n={i18n}
isGroupCall={call.callMode === CallMode.Group}
isGroupCall={activeCall.callMode === CallMode.Group}
isCallFull={isCallFull}
me={me}
onCallCanceled={cancelActiveCall}
onJoinCall={joinActiveCall}
participantNames={participantNames}
peekedParticipants={peekedParticipants}
setLocalPreview={setLocalPreview}
setLocalAudio={setLocalAudio}
setLocalVideo={setLocalVideo}
@ -200,11 +199,11 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
toggleSettings={toggleSettings}
/>
{settingsDialogOpen && renderDeviceSelection()}
{showParticipantsList && call.callMode === CallMode.Group ? (
{showParticipantsList && activeCall.callMode === CallMode.Group ? (
<CallingParticipantsList
i18n={i18n}
onClose={toggleParticipants}
participants={groupCallParticipants}
participants={peekedParticipants}
/>
) : null}
</>
@ -227,14 +226,30 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
);
}
const groupCallParticipantsForParticipantsList =
activeCall.callMode === CallMode.Group
? [
...activeCall.remoteParticipants.map(participant => ({
...participant,
hasAudio: participant.hasRemoteAudio,
hasVideo: participant.hasRemoteVideo,
isSelf: false,
})),
{
...me,
hasAudio: hasLocalAudio,
hasVideo: hasLocalVideo,
isSelf: true,
},
]
: [];
return (
<>
<CallScreen
activeCall={activeCall}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall}
hangUp={hangUp}
hasLocalAudio={hasLocalAudio}
hasLocalVideo={hasLocalVideo}
i18n={i18n}
joinedAt={joinedAt}
me={me}
@ -249,11 +264,11 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
toggleSettings={toggleSettings}
/>
{settingsDialogOpen && renderDeviceSelection()}
{showParticipantsList && call.callMode === CallMode.Group ? (
{showParticipantsList && activeCall.callMode === CallMode.Group ? (
<CallingParticipantsList
i18n={i18n}
onClose={toggleParticipants}
participants={groupCallParticipants}
participants={groupCallParticipantsForParticipantsList}
/>
) : null}
</>

View file

@ -12,102 +12,128 @@ import {
CallState,
GroupCallConnectionState,
GroupCallJoinState,
GroupCallPeekedParticipantType,
GroupCallRemoteParticipantType,
} from '../types/Calling';
import { Colors } from '../types/Colors';
import {
DirectCallStateType,
GroupCallStateType,
} from '../state/ducks/calling';
import { CallScreen, PropsType } from './CallScreen';
import { setup as setupI18n } from '../../js/modules/i18n';
import { missingCaseError } from '../util/missingCaseError';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
function getGroupCallState(): GroupCallStateType {
return {
callMode: CallMode.Group,
conversationId: '3051234567',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
peekInfo: {
conversationIds: [],
maxDevices: 16,
deviceCount: 0,
},
remoteParticipants: [],
};
const conversation = {
id: '3051234567',
avatarPath: undefined,
color: Colors[0],
title: 'Rick Sanchez',
name: 'Rick Sanchez',
phoneNumber: '3051234567',
profileName: 'Rick Sanchez',
markedUnread: false,
type: 'direct' as const,
lastUpdated: Date.now(),
};
interface OverridePropsBase {
hasLocalAudio?: boolean;
hasLocalVideo?: boolean;
}
function getDirectCallState(
overrideProps: {
callState?: CallState;
hasRemoteVideo?: boolean;
} = {}
): DirectCallStateType {
return {
callMode: CallMode.Direct,
conversationId: '3051234567',
callState: select(
'callState',
CallState,
overrideProps.callState || CallState.Accepted
),
hasRemoteVideo: boolean(
'hasRemoteVideo',
Boolean(overrideProps.hasRemoteVideo)
),
isIncoming: false,
isVideoCall: true,
};
interface DirectCallOverrideProps extends OverridePropsBase {
callMode: CallMode.Direct;
callState?: CallState;
hasRemoteVideo?: boolean;
}
interface GroupCallOverrideProps extends OverridePropsBase {
callMode: CallMode.Group;
connectionState?: GroupCallConnectionState;
peekedParticipants?: Array<GroupCallPeekedParticipantType>;
remoteParticipants?: Array<GroupCallRemoteParticipantType>;
}
const createActiveDirectCallProp = (
overrideProps: DirectCallOverrideProps
) => ({
callMode: CallMode.Direct as CallMode.Direct,
conversation,
callState: select(
'callState',
CallState,
overrideProps.callState || CallState.Accepted
),
peekedParticipants: [] as [],
remoteParticipants: [
{
hasRemoteVideo: boolean(
'hasRemoteVideo',
Boolean(overrideProps.hasRemoteVideo)
),
},
] as [
{
hasRemoteVideo: boolean;
}
],
});
const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({
callMode: CallMode.Group as CallMode.Group,
connectionState:
overrideProps.connectionState || GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
maxDevices: 5,
deviceCount: (overrideProps.remoteParticipants || []).length,
// Because remote participants are a superset, we can use them in place of peeked
// participants.
peekedParticipants:
overrideProps.peekedParticipants || overrideProps.remoteParticipants || [],
remoteParticipants: overrideProps.remoteParticipants || [],
});
const createActiveCallProp = (
overrideProps: DirectCallOverrideProps | GroupCallOverrideProps
) => {
const baseResult = {
joinedAt: Date.now(),
conversation,
hasLocalAudio: boolean(
'hasLocalAudio',
overrideProps.hasLocalAudio || false
),
hasLocalVideo: boolean(
'hasLocalVideo',
overrideProps.hasLocalVideo || false
),
pip: false,
settingsDialogOpen: false,
showParticipantsList: false,
};
switch (overrideProps.callMode) {
case CallMode.Direct:
return { ...baseResult, ...createActiveDirectCallProp(overrideProps) };
case CallMode.Group:
return { ...baseResult, ...createActiveGroupCallProp(overrideProps) };
default:
throw missingCaseError(overrideProps);
}
};
const createProps = (
overrideProps: {
callState?: CallState;
callTypeState?: DirectCallStateType | GroupCallStateType;
groupCallParticipants?: Array<GroupCallRemoteParticipantType>;
hasLocalAudio?: boolean;
hasLocalVideo?: boolean;
hasRemoteVideo?: boolean;
} = {}
overrideProps: DirectCallOverrideProps | GroupCallOverrideProps = {
callMode: CallMode.Direct as CallMode.Direct,
}
): PropsType => ({
activeCall: {
activeCallState: {
conversationId: '123',
hasLocalAudio: true,
hasLocalVideo: true,
pip: false,
settingsDialogOpen: false,
showParticipantsList: false,
},
call: overrideProps.callTypeState || getDirectCallState(overrideProps),
conversation: {
id: '3051234567',
avatarPath: undefined,
color: Colors[0],
title: 'Rick Sanchez',
name: 'Rick Sanchez',
phoneNumber: '3051234567',
profileName: 'Rick Sanchez',
markedUnread: false,
type: 'direct',
lastUpdated: Date.now(),
},
isCallFull: false,
groupCallPeekedParticipants: [],
groupCallParticipants: overrideProps.groupCallParticipants || [],
},
activeCall: createActiveCallProp(overrideProps),
// We allow `any` here because this is fake and actually comes from RingRTC, which we
// can't import.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getGroupCallVideoFrameSource: noop as any,
hangUp: action('hang-up'),
hasLocalAudio: boolean('hasLocalAudio', overrideProps.hasLocalAudio || false),
hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false),
i18n,
joinedAt: Date.now(),
me: {
color: Colors[1],
name: 'Morty Smith',
@ -135,6 +161,7 @@ story.add('Pre-Ring', () => {
return (
<CallScreen
{...createProps({
callMode: CallMode.Direct,
callState: CallState.Prering,
})}
/>
@ -145,6 +172,7 @@ story.add('Ringing', () => {
return (
<CallScreen
{...createProps({
callMode: CallMode.Direct,
callState: CallState.Ringing,
})}
/>
@ -155,6 +183,7 @@ story.add('Reconnecting', () => {
return (
<CallScreen
{...createProps({
callMode: CallMode.Direct,
callState: CallState.Reconnecting,
})}
/>
@ -165,6 +194,7 @@ story.add('Ended', () => {
return (
<CallScreen
{...createProps({
callMode: CallMode.Direct,
callState: CallState.Ended,
})}
/>
@ -172,23 +202,45 @@ story.add('Ended', () => {
});
story.add('hasLocalAudio', () => {
return <CallScreen {...createProps({ hasLocalAudio: true })} />;
return (
<CallScreen
{...createProps({
callMode: CallMode.Direct,
hasLocalAudio: true,
})}
/>
);
});
story.add('hasLocalVideo', () => {
return <CallScreen {...createProps({ hasLocalVideo: true })} />;
return (
<CallScreen
{...createProps({
callMode: CallMode.Direct,
hasLocalVideo: true,
})}
/>
);
});
story.add('hasRemoteVideo', () => {
return <CallScreen {...createProps({ hasRemoteVideo: true })} />;
return (
<CallScreen
{...createProps({
callMode: CallMode.Direct,
hasRemoteVideo: true,
})}
/>
);
});
story.add('Group call - 1', () => (
<CallScreen
{...createProps({
callTypeState: getGroupCallState(),
groupCallParticipants: [
callMode: CallMode.Group,
remoteParticipants: [
{
uuid: '72fa60e5-25fb-472d-8a56-e56867c57dda',
demuxId: 0,
hasRemoteAudio: true,
hasRemoteVideo: true,
@ -205,9 +257,10 @@ story.add('Group call - 1', () => (
story.add('Group call - Many', () => (
<CallScreen
{...createProps({
callTypeState: getGroupCallState(),
groupCallParticipants: [
callMode: CallMode.Group,
remoteParticipants: [
{
uuid: '094586f5-8fc2-4ce2-a152-2dfcc99f4630',
demuxId: 0,
hasRemoteAudio: true,
hasRemoteVideo: true,
@ -217,6 +270,7 @@ story.add('Group call - Many', () => (
videoAspectRatio: 1.3,
},
{
uuid: 'cb5bdb24-4cbb-4650-8a7a-1a2807051e74',
demuxId: 1,
hasRemoteAudio: true,
hasRemoteVideo: true,
@ -226,6 +280,7 @@ story.add('Group call - Many', () => (
videoAspectRatio: 1.3,
},
{
uuid: '2d7d13ae-53dc-4a51-8dc7-976cd85e0b57',
demuxId: 2,
hasRemoteAudio: true,
hasRemoteVideo: true,
@ -242,12 +297,11 @@ story.add('Group call - Many', () => (
story.add('Group call - reconnecting', () => (
<CallScreen
{...createProps({
callTypeState: {
...getGroupCallState(),
connectionState: GroupCallConnectionState.Reconnecting,
},
groupCallParticipants: [
callMode: CallMode.Group,
connectionState: GroupCallConnectionState.Reconnecting,
remoteParticipants: [
{
uuid: '33871c64-0c22-45ce-8aa4-0ec237ac4a31',
demuxId: 0,
hasRemoteAudio: true,
hasRemoteVideo: true,

View file

@ -5,7 +5,6 @@ import React, { useState, useRef, useEffect, useCallback } from 'react';
import { noop } from 'lodash';
import classNames from 'classnames';
import {
ActiveCallType,
HangUpType,
SetLocalAudioType,
SetLocalPreviewType,
@ -17,6 +16,7 @@ import { CallingHeader } from './CallingHeader';
import { CallingButton, CallingButtonType } from './CallingButton';
import { CallBackgroundBlur } from './CallBackgroundBlur';
import {
ActiveCallType,
CallMode,
CallState,
GroupCallConnectionState,
@ -34,8 +34,6 @@ export type PropsType = {
activeCall: ActiveCallType;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
hangUp: (_: HangUpType) => void;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
i18n: LocalizerType;
joinedAt?: number;
me: {
@ -61,8 +59,6 @@ export const CallScreen: React.FC<PropsType> = ({
activeCall,
getGroupCallVideoFrameSource,
hangUp,
hasLocalAudio,
hasLocalVideo,
i18n,
joinedAt,
me,
@ -76,7 +72,12 @@ export const CallScreen: React.FC<PropsType> = ({
togglePip,
toggleSettings,
}) => {
const { call, conversation, groupCallParticipants } = activeCall;
const {
conversation,
hasLocalAudio,
hasLocalVideo,
showParticipantsList,
} = activeCall;
const toggleAudio = useCallback(() => {
setLocalAudio({
@ -148,23 +149,25 @@ export const CallScreen: React.FC<PropsType> = ({
};
}, [toggleAudio, toggleVideo]);
let hasRemoteVideo: boolean;
const hasRemoteVideo = activeCall.remoteParticipants.some(
remoteParticipant => remoteParticipant.hasRemoteVideo
);
let headerMessage: string | undefined;
let headerTitle: string | undefined;
let isConnected: boolean;
let participantCount: number;
let remoteParticipantsElement: JSX.Element;
switch (call.callMode) {
switch (activeCall.callMode) {
case CallMode.Direct:
hasRemoteVideo = Boolean(call.hasRemoteVideo);
headerMessage = renderHeaderMessage(
i18n,
call.callState || CallState.Prering,
activeCall.callState || CallState.Prering,
acceptedDuration
);
headerTitle = conversation.title;
isConnected = call.callState === CallState.Accepted;
isConnected = activeCall.callState === CallState.Accepted;
participantCount = isConnected ? 2 : 0;
remoteParticipantsElement = (
<DirectCallRemoteParticipant
@ -176,26 +179,24 @@ export const CallScreen: React.FC<PropsType> = ({
);
break;
case CallMode.Group:
hasRemoteVideo = call.remoteParticipants.some(
remoteParticipant => remoteParticipant.hasRemoteVideo
);
participantCount = activeCall.groupCallParticipants.length;
participantCount = activeCall.remoteParticipants.length + 1;
headerMessage = undefined;
headerTitle = activeCall.groupCallParticipants.length
headerTitle = activeCall.remoteParticipants.length
? undefined
: i18n('calling__in-this-call--zero');
isConnected = call.connectionState === GroupCallConnectionState.Connected;
isConnected =
activeCall.connectionState === GroupCallConnectionState.Connected;
remoteParticipantsElement = (
<GroupCallRemoteParticipants
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
i18n={i18n}
remoteParticipants={groupCallParticipants}
remoteParticipants={activeCall.remoteParticipants}
setGroupCallVideoRequest={setGroupCallVideoRequest}
/>
);
break;
default:
throw missingCaseError(call);
throw missingCaseError(activeCall);
}
const videoButtonType = hasLocalVideo
@ -214,14 +215,12 @@ export const CallScreen: React.FC<PropsType> = ({
!showControls && !isAudioOnly && isConnected,
});
const { showParticipantsList } = activeCall.activeCallState;
return (
<div
className={classNames(
'module-calling__container',
`module-ongoing-call__container--${getCallModeClassSuffix(
call.callMode
activeCall.callMode
)}`
)}
onMouseMove={() => {
@ -229,9 +228,9 @@ export const CallScreen: React.FC<PropsType> = ({
}}
role="group"
>
{call.callMode === CallMode.Group ? (
{activeCall.callMode === CallMode.Group ? (
<GroupCallToastManager
connectionState={call.connectionState}
connectionState={activeCall.connectionState}
i18n={i18n}
/>
) : null}
@ -241,7 +240,7 @@ export const CallScreen: React.FC<PropsType> = ({
<CallingHeader
canPip
i18n={i18n}
isGroupCall={call.callMode === CallMode.Group}
isGroupCall={activeCall.callMode === CallMode.Group}
message={headerMessage}
participantCount={participantCount}
showParticipantsList={showParticipantsList}

View file

@ -5,6 +5,7 @@ import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { boolean } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { v4 as generateUuid } from 'uuid';
import { ColorType } from '../types/Colors';
import { CallingLobby, PropsType } from './CallingLobby';
@ -35,7 +36,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
me: overrideProps.me || { color: 'ultramarine' as ColorType },
onCallCanceled: action('on-call-canceled'),
onJoinCall: action('on-join-call'),
participantNames: overrideProps.participantNames || [],
peekedParticipants: overrideProps.peekedParticipants || [],
setLocalAudio: action('set-local-audio'),
setLocalPreview: action('set-local-preview'),
setLocalVideo: action('set-local-video'),
@ -47,6 +48,12 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
toggleSettings: action('toggle-settings'),
});
const fakePeekedParticipant = (title: string) => ({
isSelf: false,
title,
uuid: generateUuid(),
});
const story = storiesOf('Components/CallingLobby', module);
story.add('Default', () => {
@ -86,44 +93,51 @@ story.add('Local Video', () => {
return <CallingLobby {...props} />;
});
story.add('Group Call - 0', () => {
const props = createProps({ isGroupCall: true, participantNames: [] });
story.add('Group Call - 0 peeked participants', () => {
const props = createProps({ isGroupCall: true, peekedParticipants: [] });
return <CallingLobby {...props} />;
});
story.add('Group Call - 1', () => {
const props = createProps({ isGroupCall: true, participantNames: ['Sam'] });
return <CallingLobby {...props} />;
});
story.add('Group Call - 2', () => {
story.add('Group Call - 1 peeked participant', () => {
const props = createProps({
isGroupCall: true,
participantNames: ['Sam', 'Cayce'],
peekedParticipants: ['Sam'].map(fakePeekedParticipant),
});
return <CallingLobby {...props} />;
});
story.add('Group Call - 3', () => {
story.add('Group Call - 2 peeked participants', () => {
const props = createProps({
isGroupCall: true,
participantNames: ['Sam', 'Cayce', 'April'],
peekedParticipants: ['Sam', 'Cayce'].map(fakePeekedParticipant),
});
return <CallingLobby {...props} />;
});
story.add('Group Call - 4', () => {
story.add('Group Call - 3 peeked participants', () => {
const props = createProps({
isGroupCall: true,
participantNames: ['Sam', 'Cayce', 'April', 'Logan', 'Carl'],
peekedParticipants: ['Sam', 'Cayce', 'April'].map(fakePeekedParticipant),
});
return <CallingLobby {...props} />;
});
story.add('Group Call - 4 (participants list)', () => {
story.add('Group Call - 4 peeked participants', () => {
const props = createProps({
isGroupCall: true,
participantNames: ['Sam', 'Cayce', 'April', 'Logan', 'Carl'],
peekedParticipants: ['Sam', 'Cayce', 'April', 'Logan', 'Carl'].map(
fakePeekedParticipant
),
});
return <CallingLobby {...props} />;
});
story.add('Group Call - 4 peeked participants (participants list)', () => {
const props = createProps({
isGroupCall: true,
peekedParticipants: ['Sam', 'Cayce', 'April', 'Logan', 'Carl'].map(
fakePeekedParticipant
),
showParticipantsList: true,
});
return <CallingLobby {...props} />;

View file

@ -31,7 +31,12 @@ export type PropsType = {
};
onCallCanceled: () => void;
onJoinCall: () => void;
participantNames: Array<string>;
peekedParticipants: Array<{
firstName?: string;
isSelf: boolean;
title: string;
uuid: string;
}>;
setLocalAudio: (_: SetLocalAudioType) => void;
setLocalVideo: (_: SetLocalVideoType) => void;
setLocalPreview: (_: SetLocalPreviewType) => void;
@ -51,7 +56,7 @@ export const CallingLobby = ({
me,
onCallCanceled,
onJoinCall,
participantNames,
peekedParticipants,
setLocalAudio,
setLocalPreview,
setLocalVideo,
@ -114,6 +119,16 @@ export const CallingLobby = ({
? CallingButtonType.AUDIO_ON
: CallingButtonType.AUDIO_OFF;
// It should be rare to see yourself in this list, but it's possible if (1) you rejoin
// quickly, causing the server to return stale state (2) you have joined on another
// device.
// TODO: Improve the "it's you" case; see DESKTOP-926.
const participantNames = peekedParticipants.map(participant =>
participant.isSelf
? i18n('you')
: participant.firstName || participant.title
);
let joinButton: JSX.Element;
if (isCallFull) {
joinButton = (
@ -159,7 +174,7 @@ export const CallingLobby = ({
title={conversation.title}
i18n={i18n}
isGroupCall={isGroupCall}
participantCount={participantNames.length}
participantCount={peekedParticipants.length}
showParticipantsList={showParticipantsList}
toggleParticipants={toggleParticipants}
toggleSettings={toggleSettings}

View file

@ -4,6 +4,7 @@
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { v4 as generateUuid } from 'uuid';
import { CallingParticipantsList, PropsType } from './CallingParticipantsList';
import { Colors } from '../types/Colors';
@ -29,6 +30,7 @@ function createParticipant(
profileName: participantProps.title,
title: String(participantProps.title),
videoAspectRatio: 1.3,
uuid: generateUuid(),
};
}

View file

@ -9,18 +9,28 @@ import { Avatar } from './Avatar';
import { ContactName } from './conversation/ContactName';
import { InContactsIcon } from './InContactsIcon';
import { LocalizerType } from '../types/Util';
import { GroupCallRemoteParticipantType } from '../types/Calling';
import { GroupCallPeekedParticipantType } from '../types/Calling';
interface ParticipantType extends GroupCallPeekedParticipantType {
hasAudio?: boolean;
hasVideo?: boolean;
}
export type PropsType = {
readonly i18n: LocalizerType;
readonly onClose: () => void;
readonly participants: Array<GroupCallRemoteParticipantType>;
readonly participants: Array<ParticipantType>;
};
export const CallingParticipantsList = React.memo(
({ i18n, onClose, participants }: PropsType) => {
const [root, setRoot] = React.useState<HTMLElement | null>(null);
const sortedParticipants = React.useMemo<Array<ParticipantType>>(
() => participants.sort((a, b) => a.title.localeCompare(b.title)),
[participants]
);
React.useEffect(() => {
const div = document.createElement('div');
document.body.appendChild(div);
@ -70,10 +80,13 @@ export const CallingParticipantsList = React.memo(
/>
</div>
<ul className="module-calling-participants-list__list">
{participants.map(
(participant: GroupCallRemoteParticipantType, index: number) => (
{sortedParticipants.map(
(participant: ParticipantType, index: number) => (
<li
className="module-calling-participants-list__contact"
// It's tempting to use `participant.uuid` as the `key` here, but that
// can result in duplicate keys for participants who have joined on
// multiple devices.
key={index}
>
<div>
@ -110,10 +123,10 @@ export const CallingParticipantsList = React.memo(
)}
</div>
<div>
{!participant.hasRemoteAudio ? (
{participant.hasAudio === false ? (
<span className="module-calling-participants-list__muted--audio" />
) : null}
{!participant.hasRemoteVideo ? (
{participant.hasVideo === false ? (
<span className="module-calling-participants-list__muted--video" />
) : null}
</div>

View file

@ -9,9 +9,9 @@ import { action } from '@storybook/addon-actions';
import { ColorType } from '../types/Colors';
import { ConversationTypeType } from '../state/ducks/conversations';
import { ActiveCallType } from '../state/ducks/calling';
import { CallingPip, PropsType } from './CallingPip';
import {
ActiveCallType,
CallMode,
CallState,
GroupCallConnectionState,
@ -35,34 +35,26 @@ const conversation = {
lastUpdated: Date.now(),
};
const defaultCall = {
const getCommonActiveCallData = () => ({
conversation,
hasLocalAudio: boolean('hasLocalAudio', true),
hasLocalVideo: boolean('hasLocalVideo', false),
joinedAt: Date.now(),
pip: true,
settingsDialogOpen: false,
showParticipantsList: false,
});
const defaultCall: ActiveCallType = {
...getCommonActiveCallData(),
callMode: CallMode.Direct as CallMode.Direct,
conversationId: '3051234567',
callState: CallState.Accepted,
isIncoming: false,
isVideoCall: true,
hasRemoteVideo: true,
peekedParticipants: [],
remoteParticipants: [{ hasRemoteVideo: true }],
};
const createProps = (
overrideProps: Partial<PropsType> = {},
activeCall: Partial<ActiveCallType> = {}
): PropsType => ({
activeCall: {
activeCallState: {
conversationId: '123',
hasLocalAudio: true,
hasLocalVideo: true,
pip: false,
settingsDialogOpen: false,
showParticipantsList: true,
},
call: activeCall.call || defaultCall,
conversation: activeCall.conversation || conversation,
isCallFull: false,
groupCallPeekedParticipants: [],
groupCallParticipants: [],
},
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
activeCall: overrideProps.activeCall || defaultCall,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getGroupCallVideoFrameSource: noop as any,
hangUp: action('hang-up'),
@ -82,48 +74,43 @@ story.add('Default', () => {
});
story.add('Contact (with avatar)', () => {
const props = createProps(
{},
{
const props = createProps({
activeCall: {
...defaultCall,
conversation: {
...conversation,
avatarPath: 'https://www.fillmurray.com/64/64',
},
}
);
},
});
return <CallingPip {...props} />;
});
story.add('Contact (no color)', () => {
const props = createProps(
{},
{
const props = createProps({
activeCall: {
...defaultCall,
conversation: {
...conversation,
color: undefined,
},
}
);
},
});
return <CallingPip {...props} />;
});
story.add('Group Call', () => {
const props = createProps(
{},
{
call: {
callMode: CallMode.Group as CallMode.Group,
conversationId: '3051234567',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
peekInfo: {
conversationIds: [],
maxDevices: 16,
deviceCount: 0,
},
remoteParticipants: [],
},
}
);
const props = createProps({
activeCall: {
...getCommonActiveCallData(),
callMode: CallMode.Group as CallMode.Group,
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
maxDevices: 5,
deviceCount: 0,
peekedParticipants: [],
remoteParticipants: [],
},
});
return <CallingPip {...props} />;
});

View file

@ -5,9 +5,12 @@ import React from 'react';
import { minBy, debounce, noop } from 'lodash';
import { CallingPipRemoteVideo } from './CallingPipRemoteVideo';
import { LocalizerType } from '../types/Util';
import { GroupCallVideoRequest, VideoFrameSource } from '../types/Calling';
import {
ActiveCallType,
GroupCallVideoRequest,
VideoFrameSource,
} from '../types/Calling';
import {
HangUpType,
SetLocalPreviewType,
SetRendererCanvasType,

View file

@ -9,12 +9,13 @@ import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant';
import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant';
import { LocalizerType } from '../types/Util';
import {
ActiveCallType,
CallMode,
GroupCallRemoteParticipantType,
GroupCallVideoRequest,
VideoFrameSource,
} from '../types/Calling';
import { ActiveCallType, SetRendererCanvasType } from '../state/ducks/calling';
import { SetRendererCanvasType } from '../state/ducks/calling';
import { usePageVisibility } from '../util/hooks';
import { nonRenderedRemoteParticipant } from '../util/ringrtc/nonRenderedRemoteParticipant';
@ -74,31 +75,31 @@ export const CallingPipRemoteVideo = ({
setGroupCallVideoRequest,
setRendererCanvas,
}: PropsType): JSX.Element => {
const { call, conversation, groupCallParticipants } = activeCall;
const { conversation } = activeCall;
const isPageVisible = usePageVisibility();
const activeGroupCallSpeaker:
| undefined
| GroupCallRemoteParticipantType = useMemo(() => {
if (call.callMode !== CallMode.Group) {
if (activeCall.callMode !== CallMode.Group) {
return undefined;
}
return maxBy(
groupCallParticipants,
activeCall.remoteParticipants,
participant => participant.speakerTime || -Infinity
);
}, [call.callMode, groupCallParticipants]);
}, [activeCall.callMode, activeCall.remoteParticipants]);
useEffect(() => {
if (call.callMode !== CallMode.Group) {
if (activeCall.callMode !== CallMode.Group) {
return;
}
if (isPageVisible) {
setGroupCallVideoRequest(
groupCallParticipants.map(participant => {
activeCall.remoteParticipants.map(participant => {
const isVisible =
participant === activeGroupCallSpeaker &&
participant.hasRemoteVideo;
@ -116,19 +117,21 @@ export const CallingPipRemoteVideo = ({
);
} else {
setGroupCallVideoRequest(
groupCallParticipants.map(nonRenderedRemoteParticipant)
activeCall.remoteParticipants.map(nonRenderedRemoteParticipant)
);
}
}, [
call.callMode,
groupCallParticipants,
activeCall.callMode,
activeCall.remoteParticipants,
activeGroupCallSpeaker,
isPageVisible,
setGroupCallVideoRequest,
]);
if (call.callMode === CallMode.Direct) {
if (!call.hasRemoteVideo) {
if (activeCall.callMode === CallMode.Direct) {
const { hasRemoteVideo } = activeCall.remoteParticipants[0];
if (!hasRemoteVideo) {
return <NoVideo activeCall={activeCall} i18n={i18n} />;
}
@ -136,7 +139,7 @@ export const CallingPipRemoteVideo = ({
<div className="module-calling-pip__video--remote">
<DirectCallRemoteParticipant
conversation={conversation}
hasRemoteVideo={call.hasRemoteVideo}
hasRemoteVideo={hasRemoteVideo}
i18n={i18n}
setRendererCanvas={setRendererCanvas}
/>
@ -144,7 +147,7 @@ export const CallingPipRemoteVideo = ({
);
}
if (call.callMode === CallMode.Group) {
if (activeCall.callMode === CallMode.Group) {
if (!activeGroupCallSpeaker) {
return <NoVideo activeCall={activeCall} i18n={i18n} />;
}

View file

@ -566,24 +566,21 @@ export class CallingClass {
}
}
private uuidToConversationId(userId: ArrayBuffer): string {
const result = window.ConversationController.ensureContactIds({
uuid: arrayBufferToUuid(userId),
});
if (!result) {
throw new Error(
'Calling.uuidToConversationId: no conversation found for that UUID'
);
}
return result;
}
public formatGroupCallPeekInfoForRedux(
peekInfo: PeekInfo = { joinedMembers: [], deviceCount: 0 }
peekInfo: PeekInfo
): GroupCallPeekInfoType {
return {
conversationIds: peekInfo.joinedMembers.map(this.uuidToConversationId),
creator: peekInfo.creator && this.uuidToConversationId(peekInfo.creator),
uuids: peekInfo.joinedMembers.map(uuidBuffer => {
let uuid = arrayBufferToUuid(uuidBuffer);
if (!uuid) {
window.log.error(
'Calling.formatGroupCallPeekInfoForRedux: could not convert peek UUID ArrayBuffer to string; using fallback UUID'
);
uuid = '00000000-0000-0000-0000-000000000000';
}
return uuid;
}),
creatorUuid: peekInfo.creator && arrayBufferToUuid(peekInfo.creator),
eraId: peekInfo.eraId,
maxDevices: peekInfo.maxDevices ?? Infinity,
deviceCount: peekInfo.deviceCount,
@ -592,23 +589,16 @@ export class CallingClass {
private formatGroupCallForRedux(groupCall: GroupCall) {
const localDeviceState = groupCall.getLocalDeviceState();
const peekInfo = groupCall.getPeekInfo();
// RingRTC doesn't ensure that the demux ID is unique. This can happen if someone
// leaves the call and quickly rejoins; RingRTC will tell us that there are two
// participants with the same demux ID in the call.
// participants with the same demux ID in the call. This should be rare.
const remoteDeviceStates = uniqBy(
groupCall.getRemoteDeviceStates() || [],
remoteDeviceState => remoteDeviceState.demuxId
);
// `GroupCall.prototype.getPeekInfo()` won't return anything at first, so we try to
// set a reasonable default based on the remote device states (which is likely an
// empty array at this point, but we handle the case where it is not).
const peekInfo = groupCall.getPeekInfo() || {
joinedMembers: remoteDeviceStates.map(({ userId }) => userId),
deviceCount: remoteDeviceStates.length,
};
// It should be impossible to be disconnected and Joining or Joined. Just in case, we
// try to handle that case.
const joinState: GroupCallJoinState =
@ -616,8 +606,6 @@ export class CallingClass {
? GroupCallJoinState.NotJoined
: this.convertRingRtcJoinState(localDeviceState.joinState);
const ourConversationId = window.ConversationController.getOurConversationId();
return {
connectionState: this.convertRingRtcConnectionState(
localDeviceState.connectionState
@ -625,17 +613,22 @@ export class CallingClass {
joinState,
hasLocalAudio: !localDeviceState.audioMuted,
hasLocalVideo: !localDeviceState.videoMuted,
peekInfo: this.formatGroupCallPeekInfoForRedux(peekInfo),
peekInfo: peekInfo
? this.formatGroupCallPeekInfoForRedux(peekInfo)
: undefined,
remoteParticipants: remoteDeviceStates.map(remoteDeviceState => {
const conversationId = this.uuidToConversationId(
remoteDeviceState.userId
);
let uuid = arrayBufferToUuid(remoteDeviceState.userId);
if (!uuid) {
window.log.error(
'Calling.formatGroupCallForRedux: could not convert remote participant UUID ArrayBuffer to string; using fallback UUID'
);
uuid = '00000000-0000-0000-0000-000000000000';
}
return {
conversationId,
uuid,
demuxId: remoteDeviceState.demuxId,
hasRemoteAudio: !remoteDeviceState.audioMuted,
hasRemoteVideo: !remoteDeviceState.videoMuted,
isSelf: conversationId === ourConversationId,
speakerTime: normalizeGroupCallTimestamp(
remoteDeviceState.speakerTime
),

View file

@ -9,16 +9,13 @@ import { missingCaseError } from '../../util/missingCaseError';
import { notify } from '../../services/notify';
import { calling } from '../../services/calling';
import { StateType as RootStateType } from '../reducer';
import { ConversationType } from './conversations';
import {
CallingDeviceType,
CallMode,
CallState,
CallingDeviceType,
ChangeIODevicePayloadType,
GroupCallConnectionState,
GroupCallJoinState,
GroupCallPeekedParticipantType,
GroupCallRemoteParticipantType,
GroupCallVideoRequest,
MediaDeviceSettings,
} from '../../types/Calling';
@ -34,19 +31,18 @@ import { LatestQueue } from '../../util/LatestQueue';
// State
export interface GroupCallPeekInfoType {
conversationIds: Array<string>;
creator?: string;
uuids: Array<string>;
creatorUuid?: string;
eraId?: string;
maxDevices: number;
deviceCount: number;
}
export interface GroupCallParticipantInfoType {
conversationId: string;
uuid: string;
demuxId: number;
hasRemoteAudio: boolean;
hasRemoteVideo: boolean;
isSelf: boolean;
speakerTime?: number;
videoAspectRatio: number;
}
@ -70,15 +66,6 @@ export interface GroupCallStateType {
remoteParticipants: Array<GroupCallParticipantInfoType>;
}
export interface ActiveCallType {
activeCallState: ActiveCallStateType;
call: DirectCallStateType | GroupCallStateType;
conversation: ConversationType;
isCallFull: boolean;
groupCallPeekedParticipants: Array<GroupCallPeekedParticipantType>;
groupCallParticipants: Array<GroupCallRemoteParticipantType>;
}
export interface ActiveCallStateType {
conversationId: string;
joinedAt?: number;
@ -127,12 +114,12 @@ type GroupCallStateChangeArgumentType = {
hasLocalAudio: boolean;
hasLocalVideo: boolean;
joinState: GroupCallJoinState;
peekInfo: GroupCallPeekInfoType;
peekInfo?: GroupCallPeekInfoType;
remoteParticipants: Array<GroupCallParticipantInfoType>;
};
type GroupCallStateChangeActionPayloadType = GroupCallStateChangeArgumentType & {
ourConversationId: string;
ourUuid: string;
};
export type HangUpType = {
@ -190,7 +177,7 @@ export type ShowCallLobbyType =
joinState: GroupCallJoinState;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
peekInfo: GroupCallPeekInfoType;
peekInfo?: GroupCallPeekInfoType;
remoteParticipants: Array<GroupCallParticipantInfoType>;
};
@ -212,9 +199,9 @@ export const getActiveCall = ({
getOwn(callsByConversation, activeCallState.conversationId);
export const isAnybodyElseInGroupCall = (
{ conversationIds }: Readonly<GroupCallPeekInfoType>,
ourConversationId: string
): boolean => conversationIds.some(id => id !== ourConversationId);
{ uuids }: Readonly<GroupCallPeekInfoType>,
ourUuid: string
): boolean => uuids.some(id => id !== ourUuid);
// Actions
@ -496,7 +483,7 @@ function groupCallStateChange(
type: GROUP_CALL_STATE_CHANGE,
payload: {
...payload,
ourConversationId: getState().user.ourConversationId,
ourUuid: getState().user.ourUuid,
},
});
};
@ -808,6 +795,16 @@ export function getEmptyState(): CallingStateType {
};
}
function getExistingPeekInfo(
conversationId: string,
state: CallingStateType
): undefined | GroupCallPeekInfoType {
const existingCall = getOwn(state.callsByConversation, conversationId);
return existingCall?.callMode === CallMode.Group
? existingCall.peekInfo
: undefined;
}
function removeConversationFromState(
state: CallingStateType,
conversationId: string
@ -845,7 +842,12 @@ export function reducer(
conversationId: action.payload.conversationId,
connectionState: action.payload.connectionState,
joinState: action.payload.joinState,
peekInfo: action.payload.peekInfo,
peekInfo: action.payload.peekInfo ||
getExistingPeekInfo(action.payload.conversationId, state) || {
uuids: action.payload.remoteParticipants.map(({ uuid }) => uuid),
maxDevices: Infinity,
deviceCount: action.payload.remoteParticipants.length,
},
remoteParticipants: action.payload.remoteParticipants,
};
break;
@ -1030,11 +1032,18 @@ export function reducer(
hasLocalAudio,
hasLocalVideo,
joinState,
ourConversationId,
ourUuid,
peekInfo,
remoteParticipants,
} = action.payload;
const newPeekInfo = peekInfo ||
getExistingPeekInfo(conversationId, state) || {
uuids: remoteParticipants.map(({ uuid }) => uuid),
maxDevices: Infinity,
deviceCount: remoteParticipants.length,
};
let newActiveCallState: ActiveCallStateType | undefined;
if (connectionState === GroupCallConnectionState.NotConnected) {
@ -1043,7 +1052,7 @@ export function reducer(
? undefined
: state.activeCallState;
if (!isAnybodyElseInGroupCall(peekInfo, ourConversationId)) {
if (!isAnybodyElseInGroupCall(newPeekInfo, ourUuid)) {
return {
...state,
callsByConversation: omit(callsByConversation, conversationId),
@ -1070,7 +1079,7 @@ export function reducer(
conversationId,
connectionState,
joinState,
peekInfo,
peekInfo: newPeekInfo,
remoteParticipants,
},
},

View file

@ -3,21 +3,23 @@
import React from 'react';
import { connect } from 'react-redux';
import { memoize } from 'lodash';
import { mapDispatchToProps } from '../actions';
import { CallManager } from '../../components/CallManager';
import { calling as callingService } from '../../services/calling';
import { getUserUuid, getIntl } from '../selectors/user';
import { getMe, getConversationSelector } from '../selectors/conversations';
import { getActiveCall, GroupCallParticipantInfoType } from '../ducks/calling';
import { getActiveCall } from '../ducks/calling';
import { ConversationType } from '../ducks/conversations';
import { getIncomingCall } from '../selectors/calling';
import {
ActiveCallType,
CallMode,
GroupCallPeekedParticipantType,
GroupCallRemoteParticipantType,
} from '../../types/Calling';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
import { missingCaseError } from '../../util/missingCaseError';
import { SmartCallingDeviceSelection } from './CallingDeviceSelection';
function renderDeviceSelection(): JSX.Element {
@ -28,7 +30,9 @@ const getGroupCallVideoFrameSource = callingService.getGroupCallVideoFrameSource
callingService
);
const mapStateToActiveCallProp = (state: StateType) => {
const mapStateToActiveCallProp = (
state: StateType
): undefined | ActiveCallType => {
const { calling } = state;
const { activeCallState } = calling;
@ -51,48 +55,59 @@ const mapStateToActiveCallProp = (state: StateType) => {
return undefined;
}
// TODO: The way we deal with remote participants isn't ideal. See DESKTOP-949.
let isCallFull = false;
const groupCallPeekedParticipants: Array<GroupCallPeekedParticipantType> = [];
const groupCallParticipants: Array<GroupCallRemoteParticipantType> = [];
if (call.callMode === CallMode.Group) {
isCallFull = call.peekInfo.deviceCount >= call.peekInfo.maxDevices;
call.peekInfo.conversationIds.forEach((conversationId: string) => {
const peekedConversation = conversationSelector(conversationId);
if (!peekedConversation) {
window.log.error(
'Peeked participant has no corresponding conversation'
);
return;
}
groupCallPeekedParticipants.push({
avatarPath: peekedConversation.avatarPath,
color: peekedConversation.color,
firstName: peekedConversation.firstName,
isSelf: conversationId === state.user.ourConversationId,
name: peekedConversation.name,
profileName: peekedConversation.profileName,
title: peekedConversation.title,
});
const conversationSelectorByUuid = memoize<
(uuid: string) => undefined | ConversationType
>(uuid => {
const conversationId = window.ConversationController.ensureContactIds({
uuid,
});
return conversationId ? conversationSelector(conversationId) : undefined;
});
call.remoteParticipants.forEach(
(remoteParticipant: GroupCallParticipantInfoType) => {
const remoteConversation = conversationSelector(
remoteParticipant.conversationId
const baseResult = {
conversation,
hasLocalAudio: activeCallState.hasLocalAudio,
hasLocalVideo: activeCallState.hasLocalVideo,
joinedAt: activeCallState.joinedAt,
pip: activeCallState.pip,
settingsDialogOpen: activeCallState.settingsDialogOpen,
showParticipantsList: activeCallState.showParticipantsList,
};
switch (call.callMode) {
case CallMode.Direct:
return {
...baseResult,
callEndedReason: call.callEndedReason,
callMode: CallMode.Direct,
callState: call.callState,
peekedParticipants: [],
remoteParticipants: [
{
hasRemoteVideo: Boolean(call.hasRemoteVideo),
},
],
};
case CallMode.Group: {
const ourUuid = getUserUuid(state);
const remoteParticipants: Array<GroupCallRemoteParticipantType> = [];
const peekedParticipants: Array<GroupCallPeekedParticipantType> = [];
for (let i = 0; i < call.remoteParticipants.length; i += 1) {
const remoteParticipant = call.remoteParticipants[i];
const remoteConversation = conversationSelectorByUuid(
remoteParticipant.uuid
);
if (!remoteConversation) {
window.log.error(
'Remote participant has no corresponding conversation'
);
return;
continue;
}
groupCallParticipants.push({
remoteParticipants.push({
avatarPath: remoteConversation.avatarPath,
color: remoteConversation.color,
demuxId: remoteParticipant.demuxId,
@ -100,27 +115,55 @@ const mapStateToActiveCallProp = (state: StateType) => {
hasRemoteAudio: remoteParticipant.hasRemoteAudio,
hasRemoteVideo: remoteParticipant.hasRemoteVideo,
isBlocked: Boolean(remoteConversation.isBlocked),
isSelf: remoteParticipant.isSelf,
isSelf: remoteParticipant.uuid === ourUuid,
name: remoteConversation.name,
profileName: remoteConversation.profileName,
speakerTime: remoteParticipant.speakerTime,
title: remoteConversation.title,
uuid: remoteParticipant.uuid,
videoAspectRatio: remoteParticipant.videoAspectRatio,
});
}
);
groupCallParticipants.sort((a, b) => a.title.localeCompare(b.title));
for (let i = 0; i < call.peekInfo.uuids.length; i += 1) {
const peekedParticipantUuid = call.peekInfo.uuids[i];
const peekedConversation = conversationSelectorByUuid(
peekedParticipantUuid
);
if (!peekedConversation) {
window.log.error(
'Remote participant has no corresponding conversation'
);
continue;
}
peekedParticipants.push({
avatarPath: peekedConversation.avatarPath,
color: peekedConversation.color,
firstName: peekedConversation.firstName,
isSelf: peekedParticipantUuid === ourUuid,
name: peekedConversation.name,
profileName: peekedConversation.profileName,
title: peekedConversation.title,
uuid: peekedParticipantUuid,
});
}
return {
...baseResult,
callMode: CallMode.Group,
connectionState: call.connectionState,
deviceCount: call.peekInfo.deviceCount,
joinState: call.joinState,
maxDevices: call.peekInfo.maxDevices,
peekedParticipants,
remoteParticipants,
};
}
default:
throw missingCaseError(call);
}
return {
activeCallState,
call,
conversation,
isCallFull,
groupCallPeekedParticipants,
groupCallParticipants,
};
};
const mapStateToIncomingCallProp = (state: StateType) => {
@ -147,7 +190,12 @@ const mapStateToProps = (state: StateType) => ({
getGroupCallVideoFrameSource,
i18n: getIntl(state),
incomingCall: mapStateToIncomingCallProp(state),
me: getMe(state),
me: {
...getMe(state),
// `getMe` returns a `ConversationType` which might not have a UUID, at least
// according to the type. This ensures one is set.
uuid: getUserUuid(state),
},
renderDeviceSelection,
});

View file

@ -72,19 +72,18 @@ describe('calling duck', () => {
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined,
peekInfo: {
conversationIds: ['456'],
creator: '456',
uuids: ['456'],
creatorUuid: '456',
eraId: 'xyz',
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
conversationId: '123',
uuid: '123',
demuxId: 123,
hasRemoteAudio: true,
hasRemoteVideo: true,
isSelf: false,
videoAspectRatio: 4 / 3,
},
],
@ -104,7 +103,7 @@ describe('calling duck', () => {
},
};
const ourConversationId = 'ebf5fd79-9344-4ec1-b5c9-af463572caf5';
const ourUuid = 'ebf5fd79-9344-4ec1-b5c9-af463572caf5';
const getEmptyRootState = () => {
const rootState = rootReducer(undefined, noopAction());
@ -112,7 +111,7 @@ describe('calling duck', () => {
...rootState,
user: {
...rootState.user,
ourConversationId,
ourUuid,
},
};
};
@ -275,7 +274,7 @@ describe('calling duck', () => {
hasLocalAudio: false,
hasLocalVideo: false,
peekInfo: {
conversationIds: [],
uuids: [],
maxDevices: 16,
deviceCount: 0,
},
@ -296,7 +295,7 @@ describe('calling duck', () => {
hasLocalAudio: false,
hasLocalVideo: false,
peekInfo: {
conversationIds: [],
uuids: [],
maxDevices: 16,
deviceCount: 0,
},
@ -320,7 +319,7 @@ describe('calling duck', () => {
hasLocalAudio: false,
hasLocalVideo: false,
peekInfo: {
conversationIds: [ourConversationId],
uuids: [ourUuid],
maxDevices: 16,
deviceCount: 1,
},
@ -344,7 +343,7 @@ describe('calling duck', () => {
hasLocalAudio: false,
hasLocalVideo: false,
peekInfo: {
conversationIds: [],
uuids: [],
maxDevices: 16,
deviceCount: 0,
},
@ -365,7 +364,7 @@ describe('calling duck', () => {
hasLocalAudio: false,
hasLocalVideo: false,
peekInfo: {
conversationIds: [ourConversationId],
uuids: [ourUuid],
maxDevices: 16,
deviceCount: 1,
},
@ -386,19 +385,18 @@ describe('calling duck', () => {
hasLocalAudio: true,
hasLocalVideo: false,
peekInfo: {
conversationIds: ['456'],
creator: '456',
uuids: ['456'],
creatorUuid: '456',
eraId: 'xyz',
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
conversationId: '123',
uuid: '123',
demuxId: 123,
hasRemoteAudio: true,
hasRemoteVideo: true,
isSelf: false,
videoAspectRatio: 4 / 3,
},
],
@ -413,19 +411,18 @@ describe('calling duck', () => {
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joining,
peekInfo: {
conversationIds: ['456'],
creator: '456',
uuids: ['456'],
creatorUuid: '456',
eraId: 'xyz',
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
conversationId: '123',
uuid: '123',
demuxId: 123,
hasRemoteAudio: true,
hasRemoteVideo: true,
isSelf: false,
videoAspectRatio: 4 / 3,
},
],
@ -443,7 +440,7 @@ describe('calling duck', () => {
hasLocalAudio: false,
hasLocalVideo: false,
peekInfo: {
conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
maxDevices: 16,
deviceCount: 1,
},
@ -459,7 +456,7 @@ describe('calling duck', () => {
connectionState: GroupCallConnectionState.NotConnected,
joinState: GroupCallJoinState.NotJoined,
peekInfo: {
conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
maxDevices: 16,
deviceCount: 1,
},
@ -478,17 +475,16 @@ describe('calling duck', () => {
hasLocalAudio: true,
hasLocalVideo: false,
peekInfo: {
conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
conversationId: '123',
uuid: '123',
demuxId: 456,
hasRemoteAudio: false,
hasRemoteVideo: true,
isSelf: false,
videoAspectRatio: 16 / 9,
},
],
@ -503,17 +499,16 @@ describe('calling duck', () => {
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
peekInfo: {
conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
conversationId: '123',
uuid: '123',
demuxId: 456,
hasRemoteAudio: false,
hasRemoteVideo: true,
isSelf: false,
videoAspectRatio: 16 / 9,
},
],
@ -531,17 +526,16 @@ describe('calling duck', () => {
hasLocalAudio: true,
hasLocalVideo: false,
peekInfo: {
conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
conversationId: '123',
uuid: '123',
demuxId: 456,
hasRemoteAudio: false,
hasRemoteVideo: true,
isSelf: false,
videoAspectRatio: 16 / 9,
},
],
@ -561,17 +555,16 @@ describe('calling duck', () => {
hasLocalAudio: true,
hasLocalVideo: true,
peekInfo: {
conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
conversationId: '123',
uuid: '123',
demuxId: 456,
hasRemoteAudio: false,
hasRemoteVideo: true,
isSelf: false,
videoAspectRatio: 16 / 9,
},
],
@ -598,17 +591,16 @@ describe('calling duck', () => {
hasLocalAudio: true,
hasLocalVideo: true,
peekInfo: {
conversationIds: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
uuids: ['1b9e4d42-1f56-45c5-b6f4-d1be5a54fefa'],
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
conversationId: '123',
uuid: '123',
demuxId: 456,
hasRemoteAudio: false,
hasRemoteVideo: true,
isSelf: false,
videoAspectRatio: 16 / 9,
},
],
@ -771,7 +763,7 @@ describe('calling duck', () => {
describe('showCallLobby', () => {
const { showCallLobby } = actions;
it('saves the call and makes it active', () => {
it('saves a direct call and makes it active', () => {
const result = reducer(
getEmptyState(),
showCallLobby({
@ -797,6 +789,161 @@ describe('calling duck', () => {
settingsDialogOpen: false,
});
});
it('saves a group call and makes it active', () => {
const result = reducer(
getEmptyState(),
showCallLobby({
callMode: CallMode.Group,
conversationId: 'fake-conversation-id',
hasLocalAudio: true,
hasLocalVideo: true,
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined,
peekInfo: {
uuids: ['456'],
creatorUuid: '456',
eraId: 'xyz',
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
uuid: '123',
demuxId: 123,
hasRemoteAudio: true,
hasRemoteVideo: true,
videoAspectRatio: 4 / 3,
},
],
})
);
assert.deepEqual(result.callsByConversation['fake-conversation-id'], {
callMode: CallMode.Group,
conversationId: 'fake-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined,
peekInfo: {
uuids: ['456'],
creatorUuid: '456',
eraId: 'xyz',
maxDevices: 16,
deviceCount: 1,
},
remoteParticipants: [
{
uuid: '123',
demuxId: 123,
hasRemoteAudio: true,
hasRemoteVideo: true,
videoAspectRatio: 4 / 3,
},
],
});
assert.deepEqual(
result.activeCallState?.conversationId,
'fake-conversation-id'
);
});
it('chooses fallback peek info if none is sent and there is no existing call', () => {
const result = reducer(
getEmptyState(),
showCallLobby({
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
hasLocalAudio: true,
hasLocalVideo: true,
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined,
peekInfo: undefined,
remoteParticipants: [],
})
);
const call =
result.callsByConversation['fake-group-call-conversation-id'];
assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, {
uuids: [],
maxDevices: Infinity,
deviceCount: 0,
});
});
it("doesn't overwrite an existing group call's peek info if none was sent", () => {
const result = reducer(
stateWithGroupCall,
showCallLobby({
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
hasLocalAudio: true,
hasLocalVideo: true,
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined,
peekInfo: undefined,
remoteParticipants: [
{
uuid: '123',
demuxId: 123,
hasRemoteAudio: true,
hasRemoteVideo: true,
videoAspectRatio: 4 / 3,
},
],
})
);
const call =
result.callsByConversation['fake-group-call-conversation-id'];
assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, {
uuids: ['456'],
creatorUuid: '456',
eraId: 'xyz',
maxDevices: 16,
deviceCount: 1,
});
});
it("can overwrite an existing group call's peek info", () => {
const result = reducer(
stateWithGroupCall,
showCallLobby({
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
hasLocalAudio: true,
hasLocalVideo: true,
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined,
peekInfo: {
uuids: ['999'],
creatorUuid: '999',
eraId: 'abc',
maxDevices: 5,
deviceCount: 1,
},
remoteParticipants: [
{
uuid: '123',
demuxId: 123,
hasRemoteAudio: true,
hasRemoteVideo: true,
videoAspectRatio: 4 / 3,
},
],
})
);
const call =
result.callsByConversation['fake-group-call-conversation-id'];
assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, {
uuids: ['999'],
creatorUuid: '999',
eraId: 'abc',
maxDevices: 5,
deviceCount: 1,
});
});
});
describe('startCall', () => {
@ -965,10 +1112,10 @@ describe('calling duck', () => {
});
describe('isAnybodyElseInGroupCall', () => {
const fakePeekInfo = (conversationIds: Array<string>) => ({
conversationIds,
maxDevices: 16,
deviceCount: conversationIds.length,
const fakePeekInfo = (uuids: Array<string>) => ({
uuids,
maxDevices: 5,
deviceCount: uuids.length,
});
it('returns false if the peek info has no participants', () => {

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { ColorType } from './Colors';
import { ConversationType } from '../state/ducks/conversations';
export enum CallMode {
None = 'None',
@ -9,6 +10,40 @@ export enum CallMode {
Group = 'Group',
}
interface ActiveCallBaseType {
conversation: ConversationType;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
joinedAt?: number;
pip: boolean;
settingsDialogOpen: boolean;
showParticipantsList: boolean;
}
interface ActiveDirectCallType extends ActiveCallBaseType {
callMode: CallMode.Direct;
callState?: CallState;
callEndedReason?: CallEndedReason;
peekedParticipants: [];
remoteParticipants: [
{
hasRemoteVideo: boolean;
}
];
}
interface ActiveGroupCallType extends ActiveCallBaseType {
callMode: CallMode.Group;
connectionState: GroupCallConnectionState;
joinState: GroupCallJoinState;
maxDevices: number;
deviceCount: number;
peekedParticipants: Array<GroupCallPeekedParticipantType>;
remoteParticipants: Array<GroupCallRemoteParticipantType>;
}
export type ActiveCallType = ActiveDirectCallType | ActiveGroupCallType;
// Ideally, we would import many of these directly from RingRTC. But because Storybook
// cannot import RingRTC (as it runs in the browser), we have these copies. That also
// means we have to convert the "real" enum to our enum in some cases.
@ -58,7 +93,6 @@ export enum GroupCallJoinState {
Joined = 2,
}
// TODO: The way we deal with remote participants isn't ideal. See DESKTOP-949.
export interface GroupCallPeekedParticipantType {
avatarPath?: string;
color?: ColorType;
@ -67,20 +101,16 @@ export interface GroupCallPeekedParticipantType {
name?: string;
profileName?: string;
title: string;
uuid: string;
}
export interface GroupCallRemoteParticipantType {
avatarPath?: string;
color?: ColorType;
export interface GroupCallRemoteParticipantType
extends GroupCallPeekedParticipantType {
demuxId: number;
firstName?: string;
hasRemoteAudio: boolean;
hasRemoteVideo: boolean;
isBlocked: boolean;
isSelf: boolean;
name?: string;
profileName?: string;
speakerTime?: number;
title: string;
videoAspectRatio: number;
}

View file

@ -14400,7 +14400,7 @@
"rule": "React-useRef",
"path": "ts/components/CallingLobby.tsx",
"line": " const localVideoRef = React.useRef(null);",
"lineNumber": 62,
"lineNumber": 67,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Used to get the local video element for rendering."
@ -14427,7 +14427,7 @@
"rule": "React-useRef",
"path": "ts/components/CallingPip.tsx",
"line": " const localVideoRef = React.useRef(null);",
"lineNumber": 80,
"lineNumber": 83,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Used to get the local video element for rendering."

View file

@ -1,7 +1,7 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { GroupCallVideoRequest } from '../../types/Calling';
import { GroupCallVideoRequest } from '../../types/Calling';
export const nonRenderedRemoteParticipant = ({
demuxId,