Group calling: add overflow area

This commit is contained in:
Evan Hahn 2021-01-08 12:58:28 -06:00 committed by Scott Nonnenberg
parent 8e1391c70c
commit fbfcdbf84e
13 changed files with 713 additions and 116 deletions

View file

@ -1306,6 +1306,14 @@
"message": "You won't receive their audio or video and they won't receive yours.", "message": "You won't receive their audio or video and they won't receive yours.",
"description": "Shown in the modal dialog to describe how blocking works in a gorup call" "description": "Shown in the modal dialog to describe how blocking works in a gorup call"
}, },
"calling__overflow__scroll-up": {
"message": "Scroll up",
"description": "Label for the \"scroll up\" button in a call's overflow area"
},
"calling__overflow__scroll-down": {
"message": "Scroll down",
"description": "Label for the \"scroll down\" button in a call's overflow area"
},
"alwaysRelayCallsDescription": { "alwaysRelayCallsDescription": {
"message": "Always relay calls", "message": "Always relay calls",
"description": "Description of the always relay calls setting" "description": "Description of the always relay calls setting"

View file

@ -5842,7 +5842,7 @@ button.module-image__border-overlay:focus {
.module-calling { .module-calling {
&__container { &__container {
align-items: center; align-items: center;
background-color: $color-gray-95; background-color: $calling-background-color;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100vh;
@ -6253,6 +6253,7 @@ button.module-image__border-overlay:focus {
top: 0; top: 0;
width: 100%; width: 100%;
z-index: 2; z-index: 2;
padding-bottom: 1rem;
} }
&__header-message { &__header-message {
@ -6262,12 +6263,112 @@ button.module-image__border-overlay:focus {
letter-spacing: -0.0025em; letter-spacing: -0.0025em;
} }
&__participants {
display: flex;
flex: 1 1 0;
width: 100%;
&__grid { &__grid {
flex-grow: 1; flex-grow: 1;
width: 100%;
position: relative; position: relative;
} }
&__overflow {
flex: 0 0 auto;
position: relative;
&__inner {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
max-height: 100%;
overflow-y: scroll;
&::-webkit-scrollbar,
&::-webkit-scrollbar-thumb {
width: 0;
background: transparent;
}
}
& .module-ongoing-call__group-call-remote-participant {
width: 100%;
margin-bottom: 1rem;
}
&__scroll-marker {
$scroll-marker-selector: &;
display: flex;
justify-content: center;
left: 0;
opacity: 1;
position: absolute;
scroll-behavior: smooth;
transition: opacity 200ms ease-out;
width: 100%;
z-index: 1;
&--hidden {
opacity: 0;
}
&__button {
&::before {
@include color-svg(
'../images/icons/v2/arrow-down-24.svg',
$color-white
);
content: '';
display: block;
height: 100%;
width: 100%;
}
background: $color-gray-60;
border-radius: 100%;
border: 0;
box-shadow: 0 0 5px rgba($color-gray-95, 0.5);
height: 28px;
margin: 12px 0;
opacity: 0;
outline: none;
transition: opacity 200ms ease-out;
width: 28px;
}
&--top {
top: 0;
background: linear-gradient(
$calling-background-color,
transparent 20px,
transparent
);
#{$scroll-marker-selector}__button {
transform: rotate(180deg);
}
}
&--bottom {
bottom: 0;
background: linear-gradient(
to top,
$calling-background-color,
transparent 20px,
transparent
);
}
}
&:hover &__scroll-marker__button {
opacity: 1;
}
}
}
&__group-call-remote-participant { &__group-call-remote-participant {
display: flex; display: flex;
justify-content: center; justify-content: center;

View file

@ -1,4 +1,4 @@
// Copyright 2015-2020 Signal Messenger, LLC // Copyright 2015-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
$inter: Inter, 'Helvetica Neue', 'Source Sans Pro', 'Source Han Sans SC', $inter: Inter, 'Helvetica Neue', 'Source Sans Pro', 'Source Han Sans SC',
@ -194,3 +194,5 @@ $left-pane-width: 320px;
$header-height: 52px; $header-height: 52px;
$ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1); $ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
$calling-background-color: $color-gray-95;

View file

@ -1,8 +1,7 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
import { noop } from 'lodash';
import { storiesOf } from '@storybook/react'; import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { boolean, select, text } from '@storybook/addon-knobs'; import { boolean, select, text } from '@storybook/addon-knobs';
@ -18,6 +17,7 @@ import {
import { ConversationTypeType } from '../state/ducks/conversations'; import { ConversationTypeType } from '../state/ducks/conversations';
import { Colors, ColorType } from '../types/Colors'; import { Colors, ColorType } from '../types/Colors';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource';
import { setup as setupI18n } from '../../js/modules/i18n'; import { setup as setupI18n } from '../../js/modules/i18n';
import { Props as SafetyNumberViewerProps } from '../state/smart/SafetyNumberViewer'; import { Props as SafetyNumberViewerProps } from '../state/smart/SafetyNumberViewer';
import enMessages from '../../_locales/en/messages.json'; import enMessages from '../../_locales/en/messages.json';
@ -64,10 +64,8 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
cancelCall: action('cancel-call'), cancelCall: action('cancel-call'),
closeNeedPermissionScreen: action('close-need-permission-screen'), closeNeedPermissionScreen: action('close-need-permission-screen'),
declineCall: action('decline-call'), declineCall: action('decline-call'),
// We allow `any` here because this is fake and actually comes from RingRTC, which we getGroupCallVideoFrameSource: (_: string, demuxId: number) =>
// can't import. fakeGetGroupCallVideoFrameSource(demuxId),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getGroupCallVideoFrameSource: noop as any,
hangUp: action('hang-up'), hangUp: action('hang-up'),
i18n, i18n,
keyChangeOk: action('key-change-ok'), keyChangeOk: action('key-change-ok'),

View file

@ -1,10 +1,11 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
import { noop } from 'lodash'; import { times } from 'lodash';
import { v4 as generateUuid } from 'uuid';
import { storiesOf } from '@storybook/react'; import { storiesOf } from '@storybook/react';
import { boolean, select } from '@storybook/addon-knobs'; import { boolean, select, number } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { import {
@ -20,8 +21,11 @@ import { CallScreen, PropsType } from './CallScreen';
import { setup as setupI18n } from '../../js/modules/i18n'; import { setup as setupI18n } from '../../js/modules/i18n';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource';
import enMessages from '../../_locales/en/messages.json'; import enMessages from '../../_locales/en/messages.json';
const MAX_PARTICIPANTS = 32;
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
const conversation = { const conversation = {
@ -130,10 +134,7 @@ const createProps = (
} }
): PropsType => ({ ): PropsType => ({
activeCall: createActiveCallProp(overrideProps), activeCall: createActiveCallProp(overrideProps),
// We allow `any` here because this is fake and actually comes from RingRTC, which we getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource,
// can't import.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getGroupCallVideoFrameSource: noop as any,
hangUp: action('hang-up'), hangUp: action('hang-up'),
i18n, i18n,
me: { me: {
@ -257,48 +258,37 @@ story.add('Group call - 1', () => (
/> />
)); ));
story.add('Group call - Many', () => ( // We generate these upfront so that the list is stable when you move the slider.
const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => ({
demuxId: index,
hasRemoteAudio: index % 3 !== 0,
hasRemoteVideo: index % 4 !== 0,
videoAspectRatio: 1.3,
...getDefaultConversation({
isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1,
title: `Participant ${index + 1}`,
uuid: generateUuid(),
}),
}));
story.add('Group call - Many', () => {
return (
<CallScreen <CallScreen
{...createProps({ {...createProps({
callMode: CallMode.Group, callMode: CallMode.Group,
remoteParticipants: [ remoteParticipants: allRemoteParticipants.slice(
{ 0,
demuxId: 0, number('Participant count', 3, {
hasRemoteAudio: true, range: true,
hasRemoteVideo: true, min: 0,
videoAspectRatio: 1.3, max: MAX_PARTICIPANTS,
...getDefaultConversation({ step: 1,
isBlocked: false, })
title: 'Amy', ),
uuid: '094586f5-8fc2-4ce2-a152-2dfcc99f4630',
}),
},
{
demuxId: 1,
hasRemoteAudio: true,
hasRemoteVideo: true,
videoAspectRatio: 1.3,
...getDefaultConversation({
isBlocked: false,
title: 'Bob',
uuid: 'cb5bdb24-4cbb-4650-8a7a-1a2807051e74',
}),
},
{
demuxId: 2,
hasRemoteAudio: true,
hasRemoteVideo: true,
videoAspectRatio: 1.3,
...getDefaultConversation({
isBlocked: true,
title: 'Alice',
uuid: '2d7d13ae-53dc-4a51-8dc7-976cd85e0b57',
}),
},
],
})} })}
/> />
)); );
});
story.add('Group call - reconnecting', () => ( story.add('Group call - reconnecting', () => (
<CallScreen <CallScreen

View file

@ -1,8 +1,7 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
import { noop } from 'lodash';
import { storiesOf } from '@storybook/react'; import { storiesOf } from '@storybook/react';
import { boolean } from '@storybook/addon-knobs'; import { boolean } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
@ -17,6 +16,7 @@ import {
GroupCallConnectionState, GroupCallConnectionState,
GroupCallJoinState, GroupCallJoinState,
} from '../types/Calling'; } from '../types/Calling';
import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource';
import { setup as setupI18n } from '../../js/modules/i18n'; import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json'; import enMessages from '../../_locales/en/messages.json';
@ -55,8 +55,7 @@ const defaultCall: ActiveCallType = {
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
activeCall: overrideProps.activeCall || defaultCall, activeCall: overrideProps.activeCall || defaultCall,
// eslint-disable-next-line @typescript-eslint/no-explicit-any getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource,
getGroupCallVideoFrameSource: noop as any,
hangUp: action('hang-up'), hangUp: action('hang-up'),
hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false), hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false),
i18n, i18n,

View file

@ -0,0 +1,93 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { FC } from 'react';
import { memoize, times } from 'lodash';
import { v4 as generateUuid } from 'uuid';
import { storiesOf } from '@storybook/react';
import { number } from '@storybook/addon-knobs';
import { GroupCallOverflowArea } from './GroupCallOverflowArea';
import { setup as setupI18n } from '../../js/modules/i18n';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource';
import { FRAME_BUFFER_SIZE } from '../calling/constants';
import enMessages from '../../_locales/en/messages.json';
const MAX_PARTICIPANTS = 32;
const i18n = setupI18n('en', enMessages);
const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => ({
demuxId: index,
hasRemoteAudio: index % 3 !== 0,
hasRemoteVideo: index % 4 !== 0,
videoAspectRatio: 1.3,
...getDefaultConversation({
isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1,
title: `Participant ${index + 1}`,
uuid: generateUuid(),
}),
}));
const story = storiesOf('Components/GroupCallOverflowArea', module);
const defaultProps = {
getFrameBuffer: memoize(() => new ArrayBuffer(FRAME_BUFFER_SIZE)),
getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource,
i18n,
};
// This component is usually rendered on a call screen.
const Container: FC = ({ children }) => (
<div
style={{
background: 'black',
display: 'inline-flex',
height: '80vh',
}}
>
{children}
</div>
);
story.add('No overflowed participants', () => (
<Container>
<GroupCallOverflowArea {...defaultProps} overflowedParticipants={[]} />
</Container>
));
story.add('One overflowed participant', () => (
<Container>
<GroupCallOverflowArea
{...defaultProps}
overflowedParticipants={allRemoteParticipants.slice(0, 1)}
/>
</Container>
));
story.add('Three overflowed participants', () => (
<Container>
<GroupCallOverflowArea
{...defaultProps}
overflowedParticipants={allRemoteParticipants.slice(0, 3)}
/>
</Container>
));
story.add('Many overflowed participants', () => (
<Container>
<GroupCallOverflowArea
{...defaultProps}
overflowedParticipants={allRemoteParticipants.slice(
0,
number('Participant count', MAX_PARTICIPANTS, {
range: true,
min: 0,
max: MAX_PARTICIPANTS,
step: 1,
})
)}
/>
</Container>
));

View file

@ -0,0 +1,174 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useRef, useState, useEffect, FC, ReactElement } from 'react';
import classNames from 'classnames';
import { LocalizerType } from '../types/Util';
import {
GroupCallRemoteParticipantType,
VideoFrameSource,
} from '../types/Calling';
import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant';
const OVERFLOW_SCROLLED_TO_EDGE_THRESHOLD = 20;
const OVERFLOW_SCROLL_BUTTON_RATIO = 0.75;
// This should be an integer, as sub-pixel widths can cause performance issues.
export const OVERFLOW_PARTICIPANT_WIDTH = 140;
interface PropsType {
getFrameBuffer: () => ArrayBuffer;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
i18n: LocalizerType;
overflowedParticipants: ReadonlyArray<GroupCallRemoteParticipantType>;
}
export const GroupCallOverflowArea: FC<PropsType> = ({
getFrameBuffer,
getGroupCallVideoFrameSource,
i18n,
overflowedParticipants,
}) => {
const overflowRef = useRef<HTMLDivElement | null>(null);
const [overflowScrollTop, setOverflowScrollTop] = useState(0);
// This assumes that these values will change along with re-renders. If that's not true,
// we should add these values to the component's state.
let visibleHeight: number;
let scrollMax: number;
if (overflowRef.current) {
visibleHeight = overflowRef.current.clientHeight;
scrollMax = overflowRef.current.scrollHeight - visibleHeight;
} else {
visibleHeight = 0;
scrollMax = 0;
}
const hasOverflowedParticipants = Boolean(overflowedParticipants.length);
useEffect(() => {
// If there aren't any overflowed participants, we want to clear the scroll position
// so we don't hold onto stale values.
if (!hasOverflowedParticipants) {
setOverflowScrollTop(0);
}
}, [hasOverflowedParticipants]);
if (!hasOverflowedParticipants) {
return null;
}
const isScrolledToTop =
overflowScrollTop < OVERFLOW_SCROLLED_TO_EDGE_THRESHOLD;
const isScrolledToBottom =
overflowScrollTop > scrollMax - OVERFLOW_SCROLLED_TO_EDGE_THRESHOLD;
return (
<div
className="module-ongoing-call__participants__overflow"
style={{
// This width could live in CSS but we put it here to avoid having to keep two
// values in sync.
width: OVERFLOW_PARTICIPANT_WIDTH,
}}
>
<OverflowAreaScrollMarker
i18n={i18n}
isHidden={isScrolledToTop}
onClick={() => {
const el = overflowRef.current;
if (!el) {
return;
}
el.scrollTo({
top: Math.max(
el.scrollTop - visibleHeight * OVERFLOW_SCROLL_BUTTON_RATIO,
0
),
left: 0,
behavior: 'smooth',
});
}}
placement="top"
/>
<div
className="module-ongoing-call__participants__overflow__inner"
ref={overflowRef}
onScroll={() => {
// Ideally this would use `event.target.scrollTop`, but that does not seem to be
// available, so we use the ref.
const el = overflowRef.current;
if (!el) {
return;
}
setOverflowScrollTop(el.scrollTop);
}}
>
{overflowedParticipants.map(remoteParticipant => (
<GroupCallRemoteParticipant
key={remoteParticipant.demuxId}
getFrameBuffer={getFrameBuffer}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
i18n={i18n}
width={OVERFLOW_PARTICIPANT_WIDTH}
height={Math.floor(
OVERFLOW_PARTICIPANT_WIDTH / remoteParticipant.videoAspectRatio
)}
remoteParticipant={remoteParticipant}
/>
))}
</div>
<OverflowAreaScrollMarker
i18n={i18n}
isHidden={isScrolledToBottom}
onClick={() => {
const el = overflowRef.current;
if (!el) {
return;
}
el.scrollTo({
top: Math.min(
el.scrollTop + visibleHeight * OVERFLOW_SCROLL_BUTTON_RATIO,
scrollMax
),
left: 0,
behavior: 'smooth',
});
}}
placement="bottom"
/>
</div>
);
};
function OverflowAreaScrollMarker({
i18n,
isHidden,
onClick,
placement,
}: {
i18n: LocalizerType;
isHidden: boolean;
onClick: () => void;
placement: 'top' | 'bottom';
}): ReactElement {
const baseClassName =
'module-ongoing-call__participants__overflow__scroll-marker';
return (
<div
className={classNames(baseClassName, `${baseClassName}--${placement}`, {
[`${baseClassName}--hidden`]: isHidden,
})}
>
<button
type="button"
className={`${baseClassName}__button`}
onClick={onClick}
aria-label={i18n(
`calling__overflow__scroll-${placement === 'top' ? 'up' : 'down'}`
)}
/>
</div>
);
}

View file

@ -21,6 +21,7 @@ import { Avatar, AvatarSize } from './Avatar';
import { ConfirmationModal } from './ConfirmationModal'; import { ConfirmationModal } from './ConfirmationModal';
import { Intl } from './Intl'; import { Intl } from './Intl';
import { ContactName } from './conversation/ContactName'; import { ContactName } from './conversation/ContactName';
import { useIntersectionObserver } from '../util/hooks';
import { MAX_FRAME_SIZE } from '../calling/constants'; import { MAX_FRAME_SIZE } from '../calling/constants';
interface BasePropsType { interface BasePropsType {
@ -34,15 +35,19 @@ interface InPipPropsType {
isInPip: true; isInPip: true;
} }
interface NotInPipPropsType { interface InOverflowAreaPropsType {
height: number; height: number;
isInPip?: false; isInPip?: false;
left: number;
top: number;
width: number; width: number;
} }
export type PropsType = BasePropsType & (InPipPropsType | NotInPipPropsType); interface InGridPropsType extends InOverflowAreaPropsType {
left: number;
top: number;
}
export type PropsType = BasePropsType &
(InPipPropsType | InOverflowAreaPropsType | InGridPropsType);
export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo( export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
props => { props => {
@ -57,15 +62,26 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
isBlocked, isBlocked,
profileName, profileName,
title, title,
videoAspectRatio,
} = props.remoteParticipant; } = props.remoteParticipant;
const [isWide, setIsWide] = useState(true); const [isWide, setIsWide] = useState<boolean>(
videoAspectRatio ? videoAspectRatio >= 1 : true
);
const [hasHover, setHover] = useState(false); const [hasHover, setHover] = useState(false);
const [showBlockInfo, setShowBlockInfo] = useState(false); const [showBlockInfo, setShowBlockInfo] = useState(false);
const remoteVideoRef = useRef<HTMLCanvasElement | null>(null); const remoteVideoRef = useRef<HTMLCanvasElement | null>(null);
const canvasContextRef = useRef<CanvasRenderingContext2D | null>(null); const canvasContextRef = useRef<CanvasRenderingContext2D | null>(null);
const [
intersectionRef,
intersectionObserverEntry,
] = useIntersectionObserver();
const isVisible = intersectionObserverEntry
? intersectionObserverEntry.isIntersecting
: true;
const videoFrameSource = useMemo( const videoFrameSource = useMemo(
() => getGroupCallVideoFrameSource(demuxId), () => getGroupCallVideoFrameSource(demuxId),
[getGroupCallVideoFrameSource, demuxId] [getGroupCallVideoFrameSource, demuxId]
@ -118,7 +134,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
}, [getFrameBuffer, videoFrameSource]); }, [getFrameBuffer, videoFrameSource]);
useEffect(() => { useEffect(() => {
if (!hasRemoteVideo) { if (!hasRemoteVideo || !isVisible) {
return noop; return noop;
} }
@ -132,7 +148,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
return () => { return () => {
cancelAnimationFrame(rafId); cancelAnimationFrame(rafId);
}; };
}, [hasRemoteVideo, renderVideoFrame, videoFrameSource]); }, [hasRemoteVideo, isVisible, renderVideoFrame, videoFrameSource]);
let canvasStyles: CSSProperties; let canvasStyles: CSSProperties;
let containerStyles: CSSProperties; let containerStyles: CSSProperties;
@ -155,7 +171,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
containerStyles = canvasStyles; containerStyles = canvasStyles;
avatarSize = AvatarSize.FIFTY_TWO; avatarSize = AvatarSize.FIFTY_TWO;
} else { } else {
const { top, left, width, height } = props; const { width, height } = props;
const shorterDimension = Math.min(width, height); const shorterDimension = Math.min(width, height);
if (shorterDimension >= 240) { if (shorterDimension >= 240) {
@ -168,15 +184,18 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
containerStyles = { containerStyles = {
height, height,
left,
position: 'absolute',
top,
width, width,
}; };
if ('top' in props) {
containerStyles.position = 'absolute';
containerStyles.top = props.top;
containerStyles.left = props.left;
}
} }
const showHover = hasHover && !props.isInPip; const showHover = hasHover && !props.isInPip;
const canShowVideo = hasRemoteVideo && !isBlocked; const canShowVideo = hasRemoteVideo && !isBlocked && isVisible;
return ( return (
<> <>
@ -218,6 +237,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
<div <div
className="module-ongoing-call__group-call-remote-participant" className="module-ongoing-call__group-call-remote-participant"
ref={intersectionRef}
onMouseEnter={() => setHover(true)} onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)} onMouseLeave={() => setHover(false)}
style={containerStyles} style={containerStyles}

View file

@ -5,6 +5,10 @@ import React, { useState, useMemo, useEffect } from 'react';
import Measure from 'react-measure'; import Measure from 'react-measure';
import { takeWhile, chunk, maxBy, flatten } from 'lodash'; import { takeWhile, chunk, maxBy, flatten } from 'lodash';
import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant'; import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant';
import {
GroupCallOverflowArea,
OVERFLOW_PARTICIPANT_WIDTH,
} from './GroupCallOverflowArea';
import { import {
GroupCallRemoteParticipantType, GroupCallRemoteParticipantType,
GroupCallVideoRequest, GroupCallVideoRequest,
@ -15,7 +19,7 @@ import { LocalizerType } from '../types/Util';
import { usePageVisibility } from '../util/hooks'; import { usePageVisibility } from '../util/hooks';
import { nonRenderedRemoteParticipant } from '../util/ringrtc/nonRenderedRemoteParticipant'; import { nonRenderedRemoteParticipant } from '../util/ringrtc/nonRenderedRemoteParticipant';
const MIN_RENDERED_HEIGHT = 10; const MIN_RENDERED_HEIGHT = 180;
const PARTICIPANT_MARGIN = 10; const PARTICIPANT_MARGIN = 10;
// We scale our video requests down for performance. This number is somewhat arbitrary. // We scale our video requests down for performance. This number is somewhat arbitrary.
@ -54,9 +58,8 @@ interface PropsType {
// //
// 1. Figure out the maximum number of possible rows that could fit on the screen; this is // 1. Figure out the maximum number of possible rows that could fit on the screen; this is
// `maxRowCount`. // `maxRowCount`.
// 2. Figure out how many participants should be visible if all participants were rendered // 2. Split the participants into two groups: ones in the main grid and ones in the
// at the minimum height. Most of the time, we'll be able to render all of them, but on // overflow area. The grid should prioritize participants who have recently spoken.
// full calls with lots of participants, there could be some lost.
// 3. For each possible number of rows (starting at 0 and ending at `maxRowCount`), // 3. For each possible number of rows (starting at 0 and ending at `maxRowCount`),
// distribute participants across the rows at the minimum height. Then find the // distribute participants across the rows at the minimum height. Then find the
// "scalar": how much can we scale these boxes up while still fitting them on the // "scalar": how much can we scale these boxes up while still fitting them on the
@ -72,6 +75,11 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
width: 0, width: 0,
height: 0, height: 0,
}); });
const [gridDimensions, setGridDimensions] = useState<Dimensions>({
width: 0,
height: 0,
});
const isPageVisible = usePageVisibility(); const isPageVisible = usePageVisibility();
const getFrameBuffer = useGetCallingFrameBuffer(); const getFrameBuffer = useGetCallingFrameBuffer();
@ -92,13 +100,28 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
) )
); );
// 2. Figure out how many participants should be visible if all participants were // 2. Split participants into two groups: ones in the main grid and ones in the overflow
// rendered at the minimum height. Most of the time, we'll be able to render all of // sidebar.
// them, but on full calls with lots of participants, there could be some lost.
// //
// This is primarily memoized for clarity, not performance. We only need the result, // We start by sorting by `speakerTime` so that the most recent speakers are first in
// not any of the "intermediate" values. // line for the main grid. Then we split the list in two: one for the grid and one for
const visibleParticipants: Array<GroupCallRemoteParticipantType> = useMemo(() => { // the overflow area.
//
// Once we've sorted participants into their respective groups, we sort them on
// something stable (the `demuxId`, but we could choose something else) so that people
// don't jump around within the group.
//
// These are primarily memoized for clarity, not performance.
const sortedParticipants: Array<GroupCallRemoteParticipantType> = useMemo(
() =>
remoteParticipants
.concat()
.sort(
(a, b) => (b.speakerTime || -Infinity) - (a.speakerTime || -Infinity)
),
[remoteParticipants]
);
const gridParticipants: Array<GroupCallRemoteParticipantType> = useMemo(() => {
// Imagine that we laid out all of the rows end-to-end. That's the maximum total // Imagine that we laid out all of the rows end-to-end. That's the maximum total
// width. So if there were 5 rows and the container was 100px wide, then we can't // width. So if there were 5 rows and the container was 100px wide, then we can't
// possibly fit more than 500px of participants. // possibly fit more than 500px of participants.
@ -107,13 +130,17 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
// We do the same thing for participants, "laying them out end-to-end" until they // We do the same thing for participants, "laying them out end-to-end" until they
// exceed the maximum total width. // exceed the maximum total width.
let totalWidth = 0; let totalWidth = 0;
return takeWhile(remoteParticipants, remoteParticipant => { return takeWhile(sortedParticipants, remoteParticipant => {
totalWidth += remoteParticipant.videoAspectRatio * MIN_RENDERED_HEIGHT; totalWidth += remoteParticipant.videoAspectRatio * MIN_RENDERED_HEIGHT;
return totalWidth < maxTotalWidth; return totalWidth < maxTotalWidth;
}); }).sort(stableParticipantComparator);
}, [maxRowCount, containerDimensions.width, remoteParticipants]); }, [maxRowCount, containerDimensions.width, sortedParticipants]);
const overflowedParticipants: Array<GroupCallRemoteParticipantType> = remoteParticipants.slice( const overflowedParticipants: Array<GroupCallRemoteParticipantType> = useMemo(
visibleParticipants.length () =>
sortedParticipants
.slice(gridParticipants.length)
.sort(stableParticipantComparator),
[sortedParticipants, gridParticipants.length]
); );
// 3. For each possible number of rows (starting at 0 and ending at `maxRowCount`), // 3. For each possible number of rows (starting at 0 and ending at `maxRowCount`),
@ -126,22 +153,22 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
rows: [], rows: [],
}; };
if (!visibleParticipants.length) { if (!gridParticipants.length) {
return bestArrangement; return bestArrangement;
} }
for (let rowCount = 1; rowCount <= maxRowCount; rowCount += 1) { for (let rowCount = 1; rowCount <= maxRowCount; rowCount += 1) {
// We do something pretty naïve here and chunk the visible participants into rows. // We do something pretty naïve here and chunk the grid's participants into rows.
// For example, if there were 12 visible participants and `rowCount === 3`, there // For example, if there were 12 grid participants and `rowCount === 3`, there
// would be 4 participants per row. // would be 4 participants per row.
// //
// This naïve chunking is suboptimal in terms of absolute best fit, but it is much // This naïve chunking is suboptimal in terms of absolute best fit, but it is much
// faster and simpler than trying to do this perfectly. In practice, this works // faster and simpler than trying to do this perfectly. In practice, this works
// fine in the UI from our testing. // fine in the UI from our testing.
const numberOfParticipantsInRow = Math.ceil( const numberOfParticipantsInRow = Math.ceil(
visibleParticipants.length / rowCount gridParticipants.length / rowCount
); );
const rows = chunk(visibleParticipants, numberOfParticipantsInRow); const rows = chunk(gridParticipants, numberOfParticipantsInRow);
// We need to find the scalar for this arrangement. Imagine that we have these // We need to find the scalar for this arrangement. Imagine that we have these
// participants at the minimum heights, and we want to scale everything up until // participants at the minimum heights, and we want to scale everything up until
@ -158,11 +185,10 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
continue; continue;
} }
const widthScalar = const widthScalar =
(containerDimensions.width - (gridDimensions.width - (widestRow.length + 1) * PARTICIPANT_MARGIN) /
(widestRow.length + 1) * PARTICIPANT_MARGIN) /
totalRemoteParticipantWidthAtMinHeight(widestRow); totalRemoteParticipantWidthAtMinHeight(widestRow);
const heightScalar = const heightScalar =
(containerDimensions.height - (rowCount + 1) * PARTICIPANT_MARGIN) / (gridDimensions.height - (rowCount + 1) * PARTICIPANT_MARGIN) /
(rowCount * MIN_RENDERED_HEIGHT); (rowCount * MIN_RENDERED_HEIGHT);
const scalar = Math.min(widthScalar, heightScalar); const scalar = Math.min(widthScalar, heightScalar);
@ -174,10 +200,10 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
return bestArrangement; return bestArrangement;
}, [ }, [
visibleParticipants, gridParticipants,
maxRowCount, maxRowCount,
containerDimensions.width, gridDimensions.width,
containerDimensions.height, gridDimensions.height,
]); ]);
// 4. Lay out this arrangement on the screen. // 4. Lay out this arrangement on the screen.
@ -189,7 +215,7 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
const gridTotalRowHeightWithMargin = const gridTotalRowHeightWithMargin =
gridParticipantHeightWithMargin * gridArrangement.rows.length; gridParticipantHeightWithMargin * gridArrangement.rows.length;
const gridTopOffset = Math.floor( const gridTopOffset = Math.floor(
(containerDimensions.height - gridTotalRowHeightWithMargin) / 2 (gridDimensions.height - gridTotalRowHeightWithMargin) / 2
); );
const rowElements: Array<Array<JSX.Element>> = gridArrangement.rows.map( const rowElements: Array<Array<JSX.Element>> = gridArrangement.rows.map(
@ -202,9 +228,7 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
const totalRowWidth = const totalRowWidth =
totalRowWidthWithoutMargins + totalRowWidthWithoutMargins +
PARTICIPANT_MARGIN * (remoteParticipantsInRow.length - 1); PARTICIPANT_MARGIN * (remoteParticipantsInRow.length - 1);
const leftOffset = Math.floor( const leftOffset = Math.floor((gridDimensions.width - totalRowWidth) / 2);
(containerDimensions.width - totalRowWidth) / 2
);
let rowWidthSoFar = 0; let rowWidthSoFar = 0;
return remoteParticipantsInRow.map(remoteParticipant => { return remoteParticipantsInRow.map(remoteParticipant => {
@ -235,7 +259,7 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
useEffect(() => { useEffect(() => {
if (isPageVisible) { if (isPageVisible) {
setGroupCallVideoRequest([ setGroupCallVideoRequest([
...visibleParticipants.map(participant => { ...gridParticipants.map(participant => {
if (participant.hasRemoteVideo) { if (participant.hasRemoteVideo) {
return { return {
demuxId: participant.demuxId, demuxId: participant.demuxId,
@ -249,7 +273,21 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
} }
return nonRenderedRemoteParticipant(participant); return nonRenderedRemoteParticipant(participant);
}), }),
...overflowedParticipants.map(nonRenderedRemoteParticipant), ...overflowedParticipants.map(participant => {
if (participant.hasRemoteVideo) {
return {
demuxId: participant.demuxId,
width: Math.floor(
OVERFLOW_PARTICIPANT_WIDTH * VIDEO_REQUEST_SCALAR
),
height: Math.floor(
(OVERFLOW_PARTICIPANT_WIDTH / participant.videoAspectRatio) *
VIDEO_REQUEST_SCALAR
),
};
}
return nonRenderedRemoteParticipant(participant);
}),
]); ]);
} else { } else {
setGroupCallVideoRequest( setGroupCallVideoRequest(
@ -262,7 +300,7 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
overflowedParticipants, overflowedParticipants,
remoteParticipants, remoteParticipants,
setGroupCallVideoRequest, setGroupCallVideoRequest,
visibleParticipants, gridParticipants,
]); ]);
return ( return (
@ -276,12 +314,40 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
setContainerDimensions(bounds); setContainerDimensions(bounds);
}} }}
> >
{({ measureRef }) => ( {containerMeasure => (
<div className="module-ongoing-call__grid" ref={measureRef}> <div
className="module-ongoing-call__participants"
ref={containerMeasure.measureRef}
>
<Measure
bounds
onResize={({ bounds }) => {
if (!bounds) {
window.log.error('We should be measuring the bounds');
return;
}
setGridDimensions(bounds);
}}
>
{gridMeasure => (
<div
className="module-ongoing-call__participants__grid"
ref={gridMeasure.measureRef}
>
{flatten(rowElements)} {flatten(rowElements)}
</div> </div>
)} )}
</Measure> </Measure>
<GroupCallOverflowArea
getFrameBuffer={getFrameBuffer}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
i18n={i18n}
overflowedParticipants={overflowedParticipants}
/>
</div>
)}
</Measure>
); );
}; };
@ -294,3 +360,10 @@ function totalRemoteParticipantWidthAtMinHeight(
0 0
); );
} }
function stableParticipantComparator(
a: Readonly<{ demuxId: number }>,
b: Readonly<{ demuxId: number }>
): number {
return a.demuxId - b.demuxId;
}

View file

@ -0,0 +1,62 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { VideoFrameSource } from '../../types/Calling';
const COLORS: Array<[number, number, number]> = [
[0xff, 0x00, 0x00],
[0xff, 0x99, 0x00],
[0xff, 0xff, 0x00],
[0x00, 0xff, 0x00],
[0x00, 0x99, 0xff],
[0xff, 0x00, 0xff],
[0x99, 0x33, 0xff],
];
class FakeGroupCallVideoFrameSource implements VideoFrameSource {
private readonly sourceArray: Uint8Array;
private readonly dimensions: [number, number];
constructor(width: number, height: number, r: number, g: number, b: number) {
const length = width * height * 4;
this.sourceArray = new Uint8Array(length);
for (let i = 0; i < length; i += 4) {
this.sourceArray[i] = r;
this.sourceArray[i + 1] = g;
this.sourceArray[i + 2] = b;
this.sourceArray[i + 3] = 255;
}
this.dimensions = [width, height];
}
receiveVideoFrame(
destinationBuffer: ArrayBuffer
): [number, number] | undefined {
// Simulate network jitter. Also improves performance when testing.
if (Math.random() < 0.5) {
return undefined;
}
new Uint8Array(destinationBuffer).set(this.sourceArray);
return this.dimensions;
}
}
/**
* This produces a fake video frame source that is a single color.
*
* The aspect ratio is fixed at 1.3 because that matches many of our stories.
*/
export function fakeGetGroupCallVideoFrameSource(
demuxId: number
): VideoFrameSource {
const color = COLORS[demuxId % COLORS.length];
if (!color) {
throw new Error('Expected a color, but it was not found');
}
const [r, g, b] = color;
return new FakeGroupCallVideoFrameSource(13, 10, r, g, b);
}

View file

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
@ -66,3 +66,62 @@ export const usePageVisibility = (): boolean => {
return result; return result;
}; };
/**
* A light hook wrapper around `IntersectionObserver`.
*
* Example usage:
*
* function MyComponent() {
* const [intersectionRef, intersectionEntry] = useIntersectionObserver();
* const isVisible = intersectionEntry
* ? intersectionEntry.isIntersecting
* : true;
*
* return (
* <div ref={intersectionRef}>
* I am {isVisible ? 'on the screen' : 'invisible'}
* </div>
* );
* }
*/
export function useIntersectionObserver(): [
(el?: Element | null) => void,
IntersectionObserverEntry | null
] {
const [
intersectionObserverEntry,
setIntersectionObserverEntry,
] = React.useState<IntersectionObserverEntry | null>(null);
const unobserveRef = React.useRef<(() => unknown) | null>(null);
const setRef = React.useCallback((el?: Element | null) => {
if (unobserveRef.current) {
unobserveRef.current();
unobserveRef.current = null;
}
if (!el) {
return;
}
const observer = new IntersectionObserver(entries => {
if (entries.length !== 1) {
window.log.error(
'IntersectionObserverWrapper was observing the wrong number of elements'
);
return;
}
entries.forEach(entry => {
setIntersectionObserverEntry(entry);
});
});
unobserveRef.current = observer.unobserve.bind(observer, el);
observer.observe(el);
}, []);
return [setRef, intersectionObserverEntry];
}

View file

@ -14523,11 +14523,20 @@
"updated": "2020-11-11T21:56:04.179Z", "updated": "2020-11-11T21:56:04.179Z",
"reasonDetail": "Needed to render the remote video element." "reasonDetail": "Needed to render the remote video element."
}, },
{
"rule": "React-useRef",
"path": "ts/components/GroupCallOverflowArea.js",
"line": " const overflowRef = react_1.useRef(null);",
"lineNumber": 36,
"reasonCategory": "usageTrusted",
"updated": "2021-01-08T15:48:46.313Z",
"reasonDetail": "Used to deal with scroll position."
},
{ {
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/GroupCallRemoteParticipant.js", "path": "ts/components/GroupCallRemoteParticipant.js",
"line": " const remoteVideoRef = react_1.useRef(null);", "line": " const remoteVideoRef = react_1.useRef(null);",
"lineNumber": 43, "lineNumber": 44,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-11-11T21:56:04.179Z", "updated": "2020-11-11T21:56:04.179Z",
"reasonDetail": "Needed to render the remote video element." "reasonDetail": "Needed to render the remote video element."
@ -14536,7 +14545,7 @@
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/GroupCallRemoteParticipant.js", "path": "ts/components/GroupCallRemoteParticipant.js",
"line": " const canvasContextRef = react_1.useRef(null);", "line": " const canvasContextRef = react_1.useRef(null);",
"lineNumber": 44, "lineNumber": 45,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2020-11-17T23:29:38.698Z", "updated": "2020-11-17T23:29:38.698Z",
"reasonDetail": "Doesn't touch the DOM." "reasonDetail": "Doesn't touch the DOM."
@ -15204,5 +15213,14 @@
"lineNumber": 2177, "lineNumber": 2177,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2020-09-08T23:07:22.682Z" "updated": "2020-09-08T23:07:22.682Z"
},
{
"rule": "React-useRef",
"path": "ts/util/hooks.js",
"line": " const unobserveRef = React.useRef(null);",
"lineNumber": 95,
"reasonCategory": "usageTrusted",
"updated": "2021-01-08T15:46:32.143Z",
"reasonDetail": "Doesn't manipulate the DOM. This is just a function."
} }
] ]