Add paginated calling grid for group calls

This commit is contained in:
trevor-signal 2023-11-13 09:56:48 -05:00 committed by GitHub
parent 9c3d404c82
commit cf5b3f78b5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1237 additions and 476 deletions

View file

@ -3663,13 +3663,26 @@
"messageformat": "Fullscreen call",
"description": "Title for picture-in-picture toggle"
},
"icu:calling__switch-view--to-grid": {
"messageformat": "Switch to grid view",
"description": "Title for grid/speaker view toggle when on a call"
"icu:calling__change-view": {
"messageformat": "Change view",
"description": "Tooltip for changing the in-call layout of remote participants in a group call"
},
"icu:calling__switch-view--to-speaker": {
"messageformat": "Switch to speaker view",
"description": "Title for grid/speaker view toggle when on a call"
"icu:calling__view_mode--paginated": {
"messageformat": "Grid view",
"description": "Label for option to view participants in a group call in a paginated grid view"
},
"icu:calling__view_mode--overflow": {
"messageformat": "Sidebar view",
"description": "Label for option to view participants in a group call where videos that don't fit onto the page are put into a scrollable overflow sidebar"
},
"icu:calling__view_mode--speaker": {
"messageformat": "Speaker view",
"description": "Label for option to view participants where only the current speaker's video is fully visible and all others are put into a scrollable overflow sidebar"
},
"icu:calling__view_mode--updated": {
"messageformat": "View updated",
"description": "Toast shown whenver the calling view mode is changed (e.g. paginated view -> speaker view)"
},
"icu:calling__hangup": {
"messageformat": "Leave call",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.2 KiB

View file

@ -3598,6 +3598,9 @@ button.module-image__border-overlay:focus {
height: var(--window-height);
justify-content: center;
position: fixed;
/* stylelint-disable-next-line liberty/use-logical-spec */
left: 0;
top: 0;
width: 100%;
z-index: $z-index-calling;
}
@ -3706,7 +3709,31 @@ button.module-image__border-overlay:focus {
opacity: 0;
}
}
@mixin module-ongoing-call__controls--fade-in {
animation: {
name: module-ongoing-call__controls--fade-out;
duration: 1200ms;
timing-function: $ease-out-expo;
fill-mode: forwards;
}
}
@mixin module-ongoing-call__controls--fade-out {
animation: {
name: module-ongoing-call__controls--fade-out;
duration: 1200ms;
timing-function: $ease-out-expo;
fill-mode: forwards;
}
pointer-events: none;
}
.module-ongoing-call__container--hide-controls {
.module-ongoing-call__prev-page,
.module-ongoing-call__next-page {
@include module-ongoing-call__controls--fade-out;
}
}
.module-ongoing-call {
$local-preview-width: 108px;
$local-preview-height: 80px;
@ -3740,12 +3767,52 @@ button.module-image__border-overlay:focus {
}
}
&__next-page,
&__prev-page {
@include button-reset;
position: absolute;
top: 50%;
transform: translateY(-50%);
height: 32px;
width: 32px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
background-color: $color-gray-78;
z-index: $z-index-above-above-base;
&--arrow {
width: 20px;
height: 20px;
}
}
&__next-page {
inset-inline-end: 4px;
&--arrow {
@include color-svg(
'../images/icons/v3/chevron/chevron-right.svg',
$color-white
);
}
}
&__prev-page {
inset-inline-start: 4px;
&--arrow {
@include color-svg(
'../images/icons/v3/chevron/chevron-left.svg',
$color-white
);
}
}
&__header {
background: linear-gradient($color-black-alpha-40, transparent);
top: 0;
width: 100%;
z-index: $z-index-above-above-base;
-webkit-app-region: drag;
}
&__header-message {
@ -3767,6 +3834,14 @@ button.module-image__border-overlay:focus {
margin-block-start: 24px;
z-index: $z-index-above-base;
&__grid--wrapper {
margin-block-start: 26px;
margin-block-end: 16px;
margin-inline: 16px;
display: flex;
width: 100%;
}
&__grid {
flex-grow: 1;
position: relative;
@ -3775,7 +3850,7 @@ button.module-image__border-overlay:focus {
&__overflow {
flex: 0 0 auto;
position: relative;
margin-inline: 16px;
margin-inline-end: 16px;
&__inner {
position: absolute;
@ -3875,6 +3950,34 @@ button.module-image__border-overlay:focus {
}
}
&__group-call--pagination-tile {
@include button-reset;
position: absolute;
border-radius: 10px;
background-color: $color-gray-78;
display: flex;
justify-content: center;
align-items: center;
@include font-body-1;
color: $color-gray-20;
&--next-arrow {
@include color-svg(
'../images/icons/v3/chevron/chevron-right.svg',
$color-gray-20
);
height: 16px;
width: 16px;
}
&--prev-arrow {
@include color-svg(
'../images/icons/v3/chevron/chevron-left.svg',
$color-gray-20
);
height: 16px;
width: 16px;
}
}
&__group-call-remote-participant {
display: flex;
justify-content: center;
@ -3883,9 +3986,8 @@ button.module-image__border-overlay:focus {
line-height: 0;
overflow: hidden;
border-radius: 10px;
// stylelint-disable-next-line declaration-property-value-disallowed-list
transform: translate(0, 0);
transition: transform 200ms linear, width 200ms linear, height 200ms linear;
transition: top 200ms linear, inset-inline-start 200ms linear,
transform 200ms linear, width 200ms linear, height 200ms linear;
&:after {
content: '';
@ -4047,21 +4149,11 @@ button.module-image__border-overlay:focus {
}
&__controls--fadeIn {
animation: {
name: module-ongoing-call__controls--fade-in;
duration: 400ms;
timing-function: $ease-out-expo;
fill-mode: forwards;
}
@include module-ongoing-call__controls--fade-in;
}
&__controls--fadeOut {
animation: {
name: module-ongoing-call__controls--fade-out;
duration: 1200ms;
timing-function: $ease-out-expo;
fill-mode: forwards;
}
@include module-ongoing-call__controls--fade-out;
}
}
@ -4079,6 +4171,9 @@ button.module-image__border-overlay:focus {
&__button:last-child {
margin-inline-end: 24px;
}
.ContextMenu__container {
background: none;
}
}
.module-calling-pip {

View file

@ -34,7 +34,11 @@
@include CallSettingsButton-icon('../images/icons/v3/x/x.svg');
}
.CallSettingsButton__Icon--GridView {
.CallSettingsButton__Icon--OverflowView {
@include CallSettingsButton-icon('../images/icons/v3/grid/overflow_view.svg');
}
.CallSettingsButton__Icon--PaginatedView {
@include CallSettingsButton-icon('../images/icons/v3/grid/grid.svg');
}

View file

@ -47,3 +47,13 @@
/* stylelint-disable-next-line liberty/use-logical-spec */
left: 0;
}
.CallingToast__viewChanged {
display: flex;
align-items: center;
gap: 8px;
&__icon {
width: 18px;
height: 18px;
}
}

View file

@ -39,14 +39,38 @@
align-items: center;
display: flex;
justify-content: space-between;
padding-block: 7px;
padding-inline: 12px;
min-width: 150px;
width: 100%;
&--container {
display: flex;
align-items: center;
padding-block: 7px;
padding-inline: 12px;
&--with-selection {
padding-inline-start: 8px;
padding-inline-end: 24px;
&::before {
content: '';
width: 16px;
height: 16px;
margin-inline-end: 8px;
}
}
&--selected::before {
@include light-theme {
@include color-svg(
'../images/icons/v3/check/check.svg',
$color-black
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v3/check/check.svg',
$color-white
);
}
}
}
&--icon {
@ -55,18 +79,8 @@
width: 16px;
}
&--selected {
height: 12px;
margin-block: 0;
margin-inline: 6px;
width: 16px;
@include light-theme {
@include color-svg('../images/icons/v3/check/check.svg', $color-black);
}
@include dark-theme {
@include color-svg('../images/icons/v3/check/check.svg', $color-white);
}
&--container--with-selection &--icon {
margin-inline-end: 12px;
}
&--title {
@ -96,12 +110,10 @@
}
}
}
&__popper--single-item &__option {
&__popper--single-item &__option--container {
padding-block: 12px;
}
&__divider {
&__popper--single-item &__divider {
border-style: solid;
border-width: 0 0 1px 0;
margin-top: 2px;

View file

@ -47,7 +47,7 @@ const getCommonActiveCallData = () => ({
hasLocalAudio: true,
hasLocalVideo: false,
localAudioLevel: 0,
viewMode: CallViewMode.Grid,
viewMode: CallViewMode.Paginated,
outgoingRing: true,
pip: false,
settingsDialogOpen: false,
@ -61,6 +61,7 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
bounceAppIconStart: action('bounce-app-icon-start'),
bounceAppIconStop: action('bounce-app-icon-stop'),
cancelCall: action('cancel-call'),
changeCallView: action('change-call-view'),
closeNeedPermissionScreen: action('close-need-permission-screen'),
declineCall: action('decline-call'),
getGroupCallVideoFrameSource: (_: string, demuxId: number) =>
@ -102,7 +103,6 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
'toggle-screen-recording-permissions-dialog'
),
toggleSettings: action('toggle-settings'),
toggleSpeakerView: action('toggle-speaker-view'),
isConversationTooBigToRing: false,
pauseVoiceNotePlayer: action('pause-audio-player'),
});

View file

@ -15,6 +15,7 @@ import type { SafetyNumberProps } from './SafetyNumberChangeDialog';
import { SafetyNumberChangeDialog } from './SafetyNumberChangeDialog';
import type {
ActiveCallType,
CallViewMode,
GroupCallVideoRequest,
PresentedSource,
} from '../types/Calling';
@ -49,6 +50,7 @@ export type PropsType = {
activeCall?: ActiveCallType;
availableCameras: Array<MediaDeviceInfo>;
cancelCall: (_: CancelCallType) => void;
changeCallView: (mode: CallViewMode) => void;
closeNeedPermissionScreen: () => void;
getGroupCallVideoFrameSource: (
conversationId: string,
@ -103,7 +105,6 @@ export type PropsType = {
togglePip: () => void;
toggleScreenRecordingPermissionsDialog: () => unknown;
toggleSettings: () => void;
toggleSpeakerView: () => void;
isConversationTooBigToRing: boolean;
pauseVoiceNotePlayer: () => void;
};
@ -116,6 +117,7 @@ function ActiveCallManager({
activeCall,
availableCameras,
cancelCall,
changeCallView,
closeNeedPermissionScreen,
hangUpActiveCall,
i18n,
@ -143,7 +145,6 @@ function ActiveCallManager({
togglePip,
toggleScreenRecordingPermissionsDialog,
toggleSettings,
toggleSpeakerView,
pauseVoiceNotePlayer,
}: ActiveCallManagerPropsType): JSX.Element {
const {
@ -322,6 +323,7 @@ function ActiveCallManager({
<>
<CallScreen
activeCall={activeCall}
changeCallView={changeCallView}
getPresentingSources={getPresentingSources}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall}
groupMembers={groupMembers}
@ -344,7 +346,6 @@ function ActiveCallManager({
toggleParticipants={toggleParticipants}
togglePip={togglePip}
toggleSettings={toggleSettings}
toggleSpeakerView={toggleSpeakerView}
/>
{presentingSourcesAvailable && presentingSourcesAvailable.length ? (
<CallingSelectPresentingSourcesModal

View file

@ -6,7 +6,10 @@ import { times } from 'lodash';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { GroupCallRemoteParticipantType } from '../types/Calling';
import type {
ActiveGroupCallType,
GroupCallRemoteParticipantType,
} from '../types/Calling';
import {
CallMode,
CallViewMode,
@ -29,7 +32,7 @@ import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGr
import enMessages from '../../_locales/en/messages.json';
import { CallingToastProvider, useCallingToasts } from './CallingToast';
const MAX_PARTICIPANTS = 64;
const MAX_PARTICIPANTS = 75;
const i18n = setupI18n('en', enMessages);
@ -118,7 +121,7 @@ const createActiveCallProp = (
hasLocalAudio: overrideProps.hasLocalAudio ?? false,
hasLocalVideo: overrideProps.hasLocalVideo ?? false,
localAudioLevel: overrideProps.localAudioLevel ?? 0,
viewMode: overrideProps.viewMode ?? CallViewMode.Grid,
viewMode: overrideProps.viewMode ?? CallViewMode.Overflow,
outgoingRing: true,
pip: false,
settingsDialogOpen: false,
@ -141,6 +144,7 @@ const createProps = (
}
): PropsType => ({
activeCall: createActiveCallProp(overrideProps),
changeCallView: action('change-call-view'),
getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource,
getPresentingSources: action('get-presenting-sources'),
hangUpActiveCall: action('hang-up'),
@ -169,7 +173,6 @@ const createProps = (
'toggle-screen-recording-permissions-dialog'
),
toggleSettings: action('toggle-settings'),
toggleSpeakerView: action('toggle-speaker-view'),
});
function CallScreen(props: ReturnType<typeof createProps>): JSX.Element {
@ -301,24 +304,66 @@ const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => ({
hasRemoteVideo: index % 4 !== 0,
presenting: false,
sharingScreen: false,
videoAspectRatio: 1.3,
videoAspectRatio: Math.random() < 0.7 ? 1.3 : Math.random() * 0.4 + 0.6,
...getDefaultConversationWithServiceId({
isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1,
title: `Participant ${index + 1}`,
}),
}));
export function GroupCallMany(): JSX.Element {
export function GroupCallManyPaginated(): JSX.Element {
const props = createProps({
callMode: CallMode.Group,
remoteParticipants: allRemoteParticipants,
viewMode: CallViewMode.Paginated,
});
return <CallScreen {...props} />;
}
export function GroupCallManyPaginatedEveryoneTalking(): JSX.Element {
const [props] = React.useState(
createProps({
callMode: CallMode.Group,
remoteParticipants: allRemoteParticipants,
viewMode: CallViewMode.Paginated,
})
);
const activeCall = useMakeEveryoneTalk(
props.activeCall as ActiveGroupCallType
);
return <CallScreen {...props} activeCall={activeCall} />;
}
export function GroupCallManyOverflow(): JSX.Element {
return (
<CallScreen
{...createProps({
callMode: CallMode.Group,
remoteParticipants: allRemoteParticipants.slice(0, 40),
remoteParticipants: allRemoteParticipants,
viewMode: CallViewMode.Overflow,
})}
/>
);
}
export function GroupCallManyOverflowEveryoneTalking(): JSX.Element {
const [props] = React.useState(
createProps({
callMode: CallMode.Group,
remoteParticipants: allRemoteParticipants,
viewMode: CallViewMode.Overflow,
})
);
const activeCall = useMakeEveryoneTalk(
props.activeCall as ActiveGroupCallType
);
return <CallScreen {...props} activeCall={activeCall} />;
}
export function GroupCallSpeakerView(): JSX.Element {
return (
<CallScreen
@ -459,3 +504,50 @@ export function CallScreenToastAPalooza(): JSX.Element {
</CallingToastProvider>
);
}
function useMakeEveryoneTalk(
activeCall: ActiveGroupCallType,
frequency = 2000
) {
const [call, setCall] = React.useState(activeCall);
React.useEffect(() => {
const interval = setInterval(() => {
const idxToStartSpeaking = Math.floor(
Math.random() * call.remoteParticipants.length
);
const demuxIdToStartSpeaking = (
call.remoteParticipants[
idxToStartSpeaking
] as GroupCallRemoteParticipantType
).demuxId;
const remoteAudioLevels = new Map();
for (const [demuxId] of call.remoteAudioLevels.entries()) {
if (demuxId === demuxIdToStartSpeaking) {
remoteAudioLevels.set(demuxId, 1);
} else {
remoteAudioLevels.set(demuxId, 0);
}
}
setCall(state => ({
...state,
remoteParticipants: state.remoteParticipants.map((part, idx) => {
return {
...part,
hasRemoteAudio:
idx === idxToStartSpeaking ? true : part.hasRemoteAudio,
speakerTime:
idx === idxToStartSpeaking
? Date.now()
: (part as GroupCallRemoteParticipantType).speakerTime,
};
}),
remoteAudioLevels,
}));
}, frequency);
return () => clearInterval(interval);
}, [frequency, call]);
return call;
}

View file

@ -14,7 +14,7 @@ import type {
SetRendererCanvasType,
} from '../state/ducks/calling';
import { Avatar, AvatarSize } from './Avatar';
import { CallingHeader } from './CallingHeader';
import { CallingHeader, getCallViewIconClassname } from './CallingHeader';
import { CallingPreCallInfo, RingMode } from './CallingPreCallInfo';
import { CallingButton, CallingButtonType } from './CallingButton';
import { Button, ButtonVariant } from './Button';
@ -46,7 +46,10 @@ import type { LocalizerType } from '../types/Util';
import { NeedsScreenRecordingPermissionsModal } from './NeedsScreenRecordingPermissionsModal';
import { missingCaseError } from '../util/missingCaseError';
import * as KeyboardLayout from '../services/keyboardLayout';
import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting';
import {
usePresenter,
useActivateSpeakerViewOnPresenting,
} from '../hooks/useActivateSpeakerViewOnPresenting';
import {
CallingAudioIndicator,
SPEAKING_LINGER_MS,
@ -57,6 +60,8 @@ import {
} from '../hooks/useKeyboardShortcuts';
import { useValueAtFixedRate } from '../hooks/useValueAtFixedRate';
import { isReconnecting as callingIsReconnecting } from '../util/callingIsReconnecting';
import { usePrevious } from '../hooks/usePrevious';
import { useCallingToasts } from './CallingToast';
export type PropsType = {
activeCall: ActiveCallType;
@ -83,7 +88,7 @@ export type PropsType = {
togglePip: () => void;
toggleScreenRecordingPermissionsDialog: () => unknown;
toggleSettings: () => void;
toggleSpeakerView: () => void;
changeCallView: (mode: CallViewMode) => void;
};
export const isInSpeakerView = (
@ -123,6 +128,7 @@ function CallDuration({
export function CallScreen({
activeCall,
changeCallView,
getGroupCallVideoFrameSource,
getPresentingSources,
groupMembers,
@ -143,7 +149,6 @@ export function CallScreen({
togglePip,
toggleScreenRecordingPermissionsDialog,
toggleSettings,
toggleSpeakerView,
}: PropsType): JSX.Element {
const {
conversation,
@ -253,6 +258,7 @@ export function CallScreen({
useReconnectingToast({ activeCall, i18n });
useScreenSharingStoppedToast({ activeCall, i18n });
useViewModeChangedToast({ activeCall, i18n });
const currentPresenter = remoteParticipants.find(
participant => participant.presenting
@ -315,9 +321,9 @@ export function CallScreen({
activeCall.connectionState === GroupCallConnectionState.Connected;
remoteParticipantsElement = (
<GroupCallRemoteParticipants
callViewMode={activeCall.viewMode}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
i18n={i18n}
isInSpeakerView={isInSpeakerView(activeCall)}
remoteParticipants={activeCall.remoteParticipants}
setGroupCallVideoRequest={setGroupCallVideoRequest}
remoteAudioLevels={activeCall.remoteAudioLevels}
@ -470,7 +476,8 @@ export function CallScreen({
)}`,
`module-ongoing-call__container--${
hasCallStarted ? 'call-started' : 'call-not-started'
}`
}`,
{ 'module-ongoing-call__container--hide-controls': !showControls }
)}
onFocus={() => {
setShowControls(true);
@ -493,14 +500,14 @@ export function CallScreen({
className={classNames('module-ongoing-call__header', controlsFadeClass)}
>
<CallingHeader
callViewMode={activeCall.viewMode}
changeCallView={changeCallView}
i18n={i18n}
isInSpeakerView={isInSpeakerView(activeCall)}
isGroupCall={isGroupCall}
participantCount={participantCount}
title={headerTitle}
togglePip={togglePip}
toggleSettings={toggleSettings}
toggleSpeakerView={toggleSpeakerView}
/>
</div>
{isRinging && (
@ -625,3 +632,56 @@ function renderDuration(ms: number): string {
}
return `${mins}:${secs}`;
}
function useViewModeChangedToast({
activeCall,
i18n,
}: {
activeCall: ActiveCallType;
i18n: LocalizerType;
}): void {
const { viewMode } = activeCall;
const previousViewMode = usePrevious(viewMode, viewMode);
const presenterAci = usePresenter(activeCall.remoteParticipants);
const VIEW_MODE_CHANGED_TOAST_KEY = 'view-mode-changed';
const { showToast, hideToast } = useCallingToasts();
useEffect(() => {
if (viewMode !== previousViewMode) {
if (
// If this is an automated change to presentation mode, don't show toast
viewMode === CallViewMode.Presentation ||
// if this is an automated change away from presentation mode, don't show toast
(previousViewMode === CallViewMode.Presentation && !presenterAci)
) {
return;
}
hideToast(VIEW_MODE_CHANGED_TOAST_KEY);
showToast({
key: VIEW_MODE_CHANGED_TOAST_KEY,
content: (
<div className="CallingToast__viewChanged">
<span
className={classNames(
'CallingToast__viewChanged__icon',
getCallViewIconClassname(viewMode)
)}
/>
{i18n('icu:calling__view_mode--updated')}
</div>
),
autoClose: true,
});
}
}, [
showToast,
hideToast,
i18n,
activeCall,
viewMode,
previousViewMode,
presenterAci,
]);
}

View file

@ -8,6 +8,7 @@ import type { PropsType } from './CallingHeader';
import { CallingHeader } from './CallingHeader';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
import { CallViewMode } from '../types/Calling';
const i18n = setupI18n('en', enMessages);
@ -26,7 +27,8 @@ export default {
participantCount: 0,
title: 'With Someone',
togglePip: action('toggle-pip'),
toggleSettings: action('toggle-settings'),
callViewMode: CallViewMode.Paginated,
changeCallView: action('change-call-view'),
},
} satisfies Meta<PropsType>;

View file

@ -2,15 +2,17 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React from 'react';
import classNames from 'classnames';
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { CallViewMode } from '../types/Calling';
import { Tooltip } from './Tooltip';
import { Theme } from '../util/theme';
import { ContextMenu } from './ContextMenu';
export type PropsType = {
callViewMode?: CallViewMode;
i18n: LocalizerType;
isInSpeakerView?: boolean;
isGroupCall?: boolean;
message?: ReactNode;
onCancel?: () => void;
@ -18,12 +20,13 @@ export type PropsType = {
title?: string;
togglePip?: () => void;
toggleSettings: () => void;
toggleSpeakerView?: () => void;
changeCallView?: (mode: CallViewMode) => void;
};
export function CallingHeader({
callViewMode,
changeCallView,
i18n,
isInSpeakerView,
isGroupCall = false,
message,
onCancel,
@ -31,7 +34,6 @@ export function CallingHeader({
title,
togglePip,
toggleSettings,
toggleSpeakerView,
}: PropsType): JSX.Element {
return (
<div className="module-calling__header">
@ -42,37 +44,61 @@ export function CallingHeader({
<div className="module-ongoing-call__header-message">{message}</div>
) : null}
<div className="module-calling-tools">
{isGroupCall && participantCount > 2 && toggleSpeakerView && (
{isGroupCall &&
participantCount > 2 &&
callViewMode &&
changeCallView && (
<div className="module-calling-tools__button">
<Tooltip
content={
isInSpeakerView
? i18n('icu:calling__switch-view--to-grid')
: i18n('icu:calling__switch-view--to-speaker')
<ContextMenu
ariaLabel={i18n('icu:calling__change-view')}
i18n={i18n}
menuOptions={[
{
icon: 'CallSettingsButton__Icon--PaginatedView',
label: i18n('icu:calling__view_mode--paginated'),
onClick: () => changeCallView(CallViewMode.Paginated),
value: CallViewMode.Paginated,
},
{
icon: 'CallSettingsButton__Icon--OverflowView',
label: i18n('icu:calling__view_mode--overflow'),
onClick: () => changeCallView(CallViewMode.Overflow),
value: CallViewMode.Overflow,
},
{
icon: 'CallSettingsButton__Icon--SpeakerView',
label: i18n('icu:calling__view_mode--speaker'),
onClick: () => changeCallView(CallViewMode.Speaker),
value: CallViewMode.Speaker,
},
]}
theme={Theme.Dark}
popperOptions={{
placement: 'bottom',
strategy: 'absolute',
}}
value={
// If it's Presentation we want to still show Speaker as selected
callViewMode === CallViewMode.Presentation
? CallViewMode.Speaker
: callViewMode
}
>
<Tooltip
content={i18n('icu:calling__change-view')}
className="CallingButton__tooltip"
theme={Theme.Dark}
>
<button
aria-label={
isInSpeakerView
? i18n('icu:calling__switch-view--to-grid')
: i18n('icu:calling__switch-view--to-speaker')
}
className="CallSettingsButton__Button"
onClick={toggleSpeakerView}
type="button"
>
<div className="CallSettingsButton__Button">
<span
className={classNames(
'CallSettingsButton__Icon',
isInSpeakerView
? 'CallSettingsButton__Icon--GridView'
: 'CallSettingsButton__Icon--SpeakerView'
getCallViewIconClassname(callViewMode)
)}
/>
</button>
</div>
</Tooltip>
</ContextMenu>
</div>
)}
<div className="module-calling-tools__button">
@ -131,3 +157,13 @@ export function CallingHeader({
</div>
);
}
const CALL_VIEW_MODE_ICON_CLASSNAMES: Record<CallViewMode, string> = {
[CallViewMode.Overflow]: 'CallSettingsButton__Icon--OverflowView',
[CallViewMode.Paginated]: 'CallSettingsButton__Icon--PaginatedView',
[CallViewMode.Speaker]: 'CallSettingsButton__Icon--SpeakerView',
[CallViewMode.Presentation]: 'CallSettingsButton__Icon--SpeakerView',
};
export function getCallViewIconClassname(viewMode: CallViewMode): string {
return CALL_VIEW_MODE_ICON_CLASSNAMES[viewMode];
}

View file

@ -46,7 +46,7 @@ const getCommonActiveCallData = (overrides: Overrides) => ({
hasLocalAudio: overrides.hasLocalAudio ?? true,
hasLocalVideo: overrides.hasLocalVideo ?? false,
localAudioLevel: overrides.localAudioLevel ?? 0,
viewMode: overrides.viewMode ?? CallViewMode.Grid,
viewMode: overrides.viewMode ?? CallViewMode.Paginated,
joinedAt: Date.now(),
outgoingRing: true,
pip: true,

View file

@ -203,6 +203,7 @@ export function ContextMenu<T>({
const getClassName = getClassNamesFor('ContextMenu', moduleClassName);
const optionElements = new Array<JSX.Element>();
const isAnyOptionSelected = typeof value !== 'undefined';
for (const [index, option] of menuOptions.entries()) {
const previous = menuOptions[index - 1];
@ -229,6 +230,7 @@ export function ContextMenu<T>({
closeCurrentOpenContextMenu = undefined;
};
const isOptionSelected = isAnyOptionSelected && value === option.value;
optionElements.push(
<button
aria-label={option.label}
@ -240,7 +242,17 @@ export function ContextMenu<T>({
type="button"
onClick={onElementClick}
>
<div className={getClassName('__option--container')}>
<div
className={classNames(
getClassName('__option--container'),
isAnyOptionSelected
? getClassName('__option--container--with-selection')
: undefined,
isOptionSelected
? getClassName('__option--container--selected')
: undefined
)}
>
{option.icon && (
<div
className={classNames(
@ -260,11 +272,6 @@ export function ContextMenu<T>({
)}
</div>
</div>
{typeof value !== 'undefined' &&
typeof option.value !== 'undefined' &&
value === option.value ? (
<div className={getClassName('__option--selected')} />
) : null}
</button>
);
}
@ -308,6 +315,7 @@ export function ContextMenu<T>({
<div
className={classNames(
getClassName('__container'),
theme ? themeClassName(theme) : undefined
)}
>

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import type { AciString } from '../types/ServiceId';
import { usePrevious } from './usePrevious';
@ -12,6 +12,15 @@ type RemoteParticipant = {
aci?: AciString;
};
export function usePresenter(
remoteParticipants: ReadonlyArray<RemoteParticipant>
): AciString | undefined {
return useMemo(
() => remoteParticipants.find(participant => participant.presenting)?.aci,
[remoteParticipants]
);
}
export function useActivateSpeakerViewOnPresenting({
remoteParticipants,
switchToPresentationView,
@ -21,9 +30,7 @@ export function useActivateSpeakerViewOnPresenting({
switchToPresentationView: () => void;
switchFromPresentationView: () => void;
}): void {
const presenterAci = remoteParticipants.find(
participant => participant.presenting
)?.aci;
const presenterAci = usePresenter(remoteParticipants);
const prevPresenterAci = usePrevious(presenterAci, presenterAci);
useEffect(() => {

View file

@ -121,6 +121,7 @@ export type ActiveCallStateType = {
hasLocalVideo: boolean;
localAudioLevel: number;
viewMode: CallViewMode;
viewModeBeforePresentation?: CallViewMode;
joinedAt: number | null;
outgoingRing: boolean;
pip: boolean;
@ -413,6 +414,7 @@ const ACCEPT_CALL_PENDING = 'calling/ACCEPT_CALL_PENDING';
const CANCEL_CALL = 'calling/CANCEL_CALL';
const CANCEL_INCOMING_GROUP_CALL_RING =
'calling/CANCEL_INCOMING_GROUP_CALL_RING';
const CHANGE_CALL_VIEW = 'calling/CHANGE_CALL_VIEW';
const START_CALLING_LOBBY = 'calling/START_CALLING_LOBBY';
const CALL_STATE_CHANGE_FULFILLED = 'calling/CALL_STATE_CHANGE_FULFILLED';
const CHANGE_IO_DEVICE_FULFILLED = 'calling/CHANGE_IO_DEVICE_FULFILLED';
@ -442,7 +444,6 @@ const START_DIRECT_CALL = 'calling/START_DIRECT_CALL';
const TOGGLE_PARTICIPANTS = 'calling/TOGGLE_PARTICIPANTS';
const TOGGLE_PIP = 'calling/TOGGLE_PIP';
const TOGGLE_SETTINGS = 'calling/TOGGLE_SETTINGS';
const TOGGLE_SPEAKER_VIEW = 'calling/TOGGLE_SPEAKER_VIEW';
const SWITCH_TO_PRESENTATION_VIEW = 'calling/SWITCH_TO_PRESENTATION_VIEW';
const SWITCH_FROM_PRESENTATION_VIEW = 'calling/SWITCH_FROM_PRESENTATION_VIEW';
@ -611,8 +612,9 @@ type ToggleSettingsActionType = ReadonlyDeep<{
type: 'calling/TOGGLE_SETTINGS';
}>;
type ToggleSpeakerViewActionType = ReadonlyDeep<{
type: 'calling/TOGGLE_SPEAKER_VIEW';
type ChangeCallViewActionType = ReadonlyDeep<{
type: 'calling/CHANGE_CALL_VIEW';
viewMode: CallViewMode;
}>;
type SwitchToPresentationViewActionType = ReadonlyDeep<{
@ -628,6 +630,7 @@ export type CallingActionType =
| AcceptCallPendingActionType
| CancelCallActionType
| CancelIncomingGroupCallRingActionType
| ChangeCallViewActionType
| StartCallingLobbyActionType
| CallStateChangeFulfilledActionType
| ChangeIODeviceFulfilledActionType
@ -658,7 +661,6 @@ export type CallingActionType =
| TogglePipActionType
| SetPresentingFulfilledActionType
| ToggleSettingsActionType
| ToggleSpeakerViewActionType
| SwitchToPresentationViewActionType
| SwitchFromPresentationViewActionType;
@ -1474,9 +1476,10 @@ function toggleSettings(): ToggleSettingsActionType {
};
}
function toggleSpeakerView(): ToggleSpeakerViewActionType {
function changeCallView(mode: CallViewMode): ChangeCallViewActionType {
return {
type: TOGGLE_SPEAKER_VIEW,
type: CHANGE_CALL_VIEW,
viewMode: mode,
};
}
@ -1491,12 +1494,12 @@ function switchFromPresentationView(): SwitchFromPresentationViewActionType {
type: SWITCH_FROM_PRESENTATION_VIEW,
};
}
export const actions = {
acceptCall,
callStateChange,
cancelCall,
cancelIncomingGroupCallRing,
changeCallView,
changeIODevice,
closeNeedPermissionScreen,
declineCall,
@ -1524,9 +1527,9 @@ export const actions = {
setLocalAudio,
setLocalPreview,
setLocalVideo,
setOutgoingRing,
setPresenting,
setRendererCanvas,
setOutgoingRing,
startCall,
startCallingLobby,
switchToPresentationView,
@ -1535,7 +1538,6 @@ export const actions = {
togglePip,
toggleScreenRecordingPermissionsDialog,
toggleSettings,
toggleSpeakerView,
};
export const useCallingActions = (): BoundActionCreatorsMapObject<
@ -1643,7 +1645,7 @@ export function reducer(
hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo,
localAudioLevel: 0,
viewMode: CallViewMode.Grid,
viewMode: CallViewMode.Paginated,
pip: false,
safetyNumberChangedAcis: [],
settingsDialogOpen: false,
@ -1672,7 +1674,7 @@ export function reducer(
hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo,
localAudioLevel: 0,
viewMode: CallViewMode.Grid,
viewMode: CallViewMode.Paginated,
pip: false,
safetyNumberChangedAcis: [],
settingsDialogOpen: false,
@ -1696,7 +1698,7 @@ export function reducer(
hasLocalAudio: true,
hasLocalVideo: action.payload.asVideoCall,
localAudioLevel: 0,
viewMode: CallViewMode.Grid,
viewMode: CallViewMode.Paginated,
pip: false,
safetyNumberChangedAcis: [],
settingsDialogOpen: false,
@ -1851,7 +1853,7 @@ export function reducer(
hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo,
localAudioLevel: 0,
viewMode: CallViewMode.Grid,
viewMode: CallViewMode.Paginated,
pip: false,
safetyNumberChangedAcis: [],
settingsDialogOpen: false,
@ -2312,26 +2314,26 @@ export function reducer(
};
}
if (action.type === TOGGLE_SPEAKER_VIEW) {
if (action.type === CHANGE_CALL_VIEW) {
const { activeCallState } = state;
if (!activeCallState) {
log.warn('Cannot toggle speaker view when there is no active call');
log.warn('Cannot change call view when there is no active call');
return state;
}
let newViewMode: CallViewMode;
if (activeCallState.viewMode === CallViewMode.Grid) {
newViewMode = CallViewMode.Speaker;
} else {
// This will switch presentation/speaker to grid
newViewMode = CallViewMode.Grid;
if (activeCallState.viewMode === action.viewMode) {
return state;
}
return {
...state,
activeCallState: {
...activeCallState,
viewMode: newViewMode,
viewMode: action.viewMode,
viewModeBeforePresentation:
action.viewMode === CallViewMode.Presentation
? activeCallState.viewMode
: undefined,
},
};
}
@ -2343,9 +2345,7 @@ export function reducer(
return state;
}
// "Presentation" mode reverts to "Grid" when the call is over so don't
// switch it if it is in "Speaker" mode.
if (activeCallState.viewMode === CallViewMode.Speaker) {
if (activeCallState.viewMode === CallViewMode.Presentation) {
return state;
}
@ -2354,6 +2354,7 @@ export function reducer(
activeCallState: {
...activeCallState,
viewMode: CallViewMode.Presentation,
viewModeBeforePresentation: activeCallState.viewMode,
},
};
}
@ -2373,7 +2374,8 @@ export function reducer(
...state,
activeCallState: {
...activeCallState,
viewMode: CallViewMode.Grid,
viewMode:
activeCallState.viewModeBeforePresentation ?? CallViewMode.Paginated,
},
};
}

View file

@ -5,7 +5,6 @@ import { createSelector } from 'reselect';
import type { StateType } from '../reducer';
import type {
ActiveCallStateType,
CallingStateType,
CallsByConversationType,
DirectCallStateType,
@ -14,7 +13,6 @@ import type {
import { getIncomingCall as getIncomingCallHelper } from '../ducks/callingHelpers';
import { getUserACI } from './user';
import { getOwn } from '../../util/getOwn';
import { CallViewMode } from '../../types/Calling';
import type { AciString } from '../../types/ServiceId';
export type CallStateType = DirectCallStateType | GroupCallStateType;
@ -85,12 +83,3 @@ export const areAnyCallsActiveOrRinging = createSelector(
getIncomingCall,
(activeCall, incomingCall): boolean => Boolean(activeCall || incomingCall)
);
export const isInSpeakerView = (
call: Pick<ActiveCallStateType, 'viewMode'> | undefined
): boolean => {
return Boolean(
call?.viewMode === CallViewMode.Presentation ||
call?.viewMode === CallViewMode.Speaker
);
};

View file

@ -147,6 +147,7 @@ const mapStateToActiveCallProp = (
hasLocalVideo: activeCallState.hasLocalVideo,
localAudioLevel: activeCallState.localAudioLevel,
viewMode: activeCallState.viewMode,
viewModeBeforePresentation: activeCallState.viewModeBeforePresentation,
joinedAt: activeCallState.joinedAt,
outgoingRing: activeCallState.outgoingRing,
pip: activeCallState.pip,

View file

@ -64,7 +64,7 @@ describe('calling duck', () => {
hasLocalAudio: true,
hasLocalVideo: false,
localAudioLevel: 0,
viewMode: CallViewMode.Grid,
viewMode: CallViewMode.Paginated,
showParticipantsList: false,
safetyNumberChangedAcis: [],
outgoingRing: true,
@ -144,7 +144,7 @@ describe('calling duck', () => {
hasLocalAudio: true,
hasLocalVideo: false,
localAudioLevel: 0,
viewMode: CallViewMode.Grid,
viewMode: CallViewMode.Paginated,
showParticipantsList: false,
safetyNumberChangedAcis: [],
outgoingRing: false,
@ -154,23 +154,6 @@ describe('calling duck', () => {
},
};
const stateWithActivePresentationViewGroupCall: CallingStateTypeWithActiveCall =
{
...stateWithGroupCall,
activeCallState: {
...stateWithActiveGroupCall.activeCallState,
viewMode: CallViewMode.Presentation,
},
};
const stateWithActiveSpeakerViewGroupCall: CallingStateTypeWithActiveCall = {
...stateWithGroupCall,
activeCallState: {
...stateWithActiveGroupCall.activeCallState,
viewMode: CallViewMode.Speaker,
},
};
const ourAci = generateAci();
const getEmptyRootState = () => {
@ -476,7 +459,7 @@ describe('calling duck', () => {
hasLocalAudio: true,
hasLocalVideo: true,
localAudioLevel: 0,
viewMode: CallViewMode.Grid,
viewMode: CallViewMode.Paginated,
showParticipantsList: false,
safetyNumberChangedAcis: [],
outgoingRing: false,
@ -570,7 +553,7 @@ describe('calling duck', () => {
hasLocalAudio: true,
hasLocalVideo: true,
localAudioLevel: 0,
viewMode: CallViewMode.Grid,
viewMode: CallViewMode.Paginated,
showParticipantsList: false,
safetyNumberChangedAcis: [],
outgoingRing: false,
@ -1163,7 +1146,7 @@ describe('calling duck', () => {
hasLocalAudio: true,
hasLocalVideo: false,
localAudioLevel: 0,
viewMode: CallViewMode.Grid,
viewMode: CallViewMode.Paginated,
showParticipantsList: false,
safetyNumberChangedAcis: [],
outgoingRing: false,
@ -1695,7 +1678,7 @@ describe('calling duck', () => {
hasLocalAudio: true,
hasLocalVideo: true,
localAudioLevel: 0,
viewMode: CallViewMode.Grid,
viewMode: CallViewMode.Paginated,
showParticipantsList: false,
safetyNumberChangedAcis: [],
pip: false,
@ -1982,7 +1965,7 @@ describe('calling duck', () => {
hasLocalAudio: true,
hasLocalVideo: false,
localAudioLevel: 0,
viewMode: CallViewMode.Grid,
viewMode: CallViewMode.Paginated,
showParticipantsList: false,
safetyNumberChangedAcis: [],
pip: false,
@ -2056,58 +2039,14 @@ describe('calling duck', () => {
});
});
describe('toggleSpeakerView', () => {
const { toggleSpeakerView } = actions;
it('toggles speaker view from grid view', () => {
const afterOneToggle = reducer(
stateWithActiveGroupCall,
toggleSpeakerView()
);
const afterTwoToggles = reducer(afterOneToggle, toggleSpeakerView());
const afterThreeToggles = reducer(afterTwoToggles, toggleSpeakerView());
assert.strictEqual(
afterOneToggle.activeCallState?.viewMode,
CallViewMode.Speaker
);
assert.strictEqual(
afterTwoToggles.activeCallState?.viewMode,
CallViewMode.Grid
);
assert.strictEqual(
afterThreeToggles.activeCallState?.viewMode,
CallViewMode.Speaker
);
});
it('toggles speaker view from presentation view', () => {
const afterOneToggle = reducer(
stateWithActivePresentationViewGroupCall,
toggleSpeakerView()
);
const afterTwoToggles = reducer(afterOneToggle, toggleSpeakerView());
const afterThreeToggles = reducer(afterTwoToggles, toggleSpeakerView());
assert.strictEqual(
afterOneToggle.activeCallState?.viewMode,
CallViewMode.Grid
);
assert.strictEqual(
afterTwoToggles.activeCallState?.viewMode,
CallViewMode.Speaker
);
assert.strictEqual(
afterThreeToggles.activeCallState?.viewMode,
CallViewMode.Grid
);
});
});
describe('switchToPresentationView', () => {
const { switchToPresentationView, switchFromPresentationView } = actions;
const {
switchToPresentationView,
switchFromPresentationView,
changeCallView,
} = actions;
it('toggles presentation view from grid view', () => {
it('toggles presentation view from paginated view', () => {
const afterOneToggle = reducer(
stateWithActiveGroupCall,
switchToPresentationView()
@ -2116,7 +2055,7 @@ describe('calling duck', () => {
afterOneToggle,
switchToPresentationView()
);
const finalState = reducer(
const afterThreeToggles = reducer(
afterOneToggle,
switchFromPresentationView()
);
@ -2130,28 +2069,28 @@ describe('calling duck', () => {
CallViewMode.Presentation
);
assert.strictEqual(
finalState.activeCallState?.viewMode,
CallViewMode.Grid
afterThreeToggles.activeCallState?.viewMode,
CallViewMode.Paginated
);
});
it('does not toggle presentation view from speaker view', () => {
const afterOneToggle = reducer(
stateWithActiveSpeakerViewGroupCall,
it('switches to previously selected view after presentation', () => {
const stateOverflow = reducer(
stateWithActiveGroupCall,
changeCallView(CallViewMode.Overflow)
);
const statePresentation = reducer(
stateOverflow,
switchToPresentationView()
);
const finalState = reducer(
afterOneToggle,
const stateAfterPresentation = reducer(
statePresentation,
switchFromPresentationView()
);
assert.strictEqual(
afterOneToggle.activeCallState?.viewMode,
CallViewMode.Speaker
);
assert.strictEqual(
finalState.activeCallState?.viewMode,
CallViewMode.Speaker
stateAfterPresentation.activeCallState?.viewMode,
CallViewMode.Overflow
);
});
});

View file

@ -66,7 +66,7 @@ describe('state/selectors/calling', () => {
hasLocalAudio: true,
hasLocalVideo: false,
localAudioLevel: 0,
viewMode: CallViewMode.Grid,
viewMode: CallViewMode.Paginated,
showParticipantsList: false,
safetyNumberChangedAcis: [],
outgoingRing: true,

View file

@ -11,10 +11,12 @@ export enum CallMode {
Group = 'Group',
}
// Speaker and Presentation has the same UI, but Presentation mode will switch
// to Grid mode when the presentation is over.
// Speaker and Presentation mode have the same UI, but Presentation is only set
// automatically when someone starts to present, and will revert to the previous view mode
// once presentation is complete
export enum CallViewMode {
Grid = 'Grid',
Paginated = 'Paginated',
Overflow = 'Overflow',
Speaker = 'Speaker',
Presentation = 'Presentation',
}
@ -38,6 +40,7 @@ export type ActiveCallBaseType = {
hasLocalVideo: boolean;
localAudioLevel: number;
viewMode: CallViewMode;
viewModeBeforePresentation?: CallViewMode;
isSharingScreen?: boolean;
joinedAt: number | null;
outgoingRing: boolean;