Compare commits

..
Author SHA1 Message Date
Johannes Marbach 08f58cce35 Key connections on rooms 2026-10-27 17:59:27 +01:00
Robin 76da59be40 Merge pull request #4301 from element-hq/floating-tile
Make drag & drop of floating tiles less janky
2026-10-27 12:16:12 +01:00
Robin c6ad8b2ac7 Use the right comparators 2026-10-27 11:45:35 +01:00
Robin d33a1eb059 Merge branch 'floating-tile' of github.com:element-hq/element-call into floating-tile 2026-10-27 11:04:12 +01:00
Robin 9fcba5a704 Skip redundant tile wrapper updates 2026-10-27 10:44:33 +01:00
Johannes Marbach 96333dbc3a Merge branch 'main' into floating-tile 2026-10-26 13:46:54 +01:00
Robin ed6a0e1341 Merge pull request #4371 from element-hq/ellipsize-title
Ellipsise app bar text on mobile
2026-10-26 13:45:06 +01:00
renovate[bot]andJohannes Marbach 90ccf62d5c Update ghcr.io/element-hq/element-web:develop Docker digest to c9ffe3e (#4356)
* Update ghcr.io/element-hq/element-web:develop Docker digest to 79e47d8

* Adapt to new recovery popup button title

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-10-26 12:44:39 +00:00
Robin 9ec0cef9c5 Merge pull request #4370 from element-hq/reintroduce-livekit-alias
Reintroduce livekit_alias in published transports
2026-10-26 12:39:14 +01:00
Robin c248ff4280 Don't add livekit_alias to newer transport format 2026-10-26 11:41:45 +01:00
Robin 0f1d0941d0 Reference matrix-rust-sdk PR making livekit_alias optional, too 2026-10-26 11:17:16 +01:00
Robin 7e6e1698c0 Reintroduce livekit_alias in published transports
Some Ruma-based clients still expect it to be present. We should remove it again as soon as https://github.com/ruma/ruma/pull/2642 is widely deployed.
2026-10-26 10:57:49 +01:00
Robin 4441bb5e8c Add snapshot coverage for all call layouts
Snapshotting the layout templates so that they get reviewed as a whole feels a little more straightforward than the existing tests.
2026-09-29 03:20:14 +02:00
Robin f2f49315eb Make drag & drop performant in all layouts
While also giving the logic for drag & drop of a floating tile a common home in the view model layer.
2026-09-28 19:53:30 +02:00
Robin 99256fa01c Make drag & drop of floating tiles less janky
This fixes an issue where upon dragging a floating tile to a different corner of the interface, it would briefly move toward its original position before jerking back in the intended direction.
2026-09-28 19:48:49 +02:00
39 changed files with 1542 additions and 424 deletions
+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:b2a5a30afb4f583a7560448b005d8332bee29672f15826e4a995b393058f0291
image: ghcr.io/element-hq/element-web:develop@sha256:79e47d8d5a93848403cb83042a405d8ed233a7727c499fa53b4b39a80f090be4
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:b2a5a30afb4f583a7560448b005d8332bee29672f15826e4a995b393058f0291
image: ghcr.io/element-hq/element-web:develop@sha256:79e47d8d5a93848403cb83042a405d8ed233a7727c499fa53b4b39a80f090be4
+6 -2
View File
@@ -159,7 +159,7 @@ export class TestHelpers {
public static async dismissStartupToasts(page: Page): Promise<void> {
const expectedToasts = [
{ title: "Failed to load service worker", button: "OK" },
{ title: "Back up your chats", button: "Dismiss" },
{ title: "Back up your chats", button: "Not now" },
{ title: "Turn on key storage", button: "Dismiss" },
{ title: "Element does not support this browser", button: "Dismiss" },
];
@@ -178,7 +178,11 @@ export class TestHelpers {
);
if (toastConfig) {
await toast.getByRole("button", { name: toastConfig.button }).click();
// Bounded so that a renamed button leaves the toast in place and the
// next step reports it, instead of hanging until the test timeout.
await toast
.getByRole("button", { name: toastConfig.button })
.click({ timeout: 5000 });
} else {
// Unknown toast. We don't want to act on unknown toasts
break;
+9
View File
@@ -0,0 +1,9 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: false
excludeLinksFromLockfile: false
importers:
.: {}
+10 -3
View File
@@ -12,6 +12,7 @@ import {
MatrixRTCSessionEvent,
MembershipManagerEvent,
type Transport,
type UnstableLivekitTransport,
} from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, EMPTY, Subject } from "rxjs";
import { describe, expect, it, type Mock, vi } from "vitest";
@@ -46,10 +47,16 @@ import {
MockRTCSession,
} from "./utils/test";
const transport: Transport = {
const transport: UnstableLivekitTransport = {
type: "livekit",
livekit_service_url: "https://lk.example.org",
};
const transportWithAlias: Transport = {
livekit_alias: "!room:example.org",
...transport,
};
const userId = "@alice:example.org";
/** A token with the publish grant, as a homeserver issues it for its own SFU. */
const publisherJWT = [
@@ -223,7 +230,7 @@ describe("createRTCParticipation", () => {
await flushPromises();
expect(join).toHaveBeenCalledWith(
{ userId, deviceId, memberId: legacyMemberId },
[transport],
[transportWithAlias],
expect.objectContaining({
manageMediaKeys: true,
unstableSendStickyEvents: false,
@@ -319,7 +326,7 @@ describe("createRTCParticipation", () => {
await flushPromises();
expect(join).toHaveBeenCalledWith(
expect.anything(),
[transport],
[transportWithAlias],
expect.objectContaining({ delayedLeaveEventDelayMs: 7_200_000 }),
);
expect(fake.delegateDelayedLeave).not.toHaveBeenCalled();
+14 -8
View File
@@ -174,14 +174,20 @@ export function createRTCParticipation(
timings.syncDisconnectGracePeriodMs,
),
joinMatrixRTC: (transport, delayedLeave) =>
joinJsSdkSession(jsSdkSession, ownMembershipIdentity, transport, {
encryptMedia: encryptionSystem.kind !== E2eeType.NONE,
matrixRTCMode,
sendNotificationType: options.sendNotificationType,
applicationData: options.applicationData,
timings,
delayedLeave,
}),
joinJsSdkSession(
room.roomId,
jsSdkSession,
ownMembershipIdentity,
transport,
{
encryptMedia: encryptionSystem.kind !== E2eeType.NONE,
matrixRTCMode,
sendNotificationType: options.sendNotificationType,
applicationData: options.applicationData,
timings,
delayedLeave,
},
),
membershipManagerError$: fromEvent(
jsSdkSession,
MatrixRTCSessionEvent.MembershipManagerError,
+35 -6
View File
@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import {
type UnstableLivekitTransport,
type MatrixRTCSession,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
@@ -15,11 +16,16 @@ import { defaultSessionTimings, MatrixRTCMode } from "../config";
import { ownMemberMock } from "../utils/test";
import { joinJsSdkSession } from "./joinJsSdkSession";
const transport: Transport = {
const transport: UnstableLivekitTransport = {
type: "livekit",
livekit_service_url: "https://lk.example.org",
};
const transportWithAlias: Transport = {
livekit_alias: "!room:example.org",
...transport,
};
/** A session that only records the join; nothing else of it is touched. */
function session(): MatrixRTCSession & {
join: Mock<MatrixRTCSession["join"]>;
@@ -39,7 +45,7 @@ describe("joinJsSdkSession", () => {
keyRotationParticipantLimit: 20,
};
joinJsSdkSession(s, ownMemberMock, transport, {
joinJsSdkSession("!room:example.org", s, ownMemberMock, transport, {
encryptMedia: true,
matrixRTCMode: MatrixRTCMode.Compatibility,
sendNotificationType: "ring",
@@ -51,7 +57,7 @@ describe("joinJsSdkSession", () => {
},
});
expect(s.join).toHaveBeenCalledWith(ownMemberMock, [transport], {
expect(s.join).toHaveBeenCalledWith(ownMemberMock, [transportWithAlias], {
notificationType: "ring",
callIntent: undefined,
applicationData: {},
@@ -72,7 +78,7 @@ describe("joinJsSdkSession", () => {
it("gives up on the network once the delayed leave has probably been sent", () => {
const s = session();
joinJsSdkSession(s, ownMemberMock, transport, {
joinJsSdkSession("!room:example.org", s, ownMemberMock, transport, {
encryptMedia: false,
matrixRTCMode: MatrixRTCMode.Compatibility,
timings: { ...defaultSessionTimings, networkErrorRetryMs: 4_000 },
@@ -81,7 +87,7 @@ describe("joinJsSdkSession", () => {
expect(s.join).toHaveBeenCalledWith(
ownMemberMock,
[transport],
[transportWithAlias],
expect.objectContaining({
manageMediaKeys: false,
maximumNetworkErrorRetryCount: 4,
@@ -92,7 +98,30 @@ describe("joinJsSdkSession", () => {
it("lifts the call intent out of the application data and sends sticky events in Matrix 2.0 mode", () => {
const s = session();
joinJsSdkSession(s, ownMemberMock, transport, {
joinJsSdkSession("!room:example.org", s, ownMemberMock, transport, {
encryptMedia: true,
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
applicationData: { "m.call.intent": "audio", custom: 1 },
timings: defaultSessionTimings,
delayedLeave: defaultSessionTimings.delayedLeave,
});
expect(s.join).toHaveBeenCalledWith(
ownMemberMock,
[transportWithAlias],
expect.objectContaining({
callIntent: "audio",
applicationData: { custom: 1 },
unstableSendStickyEvents: true,
}),
);
});
it("does not add livekit_alias to newer transport format", () => {
const s = session();
const transport = { type: "livekit", url: "wss://lk.example.org" };
joinJsSdkSession("!room:example.org", s, ownMemberMock, transport, {
encryptMedia: true,
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
applicationData: { "m.call.intent": "audio", custom: 1 },
+34 -16
View File
@@ -35,6 +35,7 @@ interface Options {
* @throws If the join could not be started.
*/
export function joinJsSdkSession(
roomId: string,
session: JsSdkRTCSession,
ownMembershipIdentity: CallMembershipIdentityParts,
transport: Transport,
@@ -58,20 +59,37 @@ export function joinJsSdkSession(
timings.syncDisconnectGracePeriodMs,
delayedLeave.delay_ms,
);
session.join(ownMembershipIdentity, [transport], {
notificationType: sendNotificationType,
callIntent,
applicationData: rest,
manageMediaKeys: encryptMedia,
delayedLeaveEventRestartMs: delayedLeave.restart_ms,
delayedLeaveEventDelayMs: delayedLeave.delay_ms,
delayedLeaveEventRestartLocalTimeoutMs: delayedLeave.restart_timeout_ms,
networkErrorRetryMs: timings.networkErrorRetryMs,
makeKeyDelay: timings.waitForKeyRotationMs,
membershipEventExpiryMs: timings.membershipEventExpiryMs,
keyRotationParticipantLimit: timings.keyRotationParticipantLimit,
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
maximumNetworkErrorRetryCount:
Math.ceil(maxWaitMs / timings.networkErrorRetryMs) + 1,
});
session.join(
ownMembershipIdentity,
// TODO: `livekit_alias` should be unused these days, but some Ruma-based
// clients still expect it to be present. Remove from `publishedTransports`
// as soon as https://github.com/ruma/ruma/pull/2642 and
// https://github.com/matrix-org/matrix-rust-sdk/pull/7335 are widely
// deployed.
[
{
...("livekit_service_url" in transport
? { livekit_alias: roomId }
: {}),
...transport,
},
],
{
notificationType: sendNotificationType,
callIntent,
applicationData: rest,
manageMediaKeys: encryptMedia,
delayedLeaveEventRestartMs: delayedLeave.restart_ms,
delayedLeaveEventDelayMs: delayedLeave.delay_ms,
delayedLeaveEventRestartLocalTimeoutMs: delayedLeave.restart_timeout_ms,
networkErrorRetryMs: timings.networkErrorRetryMs,
makeKeyDelay: timings.waitForKeyRotationMs,
membershipEventExpiryMs: timings.membershipEventExpiryMs,
keyRotationParticipantLimit: timings.keyRotationParticipantLimit,
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
maximumNetworkErrorRetryCount:
Math.ceil(maxWaitMs / timings.networkErrorRetryMs) + 1,
},
);
}
@@ -44,6 +44,7 @@ import { type ClientGetTokenParts, type ClientOpenIDParts } from "./auth";
import { MatrixRTCError } from "../../errors";
import { testJWTToken } from "../../utils/test-fixtures";
import { ownMemberMock } from "../../utils/test";
import { LivekitRooms, livekitRoomKey } from "./LivekitRooms";
import { mockRemoteParticipant } from "./test";
let testScope: ObservableScope;
@@ -74,6 +75,7 @@ function connectionOpts(): ConnectionOpts {
serverName: "m.localhost",
scope: testScope,
ownMembershipIdentity: ownMemberMock,
rooms: new LivekitRooms(logger),
livekitRoomFactory: () => fakeLivekitRoom,
};
}
@@ -711,3 +713,161 @@ describe("remote track logging", () => {
// });
// });
// });
describe("LiveKit rooms", () => {
const roomKey = livekitRoomKey(transport.url!, "!example_room_id");
const otherConnection = (): Connection =>
({ role: "subscriber", stop: vi.fn() }) as unknown as Connection;
it("waits for the room its token is for, and connects with a fresh token once it holds it", async () => {
setupTest();
const rooms = new LivekitRooms(logger);
const holder = otherConnection();
await rooms.claim(holder, roomKey);
fakeLivekitRoom.connect.mockResolvedValue(undefined);
const connection = new Connection({ ...connectionOpts(), rooms }, logger);
const started = connection.start();
await vi.waitFor(() =>
expect(rooms.holders$.value.get(connection)).toBe(holder),
);
expect(fakeLivekitRoom.connect).not.toHaveBeenCalled();
expect(client._unstable_getLivekitToken).toHaveBeenCalledTimes(1);
rooms.release(holder);
await started;
expect(client._unstable_getLivekitToken).toHaveBeenCalledTimes(2);
expect(fakeLivekitRoom.connect).toHaveBeenCalledWith(
transport.url,
testJWTToken,
);
expect(rooms.holders$.value.get(connection)).toBe(connection);
});
it("connects at once with the token it has where the room is free", async () => {
setupTest();
const rooms = new LivekitRooms(logger);
fakeLivekitRoom.connect.mockResolvedValue(undefined);
const connection = new Connection({ ...connectionOpts(), rooms }, logger);
await connection.start();
expect(client._unstable_getLivekitToken).toHaveBeenCalledTimes(1);
expect(fakeLivekitRoom.connect).toHaveBeenCalled();
});
it("gives the room up when stopped, without connecting", async () => {
setupTest();
const rooms = new LivekitRooms(logger);
const holder = otherConnection();
await rooms.claim(holder, roomKey);
fakeLivekitRoom.disconnect.mockResolvedValue(undefined);
const connection = new Connection({ ...connectionOpts(), rooms }, logger);
const started = connection.start();
await vi.waitFor(() =>
expect(rooms.holders$.value.get(connection)).toBe(holder),
);
await connection.stop();
await started;
expect(fakeLivekitRoom.connect).not.toHaveBeenCalled();
expect(connection.state$.value).toEqual({ state: "stopped" });
rooms.release(holder);
expect(rooms.holders$.value.get(connection)).toBeUndefined();
});
it("hands the room on only once it has left the SFU", async () => {
setupTest();
const rooms = new LivekitRooms(logger);
fakeLivekitRoom.connect.mockResolvedValue(undefined);
const disconnected = Promise.withResolvers<void>();
fakeLivekitRoom.disconnect.mockReturnValue(disconnected.promise);
const connection = new Connection({ ...connectionOpts(), rooms }, logger);
await connection.start();
const waiter = otherConnection();
const claim = rooms.claim(waiter, roomKey);
const stopping = connection.stop();
await Promise.resolve();
expect(rooms.holderOf(roomKey)).toBe(connection);
disconnected.resolve();
await stopping;
await claim;
expect(rooms.holderOf(roomKey)).toBe(waiter);
});
it("gives the room up when the connection fails", async () => {
setupTest();
const rooms = new LivekitRooms(logger);
fakeLivekitRoom.connect.mockRejectedValue(new Error("no"));
const connection = new Connection({ ...connectionOpts(), rooms }, logger);
await expect(connection.start()).rejects.toThrow();
expect(rooms.holders$.value.size).toBe(0);
});
it("connects once the connection holding its room stops", async () => {
const rooms = new LivekitRooms(logger);
setupTest();
const holderRoom = fakeLivekitRoom;
holderRoom.connect.mockResolvedValue(undefined);
holderRoom.disconnect.mockResolvedValue(undefined);
const holder = new Connection(
{ ...connectionOpts(), rooms, livekitRoomFactory: () => holderRoom },
logger,
);
await holder.start();
setupTest();
const waiterRoom = fakeLivekitRoom;
waiterRoom.connect.mockResolvedValue(undefined);
const waiter = new Connection(
{ ...connectionOpts(), rooms, livekitRoomFactory: () => waiterRoom },
logger,
);
const started = waiter.start();
await vi.waitFor(() =>
expect(rooms.holders$.value.get(waiter)).toBe(holder),
);
expect(waiterRoom.connect).not.toHaveBeenCalled();
await holder.stop();
await started;
expect(waiterRoom.connect).toHaveBeenCalled();
expect(rooms.holderOf(roomKey)).toBe(waiter);
});
it("lets a publisher take the room over, stopping the subscriber on it", async () => {
const rooms = new LivekitRooms(logger);
setupTest();
const subscriberRoom = fakeLivekitRoom;
subscriberRoom.connect.mockResolvedValue(undefined);
subscriberRoom.disconnect.mockResolvedValue(undefined);
const subscriber = new Connection(
{ ...connectionOpts(), rooms, livekitRoomFactory: () => subscriberRoom },
logger,
);
await subscriber.start();
setupTest();
const publisherRoom = fakeLivekitRoom;
publisherRoom.connect.mockResolvedValue(undefined);
const publisher = new Connection(
{
...connectionOpts(),
role: "publisher",
rooms,
livekitRoomFactory: () => publisherRoom,
},
logger,
);
await publisher.start();
expect(publisherRoom.connect).toHaveBeenCalled();
expect(subscriberRoom.disconnect).toHaveBeenCalled();
await vi.waitFor(() =>
expect(subscriber.state$.value).toEqual({ state: "stopped" }),
);
expect(rooms.holders$.value.get(subscriber)).toBe(publisher);
expect(rooms.holders$.value.get(publisher)).toBe(publisher);
});
});
+24 -2
View File
@@ -45,6 +45,7 @@ import {
SFURoomCreationRestrictedError,
UnknownRTCError,
} from "../../errors";
import { type LivekitRooms, livekitRoomKey } from "./LivekitRooms";
export interface ConnectionOpts {
/** The identity parts to use on this connection */
@@ -63,6 +64,8 @@ export interface ConnectionOpts {
slotId: string;
/** The observable scope to use for this connection. */
scope: ObservableScope;
/** The room / connection mapping. */
rooms: LivekitRooms;
/** Optional factory to create the LiveKit room, mainly for testing purposes. */
livekitRoomFactory: () => LivekitRoom;
@@ -137,6 +140,7 @@ export class Connection {
private readonly slotId: string;
private readonly logger: Logger;
private readonly ownMembershipIdentity: CallMembershipIdentityParts;
private readonly rooms: LivekitRooms;
/**
* Creates a new connection to a matrix RTC LiveKit backend.
*
@@ -160,6 +164,7 @@ export class Connection {
this.transport = transport;
this.serverName = serverName;
this.client = client;
this.rooms = opts.rooms;
this.remoteParticipants$ = scope.behavior(
// Only tracks remote participants
@@ -287,7 +292,9 @@ export class Connection {
* This will:
* 1. Authenticate with the transport for our role, through the homeserver
* or the JWT service.
* 2. Connect to the LiveKit room the token is for.
* 2. Claim the LiveKit room the token is for, waiting while another
* connection holds it.
* 3. Connect to the room.
*
* The errors are also represented as a state in the `state$` observable.
* It is safe to ignore those errors and handle them accordingly via the `state$` observable.
@@ -300,7 +307,7 @@ export class Connection {
this.stopped = false;
try {
this._state$.next({ state: "authenticating" });
const { url, jwt, livekitAlias } = await this.getSFUConfig();
let { url, jwt, livekitAlias } = await this.getSFUConfig();
this.logger.debug(
"Starting Connection - jwt: ",
jwt,
@@ -313,6 +320,18 @@ export class Connection {
// If we were stopped while fetching the config, don't proceed to connect
if (this.stopped) return;
// Try to claim the room.
const waited = await this.rooms.claim(
this,
livekitRoomKey(url, livekitAlias),
);
if (this.stopped) return;
if (waited) {
// Get a new token since it may have expired in the meantime.
({ url, jwt, livekitAlias } = await this.getSFUConfig());
if (this.stopped) return;
}
// Setup observer once we are done with getSFUConfig
connectionStateObserver(this.livekitRoom)
.pipe(this.scope.bind())
@@ -368,6 +387,7 @@ export class Connection {
return;
}
this.logger.debug(`Failed to connect to LiveKit room: ${error}`);
this.rooms.release(this);
this._state$.next({
state: "error",
error:
@@ -408,6 +428,8 @@ export class Connection {
// disconnect sees the flag and does not report the abort as an error.
this.stopped = true;
await this.livekitRoom.disconnect();
// Release the room after disconnecting so that the next holder never meets us on the SFU.
this.rooms.release(this);
this._state$.next({ state: "stopped" });
this.logger.debug("stop: DONE disconnecting from lk room");
}
@@ -23,6 +23,7 @@ import {
LivekitConnectionFactory,
type ResolvedConnection,
} from "./ConnectionFactory";
import { LivekitRooms } from "./LivekitRooms";
import { exampleTransport } from "./test";
const urlTransport: UnstableLivekitTransport = {
@@ -56,6 +57,7 @@ function create(
constant(audioOutputDeviceId),
undefined,
mediaQuality,
new LivekitRooms(logger),
);
const connection = factory.createConnection(
testScope(),
@@ -31,6 +31,7 @@ import {
} from "./auth";
import { Connection, type ConnectionOpts } from "./Connection";
import { buildLiveKitOptions } from "./livekitOptions";
import { type LivekitRooms } from "./LivekitRooms";
import {
audioCaptureOptions,
type PublishRequestFor,
@@ -108,6 +109,7 @@ export class LivekitConnectionFactory implements ConnectionFactory {
private readonly audioOutputDeviceId$: Behavior<string | undefined>,
private readonly keyProvider: BaseKeyProvider | undefined,
private readonly mediaQuality: MediaQuality | undefined,
private readonly rooms: LivekitRooms,
) {}
public createConnection(
@@ -127,6 +129,7 @@ export class LivekitConnectionFactory implements ConnectionFactory {
serverName,
client: this.client,
scope,
rooms: this.rooms,
livekitRoomFactory: () =>
new LivekitRoom(
roomOptions(
@@ -22,6 +22,7 @@ import {
keyFor,
} from "./ConnectionMap.ts";
import { type ConnectionFactory } from "./ConnectionFactory.ts";
import { LivekitRooms, livekitRoomKey } from "./LivekitRooms";
import { type Connection } from "./Connection.ts";
import { ownMemberMock, withTestScheduler } from "../../utils/test";
import { type Behavior } from "../../reactive/Behavior";
@@ -101,6 +102,7 @@ describe("connections$ stream", () => {
remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
}),
rooms: new LivekitRooms(logger),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
@@ -138,6 +140,7 @@ describe("connections$ stream", () => {
remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_2], 0),
}),
rooms: new LivekitRooms(logger),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
@@ -180,6 +183,7 @@ describe("connections$ stream", () => {
remoteTransports$: behavior("a", {
a: new Epoch([sameServiceElsewhere], 0),
}),
rooms: new LivekitRooms(logger),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
@@ -224,6 +228,7 @@ describe("connections$ stream", () => {
remoteTransports$: behavior("a", {
a: new Epoch([sameUrlElsewhere], 0),
}),
rooms: new LivekitRooms(logger),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
@@ -257,6 +262,7 @@ describe("connections$ stream", () => {
e: new Epoch([TRANSPORT_1], 4),
f: new Epoch([TRANSPORT_1, TRANSPORT_2], 5),
}),
rooms: new LivekitRooms(logger),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
@@ -297,6 +303,7 @@ describe("connections$ stream", () => {
b: new Epoch([TRANSPORT_1, TRANSPORT_2], 1),
c: new Epoch([TRANSPORT_1], 2),
}),
rooms: new LivekitRooms(logger),
logger: logger,
ownMembershipIdentity: ownMemberMock,
});
@@ -436,6 +443,7 @@ describe("connectionMap$", () => {
remoteTransports$: behavior("a", {
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
}),
rooms: new LivekitRooms(logger),
logger,
ownMembershipIdentity: ownMemberMock,
});
@@ -491,3 +499,58 @@ describe("connectionMap$", () => {
});
});
});
describe("one connection per LiveKit room", () => {
const room = livekitRoomKey("wss://sfu.example.org", "alias");
test("reads a transport through the connection holding its room", async () => {
const rooms = new LivekitRooms(logger);
const connectionMap$ = createConnectionMap$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
localTransport: TRANSPORT_1,
remoteTransports$: new BehaviorSubject(new Epoch([TRANSPORT_2], 0)),
rooms,
logger,
ownMembershipIdentity: ownMemberMock,
});
const [conn1, conn2] = allCreatedConnections;
const participant = { identity: "a" } as RemoteParticipant;
(conn1.remoteParticipants$ as BehaviorSubject<RemoteParticipant[]>).next([
participant,
]);
expect(
connectionMap$.value.value.getParticipantsForTransport(TRANSPORT_2),
).toEqual([]);
// Both tokens name the same room: the second connection waits for it
await rooms.claim(conn1, room);
void rooms.claim(conn2, room);
const map = connectionMap$.value.value;
expect(map.getConnectionForTransport(TRANSPORT_2)).toBe(conn1);
expect(map.getParticipantsForTransport(TRANSPORT_2)).toEqual([participant]);
expect(map.getConnections()).toEqual([conn1, conn2]);
});
test("forgets a connection with its transport", async () => {
const rooms = new LivekitRooms(logger);
const remoteTransports$ = new BehaviorSubject(new Epoch([TRANSPORT_2], 0));
createConnectionMap$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
localTransport: TRANSPORT_1,
remoteTransports$,
rooms,
logger,
ownMembershipIdentity: ownMemberMock,
});
const [conn1, conn2] = allCreatedConnections;
await rooms.claim(conn1, room);
void rooms.claim(conn2, room);
expect(rooms.holders$.value.size).toBe(2);
remoteTransports$.next(new Epoch([], 1));
expect(rooms.holders$.value).toEqual(new Map([[conn1, conn1]]));
});
});
+39 -8
View File
@@ -21,6 +21,7 @@ import { generateItemsWithEpoch } from "../../reactive/observable";
import { type ConnectionRole, type TransportLocator } from "./auth";
import { type Connection } from "./Connection";
import { type ConnectionFactory } from "./ConnectionFactory";
import { type LivekitRooms } from "./LivekitRooms";
// Branded, so that only values returned by `keyFor` can be used as keys
export type TransportKey = string & { __transportKey: true };
@@ -57,12 +58,17 @@ function removeDuplicateTransports(
return [...map.values()];
}
/** The connections at one moment, with the remote participants seen on each. */
/**
* The connections at one moment, with the remote participants seen on each.
* A transport whose connection waits for a room another connection holds
* reads that room through the holder.
*/
export class ConnectionMap {
private readonly map = new Map<
TransportKey,
{ connection: Connection; participants: RemoteParticipant[] }
>();
private readonly holders = new Map<TransportKey, TransportKey>();
public constructor(private readonly logger?: Logger) {}
@@ -81,20 +87,33 @@ export class ConnectionMap {
}
}
/** Routes a transport's lookups through the connection holding its room. */
public alias(connection: Connection, holder: Connection): void {
this.holders.set(keyFor(connection), keyFor(holder));
}
public getConnections(): Connection[] {
return Array.from(this.map.values()).map(({ connection }) => connection);
}
/** The connection a transport's media arrives on or null while it has none. */
public getConnectionForTransport(
transport: TransportLocator,
): Connection | null {
return this.map.get(keyFor(transport))?.connection ?? null;
return this.entryFor(transport)?.connection ?? null;
}
public getParticipantsForTransport(
transport: TransportLocator,
): RemoteParticipant[] {
return this.map.get(keyFor(transport))?.participants ?? [];
return this.entryFor(transport)?.participants ?? [];
}
private entryFor(
transport: TransportLocator,
): { connection: Connection; participants: RemoteParticipant[] } | undefined {
const key = keyFor(transport);
return this.map.get(this.holders.get(key) ?? key);
}
}
@@ -105,6 +124,8 @@ interface Props {
localTransport: TransportLocator;
/** Every transport some member is on, deduplicated against the local one. */
remoteTransports$: Behavior<Epoch<TransportLocator[]>>;
/** The room / connection mapping. */
rooms: LivekitRooms;
logger: Logger;
ownMembershipIdentity: CallMembershipIdentityParts;
}
@@ -112,13 +133,15 @@ interface Props {
/**
* One connection per transport in use by one or more session members, with
* the remote participants each one sees. The connections start as they are
* created and stop as their transport drops out of the list.
* created and stop as their transport drops out of the list. Transports that
* resolve to one LiveKit room share the connection holding it.
*/
export function createConnectionMap$({
scope,
connectionFactory,
localTransport,
remoteTransports$,
rooms,
logger: parentLogger,
ownMembershipIdentity,
}: Props): Behavior<Epoch<ConnectionMap>> {
@@ -162,6 +185,7 @@ export function createConnectionMap$({
ownMembershipIdentity,
logger,
);
scope.onEnd(() => rooms.forget(connection));
// The state carries the progress, errors included
void connection.start();
return connection;
@@ -175,16 +199,23 @@ export function createConnectionMap$({
switchMap(({ value: connections, epoch }) => {
if (connections.length === 0)
return of(new Epoch(new ConnectionMap(), epoch));
return combineLatest(
const participants$ = combineLatest(
connections.map((connection) =>
connection.remoteParticipants$.pipe(
map((participants) => ({ connection, participants })),
),
),
(...list) => {
);
return combineLatest(
[participants$, rooms.holders$],
(list, holders) => {
const map = new ConnectionMap(logger);
for (const item of list)
map.add(item.connection, item.participants);
for (const { connection, participants } of list) {
map.add(connection, participants);
const holder = holders.get(connection);
if (holder !== undefined && holder !== connection)
map.alias(connection, holder);
}
return new Epoch(map, epoch);
},
);
@@ -39,6 +39,7 @@ import {
createTransportRegistry,
supportsDelegation,
} from "./LivekitBackend";
import { LivekitRooms, livekitRoomKey } from "./LivekitRooms";
import {
exampleServiceUrl,
exampleTransport,
@@ -173,11 +174,14 @@ describe("createLivekitBackend", () => {
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
};
/** The registry the backend handed its factory, for the test to resolve rooms in. */
let rooms: LivekitRooms;
/** Stands in for the factory: connections whose state and participants the test drives. */
function stubFactory(initialState: ConnectionState): void {
vi.spyOn(connectionFactory, "LivekitConnectionFactory").mockImplementation(
function (this: unknown) {
function (this: unknown, ...args: unknown[]) {
rooms = args.find((arg) => arg instanceof LivekitRooms) as LivekitRooms;
return {
createConnection: (
scope: ObservableScope,
@@ -506,6 +510,64 @@ describe("createLivekitBackend", () => {
expect(remote?.connection.serverName).toBe("sample.com");
});
it("shares one connection between transports that resolve to the same room", async () => {
// Two members of one homeserver, one of them advertising the SFU's url:
// two transports, one JWT service, one room, and the same subscriber
// identity on both tokens, which the SFU would not admit twice
const b = await prepared();
const withUrl: UnstableLivekitTransport = {
type: "livekit",
url: "wss://sfu.sample.com",
livekit_service_url: "https://lk.sample.com",
};
const withoutUrl: UnstableLivekitTransport = {
type: "livekit",
livekit_service_url: "https://lk.sample.com",
};
const bobMedia = b.mediaFor(testScope(), {
...bob(withUrl, ["@bob:sample.com:DEV1"]),
userId: "@bob:sample.com",
deviceId: "DEV1",
});
const carolMedia = b.mediaFor(testScope(), {
...bob(withoutUrl, ["@carol:sample.com:DEV2"]),
userId: "@carol:sample.com",
deviceId: "DEV2",
});
await flushPromises();
const [, first, second] = connections;
expect(first.connection.transport).toEqual(withUrl);
expect(second.connection.transport).toEqual(withoutUrl);
const room = livekitRoomKey("wss://sfu.sample.com", "alias");
await rooms.claim(first.connection, room);
void rooms.claim(second.connection, room);
first.participants$.next([
mockRemoteParticipant({
identity: "@bob:sample.com:DEV1",
trackPublications: new Map(),
}),
mockRemoteParticipant({
identity: "@carol:sample.com:DEV2",
trackPublications: new Map(),
}),
]);
await flushPromises();
expect(bobMedia.tracks$.value).toEqual([]);
expect(carolMedia.tracks$.value).toEqual([]);
// The waiting transport reports the state of the connection carrying it
second.state$.next({ state: "authenticating" });
await flushPromises();
expect(
b.connections$.value.map(({ transport, state }) => [transport, state]),
).toEqual([
[bothTransport, MediaConnectionState.Connected],
[withUrl, MediaConnectionState.Connected],
[withoutUrl, MediaConnectionState.Connected],
]);
});
it("connects to a remote publisher's transport on the SFU's homeserver", async () => {
const b = await prepared();
// Dave has no SFU of his own and publishes on sample.com's
@@ -61,6 +61,7 @@ import { toMediaConnectionState } from "./connectionState";
import { createDataChannel$ } from "./DataChannel";
import { createKeyProvider } from "./KeyProvider";
import { createLivekitMemberMedia } from "./LivekitMemberMedia";
import { LivekitRooms } from "./LivekitRooms";
import { createLivekitLocalMedia } from "./LocalMedia";
export interface LivekitBackendOptions {
@@ -104,6 +105,7 @@ export function createLivekitBackend(
const audioOutputDeviceId$ = new BehaviorSubject<string | undefined>(
undefined,
);
const rooms = new LivekitRooms(logger);
const connectionFactory = new LivekitConnectionFactory(
client,
roomId,
@@ -112,6 +114,7 @@ export function createLivekitBackend(
audioOutputDeviceId$,
keyProvider,
mediaQuality,
rooms,
);
// A new map, in a scope of its own, whenever the local transport changes:
@@ -123,6 +126,7 @@ export function createLivekitBackend(
connectionFactory,
localTransport,
remoteTransports$: transports.transports$,
rooms,
logger,
ownMembershipIdentity,
}),
@@ -259,7 +263,14 @@ export function createLivekitBackend(
switchMap(({ value }) => {
const connections = value.getConnections();
if (connections.length === 0) return of([]);
return combineLatest(connections.map(backendConnection$));
return combineLatest(
connections.map((connection) =>
backendConnection$(
connection,
value.getConnectionForTransport(connection) ?? connection,
),
),
);
}),
),
);
@@ -306,14 +317,16 @@ async function authenticated(
);
}
/** A transport's connection with the state of the connection holding its room. */
function backendConnection$(
connection: Connection,
holder: Connection,
): Observable<BackendConnection> {
const resolved$: Observable<ResolvedTransport | undefined> =
connection instanceof ResolvedConnection
? connection.resolved$
: of(undefined);
return combineLatest([connection.state$, resolved$]).pipe(
return combineLatest([holder.state$, resolved$]).pipe(
map(([state, resolved]): BackendConnection => ({
transport: connection.transport,
state: toMediaConnectionState(state),
@@ -0,0 +1,173 @@
/*
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 { logger } from "matrix-js-sdk/lib/logger";
import { describe, expect, it, vi } from "vitest";
import { type ConnectionRole } from "./auth";
import { type Connection } from "./Connection";
import { LivekitRooms, livekitRoomKey } from "./LivekitRooms";
const room = livekitRoomKey("wss://sfu.example.org", "alias");
const otherRoom = livekitRoomKey("wss://sfu.example.org", "other");
describe("LivekitRooms", () => {
it("hands a free room to its first claimant at once", async () => {
const rooms = new LivekitRooms(logger);
const a = connection();
await expect(rooms.claim(a, room)).resolves.toBe(false);
expect(rooms.holders$.value).toEqual(new Map([[a, a]]));
});
it("makes a subscriber wait for a held room, reading it through the holder meanwhile", async () => {
const rooms = new LivekitRooms(logger);
const a = connection();
const b = connection();
await rooms.claim(a, room);
const held = settled(rooms.claim(b, room));
expect(rooms.holders$.value).toEqual(
new Map([
[a, a],
[b, a],
]),
);
expect(await held()).toBe(false);
rooms.release(a);
expect(await held()).toBe(true);
// A released connection still reads the room through whoever holds it
expect(rooms.holders$.value).toEqual(
new Map([
[a, b],
[b, b],
]),
);
});
it("lets a publisher take a room over and stops the subscriber holding it", async () => {
const rooms = new LivekitRooms(logger);
const subscriber = connection();
const publisher = connection("publisher");
await rooms.claim(subscriber, room);
await expect(rooms.claim(publisher, room)).resolves.toBe(false);
expect(subscriber.stop).toHaveBeenCalled();
// The stopped subscriber still reads the room, now through the publisher
rooms.release(subscriber);
expect(rooms.holders$.value).toEqual(
new Map([
[subscriber, publisher],
[publisher, publisher],
]),
);
});
it("settles a waiter's claim when it is released without the room", async () => {
const rooms = new LivekitRooms(logger);
const a = connection();
const b = connection();
await rooms.claim(a, room);
const held = settled(rooms.claim(b, room));
rooms.release(b);
expect(await held()).toBe(true);
expect(rooms.holders$.value).toEqual(
new Map([
[a, a],
[b, a],
]),
);
});
it("keeps rooms apart", async () => {
const rooms = new LivekitRooms(logger);
const a = connection();
const b = connection();
await rooms.claim(a, room);
await expect(rooms.claim(b, otherRoom)).resolves.toBe(false);
expect(rooms.holders$.value).toEqual(
new Map([
[a, a],
[b, b],
]),
);
});
it("forgets a connection for good", async () => {
const rooms = new LivekitRooms(logger);
const a = connection();
const b = connection();
await rooms.claim(a, room);
void rooms.claim(b, room);
rooms.forget(a);
expect(rooms.holders$.value).toEqual(new Map([[b, b]]));
rooms.forget(b);
expect(rooms.holders$.value).toEqual(new Map());
});
it("ignores a release from a connection that never claimed", () => {
const rooms = new LivekitRooms(logger);
expect(() => rooms.release(connection())).not.toThrow();
});
it("reports whether a claimant had to wait", async () => {
const rooms = new LivekitRooms(logger);
const a = connection();
const b = connection();
await rooms.claim(a, room);
const claim = rooms.claim(b, room);
rooms.release(a);
await expect(claim).resolves.toBe(true);
});
it("names the holder of a room", async () => {
const rooms = new LivekitRooms(logger);
const a = connection();
expect(rooms.holderOf(room)).toBeUndefined();
await rooms.claim(a, room);
expect(rooms.holderOf(room)).toBe(a);
});
});
describe("livekitRoomKey", () => {
it("spells the SFU url one way however the token did", () => {
expect(livekitRoomKey("wss://sfu.example.org", "alias")).toBe(
livekitRoomKey("wss://sfu.example.org/", "alias"),
);
expect(livekitRoomKey("wss://sfu.example.org/", "alias")).toBe(
livekitRoomKey("WSS://SFU.EXAMPLE.ORG", "alias"),
);
});
it("keeps SFUs and aliases apart", () => {
const key = livekitRoomKey("wss://sfu.example.org", "alias");
expect(livekitRoomKey("wss://sfu.example.org/sfu", "alias")).not.toBe(key);
expect(livekitRoomKey("wss://sfu.example.org", "other")).not.toBe(key);
});
it("falls back to the raw string for something that is not a url", () => {
expect(livekitRoomKey("not a url", "alias")).toBe(
JSON.stringify(["not a url", "alias"]),
);
});
});
function connection(role: ConnectionRole = "subscriber"): Connection {
return { role, stop: vi.fn() } as unknown as Connection;
}
/** Whether a promise has settled by the time the microtasks have run. */
function settled(promise: Promise<unknown>): () => Promise<boolean> {
let done = false;
void promise.then(() => (done = true));
return async () => {
await Promise.resolve();
await Promise.resolve();
return done;
};
}
@@ -0,0 +1,177 @@
/*
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 Logger } from "matrix-js-sdk/lib/logger";
import { BehaviorSubject } from "rxjs";
import { type Behavior } from "../../reactive/Behavior";
import { type Connection } from "./Connection";
// Branded, so that only values returned by `livekitRoomKey` can be used as keys.
export type LivekitRoomKey = string & { __livekitRoomKey: true };
/** A LiveKit room identified by the SFU it is on and its alias there. */
export function livekitRoomKey(url: string, alias: string): LivekitRoomKey {
return JSON.stringify([normalisedUrl(url), alias]) as LivekitRoomKey;
}
/** Normalises URLs to make them comparable. */
function normalisedUrl(url: string): string {
try {
return new URL(url).href;
} catch {
return url;
}
}
/** A room some connection has resolved to, and who is on it. */
interface Room {
/** The one connection joined to the room on the SFU. */
holder: Connection;
/** Connections waiting to hold the room, in the order they will get it. */
waiting: Waiter[];
}
/** A connection's pending claim on a room. */
interface Waiter {
/**
* The connection that resolved to the room and is not connecting until it
* holds it.
*/
connection: Connection;
/**
* Settles the connection's claim after it has either acquired hold of the
* room or was released.
*/
held: () => void;
}
/** Helper to organise rooms and holding or waiting connections. */
export class LivekitRooms {
private readonly logger: Logger;
/** The rooms some connection is on or waiting for. */
private readonly rooms = new Map<LivekitRoomKey, Room>();
/** The room each connection resolved to. */
private readonly roomOf = new Map<Connection, LivekitRoomKey>();
/** The source of `holders$`. */
private readonly holders = new BehaviorSubject<Map<Connection, Connection>>(
new Map(),
);
/** Every connection that resolved a room, to the connection holding that room. */
public readonly holders$: Behavior<Map<Connection, Connection>> =
this.holders;
public constructor(parentLogger: Logger) {
this.logger = parentLogger.getChild("[LivekitRooms]");
}
/** The connection holding a room, if any does. */
public holderOf(key: LivekitRoomKey): Connection | undefined {
return this.rooms.get(key)?.holder;
}
/**
* Resolves once `connection` either holds the room or was released.
* Resolves to whether the connection had to wait for the room.
*/
public async claim(
connection: Connection,
key: LivekitRoomKey,
): Promise<boolean> {
this.roomOf.set(connection, key);
const room = this.rooms.get(key);
if (room === undefined) {
this.logger.debug(`${describe(connection)} holds room ${key}`);
this.rooms.set(key, { holder: connection, waiting: [] });
this.publish();
return false;
}
// If the connection is for publishing and a subscriber holds the room, demote it.
if (connection.role === "publisher") {
const demoted = room.holder;
this.logger.info(
`${describe(connection)} takes room ${key} over from ${describe(demoted)}, which stops`,
);
room.holder = connection;
this.publish();
void demoted.stop();
return false;
}
this.logger.info(
`${describe(connection)} waits for room ${key}, held by ${describe(room.holder)}`,
);
await new Promise<void>((held) => {
room.waiting.push({ connection, held });
this.publish();
});
return true;
}
/** Gives up holding or waiting for the room. */
public release(connection: Connection): void {
const key = this.roomOf.get(connection);
const room = key === undefined ? undefined : this.rooms.get(key);
if (key === undefined || room === undefined) {
return; // Never claimed, or the room has already emptied out
}
if (room.holder === connection) {
// We're the room's holder. Figure out the first waiter and promote it.
const next = room.waiting.shift();
if (next === undefined) {
this.rooms.delete(key);
} else {
this.logger.info(
`${describe(next.connection)} holds room ${key} now, released by ${describe(connection)}`,
);
room.holder = next.connection;
next.held();
}
} else {
// We're a waiter. Figure out our index in the waiting list to remove ourselves.
const index = room.waiting.findIndex((w) => w.connection === connection);
if (index === -1) {
return;
}
const [{ held }] = room.waiting.splice(index, 1);
held();
}
this.publish();
}
/** Releases a connection that is gone for good, so nothing reads through it. */
public forget(connection: Connection): void {
this.release(connection);
this.roomOf.delete(connection);
this.publish();
}
/** Emits `holders$` afresh if needed. */
private publish(): void {
const holders = new Map<Connection, Connection>();
for (const [connection, key] of this.roomOf) {
const holder = this.rooms.get(key)?.holder;
if (holder !== undefined) holders.set(connection, holder);
}
if (!sameHolders(this.holders.value, holders)) this.holders.next(holders);
}
}
/** Checks if two holder maps are identical. */
function sameHolders(
a: Map<Connection, Connection>,
b: Map<Connection, Connection>,
): boolean {
return (
a.size === b.size &&
[...a].every(([connection, holder]) => b.get(connection) === holder)
);
}
/** Summarises a connection for logging. */
function describe(connection: Connection): string {
return `${connection.role} connection to ${JSON.stringify(connection.transport)}`;
}
+1 -6
View File
@@ -6,12 +6,11 @@ Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import { BehaviorSubject } from "rxjs";
import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test";
import { constant } from "../state/Behavior";
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
import type { Alignment, Layout } from "../state/layout-types";
import type { Layout } from "../state/layout-types";
import type { SpotlightTileViewModel } from "../state/TileViewModel";
import type { DeviceLabel } from "../state/MediaDevices";
import { createCallFooterViewModel } from "./CallFooterViewModel";
@@ -65,10 +64,6 @@ function buildMinimalCallViewModel(layout: Layout): CallViewModel {
const gridLayout: Layout = {
type: "grid",
grid: [],
spotlightAlignment$: new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
}),
setVisibleTiles: (_: number) => {},
};
+100 -150
View File
@@ -5,28 +5,25 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { act, render, screen } from "@testing-library/react";
import { BehaviorSubject } from "rxjs";
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { arrangeTiles, type Bounds } from "./CallLayout";
import { type CallLayout } from "./CallLayout";
import { makeGridLayout } from "./GridLayout";
import { makeSpotlightLandscapeLayout } from "./SpotlightLandscapeLayout";
import { makeSpotlightPortraitLayout } from "./SpotlightPortraitLayout";
import { type DragCallback, type LayoutProps } from "./Grid";
import { type LayoutProps } from "./Grid";
import { constant } from "../state/Behavior";
import {
type Alignment,
type GridLayout,
type SpotlightLandscapeLayout,
type SpotlightPortraitLayout,
} from "../state/layout-types";
import { type Alignment } from "../state/layout-types";
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
import {
GridTileViewModel,
SpotlightTileViewModel,
type TileViewModel,
} from "../state/TileViewModel";
import { makeOneOnOneMobileLayout } from "./OneOnOneMobileLayout";
import { makeSpotlightExpandedLayout } from "./SpotlightExpandedLayout";
import { makeOneOnOneDesktopLayout } from "./OneOnOneDesktopLayout";
// Grid supplies these hooks to the layouts it renders; on their own the
// layouts have no grid to report to
@@ -36,152 +33,105 @@ vi.mock("./Grid", async (importOriginal) => ({
useVisibleTiles: (): void => {},
}));
describe("grid layout", () => {
it("sizes the tiles from the minimum bounds", () => {
const minBounds$ = new BehaviorSubject<Bounds>({ width: 800, height: 600 });
const { scrolling: Scrolling } = makeGridLayout({ minBounds$ });
render(<Scrolling model={gridModel()} Slot={Slot} />);
const layer = screen.getByTestId("slot-0").parentElement!;
expect(layer.style.width).toBe("800px");
expect(layer.style.getPropertyValue("--width")).toBe(
`${Math.floor(arrangeTiles(800, 600, 3).tileWidth)}px`,
);
const minBounds$ = constant({ width: 800, height: 800 });
act(() => minBounds$.next({ width: 400, height: 300 }));
expect(layer.style.width).toBe("400px");
expect(layer.style.getPropertyValue("--width")).toBe(
`${Math.floor(arrangeTiles(400, 300, 3).tileWidth)}px`,
);
});
it("moves the spotlight only when a drag reaches another corner", () => {
const model = gridModel();
const { fixed: Fixed } = makeGridLayout({ minBounds$: bounds });
render(<Fixed model={model} Slot={Slot} />);
const alignments: Alignment[] = [];
model.spotlightAlignment$.subscribe((a) => alignments.push(a));
const drag = drags.get("spotlight")!;
// Still in the bottom right corner
act(() => drag({ x: 0, y: 0, xRatio: 0.9, yRatio: 0.9 }));
// Over to the bottom left
act(() => drag({ x: 0, y: 0, xRatio: 0.1, yRatio: 0.9 }));
// And a little further, still bottom left
act(() => drag({ x: 0, y: 0, xRatio: 0.2, yRatio: 0.8 }));
// Up to the top left
act(() => drag({ x: 0, y: 0, xRatio: 0.2, yRatio: 0.1 }));
expect(alignments).toEqual([
{ inline: "end", block: "end" },
{ inline: "start", block: "end" },
{ inline: "start", block: "start" },
]);
const slot = screen.getByTestId("slot-spotlight");
expect(slot.getAttribute("data-inline-alignment")).toBe("start");
expect(slot.getAttribute("data-block-alignment")).toBe("start");
});
});
describe("spotlight landscape layout", () => {
it("puts the spotlight in the fixed layer and the grid in the scrolling one", () => {
const { fixed: Fixed, scrolling: Scrolling } = makeSpotlightLandscapeLayout(
{ minBounds$: bounds },
);
const model = landscapeModel();
const { unmount } = render(<Fixed model={model} Slot={Slot} />);
expect(screen.getByTestId("slot-spotlight")).toBeInTheDocument();
unmount();
render(<Scrolling model={model} Slot={Slot} />);
expect(slotIds()).toEqual(model.grid.map((tile) => tile.id));
});
});
describe("spotlight portrait layout", () => {
it("sizes the grid tiles from the minimum width", () => {
const minBounds$ = new BehaviorSubject<Bounds>({ width: 300, height: 700 });
const { scrolling: Scrolling } = makeSpotlightPortraitLayout({
minBounds$,
});
const model = portraitModel();
render(<Scrolling model={model} Slot={Slot} />);
expect(slotIds()).toEqual(model.grid.map((tile) => tile.id));
const layer = screen.getByTestId(`slot-${model.grid[0].id}`).parentElement!
.parentElement!;
expect(layer.style.getPropertyValue("--grid-tile-width")).toBe(
`${Math.floor(arrangeTiles(300, 300, 3).tileWidth)}px`,
);
act(() => minBounds$.next({ width: 500, height: 700 }));
expect(layer.style.getPropertyValue("--grid-tile-width")).toBe(
`${Math.floor(arrangeTiles(500, 500, 3).tileWidth)}px`,
);
});
});
const bounds = constant<Bounds>({ width: 800, height: 600 });
// The layouts only pass tile view models through to their slots
const media = {} as UserMediaViewModel;
const spotlight = new SpotlightTileViewModel(
constant([]),
constant(false),
constant("solid"),
const spotlight = {
vm: new SpotlightTileViewModel(
constant([]),
constant(false),
constant("solid"),
),
alignment$: constant<Alignment>({ inline: "end", block: "end" }),
onDrag: () => {},
};
const grid = Array.from(
{ length: 3 },
() => new GridTileViewModel(constant({} as UserMediaViewModel)),
);
const tiles = (): GridTileViewModel[] =>
Array.from({ length: 3 }, () => new GridTileViewModel(constant(media)));
const pip = {
alignment$: constant<Alignment>({ inline: "end", block: "end" }),
onDrag: () => {},
vm: { id: "pip" } as GridTileViewModel,
size$: constant("sm" as const),
};
function gridModel(): GridLayout {
return {
type: "grid",
spotlight,
grid: tiles(),
spotlightAlignment$: new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
}),
setVisibleTiles: () => {},
};
}
function landscapeModel(): SpotlightLandscapeLayout {
return {
type: "spotlight-landscape",
spotlight,
grid: tiles(),
setVisibleTiles: () => {},
};
}
function portraitModel(): SpotlightPortraitLayout {
return {
type: "spotlight-portrait",
spotlight,
grid: tiles(),
setVisibleTiles: () => {},
};
}
// Slots record their drag callback so a test can drag them, and show the id
// of the tile they hold
const drags = new Map<string, DragCallback | undefined>();
const Slot: LayoutProps<unknown, TileViewModel, HTMLDivElement>["Slot"] = ({
id,
model,
onDrag,
children,
...props
}) => {
drags.set(id, onDrag);
return (
<div data-testid={`slot-${id}`} {...props}>
{model instanceof GridTileViewModel ? model.id : "spotlight"}
</div>
);
};
}) => (
<div data-testid={`slot-${id}`} {...props}>
{children}
</div>
);
function slotIds(): string[] {
return screen
.getAllByTestId(/^slot-/)
.map((slot) => slot.textContent ?? "")
.filter((id) => id !== "spotlight");
function snapshotLayout<Model>(layout: CallLayout<Model>, model: Model): void {
const {
foreground,
scrolling: Scrolling,
fixed: Fixed,
} = layout({ minBounds$ });
const scrollingLayer = <Scrolling model={model} Slot={Slot} />;
const fixedLayer = <Fixed model={model} Slot={Slot} />;
const { container } = render(
foreground === "fixed" ? (
<>
{scrollingLayer}
{fixedLayer}
</>
) : (
<>
{fixedLayer}
{scrollingLayer}
</>
),
);
expect(container).toMatchSnapshot();
}
test("grid layout", () =>
snapshotLayout(makeGridLayout, {
type: "grid",
spotlight,
grid,
setVisibleTiles: () => {},
}));
test("spotlight landscape layout", () =>
snapshotLayout(makeSpotlightLandscapeLayout, {
type: "spotlight-landscape",
spotlight,
grid,
setVisibleTiles: () => {},
}));
test("spotlight portrait layout", () =>
snapshotLayout(makeSpotlightPortraitLayout, {
type: "spotlight-portrait",
spotlight,
grid,
setVisibleTiles: () => {},
}));
test("spotlight expanded layout", () =>
snapshotLayout(makeSpotlightExpandedLayout, {
type: "spotlight-expanded",
spotlight: spotlight.vm,
pip,
}));
test("one-on-one desktop layout", () =>
snapshotLayout(makeOneOnOneDesktopLayout, {
type: "one-on-one-desktop",
spotlight: { id: "spotlight" } as GridTileViewModel,
pip,
}));
test("one-on-one mobile layout", () =>
snapshotLayout(makeOneOnOneMobileLayout, {
type: "one-on-one-mobile",
spotlight: spotlight.vm,
pip,
}));
+12 -29
View File
@@ -40,6 +40,7 @@ import { useMergedRefs } from "../useMergedRefs";
import { TileWrapper } from "./TileWrapper";
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
import { useInitial } from "../useInitial";
import { type DragCallback } from "../state/layout-types";
const MAX_ANIMATED_TILES = 50; // Capped for performance reasons
@@ -83,7 +84,10 @@ interface DragState {
cursorY: number;
}
interface SlotProps<Model> extends Omit<ComponentProps<"div">, "onDrag"> {
export interface SlotProps<Model> extends Omit<
ComponentProps<"div">,
"onDrag"
> {
id: string;
model: Model;
onDrag?: DragCallback;
@@ -181,27 +185,6 @@ export interface TileProps<Model, R extends HTMLElement> {
model: Model;
}
interface Drag {
/**
* The X coordinate of the dragged tile in grid space.
*/
x: number;
/**
* The Y coordinate of the dragged tile in grid space.
*/
y: number;
/**
* The X coordinate of the dragged tile, as a scalar of the grid width.
*/
xRatio: number;
/**
* The Y coordinate of the dragged tile, as a scalar of the grid height.
*/
yRatio: number;
}
export type DragCallback = (drag: Drag) => void;
interface LayoutMemoProps<
LayoutModel,
TileModel,
@@ -464,13 +447,13 @@ export function Grid<
)
.catch(logger.error);
if (endOfGesture)
callback({
x: tileX,
y: tileY,
xRatio: tileX / (gridBounds.width - tile.width),
yRatio: tileY / (gridBounds.height - tile.height),
});
callback({
x: tileX,
y: tileY,
xRatio: tileX / (gridBounds.width - tile.width),
yRatio: tileY / (gridBounds.height - tile.height),
endOfGesture,
});
};
// Callback for useDrag. We could call useDrag here, but the default
+30 -25
View File
@@ -8,15 +8,21 @@ Please see LICENSE in the repository root for full details.
import {
type CSSProperties,
type ReactNode,
useCallback,
useMemo,
type FC,
type ComponentType,
} from "react";
import { type GridLayout as GridLayoutModel } from "../state/layout-types.ts";
import styles from "./GridLayout.module.css";
import { type CallLayout, arrangeTiles } from "./CallLayout";
import { type DragCallback, useUpdateLayout, useVisibleTiles } from "./Grid";
import { type SlotProps, useUpdateLayout, useVisibleTiles } from "./Grid";
import { useBehavior } from "../useBehavior";
import {
type SpotlightTileViewModel,
type TileViewModel,
} from "../state/TileViewModel.ts";
import { type FloatingTile } from "../state/FloatingTile.ts";
interface GridCSSProperties extends CSSProperties {
"--gap": string;
@@ -24,6 +30,27 @@ interface GridCSSProperties extends CSSProperties {
"--height": string;
}
interface SpotlightSlotProps {
model: FloatingTile & { vm: SpotlightTileViewModel };
Slot: ComponentType<SlotProps<TileViewModel>>;
}
const SpotlightSlot: FC<SpotlightSlotProps> = ({ model, Slot }) => {
useUpdateLayout();
const alignment = useBehavior(model.alignment$);
return (
<Slot
className={styles.slot}
id="spotlight"
model={model.vm}
onDrag={model.onDrag}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
);
};
/**
* An implementation of the "grid" layout, in which all participants are shown
* together in a scrolling grid.
@@ -37,32 +64,10 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
// lives
fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode {
useUpdateLayout();
const alignment = useBehavior(model.spotlightAlignment$);
const onDragSpotlight: DragCallback = useCallback(
({ xRatio, yRatio }) => {
const block = yRatio < 0.5 ? "start" : "end";
const inline = xRatio < 0.5 ? "start" : "end";
// A drag reports the same alignment on every move; only a change is
// worth a re-render
const current = model.spotlightAlignment$.value;
if (current.block !== block || current.inline !== inline)
model.spotlightAlignment$.next({ block, inline });
},
[model.spotlightAlignment$],
);
return (
<div ref={ref} className={styles.fixed}>
{model.spotlight && (
<Slot
className={styles.slot}
id="spotlight"
model={model.spotlight}
onDrag={onDragSpotlight}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
<SpotlightSlot model={model.spotlight} Slot={Slot} />
)}
</div>
);
+6 -15
View File
@@ -6,13 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useCallback, useMemo } from "react";
import { type ReactNode, useMemo } from "react";
import classNames from "classnames";
import { type OneOnOneDesktopLayout as OneOnOneDesktopLayoutModel } from "../state/layout-types.ts";
import { type CallLayout, arrangeTiles } from "./CallLayout";
import styles from "./OneOnOneDesktopLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior";
/**
@@ -37,21 +37,12 @@ export const makeOneOnOneDesktopLayout: CallLayout<
}): ReactNode {
useUpdateLayout();
const { width, height } = useBehavior(minBounds$);
const pipAlignment = useBehavior(model.pipAlignment$);
const pipAlignment = useBehavior(model.pip.alignment$);
const { tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, height, 1),
[width, height],
);
const onDragLocalTile: DragCallback = useCallback(
({ xRatio, yRatio }) =>
model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[model.pipAlignment$],
);
return (
<div ref={ref} className={styles.layer}>
<Slot
@@ -62,9 +53,9 @@ export const makeOneOnOneDesktopLayout: CallLayout<
>
<Slot
className={classNames(styles.slot, styles.local)}
id={model.pip.id}
model={model.pip}
onDrag={onDragLocalTile}
id={model.pip.vm.id}
model={model.pip.vm}
onDrag={model.pip.onDrag}
data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignment.inline}
/>
+32 -24
View File
@@ -6,14 +6,43 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useCallback } from "react";
import { type ComponentType, type FC, type ReactNode } from "react";
import classNames from "classnames";
import { type OneOnOneMobileLayout as OneOnOneMobileLayoutModel } from "../state/layout-types.ts";
import { type CallLayout } from "./CallLayout";
import styles from "./OneOnOneMobileLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { type SlotProps, useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior";
import { type FloatingTile } from "../state/FloatingTile.ts";
import {
type GridTileViewModel,
type TileViewModel,
} from "../state/TileViewModel.ts";
import { type Behavior } from "../state/Behavior.ts";
interface PipSlotProps {
model: FloatingTile & { vm: GridTileViewModel; size$: Behavior<"sm" | "lg"> };
Slot: ComponentType<SlotProps<TileViewModel>>;
}
const PipSlot: FC<PipSlotProps> = ({ model, Slot }) => {
useUpdateLayout();
const size = useBehavior(model.size$);
const alignment = useBehavior(model.alignment$);
return (
<Slot
className={classNames(styles.pip)}
id={model.vm.id}
model={model.vm}
onDrag={model.onDrag}
data-size={size}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
);
};
/**
* An implementation of the "one-on-one" layout for mobile platforms, in which
@@ -44,30 +73,9 @@ export const makeOneOnOneMobileLayout: CallLayout<
Slot,
}): ReactNode {
useUpdateLayout();
const pipSize = useBehavior(model.pipSize$);
const pipAlignment = useBehavior(model.pipAlignment$);
const onDragLocalTile: DragCallback = useCallback(
({ xRatio, yRatio }) =>
model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[model.pipAlignment$],
);
return (
<div ref={ref} className={styles.layer}>
{model.pip && (
<Slot
className={classNames(styles.pip)}
id={model.pip.id}
model={model.pip}
onDrag={onDragLocalTile}
data-size={pipSize}
data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignment.inline}
/>
)}
{model.pip && <PipSlot model={model.pip} Slot={Slot} />}
</div>
);
},
+29 -24
View File
@@ -5,13 +5,39 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useCallback } from "react";
import { type ReactNode, type ComponentType, type FC } from "react";
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/layout-types.ts";
import { type CallLayout } from "./CallLayout";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { type SlotProps, useUpdateLayout } from "./Grid";
import styles from "./SpotlightExpandedLayout.module.css";
import { useBehavior } from "../useBehavior";
import { type FloatingTile } from "../state/FloatingTile.ts";
import {
type GridTileViewModel,
type TileViewModel,
} from "../state/TileViewModel.ts";
interface PipSlotProps {
model: FloatingTile & { vm: GridTileViewModel };
Slot: ComponentType<SlotProps<TileViewModel>>;
}
const PipSlot: FC<PipSlotProps> = ({ model, Slot }) => {
useUpdateLayout();
const alignment = useBehavior(model.alignment$);
return (
<Slot
className={styles.pip}
id={model.vm.id}
model={model.vm}
onDrag={model.onDrag}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
);
};
/**
* An implementation of the "expanded spotlight" layout, in which the spotlight
@@ -28,7 +54,6 @@ export const makeSpotlightExpandedLayout: CallLayout<
Slot,
}): ReactNode {
useUpdateLayout();
return (
<div ref={ref} className={styles.layer}>
<Slot
@@ -46,29 +71,9 @@ export const makeSpotlightExpandedLayout: CallLayout<
Slot,
}): ReactNode {
useUpdateLayout();
const pipAlignment = useBehavior(model.pipAlignment$);
const onDragPip: DragCallback = useCallback(
({ xRatio, yRatio }) =>
model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[model.pipAlignment$],
);
return (
<div ref={ref} className={styles.layer}>
{model.pip && (
<Slot
className={styles.pip}
id={model.pip.id}
model={model.pip}
onDrag={onDragPip}
data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignment.inline}
/>
)}
{model.pip && <PipSlot model={model.pip} Slot={Slot} />}
</div>
);
},
+2 -2
View File
@@ -40,7 +40,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
<Slot
className={styles.slot}
id="spotlight"
model={model.spotlight}
model={model.spotlight.vm}
/>
</div>
<div className={styles.grid} />
@@ -59,7 +59,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
return (
<div ref={ref} className={styles.layer}>
<SpotlightSlot media$={model.spotlight.media$} />
<SpotlightSlot media$={model.spotlight.vm.media$} />
<div className={styles.grid}>
{model.grid.map((m) => (
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
+2 -2
View File
@@ -43,7 +43,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
<Slot
className={styles.slot}
id="spotlight"
model={model.spotlight}
model={model.spotlight.vm}
/>
</div>
</div>
@@ -65,7 +65,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
width,
model.grid.length,
);
const withIndicators = useBehavior(model.spotlight.media$).length > 1;
const withIndicators = useBehavior(model.spotlight.vm.media$).length > 1;
return (
<div
@@ -0,0 +1,186 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`grid layout 1`] = `
<div>
<div
class="_scrolling_e8555d"
style="width: 800px; --gap: 20px; --width: 370px; --height: 277px;"
>
<div
class="_slot_e8555d"
data-testid="slot-0"
/>
<div
class="_slot_e8555d"
data-testid="slot-1"
/>
<div
class="_slot_e8555d"
data-testid="slot-2"
/>
</div>
<div
class="_fixed_e8555d"
>
<div
class="_slot_e8555d"
data-block-alignment="end"
data-inline-alignment="end"
data-testid="slot-spotlight"
/>
</div>
</div>
`;
exports[`one-on-one desktop layout 1`] = `
<div>
<div
class="_layer_185815"
>
<div
class="_container_185815"
data-testid="slot-spotlight"
style="width: 760px; height: 570px;"
>
<div
class="_slot_185815 _local_185815"
data-block-alignment="end"
data-inline-alignment="end"
data-testid="slot-pip"
/>
</div>
</div>
<div />
</div>
`;
exports[`one-on-one mobile layout 1`] = `
<div>
<div
class="_layer_c81c30"
>
<div
class="_spotlight_c81c30"
data-testid="slot-spotlight"
/>
</div>
<div
class="_layer_c81c30"
>
<div
class="_pip_c81c30"
data-block-alignment="end"
data-inline-alignment="end"
data-size="sm"
data-testid="slot-pip"
/>
</div>
</div>
`;
exports[`spotlight expanded layout 1`] = `
<div>
<div
class="_layer_e70191"
>
<div
class="_spotlight_e70191"
data-testid="slot-spotlight"
/>
</div>
<div
class="_layer_e70191"
>
<div
class="_pip_e70191"
data-block-alignment="end"
data-inline-alignment="end"
data-testid="slot-pip"
/>
</div>
</div>
`;
exports[`spotlight landscape layout 1`] = `
<div>
<div
class="_layer_29809c"
>
<div
class="_spotlight_29809c"
>
<div
class="_slot_29809c"
data-testid="slot-spotlight"
/>
</div>
<div
class="_grid_29809c"
/>
</div>
<div
class="_layer_29809c"
>
<div
class="_spotlight_29809c"
/>
<div
class="_grid_29809c"
>
<div
class="_slot_29809c"
data-testid="slot-0"
/>
<div
class="_slot_29809c"
data-testid="slot-1"
/>
<div
class="_slot_29809c"
data-testid="slot-2"
/>
</div>
</div>
</div>
`;
exports[`spotlight portrait layout 1`] = `
<div>
<div
class="_layer_065bbc"
style="--grid-gap: 20px; --grid-tile-width: 370px; --grid-tile-height: 277px;"
>
<div
class="_spotlight_065bbc"
/>
<div
class="_grid_065bbc"
>
<div
class="_slot_065bbc"
data-testid="slot-0"
/>
<div
class="_slot_065bbc"
data-testid="slot-1"
/>
<div
class="_slot_065bbc"
data-testid="slot-2"
/>
</div>
</div>
<div
class="_layer_065bbc"
>
<div
class="_spotlight_065bbc"
>
<div
class="_slot_065bbc"
data-testid="slot-spotlight"
/>
</div>
</div>
</div>
`;
+10 -10
View File
@@ -155,7 +155,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
case "grid":
return combineLatest(
[
l.spotlight?.media$ ?? constant(undefined),
l.spotlight?.vm.media$ ?? constant(undefined),
...l.grid.map((vm) => vm.media$),
],
(spotlight, ...grid) => ({
@@ -167,7 +167,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
case "spotlight-landscape":
case "spotlight-portrait":
return combineLatest(
[l.spotlight.media$, ...l.grid.map((vm) => vm.media$)],
[l.spotlight.vm.media$, ...l.grid.map((vm) => vm.media$)],
(spotlight, ...grid) => ({
type: l.type,
spotlight: spotlight.map((vm) => vm.id),
@@ -176,7 +176,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
);
case "spotlight-expanded":
return combineLatest(
[l.spotlight.media$, l.pip?.media$ ?? constant(undefined)],
[l.spotlight.media$, l.pip?.vm.media$ ?? constant(undefined)],
(spotlight, pip) => ({
type: l.type,
spotlight: spotlight.map((vm) => vm.id),
@@ -185,7 +185,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
);
case "one-on-one-desktop":
return combineLatest(
[l.spotlight.media$, l.pip.media$],
[l.spotlight.media$, l.pip.vm.media$],
(spotlight, pip) => ({
type: l.type,
spotlight: spotlight.id,
@@ -196,8 +196,8 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
return combineLatest(
[
l.spotlight.media$,
l.pip?.media$ ?? constant(undefined),
l.pipSize$,
l.pip?.vm.media$ ?? constant(undefined),
l.pip?.size$ ?? constant(undefined),
],
(spotlight, pip, pipSize) => ({
type: l.type,
@@ -506,7 +506,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "lg",
pipSize: undefined,
},
b: {
type: "one-on-one-mobile",
@@ -544,7 +544,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "lg",
pipSize: undefined,
},
});
// It wouldn't be clear whether Alice or Bob is the remote video tile,
@@ -592,7 +592,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "sm",
pipSize: undefined,
},
b: {
type: "spotlight-landscape",
@@ -791,7 +791,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "lg",
pipSize: undefined,
},
b: {
// In a larger window, expect the one-on-one desktop layout
+32 -32
View File
@@ -78,14 +78,13 @@ import {
type ReactionInfo,
type ReactionOption,
} from "../../reactions";
import { shallowEquals as shallowArrayEquals } from "../../utils/array";
import { shallowEquals as shallowObjectEquals } from "../../utils/object";
import { shallowEquals as arrayShallowEquals } from "../../utils/array";
import { type MediaDevice, type MediaDevices } from "../MediaDevices";
import { type Handler, type MuteStates } from "../MuteStates";
import { HeaderStyle, type UrlParams } from "../../UrlParams";
import { type HostBridge, nullHostBridge } from "../../HostBridge";
import {
type Alignment,
layoutShallowEquals,
type Layout,
type LayoutMedia,
type WindowMode,
@@ -126,6 +125,7 @@ import {
type RingingMediaViewModel,
} from "../media/RingingMediaViewModel.ts";
import { type GridTileViewModel } from "../TileViewModel.ts";
import { createFloatingTile } from "../FloatingTile.ts";
/** How a call captures and encodes the tracks it publishes. */
export interface CaptureSettings {
@@ -830,7 +830,7 @@ export function createCallViewModel$(
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m),
);
}),
distinctUntilChanged(shallowArrayEquals),
distinctUntilChanged(arrayShallowEquals),
),
);
@@ -862,13 +862,18 @@ export function createCallViewModel$(
[ringingMedia$, screenShares$, spotlightSpeaker$, localUserMediaForPip$],
(ringing, screenShares, speaker, localPip) =>
computeSpotlight({ ringing, screenShares, speaker, localPip }),
).pipe(distinctUntilChanged(shallowObjectEquals)),
).pipe(
distinctUntilChanged(
(a, b) =>
arrayShallowEquals(a.spotlight, b.spotlight) && a.pip === b.pip,
),
),
);
const spotlight$ = scope.behavior<MediaViewModel[]>(
spotlightAndPip$.pipe(
map(({ spotlight }) => spotlight),
distinctUntilChanged<MediaViewModel[]>(shallowArrayEquals),
distinctUntilChanged<MediaViewModel[]>(arrayShallowEquals),
),
);
@@ -1029,7 +1034,7 @@ export function createCallViewModel$(
desktop: platform === "desktop",
}),
),
distinctUntilChanged(shallowObjectEquals<LayoutMedia>),
distinctUntilChanged(layoutShallowEquals),
),
);
@@ -1221,32 +1226,28 @@ export function createCallViewModel$(
);
/**
* The alignment of the floating spotlight tile, if present.
* State of the floating spotlight tile, if present.
*/
const spotlightAlignment$ = new BehaviorSubject<Alignment>({
const floatingSpotlightTile = createFloatingTile(scope, {
inline: "end",
block: "end",
});
/**
* The size of the small picture-in-picture tile, if present, when in portrait.
* State of the small picture-in-picture tile, if present, when in landscape.
*/
const portraitPipSize$ = scope.behavior(
showFooter$.pipe(map((showFooter) => (showFooter ? "lg" : "sm"))),
);
/**
* The alignment of the small picture-in-picture tile, if present, when in portrait.
*/
const portraitPipAlignment$ = new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
});
/**
* The alignment of the small picture-in-picture tile, if present, when in landscape.
*/
const landscapePipAlignment$ = new BehaviorSubject<Alignment>({
const landscapePipTile = createFloatingTile(scope, {
inline: "end",
block: "start",
});
/**
* State of the small picture-in-picture tile, if present, when in portrait.
*/
const portraitPipTile = {
...createFloatingTile(scope, { inline: "end", block: "end" }),
size$: scope.behavior(
showFooter$.pipe(map((showFooter) => (showFooter ? "lg" : "sm"))),
),
};
// There is a cyclical dependency here: the layout algorithms want to know
// which tiles are on screen, but to know which tiles are on screen we have to
@@ -1276,7 +1277,7 @@ export function createCallViewModel$(
case "spotlight-portrait":
[layout, newTiles] = gridLikeLayout(
media,
spotlightAlignment$,
floatingSpotlightTile,
visibleTiles,
setVisibleTiles,
prevTiles,
@@ -1286,26 +1287,25 @@ export function createCallViewModel$(
case "spotlight-expanded":
[layout, newTiles] = spotlightExpandedLayout(
media,
landscapePipAlignment$,
landscapePipTile,
prevTiles,
);
break;
case "one-on-one-desktop":
[layout, newTiles] = oneOnOneDesktopLayout(
media,
landscapePipAlignment$,
landscapePipTile,
prevTiles,
);
pip = layout.pip;
pip = layout.pip.vm;
break;
case "one-on-one-mobile":
[layout, newTiles] = oneOnOneMobileLayout(
media,
portraitPipSize$,
portraitPipAlignment$,
portraitPipTile,
prevTiles,
);
pip = layout.pip;
pip = layout.pip?.vm;
break;
case "pip":
[layout, newTiles] = pipLayout(media, prevTiles);
@@ -1330,7 +1330,7 @@ export function createCallViewModel$(
layoutInternals$.pipe(
map(({ layout }) => layout),
// Drop redundant layout updates before they would hit React.
distinctUntilChanged(shallowObjectEquals<Layout>),
distinctUntilChanged(layoutShallowEquals),
),
);
+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 { test } from "vitest";
import { withTestScheduler } from "../utils/test";
import { createFloatingTile } from "./FloatingTile";
test("FloatingTile changes alignment on drag", () =>
withTestScheduler(({ scope, expectObservable, schedule }) => {
// Tile starts in the top left
const { alignment$, onDrag } = createFloatingTile(scope, {
inline: "start",
block: "start",
});
// Drag to all four corners, and then repeatedly into the top left.
schedule("-↗↘↙↖↖", {
"↗": () =>
onDrag({ x: 100, y: 0, xRatio: 1, yRatio: 0, endOfGesture: false }),
"↘": () =>
onDrag({ x: 100, y: 100, xRatio: 1, yRatio: 1, endOfGesture: false }),
"↙": () =>
onDrag({ x: 0, y: 100, xRatio: 0, yRatio: 1, endOfGesture: false }),
"↖": () =>
onDrag({ x: 0, y: 0, xRatio: 0, yRatio: 0, endOfGesture: false }),
});
// Alignment should change accordingly. Notably, the alignment should *not*
// update when repeatedly dragged into the same corner, for performance.
expectObservable(alignment$).toBe("↖↗↘↙↖-", {
"↗": { inline: "end", block: "start" },
"↘": { inline: "end", block: "end" },
"↙": { inline: "start", block: "end" },
"↖": { inline: "start", block: "start" },
});
}));
+43
View File
@@ -0,0 +1,43 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { distinctUntilChanged, map, startWith, Subject } from "rxjs";
import { type Behavior } from "./Behavior";
import { type DragCallback, type Alignment, type Drag } from "./layout-types";
import { type ObservableScope } from "./ObservableScope";
/**
* Represents a floating tile which may be dragged to different corners of the
* call interface.
*/
export interface FloatingTile {
alignment$: Behavior<Alignment>;
onDrag: DragCallback;
}
export function createFloatingTile(
scope: ObservableScope,
defaultAlignment: Alignment,
): FloatingTile {
const dragEvents$ = new Subject<Drag>();
return {
alignment$: scope.behavior(
dragEvents$.pipe(
map<Drag, Alignment>(({ xRatio, yRatio }) => ({
inline: xRatio < 0.5 ? "start" : "end",
block: yRatio < 0.5 ? "start" : "end",
})),
startWith(defaultAlignment),
distinctUntilChanged(
(a1, a2) => a1.inline === a2.inline && a1.block === a2.block,
),
),
),
onDrag: (drag) => dragEvents$.next(drag),
};
}
+7 -10
View File
@@ -5,14 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type BehaviorSubject } from "rxjs";
import {
type Alignment,
type Layout,
type LayoutMedia,
} from "./layout-types.ts";
import { type Layout, type LayoutMedia } from "./layout-types.ts";
import { type TileStore } from "./TileStore";
import { type FloatingTile } from "./FloatingTile.ts";
export type GridLikeLayoutType =
| "grid"
@@ -25,7 +20,7 @@ export type GridLikeLayoutType =
*/
export function gridLikeLayout(
media: LayoutMedia & { type: GridLikeLayoutType },
spotlightAlignment$: BehaviorSubject<Alignment>,
floatingSpotlightTile: FloatingTile,
visibleTiles: number,
setVisibleTiles: (value: number) => void,
prevTiles: TileStore,
@@ -42,9 +37,11 @@ export function gridLikeLayout(
return [
{
type: media.type,
spotlight: tiles.spotlightTile,
spotlight: tiles.spotlightTile && {
vm: tiles.spotlightTile,
...floatingSpotlightTile,
},
grid: tiles.gridTiles,
spotlightAlignment$,
setVisibleTiles,
} as Layout & { type: GridLikeLayoutType },
tiles,
+3 -6
View File
@@ -6,21 +6,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type BehaviorSubject } from "rxjs";
import {
type Alignment,
type OneOnOneDesktopLayout,
type OneOnOneDesktopLayoutMedia,
} from "./layout-types";
import { type TileStore } from "./TileStore";
import { type FloatingTile } from "./FloatingTile";
/**
* Produces a one-on-one desktop layout with the given media.
*/
export function oneOnOneDesktopLayout(
media: OneOnOneDesktopLayoutMedia,
pipAlignment$: BehaviorSubject<Alignment>,
pipTile: FloatingTile,
prevTiles: TileStore,
): [OneOnOneDesktopLayout, TileStore] {
const update = prevTiles.from(2);
@@ -32,8 +30,7 @@ export function oneOnOneDesktopLayout(
{
type: media.type,
spotlight: tiles.gridTilesByMedia.get(media.spotlight)!,
pip: tiles.gridTilesByMedia.get(media.pip)!,
pipAlignment$,
pip: { vm: tiles.gridTilesByMedia.get(media.pip)!, ...pipTile },
},
tiles,
];
+4 -8
View File
@@ -6,23 +6,20 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type BehaviorSubject } from "rxjs";
import {
type Alignment,
type OneOnOneMobileLayout,
type OneOnOneMobileLayoutMedia,
} from "./layout-types";
import { type TileStore } from "./TileStore";
import { type Behavior } from "./Behavior";
import { type FloatingTile } from "./FloatingTile";
/**
* Produces a one-on-one mobile layout with the given media.
*/
export function oneOnOneMobileLayout(
media: OneOnOneMobileLayoutMedia,
pipSize$: Behavior<"sm" | "lg">,
pipAlignment$: BehaviorSubject<Alignment>,
pipTile: FloatingTile & { size$: Behavior<"sm" | "lg"> },
prevTiles: TileStore,
): [OneOnOneMobileLayout, TileStore] {
const update = prevTiles.from(media.pip === undefined ? 0 : 1);
@@ -30,13 +27,12 @@ export function oneOnOneMobileLayout(
if (media.pip !== undefined) update.registerGridTile(media.pip);
const tiles = update.build();
const pipVm = media.pip && tiles.gridTilesByMedia.get(media.pip);
return [
{
type: media.type,
spotlight: tiles.spotlightTile!,
pip: media.pip && tiles.gridTilesByMedia.get(media.pip),
pipSize$,
pipAlignment$,
pip: pipVm && { vm: pipVm, ...pipTile },
},
tiles,
];
+4 -6
View File
@@ -5,21 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type BehaviorSubject } from "rxjs";
import {
type Alignment,
type SpotlightExpandedLayout,
type SpotlightExpandedLayoutMedia,
} from "./layout-types";
import { type TileStore } from "./TileStore";
import { type FloatingTile } from "./FloatingTile";
/**
* Produces an expanded spotlight layout with the given media.
*/
export function spotlightExpandedLayout(
media: SpotlightExpandedLayoutMedia,
pipAlignment$: BehaviorSubject<Alignment>,
pipTile: FloatingTile,
prevTiles: TileStore,
): [SpotlightExpandedLayout, TileStore] {
const update = prevTiles.from(1);
@@ -27,12 +25,12 @@ export function spotlightExpandedLayout(
if (media.pip !== undefined) update.registerPipTile(media.pip);
const tiles = update.build();
const pipVm = tiles.gridTiles.at(0);
return [
{
type: media.type,
spotlight: tiles.spotlightTile!,
pip: tiles.gridTiles.at(0),
pipAlignment$,
pip: pipVm && { vm: pipVm, ...pipTile },
},
tiles,
];
+82
View File
@@ -0,0 +1,82 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { test, expect } from "vitest";
import { layoutShallowEquals, type Layout } from "./layout-types";
import {
type SpotlightTileViewModel,
type GridTileViewModel,
} from "./TileViewModel";
import { type FloatingTile } from "./FloatingTile";
const spotlightTile = {} as unknown as SpotlightTileViewModel;
const gridTile = {} as unknown as GridTileViewModel;
const pipTile = {} as unknown as FloatingTile & { vm: GridTileViewModel };
const spotlightExpanded: Layout = {
type: "spotlight-expanded",
spotlight: spotlightTile,
};
const spotlightPortrait: Layout = {
type: "spotlight-portrait",
spotlight: { vm: spotlightTile },
grid: [gridTile],
setVisibleTiles: () => {},
};
test("layoutShallowEquals considers a layout to be equal to its shallow clone", () =>
expect(layoutShallowEquals(spotlightExpanded, { ...spotlightExpanded })).toBe(
true,
));
test("layoutShallowEquals detects a missing key", () => {
expect(
layoutShallowEquals(spotlightExpanded, {
...spotlightExpanded,
pip: pipTile,
}),
).toBe(false);
expect(
layoutShallowEquals(
{ ...spotlightExpanded, pip: pipTile },
spotlightExpanded,
),
).toBe(false);
});
test("layoutShallowEquals considers tile wrappers with equal contents to be equal", () =>
expect(
layoutShallowEquals(spotlightPortrait, {
...spotlightPortrait,
spotlight: { vm: spotlightTile },
}),
).toBe(true));
test("layoutShallowEquals detects tile wrappers with different contents", () =>
expect(
layoutShallowEquals(spotlightPortrait, {
...spotlightPortrait,
spotlight: { vm: {} as unknown as SpotlightTileViewModel },
}),
).toBe(false));
test("layoutShallowEquals considers grid arrays with equal contents to be equal", () =>
expect(
layoutShallowEquals(spotlightPortrait, {
...spotlightPortrait,
grid: [...spotlightPortrait.grid],
}),
).toBe(true));
test("layoutShallowEquals detects grid arrays with different contents", () =>
expect(
layoutShallowEquals(spotlightPortrait, {
...spotlightPortrait,
grid: [...spotlightPortrait.grid, gridTile],
}),
).toBe(false));
+65 -13
View File
@@ -5,8 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type BehaviorSubject } from "rxjs";
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
import { type MediaViewModel } from "./media/MediaViewModel.ts";
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts";
@@ -16,6 +14,9 @@ import {
type SpotlightTileViewModel,
} from "./TileViewModel.ts";
import { type Behavior } from "./Behavior.ts";
import { type FloatingTile } from "./FloatingTile.ts";
import { shallowEquals } from "../utils/object.ts";
import { shallowEquals as arrayShallowEquals } from "../utils/array.ts";
/**
* The general shape of the space the call is drawn in. Called a window because
@@ -86,24 +87,48 @@ export interface Alignment {
block: "start" | "end";
}
export interface Drag {
/**
* The X offset of the dragged tile relative to its container.
*/
x: number;
/**
* The Y offset of the dragged tile relative to its container.
*/
y: number;
/**
* The X coordinate of the dragged tile, as a scalar of the container's width.
*/
xRatio: number;
/**
* The Y coordinate of the dragged tile, as a scalar of the container's height.
*/
yRatio: number;
/**
* Whether this event ends the drag gesture.
*/
endOfGesture: boolean;
}
export type DragCallback = (drag: Drag) => void;
export interface GridLayout {
type: "grid";
spotlight?: SpotlightTileViewModel;
spotlight?: FloatingTile & { vm: SpotlightTileViewModel };
grid: GridTileViewModel[];
spotlightAlignment$: BehaviorSubject<Alignment>;
setVisibleTiles: (value: number) => void;
}
export interface SpotlightLandscapeLayout {
type: "spotlight-landscape";
spotlight: SpotlightTileViewModel;
spotlight: { vm: SpotlightTileViewModel };
grid: GridTileViewModel[];
setVisibleTiles: (value: number) => void;
}
export interface SpotlightPortraitLayout {
type: "spotlight-portrait";
spotlight: SpotlightTileViewModel;
spotlight: { vm: SpotlightTileViewModel };
grid: GridTileViewModel[];
setVisibleTiles: (value: number) => void;
}
@@ -111,23 +136,19 @@ export interface SpotlightPortraitLayout {
export interface SpotlightExpandedLayout {
type: "spotlight-expanded";
spotlight: SpotlightTileViewModel;
pip?: GridTileViewModel;
pipAlignment$: BehaviorSubject<Alignment>;
pip?: FloatingTile & { vm: GridTileViewModel };
}
export interface OneOnOneDesktopLayout {
type: "one-on-one-desktop";
spotlight: GridTileViewModel;
pip: GridTileViewModel;
pipAlignment$: BehaviorSubject<Alignment>;
pip: FloatingTile & { vm: GridTileViewModel };
}
export interface OneOnOneMobileLayout {
type: "one-on-one-mobile";
spotlight: SpotlightTileViewModel;
pip?: GridTileViewModel;
pipSize$: Behavior<"sm" | "lg">;
pipAlignment$: BehaviorSubject<Alignment>;
pip?: FloatingTile & { vm: GridTileViewModel; size$: Behavior<"sm" | "lg"> };
}
export interface PipLayout {
@@ -147,3 +168,34 @@ export type Layout =
| OneOnOneDesktopLayout
| OneOnOneMobileLayout
| PipLayout;
/**
* Tests whether the top-level properties, tile wrapper properties, and array
* elements of layout `a` are equal to those of layout `b`. Useful for deduping
* redundant layout updates.
*/
export function layoutShallowEquals<T extends Layout | LayoutMedia>(
a: T,
b: T,
): boolean {
return shallowEquals<T>(a, b, (aValue, bValue) => {
// Special case for tile wrapper objects so we can detect when the
// spotlight or PiP tile is essentially the same
if (
typeof aValue === "object" &&
aValue !== null &&
"vm" in aValue &&
typeof bValue === "object" &&
bValue !== null &&
"vm" in bValue
)
return shallowEquals(aValue, bValue);
// Special case for arrays so we can detect when the grid tile arrays are
// essentially the same
if (Array.isArray(aValue) && Array.isArray(bValue))
return arrayShallowEquals(aValue, bValue);
return aValue === bValue;
});
}
+19 -4
View File
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
import { test, expect } from "vitest";
import { shallowEquals } from "./object";
import { shallowEquals as arrayShallowEquals } from "./array";
interface Thing {
type: string;
@@ -29,12 +30,26 @@ test("shallowEquals detects a missing key", () => {
test("shallowEquals detects a differing value", () =>
expect(shallowEquals(withArray, { ...withArray, value: 2 })).toBe(false));
test("shallowEquals considers arrays with equal contents to be equal", () =>
const comparator = (aValue: unknown, bValue: unknown): boolean => {
if (Array.isArray(aValue) && Array.isArray(bValue))
return arrayShallowEquals(aValue, bValue);
return aValue === bValue;
};
test("shallowEquals with comparator considers arrays with equal contents to be equal", () =>
expect(
shallowEquals(withArray, { ...withArray, items: [...withArray.items] }),
shallowEquals(
withArray,
{ ...withArray, items: [...withArray.items] },
comparator,
),
).toBe(true));
test("shallowEquals detects arrays with different contents", () =>
test("shallowEquals with comparator detects arrays with different contents", () =>
expect(
shallowEquals(withArray, { ...withArray, items: [...withArray.items, {}] }),
shallowEquals(
withArray,
{ ...withArray, items: [...withArray.items, {}] },
comparator,
),
).toBe(false));
+6 -8
View File
@@ -5,17 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { shallowEquals as arrayShallowEquals } from "./array.ts";
/**
* Determine whether two objects are equal by shallow comparison of their
* top-level properties. Array-valued properties are themselves compared
* shallowly, so two objects holding distinct arrays with the same elements are
* considered equal.
* top-level properties, using the given `comparator`.
*/
export function shallowEquals<T extends object>(
a: Readonly<T>,
b: Readonly<T>,
comparator: (aValue: unknown, bValue: unknown) => boolean = (
aValue,
bValue,
) => aValue === bValue,
): boolean {
// If a and b have the same number of keys and every key in a is also in b,
// then they have the same keys.
@@ -29,9 +29,7 @@ export function shallowEquals<T extends object>(
// Now check that they have the same values.
const aValue = (a as any)[key];
const bValue = (b as any)[key];
if (Array.isArray(aValue) && Array.isArray(bValue)) {
if (!arrayShallowEquals(aValue, bValue)) return false;
} else if (aValue !== bValue) return false;
if (!comparator(aValue, bValue)) return false;
}
return true;