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"
},
"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": {

View File

@ -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;

View File

@ -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> = {}): 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: () => <div />,
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', () => <CallManager {...createProps()} />);
story.add('Ongoing Direct Call', () => (
<CallManager
{...createProps({
activeCall: {
call: {
callMode: CallMode.Direct as CallMode.Direct,
@ -85,22 +106,17 @@ const permutations = [
isVideoCall: true,
hasRemoteVideo: true,
},
activeCallState: {
conversationId: '3051234567',
joinedAt: Date.now(),
hasLocalAudio: true,
hasLocalVideo: false,
participantsList: false,
pip: false,
settingsDialogOpen: false,
},
conversation,
activeCallState: getCallState(),
conversation: getConversation(),
groupCallParticipants: [],
},
},
},
{
title: 'Call Manager (ongoing group call)',
props: {
})}
/>
));
story.add('Ongoing Group Call', () => (
<CallManager
{...createProps({
activeCall: {
call: {
callMode: CallMode.Group as CallMode.Group,
@ -109,70 +125,36 @@ const permutations = [
joinState: GroupCallJoinState.Joined,
remoteParticipants: [],
},
activeCallState: {
conversationId: '3051234567',
joinedAt: Date.now(),
hasLocalAudio: true,
hasLocalVideo: false,
participantsList: false,
pip: false,
settingsDialogOpen: false,
},
conversation,
activeCallState: getCallState(),
conversation: getConversation(),
groupCallParticipants: [],
},
},
},
{
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', () => {
return permutations.map(
({ props, title }: { props: Partial<PropsType>; title: string }) => (
<>
<h3>{title}</h3>
<CallManager {...defaultProps} {...props} />
</>
)
);
});
story.add('Ringing', () => (
<CallManager
{...createProps({
incomingCall: {
call: getIncomingCallState(),
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
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<GroupCallRemoteParticipantType>;
}
export interface PropsType {
@ -101,13 +104,19 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
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<ActiveCallManagerPropsType> = ({
}
if (showCallLobby) {
const participantNames = groupCallParticipants.map(participant =>
participant.isSelf
? i18n('you')
: participant.firstName || participant.title
);
return (
<>
<CallingLobby
@ -168,12 +182,11 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
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<ActiveCallManagerPropsType> = ({
toggleSettings={toggleSettings}
/>
{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 && call.callMode === CallMode.Direct) {
const hasRemoteVideo = Boolean(call.hasRemoteVideo);
if (pip) {
return (
<CallingPip
call={call}
conversation={conversation}
createCanvasVideoRenderer={createCanvasVideoRenderer}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall}
hangUp={hangUp}
hasLocalVideo={hasLocalVideo}
hasRemoteVideo={hasRemoteVideo}
i18n={i18n}
setLocalPreview={setLocalPreview}
setRendererCanvas={setRendererCanvas}
@ -220,10 +239,19 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
setRendererCanvas={setRendererCanvas}
setLocalAudio={setLocalAudio}
setLocalVideo={setLocalVideo}
stickyControls={showParticipantsList}
toggleParticipants={toggleParticipants}
togglePip={togglePip}
toggleSettings={toggleSettings}
/>
{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 { 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<GroupCallParticipantInfoType>
): 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<GroupCallParticipantInfoType>;
} = {}
): 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 <CallScreen {...createProps({ callState: CallState.Prering })} />;
return (
<CallScreen
{...createProps({
callState: CallState.Prering,
})}
/>
);
});
story.add('Ringing', () => {
return <CallScreen {...createProps({ callState: CallState.Ringing })} />;
return (
<CallScreen
{...createProps({
callState: CallState.Ringing,
})}
/>
);
});
story.add('Reconnecting', () => {
return <CallScreen {...createProps({ callState: CallState.Reconnecting })} />;
return (
<CallScreen
{...createProps({
callState: CallState.Reconnecting,
})}
/>
);
});
story.add('Ended', () => {
return <CallScreen {...createProps({ callState: CallState.Ended })} />;
return (
<CallScreen
{...createProps({
callState: CallState.Ended,
})}
/>
);
});
story.add('hasLocalAudio', () => {
@ -113,3 +167,53 @@ story.add('hasLocalVideo', () => {
story.add('hasRemoteVideo', () => {
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,
} 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<PropsType> = ({
setLocalVideo,
setLocalPreview,
setRendererCanvas,
stickyControls,
toggleParticipants,
togglePip,
toggleSettings,
}) => {
@ -110,14 +115,14 @@ export const CallScreen: React.FC<PropsType> = ({
}, [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<PropsType> = ({
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 = (
<DirectCallRemoteParticipant
conversation={conversation}
hasRemoteVideo={hasRemoteVideo}
@ -166,7 +171,7 @@ export const CallScreen: React.FC<PropsType> = ({
remoteParticipant => remoteParticipant.hasRemoteVideo
);
isConnected = call.connectionState === GroupCallConnectionState.Connected;
remoteParticipants = (
remoteParticipantsElement = (
<GroupCallRemoteParticipants
remoteParticipants={call.remoteParticipants}
createCanvasVideoRenderer={createCanvasVideoRenderer}
@ -194,6 +199,9 @@ export const CallScreen: React.FC<PropsType> = ({
!showControls && !isAudioOnly && isConnected,
});
const remoteParticipants =
call.callMode === CallMode.Group ? call.remoteParticipants.length : 0;
return (
<div
className={classNames(
@ -208,40 +216,33 @@ export const CallScreen: React.FC<PropsType> = ({
role="group"
>
<div
className={classNames(
'module-calling__header',
'module-ongoing-call__header',
controlsFadeClass
)}
className={classNames('module-ongoing-call__header', controlsFadeClass)}
>
<div className="module-calling__header--header-name">
{conversation.title}
</div>
{call.callMode === CallMode.Direct &&
renderHeaderMessage(
i18n,
call.callState || CallState.Prering,
acceptedDuration
)}
<div className="module-calling-tools">
<button
type="button"
aria-label={i18n('callingDeviceSelection__settings')}
className="module-calling-tools__button module-calling-button__settings"
onClick={toggleSettings}
/>
{/* TODO: Group calls should also support the PiP. See DESKTOP-886. */}
{call.callMode === CallMode.Direct && (
<button
type="button"
aria-label={i18n('calling__pip')}
className="module-calling-tools__button module-calling-button__pip"
onClick={togglePip}
/>
)}
</div>
<CallingHeader
canPip
conversationTitle={
<>
{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}
/>
</div>
{remoteParticipants}
{remoteParticipantsElement}
<div className="module-ongoing-call__footer">
{/* This layout-only element is not ideal.
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 },
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 <CallingLobby {...props} />;
});
story.add('Group Call', () => {
const props = createProps({ isGroupCall: true });
story.add('Group Call - 0', () => {
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} />;
});

View File

@ -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<MediaDeviceInfo>;
@ -31,6 +33,7 @@ export type PropsType = {
};
onCallCanceled: () => void;
onJoinCall: () => void;
participantNames: Array<string>;
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 (
<div className="module-calling__container">
<div className="module-calling__header">
<div className="module-calling__header--header-name">
{conversation.title}
</div>
<div className="module-calling-tools">
{isGroupCall ? (
<button
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>
<CallingHeader
conversationTitle={conversation.title}
i18n={i18n}
isGroupCall={isGroupCall}
remoteParticipants={participantNames.length}
toggleParticipants={toggleParticipants}
toggleSettings={toggleSettings}
/>
<div className="module-calling-lobby__video">
{hasLocalVideo && availableCameras.length > 0 ? (
<video ref={localVideoRef} autoPlay />
@ -160,6 +154,32 @@ export const CallingLobby = ({
</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">
<button
className="module-button__gray module-calling-lobby__button"
@ -169,14 +189,29 @@ export const CallingLobby = ({
>
{i18n('cancel')}
</button>
<button
className="module-button__green module-calling-lobby__button"
onClick={onJoinCall}
tabIndex={0}
type="button"
>
{isGroupCall ? i18n('calling__join') : i18n('calling__start')}
</button>
{isCallConnecting && (
<button
className="module-button__green module-calling-lobby__button"
disabled
tabIndex={0}
type="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>
);

View File

@ -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>
): 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 => ({
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 <CallingParticipantsList {...props} />;
});
story.add('Solo Call', () => {
const props = createProps({
participants: [
createParticipant({
title: 'Bardock',
}),
],
});
return <CallingParticipantsList {...props} />;
});
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 <CallingParticipantsList {...props} />;

View File

@ -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<ParticipantType>;
readonly participants: Array<GroupCallRemoteParticipantType>;
};
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__header">
<div className="module-calling-participants-list__title">
{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),
])}
</div>
<button
type="button"
@ -67,37 +59,45 @@ export const CallingParticipantsList = React.memo(
/>
</div>
<ul className="module-calling-participants-list__list">
{participants.map((participant: ParticipantType, index: number) => (
<li
className="module-calling-participants-list__contact"
key={index}
>
<div>
<Avatar
avatarPath={participant.avatarPath}
color={participant.color}
conversationType="direct"
i18n={i18n}
profileName={participant.profileName}
title={participant.title}
size={32}
/>
<ContactName
i18n={i18n}
module="module-calling-participants-list__name"
title={participant.title}
/>
</div>
<div>
{participant.audioMuted ? (
<span className="module-calling-participants-list__muted--audio" />
) : null}
{participant.videoMuted ? (
<span className="module-calling-participants-list__muted--video" />
) : null}
</div>
</li>
))}
{participants.map(
(participant: GroupCallRemoteParticipantType, index: number) => (
<li
className="module-calling-participants-list__contact"
key={index}
>
<div>
<Avatar
avatarPath={participant.avatarPath}
color={participant.color}
conversationType="direct"
i18n={i18n}
profileName={participant.profileName}
title={participant.title}
size={32}
/>
{participant.isSelf ? (
<span className="module-calling-participants-list__name">
{i18n('you')}
</span>
) : (
<ContactName
i18n={i18n}
module="module-calling-participants-list__name"
title={participant.title}
/>
)}
</div>
<div>
{!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>
</div>
</div>,

View File

@ -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> = {}): 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 <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
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 (
<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 = {
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 ? (
<canvas
className="module-calling-pip__video--remote"
ref={remoteVideoRef}
/>
) : (
renderAvatar(conversation, i18n)
)}
<CallingPipRemoteVideo
call={call}
conversation={conversation}
createCanvasVideoRenderer={createCanvasVideoRenderer}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
i18n={i18n}
setRendererCanvas={setRendererCanvas}
/>
{hasLocalVideo ? (
<video
className="module-calling-pip__video--local"
@ -237,10 +194,18 @@ export const CallingPip = ({
/>
<button
type="button"
aria-label={i18n('calling__pip')}
aria-label={i18n('calling__pip--off')}
className="module-calling-pip__button--pip"
onClick={togglePip}
/>
>
<Tooltip
arrowSize={6}
content={i18n('calling__pip--off')}
hoverDelay={0}
>
<div />
</Tooltip>
</button>
</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;
hasRemoteAudio: boolean;
hasRemoteVideo: boolean;
height: number;
height: number | string;
left: number;
top: number;
width: number;
width: number | string;
}
export const GroupCallRemoteParticipant: React.FC<PropsType> = ({

View File

@ -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<Array<GroupCallRemoteParticipantType>>;
rows: Array<Array<GroupCallParticipantInfoType>>;
scalar: number;
}
interface PropsType {
createCanvasVideoRenderer: () => CanvasVideoRenderer;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
remoteParticipants: ReadonlyArray<GroupCallRemoteParticipantType>;
remoteParticipants: ReadonlyArray<GroupCallParticipantInfoType>;
}
// 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,
// 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
// 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<PropsType> = ({
};
function totalRemoteParticipantWidthAtMinHeight(
remoteParticipants: ReadonlyArray<GroupCallRemoteParticipantType>
remoteParticipants: ReadonlyArray<GroupCallParticipantInfoType>
): number {
return remoteParticipants.reduce(
(result, { videoAspectRatio }) =>

View File

@ -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 {

View File

@ -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),
};
}),
};
}

View File

@ -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<GroupCallRemoteParticipantType>;
remoteParticipants: Array<GroupCallParticipantInfoType>;
}
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<GroupCallRemoteParticipantType>;
remoteParticipants: Array<GroupCallParticipantInfoType>;
};
export type HangUpType = {
@ -148,7 +149,7 @@ export type ShowCallLobbyType =
joinState: GroupCallJoinState;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
remoteParticipants: Array<GroupCallRemoteParticipantType>;
remoteParticipants: Array<GroupCallParticipantInfoType>;
};
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,
},
};
}

View File

@ -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<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 {
call,
activeCallState,
call,
conversation,
groupCallParticipants,
};
};

View File

@ -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);
});
});

View File

@ -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,
},

View File

@ -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', () => {

View File

@ -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<T> {
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
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

View File

@ -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",