2021-01-08 18:58:28 +00:00
|
|
|
// Copyright 2021 Signal Messenger, LLC
|
|
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
2021-10-26 19:15:33 +00:00
|
|
|
import type { FC, ReactElement } from 'react';
|
|
|
|
import React, { useRef, useState, useEffect } from 'react';
|
2021-01-08 18:58:28 +00:00
|
|
|
import classNames from 'classnames';
|
2021-09-28 16:37:03 +00:00
|
|
|
import type { VideoFrameSource } from 'ringrtc';
|
2021-10-26 19:15:33 +00:00
|
|
|
import type { LocalizerType } from '../types/Util';
|
2021-09-28 16:37:03 +00:00
|
|
|
import type { GroupCallRemoteParticipantType } from '../types/Calling';
|
2021-01-08 18:58:28 +00:00
|
|
|
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;
|
|
|
|
|
2021-01-14 18:07:05 +00:00
|
|
|
type PropsType = {
|
2021-01-08 18:58:28 +00:00
|
|
|
getFrameBuffer: () => ArrayBuffer;
|
|
|
|
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
|
|
|
|
i18n: LocalizerType;
|
|
|
|
overflowedParticipants: ReadonlyArray<GroupCallRemoteParticipantType>;
|
2021-01-14 18:07:05 +00:00
|
|
|
};
|
2021-01-08 18:58:28 +00:00
|
|
|
|
|
|
|
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>
|
|
|
|
);
|
|
|
|
}
|