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..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 DragCallback, type LayoutProps } from "./Grid"; +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,152 +33,105 @@ 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`, - ); - }); - - 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", () => { - 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 = 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 grid = Array.from( + { length: 3 }, + () => new GridTileViewModel(constant({} as UserMediaViewModel)), ); -const tiles = (): GridTileViewModel[] => - Array.from({ length: 3 }, () => new GridTileViewModel(constant(media))); +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(), - spotlightAlignment$: new BehaviorSubject({ - inline: "end", - block: "end", - }), - setVisibleTiles: () => {}, - }; -} - -function landscapeModel(): SpotlightLandscapeLayout { - return { - type: "spotlight-landscape", - spotlight, - grid: tiles(), - setVisibleTiles: () => {}, - }; -} - -function portraitModel(): SpotlightPortraitLayout { - return { - type: "spotlight-portrait", - spotlight, - grid: tiles(), - setVisibleTiles: () => {}, - }; -} - -// Slots record their drag callback so a test can drag them, and show the id -// of the tile they hold -const drags = new Map(); const Slot: LayoutProps["Slot"] = ({ id, model, onDrag, + children, ...props -}) => { - drags.set(id, onDrag); - return ( -
- {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/Grid.tsx b/src/grid/Grid.tsx index 69bfc0760..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,27 +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; -} - -export type DragCallback = (drag: Drag) => void; - interface LayoutMemoProps< LayoutModel, TileModel, @@ -464,13 +447,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 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 (
+
+
+
+
+
+
+
+
+
+`; + +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`] = ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index aba231693..33ffe0b73 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -155,7 +155,7 @@ function summarizeLayout$(l$: Observable): Observable { case "grid": return combineLatest( [ - l.spotlight?.media$ ?? constant(undefined), + l.spotlight?.vm.media$ ?? constant(undefined), ...l.grid.map((vm) => vm.media$), ], (spotlight, ...grid) => ({ @@ -167,7 +167,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), @@ -176,7 +176,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), @@ -185,7 +185,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, @@ -196,8 +196,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, @@ -506,7 +506,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", @@ -544,7 +544,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, @@ -592,7 +592,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", @@ -791,7 +791,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 89b4570dc..ea777b12f 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -78,14 +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 { - type Alignment, + layoutShallowEquals, type Layout, type LayoutMedia, type WindowMode, @@ -126,6 +125,7 @@ import { type RingingMediaViewModel, } from "../media/RingingMediaViewModel.ts"; import { type GridTileViewModel } from "../TileViewModel.ts"; +import { createFloatingTile } from "../FloatingTile.ts"; /** How a call captures and encodes the tracks it publishes. */ export interface CaptureSettings { @@ -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), ), ); @@ -1221,32 +1226,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 @@ -1276,7 +1277,7 @@ export function createCallViewModel$( case "spotlight-portrait": [layout, newTiles] = gridLikeLayout( media, - spotlightAlignment$, + floatingSpotlightTile, visibleTiles, setVisibleTiles, prevTiles, @@ -1286,26 +1287,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); @@ -1330,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/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 new file mode 100644 index 000000000..fdb8319bf --- /dev/null +++ b/src/state/layout-types.test.ts @@ -0,0 +1,82 @@ +/* +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, expect } from "vitest"; +import { layoutShallowEquals, type Layout } from "./layout-types"; +import { + type SpotlightTileViewModel, + type GridTileViewModel, +} from "./TileViewModel"; +import { type FloatingTile } from "./FloatingTile"; + +const spotlightTile = {} as unknown as SpotlightTileViewModel; +const gridTile = {} as unknown as GridTileViewModel; +const pipTile = {} as unknown as FloatingTile & { vm: GridTileViewModel }; + +const spotlightExpanded: Layout = { + type: "spotlight-expanded", + spotlight: spotlightTile, +}; + +const spotlightPortrait: Layout = { + type: "spotlight-portrait", + spotlight: { vm: spotlightTile }, + grid: [gridTile], + setVisibleTiles: () => {}, +}; + +test("layoutShallowEquals considers a layout to be equal to its shallow clone", () => + expect(layoutShallowEquals(spotlightExpanded, { ...spotlightExpanded })).toBe( + true, + )); + +test("layoutShallowEquals detects a missing key", () => { + expect( + layoutShallowEquals(spotlightExpanded, { + ...spotlightExpanded, + pip: pipTile, + }), + ).toBe(false); + expect( + layoutShallowEquals( + { ...spotlightExpanded, pip: pipTile }, + spotlightExpanded, + ), + ).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, { + ...spotlightPortrait, + grid: [...spotlightPortrait.grid], + }), + ).toBe(true)); + +test("layoutShallowEquals detects grid arrays with different contents", () => + expect( + layoutShallowEquals(spotlightPortrait, { + ...spotlightPortrait, + grid: [...spotlightPortrait.grid, gridTile], + }), + ).toBe(false)); diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index 3d80b361e..578de449a 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"; @@ -16,6 +14,9 @@ import { type SpotlightTileViewModel, } from "./TileViewModel.ts"; import { type Behavior } from "./Behavior.ts"; +import { type FloatingTile } from "./FloatingTile.ts"; +import { shallowEquals } from "../utils/object.ts"; +import { shallowEquals as arrayShallowEquals } from "../utils/array.ts"; /** * The general shape of the space the call is drawn in. Called a window because @@ -86,24 +87,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; } @@ -111,23 +136,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 { @@ -147,3 +168,34 @@ export type Layout = | OneOnOneDesktopLayout | OneOnOneMobileLayout | PipLayout; + +/** + * 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: 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 ( + typeof aValue === "object" && + aValue !== null && + "vm" in aValue && + typeof bValue === "object" && + bValue !== null && + "vm" in bValue + ) + return shallowEquals(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; + }); +} diff --git a/src/utils/object.test.ts b/src/utils/object.test.ts index 6b55ac7f3..d747162d4 100644 --- a/src/utils/object.test.ts +++ b/src/utils/object.test.ts @@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details. import { test, expect } from "vitest"; import { shallowEquals } from "./object"; +import { shallowEquals as arrayShallowEquals } from "./array"; interface Thing { type: string; @@ -29,12 +30,26 @@ test("shallowEquals detects a missing key", () => { test("shallowEquals detects a differing value", () => expect(shallowEquals(withArray, { ...withArray, value: 2 })).toBe(false)); -test("shallowEquals considers arrays with equal contents to be equal", () => +const comparator = (aValue: unknown, bValue: unknown): boolean => { + if (Array.isArray(aValue) && Array.isArray(bValue)) + return arrayShallowEquals(aValue, bValue); + return aValue === bValue; +}; + +test("shallowEquals with comparator considers arrays with equal contents to be equal", () => expect( - shallowEquals(withArray, { ...withArray, items: [...withArray.items] }), + shallowEquals( + withArray, + { ...withArray, items: [...withArray.items] }, + comparator, + ), ).toBe(true)); -test("shallowEquals detects arrays with different contents", () => +test("shallowEquals with comparator detects arrays with different contents", () => expect( - shallowEquals(withArray, { ...withArray, items: [...withArray.items, {}] }), + shallowEquals( + withArray, + { ...withArray, items: [...withArray.items, {}] }, + comparator, + ), ).toBe(false)); diff --git a/src/utils/object.ts b/src/utils/object.ts index 7fc7b5b1d..b251b0a39 100644 --- a/src/utils/object.ts +++ b/src/utils/object.ts @@ -5,17 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { shallowEquals as arrayShallowEquals } from "./array.ts"; - /** * Determine whether two objects are equal by shallow comparison of their - * top-level properties. Array-valued properties are themselves compared - * shallowly, so two objects holding distinct arrays with the same elements are - * considered equal. + * top-level properties, using the given `comparator`. */ export function shallowEquals( a: Readonly, b: Readonly, + 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. @@ -29,9 +29,7 @@ export function shallowEquals( // 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)) { - if (!arrayShallowEquals(aValue, bValue)) return false; - } else if (aValue !== bValue) return false; + if (!comparator(aValue, bValue)) return false; } return true;