Compare commits

...
Author SHA1 Message Date
Robin aa8f73e632 Fix Vite warning 2026-10-08 14:23:59 +02:00
Johannes Marbach 14c561af8d Merge pull request #4300 from element-hq/johannes/pure-layout-functions
Make the layout decisions pure / testable functions
2026-10-08 13:20:44 +02:00
Johannes Marbach 4fd2f66949 Explain why we use merge 2026-10-08 11:14:26 +02:00
Johannes Marbach 53e038b09b layoutShallowEquals -> shallowEquals 2026-10-08 10:57:03 +02:00
renovate[bot] 3b7ccb63e2 Update ghcr.io/element-hq/element-web:develop Docker digest to 53a4fe6 (#4334)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-08 02:17:28 +00:00
Johannes Marbach 1f07f949b5 Merge pull request #4330 from element-hq/renovate/npm-postcss-selector-parser-vulnerability
Update dependency postcss-selector-parser to v7.1.6 [SECURITY]
2026-10-07 14:59:28 +02:00
renovate[bot] a60036b3d7 Update dependency postcss-selector-parser to v7.1.6 [SECURITY] 2026-10-07 12:37:14 +00:00
Timo 2eb3a2fd2d Merge pull request #4329 from element-hq/toger5/remove-lk-client-from-external-deps
Remove livekit-client from the list of external dependencies
2026-10-07 14:34:47 +02:00
Timo K. 8b6adf067e remove unnecassary (confused) comment 2026-10-07 14:20:42 +02:00
Timo K. b408610277 Remove livekit-client from the list of external dependencies 2026-10-07 12:01:21 +02:00
Johannes Marbach 6600c891f3 Extract the layout decision into a pure function 2026-09-28 17:36:24 +02:00
Johannes Marbach 33cfd2d8ad Build the layout media from one snapshot of its inputs 2026-09-28 17:34:22 +02:00
Johannes Marbach 5a8714805c Extract the spotlight and PiP choice into a pure function 2026-09-28 17:29:41 +02:00
Johannes Marbach 232e3f7ad4 Extract the spotlight speaker choice into a pure function 2026-09-28 17:28:14 +02:00
Johannes Marbach 10adf6658b Let layoutShallowEquals compare any layout-shaped object 2026-09-28 17:27:04 +02:00
Johannes Marbach 29742006df Move WindowMode next to the layout types to prevent cyclic import 2026-09-28 17:26:11 +02:00
15 changed files with 615 additions and 357 deletions
+3 -2
View File
@@ -306,9 +306,10 @@ directory target it rather than the repository; run them from the repository
root. The host imports the component from
`@element-hq/element-call-component` and the stylesheet from
`@element-hq/element-call-component/style.css`, and has to provide `react`,
`react-dom`, `matrix-js-sdk`, `livekit-client`, `@vector-im/compound-web` and
`react-dom`, `matrix-js-sdk`, `@vector-im/compound-web` and
`@vector-im/compound-design-tokens` itself, since the bundle leaves them
external. Compound's stylesheet are the host's to load too: the component shares
external.
Compound's stylesheet are the host's to load too: the component shares
the host's copy of Compound, (required for example for tooltips)
The component is large, and a host will usually load it lazily, only once a
-1
View File
@@ -40,7 +40,6 @@
"peerDependencies": {
"@vector-im/compound-design-tokens": "^11.0.0",
"@vector-im/compound-web": "^10.0.0",
"livekit-client": "^2.18.1",
"matrix-js-sdk": "*",
"react": "^19",
"react-dom": "^19"
+2 -2
View File
@@ -19,7 +19,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:589c4909fd5844a9db7dd730f9796186eabbb981580cdf9bfa597186a2fb81f3
image: ghcr.io/element-hq/element-web:develop@sha256:53a4fe6d60afc0647b95d9e1365d696679241821d63da94dfbfc33171a42ed6d
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:589c4909fd5844a9db7dd730f9796186eabbb981580cdf9bfa597186a2fb81f3
image: ghcr.io/element-hq/element-web:develop@sha256:53a4fe6d60afc0647b95d9e1365d696679241821d63da94dfbfc33171a42ed6d
+3 -1
View File
@@ -76,8 +76,10 @@ Code that builds in only one is a bug.
- `build:component` — `@element-hq/element-call-component`, sources in `component/`
(its own pnpm project; run pnpm from the repo root). Host API is in the README;
`pnpm lint:externals` rejects an import of a `react` / `react-dom` /
`matrix-js-sdk` / `livekit-client` / `@vector-im/compound-web` /
`matrix-js-sdk` / `@vector-im/compound-web` /
`@vector-im/compound-design-tokens` subpath the externals list omits.
`livekit-client` is bundled on purpose: no host imports it, and the pinned
version travels with the component.
Compound's stylesheets are loaded by `src/index.css` for the page and by the
host for the component, never by `src/base.css`. Two entry
points: `index.tsx` (the component) and `api.ts` (types, enums and
+26 -26
View File
@@ -222,7 +222,7 @@ importers:
version: 10.6.1(postcss@8.5.26)
postcss-selector-parser:
specifier: ^7.1.1
version: 7.1.3
version: 7.1.6
posthog-js:
specifier: 1.408.2
version: 1.408.2
@@ -5160,8 +5160,8 @@ packages:
peerDependencies:
postcss: ^8.4
postcss-selector-parser@7.1.3:
resolution: {integrity: sha512-ajnd7iZnqjJDkyHNfznl/ZVO0lWqvBmQXfKKENx9/p/bEiF/L3eHwdydNUg9RXZx6xfZWOCmXmBa5oeB+YrAPQ==}
postcss-selector-parser@7.1.6:
resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
engines: {node: '>=4'}
postcss-value-parser@4.2.0:
@@ -6397,9 +6397,9 @@ snapshots:
'@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.26)':
dependencies:
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
'@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.26)':
dependencies:
@@ -6505,9 +6505,9 @@ snapshots:
'@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.26)':
dependencies:
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
'@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.26)':
dependencies:
@@ -6609,7 +6609,7 @@ snapshots:
'@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.26)':
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
'@csstools/postcss-sign-functions@1.1.4(postcss@8.5.26)':
dependencies:
@@ -6653,13 +6653,13 @@ snapshots:
dependencies:
postcss: 8.5.26
'@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.3)':
'@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.6)':
dependencies:
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
'@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.3)':
'@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.6)':
dependencies:
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
'@csstools/utilities@2.0.0(postcss@8.5.26)':
dependencies:
@@ -9216,13 +9216,13 @@ snapshots:
css-blank-pseudo@7.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
css-has-pseudo@7.0.3(postcss@8.5.26):
dependencies:
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
postcss-value-parser: 4.2.0
css-prefers-color-scheme@10.0.0(postcss@8.5.26):
@@ -10666,7 +10666,7 @@ snapshots:
postcss-attribute-case-insensitive@7.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
postcss-clamp@4.1.0(postcss@8.5.26):
dependencies:
@@ -10717,12 +10717,12 @@ snapshots:
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
postcss-dir-pseudo-class@9.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
postcss-double-position-gradients@6.0.4(postcss@8.5.26):
dependencies:
@@ -10734,12 +10734,12 @@ snapshots:
postcss-focus-visible@10.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
postcss-focus-within@9.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
postcss-font-variant@5.0.0(postcss@8.5.26):
dependencies:
@@ -10771,10 +10771,10 @@ snapshots:
postcss-nesting@13.0.2(postcss@8.5.26):
dependencies:
'@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.3)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
'@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.6)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
postcss-opacity-percentage@3.0.0(postcss@8.5.26):
dependencies:
@@ -10872,7 +10872,7 @@ snapshots:
postcss-pseudo-class-any-link@10.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
postcss-replace-overflow-wrap@4.0.0(postcss@8.5.26):
dependencies:
@@ -10881,9 +10881,9 @@ snapshots:
postcss-selector-not@8.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.3
postcss-selector-parser: 7.1.6
postcss-selector-parser@7.1.3:
postcss-selector-parser@7.1.6:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
+1 -2
View File
@@ -9,7 +9,7 @@ Please see LICENSE in the repository root for full details.
* Checks that the component build leaves the packages a host must supply to
* the host.
*
* A host application already has React, the Matrix SDK, LiveKit and Compound,
* A host application already has React, the Matrix SDK and Compound,
* and a second copy of any of them is worse than dead weight: React would hold
* two sets of hooks, the Matrix client would run two sync loops, and a second
* Compound would style the tooltips it floats into the host's body with class
@@ -44,7 +44,6 @@ const MUST_BE_EXTERNAL = [
"react",
"react-dom",
"matrix-js-sdk",
"livekit-client",
"@vector-im/compound-web",
"@vector-im/compound-design-tokens",
];
+57 -218
View File
@@ -30,7 +30,6 @@ import {
scan,
startWith,
Subject,
switchAll,
switchMap,
switchScan,
take,
@@ -83,6 +82,7 @@ import {
type ReactionOption,
} from "../../reactions";
import { shallowEquals as shallowArrayEquals } from "../../utils/array";
import { shallowEquals as shallowObjectEquals } from "../../utils/object";
import { type MediaDevices } from "../MediaDevices";
import { constant, type Behavior } from "../Behavior";
import { E2eeType } from "../../e2ee/e2eeType";
@@ -92,17 +92,17 @@ import { HeaderStyle, type UrlParams } from "../../UrlParams";
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { type HostBridge, nullHostBridge } from "../../HostBridge";
import {
layoutShallowEquals,
type Alignment,
type GridLayoutMedia,
type Layout,
type LayoutMedia,
type OneOnOneDesktopLayoutMedia,
type OneOnOneMobileLayoutMedia,
type SpotlightExpandedLayoutMedia,
type SpotlightLandscapeLayoutMedia,
type SpotlightPortraitLayoutMedia,
type WindowMode,
} from "../layout-types.ts";
import {
chooseSpotlightSpeaker,
computeLayoutMedia,
computeSpotlight,
type OneOnOneMedia,
} from "../layoutMedia.ts";
import { ElementCallError, UnknownCallError } from "../../utils/errors.ts";
import { type Epoch, type ObservableScope } from "../ObservableScope.ts";
import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts";
@@ -263,21 +263,10 @@ export function callViewModelOptionsFromParams(
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
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
// with the interface
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 {
layout: Layout | null;
overflowing: boolean;
@@ -981,33 +970,16 @@ export function createCallViewModel$(
mediaItems.length === 0
? of([])
: combineLatest(
mediaItems.map((m) =>
m.speaking$.pipe(map((s) => [m, s] as const)),
mediaItems.map((media) =>
media.speaking$.pipe(map((speaking) => ({ media, speaking }))),
),
),
),
scan<
(readonly [UserMediaViewModel, boolean])[],
{ media: UserMediaViewModel; speaking: boolean }[],
UserMediaViewModel | undefined,
undefined
>((prev, mediaItems) => {
// 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),
>(chooseSpotlightSpeaker, undefined),
),
);
@@ -1051,33 +1023,12 @@ export function createCallViewModel$(
),
);
const spotlightAndPip$ = scope.behavior<{
spotlight: MediaViewModel[];
pip$: Observable<UserMediaViewModel | undefined>;
}>(
ringingMedia$.pipe(
switchMap((ringingMedia) => {
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 spotlightAndPip$ = scope.behavior(
combineLatest(
[ringingMedia$, screenShares$, spotlightSpeaker$, localUserMediaForPip$],
(ringing, screenShares, speaker, localPip) =>
computeSpotlight({ ringing, screenShares, speaker, localPip }),
).pipe(distinctUntilChanged(shallowObjectEquals)),
);
const spotlight$ = scope.behavior<MediaViewModel[]>(
@@ -1135,56 +1086,7 @@ export function createCallViewModel$(
hasRemoteScreenShares$,
);
const gridLayoutMedia$: Observable<GridLayoutMedia> = combineLatest(
[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(
const oneOnOneLayoutMedia$: Behavior<OneOnOneMedia | null> = scope.behavior(
combineLatest([userMedia$, screenShares$]).pipe(
switchMap(([userMedia, screenShares]) => {
// 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(
map((media) => {
if (media === null) return null;
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,
})),
switchMap((media) =>
media === null ? of(false) : media.local.videoEnabled$,
),
),
);
spotlight$
@@ -1297,66 +1164,38 @@ export function createCallViewModel$(
* The media to be used to produce a layout.
*/
const layoutMedia$ = scope.behavior<LayoutMedia>(
windowMode$.pipe(
switchMap((windowMode) => {
switch (windowMode) {
case "normal":
return layoutSwitchVm.layout$.pipe(
switchMap((layout) => {
switch (layout) {
case "grid":
return oneOnOneDesktopLayoutMedia$.pipe(
switchMap((oneOnOne) =>
oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne),
),
);
case "spotlight":
return spotlightExpanded$.pipe(
switchMap((expanded) =>
expanded
? spotlightExpandedLayoutMedia$(false)
: spotlightLandscapeLayoutMedia$(false),
),
);
}
}),
);
case "narrow":
return oneOnOneMobileLayoutMedia$.pipe(
switchMap((oneOnOne) =>
oneOnOne === null
? combineLatest([grid$, spotlight$], (grid, spotlight) =>
grid.length > smallMobileCallThreshold ||
spotlight.some((vm) => vm.type === "screen share")
? spotlightPortraitLayoutMedia$
: 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$;
}
}),
// We deliberately don't use combineLatest here. Several of the inputs
// below derive from the same source. So one upstream change cascades
// through them one at a time. combineLatest would see each step of the
// cascade and emit layouts built from inputs that never coexisted.
// Instead, we treat the emissions as a mere "something changed" signal
// and read every input's current value. The derived behaviors were
// subscribed before this one and a BehaviorSubject updates its value
// before notifying. So by the time the first signal arrives every value
// is already consistent. The duplicate signals from the rest of the
// cascade are dropped by distinctUntilChanged.
merge(
windowMode$,
layoutSwitchVm.layout$,
spotlightExpanded$,
oneOnOneLayoutMedia$,
localVideoEnabled$,
spotlightAndPip$,
grid$,
).pipe(
map(() =>
computeLayoutMedia({
windowMode: windowMode$.value,
layoutMode: layoutSwitchVm.layout$.value,
spotlightExpanded: spotlightExpanded$.value,
oneOnOne: oneOnOneLayoutMedia$.value,
localVideoEnabled: localVideoEnabled$.value,
...spotlightAndPip$.value,
grid: grid$.value,
desktop: platform === "desktop",
}),
),
distinctUntilChanged(shallowObjectEquals<LayoutMedia>),
),
);
@@ -1657,7 +1496,7 @@ export function createCallViewModel$(
layoutInternals$.pipe(
map(({ layout }) => layout),
// Drop redundant layout updates before they would hit React.
distinctUntilChanged<Layout>(layoutShallowEquals),
distinctUntilChanged(shallowObjectEquals<Layout>),
),
);
+1 -1
View File
@@ -14,7 +14,7 @@ import {
switchAll,
} from "rxjs";
import { type WindowMode } from "./CallViewModel/CallViewModel.ts";
import { type WindowMode } from "./layout-types.ts";
import { constant, type Behavior } from "./Behavior.ts";
import { type ObservableScope } from "./ObservableScope.ts";
-74
View File
@@ -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));
+7 -28
View File
@@ -16,7 +16,13 @@ import {
type SpotlightTileViewModel,
} from "./TileViewModel.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 {
type: "grid";
@@ -141,30 +147,3 @@ 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;
}
+220
View File
@@ -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);
});
});
+216
View File
@@ -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 };
}
+40
View File
@@ -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));
+38
View File
@@ -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;
}
+1 -2
View File
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { defineConfig } from "vite";
import { vitePluginsConfig } from "./vite.config.ts";
import { scopeStylesToRoot } from "./component/build/scopeStylesToRoot";
import { scopeStylesToRoot } from "./component/build/scopeStylesToRoot.ts";
// Config for Element Call as a React component, to be imported by an
// application embedding it rather than served as a page of its own.
@@ -88,7 +88,6 @@ export default defineConfig(({ mode }) => {
"react/compiler-runtime",
"react-dom",
"react-dom/client",
"livekit-client",
"matrix-js-sdk",
"matrix-js-sdk/lib/browser-index",
"matrix-js-sdk/lib/client",