From 20644761b04be263781f91f6b5cda3524050264c Mon Sep 17 00:00:00 2001 From: trevor-signal <131492920+trevor-signal@users.noreply.github.com> Date: Tue, 20 Jan 2026 12:29:25 -0500 Subject: [PATCH] Conditionally show sidebar view in group calls --- ts/components/CallScreen.dom.tsx | 31 ++++++++- ts/components/CallingHeader.dom.stories.tsx | 2 + ts/components/CallingHeader.dom.tsx | 18 +++-- .../GroupCallRemoteParticipants.dom.tsx | 67 +++++++++++++++++-- 4 files changed, 104 insertions(+), 14 deletions(-) diff --git a/ts/components/CallScreen.dom.tsx b/ts/components/CallScreen.dom.tsx index 3245f68f85..4113fe73b1 100644 --- a/ts/components/CallScreen.dom.tsx +++ b/ts/components/CallScreen.dom.tsx @@ -327,6 +327,20 @@ export function CallScreen({ setShowRaisedHandsList(prevValue => !prevValue); }, []); + const [sidebarViewDiffersFromGridView, setSidebarViewDiffersFromGridView] = + useState(false); + + // If the user is in Sidebar view but it's no longer different from Grid view, + // automatically switch to Grid view. + useEffect(() => { + if ( + !sidebarViewDiffersFromGridView && + activeCall.viewMode === CallViewMode.Sidebar + ) { + changeCallView(CallViewMode.Paginated); + } + }, [sidebarViewDiffersFromGridView, activeCall.viewMode, changeCallView]); + const [controlsHover, setControlsHover] = useState(false); const onControlsMouseEnter = useCallback(() => { setControlsHover(true); @@ -445,7 +459,7 @@ export function CallScreen({ }, [showReactionPicker]); useScreenSharingStoppedToast({ activeCall, i18n }); - useViewModeChangedToast({ activeCall, i18n }); + useViewModeChangedToast({ activeCall, i18n, sidebarViewDiffersFromGridView }); const currentPresenter = remoteParticipants.find( participant => participant.presenting @@ -993,6 +1007,9 @@ export function CallScreen({ imageDataCache={imageDataCache} i18n={i18n} joinedAt={activeCall.joinedAt} + onSidebarViewDiffersFromGridViewChange={ + setSidebarViewDiffersFromGridView + } remoteParticipants={activeCall.remoteParticipants} setGroupCallVideoRequest={setGroupCallVideoRequest} remoteAudioLevels={activeCall.remoteAudioLevels} @@ -1073,6 +1090,7 @@ export function CallScreen({ i18n={i18n} isGroupCall={isGroupCall} participantCount={participantCount} + showSidebarViewOption={sidebarViewDiffersFromGridView} togglePip={togglePip} toggleSettings={toggleSettings} /> @@ -1320,9 +1338,11 @@ function renderDuration(ms: number): string { function useViewModeChangedToast({ activeCall, i18n, + sidebarViewDiffersFromGridView, }: { activeCall: ActiveCallType; i18n: LocalizerType; + sidebarViewDiffersFromGridView: boolean; }): void { const { viewMode } = activeCall; const previousViewMode = usePrevious(viewMode, viewMode); @@ -1342,6 +1362,14 @@ function useViewModeChangedToast({ return; } + if ( + !sidebarViewDiffersFromGridView && + previousViewMode === CallViewMode.Sidebar && + viewMode === CallViewMode.Paginated + ) { + return; + } + hideToast(VIEW_MODE_CHANGED_TOAST_KEY); showToast({ key: VIEW_MODE_CHANGED_TOAST_KEY, @@ -1364,6 +1392,7 @@ function useViewModeChangedToast({ hideToast, i18n, activeCall, + sidebarViewDiffersFromGridView, viewMode, previousViewMode, presenterAci, diff --git a/ts/components/CallingHeader.dom.stories.tsx b/ts/components/CallingHeader.dom.stories.tsx index 763a05346f..2dbe6d49f4 100644 --- a/ts/components/CallingHeader.dom.stories.tsx +++ b/ts/components/CallingHeader.dom.stories.tsx @@ -16,11 +16,13 @@ export default { argTypes: { isGroupCall: { control: { type: 'boolean' } }, participantCount: { control: { type: 'number' } }, + showSidebarViewOption: { control: { type: 'boolean' } }, }, args: { i18n, isGroupCall: false, participantCount: 0, + showSidebarViewOption: false, togglePip: action('toggle-pip'), callViewMode: CallViewMode.Paginated, changeCallView: action('change-call-view'), diff --git a/ts/components/CallingHeader.dom.tsx b/ts/components/CallingHeader.dom.tsx index 89d7b9efff..1b13992f90 100644 --- a/ts/components/CallingHeader.dom.tsx +++ b/ts/components/CallingHeader.dom.tsx @@ -15,6 +15,7 @@ export type PropsType = { isGroupCall?: boolean; onCancel?: () => void; participantCount: number; + showSidebarViewOption?: boolean; togglePip?: () => void; toggleSettings: () => void; changeCallView?: (mode: CallViewMode) => void; @@ -27,6 +28,7 @@ export function CallingHeader({ isGroupCall = false, onCancel, participantCount, + showSidebarViewOption = false, togglePip, toggleSettings, }: PropsType): React.JSX.Element { @@ -47,12 +49,16 @@ export function CallingHeader({ onClick: () => changeCallView(CallViewMode.Paginated), value: CallViewMode.Paginated, }, - { - icon: 'CallSettingsButton__Icon--SidebarView', - label: i18n('icu:calling__view_mode--overflow'), - onClick: () => changeCallView(CallViewMode.Sidebar), - value: CallViewMode.Sidebar, - }, + ...(showSidebarViewOption + ? [ + { + icon: 'CallSettingsButton__Icon--SidebarView', + label: i18n('icu:calling__view_mode--overflow'), + onClick: () => changeCallView(CallViewMode.Sidebar), + value: CallViewMode.Sidebar, + }, + ] + : []), { icon: 'CallSettingsButton__Icon--SpeakerView', label: i18n('icu:calling__view_mode--speaker'), diff --git a/ts/components/GroupCallRemoteParticipants.dom.tsx b/ts/components/GroupCallRemoteParticipants.dom.tsx index 1a1fa83c8c..7edacf5f50 100644 --- a/ts/components/GroupCallRemoteParticipants.dom.tsx +++ b/ts/components/GroupCallRemoteParticipants.dom.tsx @@ -68,6 +68,7 @@ type PropsType = { imageDataCache: React.RefObject; isCallReconnecting: boolean; joinedAt: number | null; + onSidebarViewDiffersFromGridViewChange: (differs: boolean) => void; remoteParticipants: ReadonlyArray; setGroupCallVideoRequest: ( _: Array, @@ -121,6 +122,7 @@ export function GroupCallRemoteParticipants({ i18n, isCallReconnecting, joinedAt, + onSidebarViewDiffersFromGridViewChange, remoteParticipants, setGroupCallVideoRequest, remoteAudioLevels, @@ -140,10 +142,7 @@ export function GroupCallRemoteParticipants({ const { invisibleDemuxIds, onParticipantVisibilityChanged } = useInvisibleParticipants(remoteParticipants); - const minRenderedHeight = - callViewMode === CallViewMode.Paginated - ? SMALL_TILES_MIN_HEIGHT - : LARGE_TILES_MIN_HEIGHT; + const minRenderedHeight = getMinRenderedHeight(callViewMode); const isInSpeakerView = callViewMode === CallViewMode.Speaker || @@ -157,9 +156,7 @@ export function GroupCallRemoteParticipants({ // 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) - ); + const maxRowsPerPage = getMaxRowsPerPage(maxGridHeight, minRenderedHeight); // 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 @@ -228,6 +225,49 @@ export function GroupCallRemoteParticipants({ setPageIndex(gridParticipantsByPage.length - 1); } + // Calculate whether Sidebar view would look different from Grid (Paginated) view. + // They differ when Grid view would have multiple pages. + const minRenderedHeightForGridView = getMinRenderedHeight( + CallViewMode.Paginated + ); + const maxRowsPerPageForGridView = getMaxRowsPerPage( + maxGridHeight, + minRenderedHeightForGridView + ); + const sidebarViewDiffersFromGridView = useMemo(() => { + if (!prioritySortedParticipants.length || !maxRowsPerPageForGridView) { + return false; + } + + // When in Grid view, we've already calculated with the right params + if (isInPaginationView) { + return gridParticipantsByPage.length > 1; + } + + // For other views, calculate how many would fit on the first page of Grid view + const gridViewPages = getGridParticipantsByPage({ + participants: prioritySortedParticipants, + maxRowWidth, + maxPages: 1, + maxRowsPerPage: maxRowsPerPageForGridView, + minRenderedHeight: minRenderedHeightForGridView, + maxParticipantsPerPage: MAX_PARTICIPANTS_PER_PAGE, + }); + + return gridViewPages[0].numParticipants < prioritySortedParticipants.length; + }, [ + isInPaginationView, + gridParticipantsByPage.length, + maxRowWidth, + maxRowsPerPageForGridView, + minRenderedHeightForGridView, + prioritySortedParticipants, + ]); + + useEffect(() => { + onSidebarViewDiffersFromGridViewChange(sidebarViewDiffersFromGridView); + }, [onSidebarViewDiffersFromGridViewChange, sidebarViewDiffersFromGridView]); + const totalParticipantsInGrid = gridParticipantsByPage.reduce( (pageCount, { numParticipants }) => pageCount + numParticipants, 0 @@ -635,6 +675,19 @@ function participantWidthAtHeight( return participant.videoAspectRatio * height; } +function getMinRenderedHeight(callViewMode: CallViewMode): number { + return callViewMode === CallViewMode.Paginated + ? SMALL_TILES_MIN_HEIGHT + : LARGE_TILES_MIN_HEIGHT; +} + +function getMaxRowsPerPage( + maxGridHeight: number, + minRenderedHeight: number +): number { + return Math.floor(maxGridHeight / (minRenderedHeight + PARTICIPANT_MARGIN)); +} + function stableParticipantComparator( a: Readonly<{ demuxId: number }>, b: Readonly<{ demuxId: number }>