diff --git a/ts/components/GroupCallRemoteParticipants.tsx b/ts/components/GroupCallRemoteParticipants.tsx
index 8902c968c5..70a00d04b4 100644
--- a/ts/components/GroupCallRemoteParticipants.tsx
+++ b/ts/components/GroupCallRemoteParticipants.tsx
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useState, useMemo, useEffect } from 'react';
-import { takeWhile, clamp, chunk, maxBy, flatten, noop } from 'lodash';
+import { clamp, chunk, maxBy, flatten, noop } from 'lodash';
import type { VideoFrameSource } from '@signalapp/ringrtc';
import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant';
import {
@@ -13,6 +13,7 @@ import type {
GroupCallRemoteParticipantType,
GroupCallVideoRequest,
} from '../types/Calling';
+import { CallViewMode } from '../types/Calling';
import { useGetCallingFrameBuffer } from '../calling/useGetCallingFrameBuffer';
import type { LocalizerType } from '../types/Util';
import { usePageVisibility } from '../hooks/usePageVisibility';
@@ -25,11 +26,14 @@ import * as setUtil from '../util/setUtil';
import * as log from '../logging/log';
import { MAX_FRAME_HEIGHT, MAX_FRAME_WIDTH } from '../calling/constants';
import { SizeObserver } from '../hooks/useSizeObserver';
+import { strictAssert } from '../util/assert';
-const MIN_RENDERED_HEIGHT = 180;
-const PARTICIPANT_MARGIN = 10;
+const SMALL_TILES_MIN_HEIGHT = 80;
+const LARGE_TILES_MIN_HEIGHT = 200;
+const PARTICIPANT_MARGIN = 12;
const TIME_TO_STOP_REQUESTING_VIDEO_WHEN_PAGE_INVISIBLE = 20 * SECOND;
-
+const PAGINATION_BUTTON_ASPECT_RATIO = 1;
+const MAX_PARTICIPANTS_PER_PAGE = 49; // 49 remote + 1 self-video = 50 total
// We scale our video requests down for performance. This number is somewhat arbitrary.
const VIDEO_REQUEST_SCALAR = 0.75;
@@ -39,15 +43,24 @@ type Dimensions = {
};
type GridArrangement = {
- rows: Array
>;
+ rows: Array>;
scalar: number;
};
+type PaginationButtonType = {
+ isPaginationButton: true;
+ videoAspectRatio: number;
+ paginationButtonType: 'prev' | 'next';
+ numParticipants: number;
+};
+type ParticipantTileType =
+ | GroupCallRemoteParticipantType
+ | PaginationButtonType;
type PropsType = {
+ callViewMode: CallViewMode;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
i18n: LocalizerType;
isCallReconnecting: boolean;
- isInSpeakerView: boolean;
remoteParticipants: ReadonlyArray;
setGroupCallVideoRequest: (
_: Array,
@@ -72,37 +85,43 @@ enum VideoRequestMode {
// * Participants are arranged in 0 or more rows.
// * Each row is the same height, but each participant may have a different width.
// * It's possible, on small screens with lots of participants, to have participants
-// removed from the grid. This is because participants have a minimum rendered height.
+// removed from the grid, or on subsequent pages. This is because participants
+// have a minimum rendered height.
+// * Participant videos may have different aspect ratios
+// * We want to ensure that presenters and recent speakers are shown on the first page,
+// but we also want to minimize tiles jumping around as much as possible.
//
// There should be more specific comments throughout, but the high-level steps are:
//
-// 1. Figure out the maximum number of possible rows that could fit on the screen; this is
-// `maxRowCount`.
-// 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
-// screen? The biggest scalar wins as the "best arrangement".
-// 4. Lay out this arrangement on the screen.
+// 1. Figure out the maximum number of possible rows that could fit on a page; this is
+// `maxRowsPerPage`.
+// 2. Sort the participants in priority order: we want to fit presenters and recent
+// speakers in the grid first
+// 3. Figure out which participants should go on each page -- for non-paginated views,
+// this is just one page, but for paginated views, we could have many pages. The
+// general idea here is to fill up each page row-by-row, with each video as small
+// as we allow.
+// 4. Try to distribute the videos throughout the grid to find the largest "scalar":
+// how much can we scale these boxes up while still fitting them on the screen?
+// The biggest scalar wins as the "best arrangement".
+// 5. Lay out this arrangement on the screen.
+
export function GroupCallRemoteParticipants({
+ callViewMode,
getGroupCallVideoFrameSource,
i18n,
isCallReconnecting,
- isInSpeakerView,
remoteParticipants,
setGroupCallVideoRequest,
remoteAudioLevels,
}: PropsType): JSX.Element {
- const [containerDimensions, setContainerDimensions] = useState({
- width: 0,
- height: 0,
- });
const [gridDimensions, setGridDimensions] = useState({
width: 0,
height: 0,
});
+ const [pageIndex, setPageIndex] = useState(0);
+
const devicePixelRatio = useDevicePixelRatio();
const getFrameBuffer = useGetCallingFrameBuffer();
@@ -110,191 +129,224 @@ export function GroupCallRemoteParticipants({
const { invisibleDemuxIds, onParticipantVisibilityChanged } =
useInvisibleParticipants(remoteParticipants);
- // 1. Figure out the maximum number of possible rows that could fit on the screen.
- //
- // We choose the smaller of these two options:
- //
- // - The number of participants, which means there'd be one participant per row.
- // - The number of possible rows in the container, assuming all participants were
- // rendered at minimum height. Doesn't rely on the number of participants—it's some
- // simple division.
- //
- // Could be 0 if (a) there are no participants (b) the container's height is small.
- const maxRowCount = Math.min(
- remoteParticipants.length,
- Math.floor(
- containerDimensions.height / (MIN_RENDERED_HEIGHT + PARTICIPANT_MARGIN)
- )
+ const minRenderedHeight =
+ callViewMode === CallViewMode.Paginated
+ ? SMALL_TILES_MIN_HEIGHT
+ : LARGE_TILES_MIN_HEIGHT;
+
+ const isInSpeakerView =
+ callViewMode === CallViewMode.Speaker ||
+ callViewMode === CallViewMode.Presentation;
+
+ const isInPaginationView = callViewMode === CallViewMode.Paginated;
+ const shouldShowOverflow = !isInPaginationView;
+
+ const maxRowWidth = gridDimensions.width;
+ const maxGridHeight = gridDimensions.height;
+
+ // 1. Figure out the maximum number of possible rows that could fit on the page.
+ // Could be 0 if (a) there are no participants (b) the container's height is small.
+ const maxRowsPerPage = Math.floor(
+ maxGridHeight / (minRenderedHeight + PARTICIPANT_MARGIN)
);
- // 2. Split participants into two groups: ones in the main grid and ones in the overflow
- // sidebar.
- //
- // We start by sorting by `presenting` first since presenters should be on the main grid
- // then we sort by `speakerTime` so that the most recent speakers are next 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) =>
- Number(b.presenting || 0) - Number(a.presenting || 0) ||
- (b.speakerTime || -Infinity) - (a.speakerTime || -Infinity)
- ),
- [remoteParticipants]
- );
- const gridParticipants: Array =
- useMemo(() => {
- if (!sortedParticipants.length) {
- return [];
- }
+ // 2. Sort the participants in priority order: by `presenting` first, since presenters
+ // should be on the main grid, then by `speakerTime` so that the most recent speakers
+ // are next in line for the first pages of the grid
+ const prioritySortedParticipants: Array =
+ useMemo(
+ () =>
+ remoteParticipants
+ .concat()
+ .sort(
+ (a, b) =>
+ Number(b.presenting || 0) - Number(a.presenting || 0) ||
+ (b.speakerTime || -Infinity) - (a.speakerTime || -Infinity)
+ ),
+ [remoteParticipants]
+ );
- const candidateParticipants = isInSpeakerView
- ? [sortedParticipants[0]]
- : sortedParticipants;
-
- // 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.
- const maxTotalWidth = maxRowCount * containerDimensions.width;
-
- // 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(candidateParticipants, remoteParticipant => {
- totalWidth += remoteParticipant.videoAspectRatio * MIN_RENDERED_HEIGHT;
- return totalWidth < maxTotalWidth;
- }).sort(stableParticipantComparator);
- }, [
- containerDimensions.width,
- isInSpeakerView,
- maxRowCount,
- 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`),
- // 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
- // screen? The biggest scalar wins as the "best arrangement".
- const gridArrangement: GridArrangement = useMemo(() => {
- let bestArrangement: GridArrangement = {
- scalar: -1,
- rows: [],
- };
-
- if (!gridParticipants.length) {
- return bestArrangement;
+ // 3. Layout the participants on each page. The general algorithm is: first, try to fill
+ // up each page with as many participants as possible at the smallest acceptable video
+ // height. Second, sort the participants that fit on each page by a stable sort order,
+ // and make sure they still fit on the page! Third, add tiles at the beginning and end
+ // of each page (if paginated) to act as back and next buttons.
+ const gridParticipantsByPage: Array = useMemo(() => {
+ if (!prioritySortedParticipants.length) {
+ return [];
}
- for (let rowCount = 1; rowCount <= maxRowCount; rowCount += 1) {
- // 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(
- gridParticipants.length / rowCount
- );
- 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
- // it's about to overflow.
- //
- // We don't want it to overflow horizontally or vertically, so we calculate a
- // "width scalar" and "height scalar" and choose the smaller of the two. (Choosing
- // the LARGER of the two could cause overflow.)
- const widestRow = maxBy(rows, totalRemoteParticipantWidthAtMinHeight);
- if (!widestRow) {
- log.error('Unable to find the widest row, which should be impossible');
- continue;
- }
- const widthScalar =
- (gridDimensions.width - (widestRow.length + 1) * PARTICIPANT_MARGIN) /
- totalRemoteParticipantWidthAtMinHeight(widestRow);
- const heightScalar =
- (gridDimensions.height - (rowCount + 1) * PARTICIPANT_MARGIN) /
- (rowCount * MIN_RENDERED_HEIGHT);
- const scalar = Math.min(widthScalar, heightScalar);
-
- // If this scalar is the best one so far, we use that.
- if (scalar > bestArrangement.scalar) {
- bestArrangement = { scalar, rows };
- }
+ if (!maxRowsPerPage) {
+ return [];
}
- return bestArrangement;
+ if (isInSpeakerView) {
+ return [
+ {
+ rows: [[prioritySortedParticipants[0]]],
+ hasSpaceRemaining: false,
+ numParticipants: 1,
+ },
+ ];
+ }
+
+ return getGridParticipantsByPage({
+ participants: prioritySortedParticipants,
+ maxRowWidth,
+ maxPages: isInPaginationView ? Infinity : 1,
+ maxRowsPerPage,
+ minRenderedHeight,
+ maxParticipantsPerPage: MAX_PARTICIPANTS_PER_PAGE,
+ currentPage: pageIndex,
+ });
}, [
- gridParticipants,
- maxRowCount,
- gridDimensions.width,
- gridDimensions.height,
+ maxRowWidth,
+ isInPaginationView,
+ isInSpeakerView,
+ maxRowsPerPage,
+ minRenderedHeight,
+ pageIndex,
+ prioritySortedParticipants,
]);
- // 4. Lay out this arrangement on the screen.
- const gridParticipantHeight = Math.floor(
- gridArrangement.scalar * MIN_RENDERED_HEIGHT
+ // Make sure we're not on a page that no longer exists (e.g. if people left the call)
+ if (
+ pageIndex >= gridParticipantsByPage.length &&
+ gridParticipantsByPage.length > 0
+ ) {
+ setPageIndex(gridParticipantsByPage.length - 1);
+ }
+
+ const totalParticipantsInGrid = gridParticipantsByPage.reduce(
+ (pageCount, { numParticipants }) => pageCount + numParticipants,
+ 0
+ );
+
+ // In speaker or overflow views, not all participants will be on the grid; they'll
+ // get put in the overflow zone.
+ const overflowedParticipants: Array = useMemo(
+ () =>
+ isInPaginationView
+ ? []
+ : prioritySortedParticipants
+ .slice(totalParticipantsInGrid)
+ .sort(stableParticipantComparator),
+ [isInPaginationView, prioritySortedParticipants, totalParticipantsInGrid]
+ );
+
+ const participantsOnOtherPages = useMemo(
+ () =>
+ gridParticipantsByPage
+ .map((page, index) => {
+ if (index === pageIndex) {
+ return [];
+ }
+ return page.rows.flat();
+ })
+ .flat()
+ .filter(isGroupCallRemoteParticipant),
+ [gridParticipantsByPage, pageIndex]
+ );
+
+ const currentPage = gridParticipantsByPage.at(pageIndex) ?? {
+ rows: [],
+ };
+
+ // 4. Try to arrange the current page such that we can scale the videos up
+ // as much as possible.
+ const gridArrangement = arrangeParticipantsInGrid({
+ participantsInRows: currentPage.rows,
+ maxRowsPerPage,
+ maxRowWidth,
+ maxGridHeight,
+ minRenderedHeight,
+ });
+
+ const nextPage = () => {
+ setPageIndex(index => index + 1);
+ };
+
+ const prevPage = () => {
+ setPageIndex(index => Math.max(0, index - 1));
+ };
+
+ // 5. Lay out the current page on the screen.
+ const gridParticipantHeight = Math.round(
+ gridArrangement.scalar * minRenderedHeight
);
const gridParticipantHeightWithMargin =
gridParticipantHeight + PARTICIPANT_MARGIN;
const gridTotalRowHeightWithMargin =
- gridParticipantHeightWithMargin * gridArrangement.rows.length;
- const gridTopOffset = Math.floor(
- (gridDimensions.height - gridTotalRowHeightWithMargin) / 2
+ gridParticipantHeightWithMargin * gridArrangement.rows.length -
+ PARTICIPANT_MARGIN;
+ const gridTopOffset = Math.max(
+ 0,
+ Math.round((gridDimensions.height - gridTotalRowHeightWithMargin) / 2)
);
const rowElements: Array> = gridArrangement.rows.map(
- (remoteParticipantsInRow, index) => {
+ (tiles, index) => {
const top = gridTopOffset + index * gridParticipantHeightWithMargin;
const totalRowWidthWithoutMargins =
- totalRemoteParticipantWidthAtMinHeight(remoteParticipantsInRow) *
+ totalRowWidthAtHeight(tiles, minRenderedHeight) *
gridArrangement.scalar;
const totalRowWidth =
- totalRowWidthWithoutMargins +
- PARTICIPANT_MARGIN * (remoteParticipantsInRow.length - 1);
- const leftOffset = Math.floor((gridDimensions.width - totalRowWidth) / 2);
+ totalRowWidthWithoutMargins + PARTICIPANT_MARGIN * (tiles.length - 1);
+ const leftOffset = Math.max(
+ 0,
+ Math.round((gridDimensions.width - totalRowWidth) / 2)
+ );
let rowWidthSoFar = 0;
- return remoteParticipantsInRow.map(remoteParticipant => {
- const { demuxId, videoAspectRatio } = remoteParticipant;
-
- const audioLevel = remoteAudioLevels.get(demuxId) ?? 0;
-
- const renderedWidth = Math.floor(
- videoAspectRatio * gridParticipantHeight
- );
+ return tiles.map(tile => {
const left = rowWidthSoFar + leftOffset;
+ const renderedWidth = Math.round(
+ tile.videoAspectRatio * gridParticipantHeight
+ );
+
rowWidthSoFar += renderedWidth + PARTICIPANT_MARGIN;
+ if (isPaginationButton(tile)) {
+ const isNextButton = tile.paginationButtonType === 'next';
+ const isPrevButton = tile.paginationButtonType === 'prev';
+ return (
+
+ );
+ }
+
return (
{
- let scalar: number;
- if (participant.sharingScreen) {
- // We want best-resolution video if someone is sharing their screen.
- scalar = Math.max(devicePixelRatio, 1);
- } else {
- scalar = VIDEO_REQUEST_SCALAR;
- }
- return {
- demuxId: participant.demuxId,
- width: clamp(
- Math.floor(
- gridParticipantHeight * participant.videoAspectRatio * scalar
+ ...currentPage.rows
+ .flat()
+ // Filter out any next/previous page buttons
+ .filter(isGroupCallRemoteParticipant)
+ .map(participant => {
+ let scalar: number;
+ if (participant.sharingScreen) {
+ // We want best-resolution video if someone is sharing their screen.
+ scalar = Math.max(devicePixelRatio, 1);
+ } else {
+ scalar = VIDEO_REQUEST_SCALAR;
+ }
+ return {
+ demuxId: participant.demuxId,
+ width: clamp(
+ Math.round(
+ gridParticipantHeight *
+ participant.videoAspectRatio *
+ scalar
+ ),
+ 1,
+ MAX_FRAME_WIDTH
),
- 1,
- MAX_FRAME_WIDTH
- ),
- height: clamp(
- Math.floor(gridParticipantHeight * scalar),
- 1,
- MAX_FRAME_HEIGHT
- ),
- };
- }),
+ height: clamp(
+ Math.round(gridParticipantHeight * scalar),
+ 1,
+ MAX_FRAME_HEIGHT
+ ),
+ };
+ }),
+ ...participantsOnOtherPages.map(nonRenderedRemoteParticipant),
...overflowedParticipants.map(participant => {
if (invisibleDemuxIds.has(participant.demuxId)) {
return nonRenderedRemoteParticipant(participant);
@@ -349,12 +408,12 @@ export function GroupCallRemoteParticipants({
return {
demuxId: participant.demuxId,
width: clamp(
- Math.floor(OVERFLOW_PARTICIPANT_WIDTH * VIDEO_REQUEST_SCALAR),
+ Math.round(OVERFLOW_PARTICIPANT_WIDTH * VIDEO_REQUEST_SCALAR),
1,
MAX_FRAME_WIDTH
),
height: clamp(
- Math.floor(
+ Math.round(
(OVERFLOW_PARTICIPANT_WIDTH / participant.videoAspectRatio) *
VIDEO_REQUEST_SCALAR
),
@@ -384,58 +443,79 @@ export function GroupCallRemoteParticipants({
videoRequest = remoteParticipants.map(nonRenderedRemoteParticipant);
break;
}
-
setGroupCallVideoRequest(
videoRequest,
clamp(gridParticipantHeight, 0, MAX_FRAME_HEIGHT)
);
}, [
devicePixelRatio,
+ currentPage.rows,
gridParticipantHeight,
- gridParticipants,
invisibleDemuxIds,
overflowedParticipants,
remoteParticipants,
setGroupCallVideoRequest,
videoRequestMode,
+ participantsOnOtherPages,
]);
return (
- {
- setContainerDimensions(size);
- }}
- >
- {containerRef => (
-
-
{
- setGridDimensions(size);
- }}
- >
- {gridRef => (
-
- {flatten(rowElements)}
-
- )}
-
+
+
+
{
+ setGridDimensions(size);
+ }}
+ >
+ {gridRef => (
+
+ {flatten(rowElements)}
-
-
- )}
-
+ {isInPaginationView && (
+ <>
+ {pageIndex > 0 ? (
+
+ ) : null}
+ {pageIndex < gridParticipantsByPage.length - 1 ? (
+
+ ) : null}
+ >
+ )}
+
+ )}
+
+
+
+ {shouldShowOverflow && overflowedParticipants.length > 0 ? (
+
+ ) : null}
+
);
}
@@ -520,19 +600,425 @@ function useVideoRequestMode(): VideoRequestMode {
return result;
}
-function totalRemoteParticipantWidthAtMinHeight(
- remoteParticipants: ReadonlyArray
+function totalRowWidthAtHeight(
+ participantsInRow: ReadonlyArray<
+ Pick
+ >,
+ height: number
): number {
- return remoteParticipants.reduce(
- (result, { videoAspectRatio }) =>
- result + videoAspectRatio * MIN_RENDERED_HEIGHT,
+ return participantsInRow.reduce(
+ (result, participant) =>
+ result + participantWidthAtHeight(participant, height),
0
);
}
+function participantWidthAtHeight(
+ participant: Pick,
+ height: number
+) {
+ return participant.videoAspectRatio * height;
+}
+
function stableParticipantComparator(
a: Readonly<{ demuxId: number }>,
b: Readonly<{ demuxId: number }>
): number {
return a.demuxId - b.demuxId;
}
+
+type ParticipantsInPageType<
+ T extends { videoAspectRatio: number } = ParticipantTileType
+> = {
+ rows: Array>;
+ numParticipants: number;
+};
+
+type PageLayoutPropsType = {
+ maxRowWidth: number;
+ minRenderedHeight: number;
+ maxRowsPerPage: number;
+ maxParticipantsPerPage: number;
+};
+function getGridParticipantsByPage({
+ participants,
+ maxPages,
+ currentPage,
+ ...pageLayoutProps
+}: PageLayoutPropsType & {
+ participants: Array;
+ maxPages: number;
+ currentPage?: number;
+}): Array {
+ if (!participants.length) {
+ return [];
+ }
+
+ const pages: Array = [];
+
+ function getTotalParticipantsOnGrid() {
+ return pages.reduce((count, page) => count + page.numParticipants, 0);
+ }
+
+ let remainingParticipants = [...participants];
+ while (remainingParticipants.length) {
+ if (currentPage === pages.length - 1) {
+ // Optimization: we can stop early, we don't have to lay out the remainder of the
+ // pages
+ pages.push({
+ rows: [remainingParticipants],
+ numParticipants: remainingParticipants.length,
+ });
+ return pages;
+ }
+
+ const nextPageInPriorityOrder = getNextPage({
+ participants: remainingParticipants,
+ leaveRoomForPrevPageButton: pages.length > 0,
+ leaveRoomForNextPageButton: pages.length + 1 < maxPages,
+ ...pageLayoutProps,
+ });
+
+ // We got the next page, but it's in priority order; let's see if these participants
+ // also fit in sorted order
+ const priorityParticipantsOnNextPage = nextPageInPriorityOrder.rows.flat();
+ let sortedParticipantsHopingToFitOnPage = [
+ ...priorityParticipantsOnNextPage,
+ ].sort(stableParticipantComparator);
+ let nextPageInSortedOrder = getNextPage({
+ participants: sortedParticipantsHopingToFitOnPage,
+ leaveRoomForPrevPageButton: pages.length > 0,
+ leaveRoomForNextPageButton: pages.length + 1 < maxPages,
+ isSubsetOfAllParticipants:
+ sortedParticipantsHopingToFitOnPage.length <
+ remainingParticipants.length,
+ ...pageLayoutProps,
+ });
+
+ let nextPage: ParticipantsInPageType | undefined;
+
+ if (
+ nextPageInSortedOrder.numParticipants ===
+ nextPageInPriorityOrder.numParticipants
+ ) {
+ // Great, we're able to show everyone. It's possible that there is now extra space
+ // and we could show more people, but let's leave it here for simplicity
+ nextPage = nextPageInSortedOrder;
+ } else {
+ // We weren't able to fit everyone. Let's remove the least-prioritized person and
+ // try again. It's pretty unlikely this will take more than 1 attempt, but
+ // let's take more and more participants off the screen if it takes a lot of
+ // attempts so we don't have to iterate dozens of times.
+ const PARTICIPANTS_TO_REMOVE_PER_ATTEMPT = [1, 1, 1, 2, 5];
+ const MAX_ATTEMPTS = 5;
+ let attemptNumber = 0;
+
+ while (
+ sortedParticipantsHopingToFitOnPage.length &&
+ attemptNumber < MAX_ATTEMPTS
+ ) {
+ const numLeastPrioritizedParticipantsToRemove =
+ PARTICIPANTS_TO_REMOVE_PER_ATTEMPT[
+ Math.min(
+ attemptNumber,
+ PARTICIPANTS_TO_REMOVE_PER_ATTEMPT.length - 1
+ )
+ ];
+
+ const leastPrioritizedParticipantIds = new Set(
+ priorityParticipantsOnNextPage
+ .splice(
+ -1 * numLeastPrioritizedParticipantsToRemove,
+ numLeastPrioritizedParticipantsToRemove
+ )
+ .map(participant => participant.demuxId)
+ );
+
+ sortedParticipantsHopingToFitOnPage =
+ sortedParticipantsHopingToFitOnPage.filter(
+ participant =>
+ !leastPrioritizedParticipantIds.has(participant.demuxId)
+ );
+
+ nextPageInSortedOrder = getNextPage({
+ participants: sortedParticipantsHopingToFitOnPage,
+ leaveRoomForPrevPageButton: pages.length > 0,
+ leaveRoomForNextPageButton: pages.length + 1 < maxPages,
+ ...pageLayoutProps,
+ });
+
+ // Are we able to fill all of them now? Great, let's ship it.
+ if (
+ nextPageInSortedOrder.numParticipants ===
+ sortedParticipantsHopingToFitOnPage.length
+ ) {
+ nextPage = nextPageInSortedOrder;
+ break;
+ }
+ attemptNumber += 1;
+ }
+
+ if (!nextPage) {
+ log.warn(
+ `GroupCallRemoteParticipants: failed after ${attemptNumber} attempts to layout
+ the page; pageIndex: ${pages.length}, \
+ # fit in priority order: ${nextPageInPriorityOrder.numParticipants}, \
+ # fit in sorted order: ${nextPageInSortedOrder.numParticipants}`
+ );
+ nextPage = nextPageInSortedOrder;
+ }
+ }
+
+ if (!nextPage) {
+ break;
+ }
+
+ const nextPageTiles =
+ nextPage as ParticipantsInPageType;
+
+ // Add a previous page tile if needed
+ if (pages.length > 0) {
+ nextPageTiles.rows[0].unshift({
+ isPaginationButton: true,
+ paginationButtonType: 'prev',
+ videoAspectRatio: PAGINATION_BUTTON_ASPECT_RATIO,
+ numParticipants: getTotalParticipantsOnGrid(),
+ });
+ }
+
+ if (!nextPage.numParticipants) {
+ break;
+ }
+
+ remainingParticipants = remainingParticipants.slice(
+ nextPage.numParticipants
+ );
+
+ pages.push(nextPage);
+
+ if (pages.length === maxPages) {
+ break;
+ }
+
+ // Add a next page tile if needed
+ if (remainingParticipants.length) {
+ nextPageTiles.rows.at(-1)?.push({
+ isPaginationButton: true,
+ paginationButtonType: 'next',
+ videoAspectRatio: PAGINATION_BUTTON_ASPECT_RATIO,
+ numParticipants: remainingParticipants.length,
+ });
+ }
+ }
+ return pages;
+}
+
+/**
+ * Attempt to fill a new page with as many participants as will fit, leaving room for
+ * next/prev page buttons as needed. Participants will be added in the order provided.
+ *
+ * @returns ParticipantsInPageType, representing the participants that fit on this page
+ * assuming they are rendered at minimum height. Does not include prev/next buttons,
+ * but will leave space for them if needed. Participants are not necessarily
+ * returned in the row-distribution that will maximize video scaling; that should
+ * be done subsequently.
+ */
+function getNextPage({
+ participants,
+ maxRowWidth,
+ minRenderedHeight,
+ maxRowsPerPage,
+ maxParticipantsPerPage,
+ leaveRoomForPrevPageButton,
+ leaveRoomForNextPageButton,
+ isSubsetOfAllParticipants,
+}: PageLayoutPropsType & {
+ participants: Array;
+ leaveRoomForPrevPageButton: boolean;
+ leaveRoomForNextPageButton: boolean;
+ isSubsetOfAllParticipants?: boolean;
+}): ParticipantsInPageType {
+ const paginationButtonWidth = participantWidthAtHeight(
+ {
+ videoAspectRatio: PAGINATION_BUTTON_ASPECT_RATIO,
+ },
+ minRenderedHeight
+ );
+ let rowWidth = leaveRoomForPrevPageButton
+ ? paginationButtonWidth + PARTICIPANT_MARGIN
+ : 0;
+
+ // Initialize fresh page with empty first row
+ const rows: Array> = [[]];
+ let row = rows[0];
+ let numParticipants = 0;
+
+ // Start looping through participants and adding them to the rows one-by-one
+ for (let i = 0; i < participants.length; i += 1) {
+ const participant = participants[i];
+ const isLastParticipant =
+ !isSubsetOfAllParticipants && i === participants.length - 1;
+
+ const participantWidth = participantWidthAtHeight(
+ participant,
+ minRenderedHeight
+ );
+ const isLastRow = rows.length === maxRowsPerPage;
+ const shouldShowNextButtonInThisRow =
+ isLastRow && !isLastParticipant && leaveRoomForNextPageButton;
+
+ const currentRowMaxWidth = shouldShowNextButtonInThisRow
+ ? maxRowWidth - (paginationButtonWidth + PARTICIPANT_MARGIN)
+ : maxRowWidth;
+
+ const participantFitsOnRow =
+ rowWidth + participantWidth + (row.length ? PARTICIPANT_MARGIN : 0) <=
+ currentRowMaxWidth;
+
+ if (participantFitsOnRow) {
+ rowWidth += participantWidth + (row.length ? PARTICIPANT_MARGIN : 0);
+ row.push(participant);
+ numParticipants += 1;
+
+ if (numParticipants === maxParticipantsPerPage) {
+ return { rows, numParticipants };
+ }
+ } else {
+ if (isLastRow) {
+ return { rows, numParticipants };
+ }
+
+ // Start a new row!
+ row = [participant];
+ rows.push(row);
+ numParticipants += 1;
+ rowWidth = participantWidth;
+ }
+ }
+ return {
+ rows,
+ numParticipants,
+ };
+}
+
+/**
+ * Given an arrangement of participants in rows that we know fits on a page at minimum
+ * rendered height, try to find an arrangement that maximizes video size, or return the
+ * provided arrangement with maximal video size. The result of this is ready to be
+ * laid out on the screen.
+ *
+ * @returns GridArrangement: {
+ * rows: participants in rows,
+ * scalar: the scalar by which can scale every video on the page and still fit
+ * }
+ */
+function arrangeParticipantsInGrid({
+ participantsInRows,
+ maxRowWidth,
+ minRenderedHeight,
+ maxRowsPerPage,
+ maxGridHeight,
+}: {
+ participantsInRows: Array>;
+ maxRowWidth: number;
+ minRenderedHeight: number;
+ maxRowsPerPage: number;
+ maxGridHeight: number;
+}): GridArrangement {
+ // Start out with the arrangement that was prepared by getGridParticipantsByPage.
+ // We know this arrangement (added one-by-one) fits, so its scalar is
+ // guaranteed to be >= 1. Our chunking strategy below might not arrive at such
+ // an arrangement.
+ let bestArrangement: GridArrangement = {
+ scalar: getMaximumScaleForRows({
+ maxRowWidth,
+ minRenderedHeight,
+ rows: participantsInRows,
+ maxGridHeight,
+ }),
+ rows: participantsInRows,
+ };
+
+ const participants = participantsInRows.flat();
+
+ // 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
+ // screen? The biggest scalar wins as the "best arrangement".
+ for (let rowCount = 1; rowCount <= maxRowsPerPage; rowCount += 1) {
+ // 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(participants.length / rowCount);
+ const rows = chunk(participants, numberOfParticipantsInRow);
+
+ const scalar = getMaximumScaleForRows({
+ maxRowWidth,
+ minRenderedHeight,
+ rows,
+ maxGridHeight,
+ });
+
+ if (scalar > bestArrangement.scalar) {
+ bestArrangement = {
+ scalar,
+ rows,
+ };
+ }
+ }
+
+ return bestArrangement;
+}
+
+// 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
+// it's about to overflow.
+function getMaximumScaleForRows({
+ maxRowWidth,
+ minRenderedHeight,
+ maxGridHeight,
+ rows,
+}: {
+ maxRowWidth: number;
+ minRenderedHeight: number;
+ maxGridHeight: number;
+ rows: Array>;
+}): number {
+ if (!rows.length) {
+ return 0;
+ }
+ const widestRow = maxBy(rows, x =>
+ totalRowWidthAtHeight(x, minRenderedHeight)
+ );
+
+ strictAssert(widestRow, 'Could not find widestRow');
+
+ // We don't want it to overflow horizontally or vertically, so we calculate a
+ // "width scalar" and "height scalar" and choose the smaller of the two. (Choosing
+ // the LARGER of the two could cause overflow.)
+ const widthScalar =
+ (maxRowWidth - (widestRow.length - 1) * PARTICIPANT_MARGIN) /
+ totalRowWidthAtHeight(widestRow, minRenderedHeight);
+
+ const heightScalar =
+ (maxGridHeight - (rows.length - 1) * PARTICIPANT_MARGIN) /
+ (rows.length * minRenderedHeight);
+
+ return Math.min(widthScalar, heightScalar);
+}
+
+function isGroupCallRemoteParticipant(
+ tile: ParticipantTileType
+): tile is GroupCallRemoteParticipantType {
+ return 'demuxId' in tile;
+}
+
+function isPaginationButton(
+ tile: ParticipantTileType
+): tile is PaginationButtonType {
+ return 'isPaginationButton' in tile;
+}
diff --git a/ts/hooks/useActivateSpeakerViewOnPresenting.ts b/ts/hooks/useActivateSpeakerViewOnPresenting.ts
index 78bae138e7..5ba8ffff1c 100644
--- a/ts/hooks/useActivateSpeakerViewOnPresenting.ts
+++ b/ts/hooks/useActivateSpeakerViewOnPresenting.ts
@@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
-import { useEffect } from 'react';
+import { useEffect, useMemo } from 'react';
import type { AciString } from '../types/ServiceId';
import { usePrevious } from './usePrevious';
@@ -12,6 +12,15 @@ type RemoteParticipant = {
aci?: AciString;
};
+export function usePresenter(
+ remoteParticipants: ReadonlyArray
+): AciString | undefined {
+ return useMemo(
+ () => remoteParticipants.find(participant => participant.presenting)?.aci,
+ [remoteParticipants]
+ );
+}
+
export function useActivateSpeakerViewOnPresenting({
remoteParticipants,
switchToPresentationView,
@@ -21,9 +30,7 @@ export function useActivateSpeakerViewOnPresenting({
switchToPresentationView: () => void;
switchFromPresentationView: () => void;
}): void {
- const presenterAci = remoteParticipants.find(
- participant => participant.presenting
- )?.aci;
+ const presenterAci = usePresenter(remoteParticipants);
const prevPresenterAci = usePrevious(presenterAci, presenterAci);
useEffect(() => {
diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts
index d0ca15cfdd..e0b0aba9cd 100644
--- a/ts/state/ducks/calling.ts
+++ b/ts/state/ducks/calling.ts
@@ -121,6 +121,7 @@ export type ActiveCallStateType = {
hasLocalVideo: boolean;
localAudioLevel: number;
viewMode: CallViewMode;
+ viewModeBeforePresentation?: CallViewMode;
joinedAt: number | null;
outgoingRing: boolean;
pip: boolean;
@@ -413,6 +414,7 @@ const ACCEPT_CALL_PENDING = 'calling/ACCEPT_CALL_PENDING';
const CANCEL_CALL = 'calling/CANCEL_CALL';
const CANCEL_INCOMING_GROUP_CALL_RING =
'calling/CANCEL_INCOMING_GROUP_CALL_RING';
+const CHANGE_CALL_VIEW = 'calling/CHANGE_CALL_VIEW';
const START_CALLING_LOBBY = 'calling/START_CALLING_LOBBY';
const CALL_STATE_CHANGE_FULFILLED = 'calling/CALL_STATE_CHANGE_FULFILLED';
const CHANGE_IO_DEVICE_FULFILLED = 'calling/CHANGE_IO_DEVICE_FULFILLED';
@@ -442,7 +444,6 @@ const START_DIRECT_CALL = 'calling/START_DIRECT_CALL';
const TOGGLE_PARTICIPANTS = 'calling/TOGGLE_PARTICIPANTS';
const TOGGLE_PIP = 'calling/TOGGLE_PIP';
const TOGGLE_SETTINGS = 'calling/TOGGLE_SETTINGS';
-const TOGGLE_SPEAKER_VIEW = 'calling/TOGGLE_SPEAKER_VIEW';
const SWITCH_TO_PRESENTATION_VIEW = 'calling/SWITCH_TO_PRESENTATION_VIEW';
const SWITCH_FROM_PRESENTATION_VIEW = 'calling/SWITCH_FROM_PRESENTATION_VIEW';
@@ -611,8 +612,9 @@ type ToggleSettingsActionType = ReadonlyDeep<{
type: 'calling/TOGGLE_SETTINGS';
}>;
-type ToggleSpeakerViewActionType = ReadonlyDeep<{
- type: 'calling/TOGGLE_SPEAKER_VIEW';
+type ChangeCallViewActionType = ReadonlyDeep<{
+ type: 'calling/CHANGE_CALL_VIEW';
+ viewMode: CallViewMode;
}>;
type SwitchToPresentationViewActionType = ReadonlyDeep<{
@@ -628,6 +630,7 @@ export type CallingActionType =
| AcceptCallPendingActionType
| CancelCallActionType
| CancelIncomingGroupCallRingActionType
+ | ChangeCallViewActionType
| StartCallingLobbyActionType
| CallStateChangeFulfilledActionType
| ChangeIODeviceFulfilledActionType
@@ -658,7 +661,6 @@ export type CallingActionType =
| TogglePipActionType
| SetPresentingFulfilledActionType
| ToggleSettingsActionType
- | ToggleSpeakerViewActionType
| SwitchToPresentationViewActionType
| SwitchFromPresentationViewActionType;
@@ -1474,9 +1476,10 @@ function toggleSettings(): ToggleSettingsActionType {
};
}
-function toggleSpeakerView(): ToggleSpeakerViewActionType {
+function changeCallView(mode: CallViewMode): ChangeCallViewActionType {
return {
- type: TOGGLE_SPEAKER_VIEW,
+ type: CHANGE_CALL_VIEW,
+ viewMode: mode,
};
}
@@ -1491,12 +1494,12 @@ function switchFromPresentationView(): SwitchFromPresentationViewActionType {
type: SWITCH_FROM_PRESENTATION_VIEW,
};
}
-
export const actions = {
acceptCall,
callStateChange,
cancelCall,
cancelIncomingGroupCallRing,
+ changeCallView,
changeIODevice,
closeNeedPermissionScreen,
declineCall,
@@ -1524,9 +1527,9 @@ export const actions = {
setLocalAudio,
setLocalPreview,
setLocalVideo,
+ setOutgoingRing,
setPresenting,
setRendererCanvas,
- setOutgoingRing,
startCall,
startCallingLobby,
switchToPresentationView,
@@ -1535,7 +1538,6 @@ export const actions = {
togglePip,
toggleScreenRecordingPermissionsDialog,
toggleSettings,
- toggleSpeakerView,
};
export const useCallingActions = (): BoundActionCreatorsMapObject<
@@ -1643,7 +1645,7 @@ export function reducer(
hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo,
localAudioLevel: 0,
- viewMode: CallViewMode.Grid,
+ viewMode: CallViewMode.Paginated,
pip: false,
safetyNumberChangedAcis: [],
settingsDialogOpen: false,
@@ -1672,7 +1674,7 @@ export function reducer(
hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo,
localAudioLevel: 0,
- viewMode: CallViewMode.Grid,
+ viewMode: CallViewMode.Paginated,
pip: false,
safetyNumberChangedAcis: [],
settingsDialogOpen: false,
@@ -1696,7 +1698,7 @@ export function reducer(
hasLocalAudio: true,
hasLocalVideo: action.payload.asVideoCall,
localAudioLevel: 0,
- viewMode: CallViewMode.Grid,
+ viewMode: CallViewMode.Paginated,
pip: false,
safetyNumberChangedAcis: [],
settingsDialogOpen: false,
@@ -1851,7 +1853,7 @@ export function reducer(
hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo,
localAudioLevel: 0,
- viewMode: CallViewMode.Grid,
+ viewMode: CallViewMode.Paginated,
pip: false,
safetyNumberChangedAcis: [],
settingsDialogOpen: false,
@@ -2312,26 +2314,26 @@ export function reducer(
};
}
- if (action.type === TOGGLE_SPEAKER_VIEW) {
+ if (action.type === CHANGE_CALL_VIEW) {
const { activeCallState } = state;
if (!activeCallState) {
- log.warn('Cannot toggle speaker view when there is no active call');
+ log.warn('Cannot change call view when there is no active call');
return state;
}
- let newViewMode: CallViewMode;
- if (activeCallState.viewMode === CallViewMode.Grid) {
- newViewMode = CallViewMode.Speaker;
- } else {
- // This will switch presentation/speaker to grid
- newViewMode = CallViewMode.Grid;
+ if (activeCallState.viewMode === action.viewMode) {
+ return state;
}
return {
...state,
activeCallState: {
...activeCallState,
- viewMode: newViewMode,
+ viewMode: action.viewMode,
+ viewModeBeforePresentation:
+ action.viewMode === CallViewMode.Presentation
+ ? activeCallState.viewMode
+ : undefined,
},
};
}
@@ -2343,9 +2345,7 @@ export function reducer(
return state;
}
- // "Presentation" mode reverts to "Grid" when the call is over so don't
- // switch it if it is in "Speaker" mode.
- if (activeCallState.viewMode === CallViewMode.Speaker) {
+ if (activeCallState.viewMode === CallViewMode.Presentation) {
return state;
}
@@ -2354,6 +2354,7 @@ export function reducer(
activeCallState: {
...activeCallState,
viewMode: CallViewMode.Presentation,
+ viewModeBeforePresentation: activeCallState.viewMode,
},
};
}
@@ -2373,7 +2374,8 @@ export function reducer(
...state,
activeCallState: {
...activeCallState,
- viewMode: CallViewMode.Grid,
+ viewMode:
+ activeCallState.viewModeBeforePresentation ?? CallViewMode.Paginated,
},
};
}
diff --git a/ts/state/selectors/calling.ts b/ts/state/selectors/calling.ts
index 04ada098ad..f0745e5749 100644
--- a/ts/state/selectors/calling.ts
+++ b/ts/state/selectors/calling.ts
@@ -5,7 +5,6 @@ import { createSelector } from 'reselect';
import type { StateType } from '../reducer';
import type {
- ActiveCallStateType,
CallingStateType,
CallsByConversationType,
DirectCallStateType,
@@ -14,7 +13,6 @@ import type {
import { getIncomingCall as getIncomingCallHelper } from '../ducks/callingHelpers';
import { getUserACI } from './user';
import { getOwn } from '../../util/getOwn';
-import { CallViewMode } from '../../types/Calling';
import type { AciString } from '../../types/ServiceId';
export type CallStateType = DirectCallStateType | GroupCallStateType;
@@ -85,12 +83,3 @@ export const areAnyCallsActiveOrRinging = createSelector(
getIncomingCall,
(activeCall, incomingCall): boolean => Boolean(activeCall || incomingCall)
);
-
-export const isInSpeakerView = (
- call: Pick | undefined
-): boolean => {
- return Boolean(
- call?.viewMode === CallViewMode.Presentation ||
- call?.viewMode === CallViewMode.Speaker
- );
-};
diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx
index 40423ebff3..d20448974d 100644
--- a/ts/state/smart/CallManager.tsx
+++ b/ts/state/smart/CallManager.tsx
@@ -147,6 +147,7 @@ const mapStateToActiveCallProp = (
hasLocalVideo: activeCallState.hasLocalVideo,
localAudioLevel: activeCallState.localAudioLevel,
viewMode: activeCallState.viewMode,
+ viewModeBeforePresentation: activeCallState.viewModeBeforePresentation,
joinedAt: activeCallState.joinedAt,
outgoingRing: activeCallState.outgoingRing,
pip: activeCallState.pip,
diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts
index 788d5d485f..873e27baa3 100644
--- a/ts/test-electron/state/ducks/calling_test.ts
+++ b/ts/test-electron/state/ducks/calling_test.ts
@@ -64,7 +64,7 @@ describe('calling duck', () => {
hasLocalAudio: true,
hasLocalVideo: false,
localAudioLevel: 0,
- viewMode: CallViewMode.Grid,
+ viewMode: CallViewMode.Paginated,
showParticipantsList: false,
safetyNumberChangedAcis: [],
outgoingRing: true,
@@ -144,7 +144,7 @@ describe('calling duck', () => {
hasLocalAudio: true,
hasLocalVideo: false,
localAudioLevel: 0,
- viewMode: CallViewMode.Grid,
+ viewMode: CallViewMode.Paginated,
showParticipantsList: false,
safetyNumberChangedAcis: [],
outgoingRing: false,
@@ -154,23 +154,6 @@ describe('calling duck', () => {
},
};
- const stateWithActivePresentationViewGroupCall: CallingStateTypeWithActiveCall =
- {
- ...stateWithGroupCall,
- activeCallState: {
- ...stateWithActiveGroupCall.activeCallState,
- viewMode: CallViewMode.Presentation,
- },
- };
-
- const stateWithActiveSpeakerViewGroupCall: CallingStateTypeWithActiveCall = {
- ...stateWithGroupCall,
- activeCallState: {
- ...stateWithActiveGroupCall.activeCallState,
- viewMode: CallViewMode.Speaker,
- },
- };
-
const ourAci = generateAci();
const getEmptyRootState = () => {
@@ -476,7 +459,7 @@ describe('calling duck', () => {
hasLocalAudio: true,
hasLocalVideo: true,
localAudioLevel: 0,
- viewMode: CallViewMode.Grid,
+ viewMode: CallViewMode.Paginated,
showParticipantsList: false,
safetyNumberChangedAcis: [],
outgoingRing: false,
@@ -570,7 +553,7 @@ describe('calling duck', () => {
hasLocalAudio: true,
hasLocalVideo: true,
localAudioLevel: 0,
- viewMode: CallViewMode.Grid,
+ viewMode: CallViewMode.Paginated,
showParticipantsList: false,
safetyNumberChangedAcis: [],
outgoingRing: false,
@@ -1163,7 +1146,7 @@ describe('calling duck', () => {
hasLocalAudio: true,
hasLocalVideo: false,
localAudioLevel: 0,
- viewMode: CallViewMode.Grid,
+ viewMode: CallViewMode.Paginated,
showParticipantsList: false,
safetyNumberChangedAcis: [],
outgoingRing: false,
@@ -1695,7 +1678,7 @@ describe('calling duck', () => {
hasLocalAudio: true,
hasLocalVideo: true,
localAudioLevel: 0,
- viewMode: CallViewMode.Grid,
+ viewMode: CallViewMode.Paginated,
showParticipantsList: false,
safetyNumberChangedAcis: [],
pip: false,
@@ -1982,7 +1965,7 @@ describe('calling duck', () => {
hasLocalAudio: true,
hasLocalVideo: false,
localAudioLevel: 0,
- viewMode: CallViewMode.Grid,
+ viewMode: CallViewMode.Paginated,
showParticipantsList: false,
safetyNumberChangedAcis: [],
pip: false,
@@ -2056,58 +2039,14 @@ describe('calling duck', () => {
});
});
- describe('toggleSpeakerView', () => {
- const { toggleSpeakerView } = actions;
-
- it('toggles speaker view from grid view', () => {
- const afterOneToggle = reducer(
- stateWithActiveGroupCall,
- toggleSpeakerView()
- );
- const afterTwoToggles = reducer(afterOneToggle, toggleSpeakerView());
- const afterThreeToggles = reducer(afterTwoToggles, toggleSpeakerView());
-
- assert.strictEqual(
- afterOneToggle.activeCallState?.viewMode,
- CallViewMode.Speaker
- );
- assert.strictEqual(
- afterTwoToggles.activeCallState?.viewMode,
- CallViewMode.Grid
- );
- assert.strictEqual(
- afterThreeToggles.activeCallState?.viewMode,
- CallViewMode.Speaker
- );
- });
-
- it('toggles speaker view from presentation view', () => {
- const afterOneToggle = reducer(
- stateWithActivePresentationViewGroupCall,
- toggleSpeakerView()
- );
- const afterTwoToggles = reducer(afterOneToggle, toggleSpeakerView());
- const afterThreeToggles = reducer(afterTwoToggles, toggleSpeakerView());
-
- assert.strictEqual(
- afterOneToggle.activeCallState?.viewMode,
- CallViewMode.Grid
- );
- assert.strictEqual(
- afterTwoToggles.activeCallState?.viewMode,
- CallViewMode.Speaker
- );
- assert.strictEqual(
- afterThreeToggles.activeCallState?.viewMode,
- CallViewMode.Grid
- );
- });
- });
-
describe('switchToPresentationView', () => {
- const { switchToPresentationView, switchFromPresentationView } = actions;
+ const {
+ switchToPresentationView,
+ switchFromPresentationView,
+ changeCallView,
+ } = actions;
- it('toggles presentation view from grid view', () => {
+ it('toggles presentation view from paginated view', () => {
const afterOneToggle = reducer(
stateWithActiveGroupCall,
switchToPresentationView()
@@ -2116,7 +2055,7 @@ describe('calling duck', () => {
afterOneToggle,
switchToPresentationView()
);
- const finalState = reducer(
+ const afterThreeToggles = reducer(
afterOneToggle,
switchFromPresentationView()
);
@@ -2130,28 +2069,28 @@ describe('calling duck', () => {
CallViewMode.Presentation
);
assert.strictEqual(
- finalState.activeCallState?.viewMode,
- CallViewMode.Grid
+ afterThreeToggles.activeCallState?.viewMode,
+ CallViewMode.Paginated
);
});
- it('does not toggle presentation view from speaker view', () => {
- const afterOneToggle = reducer(
- stateWithActiveSpeakerViewGroupCall,
+ it('switches to previously selected view after presentation', () => {
+ const stateOverflow = reducer(
+ stateWithActiveGroupCall,
+ changeCallView(CallViewMode.Overflow)
+ );
+ const statePresentation = reducer(
+ stateOverflow,
switchToPresentationView()
);
- const finalState = reducer(
- afterOneToggle,
+ const stateAfterPresentation = reducer(
+ statePresentation,
switchFromPresentationView()
);
assert.strictEqual(
- afterOneToggle.activeCallState?.viewMode,
- CallViewMode.Speaker
- );
- assert.strictEqual(
- finalState.activeCallState?.viewMode,
- CallViewMode.Speaker
+ stateAfterPresentation.activeCallState?.viewMode,
+ CallViewMode.Overflow
);
});
});
diff --git a/ts/test-electron/state/selectors/calling_test.ts b/ts/test-electron/state/selectors/calling_test.ts
index 6948fa4f75..865a986ea6 100644
--- a/ts/test-electron/state/selectors/calling_test.ts
+++ b/ts/test-electron/state/selectors/calling_test.ts
@@ -66,7 +66,7 @@ describe('state/selectors/calling', () => {
hasLocalAudio: true,
hasLocalVideo: false,
localAudioLevel: 0,
- viewMode: CallViewMode.Grid,
+ viewMode: CallViewMode.Paginated,
showParticipantsList: false,
safetyNumberChangedAcis: [],
outgoingRing: true,
diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts
index 095b2a8aea..1ff1c5eeba 100644
--- a/ts/types/Calling.ts
+++ b/ts/types/Calling.ts
@@ -11,10 +11,12 @@ export enum CallMode {
Group = 'Group',
}
-// Speaker and Presentation has the same UI, but Presentation mode will switch
-// to Grid mode when the presentation is over.
+// Speaker and Presentation mode have the same UI, but Presentation is only set
+// automatically when someone starts to present, and will revert to the previous view mode
+// once presentation is complete
export enum CallViewMode {
- Grid = 'Grid',
+ Paginated = 'Paginated',
+ Overflow = 'Overflow',
Speaker = 'Speaker',
Presentation = 'Presentation',
}
@@ -38,6 +40,7 @@ export type ActiveCallBaseType = {
hasLocalVideo: boolean;
localAudioLevel: number;
viewMode: CallViewMode;
+ viewModeBeforePresentation?: CallViewMode;
isSharingScreen?: boolean;
joinedAt: number | null;
outgoingRing: boolean;