Add screensharing behind a feature flag

This commit is contained in:
Josh Perez 2021-05-20 17:54:03 -04:00 committed by Scott Nonnenberg
parent 7c7f7ee5a0
commit ceffc2380c
49 changed files with 2044 additions and 164 deletions

View file

@ -68,6 +68,7 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
declineCall: action('decline-call'),
getGroupCallVideoFrameSource: (_: string, demuxId: number) =>
fakeGetGroupCallVideoFrameSource(demuxId),
getPresentingSources: action('get-presenting-sources'),
hangUp: action('hang-up'),
i18n,
keyChangeOk: action('key-change-ok'),
@ -78,16 +79,21 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
}),
uuid: 'cb0dd0c8-7393-41e9-a0aa-d631c4109541',
},
openSystemPreferencesAction: action('open-system-preferences-action'),
renderDeviceSelection: () => <div />,
renderSafetyNumberViewer: (_: SafetyNumberViewerProps) => <div />,
setGroupCallVideoRequest: action('set-group-call-video-request'),
setLocalAudio: action('set-local-audio'),
setLocalPreview: action('set-local-preview'),
setLocalVideo: action('set-local-video'),
setPresenting: action('toggle-presenting'),
setRendererCanvas: action('set-renderer-canvas'),
startCall: action('start-call'),
toggleParticipants: action('toggle-participants'),
togglePip: action('toggle-pip'),
toggleScreenRecordingPermissionsDialog: action(
'toggle-screen-recording-permissions-dialog'
),
toggleSettings: action('toggle-settings'),
toggleSpeakerView: action('toggle-speaker-view'),
});
@ -104,7 +110,9 @@ story.add('Ongoing Direct Call', () => (
callMode: CallMode.Direct,
callState: CallState.Accepted,
peekedParticipants: [],
remoteParticipants: [{ hasRemoteVideo: true }],
remoteParticipants: [
{ hasRemoteVideo: true, presenting: false, title: 'Remy' },
],
},
})}
/>
@ -148,7 +156,9 @@ story.add('Call Request Needed', () => (
callMode: CallMode.Direct,
callState: CallState.Accepted,
peekedParticipants: [],
remoteParticipants: [{ hasRemoteVideo: true }],
remoteParticipants: [
{ hasRemoteVideo: true, presenting: false, title: 'Mike' },
],
},
})}
/>

View file

