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

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