mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-20 20:49:20 +00:00
Compare commits
17 Commits
tiles-debu
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3abc5a0435 | ||
|
|
8e20d48b1d | ||
|
|
ff57c217aa | ||
|
|
b60e7452b7 | ||
|
|
1c03ad335c | ||
|
|
87873b726f | ||
|
|
3448a3a2ef | ||
|
|
c625fb8845 | ||
|
|
cab9698fc7 | ||
|
|
7410bd8a82 | ||
|
|
f17670b731 | ||
|
|
33a0653ea5 | ||
|
|
b1c29e9a41 | ||
|
|
ad92892063 | ||
|
|
1a827e0930 | ||
|
|
a1b37bb3c9 | ||
|
|
061d25a54c |
4
.github/workflows/sync-labels.yml
vendored
4
.github/workflows/sync-labels.yml
vendored
@@ -9,7 +9,7 @@ on:
|
||||
- .github/labels.yml
|
||||
- .github/workflows/sync-labels.yml
|
||||
|
||||
permissions: {} # We use ELEMENT_BOT_TOKEN instead
|
||||
permissions: {} # We use LABEL_SYNC_GITHUB_TOKEN instead
|
||||
|
||||
jobs:
|
||||
sync-labels:
|
||||
@@ -20,4 +20,4 @@ jobs:
|
||||
DELETE: true
|
||||
WET: true
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.LABEL_SYNC_GITHUB_TOKEN }}
|
||||
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
element-web:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:af44e1e18bdd363a3332a577c3af66a9dde4ad352508a42ee5012c4a9528b30d
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:a416986d5e6086ef715bc6100a9fbb4547822ca285b8a13392d061b5c114742f
|
||||
element-web-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:af44e1e18bdd363a3332a577c3af66a9dde4ad352508a42ee5012c4a9528b30d
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:a416986d5e6086ef715bc6100a9fbb4547822ca285b8a13392d061b5c114742f
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
The following [MSCs](https://github.com/matrix-org/matrix-spec-proposals) are
|
||||
required for Element Call to work properly:
|
||||
|
||||
- **[MSC3266](https://github.com/deepbluev7/matrix-doc/blob/room-summaries/proposals/3266-room-summary.md):
|
||||
- **[MSC3266](https://github.com/deepbluev7/matrix-doc/blob/room-summaries/proposals/3266-room-summary.md)
|
||||
Room Summary API**: In Standalone mode Element Call is able to join rooms
|
||||
over federation using knocking. In this context MSC3266 is required as it
|
||||
allows to request a room summary of rooms you are not joined. The summary
|
||||
@@ -29,19 +29,26 @@ required for Element Call to work properly:
|
||||
signalling. If disabled it is very likely that you end up with stuck calls in
|
||||
Matrix rooms.
|
||||
|
||||
- **[MSC4519](https://github.com/matrix-org/matrix-spec-proposals/blob/travis/msc/voip-transports-registry/proposals/4519-rtc-transports-registry.md)
|
||||
MatrixRTC Transports Registry**: Defines an endpoint that clients can use to
|
||||
query the available MatrixRTC transports (i.e. find your LiveKit SFU).
|
||||
|
||||
- **[MSC4222](https://github.com/matrix-org/matrix-spec-proposals/blob/erikj/sync_v2_state_after/proposals/4222-sync-v2-state-after.md)
|
||||
Adding `state_after` to sync v2**: Allow clients to opt-in to a change of the
|
||||
sync v2 API that allows them to correctly track the state of the room. This is
|
||||
required by Element Call to track room state reliably.
|
||||
|
||||
If you're using [Synapse](https://github.com/element-hq/synapse/) as your
|
||||
homeserver, you'll need to additionally add the following config items to
|
||||
`homeserver.yaml` to comply with Element Call:
|
||||
homeserver, you can configure these features by adding the following entries to
|
||||
`homeserver.yaml`:
|
||||
|
||||
```yaml
|
||||
experimental_features:
|
||||
# MSC3266: Room summary API. Used for knocking over federation
|
||||
msc3266_enabled: true
|
||||
# MSC4143: MatrixRTC. For historical reasons this flag enables the transports
|
||||
# endpoint defined in MSC4519.
|
||||
msc4143_enabled: true
|
||||
# MSC4222 needed for syncv2 state_after. This allow clients to
|
||||
# correctly track the state of the room.
|
||||
msc4222_enabled: true
|
||||
@@ -61,6 +68,15 @@ rc_delayed_event_mgmt:
|
||||
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2Hz
|
||||
per_second: 1
|
||||
burst_count: 20
|
||||
|
||||
matrix_rtc:
|
||||
transports:
|
||||
# The transport you specify will be made available to clients over the
|
||||
# /_matrix/client/unstable/org.matrix.msc4143/rtc/transports endpoint as
|
||||
# defined in MSC4519.
|
||||
- type: livekit
|
||||
# Replace this with the actual URL of your MatrixRTC Authorization Service
|
||||
livekit_service_url: https://matrix-rtc.example.com/livekit/jwt
|
||||
```
|
||||
|
||||
As a prerequisite for the
|
||||
@@ -187,23 +203,6 @@ backend mxrtc_auth_backend
|
||||
|
||||
```
|
||||
|
||||
#### MatrixRTC transport announcement
|
||||
|
||||
Enable the unstable feature flag `msc4143_enabled`, and update the
|
||||
[`matrix_rtc` section](https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html#matrix_rtc)
|
||||
of your Synapse config file:
|
||||
|
||||
```yaml
|
||||
matrix_rtc:
|
||||
transports:
|
||||
- type: livekit
|
||||
livekit_service_url: https://matrix-rtc.example.com/livekit/jwt
|
||||
```
|
||||
|
||||
The transport you specify will be made available to clients over the
|
||||
`/_matrix/client/unstable/org.matrix.msc4143/rtc/transports` endpoint as defined
|
||||
in [MSC4143](https://github.com/matrix-org/matrix-spec-proposals/pull/4143).
|
||||
|
||||
## Building Element Call
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
@@ -127,6 +127,7 @@ export const Default: Story = {
|
||||
showFooter: true,
|
||||
hideControls: false,
|
||||
asOverlay: false,
|
||||
showModals: true,
|
||||
sharingScreen: false,
|
||||
audioOutputSwitcher: undefined,
|
||||
reactionIdentifier: undefined,
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface FooterState {
|
||||
/** The footer should be used as an overlay.
|
||||
* (Over the Call Grid) This saves spaces on small screens. */
|
||||
asOverlay: boolean;
|
||||
showModals: boolean;
|
||||
|
||||
buttonSize: "md" | "lg";
|
||||
showLogo: boolean;
|
||||
@@ -121,6 +122,7 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
const asOverlay = useBehavior(vm.asOverlay$);
|
||||
const showFooter = useBehavior(vm.showFooter$);
|
||||
const hideControls = useBehavior(vm.hideControls$);
|
||||
const showModals = useBehavior(vm.showModals$);
|
||||
const layoutSwitchVm = useBehavior(vm.layoutSwitchVm$);
|
||||
const openSettings = useBehavior(vm.openSettings$);
|
||||
const audioEnabled = useBehavior(vm.audioEnabled$);
|
||||
@@ -235,7 +237,8 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (reactionIdentifier && reactionData) {
|
||||
// Reaction button contains a pretty large menu, so treat it like a modal
|
||||
if (reactionIdentifier && reactionData && showModals) {
|
||||
buttons.push(
|
||||
<ReactionToggleButton
|
||||
size={buttonSize}
|
||||
|
||||
@@ -49,6 +49,7 @@ function buildMinimalCallViewModel(layout: Layout): CallViewModel {
|
||||
reactions$: constant({}),
|
||||
tileStoreGeneration$: constant(0),
|
||||
showFooter$: constant(true),
|
||||
showModals$: constant(true),
|
||||
settingsOpen$: constant(false),
|
||||
setSettingsOpen$: constant(() => {}),
|
||||
} as unknown as CallViewModel;
|
||||
|
||||
@@ -161,6 +161,7 @@ export function createCallFooterViewModel(
|
||||
// candidat to move into the FooterViewModel
|
||||
showFooter$: callModel.showFooter$,
|
||||
hideControls$: constant(!showControls),
|
||||
showModals$: callModel.showModals$,
|
||||
asOverlay$: callModel.edgeToEdge$,
|
||||
buttonSize$: scope.behavior(
|
||||
isPip$.pipe(map<boolean, "md" | "lg">((pip) => (pip ? "md" : "lg"))),
|
||||
@@ -168,12 +169,12 @@ export function createCallFooterViewModel(
|
||||
|
||||
openSettings$: scope.behavior(
|
||||
combineLatest([
|
||||
isPip$,
|
||||
callModel.showModals$,
|
||||
callModel.showHeader$,
|
||||
callModel.setSettingsOpen$,
|
||||
]).pipe(
|
||||
map(([isPip, showHeader, setSettingsOpen]) =>
|
||||
!isPip && headerStyle !== HeaderStyle.AppBar && showControls
|
||||
map(([showModals, showHeader, setSettingsOpen]) =>
|
||||
showModals && headerStyle !== HeaderStyle.AppBar && showControls
|
||||
? (): void => setSettingsOpen(true)
|
||||
: undefined,
|
||||
),
|
||||
@@ -239,6 +240,7 @@ export function createLobbyFooterViewModel(
|
||||
showLogo,
|
||||
hideControls: false,
|
||||
asOverlay: false,
|
||||
showModals: true,
|
||||
buttonSize: "lg",
|
||||
openSettings,
|
||||
hangup,
|
||||
|
||||
BIN
src/graphics/desktop-gradient.png
Normal file
BIN
src/graphics/desktop-gradient.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
@@ -1,16 +0,0 @@
|
||||
<svg width="1440" height="800" viewBox="0 0 1440 800" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_f_4162_80259)">
|
||||
<path d="M-37.0486 666.028C267.76 138.867 944.304 -46.1945 1477.05 260.929" stroke="url(#paint0_linear_4162_80259)" stroke-width="192" stroke-linecap="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_4162_80259" x="-333.118" y="-183.694" width="2106.24" height="1145.68" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="100" result="effect1_foregroundBlur_4162_80259"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_4162_80259" x1="1255.31" y1="320.254" x2="184.899" y2="607.497" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00C59E"/>
|
||||
<stop offset="0.75" stop-color="#0044A5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 938 B |
BIN
src/graphics/mobile-gradient.png
Normal file
BIN
src/graphics/mobile-gradient.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -1,86 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="1200"
|
||||
height="285"
|
||||
viewBox="0 0 1200 285"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<g
|
||||
filter="url(#filter0_f_3970_9366)"
|
||||
id="g2"
|
||||
transform="translate(422.00201)">
|
||||
<path
|
||||
d="m -164.342,495.134 c 188.2881,-188.288 494.684,-190 684.684,0"
|
||||
stroke="url(#paint0_linear_3970_9366)"
|
||||
stroke-width="235.517"
|
||||
stroke-linecap="round"
|
||||
id="path1"
|
||||
style="stroke:url(#paint0_linear_3970_9366)" />
|
||||
<path
|
||||
d="m -164.342,495.134 c 188.2881,-188.288 494.684,-190 684.684,0"
|
||||
stroke="url(#paint1_linear_3970_9366)"
|
||||
style="mix-blend-mode:overlay;stroke:url(#paint1_linear_3970_9366)"
|
||||
stroke-width="235.517"
|
||||
stroke-linecap="round"
|
||||
id="path2" />
|
||||
</g>
|
||||
<defs
|
||||
id="defs5">
|
||||
<filter
|
||||
id="filter0_f_3970_9366"
|
||||
x="-517.617"
|
||||
y="-0.00012207"
|
||||
width="1391.23"
|
||||
height="848.409"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB">
|
||||
<feFlood
|
||||
flood-opacity="0"
|
||||
result="BackgroundImageFix"
|
||||
id="feFlood2" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
id="feBlend2" />
|
||||
<feGaussianBlur
|
||||
stdDeviation="117.758"
|
||||
result="effect1_foregroundBlur_3970_9366"
|
||||
id="feGaussianBlur2" />
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_3970_9366"
|
||||
x1="349.17099"
|
||||
y1="323.96301"
|
||||
x2="6.82898"
|
||||
y2="666.30499"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop
|
||||
stop-color="#0D5CBD"
|
||||
id="stop2" />
|
||||
<stop
|
||||
offset="0.730863"
|
||||
stop-color="#0DBDA8"
|
||||
id="stop3" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_3970_9366"
|
||||
x1="349.17099"
|
||||
y1="323.96301"
|
||||
x2="6.82898"
|
||||
y2="666.30499"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop
|
||||
stop-color="#0D5CBD"
|
||||
id="stop4" />
|
||||
<stop
|
||||
offset="0.730863"
|
||||
stop-color="#0DBDA8"
|
||||
id="stop5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
@@ -78,16 +78,19 @@ body {
|
||||
body[data-background="gradient"]::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: url("graphics/mobile-gradient.svg");
|
||||
background-size: auto;
|
||||
/* Chromium abruptly fades our images to fully transparent at the edge of
|
||||
the element. If we just make the element a little bigger than the viewport,
|
||||
this is no longer visible. */
|
||||
inset: -20px;
|
||||
background-image: url("graphics/mobile-gradient.png");
|
||||
background-size: 1400px 305px;
|
||||
background-position: bottom;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
body[data-background="gradient"][data-platform="desktop"]::before {
|
||||
background-image: url("graphics/desktop-gradient.svg");
|
||||
background-size: calc(max(1440px, 100vw)) calc(max(800px, 100vh));
|
||||
background-image: url("graphics/desktop-gradient.png");
|
||||
background-size: max(1440px, 100vw) max(1440px, 100vh);
|
||||
background-position: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,6 +261,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const overflowing = useBehavior(vm.overflowing$);
|
||||
const showNameTags = useBehavior(vm.showNameTags$);
|
||||
const showHeader = useBehavior(vm.showHeader$);
|
||||
const showModals = useBehavior(vm.showModals$);
|
||||
const settingsOpen = useBehavior(vm.settingsOpen$);
|
||||
const setSettingsOpen = useBehavior(vm.setSettingsOpen$);
|
||||
const earpieceMode = useBehavior(vm.earpieceMode$);
|
||||
@@ -622,7 +623,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
{earpieceOverlay}
|
||||
<ReactionsOverlay vm={vm} />
|
||||
{footer}
|
||||
{layout.type !== "pip" && (
|
||||
{showModals && (
|
||||
<>
|
||||
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
||||
<SettingsModal
|
||||
|
||||
@@ -22,6 +22,9 @@ Please see LICENSE in the repository root for full details.
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background-color: var(--video-tile-background);
|
||||
/* In FF if you add a transform: scale/translate/matrix filter on an element,
|
||||
it'll ignore the parents' border-radius, so force back the radius to avoid UI glitch*/
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
video.mirror {
|
||||
|
||||
@@ -79,7 +79,7 @@ import {
|
||||
type ReactionInfo,
|
||||
type ReactionOption,
|
||||
} from "../../reactions";
|
||||
import { shallowEquals } from "../../utils/array";
|
||||
import { shallowEquals as shallowArrayEquals } from "../../utils/array";
|
||||
import { type MediaDevices } from "../MediaDevices";
|
||||
import { constant, type Behavior } from "../Behavior";
|
||||
import { E2eeType } from "../../e2ee/e2eeType";
|
||||
@@ -89,6 +89,7 @@ import { getUrlParams, HeaderStyle } from "../../UrlParams";
|
||||
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
||||
import { ElementWidgetActions, widget } from "../../widget";
|
||||
import {
|
||||
layoutShallowEquals,
|
||||
type Alignment,
|
||||
type GridLayoutMedia,
|
||||
type Layout,
|
||||
@@ -365,6 +366,11 @@ export interface CallViewModel {
|
||||
*/
|
||||
overflowing$: Behavior<boolean>;
|
||||
|
||||
/**
|
||||
* Whether modals such as settings and reactions should be accessible at all.
|
||||
*/
|
||||
showModals$: Behavior<boolean>;
|
||||
|
||||
settingsOpen$: Behavior<boolean>;
|
||||
setSettingsOpen$: Behavior<(open: boolean) => void>;
|
||||
|
||||
@@ -941,7 +947,7 @@ export function createCallViewModel$(
|
||||
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged(shallowEquals),
|
||||
distinctUntilChanged(shallowArrayEquals),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1000,7 +1006,7 @@ export function createCallViewModel$(
|
||||
const spotlight$ = scope.behavior<MediaViewModel[]>(
|
||||
spotlightAndPip$.pipe(
|
||||
map(({ spotlight }) => spotlight),
|
||||
distinctUntilChanged<MediaViewModel[]>(shallowEquals),
|
||||
distinctUntilChanged<MediaViewModel[]>(shallowArrayEquals),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1209,6 +1215,7 @@ export function createCallViewModel$(
|
||||
}
|
||||
return layout;
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe((orientation) => {
|
||||
@@ -1448,6 +1455,11 @@ export function createCallViewModel$(
|
||||
map((naturallyShowFooter) => naturallyShowFooter && showFooterUrlParams),
|
||||
),
|
||||
);
|
||||
|
||||
const showModals$ = scope.behavior(
|
||||
windowMode$.pipe(map((mode) => mode !== "pip")),
|
||||
);
|
||||
|
||||
const settingsOpen$ = new BehaviorSubject(false);
|
||||
const setSettingsOpen$ = constant((open: boolean) => {
|
||||
settingsOpen$.next(open);
|
||||
@@ -1574,7 +1586,11 @@ export function createCallViewModel$(
|
||||
* The layout of tiles in the call interface.
|
||||
*/
|
||||
const layout$ = scope.behavior<Layout>(
|
||||
layoutInternals$.pipe(map(({ layout }) => layout)),
|
||||
layoutInternals$.pipe(
|
||||
map(({ layout }) => layout),
|
||||
// Drop redundant layout updates before they would hit React.
|
||||
distinctUntilChanged<Layout>(layoutShallowEquals),
|
||||
),
|
||||
);
|
||||
|
||||
const overflowing$ = scope.behavior<boolean>(
|
||||
@@ -1820,6 +1836,7 @@ export function createCallViewModel$(
|
||||
showNameTags$,
|
||||
showHeader$: showHeader$,
|
||||
showFooter$: showFooter$,
|
||||
showModals$,
|
||||
settingsOpen$: settingsOpen$,
|
||||
setSettingsOpen$: setSettingsOpen$,
|
||||
edgeToEdge$,
|
||||
|
||||
74
src/state/layout-types.test.ts
Normal file
74
src/state/layout-types.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
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,6 +16,7 @@ import {
|
||||
type SpotlightTileViewModel,
|
||||
} from "./TileViewModel.ts";
|
||||
import { type Behavior } from "./Behavior.ts";
|
||||
import { shallowEquals as arrayShallowEquals } from "../utils/array.ts";
|
||||
|
||||
export interface GridLayoutMedia {
|
||||
type: "grid";
|
||||
@@ -140,3 +141,30 @@ export type Layout =
|
||||
| OneOnOneDesktopLayout
|
||||
| OneOnOneMobileLayout
|
||||
| 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user