From b562a0f7212ac4bb021eb94a1f597059959f8da9 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 23 Apr 2026 17:03:35 +0200 Subject: [PATCH] Create a proper one-on-one call layout for portrait screens --- src/AppBar.module.css | 16 + src/grid/CallLayout.ts | 25 +- src/grid/GridLayout.tsx | 9 +- ...css => OneOnOneLandscapeLayout.module.css} | 6 - ...Layout.tsx => OneOnOneLandscapeLayout.tsx} | 37 +- src/grid/OneOnOnePortraitLayout.module.css | 46 ++ src/grid/OneOnOnePortraitLayout.tsx | 74 +++ src/grid/SpotlightExpandedLayout.tsx | 14 +- src/grid/SpotlightLandscapeLayout.tsx | 2 +- src/grid/SpotlightPortraitLayout.tsx | 2 +- src/room/InCallView.module.css | 27 + src/room/InCallView.tsx | 170 +++--- .../__snapshots__/InCallView.test.tsx.snap | 10 +- src/state/CallViewModel/CallViewModel.test.ts | 135 ++++- src/state/CallViewModel/CallViewModel.ts | 499 +++++++++++------- .../CallViewModel/CallViewModelTestUtils.ts | 22 +- src/state/GridLikeLayout.ts | 10 +- ...neLayout.ts => OneOnOneLandscapeLayout.ts} | 20 +- src/state/OneOnOnePortraitLayout.ts | 43 ++ src/state/SpotlightExpandedLayout.ts | 7 +- src/state/layout-types.ts | 46 +- src/tile/GridTile.module.css | 4 + src/tile/GridTile.test.tsx | 2 + src/tile/GridTile.tsx | 4 +- src/tile/MediaView.test.tsx | 11 + src/tile/MediaView.tsx | 62 ++- src/tile/SpotlightTile.test.tsx | 4 + src/tile/SpotlightTile.tsx | 7 + 28 files changed, 911 insertions(+), 403 deletions(-) rename src/grid/{OneOnOneLayout.module.css => OneOnOneLandscapeLayout.module.css} (88%) rename src/grid/{OneOnOneLayout.tsx => OneOnOneLandscapeLayout.tsx} (61%) create mode 100644 src/grid/OneOnOnePortraitLayout.module.css create mode 100644 src/grid/OneOnOnePortraitLayout.tsx rename src/state/{OneOnOneLayout.ts => OneOnOneLandscapeLayout.ts} (55%) create mode 100644 src/state/OneOnOnePortraitLayout.ts diff --git a/src/AppBar.module.css b/src/AppBar.module.css index 5f7888d2..13e3b759 100644 --- a/src/AppBar.module.css +++ b/src/AppBar.module.css @@ -1,5 +1,21 @@ .bar { flex-shrink: 0; + position: relative; +} + +/* Pseudo-element for the gradient background */ +.bar::before { + content: ""; + position: absolute; + inset-inline: 0; + /* Extend the gradient beyond the bottom of the header for readability */ + inset-block: -24px; + z-index: var(--call-view-header-footer-layer); + background: linear-gradient( + 0deg, + rgba(0, 0, 0, 0) 0%, + var(--cpd-color-bg-canvas-default) 100% + ); } .bar > header { diff --git a/src/grid/CallLayout.ts b/src/grid/CallLayout.ts index 4ce5a7c2..3128087b 100644 --- a/src/grid/CallLayout.ts +++ b/src/grid/CallLayout.ts @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type BehaviorSubject, type Observable } from "rxjs"; +import { type Observable } from "rxjs"; import { type ComponentType } from "react"; import { type LayoutProps } from "./Grid"; @@ -16,37 +16,18 @@ export interface Bounds { height: number; } -export interface Alignment { - inline: "start" | "end"; - block: "start" | "end"; -} - -export const defaultSpotlightAlignment: Alignment = { - inline: "end", - block: "end", -}; -export const defaultPipAlignment: Alignment = { inline: "end", block: "start" }; - export interface CallLayoutInputs { /** * The minimum bounds of the layout area. */ minBounds$: Observable; - /** - * The alignment of the floating spotlight tile, if present. - */ - spotlightAlignment$: BehaviorSubject; - /** - * The alignment of the small picture-in-picture tile, if present. - */ - pipAlignment$: BehaviorSubject; } export interface CallLayoutOutputs { /** - * Whether the scrolling layer of the layout should appear on top. + * Which layer should appear in the foreground. */ - scrollingOnTop: boolean; + foreground: "fixed" | "scrolling"; /** * The visually fixed (non-scrolling) layer of the layout. */ diff --git a/src/grid/GridLayout.tsx b/src/grid/GridLayout.tsx index cf46e8b4..79c2b3a4 100644 --- a/src/grid/GridLayout.tsx +++ b/src/grid/GridLayout.tsx @@ -32,9 +32,8 @@ interface GridCSSProperties extends CSSProperties { */ export const makeGridLayout: CallLayout = ({ minBounds$, - spotlightAlignment$, }) => ({ - scrollingOnTop: false, + foreground: "fixed", // The "fixed" (non-scrolling) part of the layout is where the spotlight tile // lives @@ -42,7 +41,7 @@ export const makeGridLayout: CallLayout = ({ useUpdateLayout(); const alignment = useObservableEagerState( useInitial(() => - spotlightAlignment$.pipe( + model.spotlightAlignment$.pipe( distinctUntilChanged( (a1, a2) => a1.block === a2.block && a1.inline === a2.inline, ), @@ -52,11 +51,11 @@ export const makeGridLayout: CallLayout = ({ const onDragSpotlight: DragCallback = useCallback( ({ xRatio, yRatio }) => - spotlightAlignment$.next({ + model.spotlightAlignment$.next({ block: yRatio < 0.5 ? "start" : "end", inline: xRatio < 0.5 ? "start" : "end", }), - [], + [model.spotlightAlignment$], ); return ( diff --git a/src/grid/OneOnOneLayout.module.css b/src/grid/OneOnOneLandscapeLayout.module.css similarity index 88% rename from src/grid/OneOnOneLayout.module.css rename to src/grid/OneOnOneLandscapeLayout.module.css index 0ac1b78d..15192fb2 100644 --- a/src/grid/OneOnOneLayout.module.css +++ b/src/grid/OneOnOneLandscapeLayout.module.css @@ -22,12 +22,6 @@ Please see LICENSE in the repository root for full details. inset: 0; } -.spotlight { - position: absolute; - inline-size: 404px; - block-size: 233px; -} - .slot[data-block-alignment="start"] { inset-block-end: unset; } diff --git a/src/grid/OneOnOneLayout.tsx b/src/grid/OneOnOneLandscapeLayout.tsx similarity index 61% rename from src/grid/OneOnOneLayout.tsx rename to src/grid/OneOnOneLandscapeLayout.tsx index fd9c0a65..1e21d112 100644 --- a/src/grid/OneOnOneLayout.tsx +++ b/src/grid/OneOnOneLandscapeLayout.tsx @@ -1,5 +1,6 @@ /* Copyright 2024 New Vector Ltd. +Copyright 2026 Element Creations Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. @@ -9,31 +10,35 @@ import { type ReactNode, useCallback, useMemo } from "react"; import { useObservableEagerState } from "observable-hooks"; import classNames from "classnames"; -import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/layout-types.ts"; +import { type OneOnOneLandscapeLayout as OneOnOneLandscapeLayoutModel } from "../state/layout-types.ts"; import { type CallLayout, arrangeTiles } from "./CallLayout"; -import styles from "./OneOnOneLayout.module.css"; +import styles from "./OneOnOneLandscapeLayout.module.css"; import { type DragCallback, useUpdateLayout } from "./Grid"; import { useBehavior } from "../useBehavior"; /** - * An implementation of the "one-on-one" layout, in which the remote participant - * is shown at maximum size, overlaid by a small view of the local participant. + * An implementation of the "one-on-one" layout for landscape screens, in which + * the remote participant is shown at maximum size, overlaid by a small view of + * the local participant. */ -export const makeOneOnOneLayout: CallLayout = ({ - minBounds$, - pipAlignment$, -}) => ({ - scrollingOnTop: false, +export const makeOneOnOneLandscapeLayout: CallLayout< + OneOnOneLandscapeLayoutModel +> = ({ minBounds$ }) => ({ + foreground: "fixed", - fixed: function OneOnOneLayoutFixed({ ref }): ReactNode { + fixed: function OneOnOneLandscapeLayoutFixed({ ref }): ReactNode { useUpdateLayout(); return
; }, - scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode { + scrolling: function OneOnOneLandscapeLayoutScrolling({ + ref, + model, + Slot, + }): ReactNode { useUpdateLayout(); const { width, height } = useObservableEagerState(minBounds$); - const pipAlignmentValue = useBehavior(pipAlignment$); + const pipAlignment = useBehavior(model.pipAlignment$); const { tileWidth, tileHeight } = useMemo( () => arrangeTiles(width, height, 1), [width, height], @@ -41,11 +46,11 @@ export const makeOneOnOneLayout: CallLayout = ({ const onDragLocalTile: DragCallback = useCallback( ({ xRatio, yRatio }) => - pipAlignment$.next({ + model.pipAlignment$.next({ block: yRatio < 0.5 ? "start" : "end", inline: xRatio < 0.5 ? "start" : "end", }), - [], + [model.pipAlignment$], ); return ( @@ -61,8 +66,8 @@ export const makeOneOnOneLayout: CallLayout = ({ id={model.pip.id} model={model.pip} onDrag={onDragLocalTile} - data-block-alignment={pipAlignmentValue.block} - data-inline-alignment={pipAlignmentValue.inline} + data-block-alignment={pipAlignment.block} + data-inline-alignment={pipAlignment.inline} />
diff --git a/src/grid/OneOnOnePortraitLayout.module.css b/src/grid/OneOnOnePortraitLayout.module.css new file mode 100644 index 00000000..999f504d --- /dev/null +++ b/src/grid/OneOnOnePortraitLayout.module.css @@ -0,0 +1,46 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +.layer { + block-size: 100%; +} + +.spotlight { + block-size: 100%; + inline-size: 100%; +} + +.pip { + position: absolute; + inset: var(--cpd-space-4x); +} + +.pip[data-size="sm"] { + inline-size: 88px; + block-size: 132px; +} + +.pip[data-size="lg"] { + inline-size: 140px; + block-size: 210px; +} + +.pip[data-block-alignment="start"] { + inset-block-end: unset; +} + +.pip[data-block-alignment="end"] { + inset-block-start: unset; +} + +.pip[data-inline-alignment="start"] { + inset-inline-end: unset; +} + +.pip[data-inline-alignment="end"] { + inset-inline-start: unset; +} diff --git a/src/grid/OneOnOnePortraitLayout.tsx b/src/grid/OneOnOnePortraitLayout.tsx new file mode 100644 index 00000000..4f7c9f45 --- /dev/null +++ b/src/grid/OneOnOnePortraitLayout.tsx @@ -0,0 +1,74 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { type ReactNode, useCallback } from "react"; +import classNames from "classnames"; + +import { type OneOnOnePortraitLayout as OneOnOnePortraitLayoutModel } from "../state/layout-types.ts"; +import { type CallLayout } from "./CallLayout"; +import styles from "./OneOnOnePortraitLayout.module.css"; +import { type DragCallback, useUpdateLayout } from "./Grid"; +import { useBehavior } from "../useBehavior"; + +/** + * An implementation of the "one-on-one" layout for portrait screens, in which + * the remote participant is shown at maximum size, overlaid by a small view of + * the local participant. + */ +export const makeOneOnOnePortraitLayout: CallLayout< + OneOnOnePortraitLayoutModel +> = () => ({ + foreground: "scrolling", + + fixed: function OneOnOnePortraitLayoutFixed({ ref, model, Slot }): ReactNode { + useUpdateLayout(); + return ( +
+ +
+ ); + }, + + scrolling: function OneOnOnePortraitLayoutScrolling({ + ref, + model, + Slot, + }): ReactNode { + useUpdateLayout(); + const pipSize = useBehavior(model.pipSize$); + const pipAlignment = useBehavior(model.pipAlignment$); + const onDragLocalTile: DragCallback = useCallback( + ({ xRatio, yRatio }) => + model.pipAlignment$.next({ + block: yRatio < 0.5 ? "start" : "end", + inline: xRatio < 0.5 ? "start" : "end", + }), + [model.pipAlignment$], + ); + + return ( +
+ {model.pip && ( + + )} +
+ ); + }, +}); diff --git a/src/grid/SpotlightExpandedLayout.tsx b/src/grid/SpotlightExpandedLayout.tsx index ac47f0d4..b4fd1d0e 100644 --- a/src/grid/SpotlightExpandedLayout.tsx +++ b/src/grid/SpotlightExpandedLayout.tsx @@ -19,8 +19,8 @@ import { useBehavior } from "../useBehavior"; */ export const makeSpotlightExpandedLayout: CallLayout< SpotlightExpandedLayoutModel -> = ({ pipAlignment$ }) => ({ - scrollingOnTop: true, +> = () => ({ + foreground: "scrolling", fixed: function SpotlightExpandedLayoutFixed({ ref, @@ -46,15 +46,15 @@ export const makeSpotlightExpandedLayout: CallLayout< Slot, }): ReactNode { useUpdateLayout(); - const pipAlignmentValue = useBehavior(pipAlignment$); + const pipAlignment = useBehavior(model.pipAlignment$); const onDragPip: DragCallback = useCallback( ({ xRatio, yRatio }) => - pipAlignment$.next({ + model.pipAlignment$.next({ block: yRatio < 0.5 ? "start" : "end", inline: xRatio < 0.5 ? "start" : "end", }), - [], + [model.pipAlignment$], ); return ( @@ -65,8 +65,8 @@ export const makeSpotlightExpandedLayout: CallLayout< id={model.pip.id} model={model.pip} onDrag={onDragPip} - data-block-alignment={pipAlignmentValue.block} - data-inline-alignment={pipAlignmentValue.inline} + data-block-alignment={pipAlignment.block} + data-inline-alignment={pipAlignment.inline} /> )} diff --git a/src/grid/SpotlightLandscapeLayout.tsx b/src/grid/SpotlightLandscapeLayout.tsx index d87be1f1..d76890c5 100644 --- a/src/grid/SpotlightLandscapeLayout.tsx +++ b/src/grid/SpotlightLandscapeLayout.tsx @@ -22,7 +22,7 @@ import { useUpdateLayout, useVisibleTiles } from "./Grid"; export const makeSpotlightLandscapeLayout: CallLayout< SpotlightLandscapeLayoutModel > = ({ minBounds$ }) => ({ - scrollingOnTop: false, + foreground: "scrolling", fixed: function SpotlightLandscapeLayoutFixed({ ref, diff --git a/src/grid/SpotlightPortraitLayout.tsx b/src/grid/SpotlightPortraitLayout.tsx index a6d1241c..6939e082 100644 --- a/src/grid/SpotlightPortraitLayout.tsx +++ b/src/grid/SpotlightPortraitLayout.tsx @@ -29,7 +29,7 @@ interface GridCSSProperties extends CSSProperties { export const makeSpotlightPortraitLayout: CallLayout< SpotlightPortraitLayoutModel > = ({ minBounds$ }) => ({ - scrollingOnTop: false, + foreground: "fixed", fixed: function SpotlightPortraitLayoutFixed({ ref, diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index 390d6058..fcf1a492 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -26,6 +26,33 @@ Please see LICENSE in the repository root for full details. ); } +.header.hidden { + display: none; +} + +.header.overlay { + /* Note that the header is still position: sticky in this case so that certain + tiles can move down out of the way of the header when visible. */ + opacity: 1; + transition: opacity 0.15s; +} + +.header.overlay.hidden { + display: flex; + opacity: 0; + pointer-events: none; + /* Switch to position: absolute so the header takes up no space in the layout + when hidden. */ + position: absolute; + inset-block-start: 0; + inset-inline: 0; +} + +.header.overlay:has(:focus-visible) { + opacity: 1; + pointer-events: initial; +} + .header.filler { block-size: var(--cpd-space-6x); background: none; diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 7fc97e27..671b6b9a 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -21,7 +21,7 @@ import { import useMeasure from "react-use-measure"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import classNames from "classnames"; -import { BehaviorSubject, map } from "rxjs"; +import { map } from "rxjs"; import { useObservable } from "observable-hooks"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { useTranslation } from "react-i18next"; @@ -51,12 +51,9 @@ import { SpotlightTile } from "../tile/SpotlightTile"; import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { E2eeType } from "../e2ee/e2eeType"; import { makeGridLayout } from "../grid/GridLayout"; -import { - type CallLayoutOutputs, - defaultPipAlignment, - defaultSpotlightAlignment, -} from "../grid/CallLayout"; -import { makeOneOnOneLayout } from "../grid/OneOnOneLayout"; +import { type CallLayoutOutputs } from "../grid/CallLayout"; +import { makeOneOnOneLandscapeLayout } from "../grid/OneOnOneLandscapeLayout"; +import { makeOneOnOnePortraitLayout } from "../grid/OneOnOnePortraitLayout"; import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout"; import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout"; import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout"; @@ -239,10 +236,11 @@ export const InCallView: FC = ({ const audioParticipants = useBehavior(vm.livekitRoomItems$); const participantCount = useBehavior(vm.participantCount$); const reconnecting = useBehavior(vm.reconnecting$); - const windowMode = useBehavior(vm.windowMode$); const layout = useBehavior(vm.layout$); + const edgeToEdge = useBehavior(vm.edgeToEdge$); const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$); const [debugTileLayout] = useSetting(debugTileLayoutSetting); + const showNameTags = useBehavior(vm.showNameTags$); const gridMode = useBehavior(vm.gridMode$); const showHeader = useBehavior(vm.showHeader$); const showFooter = useBehavior(vm.showFooter$); @@ -325,15 +323,14 @@ export const InCallView: FC = ({ width: bounds.width, height: bounds.height - - headerBounds.height - - (windowMode === "flat" ? 0 : footerBounds.height), + (edgeToEdge ? 0 : headerBounds.height + footerBounds.height), }), [ bounds.width, bounds.height, headerBounds.height, footerBounds.height, - windowMode, + edgeToEdge, ], ); const gridBoundsObservable$ = useObservable( @@ -341,13 +338,6 @@ export const InCallView: FC = ({ [gridBounds], ); - const spotlightAlignment$ = useInitial( - () => new BehaviorSubject(defaultSpotlightAlignment), - ); - const pipAlignment$ = useInitial( - () => new BehaviorSubject(defaultPipAlignment), - ); - const setGridMode = useCallback( (mode: GridMode) => vm.setGridMode(mode), [vm], @@ -356,49 +346,47 @@ export const InCallView: FC = ({ useAppBarHidden(!showHeader); let header: ReactNode = null; - if (showHeader) { - switch (headerStyle) { - case HeaderStyle.AppBar: { - // dont build a header here. The AppBar will take care of it. - break; - } - case HeaderStyle.None: - // Cosmetic header to fill out space while still affecting the bounds of - // the grid - header = ( -
- ); - break; - case HeaderStyle.Standard: - header = ( -
- - - - - {showControls && onShareClick !== null && ( - - )} - -
- ); + switch (headerStyle) { + case HeaderStyle.AppBar: { + // dont build a header here. The AppBar will take care of it. + break; } + case HeaderStyle.None: + // Cosmetic header to fill out space while still affecting the bounds of + // the grid + header = showHeader && ( +
+ ); + break; + case HeaderStyle.Standard: + header = ( +
+ + + + + {showControls && onShareClick !== null && ( + + )} + +
+ ); } // The reconnecting toast cannot be dismissed @@ -445,12 +433,11 @@ export const InCallView: FC = ({ }: TileProps): ReactNode { const spotlightExpanded = useBehavior(vm.spotlightExpanded$); const onToggleExpanded = useBehavior(vm.toggleSpotlightExpanded$); - const showSpeakingIndicatorsValue = useBehavior( - vm.showSpeakingIndicators$, - ); - const showSpotlightIndicatorsValue = useBehavior( + const showSpotlightIndicators = useBehavior( vm.showSpotlightIndicators$, ); + const showSpeakingIndicators = useBehavior(vm.showSpeakingIndicators$); + const showNameTags = useBehavior(vm.showNameTags$); return model instanceof GridTileViewModel ? ( = ({ targetHeight={targetHeight} className={classNames(className, styles.tile)} style={style} - showSpeakingIndicators={showSpeakingIndicatorsValue} + showSpeakingIndicators={showSpeakingIndicators} + showNameTags={showNameTags} focusable={!contentObscured} /> ) : ( @@ -472,7 +460,8 @@ export const InCallView: FC = ({ onToggleExpanded={onToggleExpanded} targetWidth={targetWidth} targetHeight={targetHeight} - showIndicators={showSpotlightIndicatorsValue} + showIndicators={showSpotlightIndicators} + showNameTags={showNameTags} focusable={!contentObscured} className={classNames(className, styles.tile)} style={style} @@ -483,19 +472,16 @@ export const InCallView: FC = ({ ); const layouts = useMemo(() => { - const inputs = { - minBounds$: gridBoundsObservable$, - spotlightAlignment$, - pipAlignment$, - }; + const inputs = { minBounds$: gridBoundsObservable$ }; return { grid: makeGridLayout(inputs), "spotlight-landscape": makeSpotlightLandscapeLayout(inputs), "spotlight-portrait": makeSpotlightPortraitLayout(inputs), "spotlight-expanded": makeSpotlightExpandedLayout(inputs), - "one-on-one": makeOneOnOneLayout(inputs), + "one-on-one-landscape": makeOneOnOneLandscapeLayout(inputs), + "one-on-one-portrait": makeOneOnOnePortraitLayout(inputs), }; - }, [gridBoundsObservable$, spotlightAlignment$, pipAlignment$]); + }, [gridBoundsObservable$]); const renderContent = (): JSX.Element => { if (layout.type === "pip") { @@ -508,6 +494,7 @@ export const InCallView: FC = ({ targetWidth={gridBounds.width} targetHeight={gridBounds.height} showIndicators={false} + showNameTags={showNameTags} focusable={!contentObscured} aria-hidden={contentObscured} /> @@ -521,8 +508,8 @@ export const InCallView: FC = ({ className={styles.fixedGrid} style={{ insetBlockStart: - headerBounds.height > 0 ? headerBounds.bottom : bounds.top, - height: gridBounds.height, + edgeToEdge || headerBounds.height === 0 ? 0 : headerBounds.bottom, + height: edgeToEdge ? "100%" : gridBounds.height, }} model={layout} Layout={layers.fixed} @@ -540,19 +527,24 @@ export const InCallView: FC = ({ aria-hidden={contentObscured} /> ); - // The grid tiles go *under* the spotlight in the portrait layout, but - // *over* the spotlight in the expanded layout - return layout.type === "spotlight-expanded" ? ( - <> - {fixedGrid} - {scrollingGrid} - - ) : ( - <> - {scrollingGrid} - {fixedGrid} - - ); + + // Put the right layer in the foreground for the requested layout + switch (layers.foreground) { + case "fixed": + return ( + <> + {scrollingGrid} + {fixedGrid} + + ); + case "scrolling": + return ( + <> + {fixedGrid} + {scrollingGrid} + + ); + } }; const rageshakeRequestModalProps = useRageshakeRequestModal( @@ -575,7 +567,7 @@ export const InCallView: FC = ({ ref={footerRef} hidden={!showFooter} hideControls={!showControls} - asOverlay={windowMode === "flat"} + asOverlay={edgeToEdge} asPip={layout.type === "pip"} // Hide the logo for both embedded solutions. mobile: HeaderStyle.AppBar and desktop: HeaderStyle.None. hideLogo={headerStyle !== HeaderStyle.Standard} diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index d9f768e7..b17e55d2 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -83,6 +83,11 @@ exports[`InCallView > rendering > renders 1`] = ` class="nav rightNav" /> +
+
+
@@ -102,11 +107,6 @@ exports[`InCallView > rendering > renders 1`] = `
-
-
-
): Observable { @@ -187,7 +196,7 @@ function summarizeLayout$(l$: Observable): Observable { pip: pip?.id, }), ); - case "one-on-one": + case "one-on-one-landscape": return combineLatest( [l.spotlight.media$, l.pip.media$], (spotlight, pip) => ({ @@ -196,6 +205,20 @@ function summarizeLayout$(l$: Observable): Observable { pip: pip.id, }), ); + case "one-on-one-portrait": + return combineLatest( + [ + l.spotlight.media$, + l.pip?.media$ ?? constant(undefined), + l.pipSize$, + ], + (spotlight, pip, pipSize) => ({ + type: l.type, + spotlight: spotlight.map((vm) => vm.id), + pip: pip?.id, + pipSize, + }), + ); case "pip": return l.spotlight.media$.pipe( map((spotlight) => ({ @@ -405,7 +428,7 @@ describe.each([ expectedLayoutMarbles, { a: { - type: "one-on-one", + type: "one-on-one-landscape", pip: `${localId}:0`, spotlight: `${aliceId}:0`, }, @@ -421,6 +444,85 @@ describe.each([ }); }); + test("one-on-one portrait layout shows local tile when video is enabled", () => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { + // Local participant enables their video, then disables it + const videoInputMarbles = " ny--n"; + // While tile is shown, tap the screen twice + const tapScreenInputMarbles = "--aa-"; + // Layout should show local tile, make it small, enlarge it again, then hide it + const expectedLayoutMarbles = "abcba"; + + withCallViewModel( + { + remoteParticipants$: constant([aliceParticipant]), + roomMembers: [local, alice], + rtcMembers$: constant([localRtcMember, aliceRtcMember]), + videoEnabled: new Map([ + [localParticipant, behavior(videoInputMarbles, yesNo)], + ]), + windowSize$: constant({ width: 380, height: 700 }), // Mobile phone in portrait + }, + (vm) => { + schedule(tapScreenInputMarbles, { a: () => vm.tapScreen() }); + + expectObservable(vm.edgeToEdge$).toBe("y", yesNo); // Edge-to-edge-layout + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + type: "one-on-one-portrait", + spotlight: [`${aliceId}:0`], + pip: undefined, + pipSize: "lg", + }, + b: { + type: "one-on-one-portrait", + spotlight: [`${aliceId}:0`], + pip: `${localId}:0`, + pipSize: "lg", + }, + c: { + type: "one-on-one-portrait", + spotlight: [`${aliceId}:0`], + pip: `${localId}:0`, + pipSize: "sm", + }, + }, + ); + }, + ); + }); + }); + + test("one-on-one portrait layout shows name tags in room with 3 members", () => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { + withCallViewModel( + { + remoteParticipants$: constant([aliceParticipant]), + // Both Alice and Bob are with us in the room + roomMembers: [local, alice, bob], + rtcMembers$: constant([localRtcMember, aliceRtcMember]), + windowSize$: constant({ width: 380, height: 700 }), // Mobile phone in portrait + }, + (vm) => { + // Uses one-on-one portrait layout + expectObservable(summarizeLayout$(vm.layout$)).toBe("a", { + a: { + type: "one-on-one-portrait", + spotlight: [`${aliceId}:0`], + pip: undefined, + pipSize: "lg", + }, + }); + // It wouldn't be clear whether Alice or Bob is the remote video tile, + // so the interface must put a name tag on it + expectObservable(vm.showNameTags$).toBe("y", yesNo); + }, + ); + }); + }); + test("participants stay in the same order unless to appear/disappear", () => { withTestScheduler(({ behavior, schedule, expectObservable }) => { const visibilityInputMarbles = "a"; @@ -576,7 +678,7 @@ describe.each([ }); test("layout reacts to window size", () => { - withTestScheduler(({ behavior, schedule, expectObservable }) => { + withTestScheduler(({ behavior, expectObservable }) => { const windowSizeInputMarbles = "abc"; const expectedLayoutMarbles = " abc"; withCallViewModel( @@ -584,7 +686,7 @@ describe.each([ remoteParticipants$: constant([aliceParticipant]), rtcMembers$: constant([localRtcMember, aliceRtcMember]), windowSize$: behavior(windowSizeInputMarbles, { - a: { width: 300, height: 600 }, // Start very narrow, like a phone + a: { width: 380, height: 700 }, // Start very narrow, like a phone b: { width: 1000, height: 800 }, // Go to normal desktop window size c: { width: 200, height: 180 }, // Go to PiP size }), @@ -595,13 +697,14 @@ describe.each([ { a: { // This is the expected one-on-one layout for a narrow window - type: "spotlight-expanded", + type: "one-on-one-portrait", spotlight: [`${aliceId}:0`], - pip: `${localId}:0`, + pip: undefined, + pipSize: "lg", }, b: { // In a larger window, expect the normal one-on-one layout - type: "one-on-one", + type: "one-on-one-landscape", pip: `${localId}:0`, spotlight: `${aliceId}:0`, }, @@ -956,7 +1059,7 @@ describe.each([ grid: [`${localId}:0`], }, b: { - type: "one-on-one", + type: "one-on-one-landscape", pip: `${localId}:0`, spotlight: `${aliceId}:0`, }, @@ -999,7 +1102,7 @@ describe.each([ grid: [`${localId}:0`], }, b: { - type: "one-on-one", + type: "one-on-one-landscape", pip: `${localId}:0`, spotlight: `${aliceId}:0`, }, @@ -1009,7 +1112,7 @@ describe.each([ grid: [`${localId}:0`, `${aliceId}:0`, `${daveId}:0`], }, d: { - type: "one-on-one", + type: "one-on-one-landscape", pip: `${localId}:0`, spotlight: `${daveId}:0`, }, @@ -1227,7 +1330,7 @@ describe.each([ // ringing the entire time (even once timed out) expectObservable(summarizeLayout$(vm.layout$)).toBe("a", { a: { - type: "one-on-one", + type: "one-on-one-landscape", spotlight: `${localId}:0`, pip: `ringing:${aliceUserId}`, }, @@ -1266,12 +1369,12 @@ describe.each([ // ringing the entire time expectObservable(summarizeLayout$(vm.layout$)).toBe("a 20ms b", { a: { - type: "one-on-one", + type: "one-on-one-landscape", spotlight: `${localId}:0`, pip: `ringing:${aliceUserId}`, }, b: { - type: "one-on-one", + type: "one-on-one-landscape", spotlight: `${aliceId}:0`, pip: `${localId}:0`, }, diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index e298bcfd..a262b6e7 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -38,6 +38,7 @@ import { tap, throttleTime, timer, + BehaviorSubject, } from "rxjs"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { @@ -68,7 +69,8 @@ import { setPipEnabled$ } from "../../controls"; import { TileStore } from "../TileStore"; import { gridLikeLayout } from "../GridLikeLayout"; import { spotlightExpandedLayout } from "../SpotlightExpandedLayout"; -import { oneOnOneLayout } from "../OneOnOneLayout"; +import { oneOnOneLandscapeLayout } from "../OneOnOneLandscapeLayout"; +import { oneOnOnePortraitLayout } from "../OneOnOnePortraitLayout"; import { pipLayout } from "../PipLayout"; import { type EncryptionSystem } from "../../e2ee/sharedKeyManagement"; import { @@ -86,10 +88,12 @@ import { getUrlParams, HeaderStyle } from "../../UrlParams"; import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { ElementWidgetActions, widget } from "../../widget"; import { + type Alignment, type GridLayoutMedia, type Layout, type LayoutMedia, - type OneOnOneLayoutMedia, + type OneOnOneLandscapeLayoutMedia, + type OneOnOnePortraitLayoutMedia, type SpotlightExpandedLayoutMedia, type SpotlightLandscapeLayoutMedia, type SpotlightPortraitLayoutMedia, @@ -327,16 +331,6 @@ export interface CallViewModel { { sender: string; emoji: string; startX: number }[] >; - // window/layout - /** - * The general shape of the window. - */ - windowMode$: Behavior; - spotlightExpanded$: Behavior; - toggleSpotlightExpanded$: Behavior<(() => void) | null>; - gridMode$: Behavior; - setGridMode: (value: GridMode) => void; - /** * The layout of tiles in the call interface. */ @@ -347,10 +341,20 @@ export interface CallViewModel { tileStoreGeneration$: Behavior; showSpotlightIndicators$: Behavior; showSpeakingIndicators$: Behavior; + showNameTags$: Behavior; + spotlightExpanded$: Behavior; + toggleSpotlightExpanded$: Behavior<(() => void) | null>; + gridMode$: Behavior; + setGridMode: (value: GridMode) => void; // header/footer visibility showHeader$: Behavior; showFooter$: Behavior; + /** + * Whether the call layout should be displayed edge-to-edge, with the footer + * and header as overlays. + */ + edgeToEdge$: Behavior; // audio routing /** @@ -776,6 +780,7 @@ export function createCallViewModel$( callPickupState === "timeout" || callPickupState === "decline" ) { + // TODO: Respect io.element.functional_members for (const member of roomMembers.values()) { if (!userMedia.some((vm) => vm.userId === member.userId)) yield { @@ -1056,6 +1061,7 @@ export function createCallViewModel$( [grid$, spotlight$], (grid, spotlight) => ({ type: "grid", + edgeToEdge: false, spotlight: spotlight.some((vm) => vm.type === "screen share") ? spotlight : undefined, @@ -1066,6 +1072,7 @@ export function createCallViewModel$( const spotlightLandscapeLayoutMedia$: Observable = combineLatest([grid$, spotlight$], (grid, spotlight) => ({ type: "spotlight-landscape", + edgeToEdge: false, spotlight, grid, })); @@ -1073,16 +1080,20 @@ export function createCallViewModel$( const spotlightPortraitLayoutMedia$: Observable = combineLatest([grid$, spotlight$], (grid, spotlight) => ({ type: "spotlight-portrait", + edgeToEdge: false, spotlight, grid, })); - const spotlightExpandedLayoutMedia$: Observable = + const spotlightExpandedLayoutMedia$ = ( + edgeToEdge: boolean, + ): Observable => spotlightAndPip$.pipe( switchMap(({ spotlight, pip$ }) => pip$.pipe( map((pip) => ({ type: "spotlight-expanded" as const, + edgeToEdge, spotlight, pip: pip ?? undefined, })), @@ -1090,55 +1101,88 @@ export function createCallViewModel$( ), ); - const oneOnOneLayoutMedia$: Observable = - combineLatest([userMedia$, screenShares$]).pipe( - switchMap(([userMedia, screenShares]) => { - // One-on-one layout only supports 2 user media, no screen shares - if (userMedia.length <= 2 && screenShares.length === 0) { - const local = userMedia.find( - (vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel => - vm.type === "user" && vm.local, + const oneOnOneLayoutMedia$: Observable<{ + local: LocalUserMediaViewModel; + remote: UserMediaViewModel | RingingMediaViewModel; + } | null> = combineLatest([userMedia$, screenShares$]).pipe( + switchMap(([userMedia, screenShares]) => { + // One-on-one layout only supports 2 user media, no screen shares + if (userMedia.length <= 2 && screenShares.length === 0) { + const local = userMedia.find( + (vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel => + vm.type === "user" && vm.local, + ); + + if (local !== undefined) { + const remote = userMedia.find( + (vm): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel => + vm.type === "user" && !vm.local, ); - if (local !== undefined) { - const remote = userMedia.find( - ( - vm, - ): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel => - vm.type === "user" && !vm.local, + if (remote !== undefined) return of({ local, remote }); + + // If there's no other user media in the call (could still happen in + // this branch due to the duplicate tiles option), we could possibly + // show ringing media instead + if (userMedia.length === 1) + return ringingMedia$.pipe( + map((ringingMedia) => { + return ringingMedia.length === 1 + ? { + local, + remote: ringingMedia[0], + } + : null; + }), ); - - if (remote !== undefined) - return of({ - type: "one-on-one" as const, - spotlight: remote, - pip: local, - }); - - // If there's no other user media in the call (could still happen in - // this branch due to the duplicate tiles option), we could possibly - // show ringing media instead - if (userMedia.length === 1) - return ringingMedia$.pipe( - map((ringingMedia) => { - return ringingMedia.length === 1 - ? { - type: "one-on-one" as const, - spotlight: local, - pip: ringingMedia[0], - } - : null; - }), - ); - } } + } - return of(null); + return of(null); + }), + ); + + const oneOnOneLandscapeLayoutMedia$: Observable = + oneOnOneLayoutMedia$.pipe( + map((media) => { + if (media === null) return null; + return media.remote.type === "ringing" + ? { + type: "one-on-one-landscape" as const, + edgeToEdge: false, + spotlight: media.local, + pip: media.remote, + } + : { + type: "one-on-one-landscape" as const, + edgeToEdge: false, + spotlight: media.remote, + pip: media.local, + }; + }), + ); + + const oneOnOnePortraitLayoutMedia$: Observable = + oneOnOneLayoutMedia$.pipe( + switchMap((media) => { + if (media === null) return of(null); + return media.local.videoEnabled$.pipe( + map((videoEnabled) => ({ + type: "one-on-one-portrait" as const, + edgeToEdge: true as const, + spotlight: media.remote, + pip: videoEnabled ? media.local : undefined, + })), + ); }), ); const pipLayoutMedia$: Observable = spotlight$.pipe( - map((spotlight) => ({ type: "pip", spotlight })), + map((spotlight) => ({ + type: "pip", + edgeToEdge: platform !== "desktop", + spotlight, + })), ); /** @@ -1153,7 +1197,7 @@ export function createCallViewModel$( switchMap((gridMode) => { switch (gridMode) { case "grid": - return oneOnOneLayoutMedia$.pipe( + return oneOnOneLandscapeLayoutMedia$.pipe( switchMap((oneOnOne) => oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne), ), @@ -1162,7 +1206,7 @@ export function createCallViewModel$( return spotlightExpanded$.pipe( switchMap((expanded) => expanded - ? spotlightExpandedLayoutMedia$ + ? spotlightExpandedLayoutMedia$(false) : spotlightLandscapeLayoutMedia$, ), ); @@ -1170,7 +1214,7 @@ export function createCallViewModel$( }), ); case "narrow": - return oneOnOneLayoutMedia$.pipe( + return oneOnOnePortraitLayoutMedia$.pipe( switchMap((oneOnOne) => oneOnOne === null ? combineLatest([grid$, spotlight$], (grid, spotlight) => @@ -1179,9 +1223,7 @@ export function createCallViewModel$( ? spotlightPortraitLayoutMedia$ : gridLayoutMedia$, ).pipe(switchAll()) - : // The expanded spotlight layout makes for a better one-on-one - // experience in narrow windows - spotlightExpandedLayoutMedia$, + : of(oneOnOne), ), ); case "flat": @@ -1193,7 +1235,7 @@ export function createCallViewModel$( // this window mode. return spotlightLandscapeLayoutMedia$; case "spotlight": - return spotlightExpandedLayoutMedia$; + return spotlightExpandedLayoutMedia$(true); } }), ); @@ -1204,6 +1246,193 @@ export function createCallViewModel$( ), ); + const showSpotlightIndicators$ = scope.behavior( + layoutMedia$.pipe(map((l) => l.type !== "grid")), + ); + + const showSpeakingIndicators$ = scope.behavior( + layoutMedia$.pipe( + map((l) => { + switch (l.type) { + case "spotlight-landscape": + case "spotlight-portrait": + // If the spotlight is showing the active speaker, we can do without + // speaking indicators as they're a redundant visual cue. But if + // screen sharing feeds are in the spotlight we still need them. + return l.spotlight.some((m) => m.type === "screen share"); + // In expanded spotlight layout, the active speaker is always shown in + // the picture-in-picture tile so there is no need for speaking + // indicators. And in one-on-one layout there's no question as to who is + // speaking. + case "spotlight-expanded": + case "one-on-one-landscape": + case "one-on-one-portrait": + return false; + default: + return true; + } + }), + ), + ); + + const showNameTags$ = scope.behavior( + layoutMedia$.pipe( + switchMap((l) => + l.type === "pip" || l.type === "one-on-one-portrait" + ? matrixRoomMembers$.pipe( + map( + (members) => + // Hide name tags by default in these layouts. For safety we + // still need to show them in case it wouldn't be clear who + // the spotlight media belongs to. + // TODO: Respect io.element.functional_members (while still + // being careful to never show a functional member's media + // without a name tag!) + // TODO: Only hide name tags in DMs, not group chats that just + // happen to have only 2 users + members.size > 2, + ), + ) + : of(true), + ), + ), + ); + + const toggleSpotlightExpanded$ = scope.behavior<(() => void) | null>( + windowMode$.pipe( + switchMap((mode) => + mode === "normal" + ? layoutMedia$.pipe( + map( + (l) => + l.type === "spotlight-landscape" || + l.type === "spotlight-expanded", + ), + ) + : of(false), + ), + distinctUntilChanged(), + map((enabled) => + enabled ? (): void => spotlightExpandedToggle$.next() : null, + ), + ), + ); + + const edgeToEdge$ = scope.behavior( + layoutMedia$.pipe(map(({ edgeToEdge }) => edgeToEdge)), + ); + + const screenTap$ = new Subject(); + const controlsTap$ = new Subject(); + const screenHover$ = new Subject(); + const screenUnhover$ = new Subject(); + + const naturallyShowFooter$ = scope.behavior( + edgeToEdge$.pipe( + switchMap((edgeToEdge) => { + if (!edgeToEdge) return of(true); + + // Sadly Firefox has some layering glitches that prevent the footer + // from appearing properly. They happen less often if we never hide + // the footer. + if (isFirefox()) return of(true); + + // Layout is edge-to-edge; show/hide the footer in response to interactions + return windowMode$.pipe( + switchMap((mode) => { + const showInitially = mode !== "flat"; + const timeout$ = mode === "flat" ? timer(showFooterMs) : NEVER; + + return merge( + screenTap$.pipe(map(() => "tap screen" as const)), + controlsTap$.pipe(map(() => "tap controls" as const)), + screenHover$.pipe(map(() => "hover" as const)), + ).pipe( + switchScan((state, interaction) => { + switch (interaction) { + case "tap screen": + return state + ? // Toggle visibility on tap + of(false) + : // Hide after a timeout + timeout$.pipe( + map(() => false), + startWith(true), + ); + case "tap controls": + // The user is interacting with things, so reset the timeout + return timeout$.pipe( + map(() => false), + startWith(true), + ); + case "hover": + // Show on hover and hide after a timeout + return race(timeout$, screenUnhover$.pipe(take(1))).pipe( + map(() => false), + startWith(true), + ); + } + }, showInitially), + startWith(showInitially), + ); + }), + ); + }), + ), + ); + + const urlParams = getUrlParams(); + const showFooterUrlParams = !( + urlParams.header === HeaderStyle.None && urlParams.showControls === false + ); + const showFooter$ = scope.behavior( + naturallyShowFooter$.pipe( + map((naturallyShowFooter) => naturallyShowFooter && showFooterUrlParams), + ), + ); + + const showHeader$ = scope.behavior( + windowMode$.pipe( + switchMap((mode) => { + // In small windows the header would be too obstructive + if (mode === "pip" || mode === "flat") return of(false); + // In edge-to-edge layouts, couple the visibility of the header + // to that of the footer + return edgeToEdge$.pipe( + switchMap((edgeToEdge) => (edgeToEdge ? showFooter$ : of(true))), + ); + }), + ), + ); + + /** + * The alignment of the floating spotlight tile, if present. + */ + const spotlightAlignment$ = new BehaviorSubject({ + inline: "end", + block: "end", + }); + /** + * The size of the small picture-in-picture tile, if present, when in portrait. + */ + const portraitPipSize$ = scope.behavior( + showFooter$.pipe(map((showFooter) => (showFooter ? "lg" : "sm"))), + ); + /** + * The alignment of the small picture-in-picture tile, if present, when in portrait. + */ + const portraitPipAlignment$ = new BehaviorSubject({ + inline: "end", + block: "end", + }); + /** + * The alignment of the small picture-in-picture tile, if present, when in landscape. + */ + const landscapePipAlignment$ = new BehaviorSubject({ + inline: "end", + block: "start", + }); + // There is a cyclical dependency here: the layout algorithms want to know // which tiles are on screen, but to know which tiles are on screen we have to // first render a layout. To deal with this we assume initially that no tiles @@ -1230,16 +1459,33 @@ export function createCallViewModel$( case "spotlight-portrait": [layout, newTiles] = gridLikeLayout( media, + spotlightAlignment$, visibleTiles, setVisibleTiles, prevTiles, ); break; case "spotlight-expanded": - [layout, newTiles] = spotlightExpandedLayout(media, prevTiles); + [layout, newTiles] = spotlightExpandedLayout( + media, + landscapePipAlignment$, + prevTiles, + ); break; - case "one-on-one": - [layout, newTiles] = oneOnOneLayout(media, prevTiles); + case "one-on-one-landscape": + [layout, newTiles] = oneOnOneLandscapeLayout( + media, + landscapePipAlignment$, + prevTiles, + ); + break; + case "one-on-one-portrait": + [layout, newTiles] = oneOnOnePortraitLayout( + media, + portraitPipSize$, + portraitPipAlignment$, + prevTiles, + ); break; case "pip": [layout, newTiles] = pipLayout(media, prevTiles); @@ -1267,130 +1513,6 @@ export function createCallViewModel$( layoutInternals$.pipe(map(({ tiles }) => tiles.generation)), ); - const showSpotlightIndicators$ = scope.behavior( - layout$.pipe(map((l) => l.type !== "grid")), - ); - - const showSpeakingIndicators$ = scope.behavior( - layout$.pipe( - switchMap((l) => { - switch (l.type) { - case "spotlight-landscape": - case "spotlight-portrait": - // If the spotlight is showing the active speaker, we can do without - // speaking indicators as they're a redundant visual cue. But if - // screen sharing feeds are in the spotlight we still need them. - return l.spotlight.media$.pipe( - map((models: MediaViewModel[]) => - models.some((m) => m.type === "screen share"), - ), - ); - // In expanded spotlight layout, the active speaker is always shown in - // the picture-in-picture tile so there is no need for speaking - // indicators. And in one-on-one layout there's no question as to who is - // speaking. - case "spotlight-expanded": - case "one-on-one": - return of(false); - default: - return of(true); - } - }), - ), - ); - - const toggleSpotlightExpanded$ = scope.behavior<(() => void) | null>( - windowMode$.pipe( - switchMap((mode) => - mode === "normal" - ? layout$.pipe( - map( - (l) => - l.type === "spotlight-landscape" || - l.type === "spotlight-expanded", - ), - ) - : of(false), - ), - distinctUntilChanged(), - map((enabled) => - enabled ? (): void => spotlightExpandedToggle$.next() : null, - ), - ), - ); - - const screenTap$ = new Subject(); - const controlsTap$ = new Subject(); - const screenHover$ = new Subject(); - const screenUnhover$ = new Subject(); - - const showHeader$ = scope.behavior( - windowMode$.pipe(map((mode) => mode !== "pip" && mode !== "flat")), - ); - - const urlParams = getUrlParams(); - const showFooterUrlParams = !( - urlParams.header === HeaderStyle.None && urlParams.showControls === false - ); - const showFooterLayout$ = scope.behavior( - windowMode$.pipe( - switchMap((mode) => { - switch (mode) { - case "pip": - return of(platform === "desktop" ? true : false); - case "normal": - case "narrow": - return of(true); - case "flat": - // Sadly Firefox has some layering glitches that prevent the footer - // from appearing properly. They happen less often if we never hide - // the footer. - if (isFirefox()) return of(true); - // Show/hide the footer in response to interactions - return merge( - screenTap$.pipe(map(() => "tap screen" as const)), - controlsTap$.pipe(map(() => "tap controls" as const)), - screenHover$.pipe(map(() => "hover" as const)), - ).pipe( - switchScan((state, interaction) => { - switch (interaction) { - case "tap screen": - return state - ? // Toggle visibility on tap - of(false) - : // Hide after a timeout - timer(showFooterMs).pipe( - map(() => false), - startWith(true), - ); - case "tap controls": - // The user is interacting with things, so reset the timeout - return timer(showFooterMs).pipe( - map(() => false), - startWith(true), - ); - case "hover": - // Show on hover and hide after a timeout - return race( - timer(showFooterMs), - screenUnhover$.pipe(take(1)), - ).pipe( - map(() => false), - startWith(true), - ); - } - }, false), - startWith(false), - ); - } - }), - ), - ); - const showFooter$ = scope.behavior( - showFooterLayout$.pipe( - map((showFooter) => showFooter && showFooterUrlParams), - ), - ); /** * Whether audio is currently being output through the earpiece. */ @@ -1594,7 +1716,6 @@ export function createCallViewModel$( audibleReactions$: audibleReactions$, visibleReactions$: visibleReactions$, - windowMode$: windowMode$, spotlightExpanded$: spotlightExpanded$, toggleSpotlightExpanded$: toggleSpotlightExpanded$, gridMode$: gridMode$, @@ -1620,8 +1741,10 @@ export function createCallViewModel$( tileStoreGeneration$: tileStoreGeneration$, showSpotlightIndicators$: showSpotlightIndicators$, showSpeakingIndicators$: showSpeakingIndicators$, + showNameTags$, showHeader$: showHeader$, showFooter$: showFooter$, + edgeToEdge$, earpieceMode$: earpieceMode$, audioOutputSwitcher$: audioOutputSwitcher$, reconnecting$: localMembership.reconnecting$, diff --git a/src/state/CallViewModel/CallViewModelTestUtils.ts b/src/state/CallViewModel/CallViewModelTestUtils.ts index 9685c709..3155eb11 100644 --- a/src/state/CallViewModel/CallViewModelTestUtils.ts +++ b/src/state/CallViewModel/CallViewModelTestUtils.ts @@ -8,11 +8,11 @@ Please see LICENSE in the repository root for full details. import { ConnectionState, - type LocalParticipant, type Participant, ParticipantEvent, type RemoteParticipant, type Room as LivekitRoom, + type TrackPublication, } from "livekit-client"; import { SyncState } from "matrix-js-sdk/lib/sync"; import { BehaviorSubject, combineLatest, map, of } from "rxjs"; @@ -72,6 +72,7 @@ export interface CallViewModelInputs { roomMembers: RoomMember[]; livekitConnectionState$: Behavior; speaking: Map>; + videoEnabled: Map>; sharingScreen: Map>; mediaDevices: MediaDevices; initialSyncState: SyncState; @@ -98,6 +99,7 @@ export function withCallViewModel(mode: MatrixRTCMode) { ConnectionState.Connected, ), speaking = new Map(), + videoEnabled = new Map(), sharingScreen = new Map(), mediaDevices = mockMediaDevices({}), initialSyncState = SyncState.Syncing, @@ -151,11 +153,19 @@ export function withCallViewModel(mode: MatrixRTCMode) { .mockReturnValue(remoteParticipants$); const mediaSpy = vi .spyOn(ComponentsCore, "observeParticipantMedia") - .mockImplementation((p) => - of({ participant: p } as Partial< - ComponentsCore.ParticipantMedia - > as ComponentsCore.ParticipantMedia), - ); + .mockImplementation((p) => { + return (videoEnabled.get(p) ?? constant(false)).pipe( + map((videoEnabled) => ({ + participant: p, + isMicrophoneEnabled: false, + isCameraEnabled: videoEnabled, + isScreenShareEnabled: false, + cameraTrack: { + isMuted: !videoEnabled, + } as unknown as TrackPublication, + })), + ); + }); const eventsSpy = vi .spyOn(ComponentsCore, "observeParticipantEvents") .mockImplementation((p, ...eventTypes) => { diff --git a/src/state/GridLikeLayout.ts b/src/state/GridLikeLayout.ts index 0d130834..f91f8e31 100644 --- a/src/state/GridLikeLayout.ts +++ b/src/state/GridLikeLayout.ts @@ -5,7 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type Layout, type LayoutMedia } from "./layout-types.ts"; +import { type BehaviorSubject } from "rxjs"; + +import { + type Alignment, + type Layout, + type LayoutMedia, +} from "./layout-types.ts"; import { type TileStore } from "./TileStore"; export type GridLikeLayoutType = @@ -19,6 +25,7 @@ export type GridLikeLayoutType = */ export function gridLikeLayout( media: LayoutMedia & { type: GridLikeLayoutType }, + spotlightAlignment$: BehaviorSubject, visibleTiles: number, setVisibleTiles: (value: number) => void, prevTiles: TileStore, @@ -37,6 +44,7 @@ export function gridLikeLayout( type: media.type, spotlight: tiles.spotlightTile, grid: tiles.gridTiles, + spotlightAlignment$, setVisibleTiles, } as Layout & { type: GridLikeLayoutType }, tiles, diff --git a/src/state/OneOnOneLayout.ts b/src/state/OneOnOneLandscapeLayout.ts similarity index 55% rename from src/state/OneOnOneLayout.ts rename to src/state/OneOnOneLandscapeLayout.ts index 27fa4439..4198ff03 100644 --- a/src/state/OneOnOneLayout.ts +++ b/src/state/OneOnOneLandscapeLayout.ts @@ -1,29 +1,39 @@ /* Copyright 2024 New Vector Ltd. +Copyright 2026 Element Creations Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type OneOnOneLayout, type OneOnOneLayoutMedia } from "./layout-types"; +import { type BehaviorSubject } from "rxjs"; + +import { + type Alignment, + type OneOnOneLandscapeLayout, + type OneOnOneLandscapeLayoutMedia, +} from "./layout-types"; import { type TileStore } from "./TileStore"; /** - * Produces a one-on-one layout with the given media. + * Produces a one-on-one landscape layout with the given media. */ -export function oneOnOneLayout( - media: OneOnOneLayoutMedia, +export function oneOnOneLandscapeLayout( + media: OneOnOneLandscapeLayoutMedia, + pipAlignment$: BehaviorSubject, prevTiles: TileStore, -): [OneOnOneLayout, TileStore] { +): [OneOnOneLandscapeLayout, TileStore] { const update = prevTiles.from(2); update.registerGridTile(media.pip); update.registerGridTile(media.spotlight); const tiles = update.build(); + return [ { type: media.type, spotlight: tiles.gridTilesByMedia.get(media.spotlight)!, pip: tiles.gridTilesByMedia.get(media.pip)!, + pipAlignment$, }, tiles, ]; diff --git a/src/state/OneOnOnePortraitLayout.ts b/src/state/OneOnOnePortraitLayout.ts new file mode 100644 index 00000000..9be80421 --- /dev/null +++ b/src/state/OneOnOnePortraitLayout.ts @@ -0,0 +1,43 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { type BehaviorSubject } from "rxjs"; + +import { + type Alignment, + type OneOnOnePortraitLayout, + type OneOnOnePortraitLayoutMedia, +} from "./layout-types"; +import { type TileStore } from "./TileStore"; +import { type Behavior } from "./Behavior"; + +/** + * Produces a one-on-one portrait layout with the given media. + */ +export function oneOnOnePortraitLayout( + media: OneOnOnePortraitLayoutMedia, + pipSize$: Behavior<"sm" | "lg">, + pipAlignment$: BehaviorSubject, + prevTiles: TileStore, +): [OneOnOnePortraitLayout, TileStore] { + const update = prevTiles.from(media.pip === undefined ? 0 : 1); + update.registerSpotlight([media.spotlight], true); + if (media.pip !== undefined) update.registerGridTile(media.pip); + const tiles = update.build(); + + return [ + { + type: media.type, + spotlight: tiles.spotlightTile!, + pip: media.pip && tiles.gridTilesByMedia.get(media.pip), + pipSize$, + pipAlignment$, + }, + tiles, + ]; +} diff --git a/src/state/SpotlightExpandedLayout.ts b/src/state/SpotlightExpandedLayout.ts index 9dc2c815..59ab8ab9 100644 --- a/src/state/SpotlightExpandedLayout.ts +++ b/src/state/SpotlightExpandedLayout.ts @@ -5,7 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ +import { type BehaviorSubject } from "rxjs"; + import { + type Alignment, type SpotlightExpandedLayout, type SpotlightExpandedLayoutMedia, } from "./layout-types"; @@ -16,6 +19,7 @@ import { type TileStore } from "./TileStore"; */ export function spotlightExpandedLayout( media: SpotlightExpandedLayoutMedia, + pipAlignment$: BehaviorSubject, prevTiles: TileStore, ): [SpotlightExpandedLayout, TileStore] { const update = prevTiles.from(1); @@ -27,7 +31,8 @@ export function spotlightExpandedLayout( { type: media.type, spotlight: tiles.spotlightTile!, - pip: tiles.gridTiles[0], + pip: tiles.gridTiles.at(0), + pipAlignment$, }, tiles, ]; diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index 2e779057..83a80e9a 100644 --- a/src/state/layout-types.ts +++ b/src/state/layout-types.ts @@ -5,6 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ +import { type BehaviorSubject } from "rxjs"; + import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts"; import { type MediaViewModel } from "./media/MediaViewModel.ts"; import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts"; @@ -13,39 +15,53 @@ import { type GridTileViewModel, type SpotlightTileViewModel, } from "./TileViewModel.ts"; +import { type Behavior } from "./Behavior.ts"; export interface GridLayoutMedia { type: "grid"; + edgeToEdge: false; spotlight?: MediaViewModel[]; grid: UserMediaViewModel[]; } export interface SpotlightLandscapeLayoutMedia { type: "spotlight-landscape"; + edgeToEdge: false; spotlight: MediaViewModel[]; grid: UserMediaViewModel[]; } export interface SpotlightPortraitLayoutMedia { type: "spotlight-portrait"; + edgeToEdge: false; spotlight: MediaViewModel[]; grid: UserMediaViewModel[]; } export interface SpotlightExpandedLayoutMedia { type: "spotlight-expanded"; + edgeToEdge: boolean; spotlight: MediaViewModel[]; pip?: UserMediaViewModel; } -export interface OneOnOneLayoutMedia { - type: "one-on-one"; +export interface OneOnOneLandscapeLayoutMedia { + type: "one-on-one-landscape"; + edgeToEdge: false; spotlight: UserMediaViewModel; pip: LocalUserMediaViewModel | RingingMediaViewModel; } +export interface OneOnOnePortraitLayoutMedia { + type: "one-on-one-portrait"; + edgeToEdge: true; + spotlight: UserMediaViewModel | RingingMediaViewModel; + pip?: LocalUserMediaViewModel; +} + export interface PipLayoutMedia { type: "pip"; + edgeToEdge: boolean; spotlight: MediaViewModel[]; } @@ -54,13 +70,20 @@ export type LayoutMedia = | SpotlightLandscapeLayoutMedia | SpotlightPortraitLayoutMedia | SpotlightExpandedLayoutMedia - | OneOnOneLayoutMedia + | OneOnOneLandscapeLayoutMedia + | OneOnOnePortraitLayoutMedia | PipLayoutMedia; +export interface Alignment { + inline: "start" | "end"; + block: "start" | "end"; +} + export interface GridLayout { type: "grid"; spotlight?: SpotlightTileViewModel; grid: GridTileViewModel[]; + spotlightAlignment$: BehaviorSubject; setVisibleTiles: (value: number) => void; } @@ -82,12 +105,22 @@ export interface SpotlightExpandedLayout { type: "spotlight-expanded"; spotlight: SpotlightTileViewModel; pip?: GridTileViewModel; + pipAlignment$: BehaviorSubject; } -export interface OneOnOneLayout { - type: "one-on-one"; +export interface OneOnOneLandscapeLayout { + type: "one-on-one-landscape"; spotlight: GridTileViewModel; pip: GridTileViewModel; + pipAlignment$: BehaviorSubject; +} + +export interface OneOnOnePortraitLayout { + type: "one-on-one-portrait"; + spotlight: SpotlightTileViewModel; + pip?: GridTileViewModel; + pipSize$: Behavior<"sm" | "lg">; + pipAlignment$: BehaviorSubject; } export interface PipLayout { @@ -104,5 +137,6 @@ export type Layout = | SpotlightLandscapeLayout | SpotlightPortraitLayout | SpotlightExpandedLayout - | OneOnOneLayout + | OneOnOneLandscapeLayout + | OneOnOnePortraitLayout | PipLayout; diff --git a/src/tile/GridTile.module.css b/src/tile/GridTile.module.css index ee605e46..7ffe67d4 100644 --- a/src/tile/GridTile.module.css +++ b/src/tile/GridTile.module.css @@ -72,6 +72,10 @@ borders don't support gradients */ } } +.tile.edgeToEdge { + --media-view-border-radius: 0; +} + .muteIcon[data-muted="true"] { color: var(--cpd-color-icon-secondary); } diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index 501f440c..2a169cb0 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -77,6 +77,7 @@ test("GridTile is accessible", async () => { targetWidth={300} targetHeight={200} showSpeakingIndicators + showNameTags focusable /> , @@ -109,6 +110,7 @@ test("GridTile displays ringing media", async () => { targetWidth={300} targetHeight={200} showSpeakingIndicators + showNameTags focusable /> , diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 13cf677f..88754b9d 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -62,6 +62,7 @@ interface TileProps { targetHeight: number; displayName: string; mxcAvatarUrl: string | undefined; + showNameTags: boolean; focusable: boolean; } @@ -398,6 +399,7 @@ interface GridTileProps { className?: string; style?: ComponentProps["style"]; showSpeakingIndicators: boolean; + showNameTags: boolean; focusable: boolean; } @@ -419,9 +421,9 @@ export const GridTile: FC = ({ ); } else if (media.local) { diff --git a/src/tile/MediaView.test.tsx b/src/tile/MediaView.test.tsx index 6ef5eb7e..c7881976 100644 --- a/src/tile/MediaView.test.tsx +++ b/src/tile/MediaView.test.tsx @@ -42,6 +42,7 @@ describe("MediaView", () => { targetHeight: 200, mirror: false, unencryptedWarning: false, + showNameTags: true, video: trackReference, userId: "@alice:example.com", mxcAvatarUrl: undefined, @@ -107,6 +108,16 @@ describe("MediaView", () => { expect(screen.getByRole("img", { name: "Not encrypted" })).toBeTruthy(); }); + test("is shown and accessible even with name tag hidden", async () => { + const { container } = render( + + + , + ); + expect(await axe(container)).toHaveNoViolations(); + screen.getByRole("img", { name: "Not encrypted" }); + }); + test("is not shown", () => { render( diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index eb6cc6b4..6ff97f7a 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -44,6 +44,7 @@ interface Props extends ComponentProps { videoEnabled: boolean; unencryptedWarning: boolean; status?: { text: string; Icon: ComponentType> }; + showNameTags: boolean; nameTagLeadingIcon?: ReactNode; displayName: string; mxcAvatarUrl: string | undefined; @@ -72,6 +73,7 @@ export const MediaView: FC = ({ userId, videoEnabled, unencryptedWarning, + showNameTags, nameTagLeadingIcon, displayName, mxcAvatarUrl, @@ -94,6 +96,23 @@ export const MediaView: FC = ({ const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2); + const warnings = unencryptedWarning && ( + + + + ); + return ( = ({
)*/} -
- {nameTagLeadingIcon} - - {displayName} - - {unencryptedWarning && ( - = 100 ? ( +
+ {nameTagLeadingIcon} + - - - )} -
+ {displayName} + + {warnings} +
+ ) : ( + warnings + )} {primaryButton}
diff --git a/src/tile/SpotlightTile.test.tsx b/src/tile/SpotlightTile.test.tsx index 533c3b2f..ea987007 100644 --- a/src/tile/SpotlightTile.test.tsx +++ b/src/tile/SpotlightTile.test.tsx @@ -65,6 +65,7 @@ test("SpotlightTile is accessible", async () => { expanded={false} onToggleExpanded={toggleExpanded} showIndicators + showNameTags focusable={true} />, ); @@ -106,6 +107,7 @@ test("Screen share volume UI is shown when screen share has audio", async () => expanded={false} onToggleExpanded={toggleExpanded} showIndicators + showNameTags focusable /> , @@ -135,6 +137,7 @@ test("Screen share volume UI is hidden when screen share has no audio", async () expanded={false} onToggleExpanded={toggleExpanded} showIndicators + showNameTags focusable />, ); @@ -171,6 +174,7 @@ test("SpotlightTile displays ringing media", async () => { expanded={false} onToggleExpanded={toggleExpanded} showIndicators + showNameTags focusable={true} />, ); diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index 808773b0..09587497 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -66,6 +66,7 @@ interface SpotlightItemBaseProps { userId: string; displayName: string; mxcAvatarUrl: string | undefined; + showNameTags: boolean; focusable: boolean; "aria-hidden"?: boolean; } @@ -244,6 +245,7 @@ interface SpotlightItemProps { * The height this tile will have once its animations have settled. */ targetHeight: number; + showNameTags: boolean; focusable: boolean; intersectionObserver$: Observable; /** @@ -258,6 +260,7 @@ const SpotlightItem: FC = ({ vm, targetWidth, targetHeight, + showNameTags, focusable, intersectionObserver$, snap, @@ -293,6 +296,7 @@ const SpotlightItem: FC = ({ userId: vm.userId, displayName, mxcAvatarUrl, + showNameTags, focusable, "aria-hidden": ariaHidden, }; @@ -381,6 +385,7 @@ interface Props { targetWidth: number; targetHeight: number; showIndicators: boolean; + showNameTags: boolean; focusable: boolean; className?: string; style?: ComponentProps["style"]; @@ -394,6 +399,7 @@ export const SpotlightTile: FC = ({ targetWidth, targetHeight, showIndicators, + showNameTags, focusable = true, className, style, @@ -504,6 +510,7 @@ export const SpotlightTile: FC = ({ vm={vm} targetWidth={targetWidth} targetHeight={targetHeight} + showNameTags={showNameTags} focusable={focusable} intersectionObserver$={intersectionObserver$} // This is how we get the container to scroll to the right media