From 1f0c091e13f170185d0af137a678758fa8da4234 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Tue, 17 Nov 2020 10:07:53 -0500 Subject: [PATCH] Group calling enhancements --- _locales/en/messages.json | 92 ++++++++- stylesheets/_modules.scss | 21 ++- ts/components/CallManager.stories.tsx | 178 ++++++++---------- ts/components/CallManager.tsx | 60 ++++-- ts/components/CallScreen.stories.tsx | 138 ++++++++++++-- ts/components/CallScreen.tsx | 75 ++++---- ts/components/CallingHeader.stories.tsx | 54 ++++++ ts/components/CallingHeader.tsx | 89 +++++++++ ts/components/CallingLobby.stories.tsx | 34 +++- ts/components/CallingLobby.tsx | 95 +++++++--- .../CallingParticipantsList.stories.tsx | 75 +++++--- ts/components/CallingParticipantsList.tsx | 94 ++++----- ts/components/CallingPip.stories.tsx | 42 ++++- ts/components/CallingPip.tsx | 101 ++++------ ts/components/CallingPipRemoteVideo.tsx | 105 +++++++++++ ts/components/GroupCallRemoteParticipant.tsx | 4 +- ts/components/GroupCallRemoteParticipants.tsx | 10 +- ts/models/conversations.ts | 5 +- ts/services/calling.ts | 34 +++- ts/state/ducks/calling.ts | 37 ++-- ts/state/smart/CallManager.tsx | 40 +++- ts/test-electron/state/ducks/calling_test.ts | 42 +++-- .../state/selectors/calling_test.ts | 2 +- ts/test/util/combineNames_test.ts | 4 +- ts/types/Calling.ts | 19 +- ts/util/combineNames.ts | 7 +- ts/util/lint/exceptions.json | 32 +--- 27 files changed, 1038 insertions(+), 451 deletions(-) create mode 100644 ts/components/CallingHeader.stories.tsx create mode 100644 ts/components/CallingHeader.tsx create mode 100644 ts/components/CallingPipRemoteVideo.tsx diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 705477381..beae50d97 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1183,17 +1183,85 @@ "description": "Button tooltip label when the microphone is disabled" }, "calling__button--audio-off": { - "message": "Turn off microphone", + "message": "Mute mic", "description": "Button tooltip label for turning off the microphone" }, "calling__button--audio-on": { - "message": "Turn on microphone", + "message": "Unmute mic", "description": "Button tooltip label for turning on the microphone" }, "calling__your-video-is-off": { "message": "Your video is off", "description": "Label in the calling lobby indicating that your camera is off" }, + "calling__lobby-summary--zero": { + "message": "No one else is here", + "description": "Shown in the calling lobby to describe who is in the call" + }, + "calling__lobby-summary--single": { + "message": "$first$ is in this call", + "description": "Shown in the calling lobby to describe who is in the call", + "placeholders": { + "first": { + "content": "$1", + "example": "Sam" + } + } + }, + "calling__lobby-summary--double": { + "message": "$first$ and $second$ are in this call", + "description": "Shown in the calling lobby to describe who is in the call", + "placeholders": { + "first": { + "content": "$1", + "example": "Sam" + }, + "second": { + "content": "$2", + "example": "Cayce" + } + } + }, + "calling__lobby-summary--triple": { + "message": "$first$, $second$, and $third$ are in this call", + "description": "Shown in the calling lobby to describe who is in the call", + "placeholders": { + "first": { + "content": "$1", + "example": "Sam" + }, + "second": { + "content": "$2", + "example": "Cayce" + }, + "third": { + "content": "$3", + "example": "April" + } + } + }, + "calling__lobby-summary--many": { + "message": "$first$, $second$, and $others$ others are in this call", + "description": "Shown in the calling lobby to describe who is in the call", + "placeholders": { + "first": { + "content": "$1", + "example": "Sam" + }, + "second": { + "content": "$2", + "example": "Cayce" + }, + "others": { + "content": "$3", + "example": "5" + } + } + }, + "calling__in-this-call--zero": { + "message": "No one else is here", + "description": "Shown in the participants list to describe how many people are in the call" + }, "calling__in-this-call--one": { "message": "In this call ยท 1 person", "description": "Shown in the participants list to describe how many people are in the call" @@ -2984,15 +3052,25 @@ "description": "Title for device selection settings" }, "calling__participants": { - "message": "Participants", - "description": "Title for participants list toggle" + "message": "$people$ in call", + "description": "Title for participants list toggle", + "placeholders": { + "people": { + "content": "$1", + "example": "16" + } + } }, - "calling__pip": { - "message": "Picture-in-picture", + "calling__pip--on": { + "message": "Minimize call", + "description": "Title for picture-in-picture toggle" + }, + "calling__pip--off": { + "message": "Fullscreen call", "description": "Title for picture-in-picture toggle" }, "calling__hangup": { - "message": "Hang Up", + "message": "Leave call", "description": "Title for hang up button" }, "callingDeviceSelection__label--video": { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 04d847f28..94fbc8e52 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -5872,14 +5872,16 @@ button.module-image__border-overlay:focus { padding-top: 24px; text-align: center; text-shadow: 0px 0px 4px rgba(0, 0, 0, 0.25); - top: 0; width: 100%; &--header-name { - font-weight: 600; font-size: 15px; - line-height: 21px; + font-weight: 600; letter-spacing: -0.009em; + line-height: 21px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } } @@ -6239,6 +6241,8 @@ button.module-image__border-overlay:focus { &__header { background: linear-gradient($color-black-alpha-40, transparent); + top: 0; + width: 100%; } &__header-message { @@ -6373,6 +6377,9 @@ button.module-image__border-overlay:focus { .module-calling-lobby { &__actions { + align-items: flex-start; + display: flex; + flex-direction: row; flex: 0 0 100px; } @@ -6411,6 +6418,12 @@ button.module-image__border-overlay:focus { z-index: 1; } } + + &__info { + color: $color-white; + margin-bottom: 36px; + margin-top: 12px; + } } .module-calling-pip { @@ -9728,6 +9741,7 @@ button.module-image__border-overlay:focus { border-radius: 4px; border: none; color: $color-white; + line-height: 24px; outline: none; padding: 7px 14px; @@ -9744,6 +9758,7 @@ button.module-image__border-overlay:focus { border-radius: 4px; border: none; color: $color-white; + line-height: 24px; outline: none; padding: 7px 14px; diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index ca6204d86..4cad4883b 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { noop } from 'lodash'; import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; +import { boolean, select, text } from '@storybook/addon-knobs'; import { CallManager, PropsType } from './CallManager'; import { @@ -15,26 +16,47 @@ import { GroupCallJoinState, } from '../types/Calling'; import { ConversationTypeType } from '../state/ducks/conversations'; -import { ColorType } from '../types/Colors'; +import { Colors, ColorType } from '../types/Colors'; import { setup as setupI18n } from '../../js/modules/i18n'; import enMessages from '../../_locales/en/messages.json'; const i18n = setupI18n('en', enMessages); -const conversation = { +const getConversation = () => ({ id: '3051234567', avatarPath: undefined, - color: 'ultramarine' as ColorType, - title: 'Rick Sanchez', - name: 'Rick Sanchez', + color: select('Callee color', Colors, 'ultramarine' as ColorType), + title: text('Callee Title', 'Rick Sanchez'), + name: text('Callee Name', 'Rick Sanchez'), phoneNumber: '3051234567', profileName: 'Rick Sanchez', markedUnread: false, type: 'direct' as ConversationTypeType, lastUpdated: Date.now(), -}; +}); -const defaultProps = { +const getCallState = () => ({ + conversationId: '3051234567', + joinedAt: Date.now(), + hasLocalAudio: boolean('hasLocalAudio', true), + hasLocalVideo: boolean('hasLocalVideo', false), + pip: boolean('pip', false), + settingsDialogOpen: boolean('settingsDialogOpen', false), + showParticipantsList: boolean('showParticipantsList', false), +}); + +const getIncomingCallState = (extraProps = {}) => ({ + ...extraProps, + callMode: CallMode.Direct as CallMode.Direct, + conversationId: '3051234567', + callState: CallState.Ringing, + isIncoming: true, + isVideoCall: boolean('isVideoCall', true), + hasRemoteVideo: true, +}); + +const createProps = (storyProps: Partial = {}): PropsType => ({ + ...storyProps, availableCameras: [], acceptCall: action('accept-call'), cancelCall: action('cancel-call'), @@ -54,8 +76,8 @@ const defaultProps = { hangUp: action('hang-up'), i18n, me: { - color: 'ultramarine' as ColorType, - title: 'Morty Smith', + color: select('Caller color', Colors, 'ultramarine' as ColorType), + title: text('Caller Title', 'Morty Smith'), }, renderDeviceSelection: () =>
, setLocalAudio: action('set-local-audio'), @@ -66,16 +88,15 @@ const defaultProps = { toggleParticipants: action('toggle-participants'), togglePip: action('toggle-pip'), toggleSettings: action('toggle-settings'), -}; +}); -const permutations = [ - { - title: 'Call Manager (no call)', - props: {}, - }, - { - title: 'Call Manager (ongoing direct call)', - props: { +const story = storiesOf('Components/CallManager', module); + +story.add('No Call', () => ); + +story.add('Ongoing Direct Call', () => ( + +)); + +story.add('Ongoing Group Call', () => ( + +)); -storiesOf('Components/CallManager', module).add('Iterations', () => { - return permutations.map( - ({ props, title }: { props: Partial; title: string }) => ( - <> -

{title}

- - - ) - ); -}); +story.add('Ringing', () => ( + +)); + +story.add('Call Request Needed', () => ( + +)); diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index 906b66557..a4f413d71 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -2,18 +2,20 @@ // SPDX-License-Identifier: AGPL-3.0-only import React, { useCallback } from 'react'; -import { CallingPip } from './CallingPip'; import { CallNeedPermissionScreen } from './CallNeedPermissionScreen'; -import { CallingLobby } from './CallingLobby'; import { CallScreen } from './CallScreen'; +import { CallingLobby } from './CallingLobby'; +import { CallingParticipantsList } from './CallingParticipantsList'; +import { CallingPip } from './CallingPip'; import { IncomingCallBar } from './IncomingCallBar'; import { + CallEndedReason, CallMode, CallState, - CallEndedReason, CanvasVideoRenderer, - VideoFrameSource, GroupCallJoinState, + GroupCallRemoteParticipantType, + VideoFrameSource, } from '../types/Calling'; import { ConversationType } from '../state/ducks/conversations'; import { @@ -35,9 +37,10 @@ import { ColorType } from '../types/Colors'; import { missingCaseError } from '../util/missingCaseError'; interface ActiveCallType { - call: DirectCallStateType | GroupCallStateType; activeCallState: ActiveCallStateType; + call: DirectCallStateType | GroupCallStateType; conversation: ConversationType; + groupCallParticipants: Array; } export interface PropsType { @@ -101,13 +104,19 @@ const ActiveCallManager: React.FC = ({ togglePip, toggleSettings, }) => { - const { call, activeCallState, conversation } = activeCall; const { - joinedAt, + call, + activeCallState, + conversation, + groupCallParticipants, + } = activeCall; + const { hasLocalAudio, hasLocalVideo, - settingsDialogOpen, + joinedAt, pip, + settingsDialogOpen, + showParticipantsList, } = activeCallState; const cancelActiveCall = useCallback(() => { @@ -160,6 +169,11 @@ const ActiveCallManager: React.FC = ({ } if (showCallLobby) { + const participantNames = groupCallParticipants.map(participant => + participant.isSelf + ? i18n('you') + : participant.firstName || participant.title + ); return ( <> = ({ hasLocalAudio={hasLocalAudio} hasLocalVideo={hasLocalVideo} i18n={i18n} - // TODO: Set this to `true` for group calls. We can get away with this for - // now because it only affects rendering. See DESKTOP-888 and DESKTOP-889. - isGroupCall={false} + isGroupCall={call.callMode === CallMode.Group} me={me} onCallCanceled={cancelActiveCall} onJoinCall={joinActiveCall} + participantNames={participantNames} setLocalPreview={setLocalPreview} setLocalAudio={setLocalAudio} setLocalVideo={setLocalVideo} @@ -181,20 +194,26 @@ const ActiveCallManager: React.FC = ({ toggleSettings={toggleSettings} /> {settingsDialogOpen && renderDeviceSelection()} + {showParticipantsList && call.callMode === CallMode.Group ? ( + + ) : null} ); } - // TODO: Group calls should also support the PiP. See DESKTOP-886. - if (pip && call.callMode === CallMode.Direct) { - const hasRemoteVideo = Boolean(call.hasRemoteVideo); - + if (pip) { return ( = ({ setRendererCanvas={setRendererCanvas} setLocalAudio={setLocalAudio} setLocalVideo={setLocalVideo} + stickyControls={showParticipantsList} + toggleParticipants={toggleParticipants} togglePip={togglePip} toggleSettings={toggleSettings} /> {settingsDialogOpen && renderDeviceSelection()} + {showParticipantsList && call.callMode === CallMode.Group ? ( + + ) : null} ); }; diff --git a/ts/components/CallScreen.stories.tsx b/ts/components/CallScreen.stories.tsx index afab65b0e..b19263e31 100644 --- a/ts/components/CallScreen.stories.tsx +++ b/ts/components/CallScreen.stories.tsx @@ -8,40 +8,68 @@ import { boolean, select } from '@storybook/addon-knobs'; import { action } from '@storybook/addon-actions'; import { CallMode, CallState } from '../types/Calling'; -import { ColorType } from '../types/Colors'; +import { Colors } from '../types/Colors'; +import { + DirectCallStateType, + GroupCallStateType, + GroupCallParticipantInfoType, +} from '../state/ducks/calling'; import { CallScreen, PropsType } from './CallScreen'; import { setup as setupI18n } from '../../js/modules/i18n'; import enMessages from '../../_locales/en/messages.json'; const i18n = setupI18n('en', enMessages); -const createProps = ( +function getGroupCallState( + remoteParticipants: Array +): GroupCallStateType { + return { + callMode: CallMode.Group, + conversationId: '3051234567', + connectionState: 2, + joinState: 2, + remoteParticipants, + }; +} + +function getDirectCallState( overrideProps: { callState?: CallState; - hasLocalAudio?: boolean; - hasLocalVideo?: boolean; hasRemoteVideo?: boolean; } = {} -): PropsType => ({ - call: { - callMode: CallMode.Direct as CallMode.Direct, +): DirectCallStateType { + return { + callMode: CallMode.Direct, conversationId: '3051234567', callState: select( 'callState', CallState, overrideProps.callState || CallState.Accepted ), - isIncoming: false, - isVideoCall: true, hasRemoteVideo: boolean( 'hasRemoteVideo', - overrideProps.hasRemoteVideo || false + Boolean(overrideProps.hasRemoteVideo) ), - }, + isIncoming: false, + isVideoCall: true, + }; +} + +const createProps = ( + overrideProps: { + callState?: CallState; + callTypeState?: DirectCallStateType | GroupCallStateType; + hasLocalAudio?: boolean; + hasLocalVideo?: boolean; + hasRemoteVideo?: boolean; + remoteParticipants?: Array; + } = {} +): PropsType => ({ + call: overrideProps.callTypeState || getDirectCallState(overrideProps), conversation: { id: '3051234567', avatarPath: undefined, - color: 'ultramarine' as ColorType, + color: Colors[0], title: 'Rick Sanchez', name: 'Rick Sanchez', phoneNumber: '3051234567', @@ -67,7 +95,7 @@ const createProps = ( i18n, joinedAt: Date.now(), me: { - color: 'ultramarine' as ColorType, + color: Colors[1], name: 'Morty Smith', profileName: 'Morty Smith', title: 'Morty Smith', @@ -76,6 +104,8 @@ const createProps = ( setLocalPreview: action('set-local-preview'), setLocalVideo: action('set-local-video'), setRendererCanvas: action('set-renderer-canvas'), + stickyControls: boolean('stickyControls', false), + toggleParticipants: action('toggle-participants'), togglePip: action('toggle-pip'), toggleSettings: action('toggle-settings'), }); @@ -87,19 +117,43 @@ story.add('Default', () => { }); story.add('Pre-Ring', () => { - return ; + return ( + + ); }); story.add('Ringing', () => { - return ; + return ( + + ); }); story.add('Reconnecting', () => { - return ; + return ( + + ); }); story.add('Ended', () => { - return ; + return ( + + ); }); story.add('hasLocalAudio', () => { @@ -113,3 +167,53 @@ story.add('hasLocalVideo', () => { story.add('hasRemoteVideo', () => { return ; }); + +story.add('Group call - 1', () => ( + +)); + +story.add('Group call - Many', () => ( + +)); diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index 5ebaba8e1..7b738f728 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -15,6 +15,7 @@ import { SetRendererCanvasType, } from '../state/ducks/calling'; import { Avatar } from './Avatar'; +import { CallingHeader } from './CallingHeader'; import { CallingButton, CallingButtonType } from './CallingButton'; import { CallBackgroundBlur } from './CallBackgroundBlur'; import { @@ -52,6 +53,8 @@ export type PropsType = { setLocalVideo: (_: SetLocalVideoType) => void; setLocalPreview: (_: SetLocalPreviewType) => void; setRendererCanvas: (_: SetRendererCanvasType) => void; + stickyControls: boolean; + toggleParticipants: () => void; togglePip: () => void; toggleSettings: () => void; }; @@ -71,6 +74,8 @@ export const CallScreen: React.FC = ({ setLocalVideo, setLocalPreview, setRendererCanvas, + stickyControls, + toggleParticipants, togglePip, toggleSettings, }) => { @@ -110,14 +115,14 @@ export const CallScreen: React.FC = ({ }, [joinedAt]); useEffect(() => { - if (!showControls) { + if (!showControls || stickyControls) { return noop; } const timer = setTimeout(() => { setShowControls(false); }, 5000); return clearInterval.bind(null, timer); - }, [showControls]); + }, [showControls, stickyControls]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent): void => { @@ -146,13 +151,13 @@ export const CallScreen: React.FC = ({ let hasRemoteVideo: boolean; let isConnected: boolean; - let remoteParticipants: JSX.Element; + let remoteParticipantsElement: JSX.Element; switch (call.callMode) { case CallMode.Direct: hasRemoteVideo = Boolean(call.hasRemoteVideo); isConnected = call.callState === CallState.Accepted; - remoteParticipants = ( + remoteParticipantsElement = ( = ({ remoteParticipant => remoteParticipant.hasRemoteVideo ); isConnected = call.connectionState === GroupCallConnectionState.Connected; - remoteParticipants = ( + remoteParticipantsElement = ( = ({ !showControls && !isAudioOnly && isConnected, }); + const remoteParticipants = + call.callMode === CallMode.Group ? call.remoteParticipants.length : 0; + return (
= ({ role="group" >
-
- {conversation.title} -
- {call.callMode === CallMode.Direct && - renderHeaderMessage( - i18n, - call.callState || CallState.Prering, - acceptedDuration - )} -
-
+ + {call.callMode === CallMode.Group && + !call.remoteParticipants.length + ? i18n('calling__in-this-call--zero') + : conversation.title} + {call.callMode === CallMode.Direct && + renderHeaderMessage( + i18n, + call.callState || CallState.Prering, + acceptedDuration + )} + + } + i18n={i18n} + isGroupCall={call.callMode === CallMode.Group} + remoteParticipants={remoteParticipants} + toggleParticipants={toggleParticipants} + togglePip={togglePip} + toggleSettings={toggleSettings} + />
- {remoteParticipants} + {remoteParticipantsElement}
{/* This layout-only element is not ideal. See the comment in _modules.css for more. */} diff --git a/ts/components/CallingHeader.stories.tsx b/ts/components/CallingHeader.stories.tsx new file mode 100644 index 000000000..6da1aa1fa --- /dev/null +++ b/ts/components/CallingHeader.stories.tsx @@ -0,0 +1,54 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { boolean, number } from '@storybook/addon-knobs'; +import { action } from '@storybook/addon-actions'; + +import { CallingHeader, PropsType } from './CallingHeader'; +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +const i18n = setupI18n('en', enMessages); + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + canPip: boolean('canPip', Boolean(overrideProps.canPip)), + conversationTitle: overrideProps.conversationTitle || 'With Someone', + i18n, + isGroupCall: boolean('isGroupCall', Boolean(overrideProps.isGroupCall)), + remoteParticipants: number( + 'remoteParticipants', + overrideProps.remoteParticipants || 0 + ), + toggleParticipants: () => action('toggle-participants'), + togglePip: () => action('toggle-pip'), + toggleSettings: () => action('toggle-settings'), +}); + +const story = storiesOf('Components/CallingHeader', module); + +story.add('Default', () => ); + +story.add('Has Pip', () => ( + +)); + +story.add('With Participants', () => ( + +)); + +story.add('Long Title', () => ( + +)); diff --git a/ts/components/CallingHeader.tsx b/ts/components/CallingHeader.tsx new file mode 100644 index 000000000..be3910194 --- /dev/null +++ b/ts/components/CallingHeader.tsx @@ -0,0 +1,89 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import Tooltip from 'react-tooltip-lite'; +import { LocalizerType } from '../types/Util'; + +export type PropsType = { + canPip?: boolean; + conversationTitle: JSX.Element | string; + i18n: LocalizerType; + isGroupCall?: boolean; + remoteParticipants?: number; + toggleParticipants?: () => void; + togglePip?: () => void; + toggleSettings: () => void; +}; + +export const CallingHeader = ({ + canPip = false, + conversationTitle, + i18n, + isGroupCall = false, + remoteParticipants, + toggleParticipants, + togglePip, + toggleSettings, +}: PropsType): JSX.Element => ( +
+
+ {conversationTitle} +
+
+ {isGroupCall ? ( +
+ +
+ ) : null} +
+ +
+ {canPip && ( +
+ +
+ )} +
+
+); diff --git a/ts/components/CallingLobby.stories.tsx b/ts/components/CallingLobby.stories.tsx index 9a6a62728..f01929bc0 100644 --- a/ts/components/CallingLobby.stories.tsx +++ b/ts/components/CallingLobby.stories.tsx @@ -35,6 +35,7 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ me: overrideProps.me || { color: 'ultramarine' as ColorType }, onCallCanceled: action('on-call-canceled'), onJoinCall: action('on-join-call'), + participantNames: overrideProps.participantNames || [], setLocalAudio: action('set-local-audio'), setLocalPreview: action('set-local-preview'), setLocalVideo: action('set-local-video'), @@ -81,7 +82,36 @@ story.add('Local Video', () => { return ; }); -story.add('Group Call', () => { - const props = createProps({ isGroupCall: true }); +story.add('Group Call - 0', () => { + const props = createProps({ isGroupCall: true, participantNames: [] }); + return ; +}); + +story.add('Group Call - 1', () => { + const props = createProps({ isGroupCall: true, participantNames: ['Sam'] }); + return ; +}); + +story.add('Group Call - 2', () => { + const props = createProps({ + isGroupCall: true, + participantNames: ['Sam', 'Cayce'], + }); + return ; +}); + +story.add('Group Call - 3', () => { + const props = createProps({ + isGroupCall: true, + participantNames: ['Sam', 'Cayce', 'April'], + }); + return ; +}); + +story.add('Group Call - 4', () => { + const props = createProps({ + isGroupCall: true, + participantNames: ['Sam', 'Cayce', 'April', 'Logan', 'Carl'], + }); return ; }); diff --git a/ts/components/CallingLobby.tsx b/ts/components/CallingLobby.tsx index ac793b375..a2dff5327 100644 --- a/ts/components/CallingLobby.tsx +++ b/ts/components/CallingLobby.tsx @@ -13,8 +13,10 @@ import { TooltipDirection, } from './CallingButton'; import { CallBackgroundBlur } from './CallBackgroundBlur'; -import { LocalizerType } from '../types/Util'; +import { CallingHeader } from './CallingHeader'; +import { Spinner } from './Spinner'; import { ColorType } from '../types/Colors'; +import { LocalizerType } from '../types/Util'; export type PropsType = { availableCameras: Array; @@ -31,6 +33,7 @@ export type PropsType = { }; onCallCanceled: () => void; onJoinCall: () => void; + participantNames: Array; setLocalAudio: (_: SetLocalAudioType) => void; setLocalVideo: (_: SetLocalVideoType) => void; setLocalPreview: (_: SetLocalPreviewType) => void; @@ -48,6 +51,7 @@ export const CallingLobby = ({ me, onCallCanceled, onJoinCall, + participantNames, setLocalAudio, setLocalPreview, setLocalVideo, @@ -97,6 +101,8 @@ export const CallingLobby = ({ }; }, [toggleVideo, toggleAudio]); + const [isCallConnecting, setIsCallConnecting] = React.useState(false); + // eslint-disable-next-line no-nested-ternary const videoButtonType = hasLocalVideo ? CallingButtonType.VIDEO_ON @@ -109,27 +115,15 @@ export const CallingLobby = ({ return (
-
-
- {conversation.title} -
-
- {isGroupCall ? ( -
-
+ +
{hasLocalVideo && availableCameras.length > 0 ? (
+ {isGroupCall ? ( +
+ {participantNames.length === 0 && + i18n('calling__lobby-summary--zero')} + {participantNames.length === 1 && + i18n('calling__lobby-summary--single', participantNames)} + {participantNames.length === 2 && + i18n('calling__lobby-summary--double', { + first: participantNames[0], + second: participantNames[1], + })} + {participantNames.length === 3 && + i18n('calling__lobby-summary--triple', { + first: participantNames[0], + second: participantNames[1], + third: participantNames[2], + })} + {participantNames.length > 3 && + i18n('calling__lobby-summary--many', { + first: participantNames[0], + second: participantNames[1], + others: String(participantNames.length - 2), + })} +
+ ) : null} +
- + {isCallConnecting && ( + + )} + {!isCallConnecting && ( + + )}
); diff --git a/ts/components/CallingParticipantsList.stories.tsx b/ts/components/CallingParticipantsList.stories.tsx index 4a09e4c4a..0d57f319d 100644 --- a/ts/components/CallingParticipantsList.stories.tsx +++ b/ts/components/CallingParticipantsList.stories.tsx @@ -6,61 +6,76 @@ import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { CallingParticipantsList, PropsType } from './CallingParticipantsList'; +import { Colors } from '../types/Colors'; +import { GroupCallRemoteParticipantType } from '../types/Calling'; import { setup as setupI18n } from '../../js/modules/i18n'; import enMessages from '../../_locales/en/messages.json'; const i18n = setupI18n('en', enMessages); -const participant = { - title: 'Bardock', -}; +function createParticipant( + participantProps: Partial +): GroupCallRemoteParticipantType { + const randomColor = Math.floor(Math.random() * Colors.length - 1); + return { + avatarPath: participantProps.avatarPath, + color: Colors[randomColor], + hasRemoteAudio: Boolean(participantProps.hasRemoteAudio), + hasRemoteVideo: Boolean(participantProps.hasRemoteVideo), + isSelf: Boolean(participantProps.isSelf), + profileName: participantProps.title, + title: String(participantProps.title), + }; +} const createProps = (overrideProps: Partial = {}): PropsType => ({ i18n, onClose: action('on-close'), - participants: overrideProps.participants || [participant], + participants: overrideProps.participants || [], }); const story = storiesOf('Components/CallingParticipantsList', module); -story.add('Default', () => { +story.add('No one', () => { const props = createProps(); return ; }); +story.add('Solo Call', () => { + const props = createProps({ + participants: [ + createParticipant({ + title: 'Bardock', + }), + ], + }); + return ; +}); + story.add('Many Participants', () => { const props = createProps({ participants: [ - { - color: 'blue', - profileName: 'Son Goku', + createParticipant({ + isSelf: true, title: 'Son Goku', - audioMuted: true, - videoMuted: true, - }, - { - color: 'deep_orange', - profileName: 'Rage Trunks', + }), + createParticipant({ + hasRemoteAudio: true, + hasRemoteVideo: true, title: 'Rage Trunks', - }, - { - color: 'indigo', - profileName: 'Prince Vegeta', + }), + createParticipant({ + hasRemoteAudio: true, title: 'Prince Vegeta', - videoMuted: true, - }, - { - color: 'pink', - profileName: 'Goku Black', + }), + createParticipant({ + hasRemoteAudio: true, + hasRemoteVideo: true, title: 'Goku Black', - }, - { - color: 'green', - profileName: 'Supreme Kai Zamasu', + }), + createParticipant({ title: 'Supreme Kai Zamasu', - audioMuted: true, - videoMuted: true, - }, + }), ], }); return ; diff --git a/ts/components/CallingParticipantsList.tsx b/ts/components/CallingParticipantsList.tsx index 01d92a88b..42c4ab4dd 100644 --- a/ts/components/CallingParticipantsList.tsx +++ b/ts/components/CallingParticipantsList.tsx @@ -6,23 +6,14 @@ import React from 'react'; import { createPortal } from 'react-dom'; import { Avatar } from './Avatar'; -import { ColorType } from '../types/Colors'; import { ContactName } from './conversation/ContactName'; import { LocalizerType } from '../types/Util'; - -type ParticipantType = { - audioMuted?: boolean; - avatarPath?: string; - color?: ColorType; - profileName?: string; - title: string; - videoMuted?: boolean; -}; +import { GroupCallRemoteParticipantType } from '../types/Calling'; export type PropsType = { readonly i18n: LocalizerType; readonly onClose: () => void; - readonly participants: Array; + readonly participants: Array; }; export const CallingParticipantsList = React.memo( @@ -52,11 +43,12 @@ export const CallingParticipantsList = React.memo(
- {participants.length > 1 - ? i18n('calling__in-this-call--many', [ - String(participants.length), - ]) - : i18n('calling__in-this-call--one')} + {!participants.length && i18n('calling__in-this-call--zero')} + {participants.length === 1 && i18n('calling__in-this-call--one')} + {participants.length > 1 && + i18n('calling__in-this-call--many', [ + String(participants.length), + ])}
    - {participants.map((participant: ParticipantType, index: number) => ( -
  • -
    - - -
    -
    - {participant.audioMuted ? ( - - ) : null} - {participant.videoMuted ? ( - - ) : null} -
    -
  • - ))} + {participants.map( + (participant: GroupCallRemoteParticipantType, index: number) => ( +
  • +
    + + {participant.isSelf ? ( + + {i18n('you')} + + ) : ( + + )} +
    +
    + {!participant.hasRemoteAudio ? ( + + ) : null} + {!participant.hasRemoteVideo ? ( + + ) : null} +
    +
  • + ) + )}
, diff --git a/ts/components/CallingPip.stories.tsx b/ts/components/CallingPip.stories.tsx index c6a018816..fda46332e 100644 --- a/ts/components/CallingPip.stories.tsx +++ b/ts/components/CallingPip.stories.tsx @@ -2,12 +2,20 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; +import { noop } from 'lodash'; import { storiesOf } from '@storybook/react'; import { boolean } from '@storybook/addon-knobs'; import { action } from '@storybook/addon-actions'; import { ColorType } from '../types/Colors'; +import { ConversationTypeType } from '../state/ducks/conversations'; import { CallingPip, PropsType } from './CallingPip'; +import { + CallMode, + CallState, + GroupCallConnectionState, + GroupCallJoinState, +} from '../types/Calling'; import { setup as setupI18n } from '../../js/modules/i18n'; import enMessages from '../../_locales/en/messages.json'; @@ -21,16 +29,29 @@ const conversation = { name: 'Rick Sanchez', phoneNumber: '3051234567', profileName: 'Rick Sanchez', + markedUnread: false, + type: 'direct' as ConversationTypeType, + lastUpdated: Date.now(), +}; + +const defaultCall = { + callMode: CallMode.Direct as CallMode.Direct, + conversationId: '3051234567', + callState: CallState.Accepted, + isIncoming: false, + isVideoCall: true, + hasRemoteVideo: true, }; const createProps = (overrideProps: Partial = {}): PropsType => ({ + call: overrideProps.call || defaultCall, conversation: overrideProps.conversation || conversation, + /* eslint-disable @typescript-eslint/no-explicit-any */ + createCanvasVideoRenderer: noop as any, + /* eslint-disable @typescript-eslint/no-explicit-any */ + getGroupCallVideoFrameSource: noop as any, hangUp: action('hang-up'), hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false), - hasRemoteVideo: boolean( - 'hasRemoteVideo', - overrideProps.hasRemoteVideo || false - ), i18n, setLocalPreview: action('set-local-preview'), setRendererCanvas: action('set-renderer-canvas'), @@ -63,3 +84,16 @@ story.add('Contact (no color)', () => { }); return ; }); + +story.add('Group Call', () => { + const props = createProps({ + call: { + callMode: CallMode.Group as CallMode.Group, + conversationId: '3051234567', + connectionState: GroupCallConnectionState.Connected, + joinState: GroupCallJoinState.Joined, + remoteParticipants: [], + }, + }); + return ; +}); diff --git a/ts/components/CallingPip.tsx b/ts/components/CallingPip.tsx index 344111135..a3c8cc1c1 100644 --- a/ts/components/CallingPip.tsx +++ b/ts/components/CallingPip.tsx @@ -2,69 +2,26 @@ // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; +import Tooltip from 'react-tooltip-lite'; +import { CallingPipRemoteVideo } from './CallingPipRemoteVideo'; +import { LocalizerType } from '../types/Util'; +import { ConversationType } from '../state/ducks/conversations'; +import { CanvasVideoRenderer, VideoFrameSource } from '../types/Calling'; import { + DirectCallStateType, + GroupCallStateType, HangUpType, SetLocalPreviewType, SetRendererCanvasType, } from '../state/ducks/calling'; -import { Avatar } from './Avatar'; -import { CallBackgroundBlur } from './CallBackgroundBlur'; -import { ColorType } from '../types/Colors'; -import { LocalizerType } from '../types/Util'; - -function renderAvatar( - { - avatarPath, - color, - name, - phoneNumber, - profileName, - title, - }: { - avatarPath?: string; - color?: ColorType; - title: string; - name?: string; - phoneNumber?: string; - profileName?: string; - }, - i18n: LocalizerType -): JSX.Element { - return ( -
- -
- -
-
-
- ); -} export type PropsType = { - conversation: { - id: string; - avatarPath?: string; - color?: ColorType; - title: string; - name?: string; - phoneNumber?: string; - profileName?: string; - }; + call: DirectCallStateType | GroupCallStateType; + conversation: ConversationType; + createCanvasVideoRenderer: () => CanvasVideoRenderer; + getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; hangUp: (_: HangUpType) => void; hasLocalVideo: boolean; - hasRemoteVideo: boolean; i18n: LocalizerType; setLocalPreview: (_: SetLocalPreviewType) => void; setRendererCanvas: (_: SetRendererCanvasType) => void; @@ -77,10 +34,12 @@ const PIP_DEFAULT_Y = 56; const PIP_PADDING = 8; export const CallingPip = ({ + call, conversation, + createCanvasVideoRenderer, + getGroupCallVideoFrameSource, hangUp, hasLocalVideo, - hasRemoteVideo, i18n, setLocalPreview, setRendererCanvas, @@ -88,7 +47,6 @@ export const CallingPip = ({ }: PropsType): JSX.Element | null => { const videoContainerRef = React.useRef(null); const localVideoRef = React.useRef(null); - const remoteVideoRef = React.useRef(null); const [dragState, setDragState] = React.useState({ offsetX: 0, @@ -103,8 +61,7 @@ export const CallingPip = ({ React.useEffect(() => { setLocalPreview({ element: localVideoRef }); - setRendererCanvas({ element: remoteVideoRef }); - }, [setLocalPreview, setRendererCanvas]); + }, [setLocalPreview]); const handleMouseMove = React.useCallback( (ev: MouseEvent) => { @@ -211,14 +168,14 @@ export const CallingPip = ({ transition: dragState.isDragging ? 'none' : 'transform ease-out 300ms', }} > - {hasRemoteVideo ? ( - - ) : ( - renderAvatar(conversation, i18n) - )} + {hasLocalVideo ? (
); diff --git a/ts/components/CallingPipRemoteVideo.tsx b/ts/components/CallingPipRemoteVideo.tsx new file mode 100644 index 000000000..63b14ce19 --- /dev/null +++ b/ts/components/CallingPipRemoteVideo.tsx @@ -0,0 +1,105 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { Avatar } from './Avatar'; +import { CallBackgroundBlur } from './CallBackgroundBlur'; +import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant'; +import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant'; +import { LocalizerType } from '../types/Util'; +import { ConversationType } from '../state/ducks/conversations'; +import { + CallMode, + CanvasVideoRenderer, + VideoFrameSource, +} from '../types/Calling'; +import { + DirectCallStateType, + GroupCallStateType, + SetRendererCanvasType, +} from '../state/ducks/calling'; + +export interface PropsType { + call: DirectCallStateType | GroupCallStateType; + conversation: ConversationType; + createCanvasVideoRenderer: () => CanvasVideoRenderer; + getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; + i18n: LocalizerType; + setRendererCanvas: (_: SetRendererCanvasType) => void; +} + +export const CallingPipRemoteVideo = ({ + call, + conversation, + createCanvasVideoRenderer, + getGroupCallVideoFrameSource, + i18n, + setRendererCanvas, +}: PropsType): JSX.Element => { + if (call.callMode === CallMode.Direct) { + if (!call.hasRemoteVideo) { + const { + avatarPath, + color, + name, + phoneNumber, + profileName, + title, + } = conversation; + + return ( +
+ +
+ +
+
+
+ ); + } + + return ( +
+ +
+ ); + } + + if (call.callMode === CallMode.Group) { + const speaker = call.remoteParticipants[0]; + + return ( +
+ +
+ ); + } + + throw new Error('CallingRemoteVideo: Unknown Call Mode'); +}; diff --git a/ts/components/GroupCallRemoteParticipant.tsx b/ts/components/GroupCallRemoteParticipant.tsx index 341717214..98f0a6618 100644 --- a/ts/components/GroupCallRemoteParticipant.tsx +++ b/ts/components/GroupCallRemoteParticipant.tsx @@ -13,10 +13,10 @@ interface PropsType { getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; hasRemoteAudio: boolean; hasRemoteVideo: boolean; - height: number; + height: number | string; left: number; top: number; - width: number; + width: number | string; } export const GroupCallRemoteParticipant: React.FC = ({ diff --git a/ts/components/GroupCallRemoteParticipants.tsx b/ts/components/GroupCallRemoteParticipants.tsx index 13b910296..c9fba4a1d 100644 --- a/ts/components/GroupCallRemoteParticipants.tsx +++ b/ts/components/GroupCallRemoteParticipants.tsx @@ -5,7 +5,7 @@ import React, { useState, useMemo } from 'react'; import Measure from 'react-measure'; import { takeWhile, chunk, maxBy, flatten } from 'lodash'; import { CanvasVideoRenderer, VideoFrameSource } from '../types/Calling'; -import { GroupCallRemoteParticipantType } from '../state/ducks/calling'; +import { GroupCallParticipantInfoType } from '../state/ducks/calling'; import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant'; const MIN_RENDERED_HEIGHT = 10; @@ -17,14 +17,14 @@ interface Dimensions { } interface GridArrangement { - rows: Array>; + rows: Array>; scalar: number; } interface PropsType { createCanvasVideoRenderer: () => CanvasVideoRenderer; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; - remoteParticipants: ReadonlyArray; + remoteParticipants: ReadonlyArray; } // This component lays out group call remote participants. It uses a custom layout @@ -84,7 +84,7 @@ export const GroupCallRemoteParticipants: React.FC = ({ // // This is primarily memoized for clarity, not performance. We only need the result, // not any of the "intermediate" values. - const visibleParticipants: Array = useMemo(() => { + const visibleParticipants: Array = useMemo(() => { // Imagine that we laid out all of the rows end-to-end. That's the maximum total // width. So if there were 5 rows and the container was 100px wide, then we can't // possibly fit more than 500px of participants. @@ -233,7 +233,7 @@ export const GroupCallRemoteParticipants: React.FC = ({ }; function totalRemoteParticipantWidthAtMinHeight( - remoteParticipants: ReadonlyArray + remoteParticipants: ReadonlyArray ): number { return remoteParticipants.reduce( (result, { videoAspectRatio }) => diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 08e4f9818..0cc02cbab 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -3959,7 +3959,7 @@ export class ConversationModel extends window.Backbone.Model< return this.get('name') || window.i18n('unknownGroup'); } - getProfileName(): string | null { + getProfileName(): string | undefined { if (this.isPrivate()) { return Util.combineNames( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -3967,7 +3967,8 @@ export class ConversationModel extends window.Backbone.Model< this.get('profileFamilyName') ); } - return null; + + return undefined; } getNumber(): string { diff --git a/ts/services/calling.ts b/ts/services/calling.ts index 2e002778d..de51a2dd1 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -489,6 +489,8 @@ export class CallingClass { ? GroupCallJoinState.NotJoined : this.convertRingRtcJoinState(localDeviceState.joinState); + const ourId = window.ConversationController.getOurConversationId(); + return { connectionState: this.convertRingRtcConnectionState( localDeviceState.connectionState @@ -496,16 +498,28 @@ export class CallingClass { joinState, hasLocalAudio: !localDeviceState.audioMuted, hasLocalVideo: !localDeviceState.videoMuted, - remoteParticipants: remoteDeviceStates.map(remoteDeviceState => ({ - demuxId: remoteDeviceState.demuxId, - userId: arrayBufferToUuid(remoteDeviceState.userId) || '', - hasRemoteAudio: !remoteDeviceState.audioMuted, - hasRemoteVideo: !remoteDeviceState.videoMuted, - // If RingRTC doesn't send us an aspect ratio, we make a guess. - videoAspectRatio: - remoteDeviceState.videoAspectRatio || - (remoteDeviceState.videoMuted ? 1 : 4 / 3), - })), + remoteParticipants: remoteDeviceStates.map(remoteDeviceState => { + const uuid = arrayBufferToUuid(remoteDeviceState.userId); + + const id = window.ConversationController.ensureContactIds({ uuid }); + if (!id) { + throw new Error( + 'Calling.formatGroupCallForRedux: no conversation found' + ); + } + + return { + conversationId: id, + demuxId: remoteDeviceState.demuxId, + hasRemoteAudio: !remoteDeviceState.audioMuted, + hasRemoteVideo: !remoteDeviceState.videoMuted, + isSelf: id === ourId, + // If RingRTC doesn't send us an aspect ratio, we make a guess. + videoAspectRatio: + remoteDeviceState.videoAspectRatio || + (remoteDeviceState.videoMuted ? 1 : 4 / 3), + }; + }), }; } diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index 81f1cdf51..9cfe8acf6 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -10,9 +10,9 @@ import { notify } from '../../services/notify'; import { calling } from '../../services/calling'; import { StateType as RootStateType } from '../reducer'; import { - CallingDeviceType, CallMode, CallState, + CallingDeviceType, ChangeIODevicePayloadType, GroupCallConnectionState, GroupCallJoinState, @@ -27,6 +27,15 @@ import { // State +export interface GroupCallParticipantInfoType { + conversationId: string; + demuxId: number; + hasRemoteAudio: boolean; + hasRemoteVideo: boolean; + isSelf: boolean; + videoAspectRatio: number; +} + export interface DirectCallStateType { callMode: CallMode.Direct; conversationId: string; @@ -37,20 +46,12 @@ export interface DirectCallStateType { hasRemoteVideo?: boolean; } -export interface GroupCallRemoteParticipantType { - demuxId: number; - userId: string; - hasRemoteAudio: boolean; - hasRemoteVideo: boolean; - videoAspectRatio: number; -} - export interface GroupCallStateType { callMode: CallMode.Group; conversationId: string; connectionState: GroupCallConnectionState; joinState: GroupCallJoinState; - remoteParticipants: Array; + remoteParticipants: Array; } export interface ActiveCallStateType { @@ -58,7 +59,7 @@ export interface ActiveCallStateType { joinedAt?: number; hasLocalAudio: boolean; hasLocalVideo: boolean; - participantsList: boolean; + showParticipantsList: boolean; pip: boolean; settingsDialogOpen: boolean; } @@ -99,7 +100,7 @@ export type GroupCallStateChangeType = { joinState: GroupCallJoinState; hasLocalAudio: boolean; hasLocalVideo: boolean; - remoteParticipants: Array; + remoteParticipants: Array; }; export type HangUpType = { @@ -148,7 +149,7 @@ export type ShowCallLobbyType = joinState: GroupCallJoinState; hasLocalAudio: boolean; hasLocalVideo: boolean; - remoteParticipants: Array; + remoteParticipants: Array; }; export type SetLocalPreviewType = { @@ -706,7 +707,7 @@ export function reducer( conversationId: action.payload.conversationId, hasLocalAudio: action.payload.hasLocalAudio, hasLocalVideo: action.payload.hasLocalVideo, - participantsList: false, + showParticipantsList: false, pip: false, settingsDialogOpen: false, }, @@ -730,7 +731,7 @@ export function reducer( conversationId: action.payload.conversationId, hasLocalAudio: action.payload.hasLocalAudio, hasLocalVideo: action.payload.hasLocalVideo, - participantsList: false, + showParticipantsList: false, pip: false, settingsDialogOpen: false, }, @@ -749,7 +750,7 @@ export function reducer( conversationId: action.payload.conversationId, hasLocalAudio: true, hasLocalVideo: action.payload.asVideoCall, - participantsList: false, + showParticipantsList: false, pip: false, settingsDialogOpen: false, }, @@ -813,7 +814,7 @@ export function reducer( conversationId: action.payload.conversationId, hasLocalAudio: action.payload.hasLocalAudio, hasLocalVideo: action.payload.hasLocalVideo, - participantsList: false, + showParticipantsList: false, pip: false, settingsDialogOpen: false, }, @@ -1028,7 +1029,7 @@ export function reducer( ...state, activeCallState: { ...activeCallState, - participantsList: !activeCallState.participantsList, + showParticipantsList: !activeCallState.showParticipantsList, }, }; } diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index 318898bc7..867b86413 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -8,8 +8,9 @@ import { mapDispatchToProps } from '../actions'; import { CallManager } from '../../components/CallManager'; import { calling as callingService } from '../../services/calling'; import { getMe, getConversationSelector } from '../selectors/conversations'; -import { getActiveCall } from '../ducks/calling'; +import { getActiveCall, GroupCallParticipantInfoType } from '../ducks/calling'; import { getIncomingCall } from '../selectors/calling'; +import { CallMode, GroupCallRemoteParticipantType } from '../../types/Calling'; import { StateType } from '../reducer'; import { getIntl } from '../selectors/user'; @@ -42,18 +43,47 @@ const mapStateToActiveCallProp = (state: StateType) => { return undefined; } - const conversation = getConversationSelector(state)( - activeCallState.conversationId - ); + const conversationSelector = getConversationSelector(state); + const conversation = conversationSelector(activeCallState.conversationId); if (!conversation) { window.log.error('The active call has no corresponding conversation'); return undefined; } + const groupCallParticipants: Array = []; + if (call && call.callMode === CallMode.Group) { + call.remoteParticipants.forEach( + (remoteParticipant: GroupCallParticipantInfoType) => { + const remoteConversation = conversationSelector( + remoteParticipant.conversationId + ); + + if (!remoteConversation) { + window.log.error( + 'Remote participant has no corresponding conversation' + ); + return; + } + + groupCallParticipants.push({ + avatarPath: remoteConversation.avatarPath, + color: remoteConversation.color, + firstName: remoteConversation.firstName, + hasRemoteAudio: remoteParticipant.hasRemoteAudio, + hasRemoteVideo: remoteParticipant.hasRemoteVideo, + isSelf: remoteParticipant.isSelf, + profileName: remoteConversation.profileName, + title: remoteConversation.title, + }); + } + ); + } + return { - call, activeCallState, + call, conversation, + groupCallParticipants, }; }; diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index ca3ff97b8..f47fe6778 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -41,7 +41,7 @@ describe('calling duck', () => { conversationId: 'fake-direct-call-conversation-id', hasLocalAudio: true, hasLocalVideo: false, - participantsList: false, + showParticipantsList: false, pip: false, settingsDialogOpen: false, }, @@ -71,10 +71,11 @@ describe('calling duck', () => { joinState: GroupCallJoinState.NotJoined, remoteParticipants: [ { + conversationId: '123', demuxId: 123, - userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f', hasRemoteAudio: true, hasRemoteVideo: true, + isSelf: false, videoAspectRatio: 4 / 3, }, ], @@ -88,7 +89,7 @@ describe('calling duck', () => { conversationId: 'fake-group-call-conversation-id', hasLocalAudio: true, hasLocalVideo: false, - participantsList: false, + showParticipantsList: false, pip: false, settingsDialogOpen: false, }, @@ -180,7 +181,7 @@ describe('calling duck', () => { conversationId: 'fake-direct-call-conversation-id', hasLocalAudio: true, hasLocalVideo: true, - participantsList: false, + showParticipantsList: false, pip: false, settingsDialogOpen: false, }); @@ -296,10 +297,11 @@ describe('calling duck', () => { hasLocalVideo: false, remoteParticipants: [ { + conversationId: '123', demuxId: 123, - userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f', hasRemoteAudio: true, hasRemoteVideo: true, + isSelf: false, videoAspectRatio: 4 / 3, }, ], @@ -315,10 +317,11 @@ describe('calling duck', () => { joinState: GroupCallJoinState.Joining, remoteParticipants: [ { + conversationId: '123', demuxId: 123, - userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f', hasRemoteAudio: true, hasRemoteVideo: true, + isSelf: false, videoAspectRatio: 4 / 3, }, ], @@ -337,10 +340,11 @@ describe('calling duck', () => { hasLocalVideo: false, remoteParticipants: [ { + conversationId: '123', demuxId: 456, - userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f', hasRemoteAudio: false, hasRemoteVideo: true, + isSelf: false, videoAspectRatio: 16 / 9, }, ], @@ -356,10 +360,11 @@ describe('calling duck', () => { joinState: GroupCallJoinState.Joined, remoteParticipants: [ { + conversationId: '123', demuxId: 456, - userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f', hasRemoteAudio: false, hasRemoteVideo: true, + isSelf: false, videoAspectRatio: 16 / 9, }, ], @@ -378,10 +383,11 @@ describe('calling duck', () => { hasLocalVideo: false, remoteParticipants: [ { + conversationId: '123', demuxId: 456, - userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f', hasRemoteAudio: false, hasRemoteVideo: true, + isSelf: false, videoAspectRatio: 16 / 9, }, ], @@ -402,10 +408,11 @@ describe('calling duck', () => { hasLocalVideo: true, remoteParticipants: [ { + conversationId: '123', demuxId: 456, - userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f', hasRemoteAudio: false, hasRemoteVideo: true, + isSelf: false, videoAspectRatio: 16 / 9, }, ], @@ -416,7 +423,7 @@ describe('calling duck', () => { conversationId: 'fake-group-call-conversation-id', hasLocalAudio: true, hasLocalVideo: false, - participantsList: false, + showParticipantsList: false, pip: false, settingsDialogOpen: false, }); @@ -433,10 +440,11 @@ describe('calling duck', () => { hasLocalVideo: true, remoteParticipants: [ { + conversationId: '123', demuxId: 456, - userId: 'aead696f-4373-4e51-b9c2-1bb4d1adccf0', hasRemoteAudio: false, hasRemoteVideo: true, + isSelf: false, videoAspectRatio: 16 / 9, }, ], @@ -559,7 +567,7 @@ describe('calling duck', () => { conversationId: 'fake-conversation-id', hasLocalAudio: true, hasLocalVideo: true, - participantsList: false, + showParticipantsList: false, pip: false, settingsDialogOpen: false, }); @@ -638,7 +646,7 @@ describe('calling duck', () => { conversationId: 'fake-conversation-id', hasLocalAudio: true, hasLocalVideo: false, - participantsList: false, + showParticipantsList: false, pip: false, settingsDialogOpen: false, }); @@ -688,9 +696,9 @@ describe('calling duck', () => { toggleParticipants() ); - assert.isTrue(afterOneToggle.activeCallState?.participantsList); - assert.isFalse(afterTwoToggles.activeCallState?.participantsList); - assert.isTrue(afterThreeToggles.activeCallState?.participantsList); + assert.isTrue(afterOneToggle.activeCallState?.showParticipantsList); + assert.isFalse(afterTwoToggles.activeCallState?.showParticipantsList); + assert.isTrue(afterThreeToggles.activeCallState?.showParticipantsList); }); }); diff --git a/ts/test-electron/state/selectors/calling_test.ts b/ts/test-electron/state/selectors/calling_test.ts index 89117a275..55394d099 100644 --- a/ts/test-electron/state/selectors/calling_test.ts +++ b/ts/test-electron/state/selectors/calling_test.ts @@ -27,7 +27,7 @@ describe('state/selectors/calling', () => { conversationId: 'fake-direct-call-conversation-id', hasLocalAudio: true, hasLocalVideo: false, - participantsList: false, + showParticipantsList: false, pip: false, settingsDialogOpen: false, }, diff --git a/ts/test/util/combineNames_test.ts b/ts/test/util/combineNames_test.ts index a90c051f6..3059d7be5 100644 --- a/ts/test/util/combineNames_test.ts +++ b/ts/test/util/combineNames_test.ts @@ -6,8 +6,8 @@ import { assert } from 'chai'; import { combineNames } from '../../util/combineNames'; describe('combineNames', () => { - it('returns null if no names provided', () => { - assert.strictEqual(combineNames('', ''), null); + it('returns undefined if no names provided', () => { + assert.strictEqual(combineNames('', ''), undefined); }); it('returns first name only if family name not provided', () => { diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index cf0be4ce3..aac1fe6da 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -1,10 +1,12 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { ColorType } from './Colors'; + export enum CallMode { - None, - Direct, - Group, + None = 'None', + Direct = 'Direct', + Group = 'Group', } // Ideally, we would import many of these directly from RingRTC. But because Storybook @@ -56,6 +58,17 @@ export enum GroupCallJoinState { Joined = 2, } +export interface GroupCallRemoteParticipantType { + avatarPath?: string; + color?: ColorType; + firstName?: string; + hasRemoteAudio: boolean; + hasRemoteVideo: boolean; + isSelf: boolean; + profileName?: string; + title: string; +} + // Should match RingRTC's CanvasVideoRenderer interface Ref { readonly current: T | null; diff --git a/ts/util/combineNames.ts b/ts/util/combineNames.ts index a7afab15e..c892c3927 100644 --- a/ts/util/combineNames.ts +++ b/ts/util/combineNames.ts @@ -35,9 +35,12 @@ const Hangul_Syllables = /[\uAC00-\uD7AF]/; // From https://github.com/mathiasbynens/unicode-12.1.0/tree/master/Binary_Property/Ideographic const isIdeographic = /[\u3006\u3007\u3021-\u3029\u3038-\u303A\u3400-\u4DB5\u4E00-\u9FEF\uF900-\uFA6D\uFA70-\uFAD9]|[\uD81C-\uD820\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879][\uDC00-\uDFFF]|\uD821[\uDC00-\uDFF7]|\uD822[\uDC00-\uDEF2]|\uD82C[\uDD70-\uDEFB]|\uD869[\uDC00-\uDED6\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF34\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]/; -export function combineNames(given: string, family?: string): null | string { +export function combineNames( + given: string, + family?: string +): undefined | string { if (!given) { - return null; + return undefined; } // Users who haven't upgraded to dual-name, or went minimal, will just have a given name diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index afd08b0bf..52e820236 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -14391,7 +14391,7 @@ "rule": "React-useRef", "path": "ts/components/CallScreen.js", "line": " const localVideoRef = react_1.useRef(null);", - "lineNumber": 38, + "lineNumber": 39, "reasonCategory": "usageTrusted", "updated": "2020-10-26T21:35:52.858Z", "reasonDetail": "Used to get the local video element for rendering." @@ -14400,7 +14400,7 @@ "rule": "React-useRef", "path": "ts/components/CallingLobby.js", "line": " const localVideoRef = react_1.default.useRef(null);", - "lineNumber": 12, + "lineNumber": 14, "reasonCategory": "usageTrusted", "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Used to get the local video element for rendering." @@ -14409,7 +14409,7 @@ "rule": "React-useRef", "path": "ts/components/CallingLobby.tsx", "line": " const localVideoRef = React.useRef(null);", - "lineNumber": 57, + "lineNumber": 61, "reasonCategory": "usageTrusted", "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Used to get the local video element for rendering." @@ -14418,7 +14418,7 @@ "rule": "React-useRef", "path": "ts/components/CallingPip.js", "line": " const videoContainerRef = react_1.default.useRef(null);", - "lineNumber": 22, + "lineNumber": 16, "reasonCategory": "usageTrusted", "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Element is measured. Its HTML is not used." @@ -14427,25 +14427,16 @@ "rule": "React-useRef", "path": "ts/components/CallingPip.js", "line": " const localVideoRef = react_1.default.useRef(null);", - "lineNumber": 23, + "lineNumber": 17, "reasonCategory": "usageTrusted", "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Used to get the local video element for rendering." }, - { - "rule": "React-useRef", - "path": "ts/components/CallingPip.js", - "line": " const remoteVideoRef = react_1.default.useRef(null);", - "lineNumber": 24, - "reasonCategory": "usageTrusted", - "updated": "2020-10-26T19:12:24.410Z", - "reasonDetail": "Used to get the remote video element for rendering." - }, { "rule": "React-useRef", "path": "ts/components/CallingPip.tsx", "line": " const videoContainerRef = React.useRef(null);", - "lineNumber": 89, + "lineNumber": 48, "reasonCategory": "usageTrusted", "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Element is measured. Its HTML is not used." @@ -14454,20 +14445,11 @@ "rule": "React-useRef", "path": "ts/components/CallingPip.tsx", "line": " const localVideoRef = React.useRef(null);", - "lineNumber": 90, + "lineNumber": 49, "reasonCategory": "usageTrusted", "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Used to get the local video element for rendering." }, - { - "rule": "React-useRef", - "path": "ts/components/CallingPip.tsx", - "line": " const remoteVideoRef = React.useRef(null);", - "lineNumber": 91, - "reasonCategory": "usageTrusted", - "updated": "2020-10-26T19:12:24.410Z", - "reasonDetail": "Used to get the remote video element for rendering." - }, { "rule": "React-createRef", "path": "ts/components/CaptionEditor.js",