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

@ -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,39 +44,63 @@ export function CallingHeader({
<div className="module-ongoing-call__header-message">{message}</div>
) : null}
<div className="module-calling-tools">
{isGroupCall && participantCount > 2 && toggleSpeakerView && (
<div className="module-calling-tools__button">
<Tooltip
content={
isInSpeakerView
? i18n('icu:calling__switch-view--to-grid')
: i18n('icu:calling__switch-view--to-speaker')
}
className="CallingButton__tooltip"
theme={Theme.Dark}
>
<button
aria-label={
isInSpeakerView
? i18n('icu:calling__switch-view--to-grid')
: i18n('icu:calling__switch-view--to-speaker')
{isGroupCall &&
participantCount > 2 &&
callViewMode &&
changeCallView && (
<div className="module-calling-tools__button">
<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
}
className="CallSettingsButton__Button"
onClick={toggleSpeakerView}
type="button"
>
<span
className={classNames(
'CallSettingsButton__Icon',
isInSpeakerView
? 'CallSettingsButton__Icon--GridView'
: 'CallSettingsButton__Icon--SpeakerView'
)}
/>
</button>
</Tooltip>
</div>
)}
<Tooltip
content={i18n('icu:calling__change-view')}
className="CallingButton__tooltip"
theme={Theme.Dark}
>
<div className="CallSettingsButton__Button">
<span
className={classNames(
'CallSettingsButton__Icon',
getCallViewIconClassname(callViewMode)
)}
/>
</div>
</Tooltip>
</ContextMenu>
</div>
)}
<div className="module-calling-tools__button">
<Tooltip
content={i18n('icu:callingDeviceSelection__settings')}
@ -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