From 4a853618f4a83b659015efee167bb0cb5fe7eed3 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 27 Apr 2026 19:25:22 +0200 Subject: [PATCH] WIP --- src/grid/CallLayout.ts | 32 +- src/grid/GridLayout.tsx | 7 +- src/grid/OneOnOneLandscapeLayout.module.css | 6 - src/grid/OneOnOneLandscapeLayout.tsx | 12 +- src/grid/OneOnOnePortraitLayout.module.css | 43 +-- src/grid/OneOnOnePortraitLayout.tsx | 60 ++-- src/grid/SpotlightExpandedLayout.tsx | 12 +- src/room/InCallView.module.css | 27 ++ src/room/InCallView.tsx | 159 ++++----- src/state/CallViewModel/CallViewModel.ts | 353 ++++++++++++-------- src/state/GridLikeLayout.ts | 11 +- src/state/OneOnOneLandscapeLayout.ts | 7 + src/state/OneOnOnePortraitLayout.ts | 16 +- src/state/PipLayout.ts | 1 + src/state/SpotlightExpandedLayout.ts | 8 +- src/state/TileStore.ts | 46 ++- src/state/TileViewModel.ts | 1 + src/state/layout-types.ts | 27 ++ src/tile/GridTile.module.css | 4 + src/tile/GridTile.tsx | 14 +- src/tile/MediaView.tsx | 66 ++-- 21 files changed, 534 insertions(+), 378 deletions(-) diff --git a/src/grid/CallLayout.ts b/src/grid/CallLayout.ts index 54f53ee9..6f9f3ce9 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,41 +16,11 @@ export interface Bounds { height: number; } -export interface Alignment { - inline: "start" | "end"; - block: "start" | "end"; -} - -export const defaultSpotlightAlignment: Alignment = { - inline: "end", - block: "end", -}; -export const defaultPortraitPipAlignment: Alignment = { - inline: "end", - block: "end", -}; -export const defaultLandscapePipAlignment: 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, when in portrait. - */ - portraitPipAlignment$: BehaviorSubject; - /** - * The alignment of the small picture-in-picture tile, if present, when in landscape. - */ - landscapePipAlignment$: BehaviorSubject; } export interface CallLayoutOutputs { diff --git a/src/grid/GridLayout.tsx b/src/grid/GridLayout.tsx index cf46e8b4..7863b266 100644 --- a/src/grid/GridLayout.tsx +++ b/src/grid/GridLayout.tsx @@ -32,7 +32,6 @@ interface GridCSSProperties extends CSSProperties { */ export const makeGridLayout: CallLayout = ({ minBounds$, - spotlightAlignment$, }) => ({ scrollingOnTop: false, @@ -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/OneOnOneLandscapeLayout.module.css b/src/grid/OneOnOneLandscapeLayout.module.css index 0ac1b78d..15192fb2 100644 --- a/src/grid/OneOnOneLandscapeLayout.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/OneOnOneLandscapeLayout.tsx b/src/grid/OneOnOneLandscapeLayout.tsx index e7ad3440..d3358dc6 100644 --- a/src/grid/OneOnOneLandscapeLayout.tsx +++ b/src/grid/OneOnOneLandscapeLayout.tsx @@ -23,7 +23,7 @@ import { useBehavior } from "../useBehavior"; */ export const makeOneOnOneLandscapeLayout: CallLayout< OneOnOneLandscapeLayoutModel -> = ({ minBounds$, landscapePipAlignment$ }) => ({ +> = ({ minBounds$ }) => ({ scrollingOnTop: false, fixed: function OneOnOneLandscapeLayoutFixed({ ref }): ReactNode { @@ -38,7 +38,7 @@ export const makeOneOnOneLandscapeLayout: CallLayout< }): ReactNode { useUpdateLayout(); const { width, height } = useObservableEagerState(minBounds$); - const pipAlignmentValue = useBehavior(landscapePipAlignment$); + const pipAlignment = useBehavior(model.pipAlignment$); const { tileWidth, tileHeight } = useMemo( () => arrangeTiles(width, height, 1), [width, height], @@ -46,11 +46,11 @@ export const makeOneOnOneLandscapeLayout: CallLayout< const onDragLocalTile: DragCallback = useCallback( ({ xRatio, yRatio }) => - landscapePipAlignment$.next({ + model.pipAlignment$.next({ block: yRatio < 0.5 ? "start" : "end", inline: xRatio < 0.5 ? "start" : "end", }), - [], + [model.pipAlignment$], ); return ( @@ -66,8 +66,8 @@ export const makeOneOnOneLandscapeLayout: 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 index 0ac1b78d..999f504d 100644 --- a/src/grid/OneOnOnePortraitLayout.module.css +++ b/src/grid/OneOnOnePortraitLayout.module.css @@ -1,5 +1,5 @@ /* -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. @@ -7,39 +7,40 @@ Please see LICENSE in the repository root for full details. .layer { block-size: 100%; - display: grid; - place-items: center; -} - -.container { - position: relative; -} - -.local { - position: absolute; - inline-size: 180px; - block-size: 135px; - inset: 0; } .spotlight { - position: absolute; - inline-size: 404px; - block-size: 233px; + block-size: 100%; + inline-size: 100%; } -.slot[data-block-alignment="start"] { +.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; } -.slot[data-block-alignment="end"] { +.pip[data-block-alignment="end"] { inset-block-start: unset; } -.slot[data-inline-alignment="start"] { +.pip[data-inline-alignment="start"] { inset-inline-end: unset; } -.slot[data-inline-alignment="end"] { +.pip[data-inline-alignment="end"] { inset-inline-start: unset; } diff --git a/src/grid/OneOnOnePortraitLayout.tsx b/src/grid/OneOnOnePortraitLayout.tsx index e1a1bbd8..2f302349 100644 --- a/src/grid/OneOnOnePortraitLayout.tsx +++ b/src/grid/OneOnOnePortraitLayout.tsx @@ -6,12 +6,11 @@ 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, useMemo } from "react"; -import { useObservableEagerState } from "observable-hooks"; +import { type ReactNode, useCallback } from "react"; import classNames from "classnames"; import { type OneOnOnePortraitLayout as OneOnOnePortraitLayoutModel } from "../state/layout-types.ts"; -import { type CallLayout, arrangeTiles } from "./CallLayout"; +import { type CallLayout } from "./CallLayout"; import styles from "./OneOnOnePortraitLayout.module.css"; import { type DragCallback, useUpdateLayout } from "./Grid"; import { useBehavior } from "../useBehavior"; @@ -23,12 +22,20 @@ import { useBehavior } from "../useBehavior"; */ export const makeOneOnOnePortraitLayout: CallLayout< OneOnOnePortraitLayoutModel -> = ({ minBounds$, portraitPipAlignment$ }) => ({ +> = () => ({ scrollingOnTop: false, - fixed: function OneOnOnePortraitLayoutFixed({ ref }): ReactNode { + fixed: function OneOnOnePortraitLayoutFixed({ ref, model, Slot }): ReactNode { useUpdateLayout(); - return
; + return ( +
+ +
+ ); }, scrolling: function OneOnOnePortraitLayoutScrolling({ @@ -37,41 +44,30 @@ export const makeOneOnOnePortraitLayout: CallLayout< Slot, }): ReactNode { useUpdateLayout(); - const { width, height } = useObservableEagerState(minBounds$); - const pipAlignmentValue = useBehavior(portraitPipAlignment$); - const { tileWidth, tileHeight } = useMemo( - () => arrangeTiles(width, height, 1), - [width, height], - ); - + const pipSize = useBehavior(model.pipSize$); + const pipAlignment = useBehavior(model.pipAlignment$); const onDragLocalTile: DragCallback = useCallback( ({ xRatio, yRatio }) => - portraitPipAlignment$.next({ + model.pipAlignment$.next({ block: yRatio < 0.5 ? "start" : "end", inline: xRatio < 0.5 ? "start" : "end", }), - [], + [model.pipAlignment$], ); return (
- - {model.pip && ( - - )} - + {model.pip && ( + + )}
); }, diff --git a/src/grid/SpotlightExpandedLayout.tsx b/src/grid/SpotlightExpandedLayout.tsx index f7bcefc0..f3916fed 100644 --- a/src/grid/SpotlightExpandedLayout.tsx +++ b/src/grid/SpotlightExpandedLayout.tsx @@ -19,7 +19,7 @@ import { useBehavior } from "../useBehavior"; */ export const makeSpotlightExpandedLayout: CallLayout< SpotlightExpandedLayoutModel -> = ({ landscapePipAlignment$ }) => ({ +> = () => ({ scrollingOnTop: true, fixed: function SpotlightExpandedLayoutFixed({ @@ -46,15 +46,15 @@ export const makeSpotlightExpandedLayout: CallLayout< Slot, }): ReactNode { useUpdateLayout(); - const pipAlignmentValue = useBehavior(landscapePipAlignment$); + const pipAlignment = useBehavior(model.pipAlignment$); const onDragPip: DragCallback = useCallback( ({ xRatio, yRatio }) => - landscapePipAlignment$.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/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 b042bd59..7ee9b589 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,7 @@ import { SpotlightTile } from "../tile/SpotlightTile"; import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { E2eeType } from "../e2ee/e2eeType"; import { makeGridLayout } from "../grid/GridLayout"; -import { - type CallLayoutOutputs, - defaultSpotlightAlignment, - defaultPortraitPipAlignment, - defaultLandscapePipAlignment, -} from "../grid/CallLayout"; +import { type CallLayoutOutputs } from "../grid/CallLayout"; import { makeOneOnOneLandscapeLayout } from "../grid/OneOnOneLandscapeLayout"; import { makeOneOnOnePortraitLayout } from "../grid/OneOnOnePortraitLayout"; import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout"; @@ -241,8 +236,8 @@ 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 gridMode = useBehavior(vm.gridMode$); @@ -327,15 +322,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( @@ -343,16 +337,6 @@ export const InCallView: FC = ({ [gridBounds], ); - const spotlightAlignment$ = useInitial( - () => new BehaviorSubject(defaultSpotlightAlignment), - ); - const portraitPipAlignment$ = useInitial( - () => new BehaviorSubject(defaultPortraitPipAlignment), - ); - const landscapePipAlignment$ = useInitial( - () => new BehaviorSubject(defaultLandscapePipAlignment), - ); - const setGridMode = useCallback( (mode: GridMode) => vm.setGridMode(mode), [vm], @@ -361,49 +345,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 = ( +
+ ); + break; + case HeaderStyle.Standard: + header = ( +
+ + + + + {showControls && onShareClick !== null && ( + + )} + +
+ ); } // The reconnecting toast cannot be dismissed @@ -488,12 +470,7 @@ export const InCallView: FC = ({ ); const layouts = useMemo(() => { - const inputs = { - minBounds$: gridBoundsObservable$, - spotlightAlignment$, - portraitPipAlignment$, - landscapePipAlignment$, - }; + const inputs = { minBounds$: gridBoundsObservable$ }; return { grid: makeGridLayout(inputs), "spotlight-landscape": makeSpotlightLandscapeLayout(inputs), @@ -502,12 +479,7 @@ export const InCallView: FC = ({ "one-on-one-landscape": makeOneOnOneLandscapeLayout(inputs), "one-on-one-portrait": makeOneOnOnePortraitLayout(inputs), }; - }, [ - gridBoundsObservable$, - spotlightAlignment$, - portraitPipAlignment$, - landscapePipAlignment$, - ]); + }, [gridBoundsObservable$]); const renderContent = (): JSX.Element => { if (layout.type === "pip") { @@ -533,7 +505,9 @@ export const InCallView: FC = ({ className={styles.fixedGrid} style={{ insetBlockStart: - headerBounds.height > 0 ? headerBounds.bottom : bounds.top, + edgeToEdge || headerBounds.height === 0 + ? bounds.top + : headerBounds.bottom, height: gridBounds.height, }} model={layout} @@ -552,19 +526,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 (layout.foreground) { + case "fixed": + return ( + <> + {fixedGrid} + {scrollingGrid} + + ); + case "scrolling": + return ( + <> + {scrollingGrid} + {fixedGrid} + + ); + } }; const rageshakeRequestModalProps = useRageshakeRequestModal( @@ -587,7 +566,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/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index c1c1d83a..a9fd18fc 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 { @@ -87,6 +88,7 @@ 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, @@ -329,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. */ @@ -349,10 +341,19 @@ export interface CallViewModel { tileStoreGeneration$: Behavior; showSpotlightIndicators$: Behavior; showSpeakingIndicators$: 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 /** @@ -1058,6 +1059,7 @@ export function createCallViewModel$( [grid$, spotlight$], (grid, spotlight) => ({ type: "grid", + edgeToEdge: false, spotlight: spotlight.some((vm) => vm.type === "screen share") ? spotlight : undefined, @@ -1068,6 +1070,7 @@ export function createCallViewModel$( const spotlightLandscapeLayoutMedia$: Observable = combineLatest([grid$, spotlight$], (grid, spotlight) => ({ type: "spotlight-landscape", + edgeToEdge: false, spotlight, grid, })); @@ -1075,16 +1078,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, })), @@ -1140,11 +1147,13 @@ export function createCallViewModel$( 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, }; @@ -1158,6 +1167,7 @@ export function createCallViewModel$( 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, })), @@ -1166,7 +1176,11 @@ export function createCallViewModel$( ); const pipLayoutMedia$: Observable = spotlight$.pipe( - map((spotlight) => ({ type: "pip", spotlight })), + map((spotlight) => ({ + type: "pip", + edgeToEdge: platform !== "desktop", + spotlight, + })), ); /** @@ -1190,7 +1204,7 @@ export function createCallViewModel$( return spotlightExpanded$.pipe( switchMap((expanded) => expanded - ? spotlightExpandedLayoutMedia$ + ? spotlightExpandedLayoutMedia$(false) : spotlightLandscapeLayoutMedia$, ), ); @@ -1219,7 +1233,7 @@ export function createCallViewModel$( // this window mode. return spotlightLandscapeLayoutMedia$; case "spotlight": - return spotlightExpandedLayoutMedia$; + return spotlightExpandedLayoutMedia$(true); } }), ); @@ -1230,6 +1244,170 @@ 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 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 @@ -1256,19 +1434,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-landscape": - [layout, newTiles] = oneOnOneLandscapeLayout(media, prevTiles); + [layout, newTiles] = oneOnOneLandscapeLayout( + media, + landscapePipAlignment$, + prevTiles, + ); break; case "one-on-one-portrait": - [layout, newTiles] = oneOnOnePortraitLayout(media, prevTiles); + [layout, newTiles] = oneOnOnePortraitLayout( + media, + portraitPipSize$, + portraitPipAlignment$, + prevTiles, + ); break; case "pip": [layout, newTiles] = pipLayout(media, prevTiles); @@ -1296,131 +1488,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-landscape": - case "one-on-one-portrait": - 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. */ @@ -1624,7 +1691,6 @@ export function createCallViewModel$( audibleReactions$: audibleReactions$, visibleReactions$: visibleReactions$, - windowMode$: windowMode$, spotlightExpanded$: spotlightExpanded$, toggleSpotlightExpanded$: toggleSpotlightExpanded$, gridMode$: gridMode$, @@ -1652,6 +1718,7 @@ export function createCallViewModel$( showSpeakingIndicators$: showSpeakingIndicators$, showHeader$: showHeader$, showFooter$: showFooter$, + edgeToEdge$, earpieceMode$: earpieceMode$, audioOutputSwitcher$: audioOutputSwitcher$, reconnecting$: localMembership.reconnecting$, diff --git a/src/state/GridLikeLayout.ts b/src/state/GridLikeLayout.ts index 0d130834..eb8aa167 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, @@ -35,8 +42,10 @@ export function gridLikeLayout( return [ { type: media.type, + foreground: "scrolling", spotlight: tiles.spotlightTile, grid: tiles.gridTiles, + spotlightAlignment$, setVisibleTiles, } as Layout & { type: GridLikeLayoutType }, tiles, diff --git a/src/state/OneOnOneLandscapeLayout.ts b/src/state/OneOnOneLandscapeLayout.ts index 7a5d2046..20a269bd 100644 --- a/src/state/OneOnOneLandscapeLayout.ts +++ b/src/state/OneOnOneLandscapeLayout.ts @@ -6,7 +6,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 OneOnOneLandscapeLayout, type OneOnOneLandscapeLayoutMedia, } from "./layout-types"; @@ -17,17 +20,21 @@ import { type TileStore } from "./TileStore"; */ export function oneOnOneLandscapeLayout( media: OneOnOneLandscapeLayoutMedia, + pipAlignment$: BehaviorSubject, prevTiles: TileStore, ): [OneOnOneLandscapeLayout, TileStore] { const update = prevTiles.from(2); update.registerGridTile(media.pip); update.registerGridTile(media.spotlight); const tiles = update.build(); + return [ { type: media.type, + foreground: "scrolling", 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 index afea5171..8f4e3b8c 100644 --- a/src/state/OneOnOnePortraitLayout.ts +++ b/src/state/OneOnOnePortraitLayout.ts @@ -6,28 +6,38 @@ 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 ? 1 : 2); - if (media.pip !== undefined) update.registerGridTile(media.pip); - update.registerGridTile(media.spotlight); + if (media.pip !== undefined) update.registerGridTile(media.pip, false); + update.registerGridTile(media.spotlight, false, true); const tiles = update.build(); + return [ { type: media.type, + foreground: "fixed", spotlight: tiles.gridTilesByMedia.get(media.spotlight)!, - pip: media.pip && tiles.gridTilesByMedia.get(media.pip)!, + pip: media.pip && tiles.gridTilesByMedia.get(media.pip), + pipSize$, + pipAlignment$, }, tiles, ]; diff --git a/src/state/PipLayout.ts b/src/state/PipLayout.ts index 6ac1e4f0..f7d8f27d 100644 --- a/src/state/PipLayout.ts +++ b/src/state/PipLayout.ts @@ -26,6 +26,7 @@ export function pipLayout( return [ { type: media.type, + foreground: "fixed", spotlight: tiles.spotlightTile!, }, tiles, diff --git a/src/state/SpotlightExpandedLayout.ts b/src/state/SpotlightExpandedLayout.ts index 9dc2c815..22bc5277 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); @@ -26,8 +30,10 @@ export function spotlightExpandedLayout( return [ { type: media.type, + foreground: "fixed", spotlight: tiles.spotlightTile!, - pip: tiles.gridTiles[0], + pip: tiles.gridTiles.at(0), + pipAlignment$, }, tiles, ]; diff --git a/src/state/TileStore.ts b/src/state/TileStore.ts index 300e6bd2..ca9381bc 100644 --- a/src/state/TileStore.ts +++ b/src/state/TileStore.ts @@ -59,11 +59,37 @@ class GridTileData { this.media$.next(value); } + private readonly showNameTag$: BehaviorSubject; + public get showNameTag(): boolean { + return this.showNameTag$.value; + } + public set showNameTag(value: boolean) { + this.showNameTag$.next(value); + } + + private readonly edgeToEdge$: BehaviorSubject; + public get edgeToEdge(): boolean { + return this.edgeToEdge$.value; + } + public set edgeToEdge(value: boolean) { + this.edgeToEdge$.next(value); + } + public readonly vm: GridTileViewModel; - public constructor(media: UserMediaViewModel | RingingMediaViewModel) { + public constructor( + media: UserMediaViewModel | RingingMediaViewModel, + showNameTag: boolean, + edgeToEdge: boolean, + ) { this.media$ = new BehaviorSubject(media); - this.vm = new GridTileViewModel(this.media$); + this.showNameTag$ = new BehaviorSubject(showNameTag); + this.edgeToEdge$ = new BehaviorSubject(edgeToEdge); + this.vm = new GridTileViewModel( + this.media$, + this.showNameTag$, + this.edgeToEdge$, + ); } } @@ -183,6 +209,8 @@ export class TileStoreBuilder { */ public registerGridTile( media: UserMediaViewModel | RingingMediaViewModel, + showNameTag = true, + edgeToEdge = false, ): void { if (DEBUG_ENABLED) logger.debug( @@ -220,6 +248,8 @@ export class TileStoreBuilder { this.stationaryGridEntries[prevIndex] = entry; // Do the media swap entry.media = media; + entry.showNameTag = showNameTag; + entry.edgeToEdge = edgeToEdge; this.prevGridByMedia.delete(this.spotlight.media[0]); this.prevGridByMedia.set(media, prev); } else { @@ -228,7 +258,7 @@ export class TileStoreBuilder { (nowVisible ? this.visibleGridEntries : this.invisibleGridEntries - ).push(new GridTileData(media)); + ).push(new GridTileData(media, showNameTag, edgeToEdge)); } this.numGridEntries++; @@ -244,10 +274,12 @@ export class TileStoreBuilder { (this.numGridEntries < this.visibleTiles ? this.visibleGridEntries : this.invisibleGridEntries - ).push(new GridTileData(media)); + ).push(new GridTileData(media, showNameTag, edgeToEdge)); } else { // Reuse the existing tile const [entry, prevIndex] = prev; + entry.showNameTag = showNameTag; + entry.edgeToEdge = edgeToEdge; const previouslyVisible = prevIndex < this.visibleTiles; const nowVisible = this.numGridEntries < this.visibleTiles; // If it doesn't need to move between the visible/invisible sections of @@ -270,7 +302,7 @@ export class TileStoreBuilder { * method will more eagerly try to reuse an existing tile, replacing its * media, than registerGridTile would. */ - public registerPipTile(media: UserMediaViewModel): void { + public registerPipTile(media: UserMediaViewModel, showNameTag = true): void { if (DEBUG_ENABLED) logger.debug( `[TileStore, ${this.generation}] register PiP tile: ${media.displayName$.value}`, @@ -282,10 +314,12 @@ export class TileStoreBuilder { this.stationaryGridEntries[0] = entry; // Do the media swap entry.media = media; + entry.showNameTag = showNameTag; + entry.edgeToEdge = false; this.prevGridByMedia.delete(entry.media); this.prevGridByMedia.set(media, [entry, 0]); } else { - this.visibleGridEntries.push(new GridTileData(media)); + this.visibleGridEntries.push(new GridTileData(media, showNameTag, false)); } this.numGridEntries++; diff --git a/src/state/TileViewModel.ts b/src/state/TileViewModel.ts index 26bf418c..39b0f5a7 100644 --- a/src/state/TileViewModel.ts +++ b/src/state/TileViewModel.ts @@ -23,6 +23,7 @@ export class GridTileViewModel { UserMediaViewModel | RingingMediaViewModel >, public readonly showNameTag$: Behavior, + public readonly edgeToEdge$: Behavior, ) {} } diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index 84fd8972..90130865 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,45 +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 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[]; } @@ -64,15 +74,23 @@ export type LayoutMedia = | OneOnOnePortraitLayoutMedia | PipLayoutMedia; +export interface Alignment { + inline: "start" | "end"; + block: "start" | "end"; +} + export interface GridLayout { type: "grid"; + foreground: "scrolling"; spotlight?: SpotlightTileViewModel; grid: GridTileViewModel[]; + spotlightAlignment$: BehaviorSubject; setVisibleTiles: (value: number) => void; } export interface SpotlightLandscapeLayout { type: "spotlight-landscape"; + foreground: "scrolling"; spotlight: SpotlightTileViewModel; grid: GridTileViewModel[]; setVisibleTiles: (value: number) => void; @@ -80,6 +98,7 @@ export interface SpotlightLandscapeLayout { export interface SpotlightPortraitLayout { type: "spotlight-portrait"; + foreground: "scrolling"; spotlight: SpotlightTileViewModel; grid: GridTileViewModel[]; setVisibleTiles: (value: number) => void; @@ -87,24 +106,32 @@ export interface SpotlightPortraitLayout { export interface SpotlightExpandedLayout { type: "spotlight-expanded"; + foreground: "fixed"; spotlight: SpotlightTileViewModel; pip?: GridTileViewModel; + pipAlignment$: BehaviorSubject; } export interface OneOnOneLandscapeLayout { type: "one-on-one-landscape"; + foreground: "scrolling"; spotlight: GridTileViewModel; pip: GridTileViewModel; + pipAlignment$: BehaviorSubject; } export interface OneOnOnePortraitLayout { type: "one-on-one-portrait"; + foreground: "fixed"; spotlight: GridTileViewModel; pip?: GridTileViewModel; + pipSize$: Behavior<"sm" | "lg">; + pipAlignment$: BehaviorSubject; } export interface PipLayout { type: "pip"; + foreground: "fixed"; spotlight: SpotlightTileViewModel; } 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.tsx b/src/tile/GridTile.tsx index 13cf677f..be4c7828 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -62,6 +62,7 @@ interface TileProps { targetHeight: number; displayName: string; mxcAvatarUrl: string | undefined; + showNameTag: boolean; focusable: boolean; } @@ -406,6 +407,7 @@ export const GridTile: FC = ({ vm, showSpeakingIndicators, onOpenProfile, + className, ...props }) => { const ourRef = useRef(null); @@ -413,26 +415,34 @@ export const GridTile: FC = ({ const media = useBehavior(vm.media$); const displayName = useBehavior(media.displayName$); const mxcAvatarUrl = useBehavior(media.mxcAvatarUrl$); + const showNameTag = useBehavior(vm.showNameTag$); + const edgeToEdge = useBehavior(vm.edgeToEdge$); + + const classes = classNames(className, { [styles.edgeToEdge]: edgeToEdge }); if (media.type === "ringing") { return ( ); } else if (media.local) { return ( ); @@ -440,10 +450,12 @@ export const GridTile: FC = ({ return ( ); diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index eb6cc6b4..508c027d 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -44,6 +44,11 @@ interface Props extends ComponentProps { videoEnabled: boolean; unencryptedWarning: boolean; status?: { text: string; Icon: ComponentType> }; + /** + * Whether to show the participant's name tag. + * @default true + */ + showNameTag?: boolean; nameTagLeadingIcon?: ReactNode; displayName: string; mxcAvatarUrl: string | undefined; @@ -72,6 +77,7 @@ export const MediaView: FC = ({ userId, videoEnabled, unencryptedWarning, + showNameTag = true, nameTagLeadingIcon, displayName, mxcAvatarUrl, @@ -94,6 +100,23 @@ export const MediaView: FC = ({ const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2); + const warnings = unencryptedWarning && ( + + + + ); + return ( = ({
)*/} -
- {nameTagLeadingIcon} - - {displayName} - - {unencryptedWarning && ( - + {nameTagLeadingIcon} + - - - )} -
+ {displayName} + + {warnings} +
+ ) : ( + warnings + )} {primaryButton}