diff --git a/_locales/en/messages.json b/_locales/en/messages.json index bec11218c6..b957a1412a 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1306,6 +1306,14 @@ "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" }, + "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": { "message": "Always relay calls", "description": "Description of the always relay calls setting" diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 0f8bb372ae..43ff393afb 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -5842,7 +5842,7 @@ button.module-image__border-overlay:focus { .module-calling { &__container { align-items: center; - background-color: $color-gray-95; + background-color: $calling-background-color; display: flex; flex-direction: column; height: 100vh; @@ -6253,6 +6253,7 @@ button.module-image__border-overlay:focus { top: 0; width: 100%; z-index: 2; + padding-bottom: 1rem; } &__header-message { @@ -6262,10 +6263,110 @@ button.module-image__border-overlay:focus { letter-spacing: -0.0025em; } - &__grid { - flex-grow: 1; + &__participants { + display: flex; + flex: 1 1 0; width: 100%; - position: relative; + + &__grid { + flex-grow: 1; + 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 { diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index acb0db5b57..7304491cf6 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -1,4 +1,4 @@ -// Copyright 2015-2020 Signal Messenger, LLC +// Copyright 2015-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only $inter: Inter, 'Helvetica Neue', 'Source Sans Pro', 'Source Han Sans SC', @@ -194,3 +194,5 @@ $left-pane-width: 320px; $header-height: 52px; $ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1); + +$calling-background-color: $color-gray-95; diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index a1583ad1d1..39d0590f88 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -1,8 +1,7 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; -import { noop } from 'lodash'; import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { boolean, select, text } from '@storybook/addon-knobs'; @@ -18,6 +17,7 @@ import { import { ConversationTypeType } from '../state/ducks/conversations'; import { Colors, ColorType } from '../types/Colors'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; +import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource'; import { setup as setupI18n } from '../../js/modules/i18n'; import { Props as SafetyNumberViewerProps } from '../state/smart/SafetyNumberViewer'; import enMessages from '../../_locales/en/messages.json'; @@ -64,10 +64,8 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ cancelCall: action('cancel-call'), closeNeedPermissionScreen: action('close-need-permission-screen'), declineCall: action('decline-call'), - // We allow `any` here because this is fake and actually comes from RingRTC, which we - // can't import. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getGroupCallVideoFrameSource: noop as any, + getGroupCallVideoFrameSource: (_: string, demuxId: number) => + fakeGetGroupCallVideoFrameSource(demuxId), hangUp: action('hang-up'), i18n, keyChangeOk: action('key-change-ok'), diff --git a/ts/components/CallScreen.stories.tsx b/ts/components/CallScreen.stories.tsx index b1489b8612..2f2b5df64b 100644 --- a/ts/components/CallScreen.stories.tsx +++ b/ts/components/CallScreen.stories.tsx @@ -1,10 +1,11 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only 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 { boolean, select } from '@storybook/addon-knobs'; +import { boolean, select, number } from '@storybook/addon-knobs'; import { action } from '@storybook/addon-actions'; import { @@ -20,8 +21,11 @@ import { CallScreen, PropsType } from './CallScreen'; import { setup as setupI18n } from '../../js/modules/i18n'; import { missingCaseError } from '../util/missingCaseError'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; +import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource'; import enMessages from '../../_locales/en/messages.json'; +const MAX_PARTICIPANTS = 32; + const i18n = setupI18n('en', enMessages); const conversation = { @@ -130,10 +134,7 @@ const createProps = ( } ): PropsType => ({ activeCall: createActiveCallProp(overrideProps), - // We allow `any` here because this is fake and actually comes from RingRTC, which we - // can't import. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getGroupCallVideoFrameSource: noop as any, + getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource, hangUp: action('hang-up'), i18n, 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 ( + + ); +}); story.add('Group call - reconnecting', () => ( = {}): PropsType => ({ activeCall: overrideProps.activeCall || defaultCall, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getGroupCallVideoFrameSource: noop as any, + getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource, hangUp: action('hang-up'), hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false), i18n, diff --git a/ts/components/GroupCallOverflowArea.stories.tsx b/ts/components/GroupCallOverflowArea.stories.tsx new file mode 100644 index 0000000000..507a52f23f --- /dev/null +++ b/ts/components/GroupCallOverflowArea.stories.tsx @@ -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 }) => ( +
+ {children} +
+); + +story.add('No overflowed participants', () => ( + + + +)); + +story.add('One overflowed participant', () => ( + + + +)); + +story.add('Three overflowed participants', () => ( + + + +)); + +story.add('Many overflowed participants', () => ( + + + +)); diff --git a/ts/components/GroupCallOverflowArea.tsx b/ts/components/GroupCallOverflowArea.tsx new file mode 100644 index 0000000000..e6d5b84f85 --- /dev/null +++ b/ts/components/GroupCallOverflowArea.tsx @@ -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; +} + +export const GroupCallOverflowArea: FC = ({ + getFrameBuffer, + getGroupCallVideoFrameSource, + i18n, + overflowedParticipants, +}) => { + const overflowRef = useRef(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 ( +
+ { + 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" + /> +
{ + // 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 => ( + + ))} +
+ { + 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" + /> +
+ ); +}; + +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 ( +
+
+ ); +} diff --git a/ts/components/GroupCallRemoteParticipant.tsx b/ts/components/GroupCallRemoteParticipant.tsx index 5c2d3e1f37..c7cd06ea78 100644 --- a/ts/components/GroupCallRemoteParticipant.tsx +++ b/ts/components/GroupCallRemoteParticipant.tsx @@ -21,6 +21,7 @@ import { Avatar, AvatarSize } from './Avatar'; import { ConfirmationModal } from './ConfirmationModal'; import { Intl } from './Intl'; import { ContactName } from './conversation/ContactName'; +import { useIntersectionObserver } from '../util/hooks'; import { MAX_FRAME_SIZE } from '../calling/constants'; interface BasePropsType { @@ -34,15 +35,19 @@ interface InPipPropsType { isInPip: true; } -interface NotInPipPropsType { +interface InOverflowAreaPropsType { height: number; isInPip?: false; - left: number; - top: 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 = React.memo( props => { @@ -57,15 +62,26 @@ export const GroupCallRemoteParticipant: React.FC = React.memo( isBlocked, profileName, title, + videoAspectRatio, } = props.remoteParticipant; - const [isWide, setIsWide] = useState(true); + const [isWide, setIsWide] = useState( + videoAspectRatio ? videoAspectRatio >= 1 : true + ); const [hasHover, setHover] = useState(false); const [showBlockInfo, setShowBlockInfo] = useState(false); const remoteVideoRef = useRef(null); const canvasContextRef = useRef(null); + const [ + intersectionRef, + intersectionObserverEntry, + ] = useIntersectionObserver(); + const isVisible = intersectionObserverEntry + ? intersectionObserverEntry.isIntersecting + : true; + const videoFrameSource = useMemo( () => getGroupCallVideoFrameSource(demuxId), [getGroupCallVideoFrameSource, demuxId] @@ -118,7 +134,7 @@ export const GroupCallRemoteParticipant: React.FC = React.memo( }, [getFrameBuffer, videoFrameSource]); useEffect(() => { - if (!hasRemoteVideo) { + if (!hasRemoteVideo || !isVisible) { return noop; } @@ -132,7 +148,7 @@ export const GroupCallRemoteParticipant: React.FC = React.memo( return () => { cancelAnimationFrame(rafId); }; - }, [hasRemoteVideo, renderVideoFrame, videoFrameSource]); + }, [hasRemoteVideo, isVisible, renderVideoFrame, videoFrameSource]); let canvasStyles: CSSProperties; let containerStyles: CSSProperties; @@ -155,7 +171,7 @@ export const GroupCallRemoteParticipant: React.FC = React.memo( containerStyles = canvasStyles; avatarSize = AvatarSize.FIFTY_TWO; } else { - const { top, left, width, height } = props; + const { width, height } = props; const shorterDimension = Math.min(width, height); if (shorterDimension >= 240) { @@ -168,15 +184,18 @@ export const GroupCallRemoteParticipant: React.FC = React.memo( containerStyles = { height, - left, - position: 'absolute', - top, width, }; + + if ('top' in props) { + containerStyles.position = 'absolute'; + containerStyles.top = props.top; + containerStyles.left = props.left; + } } const showHover = hasHover && !props.isInPip; - const canShowVideo = hasRemoteVideo && !isBlocked; + const canShowVideo = hasRemoteVideo && !isBlocked && isVisible; return ( <> @@ -218,6 +237,7 @@ export const GroupCallRemoteParticipant: React.FC = React.memo(
setHover(true)} onMouseLeave={() => setHover(false)} style={containerStyles} diff --git a/ts/components/GroupCallRemoteParticipants.tsx b/ts/components/GroupCallRemoteParticipants.tsx index b893a37466..86a669a0d3 100644 --- a/ts/components/GroupCallRemoteParticipants.tsx +++ b/ts/components/GroupCallRemoteParticipants.tsx @@ -5,6 +5,10 @@ import React, { useState, useMemo, useEffect } from 'react'; import Measure from 'react-measure'; import { takeWhile, chunk, maxBy, flatten } from 'lodash'; import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant'; +import { + GroupCallOverflowArea, + OVERFLOW_PARTICIPANT_WIDTH, +} from './GroupCallOverflowArea'; import { GroupCallRemoteParticipantType, GroupCallVideoRequest, @@ -15,7 +19,7 @@ import { LocalizerType } from '../types/Util'; import { usePageVisibility } from '../util/hooks'; import { nonRenderedRemoteParticipant } from '../util/ringrtc/nonRenderedRemoteParticipant'; -const MIN_RENDERED_HEIGHT = 10; +const MIN_RENDERED_HEIGHT = 180; const PARTICIPANT_MARGIN = 10; // 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 // `maxRowCount`. -// 2. Figure out how many participants should be visible if all participants were rendered -// at the minimum height. Most of the time, we'll be able to render all of them, but on -// full calls with lots of participants, there could be some lost. +// 2. Split the participants into two groups: ones in the main grid and ones in the +// overflow area. The grid should prioritize participants who have recently spoken. // 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 // "scalar": how much can we scale these boxes up while still fitting them on the @@ -72,6 +75,11 @@ export const GroupCallRemoteParticipants: React.FC = ({ width: 0, height: 0, }); + const [gridDimensions, setGridDimensions] = useState({ + width: 0, + height: 0, + }); + const isPageVisible = usePageVisibility(); const getFrameBuffer = useGetCallingFrameBuffer(); @@ -92,13 +100,28 @@ export const GroupCallRemoteParticipants: React.FC = ({ ) ); - // 2. Figure out how many participants should be visible if all participants were - // rendered at the minimum height. Most of the time, we'll be able to render all of - // them, but on full calls with lots of participants, there could be some lost. + // 2. Split participants into two groups: ones in the main grid and ones in the overflow + // sidebar. // - // This is primarily memoized for clarity, not performance. We only need the result, - // not any of the "intermediate" values. - const visibleParticipants: Array = useMemo(() => { + // We start by sorting by `speakerTime` so that the most recent speakers are first in + // line for the main grid. Then we split the list in two: one for the grid and one for + // 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 = useMemo( + () => + remoteParticipants + .concat() + .sort( + (a, b) => (b.speakerTime || -Infinity) - (a.speakerTime || -Infinity) + ), + [remoteParticipants] + ); + const gridParticipants: Array = useMemo(() => { // Imagine that we laid out all of the rows end-to-end. That's the maximum total // width. So if there were 5 rows and the container was 100px wide, then we can't // possibly fit more than 500px of participants. @@ -107,13 +130,17 @@ export const GroupCallRemoteParticipants: React.FC = ({ // We do the same thing for participants, "laying them out end-to-end" until they // exceed the maximum total width. let totalWidth = 0; - return takeWhile(remoteParticipants, remoteParticipant => { + return takeWhile(sortedParticipants, remoteParticipant => { totalWidth += remoteParticipant.videoAspectRatio * MIN_RENDERED_HEIGHT; return totalWidth < maxTotalWidth; - }); - }, [maxRowCount, containerDimensions.width, remoteParticipants]); - const overflowedParticipants: Array = remoteParticipants.slice( - visibleParticipants.length + }).sort(stableParticipantComparator); + }, [maxRowCount, containerDimensions.width, sortedParticipants]); + const overflowedParticipants: Array = useMemo( + () => + sortedParticipants + .slice(gridParticipants.length) + .sort(stableParticipantComparator), + [sortedParticipants, gridParticipants.length] ); // 3. For each possible number of rows (starting at 0 and ending at `maxRowCount`), @@ -126,22 +153,22 @@ export const GroupCallRemoteParticipants: React.FC = ({ rows: [], }; - if (!visibleParticipants.length) { + if (!gridParticipants.length) { return bestArrangement; } for (let rowCount = 1; rowCount <= maxRowCount; rowCount += 1) { - // We do something pretty naïve here and chunk the visible participants into rows. - // For example, if there were 12 visible participants and `rowCount === 3`, there + // We do something pretty naïve here and chunk the grid's participants into rows. + // For example, if there were 12 grid participants and `rowCount === 3`, there // would be 4 participants per row. // // 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 // fine in the UI from our testing. 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 // participants at the minimum heights, and we want to scale everything up until @@ -158,11 +185,10 @@ export const GroupCallRemoteParticipants: React.FC = ({ continue; } const widthScalar = - (containerDimensions.width - - (widestRow.length + 1) * PARTICIPANT_MARGIN) / + (gridDimensions.width - (widestRow.length + 1) * PARTICIPANT_MARGIN) / totalRemoteParticipantWidthAtMinHeight(widestRow); const heightScalar = - (containerDimensions.height - (rowCount + 1) * PARTICIPANT_MARGIN) / + (gridDimensions.height - (rowCount + 1) * PARTICIPANT_MARGIN) / (rowCount * MIN_RENDERED_HEIGHT); const scalar = Math.min(widthScalar, heightScalar); @@ -174,10 +200,10 @@ export const GroupCallRemoteParticipants: React.FC = ({ return bestArrangement; }, [ - visibleParticipants, + gridParticipants, maxRowCount, - containerDimensions.width, - containerDimensions.height, + gridDimensions.width, + gridDimensions.height, ]); // 4. Lay out this arrangement on the screen. @@ -189,7 +215,7 @@ export const GroupCallRemoteParticipants: React.FC = ({ const gridTotalRowHeightWithMargin = gridParticipantHeightWithMargin * gridArrangement.rows.length; const gridTopOffset = Math.floor( - (containerDimensions.height - gridTotalRowHeightWithMargin) / 2 + (gridDimensions.height - gridTotalRowHeightWithMargin) / 2 ); const rowElements: Array> = gridArrangement.rows.map( @@ -202,9 +228,7 @@ export const GroupCallRemoteParticipants: React.FC = ({ const totalRowWidth = totalRowWidthWithoutMargins + PARTICIPANT_MARGIN * (remoteParticipantsInRow.length - 1); - const leftOffset = Math.floor( - (containerDimensions.width - totalRowWidth) / 2 - ); + const leftOffset = Math.floor((gridDimensions.width - totalRowWidth) / 2); let rowWidthSoFar = 0; return remoteParticipantsInRow.map(remoteParticipant => { @@ -235,7 +259,7 @@ export const GroupCallRemoteParticipants: React.FC = ({ useEffect(() => { if (isPageVisible) { setGroupCallVideoRequest([ - ...visibleParticipants.map(participant => { + ...gridParticipants.map(participant => { if (participant.hasRemoteVideo) { return { demuxId: participant.demuxId, @@ -249,7 +273,21 @@ export const GroupCallRemoteParticipants: React.FC = ({ } 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 { setGroupCallVideoRequest( @@ -262,7 +300,7 @@ export const GroupCallRemoteParticipants: React.FC = ({ overflowedParticipants, remoteParticipants, setGroupCallVideoRequest, - visibleParticipants, + gridParticipants, ]); return ( @@ -276,9 +314,37 @@ export const GroupCallRemoteParticipants: React.FC = ({ setContainerDimensions(bounds); }} > - {({ measureRef }) => ( -
- {flatten(rowElements)} + {containerMeasure => ( +
+ { + if (!bounds) { + window.log.error('We should be measuring the bounds'); + return; + } + setGridDimensions(bounds); + }} + > + {gridMeasure => ( +
+ {flatten(rowElements)} +
+ )} +
+ +
)} @@ -294,3 +360,10 @@ function totalRemoteParticipantWidthAtMinHeight( 0 ); } + +function stableParticipantComparator( + a: Readonly<{ demuxId: number }>, + b: Readonly<{ demuxId: number }> +): number { + return a.demuxId - b.demuxId; +} diff --git a/ts/test-both/helpers/fakeGetGroupCallVideoFrameSource.ts b/ts/test-both/helpers/fakeGetGroupCallVideoFrameSource.ts new file mode 100644 index 0000000000..b148af1e11 --- /dev/null +++ b/ts/test-both/helpers/fakeGetGroupCallVideoFrameSource.ts @@ -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); +} diff --git a/ts/util/hooks.ts b/ts/util/hooks.ts index dc8f8af36e..dad04054d3 100644 --- a/ts/util/hooks.ts +++ b/ts/util/hooks.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -66,3 +66,62 @@ export const usePageVisibility = (): boolean => { return result; }; + +/** + * A light hook wrapper around `IntersectionObserver`. + * + * Example usage: + * + * function MyComponent() { + * const [intersectionRef, intersectionEntry] = useIntersectionObserver(); + * const isVisible = intersectionEntry + * ? intersectionEntry.isIntersecting + * : true; + * + * return ( + *
+ * I am {isVisible ? 'on the screen' : 'invisible'} + *
+ * ); + * } + */ +export function useIntersectionObserver(): [ + (el?: Element | null) => void, + IntersectionObserverEntry | null +] { + const [ + intersectionObserverEntry, + setIntersectionObserverEntry, + ] = React.useState(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]; +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 9f3d964b53..395d93a9cc 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -14523,11 +14523,20 @@ "updated": "2020-11-11T21:56:04.179Z", "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", "path": "ts/components/GroupCallRemoteParticipant.js", "line": " const remoteVideoRef = react_1.useRef(null);", - "lineNumber": 43, + "lineNumber": 44, "reasonCategory": "usageTrusted", "updated": "2020-11-11T21:56:04.179Z", "reasonDetail": "Needed to render the remote video element." @@ -14536,7 +14545,7 @@ "rule": "React-useRef", "path": "ts/components/GroupCallRemoteParticipant.js", "line": " const canvasContextRef = react_1.useRef(null);", - "lineNumber": 44, + "lineNumber": 45, "reasonCategory": "usageTrusted", "updated": "2020-11-17T23:29:38.698Z", "reasonDetail": "Doesn't touch the DOM." @@ -15204,5 +15213,14 @@ "lineNumber": 2177, "reasonCategory": "falseMatch", "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." } -] \ No newline at end of file +]