Group calling: add overflow area
This commit is contained in:
parent
8e1391c70c
commit
fbfcdbf84e
13 changed files with 713 additions and 116 deletions
|
@ -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"
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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> = {}): 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'),
|
||||
|
|
|
@ -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', () => (
|
||||
<CallScreen
|
||||
{...createProps({
|
||||
callMode: CallMode.Group,
|
||||
remoteParticipants: [
|
||||
{
|
||||
demuxId: 0,
|
||||
hasRemoteAudio: true,
|
||||
hasRemoteVideo: true,
|
||||
videoAspectRatio: 1.3,
|
||||
...getDefaultConversation({
|
||||
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',
|
||||
}),
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>
|
||||
));
|
||||
// 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
|
||||
{...createProps({
|
||||
callMode: CallMode.Group,
|
||||
remoteParticipants: allRemoteParticipants.slice(
|
||||
0,
|
||||
number('Participant count', 3, {
|
||||
range: true,
|
||||
min: 0,
|
||||
max: MAX_PARTICIPANTS,
|
||||
step: 1,
|
||||
})
|
||||
),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
story.add('Group call - reconnecting', () => (
|
||||
<CallScreen
|
||||
|
|
|
@ -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 { boolean } from '@storybook/addon-knobs';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
@ -17,6 +16,7 @@ import {
|
|||
GroupCallConnectionState,
|
||||
GroupCallJoinState,
|
||||
} from '../types/Calling';
|
||||
import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource';
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
|
@ -55,8 +55,7 @@ const defaultCall: ActiveCallType = {
|
|||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): 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,
|
||||
|
|
93
ts/components/GroupCallOverflowArea.stories.tsx
Normal file
93
ts/components/GroupCallOverflowArea.stories.tsx
Normal 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>
|
||||
));
|
174
ts/components/GroupCallOverflowArea.tsx
Normal file
174
ts/components/GroupCallOverflowArea.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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<PropsType> = React.memo(
|
||||
props => {
|
||||
|
@ -57,15 +62,26 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
|
|||
isBlocked,
|
||||
profileName,
|
||||
title,
|
||||
videoAspectRatio,
|
||||
} = props.remoteParticipant;
|
||||
|
||||
const [isWide, setIsWide] = useState(true);
|
||||
const [isWide, setIsWide] = useState<boolean>(
|
||||
videoAspectRatio ? videoAspectRatio >= 1 : true
|
||||
);
|
||||
const [hasHover, setHover] = useState(false);
|
||||
const [showBlockInfo, setShowBlockInfo] = useState(false);
|
||||
|
||||
const remoteVideoRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const canvasContextRef = useRef<CanvasRenderingContext2D | null>(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<PropsType> = React.memo(
|
|||
}, [getFrameBuffer, videoFrameSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasRemoteVideo) {
|
||||
if (!hasRemoteVideo || !isVisible) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
|
@ -132,7 +148,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = 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<PropsType> = 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<PropsType> = 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<PropsType> = React.memo(
|
|||
|
||||
<div
|
||||
className="module-ongoing-call__group-call-remote-participant"
|
||||
ref={intersectionRef}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={containerStyles}
|
||||
|
|
|
@ -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<PropsType> = ({
|
|||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
const [gridDimensions, setGridDimensions] = useState<Dimensions>({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
const isPageVisible = usePageVisibility();
|
||||
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
|
||||
// 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<GroupCallRemoteParticipantType> = 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<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
|
||||
// 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<PropsType> = ({
|
|||
// 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<GroupCallRemoteParticipantType> = remoteParticipants.slice(
|
||||
visibleParticipants.length
|
||||
}).sort(stableParticipantComparator);
|
||||
}, [maxRowCount, containerDimensions.width, sortedParticipants]);
|
||||
const overflowedParticipants: Array<GroupCallRemoteParticipantType> = 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<PropsType> = ({
|
|||
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<PropsType> = ({
|
|||
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<PropsType> = ({
|
|||
|
||||
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<PropsType> = ({
|
|||
const gridTotalRowHeightWithMargin =
|
||||
gridParticipantHeightWithMargin * gridArrangement.rows.length;
|
||||
const gridTopOffset = Math.floor(
|
||||
(containerDimensions.height - gridTotalRowHeightWithMargin) / 2
|
||||
(gridDimensions.height - gridTotalRowHeightWithMargin) / 2
|
||||
);
|
||||
|
||||
const rowElements: Array<Array<JSX.Element>> = gridArrangement.rows.map(
|
||||
|
@ -202,9 +228,7 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
|
|||
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<PropsType> = ({
|
|||
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<PropsType> = ({
|
|||
}
|
||||
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<PropsType> = ({
|
|||
overflowedParticipants,
|
||||
remoteParticipants,
|
||||
setGroupCallVideoRequest,
|
||||
visibleParticipants,
|
||||
gridParticipants,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
@ -276,9 +314,37 @@ export const GroupCallRemoteParticipants: React.FC<PropsType> = ({
|
|||
setContainerDimensions(bounds);
|
||||
}}
|
||||
>
|
||||
{({ measureRef }) => (
|
||||
<div className="module-ongoing-call__grid" ref={measureRef}>
|
||||
{flatten(rowElements)}
|
||||
{containerMeasure => (
|
||||
<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)}
|
||||
</div>
|
||||
)}
|
||||
</Measure>
|
||||
|
||||
<GroupCallOverflowArea
|
||||
getFrameBuffer={getFrameBuffer}
|
||||
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
|
||||
i18n={i18n}
|
||||
overflowedParticipants={overflowedParticipants}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Measure>
|
||||
|
@ -294,3 +360,10 @@ function totalRemoteParticipantWidthAtMinHeight(
|
|||
0
|
||||
);
|
||||
}
|
||||
|
||||
function stableParticipantComparator(
|
||||
a: Readonly<{ demuxId: number }>,
|
||||
b: Readonly<{ demuxId: number }>
|
||||
): number {
|
||||
return a.demuxId - b.demuxId;
|
||||
}
|
||||
|
|
62
ts/test-both/helpers/fakeGetGroupCallVideoFrameSource.ts
Normal file
62
ts/test-both/helpers/fakeGetGroupCallVideoFrameSource.ts
Normal 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);
|
||||
}
|
|
@ -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 (
|
||||
* <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];
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
]
|
||||
|
|
Loading…
Reference in a new issue