@ -6,6 +6,7 @@ import { CallNeedPermissionScreen } from './CallNeedPermissionScreen';
import { CallScreen } from './CallScreen';
import { CallingLobby } from './CallingLobby';
import { CallingParticipantsList } from './CallingParticipantsList';
import { CallingSelectPresentingSourcesModal } from './CallingSelectPresentingSourcesModal';
import { CallingPip } from './CallingPip';
import { IncomingCallBar } from './IncomingCallBar';
import {
@ -19,6 +20,7 @@ import {
CallState,
GroupCallJoinState,
GroupCallVideoRequest,
PresentedSource,
VideoFrameSource,
} from '../types/Calling';
import { ConversationType } from '../state/ducks/conversations';
@ -52,6 +54,7 @@ export type PropsType = {
conversationId: string,
demuxId: number
) => VideoFrameSource;
getPresentingSources: () => void;
incomingCall?: {
call: DirectCallStateType;
conversation: ConversationType;
@ -65,13 +68,16 @@ export type PropsType = {
declineCall: (_: DeclineCallType) => void;
i18n: LocalizerType;
me: MeType;
openSystemPreferencesAction: () => unknown;
setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void;
setLocalAudio: (_: SetLocalAudioType) => void;
setLocalVideo: (_: SetLocalVideoType) => void;
setLocalPreview: (_: SetLocalPreviewType) => void;
setPresenting: (_?: PresentedSource) => void;
setRendererCanvas: (_: SetRendererCanvasType) => void;
hangUp: (_: HangUpType) => void;
togglePip: () => void;
toggleScreenRecordingPermissionsDialog: () => unknown;
toggleSettings: () => void;
toggleSpeakerView: () => void;
};
@ -89,17 +95,21 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
i18n,
keyChangeOk,
getGroupCallVideoFrameSource,
getPresentingSources,
me,
openSystemPreferencesAction,
renderDeviceSelection,
renderSafetyNumberViewer,
setGroupCallVideoRequest,
setLocalAudio,
setLocalPreview,
setLocalVideo,
setPresenting,
setRendererCanvas,
startCall,
toggleParticipants,
togglePip,
toggleScreenRecordingPermissionsDialog,
toggleSettings,
toggleSpeakerView,
}) => {
@ -110,6 +120,7 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
joinedAt,
peekedParticipants,
pip,
presentingSourcesAvailable,
settingsDialogOpen,
showParticipantsList,
} = activeCall;
@ -238,13 +249,15 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
? [
...activeCall.remoteParticipants.map(participant => ({
...participant,
hasAudio: participant.hasRemoteAudio,
hasVideo: participant.hasRemoteVideo,
hasRemoteAudio: participant.hasRemoteAudio,
hasRemoteVideo: participant.hasRemoteVideo,
presenting: participant.presenting,
})),
{
...me,
hasAudio: hasLocalAudio,
hasVideo: hasLocalVideo,
hasRemoteAudio: hasLocalAudio,
hasRemoteVideo: hasLocalVideo,
presenting: Boolean(activeCall.presentingSource),
},
]
: [];
@ -253,22 +266,35 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
<>
<CallScreen
activeCall={activeCall}
getPresentingSources={getPresentingSources}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall}
hangUp={hangUp}
i18n={i18n}
joinedAt={joinedAt}
me={me}
openSystemPreferencesAction={openSystemPreferencesAction}
setGroupCallVideoRequest={setGroupCallVideoRequestForConversation}
setLocalPreview={setLocalPreview}
setRendererCanvas={setRendererCanvas}
setLocalAudio={setLocalAudio}
setLocalVideo={setLocalVideo}
setPresenting={setPresenting}
stickyControls={showParticipantsList}
toggleScreenRecordingPermissionsDialog={
toggleScreenRecordingPermissionsDialog
}
toggleParticipants={toggleParticipants}
togglePip={togglePip}
toggleSettings={toggleSettings}
toggleSpeakerView={toggleSpeakerView}
/>
{presentingSourcesAvailable && presentingSourcesAvailable.length ? (
<CallingSelectPresentingSourcesModal
i18n={i18n}
presentingSourcesAvailable={presentingSourcesAvailable}
setPresenting={setPresenting}
/>
) : null}
{settingsDialogOpen && renderDeviceSelection()}
{showParticipantsList && activeCall.callMode === CallMode.Group ? (
<CallingParticipantsList

View file

@ -74,10 +74,14 @@ const createActiveDirectCallProp = (
'hasRemoteVideo',
Boolean(overrideProps.hasRemoteVideo)
),
presenting: false,
title: 'test',
},
] as [
{
hasRemoteVideo: boolean;
presenting: boolean;
title: string;
}
],
});
@ -137,6 +141,7 @@ const createProps = (
): PropsType => ({
activeCall: createActiveCallProp(overrideProps),
getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource,
getPresentingSources: action('get-presenting-sources'),
hangUp: action('hang-up'),
i18n,
me: {
@ -145,14 +150,19 @@ const createProps = (
profileName: 'Morty Smith',
title: 'Morty Smith',
},
openSystemPreferencesAction: action('open-system-preferences-action'),
setGroupCallVideoRequest: action('set-group-call-video-request'),
setLocalAudio: action('set-local-audio'),
setLocalPreview: action('set-local-preview'),
setLocalVideo: action('set-local-video'),
setPresenting: action('toggle-presenting'),
setRendererCanvas: action('set-renderer-canvas'),
stickyControls: boolean('stickyControls', false),
toggleParticipants: action('toggle-participants'),
togglePip: action('toggle-pip'),
toggleScreenRecordingPermissionsDialog: action(
'toggle-screen-recording-permissions-dialog'
),
toggleSettings: action('toggle-settings'),
toggleSpeakerView: action('toggle-speaker-view'),
});
@ -249,6 +259,8 @@ story.add('Group call - 1', () => (
demuxId: 0,
hasRemoteAudio: true,
hasRemoteVideo: true,
presenting: false,
sharingScreen: false,
videoAspectRatio: 1.3,
...getDefaultConversation({
isBlocked: false,
@ -266,6 +278,8 @@ const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => ({
demuxId: index,
hasRemoteAudio: index % 3 !== 0,
hasRemoteVideo: index % 4 !== 0,
presenting: false,
sharingScreen: false,
videoAspectRatio: 1.3,
...getDefaultConversation({
isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1,
@ -303,6 +317,8 @@ story.add('Group call - reconnecting', () => (
demuxId: 0,
hasRemoteAudio: true,
hasRemoteVideo: true,
presenting: false,
sharingScreen: false,
videoAspectRatio: 1.3,
...getDefaultConversation({
isBlocked: false,

View file

@ -21,18 +21,23 @@ import {
CallState,
GroupCallConnectionState,
GroupCallVideoRequest,
PresentedSource,
VideoFrameSource,
} from '../types/Calling';
import { CallingToastManager } from './CallingToastManager';
import { ColorType } from '../types/Colors';
import { LocalizerType } from '../types/Util';
import { missingCaseError } from '../util/missingCaseError';
import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant';
import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants';
import { GroupCallToastManager } from './GroupCallToastManager';
import { LocalizerType } from '../types/Util';
import { isScreenSharingEnabled } from '../util/isScreenSharingEnabled';
import { missingCaseError } from '../util/missingCaseError';
import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting';
import { NeedsScreenRecordingPermissionsModal } from './NeedsScreenRecordingPermissionsModal';
export type PropsType = {
activeCall: ActiveCallType;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
getPresentingSources: () => void;
hangUp: (_: HangUpType) => void;
i18n: LocalizerType;
joinedAt?: number;
@ -44,14 +49,17 @@ export type PropsType = {
profileName?: string;
title: string;
};
openSystemPreferencesAction: () => unknown;
setGroupCallVideoRequest: (_: Array<GroupCallVideoRequest>) => void;
setLocalAudio: (_: SetLocalAudioType) => void;
setLocalVideo: (_: SetLocalVideoType) => void;
setLocalPreview: (_: SetLocalPreviewType) => void;
setPresenting: (_?: PresentedSource) => void;
setRendererCanvas: (_: SetRendererCanvasType) => void;
stickyControls: boolean;
toggleParticipants: () => void;
togglePip: () => void;
toggleScreenRecordingPermissionsDialog: () => unknown;
toggleSettings: () => void;
toggleSpeakerView: () => void;
};
@ -59,18 +67,22 @@ export type PropsType = {
export const CallScreen: React.FC<PropsType> = ({
activeCall,
getGroupCallVideoFrameSource,
getPresentingSources,
hangUp,
i18n,
joinedAt,
me,
openSystemPreferencesAction,
setGroupCallVideoRequest,
setLocalAudio,
setLocalVideo,
setLocalPreview,
setPresenting,
setRendererCanvas,
stickyControls,
toggleParticipants,
togglePip,
toggleScreenRecordingPermissionsDialog,
toggleSettings,
toggleSpeakerView,
}) => {
@ -78,9 +90,19 @@ export const CallScreen: React.FC<PropsType> = ({
conversation,
hasLocalAudio,
hasLocalVideo,
isInSpeakerView,
presentingSource,
remoteParticipants,
showNeedsScreenRecordingPermissionsWarning,
showParticipantsList,
} = activeCall;
useActivateSpeakerViewOnPresenting(
remoteParticipants,
isInSpeakerView,
toggleSpeakerView
);
const toggleAudio = useCallback(() => {
setLocalAudio({
enabled: !hasLocalAudio,
@ -93,6 +115,14 @@ export const CallScreen: React.FC<PropsType> = ({
});
}, [setLocalVideo, hasLocalVideo]);
const togglePresenting = useCallback(() => {
if (presentingSource) {
setPresenting();
} else {
getPresentingSources();
}
}, [getPresentingSources, presentingSource, setPresenting]);
const [acceptedDuration, setAcceptedDuration] = useState<number | null>(null);
const [showControls, setShowControls] = useState(true);
@ -151,7 +181,11 @@ export const CallScreen: React.FC<PropsType> = ({
};
}, [toggleAudio, toggleVideo]);
const hasRemoteVideo = activeCall.remoteParticipants.some(
const currentPresenter = remoteParticipants.find(
participant => participant.presenting
);
const hasRemoteVideo = remoteParticipants.some(
remoteParticipant => remoteParticipant.hasRemoteVideo
);
@ -183,16 +217,22 @@ export const CallScreen: React.FC<PropsType> = ({
case CallMode.Group:
participantCount = activeCall.remoteParticipants.length + 1;
headerMessage = undefined;
headerTitle = activeCall.remoteParticipants.length
? undefined
: i18n('calling__in-this-call--zero');
if (currentPresenter) {
headerTitle = i18n('calling__presenting--person-ongoing', [
currentPresenter.title,
]);
} else if (!activeCall.remoteParticipants.length) {
headerTitle = i18n('calling__in-this-call--zero');
}
isConnected =
activeCall.connectionState === GroupCallConnectionState.Connected;
remoteParticipantsElement = (
<GroupCallRemoteParticipants
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
i18n={i18n}
isInSpeakerView={activeCall.isInSpeakerView}
isInSpeakerView={isInSpeakerView}
remoteParticipants={activeCall.remoteParticipants}
setGroupCallVideoRequest={setGroupCallVideoRequest}
/>
@ -206,9 +246,15 @@ export const CallScreen: React.FC<PropsType> = ({
activeCall.callMode === CallMode.Group &&
!activeCall.remoteParticipants.length;
const videoButtonType = hasLocalVideo
? CallingButtonType.VIDEO_ON
: CallingButtonType.VIDEO_OFF;
let videoButtonType: CallingButtonType;
if (presentingSource) {
videoButtonType = CallingButtonType.VIDEO_DISABLED;
} else if (hasLocalVideo) {
videoButtonType = CallingButtonType.VIDEO_ON;
} else {
videoButtonType = CallingButtonType.VIDEO_OFF;
}
const audioButtonType = hasLocalAudio
? CallingButtonType.AUDIO_ON
: CallingButtonType.AUDIO_OFF;
@ -222,6 +268,23 @@ export const CallScreen: React.FC<PropsType> = ({
!showControls && !isAudioOnly && isConnected,
});
const isGroupCall = activeCall.callMode === CallMode.Group;
const localPreviewVideoClass = classNames({
'module-ongoing-call__footer__local-preview__video': true,
'module-ongoing-call__footer__local-preview__video--presenting': Boolean(
presentingSource
),
});
let presentingButtonType: CallingButtonType;
if (presentingSource) {
presentingButtonType = CallingButtonType.PRESENTING_ON;
} else if (currentPresenter) {
presentingButtonType = CallingButtonType.PRESENTING_DISABLED;
} else {
presentingButtonType = CallingButtonType.PRESENTING_OFF;
}
return (
<div
className={classNames(
@ -235,20 +298,24 @@ export const CallScreen: React.FC<PropsType> = ({
}}
role="group"
>
{activeCall.callMode === CallMode.Group ? (
<GroupCallToastManager
connectionState={activeCall.connectionState}
{showNeedsScreenRecordingPermissionsWarning ? (
<NeedsScreenRecordingPermissionsModal
toggleScreenRecordingPermissionsDialog={
toggleScreenRecordingPermissionsDialog
}
i18n={i18n}
openSystemPreferencesAction={openSystemPreferencesAction}
/>
) : null}
<CallingToastManager activeCall={activeCall} i18n={i18n} />
<div
className={classNames('module-ongoing-call__header', controlsFadeClass)}
>
<CallingHeader
canPip
i18n={i18n}
isInSpeakerView={activeCall.isInSpeakerView}
isGroupCall={activeCall.callMode === CallMode.Group}
isInSpeakerView={isInSpeakerView}
isGroupCall={isGroupCall}
message={headerMessage}
participantCount={participantCount}
showParticipantsList={showParticipantsList}
@ -263,7 +330,7 @@ export const CallScreen: React.FC<PropsType> = ({
{hasLocalVideo && isLonelyInGroup ? (
<div className="module-ongoing-call__local-preview-fullsize">
<video
className="module-ongoing-call__footer__local-preview__video"
className={localPreviewVideoClass}
ref={localVideoRef}
autoPlay
/>
@ -308,6 +375,13 @@ export const CallScreen: React.FC<PropsType> = ({
controlsFadeClass
)}
>
{isScreenSharingEnabled() ? (
<CallingButton
buttonType={presentingButtonType}
i18n={i18n}
onClick={togglePresenting}
/>
) : null}
<CallingButton
buttonType={videoButtonType}
i18n={i18n}
@ -333,7 +407,7 @@ export const CallScreen: React.FC<PropsType> = ({
>
{hasLocalVideo && !isLonelyInGroup ? (
<video
className="module-ongoing-call__footer__local-preview__video"
className={localPreviewVideoClass}
ref={localVideoRef}
autoPlay
/>

View file

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -14,11 +14,9 @@ import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
buttonType: select(
'buttonType',
CallingButtonType,
overrideProps.buttonType || CallingButtonType.HANG_UP
),
buttonType:
overrideProps.buttonType ||
select('buttonType', CallingButtonType, CallingButtonType.HANG_UP),
i18n,
onClick: action('on-click'),
tooltipDirection: select(
@ -30,9 +28,16 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
const story = storiesOf('Components/CallingButton', module);
story.add('Default', () => {
const props = createProps();
return <CallingButton {...props} />;
story.add('Kitchen Sink', () => {
return (
<>
{Object.keys(CallingButtonType).map(buttonType => (
<CallingButton
{...createProps({ buttonType: buttonType as CallingButtonType })}
/>
))}
</>
);
});
story.add('Audio On', () => {
@ -83,3 +88,17 @@ story.add('Tooltip right', () => {
});
return <CallingButton {...props} />;
});
story.add('Presenting On', () => {
const props = createProps({
buttonType: CallingButtonType.PRESENTING_ON,
});
return <CallingButton {...props} />;
});
story.add('Presenting Off', () => {
const props = createProps({
buttonType: CallingButtonType.PRESENTING_OFF,
});
return <CallingButton {...props} />;
});

View file

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
@ -12,6 +12,9 @@ export enum CallingButtonType {
AUDIO_OFF = 'AUDIO_OFF',
AUDIO_ON = 'AUDIO_ON',
HANG_UP = 'HANG_UP',
PRESENTING_DISABLED = 'PRESENTING_DISABLED',
PRESENTING_OFF = 'PRESENTING_OFF',
PRESENTING_ON = 'PRESENTING_ON',
VIDEO_DISABLED = 'VIDEO_DISABLED',
VIDEO_OFF = 'VIDEO_OFF',
VIDEO_ON = 'VIDEO_ON',
@ -32,9 +35,11 @@ export const CallingButton = ({
}: PropsType): JSX.Element => {
let classNameSuffix = '';
let tooltipContent = '';
let disabled = false;
if (buttonType === CallingButtonType.AUDIO_DISABLED) {
classNameSuffix = 'audio--disabled';
tooltipContent = i18n('calling__button--audio-disabled');
disabled = true;
} else if (buttonType === CallingButtonType.AUDIO_OFF) {
classNameSuffix = 'audio--off';
tooltipContent = i18n('calling__button--audio-on');
@ -44,6 +49,7 @@ export const CallingButton = ({
} else if (buttonType === CallingButtonType.VIDEO_DISABLED) {
classNameSuffix = 'video--disabled';
tooltipContent = i18n('calling__button--video-disabled');
disabled = true;
} else if (buttonType === CallingButtonType.VIDEO_OFF) {
classNameSuffix = 'video--off';
tooltipContent = i18n('calling__button--video-on');
@ -53,6 +59,16 @@ export const CallingButton = ({
} else if (buttonType === CallingButtonType.HANG_UP) {
classNameSuffix = 'hangup';
tooltipContent = i18n('calling__hangup');
} else if (buttonType === CallingButtonType.PRESENTING_DISABLED) {
classNameSuffix = 'presenting--disabled';
tooltipContent = i18n('calling__button--presenting-disabled');
disabled = true;
} else if (buttonType === CallingButtonType.PRESENTING_ON) {
classNameSuffix = 'presenting--on';
tooltipContent = i18n('calling__button--presenting-off');
} else if (buttonType === CallingButtonType.PRESENTING_OFF) {
classNameSuffix = 'presenting--off';
tooltipContent = i18n('calling__button--presenting-on');
}
const className = classNames(
@ -68,9 +84,10 @@ export const CallingButton = ({
>
<button
aria-label={tooltipContent}
type="button"
className={className}
disabled={disabled}
onClick={onClick}
type="button"
>
<div />
</button>

View file

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
@ -23,6 +23,8 @@ function createParticipant(
demuxId: 2,
hasRemoteAudio: Boolean(participantProps.hasRemoteAudio),
hasRemoteVideo: Boolean(participantProps.hasRemoteVideo),
presenting: Boolean(participantProps.presenting),
sharingScreen: Boolean(participantProps.sharingScreen),
videoAspectRatio: 1.3,
...getDefaultConversation({
avatarPath: participantProps.avatarPath,
@ -69,7 +71,7 @@ story.add('Many Participants', () => {
}),
createParticipant({
hasRemoteAudio: true,
hasRemoteVideo: true,
presenting: true,
name: 'Rage Trunks',
title: 'Rage Trunks',
}),

View file

@ -13,8 +13,9 @@ import { sortByTitle } from '../util/sortByTitle';
import { ConversationType } from '../state/ducks/conversations';
type ParticipantType = ConversationType & {
hasAudio?: boolean;
hasVideo?: boolean;
hasRemoteAudio?: boolean;
hasRemoteVideo?: boolean;
presenting?: boolean;
};
export type PropsType = {
@ -130,12 +131,15 @@ export const CallingParticipantsList = React.memo(
)}
</div>
<div>
{participant.hasAudio === false ? (
{participant.hasRemoteAudio === false ? (
<span className="module-calling-participants-list__muted--audio" />
) : null}
{participant.hasVideo === false ? (
{participant.hasRemoteVideo === false ? (
<span className="module-calling-participants-list__muted--video" />
) : null}
{participant.presenting ? (
<span className="module-calling-participants-list__presenting" />
) : null}
</div>
</li>
)

View file

@ -49,7 +49,9 @@ const defaultCall: ActiveCallType = {
callMode: CallMode.Direct as CallMode.Direct,
callState: CallState.Accepted,
peekedParticipants: [],
remoteParticipants: [{ hasRemoteVideo: true }],
remoteParticipants: [
{ hasRemoteVideo: true, presenting: false, title: 'Arsene' },
],
};
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
@ -79,7 +81,9 @@ story.add('Contact (with avatar and no video)', () => {
...conversation,
avatarPath: 'https://www.fillmurray.com/64/64',
},
remoteParticipants: [{ hasRemoteVideo: false }],
remoteParticipants: [
{ hasRemoteVideo: false, presenting: false, title: 'Julian' },
],
},
});
return <CallingPip {...props} />;

View file

@ -96,9 +96,8 @@ export const CallingPipRemoteVideo = ({
return undefined;
}
return maxBy(
activeCall.remoteParticipants,
participant => participant.speakerTime || -Infinity
return maxBy(activeCall.remoteParticipants, participant =>
participant.presenting ? Infinity : participant.speakerTime || -Infinity
);
}, [activeCall.callMode, activeCall.remoteParticipants]);

View file

@ -0,0 +1,29 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import {
CallingScreenSharingController,
PropsType,
} from './CallingScreenSharingController';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const createProps = (): PropsType => ({
i18n,
onCloseController: action('on-close-controller'),
onStopSharing: action('on-stop-sharing'),
presentedSourceName: 'Application',
});
const story = storiesOf('Components/CallingScreenSharingController', module);
story.add('Controller', () => {
return <CallingScreenSharingController {...createProps()} />;
});

View file

@ -0,0 +1,39 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Button, ButtonVariant } from './Button';
import { LocalizerType } from '../types/Util';
export type PropsType = {
i18n: LocalizerType;
onCloseController: () => unknown;
onStopSharing: () => unknown;
presentedSourceName: string;
};
export const CallingScreenSharingController = ({
i18n,
onCloseController,
onStopSharing,
presentedSourceName,
}: PropsType): JSX.Element => {
return (
<div className="module-CallingScreenSharingController">
<div className="module-CallingScreenSharingController__text">
{i18n('calling__presenting--info', [presentedSourceName])}
</div>
<div className="module-CallingScreenSharingController__buttons">
<Button onClick={onStopSharing} variant={ButtonVariant.Destructive}>
{i18n('calling__presenting--stop')}
</Button>
<button
aria-label={i18n('close')}
className="module-CallingScreenSharingController__close"
onClick={onCloseController}
type="button"
/>
</div>
</div>
);
};

View file

@ -0,0 +1,61 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import {
CallingSelectPresentingSourcesModal,
PropsType,
} from './CallingSelectPresentingSourcesModal';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const createProps = (): PropsType => ({
i18n,
presentingSourcesAvailable: [
{
id: 'screen',
name: 'Entire Screen',
thumbnail:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P/1PwAF8AL1sEVIPAAAAABJRU5ErkJggg==',
},
{
id: 'window:123',
name: 'Bozirro Airhorse',
thumbnail:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z1D4HwAF5wJxzsNOIAAAAABJRU5ErkJggg==',
},
{
id: 'window:456',
name: 'Discoverer',
thumbnail:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8HwHwAFHQIIj4yLtgAAAABJRU5ErkJggg==',
},
{
id: 'window:789',
name: 'Signal Beta',
thumbnail: '',
},
{
id: 'window:xyz',
name: 'Window that has a really long name and overflows',
thumbnail:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+O/wHwAEhgJAyqFnAgAAAABJRU5ErkJggg==',
},
],
setPresenting: action('set-presenting'),
});
const story = storiesOf(
'Components/CallingSelectPresentingSourcesModal',
module
);
story.add('Modal', () => {
return <CallingSelectPresentingSourcesModal {...createProps()} />;
});

View file

@ -0,0 +1,137 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import classNames from 'classnames';
import { groupBy } from 'lodash';
import { Button, ButtonVariant } from './Button';
import { LocalizerType } from '../types/Util';
import { Modal } from './Modal';
import { PresentedSource, PresentableSource } from '../types/Calling';
import { Theme } from '../util/theme';
export type PropsType = {
i18n: LocalizerType;
presentingSourcesAvailable: Array<PresentableSource>;
setPresenting: (_?: PresentedSource) => void;
};
const Source = ({
onSourceClick,
source,
sourceToPresent,
}: {
onSourceClick: (source: PresentedSource) => void;
source: PresentableSource;
sourceToPresent?: PresentedSource;
}): JSX.Element => {
return (
<button
className={classNames({
'module-CallingSelectPresentingSourcesModal__source': true,
'module-CallingSelectPresentingSourcesModal__source--selected':
sourceToPresent?.id === source.id,
})}
key={source.id}
onClick={() => {
onSourceClick({
id: source.id,
name: source.name,
});
}}
type="button"
>
<img
alt={source.name}
className="module-CallingSelectPresentingSourcesModal__name--screenshot"
src={source.thumbnail}
/>
<div className="module-CallingSelectPresentingSourcesModal__name--container">
{source.appIcon ? (
<img
alt={source.name}
className="module-CallingSelectPresentingSourcesModal__name--icon"
height={16}
src={source.appIcon}
width={16}
/>
) : null}
<span className="module-CallingSelectPresentingSourcesModal__name--text">
{source.name}
</span>
</div>
</button>
);
};
export const CallingSelectPresentingSourcesModal = ({
i18n,
presentingSourcesAvailable,
setPresenting,
}: PropsType): JSX.Element | null => {
const [sourceToPresent, setSourceToPresent] = useState<
PresentedSource | undefined
>(undefined);
if (!presentingSourcesAvailable.length) {
throw new Error('No sources available for presenting');
}
const sources = groupBy(presentingSourcesAvailable, source =>
source.id.startsWith('screen')
);
return (
<Modal
hasXButton
i18n={i18n}
moduleClassName="module-CallingSelectPresentingSourcesModal"
onClose={() => {
setPresenting(sourceToPresent);
}}
theme={Theme.Dark}
title={i18n('calling__SelectPresentingSourcesModal--title')}
>
<div className="module-CallingSelectPresentingSourcesModal__title">
{i18n('calling__SelectPresentingSourcesModal--entireScreen')}
</div>
<div className="module-CallingSelectPresentingSourcesModal__sources">
{sources.true.map(source => (
<Source
key={source.id}
onSourceClick={selectedSource => setSourceToPresent(selectedSource)}
source={source}
sourceToPresent={sourceToPresent}
/>
))}
</div>
<div className="module-CallingSelectPresentingSourcesModal__title">
{i18n('calling__SelectPresentingSourcesModal--window')}
</div>
<div className="module-CallingSelectPresentingSourcesModal__sources">
{sources.false.map(source => (
<Source
key={source.id}
onSourceClick={selectedSource => setSourceToPresent(selectedSource)}
source={source}
sourceToPresent={sourceToPresent}
/>
))}
</div>
<Modal.Footer moduleClassName="module-CallingSelectPresentingSourcesModal">
<Button
onClick={() => setPresenting()}
variant={ButtonVariant.Secondary}
>
{i18n('cancel')}
</Button>
<Button
disabled={!sourceToPresent}
onClick={() => setPresenting(sourceToPresent)}
>
{i18n('calling__SelectPresentingSourcesModal--confirm')}
</Button>
</Modal.Footer>
</Modal>
);
};

View file

@ -0,0 +1,163 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import {
ActiveCallType,
CallMode,
GroupCallConnectionState,
} from '../types/Calling';
import { ConversationType } from '../state/ducks/conversations';
import { LocalizerType } from '../types/Util';
type PropsType = {
activeCall: ActiveCallType;
i18n: LocalizerType;
};
type ToastType =
| {
message: string;
type: 'dismissable' | 'static';
}
| undefined;
function getReconnectingToast({ activeCall, i18n }: PropsType): ToastType {
if (
activeCall.callMode === CallMode.Group &&
activeCall.connectionState === GroupCallConnectionState.Reconnecting
) {
return {
message: i18n('callReconnecting'),
type: 'static',
};
}
return undefined;
}
const ME = Symbol('me');
function getCurrentPresenter(
activeCall: Readonly<ActiveCallType>
): ConversationType | typeof ME | undefined {
if (activeCall.presentingSource) {
return ME;
}
if (activeCall.callMode === CallMode.Direct) {
const isOtherPersonPresenting = activeCall.remoteParticipants.some(
participant => participant.presenting
);
return isOtherPersonPresenting ? activeCall.conversation : undefined;
}
if (activeCall.callMode === CallMode.Group) {
return activeCall.remoteParticipants.find(
participant => participant.presenting
);
}
return undefined;
}
function useScreenSharingToast({ activeCall, i18n }: PropsType): ToastType {
const [result, setResult] = useState<undefined | ToastType>(undefined);
const [previousPresenter, setPreviousPresenter] = useState<
undefined | { id: string | typeof ME; title?: string }
>(undefined);
const previousPresenterId = previousPresenter?.id;
const previousPresenterTitle = previousPresenter?.title;
useEffect(() => {
const currentPresenter = getCurrentPresenter(activeCall);
if (!currentPresenter && previousPresenterId) {
if (previousPresenterId === ME) {
setResult({
type: 'dismissable',
message: i18n('calling__presenting--you-stopped'),
});
} else if (previousPresenterTitle) {
setResult({
type: 'dismissable',
message: i18n('calling__presenting--person-stopped', [
previousPresenterTitle,
]),
});
}
}
}, [activeCall, i18n, previousPresenterId, previousPresenterTitle]);
useEffect(() => {
const currentPresenter = getCurrentPresenter(activeCall);
if (currentPresenter === ME) {
setPreviousPresenter({
id: ME,
});
} else if (!currentPresenter) {
setPreviousPresenter(undefined);
} else {
const { id, title } = currentPresenter;
setPreviousPresenter({ id, title });
}
}, [activeCall]);
return result;
}
const DEFAULT_DELAY = 5000;
// In the future, this component should show toasts when users join or leave. See
// DESKTOP-902.
export const CallingToastManager: React.FC<PropsType> = props => {
const reconnectingToast = getReconnectingToast(props);
const screenSharingToast = useScreenSharingToast(props);
let toast: ToastType;
if (reconnectingToast) {
toast = reconnectingToast;
} else if (screenSharingToast) {
toast = screenSharingToast;
}
const [toastMessage, setToastMessage] = useState('');
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const dismissToast = useCallback(() => {
if (timeoutRef) {
setToastMessage('');
}
}, [setToastMessage, timeoutRef]);
useEffect(() => {
if (toast) {
if (toast.type === 'dismissable') {
if (timeoutRef && timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(dismissToast, DEFAULT_DELAY);
}
setToastMessage(toast.message);
}
return () => {
if (timeoutRef && timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [dismissToast, setToastMessage, timeoutRef, toast]);
const isVisible = Boolean(toastMessage);
return (
<button
className={classNames('module-ongoing-call__toast', {
'module-ongoing-call__toast--hidden': !isVisible,
})}
type="button"
onClick={dismissToast}
>
{toastMessage}
</button>
);
};

View file

@ -22,6 +22,8 @@ const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => ({
demuxId: index,
hasRemoteAudio: index % 3 !== 0,
hasRemoteVideo: index % 4 !== 0,
presenting: false,
sharingScreen: false,
videoAspectRatio: 1.3,
...getDefaultConversation({
isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1,

View file

@ -42,6 +42,8 @@ const createProps = (
demuxId: 123,
hasRemoteAudio: false,
hasRemoteVideo: true,
presenting: false,
sharingScreen: false,
videoAspectRatio: 1.3,
...getDefaultConversation({
isBlocked: Boolean(isBlocked),

View file

@ -105,7 +105,8 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
// 2. Split participants into two groups: ones in the main grid and ones in the overflow
// sidebar.
//
// We start by sorting by `speakerTime` so that the most recent speakers are first in
// We start by sorting by `presenting` first since presenters should be on the main grid
// then we sort by `speakerTime` so that the most recent speakers are next in
// line for the main grid. Then we split the list in two: one for the grid and one for
// the overflow area.
//
@ -119,7 +120,9 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
remoteParticipants
.concat()
.sort(
(a, b) => (b.speakerTime || -Infinity) - (a.speakerTime || -Infinity)
(a, b) =>
Number(b.presenting || 0) - Number(a.presenting || 0) ||
(b.speakerTime || -Infinity) - (a.speakerTime || -Infinity)
),
[remoteParticipants]
);
@ -275,18 +278,23 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
if (isPageVisible) {
setGroupCallVideoRequest([
...gridParticipants.map(participant => {
if (participant.hasRemoteVideo) {
return {
demuxId: participant.demuxId,
width: Math.floor(
gridParticipantHeight *
participant.videoAspectRatio *
VIDEO_REQUEST_SCALAR
),
height: Math.floor(gridParticipantHeight * VIDEO_REQUEST_SCALAR),
};
let scalar: number;
if (participant.sharingScreen) {
// We want best-resolution video if someone is sharing their screen. This code
// is extra-defensive against strange devicePixelRatios.
scalar = Math.max(window.devicePixelRatio || 1, 1);
} else if (participant.hasRemoteVideo) {
scalar = VIDEO_REQUEST_SCALAR;
} else {
scalar = 0;
}
return nonRenderedRemoteParticipant(participant);
return {
demuxId: participant.demuxId,
width: Math.floor(
gridParticipantHeight * participant.videoAspectRatio * scalar
),
height: Math.floor(gridParticipantHeight * scalar),
};
}),
...overflowedParticipants.map(participant => {
if (participant.hasRemoteVideo) {

View file

@ -1,37 +0,0 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState, useEffect } from 'react';
import classNames from 'classnames';
import { GroupCallConnectionState } from '../types/Calling';
import { LocalizerType } from '../types/Util';
type PropsType = {
connectionState: GroupCallConnectionState;
i18n: LocalizerType;
};
// In the future, this component should show toasts when users join or leave. See
// DESKTOP-902.
export const GroupCallToastManager: React.FC<PropsType> = ({
connectionState,
i18n,
}) => {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
setIsVisible(connectionState === GroupCallConnectionState.Reconnecting);
}, [connectionState, setIsVisible]);
const message = i18n('callReconnecting');
return (
<div
className={classNames('module-ongoing-call__toast', {
'module-ongoing-call__toast--hidden': !isVisible,
})}
>
{message}
</div>
);
};

View file

@ -0,0 +1,60 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { LocalizerType } from '../types/Util';
import { Theme } from '../util/theme';
import { Modal } from './Modal';
import { Button, ButtonVariant } from './Button';
type PropsType = {
i18n: LocalizerType;
openSystemPreferencesAction: () => unknown;
toggleScreenRecordingPermissionsDialog: () => unknown;
};
function focusRef(el: HTMLElement | null) {
if (el) {
el.focus();
}
}
export const NeedsScreenRecordingPermissionsModal = ({
i18n,
openSystemPreferencesAction,
toggleScreenRecordingPermissionsDialog,
}: PropsType): JSX.Element => {
return (
<Modal
i18n={i18n}
title={i18n('calling__presenting--permission-title')}
theme={Theme.Dark}
>
<p>{i18n('calling__presenting--macos-permission-description')}</p>
<ol style={{ paddingLeft: 16 }}>
<li>{i18n('calling__presenting--permission-instruction-step1')}</li>
<li>{i18n('calling__presenting--permission-instruction-step2')}</li>
<li>{i18n('calling__presenting--permission-instruction-step3')}</li>
<li>{i18n('calling__presenting--permission-instruction-step4')}</li>
</ol>
<Modal.Footer>
<Button
onClick={toggleScreenRecordingPermissionsDialog}
ref={focusRef}
variant={ButtonVariant.Secondary}
>
{i18n('calling__presenting--permission-cancel')}
</Button>
<Button
onClick={() => {
openSystemPreferencesAction();
toggleScreenRecordingPermissionsDialog();
}}
variant={ButtonVariant.Primary}
>
{i18n('calling__presenting--permission-open')}
</Button>
</Modal.Footer>
</Modal>
);
};