Group calling enhancements

This commit is contained in:
Josh Perez 2020-11-17 10:07:53 -05:00 committed by Josh Perez
parent 72e4ec95ce
commit 1f0c091e13
27 changed files with 1038 additions and 451 deletions

View File

@ -1183,17 +1183,85 @@
"description": "Button tooltip label when the microphone is disabled" "description": "Button tooltip label when the microphone is disabled"
}, },
"calling__button--audio-off": { "calling__button--audio-off": {
"message": "Turn off microphone", "message": "Mute mic",
"description": "Button tooltip label for turning off the microphone" "description": "Button tooltip label for turning off the microphone"
}, },
"calling__button--audio-on": { "calling__button--audio-on": {
"message": "Turn on microphone", "message": "Unmute mic",
"description": "Button tooltip label for turning on the microphone" "description": "Button tooltip label for turning on the microphone"
}, },
"calling__your-video-is-off": { "calling__your-video-is-off": {
"message": "Your video is off", "message": "Your video is off",
"description": "Label in the calling lobby indicating that your camera 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": { "calling__in-this-call--one": {
"message": "In this call · 1 person", "message": "In this call · 1 person",
"description": "Shown in the participants list to describe how many people are in the call" "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" "description": "Title for device selection settings"
}, },
"calling__participants": { "calling__participants": {
"message": "Participants", "message": "$people$ in call",
"description": "Title for participants list toggle" "description": "Title for participants list toggle",
"placeholders": {
"people": {
"content": "$1",
"example": "16"
}
}
}, },
"calling__pip": { "calling__pip--on": {
"message": "Picture-in-picture", "message": "Minimize call",
"description": "Title for picture-in-picture toggle"
},
"calling__pip--off": {
"message": "Fullscreen call",
"description": "Title for picture-in-picture toggle" "description": "Title for picture-in-picture toggle"
}, },
"calling__hangup": { "calling__hangup": {
"message": "Hang Up", "message": "Leave call",
"description": "Title for hang up button" "description": "Title for hang up button"
}, },
"callingDeviceSelection__label--video": { "callingDeviceSelection__label--video": {

View File

@ -5872,14 +5872,16 @@ button.module-image__border-overlay:focus {
padding-top: 24px; padding-top: 24px;
text-align: center; text-align: center;
text-shadow: 0px 0px 4px rgba(0, 0, 0, 0.25); text-shadow: 0px 0px 4px rgba(0, 0, 0, 0.25);
top: 0;
width: 100%; width: 100%;
&--header-name { &--header-name {
font-weight: 600;
font-size: 15px; font-size: 15px;
line-height: 21px; font-weight: 600;
letter-spacing: -0.009em; 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 { &__header {
background: linear-gradient($color-black-alpha-40, transparent); background: linear-gradient($color-black-alpha-40, transparent);
top: 0;
width: 100%;
} }
&__header-message { &__header-message {
@ -6373,6 +6377,9 @@ button.module-image__border-overlay:focus {
.module-calling-lobby { .module-calling-lobby {
&__actions { &__actions {
align-items: flex-start;
display: flex;
flex-direction: row;
flex: 0 0 100px; flex: 0 0 100px;
} }
@ -6411,6 +6418,12 @@ button.module-image__border-overlay:focus {
z-index: 1; z-index: 1;
} }
} }
&__info {
color: $color-white;
margin-bottom: 36px;
margin-top: 12px;
}
} }
.module-calling-pip { .module-calling-pip {
@ -9728,6 +9741,7 @@ button.module-image__border-overlay:focus {
border-radius: 4px; border-radius: 4px;
border: none; border: none;
color: $color-white; color: $color-white;
line-height: 24px;
outline: none; outline: none;
padding: 7px 14px; padding: 7px 14px;
@ -9744,6 +9758,7 @@ button.module-image__border-overlay:focus {
border-radius: 4px; border-radius: 4px;
border: none; border: none;
color: $color-white; color: $color-white;
line-height: 24px;
outline: none; outline: none;
padding: 7px 14px; padding: 7px 14px;

View File

@ -5,6 +5,7 @@ import * as React from 'react';
import { noop } from 'lodash'; import { noop } from 'lodash';
import { storiesOf } from '@storybook/react'; import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { boolean, select, text } from '@storybook/addon-knobs';
import { CallManager, PropsType } from './CallManager'; import { CallManager, PropsType } from './CallManager';
import { import {
@ -15,26 +16,47 @@ import {
GroupCallJoinState, GroupCallJoinState,
} from '../types/Calling'; } from '../types/Calling';
import { ConversationTypeType } from '../state/ducks/conversations'; 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 { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json'; import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
const conversation = { const getConversation = () => ({
id: '3051234567', id: '3051234567',
avatarPath: undefined, avatarPath: undefined,
color: 'ultramarine' as ColorType, color: select('Callee color', Colors, 'ultramarine' as ColorType),
title: 'Rick Sanchez', title: text('Callee Title', 'Rick Sanchez'),
name: 'Rick Sanchez', name: text('Callee Name', 'Rick Sanchez'),
phoneNumber: '3051234567', phoneNumber: '3051234567',
profileName: 'Rick Sanchez', profileName: 'Rick Sanchez',
markedUnread: false, markedUnread: false,
type: 'direct' as ConversationTypeType, type: 'direct' as ConversationTypeType,
lastUpdated: Date.now(), 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> = {}): PropsType => ({
...storyProps,
availableCameras: [], availableCameras: [],
acceptCall: action('accept-call'), acceptCall: action('accept-call'),
cancelCall: action('cancel-call'), cancelCall: action('cancel-call'),
@ -54,8 +76,8 @@ const defaultProps = {
hangUp: action('hang-up'), hangUp: action('hang-up'),
i18n, i18n,
me: { me: {
color: 'ultramarine' as ColorType, color: select('Caller color', Colors, 'ultramarine' as ColorType),
title: 'Morty Smith', title: text('Caller Title', 'Morty Smith'),
}, },
renderDeviceSelection: () => <div />, renderDeviceSelection: () => <div />,
setLocalAudio: action('set-local-audio'), setLocalAudio: action('set-local-audio'),
@ -66,16 +88,15 @@ const defaultProps = {
toggleParticipants: action('toggle-participants'), toggleParticipants: action('toggle-participants'),
togglePip: action('toggle-pip'), togglePip: action('toggle-pip'),
toggleSettings: action('toggle-settings'), toggleSettings: action('toggle-settings'),
}; });
const permutations = [ const story = storiesOf('Components/CallManager', module);
{
title: 'Call Manager (no call)', story.add('No Call', () => <CallManager {...createProps()} />);
props: {},
}, story.add('Ongoing Direct Call', () => (
{ <CallManager
title: 'Call Manager (ongoing direct call)', {...createProps({
props: {
activeCall: { activeCall: {
call: { call: {
callMode: CallMode.Direct as CallMode.Direct, callMode: CallMode.Direct as CallMode.Direct,
@ -85,22 +106,17 @@ const permutations = [
isVideoCall: true, isVideoCall: true,
hasRemoteVideo: true, hasRemoteVideo: true,
}, },
activeCallState: { activeCallState: getCallState(),
conversationId: '3051234567', conversation: getConversation(),
joinedAt: Date.now(), groupCallParticipants: [],
hasLocalAudio: true,
hasLocalVideo: false,
participantsList: false,
pip: false,
settingsDialogOpen: false,
},
conversation,
}, },
}, })}
}, />
{ ));
title: 'Call Manager (ongoing group call)',
props: { story.add('Ongoing Group Call', () => (
<CallManager
{...createProps({
activeCall: { activeCall: {
call: { call: {
callMode: CallMode.Group as CallMode.Group, callMode: CallMode.Group as CallMode.Group,
@ -109,70 +125,36 @@ const permutations = [
joinState: GroupCallJoinState.Joined, joinState: GroupCallJoinState.Joined,
remoteParticipants: [], remoteParticipants: [],
}, },
activeCallState: { activeCallState: getCallState(),
conversationId: '3051234567', conversation: getConversation(),
joinedAt: Date.now(), groupCallParticipants: [],
hasLocalAudio: true,
hasLocalVideo: false,
participantsList: false,
pip: false,
settingsDialogOpen: false,
},
conversation,
}, },
}, })}
}, />
{ ));
title: 'Call Manager (ringing)',
props: {
incomingCall: {
call: {
callMode: CallMode.Direct as CallMode.Direct,
conversationId: '3051234567',
callState: CallState.Ringing,
isIncoming: true,
isVideoCall: true,
hasRemoteVideo: true,
},
conversation,
},
},
},
{
title: 'Call Manager (call request needed)',
props: {
activeCall: {
call: {
callMode: CallMode.Direct as CallMode.Direct,
conversationId: '3051234567',
callState: CallState.Ended,
callEndedReason: CallEndedReason.RemoteHangupNeedPermission,
isIncoming: false,
isVideoCall: true,
hasRemoteVideo: true,
},
activeCallState: {
conversationId: '3051234567',
joinedAt: Date.now(),
hasLocalAudio: true,
hasLocalVideo: false,
participantsList: false,
pip: false,
settingsDialogOpen: false,
},
conversation,
},
},
},
];
storiesOf('Components/CallManager', module).add('Iterations', () => { story.add('Ringing', () => (
return permutations.map( <CallManager
({ props, title }: { props: Partial<PropsType>; title: string }) => ( {...createProps({
<> incomingCall: {
<h3>{title}</h3> call: getIncomingCallState(),
<CallManager {...defaultProps} {...props} /> conversation: getConversation(),
</> },
) })}
); />
}); ));
story.add('Call Request Needed', () => (
<CallManager
{...createProps({
activeCall: {
call: getIncomingCallState({
callEndedReason: CallEndedReason.RemoteHangupNeedPermission,
}),
activeCallState: getCallState(),
conversation: getConversation(),
groupCallParticipants: [],
},
})}
/>
));

View File

@ -2,18 +2,20 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { CallingPip } from './CallingPip';
import { CallNeedPermissionScreen } from './CallNeedPermissionScreen'; import { CallNeedPermissionScreen } from './CallNeedPermissionScreen';
import { CallingLobby } from './CallingLobby';
import { CallScreen } from './CallScreen'; import { CallScreen } from './CallScreen';
import { CallingLobby } from './CallingLobby';
import { CallingParticipantsList } from './CallingParticipantsList';
import { CallingPip } from './CallingPip';
import { IncomingCallBar } from './IncomingCallBar'; import { IncomingCallBar } from './IncomingCallBar';
import { import {
CallEndedReason,
CallMode, CallMode,
CallState, CallState,
CallEndedReason,
CanvasVideoRenderer, CanvasVideoRenderer,
VideoFrameSource,
GroupCallJoinState, GroupCallJoinState,
GroupCallRemoteParticipantType,
VideoFrameSource,
} from '../types/Calling'; } from '../types/Calling';
import { ConversationType } from '../state/ducks/conversations'; import { ConversationType } from '../state/ducks/conversations';
import { import {
@ -35,9 +37,10 @@ import { ColorType } from '../types/Colors';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
interface ActiveCallType { interface ActiveCallType {
call: DirectCallStateType | GroupCallStateType;
activeCallState: ActiveCallStateType; activeCallState: ActiveCallStateType;
call: DirectCallStateType | GroupCallStateType;
conversation: ConversationType; conversation: ConversationType;
groupCallParticipants: Array<GroupCallRemoteParticipantType>;
} }
export interface PropsType { export interface PropsType {
@ -101,13 +104,19 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
togglePip, togglePip,
toggleSettings, toggleSettings,
}) => { }) => {
const { call, activeCallState, conversation } = activeCall;
const { const {
joinedAt, call,
activeCallState,
conversation,
groupCallParticipants,
} = activeCall;
const {
hasLocalAudio, hasLocalAudio,
hasLocalVideo, hasLocalVideo,
settingsDialogOpen, joinedAt,
pip, pip,
settingsDialogOpen,
showParticipantsList,
} = activeCallState; } = activeCallState;
const cancelActiveCall = useCallback(() => { const cancelActiveCall = useCallback(() => {
@ -160,6 +169,11 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
} }
if (showCallLobby) { if (showCallLobby) {
const participantNames = groupCallParticipants.map(participant =>
participant.isSelf
? i18n('you')
: participant.firstName || participant.title
);
return ( return (
<> <>
<CallingLobby <CallingLobby
@ -168,12 +182,11 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
hasLocalAudio={hasLocalAudio} hasLocalAudio={hasLocalAudio}
hasLocalVideo={hasLocalVideo} hasLocalVideo={hasLocalVideo}
i18n={i18n} i18n={i18n}
// TODO: Set this to `true` for group calls. We can get away with this for isGroupCall={call.callMode === CallMode.Group}
// now because it only affects rendering. See DESKTOP-888 and DESKTOP-889.
isGroupCall={false}
me={me} me={me}
onCallCanceled={cancelActiveCall} onCallCanceled={cancelActiveCall}
onJoinCall={joinActiveCall} onJoinCall={joinActiveCall}
participantNames={participantNames}
setLocalPreview={setLocalPreview} setLocalPreview={setLocalPreview}
setLocalAudio={setLocalAudio} setLocalAudio={setLocalAudio}
setLocalVideo={setLocalVideo} setLocalVideo={setLocalVideo}
@ -181,20 +194,26 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
toggleSettings={toggleSettings} toggleSettings={toggleSettings}
/> />
{settingsDialogOpen && renderDeviceSelection()} {settingsDialogOpen && renderDeviceSelection()}
{showParticipantsList && call.callMode === CallMode.Group ? (
<CallingParticipantsList
i18n={i18n}
onClose={toggleParticipants}
participants={groupCallParticipants}
/>
) : null}
</> </>
); );
} }
// TODO: Group calls should also support the PiP. See DESKTOP-886. if (pip) {
if (pip && call.callMode === CallMode.Direct) {
const hasRemoteVideo = Boolean(call.hasRemoteVideo);
return ( return (
<CallingPip <CallingPip
call={call}
conversation={conversation} conversation={conversation}
createCanvasVideoRenderer={createCanvasVideoRenderer}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall}
hangUp={hangUp} hangUp={hangUp}
hasLocalVideo={hasLocalVideo} hasLocalVideo={hasLocalVideo}
hasRemoteVideo={hasRemoteVideo}
i18n={i18n} i18n={i18n}
setLocalPreview={setLocalPreview} setLocalPreview={setLocalPreview}
setRendererCanvas={setRendererCanvas} setRendererCanvas={setRendererCanvas}
@ -220,10 +239,19 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
setRendererCanvas={setRendererCanvas} setRendererCanvas={setRendererCanvas}
setLocalAudio={setLocalAudio} setLocalAudio={setLocalAudio}
setLocalVideo={setLocalVideo} setLocalVideo={setLocalVideo}
stickyControls={showParticipantsList}
toggleParticipants={toggleParticipants}
togglePip={togglePip} togglePip={togglePip}
toggleSettings={toggleSettings} toggleSettings={toggleSettings}
/> />
{settingsDialogOpen && renderDeviceSelection()} {settingsDialogOpen && renderDeviceSelection()}
{showParticipantsList && call.callMode === CallMode.Group ? (
<CallingParticipantsList
i18n={i18n}
onClose={toggleParticipants}
participants={groupCallParticipants}
/>
) : null}
</> </>
); );
}; };

View File

@ -8,40 +8,68 @@ import { boolean, select } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { CallMode, CallState } from '../types/Calling'; 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 { CallScreen, PropsType } from './CallScreen';
import { setup as setupI18n } from '../../js/modules/i18n'; import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json'; import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
const createProps = ( function getGroupCallState(
remoteParticipants: Array<GroupCallParticipantInfoType>
): GroupCallStateType {
return {
callMode: CallMode.Group,
conversationId: '3051234567',
connectionState: 2,
joinState: 2,
remoteParticipants,
};
}
function getDirectCallState(
overrideProps: { overrideProps: {
callState?: CallState; callState?: CallState;
hasLocalAudio?: boolean;
hasLocalVideo?: boolean;
hasRemoteVideo?: boolean; hasRemoteVideo?: boolean;
} = {} } = {}
): PropsType => ({ ): DirectCallStateType {
call: { return {
callMode: CallMode.Direct as CallMode.Direct, callMode: CallMode.Direct,
conversationId: '3051234567', conversationId: '3051234567',
callState: select( callState: select(
'callState', 'callState',
CallState, CallState,
overrideProps.callState || CallState.Accepted overrideProps.callState || CallState.Accepted
), ),
isIncoming: false,
isVideoCall: true,
hasRemoteVideo: boolean( hasRemoteVideo: boolean(
'hasRemoteVideo', '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<GroupCallParticipantInfoType>;
} = {}
): PropsType => ({
call: overrideProps.callTypeState || getDirectCallState(overrideProps),
conversation: { conversation: {
id: '3051234567', id: '3051234567',
avatarPath: undefined, avatarPath: undefined,
color: 'ultramarine' as ColorType, color: Colors[0],
title: 'Rick Sanchez', title: 'Rick Sanchez',
name: 'Rick Sanchez', name: 'Rick Sanchez',
phoneNumber: '3051234567', phoneNumber: '3051234567',
@ -67,7 +95,7 @@ const createProps = (
i18n, i18n,
joinedAt: Date.now(), joinedAt: Date.now(),
me: { me: {
color: 'ultramarine' as ColorType, color: Colors[1],
name: 'Morty Smith', name: 'Morty Smith',
profileName: 'Morty Smith', profileName: 'Morty Smith',
title: 'Morty Smith', title: 'Morty Smith',
@ -76,6 +104,8 @@ const createProps = (
setLocalPreview: action('set-local-preview'), setLocalPreview: action('set-local-preview'),
setLocalVideo: action('set-local-video'), setLocalVideo: action('set-local-video'),
setRendererCanvas: action('set-renderer-canvas'), setRendererCanvas: action('set-renderer-canvas'),
stickyControls: boolean('stickyControls', false),
toggleParticipants: action('toggle-participants'),
togglePip: action('toggle-pip'), togglePip: action('toggle-pip'),
toggleSettings: action('toggle-settings'), toggleSettings: action('toggle-settings'),
}); });
@ -87,19 +117,43 @@ story.add('Default', () => {
}); });
story.add('Pre-Ring', () => { story.add('Pre-Ring', () => {
return <CallScreen {...createProps({ callState: CallState.Prering })} />; return (
<CallScreen
{...createProps({
callState: CallState.Prering,
})}
/>
);
}); });
story.add('Ringing', () => { story.add('Ringing', () => {
return <CallScreen {...createProps({ callState: CallState.Ringing })} />; return (
<CallScreen
{...createProps({
callState: CallState.Ringing,
})}
/>
);
}); });
story.add('Reconnecting', () => { story.add('Reconnecting', () => {
return <CallScreen {...createProps({ callState: CallState.Reconnecting })} />; return (
<CallScreen
{...createProps({
callState: CallState.Reconnecting,
})}
/>
);
}); });
story.add('Ended', () => { story.add('Ended', () => {
return <CallScreen {...createProps({ callState: CallState.Ended })} />; return (
<CallScreen
{...createProps({
callState: CallState.Ended,
})}
/>
);
}); });
story.add('hasLocalAudio', () => { story.add('hasLocalAudio', () => {
@ -113,3 +167,53 @@ story.add('hasLocalVideo', () => {
story.add('hasRemoteVideo', () => { story.add('hasRemoteVideo', () => {
return <CallScreen {...createProps({ hasRemoteVideo: true })} />; return <CallScreen {...createProps({ hasRemoteVideo: true })} />;
}); });
story.add('Group call - 1', () => (
<CallScreen
{...createProps({
callTypeState: getGroupCallState([
{
conversationId: '123',
demuxId: 0,
hasRemoteAudio: true,
hasRemoteVideo: true,
isSelf: false,
videoAspectRatio: 1.3,
},
]),
})}
/>
));
story.add('Group call - Many', () => (
<CallScreen
{...createProps({
callTypeState: getGroupCallState([
{
conversationId: '123',
demuxId: 0,
hasRemoteAudio: true,
hasRemoteVideo: true,
isSelf: false,
videoAspectRatio: 1.3,
},
{
conversationId: '456',
demuxId: 1,
hasRemoteAudio: true,
hasRemoteVideo: true,
isSelf: true,
videoAspectRatio: 1.3,
},
{
conversationId: '789',
demuxId: 2,
hasRemoteAudio: true,
hasRemoteVideo: true,
isSelf: false,
videoAspectRatio: 1.3,
},
]),
})}
/>
));

View File

@ -15,6 +15,7 @@ import {
SetRendererCanvasType, SetRendererCanvasType,
} from '../state/ducks/calling'; } from '../state/ducks/calling';
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
import { CallingHeader } from './CallingHeader';
import { CallingButton, CallingButtonType } from './CallingButton'; import { CallingButton, CallingButtonType } from './CallingButton';
import { CallBackgroundBlur } from './CallBackgroundBlur'; import { CallBackgroundBlur } from './CallBackgroundBlur';
import { import {
@ -52,6 +53,8 @@ export type PropsType = {
setLocalVideo: (_: SetLocalVideoType) => void; setLocalVideo: (_: SetLocalVideoType) => void;
setLocalPreview: (_: SetLocalPreviewType) => void; setLocalPreview: (_: SetLocalPreviewType) => void;
setRendererCanvas: (_: SetRendererCanvasType) => void; setRendererCanvas: (_: SetRendererCanvasType) => void;
stickyControls: boolean;
toggleParticipants: () => void;
togglePip: () => void; togglePip: () => void;
toggleSettings: () => void; toggleSettings: () => void;
}; };
@ -71,6 +74,8 @@ export const CallScreen: React.FC<PropsType> = ({
setLocalVideo, setLocalVideo,
setLocalPreview, setLocalPreview,
setRendererCanvas, setRendererCanvas,
stickyControls,
toggleParticipants,
togglePip, togglePip,
toggleSettings, toggleSettings,
}) => { }) => {
@ -110,14 +115,14 @@ export const CallScreen: React.FC<PropsType> = ({
}, [joinedAt]); }, [joinedAt]);
useEffect(() => { useEffect(() => {
if (!showControls) { if (!showControls || stickyControls) {
return noop; return noop;
} }
const timer = setTimeout(() => { const timer = setTimeout(() => {
setShowControls(false); setShowControls(false);
}, 5000); }, 5000);
return clearInterval.bind(null, timer); return clearInterval.bind(null, timer);
}, [showControls]); }, [showControls, stickyControls]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => { const handleKeyDown = (event: KeyboardEvent): void => {
@ -146,13 +151,13 @@ export const CallScreen: React.FC<PropsType> = ({
let hasRemoteVideo: boolean; let hasRemoteVideo: boolean;
let isConnected: boolean; let isConnected: boolean;
let remoteParticipants: JSX.Element; let remoteParticipantsElement: JSX.Element;
switch (call.callMode) { switch (call.callMode) {
case CallMode.Direct: case CallMode.Direct:
hasRemoteVideo = Boolean(call.hasRemoteVideo); hasRemoteVideo = Boolean(call.hasRemoteVideo);
isConnected = call.callState === CallState.Accepted; isConnected = call.callState === CallState.Accepted;
remoteParticipants = ( remoteParticipantsElement = (
<DirectCallRemoteParticipant <DirectCallRemoteParticipant
conversation={conversation} conversation={conversation}
hasRemoteVideo={hasRemoteVideo} hasRemoteVideo={hasRemoteVideo}
@ -166,7 +171,7 @@ export const CallScreen: React.FC<PropsType> = ({
remoteParticipant => remoteParticipant.hasRemoteVideo remoteParticipant => remoteParticipant.hasRemoteVideo
); );
isConnected = call.connectionState === GroupCallConnectionState.Connected; isConnected = call.connectionState === GroupCallConnectionState.Connected;
remoteParticipants = ( remoteParticipantsElement = (
<GroupCallRemoteParticipants <GroupCallRemoteParticipants
remoteParticipants={call.remoteParticipants} remoteParticipants={call.remoteParticipants}
createCanvasVideoRenderer={createCanvasVideoRenderer} createCanvasVideoRenderer={createCanvasVideoRenderer}
@ -194,6 +199,9 @@ export const CallScreen: React.FC<PropsType> = ({
!showControls && !isAudioOnly && isConnected, !showControls && !isAudioOnly && isConnected,
}); });
const remoteParticipants =
call.callMode === CallMode.Group ? call.remoteParticipants.length : 0;
return ( return (
<div <div
className={classNames( className={classNames(
@ -208,40 +216,33 @@ export const CallScreen: React.FC<PropsType> = ({
role="group" role="group"
> >
<div <div
className={classNames( className={classNames('module-ongoing-call__header', controlsFadeClass)}
'module-calling__header',
'module-ongoing-call__header',
controlsFadeClass
)}
> >
<div className="module-calling__header--header-name"> <CallingHeader
{conversation.title} canPip
</div> conversationTitle={
{call.callMode === CallMode.Direct && <>
renderHeaderMessage( {call.callMode === CallMode.Group &&
i18n, !call.remoteParticipants.length
call.callState || CallState.Prering, ? i18n('calling__in-this-call--zero')
acceptedDuration : conversation.title}
)} {call.callMode === CallMode.Direct &&
<div className="module-calling-tools"> renderHeaderMessage(
<button i18n,
type="button" call.callState || CallState.Prering,
aria-label={i18n('callingDeviceSelection__settings')} acceptedDuration
className="module-calling-tools__button module-calling-button__settings" )}
onClick={toggleSettings} </>
/> }
{/* TODO: Group calls should also support the PiP. See DESKTOP-886. */} i18n={i18n}
{call.callMode === CallMode.Direct && ( isGroupCall={call.callMode === CallMode.Group}
<button remoteParticipants={remoteParticipants}
type="button" toggleParticipants={toggleParticipants}
aria-label={i18n('calling__pip')} togglePip={togglePip}
className="module-calling-tools__button module-calling-button__pip" toggleSettings={toggleSettings}
onClick={togglePip} />
/>
)}
</div>
</div> </div>
{remoteParticipants} {remoteParticipantsElement}
<div className="module-ongoing-call__footer"> <div className="module-ongoing-call__footer">
{/* This layout-only element is not ideal. {/* This layout-only element is not ideal.
See the comment in _modules.css for more. */} See the comment in _modules.css for more. */}

View File

@ -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> = {}): 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', () => <CallingHeader {...createProps()} />);
story.add('Has Pip', () => (
<CallingHeader {...createProps({ canPip: true })} />
));
story.add('With Participants', () => (
<CallingHeader
{...createProps({
canPip: true,
isGroupCall: true,
remoteParticipants: 10,
})}
/>
));
story.add('Long Title', () => (
<CallingHeader
{...createProps({
conversationTitle:
'What do I got to, what do I got to do to wake you up? To shake you up, to break the structure up?',
})}
/>
));

View File

@ -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 => (
<div className="module-calling__header">
<div className="module-calling__header--header-name">
{conversationTitle}
</div>
<div className="module-calling-tools">
{isGroupCall ? (
<div className="module-calling-tools__button">
<Tooltip
arrowSize={6}
content={i18n('calling__participants', [
String(remoteParticipants),
])}
direction="down"
hoverDelay={0}
>
<button
type="button"
aria-label={i18n('calling__participants', [
String(remoteParticipants),
])}
className="module-calling-button__participants"
onClick={toggleParticipants}
/>
</Tooltip>
</div>
) : null}
<div className="module-calling-tools__button">
<Tooltip
arrowSize={6}
content={i18n('callingDeviceSelection__settings')}
direction="down"
hoverDelay={0}
>
<button
type="button"
aria-label={i18n('callingDeviceSelection__settings')}
className="module-calling-button__settings"
onClick={toggleSettings}
/>
</Tooltip>
</div>
{canPip && (
<div className="module-calling-tools__button">
<Tooltip
arrowSize={6}
content={i18n('calling__pip--on')}
direction="down"
hoverDelay={0}
>
<button
type="button"
aria-label={i18n('calling__pip--on')}
className="module-calling-button__pip"
onClick={togglePip}
/>
</Tooltip>
</div>
)}
</div>
</div>
);

View File

@ -35,6 +35,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
me: overrideProps.me || { color: 'ultramarine' as ColorType }, me: overrideProps.me || { color: 'ultramarine' as ColorType },
onCallCanceled: action('on-call-canceled'), onCallCanceled: action('on-call-canceled'),
onJoinCall: action('on-join-call'), onJoinCall: action('on-join-call'),
participantNames: overrideProps.participantNames || [],
setLocalAudio: action('set-local-audio'), setLocalAudio: action('set-local-audio'),
setLocalPreview: action('set-local-preview'), setLocalPreview: action('set-local-preview'),
setLocalVideo: action('set-local-video'), setLocalVideo: action('set-local-video'),
@ -81,7 +82,36 @@ story.add('Local Video', () => {
return <CallingLobby {...props} />; return <CallingLobby {...props} />;
}); });
story.add('Group Call', () => { story.add('Group Call - 0', () => {
const props = createProps({ isGroupCall: true }); const props = createProps({ isGroupCall: true, participantNames: [] });
return <CallingLobby {...props} />;
});
story.add('Group Call - 1', () => {
const props = createProps({ isGroupCall: true, participantNames: ['Sam'] });
return <CallingLobby {...props} />;
});
story.add('Group Call - 2', () => {
const props = createProps({
isGroupCall: true,
participantNames: ['Sam', 'Cayce'],
});
return <CallingLobby {...props} />;
});
story.add('Group Call - 3', () => {
const props = createProps({
isGroupCall: true,
participantNames: ['Sam', 'Cayce', 'April'],
});
return <CallingLobby {...props} />;
});
story.add('Group Call - 4', () => {
const props = createProps({
isGroupCall: true,
participantNames: ['Sam', 'Cayce', 'April', 'Logan', 'Carl'],
});
return <CallingLobby {...props} />; return <CallingLobby {...props} />;
}); });

View File

@ -13,8 +13,10 @@ import {
TooltipDirection, TooltipDirection,
} from './CallingButton'; } from './CallingButton';
import { CallBackgroundBlur } from './CallBackgroundBlur'; import { CallBackgroundBlur } from './CallBackgroundBlur';
import { LocalizerType } from '../types/Util'; import { CallingHeader } from './CallingHeader';
import { Spinner } from './Spinner';
import { ColorType } from '../types/Colors'; import { ColorType } from '../types/Colors';
import { LocalizerType } from '../types/Util';
export type PropsType = { export type PropsType = {
availableCameras: Array<MediaDeviceInfo>; availableCameras: Array<MediaDeviceInfo>;
@ -31,6 +33,7 @@ export type PropsType = {
}; };
onCallCanceled: () => void; onCallCanceled: () => void;
onJoinCall: () => void; onJoinCall: () => void;
participantNames: Array<string>;
setLocalAudio: (_: SetLocalAudioType) => void; setLocalAudio: (_: SetLocalAudioType) => void;
setLocalVideo: (_: SetLocalVideoType) => void; setLocalVideo: (_: SetLocalVideoType) => void;
setLocalPreview: (_: SetLocalPreviewType) => void; setLocalPreview: (_: SetLocalPreviewType) => void;
@ -48,6 +51,7 @@ export const CallingLobby = ({
me, me,
onCallCanceled, onCallCanceled,
onJoinCall, onJoinCall,
participantNames,
setLocalAudio, setLocalAudio,
setLocalPreview, setLocalPreview,
setLocalVideo, setLocalVideo,
@ -97,6 +101,8 @@ export const CallingLobby = ({
}; };
}, [toggleVideo, toggleAudio]); }, [toggleVideo, toggleAudio]);
const [isCallConnecting, setIsCallConnecting] = React.useState(false);
// eslint-disable-next-line no-nested-ternary // eslint-disable-next-line no-nested-ternary
const videoButtonType = hasLocalVideo const videoButtonType = hasLocalVideo
? CallingButtonType.VIDEO_ON ? CallingButtonType.VIDEO_ON
@ -109,27 +115,15 @@ export const CallingLobby = ({
return ( return (
<div className="module-calling__container"> <div className="module-calling__container">
<div className="module-calling__header"> <CallingHeader
<div className="module-calling__header--header-name"> conversationTitle={conversation.title}
{conversation.title} i18n={i18n}
</div> isGroupCall={isGroupCall}
<div className="module-calling-tools"> remoteParticipants={participantNames.length}
{isGroupCall ? ( toggleParticipants={toggleParticipants}
<button toggleSettings={toggleSettings}
type="button" />
aria-label={i18n('calling__participants')}
className="module-calling-tools__button module-calling-button__participants"
onClick={toggleParticipants}
/>
) : null}
<button
type="button"
aria-label={i18n('callingDeviceSelection__settings')}
className="module-calling-tools__button module-calling-button__settings"
onClick={toggleSettings}
/>
</div>
</div>
<div className="module-calling-lobby__video"> <div className="module-calling-lobby__video">
{hasLocalVideo && availableCameras.length > 0 ? ( {hasLocalVideo && availableCameras.length > 0 ? (
<video ref={localVideoRef} autoPlay /> <video ref={localVideoRef} autoPlay />
@ -160,6 +154,32 @@ export const CallingLobby = ({
</div> </div>
</div> </div>
{isGroupCall ? (
<div className="module-calling-lobby__info">
{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),
})}
</div>
) : null}
<div className="module-calling-lobby__actions"> <div className="module-calling-lobby__actions">
<button <button
className="module-button__gray module-calling-lobby__button" className="module-button__gray module-calling-lobby__button"
@ -169,14 +189,29 @@ export const CallingLobby = ({
> >
{i18n('cancel')} {i18n('cancel')}
</button> </button>
<button {isCallConnecting && (
className="module-button__green module-calling-lobby__button" <button
onClick={onJoinCall} className="module-button__green module-calling-lobby__button"
tabIndex={0} disabled
type="button" tabIndex={0}
> type="button"
{isGroupCall ? i18n('calling__join') : i18n('calling__start')} >
</button> <Spinner svgSize="small" />
</button>
)}
{!isCallConnecting && (
<button
className="module-button__green module-calling-lobby__button"
onClick={() => {
setIsCallConnecting(true);
onJoinCall();
}}
tabIndex={0}
type="button"
>
{isGroupCall ? i18n('calling__join') : i18n('calling__start')}
</button>
)}
</div> </div>
</div> </div>
); );

View File

@ -6,61 +6,76 @@ import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { CallingParticipantsList, PropsType } from './CallingParticipantsList'; import { CallingParticipantsList, PropsType } from './CallingParticipantsList';
import { Colors } from '../types/Colors';
import { GroupCallRemoteParticipantType } from '../types/Calling';
import { setup as setupI18n } from '../../js/modules/i18n'; import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json'; import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
const participant = { function createParticipant(
title: 'Bardock', participantProps: Partial<GroupCallRemoteParticipantType>
}; ): 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> = {}): PropsType => ({ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
i18n, i18n,
onClose: action('on-close'), onClose: action('on-close'),
participants: overrideProps.participants || [participant], participants: overrideProps.participants || [],
}); });
const story = storiesOf('Components/CallingParticipantsList', module); const story = storiesOf('Components/CallingParticipantsList', module);
story.add('Default', () => { story.add('No one', () => {
const props = createProps(); const props = createProps();
return <CallingParticipantsList {...props} />; return <CallingParticipantsList {...props} />;
}); });
story.add('Solo Call', () => {
const props = createProps({
participants: [
createParticipant({
title: 'Bardock',
}),
],
});
return <CallingParticipantsList {...props} />;
});
story.add('Many Participants', () => { story.add('Many Participants', () => {
const props = createProps({ const props = createProps({
participants: [ participants: [
{ createParticipant({
color: 'blue', isSelf: true,
profileName: 'Son Goku',
title: 'Son Goku', title: 'Son Goku',
audioMuted: true, }),
videoMuted: true, createParticipant({
}, hasRemoteAudio: true,
{ hasRemoteVideo: true,
color: 'deep_orange',
profileName: 'Rage Trunks',
title: 'Rage Trunks', title: 'Rage Trunks',
}, }),
{ createParticipant({
color: 'indigo', hasRemoteAudio: true,
profileName: 'Prince Vegeta',
title: 'Prince Vegeta', title: 'Prince Vegeta',
videoMuted: true, }),
}, createParticipant({
{ hasRemoteAudio: true,
color: 'pink', hasRemoteVideo: true,
profileName: 'Goku Black',
title: 'Goku Black', title: 'Goku Black',
}, }),
{ createParticipant({
color: 'green',
profileName: 'Supreme Kai Zamasu',
title: 'Supreme Kai Zamasu', title: 'Supreme Kai Zamasu',
audioMuted: true, }),
videoMuted: true,
},
], ],
}); });
return <CallingParticipantsList {...props} />; return <CallingParticipantsList {...props} />;

View File

@ -6,23 +6,14 @@
import React from 'react'; import React from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
import { ColorType } from '../types/Colors';
import { ContactName } from './conversation/ContactName'; import { ContactName } from './conversation/ContactName';
import { LocalizerType } from '../types/Util'; import { LocalizerType } from '../types/Util';
import { GroupCallRemoteParticipantType } from '../types/Calling';
type ParticipantType = {
audioMuted?: boolean;
avatarPath?: string;
color?: ColorType;
profileName?: string;
title: string;
videoMuted?: boolean;
};
export type PropsType = { export type PropsType = {
readonly i18n: LocalizerType; readonly i18n: LocalizerType;
readonly onClose: () => void; readonly onClose: () => void;
readonly participants: Array<ParticipantType>; readonly participants: Array<GroupCallRemoteParticipantType>;
}; };
export const CallingParticipantsList = React.memo( export const CallingParticipantsList = React.memo(
@ -52,11 +43,12 @@ export const CallingParticipantsList = React.memo(
<div className="module-calling-participants-list"> <div className="module-calling-participants-list">
<div className="module-calling-participants-list__header"> <div className="module-calling-participants-list__header">
<div className="module-calling-participants-list__title"> <div className="module-calling-participants-list__title">
{participants.length > 1 {!participants.length && i18n('calling__in-this-call--zero')}
? i18n('calling__in-this-call--many', [ {participants.length === 1 && i18n('calling__in-this-call--one')}
String(participants.length), {participants.length > 1 &&
]) i18n('calling__in-this-call--many', [
: i18n('calling__in-this-call--one')} String(participants.length),
])}
</div> </div>
<button <button
type="button" type="button"
@ -67,37 +59,45 @@ export const CallingParticipantsList = React.memo(
/> />
</div> </div>
<ul className="module-calling-participants-list__list"> <ul className="module-calling-participants-list__list">
{participants.map((participant: ParticipantType, index: number) => ( {participants.map(
<li (participant: GroupCallRemoteParticipantType, index: number) => (
className="module-calling-participants-list__contact" <li
key={index} className="module-calling-participants-list__contact"
> key={index}
<div> >
<Avatar <div>
avatarPath={participant.avatarPath} <Avatar
color={participant.color} avatarPath={participant.avatarPath}
conversationType="direct" color={participant.color}
i18n={i18n} conversationType="direct"
profileName={participant.profileName} i18n={i18n}
title={participant.title} profileName={participant.profileName}
size={32} title={participant.title}
/> size={32}
<ContactName />
i18n={i18n} {participant.isSelf ? (
module="module-calling-participants-list__name" <span className="module-calling-participants-list__name">
title={participant.title} {i18n('you')}
/> </span>
</div> ) : (
<div> <ContactName
{participant.audioMuted ? ( i18n={i18n}
<span className="module-calling-participants-list__muted--audio" /> module="module-calling-participants-list__name"
) : null} title={participant.title}
{participant.videoMuted ? ( />
<span className="module-calling-participants-list__muted--video" /> )}
) : null} </div>
</div> <div>
</li> {!participant.hasRemoteAudio ? (
))} <span className="module-calling-participants-list__muted--audio" />
) : null}
{!participant.hasRemoteVideo ? (
<span className="module-calling-participants-list__muted--video" />
) : null}
</div>
</li>
)
)}
</ul> </ul>
</div> </div>
</div>, </div>,

View File

@ -2,12 +2,20 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
import { noop } from 'lodash';
import { storiesOf } from '@storybook/react'; import { storiesOf } from '@storybook/react';
import { boolean } from '@storybook/addon-knobs'; import { boolean } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { ColorType } from '../types/Colors'; import { ColorType } from '../types/Colors';
import { ConversationTypeType } from '../state/ducks/conversations';
import { CallingPip, PropsType } from './CallingPip'; import { CallingPip, PropsType } from './CallingPip';
import {
CallMode,
CallState,
GroupCallConnectionState,
GroupCallJoinState,
} from '../types/Calling';
import { setup as setupI18n } from '../../js/modules/i18n'; import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json'; import enMessages from '../../_locales/en/messages.json';
@ -21,16 +29,29 @@ const conversation = {
name: 'Rick Sanchez', name: 'Rick Sanchez',
phoneNumber: '3051234567', phoneNumber: '3051234567',
profileName: 'Rick Sanchez', 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> = {}): PropsType => ({ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
call: overrideProps.call || defaultCall,
conversation: overrideProps.conversation || conversation, 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'), hangUp: action('hang-up'),
hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false), hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false),
hasRemoteVideo: boolean(
'hasRemoteVideo',
overrideProps.hasRemoteVideo || false
),
i18n, i18n,
setLocalPreview: action('set-local-preview'), setLocalPreview: action('set-local-preview'),
setRendererCanvas: action('set-renderer-canvas'), setRendererCanvas: action('set-renderer-canvas'),
@ -63,3 +84,16 @@ story.add('Contact (no color)', () => {
}); });
return <CallingPip {...props} />; return <CallingPip {...props} />;
}); });
story.add('Group Call', () => {
const props = createProps({
call: {
callMode: CallMode.Group as CallMode.Group,
conversationId: '3051234567',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.Joined,
remoteParticipants: [],
},
});
return <CallingPip {...props} />;
});

View File

@ -2,69 +2,26 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; 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 { import {
DirectCallStateType,
GroupCallStateType,
HangUpType, HangUpType,
SetLocalPreviewType, SetLocalPreviewType,
SetRendererCanvasType, SetRendererCanvasType,
} from '../state/ducks/calling'; } 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 (
<div className="module-calling-pip__video--remote">
<CallBackgroundBlur avatarPath={avatarPath} color={color}>
<div className="module-calling-pip__video--avatar">
<Avatar
avatarPath={avatarPath}
color={color || 'ultramarine'}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
size={52}
/>
</div>
</CallBackgroundBlur>
</div>
);
}
export type PropsType = { export type PropsType = {
conversation: { call: DirectCallStateType | GroupCallStateType;
id: string; conversation: ConversationType;
avatarPath?: string; createCanvasVideoRenderer: () => CanvasVideoRenderer;
color?: ColorType; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
title: string;
name?: string;
phoneNumber?: string;
profileName?: string;
};
hangUp: (_: HangUpType) => void; hangUp: (_: HangUpType) => void;
hasLocalVideo: boolean; hasLocalVideo: boolean;
hasRemoteVideo: boolean;
i18n: LocalizerType; i18n: LocalizerType;
setLocalPreview: (_: SetLocalPreviewType) => void; setLocalPreview: (_: SetLocalPreviewType) => void;
setRendererCanvas: (_: SetRendererCanvasType) => void; setRendererCanvas: (_: SetRendererCanvasType) => void;
@ -77,10 +34,12 @@ const PIP_DEFAULT_Y = 56;
const PIP_PADDING = 8; const PIP_PADDING = 8;
export const CallingPip = ({ export const CallingPip = ({
call,
conversation, conversation,
createCanvasVideoRenderer,
getGroupCallVideoFrameSource,
hangUp, hangUp,
hasLocalVideo, hasLocalVideo,
hasRemoteVideo,
i18n, i18n,
setLocalPreview, setLocalPreview,
setRendererCanvas, setRendererCanvas,
@ -88,7 +47,6 @@ export const CallingPip = ({
}: PropsType): JSX.Element | null => { }: PropsType): JSX.Element | null => {
const videoContainerRef = React.useRef(null); const videoContainerRef = React.useRef(null);
const localVideoRef = React.useRef(null); const localVideoRef = React.useRef(null);
const remoteVideoRef = React.useRef(null);
const [dragState, setDragState] = React.useState({ const [dragState, setDragState] = React.useState({
offsetX: 0, offsetX: 0,
@ -103,8 +61,7 @@ export const CallingPip = ({
React.useEffect(() => { React.useEffect(() => {
setLocalPreview({ element: localVideoRef }); setLocalPreview({ element: localVideoRef });
setRendererCanvas({ element: remoteVideoRef }); }, [setLocalPreview]);
}, [setLocalPreview, setRendererCanvas]);
const handleMouseMove = React.useCallback( const handleMouseMove = React.useCallback(
(ev: MouseEvent) => { (ev: MouseEvent) => {
@ -211,14 +168,14 @@ export const CallingPip = ({
transition: dragState.isDragging ? 'none' : 'transform ease-out 300ms', transition: dragState.isDragging ? 'none' : 'transform ease-out 300ms',
}} }}
> >
{hasRemoteVideo ? ( <CallingPipRemoteVideo
<canvas call={call}
className="module-calling-pip__video--remote" conversation={conversation}
ref={remoteVideoRef} createCanvasVideoRenderer={createCanvasVideoRenderer}
/> getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
) : ( i18n={i18n}
renderAvatar(conversation, i18n) setRendererCanvas={setRendererCanvas}
)} />
{hasLocalVideo ? ( {hasLocalVideo ? (
<video <video
className="module-calling-pip__video--local" className="module-calling-pip__video--local"
@ -237,10 +194,18 @@ export const CallingPip = ({
/> />
<button <button
type="button" type="button"
aria-label={i18n('calling__pip')} aria-label={i18n('calling__pip--off')}
className="module-calling-pip__button--pip" className="module-calling-pip__button--pip"
onClick={togglePip} onClick={togglePip}
/> >
<Tooltip
arrowSize={6}
content={i18n('calling__pip--off')}
hoverDelay={0}
>
<div />
</Tooltip>
</button>
</div> </div>
</div> </div>
); );

View File

@ -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 (
<div className="module-calling-pip__video--remote">
<CallBackgroundBlur avatarPath={avatarPath} color={color}>
<div className="module-calling-pip__video--avatar">
<Avatar
avatarPath={avatarPath}
color={color || 'ultramarine'}
noteToSelf={false}
conversationType="direct"
i18n={i18n}
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
size={52}
/>
</div>
</CallBackgroundBlur>
</div>
);
}
return (
<div className="module-calling-pip__video--remote">
<DirectCallRemoteParticipant
conversation={conversation}
hasRemoteVideo={call.hasRemoteVideo}
i18n={i18n}
setRendererCanvas={setRendererCanvas}
/>
</div>
);
}
if (call.callMode === CallMode.Group) {
const speaker = call.remoteParticipants[0];
return (
<div className="module-calling-pip__video--remote">
<GroupCallRemoteParticipant
key={speaker.demuxId}
createCanvasVideoRenderer={createCanvasVideoRenderer}
demuxId={speaker.demuxId}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
hasRemoteVideo={speaker.hasRemoteVideo}
height="100%"
left={0}
top={0}
width="100%"
/>
</div>
);
}
throw new Error('CallingRemoteVideo: Unknown Call Mode');
};

View File

@ -13,10 +13,10 @@ interface PropsType {
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
hasRemoteAudio: boolean; hasRemoteAudio: boolean;
hasRemoteVideo: boolean; hasRemoteVideo: boolean;
height: number; height: number | string;
left: number; left: number;
top: number; top: number;
width: number; width: number | string;
} }
export const GroupCallRemoteParticipant: React.FC<PropsType> = ({ export const GroupCallRemoteParticipant: React.FC<PropsType> = ({

View File

@ -5,7 +5,7 @@ import React, { useState, useMemo } from 'react';
import Measure from 'react-measure'; import Measure from 'react-measure';
import { takeWhile, chunk, maxBy, flatten } from 'lodash'; import { takeWhile, chunk, maxBy, flatten } from 'lodash';
import { CanvasVideoRenderer, VideoFrameSource } from '../types/Calling'; import { CanvasVideoRenderer, VideoFrameSource } from '../types/Calling';
import { GroupCallRemoteParticipantType } from '../state/ducks/calling'; import { GroupCallParticipantInfoType } from '../state/ducks/calling';
import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant'; import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant';
const MIN_RENDERED_HEIGHT = 10; const MIN_RENDERED_HEIGHT = 10;
@ -17,14 +17,14 @@ interface Dimensions {
} }
interface GridArrangement { interface GridArrangement {
rows: Array<Array<GroupCallRemoteParticipantType>>; rows: Array<Array<GroupCallParticipantInfoType>>;
scalar: number; scalar: number;
} }
interface PropsType { interface PropsType {
createCanvasVideoRenderer: () => CanvasVideoRenderer; createCanvasVideoRenderer: () => CanvasVideoRenderer;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
remoteParticipants: ReadonlyArray<GroupCallRemoteParticipantType>; remoteParticipants: ReadonlyArray<GroupCallParticipantInfoType>;
} }
// This component lays out group call remote participants. It uses a custom layout // This component lays out group call remote participants. It uses a custom layout
@ -84,7 +84,7 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
// //
// This is primarily memoized for clarity, not performance. We only need the result, // This is primarily memoized for clarity, not performance. We only need the result,
// not any of the "intermediate" values. // not any of the "intermediate" values.
const visibleParticipants: Array<GroupCallRemoteParticipantType> = useMemo(() => { const visibleParticipants: Array<GroupCallParticipantInfoType> = useMemo(() => {
// Imagine that we laid out all of the rows end-to-end. That's the maximum total // 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 // width. So if there were 5 rows and the container was 100px wide, then we can't
// possibly fit more than 500px of participants. // possibly fit more than 500px of participants.
@ -233,7 +233,7 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
}; };
function totalRemoteParticipantWidthAtMinHeight( function totalRemoteParticipantWidthAtMinHeight(
remoteParticipants: ReadonlyArray<GroupCallRemoteParticipantType> remoteParticipants: ReadonlyArray<GroupCallParticipantInfoType>
): number { ): number {
return remoteParticipants.reduce( return remoteParticipants.reduce(
(result, { videoAspectRatio }) => (result, { videoAspectRatio }) =>

View File

@ -3959,7 +3959,7 @@ export class ConversationModel extends window.Backbone.Model<
return this.get('name') || window.i18n('unknownGroup'); return this.get('name') || window.i18n('unknownGroup');
} }
getProfileName(): string | null { getProfileName(): string | undefined {
if (this.isPrivate()) { if (this.isPrivate()) {
return Util.combineNames( return Util.combineNames(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@ -3967,7 +3967,8 @@ export class ConversationModel extends window.Backbone.Model<
this.get('profileFamilyName') this.get('profileFamilyName')
); );
} }
return null;
return undefined;
} }
getNumber(): string { getNumber(): string {

View File

@ -489,6 +489,8 @@ export class CallingClass {
? GroupCallJoinState.NotJoined ? GroupCallJoinState.NotJoined
: this.convertRingRtcJoinState(localDeviceState.joinState); : this.convertRingRtcJoinState(localDeviceState.joinState);
const ourId = window.ConversationController.getOurConversationId();
return { return {
connectionState: this.convertRingRtcConnectionState( connectionState: this.convertRingRtcConnectionState(
localDeviceState.connectionState localDeviceState.connectionState
@ -496,16 +498,28 @@ export class CallingClass {
joinState, joinState,
hasLocalAudio: !localDeviceState.audioMuted, hasLocalAudio: !localDeviceState.audioMuted,
hasLocalVideo: !localDeviceState.videoMuted, hasLocalVideo: !localDeviceState.videoMuted,
remoteParticipants: remoteDeviceStates.map(remoteDeviceState => ({ remoteParticipants: remoteDeviceStates.map(remoteDeviceState => {
demuxId: remoteDeviceState.demuxId, const uuid = arrayBufferToUuid(remoteDeviceState.userId);
userId: arrayBufferToUuid(remoteDeviceState.userId) || '',
hasRemoteAudio: !remoteDeviceState.audioMuted, const id = window.ConversationController.ensureContactIds({ uuid });
hasRemoteVideo: !remoteDeviceState.videoMuted, if (!id) {
// If RingRTC doesn't send us an aspect ratio, we make a guess. throw new Error(
videoAspectRatio: 'Calling.formatGroupCallForRedux: no conversation found'
remoteDeviceState.videoAspectRatio || );
(remoteDeviceState.videoMuted ? 1 : 4 / 3), }
})),
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),
};
}),
}; };
} }

View File

@ -10,9 +10,9 @@ import { notify } from '../../services/notify';
import { calling } from '../../services/calling'; import { calling } from '../../services/calling';
import { StateType as RootStateType } from '../reducer'; import { StateType as RootStateType } from '../reducer';
import { import {
CallingDeviceType,
CallMode, CallMode,
CallState, CallState,
CallingDeviceType,
ChangeIODevicePayloadType, ChangeIODevicePayloadType,
GroupCallConnectionState, GroupCallConnectionState,
GroupCallJoinState, GroupCallJoinState,
@ -27,6 +27,15 @@ import {
// State // State
export interface GroupCallParticipantInfoType {
conversationId: string;
demuxId: number;
hasRemoteAudio: boolean;
hasRemoteVideo: boolean;
isSelf: boolean;
videoAspectRatio: number;
}
export interface DirectCallStateType { export interface DirectCallStateType {
callMode: CallMode.Direct; callMode: CallMode.Direct;
conversationId: string; conversationId: string;
@ -37,20 +46,12 @@ export interface DirectCallStateType {
hasRemoteVideo?: boolean; hasRemoteVideo?: boolean;
} }
export interface GroupCallRemoteParticipantType {
demuxId: number;
userId: string;
hasRemoteAudio: boolean;
hasRemoteVideo: boolean;
videoAspectRatio: number;
}
export interface GroupCallStateType { export interface GroupCallStateType {
callMode: CallMode.Group; callMode: CallMode.Group;
conversationId: string; conversationId: string;
connectionState: GroupCallConnectionState; connectionState: GroupCallConnectionState;
joinState: GroupCallJoinState; joinState: GroupCallJoinState;
remoteParticipants: Array<GroupCallRemoteParticipantType>; remoteParticipants: Array<GroupCallParticipantInfoType>;
} }
export interface ActiveCallStateType { export interface ActiveCallStateType {
@ -58,7 +59,7 @@ export interface ActiveCallStateType {
joinedAt?: number; joinedAt?: number;
hasLocalAudio: boolean; hasLocalAudio: boolean;
hasLocalVideo: boolean; hasLocalVideo: boolean;
participantsList: boolean; showParticipantsList: boolean;
pip: boolean; pip: boolean;
settingsDialogOpen: boolean; settingsDialogOpen: boolean;
} }
@ -99,7 +100,7 @@ export type GroupCallStateChangeType = {
joinState: GroupCallJoinState; joinState: GroupCallJoinState;
hasLocalAudio: boolean; hasLocalAudio: boolean;
hasLocalVideo: boolean; hasLocalVideo: boolean;
remoteParticipants: Array<GroupCallRemoteParticipantType>; remoteParticipants: Array<GroupCallParticipantInfoType>;
}; };
export type HangUpType = { export type HangUpType = {
@ -148,7 +149,7 @@ export type ShowCallLobbyType =
joinState: GroupCallJoinState; joinState: GroupCallJoinState;
hasLocalAudio: boolean; hasLocalAudio: boolean;
hasLocalVideo: boolean; hasLocalVideo: boolean;
remoteParticipants: Array<GroupCallRemoteParticipantType>; remoteParticipants: Array<GroupCallParticipantInfoType>;
}; };
export type SetLocalPreviewType = { export type SetLocalPreviewType = {
@ -706,7 +707,7 @@ export function reducer(
conversationId: action.payload.conversationId, conversationId: action.payload.conversationId,
hasLocalAudio: action.payload.hasLocalAudio, hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo, hasLocalVideo: action.payload.hasLocalVideo,
participantsList: false, showParticipantsList: false,
pip: false, pip: false,
settingsDialogOpen: false, settingsDialogOpen: false,
}, },
@ -730,7 +731,7 @@ export function reducer(
conversationId: action.payload.conversationId, conversationId: action.payload.conversationId,
hasLocalAudio: action.payload.hasLocalAudio, hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo, hasLocalVideo: action.payload.hasLocalVideo,
participantsList: false, showParticipantsList: false,
pip: false, pip: false,
settingsDialogOpen: false, settingsDialogOpen: false,
}, },
@ -749,7 +750,7 @@ export function reducer(
conversationId: action.payload.conversationId, conversationId: action.payload.conversationId,
hasLocalAudio: true, hasLocalAudio: true,
hasLocalVideo: action.payload.asVideoCall, hasLocalVideo: action.payload.asVideoCall,
participantsList: false, showParticipantsList: false,
pip: false, pip: false,
settingsDialogOpen: false, settingsDialogOpen: false,
}, },
@ -813,7 +814,7 @@ export function reducer(
conversationId: action.payload.conversationId, conversationId: action.payload.conversationId,
hasLocalAudio: action.payload.hasLocalAudio, hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo, hasLocalVideo: action.payload.hasLocalVideo,
participantsList: false, showParticipantsList: false,
pip: false, pip: false,
settingsDialogOpen: false, settingsDialogOpen: false,
}, },
@ -1028,7 +1029,7 @@ export function reducer(
...state, ...state,
activeCallState: { activeCallState: {
...activeCallState, ...activeCallState,
participantsList: !activeCallState.participantsList, showParticipantsList: !activeCallState.showParticipantsList,
}, },
}; };
} }

View File

@ -8,8 +8,9 @@ import { mapDispatchToProps } from '../actions';
import { CallManager } from '../../components/CallManager'; import { CallManager } from '../../components/CallManager';
import { calling as callingService } from '../../services/calling'; import { calling as callingService } from '../../services/calling';
import { getMe, getConversationSelector } from '../selectors/conversations'; import { getMe, getConversationSelector } from '../selectors/conversations';
import { getActiveCall } from '../ducks/calling'; import { getActiveCall, GroupCallParticipantInfoType } from '../ducks/calling';
import { getIncomingCall } from '../selectors/calling'; import { getIncomingCall } from '../selectors/calling';
import { CallMode, GroupCallRemoteParticipantType } from '../../types/Calling';
import { StateType } from '../reducer'; import { StateType } from '../reducer';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
@ -42,18 +43,47 @@ const mapStateToActiveCallProp = (state: StateType) => {
return undefined; return undefined;
} }
const conversation = getConversationSelector(state)( const conversationSelector = getConversationSelector(state);
activeCallState.conversationId const conversation = conversationSelector(activeCallState.conversationId);
);
if (!conversation) { if (!conversation) {
window.log.error('The active call has no corresponding conversation'); window.log.error('The active call has no corresponding conversation');
return undefined; return undefined;
} }
const groupCallParticipants: Array<GroupCallRemoteParticipantType> = [];
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 { return {
call,
activeCallState, activeCallState,
call,
conversation, conversation,
groupCallParticipants,
}; };
}; };

View File

@ -41,7 +41,7 @@ describe('calling duck', () => {
conversationId: 'fake-direct-call-conversation-id', conversationId: 'fake-direct-call-conversation-id',
hasLocalAudio: true, hasLocalAudio: true,
hasLocalVideo: false, hasLocalVideo: false,
participantsList: false, showParticipantsList: false,
pip: false, pip: false,
settingsDialogOpen: false, settingsDialogOpen: false,
}, },
@ -71,10 +71,11 @@ describe('calling duck', () => {
joinState: GroupCallJoinState.NotJoined, joinState: GroupCallJoinState.NotJoined,
remoteParticipants: [ remoteParticipants: [
{ {
conversationId: '123',
demuxId: 123, demuxId: 123,
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
hasRemoteAudio: true, hasRemoteAudio: true,
hasRemoteVideo: true, hasRemoteVideo: true,
isSelf: false,
videoAspectRatio: 4 / 3, videoAspectRatio: 4 / 3,
}, },
], ],
@ -88,7 +89,7 @@ describe('calling duck', () => {
conversationId: 'fake-group-call-conversation-id', conversationId: 'fake-group-call-conversation-id',
hasLocalAudio: true, hasLocalAudio: true,
hasLocalVideo: false, hasLocalVideo: false,
participantsList: false, showParticipantsList: false,
pip: false, pip: false,
settingsDialogOpen: false, settingsDialogOpen: false,
}, },
@ -180,7 +181,7 @@ describe('calling duck', () => {
conversationId: 'fake-direct-call-conversation-id', conversationId: 'fake-direct-call-conversation-id',
hasLocalAudio: true, hasLocalAudio: true,
hasLocalVideo: true, hasLocalVideo: true,
participantsList: false, showParticipantsList: false,
pip: false, pip: false,
settingsDialogOpen: false, settingsDialogOpen: false,
}); });
@ -296,10 +297,11 @@ describe('calling duck', () => {
hasLocalVideo: false, hasLocalVideo: false,
remoteParticipants: [ remoteParticipants: [
{ {
conversationId: '123',
demuxId: 123, demuxId: 123,
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
hasRemoteAudio: true, hasRemoteAudio: true,
hasRemoteVideo: true, hasRemoteVideo: true,
isSelf: false,
videoAspectRatio: 4 / 3, videoAspectRatio: 4 / 3,
}, },
], ],
@ -315,10 +317,11 @@ describe('calling duck', () => {
joinState: GroupCallJoinState.Joining, joinState: GroupCallJoinState.Joining,
remoteParticipants: [ remoteParticipants: [
{ {
conversationId: '123',
demuxId: 123, demuxId: 123,
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
hasRemoteAudio: true, hasRemoteAudio: true,
hasRemoteVideo: true, hasRemoteVideo: true,
isSelf: false,
videoAspectRatio: 4 / 3, videoAspectRatio: 4 / 3,
}, },
], ],
@ -337,10 +340,11 @@ describe('calling duck', () => {
hasLocalVideo: false, hasLocalVideo: false,
remoteParticipants: [ remoteParticipants: [
{ {
conversationId: '123',
demuxId: 456, demuxId: 456,
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
hasRemoteAudio: false, hasRemoteAudio: false,
hasRemoteVideo: true, hasRemoteVideo: true,
isSelf: false,
videoAspectRatio: 16 / 9, videoAspectRatio: 16 / 9,
}, },
], ],
@ -356,10 +360,11 @@ describe('calling duck', () => {
joinState: GroupCallJoinState.Joined, joinState: GroupCallJoinState.Joined,
remoteParticipants: [ remoteParticipants: [
{ {
conversationId: '123',
demuxId: 456, demuxId: 456,
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
hasRemoteAudio: false, hasRemoteAudio: false,
hasRemoteVideo: true, hasRemoteVideo: true,
isSelf: false,
videoAspectRatio: 16 / 9, videoAspectRatio: 16 / 9,
}, },
], ],
@ -378,10 +383,11 @@ describe('calling duck', () => {
hasLocalVideo: false, hasLocalVideo: false,
remoteParticipants: [ remoteParticipants: [
{ {
conversationId: '123',
demuxId: 456, demuxId: 456,
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
hasRemoteAudio: false, hasRemoteAudio: false,
hasRemoteVideo: true, hasRemoteVideo: true,
isSelf: false,
videoAspectRatio: 16 / 9, videoAspectRatio: 16 / 9,
}, },
], ],
@ -402,10 +408,11 @@ describe('calling duck', () => {
hasLocalVideo: true, hasLocalVideo: true,
remoteParticipants: [ remoteParticipants: [
{ {
conversationId: '123',
demuxId: 456, demuxId: 456,
userId: '6d174bc4-2ea1-45b6-9099-c46fc87ce72f',
hasRemoteAudio: false, hasRemoteAudio: false,
hasRemoteVideo: true, hasRemoteVideo: true,
isSelf: false,
videoAspectRatio: 16 / 9, videoAspectRatio: 16 / 9,
}, },
], ],
@ -416,7 +423,7 @@ describe('calling duck', () => {
conversationId: 'fake-group-call-conversation-id', conversationId: 'fake-group-call-conversation-id',
hasLocalAudio: true, hasLocalAudio: true,
hasLocalVideo: false, hasLocalVideo: false,
participantsList: false, showParticipantsList: false,
pip: false, pip: false,
settingsDialogOpen: false, settingsDialogOpen: false,
}); });
@ -433,10 +440,11 @@ describe('calling duck', () => {
hasLocalVideo: true, hasLocalVideo: true,
remoteParticipants: [ remoteParticipants: [
{ {
conversationId: '123',
demuxId: 456, demuxId: 456,
userId: 'aead696f-4373-4e51-b9c2-1bb4d1adccf0',
hasRemoteAudio: false, hasRemoteAudio: false,
hasRemoteVideo: true, hasRemoteVideo: true,
isSelf: false,
videoAspectRatio: 16 / 9, videoAspectRatio: 16 / 9,
}, },
], ],
@ -559,7 +567,7 @@ describe('calling duck', () => {
conversationId: 'fake-conversation-id', conversationId: 'fake-conversation-id',
hasLocalAudio: true, hasLocalAudio: true,
hasLocalVideo: true, hasLocalVideo: true,
participantsList: false, showParticipantsList: false,
pip: false, pip: false,
settingsDialogOpen: false, settingsDialogOpen: false,
}); });
@ -638,7 +646,7 @@ describe('calling duck', () => {
conversationId: 'fake-conversation-id', conversationId: 'fake-conversation-id',
hasLocalAudio: true, hasLocalAudio: true,
hasLocalVideo: false, hasLocalVideo: false,
participantsList: false, showParticipantsList: false,
pip: false, pip: false,
settingsDialogOpen: false, settingsDialogOpen: false,
}); });
@ -688,9 +696,9 @@ describe('calling duck', () => {
toggleParticipants() toggleParticipants()
); );
assert.isTrue(afterOneToggle.activeCallState?.participantsList); assert.isTrue(afterOneToggle.activeCallState?.showParticipantsList);
assert.isFalse(afterTwoToggles.activeCallState?.participantsList); assert.isFalse(afterTwoToggles.activeCallState?.showParticipantsList);
assert.isTrue(afterThreeToggles.activeCallState?.participantsList); assert.isTrue(afterThreeToggles.activeCallState?.showParticipantsList);
}); });
}); });

View File

@ -27,7 +27,7 @@ describe('state/selectors/calling', () => {
conversationId: 'fake-direct-call-conversation-id', conversationId: 'fake-direct-call-conversation-id',
hasLocalAudio: true, hasLocalAudio: true,
hasLocalVideo: false, hasLocalVideo: false,
participantsList: false, showParticipantsList: false,
pip: false, pip: false,
settingsDialogOpen: false, settingsDialogOpen: false,
}, },

View File

@ -6,8 +6,8 @@ import { assert } from 'chai';
import { combineNames } from '../../util/combineNames'; import { combineNames } from '../../util/combineNames';
describe('combineNames', () => { describe('combineNames', () => {
it('returns null if no names provided', () => { it('returns undefined if no names provided', () => {
assert.strictEqual(combineNames('', ''), null); assert.strictEqual(combineNames('', ''), undefined);
}); });
it('returns first name only if family name not provided', () => { it('returns first name only if family name not provided', () => {

View File

@ -1,10 +1,12 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { ColorType } from './Colors';
export enum CallMode { export enum CallMode {
None, None = 'None',
Direct, Direct = 'Direct',
Group, Group = 'Group',
} }
// Ideally, we would import many of these directly from RingRTC. But because Storybook // Ideally, we would import many of these directly from RingRTC. But because Storybook
@ -56,6 +58,17 @@ export enum GroupCallJoinState {
Joined = 2, 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 // Should match RingRTC's CanvasVideoRenderer
interface Ref<T> { interface Ref<T> {
readonly current: T | null; readonly current: T | null;

View File

@ -35,9 +35,12 @@ const Hangul_Syllables = /[\uAC00-\uD7AF]/;
// From https://github.com/mathiasbynens/unicode-12.1.0/tree/master/Binary_Property/Ideographic // 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]/; 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) { if (!given) {
return null; return undefined;
} }
// Users who haven't upgraded to dual-name, or went minimal, will just have a given name // Users who haven't upgraded to dual-name, or went minimal, will just have a given name

View File

@ -14391,7 +14391,7 @@
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/CallScreen.js", "path": "ts/components/CallScreen.js",
"line": " const localVideoRef = react_1.useRef(null);", "line": " const localVideoRef = react_1.useRef(null);",
"lineNumber": 38, "lineNumber": 39,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-10-26T21:35:52.858Z", "updated": "2020-10-26T21:35:52.858Z",
"reasonDetail": "Used to get the local video element for rendering." "reasonDetail": "Used to get the local video element for rendering."
@ -14400,7 +14400,7 @@
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/CallingLobby.js", "path": "ts/components/CallingLobby.js",
"line": " const localVideoRef = react_1.default.useRef(null);", "line": " const localVideoRef = react_1.default.useRef(null);",
"lineNumber": 12, "lineNumber": 14,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z", "updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Used to get the local video element for rendering." "reasonDetail": "Used to get the local video element for rendering."
@ -14409,7 +14409,7 @@
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/CallingLobby.tsx", "path": "ts/components/CallingLobby.tsx",
"line": " const localVideoRef = React.useRef(null);", "line": " const localVideoRef = React.useRef(null);",
"lineNumber": 57, "lineNumber": 61,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z", "updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Used to get the local video element for rendering." "reasonDetail": "Used to get the local video element for rendering."
@ -14418,7 +14418,7 @@
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/CallingPip.js", "path": "ts/components/CallingPip.js",
"line": " const videoContainerRef = react_1.default.useRef(null);", "line": " const videoContainerRef = react_1.default.useRef(null);",
"lineNumber": 22, "lineNumber": 16,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z", "updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Element is measured. Its HTML is not used." "reasonDetail": "Element is measured. Its HTML is not used."
@ -14427,25 +14427,16 @@
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/CallingPip.js", "path": "ts/components/CallingPip.js",
"line": " const localVideoRef = react_1.default.useRef(null);", "line": " const localVideoRef = react_1.default.useRef(null);",
"lineNumber": 23, "lineNumber": 17,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z", "updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Used to get the local video element for rendering." "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", "rule": "React-useRef",
"path": "ts/components/CallingPip.tsx", "path": "ts/components/CallingPip.tsx",
"line": " const videoContainerRef = React.useRef(null);", "line": " const videoContainerRef = React.useRef(null);",
"lineNumber": 89, "lineNumber": 48,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z", "updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Element is measured. Its HTML is not used." "reasonDetail": "Element is measured. Its HTML is not used."
@ -14454,20 +14445,11 @@
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/CallingPip.tsx", "path": "ts/components/CallingPip.tsx",
"line": " const localVideoRef = React.useRef(null);", "line": " const localVideoRef = React.useRef(null);",
"lineNumber": 90, "lineNumber": 49,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z", "updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Used to get the local video element for rendering." "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", "rule": "React-createRef",
"path": "ts/components/CaptionEditor.js", "path": "ts/components/CaptionEditor.js",