diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index 2be945c956..1d6009cb76 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -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 => ({ 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', () => ( @@ -114,23 +107,14 @@ story.add('Ongoing Group Call', () => ( @@ -151,14 +135,12 @@ story.add('Call Request Needed', () => ( diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index 297287f866..3c5582f3c1 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -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 = ({ 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 = ({ 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 = ({ [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 = ({ ); } 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 ( <> = ({ 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 = ({ toggleSettings={toggleSettings} /> {settingsDialogOpen && renderDeviceSelection()} - {showParticipantsList && call.callMode === CallMode.Group ? ( + {showParticipantsList && activeCall.callMode === CallMode.Group ? ( ) : null} @@ -227,14 +226,30 @@ const ActiveCallManager: React.FC = ({ ); } + 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 ( <> = ({ toggleSettings={toggleSettings} /> {settingsDialogOpen && renderDeviceSelection()} - {showParticipantsList && call.callMode === CallMode.Group ? ( + {showParticipantsList && activeCall.callMode === CallMode.Group ? ( ) : null} diff --git a/ts/components/CallScreen.stories.tsx b/ts/components/CallScreen.stories.tsx index a5d2d7351d..06d2df2edd 100644 --- a/ts/components/CallScreen.stories.tsx +++ b/ts/components/CallScreen.stories.tsx @@ -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; + remoteParticipants?: Array; +} + +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; - 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 ( @@ -145,6 +172,7 @@ story.add('Ringing', () => { return ( @@ -155,6 +183,7 @@ story.add('Reconnecting', () => { return ( @@ -165,6 +194,7 @@ story.add('Ended', () => { return ( @@ -172,23 +202,45 @@ story.add('Ended', () => { }); story.add('hasLocalAudio', () => { - return ; + return ( + + ); }); story.add('hasLocalVideo', () => { - return ; + return ( + + ); }); story.add('hasRemoteVideo', () => { - return ; + return ( + + ); }); story.add('Group call - 1', () => ( ( 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', () => ( VideoFrameSource; hangUp: (_: HangUpType) => void; - hasLocalAudio: boolean; - hasLocalVideo: boolean; i18n: LocalizerType; joinedAt?: number; me: { @@ -61,8 +59,6 @@ export const CallScreen: React.FC = ({ activeCall, getGroupCallVideoFrameSource, hangUp, - hasLocalAudio, - hasLocalVideo, i18n, joinedAt, me, @@ -76,7 +72,12 @@ export const CallScreen: React.FC = ({ 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 = ({ }; }, [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 = ( = ({ ); 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 = ( ); break; default: - throw missingCaseError(call); + throw missingCaseError(activeCall); } const videoButtonType = hasLocalVideo @@ -214,14 +215,12 @@ export const CallScreen: React.FC = ({ !showControls && !isAudioOnly && isConnected, }); - const { showParticipantsList } = activeCall.activeCallState; - return (
{ @@ -229,9 +228,9 @@ export const CallScreen: React.FC = ({ }} role="group" > - {call.callMode === CallMode.Group ? ( + {activeCall.callMode === CallMode.Group ? ( ) : null} @@ -241,7 +240,7 @@ export const CallScreen: React.FC = ({ = {}): 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 => ({ 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 ; }); -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 ; }); -story.add('Group Call - 1', () => { - const props = createProps({ isGroupCall: true, participantNames: ['Sam'] }); - return ; -}); - -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 ; }); -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 ; }); -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 ; }); -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 ; +}); + +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 ; diff --git a/ts/components/CallingLobby.tsx b/ts/components/CallingLobby.tsx index fea2f9f1ce..cf72845270 100644 --- a/ts/components/CallingLobby.tsx +++ b/ts/components/CallingLobby.tsx @@ -31,7 +31,12 @@ export type PropsType = { }; onCallCanceled: () => void; onJoinCall: () => void; - participantNames: Array; + 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} diff --git a/ts/components/CallingParticipantsList.stories.tsx b/ts/components/CallingParticipantsList.stories.tsx index 6626d82d86..2542e6d6f5 100644 --- a/ts/components/CallingParticipantsList.stories.tsx +++ b/ts/components/CallingParticipantsList.stories.tsx @@ -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(), }; } diff --git a/ts/components/CallingParticipantsList.tsx b/ts/components/CallingParticipantsList.tsx index 7550667859..207185e363 100644 --- a/ts/components/CallingParticipantsList.tsx +++ b/ts/components/CallingParticipantsList.tsx @@ -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; + readonly participants: Array; }; export const CallingParticipantsList = React.memo( ({ i18n, onClose, participants }: PropsType) => { const [root, setRoot] = React.useState(null); + const sortedParticipants = React.useMemo>( + () => 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( />
    - {participants.map( - (participant: GroupCallRemoteParticipantType, index: number) => ( + {sortedParticipants.map( + (participant: ParticipantType, index: number) => (
  • @@ -110,10 +123,10 @@ export const CallingParticipantsList = React.memo( )}
    - {!participant.hasRemoteAudio ? ( + {participant.hasAudio === false ? ( ) : null} - {!participant.hasRemoteVideo ? ( + {participant.hasVideo === false ? ( ) : null}
    diff --git a/ts/components/CallingPip.stories.tsx b/ts/components/CallingPip.stories.tsx index 1de5840dc9..e254ae81b6 100644 --- a/ts/components/CallingPip.stories.tsx +++ b/ts/components/CallingPip.stories.tsx @@ -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 = {}, - activeCall: Partial = {} -): 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 => ({ + 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 ; }); story.add('Contact (no color)', () => { - const props = createProps( - {}, - { + const props = createProps({ + activeCall: { + ...defaultCall, conversation: { ...conversation, color: undefined, }, - } - ); + }, + }); return ; }); 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 ; }); diff --git a/ts/components/CallingPip.tsx b/ts/components/CallingPip.tsx index f697647eff..2289a61027 100644 --- a/ts/components/CallingPip.tsx +++ b/ts/components/CallingPip.tsx @@ -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, diff --git a/ts/components/CallingPipRemoteVideo.tsx b/ts/components/CallingPipRemoteVideo.tsx index 99313baeaa..5891468460 100644 --- a/ts/components/CallingPipRemoteVideo.tsx +++ b/ts/components/CallingPipRemoteVideo.tsx @@ -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 ; } @@ -136,7 +139,7 @@ export const CallingPipRemoteVideo = ({
    @@ -144,7 +147,7 @@ export const CallingPipRemoteVideo = ({ ); } - if (call.callMode === CallMode.Group) { + if (activeCall.callMode === CallMode.Group) { if (!activeGroupCallSpeaker) { return ; } diff --git a/ts/services/calling.ts b/ts/services/calling.ts index c22cd28acb..0286df72d5 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -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 ), diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index 71cfdadf86..6baad1f2ab 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -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; - creator?: string; + uuids: Array; + 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; } -export interface ActiveCallType { - activeCallState: ActiveCallStateType; - call: DirectCallStateType | GroupCallStateType; - conversation: ConversationType; - isCallFull: boolean; - groupCallPeekedParticipants: Array; - groupCallParticipants: Array; -} - 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; }; 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; }; @@ -212,9 +199,9 @@ export const getActiveCall = ({ getOwn(callsByConversation, activeCallState.conversationId); export const isAnybodyElseInGroupCall = ( - { conversationIds }: Readonly, - ourConversationId: string -): boolean => conversationIds.some(id => id !== ourConversationId); + { uuids }: Readonly, + 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, }, }, diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index 5c2b65e94c..2dfd7ccf51 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -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 = []; - const groupCallParticipants: Array = []; - 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 = []; + const peekedParticipants: Array = []; + + 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, }); diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index 89384169fb..c01126f3c7 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -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) => ({ - conversationIds, - maxDevices: 16, - deviceCount: conversationIds.length, + const fakePeekInfo = (uuids: Array) => ({ + uuids, + maxDevices: 5, + deviceCount: uuids.length, }); it('returns false if the peek info has no participants', () => { diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index 3b6fbfc919..f2ea6dbf87 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -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; + remoteParticipants: Array; +} + +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; } diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 88869eda80..91eb9f2747 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -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." diff --git a/ts/util/ringrtc/nonRenderedRemoteParticipant.ts b/ts/util/ringrtc/nonRenderedRemoteParticipant.ts index 4f2ae27a61..7902cc3f7f 100644 --- a/ts/util/ringrtc/nonRenderedRemoteParticipant.ts +++ b/ts/util/ringrtc/nonRenderedRemoteParticipant.ts @@ -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,