From 99256fa01cf42268f17e9c5189b00f2e3a28a84f Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 28 Sep 2026 19:45:15 +0200 Subject: [PATCH 1/5] Make drag & drop of floating tiles less janky This fixes an issue where upon dragging a floating tile to a different corner of the interface, it would briefly move toward its original position before jerking back in the intended direction. --- src/grid/Grid.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/grid/Grid.tsx b/src/grid/Grid.tsx index 69bfc0760..1e70475b2 100644 --- a/src/grid/Grid.tsx +++ b/src/grid/Grid.tsx @@ -198,6 +198,10 @@ interface Drag { * The Y coordinate of the dragged tile, as a scalar of the grid height. */ yRatio: number; + /** + * Whether this event ends the drag gesture. + */ + endOfGesture: boolean } export type DragCallback = (drag: Drag) => void; @@ -464,13 +468,13 @@ export function Grid< ) .catch(logger.error); - if (endOfGesture) - callback({ - x: tileX, - y: tileY, - xRatio: tileX / (gridBounds.width - tile.width), - yRatio: tileY / (gridBounds.height - tile.height), - }); + callback({ + x: tileX, + y: tileY, + xRatio: tileX / (gridBounds.width - tile.width), + yRatio: tileY / (gridBounds.height - tile.height), + endOfGesture, + }); }; // Callback for useDrag. We could call useDrag here, but the default From f2f49315eb0bd048ca5fea9c14f14c6b4279a79e Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 28 Sep 2026 19:36:46 +0200 Subject: [PATCH 2/5] Make drag & drop performant in all layouts While also giving the logic for drag & drop of a floating tile a common home in the view model layer. --- src/components/CallFooterViewModel.test.ts | 7 +- src/grid/CallLayout.test.tsx | 64 +++++-------------- src/grid/Grid.tsx | 31 ++------- src/grid/GridLayout.tsx | 55 ++++++++-------- src/grid/OneOnOneDesktopLayout.tsx | 21 ++---- src/grid/OneOnOneMobileLayout.tsx | 56 +++++++++------- src/grid/SpotlightExpandedLayout.tsx | 53 ++++++++------- src/grid/SpotlightLandscapeLayout.tsx | 4 +- src/grid/SpotlightPortraitLayout.tsx | 4 +- src/state/CallViewModel/CallViewModel.test.ts | 20 +++--- src/state/CallViewModel/CallViewModel.ts | 45 ++++++------- src/state/FloatingTile.test.ts | 38 +++++++++++ src/state/FloatingTile.ts | 43 +++++++++++++ src/state/GridLikeLayout.ts | 17 ++--- src/state/OneOnOneDesktopLayout.ts | 9 +-- src/state/OneOnOneMobileLayout.ts | 12 ++-- src/state/SpotlightExpandedLayout.ts | 10 ++- src/state/layout-types.test.ts | 20 ++---- src/state/layout-types.ts | 45 +++++++++---- 19 files changed, 290 insertions(+), 264 deletions(-) create mode 100644 src/state/FloatingTile.test.ts create mode 100644 src/state/FloatingTile.ts diff --git a/src/components/CallFooterViewModel.test.ts b/src/components/CallFooterViewModel.test.ts index 0c02a2ba7..ed0ffe8ed 100644 --- a/src/components/CallFooterViewModel.test.ts +++ b/src/components/CallFooterViewModel.test.ts @@ -6,12 +6,11 @@ Please see LICENSE in the repository root for full details. */ import { describe, expect, it, vi } from "vitest"; -import { BehaviorSubject } from "rxjs"; import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test"; import { constant } from "../state/Behavior"; import type { CallViewModel } from "../state/CallViewModel/CallViewModel"; -import type { Alignment, Layout } from "../state/layout-types"; +import type { Layout } from "../state/layout-types"; import type { SpotlightTileViewModel } from "../state/TileViewModel"; import type { DeviceLabel } from "../state/MediaDevices"; import { createCallFooterViewModel } from "./CallFooterViewModel"; @@ -65,10 +64,6 @@ function buildMinimalCallViewModel(layout: Layout): CallViewModel { const gridLayout: Layout = { type: "grid", grid: [], - spotlightAlignment$: new BehaviorSubject({ - inline: "end", - block: "end", - }), setVisibleTiles: (_: number) => {}, }; diff --git a/src/grid/CallLayout.test.tsx b/src/grid/CallLayout.test.tsx index 59e05d326..3338b5802 100644 --- a/src/grid/CallLayout.test.tsx +++ b/src/grid/CallLayout.test.tsx @@ -13,7 +13,7 @@ import { arrangeTiles, type Bounds } from "./CallLayout"; import { makeGridLayout } from "./GridLayout"; import { makeSpotlightLandscapeLayout } from "./SpotlightLandscapeLayout"; import { makeSpotlightPortraitLayout } from "./SpotlightPortraitLayout"; -import { type DragCallback, type LayoutProps } from "./Grid"; +import { type LayoutProps } from "./Grid"; import { constant } from "../state/Behavior"; import { type Alignment, @@ -53,33 +53,6 @@ describe("grid layout", () => { `${Math.floor(arrangeTiles(400, 300, 3).tileWidth)}px`, ); }); - - it("moves the spotlight only when a drag reaches another corner", () => { - const model = gridModel(); - const { fixed: Fixed } = makeGridLayout({ minBounds$: bounds }); - render(); - const alignments: Alignment[] = []; - model.spotlightAlignment$.subscribe((a) => alignments.push(a)); - const drag = drags.get("spotlight")!; - - // Still in the bottom right corner - act(() => drag({ x: 0, y: 0, xRatio: 0.9, yRatio: 0.9 })); - // Over to the bottom left - act(() => drag({ x: 0, y: 0, xRatio: 0.1, yRatio: 0.9 })); - // And a little further, still bottom left - act(() => drag({ x: 0, y: 0, xRatio: 0.2, yRatio: 0.8 })); - // Up to the top left - act(() => drag({ x: 0, y: 0, xRatio: 0.2, yRatio: 0.1 })); - - expect(alignments).toEqual([ - { inline: "end", block: "end" }, - { inline: "start", block: "end" }, - { inline: "start", block: "start" }, - ]); - const slot = screen.getByTestId("slot-spotlight"); - expect(slot.getAttribute("data-inline-alignment")).toBe("start"); - expect(slot.getAttribute("data-block-alignment")).toBe("start"); - }); }); describe("spotlight landscape layout", () => { @@ -123,11 +96,15 @@ const bounds = constant({ width: 800, height: 600 }); // The layouts only pass tile view models through to their slots const media = {} as UserMediaViewModel; -const spotlight = new SpotlightTileViewModel( - constant([]), - constant(false), - constant("solid"), -); +const spotlight = { + vm: new SpotlightTileViewModel( + constant([]), + constant(false), + constant("solid"), + ), + alignment$: constant({ inline: "end", block: "end" }), + onDrag: () => {}, +}; const tiles = (): GridTileViewModel[] => Array.from({ length: 3 }, () => new GridTileViewModel(constant(media))); @@ -136,10 +113,6 @@ function gridModel(): GridLayout { type: "grid", spotlight, grid: tiles(), - spotlightAlignment$: new BehaviorSubject({ - inline: "end", - block: "end", - }), setVisibleTiles: () => {}, }; } @@ -162,22 +135,17 @@ function portraitModel(): SpotlightPortraitLayout { }; } -// Slots record their drag callback so a test can drag them, and show the id -// of the tile they hold -const drags = new Map(); +// Slots show the id of the tile they hold const Slot: LayoutProps["Slot"] = ({ id, model, onDrag, ...props -}) => { - drags.set(id, onDrag); - return ( -
- {model instanceof GridTileViewModel ? model.id : "spotlight"} -
- ); -}; +}) => ( +
+ {model instanceof GridTileViewModel ? model.id : "spotlight"} +
+); function slotIds(): string[] { return screen diff --git a/src/grid/Grid.tsx b/src/grid/Grid.tsx index 1e70475b2..39b8a8ed3 100644 --- a/src/grid/Grid.tsx +++ b/src/grid/Grid.tsx @@ -40,6 +40,7 @@ import { useMergedRefs } from "../useMergedRefs"; import { TileWrapper } from "./TileWrapper"; import { usePrefersReducedMotion } from "../usePrefersReducedMotion"; import { useInitial } from "../useInitial"; +import { type DragCallback } from "../state/layout-types"; const MAX_ANIMATED_TILES = 50; // Capped for performance reasons @@ -83,7 +84,10 @@ interface DragState { cursorY: number; } -interface SlotProps extends Omit, "onDrag"> { +export interface SlotProps extends Omit< + ComponentProps<"div">, + "onDrag" +> { id: string; model: Model; onDrag?: DragCallback; @@ -181,31 +185,6 @@ export interface TileProps { model: Model; } -interface Drag { - /** - * The X coordinate of the dragged tile in grid space. - */ - x: number; - /** - * The Y coordinate of the dragged tile in grid space. - */ - y: number; - /** - * The X coordinate of the dragged tile, as a scalar of the grid width. - */ - xRatio: number; - /** - * The Y coordinate of the dragged tile, as a scalar of the grid height. - */ - yRatio: number; - /** - * Whether this event ends the drag gesture. - */ - endOfGesture: boolean -} - -export type DragCallback = (drag: Drag) => void; - interface LayoutMemoProps< LayoutModel, TileModel, diff --git a/src/grid/GridLayout.tsx b/src/grid/GridLayout.tsx index ecf451435..0e75f8966 100644 --- a/src/grid/GridLayout.tsx +++ b/src/grid/GridLayout.tsx @@ -8,15 +8,21 @@ Please see LICENSE in the repository root for full details. import { type CSSProperties, type ReactNode, - useCallback, useMemo, + type FC, + type ComponentType, } from "react"; import { type GridLayout as GridLayoutModel } from "../state/layout-types.ts"; import styles from "./GridLayout.module.css"; import { type CallLayout, arrangeTiles } from "./CallLayout"; -import { type DragCallback, useUpdateLayout, useVisibleTiles } from "./Grid"; +import { type SlotProps, useUpdateLayout, useVisibleTiles } from "./Grid"; import { useBehavior } from "../useBehavior"; +import { + type SpotlightTileViewModel, + type TileViewModel, +} from "../state/TileViewModel.ts"; +import { type FloatingTile } from "../state/FloatingTile.ts"; interface GridCSSProperties extends CSSProperties { "--gap": string; @@ -24,6 +30,27 @@ interface GridCSSProperties extends CSSProperties { "--height": string; } +interface SpotlightSlotProps { + model: FloatingTile & { vm: SpotlightTileViewModel }; + Slot: ComponentType>; +} + +const SpotlightSlot: FC = ({ model, Slot }) => { + useUpdateLayout(); + const alignment = useBehavior(model.alignment$); + + return ( + + ); +}; + /** * An implementation of the "grid" layout, in which all participants are shown * together in a scrolling grid. @@ -37,32 +64,10 @@ export const makeGridLayout: CallLayout = ({ // lives fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode { useUpdateLayout(); - const alignment = useBehavior(model.spotlightAlignment$); - - const onDragSpotlight: DragCallback = useCallback( - ({ xRatio, yRatio }) => { - const block = yRatio < 0.5 ? "start" : "end"; - const inline = xRatio < 0.5 ? "start" : "end"; - // A drag reports the same alignment on every move; only a change is - // worth a re-render - const current = model.spotlightAlignment$.value; - if (current.block !== block || current.inline !== inline) - model.spotlightAlignment$.next({ block, inline }); - }, - [model.spotlightAlignment$], - ); - return (
{model.spotlight && ( - + )}
); diff --git a/src/grid/OneOnOneDesktopLayout.tsx b/src/grid/OneOnOneDesktopLayout.tsx index beba14c10..0275dc1c4 100644 --- a/src/grid/OneOnOneDesktopLayout.tsx +++ b/src/grid/OneOnOneDesktopLayout.tsx @@ -6,13 +6,13 @@ 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 { type ReactNode, useMemo } from "react"; import classNames from "classnames"; import { type OneOnOneDesktopLayout as OneOnOneDesktopLayoutModel } from "../state/layout-types.ts"; import { type CallLayout, arrangeTiles } from "./CallLayout"; import styles from "./OneOnOneDesktopLayout.module.css"; -import { type DragCallback, useUpdateLayout } from "./Grid"; +import { useUpdateLayout } from "./Grid"; import { useBehavior } from "../useBehavior"; /** @@ -37,21 +37,12 @@ export const makeOneOnOneDesktopLayout: CallLayout< }): ReactNode { useUpdateLayout(); const { width, height } = useBehavior(minBounds$); - const pipAlignment = useBehavior(model.pipAlignment$); + const pipAlignment = useBehavior(model.pip.alignment$); const { tileWidth, tileHeight } = useMemo( () => arrangeTiles(width, height, 1), [width, height], ); - const onDragLocalTile: DragCallback = useCallback( - ({ xRatio, yRatio }) => - model.pipAlignment$.next({ - block: yRatio < 0.5 ? "start" : "end", - inline: xRatio < 0.5 ? "start" : "end", - }), - [model.pipAlignment$], - ); - return (
diff --git a/src/grid/OneOnOneMobileLayout.tsx b/src/grid/OneOnOneMobileLayout.tsx index 628ade1c8..c642fcc4e 100644 --- a/src/grid/OneOnOneMobileLayout.tsx +++ b/src/grid/OneOnOneMobileLayout.tsx @@ -6,14 +6,43 @@ 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 { type ComponentType, type FC, type ReactNode } from "react"; import classNames from "classnames"; import { type OneOnOneMobileLayout as OneOnOneMobileLayoutModel } from "../state/layout-types.ts"; import { type CallLayout } from "./CallLayout"; import styles from "./OneOnOneMobileLayout.module.css"; -import { type DragCallback, useUpdateLayout } from "./Grid"; +import { type SlotProps, useUpdateLayout } from "./Grid"; import { useBehavior } from "../useBehavior"; +import { type FloatingTile } from "../state/FloatingTile.ts"; +import { + type GridTileViewModel, + type TileViewModel, +} from "../state/TileViewModel.ts"; +import { type Behavior } from "../state/Behavior.ts"; + +interface PipSlotProps { + model: FloatingTile & { vm: GridTileViewModel; size$: Behavior<"sm" | "lg"> }; + Slot: ComponentType>; +} + +const PipSlot: FC = ({ model, Slot }) => { + useUpdateLayout(); + const size = useBehavior(model.size$); + const alignment = useBehavior(model.alignment$); + + return ( + + ); +}; /** * An implementation of the "one-on-one" layout for mobile platforms, in which @@ -44,30 +73,9 @@ export const makeOneOnOneMobileLayout: CallLayout< 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 && ( - - )} + {model.pip && }
); }, diff --git a/src/grid/SpotlightExpandedLayout.tsx b/src/grid/SpotlightExpandedLayout.tsx index b4fd1d0e6..e8bb948d4 100644 --- a/src/grid/SpotlightExpandedLayout.tsx +++ b/src/grid/SpotlightExpandedLayout.tsx @@ -5,13 +5,39 @@ 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 { type ReactNode, type ComponentType, type FC } from "react"; import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/layout-types.ts"; import { type CallLayout } from "./CallLayout"; -import { type DragCallback, useUpdateLayout } from "./Grid"; +import { type SlotProps, useUpdateLayout } from "./Grid"; import styles from "./SpotlightExpandedLayout.module.css"; import { useBehavior } from "../useBehavior"; +import { type FloatingTile } from "../state/FloatingTile.ts"; +import { + type GridTileViewModel, + type TileViewModel, +} from "../state/TileViewModel.ts"; + +interface PipSlotProps { + model: FloatingTile & { vm: GridTileViewModel }; + Slot: ComponentType>; +} + +const PipSlot: FC = ({ model, Slot }) => { + useUpdateLayout(); + const alignment = useBehavior(model.alignment$); + + return ( + + ); +}; /** * An implementation of the "expanded spotlight" layout, in which the spotlight @@ -28,7 +54,6 @@ export const makeSpotlightExpandedLayout: CallLayout< Slot, }): ReactNode { useUpdateLayout(); - return (
- 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/SpotlightLandscapeLayout.tsx b/src/grid/SpotlightLandscapeLayout.tsx index 989e3d398..77bd31a59 100644 --- a/src/grid/SpotlightLandscapeLayout.tsx +++ b/src/grid/SpotlightLandscapeLayout.tsx @@ -40,7 +40,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
@@ -59,7 +59,7 @@ export const makeSpotlightLandscapeLayout: CallLayout< return (
- +
{model.grid.map((m) => ( diff --git a/src/grid/SpotlightPortraitLayout.tsx b/src/grid/SpotlightPortraitLayout.tsx index 70c428e97..21b3e32d6 100644 --- a/src/grid/SpotlightPortraitLayout.tsx +++ b/src/grid/SpotlightPortraitLayout.tsx @@ -43,7 +43,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
@@ -65,7 +65,7 @@ export const makeSpotlightPortraitLayout: CallLayout< width, model.grid.length, ); - const withIndicators = useBehavior(model.spotlight.media$).length > 1; + const withIndicators = useBehavior(model.spotlight.vm.media$).length > 1; return (
): Observable { case "grid": return combineLatest( [ - l.spotlight?.media$ ?? constant(undefined), + l.spotlight?.vm.media$ ?? constant(undefined), ...l.grid.map((vm) => vm.media$), ], (spotlight, ...grid) => ({ @@ -188,7 +188,7 @@ function summarizeLayout$(l$: Observable): Observable { case "spotlight-landscape": case "spotlight-portrait": return combineLatest( - [l.spotlight.media$, ...l.grid.map((vm) => vm.media$)], + [l.spotlight.vm.media$, ...l.grid.map((vm) => vm.media$)], (spotlight, ...grid) => ({ type: l.type, spotlight: spotlight.map((vm) => vm.id), @@ -197,7 +197,7 @@ function summarizeLayout$(l$: Observable): Observable { ); case "spotlight-expanded": return combineLatest( - [l.spotlight.media$, l.pip?.media$ ?? constant(undefined)], + [l.spotlight.media$, l.pip?.vm.media$ ?? constant(undefined)], (spotlight, pip) => ({ type: l.type, spotlight: spotlight.map((vm) => vm.id), @@ -206,7 +206,7 @@ function summarizeLayout$(l$: Observable): Observable { ); case "one-on-one-desktop": return combineLatest( - [l.spotlight.media$, l.pip.media$], + [l.spotlight.media$, l.pip.vm.media$], (spotlight, pip) => ({ type: l.type, spotlight: spotlight.id, @@ -217,8 +217,8 @@ function summarizeLayout$(l$: Observable): Observable { return combineLatest( [ l.spotlight.media$, - l.pip?.media$ ?? constant(undefined), - l.pipSize$, + l.pip?.vm.media$ ?? constant(undefined), + l.pip?.size$ ?? constant(undefined), ], (spotlight, pip, pipSize) => ({ type: l.type, @@ -480,7 +480,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => { type: "one-on-one-mobile", spotlight: [`${aliceId}:0`], pip: undefined, - pipSize: "lg", + pipSize: undefined, }, b: { type: "one-on-one-mobile", @@ -518,7 +518,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => { type: "one-on-one-mobile", spotlight: [`${aliceId}:0`], pip: undefined, - pipSize: "lg", + pipSize: undefined, }, }); // It wouldn't be clear whether Alice or Bob is the remote video tile, @@ -566,7 +566,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => { type: "one-on-one-mobile", spotlight: [`${aliceId}:0`], pip: undefined, - pipSize: "sm", + pipSize: undefined, }, b: { type: "spotlight-landscape", @@ -765,7 +765,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => { type: "one-on-one-mobile", spotlight: [`${aliceId}:0`], pip: undefined, - pipSize: "lg", + pipSize: undefined, }, b: { // In a larger window, expect the one-on-one desktop layout diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 1ab4ea134..ae29bb0e5 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -93,7 +93,6 @@ import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { type HostBridge, nullHostBridge } from "../../HostBridge"; import { layoutShallowEquals, - type Alignment, type GridLayoutMedia, type Layout, type LayoutMedia, @@ -163,6 +162,7 @@ import { type RingingMediaViewModel, } from "../media/RingingMediaViewModel.ts"; import { type GridTileViewModel } from "../TileViewModel.ts"; +import { createFloatingTile } from "../FloatingTile.ts"; //TODO // Larger rename @@ -1548,32 +1548,28 @@ export function createCallViewModel$( ); /** - * The alignment of the floating spotlight tile, if present. + * State of the floating spotlight tile, if present. */ - const spotlightAlignment$ = new BehaviorSubject({ + const floatingSpotlightTile = createFloatingTile(scope, { inline: "end", block: "end", }); /** - * The size of the small picture-in-picture tile, if present, when in portrait. + * State of the small picture-in-picture tile, if present, when in landscape. */ - 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({ + const landscapePipTile = createFloatingTile(scope, { inline: "end", block: "start", }); + /** + * State of the small picture-in-picture tile, if present, when in portrait. + */ + const portraitPipTile = { + ...createFloatingTile(scope, { inline: "end", block: "end" }), + size$: scope.behavior( + showFooter$.pipe(map((showFooter) => (showFooter ? "lg" : "sm"))), + ), + }; // 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 @@ -1603,7 +1599,7 @@ export function createCallViewModel$( case "spotlight-portrait": [layout, newTiles] = gridLikeLayout( media, - spotlightAlignment$, + floatingSpotlightTile, visibleTiles, setVisibleTiles, prevTiles, @@ -1613,26 +1609,25 @@ export function createCallViewModel$( case "spotlight-expanded": [layout, newTiles] = spotlightExpandedLayout( media, - landscapePipAlignment$, + landscapePipTile, prevTiles, ); break; case "one-on-one-desktop": [layout, newTiles] = oneOnOneDesktopLayout( media, - landscapePipAlignment$, + landscapePipTile, prevTiles, ); - pip = layout.pip; + pip = layout.pip.vm; break; case "one-on-one-mobile": [layout, newTiles] = oneOnOneMobileLayout( media, - portraitPipSize$, - portraitPipAlignment$, + portraitPipTile, prevTiles, ); - pip = layout.pip; + pip = layout.pip?.vm; break; case "pip": [layout, newTiles] = pipLayout(media, prevTiles); diff --git a/src/state/FloatingTile.test.ts b/src/state/FloatingTile.test.ts new file mode 100644 index 000000000..aa6f6d5f4 --- /dev/null +++ b/src/state/FloatingTile.test.ts @@ -0,0 +1,38 @@ +/* +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 { test } from "vitest"; +import { withTestScheduler } from "../utils/test"; +import { createFloatingTile } from "./FloatingTile"; + +test("FloatingTile changes alignment on drag", () => + withTestScheduler(({ scope, expectObservable, schedule }) => { + // Tile starts in the top left + const { alignment$, onDrag } = createFloatingTile(scope, { + inline: "start", + block: "start", + }); + // Drag to all four corners, and then repeatedly into the top left. + schedule("-↗↘↙↖↖", { + "↗": () => + onDrag({ x: 100, y: 0, xRatio: 1, yRatio: 0, endOfGesture: false }), + "↘": () => + onDrag({ x: 100, y: 100, xRatio: 1, yRatio: 1, endOfGesture: false }), + "↙": () => + onDrag({ x: 0, y: 100, xRatio: 0, yRatio: 1, endOfGesture: false }), + "↖": () => + onDrag({ x: 0, y: 0, xRatio: 0, yRatio: 0, endOfGesture: false }), + }); + // Alignment should change accordingly. Notably, the alignment should *not* + // update when repeatedly dragged into the same corner, for performance. + expectObservable(alignment$).toBe("↖↗↘↙↖-", { + "↗": { inline: "end", block: "start" }, + "↘": { inline: "end", block: "end" }, + "↙": { inline: "start", block: "end" }, + "↖": { inline: "start", block: "start" }, + }); + })); diff --git a/src/state/FloatingTile.ts b/src/state/FloatingTile.ts new file mode 100644 index 000000000..aa0360973 --- /dev/null +++ b/src/state/FloatingTile.ts @@ -0,0 +1,43 @@ +/* +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 { distinctUntilChanged, map, startWith, Subject } from "rxjs"; + +import { type Behavior } from "./Behavior"; +import { type DragCallback, type Alignment, type Drag } from "./layout-types"; +import { type ObservableScope } from "./ObservableScope"; + +/** + * Represents a floating tile which may be dragged to different corners of the + * call interface. + */ +export interface FloatingTile { + alignment$: Behavior; + onDrag: DragCallback; +} + +export function createFloatingTile( + scope: ObservableScope, + defaultAlignment: Alignment, +): FloatingTile { + const dragEvents$ = new Subject(); + return { + alignment$: scope.behavior( + dragEvents$.pipe( + map(({ xRatio, yRatio }) => ({ + inline: xRatio < 0.5 ? "start" : "end", + block: yRatio < 0.5 ? "start" : "end", + })), + startWith(defaultAlignment), + distinctUntilChanged( + (a1, a2) => a1.inline === a2.inline && a1.block === a2.block, + ), + ), + ), + onDrag: (drag) => dragEvents$.next(drag), + }; +} diff --git a/src/state/GridLikeLayout.ts b/src/state/GridLikeLayout.ts index f91f8e310..c0c83660f 100644 --- a/src/state/GridLikeLayout.ts +++ b/src/state/GridLikeLayout.ts @@ -5,14 +5,9 @@ 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 Layout, - type LayoutMedia, -} from "./layout-types.ts"; +import { type Layout, type LayoutMedia } from "./layout-types.ts"; import { type TileStore } from "./TileStore"; +import { type FloatingTile } from "./FloatingTile.ts"; export type GridLikeLayoutType = | "grid" @@ -25,7 +20,7 @@ export type GridLikeLayoutType = */ export function gridLikeLayout( media: LayoutMedia & { type: GridLikeLayoutType }, - spotlightAlignment$: BehaviorSubject, + floatingSpotlightTile: FloatingTile, visibleTiles: number, setVisibleTiles: (value: number) => void, prevTiles: TileStore, @@ -42,9 +37,11 @@ export function gridLikeLayout( return [ { type: media.type, - spotlight: tiles.spotlightTile, + spotlight: tiles.spotlightTile && { + vm: tiles.spotlightTile, + ...floatingSpotlightTile, + }, grid: tiles.gridTiles, - spotlightAlignment$, setVisibleTiles, } as Layout & { type: GridLikeLayoutType }, tiles, diff --git a/src/state/OneOnOneDesktopLayout.ts b/src/state/OneOnOneDesktopLayout.ts index 51fab4c87..d50c9bb0b 100644 --- a/src/state/OneOnOneDesktopLayout.ts +++ b/src/state/OneOnOneDesktopLayout.ts @@ -6,21 +6,19 @@ 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 OneOnOneDesktopLayout, type OneOnOneDesktopLayoutMedia, } from "./layout-types"; import { type TileStore } from "./TileStore"; +import { type FloatingTile } from "./FloatingTile"; /** * Produces a one-on-one desktop layout with the given media. */ export function oneOnOneDesktopLayout( media: OneOnOneDesktopLayoutMedia, - pipAlignment$: BehaviorSubject, + pipTile: FloatingTile, prevTiles: TileStore, ): [OneOnOneDesktopLayout, TileStore] { const update = prevTiles.from(2); @@ -32,8 +30,7 @@ export function oneOnOneDesktopLayout( { type: media.type, spotlight: tiles.gridTilesByMedia.get(media.spotlight)!, - pip: tiles.gridTilesByMedia.get(media.pip)!, - pipAlignment$, + pip: { vm: tiles.gridTilesByMedia.get(media.pip)!, ...pipTile }, }, tiles, ]; diff --git a/src/state/OneOnOneMobileLayout.ts b/src/state/OneOnOneMobileLayout.ts index d8395f0ca..03f809cc3 100644 --- a/src/state/OneOnOneMobileLayout.ts +++ b/src/state/OneOnOneMobileLayout.ts @@ -6,23 +6,20 @@ 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 OneOnOneMobileLayout, type OneOnOneMobileLayoutMedia, } from "./layout-types"; import { type TileStore } from "./TileStore"; import { type Behavior } from "./Behavior"; +import { type FloatingTile } from "./FloatingTile"; /** * Produces a one-on-one mobile layout with the given media. */ export function oneOnOneMobileLayout( media: OneOnOneMobileLayoutMedia, - pipSize$: Behavior<"sm" | "lg">, - pipAlignment$: BehaviorSubject, + pipTile: FloatingTile & { size$: Behavior<"sm" | "lg"> }, prevTiles: TileStore, ): [OneOnOneMobileLayout, TileStore] { const update = prevTiles.from(media.pip === undefined ? 0 : 1); @@ -30,13 +27,12 @@ export function oneOnOneMobileLayout( if (media.pip !== undefined) update.registerGridTile(media.pip); const tiles = update.build(); + const pipVm = media.pip && tiles.gridTilesByMedia.get(media.pip); return [ { type: media.type, spotlight: tiles.spotlightTile!, - pip: media.pip && tiles.gridTilesByMedia.get(media.pip), - pipSize$, - pipAlignment$, + pip: pipVm && { vm: pipVm, ...pipTile }, }, tiles, ]; diff --git a/src/state/SpotlightExpandedLayout.ts b/src/state/SpotlightExpandedLayout.ts index 45a37ca29..5e316bb55 100644 --- a/src/state/SpotlightExpandedLayout.ts +++ b/src/state/SpotlightExpandedLayout.ts @@ -5,21 +5,19 @@ 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"; import { type TileStore } from "./TileStore"; +import { type FloatingTile } from "./FloatingTile"; /** * Produces an expanded spotlight layout with the given media. */ export function spotlightExpandedLayout( media: SpotlightExpandedLayoutMedia, - pipAlignment$: BehaviorSubject, + pipTile: FloatingTile, prevTiles: TileStore, ): [SpotlightExpandedLayout, TileStore] { const update = prevTiles.from(1); @@ -27,12 +25,12 @@ export function spotlightExpandedLayout( if (media.pip !== undefined) update.registerPipTile(media.pip); const tiles = update.build(); + const pipVm = tiles.gridTiles.at(0); return [ { type: media.type, spotlight: tiles.spotlightTile!, - pip: tiles.gridTiles.at(0), - pipAlignment$, + pip: pipVm && { vm: pipVm, ...pipTile }, }, tiles, ]; diff --git a/src/state/layout-types.test.ts b/src/state/layout-types.test.ts index 468c15bb5..a333d0013 100644 --- a/src/state/layout-types.test.ts +++ b/src/state/layout-types.test.ts @@ -6,33 +6,25 @@ Please see LICENSE in the repository root for full details. */ import { test, expect } from "vitest"; -import { - type Alignment, - layoutShallowEquals, - type Layout, -} from "./layout-types"; +import { layoutShallowEquals, type Layout } from "./layout-types"; import { type SpotlightTileViewModel, type GridTileViewModel, } from "./TileViewModel"; -import { BehaviorSubject } from "rxjs"; +import { type FloatingTile } from "./FloatingTile"; const spotlightTile = {} as unknown as SpotlightTileViewModel; const gridTile = {} as unknown as GridTileViewModel; -const pipAlignment$ = new BehaviorSubject({ - inline: "end", - block: "end", -}); +const pipTile = {} as unknown as FloatingTile & { vm: GridTileViewModel }; const spotlightExpanded: Layout = { type: "spotlight-expanded", spotlight: spotlightTile, - pipAlignment$, }; const spotlightPortrait: Layout = { type: "spotlight-portrait", - spotlight: spotlightTile, + spotlight: { vm: spotlightTile }, grid: [gridTile], setVisibleTiles: () => {}, }; @@ -46,12 +38,12 @@ test("layoutShallowEquals detects a missing key", () => { expect( layoutShallowEquals(spotlightExpanded, { ...spotlightExpanded, - pip: gridTile, + pip: pipTile, }), ).toBe(false); expect( layoutShallowEquals( - { ...spotlightExpanded, pip: gridTile }, + { ...spotlightExpanded, pip: pipTile }, spotlightExpanded, ), ).toBe(false); diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index be86f2a36..d2e080da7 100644 --- a/src/state/layout-types.ts +++ b/src/state/layout-types.ts @@ -5,8 +5,6 @@ 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"; @@ -17,6 +15,7 @@ import { } from "./TileViewModel.ts"; import { type Behavior } from "./Behavior.ts"; import { shallowEquals as arrayShallowEquals } from "../utils/array.ts"; +import { type FloatingTile } from "./FloatingTile.ts"; export interface GridLayoutMedia { type: "grid"; @@ -80,24 +79,48 @@ export interface Alignment { block: "start" | "end"; } +export interface Drag { + /** + * The X offset of the dragged tile relative to its container. + */ + x: number; + /** + * The Y offset of the dragged tile relative to its container. + */ + y: number; + /** + * The X coordinate of the dragged tile, as a scalar of the container's width. + */ + xRatio: number; + /** + * The Y coordinate of the dragged tile, as a scalar of the container's height. + */ + yRatio: number; + /** + * Whether this event ends the drag gesture. + */ + endOfGesture: boolean; +} + +export type DragCallback = (drag: Drag) => void; + export interface GridLayout { type: "grid"; - spotlight?: SpotlightTileViewModel; + spotlight?: FloatingTile & { vm: SpotlightTileViewModel }; grid: GridTileViewModel[]; - spotlightAlignment$: BehaviorSubject; setVisibleTiles: (value: number) => void; } export interface SpotlightLandscapeLayout { type: "spotlight-landscape"; - spotlight: SpotlightTileViewModel; + spotlight: { vm: SpotlightTileViewModel }; grid: GridTileViewModel[]; setVisibleTiles: (value: number) => void; } export interface SpotlightPortraitLayout { type: "spotlight-portrait"; - spotlight: SpotlightTileViewModel; + spotlight: { vm: SpotlightTileViewModel }; grid: GridTileViewModel[]; setVisibleTiles: (value: number) => void; } @@ -105,23 +128,19 @@ export interface SpotlightPortraitLayout { export interface SpotlightExpandedLayout { type: "spotlight-expanded"; spotlight: SpotlightTileViewModel; - pip?: GridTileViewModel; - pipAlignment$: BehaviorSubject; + pip?: FloatingTile & { vm: GridTileViewModel }; } export interface OneOnOneDesktopLayout { type: "one-on-one-desktop"; spotlight: GridTileViewModel; - pip: GridTileViewModel; - pipAlignment$: BehaviorSubject; + pip: FloatingTile & { vm: GridTileViewModel }; } export interface OneOnOneMobileLayout { type: "one-on-one-mobile"; spotlight: SpotlightTileViewModel; - pip?: GridTileViewModel; - pipSize$: Behavior<"sm" | "lg">; - pipAlignment$: BehaviorSubject; + pip?: FloatingTile & { vm: GridTileViewModel; size$: Behavior<"sm" | "lg"> }; } export interface PipLayout { From 4441bb5e8c30a48f73596e14990ac29f8f523237 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 29 Sep 2026 03:13:19 +0200 Subject: [PATCH 3/5] Add snapshot coverage for all call layouts Snapshotting the layout templates so that they get reviewed as a whole feels a little more straightforward than the existing tests. --- src/grid/CallLayout.test.tsx | 192 ++++++++---------- .../__snapshots__/CallLayout.test.tsx.snap | 186 +++++++++++++++++ 2 files changed, 273 insertions(+), 105 deletions(-) create mode 100644 src/grid/__snapshots__/CallLayout.test.tsx.snap diff --git a/src/grid/CallLayout.test.tsx b/src/grid/CallLayout.test.tsx index 3338b5802..4c0a9c1e2 100644 --- a/src/grid/CallLayout.test.tsx +++ b/src/grid/CallLayout.test.tsx @@ -5,28 +5,25 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { act, render, screen } from "@testing-library/react"; -import { BehaviorSubject } from "rxjs"; -import { describe, expect, it, vi } from "vitest"; +import { render } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; -import { arrangeTiles, type Bounds } from "./CallLayout"; +import { type CallLayout } from "./CallLayout"; import { makeGridLayout } from "./GridLayout"; import { makeSpotlightLandscapeLayout } from "./SpotlightLandscapeLayout"; import { makeSpotlightPortraitLayout } from "./SpotlightPortraitLayout"; import { type LayoutProps } from "./Grid"; import { constant } from "../state/Behavior"; -import { - type Alignment, - type GridLayout, - type SpotlightLandscapeLayout, - type SpotlightPortraitLayout, -} from "../state/layout-types"; +import { type Alignment } from "../state/layout-types"; import { type UserMediaViewModel } from "../state/media/UserMediaViewModel"; import { GridTileViewModel, SpotlightTileViewModel, type TileViewModel, } from "../state/TileViewModel"; +import { makeOneOnOneMobileLayout } from "./OneOnOneMobileLayout"; +import { makeSpotlightExpandedLayout } from "./SpotlightExpandedLayout"; +import { makeOneOnOneDesktopLayout } from "./OneOnOneDesktopLayout"; // Grid supplies these hooks to the layouts it renders; on their own the // layouts have no grid to report to @@ -36,66 +33,8 @@ vi.mock("./Grid", async (importOriginal) => ({ useVisibleTiles: (): void => {}, })); -describe("grid layout", () => { - it("sizes the tiles from the minimum bounds", () => { - const minBounds$ = new BehaviorSubject({ width: 800, height: 600 }); - const { scrolling: Scrolling } = makeGridLayout({ minBounds$ }); - render(); - const layer = screen.getByTestId("slot-0").parentElement!; - expect(layer.style.width).toBe("800px"); - expect(layer.style.getPropertyValue("--width")).toBe( - `${Math.floor(arrangeTiles(800, 600, 3).tileWidth)}px`, - ); +const minBounds$ = constant({ width: 800, height: 800 }); - act(() => minBounds$.next({ width: 400, height: 300 })); - expect(layer.style.width).toBe("400px"); - expect(layer.style.getPropertyValue("--width")).toBe( - `${Math.floor(arrangeTiles(400, 300, 3).tileWidth)}px`, - ); - }); -}); - -describe("spotlight landscape layout", () => { - it("puts the spotlight in the fixed layer and the grid in the scrolling one", () => { - const { fixed: Fixed, scrolling: Scrolling } = makeSpotlightLandscapeLayout( - { minBounds$: bounds }, - ); - const model = landscapeModel(); - const { unmount } = render(); - expect(screen.getByTestId("slot-spotlight")).toBeInTheDocument(); - unmount(); - - render(); - expect(slotIds()).toEqual(model.grid.map((tile) => tile.id)); - }); -}); - -describe("spotlight portrait layout", () => { - it("sizes the grid tiles from the minimum width", () => { - const minBounds$ = new BehaviorSubject({ width: 300, height: 700 }); - const { scrolling: Scrolling } = makeSpotlightPortraitLayout({ - minBounds$, - }); - const model = portraitModel(); - render(); - expect(slotIds()).toEqual(model.grid.map((tile) => tile.id)); - const layer = screen.getByTestId(`slot-${model.grid[0].id}`).parentElement! - .parentElement!; - expect(layer.style.getPropertyValue("--grid-tile-width")).toBe( - `${Math.floor(arrangeTiles(300, 300, 3).tileWidth)}px`, - ); - - act(() => minBounds$.next({ width: 500, height: 700 })); - expect(layer.style.getPropertyValue("--grid-tile-width")).toBe( - `${Math.floor(arrangeTiles(500, 500, 3).tileWidth)}px`, - ); - }); -}); - -const bounds = constant({ width: 800, height: 600 }); - -// The layouts only pass tile view models through to their slots -const media = {} as UserMediaViewModel; const spotlight = { vm: new SpotlightTileViewModel( constant([]), @@ -105,51 +44,94 @@ const spotlight = { alignment$: constant({ inline: "end", block: "end" }), onDrag: () => {}, }; -const tiles = (): GridTileViewModel[] => - Array.from({ length: 3 }, () => new GridTileViewModel(constant(media))); +const grid = Array.from( + { length: 3 }, + () => new GridTileViewModel(constant({} as UserMediaViewModel)), +); +const pip = { + alignment$: constant({ inline: "end", block: "end" }), + onDrag: () => {}, + vm: { id: "pip" } as GridTileViewModel, + size$: constant("sm" as const), +}; -function gridModel(): GridLayout { - return { - type: "grid", - spotlight, - grid: tiles(), - setVisibleTiles: () => {}, - }; -} - -function landscapeModel(): SpotlightLandscapeLayout { - return { - type: "spotlight-landscape", - spotlight, - grid: tiles(), - setVisibleTiles: () => {}, - }; -} - -function portraitModel(): SpotlightPortraitLayout { - return { - type: "spotlight-portrait", - spotlight, - grid: tiles(), - setVisibleTiles: () => {}, - }; -} - -// Slots show the id of the tile they hold const Slot: LayoutProps["Slot"] = ({ id, model, onDrag, + children, ...props }) => (
- {model instanceof GridTileViewModel ? model.id : "spotlight"} + {children}
); -function slotIds(): string[] { - return screen - .getAllByTestId(/^slot-/) - .map((slot) => slot.textContent ?? "") - .filter((id) => id !== "spotlight"); +function snapshotLayout(layout: CallLayout, model: Model): void { + const { + foreground, + scrolling: Scrolling, + fixed: Fixed, + } = layout({ minBounds$ }); + const scrollingLayer = ; + const fixedLayer = ; + const { container } = render( + foreground === "fixed" ? ( + <> + {scrollingLayer} + {fixedLayer} + + ) : ( + <> + {fixedLayer} + {scrollingLayer} + + ), + ); + expect(container).toMatchSnapshot(); } + +test("grid layout", () => + snapshotLayout(makeGridLayout, { + type: "grid", + spotlight, + grid, + setVisibleTiles: () => {}, + })); + +test("spotlight landscape layout", () => + snapshotLayout(makeSpotlightLandscapeLayout, { + type: "spotlight-landscape", + spotlight, + grid, + setVisibleTiles: () => {}, + })); + +test("spotlight portrait layout", () => + snapshotLayout(makeSpotlightPortraitLayout, { + type: "spotlight-portrait", + spotlight, + grid, + setVisibleTiles: () => {}, + })); + +test("spotlight expanded layout", () => + snapshotLayout(makeSpotlightExpandedLayout, { + type: "spotlight-expanded", + spotlight: spotlight.vm, + pip, + })); + +test("one-on-one desktop layout", () => + snapshotLayout(makeOneOnOneDesktopLayout, { + type: "one-on-one-desktop", + spotlight: { id: "spotlight" } as GridTileViewModel, + pip, + })); + +test("one-on-one mobile layout", () => + snapshotLayout(makeOneOnOneMobileLayout, { + type: "one-on-one-mobile", + spotlight: spotlight.vm, + pip, + })); diff --git a/src/grid/__snapshots__/CallLayout.test.tsx.snap b/src/grid/__snapshots__/CallLayout.test.tsx.snap new file mode 100644 index 000000000..4e22011e0 --- /dev/null +++ b/src/grid/__snapshots__/CallLayout.test.tsx.snap @@ -0,0 +1,186 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`grid layout 1`] = ` +
+
+
+
+
+
+
+
+
+
+`; + +exports[`one-on-one desktop layout 1`] = ` +
+
+
+
+
+
+
+
+`; + +exports[`one-on-one mobile layout 1`] = ` +
+
+
+
+
+
+
+
+`; + +exports[`spotlight expanded layout 1`] = ` +
+
+
+
+
+
+
+
+`; + +exports[`spotlight landscape layout 1`] = ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`spotlight portrait layout 1`] = ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; From 9fcba5a70437eefcd5110135227d611810ecc1bc Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 27 Oct 2026 10:44:33 +0100 Subject: [PATCH 4/5] Skip redundant tile wrapper updates --- src/state/layout-types.test.ts | 16 +++++++++++ src/state/layout-types.ts | 51 +++++++++++++++++++++++++++------- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/src/state/layout-types.test.ts b/src/state/layout-types.test.ts index a333d0013..fdb8319bf 100644 --- a/src/state/layout-types.test.ts +++ b/src/state/layout-types.test.ts @@ -49,6 +49,22 @@ test("layoutShallowEquals detects a missing key", () => { ).toBe(false); }); +test("layoutShallowEquals considers tile wrappers with equal contents to be equal", () => + expect( + layoutShallowEquals(spotlightPortrait, { + ...spotlightPortrait, + spotlight: { vm: spotlightTile }, + }), + ).toBe(true)); + +test("layoutShallowEquals detects tile wrappers with different contents", () => + expect( + layoutShallowEquals(spotlightPortrait, { + ...spotlightPortrait, + spotlight: { vm: {} as unknown as SpotlightTileViewModel }, + }), + ).toBe(false)); + test("layoutShallowEquals considers grid arrays with equal contents to be equal", () => expect( layoutShallowEquals(spotlightPortrait, { diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index d2e080da7..e7d0932c8 100644 --- a/src/state/layout-types.ts +++ b/src/state/layout-types.ts @@ -162,10 +162,17 @@ export type Layout = | PipLayout; /** - * Tests whether the top-level properties and array elements of layout `a` are - * equal to those of layout `b`. Useful for deduping redundant layout updates. + * Tests whether the top-level properties of object `a` are equal to those of + * object `b` by the given comparator. */ -export function layoutShallowEquals(a: Layout, b: Layout): boolean { +function equals( + a: object, + b: object, + comparator: (aValue: unknown, bValue: unknown) => boolean = ( + aValue, + bValue, + ) => aValue === bValue, +): boolean { // If a and b have the same number of keys and every key in a is also in b, // then they have the same keys. const aKeys = Object.keys(a); @@ -176,14 +183,38 @@ export function layoutShallowEquals(a: Layout, b: Layout): boolean { if (!(key in b)) return false; // Now check that they have the same values. - const aValue = (a as any)[key]; - const bValue = (b as any)[key]; - if (Array.isArray(aValue) && Array.isArray(bValue)) { - // Special case for arrays so we can detect when the grid tiles arrays are - // essentially the same. - if (!arrayShallowEquals(aValue, bValue)) return false; - } else if (aValue !== bValue) return false; + const aValue: unknown = (a as any)[key]; + const bValue: unknown = (b as any)[key]; + if (!comparator(aValue, bValue)) return false; } return true; } + +/** + * Tests whether the top-level properties, tile wrapper properties, and array + * elements of layout `a` are equal to those of layout `b`. Useful for deduping + * redundant layout updates. + */ +export function layoutShallowEquals(a: Layout, b: Layout): boolean { + return equals(a, b, (aValue, bValue) => { + // Special case for tile wrapper objects so we can detect when the + // spotlight or PiP tile is essentially the same + if ( + typeof aValue === "object" && + aValue !== null && + "vm" in aValue && + typeof bValue === "object" && + bValue !== null && + "vm" in bValue + ) + return equals(aValue, bValue); + + // Special case for arrays so we can detect when the grid tile arrays are + // essentially the same + if (Array.isArray(aValue) && Array.isArray(bValue)) + return arrayShallowEquals(aValue, bValue); + + return aValue === bValue; + }); +} From c6ad8b2ac7dd231e54718a614430f2f3404c85e7 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 27 Oct 2026 11:45:35 +0100 Subject: [PATCH 5/5] Use the right comparators --- src/state/CallViewModel/CallViewModel.ts | 19 ++++++++++++------- src/state/layout-types.ts | 7 +++++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 2bd1387ab..ea777b12f 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -78,13 +78,13 @@ import { type ReactionInfo, type ReactionOption, } from "../../reactions"; -import { shallowEquals as shallowArrayEquals } from "../../utils/array"; -import { shallowEquals as shallowObjectEquals } from "../../utils/object"; +import { shallowEquals as arrayShallowEquals } from "../../utils/array"; import { type MediaDevice, type MediaDevices } from "../MediaDevices"; import { type Handler, type MuteStates } from "../MuteStates"; import { HeaderStyle, type UrlParams } from "../../UrlParams"; import { type HostBridge, nullHostBridge } from "../../HostBridge"; import { + layoutShallowEquals, type Layout, type LayoutMedia, type WindowMode, @@ -830,7 +830,7 @@ export function createCallViewModel$( bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m), ); }), - distinctUntilChanged(shallowArrayEquals), + distinctUntilChanged(arrayShallowEquals), ), ); @@ -862,13 +862,18 @@ export function createCallViewModel$( [ringingMedia$, screenShares$, spotlightSpeaker$, localUserMediaForPip$], (ringing, screenShares, speaker, localPip) => computeSpotlight({ ringing, screenShares, speaker, localPip }), - ).pipe(distinctUntilChanged(shallowObjectEquals)), + ).pipe( + distinctUntilChanged( + (a, b) => + arrayShallowEquals(a.spotlight, b.spotlight) && a.pip === b.pip, + ), + ), ); const spotlight$ = scope.behavior( spotlightAndPip$.pipe( map(({ spotlight }) => spotlight), - distinctUntilChanged(shallowArrayEquals), + distinctUntilChanged(arrayShallowEquals), ), ); @@ -1029,7 +1034,7 @@ export function createCallViewModel$( desktop: platform === "desktop", }), ), - distinctUntilChanged(shallowObjectEquals), + distinctUntilChanged(layoutShallowEquals), ), ); @@ -1325,7 +1330,7 @@ export function createCallViewModel$( layoutInternals$.pipe( map(({ layout }) => layout), // Drop redundant layout updates before they would hit React. - distinctUntilChanged(shallowObjectEquals), + distinctUntilChanged(layoutShallowEquals), ), ); diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index b46f15f15..578de449a 100644 --- a/src/state/layout-types.ts +++ b/src/state/layout-types.ts @@ -174,8 +174,11 @@ export type Layout = * elements of layout `a` are equal to those of layout `b`. Useful for deduping * redundant layout updates. */ -export function layoutShallowEquals(a: Layout, b: Layout): boolean { - return shallowEquals(a, b, (aValue, bValue) => { +export function layoutShallowEquals( + a: T, + b: T, +): boolean { + return shallowEquals(a, b, (aValue, bValue) => { // Special case for tile wrapper objects so we can detect when the // spotlight or PiP tile is essentially the same if (