mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-10 23:25:52 +00:00
Merge pull request #4300 from element-hq/johannes/pure-layout-functions
Make the layout decisions pure / testable functions
This commit is contained in:
@@ -30,7 +30,6 @@ import {
|
|||||||
scan,
|
scan,
|
||||||
startWith,
|
startWith,
|
||||||
Subject,
|
Subject,
|
||||||
switchAll,
|
|
||||||
switchMap,
|
switchMap,
|
||||||
switchScan,
|
switchScan,
|
||||||
take,
|
take,
|
||||||
@@ -83,6 +82,7 @@ import {
|
|||||||
type ReactionOption,
|
type ReactionOption,
|
||||||
} from "../../reactions";
|
} from "../../reactions";
|
||||||
import { shallowEquals as shallowArrayEquals } from "../../utils/array";
|
import { shallowEquals as shallowArrayEquals } from "../../utils/array";
|
||||||
|
import { shallowEquals as shallowObjectEquals } from "../../utils/object";
|
||||||
import { type MediaDevices } from "../MediaDevices";
|
import { type MediaDevices } from "../MediaDevices";
|
||||||
import { constant, type Behavior } from "../Behavior";
|
import { constant, type Behavior } from "../Behavior";
|
||||||
import { E2eeType } from "../../e2ee/e2eeType";
|
import { E2eeType } from "../../e2ee/e2eeType";
|
||||||
@@ -92,17 +92,17 @@ import { HeaderStyle, type UrlParams } from "../../UrlParams";
|
|||||||
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
||||||
import { type HostBridge, nullHostBridge } from "../../HostBridge";
|
import { type HostBridge, nullHostBridge } from "../../HostBridge";
|
||||||
import {
|
import {
|
||||||
layoutShallowEquals,
|
|
||||||
type Alignment,
|
type Alignment,
|
||||||
type GridLayoutMedia,
|
|
||||||
type Layout,
|
type Layout,
|
||||||
type LayoutMedia,
|
type LayoutMedia,
|
||||||
type OneOnOneDesktopLayoutMedia,
|
type WindowMode,
|
||||||
type OneOnOneMobileLayoutMedia,
|
|
||||||
type SpotlightExpandedLayoutMedia,
|
|
||||||
type SpotlightLandscapeLayoutMedia,
|
|
||||||
type SpotlightPortraitLayoutMedia,
|
|
||||||
} from "../layout-types.ts";
|
} from "../layout-types.ts";
|
||||||
|
import {
|
||||||
|
chooseSpotlightSpeaker,
|
||||||
|
computeLayoutMedia,
|
||||||
|
computeSpotlight,
|
||||||
|
type OneOnOneMedia,
|
||||||
|
} from "../layoutMedia.ts";
|
||||||
import { ElementCallError, UnknownCallError } from "../../utils/errors.ts";
|
import { ElementCallError, UnknownCallError } from "../../utils/errors.ts";
|
||||||
import { type Epoch, type ObservableScope } from "../ObservableScope.ts";
|
import { type Epoch, type ObservableScope } from "../ObservableScope.ts";
|
||||||
import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts";
|
import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts";
|
||||||
@@ -263,21 +263,10 @@ export function callViewModelOptionsFromParams(
|
|||||||
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
|
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
|
||||||
export const THROTTLE_SOUND_EFFECT_MS = 500;
|
export const THROTTLE_SOUND_EFFECT_MS = 500;
|
||||||
|
|
||||||
// This is the number of participants that we think constitutes a "small" call
|
|
||||||
// on mobile. No spotlight tile should be shown below this threshold.
|
|
||||||
const smallMobileCallThreshold = 3;
|
|
||||||
|
|
||||||
// How long the footer should be shown for when hovering over or interacting
|
// How long the footer should be shown for when hovering over or interacting
|
||||||
// with the interface
|
// with the interface
|
||||||
const showFooterMs = 4000;
|
const showFooterMs = 4000;
|
||||||
|
|
||||||
/**
|
|
||||||
* The general shape of the space the call is drawn in. Called a window because
|
|
||||||
* that is what it is in the standalone app; for a component it is the container
|
|
||||||
* the host gave us, which may be a small corner of a large window.
|
|
||||||
*/
|
|
||||||
export type WindowMode = "normal" | "narrow" | "flat" | "pip";
|
|
||||||
|
|
||||||
interface LayoutScanState {
|
interface LayoutScanState {
|
||||||
layout: Layout | null;
|
layout: Layout | null;
|
||||||
overflowing: boolean;
|
overflowing: boolean;
|
||||||
@@ -981,33 +970,16 @@ export function createCallViewModel$(
|
|||||||
mediaItems.length === 0
|
mediaItems.length === 0
|
||||||
? of([])
|
? of([])
|
||||||
: combineLatest(
|
: combineLatest(
|
||||||
mediaItems.map((m) =>
|
mediaItems.map((media) =>
|
||||||
m.speaking$.pipe(map((s) => [m, s] as const)),
|
media.speaking$.pipe(map((speaking) => ({ media, speaking }))),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
scan<
|
scan<
|
||||||
(readonly [UserMediaViewModel, boolean])[],
|
{ media: UserMediaViewModel; speaking: boolean }[],
|
||||||
UserMediaViewModel | undefined,
|
UserMediaViewModel | undefined,
|
||||||
undefined
|
undefined
|
||||||
>((prev, mediaItems) => {
|
>(chooseSpotlightSpeaker, undefined),
|
||||||
// Only remote users that are still in the call should be sticky
|
|
||||||
const [stickyMedia, stickySpeaking] =
|
|
||||||
(!prev?.local && mediaItems.find(([m]) => m === prev)) || [];
|
|
||||||
// Decide who to spotlight:
|
|
||||||
// If the previous speaker is still speaking, stick with them rather
|
|
||||||
// than switching eagerly to someone else
|
|
||||||
return stickySpeaking
|
|
||||||
? stickyMedia!
|
|
||||||
: // Otherwise, select any remote user who is speaking
|
|
||||||
(mediaItems.find(([m, s]) => !m.local && s)?.[0] ??
|
|
||||||
// Otherwise, stick with the person who was last speaking
|
|
||||||
stickyMedia ??
|
|
||||||
// Otherwise, spotlight an arbitrary remote user
|
|
||||||
mediaItems.find(([m]) => !m.local)?.[0] ??
|
|
||||||
// Otherwise, spotlight the local user
|
|
||||||
mediaItems.find(([m]) => m.local)?.[0]);
|
|
||||||
}, undefined),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1051,33 +1023,12 @@ export function createCallViewModel$(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const spotlightAndPip$ = scope.behavior<{
|
const spotlightAndPip$ = scope.behavior(
|
||||||
spotlight: MediaViewModel[];
|
combineLatest(
|
||||||
pip$: Observable<UserMediaViewModel | undefined>;
|
[ringingMedia$, screenShares$, spotlightSpeaker$, localUserMediaForPip$],
|
||||||
}>(
|
(ringing, screenShares, speaker, localPip) =>
|
||||||
ringingMedia$.pipe(
|
computeSpotlight({ ringing, screenShares, speaker, localPip }),
|
||||||
switchMap((ringingMedia) => {
|
).pipe(distinctUntilChanged(shallowObjectEquals)),
|
||||||
if (ringingMedia !== null)
|
|
||||||
return of({ spotlight: [ringingMedia], pip$: localUserMediaForPip$ });
|
|
||||||
|
|
||||||
return screenShares$.pipe(
|
|
||||||
switchMap((screenShares) => {
|
|
||||||
if (screenShares.length > 0)
|
|
||||||
return of({ spotlight: screenShares, pip$: spotlightSpeaker$ });
|
|
||||||
|
|
||||||
return spotlightSpeaker$.pipe(
|
|
||||||
map((speaker) => ({
|
|
||||||
spotlight: speaker ? [speaker] : [],
|
|
||||||
// Hide PiP if redundant (i.e. if local user is already in spotlight)
|
|
||||||
pip$: localUserMediaForPip$.pipe(
|
|
||||||
map((m) => (m === speaker ? undefined : m)),
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const spotlight$ = scope.behavior<MediaViewModel[]>(
|
const spotlight$ = scope.behavior<MediaViewModel[]>(
|
||||||
@@ -1135,56 +1086,7 @@ export function createCallViewModel$(
|
|||||||
hasRemoteScreenShares$,
|
hasRemoteScreenShares$,
|
||||||
);
|
);
|
||||||
|
|
||||||
const gridLayoutMedia$: Observable<GridLayoutMedia> = combineLatest(
|
const oneOnOneLayoutMedia$: Behavior<OneOnOneMedia | null> = scope.behavior(
|
||||||
[grid$, spotlight$],
|
|
||||||
(grid, spotlight) => ({
|
|
||||||
type: "grid",
|
|
||||||
edgeToEdge: false,
|
|
||||||
spotlight: spotlight.some((vm) => vm.type === "screen share")
|
|
||||||
? spotlight
|
|
||||||
: undefined,
|
|
||||||
grid,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const spotlightLandscapeLayoutMedia$ = (
|
|
||||||
edgeToEdge: boolean,
|
|
||||||
): Observable<SpotlightLandscapeLayoutMedia> =>
|
|
||||||
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
|
|
||||||
type: "spotlight-landscape",
|
|
||||||
edgeToEdge,
|
|
||||||
spotlight,
|
|
||||||
grid,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const spotlightPortraitLayoutMedia$: Observable<SpotlightPortraitLayoutMedia> =
|
|
||||||
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
|
|
||||||
type: "spotlight-portrait",
|
|
||||||
edgeToEdge: false,
|
|
||||||
spotlight,
|
|
||||||
grid,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const spotlightExpandedLayoutMedia$ = (
|
|
||||||
edgeToEdge: boolean,
|
|
||||||
): Observable<SpotlightExpandedLayoutMedia> =>
|
|
||||||
spotlightAndPip$.pipe(
|
|
||||||
switchMap(({ spotlight, pip$ }) =>
|
|
||||||
pip$.pipe(
|
|
||||||
map((pip) => ({
|
|
||||||
type: "spotlight-expanded" as const,
|
|
||||||
edgeToEdge,
|
|
||||||
spotlight,
|
|
||||||
pip: pip ?? undefined,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const oneOnOneLayoutMedia$: Behavior<{
|
|
||||||
local: LocalUserMediaViewModel;
|
|
||||||
remote: UserMediaViewModel | RingingMediaViewModel;
|
|
||||||
} | null> = scope.behavior(
|
|
||||||
combineLatest([userMedia$, screenShares$]).pipe(
|
combineLatest([userMedia$, screenShares$]).pipe(
|
||||||
switchMap(([userMedia, screenShares]) => {
|
switchMap(([userMedia, screenShares]) => {
|
||||||
// One-on-one layout only supports 2 user media, no screen shares
|
// One-on-one layout only supports 2 user media, no screen shares
|
||||||
@@ -1222,47 +1124,12 @@ export function createCallViewModel$(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const oneOnOneDesktopLayoutMedia$: Observable<OneOnOneDesktopLayoutMedia | null> =
|
const localVideoEnabled$ = scope.behavior<boolean>(
|
||||||
oneOnOneLayoutMedia$.pipe(
|
oneOnOneLayoutMedia$.pipe(
|
||||||
map((media) => {
|
switchMap((media) =>
|
||||||
if (media === null) return null;
|
media === null ? of(false) : media.local.videoEnabled$,
|
||||||
return media.remote.type === "ringing"
|
),
|
||||||
? {
|
),
|
||||||
type: "one-on-one-desktop" as const,
|
|
||||||
edgeToEdge: false,
|
|
||||||
spotlight: media.local,
|
|
||||||
pip: media.remote,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
type: "one-on-one-desktop" as const,
|
|
||||||
edgeToEdge: false,
|
|
||||||
spotlight: media.remote,
|
|
||||||
pip: media.local,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const oneOnOneMobileLayoutMedia$: Observable<OneOnOneMobileLayoutMedia | null> =
|
|
||||||
oneOnOneLayoutMedia$.pipe(
|
|
||||||
switchMap((media) => {
|
|
||||||
if (media === null) return of(null);
|
|
||||||
return media.local.videoEnabled$.pipe(
|
|
||||||
map((videoEnabled) => ({
|
|
||||||
type: "one-on-one-mobile" as const,
|
|
||||||
edgeToEdge: true as const,
|
|
||||||
spotlight: media.remote,
|
|
||||||
pip: videoEnabled ? media.local : undefined,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const pipLayoutMedia$: Observable<LayoutMedia> = spotlight$.pipe(
|
|
||||||
map((spotlight) => ({
|
|
||||||
type: "pip",
|
|
||||||
edgeToEdge: platform !== "desktop",
|
|
||||||
spotlight,
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
spotlight$
|
spotlight$
|
||||||
@@ -1297,66 +1164,38 @@ export function createCallViewModel$(
|
|||||||
* The media to be used to produce a layout.
|
* The media to be used to produce a layout.
|
||||||
*/
|
*/
|
||||||
const layoutMedia$ = scope.behavior<LayoutMedia>(
|
const layoutMedia$ = scope.behavior<LayoutMedia>(
|
||||||
windowMode$.pipe(
|
// We deliberately don't use combineLatest here. Several of the inputs
|
||||||
switchMap((windowMode) => {
|
// below derive from the same source. So one upstream change cascades
|
||||||
switch (windowMode) {
|
// through them one at a time. combineLatest would see each step of the
|
||||||
case "normal":
|
// cascade and emit layouts built from inputs that never coexisted.
|
||||||
return layoutSwitchVm.layout$.pipe(
|
// Instead, we treat the emissions as a mere "something changed" signal
|
||||||
switchMap((layout) => {
|
// and read every input's current value. The derived behaviors were
|
||||||
switch (layout) {
|
// subscribed before this one and a BehaviorSubject updates its value
|
||||||
case "grid":
|
// before notifying. So by the time the first signal arrives every value
|
||||||
return oneOnOneDesktopLayoutMedia$.pipe(
|
// is already consistent. The duplicate signals from the rest of the
|
||||||
switchMap((oneOnOne) =>
|
// cascade are dropped by distinctUntilChanged.
|
||||||
oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne),
|
merge(
|
||||||
),
|
windowMode$,
|
||||||
);
|
layoutSwitchVm.layout$,
|
||||||
case "spotlight":
|
spotlightExpanded$,
|
||||||
return spotlightExpanded$.pipe(
|
oneOnOneLayoutMedia$,
|
||||||
switchMap((expanded) =>
|
localVideoEnabled$,
|
||||||
expanded
|
spotlightAndPip$,
|
||||||
? spotlightExpandedLayoutMedia$(false)
|
grid$,
|
||||||
: spotlightLandscapeLayoutMedia$(false),
|
).pipe(
|
||||||
),
|
map(() =>
|
||||||
);
|
computeLayoutMedia({
|
||||||
}
|
windowMode: windowMode$.value,
|
||||||
}),
|
layoutMode: layoutSwitchVm.layout$.value,
|
||||||
);
|
spotlightExpanded: spotlightExpanded$.value,
|
||||||
case "narrow":
|
oneOnOne: oneOnOneLayoutMedia$.value,
|
||||||
return oneOnOneMobileLayoutMedia$.pipe(
|
localVideoEnabled: localVideoEnabled$.value,
|
||||||
switchMap((oneOnOne) =>
|
...spotlightAndPip$.value,
|
||||||
oneOnOne === null
|
grid: grid$.value,
|
||||||
? combineLatest([grid$, spotlight$], (grid, spotlight) =>
|
desktop: platform === "desktop",
|
||||||
grid.length > smallMobileCallThreshold ||
|
}),
|
||||||
spotlight.some((vm) => vm.type === "screen share")
|
),
|
||||||
? spotlightPortraitLayoutMedia$
|
distinctUntilChanged(shallowObjectEquals<LayoutMedia>),
|
||||||
: gridLayoutMedia$,
|
|
||||||
).pipe(switchAll())
|
|
||||||
: of(oneOnOne),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
case "flat":
|
|
||||||
return oneOnOneMobileLayoutMedia$.pipe(
|
|
||||||
switchMap((oneOnOne) =>
|
|
||||||
oneOnOne === null
|
|
||||||
? layoutSwitchVm.layout$.pipe(
|
|
||||||
switchMap((layout) => {
|
|
||||||
switch (layout) {
|
|
||||||
case "grid":
|
|
||||||
// Yes, grid mode actually gets you a "spotlight" layout in
|
|
||||||
// this window mode.
|
|
||||||
return spotlightLandscapeLayoutMedia$(true);
|
|
||||||
case "spotlight":
|
|
||||||
return spotlightExpandedLayoutMedia$(true);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
: of(oneOnOne),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
case "pip":
|
|
||||||
return pipLayoutMedia$;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1657,7 +1496,7 @@ export function createCallViewModel$(
|
|||||||
layoutInternals$.pipe(
|
layoutInternals$.pipe(
|
||||||
map(({ layout }) => layout),
|
map(({ layout }) => layout),
|
||||||
// Drop redundant layout updates before they would hit React.
|
// Drop redundant layout updates before they would hit React.
|
||||||
distinctUntilChanged<Layout>(layoutShallowEquals),
|
distinctUntilChanged(shallowObjectEquals<Layout>),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
switchAll,
|
switchAll,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
import { type WindowMode } from "./CallViewModel/CallViewModel.ts";
|
import { type WindowMode } from "./layout-types.ts";
|
||||||
import { constant, type Behavior } from "./Behavior.ts";
|
import { constant, type Behavior } from "./Behavior.ts";
|
||||||
import { type ObservableScope } from "./ObservableScope.ts";
|
import { type ObservableScope } from "./ObservableScope.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
/*
|
|
||||||
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 {
|
|
||||||
type Alignment,
|
|
||||||
layoutShallowEquals,
|
|
||||||
type Layout,
|
|
||||||
} from "./layout-types";
|
|
||||||
import {
|
|
||||||
type SpotlightTileViewModel,
|
|
||||||
type GridTileViewModel,
|
|
||||||
} from "./TileViewModel";
|
|
||||||
import { BehaviorSubject } from "rxjs";
|
|
||||||
|
|
||||||
const spotlightTile = {} as unknown as SpotlightTileViewModel;
|
|
||||||
const gridTile = {} as unknown as GridTileViewModel;
|
|
||||||
const pipAlignment$ = new BehaviorSubject<Alignment>({
|
|
||||||
inline: "end",
|
|
||||||
block: "end",
|
|
||||||
});
|
|
||||||
|
|
||||||
const spotlightExpanded: Layout = {
|
|
||||||
type: "spotlight-expanded",
|
|
||||||
spotlight: spotlightTile,
|
|
||||||
pipAlignment$,
|
|
||||||
};
|
|
||||||
|
|
||||||
const spotlightPortrait: Layout = {
|
|
||||||
type: "spotlight-portrait",
|
|
||||||
spotlight: 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: gridTile,
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
layoutShallowEquals(
|
|
||||||
{ ...spotlightExpanded, pip: gridTile },
|
|
||||||
spotlightExpanded,
|
|
||||||
),
|
|
||||||
).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));
|
|
||||||
@@ -16,7 +16,13 @@ import {
|
|||||||
type SpotlightTileViewModel,
|
type SpotlightTileViewModel,
|
||||||
} from "./TileViewModel.ts";
|
} from "./TileViewModel.ts";
|
||||||
import { type Behavior } from "./Behavior.ts";
|
import { type Behavior } from "./Behavior.ts";
|
||||||
import { shallowEquals as arrayShallowEquals } from "../utils/array.ts";
|
|
||||||
|
/**
|
||||||
|
* The general shape of the space the call is drawn in. Called a window because
|
||||||
|
* that is what it is in the standalone app; for a component it is the container
|
||||||
|
* the host gave us, which may be a small corner of a large window.
|
||||||
|
*/
|
||||||
|
export type WindowMode = "normal" | "narrow" | "flat" | "pip";
|
||||||
|
|
||||||
export interface GridLayoutMedia {
|
export interface GridLayoutMedia {
|
||||||
type: "grid";
|
type: "grid";
|
||||||
@@ -141,30 +147,3 @@ export type Layout =
|
|||||||
| OneOnOneDesktopLayout
|
| OneOnOneDesktopLayout
|
||||||
| OneOnOneMobileLayout
|
| OneOnOneMobileLayout
|
||||||
| PipLayout;
|
| 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.
|
|
||||||
*/
|
|
||||||
export function layoutShallowEquals(a: Layout, b: Layout): 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);
|
|
||||||
const bKeys = Object.keys(b);
|
|
||||||
if (aKeys.length !== bKeys.length) return false;
|
|
||||||
|
|
||||||
for (const key of aKeys) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/*
|
||||||
|
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 { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
chooseSpotlightSpeaker,
|
||||||
|
computeLayoutMedia,
|
||||||
|
computeSpotlight,
|
||||||
|
type LayoutMediaInputs,
|
||||||
|
} from "./layoutMedia";
|
||||||
|
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel";
|
||||||
|
import { type RemoteUserMediaViewModel } from "./media/RemoteUserMediaViewModel";
|
||||||
|
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel";
|
||||||
|
import { type ScreenShareViewModel } from "./media/ScreenShareViewModel";
|
||||||
|
|
||||||
|
// The layout functions only look at `type` and `local`, so stubs suffice
|
||||||
|
const local = { type: "user", local: true } as LocalUserMediaViewModel;
|
||||||
|
const alice = { type: "user", local: false } as RemoteUserMediaViewModel;
|
||||||
|
const bob = { type: "user", local: false } as RemoteUserMediaViewModel;
|
||||||
|
const carol = { type: "user", local: false } as RemoteUserMediaViewModel;
|
||||||
|
const screenShare = {
|
||||||
|
type: "screen share",
|
||||||
|
local: false,
|
||||||
|
} as ScreenShareViewModel;
|
||||||
|
const ringing = { type: "ringing" } as RingingMediaViewModel;
|
||||||
|
|
||||||
|
describe("chooseSpotlightSpeaker", () => {
|
||||||
|
const items = (
|
||||||
|
...speaking: boolean[]
|
||||||
|
): {
|
||||||
|
media: RemoteUserMediaViewModel | LocalUserMediaViewModel;
|
||||||
|
speaking: boolean;
|
||||||
|
}[] =>
|
||||||
|
[local, alice, bob].map((media, i) => ({
|
||||||
|
media,
|
||||||
|
speaking: speaking[i] ?? false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("prefers a remote speaker over the local user", () => {
|
||||||
|
expect(chooseSpotlightSpeaker(undefined, items(true, false, true))).toBe(
|
||||||
|
bob,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to any remote user, then the local user", () => {
|
||||||
|
expect(chooseSpotlightSpeaker(undefined, items())).toBe(alice);
|
||||||
|
expect(
|
||||||
|
chooseSpotlightSpeaker(undefined, [{ media: local, speaking: false }]),
|
||||||
|
).toBe(local);
|
||||||
|
expect(chooseSpotlightSpeaker(undefined, [])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sticks with the previous speaker while they keep speaking", () => {
|
||||||
|
expect(chooseSpotlightSpeaker(bob, items(false, true, true))).toBe(bob);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sticks with the previous speaker when nobody is speaking", () => {
|
||||||
|
expect(chooseSpotlightSpeaker(bob, items())).toBe(bob);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches when someone else speaks and the previous speaker is silent", () => {
|
||||||
|
expect(chooseSpotlightSpeaker(bob, items(false, true, false))).toBe(alice);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forgets a previous speaker who left, and never sticks to the local user", () => {
|
||||||
|
expect(chooseSpotlightSpeaker(carol, items())).toBe(alice);
|
||||||
|
expect(chooseSpotlightSpeaker(local, items())).toBe(alice);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeSpotlight", () => {
|
||||||
|
it("spotlights ringing media with the local user in the PiP", () => {
|
||||||
|
expect(
|
||||||
|
computeSpotlight({
|
||||||
|
ringing,
|
||||||
|
screenShares: [screenShare],
|
||||||
|
speaker: alice,
|
||||||
|
localPip: local,
|
||||||
|
}),
|
||||||
|
).toEqual({ spotlight: [ringing], pip: local });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spotlights screen shares with the speaker in the PiP", () => {
|
||||||
|
expect(
|
||||||
|
computeSpotlight({
|
||||||
|
ringing: null,
|
||||||
|
screenShares: [screenShare],
|
||||||
|
speaker: alice,
|
||||||
|
localPip: local,
|
||||||
|
}),
|
||||||
|
).toEqual({ spotlight: [screenShare], pip: alice });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("otherwise spotlights the speaker and hides a redundant PiP", () => {
|
||||||
|
expect(
|
||||||
|
computeSpotlight({
|
||||||
|
ringing: null,
|
||||||
|
screenShares: [],
|
||||||
|
speaker: alice,
|
||||||
|
localPip: local,
|
||||||
|
}),
|
||||||
|
).toEqual({ spotlight: [alice], pip: local });
|
||||||
|
expect(
|
||||||
|
computeSpotlight({
|
||||||
|
ringing: null,
|
||||||
|
screenShares: [],
|
||||||
|
speaker: local,
|
||||||
|
localPip: local,
|
||||||
|
}),
|
||||||
|
).toEqual({ spotlight: [local], pip: undefined });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeLayoutMedia", () => {
|
||||||
|
const base: LayoutMediaInputs = {
|
||||||
|
windowMode: "normal",
|
||||||
|
layoutMode: "grid",
|
||||||
|
spotlightExpanded: false,
|
||||||
|
oneOnOne: null,
|
||||||
|
localVideoEnabled: true,
|
||||||
|
spotlight: [alice],
|
||||||
|
grid: [local, alice, bob],
|
||||||
|
pip: local,
|
||||||
|
desktop: false,
|
||||||
|
};
|
||||||
|
const layout = (
|
||||||
|
overrides: Partial<LayoutMediaInputs>,
|
||||||
|
): ReturnType<typeof computeLayoutMedia> =>
|
||||||
|
computeLayoutMedia({ ...base, ...overrides });
|
||||||
|
|
||||||
|
it("uses a plain grid unless a screen share needs the spotlight", () => {
|
||||||
|
expect(layout({})).toEqual({
|
||||||
|
type: "grid",
|
||||||
|
edgeToEdge: false,
|
||||||
|
spotlight: undefined,
|
||||||
|
grid: base.grid,
|
||||||
|
});
|
||||||
|
expect(layout({ spotlight: [screenShare] }).spotlight).toEqual([
|
||||||
|
screenShare,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches between landscape and expanded spotlight", () => {
|
||||||
|
expect(layout({ layoutMode: "spotlight" })).toMatchObject({
|
||||||
|
type: "spotlight-landscape",
|
||||||
|
edgeToEdge: false,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
layout({ layoutMode: "spotlight", spotlightExpanded: true }),
|
||||||
|
).toEqual({
|
||||||
|
type: "spotlight-expanded",
|
||||||
|
edgeToEdge: false,
|
||||||
|
spotlight: [alice],
|
||||||
|
pip: local,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the remote user in the desktop one-on-one spotlight, or the local user when ringing", () => {
|
||||||
|
expect(layout({ oneOnOne: { local, remote: alice } })).toEqual({
|
||||||
|
type: "one-on-one-desktop",
|
||||||
|
edgeToEdge: false,
|
||||||
|
spotlight: alice,
|
||||||
|
pip: local,
|
||||||
|
});
|
||||||
|
expect(layout({ oneOnOne: { local, remote: ringing } })).toMatchObject({
|
||||||
|
spotlight: local,
|
||||||
|
pip: ringing,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a mobile one-on-one layout in narrow and flat windows, hiding the PiP without video", () => {
|
||||||
|
const oneOnOne = { local, remote: alice };
|
||||||
|
expect(layout({ windowMode: "narrow", oneOnOne })).toEqual({
|
||||||
|
type: "one-on-one-mobile",
|
||||||
|
edgeToEdge: true,
|
||||||
|
spotlight: alice,
|
||||||
|
pip: local,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
layout({ windowMode: "flat", oneOnOne, localVideoEnabled: false }),
|
||||||
|
).toMatchObject({ type: "one-on-one-mobile", pip: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a portrait spotlight in narrow windows for big calls or screen shares", () => {
|
||||||
|
expect(layout({ windowMode: "narrow" }).type).toBe("grid");
|
||||||
|
expect(
|
||||||
|
layout({ windowMode: "narrow", grid: [local, alice, bob, carol] }).type,
|
||||||
|
).toBe("spotlight-portrait");
|
||||||
|
expect(
|
||||||
|
layout({ windowMode: "narrow", spotlight: [screenShare] }).type,
|
||||||
|
).toBe("spotlight-portrait");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses edge-to-edge spotlight layouts in flat windows", () => {
|
||||||
|
expect(layout({ windowMode: "flat" })).toMatchObject({
|
||||||
|
type: "spotlight-landscape",
|
||||||
|
edgeToEdge: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
layout({ windowMode: "flat", layoutMode: "spotlight" }),
|
||||||
|
).toMatchObject({
|
||||||
|
type: "spotlight-expanded",
|
||||||
|
edgeToEdge: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the PiP layout, edge to edge except on desktop", () => {
|
||||||
|
expect(layout({ windowMode: "pip" })).toEqual({
|
||||||
|
type: "pip",
|
||||||
|
edgeToEdge: true,
|
||||||
|
spotlight: [alice],
|
||||||
|
});
|
||||||
|
expect(layout({ windowMode: "pip", desktop: true }).edgeToEdge).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type LayoutMode } from "./LayoutSwitchViewModel.ts";
|
||||||
|
import {
|
||||||
|
type GridLayoutMedia,
|
||||||
|
type LayoutMedia,
|
||||||
|
type OneOnOneDesktopLayoutMedia,
|
||||||
|
type OneOnOneMobileLayoutMedia,
|
||||||
|
type SpotlightExpandedLayoutMedia,
|
||||||
|
type SpotlightLandscapeLayoutMedia,
|
||||||
|
type SpotlightPortraitLayoutMedia,
|
||||||
|
type WindowMode,
|
||||||
|
} from "./layout-types.ts";
|
||||||
|
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
|
||||||
|
import { type MediaViewModel } from "./media/MediaViewModel.ts";
|
||||||
|
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts";
|
||||||
|
import { type ScreenShareViewModel } from "./media/ScreenShareViewModel.ts";
|
||||||
|
import { type UserMediaViewModel } from "./media/UserMediaViewModel.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is the number of participants that we think constitutes a "small" call
|
||||||
|
* on mobile. No spotlight tile should be shown below this threshold.
|
||||||
|
*/
|
||||||
|
const smallMobileCallThreshold = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks who to spotlight based on who is speaking, preferring the previous
|
||||||
|
* pick so that the spotlight doesn't flip eagerly between speakers.
|
||||||
|
*
|
||||||
|
* @param prev The previous pick, if any.
|
||||||
|
* @param items All user media in the call along with whether each is speaking.
|
||||||
|
*/
|
||||||
|
export function chooseSpotlightSpeaker<M extends { local: boolean }>(
|
||||||
|
prev: M | undefined,
|
||||||
|
items: readonly { media: M; speaking: boolean }[],
|
||||||
|
): M | undefined {
|
||||||
|
// Only remote users that are still in the call should be sticky
|
||||||
|
const sticky = items.find((i) => i.media === prev && !i.media.local);
|
||||||
|
// If the previous speaker is still speaking, stick with them
|
||||||
|
if (sticky?.speaking) return sticky.media;
|
||||||
|
return (
|
||||||
|
// Otherwise, select any remote user who is speaking
|
||||||
|
items.find((i) => !i.media.local && i.speaking)?.media ??
|
||||||
|
// Otherwise, stick with the person who was last speaking
|
||||||
|
sticky?.media ??
|
||||||
|
// Otherwise, spotlight an arbitrary remote user
|
||||||
|
items.find((i) => !i.media.local)?.media ??
|
||||||
|
// Otherwise, spotlight the local user
|
||||||
|
items.find((i) => i.media.local)?.media
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpotlightInputs {
|
||||||
|
ringing: RingingMediaViewModel | null;
|
||||||
|
screenShares: ScreenShareViewModel[];
|
||||||
|
speaker: UserMediaViewModel | undefined;
|
||||||
|
/** Local user media suitable for showing in a PiP, if any. */
|
||||||
|
localPip: UserMediaViewModel | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides what goes in the spotlight, and what goes in the picture-in-picture
|
||||||
|
* tile of the expanded spotlight layout.
|
||||||
|
*/
|
||||||
|
export function computeSpotlight({
|
||||||
|
ringing,
|
||||||
|
screenShares,
|
||||||
|
speaker,
|
||||||
|
localPip,
|
||||||
|
}: SpotlightInputs): {
|
||||||
|
spotlight: MediaViewModel[];
|
||||||
|
pip: UserMediaViewModel | undefined;
|
||||||
|
} {
|
||||||
|
if (ringing !== null) return { spotlight: [ringing], pip: localPip };
|
||||||
|
if (screenShares.length > 0) return { spotlight: screenShares, pip: speaker };
|
||||||
|
return {
|
||||||
|
spotlight: speaker ? [speaker] : [],
|
||||||
|
// Hide PiP if redundant (i.e. if local user is already in spotlight)
|
||||||
|
pip: localPip === speaker ? undefined : localPip,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneOnOneMedia {
|
||||||
|
local: LocalUserMediaViewModel;
|
||||||
|
remote: UserMediaViewModel | RingingMediaViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutMediaInputs {
|
||||||
|
windowMode: WindowMode;
|
||||||
|
/** The layout chosen with the layout switch. */
|
||||||
|
layoutMode: LayoutMode;
|
||||||
|
spotlightExpanded: boolean;
|
||||||
|
/** Set when the call qualifies for a one-on-one layout. */
|
||||||
|
oneOnOne: OneOnOneMedia | null;
|
||||||
|
localVideoEnabled: boolean;
|
||||||
|
spotlight: MediaViewModel[];
|
||||||
|
grid: UserMediaViewModel[];
|
||||||
|
pip: UserMediaViewModel | undefined;
|
||||||
|
desktop: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides which layout to use and which media goes where in it.
|
||||||
|
*/
|
||||||
|
export function computeLayoutMedia({
|
||||||
|
windowMode,
|
||||||
|
layoutMode,
|
||||||
|
spotlightExpanded,
|
||||||
|
oneOnOne,
|
||||||
|
localVideoEnabled,
|
||||||
|
spotlight,
|
||||||
|
grid,
|
||||||
|
pip,
|
||||||
|
desktop,
|
||||||
|
}: LayoutMediaInputs): LayoutMedia {
|
||||||
|
switch (windowMode) {
|
||||||
|
case "normal":
|
||||||
|
if (layoutMode === "grid")
|
||||||
|
return oneOnOne === null
|
||||||
|
? gridLayoutMedia(spotlight, grid)
|
||||||
|
: oneOnOneDesktopLayoutMedia(oneOnOne);
|
||||||
|
return spotlightExpanded
|
||||||
|
? spotlightExpandedLayoutMedia(false, spotlight, pip)
|
||||||
|
: spotlightLandscapeLayoutMedia(false, spotlight, grid);
|
||||||
|
case "narrow":
|
||||||
|
if (oneOnOne !== null)
|
||||||
|
return oneOnOneMobileLayoutMedia(oneOnOne, localVideoEnabled);
|
||||||
|
return grid.length > smallMobileCallThreshold || hasScreenShare(spotlight)
|
||||||
|
? spotlightPortraitLayoutMedia(spotlight, grid)
|
||||||
|
: gridLayoutMedia(spotlight, grid);
|
||||||
|
case "flat":
|
||||||
|
if (oneOnOne !== null)
|
||||||
|
return oneOnOneMobileLayoutMedia(oneOnOne, localVideoEnabled);
|
||||||
|
// Yes, grid mode actually gets you a "spotlight" layout in this window
|
||||||
|
// mode.
|
||||||
|
return layoutMode === "grid"
|
||||||
|
? spotlightLandscapeLayoutMedia(true, spotlight, grid)
|
||||||
|
: spotlightExpandedLayoutMedia(true, spotlight, pip);
|
||||||
|
case "pip":
|
||||||
|
return { type: "pip", edgeToEdge: !desktop, spotlight };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasScreenShare(spotlight: MediaViewModel[]): boolean {
|
||||||
|
return spotlight.some((vm) => vm.type === "screen share");
|
||||||
|
}
|
||||||
|
|
||||||
|
function gridLayoutMedia(
|
||||||
|
spotlight: MediaViewModel[],
|
||||||
|
grid: UserMediaViewModel[],
|
||||||
|
): GridLayoutMedia {
|
||||||
|
return {
|
||||||
|
type: "grid",
|
||||||
|
edgeToEdge: false,
|
||||||
|
spotlight: hasScreenShare(spotlight) ? spotlight : undefined,
|
||||||
|
grid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneOnOneDesktopLayoutMedia(
|
||||||
|
media: OneOnOneMedia,
|
||||||
|
): OneOnOneDesktopLayoutMedia {
|
||||||
|
return media.remote.type === "ringing"
|
||||||
|
? {
|
||||||
|
type: "one-on-one-desktop",
|
||||||
|
edgeToEdge: false,
|
||||||
|
spotlight: media.local,
|
||||||
|
pip: media.remote,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "one-on-one-desktop",
|
||||||
|
edgeToEdge: false,
|
||||||
|
spotlight: media.remote,
|
||||||
|
pip: media.local,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function spotlightExpandedLayoutMedia(
|
||||||
|
edgeToEdge: boolean,
|
||||||
|
spotlight: MediaViewModel[],
|
||||||
|
pip: UserMediaViewModel | undefined,
|
||||||
|
): SpotlightExpandedLayoutMedia {
|
||||||
|
return { type: "spotlight-expanded", edgeToEdge, spotlight, pip };
|
||||||
|
}
|
||||||
|
|
||||||
|
function spotlightLandscapeLayoutMedia(
|
||||||
|
edgeToEdge: boolean,
|
||||||
|
spotlight: MediaViewModel[],
|
||||||
|
grid: UserMediaViewModel[],
|
||||||
|
): SpotlightLandscapeLayoutMedia {
|
||||||
|
return { type: "spotlight-landscape", edgeToEdge, spotlight, grid };
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneOnOneMobileLayoutMedia(
|
||||||
|
media: OneOnOneMedia,
|
||||||
|
localVideoEnabled: boolean,
|
||||||
|
): OneOnOneMobileLayoutMedia {
|
||||||
|
return {
|
||||||
|
type: "one-on-one-mobile",
|
||||||
|
edgeToEdge: true,
|
||||||
|
spotlight: media.remote,
|
||||||
|
pip: localVideoEnabled ? media.local : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function spotlightPortraitLayoutMedia(
|
||||||
|
spotlight: MediaViewModel[],
|
||||||
|
grid: UserMediaViewModel[],
|
||||||
|
): SpotlightPortraitLayoutMedia {
|
||||||
|
return { type: "spotlight-portrait", edgeToEdge: false, spotlight, grid };
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
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 { shallowEquals } from "./object";
|
||||||
|
|
||||||
|
interface Thing {
|
||||||
|
type: string;
|
||||||
|
items: object[];
|
||||||
|
value: number;
|
||||||
|
extra?: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = {};
|
||||||
|
const withArray: Thing = { type: "a", items: [item], value: 1 };
|
||||||
|
|
||||||
|
test("shallowEquals considers an object to be equal to its shallow clone", () =>
|
||||||
|
expect(shallowEquals(withArray, { ...withArray })).toBe(true));
|
||||||
|
|
||||||
|
test("shallowEquals detects a missing key", () => {
|
||||||
|
expect(shallowEquals(withArray, { ...withArray, extra: item })).toBe(false);
|
||||||
|
expect(shallowEquals({ ...withArray, extra: item }, withArray)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shallowEquals detects a differing value", () =>
|
||||||
|
expect(shallowEquals(withArray, { ...withArray, value: 2 })).toBe(false));
|
||||||
|
|
||||||
|
test("shallowEquals considers arrays with equal contents to be equal", () =>
|
||||||
|
expect(
|
||||||
|
shallowEquals(withArray, { ...withArray, items: [...withArray.items] }),
|
||||||
|
).toBe(true));
|
||||||
|
|
||||||
|
test("shallowEquals detects arrays with different contents", () =>
|
||||||
|
expect(
|
||||||
|
shallowEquals(withArray, { ...withArray, items: [...withArray.items, {}] }),
|
||||||
|
).toBe(false));
|
||||||
@@ -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 { 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.
|
||||||
|
*/
|
||||||
|
export function shallowEquals<T extends object>(
|
||||||
|
a: Readonly<T>,
|
||||||
|
b: Readonly<T>,
|
||||||
|
): 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);
|
||||||
|
const bKeys = Object.keys(b);
|
||||||
|
if (aKeys.length !== bKeys.length) return false;
|
||||||
|
|
||||||
|
for (const key of aKeys) {
|
||||||
|
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)) {
|
||||||
|
if (!arrayShallowEquals(aValue, bValue)) return false;
|
||||||
|
} else if (aValue !== bValue) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user