From bd3e91738e7849d3d884e9a7e73b5008ee8861cc Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 27 Nov 2025 15:06:36 +0100 Subject: [PATCH 001/119] Reset overwrite url if it is invalid (does fail to reach sfu) --- locales/en/app.json | 1 + src/settings/DeveloperSettingsTab.tsx | 9 ++++--- src/settings/settings.ts | 20 ++++++++++++++- .../localMember/LocalTransport.ts | 25 +++++++++++++++---- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/locales/en/app.json b/locales/en/app.json index 1ff066ea..58291027 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -72,6 +72,7 @@ "save": "Save", "saving": "Saving..." }, + "custom_url_update_reason_invalid": "Auto reset, custom url was invalid!", "debug_tile_layout_label": "Debug tile layout", "device_id": "Device ID: {{id}}", "duplicate_tiles_label": "Number of additional tile copies per participant", diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index 254aaf0f..77775ad7 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -41,6 +41,7 @@ import { matrixRTCMode as matrixRTCModeSetting, customLivekitUrl as customLivekitUrlSetting, MatrixRTCMode, + useSettingWithLastUpdateReason, } from "./settings"; import type { Room as LivekitRoom } from "livekit-client"; import styles from "./DeveloperSettingsTab.module.css"; @@ -92,9 +93,8 @@ export const DeveloperSettingsTab: FC = ({ alwaysShowIphoneEarpieceSetting, ); - const [customLivekitUrl, setCustomLivekitUrl] = useSetting( - customLivekitUrlSetting, - ); + const [customLivekitUrl, setCustomLivekitUrl, customLivekitUrlUpdateReason] = + useSettingWithLastUpdateReason(customLivekitUrlSetting); const [customLivekitUrlTextBuffer, setCustomLivekitUrlTextBuffer] = useState(customLivekitUrl); useEffect(() => { @@ -220,7 +220,8 @@ export const DeveloperSettingsTab: FC = ({ onSubmit={(e) => e.preventDefault()} helpLabel={ customLivekitUrl === null - ? t("developer_mode.custom_livekit_url.from_config") + ? t("developer_mode.custom_livekit_url.from_config") + + (customLivekitUrlUpdateReason ?? "") : t("developer_mode.custom_livekit_url.current_url") + customLivekitUrl } diff --git a/src/settings/settings.ts b/src/settings/settings.ts index f85e1414..85af4579 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -34,15 +34,20 @@ export class Setting { this._value$ = new BehaviorSubject(initialValue); this.value$ = this._value$; + this._lastUpdateReason$ = new BehaviorSubject(null); + this.lastUpdateReason$ = this._lastUpdateReason$; } private readonly key: string; private readonly _value$: BehaviorSubject; + private readonly _lastUpdateReason$: BehaviorSubject; public readonly value$: Behavior; + public readonly lastUpdateReason$: Behavior; - public readonly setValue = (value: T): void => { + public readonly setValue = (value: T, reason?: string): void => { this._value$.next(value); + this._lastUpdateReason$.next(reason ?? null); localStorage.setItem(this.key, JSON.stringify(value)); }; public readonly getValue = (): T => { @@ -57,6 +62,19 @@ export function useSetting(setting: Setting): [T, (value: T) => void] { return [useBehavior(setting.value$), setting.setValue]; } +/** + * React hook that returns a settings's current value and a setter. + */ +export function useSettingWithLastUpdateReason( + setting: Setting, +): [T, (value: T) => void, string | null] { + return [ + useBehavior(setting.value$), + setting.setValue, + useBehavior(setting.lastUpdateReason$), + ]; +} + // null = undecided export const optInAnalytics = new Setting( "opt-in-analytics", diff --git a/src/state/CallViewModel/localMember/LocalTransport.ts b/src/state/CallViewModel/localMember/LocalTransport.ts index 0a85bbc1..8926e97b 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.ts @@ -23,6 +23,7 @@ import { } from "rxjs"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery"; +import { t } from "i18next"; import { type Behavior } from "../../Behavior.ts"; import { type Epoch, type ObservableScope } from "../../ObservableScope.ts"; @@ -178,11 +179,25 @@ async function makeTransport( if (!transport) throw new MatrixRTCTransportMissingError(domain ?? ""); // this will call the jwt/sfu/get endpoint to pre create the livekit room. - await getSFUConfigWithOpenID( - client, - transport.livekit_service_url, - transport.livekit_alias, - ); + try { + await getSFUConfigWithOpenID( + client, + transport.livekit_service_url, + transport.livekit_alias, + ); + } catch (e) { + if (urlFromDevSettings !== undefined) { + logger.error( + "Failed to get SFU config with dev settings overwrite, Resetting dev settings", + ); + customLivekitUrl.setValue( + null, + `\n${t("developer_mode.custom_url_update_reason_invalid")}`, + ); + } else { + throw e; + } + } return transport; } From ed4517703ff235f8667aa8e4c41c772e78df0c4c Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 1 Dec 2025 19:33:51 +0100 Subject: [PATCH 002/119] better UX (valiate on save) --- src/settings/DeveloperSettingsTab.tsx | 58 +++++++++++++++++++++------ src/settings/settings.ts | 13 ------ 2 files changed, 45 insertions(+), 26 deletions(-) diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index 77775ad7..7f90c4c1 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -28,6 +28,7 @@ import { InlineField, Label, RadioControl, + Text, } from "@vector-im/compound-web"; import { FieldRow, InputField } from "../input/Input"; @@ -41,11 +42,11 @@ import { matrixRTCMode as matrixRTCModeSetting, customLivekitUrl as customLivekitUrlSetting, MatrixRTCMode, - useSettingWithLastUpdateReason, } from "./settings"; import type { Room as LivekitRoom } from "livekit-client"; import styles from "./DeveloperSettingsTab.module.css"; import { useUrlParams } from "../UrlParams"; +import { getSFUConfigWithOpenID } from "../livekit/openIDSFU"; interface Props { client: MatrixClient; @@ -93,8 +94,11 @@ export const DeveloperSettingsTab: FC = ({ alwaysShowIphoneEarpieceSetting, ); - const [customLivekitUrl, setCustomLivekitUrl, customLivekitUrlUpdateReason] = - useSettingWithLastUpdateReason(customLivekitUrlSetting); + const [customLivekitUrlUpdateError, setCustomLivekitUrlUpdateError] = + useState(null); + const [customLivekitUrl, setCustomLivekitUrl] = useSetting( + customLivekitUrlSetting, + ); const [customLivekitUrlTextBuffer, setCustomLivekitUrlTextBuffer] = useState(customLivekitUrl); useEffect(() => { @@ -217,11 +221,11 @@ export const DeveloperSettingsTab: FC = ({ />{" "} e.preventDefault()} helpLabel={ customLivekitUrl === null - ? t("developer_mode.custom_livekit_url.from_config") + - (customLivekitUrlUpdateReason ?? "") + ? t("developer_mode.custom_livekit_url.from_config") : t("developer_mode.custom_livekit_url.current_url") + customLivekitUrl } @@ -230,14 +234,36 @@ export const DeveloperSettingsTab: FC = ({ savingLabel={t("developer_mode.custom_livekit_url.saving")} cancelButtonLabel={t("developer_mode.custom_livekit_url.reset")} onSave={useCallback( - (e: React.FormEvent) => { - setCustomLivekitUrl( - customLivekitUrlTextBuffer === "" - ? null - : customLivekitUrlTextBuffer, - ); + async (e: React.FormEvent): Promise => { + if ( + customLivekitUrlTextBuffer === "" || + customLivekitUrlTextBuffer === null + ) { + setCustomLivekitUrl(null); + return Promise.resolve(); + } + + try { + logger.debug("try setting"); + await getSFUConfigWithOpenID( + client, + customLivekitUrlTextBuffer, + "Test-room-alias-" + Date.now().toString() + client.getUserId(), + ); + logger.debug("done setting! Success"); + setCustomLivekitUrlUpdateError(null); + setCustomLivekitUrl(customLivekitUrlTextBuffer); + } catch (e) { + logger.error("failed setting", e); + setCustomLivekitUrlUpdateError("invalid URL (did not update)"); + // automatically unset the error after 4 seconds (2 seconds will be for the save label) + setTimeout(() => { + logger.debug("unsetting error"); + setCustomLivekitUrlUpdateError(null); + }, 2000); + } }, - [setCustomLivekitUrl, customLivekitUrlTextBuffer], + [customLivekitUrlTextBuffer, setCustomLivekitUrl, client], )} value={customLivekitUrlTextBuffer ?? ""} onChange={useCallback( @@ -252,7 +278,13 @@ export const DeveloperSettingsTab: FC = ({ }, [setCustomLivekitUrl], )} - /> + > + {customLivekitUrlUpdateError !== null && ( + + {customLivekitUrlUpdateError} + + )} + {t("developer_mode.matrixRTCMode.title")} diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 85af4579..03008dca 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -62,19 +62,6 @@ export function useSetting(setting: Setting): [T, (value: T) => void] { return [useBehavior(setting.value$), setting.setValue]; } -/** - * React hook that returns a settings's current value and a setter. - */ -export function useSettingWithLastUpdateReason( - setting: Setting, -): [T, (value: T) => void, string | null] { - return [ - useBehavior(setting.value$), - setting.setValue, - useBehavior(setting.lastUpdateReason$), - ]; -} - // null = undecided export const optInAnalytics = new Setting( "opt-in-analytics", From 5ceb140d13899bc4235a1626ed358c2be6da34fa Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 1 Dec 2025 19:50:52 +0100 Subject: [PATCH 003/119] remove check in local transport --- .../localMember/LocalTransport.ts | 25 ++++--------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/src/state/CallViewModel/localMember/LocalTransport.ts b/src/state/CallViewModel/localMember/LocalTransport.ts index 8926e97b..0a85bbc1 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.ts @@ -23,7 +23,6 @@ import { } from "rxjs"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery"; -import { t } from "i18next"; import { type Behavior } from "../../Behavior.ts"; import { type Epoch, type ObservableScope } from "../../ObservableScope.ts"; @@ -179,25 +178,11 @@ async function makeTransport( if (!transport) throw new MatrixRTCTransportMissingError(domain ?? ""); // this will call the jwt/sfu/get endpoint to pre create the livekit room. - try { - await getSFUConfigWithOpenID( - client, - transport.livekit_service_url, - transport.livekit_alias, - ); - } catch (e) { - if (urlFromDevSettings !== undefined) { - logger.error( - "Failed to get SFU config with dev settings overwrite, Resetting dev settings", - ); - customLivekitUrl.setValue( - null, - `\n${t("developer_mode.custom_url_update_reason_invalid")}`, - ); - } else { - throw e; - } - } + await getSFUConfigWithOpenID( + client, + transport.livekit_service_url, + transport.livekit_alias, + ); return transport; } From c20b206ab2a05274350ec5b0936454c05b6cb70c Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 1 Dec 2025 19:53:27 +0100 Subject: [PATCH 004/119] i18n --- locales/en/app.json | 1 - 1 file changed, 1 deletion(-) diff --git a/locales/en/app.json b/locales/en/app.json index 58291027..1ff066ea 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -72,7 +72,6 @@ "save": "Save", "saving": "Saving..." }, - "custom_url_update_reason_invalid": "Auto reset, custom url was invalid!", "debug_tile_layout_label": "Debug tile layout", "device_id": "Device ID: {{id}}", "duplicate_tiles_label": "Number of additional tile copies per participant", From df2f503a05d4e9f45cb7a2e01204cb83c7a2396b Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 11 Dec 2025 18:00:23 +0100 Subject: [PATCH 005/119] review --- src/settings/DeveloperSettingsTab.test.tsx | 1 + src/settings/DeveloperSettingsTab.tsx | 26 ++++++++-------------- src/settings/SettingsModal.tsx | 1 + 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/settings/DeveloperSettingsTab.test.tsx b/src/settings/DeveloperSettingsTab.test.tsx index c18cf23b..7bdf7091 100644 --- a/src/settings/DeveloperSettingsTab.test.tsx +++ b/src/settings/DeveloperSettingsTab.test.tsx @@ -79,6 +79,7 @@ describe("DeveloperSettingsTab", () => { const { container } = render( , diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index 7f90c4c1..d16e3ef7 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -22,13 +22,13 @@ import { import { logger } from "matrix-js-sdk/lib/logger"; import { EditInPlace, + ErrorMessage, Root as Form, Heading, HelpMessage, InlineField, Label, RadioControl, - Text, } from "@vector-im/compound-web"; import { FieldRow, InputField } from "../input/Input"; @@ -50,6 +50,7 @@ import { getSFUConfigWithOpenID } from "../livekit/openIDSFU"; interface Props { client: MatrixClient; + roomId: string; livekitRooms?: { room: LivekitRoom; url: string; isLocal?: boolean }[]; env: ImportMetaEnv; } @@ -57,6 +58,7 @@ interface Props { export const DeveloperSettingsTab: FC = ({ client, livekitRooms, + roomId, env, }) => { const { t } = useTranslation(); @@ -221,7 +223,6 @@ export const DeveloperSettingsTab: FC = ({ />{" "} e.preventDefault()} helpLabel={ customLivekitUrl === null @@ -240,30 +241,22 @@ export const DeveloperSettingsTab: FC = ({ customLivekitUrlTextBuffer === null ) { setCustomLivekitUrl(null); - return Promise.resolve(); + return; } try { - logger.debug("try setting"); await getSFUConfigWithOpenID( client, customLivekitUrlTextBuffer, - "Test-room-alias-" + Date.now().toString() + client.getUserId(), + roomId, ); - logger.debug("done setting! Success"); setCustomLivekitUrlUpdateError(null); setCustomLivekitUrl(customLivekitUrlTextBuffer); - } catch (e) { - logger.error("failed setting", e); + } catch { setCustomLivekitUrlUpdateError("invalid URL (did not update)"); - // automatically unset the error after 4 seconds (2 seconds will be for the save label) - setTimeout(() => { - logger.debug("unsetting error"); - setCustomLivekitUrlUpdateError(null); - }, 2000); } }, - [customLivekitUrlTextBuffer, setCustomLivekitUrl, client], + [customLivekitUrlTextBuffer, setCustomLivekitUrl, client, roomId], )} value={customLivekitUrlTextBuffer ?? ""} onChange={useCallback( @@ -278,11 +271,10 @@ export const DeveloperSettingsTab: FC = ({ }, [setCustomLivekitUrl], )} + serverInvalid={customLivekitUrlUpdateError !== null} > {customLivekitUrlUpdateError !== null && ( - - {customLivekitUrlUpdateError} - + {customLivekitUrlUpdateError} )} diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 2b4078aa..30ac3618 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -213,6 +213,7 @@ export const SettingsModal: FC = ({ env={import.meta.env} client={client} livekitRooms={livekitRooms} + roomId={roomId} /> ), }; From c35b96062757bfeb5ff1a7c6786ec947ba8198c6 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 5 Jan 2026 10:42:08 +0100 Subject: [PATCH 006/119] fix lints --- src/settings/DeveloperSettingsTab.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index d16e3ef7..0b77f095 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -50,7 +50,7 @@ import { getSFUConfigWithOpenID } from "../livekit/openIDSFU"; interface Props { client: MatrixClient; - roomId: string; + roomId?: string; livekitRooms?: { room: LivekitRoom; url: string; isLocal?: boolean }[]; env: ImportMetaEnv; } @@ -237,6 +237,7 @@ export const DeveloperSettingsTab: FC = ({ onSave={useCallback( async (e: React.FormEvent): Promise => { if ( + roomId === undefined || customLivekitUrlTextBuffer === "" || customLivekitUrlTextBuffer === null ) { From eb604735108d5bf6197d01ddb4256f5f9f349332 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 5 Jan 2026 14:17:25 +0100 Subject: [PATCH 007/119] add tests --- src/settings/DeveloperSettingsTab.test.tsx | 196 ++++++++++++++++++--- 1 file changed, 172 insertions(+), 24 deletions(-) diff --git a/src/settings/DeveloperSettingsTab.test.tsx b/src/settings/DeveloperSettingsTab.test.tsx index 7bdf7091..51255666 100644 --- a/src/settings/DeveloperSettingsTab.test.tsx +++ b/src/settings/DeveloperSettingsTab.test.tsx @@ -5,13 +5,17 @@ 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, vi } from "vitest"; -import { render, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, type Mock, vi } from "vitest"; +import { render, waitFor, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { TooltipProvider } from "@vector-im/compound-web"; import type { MatrixClient } from "matrix-js-sdk"; import type { Room as LivekitRoom } from "livekit-client"; +// import { DeveloperSettingsTab } from "./DeveloperSettingsTab"; import { DeveloperSettingsTab } from "./DeveloperSettingsTab"; - +import { getSFUConfigWithOpenID } from "../livekit/openIDSFU"; +import { customLivekitUrl as customLivekitUrlSetting } from "./settings"; // Mock url params hook to avoid environment-dependent snapshot churn. vi.mock("../UrlParams", () => ({ useUrlParams: (): { mocked: boolean; answer: number } => ({ @@ -20,6 +24,14 @@ vi.mock("../UrlParams", () => ({ }), })); +// IMPORTANT: mock the same specifier used by DeveloperSettingsTab +vi.mock("../livekit/openIDSFU", () => ({ + getSFUConfigWithOpenID: vi.fn().mockResolvedValue({ + url: "mock-url", + jwt: "mock-jwt", + }), +})); + // Provide a minimal mock of a Livekit Room structure used by the component. function createMockLivekitRoom( wsUrl: string, @@ -52,30 +64,30 @@ function createMockMatrixClient(): MatrixClient { } describe("DeveloperSettingsTab", () => { + const livekitRooms: { + room: LivekitRoom; + url: string; + isLocal?: boolean; + }[] = [ + createMockLivekitRoom( + "wss://local-sfu.example.org", + { region: "local", version: "1.2.3" }, + "local-metadata", + ), + { + isLocal: false, + url: "wss://remote-sfu.example.org", + room: { + serverInfo: { region: "remote", version: "4.5.6" }, + metadata: "remote-metadata", + engine: { client: { ws: { url: "wss://remote-sfu.example.org" } } }, + } as unknown as LivekitRoom, + }, + ]; + it("renders and matches snapshot", async () => { const client = createMockMatrixClient(); - const livekitRooms: { - room: LivekitRoom; - url: string; - isLocal?: boolean; - }[] = [ - createMockLivekitRoom( - "wss://local-sfu.example.org", - { region: "local", version: "1.2.3" }, - "local-metadata", - ), - { - isLocal: false, - url: "wss://remote-sfu.example.org", - room: { - serverInfo: { region: "remote", version: "4.5.6" }, - metadata: "remote-metadata", - engine: { client: { ws: { url: "wss://remote-sfu.example.org" } } }, - } as unknown as LivekitRoom, - }, - ]; - const { container } = render( { expect(container).toMatchSnapshot(); }); + describe("custom livekit url", () => { + afterEach(() => { + customLivekitUrlSetting.setValue(null); + }); + const client = { + doesServerSupportUnstableFeature: vi.fn().mockResolvedValue(true), + getCrypto: () => ({ getVersion: (): string => "x" }), + getUserId: () => "@u:hs", + getDeviceId: () => "DEVICE", + } as unknown as MatrixClient; + it("will not update custom livekit url without roomId", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + const input = screen.getByLabelText("Custom Livekit-url"); + await user.clear(input); + await user.type(input, "wss://example.livekit.invalid"); + + const saveButton = screen.getByRole("button", { name: "Save" }); + await user.click(saveButton); + expect(getSFUConfigWithOpenID).not.toHaveBeenCalled(); + + expect(customLivekitUrlSetting.getValue()).toBe(null); + }); + it("will not update custom livekit url without text in input", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + const input = screen.getByLabelText("Custom Livekit-url"); + await user.clear(input); + + const saveButton = screen.getByRole("button", { name: "Save" }); + await user.click(saveButton); + expect(getSFUConfigWithOpenID).not.toHaveBeenCalled(); + + expect(customLivekitUrlSetting.getValue()).toBe(null); + }); + it("will not update custom livekit url when pressing cancel", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + const input = screen.getByLabelText("Custom Livekit-url"); + await user.clear(input); + await user.type(input, "wss://example.livekit.invalid"); + + const cancelButton = screen.getByRole("button", { + name: "Reset overwrite", + }); + await user.click(cancelButton); + expect(getSFUConfigWithOpenID).not.toHaveBeenCalled(); + + expect(customLivekitUrlSetting.getValue()).toBe(null); + }); + it("will update custom livekit url", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + const input = screen.getByLabelText("Custom Livekit-url"); + await user.clear(input); + await user.type(input, "wss://example.livekit.valid"); + + const saveButton = screen.getByRole("button", { name: "Save" }); + await user.click(saveButton); + expect(getSFUConfigWithOpenID).toHaveBeenCalledWith( + expect.anything(), + "wss://example.livekit.valid", + "#testRoom", + ); + + expect(customLivekitUrlSetting.getValue()).toBe( + "wss://example.livekit.valid", + ); + }); + it("will show error on invalid url", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + const input = screen.getByLabelText("Custom Livekit-url"); + await user.clear(input); + await user.type(input, "wss://example.livekit.valid"); + + const saveButton = screen.getByRole("button", { name: "Save" }); + (getSFUConfigWithOpenID as Mock).mockImplementation(() => { + throw new Error("Invalid URL"); + }); + await user.click(saveButton); + expect( + screen.getByText("invalid URL (did not update)"), + ).toBeInTheDocument(); + expect(customLivekitUrlSetting.getValue()).toBe(null); + }); + }); }); From 636f737cd3e5937e5bc0451943c216a415f204a3 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 13 Jan 2026 18:05:05 +0100 Subject: [PATCH 008/119] reduce diff --- src/settings/DeveloperSettingsTab.test.tsx | 46 +++++++++++----------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/settings/DeveloperSettingsTab.test.tsx b/src/settings/DeveloperSettingsTab.test.tsx index 9e3ae0c2..4820d14c 100644 --- a/src/settings/DeveloperSettingsTab.test.tsx +++ b/src/settings/DeveloperSettingsTab.test.tsx @@ -65,32 +65,32 @@ function createMockMatrixClient(): MatrixClient { } describe("DeveloperSettingsTab", () => { - const livekitRooms: { - room: LivekitRoom; - url: string; - isLocal?: boolean; - }[] = [ - createMockLivekitRoom( - "wss://local-sfu.example.org", - { region: "local", version: "1.2.3" }, - "local-metadata", - ), - { - isLocal: false, - url: "wss://remote-sfu.example.org", - room: { - localParticipant: { identity: "localParticipantIdentity" }, - remoteParticipants: new Map(), - serverInfo: { region: "remote", version: "4.5.6" }, - metadata: "remote-metadata", - engine: { client: { ws: { url: "wss://remote-sfu.example.org" } } }, - } as unknown as LivekitRoom, - }, - ]; - it("renders and matches snapshot", async () => { const client = createMockMatrixClient(); + const livekitRooms: { + room: LivekitRoom; + url: string; + isLocal?: boolean; + }[] = [ + createMockLivekitRoom( + "wss://local-sfu.example.org", + { region: "local", version: "1.2.3" }, + "local-metadata", + ), + { + isLocal: false, + url: "wss://remote-sfu.example.org", + room: { + localParticipant: { identity: "localParticipantIdentity" }, + remoteParticipants: new Map(), + serverInfo: { region: "remote", version: "4.5.6" }, + metadata: "remote-metadata", + engine: { client: { ws: { url: "wss://remote-sfu.example.org" } } }, + } as unknown as LivekitRoom, + }, + ]; + const { container } = render( Date: Tue, 13 Jan 2026 18:17:37 +0100 Subject: [PATCH 009/119] fix lint --- src/settings/DeveloperSettingsTab.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index fe539571..d10e3362 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -246,8 +246,15 @@ export const DeveloperSettingsTab: FC = ({ } try { + const userId = client.getUserId(); + const deviceId = client.getDeviceId(); + + if (userId === null || deviceId === null) { + throw new Error("Invalid user or device ID"); + } await getSFUConfigWithOpenID( client, + { userId, deviceId, memberId: "" }, customLivekitUrlTextBuffer, roomId, ); From c376d9e5c1e37a310ee0c9c21edf4fece22ed930 Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 16 Jan 2026 13:12:31 +0100 Subject: [PATCH 010/119] fix tests --- src/settings/DeveloperSettingsTab.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/settings/DeveloperSettingsTab.test.tsx b/src/settings/DeveloperSettingsTab.test.tsx index 1ddedc14..77ea81d6 100644 --- a/src/settings/DeveloperSettingsTab.test.tsx +++ b/src/settings/DeveloperSettingsTab.test.tsx @@ -210,6 +210,7 @@ describe("DeveloperSettingsTab", () => { const saveButton = screen.getByRole("button", { name: "Save" }); await user.click(saveButton); expect(getSFUConfigWithOpenID).toHaveBeenCalledWith( + expect.anything(), expect.anything(), "wss://example.livekit.valid", "#testRoom", From 33f6271d1391ab0c2e0726f8dd1b46bc34955a2f Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 4 Feb 2026 20:00:40 +0100 Subject: [PATCH 011/119] sdk improvements - compatible with custom rtc application - add local member - add optional make sticky - default to video not enabled - allow sending matrix events --- .github/workflows/build-element-call.yaml | 2 +- .github/workflows/build.yaml | 14 ++++ .github/workflows/deploy-to-netlify.yaml | 14 +++- .github/workflows/pr-deploy.yaml | 20 ++++- package.json | 5 +- sdk/helper.ts | 7 +- sdk/main.ts | 92 ++++++++++++++++++----- src/widget.test.ts | 19 +++-- src/widget.ts | 12 ++- yarn.lock | 10 +-- 10 files changed, 147 insertions(+), 48 deletions(-) diff --git a/.github/workflows/build-element-call.yaml b/.github/workflows/build-element-call.yaml index 01553fec..4ca5ccad 100644 --- a/.github/workflows/build-element-call.yaml +++ b/.github/workflows/build-element-call.yaml @@ -7,7 +7,7 @@ on: type: string package: type: string # This would ideally be a `choice` type, but that isn't supported yet - description: The package type to be built. Must be one of 'full' or 'embedded' + description: The package type to be built. Must be one of 'full', 'embedded', or 'sdk' required: true build_mode: type: string # This would ideally be a `choice` type, but that isn't supported yet diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 6aa5fae6..9b86215e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -69,3 +69,17 @@ jobs: SENTRY_URL: ${{ secrets.SENTRY_URL }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + build_sdk_element_call: + # Use the embedded package vite build + uses: ./.github/workflows/build-element-call.yaml + with: + package: sdk + vite_app_version: ${{ github.event.release.tag_name || github.sha }} + build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }} + secrets: + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + SENTRY_URL: ${{ secrets.SENTRY_URL }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/deploy-to-netlify.yaml b/.github/workflows/deploy-to-netlify.yaml index 388192e4..4b7ba22f 100644 --- a/.github/workflows/deploy-to-netlify.yaml +++ b/.github/workflows/deploy-to-netlify.yaml @@ -14,6 +14,10 @@ on: deployment_ref: required: true type: string + package: + required: true + type: string + description: Which package to deploy - 'full', 'embedded', or 'sdk' artifact_run_id: required: false type: string @@ -50,7 +54,7 @@ jobs: with: github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} run-id: ${{ inputs.artifact_run_id }} - name: build-output-full + name: build-output-${{ inputs.package }} path: webapp - name: Add redirects file @@ -58,15 +62,17 @@ jobs: run: curl -s https://raw.githubusercontent.com/element-hq/element-call/main/config/netlify_redirects > webapp/_redirects - name: Add config file - run: curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview.json" > webapp/config.json - + run: | + if [ "${{ inputs.package }}" = "full" ]; then + curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview_sdk.json" > webapp/config.json + fi - name: ☁️ Deploy to Netlify id: netlify uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0 with: publish-dir: webapp deploy-message: "Deploy from GitHub Actions" - alias: pr${{ inputs.pr_number }} + alias: ${{ inputs.package == 'sdk' && format('pr{0}-sdk', inputs.pr_number) || format('pr{0}', inputs.pr_number) }} env: NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} diff --git a/.github/workflows/pr-deploy.yaml b/.github/workflows/pr-deploy.yaml index 7b128352..fe934162 100644 --- a/.github/workflows/pr-deploy.yaml +++ b/.github/workflows/pr-deploy.yaml @@ -20,7 +20,7 @@ jobs: owner: ${{ github.event.workflow_run.head_repository.owner.login }} branch: ${{ github.event.workflow_run.head_branch }} - netlify: + netlify-full: needs: prdetails permissions: deployments: write @@ -31,6 +31,24 @@ jobs: pr_head_full_name: ${{ github.event.workflow_run.head_repository.full_name }} pr_head_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.ref }} deployment_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.sha || github.ref || github.head_ref }} + package: full + secrets: + ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + + netlify-sdk: + needs: prdetails + permissions: + deployments: write + uses: ./.github/workflows/deploy-to-netlify.yaml + with: + artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }} + pr_number: ${{ needs.prdetails.outputs.pr_number }} + pr_head_full_name: ${{ github.event.workflow_run.head_repository.full_name }} + pr_head_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.ref }} + deployment_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.sha || github.ref || github.head_ref }} + package: sdk secrets: ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} diff --git a/package.json b/package.json index 14193013..b835a128 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,9 @@ "build:embedded": "yarn build:full --config vite-embedded.config.js", "build:embedded:production": "yarn build:embedded", "build:embedded:development": "yarn build:embedded --mode development", - "build:sdk": "yarn build:full --config vite-sdk.config.js", "build:sdk:development": "yarn build:sdk --mode development", + "build:sdk": "yarn build:full --config vite-sdk.config.js", + "build:sdk:production": "yarn build:sdk", "serve": "vite preview", "prettier:check": "prettier -c .", "prettier:format": "prettier -w .", @@ -104,7 +105,7 @@ "livekit-client": "^2.13.0", "lodash-es": "^4.17.21", "loglevel": "^1.9.1", - "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", + "matrix-js-sdk": "40.2.0-rc.0", "matrix-widget-api": "^1.16.1", "node-stdlib-browser": "^1.3.1", "normalize.css": "^8.0.1", diff --git a/sdk/helper.ts b/sdk/helper.ts index a3d597be..47de4a93 100644 --- a/sdk/helper.ts +++ b/sdk/helper.ts @@ -12,15 +12,12 @@ Please see LICENSE in the repository root for full details. import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { scan } from "rxjs"; -import { widget as _widget } from "../src/widget"; +import { type WidgetHelpers } from "../src/widget"; import { type LivekitRoomItem } from "../src/state/CallViewModel/CallViewModel"; export const logger = rootLogger.getChild("[MatrixRTCSdk]"); -if (!_widget) throw Error("No widget. This webapp can only start as a widget"); -export const widget = _widget; - -export const tryMakeSticky = (): void => { +export const tryMakeSticky = (widget: WidgetHelpers): void => { logger.info("try making sticky MatrixRTCSdk"); void widget.api .setAlwaysOnScreen(true) diff --git a/sdk/main.ts b/sdk/main.ts index a273ed8a..fddba53c 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -30,8 +30,8 @@ import { } from "rxjs"; import { type CallMembership, - MatrixRTCSession, MatrixRTCSessionEvent, + MatrixRTCSessionManager, } from "matrix-js-sdk/lib/matrixrtc"; import { type Room as LivekitRoom, @@ -50,14 +50,12 @@ import { getUrlParams } from "../src/UrlParams"; import { MuteStates } from "../src/state/MuteStates"; import { MediaDevices } from "../src/state/MediaDevices"; import { E2eeType } from "../src/e2ee/e2eeType"; +import { currentAndPrev, logger, TEXT_LK_TOPIC, tryMakeSticky } from "./helper"; import { - currentAndPrev, - logger, - TEXT_LK_TOPIC, - tryMakeSticky, - widget, -} from "./helper"; -import { ElementWidgetActions, initializeWidget } from "../src/widget"; + ElementWidgetActions, + widget as _widget, + initializeWidget, +} from "../src/widget"; import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection"; interface MatrixRTCSdk { @@ -68,7 +66,7 @@ interface MatrixRTCSdk { join: () => void; /** @throws on leave errors */ leave: () => void; - data$: Observable<{ sender: string; data: string }>; + data$: Observable<{ rtcBackendIdentity: string; data: string }>; /** * flattened list of members */ @@ -79,32 +77,54 @@ interface MatrixRTCSdk { participant: LocalParticipant | RemoteParticipant | null; }[] >; + /** + * flattened local members + */ + localMember$: Behavior<{ + connection: Connection | null; + membership: CallMembership; + participant: LocalParticipant | null; + } | null>; /** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */ connected$: Behavior; sendData?: (data: unknown) => Promise; + sendRoomMessage?: (message: string) => Promise; } export async function createMatrixRTCSdk( application: string = "m.call", id: string = "", + sticky: boolean = false, ): Promise { - initializeWidget(); + const scope = new ObservableScope(); + + // widget client + initializeWidget(application, true); + const widget = _widget; + if (!widget) throw Error("No widget. This webapp can only start as a widget"); const client = await widget.client; logger.info("client created"); - const scope = new ObservableScope(); + + // url params const { roomId } = getUrlParams(); if (roomId === null) throw Error("could not get roomId from url params"); - const room = client.getRoom(roomId); if (room === null) throw Error("could not get room from client"); + // rtc session + const slot = { application, id }; + const rtcSessionManager = new MatrixRTCSessionManager(logger, client, slot); + rtcSessionManager.start(); + const rtcSession = rtcSessionManager.getRoomSession(room); + + // media devices const mediaDevices = new MediaDevices(scope); const muteStates = new MuteStates(scope, mediaDevices, { - audioEnabled: true, - videoEnabled: true, + audioEnabled: false, + videoEnabled: false, }); - const slot = { application, id }; - const rtcSession = new MatrixRTCSession(client, room, slot); + + // call view model const callViewModel = createCallViewModel$( scope, rtcSession, @@ -117,8 +137,9 @@ export async function createMatrixRTCSdk( constant({ supported: false, processor: undefined }), ); logger.info("CallViewModelCreated"); + // create data listener - const data$ = new Subject<{ sender: string; data: string }>(); + const data$ = new Subject<{ rtcBackendIdentity: string; data: string }>(); const lkTextStreamHandlerFunction = async ( reader: TextStreamReader, @@ -140,7 +161,7 @@ export async function createMatrixRTCSdk( if (participants && participants.includes(participantInfo.identity)) { const text = await reader.readAll(); logger.info(`Received text: ${text}`); - data$.next({ sender: participantInfo.identity, data: text }); + data$.next({ rtcBackendIdentity: participantInfo.identity, data: text }); } else { logger.warn( "Received text from unknown participant", @@ -230,6 +251,16 @@ export async function createMatrixRTCSdk( } }; + const sendRoomMessage = async (message: string): Promise => { + const messageString = JSON.stringify(message); + logger.info("try sending to room: ", messageString); + try { + await client.sendTextMessage(room.roomId, message); + } catch (e) { + logger.error("failed sending to room: ", messageString, e); + } + }; + // after hangup gets called const leaveSubs = callViewModel.leave$.subscribe(() => { const scheduleWidgetCloseOnLeave = async (): Promise => { @@ -267,7 +298,7 @@ export async function createMatrixRTCSdk( return { join: (): void => { // first lets try making the widget sticky - tryMakeSticky(); + if (sticky) tryMakeSticky(widget); callViewModel.join(); }, leave: (): void => { @@ -276,6 +307,28 @@ export async function createMatrixRTCSdk( livekitRoomItemsSub.unsubscribe(); }, data$, + localMember$: scope.behavior( + callViewModel.localMatrixLivekitMember$.pipe( + tap((member) => + logger.info("localMatrixLivekitMember$ next: ", member), + ), + switchMap((member) => { + if (member === null) return of(null); + return combineLatest([ + member.connection$, + member.membership$, + member.participant.value$, + ]).pipe( + map(([connection, membership, participant]) => ({ + connection, + membership, + participant, + })), + ); + }), + tap((member) => logger.info("localMember$ next: ", member)), + ), + ), connected$: callViewModel.connected$, members$: scope.behavior( callViewModel.matrixLivekitMembers$.pipe( @@ -302,5 +355,6 @@ export async function createMatrixRTCSdk( [], ), sendData, + sendRoomMessage, }; } diff --git a/src/widget.test.ts b/src/widget.test.ts index f85c56bc..ecebc823 100644 --- a/src/widget.test.ts +++ b/src/widget.test.ts @@ -35,7 +35,7 @@ vi.mock("./UrlParams", () => ({ })), })); -initializeWidget(); +initializeWidget("ANYRTCAPP"); describe("widget", () => { beforeAll(() => {}); @@ -66,13 +66,16 @@ describe("widget", () => { ]; const sendState = [ - "myYser", // Legacy call membership events - `_myYser_AAAAA_m.call`, // Session membership events - `myYser_AAAAA_m.call`, // The above with no leading underscore, for room versions whose auth rules allow it - ].map((stateKey) => ({ - eventType: EventType.GroupCallMemberPrefix, - stateKey, - })); + { eventType: "org.matrix.msc3401.call.member", stateKey: "myYser" }, // Legacy call membership events + { + eventType: "org.matrix.msc3401.call.member", + stateKey: `_myYser_AAAAA_ANYRTCAPP`, + }, // Session membership events + { + eventType: "org.matrix.msc3401.call.member", + stateKey: `myYser_AAAAA_ANYRTCAPP`, + }, // The above with no leading underscore, for room versions whose auth rules allow it + ]; const receiveState = [ { eventType: EventType.RoomCreate }, { eventType: EventType.RoomName }, diff --git a/src/widget.ts b/src/widget.ts index 16dbf514..321727f6 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -68,7 +68,10 @@ export let widget: WidgetHelpers | null; */ // this needs to be a seperate call and cannot be done on import to allow us to spy on methods in here before // execution. -export const initializeWidget = (): void => { +export const initializeWidget = ( + rtcApplication: string = "m.call", + sendRoomEvents = false, +): void => { try { const { widgetId, @@ -116,6 +119,9 @@ export const initializeWidget = (): void => { EventType.CallNotify, // Sent as a deprecated fallback EventType.RTCNotification, ]; + if (sendRoomEvents) { + sendEvent.push(EventType.RoomMessage); + } const sendRecvEvent = [ "org.matrix.rageshake_request", EventType.CallEncryptionKeysPrefix, @@ -128,8 +134,8 @@ export const initializeWidget = (): void => { const sendState = [ userId, // Legacy call membership events - `_${userId}_${deviceId}_m.call`, // Session membership events - `${userId}_${deviceId}_m.call`, // The above with no leading underscore, for room versions whose auth rules allow it + `_${userId}_${deviceId}_${rtcApplication}`, // Session membership events + `${userId}_${deviceId}_${rtcApplication}`, // The above with no leading underscore, for room versions whose auth rules allow it ].map((stateKey) => ({ eventType: EventType.GroupCallMemberPrefix, stateKey, diff --git a/yarn.lock b/yarn.lock index e486bf6b..43ec9545 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8364,7 +8364,7 @@ __metadata: livekit-client: "npm:^2.13.0" lodash-es: "npm:^4.17.21" loglevel: "npm:^1.9.1" - matrix-js-sdk: "matrix-org/matrix-js-sdk#develop" + matrix-js-sdk: "npm:40.2.0-rc.0" matrix-widget-api: "npm:^1.16.1" node-stdlib-browser: "npm:^1.3.1" normalize.css: "npm:^8.0.1" @@ -11452,9 +11452,9 @@ __metadata: languageName: node linkType: hard -"matrix-js-sdk@matrix-org/matrix-js-sdk#develop": - version: 40.1.0 - resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=f2157f28bbadf2898fe21991f69ccb2af40df326" +"matrix-js-sdk@npm:40.2.0-rc.0": + version: 40.2.0-rc.0 + resolution: "matrix-js-sdk@npm:40.2.0-rc.0" dependencies: "@babel/runtime": "npm:^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm": "npm:^17.0.0" @@ -11470,7 +11470,7 @@ __metadata: sdp-transform: "npm:^3.0.0" unhomoglyph: "npm:^1.0.6" uuid: "npm:13" - checksum: 10c0/d646b9214abbf0b9126760105edd9c57be7ffe8b53ae4acd5fefe841a51ad7d78fa57130922b3eac65ff2266b43f31ea60b4bdda9481e6bf8f1808d96726ed8a + checksum: 10c0/82311a60bc0fd2c8f5dff5219d05744d45577c2ea3145d17bef71e6ea194f4bb16f4557a5e74839dbc1b17fe95e08f0f510b7fd0da10f82dda8cb55ce28cd5f5 languageName: node linkType: hard From 927e8e195ce968359ace80e3317b51c84f95223e Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 4 Feb 2026 20:16:31 +0100 Subject: [PATCH 012/119] add experimental label --- sdk/README.md | 2 +- sdk/main.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/sdk/README.md b/sdk/README.md index 91337f10..ad8ff97e 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -1,4 +1,4 @@ -# SDK mode +# SDK mode (EXPERIMENTAL) EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps. diff --git a/sdk/main.ts b/sdk/main.ts index fddba53c..521af6e4 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -6,6 +6,8 @@ Please see LICENSE in the repository root for full details. */ /** + * EXPERIMENTAL + * * This file is the entrypoint for the sdk build of element call: `yarn build:sdk` * use in widgets. * It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that From 809186a2e798a4b243de870741f9906a357c2627 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 4 Feb 2026 21:16:50 +0100 Subject: [PATCH 013/119] test: test requesting send message permission in initialize widget --- src/widget.test.ts | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/widget.test.ts b/src/widget.test.ts index ecebc823..2e5bf743 100644 --- a/src/widget.test.ts +++ b/src/widget.test.ts @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { beforeAll, describe, expect, vi, it } from "vitest"; +import { describe, expect, vi, it, beforeEach } from "vitest"; import { createRoomWidgetClient, EventType } from "matrix-js-sdk"; import { getUrlParams } from "./UrlParams"; @@ -35,11 +35,14 @@ vi.mock("./UrlParams", () => ({ })), })); -initializeWidget("ANYRTCAPP"); -describe("widget", () => { - beforeAll(() => {}); +beforeEach(() => { + createRoomWidgetClientSpy.mockClear(); +}); +describe("widget", () => { it("should create an embedded client with the correct params", () => { + initializeWidget("ANYRTCAPP"); + expect(getUrlParams()).toStrictEqual({ widgetId: "id", parentUrl: "http://parentUrl", @@ -127,4 +130,32 @@ describe("widget", () => { }); expect(createRoomWidgetClientSpy.mock.calls[0][4]).toStrictEqual(false); }); + + it("should request send message permission if requested", () => { + initializeWidget("ANYRTCAPP", true); + expect(createRoomWidgetClientSpy).toHaveBeenLastCalledWith( + expect.anything(), + // capabilities + expect.objectContaining({ + sendEvent: expect.arrayContaining(["m.room.message"]), + }), + expect.anything(), + expect.anything(), + expect.anything(), + ); + }); + + it("should not request send message permission when not requested", () => { + initializeWidget("", false); + expect(createRoomWidgetClientSpy).toHaveBeenLastCalledWith( + expect.anything(), + // capabilities + expect.objectContaining({ + sendEvent: expect.not.arrayContaining(["m.room.message"]), + }), + expect.anything(), + expect.anything(), + expect.anything(), + ); + }); }); From 42efc05bd0e96f68edd8263dbf77707427c99173 Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 5 Feb 2026 06:04:38 +0100 Subject: [PATCH 014/119] Add more granular leave/join api --- sdk/main.ts | 12 +++++++++--- src/state/CallViewModel/CallViewModel.ts | 13 +++++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/sdk/main.ts b/sdk/main.ts index 521af6e4..ee3101fb 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -68,6 +68,12 @@ interface MatrixRTCSdk { join: () => void; /** @throws on leave errors */ leave: () => void; + /** + * Ends the rtc sdk. This will unsubscribe any event listeners. And end the associated scope. + * No updates can be received from the rtc sdk. The sdk cannot be restarted after. + * A new sdk needs to be created via createMatrixRTCSdk. + */ + stop: () => void; data$: Observable<{ rtcBackendIdentity: string; data: string }>; /** * flattened list of members @@ -290,9 +296,6 @@ export async function createMatrixRTCSdk( // schedule close first and then leave (scope.end) void scheduleWidgetCloseOnLeave(); - - // actual hangup (ending scope will send the leave event.. its kinda odd. since you might end up closing the widget too fast) - scope.end(); }); logger.info("createMatrixRTCSdk done"); @@ -305,8 +308,11 @@ export async function createMatrixRTCSdk( }, leave: (): void => { callViewModel.hangup(); + }, + stop: (): void => { leaveSubs.unsubscribe(); livekitRoomItemsSub.unsubscribe(); + scope.end(); }, data$, localMember$: scope.behavior( diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index cf6ca92b..45ef1083 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -217,15 +217,23 @@ export interface CallViewModel { "unknown" | "ringing" | "timeout" | "decline" | "success" | null >; /** Observable that emits when the user should leave the call (hangup pressed, widget action, error). - * THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is by ending the scope. + * THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is + * - by ending the scope + * - or calling requestDisconnect + * + * TODO: it seems more reasonable to add a leave() method (that calls requestDisconnect) that will then update leave$ and remove the hangup pattern */ leave$: Observable<"user" | AutoLeaveReason>; - /** Call to initiate hangup. Use in conbination with reconnectino state track the async hangup process. */ + /** Call to initiate hangup. Use in conbination with reconnection state track the async hangup process. */ hangup: () => void; // joining join: () => void; + /** + * calls requestDisconnect. The async leave state can than be observed via connected$ + */ + leave: () => void; // screen sharing /** * Callback to toggle screen sharing. If null, screen sharing is not possible. @@ -1496,6 +1504,7 @@ export function createCallViewModel$( leave$: leave$, hangup: (): void => userHangup$.next(), join: localMembership.requestJoinAndPublish, + leave: localMembership.requestDisconnect, toggleScreenSharing: toggleScreenSharing, sharingScreen$: sharingScreen$, From 2e26193c4a9a02dbddf2159e38d648cd8329a35b Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 5 Feb 2026 06:33:51 +0100 Subject: [PATCH 015/119] update leave --- sdk/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/main.ts b/sdk/main.ts index ee3101fb..c65bf4a7 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -307,7 +307,7 @@ export async function createMatrixRTCSdk( callViewModel.join(); }, leave: (): void => { - callViewModel.hangup(); + callViewModel.leave(); }, stop: (): void => { leaveSubs.unsubscribe(); From e1ef28fdcc8ef0e84abc03c99cfde83c2b93009b Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 5 Feb 2026 09:04:03 +0100 Subject: [PATCH 016/119] use js-sdk fixing room hack for any app --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index b835a128..a3c92599 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "livekit-client": "^2.13.0", "lodash-es": "^4.17.21", "loglevel": "^1.9.1", - "matrix-js-sdk": "40.2.0-rc.0", + "matrix-js-sdk": "matrix-org/matrix-js-sdk#25bed5303067ba756ffe076ff9ef525246e44880", "matrix-widget-api": "^1.16.1", "node-stdlib-browser": "^1.3.1", "normalize.css": "^8.0.1", diff --git a/yarn.lock b/yarn.lock index 43ec9545..9312bdae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8364,7 +8364,7 @@ __metadata: livekit-client: "npm:^2.13.0" lodash-es: "npm:^4.17.21" loglevel: "npm:^1.9.1" - matrix-js-sdk: "npm:40.2.0-rc.0" + matrix-js-sdk: "matrix-org/matrix-js-sdk#25bed5303067ba756ffe076ff9ef525246e44880" matrix-widget-api: "npm:^1.16.1" node-stdlib-browser: "npm:^1.3.1" normalize.css: "npm:^8.0.1" @@ -11452,9 +11452,9 @@ __metadata: languageName: node linkType: hard -"matrix-js-sdk@npm:40.2.0-rc.0": - version: 40.2.0-rc.0 - resolution: "matrix-js-sdk@npm:40.2.0-rc.0" +"matrix-js-sdk@matrix-org/matrix-js-sdk#25bed5303067ba756ffe076ff9ef525246e44880": + version: 40.1.0 + resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=25bed5303067ba756ffe076ff9ef525246e44880" dependencies: "@babel/runtime": "npm:^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm": "npm:^17.0.0" @@ -11470,7 +11470,7 @@ __metadata: sdp-transform: "npm:^3.0.0" unhomoglyph: "npm:^1.0.6" uuid: "npm:13" - checksum: 10c0/82311a60bc0fd2c8f5dff5219d05744d45577c2ea3145d17bef71e6ea194f4bb16f4557a5e74839dbc1b17fe95e08f0f510b7fd0da10f82dda8cb55ce28cd5f5 + checksum: 10c0/f3d112ad6026de07f4eed35e7a5426d87d79f872435334ed0b3370cb2b6dde2f8f902ed7d4506dc40af231dea8709dff1e576473a1c4b0dc34327d3671b71609 languageName: node linkType: hard From a0209eb4331a35441be00232c06e2004c831becc Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 6 Feb 2026 13:02:20 +0100 Subject: [PATCH 017/119] Add debug logs to generateItems It's always worth having logs for when state holders are created or destroyed (these are often the most interesting things happening in the application), so I thought it would be nice to have generateItems always log for you when it's doing that. --- src/state/CallViewModel/CallViewModel.ts | 1 + .../remoteMembers/ConnectionManager.ts | 1 + .../remoteMembers/MatrixLivekitMembers.ts | 23 ++++---- src/state/UserMedia.ts | 1 + src/utils/observable.test.ts | 1 + src/utils/observable.ts | 54 ++++++++++++++----- 6 files changed, 54 insertions(+), 27 deletions(-) diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index cf6ca92b..e6d50fff 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -715,6 +715,7 @@ export function createCallViewModel$( // Generate a collection of MediaItems from the list of expected (whether // present or missing) LiveKit participants. generateItems( + "CallViewModel userMedia$", function* ([ localMatrixLivekitMember, matrixLivekitMembers, diff --git a/src/state/CallViewModel/remoteMembers/ConnectionManager.ts b/src/state/CallViewModel/remoteMembers/ConnectionManager.ts index 60c824b9..e4376c12 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionManager.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionManager.ts @@ -162,6 +162,7 @@ export function createConnectionManager$({ const connections$ = scope.behavior( localAndRemoteTransports$.pipe( generateItemsWithEpoch( + "ConnectionManager connections$", function* (transports) { for (const transportWithOrWithoutSfuConfig of transports) { if ( diff --git a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts index 04c211d9..acd5b55f 100644 --- a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts +++ b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts @@ -11,7 +11,6 @@ import { type LivekitTransportConfig, } from "matrix-js-sdk/lib/matrixrtc"; import { combineLatest, filter, map } from "rxjs"; -import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { type Behavior } from "../../Behavior"; import { type IConnectionManager } from "./ConnectionManager"; @@ -19,8 +18,6 @@ import { Epoch, type ObservableScope } from "../../ObservableScope"; import { type Connection } from "./Connection"; import { generateItemsWithEpoch } from "../../../utils/observable"; -const logger = rootLogger.getChild("[MatrixLivekitMembers]"); - interface LocalTaggedParticipant { type: "local"; value$: Behavior; @@ -94,9 +91,10 @@ export function createMatrixLivekitMembers$({ ), map(([ms, data]) => new Epoch([ms.value, data.value] as const, ms.epoch)), generateItemsWithEpoch( + "MatrixLivekitMembers", // Generator function. // creates an array of `{key, data}[]` - // Each change in the keys (new key, missing key) will result in a call to the factory function. + // Each change in the keys (new key) will result in a call to the factory function. function* ([membershipsWithTransport, managerData]) { for (const { membership, transport } of membershipsWithTransport) { const participants = transport @@ -111,26 +109,23 @@ export function createMatrixLivekitMembers$({ : null; yield { - // This could also just be the memberId without the other fields. - // In theory we should never have the same memberId for different userIds (they are UUIDs) - // This still makes us resilient agains someone who intentionally tries to use the same memberId. - // If they want to do this they would now need to also use the same sender which is impossible. + // This could just be the backend identity without the other keys. + // The user ID, device ID, and member ID are included however so + // they show up in debug logs. keys: [ membership.userId, membership.deviceId, membership.memberId, + membership.rtcBackendIdentity, ], data: { membership, participant, connection }, }; } }, - // Each update where the key of the generator array do not change will result in updates to the `data$` observable in the factory. - (scope, data$, userId, deviceId, memberId) => { - logger.debug( - `Generating member for livekitIdentity: ${data$.value.membership.rtcBackendIdentity},keys userId:deviceId:memberId ${userId}:${deviceId}:${memberId}`, - ); + // Each update where the key of the generator array do not change will result in updates to the `data$` behavior. + (scope, data$, userId, _deviceId, _memberId, _rtcBackendIdentity) => { const { participant$, ...rest } = scope.splitBehavior(data$); - // will only get called once per `participantId, userId` pair. + // will only get called once per backend identity. // updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent. return { userId, diff --git a/src/state/UserMedia.ts b/src/state/UserMedia.ts index 2a125257..74d24e2f 100644 --- a/src/state/UserMedia.ts +++ b/src/state/UserMedia.ts @@ -130,6 +130,7 @@ export class UserMedia { // MediaViewModels don't support it though since they look for a unique // track for the given source. So generateItems here is a bit overkill. generateItems( + `${this.id} screenShares$`, function* (p) { if (p.isScreenShareEnabled) yield { diff --git a/src/utils/observable.test.ts b/src/utils/observable.test.ts index be677367..80cbb3c8 100644 --- a/src/utils/observable.test.ts +++ b/src/utils/observable.test.ts @@ -47,6 +47,7 @@ test("generateItems", () => { expectObservable( hot(inputMarbles).pipe( generateItems( + "test items", function* (input) { for (let i = 1; i <= +input; i++) { yield { keys: [i], data: undefined }; diff --git a/src/utils/observable.ts b/src/utils/observable.ts index 9739353f..d4182021 100644 --- a/src/utils/observable.ts +++ b/src/utils/observable.ts @@ -24,6 +24,7 @@ import { type OperatorFunction, distinctUntilChanged, } from "rxjs"; +import { logger } from "matrix-js-sdk/lib/logger"; import { type Behavior } from "../state/Behavior"; import { Epoch, ObservableScope } from "../state/ObservableScope"; @@ -122,8 +123,9 @@ export function pauseWhen(pause$: Behavior) { ); } -interface ItemHandle { +interface ItemHandle { scope: ObservableScope; + keys: readonly [...Keys]; data$: BehaviorSubject; item: Item; } @@ -135,6 +137,7 @@ interface ItemHandle { * requested at a later time, and destroyed (have their scope ended) when the * key is no longer requested. * + * @param name A name for this collection to use in debug logs. * @param generator A generator function yielding a tuple of keys and the * currently associated data for each item that it wants to exist. * @param factory A function constructing an individual item, given the item's key, @@ -146,16 +149,17 @@ export function generateItems< Data, Item, >( + name: string, generator: ( input: Input, - ) => Generator<{ keys: readonly [...Keys]; data: Data }, void, void>, + ) => Iterable<{ keys: readonly [...Keys]; data: Data }, void, void>, factory: ( scope: ObservableScope, data$: Behavior, ...keys: Keys ) => Item, ): OperatorFunction { - return generateItemsInternal(generator, factory, (items) => items); + return generateItemsInternal(name, generator, factory, (items) => items); } /** @@ -167,9 +171,10 @@ export function generateItemsWithEpoch< Data, Item, >( + name: string, generator: ( input: Input, - ) => Generator<{ keys: readonly [...Keys]; data: Data }, void, void>, + ) => Iterable<{ keys: readonly [...Keys]; data: Data }, void, void>, factory: ( scope: ObservableScope, data$: Behavior, @@ -177,6 +182,7 @@ export function generateItemsWithEpoch< ) => Item, ): OperatorFunction, Epoch> { return generateItemsInternal( + name, function* (input) { yield* generator(input.value); }, @@ -214,9 +220,10 @@ function generateItemsInternal< Item, Output, >( + name: string, generator: ( input: Input, - ) => Generator<{ keys: readonly [...Keys]; data: Data }, void, void>, + ) => Iterable<{ keys: readonly [...Keys]; data: Data }, void, void>, factory: ( scope: ObservableScope, data$: Behavior, @@ -232,26 +239,34 @@ function generateItemsInternal< Input, { map: Map; - items: Set>; + items: Set>; input: Input; }, - { map: Map; items: Set> } + { map: Map; items: Set> } >( ({ map: prevMap, items: prevItems }, input) => { const nextMap = new Map(); - const nextItems = new Set>(); + const nextItems = new Set>(); for (const { keys, data } of generator(input)) { // Disable type checks for a second to grab the item out of a nested map let i: any = prevMap; for (const key of keys) i = i?.get(key); - let item = i as ItemHandle | undefined; + let item = i as ItemHandle | undefined; if (item === undefined) { // First time requesting the key; create the item const scope = new ObservableScope(); const data$ = new BehaviorSubject(data); - item = { scope, data$, item: factory(scope, data$, ...keys) }; + logger.debug( + `[${name}] Creating item with keys ${keys.join(", ")}`, + ); + item = { + scope, + keys, + data$, + item: factory(scope, data$, ...keys), + }; } else { item.data$.next(data); } @@ -269,7 +284,7 @@ function generateItemsInternal< const finalKey = keys[keys.length - 1]; if (m.has(finalKey)) throw new Error( - `Keys must be unique (tried to generate multiple items for key ${keys})`, + `Keys must be unique (tried to generate multiple items for key ${keys.join(", ")})`, ); m.set(keys[keys.length - 1], item); nextItems.add(item); @@ -277,7 +292,12 @@ function generateItemsInternal< // Destroy all items that are no longer being requested for (const item of prevItems) - if (!nextItems.has(item)) item.scope.end(); + if (!nextItems.has(item)) { + logger.debug( + `[${name}] Destroying item with keys ${item.keys.join(", ")}`, + ); + item.scope.end(); + } return { map: nextMap, items: nextItems, input }; }, @@ -285,7 +305,15 @@ function generateItemsInternal< ), finalizeValue(({ items }) => { // Destroy all remaining items when no longer subscribed - for (const { scope } of items) scope.end(); + logger.debug( + `[${name}] End of scope, destroying all ${items.size} items…`, + ); + for (const item of items) { + logger.debug( + `[${name}] Destroying item with keys ${item.keys.join(", ")}`, + ); + item.scope.end(); + } }), map(({ items, input }) => project( From 652ff0060c293b8af3a9edf59f6194a76e63a798 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 9 Feb 2026 09:07:45 +0100 Subject: [PATCH 018/119] Back to develop branck (matrix-js-skd) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a3c92599..49612120 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "livekit-client": "^2.13.0", "lodash-es": "^4.17.21", "loglevel": "^1.9.1", - "matrix-js-sdk": "matrix-org/matrix-js-sdk#25bed5303067ba756ffe076ff9ef525246e44880", + "matrix-js-sdk": "matrix-org/matrix-js-sdk#6e3efef0c5f660df47cf00874927dec1c75cc3cf", "matrix-widget-api": "^1.16.1", "node-stdlib-browser": "^1.3.1", "normalize.css": "^8.0.1", diff --git a/yarn.lock b/yarn.lock index 9312bdae..6c6fa5fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8364,7 +8364,7 @@ __metadata: livekit-client: "npm:^2.13.0" lodash-es: "npm:^4.17.21" loglevel: "npm:^1.9.1" - matrix-js-sdk: "matrix-org/matrix-js-sdk#25bed5303067ba756ffe076ff9ef525246e44880" + matrix-js-sdk: "matrix-org/matrix-js-sdk#6e3efef0c5f660df47cf00874927dec1c75cc3cf" matrix-widget-api: "npm:^1.16.1" node-stdlib-browser: "npm:^1.3.1" normalize.css: "npm:^8.0.1" @@ -11452,9 +11452,9 @@ __metadata: languageName: node linkType: hard -"matrix-js-sdk@matrix-org/matrix-js-sdk#25bed5303067ba756ffe076ff9ef525246e44880": +"matrix-js-sdk@matrix-org/matrix-js-sdk#6e3efef0c5f660df47cf00874927dec1c75cc3cf": version: 40.1.0 - resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=25bed5303067ba756ffe076ff9ef525246e44880" + resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=6e3efef0c5f660df47cf00874927dec1c75cc3cf" dependencies: "@babel/runtime": "npm:^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm": "npm:^17.0.0" @@ -11470,7 +11470,7 @@ __metadata: sdp-transform: "npm:^3.0.0" unhomoglyph: "npm:^1.0.6" uuid: "npm:13" - checksum: 10c0/f3d112ad6026de07f4eed35e7a5426d87d79f872435334ed0b3370cb2b6dde2f8f902ed7d4506dc40af231dea8709dff1e576473a1c4b0dc34327d3671b71609 + checksum: 10c0/2c4db56fd0164d801c2f125ab2a442e3659314d4cc2fd640ea152b829d0db8b05ff808020e387a761afde4ff7a07b271c25431337de9f7c765c523c8cd837e36 languageName: node linkType: hard From 73245e875dd674e7fb3ad8b9714f2f9252aeffd5 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 10 Feb 2026 15:11:43 +0100 Subject: [PATCH 019/119] fix: Netlify preview broken --- .github/workflows/deploy-to-netlify.yaml | 2 ++ config/config_netlify_preview_sdk.json | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 config/config_netlify_preview_sdk.json diff --git a/.github/workflows/deploy-to-netlify.yaml b/.github/workflows/deploy-to-netlify.yaml index 4b7ba22f..3f964ad4 100644 --- a/.github/workflows/deploy-to-netlify.yaml +++ b/.github/workflows/deploy-to-netlify.yaml @@ -64,6 +64,8 @@ jobs: - name: Add config file run: | if [ "${{ inputs.package }}" = "full" ]; then + curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview.json" > webapp/config.json + else curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview_sdk.json" > webapp/config.json fi - name: ☁️ Deploy to Netlify diff --git a/config/config_netlify_preview_sdk.json b/config/config_netlify_preview_sdk.json new file mode 100644 index 00000000..784f0c7e --- /dev/null +++ b/config/config_netlify_preview_sdk.json @@ -0,0 +1,16 @@ +{ + "default_server_config": { + "m.homeserver": { + "base_url": "https://call-unstable.ems.host", + "server_name": "call-unstable.ems.host" + } + }, + "ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf", + "matrix_rtc_session": { + "wait_for_key_rotation_ms": 3000, + "membership_event_expiry_ms": 180000000, + "delayed_leave_event_delay_ms": 18000, + "delayed_leave_event_restart_ms": 4000, + "network_error_retry_ms": 100 + } +} From 1e9f2e6282f1ed86629227e847ee2856a46b7cca Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 10 Feb 2026 17:24:01 +0100 Subject: [PATCH 020/119] fix: Simplify log causing stringify `TypeError` on widget mode --- src/state/CallViewModel/CallViewModel.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 82bcaedb..5f7303c6 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -1555,7 +1555,15 @@ export function createCallViewModel$( matrixLivekitMembers$.pipe( map((members) => members.value), tap((v) => { - logger.debug("matrixLivekitMembers$ updated (exported)", v); + const listForLogs = v + .map( + (m) => + m.membership$.value.userId + "|" + m.membership$.value.deviceId, + ) + .join(","); + logger.debug( + `matrixLivekitMembers$ updated (exported) [${listForLogs}]`, + ); }), ), ), From 0d24995c3ebc038a931227259aa06a6aa206d15d Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 10 Feb 2026 17:46:58 +0100 Subject: [PATCH 021/119] add defensive coding against JSON.stringify --- src/settings/rageshake.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/settings/rageshake.ts b/src/settings/rageshake.ts index 26d0839b..c288f73e 100644 --- a/src/settings/rageshake.ts +++ b/src/settings/rageshake.ts @@ -75,7 +75,14 @@ class ConsoleLogger extends EventEmitter { } else if (arg instanceof Error) { return arg.message + (arg.stack ? `\n${arg.stack}` : ""); } else if (typeof arg === "object") { - return JSON.stringify(arg, getCircularReplacer()); + try { + return JSON.stringify(arg, getCircularReplacer()); + } catch { + // Stringify can fail if the object has circular references or if + // there is a bigInt. + // Did happen even with our `getCircularReplacer`. In this case, just log + return "<$ failed to serialize object $>"; + } } else { return arg; } From 13d131c2e939fee8e6b6eb0317eb8078eb23c022 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 9 Feb 2026 19:07:27 +0100 Subject: [PATCH 022/119] Logically separate the advertised transport from the active transport To correctly implement the legacy "oldest membership" mode, we need the code to be more nuanced about the local transport. Specifically, it needs to allow for the transport we advertise in our membership to be different from the transport that we connect to and publish media on. Otherwise, if these two are yoked together, members will resend their memberships whenever an SFU hop occurs, which an attacker could use to cause an amplified wave of state changes. --- src/state/CallViewModel/CallViewModel.ts | 66 ++--- .../CallViewModel/localMember/LocalMember.ts | 11 +- .../localMember/LocalTransport.ts | 265 +++++++++++------- .../remoteMembers/ConnectionManager.ts | 22 +- 4 files changed, 211 insertions(+), 153 deletions(-) diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 82bcaedb..e2c6e46e 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -444,35 +444,34 @@ export function createCallViewModel$( memberId: uuidv4(), }; - const localTransport$ = createLocalTransport$({ - scope: scope, - memberships$: memberships$, - ownMembershipIdentity, - client, - delayId$: scope.behavior( - ( - fromEvent( - matrixRTCSession, - MembershipManagerEvent.DelayIdChanged, - // The type of reemitted event includes the original emitted as the second arg. - ) as Observable<[string | undefined, IMembershipManager]> - ).pipe(map(([delayId]) => delayId ?? null)), - matrixRTCSession.delayId ?? null, - ), - roomId: matrixRoom.roomId, - forceJwtEndpoint$: scope.behavior( - matrixRTCMode$.pipe( - map((v) => - v === MatrixRTCMode.Matrix_2_0 - ? JwtEndpointVersion.Matrix_2_0 - : JwtEndpointVersion.Legacy, - ), + const localTransport$ = scope.behavior( + matrixRTCMode$.pipe( + map((mode) => + createLocalTransport$({ + scope: scope, + memberships$: memberships$, + ownMembershipIdentity, + client, + delayId$: scope.behavior( + ( + fromEvent( + matrixRTCSession, + MembershipManagerEvent.DelayIdChanged, + // The type of reemitted event includes the original emitted as the second arg. + ) as Observable<[string | undefined, IMembershipManager]> + ).pipe(map(([delayId]) => delayId ?? null)), + matrixRTCSession.delayId ?? null, + ), + roomId: matrixRoom.roomId, + forceJwtEndpoint: + mode === MatrixRTCMode.Matrix_2_0 + ? JwtEndpointVersion.Matrix_2_0 + : JwtEndpointVersion.Legacy, + useOldestMember: mode === MatrixRTCMode.Legacy, + }), ), ), - useOldestMember$: scope.behavior( - matrixRTCMode$.pipe(map((v) => v === MatrixRTCMode.Legacy)), - ), - }); + ); const connectionFactory = new ECConnectionFactory( client, @@ -491,6 +490,7 @@ export function createCallViewModel$( connectionFactory: connectionFactory, localTransport$: scope.behavior( localTransport$.pipe( + switchMap((t) => t.active$), catchError((e: unknown) => { logger.info( "could not pass local transport to createConnectionManager$. localTransport$ threw an error", @@ -524,13 +524,13 @@ export function createCallViewModel$( ); const localMembership = createLocalMembership$({ - scope: scope, + scope, homeserverConnected: createHomeserverConnected$( scope, client, matrixRTCSession, ), - muteStates: muteStates, + muteStates, joinMatrixRTC: (transport: LivekitTransportConfig) => { return enterRTCSession( matrixRTCSession, @@ -550,9 +550,11 @@ export function createCallViewModel$( ), ); }, - connectionManager: connectionManager, - matrixRTCSession: matrixRTCSession, - localTransport$: localTransport$, + connectionManager, + matrixRTCSession, + localTransport$: scope.behavior( + localTransport$.pipe(switchMap((t) => t.advertised$)), + ), logger: logger.getChild(`[${Date.now()}]`), }); diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index 2f38ad82..eb641ca7 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -62,7 +62,6 @@ import { } from "../remoteMembers/Connection.ts"; import { type HomeserverConnected } from "./HomeserverConnected.ts"; import { and$ } from "../../../utils/observable.ts"; -import { type LocalTransportWithSFUConfig } from "./LocalTransport.ts"; export enum TransportState { /** Not even a transport is available to the LocalMembership */ @@ -128,7 +127,7 @@ interface Props { createPublisherFactory: (connection: Connection) => Publisher; joinMatrixRTC: (transport: LivekitTransportConfig) => void; homeserverConnected: HomeserverConnected; - localTransport$: Behavior; + localTransport$: Behavior; matrixRTCSession: Pick< MatrixRTCSession, "updateCallIntent" | "leaveRoomSession" @@ -147,7 +146,7 @@ interface Props { * @param props.createPublisherFactory Factory to create a publisher once we have a connection. * @param props.joinMatrixRTC Callback to join the matrix RTC session once we have a transport. * @param props.homeserverConnected The homeserver connected state. - * @param props.localTransport$ The local transport to use for publishing. + * @param props.localTransport$ The transport to advertise in our membership. * @param props.logger The logger to use. * @param props.muteStates The mute states for video and audio. * @param props.matrixRTCSession The matrix RTC session to join. @@ -237,9 +236,7 @@ export const createLocalMembership$ = ({ return null; } - return connectionData.getConnectionForTransport( - localTransport.transport, - ); + return connectionData.getConnectionForTransport(localTransport); }), tap((connection) => { logger.info( @@ -549,7 +546,7 @@ export const createLocalMembership$ = ({ if (!shouldConnect) return; try { - joinMatrixRTC(transport.transport); + joinMatrixRTC(transport); } catch (error) { logger.error("Error entering RTC session", error); if (error instanceof Error) diff --git a/src/state/CallViewModel/localMember/LocalTransport.ts b/src/state/CallViewModel/localMember/LocalTransport.ts index 73364094..0b566ba0 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.ts @@ -13,12 +13,15 @@ import { } from "matrix-js-sdk/lib/matrixrtc"; import { MatrixError, type MatrixClient } from "matrix-js-sdk"; import { - combineLatest, distinctUntilChanged, + first, from, map, + merge, of, + startWith, switchMap, + tap, } from "rxjs"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery"; @@ -58,8 +61,8 @@ interface Props { OpenIDClientParts; // Used by the jwt service to create the livekit room and compute the livekit alias. roomId: string; - useOldestMember$: Behavior; - forceJwtEndpoint$: Behavior; + useOldestMember: boolean; + forceJwtEndpoint: JwtEndpointVersion; delayId$: Behavior; } @@ -93,23 +96,35 @@ export interface LocalTransportWithSFUConfig { transport: LivekitTransportConfig; sfuConfig: SFUConfig; } + export function isLocalTransportWithSFUConfig( obj: LivekitTransportConfig | LocalTransportWithSFUConfig, ): obj is LocalTransportWithSFUConfig { return "transport" in obj && "sfuConfig" in obj; } +interface LocalTransport { + /** + * The transport to be advertised in our MatrixRTC membership. `null` when not + * yet fetched/validated. + */ + advertised$: Behavior; + /** + * The transport to connect to and publish media on. `null` when not yet known + * or available. + */ + active$: Behavior; +} + /** - * This class is responsible for managing the local transport. - * "Which transport is the local member going to use" + * Connects to the JWT service and determines the transports that the local member should use. * * @prop useOldestMember Whether to use the same transport as the oldest member. * This will only update once the first oldest member appears. Will not recompute if the oldest member leaves. - * - * @prop useOldJwtEndpoint$ Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint. + * @prop useOldJwtEndpoint Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint. * This is used when the connection manager needs to know if it has to use the legacy endpoint which implies a string concatenated rtcBackendIdentity. * (which is expected for non sticky event based rtc member events) - * @returns The local transport. It will be created using the correct sfu endpoint based on the useOldJwtEndpoint$ value. + * @returns The transport to advertise in the local MatrixRTC membership, along with the transport to actively publish media to. * @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken */ export const createLocalTransport$ = ({ @@ -118,114 +133,156 @@ export const createLocalTransport$ = ({ ownMembershipIdentity, client, roomId, - useOldestMember$, - forceJwtEndpoint$, + useOldestMember, + forceJwtEndpoint, delayId$, -}: Props): Behavior => { +}: Props): LocalTransport => { /** - * The transport over which we should be actively publishing our media. - * undefined when not joined. + * The LiveKit transport in use by the oldest RTC membership. `null` when the + * oldest member has no such transport. */ - const oldestMemberTransport$ = - scope.behavior( - combineLatest([memberships$, useOldestMember$]).pipe( - map(([memberships, useOldestMember]) => { - if (!useOldestMember) return null; // No need to do any prefetching if not using oldest member - const oldestMember = memberships.value[0]; - const transport = oldestMember?.getTransport(oldestMember); - if (!transport) return null; - return transport; - }), - switchMap((transport) => { - if (transport !== null && isLivekitTransportConfig(transport)) { - // Get the open jwt token to connect to the sfu - const computeLocalTransportWithSFUConfig = - async (): Promise => { - return { - transport, - sfuConfig: await getSFUConfigWithOpenID( - client, - ownMembershipIdentity, - transport.livekit_service_url, - roomId, - { forceJwtEndpoint: JwtEndpointVersion.Legacy }, - logger, - ), - }; - }; - return from(computeLocalTransportWithSFUConfig()); - } - return of(null); - }), - ), - null, - ); + const oldestMemberTransport$ = scope.behavior( + memberships$.pipe( + map((memberships) => { + const oldestMember = memberships.value[0]; + if (oldestMember === undefined) { + logger.info("Oldest member: not found"); + return null; + } + const transport = oldestMember.getTransport(oldestMember); + if (transport === undefined) { + logger.warn( + `Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has no transport`, + ); + return null; + } + if (!isLivekitTransportConfig(transport)) { + logger.warn( + `Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has invalid transport`, + ); + return null; + } + logger.info( + "Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has valid transport", + ); + return transport; + }), + distinctUntilChanged(areLivekitTransportsEqual), + ), + ); /** * The transport that we would personally prefer to publish on (if not for the - * transport preferences of others, perhaps). + * transport preferences of others, perhaps). `null` until fetched and + * validated. * * @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken */ - const preferredTransport$ = scope.behavior( - // preferredTransport$ (used for multi sfu) needs to know if we are using the old or new - // jwt endpoint (`get_token` vs `sfu/get`) based on that the jwt endpoint will compute the rtcBackendIdentity - // differently. (sha(`${userId}|${deviceId}|${memberId}`) vs `${userId}|${deviceId}|${memberId}`) - // When using sticky events (we need to use the new endpoint). - combineLatest([customLivekitUrl.value$, delayId$, forceJwtEndpoint$]).pipe( - switchMap(([customUrl, delayId, forceEndpoint]) => { - logger.info( - "Creating preferred transport based on: ", - "customUrl: ", - customUrl, - "delayId: ", - delayId, - "forceEndpoint: ", - forceEndpoint, - ); - return from( - makeTransport( - client, - ownMembershipIdentity, - roomId, - customUrl, - forceEndpoint, - delayId ?? undefined, + const preferredTransport$ = + scope.behavior( + // preferredTransport$ (used for multi sfu) needs to know if we are using the old or new + // jwt endpoint (`get_token` vs `sfu/get`) based on that the jwt endpoint will compute the rtcBackendIdentity + // differently. (sha(`${userId}|${deviceId}|${memberId}`) vs `${userId}|${deviceId}|${memberId}`) + // When using sticky events (we need to use the new endpoint). + customLivekitUrl.value$.pipe( + switchMap((customUrl) => + startWith(null)( + // Fetch the SFU config, and repeat this asynchronously for every + // change in delay ID. + delayId$.pipe( + switchMap(async (delayId) => { + logger.info( + "Creating preferred transport based on: ", + "customUrl: ", + customUrl, + "delayId: ", + delayId, + "forceJwtEndpoint: ", + forceJwtEndpoint, + ); + return makeTransport( + client, + ownMembershipIdentity, + roomId, + customUrl, + forceJwtEndpoint, + delayId ?? undefined, + ); + }), + // We deliberately hide any changes to the SFU config because we + // do not actually want the app to reconnect whenever the JWT + // token changes due to us delegating a new delayed event. The + // initial SFU config for the transport is all the app needs. + distinctUntilChanged((prev, next) => + areLivekitTransportsEqual(prev.transport, next.transport), + ), + ), ), - ); - }), - ), - null, - ); + ), + ), + ); - /** - * The chosen transport we should advertise in our MatrixRTC membership. - */ - return scope.behavior( - combineLatest([ - useOldestMember$, - oldestMemberTransport$, - preferredTransport$, - ]).pipe( - map(([useOldestMember, oldestMemberTransport, preferredTransport]) => { - return useOldestMember - ? (oldestMemberTransport ?? preferredTransport) - : preferredTransport; - }), - distinctUntilChanged((t1, t2) => { - logger.info( - "Local Transport Update from:", - t1?.transport.livekit_service_url, - " to ", - t2?.transport.livekit_service_url, - ); - return areLivekitTransportsEqual( - t1?.transport ?? null, - t2?.transport ?? null, - ); - }), + if (useOldestMember) { + // --- Oldest member mode --- + return { + // Never update the transport that we advertise in our membership. Just + // take the first valid oldest member or preferred transport that we learn + // about, and stick with that. This avoids unnecessary SFU hops and room + // state changes. + advertised$: scope.behavior( + merge( + oldestMemberTransport$, + preferredTransport$.pipe(map((t) => t?.transport ?? null)), + ).pipe( + first((t) => t !== null), + tap((t) => + logger.info(`Advertise transport: ${t.livekit_service_url}`), + ), + ), + null, + ), + // Publish on the transport used by the oldest member. + active$: scope.behavior( + oldestMemberTransport$.pipe( + switchMap((transport) => { + // Oldest member not available (or invalid SFU config). + if (transport === null) return of(null); + // Oldest member available: fetch the SFU config. + const fetchOldestMemberTransport = + async (): Promise => ({ + transport, + sfuConfig: await getSFUConfigWithOpenID( + client, + ownMembershipIdentity, + transport.livekit_service_url, + roomId, + { forceJwtEndpoint: JwtEndpointVersion.Legacy }, + logger, + ), + }); + return from(fetchOldestMemberTransport()).pipe(startWith(null)); + }), + tap((t) => + logger.info( + `Publish on transport: ${t?.transport.livekit_service_url}`, + ), + ), + ), + ), + }; + } + + // --- Multi-SFU mode --- + // Always publish on and advertise the preferred transport. + return { + advertised$: scope.behavior( + preferredTransport$.pipe( + map((t) => t?.transport ?? null), + distinctUntilChanged(areLivekitTransportsEqual), + ), ), - ); + active$: preferredTransport$, + }; }; const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci"; diff --git a/src/state/CallViewModel/remoteMembers/ConnectionManager.ts b/src/state/CallViewModel/remoteMembers/ConnectionManager.ts index e4376c12..727f68bc 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionManager.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionManager.ts @@ -90,7 +90,7 @@ export interface IConnectionManager { * @param props - Configuration object * @param props.scope - The observable scope used by this object * @param props.connectionFactory - Used to create new connections - * @param props.localTransport$ - The local transport to use. (deduplicated with remoteTransports$) + * @param props.localTransport$ - The transport to publish local media on. (deduplicated with remoteTransports$) * @param props.remoteTransports$ - All other transports. The connection manager will create connections for each transport. (deduplicated with localTransport$) * @param props.ownMembershipIdentity - The own membership identity to use. * @param props.logger - The logger to use. @@ -164,21 +164,21 @@ export function createConnectionManager$({ generateItemsWithEpoch( "ConnectionManager connections$", function* (transports) { - for (const transportWithOrWithoutSfuConfig of transports) { - if ( - isLocalTransportWithSFUConfig(transportWithOrWithoutSfuConfig) - ) { - // This is the local transport only the `LocalTransportWithSFUConfig` has a `sfuConfig` field - const { transport, sfuConfig } = transportWithOrWithoutSfuConfig; + for (const transport of transports) { + if (isLocalTransportWithSFUConfig(transport)) { + // This is the local transport; only the `LocalTransportWithSFUConfig` has a `sfuConfig` field. yield { - keys: [transport.livekit_service_url, sfuConfig], + keys: [ + transport.transport.livekit_service_url, + transport.sfuConfig, + ], data: undefined, }; } else { yield { keys: [ - transportWithOrWithoutSfuConfig.livekit_service_url, - undefined as undefined | SFUConfig, + transport.livekit_service_url, + undefined as SFUConfig | undefined, ], data: undefined, }; @@ -194,6 +194,8 @@ export function createConnectionManager$({ }, ownMembershipIdentity, logger, + // TODO: This whole optional SFUConfig parameter is not particularly elegant. + // I would like it if connections always fetched the SFUConfig by themselves. sfuConfig, ); // Start the connection immediately From 92992df7dfaaf2556dd68231b8130f5b7d101a02 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 11 Feb 2026 13:56:42 +0100 Subject: [PATCH 023/119] test: ensure ragelogger resist to JSON.stringify throws --- src/settings/rageshake.test.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/settings/rageshake.test.ts diff --git a/src/settings/rageshake.test.ts b/src/settings/rageshake.test.ts new file mode 100644 index 00000000..9c3f1486 --- /dev/null +++ b/src/settings/rageshake.test.ts @@ -0,0 +1,34 @@ +/* +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 { expect, it } from "vitest"; + +import { init as initRageshake } from "./rageshake"; + +it("Logger should not crash if JSON.stringify fails", async () => { + // JSON.stringify can throw. We want to make sure that the logger can handle this gracefully. + await initRageshake(); + + const bigIntObj = { n: 1n }; + const notStringifiable = { + bigIntObj, + }; + // @ts-expect-error - we want to create an object that cannot be stringified + notStringifiable.foo = notStringifiable; // circular reference + + // ensure this cannot be stringified + expect(() => JSON.stringify(notStringifiable)).toThrow(); + + expect(() => + global.mx_rage_logger.log( + 1, + "test", + "This is a test message", + notStringifiable, + ), + ).not.toThrow(); +}); From 91da088da4e25003d4d7e8a2c7563b24dbb73fe4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:15:03 +0000 Subject: [PATCH 024/119] Update dependency matrix-widget-api to v1.17.0 --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6c6fa5fb..b1d27dec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11475,12 +11475,12 @@ __metadata: linkType: hard "matrix-widget-api@npm:^1.16.1": - version: 1.16.1 - resolution: "matrix-widget-api@npm:1.16.1" + version: 1.17.0 + resolution: "matrix-widget-api@npm:1.17.0" dependencies: "@types/events": "npm:^3.0.0" events: "npm:^3.2.0" - checksum: 10c0/d88180f514104b84d3018055fc955138d65195465480a51e9afe5dbf2f3175b54e3483b4c4f1feab2dd27440f403051d9c8b293bd0532c09b136c6b23606e1ee + checksum: 10c0/3651c860900149ecc2fe74640b47687bab8a347eb718a522085189e2b84efe462c9d81c1e8caff08d122f0b3e9cef8303a5802837673e5c9b465f7624c56a8f3 languageName: node linkType: hard From 6cf859fd9e242501620c3053507d4a669f56004e Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 13 Feb 2026 12:39:40 +0100 Subject: [PATCH 025/119] Fix resource leak due to unsafe scope reference --- src/state/CallViewModel/CallViewModel.ts | 51 +++++++++++++----------- src/utils/observable.ts | 32 +++++++++++++++ 2 files changed, 60 insertions(+), 23 deletions(-) diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index e2c6e46e..90bf71fe 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -60,6 +60,7 @@ import { import { accumulate, filterBehavior, + generateItem, generateItems, pauseWhen, } from "../../utils/observable"; @@ -446,29 +447,33 @@ export function createCallViewModel$( const localTransport$ = scope.behavior( matrixRTCMode$.pipe( - map((mode) => - createLocalTransport$({ - scope: scope, - memberships$: memberships$, - ownMembershipIdentity, - client, - delayId$: scope.behavior( - ( - fromEvent( - matrixRTCSession, - MembershipManagerEvent.DelayIdChanged, - // The type of reemitted event includes the original emitted as the second arg. - ) as Observable<[string | undefined, IMembershipManager]> - ).pipe(map(([delayId]) => delayId ?? null)), - matrixRTCSession.delayId ?? null, - ), - roomId: matrixRoom.roomId, - forceJwtEndpoint: - mode === MatrixRTCMode.Matrix_2_0 - ? JwtEndpointVersion.Matrix_2_0 - : JwtEndpointVersion.Legacy, - useOldestMember: mode === MatrixRTCMode.Legacy, - }), + generateItem( + "CallViewModel localTransport$", + // Re-create LocalTransport whenever the mode changes + (mode) => ({ keys: [mode], data: undefined }), + (scope, _data$, mode) => + createLocalTransport$({ + scope: scope, + memberships$: memberships$, + ownMembershipIdentity, + client, + delayId$: scope.behavior( + ( + fromEvent( + matrixRTCSession, + MembershipManagerEvent.DelayIdChanged, + // The type of reemitted event includes the original emitted as the second arg. + ) as Observable<[string | undefined, IMembershipManager]> + ).pipe(map(([delayId]) => delayId ?? null)), + matrixRTCSession.delayId ?? null, + ), + roomId: matrixRoom.roomId, + forceJwtEndpoint: + mode === MatrixRTCMode.Matrix_2_0 + ? JwtEndpointVersion.Matrix_2_0 + : JwtEndpointVersion.Legacy, + useOldestMember: mode === MatrixRTCMode.Legacy, + }), ), ), ); diff --git a/src/utils/observable.ts b/src/utils/observable.ts index d4182021..2e19748b 100644 --- a/src/utils/observable.ts +++ b/src/utils/observable.ts @@ -213,6 +213,38 @@ export function filterBehavior( ); } +/** + * Maps a changing input value to an item whose lifetime is tied to a certain + * computed key. The item may capture some dynamic data from the input. + */ +export function generateItem< + Input, + Keys extends [unknown, ...unknown[]], + Data, + Item, +>( + name: string, + generator: (input: Input) => { keys: readonly [...Keys]; data: Data }, + factory: ( + scope: ObservableScope, + data$: Behavior, + ...keys: Keys + ) => Item, +): OperatorFunction { + return (input$) => + input$.pipe( + generateItemsInternal( + name, + function* (input) { + yield generator(input); + }, + factory, + (items) => items, + ), + map(([item]) => item), + ); +} + function generateItemsInternal< Input, Keys extends [unknown, ...unknown[]], From 2a56830426036fa59c81624c7a70a4372d5a235a Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 13 Feb 2026 12:43:13 +0100 Subject: [PATCH 026/119] Fix existing LocalTransport tests --- .../localMember/LocalMember.test.ts | 49 ++--- .../localMember/LocalTransport.test.ts | 190 ++++++++---------- 2 files changed, 99 insertions(+), 140 deletions(-) diff --git a/src/state/CallViewModel/localMember/LocalMember.test.ts b/src/state/CallViewModel/localMember/LocalMember.test.ts index b228cd08..e5e9f327 100644 --- a/src/state/CallViewModel/localMember/LocalMember.test.ts +++ b/src/state/CallViewModel/localMember/LocalMember.test.ts @@ -39,7 +39,6 @@ import { constant } from "../../Behavior"; import { ConnectionManagerData } from "../remoteMembers/ConnectionManager"; import { ConnectionState, type Connection } from "../remoteMembers/Connection"; import { type Publisher } from "./Publisher"; -import { type LocalTransportWithSFUConfig } from "./LocalTransport"; import { initializeWidget } from "../../../widget"; initializeWidget(); @@ -216,11 +215,10 @@ describe("LocalMembership", () => { it("throws error on missing RTC config error", () => { withTestScheduler(({ scope, hot, expectObservable }) => { - const localTransport$ = - scope.behavior( - hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")), - null, - ); + const localTransport$ = scope.behavior( + hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")), + null, + ); // we do not need any connection data since we want to fail before reaching that. const mockConnectionManager = { @@ -279,23 +277,11 @@ describe("LocalMembership", () => { }); const aTransport = { - transport: { - livekit_service_url: "a", - } as LivekitTransportConfig, - sfuConfig: { - url: "sfu-url", - jwt: "sfu-token", - }, - } as LocalTransportWithSFUConfig; + livekit_service_url: "a", + } as LivekitTransportConfig; const bTransport = { - transport: { - livekit_service_url: "b", - } as LivekitTransportConfig, - sfuConfig: { - url: "sfu-url", - jwt: "sfu-token", - }, - } as LocalTransportWithSFUConfig; + livekit_service_url: "b", + } as LivekitTransportConfig; const connectionTransportAConnected = { livekitRoom: mockLivekitRoom({ @@ -305,7 +291,7 @@ describe("LocalMembership", () => { } as unknown as LocalParticipant, }), state$: constant(ConnectionState.LivekitConnected), - transport: aTransport.transport, + transport: aTransport, } as unknown as Connection; const connectionTransportAConnecting = { ...connectionTransportAConnected, @@ -314,7 +300,7 @@ describe("LocalMembership", () => { } as unknown as Connection; const connectionTransportBConnected = { state$: constant(ConnectionState.LivekitConnected), - transport: bTransport.transport, + transport: bTransport, livekitRoom: mockLivekitRoom({}), } as unknown as Connection; @@ -368,12 +354,8 @@ describe("LocalMembership", () => { // stop the first Publisher and let the second one life. expect(publishers[0].destroy).toHaveBeenCalled(); expect(publishers[1].destroy).not.toHaveBeenCalled(); - expect(publisherFactory.mock.calls[0][0].transport).toBe( - aTransport.transport, - ); - expect(publisherFactory.mock.calls[1][0].transport).toBe( - bTransport.transport, - ); + expect(publisherFactory.mock.calls[0][0].transport).toBe(aTransport); + expect(publisherFactory.mock.calls[1][0].transport).toBe(bTransport); scope.end(); await flushPromises(); // stop all tracks after ending scopes @@ -446,8 +428,9 @@ describe("LocalMembership", () => { const scope = new ObservableScope(); const connectionManagerData = new ConnectionManagerData(); - const localTransport$ = - new BehaviorSubject(null); + const localTransport$ = new BehaviorSubject( + null, + ); const connectionManagerData$ = new BehaviorSubject( new Epoch(connectionManagerData), ); @@ -519,7 +502,7 @@ describe("LocalMembership", () => { }); ( - connectionManagerData2.getConnectionForTransport(aTransport.transport)! + connectionManagerData2.getConnectionForTransport(aTransport)! .state$ as BehaviorSubject ).next(ConnectionState.LivekitConnected); expect(localMembership.localMemberState$.value).toStrictEqual({ diff --git a/src/state/CallViewModel/localMember/LocalTransport.test.ts b/src/state/CallViewModel/localMember/LocalTransport.test.ts index 2476923a..e63f7c72 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.test.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.test.ts @@ -43,10 +43,10 @@ describe("LocalTransport", () => { afterEach(() => scope.end()); it("throws if config is missing", async () => { - const localTransport$ = createLocalTransport$({ + const { advertised$, active$ } = createLocalTransport$({ scope, roomId: "!room:example.org", - useOldestMember$: constant(false), + useOldestMember: false, memberships$: constant(new Epoch([])), client: { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -58,14 +58,15 @@ describe("LocalTransport", () => { getDeviceId: vi.fn(), }, ownMembershipIdentity: ownMemberMock, - forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy), + forceJwtEndpoint: JwtEndpointVersion.Legacy, delayId$: constant("delay_id_mock"), }); await flushPromises(); - expect(() => localTransport$.value).toThrow( + expect(() => advertised$.value).toThrow( new MatrixRTCTransportMissingError(""), ); + expect(() => active$.value).toThrow(new MatrixRTCTransportMissingError("")); }); it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => { @@ -83,10 +84,10 @@ describe("LocalTransport", () => { ); const observations: unknown[] = []; const errors: Error[] = []; - const localTransport$ = createLocalTransport$({ + const { advertised$, active$ } = createLocalTransport$({ scope, roomId: "!example_room_id", - useOldestMember$: constant(false), + useOldestMember: false, memberships$: constant(new Epoch([])), client: { baseUrl: "https://lk.example.org", @@ -98,10 +99,10 @@ describe("LocalTransport", () => { getDeviceId: vi.fn(), }, ownMembershipIdentity: ownMemberMock, - forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy), + forceJwtEndpoint: JwtEndpointVersion.Legacy, delayId$: constant("delay_id_mock"), }); - localTransport$.subscribe( + active$.subscribe( (o) => observations.push(o), (e) => errors.push(e), ); @@ -111,7 +112,8 @@ describe("LocalTransport", () => { const expectedError = new FailToGetOpenIdToken(new Error("no openid")); expect(observations).toStrictEqual([null]); expect(errors).toStrictEqual([expectedError]); - expect(() => localTransport$.value).toThrow(expectedError); + expect(() => advertised$.value).toThrow(expectedError); + expect(() => active$.value).toThrow(expectedError); }); it("emits preferred transport after OpenID resolves", async () => { @@ -126,10 +128,10 @@ describe("LocalTransport", () => { openIdResolver.promise, ); - const localTransport$ = createLocalTransport$({ + const { advertised$, active$ } = createLocalTransport$({ scope, roomId: "!room:example.org", - useOldestMember$: constant(false), + useOldestMember: false, memberships$: constant(new Epoch([])), client: { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -140,7 +142,7 @@ describe("LocalTransport", () => { baseUrl: "https://lk.example.org", }, ownMembershipIdentity: ownMemberMock, - forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy), + forceJwtEndpoint: JwtEndpointVersion.Legacy, delayId$: constant("delay_id_mock"), }); @@ -150,14 +152,17 @@ describe("LocalTransport", () => { livekitAlias: "Akph4alDMhen", livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId, }); - expect(localTransport$.value).toBe(null); + expect(advertised$.value).toBe(null); + expect(active$.value).toBe(null); await flushPromises(); // final - expect(localTransport$.value).toStrictEqual({ - transport: { - livekit_service_url: "https://lk.example.org", - type: "livekit", - }, + const expectedTransport = { + livekit_service_url: "https://lk.example.org", + type: "livekit", + }; + expect(advertised$.value).toStrictEqual(expectedTransport); + expect(active$.value).toStrictEqual({ + transport: expectedTransport, sfuConfig: { jwt: "jwt", livekitAlias: "Akph4alDMhen", @@ -167,53 +172,8 @@ describe("LocalTransport", () => { }); }); - it("updates local transport when oldest member changes", async () => { - // Use config so transport discovery succeeds, but delay OpenID JWT fetch - mockConfig({ - livekit: { livekit_service_url: "https://lk.example.org" }, - }); - const memberships$ = new BehaviorSubject(new Epoch([])); - const openIdResolver = Promise.withResolvers(); - - vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue( - openIdResolver.promise, - ); - - const localTransport$ = createLocalTransport$({ - scope, - roomId: "!example_room_id", - useOldestMember$: constant(true), - memberships$, - client: { - getDomain: () => "", - // eslint-disable-next-line @typescript-eslint/naming-convention - _unstable_getRTCTransports: async () => Promise.resolve([]), - getOpenIdToken: vi.fn(), - getDeviceId: vi.fn(), - baseUrl: "https://lk.example.org", - }, - ownMembershipIdentity: ownMemberMock, - forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy), - delayId$: constant("delay_id_mock"), - }); - - openIdResolver.resolve?.(openIdResponse); - expect(localTransport$.value).toBe(null); - await flushPromises(); - // final - expect(localTransport$.value).toStrictEqual({ - transport: { - livekit_service_url: "https://lk.example.org", - type: "livekit", - }, - sfuConfig: { - jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=", - livekitAlias: "Akph4alDMhen", - livekitIdentity: "@lk_user:ABCDEF", - url: "https://lk.example.org", - }, - }); - }); + // TODO: This test previously didn't test what it claims to. + it.todo("updates local transport when oldest member changes"); type LocalTransportProps = Parameters[0]; @@ -229,8 +189,8 @@ describe("LocalTransport", () => { ownMembershipIdentity: ownMemberMock, scope, roomId: "!example_room_id", - useOldestMember$: constant(false), - forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy), + useOldestMember: false, + forceJwtEndpoint: JwtEndpointVersion.Legacy, delayId$: constant(null), memberships$: constant(new Epoch([])), client: { @@ -256,15 +216,19 @@ describe("LocalTransport", () => { mockConfig({ livekit: { livekit_service_url: "https://lk.example.org" }, }); - const localTransport$ = createLocalTransport$(localTransportOpts); + const { advertised$, active$ } = + createLocalTransport$(localTransportOpts); openIdResolver.resolve?.(openIdResponse); - expect(localTransport$.value).toBe(null); + expect(advertised$.value).toBe(null); + expect(active$.value).toBe(null); await flushPromises(); - expect(localTransport$.value).toStrictEqual({ - transport: { - livekit_service_url: "https://lk.example.org", - type: "livekit", - }, + const expectedTransport = { + livekit_service_url: "https://lk.example.org", + type: "livekit", + }; + expect(advertised$.value).toStrictEqual(expectedTransport); + expect(active$.value).toStrictEqual({ + transport: expectedTransport, sfuConfig: { jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=", livekitAlias: "Akph4alDMhen", @@ -273,13 +237,15 @@ describe("LocalTransport", () => { }, }); }); + it("supports getting transport via user settings", async () => { customLivekitUrl.setValue("https://lk.example.org"); - const localTransport$ = createLocalTransport$(localTransportOpts); + const { advertised$, active$ } = + createLocalTransport$(localTransportOpts); openIdResolver.resolve?.(openIdResponse); - expect(localTransport$.value).toBe(null); + expect(advertised$.value).toBe(null); await flushPromises(); - expect(localTransport$.value).toStrictEqual({ + expect(active$.value).toStrictEqual({ transport: { livekit_service_url: "https://lk.example.org", type: "livekit", @@ -292,19 +258,24 @@ describe("LocalTransport", () => { }, }); }); + it("supports getting transport via backend", async () => { localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([ { type: "livekit", livekit_service_url: "https://lk.example.org" }, ]); - const localTransport$ = createLocalTransport$(localTransportOpts); + const { advertised$, active$ } = + createLocalTransport$(localTransportOpts); openIdResolver.resolve?.(openIdResponse); - expect(localTransport$.value).toBe(null); + expect(advertised$.value).toBe(null); + expect(active$.value).toBe(null); await flushPromises(); - expect(localTransport$.value).toStrictEqual({ - transport: { - livekit_service_url: "https://lk.example.org", - type: "livekit", - }, + const expectedTransport = { + livekit_service_url: "https://lk.example.org", + type: "livekit", + }; + expect(advertised$.value).toStrictEqual(expectedTransport); + expect(active$.value).toStrictEqual({ + transport: expectedTransport, sfuConfig: { jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=", livekitAlias: "Akph4alDMhen", @@ -313,6 +284,7 @@ describe("LocalTransport", () => { }, }); }); + it("fails fast if the openID request fails for backend config", async () => { localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([ { type: "livekit", livekit_service_url: "https://lk.example.org" }, @@ -320,13 +292,11 @@ describe("LocalTransport", () => { openIdResolver.reject( new FailToGetOpenIdToken(new Error("Test driven error")), ); - try { - await lastValueFrom(createLocalTransport$(localTransportOpts)); - throw Error("Expected test to throw"); - } catch (ex) { - expect(ex).toBeInstanceOf(FailToGetOpenIdToken); - } + await expect(async () => + lastValueFrom(createLocalTransport$(localTransportOpts).active$), + ).rejects.toThrow(expect.any(FailToGetOpenIdToken)); }); + it("supports getting transport via well-known", async () => { localTransportOpts.client.getDomain.mockReturnValue("example.org"); fetchMock.getOnce("https://example.org/.well-known/matrix/client", { @@ -334,15 +304,19 @@ describe("LocalTransport", () => { { type: "livekit", livekit_service_url: "https://lk.example.org" }, ], }); - const localTransport$ = createLocalTransport$(localTransportOpts); + const { advertised$, active$ } = + createLocalTransport$(localTransportOpts); openIdResolver.resolve?.(openIdResponse); - expect(localTransport$.value).toBe(null); + expect(advertised$.value).toBe(null); + expect(active$.value).toBe(null); await flushPromises(); - expect(localTransport$.value).toStrictEqual({ - transport: { - livekit_service_url: "https://lk.example.org", - type: "livekit", - }, + const expectedTransport = { + livekit_service_url: "https://lk.example.org", + type: "livekit", + }; + expect(advertised$.value).toStrictEqual(expectedTransport); + expect(active$.value).toStrictEqual({ + transport: expectedTransport, sfuConfig: { jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=", livekitAlias: "Akph4alDMhen", @@ -352,6 +326,7 @@ describe("LocalTransport", () => { }); expect(fetchMock.done()).toEqual(true); }); + it("fails fast if the openId request fails for the well-known config", async () => { localTransportOpts.client.getDomain.mockReturnValue("example.org"); fetchMock.getOnce("https://example.org/.well-known/matrix/client", { @@ -362,20 +337,18 @@ describe("LocalTransport", () => { openIdResolver.reject( new FailToGetOpenIdToken(new Error("Test driven error")), ); - try { - await lastValueFrom(createLocalTransport$(localTransportOpts)); - throw Error("Expected test to throw"); - } catch (ex) { - expect(ex).toBeInstanceOf(FailToGetOpenIdToken); - } + await expect(async () => + lastValueFrom(createLocalTransport$(localTransportOpts).active$), + ).rejects.toThrow(expect.any(FailToGetOpenIdToken)); }); + it("throws if no options are available", async () => { - const localTransport$ = createLocalTransport$({ + const { advertised$, active$ } = createLocalTransport$({ scope, ownMembershipIdentity: ownMemberMock, roomId: "!example_room_id", - useOldestMember$: constant(false), - forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy), + useOldestMember: false, + forceJwtEndpoint: JwtEndpointVersion.Legacy, delayId$: constant(null), memberships$: constant(new Epoch([])), client: { @@ -390,7 +363,10 @@ describe("LocalTransport", () => { }); await flushPromises(); - expect(() => localTransport$.value).toThrow( + expect(() => advertised$.value).toThrow( + new MatrixRTCTransportMissingError(""), + ); + expect(() => active$.value).toThrow( new MatrixRTCTransportMissingError(""), ); }); From 450800294765baa2e42f1940a35bc4bd3f65994d Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 13 Feb 2026 13:46:32 +0100 Subject: [PATCH 027/119] Test local transport code in oldest member mode --- .../localMember/LocalTransport.test.ts | 135 +++++++++++++++++- 1 file changed, 130 insertions(+), 5 deletions(-) diff --git a/src/state/CallViewModel/localMember/LocalTransport.test.ts b/src/state/CallViewModel/localMember/LocalTransport.test.ts index e63f7c72..8454b09a 100644 --- a/src/state/CallViewModel/localMember/LocalTransport.test.ts +++ b/src/state/CallViewModel/localMember/LocalTransport.test.ts @@ -13,15 +13,24 @@ import { it, type MockedObject, vi, + type MockInstance, } from "vitest"; -import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc"; +import { + type CallMembership, + type LivekitTransportConfig, +} from "matrix-js-sdk/lib/matrixrtc"; import { BehaviorSubject, lastValueFrom } from "rxjs"; import fetchMock from "fetch-mock"; -import { mockConfig, flushPromises, ownMemberMock } from "../../../utils/test"; +import { + mockConfig, + flushPromises, + ownMemberMock, + mockRtcMembership, +} from "../../../utils/test"; import { createLocalTransport$, JwtEndpointVersion } from "./LocalTransport"; import { constant } from "../../Behavior"; -import { Epoch, ObservableScope } from "../../ObservableScope"; +import { Epoch, ObservableScope, trackEpoch } from "../../ObservableScope"; import { MatrixRTCTransportMissingError, FailToGetOpenIdToken, @@ -172,8 +181,124 @@ describe("LocalTransport", () => { }); }); - // TODO: This test previously didn't test what it claims to. - it.todo("updates local transport when oldest member changes"); + describe("oldest member mode", () => { + const aliceTransport: LivekitTransportConfig = { + type: "livekit", + livekit_service_url: "https://alice.example.org", + }; + const bobTransport: LivekitTransportConfig = { + type: "livekit", + livekit_service_url: "https://bob.example.org", + }; + const aliceMembership = mockRtcMembership("@alice:example.org", "AAA", { + fociPreferred: [aliceTransport], + }); + const bobMembership = mockRtcMembership("@bob:example.org", "BBB", { + fociPreferred: [bobTransport], + }); + + let openIdSpy: MockInstance<(typeof openIDSFU)["getSFUConfigWithOpenID"]>; + beforeEach(() => { + openIdSpy = vi + .spyOn(openIDSFU, "getSFUConfigWithOpenID") + .mockResolvedValue(openIdResponse); + }); + + it("updates active transport when oldest member changes", async () => { + // Initially, Alice is the only member + const memberships$ = new BehaviorSubject([aliceMembership]); + + const { advertised$, active$ } = createLocalTransport$({ + scope, + roomId: "!example_room_id", + useOldestMember: true, + memberships$: scope.behavior(memberships$.pipe(trackEpoch())), + client: { + getDomain: () => "", + // eslint-disable-next-line @typescript-eslint/naming-convention + _unstable_getRTCTransports: async () => Promise.resolve([]), + getOpenIdToken: vi.fn(), + getDeviceId: vi.fn(), + baseUrl: "https://lk.example.org", + }, + ownMembershipIdentity: ownMemberMock, + forceJwtEndpoint: JwtEndpointVersion.Legacy, + delayId$: constant("delay_id_mock"), + }); + + expect(active$.value).toBe(null); + await flushPromises(); + // SFU config should've been fetched + expect(openIdSpy).toHaveBeenCalled(); + // Alice's transport should be active and advertised + expect(active$.value?.transport).toStrictEqual(aliceTransport); + expect(advertised$.value).toStrictEqual(aliceTransport); + + // Now Bob joins the call, but Alice is still the oldest member + openIdSpy.mockClear(); + memberships$.next([aliceMembership, bobMembership]); + await flushPromises(); + // No new SFU config should've been fetched + expect(openIdSpy).not.toHaveBeenCalled(); + // Alice's transport should still be active and advertised + expect(active$.value?.transport).toStrictEqual(aliceTransport); + expect(advertised$.value).toStrictEqual(aliceTransport); + + // Now Bob takes Alice's place as the oldest member + openIdSpy.mockClear(); + memberships$.next([bobMembership, aliceMembership]); + // Active transport should reset to null until we have Bob's SFU config + expect(active$.value).toStrictEqual(null); + await flushPromises(); + // Bob's SFU config should've been fetched + expect(openIdSpy).toHaveBeenCalled(); + // Bob's transport should be active, but Alice's should remain advertised + // (since we don't want the change in oldest member to cause a wave of new + // state events) + expect(active$.value?.transport).toStrictEqual(bobTransport); + expect(advertised$.value).toStrictEqual(aliceTransport); + }); + + it("advertises preferred transport when no other member exists", async () => { + // Initially, there are no members + const memberships$ = new BehaviorSubject([]); + + const { advertised$, active$ } = createLocalTransport$({ + scope, + roomId: "!example_room_id", + useOldestMember: true, + memberships$: scope.behavior(memberships$.pipe(trackEpoch())), + client: { + getDomain: () => "", + // eslint-disable-next-line @typescript-eslint/naming-convention + _unstable_getRTCTransports: async () => + Promise.resolve([aliceTransport]), + getOpenIdToken: vi.fn(), + getDeviceId: vi.fn(), + baseUrl: "https://lk.example.org", + }, + ownMembershipIdentity: ownMemberMock, + forceJwtEndpoint: JwtEndpointVersion.Legacy, + delayId$: constant("delay_id_mock"), + }); + + expect(active$.value).toBe(null); + await flushPromises(); + // Our own preferred transport should be advertised + expect(advertised$.value).toStrictEqual(aliceTransport); + // No transport should be active however (there is still no oldest member) + expect(active$.value).toBe(null); + + // Now Bob joins the call and becomes the oldest member + memberships$.next([bobMembership]); + await flushPromises(); + // We should still advertise our own preferred transport (to avoid + // unnecessary state changes) + expect(advertised$.value).toStrictEqual(aliceTransport); + // Bob's transport should become active + expect(active$.value?.transport).toBe(bobTransport); + }); + }); type LocalTransportProps = Parameters[0]; From 789de1963afb9ac59d8ae5fc6f656432078c32a5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:01:46 +0100 Subject: [PATCH 028/119] Update GitHub Actions (#3711) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build-and-publish-docker.yaml | 8 ++++---- .github/workflows/publish-embedded-packages.yaml | 2 +- .github/workflows/test.yaml | 2 +- .github/workflows/translations-download.yaml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-and-publish-docker.yaml b/.github/workflows/build-and-publish-docker.yaml index 4ad1a551..dbde6c76 100644 --- a/.github/workflows/build-and-publish-docker.yaml +++ b/.github/workflows/build-and-publish-docker.yaml @@ -34,7 +34,7 @@ jobs: path: dist - name: Log in to container registry - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -42,7 +42,7 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: ${{ inputs.docker_tags}} @@ -50,10 +50,10 @@ jobs: org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Build and push Docker image - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/publish-embedded-packages.yaml b/.github/workflows/publish-embedded-packages.yaml index 275397b5..fc8a640f 100644 --- a/.github/workflows/publish-embedded-packages.yaml +++ b/.github/workflows/publish-embedded-packages.yaml @@ -153,7 +153,7 @@ jobs: path: embedded/android/lib/src/main/assets/element-call - name: ☕️ Setup Java - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: distribution: "temurin" java-version: "17" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 3251f50e..012de7cb 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -22,7 +22,7 @@ jobs: - name: Vitest run: "yarn run test:coverage" - name: Upload to codecov - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: diff --git a/.github/workflows/translations-download.yaml b/.github/workflows/translations-download.yaml index 76fe418c..45f366cd 100644 --- a/.github/workflows/translations-download.yaml +++ b/.github/workflows/translations-download.yaml @@ -42,7 +42,7 @@ jobs: - name: Create Pull Request id: cpr - uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 + uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 with: token: ${{ secrets.ELEMENT_BOT_TOKEN }} branch: actions/localazy-download From d87c3293c91b3039dee9fb145a5df2e91a0e9ae5 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 18 Feb 2026 13:44:20 +0100 Subject: [PATCH 029/119] Make one-on-one layout media types stricter --- src/state/layout-types.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index 3796715c..f32869df 100644 --- a/src/state/layout-types.ts +++ b/src/state/layout-types.ts @@ -10,6 +10,8 @@ import { type SpotlightTileViewModel, } from "./TileViewModel.ts"; import { + type LocalUserMediaViewModel, + type RemoteUserMediaViewModel, type MediaViewModel, type UserMediaViewModel, } from "./MediaViewModel.ts"; @@ -40,8 +42,8 @@ export interface SpotlightExpandedLayoutMedia { export interface OneOnOneLayoutMedia { type: "one-on-one"; - local: UserMediaViewModel; - remote: UserMediaViewModel; + local: LocalUserMediaViewModel; + remote: RemoteUserMediaViewModel; } export interface PipLayoutMedia { From 9d3712567757b9c2e938f91d89e5413f8e1eab65 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 18 Feb 2026 13:46:18 +0100 Subject: [PATCH 030/119] Only expose RTC backend identity debug info where we actually use it We only ever inspect the RTC backend identity of user media tiles. So it only needs to be exposed on the user media view model. --- src/state/MediaViewModel.ts | 9 ++++----- src/state/ScreenShare.ts | 2 -- src/state/UserMedia.ts | 1 - 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/state/MediaViewModel.ts b/src/state/MediaViewModel.ts index 3da69c46..87288424 100644 --- a/src/state/MediaViewModel.ts +++ b/src/state/MediaViewModel.ts @@ -257,7 +257,6 @@ abstract class BaseMediaViewModel { * The Matrix user to which this media belongs. */ public readonly userId: string, - public readonly rtcBackendIdentity: string, // We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through // livekit. protected readonly participant$: Observable< @@ -407,7 +406,10 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { scope: ObservableScope, id: string, userId: string, - rtcBackendIdentity: string, + /** + * The expected identity of the LiveKit participant. Exposed for debugging. + */ + public readonly rtcBackendIdentity: string, participant$: Observable, encryptionSystem: EncryptionSystem, livekitRoom$: Behavior, @@ -421,7 +423,6 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { scope, id, userId, - rtcBackendIdentity, participant$, encryptionSystem, Track.Source.Microphone, @@ -779,7 +780,6 @@ export class ScreenShareViewModel extends BaseMediaViewModel { scope: ObservableScope, id: string, userId: string, - rtcBackendIdentity: string, participant$: Observable, encryptionSystem: EncryptionSystem, livekitRoom$: Behavior, @@ -793,7 +793,6 @@ export class ScreenShareViewModel extends BaseMediaViewModel { scope, id, userId, - rtcBackendIdentity, participant$, encryptionSystem, Track.Source.ScreenShareAudio, diff --git a/src/state/ScreenShare.ts b/src/state/ScreenShare.ts index e4f5de1f..0a241cdf 100644 --- a/src/state/ScreenShare.ts +++ b/src/state/ScreenShare.ts @@ -28,7 +28,6 @@ export class ScreenShare { private readonly scope: ObservableScope, id: string, userId: string, - rtcBackendIdentity: string, participant: LocalParticipant | RemoteParticipant, encryptionSystem: EncryptionSystem, livekitRoom$: Behavior, @@ -41,7 +40,6 @@ export class ScreenShare { this.scope, id, userId, - rtcBackendIdentity, of(participant), encryptionSystem, livekitRoom$, diff --git a/src/state/UserMedia.ts b/src/state/UserMedia.ts index 74d24e2f..2adc9134 100644 --- a/src/state/UserMedia.ts +++ b/src/state/UserMedia.ts @@ -143,7 +143,6 @@ export class UserMedia { scope, `${this.id}:${key}`, this.userId, - this.rtcBackendIdentity, p, this.encryptionSystem, this.livekitRoom$, From bc238778ad2461c53fd938156845bba5fe500d90 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 18 Feb 2026 14:01:55 +0100 Subject: [PATCH 031/119] Make the type of participant$ stricter It is, in fact, required to be a behavior. --- src/state/MediaViewModel.ts | 8 ++++---- src/state/ScreenShare.ts | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/state/MediaViewModel.ts b/src/state/MediaViewModel.ts index 87288424..7f806697 100644 --- a/src/state/MediaViewModel.ts +++ b/src/state/MediaViewModel.ts @@ -259,7 +259,7 @@ abstract class BaseMediaViewModel { public readonly userId: string, // We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through // livekit. - protected readonly participant$: Observable< + protected readonly participant$: Behavior< LocalParticipant | RemoteParticipant | null >, @@ -410,7 +410,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { * The expected identity of the LiveKit participant. Exposed for debugging. */ public readonly rtcBackendIdentity: string, - participant$: Observable, + participant$: Behavior, encryptionSystem: EncryptionSystem, livekitRoom$: Behavior, focusUrl$: Behavior, @@ -678,7 +678,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { id: string, userId: string, rtcBackendIdentity: string, - participant$: Observable, + participant$: Behavior, encryptionSystem: EncryptionSystem, livekitRoom$: Behavior, focusUrl$: Behavior, @@ -780,7 +780,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel { scope: ObservableScope, id: string, userId: string, - participant$: Observable, + participant$: Behavior, encryptionSystem: EncryptionSystem, livekitRoom$: Behavior, focusUrl$: Behavior, diff --git a/src/state/ScreenShare.ts b/src/state/ScreenShare.ts index 0a241cdf..6c908b1f 100644 --- a/src/state/ScreenShare.ts +++ b/src/state/ScreenShare.ts @@ -4,7 +4,7 @@ Copyright 2025 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { of } from "rxjs"; + import { type LocalParticipant, type RemoteParticipant, @@ -14,7 +14,7 @@ import { import { type ObservableScope } from "./ObservableScope.ts"; import { ScreenShareViewModel } from "./MediaViewModel.ts"; import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts"; -import type { Behavior } from "./Behavior.ts"; +import { constant, type Behavior } from "./Behavior.ts"; /** * A screen share media item to be presented in a tile. This is a thin wrapper @@ -40,7 +40,7 @@ export class ScreenShare { this.scope, id, userId, - of(participant), + constant(participant), encryptionSystem, livekitRoom$, focusUrl$, From c8f5be7e4f9884033393593b1c4538ed751311a4 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 24 Feb 2026 11:54:41 +0100 Subject: [PATCH 032/119] remove deprecated `analyticsID` url param --- docs/url-params.md | 52 +++++++++++++++++++++++----------------------- src/UrlParams.ts | 3 +-- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/docs/url-params.md b/docs/url-params.md index a474daed..e24e9823 100644 --- a/docs/url-params.md +++ b/docs/url-params.md @@ -46,32 +46,32 @@ possible to support encryption. These parameters are relevant to both [widget](./embedded-standalone.md) and [standalone](./embedded-standalone.md) modes: -| Name | Values | Required for widget | Required for SPA | Description | -| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `intent` | `start_call`, `join_existing`, `start_call_dm`, `join_existing_dm. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. | -| `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesn’t provide any. | -| `analyticsID` (deprecated: use `posthogUserId` instead) | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. | -| `appPrompt` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Prompts the user to launch the native mobile app upon entering a room, applicable only on Android and iOS, and must be enabled in config. | -| `confineToRoom` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Keeps the user confined to the current call/room. | -| `displayName` | | No | No | Display name used for auto-registration. | -| `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. | -| `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. | -| `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. | -| `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. | -| `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. | -| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. | -| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. | -| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) | -| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. | -| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. | -| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. | -| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. | -| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) | -| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. | -| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. | -| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. | -| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. | -| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. | +| Name | Values | Required for widget | Required for SPA | Description | +| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `intent` | `start_call`, `join_existing`, `start_call_dm`, `join_existing_dm. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. | +| `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesn’t provide any. | +| `posthogUserId` | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. | +| `appPrompt` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Prompts the user to launch the native mobile app upon entering a room, applicable only on Android and iOS, and must be enabled in config. | +| `confineToRoom` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Keeps the user confined to the current call/room. | +| `displayName` | | No | No | Display name used for auto-registration. | +| `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. | +| `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. | +| `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. | +| `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. | +| `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. | +| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. | +| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. | +| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) | +| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. | +| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. | +| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. | +| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. | +| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) | +| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. | +| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. | +| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. | +| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. | +| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. | ### Widget-only parameters diff --git a/src/UrlParams.ts b/src/UrlParams.ts index f8ee22fb..31101197 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -473,8 +473,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => { homeserver: !isWidget ? parser.getParam("homeserver") : null, posthogApiHost: parser.getParam("posthogApiHost"), posthogApiKey: parser.getParam("posthogApiKey"), - posthogUserId: - parser.getParam("posthogUserId") ?? parser.getParam("analyticsID"), + posthogUserId: parser.getParam("posthogUserId"), rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"), sentryDsn: parser.getParam("sentryDsn"), sentryEnvironment: parser.getParam("sentryEnvironment"), From 6995388a299ac04feaf2e241718b9820c2bc82b2 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 25 Feb 2026 14:02:59 +0100 Subject: [PATCH 033/119] Convert media view model classes to interfaces Timo and I agreed previously that we should ditch the class pattern for view models and instead have them be interfaces which are simply created by functions. They're more straightforward to write, mock, and instantiate this way. The code for media view models and media items is pretty much the last remaining instance of the class pattern. Since I was about to introduce a new media view model for ringing, I wanted to get this refactor out of the way first rather than add to the technical debt. This refactor also makes things a little easier for https://github.com/element-hq/element-call/pull/3747 by extracting volume controls into their own module. --- src/state/CallViewModel/CallViewModel.ts | 187 +++--- src/state/MediaItem.ts | 196 ++++++ src/state/MediaViewModel.test.ts | 44 +- src/state/MediaViewModel.ts | 795 ++++++++++------------- src/state/ScreenShare.ts | 53 -- src/state/UserMedia.ts | 209 ------ src/state/VolumeControls.ts | 101 +++ src/tile/GridTile.test.tsx | 4 +- src/tile/GridTile.tsx | 41 +- src/tile/MediaView.tsx | 8 +- src/tile/SpotlightTile.test.tsx | 8 +- src/tile/SpotlightTile.tsx | 46 +- src/utils/observable.ts | 14 + src/utils/test.ts | 70 +- 14 files changed, 862 insertions(+), 914 deletions(-) create mode 100644 src/state/MediaItem.ts delete mode 100644 src/state/ScreenShare.ts delete mode 100644 src/state/UserMedia.ts create mode 100644 src/state/VolumeControls.ts diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 61afb7b9..7f34d86f 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -51,14 +51,12 @@ import { v4 as uuidv4 } from "uuid"; import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager"; import { - LocalUserMediaViewModel, type MediaViewModel, - type RemoteUserMediaViewModel, - ScreenShareViewModel, + type ScreenShareViewModel, type UserMediaViewModel, } from "../MediaViewModel"; import { - accumulate, + createToggle$, filterBehavior, generateItem, generateItems, @@ -92,8 +90,6 @@ import { type MuteStates } from "../MuteStates"; import { getUrlParams } from "../../UrlParams"; import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { ElementWidgetActions, widget } from "../../widget"; -import { UserMedia } from "../UserMedia.ts"; -import { ScreenShare } from "../ScreenShare.ts"; import { type GridLayoutMedia, type Layout, @@ -144,6 +140,11 @@ import { import { Publisher } from "./localMember/Publisher.ts"; import { type Connection } from "./remoteMembers/Connection.ts"; import { createLayoutModeSwitch } from "./LayoutSwitch.ts"; +import { + createWrappedUserMedia, + type WrappedUserMediaViewModel, + type MediaItem, +} from "../MediaItem.ts"; const logger = rootLogger.getChild("[CallViewModel]"); //TODO @@ -193,7 +194,6 @@ interface LayoutScanState { tiles: TileStore; } -type MediaItem = UserMedia | ScreenShare; export type LivekitRoomItem = { livekitRoom: LivekitRoom; participants: string[]; @@ -283,7 +283,6 @@ export interface CallViewModel { allConnections$: Behavior; /** Participants sorted by livekit room so they can be used in the audio rendering */ livekitRoomItems$: Behavior; - userMedia$: Behavior; /** use the layout instead, this is just for the sdk export. */ matrixLivekitMembers$: Behavior; localMatrixLivekitMember$: Behavior; @@ -334,10 +333,6 @@ export interface CallViewModel { gridMode$: Behavior; setGridMode: (value: GridMode) => void; - // media view models and layout - grid$: Behavior; - spotlight$: Behavior; - pip$: Behavior; /** * The layout of tiles in the call interface. */ @@ -721,7 +716,7 @@ export function createCallViewModel$( /** * List of user media (camera feeds) that we want tiles for. */ - const userMedia$ = scope.behavior( + const userMedia$ = scope.behavior( combineLatest([ localMatrixLivekitMember$, matrixLivekitMembers$, @@ -767,36 +762,35 @@ export function createCallViewModel$( } } }, - (scope, _, dup, mediaId, userId, participant, connection$, rtcId) => { - const livekitRoom$ = scope.behavior( - connection$.pipe(map((c) => c?.livekitRoom)), - ); - const focusUrl$ = scope.behavior( - connection$.pipe(map((c) => c?.transport.livekit_service_url)), - ); - const displayName$ = scope.behavior( - matrixMemberMetadataStore - .createDisplayNameBehavior$(userId) - .pipe(map((name) => name ?? userId)), - ); - - return new UserMedia( - scope, - `${mediaId}:${dup}`, + (scope, _, dup, mediaId, userId, participant, connection$, rtcId) => + createWrappedUserMedia(scope, { + id: `${mediaId}:${dup}`, userId, - rtcId, + rtcBackendIdentity: rtcId, participant, - options.encryptionSystem, - livekitRoom$, - focusUrl$, + encryptionSystem: options.encryptionSystem, + livekitRoom$: scope.behavior( + connection$.pipe(map((c) => c?.livekitRoom)), + ), + focusUrl$: scope.behavior( + connection$.pipe(map((c) => c?.transport.livekit_service_url)), + ), mediaDevices, - localMembership.reconnecting$, - displayName$, - matrixMemberMetadataStore.createAvatarUrlBehavior$(userId), - handsRaised$.pipe(map((v) => v[mediaId]?.time ?? null)), - reactions$.pipe(map((v) => v[mediaId] ?? undefined)), - ); - }, + pretendToBeDisconnected$: localMembership.reconnecting$, + displayName$: scope.behavior( + matrixMemberMetadataStore + .createDisplayNameBehavior$(userId) + .pipe(map((name) => name ?? userId)), + ), + mxcAvatarUrl$: + matrixMemberMetadataStore.createAvatarUrlBehavior$(userId), + handRaised$: scope.behavior( + handsRaised$.pipe(map((v) => v[mediaId]?.time ?? null)), + ), + reaction$: scope.behavior( + reactions$.pipe(map((v) => v[mediaId] ?? undefined)), + ), + }), ), ), ); @@ -821,11 +815,9 @@ export function createCallViewModel$( /** * List of MediaItems that we want to display, that are of type ScreenShare */ - const screenShares$ = scope.behavior( + const screenShares$ = scope.behavior( mediaItems$.pipe( - map((mediaItems) => - mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare), - ), + map((mediaItems) => mediaItems.filter((m) => m.type === "screen share")), ), ); @@ -888,39 +880,39 @@ export function createCallViewModel$( merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)), ).pipe(scope.share); - const spotlightSpeaker$ = scope.behavior( + const spotlightSpeaker$ = scope.behavior( userMedia$.pipe( switchMap((mediaItems) => mediaItems.length === 0 ? of([]) : combineLatest( mediaItems.map((m) => - m.vm.speaking$.pipe(map((s) => [m, s] as const)), + m.speaking$.pipe(map((s) => [m, s] as const)), ), ), ), - scan<(readonly [UserMedia, boolean])[], UserMedia | undefined, null>( - (prev, mediaItems) => { - // Only remote users that are still in the call should be sticky - const [stickyMedia, stickySpeaking] = - (!prev?.vm.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.vm.local && s)?.[0] ?? - // Otherwise, stick with the person who was last speaking - stickyMedia ?? - // Otherwise, spotlight an arbitrary remote user - mediaItems.find(([m]) => !m.vm.local)?.[0] ?? - // Otherwise, spotlight the local user - mediaItems.find(([m]) => m.vm.local)?.[0]); - }, - null, - ), - map((speaker) => speaker?.vm ?? null), + scan< + (readonly [UserMediaViewModel, 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), ), ); @@ -934,7 +926,7 @@ export function createCallViewModel$( return bins.length === 0 ? of([]) : combineLatest(bins, (...bins) => - bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m.vm), + bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m), ); }), distinctUntilChanged(shallowEquals), @@ -944,9 +936,7 @@ export function createCallViewModel$( const spotlight$ = scope.behavior( screenShares$.pipe( switchMap((screenShares) => { - if (screenShares.length > 0) { - return of(screenShares.map((m) => m.vm)); - } + if (screenShares.length > 0) return of(screenShares); return spotlightSpeaker$.pipe( map((speaker) => (speaker ? [speaker] : [])), @@ -956,7 +946,7 @@ export function createCallViewModel$( ), ); - const pip$ = scope.behavior( + const pip$ = scope.behavior( combineLatest([ // TODO This also needs epoch logic to dedupe the screenshares and mediaItems emits screenShares$, @@ -968,28 +958,17 @@ export function createCallViewModel$( return spotlightSpeaker$; } if (!spotlight || spotlight.local) { - return of(null); + return of(undefined); } const localUserMedia = mediaItems.find( - (m) => m.vm instanceof LocalUserMediaViewModel, - ) as UserMedia | undefined; - - const localUserMediaViewModel = localUserMedia?.vm as - | LocalUserMediaViewModel - | undefined; - - if (!localUserMediaViewModel) { - return of(null); + (m) => m.type === "user" && m.local, + ); + if (!localUserMedia) { + return of(undefined); } - return localUserMediaViewModel.alwaysShow$.pipe( - map((alwaysShow) => { - if (alwaysShow) { - return localUserMediaViewModel; - } - - return null; - }), + return localUserMedia.alwaysShow$.pipe( + map((alwaysShow) => (alwaysShow ? localUserMedia : undefined)), ); }), ), @@ -998,7 +977,7 @@ export function createCallViewModel$( const hasRemoteScreenShares$ = scope.behavior( spotlight$.pipe( map((spotlight) => - spotlight.some((vm) => !vm.local && vm instanceof ScreenShareViewModel), + spotlight.some((vm) => vm.type === "screen share" && !vm.local), ), ), ); @@ -1039,8 +1018,10 @@ export function createCallViewModel$( ); const spotlightExpandedToggle$ = new Subject(); - const spotlightExpanded$ = scope.behavior( - spotlightExpandedToggle$.pipe(accumulate(false, (expanded) => !expanded)), + const spotlightExpanded$ = createToggle$( + scope, + false, + spotlightExpandedToggle$, ); const { setGridMode, gridMode$ } = createLayoutModeSwitch( @@ -1053,7 +1034,7 @@ export function createCallViewModel$( [grid$, spotlight$], (grid, spotlight) => ({ type: "grid", - spotlight: spotlight.some((vm) => vm instanceof ScreenShareViewModel) + spotlight: spotlight.some((vm) => vm.type === "screen share") ? spotlight : undefined, grid, @@ -1085,12 +1066,8 @@ export function createCallViewModel$( mediaItems$.pipe( map((mediaItems) => { if (mediaItems.length !== 2) return null; - const local = mediaItems.find((vm) => vm.vm.local)?.vm as - | LocalUserMediaViewModel - | undefined; - const remote = mediaItems.find((vm) => !vm.vm.local)?.vm as - | RemoteUserMediaViewModel - | undefined; + const local = mediaItems.find((vm) => vm.type === "user" && vm.local); + const remote = mediaItems.find((vm) => vm.type === "user" && !vm.local); // There might not be a remote tile if there are screen shares, or if // only the local user is in the call and they're using the duplicate // tiles option @@ -1138,7 +1115,7 @@ export function createCallViewModel$( oneOnOne === null ? combineLatest([grid$, spotlight$], (grid, spotlight) => grid.length > smallMobileCallThreshold || - spotlight.some((vm) => vm instanceof ScreenShareViewModel) + spotlight.some((vm) => vm.type === "screen share") ? spotlightPortraitLayoutMedia$ : gridLayoutMedia$, ).pipe(switchAll()) @@ -1245,7 +1222,7 @@ export function createCallViewModel$( // screen sharing feeds are in the spotlight we still need them. return l.spotlight.media$.pipe( map((models: MediaViewModel[]) => - models.some((m) => m instanceof ScreenShareViewModel), + models.some((m) => m.type === "screen share"), ), ); // In expanded spotlight layout, the active speaker is always shown in @@ -1552,11 +1529,7 @@ export function createCallViewModel$( toggleSpotlightExpanded$: toggleSpotlightExpanded$, gridMode$: gridMode$, setGridMode: setGridMode, - grid$: grid$, - spotlight$: spotlight$, - pip$: pip$, layout$: layout$, - userMedia$, localMatrixLivekitMember$, matrixLivekitMembers$: scope.behavior( matrixLivekitMembers$.pipe( diff --git a/src/state/MediaItem.ts b/src/state/MediaItem.ts new file mode 100644 index 00000000..947fdfd4 --- /dev/null +++ b/src/state/MediaItem.ts @@ -0,0 +1,196 @@ +/* +Copyright 2025-2026 Element Software Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { combineLatest, map, of, switchMap } from "rxjs"; +import { + type LocalParticipant, + ParticipantEvent, + type RemoteParticipant, +} from "livekit-client"; +import { observeParticipantEvents } from "@livekit/components-core"; + +import { type ObservableScope } from "./ObservableScope.ts"; +import { + createLocalScreenShare, + createLocalUserMedia, + createRemoteScreenShare, + createRemoteUserMedia, + type ScreenShareViewModel, + type UserMediaViewModel, + type LocalUserMediaInputs, + type RemoteUserMediaInputs, +} from "./MediaViewModel.ts"; +import type { Behavior } from "./Behavior.ts"; +import type { MediaDevices } from "./MediaDevices.ts"; +import { observeSpeaker$ } from "./observeSpeaker.ts"; +import { generateItems } from "../utils/observable.ts"; +import { type TaggedParticipant } from "./CallViewModel/remoteMembers/MatrixLivekitMembers.ts"; + +/** + * Sorting bins defining the order in which media tiles appear in the layout. + */ +enum SortingBin { + /** + * Yourself, when the "always show self" option is on. + */ + SelfAlwaysShown, + /** + * Participants that are sharing their screen. + */ + Presenters, + /** + * Participants that have been speaking recently. + */ + Speakers, + /** + * Participants that have their hand raised. + */ + HandRaised, + /** + * Participants with video. + */ + Video, + /** + * Participants not sharing any video. + */ + NoVideo, + /** + * Yourself, when the "always show self" option is off. + */ + SelfNotAlwaysShown, +} + +/** + * A user media item to be presented in a tile. This is a thin wrapper around + * UserMediaViewModel which additionally carries data relevant to the tile + * layout algorithms (data which the MediaView component should be ignorant of). + */ +export type WrappedUserMediaViewModel = UserMediaViewModel & { + /** + * All screen share media associated with this user media. + */ + screenShares$: Behavior; + /** + * Which sorting bin the media item should be placed in. + */ + bin$: Behavior; +}; + +interface WrappedUserMediaInputs extends Omit< + LocalUserMediaInputs & RemoteUserMediaInputs, + "participant$" +> { + participant: TaggedParticipant; + mediaDevices: MediaDevices; + pretendToBeDisconnected$: Behavior; +} + +export function createWrappedUserMedia( + scope: ObservableScope, + { + participant, + mediaDevices, + pretendToBeDisconnected$, + ...inputs + }: WrappedUserMediaInputs, +): WrappedUserMediaViewModel { + const userMedia = + participant.type === "local" + ? createLocalUserMedia(scope, { + participant$: participant.value$, + mediaDevices, + ...inputs, + }) + : createRemoteUserMedia(scope, { + participant$: participant.value$, + pretendToBeDisconnected$, + ...inputs, + }); + + // TypeScript needs this widening of the type to happen in a separate statement + const participant$: Behavior = + participant.value$; + + const screenShares$ = scope.behavior( + participant$.pipe( + switchMap((p) => + p === null + ? of([]) + : observeParticipantEvents( + p, + ParticipantEvent.TrackPublished, + ParticipantEvent.TrackUnpublished, + ParticipantEvent.LocalTrackPublished, + ParticipantEvent.LocalTrackUnpublished, + ).pipe( + // Technically more than one screen share might be possible... our + // MediaViewModels don't support it though since they look for a unique + // track for the given source. So generateItems here is a bit overkill. + generateItems( + `${inputs.id} screenShares$`, + function* (p) { + if (p.isScreenShareEnabled) + yield { + keys: ["screen-share"], + data: undefined, + }; + }, + (scope, _data$, key) => { + const id = `${inputs.id}:${key}`; + return participant.type === "local" + ? createLocalScreenShare(scope, { + ...inputs, + id, + participant$: participant.value$, + }) + : createRemoteScreenShare(scope, { + ...inputs, + id, + participant$: participant.value$, + pretendToBeDisconnected$, + }); + }, + ), + ), + ), + ), + ); + + const speaker$ = scope.behavior(observeSpeaker$(userMedia.speaking$)); + const presenter$ = scope.behavior( + screenShares$.pipe(map((screenShares) => screenShares.length > 0)), + ); + + return { + ...userMedia, + screenShares$, + bin$: scope.behavior( + combineLatest( + [ + speaker$, + presenter$, + userMedia.videoEnabled$, + userMedia.handRaised$, + userMedia.local ? userMedia.alwaysShow$ : of(null), + ], + (speaker, presenter, video, handRaised, alwaysShow) => { + if (alwaysShow !== null) + return alwaysShow + ? SortingBin.SelfAlwaysShown + : SortingBin.SelfNotAlwaysShown; + else if (presenter) return SortingBin.Presenters; + else if (speaker) return SortingBin.Speakers; + else if (handRaised) return SortingBin.HandRaised; + else if (video) return SortingBin.Video; + else return SortingBin.NoVideo; + }, + ), + ), + }; +} + +export type MediaItem = WrappedUserMediaViewModel | ScreenShareViewModel; diff --git a/src/state/MediaViewModel.test.ts b/src/state/MediaViewModel.test.ts index 92868216..73b396db 100644 --- a/src/state/MediaViewModel.test.ts +++ b/src/state/MediaViewModel.test.ts @@ -17,8 +17,8 @@ import { mockLocalParticipant, mockMediaDevices, mockRtcMembership, - createLocalMedia, - createRemoteMedia, + mockLocalMedia, + mockRemoteMedia, withTestScheduler, mockRemoteParticipant, } from "../utils/test"; @@ -45,7 +45,7 @@ const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA"); test("control a participant's volume", () => { const setVolumeSpy = vi.fn(); - const vm = createRemoteMedia( + const vm = mockRemoteMedia( rtcMembership, {}, mockRemoteParticipant({ setVolume: setVolumeSpy }), @@ -54,33 +54,33 @@ test("control a participant's volume", () => { schedule("-ab---c---d|", { a() { // Try muting by toggling - vm.toggleLocallyMuted(); + vm.togglePlaybackMuted(); expect(setVolumeSpy).toHaveBeenLastCalledWith(0); }, b() { // Try unmuting by dragging the slider back up - vm.setLocalVolume(0.6); - vm.setLocalVolume(0.8); - vm.commitLocalVolume(); + vm.adjustPlaybackVolume(0.6); + vm.adjustPlaybackVolume(0.8); + vm.commitPlaybackVolume(); expect(setVolumeSpy).toHaveBeenCalledWith(0.6); expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8); }, c() { // Try muting by dragging the slider back down - vm.setLocalVolume(0.2); - vm.setLocalVolume(0); - vm.commitLocalVolume(); + vm.adjustPlaybackVolume(0.2); + vm.adjustPlaybackVolume(0); + vm.commitPlaybackVolume(); expect(setVolumeSpy).toHaveBeenCalledWith(0.2); expect(setVolumeSpy).toHaveBeenLastCalledWith(0); }, d() { // Try unmuting by toggling - vm.toggleLocallyMuted(); + vm.togglePlaybackMuted(); // The volume should return to the last non-zero committed volume expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8); }, }); - expectObservable(vm.localVolume$).toBe("ab(cd)(ef)g", { + expectObservable(vm.playbackVolume$).toBe("ab(cd)(ef)g", { a: 1, b: 0, c: 0.6, @@ -93,11 +93,11 @@ test("control a participant's volume", () => { }); test("toggle fit/contain for a participant's video", () => { - const vm = createRemoteMedia(rtcMembership, {}, mockRemoteParticipant({})); + const vm = mockRemoteMedia(rtcMembership, {}, mockRemoteParticipant({})); withTestScheduler(({ expectObservable, schedule }) => { schedule("-ab|", { - a: () => vm.toggleFitContain(), - b: () => vm.toggleFitContain(), + a: () => vm.toggleCropVideo(), + b: () => vm.toggleCropVideo(), }); expectObservable(vm.cropVideo$).toBe("abc", { a: true, @@ -108,7 +108,7 @@ test("toggle fit/contain for a participant's video", () => { }); test("local media remembers whether it should always be shown", () => { - const vm1 = createLocalMedia( + const vm1 = mockLocalMedia( rtcMembership, {}, mockLocalParticipant({}), @@ -120,7 +120,7 @@ test("local media remembers whether it should always be shown", () => { }); // Next local media should start out *not* always shown - const vm2 = createLocalMedia( + const vm2 = mockLocalMedia( rtcMembership, {}, mockLocalParticipant({}), @@ -166,7 +166,7 @@ test("switch cameras", async () => { const selectVideoInput = vi.fn(); - const vm = createLocalMedia( + const vm = mockLocalMedia( rtcMembership, {}, mockLocalParticipant({ @@ -206,17 +206,17 @@ test("switch cameras", async () => { }); test("remote media is in waiting state when participant has not yet connected", () => { - const vm = createRemoteMedia(rtcMembership, {}, null); // null participant + const vm = mockRemoteMedia(rtcMembership, {}, null); // null participant expect(vm.waitingForMedia$.value).toBe(true); }); test("remote media is not in waiting state when participant is connected", () => { - const vm = createRemoteMedia(rtcMembership, {}, mockRemoteParticipant({})); + const vm = mockRemoteMedia(rtcMembership, {}, mockRemoteParticipant({})); expect(vm.waitingForMedia$.value).toBe(false); }); test("remote media is not in waiting state when participant is connected with no publications", () => { - const vm = createRemoteMedia( + const vm = mockRemoteMedia( rtcMembership, {}, mockRemoteParticipant({ @@ -228,7 +228,7 @@ test("remote media is not in waiting state when participant is connected with no }); test("remote media is not in waiting state when user does not intend to publish anywhere", () => { - const vm = createRemoteMedia( + const vm = mockRemoteMedia( rtcMembership, {}, mockRemoteParticipant({}), diff --git a/src/state/MediaViewModel.ts b/src/state/MediaViewModel.ts index 7f806697..c8c468ac 100644 --- a/src/state/MediaViewModel.ts +++ b/src/state/MediaViewModel.ts @@ -29,7 +29,6 @@ import { } from "livekit-client"; import { logger } from "matrix-js-sdk/lib/logger"; import { - BehaviorSubject, type Observable, Subject, combineLatest, @@ -47,7 +46,7 @@ import { import { alwaysShowSelf } from "../settings/settings"; import { showConnectionStats } from "../settings/settings"; -import { accumulate } from "../utils/observable"; +import { createToggle$ } from "../utils/observable"; import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { E2eeType } from "../e2ee/e2eeType"; import { type ReactionOption } from "../reactions"; @@ -55,6 +54,7 @@ import { platform } from "../Platform"; import { type MediaDevices } from "./MediaDevices"; import { type Behavior } from "./Behavior"; import { type ObservableScope } from "./ObservableScope"; +import { createVolumeControls, type VolumeControls } from "./VolumeControls"; export function observeTrackReference$( participant: Participant, @@ -103,7 +103,7 @@ export function observeRtpStreamStats$( ); } -export function observeInboundRtpStreamStats$( +function observeInboundRtpStreamStats$( participant: Participant, source: Track.Source, ): Observable { @@ -112,15 +112,6 @@ export function observeInboundRtpStreamStats$( ); } -export function observeOutboundRtpStreamStats$( - participant: Participant, - source: Track.Source, -): Observable { - return observeRtpStreamStats$(participant, source, "outbound-rtp").pipe( - map((x) => x as RTCOutboundRtpStreamStats | undefined), - ); -} - function observeRemoteTrackReceivingOkay$( participant: Participant, source: Track.Source, @@ -218,74 +209,98 @@ export enum EncryptionStatus { PasswordInvalid, } -abstract class BaseMediaViewModel { +interface BaseMediaViewModel { + /** + * An opaque identifier for this media. + */ + id: string; + /** + * The Matrix user to which this media belongs. + */ + userId: string; + displayName$: Behavior; + mxcAvatarUrl$: Behavior; +} + +type BaseMediaInputs = BaseMediaViewModel; + +// This function exists to strip out superfluous data from the input object +function createBaseMedia({ + id, + userId, + displayName$, + mxcAvatarUrl$, +}: BaseMediaInputs): BaseMediaViewModel { + return { id, userId, displayName$, mxcAvatarUrl$ }; +} + +interface MemberMediaViewModel extends BaseMediaViewModel { /** * The LiveKit video track for this media. */ - public readonly video$: Behavior; + video$: Behavior; + /** + * The URL of the LiveKit focus on which this member should be publishing. + * Exposed for debugging. + */ + focusUrl$: Behavior; /** * Whether there should be a warning that this media is unencrypted. */ - public readonly unencryptedWarning$: Behavior; + unencryptedWarning$: Behavior; + encryptionStatus$: Behavior; +} - public readonly encryptionStatus$: Behavior; +interface MemberMediaInputs extends BaseMediaViewModel { + participant$: Behavior; + livekitRoom$: Behavior; + audioSource: AudioSource; + videoSource: VideoSource; + focusUrl$: Behavior; + encryptionSystem: EncryptionSystem; +} - /** - * Whether this media corresponds to the local participant. - */ - public abstract readonly local: boolean; - - private observeTrackReference$( +function createMemberMedia( + scope: ObservableScope, + { + participant$, + livekitRoom$, + audioSource, + videoSource, + focusUrl$, + encryptionSystem, + ...inputs + }: MemberMediaInputs, +): MemberMediaViewModel { + const trackBehavior$ = ( source: Track.Source, - ): Behavior { - return this.scope.behavior( - this.participant$.pipe( + ): Behavior => + scope.behavior( + participant$.pipe( switchMap((p) => !p ? of(undefined) : observeTrackReference$(p, source), ), ), ); - } - public constructor( - protected readonly scope: ObservableScope, - /** - * An opaque identifier for this media. - */ - public readonly id: string, - /** - * The Matrix user to which this media belongs. - */ - public readonly userId: string, - // We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through - // livekit. - protected readonly participant$: Behavior< - LocalParticipant | RemoteParticipant | null - >, + const audio$ = trackBehavior$(audioSource); + const video$ = trackBehavior$(videoSource); - encryptionSystem: EncryptionSystem, - audioSource: AudioSource, - videoSource: VideoSource, - protected readonly livekitRoom$: Behavior, - public readonly focusUrl$: Behavior, - public readonly displayName$: Behavior, - public readonly mxcAvatarUrl$: Behavior, - ) { - const audio$ = this.observeTrackReference$(audioSource); - this.video$ = this.observeTrackReference$(videoSource); - - this.unencryptedWarning$ = this.scope.behavior( + return { + ...createBaseMedia(inputs), + video$, + focusUrl$, + unencryptedWarning$: scope.behavior( combineLatest( - [audio$, this.video$], + [audio$, video$], (a, v) => encryptionSystem.kind !== E2eeType.NONE && (a?.publication.isEncrypted === false || v?.publication.isEncrypted === false), ), - ); - - this.encryptionStatus$ = this.scope.behavior( - this.participant$.pipe( + ), + encryptionStatus$: scope.behavior( + participant$.pipe( switchMap((participant): Observable => { if (!participant) { return of(EncryptionStatus.Connecting); @@ -346,132 +361,149 @@ abstract class BaseMediaViewModel { } }), ), - ); - } + ), + }; } -/** - * Some participant's media. - */ -export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel; -export type UserMediaViewModel = - | LocalUserMediaViewModel - | RemoteUserMediaViewModel; +interface BaseUserMediaViewModel extends MemberMediaViewModel { + type: "user"; + speaking$: Behavior; + audioEnabled$: Behavior; + videoEnabled$: Behavior; + cropVideo$: Behavior; + toggleCropVideo: () => void; + /** + * The expected identity of the LiveKit participant. Exposed for debugging. + */ + rtcBackendIdentity: string; + handRaised$: Behavior; + reaction$: Behavior; + audioStreamStats$: Observable< + RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined + >; + videoStreamStats$: Observable< + RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined + >; +} -/** - * Some participant's user media. - */ -abstract class BaseUserMediaViewModel extends BaseMediaViewModel { - private readonly _speaking$ = this.scope.behavior( - this.participant$.pipe( - switchMap((p) => - p - ? observeParticipantEvents( - p, - ParticipantEvent.IsSpeakingChanged, - ).pipe(map((p) => p.isSpeaking)) - : of(false), - ), +interface BaseUserMediaInputs extends Omit< + MemberMediaInputs, + "audioSource" | "videoSource" +> { + rtcBackendIdentity: string; + handRaised$: Behavior; + reaction$: Behavior; + statsType: "inbound-rtp" | "outbound-rtp"; +} + +function createBaseUserMedia( + scope: ObservableScope, + { + rtcBackendIdentity, + handRaised$, + reaction$, + statsType, + ...inputs + }: BaseUserMediaInputs, +): BaseUserMediaViewModel { + const { participant$ } = inputs; + const media$ = scope.behavior( + participant$.pipe( + switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)), ), ); - /** - * Whether the participant is speaking. - */ - // Getter backed by a private field so that subclasses can override it - public get speaking$(): Behavior { - return this._speaking$; - } + const toggleCropVideo$ = new Subject(); - /** - * Whether this participant is sending audio (i.e. is unmuted on their side). - */ - public readonly audioEnabled$: Behavior; - - private readonly _videoEnabled$: Behavior; - /** - * Whether this participant is sending video. - */ - // Getter backed by a private field so that subclasses can override it - public get videoEnabled$(): Behavior { - return this._videoEnabled$; - } - - private readonly _cropVideo$ = new BehaviorSubject(true); - /** - * Whether the tile video should be contained inside the tile or be cropped to fit. - */ - public readonly cropVideo$: Behavior = this._cropVideo$; - - public constructor( - scope: ObservableScope, - id: string, - userId: string, - /** - * The expected identity of the LiveKit participant. Exposed for debugging. - */ - public readonly rtcBackendIdentity: string, - participant$: Behavior, - encryptionSystem: EncryptionSystem, - livekitRoom$: Behavior, - focusUrl$: Behavior, - displayName$: Behavior, - mxcAvatarUrl$: Behavior, - public readonly handRaised$: Behavior, - public readonly reaction$: Behavior, - ) { - super( - scope, - id, - userId, - participant$, - encryptionSystem, - Track.Source.Microphone, - Track.Source.Camera, - livekitRoom$, - focusUrl$, - displayName$, - mxcAvatarUrl$, - ); - - const media$ = this.scope.behavior( + return { + ...createMemberMedia(scope, { + ...inputs, + audioSource: Track.Source.Microphone, + videoSource: Track.Source.Camera, + }), + type: "user", + speaking$: scope.behavior( participant$.pipe( - switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)), + switchMap((p) => + p + ? observeParticipantEvents( + p, + ParticipantEvent.IsSpeakingChanged, + ).pipe(map((p) => p.isSpeaking)) + : of(false), + ), ), - ); - this.audioEnabled$ = this.scope.behavior( + ), + audioEnabled$: scope.behavior( media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)), - ); - this._videoEnabled$ = this.scope.behavior( + ), + videoEnabled$: scope.behavior( media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)), - ); - } - - public toggleFitContain(): void { - this._cropVideo$.next(!this._cropVideo$.value); - } - - public get local(): boolean { - return this instanceof LocalUserMediaViewModel; - } - - public abstract get audioStreamStats$(): Observable< - RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined - >; - public abstract get videoStreamStats$(): Observable< - RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined - >; + ), + cropVideo$: createToggle$(scope, true, toggleCropVideo$), + toggleCropVideo: () => toggleCropVideo$.next(), + rtcBackendIdentity, + handRaised$, + reaction$, + audioStreamStats$: combineLatest([ + participant$, + showConnectionStats.value$, + ]).pipe( + switchMap(([p, showConnectionStats]) => { + // + if (!p || !showConnectionStats) return of(undefined); + return observeRtpStreamStats$(p, Track.Source.Microphone, statsType); + }), + ), + videoStreamStats$: combineLatest([ + participant$, + showConnectionStats.value$, + ]).pipe( + switchMap(([p, showConnectionStats]) => { + if (!p || !showConnectionStats) return of(undefined); + return observeRtpStreamStats$(p, Track.Source.Camera, statsType); + }), + ), + }; } -/** - * The local participant's user media. - */ -export class LocalUserMediaViewModel extends BaseUserMediaViewModel { +export interface LocalUserMediaViewModel extends BaseUserMediaViewModel { + local: true; + /** + * Whether the video should be mirrored. + */ + mirror$: Behavior; + /** + * Whether to show this tile in a highly visible location near the start of + * the grid. + */ + alwaysShow$: Behavior; + setAlwaysShow: (value: boolean) => void; + switchCamera$: Behavior<(() => void) | null>; +} + +export interface LocalUserMediaInputs extends Omit< + BaseUserMediaInputs, + "statsType" +> { + participant$: Behavior; + mediaDevices: MediaDevices; +} + +export function createLocalUserMedia( + scope: ObservableScope, + { mediaDevices, ...inputs }: LocalUserMediaInputs, +): LocalUserMediaViewModel { + const baseUserMedia = createBaseUserMedia(scope, { + ...inputs, + statsType: "outbound-rtp", + }); + /** * The local video track as an observable that emits whenever the track * changes, the camera is switched, or the track is muted. */ - private readonly videoTrack$: Observable = - this.video$.pipe( + const videoTrack$: Observable = + baseUserMedia.video$.pipe( switchMap((v) => { const track = v?.publication.track; if (!(track instanceof LocalVideoTrack)) return of(null); @@ -488,35 +520,25 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel { }), ); - /** - * Whether the video should be mirrored. - */ - public readonly mirror$ = this.scope.behavior( - this.videoTrack$.pipe( - // Mirror only front-facing cameras (those that face the user) - map( - (track) => - track !== null && - facingModeFromLocalTrack(track).facingMode === "user", + return { + ...baseUserMedia, + local: true, + mirror$: scope.behavior( + videoTrack$.pipe( + // Mirror only front-facing cameras (those that face the user) + map( + (track) => + track !== null && + facingModeFromLocalTrack(track).facingMode === "user", + ), ), ), - ); - - /** - * Whether to show this tile in a highly visible location near the start of - * the grid. - */ - public readonly alwaysShow$ = alwaysShowSelf.value$; - public readonly setAlwaysShow = alwaysShowSelf.setValue; - - /** - * Callback for switching between the front and back cameras. - */ - public readonly switchCamera$: Behavior<(() => void) | null> = - this.scope.behavior( + alwaysShow$: alwaysShowSelf.value$, + setAlwaysShow: alwaysShowSelf.setValue, + switchCamera$: scope.behavior( platform === "desktop" ? of(null) - : this.videoTrack$.pipe( + : videoTrack$.pipe( map((track) => { if (track === null) return null; const facingMode = facingModeFromLocalTrack(track).facingMode; @@ -535,272 +557,157 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel { const deviceId = track.mediaStreamTrack.getSettings().deviceId; if (deviceId !== undefined) - this.mediaDevices.videoInput.select(deviceId); + mediaDevices.videoInput.select(deviceId); }) .catch((e) => logger.error("Failed to switch camera", facingMode, e), ); }), ), - ); - - public constructor( - scope: ObservableScope, - id: string, - userId: string, - rtcBackendIdentity: string, - participant$: Behavior, - encryptionSystem: EncryptionSystem, - livekitRoom$: Behavior, - focusUrl$: Behavior, - private readonly mediaDevices: MediaDevices, - displayName$: Behavior, - mxcAvatarUrl$: Behavior, - handRaised$: Behavior, - reaction$: Behavior, - ) { - super( - scope, - id, - userId, - rtcBackendIdentity, - participant$, - encryptionSystem, - livekitRoom$, - focusUrl$, - displayName$, - mxcAvatarUrl$, - handRaised$, - reaction$, - ); - } - - public audioStreamStats$ = combineLatest([ - this.participant$, - showConnectionStats.value$, - ]).pipe( - switchMap(([p, showConnectionStats]) => { - if (!p || !showConnectionStats) return of(undefined); - return observeOutboundRtpStreamStats$(p, Track.Source.Microphone); - }), - ); - - public videoStreamStats$ = combineLatest([ - this.participant$, - showConnectionStats.value$, - ]).pipe( - switchMap(([p, showConnectionStats]) => { - if (!p || !showConnectionStats) return of(undefined); - return observeOutboundRtpStreamStats$(p, Track.Source.Camera); - }), - ); + ), + }; } -/** - * A remote participant's user media. - */ -export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { +export interface RemoteUserMediaViewModel + extends BaseUserMediaViewModel, VolumeControls { + local: false; /** * Whether we are waiting for this user's LiveKit participant to exist. This * could be because either we or the remote party are still connecting. */ - public readonly waitingForMedia$ = this.scope.behavior( - combineLatest( - [this.livekitRoom$, this.participant$], - (livekitRoom, participant) => - // If livekitRoom is undefined, the user is not attempting to publish on - // any transport and so we shouldn't expect a participant. (They might - // be a subscribe-only bot for example.) - livekitRoom !== undefined && participant === null, - ), - ); - - // This private field is used to override the value from the superclass - private __speaking$: Behavior; - public get speaking$(): Behavior { - return this.__speaking$; - } - - private readonly locallyMutedToggle$ = new Subject(); - private readonly localVolumeAdjustment$ = new Subject(); - private readonly localVolumeCommit$ = new Subject(); - - /** - * The volume to which this participant's audio is set, as a scalar - * multiplier. - */ - public readonly localVolume$ = this.scope.behavior( - merge( - this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)), - this.localVolumeAdjustment$, - this.localVolumeCommit$.pipe(map(() => "commit" as const)), - ).pipe( - accumulate({ volume: 1, committedVolume: 1 }, (state, event) => { - switch (event) { - case "toggle mute": - return { - ...state, - volume: state.volume === 0 ? state.committedVolume : 0, - }; - case "commit": - // Dragging the slider to zero should have the same effect as - // muting: keep the original committed volume, as if it were never - // dragged - return { - ...state, - committedVolume: - state.volume === 0 ? state.committedVolume : state.volume, - }; - default: - // Volume adjustment - return { ...state, volume: event }; - } - }), - map(({ volume }) => volume), - ), - ); - - // This private field is used to override the value from the superclass - private __videoEnabled$: Behavior; - public get videoEnabled$(): Behavior { - return this.__videoEnabled$; - } - - /** - * Whether this participant's audio is disabled. - */ - public readonly locallyMuted$ = this.scope.behavior( - this.localVolume$.pipe(map((volume) => volume === 0)), - ); - - public constructor( - scope: ObservableScope, - id: string, - userId: string, - rtcBackendIdentity: string, - participant$: Behavior, - encryptionSystem: EncryptionSystem, - livekitRoom$: Behavior, - focusUrl$: Behavior, - private readonly pretendToBeDisconnected$: Behavior, - displayName$: Behavior, - mxcAvatarUrl$: Behavior, - handRaised$: Behavior, - reaction$: Behavior, - ) { - super( - scope, - id, - userId, - rtcBackendIdentity, - participant$, - encryptionSystem, - livekitRoom$, - focusUrl$, - displayName$, - mxcAvatarUrl$, - handRaised$, - reaction$, - ); - - this.__speaking$ = this.scope.behavior( - pretendToBeDisconnected$.pipe( - switchMap((disconnected) => - disconnected ? of(false) : super.speaking$, - ), - ), - ); - - this.__videoEnabled$ = this.scope.behavior( - pretendToBeDisconnected$.pipe( - switchMap((disconnected) => - disconnected ? of(false) : super.videoEnabled$, - ), - ), - ); - - // Sync the local volume with LiveKit - combineLatest([ - participant$, - // The local volume, taking into account whether we're supposed to pretend - // that the audio stream is disconnected (since we don't necessarily want - // that to modify the UI state). - this.pretendToBeDisconnected$.pipe( - switchMap((disconnected) => (disconnected ? of(0) : this.localVolume$)), - this.scope.bind(), - ), - ]).subscribe(([p, volume]) => p?.setVolume(volume)); - } - - public toggleLocallyMuted(): void { - this.locallyMutedToggle$.next(); - } - - public setLocalVolume(value: number): void { - this.localVolumeAdjustment$.next(value); - } - - public commitLocalVolume(): void { - this.localVolumeCommit$.next(); - } - - public audioStreamStats$ = combineLatest([ - this.participant$, - showConnectionStats.value$, - ]).pipe( - switchMap(([p, showConnectionStats]) => { - if (!p || !showConnectionStats) return of(undefined); - return observeInboundRtpStreamStats$(p, Track.Source.Microphone); - }), - ); - - public videoStreamStats$ = combineLatest([ - this.participant$, - showConnectionStats.value$, - ]).pipe( - switchMap(([p, showConnectionStats]) => { - if (!p || !showConnectionStats) return of(undefined); - return observeInboundRtpStreamStats$(p, Track.Source.Camera); - }), - ); + waitingForMedia$: Behavior; } -/** - * Some participant's screen share media. - */ -export class ScreenShareViewModel extends BaseMediaViewModel { +export interface RemoteUserMediaInputs extends Omit< + BaseUserMediaInputs, + "statsType" +> { + participant$: Behavior; + pretendToBeDisconnected$: Behavior; +} + +export function createRemoteUserMedia( + scope: ObservableScope, + { pretendToBeDisconnected$, ...inputs }: RemoteUserMediaInputs, +): RemoteUserMediaViewModel { + const baseUserMedia = createBaseUserMedia(scope, { + ...inputs, + statsType: "inbound-rtp", + }); + + return { + ...baseUserMedia, + ...createVolumeControls(scope, { + pretendToBeDisconnected$, + sink$: scope.behavior( + inputs.participant$.pipe(map((p) => (volume) => p?.setVolume(volume))), + ), + }), + local: false, + speaking$: scope.behavior( + pretendToBeDisconnected$.pipe( + switchMap((disconnected) => + disconnected ? of(false) : baseUserMedia.speaking$, + ), + ), + ), + videoEnabled$: scope.behavior( + pretendToBeDisconnected$.pipe( + switchMap((disconnected) => + disconnected ? of(false) : baseUserMedia.videoEnabled$, + ), + ), + ), + waitingForMedia$: scope.behavior( + combineLatest( + [inputs.livekitRoom$, inputs.participant$], + (livekitRoom, participant) => + // If livekitRoom is undefined, the user is not attempting to publish on + // any transport and so we shouldn't expect a participant. (They might + // be a subscribe-only bot for example.) + livekitRoom !== undefined && participant === null, + ), + ), + }; +} + +interface BaseScreenShareViewModel extends MemberMediaViewModel { + type: "screen share"; +} + +type BaseScreenShareInputs = Omit< + MemberMediaInputs, + "audioSource" | "videoSource" +>; + +function createBaseScreenShare( + scope: ObservableScope, + inputs: BaseScreenShareInputs, +): BaseScreenShareViewModel { + return { + ...createMemberMedia(scope, { + ...inputs, + audioSource: Track.Source.ScreenShareAudio, + videoSource: Track.Source.ScreenShare, + }), + type: "screen share", + }; +} + +export interface LocalScreenShareViewModel extends BaseScreenShareViewModel { + local: true; +} + +interface LocalScreenShareInputs extends BaseScreenShareInputs { + participant$: Behavior; +} + +export function createLocalScreenShare( + scope: ObservableScope, + inputs: LocalScreenShareInputs, +): LocalScreenShareViewModel { + return { ...createBaseScreenShare(scope, inputs), local: true }; +} + +export interface RemoteScreenShareViewModel extends BaseScreenShareViewModel { + local: false; /** * Whether this screen share's video should be displayed. */ - public readonly videoEnabled$ = this.scope.behavior( - this.pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)), - ); - - public constructor( - scope: ObservableScope, - id: string, - userId: string, - participant$: Behavior, - encryptionSystem: EncryptionSystem, - livekitRoom$: Behavior, - focusUrl$: Behavior, - private readonly pretendToBeDisconnected$: Behavior, - displayName$: Behavior, - mxcAvatarUrl$: Behavior, - public readonly local: boolean, - ) { - super( - scope, - id, - userId, - participant$, - encryptionSystem, - Track.Source.ScreenShareAudio, - Track.Source.ScreenShare, - livekitRoom$, - focusUrl$, - displayName$, - mxcAvatarUrl$, - ); - } + videoEnabled$: Behavior; } + +interface RemoteScreenShareInputs extends BaseScreenShareInputs { + participant$: Behavior; + pretendToBeDisconnected$: Behavior; +} + +export function createRemoteScreenShare( + scope: ObservableScope, + { pretendToBeDisconnected$, ...inputs }: RemoteScreenShareInputs, +): RemoteScreenShareViewModel { + return { + ...createBaseScreenShare(scope, inputs), + local: false, + videoEnabled$: scope.behavior( + pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)), + ), + }; +} + +/** + * Some participant's media. + */ +export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel; +/** + * Some participant's user media (i.e. their microphone and camera feed). + */ +export type UserMediaViewModel = + | LocalUserMediaViewModel + | RemoteUserMediaViewModel; +/** + * Some participant's screen share media. + */ +export type ScreenShareViewModel = + | LocalScreenShareViewModel + | RemoteScreenShareViewModel; diff --git a/src/state/ScreenShare.ts b/src/state/ScreenShare.ts deleted file mode 100644 index 6c908b1f..00000000 --- a/src/state/ScreenShare.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* -Copyright 2025 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE in the repository root for full details. -*/ - -import { - type LocalParticipant, - type RemoteParticipant, - type Room as LivekitRoom, -} from "livekit-client"; - -import { type ObservableScope } from "./ObservableScope.ts"; -import { ScreenShareViewModel } from "./MediaViewModel.ts"; -import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts"; -import { constant, type Behavior } from "./Behavior.ts"; - -/** - * A screen share media item to be presented in a tile. This is a thin wrapper - * around ScreenShareViewModel which essentially just establishes an - * ObservableScope for behaviors that the view model depends on. - */ -export class ScreenShare { - public readonly vm: ScreenShareViewModel; - - public constructor( - private readonly scope: ObservableScope, - id: string, - userId: string, - participant: LocalParticipant | RemoteParticipant, - encryptionSystem: EncryptionSystem, - livekitRoom$: Behavior, - focusUrl$: Behavior, - pretendToBeDisconnected$: Behavior, - displayName$: Behavior, - mxcAvatarUrl$: Behavior, - ) { - this.vm = new ScreenShareViewModel( - this.scope, - id, - userId, - constant(participant), - encryptionSystem, - livekitRoom$, - focusUrl$, - pretendToBeDisconnected$, - displayName$, - mxcAvatarUrl$, - participant.isLocal, - ); - } -} diff --git a/src/state/UserMedia.ts b/src/state/UserMedia.ts deleted file mode 100644 index 2adc9134..00000000 --- a/src/state/UserMedia.ts +++ /dev/null @@ -1,209 +0,0 @@ -/* -Copyright 2025 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE in the repository root for full details. -*/ - -import { combineLatest, map, type Observable, of, switchMap } from "rxjs"; -import { - type LocalParticipant, - ParticipantEvent, - type RemoteParticipant, - type Room as LivekitRoom, -} from "livekit-client"; -import { observeParticipantEvents } from "@livekit/components-core"; - -import { type ObservableScope } from "./ObservableScope.ts"; -import { - LocalUserMediaViewModel, - RemoteUserMediaViewModel, - type UserMediaViewModel, -} from "./MediaViewModel.ts"; -import type { Behavior } from "./Behavior.ts"; -import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts"; -import type { MediaDevices } from "./MediaDevices.ts"; -import type { ReactionOption } from "../reactions"; -import { observeSpeaker$ } from "./observeSpeaker.ts"; -import { generateItems } from "../utils/observable.ts"; -import { ScreenShare } from "./ScreenShare.ts"; -import { type TaggedParticipant } from "./CallViewModel/remoteMembers/MatrixLivekitMembers.ts"; - -/** - * Sorting bins defining the order in which media tiles appear in the layout. - */ -enum SortingBin { - /** - * Yourself, when the "always show self" option is on. - */ - SelfAlwaysShown, - /** - * Participants that are sharing their screen. - */ - Presenters, - /** - * Participants that have been speaking recently. - */ - Speakers, - /** - * Participants that have their hand raised. - */ - HandRaised, - /** - * Participants with video. - */ - Video, - /** - * Participants not sharing any video. - */ - NoVideo, - /** - * Yourself, when the "always show self" option is off. - */ - SelfNotAlwaysShown, -} - -/** - * A user media item to be presented in a tile. This is a thin wrapper around - * UserMediaViewModel which additionally determines the media item's sorting bin - * for inclusion in the call layout and tracks associated screen shares. - */ -export class UserMedia { - public readonly vm: UserMediaViewModel = - this.participant.type === "local" - ? new LocalUserMediaViewModel( - this.scope, - this.id, - this.userId, - this.rtcBackendIdentity, - this.participant.value$, - this.encryptionSystem, - this.livekitRoom$, - this.focusUrl$, - this.mediaDevices, - this.displayName$, - this.mxcAvatarUrl$, - this.scope.behavior(this.handRaised$), - this.scope.behavior(this.reaction$), - ) - : new RemoteUserMediaViewModel( - this.scope, - this.id, - this.userId, - this.rtcBackendIdentity, - this.participant.value$, - this.encryptionSystem, - this.livekitRoom$, - this.focusUrl$, - this.pretendToBeDisconnected$, - this.displayName$, - this.mxcAvatarUrl$, - this.scope.behavior(this.handRaised$), - this.scope.behavior(this.reaction$), - ); - - private readonly speaker$ = this.scope.behavior( - observeSpeaker$(this.vm.speaking$), - ); - - // TypeScript needs this widening of the type to happen in a separate statement - private readonly participant$: Behavior< - LocalParticipant | RemoteParticipant | null - > = this.participant.value$; - - /** - * All screen share media associated with this user media. - */ - public readonly screenShares$ = this.scope.behavior( - this.participant$.pipe( - switchMap((p) => - p === null - ? of([]) - : observeParticipantEvents( - p, - ParticipantEvent.TrackPublished, - ParticipantEvent.TrackUnpublished, - ParticipantEvent.LocalTrackPublished, - ParticipantEvent.LocalTrackUnpublished, - ).pipe( - // Technically more than one screen share might be possible... our - // MediaViewModels don't support it though since they look for a unique - // track for the given source. So generateItems here is a bit overkill. - generateItems( - `${this.id} screenShares$`, - function* (p) { - if (p.isScreenShareEnabled) - yield { - keys: ["screen-share"], - data: undefined, - }; - }, - (scope, _data$, key) => - new ScreenShare( - scope, - `${this.id}:${key}`, - this.userId, - p, - this.encryptionSystem, - this.livekitRoom$, - this.focusUrl$, - this.pretendToBeDisconnected$, - this.displayName$, - this.mxcAvatarUrl$, - ), - ), - ), - ), - ), - ); - - private readonly presenter$ = this.scope.behavior( - this.screenShares$.pipe(map((screenShares) => screenShares.length > 0)), - ); - - /** - * Which sorting bin the media item should be placed in. - */ - // This is exposed here rather than by UserMediaViewModel because it's only - // relevant to the layout algorithms; the MediaView component should be - // ignorant of this value. - public readonly bin$ = combineLatest( - [ - this.speaker$, - this.presenter$, - this.vm.videoEnabled$, - this.vm.handRaised$, - this.vm instanceof LocalUserMediaViewModel - ? this.vm.alwaysShow$ - : of(false), - ], - (speaker, presenter, video, handRaised, alwaysShow) => { - if (this.vm.local) - return alwaysShow - ? SortingBin.SelfAlwaysShown - : SortingBin.SelfNotAlwaysShown; - else if (presenter) return SortingBin.Presenters; - else if (speaker) return SortingBin.Speakers; - else if (handRaised) return SortingBin.HandRaised; - else if (video) return SortingBin.Video; - else return SortingBin.NoVideo; - }, - ); - - public constructor( - private readonly scope: ObservableScope, - public readonly id: string, - private readonly userId: string, - private readonly rtcBackendIdentity: string, - private readonly participant: TaggedParticipant, - private readonly encryptionSystem: EncryptionSystem, - private readonly livekitRoom$: Behavior, - private readonly focusUrl$: Behavior, - private readonly mediaDevices: MediaDevices, - private readonly pretendToBeDisconnected$: Behavior, - private readonly displayName$: Behavior, - private readonly mxcAvatarUrl$: Behavior, - private readonly handRaised$: Observable, - private readonly reaction$: Observable, - ) {} -} diff --git a/src/state/VolumeControls.ts b/src/state/VolumeControls.ts new file mode 100644 index 00000000..beb7ae00 --- /dev/null +++ b/src/state/VolumeControls.ts @@ -0,0 +1,101 @@ +/* +Copyright 2026 Element Software Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { combineLatest, map, merge, of, Subject, switchMap } from "rxjs"; + +import { type Behavior } from "./Behavior"; +import { type ObservableScope } from "./ObservableScope"; +import { accumulate } from "../utils/observable"; + +/** + * Controls for audio playback volume. + */ +export interface VolumeControls { + /** + * The volume to which the audio is set, as a scalar multiplier. + */ + playbackVolume$: Behavior; + /** + * Whether playback of this audio is disabled. + */ + playbackMuted$: Behavior; + togglePlaybackMuted: () => void; + adjustPlaybackVolume: (value: number) => void; + commitPlaybackVolume: () => void; +} + +interface VolumeControlsInputs { + pretendToBeDisconnected$: Behavior; + /** + * The callback to run to notify the module performing audio playback of the + * requested volume. + */ + sink$: Behavior<(volume: number) => void>; +} + +/** + * Creates a set of controls for audio playback volume and syncs this with the + * audio playback module for the duration of the scope. + */ +export function createVolumeControls( + scope: ObservableScope, + { pretendToBeDisconnected$, sink$ }: VolumeControlsInputs, +): VolumeControls { + const toggleMuted$ = new Subject<"toggle mute">(); + const adjustVolume$ = new Subject(); + const commitVolume$ = new Subject<"commit">(); + + const playbackVolume$ = scope.behavior( + merge(toggleMuted$, adjustVolume$, commitVolume$).pipe( + accumulate({ volume: 1, committedVolume: 1 }, (state, event) => { + switch (event) { + case "toggle mute": + return { + ...state, + volume: state.volume === 0 ? state.committedVolume : 0, + }; + case "commit": + // Dragging the slider to zero should have the same effect as + // muting: keep the original committed volume, as if it were never + // dragged + return { + ...state, + committedVolume: + state.volume === 0 ? state.committedVolume : state.volume, + }; + default: + // Volume adjustment + return { ...state, volume: event }; + } + }), + map(({ volume }) => volume), + ), + ); + + // Sync the requested volume with the audio playback module + combineLatest([ + sink$, + // The playback volume, taking into account whether we're supposed to + // pretend that the audio stream is disconnected (since we don't necessarily + // want that to modify the UI state). + pretendToBeDisconnected$.pipe( + switchMap((disconnected) => (disconnected ? of(0) : playbackVolume$)), + ), + ]) + .pipe(scope.bind()) + .subscribe(([sink, volume]) => sink(volume)); + + return { + playbackVolume$, + playbackMuted$: scope.behavior( + playbackVolume$.pipe(map((volume) => volume === 0)), + ), + togglePlaybackMuted: () => toggleMuted$.next("toggle mute"), + adjustPlaybackVolume: (value: number) => adjustVolume$.next(value), + commitPlaybackVolume: () => commitVolume$.next("commit"), + }; +} diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index 9bc0efb2..02f09a17 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -14,7 +14,7 @@ import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { GridTile } from "./GridTile"; import { mockRtcMembership, - createRemoteMedia, + mockRemoteMedia, mockRemoteParticipant, } from "../utils/test"; import { GridTileViewModel } from "../state/TileViewModel"; @@ -29,7 +29,7 @@ global.IntersectionObserver = class MockIntersectionObserver { } as unknown as typeof IntersectionObserver; test("GridTile is accessible", async () => { - const vm = createRemoteMedia( + const vm = mockRemoteMedia( mockRtcMembership("@alice:example.org", "AAAA"), { rawDisplayName: "Alice", diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 92262f05..370e0723 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -41,7 +41,7 @@ import { useObservableEagerState } from "observable-hooks"; import styles from "./GridTile.module.css"; import { type UserMediaViewModel, - LocalUserMediaViewModel, + type LocalUserMediaViewModel, type RemoteUserMediaViewModel, } from "../state/MediaViewModel"; import { Slider } from "../Slider"; @@ -68,7 +68,7 @@ interface TileProps { interface UserMediaTileProps extends TileProps { vm: UserMediaViewModel; mirror: boolean; - locallyMuted: boolean; + playbackMuted: boolean; waitingForMedia?: boolean; primaryButton?: ReactNode; menuStart?: ReactNode; @@ -79,7 +79,7 @@ const UserMediaTile: FC = ({ ref, vm, showSpeakingIndicators, - locallyMuted, + playbackMuted, waitingForMedia, primaryButton, menuStart, @@ -109,7 +109,7 @@ const UserMediaTile: FC = ({ const onSelectFitContain = useCallback( (e: Event) => { e.preventDefault(); - vm.toggleFitContain(); + vm.toggleCropVideo(); }, [vm], ); @@ -117,12 +117,12 @@ const UserMediaTile: FC = ({ const handRaised = useBehavior(vm.handRaised$); const reaction = useBehavior(vm.reaction$); - const AudioIcon = locallyMuted + const AudioIcon = playbackMuted ? VolumeOffSolidIcon : audioEnabled ? MicOnSolidIcon : MicOffSolidIcon; - const audioIconLabel = locallyMuted + const audioIconLabel = playbackMuted ? t("video_tile.muted_for_me") : audioEnabled ? t("microphone_on") @@ -166,7 +166,7 @@ const UserMediaTile: FC = ({ width={20} height={20} aria-label={audioIconLabel} - data-muted={locallyMuted || !audioEnabled} + data-muted={playbackMuted || !audioEnabled} className={styles.muteIcon} /> } @@ -245,7 +245,7 @@ const LocalUserMediaTile: FC = ({ = ({ }) => { const { t } = useTranslation(); const waitingForMedia = useBehavior(vm.waitingForMedia$); - const locallyMuted = useBehavior(vm.locallyMuted$); - const localVolume = useBehavior(vm.localVolume$); + const playbackMuted = useBehavior(vm.playbackMuted$); + const playbackVolume = useBehavior(vm.playbackVolume$); const onSelectMute = useCallback( (e: Event) => { e.preventDefault(); - vm.toggleLocallyMuted(); + vm.togglePlaybackMuted(); }, [vm], ); - const onChangeLocalVolume = useCallback( - (v: number) => vm.setLocalVolume(v), - [vm], - ); - const onCommitLocalVolume = useCallback(() => vm.commitLocalVolume(), [vm]); - const VolumeIcon = locallyMuted ? VolumeOffIcon : VolumeOnIcon; + const VolumeIcon = playbackMuted ? VolumeOffIcon : VolumeOnIcon; return ( {/* TODO: Figure out how to make this slider keyboard accessible */} @@ -332,9 +327,9 @@ const RemoteUserMediaTile: FC = ({ = ({ const displayName = useBehavior(media.displayName$); const mxcAvatarUrl = useBehavior(media.mxcAvatarUrl$); - if (media instanceof LocalUserMediaViewModel) { + if (media.local) { return ( = ({ }) => { const { t } = useTranslation(); const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer); - const [showConnectioStats] = useSetting(showConnectionStats); + const [showConnectionStats] = useSetting(showConnectionStatsSetting); const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2); @@ -139,10 +139,10 @@ export const MediaView: FC = ({ {waitingForMedia && (
{t("video_tile.waiting_for_media")} - {showConnectioStats ? " " + rtcBackendIdentity : ""} + {showConnectionStats ? " " + rtcBackendIdentity : ""}
)} - {(audioStreamStats || videoStreamStats) && ( + {showConnectionStats && ( <> { - const vm1 = createRemoteMedia( + const vm1 = mockRemoteMedia( mockRtcMembership("@alice:example.org", "AAAA"), { rawDisplayName: "Alice", @@ -37,7 +37,7 @@ test("SpotlightTile is accessible", async () => { mockRemoteParticipant({}), ); - const vm2 = createLocalMedia( + const vm2 = mockLocalMedia( mockRtcMembership("@bob:example.org", "BBBB"), { rawDisplayName: "Bob", diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index e685327e..c0f1135f 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -34,11 +34,12 @@ import { MediaView } from "./MediaView"; import styles from "./SpotlightTile.module.css"; import { type EncryptionStatus, - LocalUserMediaViewModel, + type LocalUserMediaViewModel, type MediaViewModel, - ScreenShareViewModel, type UserMediaViewModel, type RemoteUserMediaViewModel, + type ScreenShareViewModel, + type RemoteScreenShareViewModel, } from "../state/MediaViewModel"; import { useInitial } from "../useInitial"; import { useMergedRefs } from "../useMergedRefs"; @@ -54,7 +55,6 @@ interface SpotlightItemBaseProps { targetWidth: number; targetHeight: number; video: TrackReferenceOrPlaceholder | undefined; - videoEnabled: boolean; userId: string; unencryptedWarning: boolean; encryptionStatus: EncryptionStatus; @@ -67,6 +67,7 @@ interface SpotlightItemBaseProps { interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps { videoFit: "contain" | "cover"; + videoEnabled: boolean; } interface SpotlightLocalUserMediaItemProps extends SpotlightUserMediaItemBaseProps { @@ -106,14 +107,16 @@ const SpotlightUserMediaItem: FC = ({ ...props }) => { const cropVideo = useBehavior(vm.cropVideo$); + const videoEnabled = useBehavior(vm.videoEnabled$); const baseProps: SpotlightUserMediaItemBaseProps & RefAttributes = { videoFit: cropVideo ? "cover" : "contain", + videoEnabled, ...props, }; - return vm instanceof LocalUserMediaViewModel ? ( + return vm.local ? ( ) : ( @@ -122,6 +125,31 @@ const SpotlightUserMediaItem: FC = ({ SpotlightUserMediaItem.displayName = "SpotlightUserMediaItem"; +interface SpotlightScreenShareItemProps extends SpotlightItemBaseProps { + vm: ScreenShareViewModel; + videoEnabled: boolean; +} + +const SpotlightScreenShareItem: FC = ({ + vm, + ...props +}) => { + return ; +}; + +interface SpotlightRemoteScreenShareItemProps extends SpotlightItemBaseProps { + vm: RemoteScreenShareViewModel; +} + +const SpotlightRemoteScreenShareItem: FC< + SpotlightRemoteScreenShareItemProps +> = ({ vm, ...props }) => { + const videoEnabled = useBehavior(vm.videoEnabled$); + return ( + + ); +}; + interface SpotlightItemProps { ref?: Ref; vm: MediaViewModel; @@ -152,7 +180,6 @@ const SpotlightItem: FC = ({ const displayName = useBehavior(vm.displayName$); const mxcAvatarUrl = useBehavior(vm.mxcAvatarUrl$); const video = useBehavior(vm.video$); - const videoEnabled = useBehavior(vm.videoEnabled$); const unencryptedWarning = useBehavior(vm.unencryptedWarning$); const encryptionStatus = useBehavior(vm.encryptionStatus$); @@ -178,7 +205,6 @@ const SpotlightItem: FC = ({ targetWidth, targetHeight, video: video ?? undefined, - videoEnabled, userId: vm.userId, unencryptedWarning, focusUrl, @@ -189,10 +215,12 @@ const SpotlightItem: FC = ({ "aria-hidden": ariaHidden, }; - return vm instanceof ScreenShareViewModel ? ( - + if (vm.type === "user") + return ; + return vm.local ? ( + ) : ( - + ); }; diff --git a/src/utils/observable.ts b/src/utils/observable.ts index 2e19748b..353dc877 100644 --- a/src/utils/observable.ts +++ b/src/utils/observable.ts @@ -61,6 +61,20 @@ export function accumulate( events$.pipe(scan(update, initial), startWith(initial)); } +/** + * Given a source of toggle events, creates a Behavior whose value toggles + * between `true` and `false`. + */ +export function createToggle$( + scope: ObservableScope, + initialValue: boolean, + toggle$: Observable, +): Behavior { + return scope.behavior( + toggle$.pipe(accumulate(initialValue, (state) => !state)), + ); +} + const switchSymbol = Symbol("switch"); /** diff --git a/src/utils/test.ts b/src/utils/test.ts index d78bdf42..9231a3d1 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -53,8 +53,10 @@ import { import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; import { - LocalUserMediaViewModel, - RemoteUserMediaViewModel, + createLocalUserMedia, + createRemoteUserMedia, + type LocalUserMediaViewModel, + type RemoteUserMediaViewModel, } from "../state/MediaViewModel"; import { E2eeType } from "../e2ee/e2eeType"; import { @@ -323,30 +325,27 @@ export function mockLocalParticipant( } as Partial as LocalParticipant; } -export function createLocalMedia( +export function mockLocalMedia( rtcMember: CallMembership, roomMember: Partial, localParticipant: LocalParticipant, mediaDevices: MediaDevices, ): LocalUserMediaViewModel { const member = mockMatrixRoomMember(rtcMember, roomMember); - return new LocalUserMediaViewModel( - testScope(), - "local", - member.userId, - rtcMember.rtcBackendIdentity, - constant(localParticipant), - { - kind: E2eeType.PER_PARTICIPANT, - }, - constant(mockLivekitRoom({ localParticipant })), - constant("https://rtc-example.org"), + return createLocalUserMedia(testScope(), { + id: "local", + userId: member.userId, + rtcBackendIdentity: rtcMember.rtcBackendIdentity, + participant$: constant(localParticipant), + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + livekitRoom$: constant(mockLivekitRoom({ localParticipant })), + focusUrl$: constant("https://rtc-example.org"), mediaDevices, - constant(member.rawDisplayName ?? "nodisplayname"), - constant(member.getMxcAvatarUrl()), - constant(null), - constant(null), - ); + displayName$: constant(member.rawDisplayName ?? "nodisplayname"), + mxcAvatarUrl$: constant(member.getMxcAvatarUrl()), + handRaised$: constant(null), + reaction$: constant(null), + }); } export function mockRemoteParticipant( @@ -364,7 +363,7 @@ export function mockRemoteParticipant( } as RemoteParticipant; } -export function createRemoteMedia( +export function mockRemoteMedia( rtcMember: CallMembership, roomMember: Partial, participant: RemoteParticipant | null, @@ -376,23 +375,20 @@ export function createRemoteMedia( ), ): RemoteUserMediaViewModel { const member = mockMatrixRoomMember(rtcMember, roomMember); - return new RemoteUserMediaViewModel( - testScope(), - "remote", - member.userId, - rtcMember.rtcBackendIdentity, - constant(participant), - { - kind: E2eeType.PER_PARTICIPANT, - }, - constant(livekitRoom), - constant("https://rtc-example.org"), - constant(false), - constant(member.rawDisplayName ?? "nodisplayname"), - constant(member.getMxcAvatarUrl()), - constant(null), - constant(null), - ); + return createRemoteUserMedia(testScope(), { + id: "remote", + userId: member.userId, + rtcBackendIdentity: rtcMember.rtcBackendIdentity, + participant$: constant(participant), + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + livekitRoom$: constant(livekitRoom), + focusUrl$: constant("https://rtc-example.org"), + pretendToBeDisconnected$: constant(false), + displayName$: constant(member.rawDisplayName ?? "nodisplayname"), + mxcAvatarUrl$: constant(member.getMxcAvatarUrl()), + handRaised$: constant(null), + reaction$: constant(null), + }); } export function mockConfig( From dcf3a722a785f772438c107c26cdad161eb6bd34 Mon Sep 17 00:00:00 2001 From: fkwp Date: Wed, 25 Feb 2026 17:45:56 +0100 Subject: [PATCH 034/119] Push docker images to oci.element.io (#3725) * Push docker images to oci.element.io * prettier --- .../workflows/build-and-publish-docker.yaml | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-publish-docker.yaml b/.github/workflows/build-and-publish-docker.yaml index dbde6c76..68f7131c 100644 --- a/.github/workflows/build-and-publish-docker.yaml +++ b/.github/workflows/build-and-publish-docker.yaml @@ -40,12 +40,50 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Connect to Tailscale + uses: tailscale/github-action@53acf823325fe9ca47f4cdaa951f90b4b0de5bb9 # v4 + if: github.event_name != 'pull_request' + with: + oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} + audience: ${{ secrets.TS_AUDIENCE }} + tags: tag:github-actions + + - name: Compute vault jwt role name + id: vault-jwt-role + if: github.event_name != 'pull_request' + run: | + echo "role_name=github_service_management_$( echo "${{ github.repository }}" | sed -r 's|[/-]|_|g')" | tee -a "$GITHUB_OUTPUT" + + - name: Get team registry token + id: import-secrets + uses: hashicorp/vault-action@4c06c5ccf5c0761b6029f56cfb1dcf5565918a3b # v3 + if: github.event_name != 'pull_request' + with: + url: https://vault.infra.ci.i.element.dev + role: ${{ steps.vault-jwt-role.outputs.role_name }} + path: service-management/github-actions + jwtGithubAudience: https://vault.infra.ci.i.element.dev + method: jwt + secrets: | + services/-repositories/secret/data/oci.element.io username | OCI_USERNAME ; + services/-repositories/secret/data/oci.element.io password | OCI_PASSWORD ; + + - name: Login to oci.element.io Registry + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 + if: github.event_name != 'pull_request' + with: + registry: oci-push.vpn.infra.element.io + username: ${{ steps.import-secrets.outputs.OCI_USERNAME }} + password: ${{ steps.import-secrets.outputs.OCI_PASSWORD }} + - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: ${{ inputs.docker_tags}} + images: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + oci-push.vpn.infra.element.io/element-web + tags: ${{ inputs.docker_tags }} labels: | org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial From 465d28a1cd96be539a79772f49a3c686d91800fc Mon Sep 17 00:00:00 2001 From: fkwp Date: Wed, 25 Feb 2026 17:46:35 +0100 Subject: [PATCH 035/119] bumping LiveKit SFU and synapse versions for ci/cd (#3757) --- dev-backend-docker-compose.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dev-backend-docker-compose.yml b/dev-backend-docker-compose.yml index 28682a33..8d885399 100644 --- a/dev-backend-docker-compose.yml +++ b/dev-backend-docker-compose.yml @@ -47,7 +47,7 @@ services: - ecbackend livekit: - image: livekit/livekit-server:v1.9.4 + image: livekit/livekit-server:v1.9.11 pull_policy: always hostname: livekit-sfu command: --dev --config /etc/livekit.yaml @@ -67,7 +67,7 @@ services: - ecbackend livekit-1: - image: livekit/livekit-server:v1.9.4 + image: livekit/livekit-server:v1.9.11 pull_policy: always hostname: livekit-sfu-1 command: --dev --config /etc/livekit.yaml @@ -88,7 +88,7 @@ services: synapse: hostname: homeserver - image: ghcr.io/element-hq/synapse:pr-18968-dcb7678281bc02d4551043a6338fe5b7e6aa47ce + image: ghcr.io/element-hq/synapse:latest pull_policy: always environment: - SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml @@ -106,7 +106,7 @@ services: synapse-1: hostname: homeserver-1 - image: ghcr.io/element-hq/synapse:pr-18968-dcb7678281bc02d4551043a6338fe5b7e6aa47ce + image: ghcr.io/element-hq/synapse:latest pull_policy: always environment: - SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml From 62b408a2b3a43f9708f164f2e6ffb6f9cd61b3a7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:47:27 +0000 Subject: [PATCH 036/119] Update all non-major dependencies --- package.json | 6 +- yarn.lock | 1443 +++++++++++++++++++++++++++----------------------- 2 files changed, 776 insertions(+), 673 deletions(-) diff --git a/package.json b/package.json index 49612120..8602516f 100644 --- a/package.json +++ b/package.json @@ -43,12 +43,12 @@ "@codecov/vite-plugin": "^1.3.0", "@fontsource/inconsolata": "^5.1.0", "@fontsource/inter": "^5.1.0", - "@formatjs/intl-durationformat": "^0.9.0", + "@formatjs/intl-durationformat": "^0.10.0", "@formatjs/intl-segmenter": "^11.7.3", "@livekit/components-core": "^0.12.0", "@livekit/components-react": "^2.0.0", "@livekit/protocol": "^1.42.2", - "@livekit/track-processors": "^0.6.0 || ^0.7.1", + "@livekit/track-processors": "^0.6.0 || ^0.7.1 || ^0.7.0", "@mediapipe/tasks-vision": "^0.10.18", "@playwright/test": "^1.57.0", "@radix-ui/react-dialog": "^1.0.4", @@ -118,7 +118,7 @@ "qrcode": "^1.5.4", "react": "19", "react-dom": "19", - "react-i18next": "^16.0.0 <16.1.0", + "react-i18next": "^16.0.0 <16.6.0", "react-router-dom": "^7.0.0", "react-use-measure": "^2.1.1", "rxjs": "^7.8.1", diff --git a/yarn.lock b/yarn.lock index b1d27dec..d8416b4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -115,6 +115,17 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/code-frame@npm:7.29.0" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.28.5" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.1.1" + checksum: 10c0/d34cc504e7765dfb576a663d97067afb614525806b5cad1a5cc1a7183b916fec8ff57fa233585e3926fd5a9e6b31aae6df91aa81ae9775fb7a28f658d3346f0d + languageName: node + linkType: hard + "@babel/compat-data@npm:^7.27.2": version: 7.27.3 resolution: "@babel/compat-data@npm:7.27.3" @@ -122,40 +133,33 @@ __metadata: languageName: node linkType: hard -"@babel/compat-data@npm:^7.27.7": - version: 7.28.0 - resolution: "@babel/compat-data@npm:7.28.0" - checksum: 10c0/c4e527302bcd61052423f757355a71c3bc62362bac13f7f130de16e439716f66091ff5bdecda418e8fa0271d4c725f860f0ee23ab7bf6e769f7a8bb16dfcb531 - languageName: node - linkType: hard - -"@babel/compat-data@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/compat-data@npm:7.28.5" - checksum: 10c0/702a25de73087b0eba325c1d10979eed7c9b6662677386ba7b5aa6eace0fc0676f78343bae080a0176ae26f58bd5535d73b9d0fbb547fef377692e8b249353a7 +"@babel/compat-data@npm:^7.28.6, @babel/compat-data@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/compat-data@npm:7.29.0" + checksum: 10c0/08f348554989d23aa801bf1405aa34b15e841c0d52d79da7e524285c77a5f9d298e70e11d91cc578d8e2c9542efc586d50c5f5cf8e1915b254a9dcf786913a94 languageName: node linkType: hard "@babel/core@npm:^7.16.5, @babel/core@npm:^7.18.5, @babel/core@npm:^7.21.3, @babel/core@npm:^7.28.0": - version: 7.28.5 - resolution: "@babel/core@npm:7.28.5" + version: 7.29.0 + resolution: "@babel/core@npm:7.29.0" dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/generator": "npm:^7.28.5" - "@babel/helper-compilation-targets": "npm:^7.27.2" - "@babel/helper-module-transforms": "npm:^7.28.3" - "@babel/helpers": "npm:^7.28.4" - "@babel/parser": "npm:^7.28.5" - "@babel/template": "npm:^7.27.2" - "@babel/traverse": "npm:^7.28.5" - "@babel/types": "npm:^7.28.5" + "@babel/code-frame": "npm:^7.29.0" + "@babel/generator": "npm:^7.29.0" + "@babel/helper-compilation-targets": "npm:^7.28.6" + "@babel/helper-module-transforms": "npm:^7.28.6" + "@babel/helpers": "npm:^7.28.6" + "@babel/parser": "npm:^7.29.0" + "@babel/template": "npm:^7.28.6" + "@babel/traverse": "npm:^7.29.0" + "@babel/types": "npm:^7.29.0" "@jridgewell/remapping": "npm:^2.3.5" convert-source-map: "npm:^2.0.0" debug: "npm:^4.1.0" gensync: "npm:^1.0.0-beta.2" json5: "npm:^2.2.3" semver: "npm:^6.3.1" - checksum: 10c0/535f82238027621da6bdffbdbe896ebad3558b311d6f8abc680637a9859b96edbf929ab010757055381570b29cf66c4a295b5618318d27a4273c0e2033925e72 + checksum: 10c0/5127d2e8e842ae409e11bcbb5c2dff9874abf5415e8026925af7308e903f4f43397341467a130490d1a39884f461bc2b67f3063bce0be44340db89687fd852aa languageName: node linkType: hard @@ -185,19 +189,6 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.28.0": - version: 7.28.0 - resolution: "@babel/generator@npm:7.28.0" - dependencies: - "@babel/parser": "npm:^7.28.0" - "@babel/types": "npm:^7.28.0" - "@jridgewell/gen-mapping": "npm:^0.3.12" - "@jridgewell/trace-mapping": "npm:^0.3.28" - jsesc: "npm:^3.0.2" - checksum: 10c0/1b3d122268ea3df50fde707ad864d9a55c72621357d5cebb972db3dd76859c45810c56e16ad23123f18f80cc2692f5a015d2858361300f0f224a05dc43d36a92 - languageName: node - linkType: hard - "@babel/generator@npm:^7.28.5": version: 7.28.5 resolution: "@babel/generator@npm:7.28.5" @@ -211,6 +202,19 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.29.0": + version: 7.29.1 + resolution: "@babel/generator@npm:7.29.1" + dependencies: + "@babel/parser": "npm:^7.29.0" + "@babel/types": "npm:^7.29.0" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" + jsesc: "npm:^3.0.2" + checksum: 10c0/349086e6876258ef3fb2823030fee0f6c0eb9c3ebe35fc572e16997f8c030d765f636ddc6299edae63e760ea6658f8ee9a2edfa6d6b24c9a80c917916b973551 + languageName: node + linkType: hard + "@babel/helper-annotate-as-pure@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-annotate-as-pure@npm:7.25.9" @@ -229,7 +233,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-compilation-targets@npm:^7.27.1, @babel/helper-compilation-targets@npm:^7.27.2": +"@babel/helper-compilation-targets@npm:^7.27.1": version: 7.27.2 resolution: "@babel/helper-compilation-targets@npm:7.27.2" dependencies: @@ -242,24 +246,20 @@ __metadata: languageName: node linkType: hard -"@babel/helper-create-class-features-plugin@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/helper-create-class-features-plugin@npm:7.27.1" +"@babel/helper-compilation-targets@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-compilation-targets@npm:7.28.6" dependencies: - "@babel/helper-annotate-as-pure": "npm:^7.27.1" - "@babel/helper-member-expression-to-functions": "npm:^7.27.1" - "@babel/helper-optimise-call-expression": "npm:^7.27.1" - "@babel/helper-replace-supers": "npm:^7.27.1" - "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.27.1" - "@babel/traverse": "npm:^7.27.1" + "@babel/compat-data": "npm:^7.28.6" + "@babel/helper-validator-option": "npm:^7.27.1" + browserslist: "npm:^4.24.0" + lru-cache: "npm:^5.1.1" semver: "npm:^6.3.1" - peerDependencies: - "@babel/core": ^7.0.0 - checksum: 10c0/4ee199671d6b9bdd4988aa2eea4bdced9a73abfc831d81b00c7634f49a8fc271b3ceda01c067af58018eb720c6151322015d463abea7072a368ee13f35adbb4c + checksum: 10c0/3fcdf3b1b857a1578e99d20508859dbd3f22f3c87b8a0f3dc540627b4be539bae7f6e61e49d931542fe5b557545347272bbdacd7f58a5c77025a18b745593a50 languageName: node linkType: hard -"@babel/helper-create-class-features-plugin@npm:^7.28.3, @babel/helper-create-class-features-plugin@npm:^7.28.5": +"@babel/helper-create-class-features-plugin@npm:^7.28.5": version: 7.28.5 resolution: "@babel/helper-create-class-features-plugin@npm:7.28.5" dependencies: @@ -276,6 +276,23 @@ __metadata: languageName: node linkType: hard +"@babel/helper-create-class-features-plugin@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-create-class-features-plugin@npm:7.28.6" + dependencies: + "@babel/helper-annotate-as-pure": "npm:^7.27.3" + "@babel/helper-member-expression-to-functions": "npm:^7.28.5" + "@babel/helper-optimise-call-expression": "npm:^7.27.1" + "@babel/helper-replace-supers": "npm:^7.28.6" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.27.1" + "@babel/traverse": "npm:^7.28.6" + semver: "npm:^6.3.1" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10c0/0b62b46717891f4366006b88c9b7f277980d4f578c4c3789b7a4f5a2e09e121de4cda9a414ab403986745cd3ad1af3fe2d948c9f78ab80d4dc085afc9602af50 + languageName: node + linkType: hard + "@babel/helper-create-regexp-features-plugin@npm:^7.18.6": version: 7.26.3 resolution: "@babel/helper-create-regexp-features-plugin@npm:7.26.3" @@ -302,18 +319,31 @@ __metadata: languageName: node linkType: hard -"@babel/helper-define-polyfill-provider@npm:^0.6.5": - version: 0.6.5 - resolution: "@babel/helper-define-polyfill-provider@npm:0.6.5" +"@babel/helper-create-regexp-features-plugin@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/helper-create-regexp-features-plugin@npm:7.28.5" dependencies: - "@babel/helper-compilation-targets": "npm:^7.27.2" - "@babel/helper-plugin-utils": "npm:^7.27.1" - debug: "npm:^4.4.1" + "@babel/helper-annotate-as-pure": "npm:^7.27.3" + regexpu-core: "npm:^6.3.1" + semver: "npm:^6.3.1" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10c0/7af3d604cadecdb2b0d2cedd696507f02a53a58be0523281c2d6766211443b55161dde1e6c0d96ab16ddfd82a2607a2f792390caa24797e9733631f8aa86859f + languageName: node + linkType: hard + +"@babel/helper-define-polyfill-provider@npm:^0.6.6": + version: 0.6.6 + resolution: "@babel/helper-define-polyfill-provider@npm:0.6.6" + dependencies: + "@babel/helper-compilation-targets": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.28.6" + debug: "npm:^4.4.3" lodash.debounce: "npm:^4.0.8" - resolve: "npm:^1.22.10" + resolve: "npm:^1.22.11" peerDependencies: "@babel/core": ^7.4.0 || ^8.0.0-0 <8.0.0 - checksum: 10c0/4886a068d9ca1e70af395340656a9dda33c50502c67eed39ff6451785f370bdfc6e57095b90cb92678adcd4a111ca60909af53d3a741120719c5604346ae409e + checksum: 10c0/1293d6f54d4ebb10c9e947e54de1aaa23b00233e19aca9790072f1893bf143af01442613f7b413300be7016d8e41b550af77acab28e7fa5fb796b2a175c528a1 languageName: node linkType: hard @@ -354,6 +384,16 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-imports@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-module-imports@npm:7.28.6" + dependencies: + "@babel/traverse": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + checksum: 10c0/b49d8d8f204d9dbfd5ac70c54e533e5269afb3cea966a9d976722b13e9922cc773a653405f53c89acb247d5aebdae4681d631a3ae3df77ec046b58da76eda2ac + languageName: node + linkType: hard + "@babel/helper-module-transforms@npm:^7.27.1": version: 7.27.3 resolution: "@babel/helper-module-transforms@npm:7.27.3" @@ -367,16 +407,16 @@ __metadata: languageName: node linkType: hard -"@babel/helper-module-transforms@npm:^7.28.3": - version: 7.28.3 - resolution: "@babel/helper-module-transforms@npm:7.28.3" +"@babel/helper-module-transforms@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-module-transforms@npm:7.28.6" dependencies: - "@babel/helper-module-imports": "npm:^7.27.1" - "@babel/helper-validator-identifier": "npm:^7.27.1" - "@babel/traverse": "npm:^7.28.3" + "@babel/helper-module-imports": "npm:^7.28.6" + "@babel/helper-validator-identifier": "npm:^7.28.5" + "@babel/traverse": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10c0/549be62515a6d50cd4cfefcab1b005c47f89bd9135a22d602ee6a5e3a01f27571868ada10b75b033569f24dc4a2bb8d04bfa05ee75c16da7ade2d0db1437fcdb + checksum: 10c0/6f03e14fc30b287ce0b839474b5f271e72837d0cafe6b172d759184d998fbee3903a035e81e07c2c596449e504f453463d58baa65b6f40a37ded5bec74620b2b languageName: node linkType: hard @@ -403,6 +443,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-plugin-utils@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-plugin-utils@npm:7.28.6" + checksum: 10c0/3f5f8acc152fdbb69a84b8624145ff4f9b9f6e776cb989f9f968f8606eb7185c5c3cfcf3ba08534e37e1e0e1c118ac67080610333f56baa4f7376c99b5f1143d + languageName: node + linkType: hard + "@babel/helper-remap-async-to-generator@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-remap-async-to-generator@npm:7.27.1" @@ -429,6 +476,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-replace-supers@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-replace-supers@npm:7.28.6" + dependencies: + "@babel/helper-member-expression-to-functions": "npm:^7.28.5" + "@babel/helper-optimise-call-expression": "npm:^7.27.1" + "@babel/traverse": "npm:^7.28.6" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10c0/04663c6389551b99b8c3e7ba4e2638b8ca2a156418c26771516124c53083aa8e74b6a45abe5dd46360af79709a0e9c6b72c076d0eab9efecdd5aaf836e79d8d5 + languageName: node + linkType: hard + "@babel/helper-skip-transparent-expression-wrappers@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.27.1" @@ -492,13 +552,13 @@ __metadata: languageName: node linkType: hard -"@babel/helpers@npm:^7.28.4": - version: 7.28.4 - resolution: "@babel/helpers@npm:7.28.4" +"@babel/helpers@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helpers@npm:7.28.6" dependencies: - "@babel/template": "npm:^7.27.2" - "@babel/types": "npm:^7.28.4" - checksum: 10c0/aaa5fb8098926dfed5f223adf2c5e4c7fbba4b911b73dfec2d7d3083f8ba694d201a206db673da2d9b3ae8c01793e795767654558c450c8c14b4c2175b4fcb44 + "@babel/template": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + checksum: 10c0/c4a779c66396bb0cf619402d92f1610601ff3832db2d3b86b9c9dd10983bf79502270e97ac6d5280cea1b1a37de2f06ecbac561bd2271545270407fbe64027cb languageName: node linkType: hard @@ -558,17 +618,6 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.28.0": - version: 7.28.0 - resolution: "@babel/parser@npm:7.28.0" - dependencies: - "@babel/types": "npm:^7.28.0" - bin: - parser: ./bin/babel-parser.js - checksum: 10c0/c2ef81d598990fa949d1d388429df327420357cb5200271d0d0a2784f1e6d54afc8301eb8bdf96d8f6c77781e402da93c7dc07980fcc136ac5b9d5f1fce701b5 - languageName: node - linkType: hard - "@babel/parser@npm:^7.28.5": version: 7.28.5 resolution: "@babel/parser@npm:7.28.5" @@ -580,6 +629,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/parser@npm:7.29.0" + dependencies: + "@babel/types": "npm:^7.29.0" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/333b2aa761264b91577a74bee86141ef733f9f9f6d4fc52548e4847dc35dfbf821f58c46832c637bfa761a6d9909d6a68f7d1ed59e17e4ffbb958dc510c17b62 + languageName: node + linkType: hard + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.28.5": version: 7.28.5 resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.28.5" @@ -627,15 +687,15 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@npm:^7.28.3": - version: 7.28.3 - resolution: "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@npm:7.28.3" +"@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@npm:7.28.6" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" - "@babel/traverse": "npm:^7.28.3" + "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/traverse": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10c0/3cdc27c4e08a632a58e62c6017369401976edf1cd9ae73fd9f0d6770ddd9accf40b494db15b66bab8db2a8d5dc5bab5ca8c65b19b81fdca955cd8cbbe24daadb + checksum: 10c0/f1a9194e8d1742081def7af748e9249eb5082c25d0ced292720a1f054895f99041c764a05f45af669a2c8898aeb79266058aedb0d3e1038963ad49be8288918a languageName: node linkType: hard @@ -648,25 +708,25 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-import-assertions@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-syntax-import-assertions@npm:7.27.1" +"@babel/plugin-syntax-import-assertions@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-syntax-import-assertions@npm:7.28.6" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/06a954ee672f7a7c44d52b6e55598da43a7064e80df219765c51c37a0692641277e90411028f7cae4f4d1dedeed084f0c453576fa421c35a81f1603c5e3e0146 + checksum: 10c0/f3b8bdccb9b4d3e3b9226684ca518e055399d05579da97dfe0160a38d65198cfe7dce809e73179d6463a863a040f980de32425a876d88efe4eda933d0d95982c languageName: node linkType: hard -"@babel/plugin-syntax-import-attributes@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-syntax-import-attributes@npm:7.27.1" +"@babel/plugin-syntax-import-attributes@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-syntax-import-attributes@npm:7.28.6" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/e66f7a761b8360419bbb93ab67d87c8a97465ef4637a985ff682ce7ba6918b34b29d81190204cf908d0933058ee7b42737423cd8a999546c21b3aabad4affa9a + checksum: 10c0/1be160e2c426faa74e5be2e30e39e8d0d8c543063bd5d06cd804f8751b8fbcb82ce824ca7f9ce4b09c003693f6c06a11ce503b7e34d85e1a259631e4c3f72ad2 languageName: node linkType: hard @@ -715,29 +775,29 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-async-generator-functions@npm:^7.28.0": - version: 7.28.0 - resolution: "@babel/plugin-transform-async-generator-functions@npm:7.28.0" +"@babel/plugin-transform-async-generator-functions@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/plugin-transform-async-generator-functions@npm:7.29.0" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.28.6" "@babel/helper-remap-async-to-generator": "npm:^7.27.1" - "@babel/traverse": "npm:^7.28.0" + "@babel/traverse": "npm:^7.29.0" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/739d577e649d7d7b9845dc309e132964327ab3eaea43ad04d04a7dcb977c63f9aa9a423d1ca39baf10939128d02f52e6fda39c834fb9f1753785b1497e72c4dc + checksum: 10c0/4080fc5e7dad7761bfebbb4fbe06bdfeb3a8bf0c027bcb4373e59e6b3dc7c5002eca7cbb1afba801d6439df8f92f7bcb3fb862e8fbbe43a9e59bb5653dcc0568 languageName: node linkType: hard -"@babel/plugin-transform-async-to-generator@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-async-to-generator@npm:7.27.1" +"@babel/plugin-transform-async-to-generator@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-async-to-generator@npm:7.28.6" dependencies: - "@babel/helper-module-imports": "npm:^7.27.1" - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-module-imports": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.28.6" "@babel/helper-remap-async-to-generator": "npm:^7.27.1" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/e76b1f6f9c3bbf72e17d7639406d47f09481806de4db99a8de375a0bb40957ea309b20aa705f0c25ab1d7c845e3f365af67eafa368034521151a0e352a03ef2f + checksum: 10c0/2eb0826248587df6e50038f36194a138771a7df22581020451c7779edeaf9ef39bf47c5b7a20ae2645af6416e8c896feeca273317329652e84abd79a4ab920ad languageName: node linkType: hard @@ -752,78 +812,66 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-block-scoping@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/plugin-transform-block-scoping@npm:7.28.5" +"@babel/plugin-transform-block-scoping@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-block-scoping@npm:7.28.6" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/6b098887b375c23813ccee7a00179501fc5f709b4ee5a4b2a5c5c9ef3b44cee49e240214b1a9b4ad2bd1911fab3335eac2f0a3c5f014938a1b61bec84cec4845 + checksum: 10c0/2e3e09e1f9770b56cef4dcbffddf262508fd03416072f815ac66b2b224a3a12cd285cfec12fc067f1add414e7db5ce6dafb5164a6e0fb1a728e6a97d0c6f6e9d languageName: node linkType: hard -"@babel/plugin-transform-class-properties@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-class-properties@npm:7.27.1" +"@babel/plugin-transform-class-properties@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-class-properties@npm:7.28.6" dependencies: - "@babel/helper-create-class-features-plugin": "npm:^7.27.1" - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-create-class-features-plugin": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/cc0662633c0fe6df95819fef223506ddf26c369c8d64ab21a728d9007ec866bf9436a253909819216c24a82186b6ccbc1ec94d7aaf3f82df227c7c02fa6a704b + checksum: 10c0/c4327fcd730c239d9f173f9b695b57b801729e273b4848aef1f75818069dfd31d985d75175db188d947b9b1bbe5353dae298849042026a5e4fcf07582ff3f9f1 languageName: node linkType: hard -"@babel/plugin-transform-class-static-block@npm:^7.28.3": - version: 7.28.3 - resolution: "@babel/plugin-transform-class-static-block@npm:7.28.3" +"@babel/plugin-transform-class-static-block@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-class-static-block@npm:7.28.6" dependencies: - "@babel/helper-create-class-features-plugin": "npm:^7.28.3" - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-create-class-features-plugin": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.12.0 - checksum: 10c0/8c922a64f6f5b359f7515c89ef0037bad583b4484dfebc1f6bc1cf13462547aaceb19788827c57ec9a2d62495f34c4b471ca636bf61af00fdaea5e9642c82b60 + checksum: 10c0/dbe9b1fd302ae41b73186e17ac8d8ecf625ebc2416a91f2dc8013977a1bdf21e6ea288a83f084752b412242f3866e789d4fddeb428af323fe35b60e0fae4f98c languageName: node linkType: hard -"@babel/plugin-transform-classes@npm:^7.28.4": - version: 7.28.4 - resolution: "@babel/plugin-transform-classes@npm:7.28.4" +"@babel/plugin-transform-classes@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-classes@npm:7.28.6" dependencies: "@babel/helper-annotate-as-pure": "npm:^7.27.3" - "@babel/helper-compilation-targets": "npm:^7.27.2" + "@babel/helper-compilation-targets": "npm:^7.28.6" "@babel/helper-globals": "npm:^7.28.0" - "@babel/helper-plugin-utils": "npm:^7.27.1" - "@babel/helper-replace-supers": "npm:^7.27.1" - "@babel/traverse": "npm:^7.28.4" + "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-replace-supers": "npm:^7.28.6" + "@babel/traverse": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/76687ed37216ff012c599870dc00183fb716f22e1a02fe9481943664c0e4d0d88c3da347dc3fe290d4728f4d47cd594ffa621d23845e2bb8ab446e586308e066 + checksum: 10c0/dc22f1f6eadab17305128fbf9cc5f30e87a51a77dd0a6d5498097994e8a9b9a90ab298c11edf2342acbeaac9edc9c601cad72eedcf4b592cd465a787d7f41490 languageName: node linkType: hard -"@babel/plugin-transform-computed-properties@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-computed-properties@npm:7.27.1" +"@babel/plugin-transform-computed-properties@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-computed-properties@npm:7.28.6" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" - "@babel/template": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/template": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/e09a12f8c8ae0e6a6144c102956947b4ec05f6c844169121d0ec4529c2d30ad1dc59fee67736193b87a402f44552c888a519a680a31853bdb4d34788c28af3b0 - languageName: node - linkType: hard - -"@babel/plugin-transform-destructuring@npm:^7.28.0": - version: 7.28.0 - resolution: "@babel/plugin-transform-destructuring@npm:7.28.0" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" - "@babel/traverse": "npm:^7.28.0" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/cc7ccafa952b3ff7888544d5688cfafaba78c69ce1e2f04f3233f4f78c9de5e46e9695f5ea42c085b0c0cfa39b10f366d362a2be245b6d35b66d3eb1d427ccb2 + checksum: 10c0/1e9893503ae6d651125701cc29450e87c0b873c8febebff19da75da9c40cfb7968c52c28bf948244e461110aeb7b3591f2cc199b7406ff74a24c50c7a5729f39 languageName: node linkType: hard @@ -839,15 +887,15 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-dotall-regex@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-dotall-regex@npm:7.27.1" +"@babel/plugin-transform-dotall-regex@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-dotall-regex@npm:7.28.6" dependencies: - "@babel/helper-create-regexp-features-plugin": "npm:^7.27.1" - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-create-regexp-features-plugin": "npm:^7.28.5" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/f9caddfad9a551b4dabe0dcb7c040f458fbaaa7bbb44200c20198b32c8259be8e050e58d2c853fdac901a4cfe490b86aa857036d8d461b192dd010d0e242dedb + checksum: 10c0/e2fb76b7ae99087cf4212013a3ca9dee07048f90f98fd6264855080fb6c3f169be11c9b8c9d8b26cf9a407e4d0a5fa6e103f7cef433a542b75cf7127c99d4f97 languageName: node linkType: hard @@ -862,15 +910,15 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-duplicate-named-capturing-groups-regex@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-duplicate-named-capturing-groups-regex@npm:7.27.1" +"@babel/plugin-transform-duplicate-named-capturing-groups-regex@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/plugin-transform-duplicate-named-capturing-groups-regex@npm:7.29.0" dependencies: - "@babel/helper-create-regexp-features-plugin": "npm:^7.27.1" - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-create-regexp-features-plugin": "npm:^7.28.5" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10c0/121502a252b3206913e1e990a47fea34397b4cbf7804d4cd872d45961bc45b603423f60ca87f3a3023a62528f5feb475ac1c9ec76096899ec182fcb135eba375 + checksum: 10c0/6f03d9e5e31a05b28555541be6e283407e08447a36be6ddf8068b3efa970411d832e04b1282e2b894baf89a3864ff7e7f1e36346652a8d983170c6d548555167 languageName: node linkType: hard @@ -885,26 +933,26 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-explicit-resource-management@npm:^7.28.0": - version: 7.28.0 - resolution: "@babel/plugin-transform-explicit-resource-management@npm:7.28.0" +"@babel/plugin-transform-explicit-resource-management@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-explicit-resource-management@npm:7.28.6" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" - "@babel/plugin-transform-destructuring": "npm:^7.28.0" + "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/plugin-transform-destructuring": "npm:^7.28.5" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/3baa706af3112adf2ae0c7ec0dc61b63dd02695eb5582f3c3a2b2d05399c6aa7756f55e7bbbd5412e613a6ba1dd6b6736904074b4d7ebd6b45a1e3f9145e4094 + checksum: 10c0/e6ea28c26e058fe61ada3e70b0def1992dd5a44f5fc14d8e2c6a3a512fb4d4c6dc96a3e1d0b466d83db32a9101e0b02df94051e48d3140da115b8ea9f8a31f37 languageName: node linkType: hard -"@babel/plugin-transform-exponentiation-operator@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/plugin-transform-exponentiation-operator@npm:7.28.5" +"@babel/plugin-transform-exponentiation-operator@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-exponentiation-operator@npm:7.28.6" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/006566e003c2a8175346cc4b3260fcd9f719b912ceae8a4e930ce02ee3cf0b2841d5c21795ba71790871783d3c0c1c3d22ce441b8819c37975844bfba027d3f7 + checksum: 10c0/4572d955a50dbc9a652a19431b4bb822cb479ee6045f4e6df72659c499c13036da0a2adf650b07ca995f2781e80aa868943bea1e7bff1de3169ec3f0a73a902e languageName: node linkType: hard @@ -944,14 +992,14 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-json-strings@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-json-strings@npm:7.27.1" +"@babel/plugin-transform-json-strings@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-json-strings@npm:7.28.6" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/2379714aca025516452a7c1afa1ca42a22b9b51a5050a653cc6198a51665ab82bdecf36106d32d731512706a1e373c5637f5ff635737319aa42f3827da2326d6 + checksum: 10c0/ab1091798c58e6c0bb8a864ee2b727c400924592c6ed69797a26b4c205f850a935de77ad516570be0419c279a3d9f7740c2aa448762eb8364ea77a6a357a9653 languageName: node linkType: hard @@ -966,14 +1014,14 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-logical-assignment-operators@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/plugin-transform-logical-assignment-operators@npm:7.28.5" +"@babel/plugin-transform-logical-assignment-operators@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-logical-assignment-operators@npm:7.28.6" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/fba4faa96d86fa745b0539bb631deee3f2296f0643c087a50ad0fac2e5f0a787fa885e9bdd90ae3e7832803f3c08e7cd3f1e830e7079dbdc023704923589bb23 + checksum: 10c0/4632a35453d2131f0be466681d0a33e3db44d868ff51ec46cd87e0ebd1e47c6a39b894f7d1c9b06f931addf6efa9d30e60c4cdedeb4f69d426f683e11f8490cf languageName: node linkType: hard @@ -1012,17 +1060,29 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-modules-systemjs@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/plugin-transform-modules-systemjs@npm:7.28.5" +"@babel/plugin-transform-modules-commonjs@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-modules-commonjs@npm:7.28.6" dependencies: - "@babel/helper-module-transforms": "npm:^7.28.3" - "@babel/helper-plugin-utils": "npm:^7.27.1" - "@babel/helper-validator-identifier": "npm:^7.28.5" - "@babel/traverse": "npm:^7.28.5" + "@babel/helper-module-transforms": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/7e8c0bcff79689702b974f6a0fedb5d0c6eeb5a5e3384deb7028e7cfe92a5242cc80e981e9c1817aad29f2ecc01841753365dd38d877aa0b91737ceec2acfd07 + checksum: 10c0/7c45992797c6150644c8552feff4a016ba7bd6d59ff2b039ed969a9c5b20a6804cd9d21db5045fc8cca8ca7f08262497e354e93f8f2be6a1cdf3fbfa8c31a9b6 + languageName: node + linkType: hard + +"@babel/plugin-transform-modules-systemjs@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/plugin-transform-modules-systemjs@npm:7.29.0" + dependencies: + "@babel/helper-module-transforms": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-validator-identifier": "npm:^7.28.5" + "@babel/traverse": "npm:^7.29.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/44ea502f2c990398b7d9adc5b44d9e1810a0a5e86eebc05c92d039458f0b3994fe243efa9353b90f8a648d8a91b79845fb353d8679d7324cc9de0162d732771d languageName: node linkType: hard @@ -1038,15 +1098,15 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-named-capturing-groups-regex@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-named-capturing-groups-regex@npm:7.27.1" +"@babel/plugin-transform-named-capturing-groups-regex@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/plugin-transform-named-capturing-groups-regex@npm:7.29.0" dependencies: - "@babel/helper-create-regexp-features-plugin": "npm:^7.27.1" - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-create-regexp-features-plugin": "npm:^7.28.5" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10c0/8eaa8c9aee00a00f3bd8bd8b561d3f569644d98cb2cfe3026d7398aabf9b29afd62f24f142b4112fa1f572d9b0e1928291b099cde59f56d6b59f4d565e58abf2 + checksum: 10c0/1904db22da7f2bc3e380cd2c0786bda330ee1b1b3efa3f5203d980708c4bfeb5daa4dff48d01692193040bcc5f275dbdc0c2eadc8b1eb1b6dfe363564ad6e898 languageName: node linkType: hard @@ -1061,40 +1121,40 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-nullish-coalescing-operator@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.27.1" +"@babel/plugin-transform-nullish-coalescing-operator@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.28.6" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/a435fc03aaa65c6ef8e99b2d61af0994eb5cdd4a28562d78c3b0b0228ca7e501aa255e1dff091a6996d7d3ea808eb5a65fd50ecd28dfb10687a8a1095dcadc7a + checksum: 10c0/6607f2201d66ccb688f0b1db09475ef995837df19f14705da41f693b669f834c206147a854864ab107913d7b4f4748878b0cd9fe9ca8bfd1bee0c206fc027b49 languageName: node linkType: hard -"@babel/plugin-transform-numeric-separator@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-numeric-separator@npm:7.27.1" +"@babel/plugin-transform-numeric-separator@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-numeric-separator@npm:7.28.6" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/b72cbebbfe46fcf319504edc1cf59f3f41c992dd6840db766367f6a1d232cd2c52143c5eaf57e0316710bee251cae94be97c6d646b5022fcd9274ccb131b470c + checksum: 10c0/191097d8d2753cdd16d1acca65a945d1645ab20b65655c2f5b030a9e38967a52e093dcb21ebf391e342222705c6ffe5dea15dafd6257f7b51b77fb64a830b637 languageName: node linkType: hard -"@babel/plugin-transform-object-rest-spread@npm:^7.28.4": - version: 7.28.4 - resolution: "@babel/plugin-transform-object-rest-spread@npm:7.28.4" +"@babel/plugin-transform-object-rest-spread@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-object-rest-spread@npm:7.28.6" dependencies: - "@babel/helper-compilation-targets": "npm:^7.27.2" - "@babel/helper-plugin-utils": "npm:^7.27.1" - "@babel/plugin-transform-destructuring": "npm:^7.28.0" + "@babel/helper-compilation-targets": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/plugin-transform-destructuring": "npm:^7.28.5" "@babel/plugin-transform-parameters": "npm:^7.27.7" - "@babel/traverse": "npm:^7.28.4" + "@babel/traverse": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/81725c8d6349957899975f3f789b1d4fb050ee8b04468ebfaccd5b59e0bda15cbfdef09aee8b4359f322b6715149d680361f11c1a420c4bdbac095537ecf7a90 + checksum: 10c0/f55334352d4fcde385f2e8a58836687e71ff668c9b6e4c34d52575bf2789cdde92d9d3116edba13647ac0bc3e51fb2a6d1e8fb822dce7e8123334b82600bc4c3 languageName: node linkType: hard @@ -1110,14 +1170,14 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-optional-catch-binding@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-optional-catch-binding@npm:7.27.1" +"@babel/plugin-transform-optional-catch-binding@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-optional-catch-binding@npm:7.28.6" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/807a4330f1fac08e2682d57bc82e714868fc651c8876f9a8b3a3fd8f53c129e87371f8243e712ac7dae11e090b737a2219a02fe1b6459a29e664fa073c3277bb + checksum: 10c0/36e8face000ee65e478a55febf687ce9be7513ad498c60dfe585851555565e0c28e7cb891b3c59709318539ce46f7697d5f42130eb18f385cd47e47cfa297446 languageName: node linkType: hard @@ -1133,15 +1193,15 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-optional-chaining@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/plugin-transform-optional-chaining@npm:7.28.5" +"@babel/plugin-transform-optional-chaining@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-optional-chaining@npm:7.28.6" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.28.6" "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.27.1" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/adf5f70b1f9eb0dd6ff3d159a714683af3c910775653e667bd9f864c3dc2dc9872aba95f6c1e5f2a9675067241942f4fd0d641147ef4bf2bd8bc15f1fa0f2ed5 + checksum: 10c0/c159cc74115c2266be21791f192dd079e2aeb65c8731157e53b80fcefa41e8e28ad370021d4dfbdb31f25e5afa0322669a8eb2d032cd96e65ac37e020324c763 languageName: node linkType: hard @@ -1156,28 +1216,28 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-private-methods@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-private-methods@npm:7.27.1" +"@babel/plugin-transform-private-methods@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-private-methods@npm:7.28.6" dependencies: - "@babel/helper-create-class-features-plugin": "npm:^7.27.1" - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-create-class-features-plugin": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/232bedfe9d28df215fb03cc7623bdde468b1246bdd6dc24465ff4bf9cc5f5a256ae33daea1fafa6cc59705e4d29da9024bb79baccaa5cd92811ac5db9b9244f2 + checksum: 10c0/fb504e2bfdcf3f734d2a90ab20d61427c58385f57f950d3de6ff4e6d12dd4aa7d552147312d218367e129b7920dccfc3230ba554de861986cda38921bad84067 languageName: node linkType: hard -"@babel/plugin-transform-private-property-in-object@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-private-property-in-object@npm:7.27.1" +"@babel/plugin-transform-private-property-in-object@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-private-property-in-object@npm:7.28.6" dependencies: - "@babel/helper-annotate-as-pure": "npm:^7.27.1" - "@babel/helper-create-class-features-plugin": "npm:^7.27.1" - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-annotate-as-pure": "npm:^7.27.3" + "@babel/helper-create-class-features-plugin": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/a8c4536273ca716dcc98e74ea25ca76431528554922f184392be3ddaf1761d4aa0e06f1311577755bd1613f7054fb51d29de2ada1130f743d329170a1aa1fe56 + checksum: 10c0/0f6bbc6ec3f93b556d3de7d56bf49335255fc4c43488e51a5025d6ee0286183fd3cf950ffcac1bbeed8a45777f860a49996455c8d3b4a04c3b1a5f28e697fe31 languageName: node linkType: hard @@ -1263,26 +1323,26 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-regenerator@npm:^7.28.4": - version: 7.28.4 - resolution: "@babel/plugin-transform-regenerator@npm:7.28.4" +"@babel/plugin-transform-regenerator@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/plugin-transform-regenerator@npm:7.29.0" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/5ad14647ffaac63c920e28df1b580ee2e932586bbdc71f61ec264398f68a5406c71a7f921de397a41b954a69316c5ab90e5d789ffa2bb34c5e6feb3727cfefb8 + checksum: 10c0/86c7db9b97f85ee47c0fae0528802cbc06e5775e61580ee905335c16bb971270086764a3859873d9adcd7d0f913a5b93eb0dc271aec8fb9e93e090e4ac95e29e languageName: node linkType: hard -"@babel/plugin-transform-regexp-modifiers@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-regexp-modifiers@npm:7.27.1" +"@babel/plugin-transform-regexp-modifiers@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-regexp-modifiers@npm:7.28.6" dependencies: - "@babel/helper-create-regexp-features-plugin": "npm:^7.27.1" - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-create-regexp-features-plugin": "npm:^7.28.5" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10c0/31ae596ab56751cf43468a6c0a9d6bc3521d306d2bee9c6957cdb64bea53812ce24bd13a32f766150d62b737bca5b0650b2c62db379382fff0dccbf076055c33 + checksum: 10c0/97e36b086800f71694fa406abc00192e3833662f2bdd5f51c018bd0c95eef247c4ae187417c207d03a9c5374342eac0bb65a39112c431a9b23b09b1eda1562e5 languageName: node linkType: hard @@ -1308,15 +1368,15 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-spread@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-spread@npm:7.27.1" +"@babel/plugin-transform-spread@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-spread@npm:7.28.6" dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-plugin-utils": "npm:^7.28.6" "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.27.1" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/b34fc58b33bd35b47d67416655c2cbc8578fbb3948b4592bc15eb6d8b4046986e25c06e3b9929460fa4ab08e9653582415e7ef8b87d265e1239251bdf5a4c162 + checksum: 10c0/bcac50e558d6f0c501cbce19ec197af558cef51fe3b3a6eba27276e323e57a5be28109b4264a5425ac12a67bf95d6af9c2a42b05e79c522ce913fb9529259d76 languageName: node linkType: hard @@ -1379,15 +1439,15 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-unicode-property-regex@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-unicode-property-regex@npm:7.27.1" +"@babel/plugin-transform-unicode-property-regex@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-unicode-property-regex@npm:7.28.6" dependencies: - "@babel/helper-create-regexp-features-plugin": "npm:^7.27.1" - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-create-regexp-features-plugin": "npm:^7.28.5" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/a332bc3cb3eeea67c47502bc52d13a0f8abae5a7bfcb08b93a8300ddaff8d9e1238f912969494c1b494c1898c6f19687054440706700b6d12cb0b90d88beb4d0 + checksum: 10c0/b25f8cde643f4f47e0fa4f7b5c552e2dfbb6ad0ce07cf40f7e8ae40daa9855ad855d76d4d6d010153b74e48c8794685955c92ca637c0da152ce5f0fa9e7c90fa languageName: node linkType: hard @@ -1403,95 +1463,95 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-unicode-sets-regex@npm:^7.27.1": - version: 7.27.1 - resolution: "@babel/plugin-transform-unicode-sets-regex@npm:7.27.1" +"@babel/plugin-transform-unicode-sets-regex@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/plugin-transform-unicode-sets-regex@npm:7.28.6" dependencies: - "@babel/helper-create-regexp-features-plugin": "npm:^7.27.1" - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/helper-create-regexp-features-plugin": "npm:^7.28.5" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10c0/236645f4d0a1fba7c18dc8ffe3975933af93e478f2665650c2d91cf528cfa1587cde5cfe277e0e501fc03b5bf57638369575d6539cef478632fb93bd7d7d7178 + checksum: 10c0/c03c8818736b138db73d1f7a96fbfa22d1994639164d743f0f00e6383d3b7b3144d333de960ff4afad0bddd0baaac257295e3316969eba995b1b6a1b4dec933e languageName: node linkType: hard "@babel/preset-env@npm:^7.22.20": - version: 7.28.5 - resolution: "@babel/preset-env@npm:7.28.5" + version: 7.29.0 + resolution: "@babel/preset-env@npm:7.29.0" dependencies: - "@babel/compat-data": "npm:^7.28.5" - "@babel/helper-compilation-targets": "npm:^7.27.2" - "@babel/helper-plugin-utils": "npm:^7.27.1" + "@babel/compat-data": "npm:^7.29.0" + "@babel/helper-compilation-targets": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.28.6" "@babel/helper-validator-option": "npm:^7.27.1" "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "npm:^7.28.5" "@babel/plugin-bugfix-safari-class-field-initializer-scope": "npm:^7.27.1" "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "npm:^7.27.1" "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "npm:^7.27.1" - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "npm:^7.28.3" + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "npm:^7.28.6" "@babel/plugin-proposal-private-property-in-object": "npm:7.21.0-placeholder-for-preset-env.2" - "@babel/plugin-syntax-import-assertions": "npm:^7.27.1" - "@babel/plugin-syntax-import-attributes": "npm:^7.27.1" + "@babel/plugin-syntax-import-assertions": "npm:^7.28.6" + "@babel/plugin-syntax-import-attributes": "npm:^7.28.6" "@babel/plugin-syntax-unicode-sets-regex": "npm:^7.18.6" "@babel/plugin-transform-arrow-functions": "npm:^7.27.1" - "@babel/plugin-transform-async-generator-functions": "npm:^7.28.0" - "@babel/plugin-transform-async-to-generator": "npm:^7.27.1" + "@babel/plugin-transform-async-generator-functions": "npm:^7.29.0" + "@babel/plugin-transform-async-to-generator": "npm:^7.28.6" "@babel/plugin-transform-block-scoped-functions": "npm:^7.27.1" - "@babel/plugin-transform-block-scoping": "npm:^7.28.5" - "@babel/plugin-transform-class-properties": "npm:^7.27.1" - "@babel/plugin-transform-class-static-block": "npm:^7.28.3" - "@babel/plugin-transform-classes": "npm:^7.28.4" - "@babel/plugin-transform-computed-properties": "npm:^7.27.1" + "@babel/plugin-transform-block-scoping": "npm:^7.28.6" + "@babel/plugin-transform-class-properties": "npm:^7.28.6" + "@babel/plugin-transform-class-static-block": "npm:^7.28.6" + "@babel/plugin-transform-classes": "npm:^7.28.6" + "@babel/plugin-transform-computed-properties": "npm:^7.28.6" "@babel/plugin-transform-destructuring": "npm:^7.28.5" - "@babel/plugin-transform-dotall-regex": "npm:^7.27.1" + "@babel/plugin-transform-dotall-regex": "npm:^7.28.6" "@babel/plugin-transform-duplicate-keys": "npm:^7.27.1" - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "npm:^7.27.1" + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "npm:^7.29.0" "@babel/plugin-transform-dynamic-import": "npm:^7.27.1" - "@babel/plugin-transform-explicit-resource-management": "npm:^7.28.0" - "@babel/plugin-transform-exponentiation-operator": "npm:^7.28.5" + "@babel/plugin-transform-explicit-resource-management": "npm:^7.28.6" + "@babel/plugin-transform-exponentiation-operator": "npm:^7.28.6" "@babel/plugin-transform-export-namespace-from": "npm:^7.27.1" "@babel/plugin-transform-for-of": "npm:^7.27.1" "@babel/plugin-transform-function-name": "npm:^7.27.1" - "@babel/plugin-transform-json-strings": "npm:^7.27.1" + "@babel/plugin-transform-json-strings": "npm:^7.28.6" "@babel/plugin-transform-literals": "npm:^7.27.1" - "@babel/plugin-transform-logical-assignment-operators": "npm:^7.28.5" + "@babel/plugin-transform-logical-assignment-operators": "npm:^7.28.6" "@babel/plugin-transform-member-expression-literals": "npm:^7.27.1" "@babel/plugin-transform-modules-amd": "npm:^7.27.1" - "@babel/plugin-transform-modules-commonjs": "npm:^7.27.1" - "@babel/plugin-transform-modules-systemjs": "npm:^7.28.5" + "@babel/plugin-transform-modules-commonjs": "npm:^7.28.6" + "@babel/plugin-transform-modules-systemjs": "npm:^7.29.0" "@babel/plugin-transform-modules-umd": "npm:^7.27.1" - "@babel/plugin-transform-named-capturing-groups-regex": "npm:^7.27.1" + "@babel/plugin-transform-named-capturing-groups-regex": "npm:^7.29.0" "@babel/plugin-transform-new-target": "npm:^7.27.1" - "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.27.1" - "@babel/plugin-transform-numeric-separator": "npm:^7.27.1" - "@babel/plugin-transform-object-rest-spread": "npm:^7.28.4" + "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.28.6" + "@babel/plugin-transform-numeric-separator": "npm:^7.28.6" + "@babel/plugin-transform-object-rest-spread": "npm:^7.28.6" "@babel/plugin-transform-object-super": "npm:^7.27.1" - "@babel/plugin-transform-optional-catch-binding": "npm:^7.27.1" - "@babel/plugin-transform-optional-chaining": "npm:^7.28.5" + "@babel/plugin-transform-optional-catch-binding": "npm:^7.28.6" + "@babel/plugin-transform-optional-chaining": "npm:^7.28.6" "@babel/plugin-transform-parameters": "npm:^7.27.7" - "@babel/plugin-transform-private-methods": "npm:^7.27.1" - "@babel/plugin-transform-private-property-in-object": "npm:^7.27.1" + "@babel/plugin-transform-private-methods": "npm:^7.28.6" + "@babel/plugin-transform-private-property-in-object": "npm:^7.28.6" "@babel/plugin-transform-property-literals": "npm:^7.27.1" - "@babel/plugin-transform-regenerator": "npm:^7.28.4" - "@babel/plugin-transform-regexp-modifiers": "npm:^7.27.1" + "@babel/plugin-transform-regenerator": "npm:^7.29.0" + "@babel/plugin-transform-regexp-modifiers": "npm:^7.28.6" "@babel/plugin-transform-reserved-words": "npm:^7.27.1" "@babel/plugin-transform-shorthand-properties": "npm:^7.27.1" - "@babel/plugin-transform-spread": "npm:^7.27.1" + "@babel/plugin-transform-spread": "npm:^7.28.6" "@babel/plugin-transform-sticky-regex": "npm:^7.27.1" "@babel/plugin-transform-template-literals": "npm:^7.27.1" "@babel/plugin-transform-typeof-symbol": "npm:^7.27.1" "@babel/plugin-transform-unicode-escapes": "npm:^7.27.1" - "@babel/plugin-transform-unicode-property-regex": "npm:^7.27.1" + "@babel/plugin-transform-unicode-property-regex": "npm:^7.28.6" "@babel/plugin-transform-unicode-regex": "npm:^7.27.1" - "@babel/plugin-transform-unicode-sets-regex": "npm:^7.27.1" + "@babel/plugin-transform-unicode-sets-regex": "npm:^7.28.6" "@babel/preset-modules": "npm:0.1.6-no-external-plugins" - babel-plugin-polyfill-corejs2: "npm:^0.4.14" - babel-plugin-polyfill-corejs3: "npm:^0.13.0" - babel-plugin-polyfill-regenerator: "npm:^0.6.5" - core-js-compat: "npm:^3.43.0" + babel-plugin-polyfill-corejs2: "npm:^0.4.15" + babel-plugin-polyfill-corejs3: "npm:^0.14.0" + babel-plugin-polyfill-regenerator: "npm:^0.6.6" + core-js-compat: "npm:^3.48.0" semver: "npm:^6.3.1" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/d1b730158de290f1c54ed7db0f4fed3f82db5f868ab0a4cb3fc2ea76ed683b986ae136f6e7eb0b44b91bc9a99039a2559851656b4fd50193af1a815a3e32e524 + checksum: 10c0/08737e333a538703ba20e9e93b5bfbc01abbb9d3b2519b5b62ad05d3b6b92d79445b1dac91229b8cfcfb0b681b22b7c6fa88d7c1cc15df1690a23b21287f55b6 languageName: node linkType: hard @@ -1573,7 +1633,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.27.6, @babel/runtime@npm:^7.28.4": +"@babel/runtime@npm:^7.28.4": version: 7.28.4 resolution: "@babel/runtime@npm:7.28.4" checksum: 10c0/792ce7af9750fb9b93879cc9d1db175701c4689da890e6ced242ea0207c9da411ccf16dc04e689cc01158b28d7898c40d75598f4559109f761c12ce01e959bf7 @@ -1602,6 +1662,17 @@ __metadata: languageName: node linkType: hard +"@babel/template@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/template@npm:7.28.6" + dependencies: + "@babel/code-frame": "npm:^7.28.6" + "@babel/parser": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + checksum: 10c0/66d87225ed0bc77f888181ae2d97845021838c619944877f7c4398c6748bcf611f216dfd6be74d39016af502bca876e6ce6873db3c49e4ac354c56d34d57e9f5 + languageName: node + linkType: hard + "@babel/traverse@npm:^7.10.3": version: 7.25.9 resolution: "@babel/traverse@npm:7.25.9" @@ -1632,22 +1703,7 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:^7.28.0": - version: 7.28.0 - resolution: "@babel/traverse@npm:7.28.0" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/generator": "npm:^7.28.0" - "@babel/helper-globals": "npm:^7.28.0" - "@babel/parser": "npm:^7.28.0" - "@babel/template": "npm:^7.27.2" - "@babel/types": "npm:^7.28.0" - debug: "npm:^4.3.1" - checksum: 10c0/32794402457827ac558173bcebdcc0e3a18fa339b7c41ca35621f9f645f044534d91bb923ff385f5f960f2e495f56ce18d6c7b0d064d2f0ccb55b285fa6bc7b9 - languageName: node - linkType: hard - -"@babel/traverse@npm:^7.28.3, @babel/traverse@npm:^7.28.4, @babel/traverse@npm:^7.28.5": +"@babel/traverse@npm:^7.28.5": version: 7.28.5 resolution: "@babel/traverse@npm:7.28.5" dependencies: @@ -1662,6 +1718,21 @@ __metadata: languageName: node linkType: hard +"@babel/traverse@npm:^7.28.6, @babel/traverse@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/traverse@npm:7.29.0" + dependencies: + "@babel/code-frame": "npm:^7.29.0" + "@babel/generator": "npm:^7.29.0" + "@babel/helper-globals": "npm:^7.28.0" + "@babel/parser": "npm:^7.29.0" + "@babel/template": "npm:^7.28.6" + "@babel/types": "npm:^7.29.0" + debug: "npm:^4.3.1" + checksum: 10c0/f63ef6e58d02a9fbf3c0e2e5f1c877da3e0bc57f91a19d2223d53e356a76859cbaf51171c9211c71816d94a0e69efa2732fd27ffc0e1bbc84b636e60932333eb + languageName: node + linkType: hard + "@babel/types@npm:^7.0.0, @babel/types@npm:^7.10.3, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3": version: 7.26.0 resolution: "@babel/types@npm:7.26.0" @@ -1702,17 +1773,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.28.0": - version: 7.28.2 - resolution: "@babel/types@npm:7.28.2" - dependencies: - "@babel/helper-string-parser": "npm:^7.27.1" - "@babel/helper-validator-identifier": "npm:^7.27.1" - checksum: 10c0/24b11c9368e7e2c291fe3c1bcd1ed66f6593a3975f479cbb9dd7b8c8d8eab8a962b0d2fca616c043396ce82500ac7d23d594fbbbd013828182c01596370a0b10 - languageName: node - linkType: hard - -"@babel/types@npm:^7.28.4, @babel/types@npm:^7.28.5": +"@babel/types@npm:^7.28.5": version: 7.28.5 resolution: "@babel/types@npm:7.28.5" dependencies: @@ -1722,6 +1783,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/types@npm:7.29.0" + dependencies: + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.28.5" + checksum: 10c0/23cc3466e83bcbfab8b9bd0edaafdb5d4efdb88b82b3be6728bbade5ba2f0996f84f63b1c5f7a8c0d67efded28300898a5f930b171bb40b311bca2029c4e9b4f + languageName: node + linkType: hard + "@bcoe/v8-coverage@npm:^1.0.2": version: 1.0.2 resolution: "@bcoe/v8-coverage@npm:1.0.2" @@ -2195,14 +2266,14 @@ __metadata: languageName: node linkType: hard -"@csstools/postcss-normalize-display-values@npm:^4.0.0": - version: 4.0.0 - resolution: "@csstools/postcss-normalize-display-values@npm:4.0.0" +"@csstools/postcss-normalize-display-values@npm:^4.0.1": + version: 4.0.1 + resolution: "@csstools/postcss-normalize-display-values@npm:4.0.1" dependencies: postcss-value-parser: "npm:^4.2.0" peerDependencies: postcss: ^8.4 - checksum: 10c0/d3a3a362b532163bd791f97348ef28b7a43baf01987c7702b06285e751cdc5ea3e3a2553f088260515b4d28263d5c475923d4d4780ecb4078ec66dff50c9e638 + checksum: 10c0/5d19364bad8554b047cebd94ad7e203723ed76abaf690e4b92c74e6fc7c3642cb8858ade3263da61aff26d97bb258af567b1036e97865b7aa3b17522241fd1e1 languageName: node linkType: hard @@ -2430,16 +2501,16 @@ __metadata: languageName: node linkType: hard -"@es-joy/jsdoccomment@npm:~0.76.0": - version: 0.76.0 - resolution: "@es-joy/jsdoccomment@npm:0.76.0" +"@es-joy/jsdoccomment@npm:~0.78.0": + version: 0.78.0 + resolution: "@es-joy/jsdoccomment@npm:0.78.0" dependencies: "@types/estree": "npm:^1.0.8" - "@typescript-eslint/types": "npm:^8.46.0" + "@typescript-eslint/types": "npm:^8.46.4" comment-parser: "npm:1.4.1" esquery: "npm:^1.6.0" - jsdoc-type-pratt-parser: "npm:~6.10.0" - checksum: 10c0/8fe4edec7d60562787ea8c77193ebe8737a9e28ec3143d383506b63890d0ffd45a2813e913ad1f00f227cb10e3a1fb913e5a696b33d499dc564272ff1a6f3fdb + jsdoc-type-pratt-parser: "npm:~7.0.0" + checksum: 10c0/be18b8149303e8e7c9414b0b0453a0fa959c1c8db6f721b75178336e01b65a9f251db98ecfedfb1b3cfa5e717f3e2abdb06a0f8dbe45d3330a62262c5331c327 languageName: node linkType: hard @@ -2829,21 +2900,21 @@ __metadata: languageName: node linkType: hard -"@eslint-community/eslint-utils@npm:^4.7.0": - version: 4.7.0 - resolution: "@eslint-community/eslint-utils@npm:4.7.0" +"@eslint-community/eslint-utils@npm:^4.9.1": + version: 4.9.1 + resolution: "@eslint-community/eslint-utils@npm:4.9.1" dependencies: eslint-visitor-keys: "npm:^3.4.3" peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - checksum: 10c0/c0f4f2bd73b7b7a9de74b716a664873d08ab71ab439e51befe77d61915af41a81ecec93b408778b3a7856185244c34c2c8ee28912072ec14def84ba2dec70adf + checksum: 10c0/dc4ab5e3e364ef27e33666b11f4b86e1a6c1d7cbf16f0c6ff87b1619b3562335e9201a3d6ce806221887ff780ec9d828962a290bb910759fd40a674686503f02 languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.10.0": - version: 4.12.1 - resolution: "@eslint-community/regexpp@npm:4.12.1" - checksum: 10c0/a03d98c246bcb9109aec2c08e4d10c8d010256538dcb3f56610191607214523d4fb1b00aa81df830b6dffb74c5fa0be03642513a289c567949d3e550ca11cdf6 +"@eslint-community/regexpp@npm:^4.12.2": + version: 4.12.2 + resolution: "@eslint-community/regexpp@npm:4.12.2" + checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d languageName: node linkType: hard @@ -2989,15 +3060,15 @@ __metadata: languageName: node linkType: hard -"@formatjs/ecma402-abstract@npm:3.0.7": - version: 3.0.7 - resolution: "@formatjs/ecma402-abstract@npm:3.0.7" +"@formatjs/ecma402-abstract@npm:3.1.1": + version: 3.1.1 + resolution: "@formatjs/ecma402-abstract@npm:3.1.1" dependencies: - "@formatjs/fast-memoize": "npm:3.0.2" - "@formatjs/intl-localematcher": "npm:0.7.4" - decimal.js: "npm:^10.4.3" - tslib: "npm:^2.8.0" - checksum: 10c0/0fdc25ef72dcd5bbe1deeb190be2f0a2e2770a2135904d16ddfb424305a1efed14b026fba6c48121bc32f693abf1fe08c0ee12cb7d888cb2ba92963236d82c77 + "@formatjs/fast-memoize": "npm:3.1.0" + "@formatjs/intl-localematcher": "npm:0.8.1" + decimal.js: "npm:^10.6.0" + tslib: "npm:^2.8.1" + checksum: 10c0/0b4aad9d3917e385d5b090dd1bf6c0a4600851d87149b6a2b552b4f7d31cdf348fcd19ec534cc79efb375997747ae17f9d09633121f4282fac3c5b1cce90ae98 languageName: node linkType: hard @@ -3010,23 +3081,23 @@ __metadata: languageName: node linkType: hard -"@formatjs/fast-memoize@npm:3.0.2": - version: 3.0.2 - resolution: "@formatjs/fast-memoize@npm:3.0.2" +"@formatjs/fast-memoize@npm:3.1.0": + version: 3.1.0 + resolution: "@formatjs/fast-memoize@npm:3.1.0" dependencies: - tslib: "npm:^2.8.0" - checksum: 10c0/f7d1074090df309d37322979fe5fc96451531317b42bd927102a3a86dee537b1cb0e378158c74e00efd9714a0aa0f1e5a673c749535df200e13167112676ce88 + tslib: "npm:^2.8.1" + checksum: 10c0/367cf8b2816117a3870224a56a3127f2fa5fb854f696102e1cb6229c2f6dec35ccb433fa5343cda76ee5a0a21bff977fad1e4a15f9fba06bcb11f5d4e76d8919 languageName: node linkType: hard -"@formatjs/intl-durationformat@npm:^0.9.0": - version: 0.9.1 - resolution: "@formatjs/intl-durationformat@npm:0.9.1" +"@formatjs/intl-durationformat@npm:^0.10.0": + version: 0.10.1 + resolution: "@formatjs/intl-durationformat@npm:0.10.1" dependencies: - "@formatjs/ecma402-abstract": "npm:3.0.7" - "@formatjs/intl-localematcher": "npm:0.7.4" - tslib: "npm:^2.8.0" - checksum: 10c0/6f7b01027c07162b26be3014bba17a7633d1f9cfe6c26c5f403e72b92ac26c67cda2d88aeedab891b080664cfb4aace0eacec70b6f006616fe0d322dc2e8145d + "@formatjs/ecma402-abstract": "npm:3.1.1" + "@formatjs/intl-localematcher": "npm:0.8.1" + tslib: "npm:^2.8.1" + checksum: 10c0/9b0863ba7dd1abc64bbbd15afd24b3bc5ccabe39ab13db97bdfe4d78de7861bd9b0b4a060d6087f1817184328f97b64bfb69ca119071d20dab8f3b5ad8cf9231 languageName: node linkType: hard @@ -3039,13 +3110,13 @@ __metadata: languageName: node linkType: hard -"@formatjs/intl-localematcher@npm:0.7.4": - version: 0.7.4 - resolution: "@formatjs/intl-localematcher@npm:0.7.4" +"@formatjs/intl-localematcher@npm:0.8.1": + version: 0.8.1 + resolution: "@formatjs/intl-localematcher@npm:0.8.1" dependencies: - "@formatjs/fast-memoize": "npm:3.0.2" - tslib: "npm:^2.8.0" - checksum: 10c0/7fc31e13397317faadee033dcf668cda49f031b28542c634c920339f374f483235543e08be2077152cfe5dd41e651d8d2d37b6ece8aa044c0998c48f5472fb1a + "@formatjs/fast-memoize": "npm:3.1.0" + tslib: "npm:^2.8.1" + checksum: 10c0/c1ecd407891dec31bc5e9cab7ac4294bfb8c9eb11a5e624d9ae81627fb4dbb27ce38b0efafcfd8b26981b3ea43d765de34238a50474d07fd9556d1e79cfbcc6b languageName: node linkType: hard @@ -3266,23 +3337,23 @@ __metadata: linkType: hard "@livekit/protocol@npm:^1.42.2": - version: 1.43.4 - resolution: "@livekit/protocol@npm:1.43.4" + version: 1.44.0 + resolution: "@livekit/protocol@npm:1.44.0" dependencies: "@bufbuild/protobuf": "npm:^1.10.0" - checksum: 10c0/38077ceec44151b7481a95ce25869570b1466359de4992d9367002fc5b0925fc8ca120ed448099ae552064f23664ebe0920669f4fba97164eacbf181664683f2 + checksum: 10c0/f547a5ee586cae002ed2834f0a823573e38887562dbc793e261791b0572472c6732262a5466c96082464575a3248a4c6cb0428420418e834cdbef1b202cddedf languageName: node linkType: hard -"@livekit/track-processors@npm:^0.6.0 || ^0.7.1": - version: 0.6.1 - resolution: "@livekit/track-processors@npm:0.6.1" +"@livekit/track-processors@npm:^0.6.0 || ^0.7.1 || ^0.7.0": + version: 0.7.0 + resolution: "@livekit/track-processors@npm:0.7.0" dependencies: "@mediapipe/tasks-vision": "npm:0.10.14" peerDependencies: "@types/dom-mediacapture-transform": ^0.1.9 livekit-client: ^1.12.0 || ^2.1.0 - checksum: 10c0/80f54663c7e13de299de9e2565b6cbd2ba74ea0a4a8adf8a366e8cfd0e19dedfb9d699899137f1a6133414f28779877eeb3200074c03893bc63aeb0d8c912a91 + checksum: 10c0/4c1ec427586e885c44d2865a98008b563d002b1b98d117383637a696597d71a0ff64d8a5bcba48033298e5c2cbaa9e357481e8a4a182982a355eb9e0eeb87643 languageName: node linkType: hard @@ -3294,9 +3365,9 @@ __metadata: linkType: hard "@mediapipe/tasks-vision@npm:^0.10.18": - version: 0.10.21 - resolution: "@mediapipe/tasks-vision@npm:0.10.21" - checksum: 10c0/11b2bdf98b8cb6e044f2a954e7c8393169e62c86ff49b3d0b61c3b327d18e1ccd47a187999b023bad48380c9da41bfa66eb165301c80da07746390482cb18a19 + version: 0.10.32 + resolution: "@mediapipe/tasks-vision@npm:0.10.32" + checksum: 10c0/734d472ece8f10e8ba6bdcda7adfc46ddd4da737797e3699aabdb857a4ec5ae87de064b0d7ed41bd96fe49bb8a9420afd1fcc337eea38cd536e7b41bed9f88b7 languageName: node linkType: hard @@ -3774,13 +3845,13 @@ __metadata: linkType: hard "@playwright/test@npm:^1.57.0": - version: 1.57.0 - resolution: "@playwright/test@npm:1.57.0" + version: 1.58.2 + resolution: "@playwright/test@npm:1.58.2" dependencies: - playwright: "npm:1.57.0" + playwright: "npm:1.58.2" bin: playwright: cli.js - checksum: 10c0/35ba4b28be72bf0a53e33dbb11c6cff848fb9a37f49e893ce63a90675b5291ec29a1ba82c8a3b043abaead129400f0589623e9ace2e6a1c8eaa409721ecc3774 + checksum: 10c0/2164c03ad97c3653ff02e8818a71f3b2bbc344ac07924c9d8e31cd57505d6d37596015a41f51396b3ed8de6840f59143eaa9c21bf65515963da20740119811da languageName: node linkType: hard @@ -5548,8 +5619,8 @@ __metadata: linkType: hard "@testing-library/react@npm:^16.0.0": - version: 16.3.1 - resolution: "@testing-library/react@npm:16.3.1" + version: 16.3.2 + resolution: "@testing-library/react@npm:16.3.2" dependencies: "@babel/runtime": "npm:^7.12.5" peerDependencies: @@ -5563,7 +5634,7 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 10c0/5a26ceaa4ab1d065be722d93e3b019883864ae038f9fd1c974f5b8a173f5f35a25768ecb2baa02a783299f009cbcd09fa7ee0b8b3d360d1c0f81535436358b28 + checksum: 10c0/f9c7f0915e1b5f7b750e6c7d8b51f091b8ae7ea99bacb761d7b8505ba25de9cfcb749a0f779f1650fb268b499dd79165dc7e1ee0b8b4cb63430d3ddc81ffe044 languageName: node linkType: hard @@ -5740,20 +5811,20 @@ __metadata: linkType: hard "@types/node@npm:*": - version: 25.0.3 - resolution: "@types/node@npm:25.0.3" + version: 25.3.0 + resolution: "@types/node@npm:25.3.0" dependencies: - undici-types: "npm:~7.16.0" - checksum: 10c0/b7568f0d765d9469621615e2bb257c7fd1953d95e9acbdb58dffb6627a2c4150d405a4600aa1ad8a40182a94fe5f903cafd3c0a2f5132814debd0e3bfd61f835 + undici-types: "npm:~7.18.0" + checksum: 10c0/7b2b18c9d68047157367fc2f786d4f166d22dc0ad9f82331ca02fb16f2f391854123dbe604dcb938cda119c87051e4bb71dcb9ece44a579f483a6f96d4bd41de languageName: node linkType: hard "@types/node@npm:^24.0.0": - version: 24.10.4 - resolution: "@types/node@npm:24.10.4" + version: 24.10.13 + resolution: "@types/node@npm:24.10.13" dependencies: undici-types: "npm:~7.16.0" - checksum: 10c0/069639cb7233ee747df1897b5e784f6b6c5da765c96c94773c580aac888fa1a585048d2a6e95eb8302d89c7a9df75801c8b5a0b7d0221d4249059cf09a5f4228 + checksum: 10c0/4ff0b9b060b5477c0fec5b11a176f294be588104ab546295db65b17a92ba0a6077b52ad92dd3c0d2154198c7f9d0021e6c1d42b00c9ac7ebfd85632afbcc48a4 languageName: node linkType: hard @@ -5790,11 +5861,11 @@ __metadata: linkType: hard "@types/react@npm:^19.0.0": - version: 19.2.7 - resolution: "@types/react@npm:19.2.7" + version: 19.2.14 + resolution: "@types/react@npm:19.2.14" dependencies: csstype: "npm:^3.2.2" - checksum: 10c0/a7b75f1f9fcb34badd6f84098be5e35a0aeca614bc91f93d2698664c0b2ba5ad128422bd470ada598238cebe4f9e604a752aead7dc6f5a92261d0c7f9b27cfd1 + checksum: 10c0/7d25bf41b57719452d86d2ac0570b659210402707313a36ee612666bf11275a1c69824f8c3ee1fdca077ccfe15452f6da8f1224529b917050eb2d861e52b59b7 languageName: node linkType: hard @@ -5850,22 +5921,22 @@ __metadata: linkType: hard "@typescript-eslint/eslint-plugin@npm:^8.31.0": - version: 8.51.0 - resolution: "@typescript-eslint/eslint-plugin@npm:8.51.0" + version: 8.56.1 + resolution: "@typescript-eslint/eslint-plugin@npm:8.56.1" dependencies: - "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:8.51.0" - "@typescript-eslint/type-utils": "npm:8.51.0" - "@typescript-eslint/utils": "npm:8.51.0" - "@typescript-eslint/visitor-keys": "npm:8.51.0" - ignore: "npm:^7.0.0" + "@eslint-community/regexpp": "npm:^4.12.2" + "@typescript-eslint/scope-manager": "npm:8.56.1" + "@typescript-eslint/type-utils": "npm:8.56.1" + "@typescript-eslint/utils": "npm:8.56.1" + "@typescript-eslint/visitor-keys": "npm:8.56.1" + ignore: "npm:^7.0.5" natural-compare: "npm:^1.4.0" - ts-api-utils: "npm:^2.2.0" + ts-api-utils: "npm:^2.4.0" peerDependencies: - "@typescript-eslint/parser": ^8.51.0 - eslint: ^8.57.0 || ^9.0.0 + "@typescript-eslint/parser": ^8.56.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/3140e66a0f722338d56bf3de2b7cbb9a74a812d8da90fc61975ea029f6a401252c0824063d4c4baab9827de6f0209b34f4bbdc46e3f5fefd8fa2ff4a3980406f + checksum: 10c0/8a97e777792ee3e25078884ba0a04f6732367779c9487abcdc5a2d65b224515fa6a0cf1fac1aafc52fb30f3af97f2e1c9949aadbd6ca74a0165691f95494a721 languageName: node linkType: hard @@ -5881,31 +5952,31 @@ __metadata: linkType: hard "@typescript-eslint/parser@npm:^8.31.0": - version: 8.51.0 - resolution: "@typescript-eslint/parser@npm:8.51.0" + version: 8.56.1 + resolution: "@typescript-eslint/parser@npm:8.56.1" dependencies: - "@typescript-eslint/scope-manager": "npm:8.51.0" - "@typescript-eslint/types": "npm:8.51.0" - "@typescript-eslint/typescript-estree": "npm:8.51.0" - "@typescript-eslint/visitor-keys": "npm:8.51.0" - debug: "npm:^4.3.4" + "@typescript-eslint/scope-manager": "npm:8.56.1" + "@typescript-eslint/types": "npm:8.56.1" + "@typescript-eslint/typescript-estree": "npm:8.56.1" + "@typescript-eslint/visitor-keys": "npm:8.56.1" + debug: "npm:^4.4.3" peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/b6aab1d82cc98a77aaae7637bf2934980104799793b3fd5b893065d930fe9b23cd6c2059d6f73fb454ea08f9e956e84fa940310d8435092a14be645a42062d94 + checksum: 10c0/61c9dab481e795b01835c00c9c7c845f1d7ea7faf3b8657fccee0f8658a65390cb5fe2b5230ae8c4241bd6e0c32aa9455a91989a492bd3bd6fec7c7d9339377a languageName: node linkType: hard -"@typescript-eslint/project-service@npm:8.51.0": - version: 8.51.0 - resolution: "@typescript-eslint/project-service@npm:8.51.0" +"@typescript-eslint/project-service@npm:8.56.1": + version: 8.56.1 + resolution: "@typescript-eslint/project-service@npm:8.56.1" dependencies: - "@typescript-eslint/tsconfig-utils": "npm:^8.51.0" - "@typescript-eslint/types": "npm:^8.51.0" - debug: "npm:^4.3.4" + "@typescript-eslint/tsconfig-utils": "npm:^8.56.1" + "@typescript-eslint/types": "npm:^8.56.1" + debug: "npm:^4.4.3" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/c6e6efbf79e126261e1742990b0872a34bbbe9931d99f0aabd12cb70a65a361e02d626db4b632dabee2b2c26b7e5b48344fc5a796c56438ae0788535e2bbe092 + checksum: 10c0/ca61cde575233bc79046d73ddd330d183fb3cbb941fddc31919336317cda39885c59296e2e5401b03d9325a64a629e842fd66865705ff0d85d83ee3ee40871e8 languageName: node linkType: hard @@ -5929,38 +6000,38 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.51.0": - version: 8.51.0 - resolution: "@typescript-eslint/scope-manager@npm:8.51.0" +"@typescript-eslint/scope-manager@npm:8.56.1": + version: 8.56.1 + resolution: "@typescript-eslint/scope-manager@npm:8.56.1" dependencies: - "@typescript-eslint/types": "npm:8.51.0" - "@typescript-eslint/visitor-keys": "npm:8.51.0" - checksum: 10c0/dd1e75fc13e6b1119954612d9e8ad3f2d91bc37dcde85fd00e959171aaf6c716c4c265c90c5accf24b5831bd3f48510b0775e5583085b8fa2ad5c37c8980ae1a + "@typescript-eslint/types": "npm:8.56.1" + "@typescript-eslint/visitor-keys": "npm:8.56.1" + checksum: 10c0/89cc1af2635eee23f2aa2ff87c08f88f3ad972ebf67eaacdc604a4ef4178535682bad73fd086e6f3c542e4e5d874253349af10d58291d079cc29c6c7e9831de4 languageName: node linkType: hard -"@typescript-eslint/tsconfig-utils@npm:8.51.0, @typescript-eslint/tsconfig-utils@npm:^8.51.0": - version: 8.51.0 - resolution: "@typescript-eslint/tsconfig-utils@npm:8.51.0" +"@typescript-eslint/tsconfig-utils@npm:8.56.1, @typescript-eslint/tsconfig-utils@npm:^8.56.1": + version: 8.56.1 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.56.1" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/46cab9a5342b4a8f8a1d05aaee4236c5262a540ad0bca1f0e8dad5d63ed1e634b88ce0c82a612976dab09861e21086fc995a368df0435ac43fb960e0b9e5cde2 + checksum: 10c0/d03b64d7ff19020beeefa493ae667c2e67a4547d25a3ecb9210a3a52afe980c093d772a91014bae699ee148bfb60cc659479e02bfc2946ea06954a8478ef1fe1 languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.51.0": - version: 8.51.0 - resolution: "@typescript-eslint/type-utils@npm:8.51.0" +"@typescript-eslint/type-utils@npm:8.56.1": + version: 8.56.1 + resolution: "@typescript-eslint/type-utils@npm:8.56.1" dependencies: - "@typescript-eslint/types": "npm:8.51.0" - "@typescript-eslint/typescript-estree": "npm:8.51.0" - "@typescript-eslint/utils": "npm:8.51.0" - debug: "npm:^4.3.4" - ts-api-utils: "npm:^2.2.0" + "@typescript-eslint/types": "npm:8.56.1" + "@typescript-eslint/typescript-estree": "npm:8.56.1" + "@typescript-eslint/utils": "npm:8.56.1" + debug: "npm:^4.4.3" + ts-api-utils: "npm:^2.4.0" peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/7c17214e54bc3a4fe4551d9251ffbac52e84ca46eeae840c0f981994b7cbcc837ef32a2b6d510b02d958a8f568df355e724d9c6938a206716271a1b0c00801b7 + checksum: 10c0/66517aed5059ef4a29605d06a510582f934d5789ae40ad673f1f0421f8aa13ec9ba7b8caab57ae9f270afacbf13ec5359cedfe74f21ae77e9a2364929f7e7cee languageName: node linkType: hard @@ -5978,10 +6049,10 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/types@npm:8.51.0, @typescript-eslint/types@npm:^8.46.0, @typescript-eslint/types@npm:^8.51.0": - version: 8.51.0 - resolution: "@typescript-eslint/types@npm:8.51.0" - checksum: 10c0/eb3473d0bb71eb886438f35887b620ffadae7853b281752a40c73158aee644d136adeb82549be7d7c30f346fe888b2e979dff7e30e67b35377e8281018034529 +"@typescript-eslint/types@npm:8.56.1, @typescript-eslint/types@npm:^8.46.4, @typescript-eslint/types@npm:^8.56.1": + version: 8.56.1 + resolution: "@typescript-eslint/types@npm:8.56.1" + checksum: 10c0/e5a0318abddf0c4f98da3039cb10b3c0601c8601f7a9f7043630f0d622dabfe83a4cd833545ad3531fc846e46ca2874377277b392c2490dffec279d9242d827b languageName: node linkType: hard @@ -6021,22 +6092,22 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.51.0": - version: 8.51.0 - resolution: "@typescript-eslint/typescript-estree@npm:8.51.0" +"@typescript-eslint/typescript-estree@npm:8.56.1": + version: 8.56.1 + resolution: "@typescript-eslint/typescript-estree@npm:8.56.1" dependencies: - "@typescript-eslint/project-service": "npm:8.51.0" - "@typescript-eslint/tsconfig-utils": "npm:8.51.0" - "@typescript-eslint/types": "npm:8.51.0" - "@typescript-eslint/visitor-keys": "npm:8.51.0" - debug: "npm:^4.3.4" - minimatch: "npm:^9.0.4" - semver: "npm:^7.6.0" + "@typescript-eslint/project-service": "npm:8.56.1" + "@typescript-eslint/tsconfig-utils": "npm:8.56.1" + "@typescript-eslint/types": "npm:8.56.1" + "@typescript-eslint/visitor-keys": "npm:8.56.1" + debug: "npm:^4.4.3" + minimatch: "npm:^10.2.2" + semver: "npm:^7.7.3" tinyglobby: "npm:^0.2.15" - ts-api-utils: "npm:^2.2.0" + ts-api-utils: "npm:^2.4.0" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/5386acc67298a6757681b6264c29a6b9304be7a188f11498bbaa82bb0a3095fd79394ad80d6520bdff3fa3093199f9a438246604ee3281b76f7ed574b7516854 + checksum: 10c0/92f4421dac41be289761200dc2ed85974fa451deacb09490ae1870a25b71b97218e609a90d4addba9ded5b2abdebc265c9db7f6e9ce6d29ed20e89b8487e9618 languageName: node linkType: hard @@ -6058,18 +6129,18 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.51.0": - version: 8.51.0 - resolution: "@typescript-eslint/utils@npm:8.51.0" +"@typescript-eslint/utils@npm:8.56.1": + version: 8.56.1 + resolution: "@typescript-eslint/utils@npm:8.56.1" dependencies: - "@eslint-community/eslint-utils": "npm:^4.7.0" - "@typescript-eslint/scope-manager": "npm:8.51.0" - "@typescript-eslint/types": "npm:8.51.0" - "@typescript-eslint/typescript-estree": "npm:8.51.0" + "@eslint-community/eslint-utils": "npm:^4.9.1" + "@typescript-eslint/scope-manager": "npm:8.56.1" + "@typescript-eslint/types": "npm:8.56.1" + "@typescript-eslint/typescript-estree": "npm:8.56.1" peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/ffb8237cfb33a1998ae2812b136d42fb65e7497f185d46097d19e43112e41b3ef59f901ba679c2e5372ad3007026f6e5add3a3de0f2e75ce6896918713fa38a8 + checksum: 10c0/d9ffd9b2944a2c425e0532f71dc61e61d0a923d1a17733cf2777c2a4ae638307d12d44f63b33b6b3dc62f02f47db93ec49344ecefe17b76ee3e4fb0833325be3 languageName: node linkType: hard @@ -6108,13 +6179,13 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.51.0": - version: 8.51.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.51.0" +"@typescript-eslint/visitor-keys@npm:8.56.1": + version: 8.56.1 + resolution: "@typescript-eslint/visitor-keys@npm:8.56.1" dependencies: - "@typescript-eslint/types": "npm:8.51.0" - eslint-visitor-keys: "npm:^4.2.1" - checksum: 10c0/fce5603961cf336e71095f7599157de65e3182f61cbd6cab33a43551ee91485b4e9bf6cacc1b275cf6f3503b92f8568fe2267a45c82e60e386ee73db727a26ca + "@typescript-eslint/types": "npm:8.56.1" + eslint-visitor-keys: "npm:^5.0.0" + checksum: 10c0/86d97905dec1af964cc177c185933d040449acf6006096497f2e0093c6a53eb92b3ac1db9eb40a5a2e8d91160f558c9734331a9280797f09f284c38978b22190 languageName: node linkType: hard @@ -6344,12 +6415,12 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.15.0": - version: 8.15.0 - resolution: "acorn@npm:8.15.0" +"acorn@npm:^8.16.0": + version: 8.16.0 + resolution: "acorn@npm:8.16.0" bin: acorn: bin/acorn - checksum: 10c0/dec73ff59b7d6628a01eebaece7f2bdb8bb62b9b5926dcad0f8931f2b8b79c2be21f6c68ac095592adb5adb15831a3635d9343e6a91d028bbe85d564875ec3ec + checksum: 10c0/c9c52697227661b68d0debaf972222d4f622aa06b185824164e153438afa7b08273432ca43ea792cadb24dada1d46f6f6bb1ef8de9956979288cc1b96bf9914e languageName: node linkType: hard @@ -6744,39 +6815,39 @@ __metadata: languageName: node linkType: hard -"babel-plugin-polyfill-corejs2@npm:^0.4.14": - version: 0.4.14 - resolution: "babel-plugin-polyfill-corejs2@npm:0.4.14" +"babel-plugin-polyfill-corejs2@npm:^0.4.15": + version: 0.4.15 + resolution: "babel-plugin-polyfill-corejs2@npm:0.4.15" dependencies: - "@babel/compat-data": "npm:^7.27.7" - "@babel/helper-define-polyfill-provider": "npm:^0.6.5" + "@babel/compat-data": "npm:^7.28.6" + "@babel/helper-define-polyfill-provider": "npm:^0.6.6" semver: "npm:^6.3.1" peerDependencies: "@babel/core": ^7.4.0 || ^8.0.0-0 <8.0.0 - checksum: 10c0/d74cba0600a6508e86d220bde7164eb528755d91be58020e5ea92ea7fbb12c9d8d2c29246525485adfe7f68ae02618ec428f9a589cac6cbedf53cc3972ad7fbe + checksum: 10c0/5e3ff853a5056bdc0816320523057b45d52c9ea01c847fd07886a4202b0c1324dc97eda4b777c98387927ff02d913fedbe9ba9943c0d4030714048e0b9e61682 languageName: node linkType: hard -"babel-plugin-polyfill-corejs3@npm:^0.13.0": - version: 0.13.0 - resolution: "babel-plugin-polyfill-corejs3@npm:0.13.0" +"babel-plugin-polyfill-corejs3@npm:^0.14.0": + version: 0.14.0 + resolution: "babel-plugin-polyfill-corejs3@npm:0.14.0" dependencies: - "@babel/helper-define-polyfill-provider": "npm:^0.6.5" - core-js-compat: "npm:^3.43.0" + "@babel/helper-define-polyfill-provider": "npm:^0.6.6" + core-js-compat: "npm:^3.48.0" peerDependencies: "@babel/core": ^7.4.0 || ^8.0.0-0 <8.0.0 - checksum: 10c0/5d8e228da425edc040d8c868486fd01ba10b0440f841156a30d9f8986f330f723e2ee61553c180929519563ef5b64acce2caac36a5a847f095d708dda5d8206d + checksum: 10c0/db7f530752a2bcb891c0dc80c3d025a48d49c78d41b0ad91cc853669460cd9e3107857a3667f645f0e25c2af9fc3d1e38d5b1c4e3e60aa22e7df9d68550712a4 languageName: node linkType: hard -"babel-plugin-polyfill-regenerator@npm:^0.6.5": - version: 0.6.5 - resolution: "babel-plugin-polyfill-regenerator@npm:0.6.5" +"babel-plugin-polyfill-regenerator@npm:^0.6.6": + version: 0.6.6 + resolution: "babel-plugin-polyfill-regenerator@npm:0.6.6" dependencies: - "@babel/helper-define-polyfill-provider": "npm:^0.6.5" + "@babel/helper-define-polyfill-provider": "npm:^0.6.6" peerDependencies: "@babel/core": ^7.4.0 || ^8.0.0-0 <8.0.0 - checksum: 10c0/63aa8ed716df6a9277c6ab42b887858fa9f57a70cc1d0ae2b91bdf081e45d4502848cba306fb60b02f59f99b32fd02ff4753b373cac48ccdac9b7d19dd56f06d + checksum: 10c0/0ef91d8361c118e7b16d8592c053707325b8168638ea4636b76530c8bc6a1b5aac5c6ca5140e8f3fcdb634a7a2e636133e6b9ef70a75e6417a258a7fddc04bd7 languageName: node linkType: hard @@ -6797,6 +6868,13 @@ __metadata: languageName: node linkType: hard +"balanced-match@npm:^4.0.2": + version: 4.0.4 + resolution: "balanced-match@npm:4.0.4" + checksum: 10c0/07e86102a3eb2ee2a6a1a89164f29d0dbaebd28f2ca3f5ca786f36b8b23d9e417eb3be45a4acf754f837be5ac0a2317de90d3fcb7f4f4dc95720a1f36b26a17b + languageName: node + linkType: hard + "bare-events@npm:^2.2.0": version: 2.5.4 resolution: "bare-events@npm:2.5.4" @@ -6903,6 +6981,15 @@ __metadata: languageName: node linkType: hard +"brace-expansion@npm:^5.0.2": + version: 5.0.3 + resolution: "brace-expansion@npm:5.0.3" + dependencies: + balanced-match: "npm:^4.0.2" + checksum: 10c0/e474d300e581ec56851b3863ff1cf18573170c6d06deb199ccbd03b2119c36975f6ce2abc7b770f5bebddc1ab022661a9fea9b4d56f33315d7bef54d8793869e + languageName: node + linkType: hard + "braces@npm:^3.0.3, braces@npm:~3.0.2": version: 3.0.3 resolution: "braces@npm:3.0.3" @@ -7056,20 +7143,6 @@ __metadata: languageName: node linkType: hard -"browserslist@npm:^4.25.1": - version: 4.25.1 - resolution: "browserslist@npm:4.25.1" - dependencies: - caniuse-lite: "npm:^1.0.30001726" - electron-to-chromium: "npm:^1.5.173" - node-releases: "npm:^2.0.19" - update-browserslist-db: "npm:^1.1.3" - bin: - browserslist: cli.js - checksum: 10c0/acba5f0bdbd5e72dafae1e6ec79235b7bad305ed104e082ed07c34c38c7cb8ea1bc0f6be1496958c40482e40166084458fc3aee15111f15faa79212ad9081b2a - languageName: node - linkType: hard - "browserslist@npm:^4.28.1": version: 4.28.1 resolution: "browserslist@npm:4.28.1" @@ -7259,7 +7332,7 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.30001688, caniuse-lite@npm:^1.0.30001726": +"caniuse-lite@npm:^1.0.30001688": version: 1.0.30001757 resolution: "caniuse-lite@npm:1.0.30001757" checksum: 10c0/3ccb71fa2bf1f8c96ff1bf9b918b08806fed33307e20a3ce3259155fda131eaf96cfcd88d3d309c8fd7f8285cc71d89a3b93648a1c04814da31c301f98508d42 @@ -7637,12 +7710,12 @@ __metadata: languageName: node linkType: hard -"core-js-compat@npm:^3.43.0": - version: 3.44.0 - resolution: "core-js-compat@npm:3.44.0" +"core-js-compat@npm:^3.48.0": + version: 3.48.0 + resolution: "core-js-compat@npm:3.48.0" dependencies: - browserslist: "npm:^4.25.1" - checksum: 10c0/5de4b042b8bb232b8390be3079030de5c7354610f136ed3eb91310a44455a78df02cfcf49b2fd05d5a5aa2695460620abf1b400784715f7482ed4770d40a68b2 + browserslist: "npm:^4.28.1" + checksum: 10c0/7bb6522127928fff5d56c7050f379a034de85fe2d5c6e6925308090d4b51fb0cb88e0db99619c932ee84d8756d531bf851232948fe1ad18598cb1e7278e8db13 languageName: node linkType: hard @@ -8006,6 +8079,13 @@ __metadata: languageName: node linkType: hard +"decimal.js@npm:^10.6.0": + version: 10.6.0 + resolution: "decimal.js@npm:10.6.0" + checksum: 10c0/07d69fbcc54167a340d2d97de95f546f9ff1f69d2b45a02fd7a5292412df3cd9eb7e23065e532a318f5474a2e1bccf8392fdf0443ef467f97f3bf8cb0477e5aa + languageName: node + linkType: hard + "deep-eql@npm:^5.0.1": version: 5.0.2 resolution: "deep-eql@npm:5.0.2" @@ -8270,13 +8350,6 @@ __metadata: languageName: node linkType: hard -"electron-to-chromium@npm:^1.5.173": - version: 1.5.178 - resolution: "electron-to-chromium@npm:1.5.178" - checksum: 10c0/2734c8ee211fb6c5b4ac55d5797cbf9882a37515c3f9403427b8a97d75413f9e08786d1f5d7aa7dfd433bd53b0ae97fb186bcdd5bb137978eb0fa6a436f07de4 - languageName: node - linkType: hard - "electron-to-chromium@npm:^1.5.263": version: 1.5.267 resolution: "electron-to-chromium@npm:1.5.267" @@ -8302,12 +8375,12 @@ __metadata: "@codecov/vite-plugin": "npm:^1.3.0" "@fontsource/inconsolata": "npm:^5.1.0" "@fontsource/inter": "npm:^5.1.0" - "@formatjs/intl-durationformat": "npm:^0.9.0" + "@formatjs/intl-durationformat": "npm:^0.10.0" "@formatjs/intl-segmenter": "npm:^11.7.3" "@livekit/components-core": "npm:^0.12.0" "@livekit/components-react": "npm:^2.0.0" "@livekit/protocol": "npm:^1.42.2" - "@livekit/track-processors": "npm:^0.6.0 || ^0.7.1" + "@livekit/track-processors": "npm:^0.6.0 || ^0.7.1 || ^0.7.0" "@mediapipe/tasks-vision": "npm:^0.10.18" "@playwright/test": "npm:^1.57.0" "@radix-ui/react-dialog": "npm:^1.0.4" @@ -8377,7 +8450,7 @@ __metadata: qrcode: "npm:^1.5.4" react: "npm:19" react-dom: "npm:19" - react-i18next: "npm:^16.0.0 <16.1.0" + react-i18next: "npm:^16.0.0 <16.6.0" react-router-dom: "npm:^7.0.0" react-use-measure: "npm:^2.1.1" rxjs: "npm:^7.8.1" @@ -9013,17 +9086,17 @@ __metadata: linkType: hard "eslint-plugin-jsdoc@npm:^61.5.0": - version: 61.5.0 - resolution: "eslint-plugin-jsdoc@npm:61.5.0" + version: 61.7.1 + resolution: "eslint-plugin-jsdoc@npm:61.7.1" dependencies: - "@es-joy/jsdoccomment": "npm:~0.76.0" + "@es-joy/jsdoccomment": "npm:~0.78.0" "@es-joy/resolve.exports": "npm:1.2.0" are-docs-informative: "npm:^0.0.2" comment-parser: "npm:1.4.1" debug: "npm:^4.4.3" escape-string-regexp: "npm:^4.0.0" - espree: "npm:^10.4.0" - esquery: "npm:^1.6.0" + espree: "npm:^11.0.0" + esquery: "npm:^1.7.0" html-entities: "npm:^2.6.0" object-deep-merge: "npm:^2.0.0" parse-imports-exports: "npm:^0.2.4" @@ -9032,7 +9105,7 @@ __metadata: to-valid-identifier: "npm:^1.0.0" peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 - checksum: 10c0/fabb04f6efe58a167a0839d3c05676a76080c6e91d98a269fa768c1bfd835aa0ded5822d400da2874216177044d2d227ebe241d73e923f3fe1c08bafd19cfd3d + checksum: 10c0/d0904b923f68a4e9e6da156316a4e2a972445bf79118bde9618ad80b4ef5927fc2c9dd597b22b776742ef548d65914e75fca190ab3be942385f268a3b83c1087 languageName: node linkType: hard @@ -9204,10 +9277,10 @@ __metadata: languageName: node linkType: hard -"eslint-visitor-keys@npm:^4.2.1": - version: 4.2.1 - resolution: "eslint-visitor-keys@npm:4.2.1" - checksum: 10c0/fcd43999199d6740db26c58dbe0c2594623e31ca307e616ac05153c9272f12f1364f5a0b1917a8e962268fdecc6f3622c1c2908b4fcc2e047a106fe6de69dc43 +"eslint-visitor-keys@npm:^5.0.0, eslint-visitor-keys@npm:^5.0.1": + version: 5.0.1 + resolution: "eslint-visitor-keys@npm:5.0.1" + checksum: 10c0/16190bdf2cbae40a1109384c94450c526a79b0b9c3cb21e544256ed85ac48a4b84db66b74a6561d20fe6ab77447f150d711c2ad5ad74df4fcc133736bce99678 languageName: node linkType: hard @@ -9270,14 +9343,14 @@ __metadata: languageName: node linkType: hard -"espree@npm:^10.4.0": - version: 10.4.0 - resolution: "espree@npm:10.4.0" +"espree@npm:^11.0.0": + version: 11.1.1 + resolution: "espree@npm:11.1.1" dependencies: - acorn: "npm:^8.15.0" + acorn: "npm:^8.16.0" acorn-jsx: "npm:^5.3.2" - eslint-visitor-keys: "npm:^4.2.1" - checksum: 10c0/c63fe06131c26c8157b4083313cb02a9a54720a08e21543300e55288c40e06c3fc284bdecf108d3a1372c5934a0a88644c98714f38b6ae8ed272b40d9ea08d6b + eslint-visitor-keys: "npm:^5.0.1" + checksum: 10c0/2feae74efdfb037b9e9fcb30506799845cf20900de5e441ed03e5c51aaa249f85ea5818ff177682acc0c9bfb4ac97e1965c238ee44ac7c305aab8747177bab69 languageName: node linkType: hard @@ -9301,6 +9374,15 @@ __metadata: languageName: node linkType: hard +"esquery@npm:^1.7.0": + version: 1.7.0 + resolution: "esquery@npm:1.7.0" + dependencies: + estraverse: "npm:^5.1.0" + checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 + languageName: node + linkType: hard + "esrecurse@npm:^4.3.0": version: 4.3.0 resolution: "esrecurse@npm:4.3.0" @@ -10271,11 +10353,11 @@ __metadata: linkType: hard "i18next-browser-languagedetector@npm:^8.0.0": - version: 8.2.0 - resolution: "i18next-browser-languagedetector@npm:8.2.0" + version: 8.2.1 + resolution: "i18next-browser-languagedetector@npm:8.2.1" dependencies: "@babel/runtime": "npm:^7.23.2" - checksum: 10c0/4fcb6ec316e0fd4a10eee67a8d1e3d7e1407f14d5bed98978c50ed6f1853f5d559dc18ea7fd4b2de445ac0a4ed44df5b38f0b31b89b9ac883f99050d59ffec82 + checksum: 10c0/d200847a79b4cb2764ef59b33e5399085d4d56b2b038e884bb54fffe17953b467899142a6ef6e985592234f10049d14d06b80f2c56441ae80648c5a6717704f3 languageName: node linkType: hard @@ -10321,8 +10403,8 @@ __metadata: linkType: hard "i18next@npm:^25.0.0": - version: 25.7.3 - resolution: "i18next@npm:25.7.3" + version: 25.8.13 + resolution: "i18next@npm:25.8.13" dependencies: "@babel/runtime": "npm:^7.28.4" peerDependencies: @@ -10330,7 +10412,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10c0/0b10452c26d6526bbfa8f0fb76241f5e17f0dc08d9b9cc9810bc3103047a3656ec6482b170a86a408a49178af5683a4b88b43986580c5f95f497d4afc9719088 + checksum: 10c0/12c661c2b58fe70445f8491b72f937eef28a5f9413f76bd178bbca92d4378d8436003c3bea1d5d760b8a69f809cbcef2ce389beffd9bc0434651134c6b37fecc languageName: node linkType: hard @@ -10357,10 +10439,10 @@ __metadata: languageName: node linkType: hard -"ignore@npm:^7.0.0": - version: 7.0.4 - resolution: "ignore@npm:7.0.4" - checksum: 10c0/90e1f69ce352b9555caecd9cbfd07abe7626d312a6f90efbbb52c7edca6ea8df065d66303863b30154ab1502afb2da8bc59d5b04e1719a52ef75bbf675c488eb +"ignore@npm:^7.0.5": + version: 7.0.5 + resolution: "ignore@npm:7.0.5" + checksum: 10c0/ae00db89fe873064a093b8999fe4cc284b13ef2a178636211842cceb650b9c3e390d3339191acb145d81ed5379d2074840cf0c33a20bdbd6f32821f79eb4ad5d languageName: node linkType: hard @@ -10517,7 +10599,7 @@ __metadata: languageName: node linkType: hard -"is-core-module@npm:^2.13.0, is-core-module@npm:^2.16.0, is-core-module@npm:^2.16.1": +"is-core-module@npm:^2.13.0, is-core-module@npm:^2.16.1": version: 2.16.1 resolution: "is-core-module@npm:2.16.1" dependencies: @@ -10975,10 +11057,10 @@ __metadata: languageName: node linkType: hard -"jsdoc-type-pratt-parser@npm:~6.10.0": - version: 6.10.0 - resolution: "jsdoc-type-pratt-parser@npm:6.10.0" - checksum: 10c0/8ea395df0cae0e41d4bdba5f8d81b8d3e467fe53d1e4182a5d4e653235a5f17d60ed137343d68dbc74fa10e767f1c58fb85b1f6d5489c2cf16fc7216cc6d3e1a +"jsdoc-type-pratt-parser@npm:~7.0.0": + version: 7.0.0 + resolution: "jsdoc-type-pratt-parser@npm:7.0.0" + checksum: 10c0/3ede53c80dddf940a51dcdc79e3923537650f6fb6e9001fc76023c2d5cb0195cc8b24b7eebf9b3f20a7bc00d5e6b7f70318f0b8cb5972f6aff884152e6698014 languageName: node linkType: hard @@ -11015,7 +11097,7 @@ __metadata: languageName: node linkType: hard -"jsesc@npm:^3.0.2": +"jsesc@npm:^3.0.2, jsesc@npm:~3.1.0": version: 3.1.0 resolution: "jsesc@npm:3.1.0" bin: @@ -11151,8 +11233,8 @@ __metadata: linkType: hard "knip@npm:^5.27.2": - version: 5.79.0 - resolution: "knip@npm:5.79.0" + version: 5.85.0 + resolution: "knip@npm:5.85.0" dependencies: "@nodelib/fs.walk": "npm:^1.2.3" fast-glob: "npm:^3.3.3" @@ -11172,7 +11254,7 @@ __metadata: bin: knip: bin/knip.js knip-bun: bin/knip-bun.js - checksum: 10c0/dc3599247763912c0602621b83d125cba4e111d85ec5f01f9b65808a0091a60d7be85ed6cecc93d0afb39b895127231f7a68dd4c4bb7e210dd727b6ef9c1571d + checksum: 10c0/d1eb6c85bb24cd357cedf67d02f9697261b70721a59eed48bd0be716e99b86ceb3e55ee9e6cfef60110272303e2de8dc6ea0621936f9e209490eaf5c24b2697f languageName: node linkType: hard @@ -11561,6 +11643,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^10.2.2": + version: 10.2.3 + resolution: "minimatch@npm:10.2.3" + dependencies: + brace-expansion: "npm:^5.0.2" + checksum: 10c0/d9ae5f355e8bb77a42dd8c20b950141cec8773ef8716a2bb6df7a6840cc44a00ed828883884e4f1c7b5cb505fa06a17e3ea9ca2edb18fd1dec865ea7f9fcf0e5 + languageName: node + linkType: hard + "minimatch@npm:^3.0.2, minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" @@ -12460,27 +12551,27 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.57.0": - version: 1.57.0 - resolution: "playwright-core@npm:1.57.0" +"playwright-core@npm:1.58.2": + version: 1.58.2 + resolution: "playwright-core@npm:1.58.2" bin: playwright-core: cli.js - checksum: 10c0/798e35d83bf48419a8c73de20bb94d68be5dde68de23f95d80a0ebe401e3b83e29e3e84aea7894d67fa6c79d2d3d40cc5bcde3e166f657ce50987aaa2421b6a9 + checksum: 10c0/5aa15b2b764e6ffe738293a09081a6f7023847a0dbf4cd05fe10eed2e25450d321baf7482f938f2d2eb330291e197fa23e57b29a5b552b89927ceb791266225b languageName: node linkType: hard -"playwright@npm:1.57.0": - version: 1.57.0 - resolution: "playwright@npm:1.57.0" +"playwright@npm:1.58.2": + version: 1.58.2 + resolution: "playwright@npm:1.58.2" dependencies: fsevents: "npm:2.3.2" - playwright-core: "npm:1.57.0" + playwright-core: "npm:1.58.2" dependenciesMeta: fsevents: optional: true bin: playwright: cli.js - checksum: 10c0/ab03c99a67b835bdea9059f516ad3b6e42c21025f9adaa161a4ef6bc7ca716dcba476d287140bb240d06126eb23f889a8933b8f5f1f1a56b80659d92d1358899 + checksum: 10c0/d060d9b7cc124bd8b5dffebaab5e84f6b34654a553758fe7b19cc598dfbee93f6ecfbdc1832b40a6380ae04eade86ef3285ba03aa0b136799e83402246dc0727 languageName: node linkType: hard @@ -12765,8 +12856,8 @@ __metadata: linkType: hard "postcss-preset-env@npm:^10.0.0": - version: 10.6.0 - resolution: "postcss-preset-env@npm:10.6.0" + version: 10.6.1 + resolution: "postcss-preset-env@npm:10.6.1" dependencies: "@csstools/postcss-alpha-function": "npm:^1.0.1" "@csstools/postcss-cascade-layers": "npm:^5.0.2" @@ -12793,7 +12884,7 @@ __metadata: "@csstools/postcss-media-minmax": "npm:^2.0.9" "@csstools/postcss-media-queries-aspect-ratio-number-values": "npm:^3.0.5" "@csstools/postcss-nested-calc": "npm:^4.0.0" - "@csstools/postcss-normalize-display-values": "npm:^4.0.0" + "@csstools/postcss-normalize-display-values": "npm:^4.0.1" "@csstools/postcss-oklab-function": "npm:^4.0.12" "@csstools/postcss-position-area-property": "npm:^1.0.0" "@csstools/postcss-progressive-custom-properties": "npm:^4.2.1" @@ -12841,7 +12932,7 @@ __metadata: postcss-selector-not: "npm:^8.0.1" peerDependencies: postcss: ^8.4 - checksum: 10c0/61162c9d675004db842d58829605c3c9ee81ed1a15684793a419b94c2c28e3be2ff9a7373f0996a1a255caf208d8f3d5dd907e61af1bbb0c7634e3215e87fc56 + checksum: 10c0/e8da96f208918ebc0dc9acc8ba8961a92569f1d130b29abe25adaf7dbd56ef29fc6f778b75964c80fe7f3469012c763ea9447e5c2f559a002a155bc0462cce35 languageName: node linkType: hard @@ -12930,11 +13021,11 @@ __metadata: linkType: hard "prettier@npm:^3.0.0": - version: 3.7.4 - resolution: "prettier@npm:3.7.4" + version: 3.8.1 + resolution: "prettier@npm:3.8.1" bin: prettier: bin/prettier.cjs - checksum: 10c0/9675d2cd08eacb1faf1d1a2dbfe24bfab6a912b059fc9defdb380a408893d88213e794a40a2700bd29b140eb3172e0b07c852853f6e22f16f3374659a1a13389 + checksum: 10c0/33169b594009e48f570471271be7eac7cdcf88a209eed39ac3b8d6d78984039bfa9132f82b7e6ba3b06711f3bfe0222a62a1bfb87c43f50c25a83df1b78a2c42 languageName: node linkType: hard @@ -13117,24 +13208,25 @@ __metadata: linkType: hard "react-dom@npm:19": - version: 19.2.3 - resolution: "react-dom@npm:19.2.3" + version: 19.2.4 + resolution: "react-dom@npm:19.2.4" dependencies: scheduler: "npm:^0.27.0" peerDependencies: - react: ^19.2.3 - checksum: 10c0/dc43f7ede06f46f3acc16ee83107c925530de9b91d1d0b3824583814746ff4c498ea64fd65cd83aba363205268adff52e2827c582634ae7b15069deaeabc4892 + react: ^19.2.4 + checksum: 10c0/f0c63f1794dedb154136d4d0f59af00b41907f4859571c155940296808f4b94bf9c0c20633db75b5b2112ec13d8d7dd4f9bf57362ed48782f317b11d05a44f35 languageName: node linkType: hard -"react-i18next@npm:^16.0.0 <16.1.0": - version: 16.0.1 - resolution: "react-i18next@npm:16.0.1" +"react-i18next@npm:^16.0.0 <16.6.0": + version: 16.5.4 + resolution: "react-i18next@npm:16.5.4" dependencies: - "@babel/runtime": "npm:^7.27.6" + "@babel/runtime": "npm:^7.28.4" html-parse-stringify: "npm:^3.0.1" + use-sync-external-store: "npm:^1.6.0" peerDependencies: - i18next: ">= 25.5.2" + i18next: ">= 25.6.2" react: ">= 16.8.0" typescript: ^5 peerDependenciesMeta: @@ -13144,7 +13236,7 @@ __metadata: optional: true typescript: optional: true - checksum: 10c0/8fcd8dea9bd083aac37acf569478872980216d2f2d85eff18444316a407832f1d3f589d1d0e587c6fe709d43423f5ec82c2a96f52cc2999ddd2180fa3de20ace + checksum: 10c0/41d0b76873addfa3abe0c6b8a10a796e01f205f3636bc2d090d0078b42222f2949c4303f18d7a80cc26cf1298918cb6220d96e39ae2b8644abfdbec3bb504b37 languageName: node linkType: hard @@ -13224,20 +13316,20 @@ __metadata: linkType: hard "react-router-dom@npm:^7.0.0": - version: 7.11.0 - resolution: "react-router-dom@npm:7.11.0" + version: 7.13.1 + resolution: "react-router-dom@npm:7.13.1" dependencies: - react-router: "npm:7.11.0" + react-router: "npm:7.13.1" peerDependencies: react: ">=18" react-dom: ">=18" - checksum: 10c0/0e8061fe0ef7915cc411dd92f5f41109f6343b6abef36571b08ff231365bf61f52364bea128d1c964e9b8eb19426c9bd21923df0b3e1bb993d21bd2b7440fb49 + checksum: 10c0/2b8ed9dc753f1f7be599a53a00900df04e2b4d1186b0a4d63004eebb2250cd78cd6837ff15fcada5f88d53ad127fff0d1de31468715dcd6dd79dad8cfa8414e9 languageName: node linkType: hard -"react-router@npm:7.11.0": - version: 7.11.0 - resolution: "react-router@npm:7.11.0" +"react-router@npm:7.13.1": + version: 7.13.1 + resolution: "react-router@npm:7.13.1" dependencies: cookie: "npm:^1.0.1" set-cookie-parser: "npm:^2.6.0" @@ -13247,7 +13339,7 @@ __metadata: peerDependenciesMeta: react-dom: optional: true - checksum: 10c0/eb3693d63d1c52221a3449de5db170e2fa9e00536b011998b17f8a277f8b5e89b752d104dbbeb4ee3d474f8e4570167db00293b4510f63277e5e6658c5dab22b + checksum: 10c0/a64c645cede74251f21483fbfad740b36dc5133522d6f53f12317a873a22865fce659d4c2377d5e19c912f85c7b12b88224a2c70d8f70c082496b569cc4abc31 languageName: node linkType: hard @@ -13281,9 +13373,9 @@ __metadata: linkType: hard "react@npm:19": - version: 19.2.3 - resolution: "react@npm:19.2.3" - checksum: 10c0/094220b3ba3a76c1b668f972ace1dd15509b157aead1b40391d1c8e657e720c201d9719537375eff08f5e0514748c0319063392a6f000e31303aafc4471f1436 + version: 19.2.4 + resolution: "react@npm:19.2.4" + checksum: 10c0/cd2c9ff67a720799cc3b38a516009986f7fc4cb8d3e15716c6211cf098d1357ee3e348ab05ad0600042bbb0fd888530ba92e329198c92eafa0994f5213396596 languageName: node linkType: hard @@ -13387,6 +13479,15 @@ __metadata: languageName: node linkType: hard +"regenerate-unicode-properties@npm:^10.2.2": + version: 10.2.2 + resolution: "regenerate-unicode-properties@npm:10.2.2" + dependencies: + regenerate: "npm:^1.4.2" + checksum: 10c0/66a1d6a1dbacdfc49afd88f20b2319a4c33cee56d245163e4d8f5f283e0f45d1085a78f7f7406dd19ea3a5dd7a7799cd020cd817c97464a7507f9d10fbdce87c + languageName: node + linkType: hard + "regenerate@npm:^1.4.2": version: 1.4.2 resolution: "regenerate@npm:1.4.2" @@ -13452,6 +13553,20 @@ __metadata: languageName: node linkType: hard +"regexpu-core@npm:^6.3.1": + version: 6.4.0 + resolution: "regexpu-core@npm:6.4.0" + dependencies: + regenerate: "npm:^1.4.2" + regenerate-unicode-properties: "npm:^10.2.2" + regjsgen: "npm:^0.8.0" + regjsparser: "npm:^0.13.0" + unicode-match-property-ecmascript: "npm:^2.0.0" + unicode-match-property-value-ecmascript: "npm:^2.2.1" + checksum: 10c0/1eed9783c023dd06fb1f3ce4b6e3fdf0bc1e30cb036f30aeb2019b351e5e0b74355b40462282ea5db092c79a79331c374c7e9897e44a5ca4509e9f0b570263de + languageName: node + linkType: hard + "regjsgen@npm:^0.8.0": version: 0.8.0 resolution: "regjsgen@npm:0.8.0" @@ -13481,6 +13596,17 @@ __metadata: languageName: node linkType: hard +"regjsparser@npm:^0.13.0": + version: 0.13.0 + resolution: "regjsparser@npm:0.13.0" + dependencies: + jsesc: "npm:~3.1.0" + bin: + regjsparser: bin/parser + checksum: 10c0/4702f85cda09f67747c1b2fb673a0f0e5d1ba39d55f177632265a0be471ba59e3f320623f411649141f752b126b8126eac3ff4c62d317921e430b0472bfc6071 + languageName: node + linkType: hard + "relateurl@npm:^0.2.7": version: 0.2.7 resolution: "relateurl@npm:0.2.7" @@ -13559,7 +13685,7 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.17.0": +"resolve@npm:^1.17.0, resolve@npm:^1.22.11": version: 1.22.11 resolution: "resolve@npm:1.22.11" dependencies: @@ -13572,19 +13698,6 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.22.10": - version: 1.22.10 - resolution: "resolve@npm:1.22.10" - dependencies: - is-core-module: "npm:^2.16.0" - path-parse: "npm:^1.0.7" - supports-preserve-symlinks-flag: "npm:^1.0.0" - bin: - resolve: bin/resolve - checksum: 10c0/8967e1f4e2cc40f79b7e080b4582b9a8c5ee36ffb46041dccb20e6461161adf69f843b43067b4a375de926a2cd669157e29a29578191def399dd5ef89a1b5203 - languageName: node - linkType: hard - "resolve@npm:^2.0.0-next.5": version: 2.0.0-next.5 resolution: "resolve@npm:2.0.0-next.5" @@ -13611,7 +13724,7 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@npm%3A^1.17.0#optional!builtin": +"resolve@patch:resolve@npm%3A^1.17.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.11#optional!builtin": version: 1.22.11 resolution: "resolve@patch:resolve@npm%3A1.22.11#optional!builtin::version=1.22.11&hash=c3c19d" dependencies: @@ -13624,19 +13737,6 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@npm%3A^1.22.10#optional!builtin": - version: 1.22.10 - resolution: "resolve@patch:resolve@npm%3A1.22.10#optional!builtin::version=1.22.10&hash=c3c19d" - dependencies: - is-core-module: "npm:^2.16.0" - path-parse: "npm:^1.0.7" - supports-preserve-symlinks-flag: "npm:^1.0.0" - bin: - resolve: bin/resolve - checksum: 10c0/52a4e505bbfc7925ac8f4cd91fd8c4e096b6a89728b9f46861d3b405ac9a1ccf4dcbf8befb4e89a2e11370dacd0160918163885cbc669369590f2f31f4c58939 - languageName: node - linkType: hard - "resolve@patch:resolve@npm%3A^2.0.0-next.5#optional!builtin": version: 2.0.0-next.5 resolution: "resolve@patch:resolve@npm%3A2.0.0-next.5#optional!builtin::version=2.0.0-next.5&hash=c3c19d" @@ -13897,8 +13997,8 @@ __metadata: linkType: hard "sass@npm:^1.42.1": - version: 1.97.1 - resolution: "sass@npm:1.97.1" + version: 1.97.3 + resolution: "sass@npm:1.97.3" dependencies: "@parcel/watcher": "npm:^2.4.1" chokidar: "npm:^4.0.0" @@ -13909,7 +14009,7 @@ __metadata: optional: true bin: sass: sass.js - checksum: 10c0/c389d5d6405869b49fa2291e8328500fe7936f3b72136bc2c338bee6e7fec936bb9a48d77a1310dea66aa4669ba74ae6b82a112eb32521b9b36d740138a39ea0 + checksum: 10c0/67f6b5d220f20c1c23a8b16dda5fd1c5d119ad5caf8195b185d553b5b239fb188a3787f04fc00171c62515f2c4e5e0eb5ad4992a80f8543428556883c1240ba3 languageName: node linkType: hard @@ -14844,7 +14944,7 @@ __metadata: languageName: node linkType: hard -"ts-api-utils@npm:^2.2.0": +"ts-api-utils@npm:^2.4.0": version: 2.4.0 resolution: "ts-api-utils@npm:2.4.0" peerDependencies: @@ -14872,7 +14972,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:2.8.1, tslib@npm:^2.0.0, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.8.0": +"tslib@npm:2.8.1, tslib@npm:^2.0.0, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.8.0, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -15033,7 +15133,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.0.4": +"typescript@npm:^5.0.4, typescript@npm:^5.8.3": version: 5.9.3 resolution: "typescript@npm:5.9.3" bin: @@ -15043,17 +15143,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.8.3": - version: 5.8.3 - resolution: "typescript@npm:5.8.3" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/5f8bb01196e542e64d44db3d16ee0e4063ce4f3e3966df6005f2588e86d91c03e1fb131c2581baf0fb65ee79669eea6e161cd448178986587e9f6844446dbb48 - languageName: node - linkType: hard - -"typescript@patch:typescript@npm%3A^5.0.4#optional!builtin": +"typescript@patch:typescript@npm%3A^5.0.4#optional!builtin, typescript@patch:typescript@npm%3A^5.8.3#optional!builtin": version: 5.9.3 resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" bin: @@ -15063,16 +15153,6 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.8.3#optional!builtin": - version: 5.8.3 - resolution: "typescript@patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/39117e346ff8ebd87ae1510b3a77d5d92dae5a89bde588c747d25da5c146603a99c8ee588c7ef80faaf123d89ed46f6dbd918d534d641083177d5fac38b8a1cb - languageName: node - linkType: hard - "unbox-primitive@npm:^1.1.0": version: 1.1.0 resolution: "unbox-primitive@npm:1.1.0" @@ -15102,6 +15182,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.18.0": + version: 7.18.2 + resolution: "undici-types@npm:7.18.2" + checksum: 10c0/85a79189113a238959d7a647368e4f7c5559c3a404ebdb8fc4488145ce9426fcd82252a844a302798dfc0e37e6fb178ff481ed03bc4caf634c5757d9ef43521d + languageName: node + linkType: hard + "undici@npm:^5.25.4": version: 5.29.0 resolution: "undici@npm:5.29.0" @@ -15149,6 +15236,13 @@ __metadata: languageName: node linkType: hard +"unicode-match-property-value-ecmascript@npm:^2.2.1": + version: 2.2.1 + resolution: "unicode-match-property-value-ecmascript@npm:2.2.1" + checksum: 10c0/93acd1ad9496b600e5379d1aaca154cf551c5d6d4a0aefaf0984fc2e6288e99220adbeb82c935cde461457fb6af0264a1774b8dfd4d9a9e31548df3352a4194d + languageName: node + linkType: hard + "unicode-property-aliases-ecmascript@npm:^2.0.0": version: 2.1.0 resolution: "unicode-property-aliases-ecmascript@npm:2.1.0" @@ -15224,7 +15318,7 @@ __metadata: languageName: node linkType: hard -"update-browserslist-db@npm:^1.1.1, update-browserslist-db@npm:^1.1.3": +"update-browserslist-db@npm:^1.1.1": version: 1.1.3 resolution: "update-browserslist-db@npm:1.1.3" dependencies: @@ -15302,6 +15396,15 @@ __metadata: languageName: node linkType: hard +"use-sync-external-store@npm:^1.6.0": + version: 1.6.0 + resolution: "use-sync-external-store@npm:1.6.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/35e1179f872a53227bdf8a827f7911da4c37c0f4091c29b76b1e32473d1670ebe7bcd880b808b7549ba9a5605c233350f800ffab963ee4a4ee346ee983b6019b + languageName: node + linkType: hard + "usehooks-ts@npm:3.1.1": version: 3.1.1 resolution: "usehooks-ts@npm:3.1.1" @@ -15505,8 +15608,8 @@ __metadata: linkType: hard "vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0, vite@npm:^7.0.0": - version: 7.3.0 - resolution: "vite@npm:7.3.0" + version: 7.3.1 + resolution: "vite@npm:7.3.1" dependencies: esbuild: "npm:^0.27.0" fdir: "npm:^6.5.0" @@ -15555,7 +15658,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10c0/0457c196cdd5761ec351c0f353945430fbad330e615b9eeab729c8ae163334f18acdc1d9cd7d9d673dbf111f07f6e4f0b25d4ac32360e65b4a6df9991046f3ff + checksum: 10c0/5c7548f5f43a23533e53324304db4ad85f1896b1bfd3ee32ae9b866bac2933782c77b350eb2b52a02c625c8ad1ddd4c000df077419410650c982cd97fde8d014 languageName: node linkType: hard From 2fca7e37191e81f015426eb3756a19b19277031e Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 25 Feb 2026 18:03:04 +0100 Subject: [PATCH 037/119] Roll back @livekit/track-processors to fix type error (again!) While we wait for https://github.com/livekit/track-processors-js/pull/118 to be released --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 8602516f..e5ba595d 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@livekit/components-core": "^0.12.0", "@livekit/components-react": "^2.0.0", "@livekit/protocol": "^1.42.2", - "@livekit/track-processors": "^0.6.0 || ^0.7.1 || ^0.7.0", + "@livekit/track-processors": "^0.6.0 || ^0.7.1", "@mediapipe/tasks-vision": "^0.10.18", "@playwright/test": "^1.57.0", "@radix-ui/react-dialog": "^1.0.4", diff --git a/yarn.lock b/yarn.lock index d8416b4d..32903d88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3345,15 +3345,15 @@ __metadata: languageName: node linkType: hard -"@livekit/track-processors@npm:^0.6.0 || ^0.7.1 || ^0.7.0": - version: 0.7.0 - resolution: "@livekit/track-processors@npm:0.7.0" +"@livekit/track-processors@npm:^0.6.0 || ^0.7.1": + version: 0.6.1 + resolution: "@livekit/track-processors@npm:0.6.1" dependencies: "@mediapipe/tasks-vision": "npm:0.10.14" peerDependencies: "@types/dom-mediacapture-transform": ^0.1.9 livekit-client: ^1.12.0 || ^2.1.0 - checksum: 10c0/4c1ec427586e885c44d2865a98008b563d002b1b98d117383637a696597d71a0ff64d8a5bcba48033298e5c2cbaa9e357481e8a4a182982a355eb9e0eeb87643 + checksum: 10c0/80f54663c7e13de299de9e2565b6cbd2ba74ea0a4a8adf8a366e8cfd0e19dedfb9d699899137f1a6133414f28779877eeb3200074c03893bc63aeb0d8c912a91 languageName: node linkType: hard @@ -8380,7 +8380,7 @@ __metadata: "@livekit/components-core": "npm:^0.12.0" "@livekit/components-react": "npm:^2.0.0" "@livekit/protocol": "npm:^1.42.2" - "@livekit/track-processors": "npm:^0.6.0 || ^0.7.1 || ^0.7.0" + "@livekit/track-processors": "npm:^0.6.0 || ^0.7.1" "@mediapipe/tasks-vision": "npm:^0.10.18" "@playwright/test": "npm:^1.57.0" "@radix-ui/react-dialog": "npm:^1.0.4" From 44eb8acaeec990656e921d954694e459722acb48 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 25 Feb 2026 19:36:00 +0100 Subject: [PATCH 038/119] Pin Knip to 5.82.1 to work around Yarn plugin issue We can upgrade beyond 5.82.1 once https://github.com/webpro-nl/knip/pull/1574 is merged. --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index e5ba595d..705b0f10 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "i18next-browser-languagedetector": "^8.0.0", "i18next-parser": "^9.1.0", "jsdom": "^26.0.0", - "knip": "^5.27.2", + "knip": "5.82.1", "livekit-client": "^2.13.0", "lodash-es": "^4.17.21", "loglevel": "^1.9.1", diff --git a/yarn.lock b/yarn.lock index 32903d88..4675d0e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8433,7 +8433,7 @@ __metadata: i18next-browser-languagedetector: "npm:^8.0.0" i18next-parser: "npm:^9.1.0" jsdom: "npm:^26.0.0" - knip: "npm:^5.27.2" + knip: "npm:5.82.1" livekit-client: "npm:^2.13.0" lodash-es: "npm:^4.17.21" loglevel: "npm:^1.9.1" @@ -11232,9 +11232,9 @@ __metadata: languageName: node linkType: hard -"knip@npm:^5.27.2": - version: 5.85.0 - resolution: "knip@npm:5.85.0" +"knip@npm:5.82.1": + version: 5.82.1 + resolution: "knip@npm:5.82.1" dependencies: "@nodelib/fs.walk": "npm:^1.2.3" fast-glob: "npm:^3.3.3" @@ -11254,7 +11254,7 @@ __metadata: bin: knip: bin/knip.js knip-bun: bin/knip-bun.js - checksum: 10c0/d1eb6c85bb24cd357cedf67d02f9697261b70721a59eed48bd0be716e99b86ceb3e55ee9e6cfef60110272303e2de8dc6ea0621936f9e209490eaf5c24b2697f + checksum: 10c0/c3bfe898fe3103bb6a59ee2ba4297f05ea4d2db474571db89ae199ebbd74eafa5061d05b3bc2c75e4ec2322ba7ffee44493c76132d3d8991fae66ba742b9ccb4 languageName: node linkType: hard From 6b51b7dc58e534ca5f8fe611cfab03f9fe318e3f Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 25 Feb 2026 22:34:07 +0100 Subject: [PATCH 039/119] Split MediaViewModel into multiple files --- src/state/CallViewModel/CallViewModel.ts | 12 +- .../CallViewModel/localMember/Publisher.ts | 2 +- src/state/MediaViewModel.ts | 713 ------------------ src/state/TileStore.ts | 3 +- src/state/TileViewModel.ts | 3 +- src/state/layout-types.ts | 10 +- src/state/media/LocalScreenShareViewModel.ts | 32 + src/state/media/LocalUserMediaViewModel.ts | 137 ++++ src/state/{ => media}/MediaItem.ts | 32 +- src/state/{ => media}/MediaViewModel.test.ts | 11 +- src/state/media/MediaViewModel.ts | 44 ++ src/state/media/MemberMediaViewModel.ts | 280 +++++++ src/state/media/RemoteScreenShareViewModel.ts | 44 ++ src/state/media/RemoteUserMediaViewModel.ts | 82 ++ src/state/media/ScreenShareViewModel.ts | 51 ++ src/state/media/UserMediaViewModel.ts | 143 ++++ src/state/media/observeRtpStreamStats.ts | 59 ++ src/state/{ => media}/observeSpeaker.test.ts | 2 +- src/state/{ => media}/observeSpeaker.ts | 0 src/state/observeTrackReference.ts | 28 + src/tile/GridTile.tsx | 8 +- src/tile/MediaView.test.tsx | 2 +- src/tile/MediaView.tsx | 2 +- src/tile/SpotlightTile.tsx | 16 +- src/utils/test.ts | 14 +- 25 files changed, 957 insertions(+), 773 deletions(-) delete mode 100644 src/state/MediaViewModel.ts create mode 100644 src/state/media/LocalScreenShareViewModel.ts create mode 100644 src/state/media/LocalUserMediaViewModel.ts rename src/state/{ => media}/MediaItem.ts (88%) rename src/state/{ => media}/MediaViewModel.test.ts (96%) create mode 100644 src/state/media/MediaViewModel.ts create mode 100644 src/state/media/MemberMediaViewModel.ts create mode 100644 src/state/media/RemoteScreenShareViewModel.ts create mode 100644 src/state/media/RemoteUserMediaViewModel.ts create mode 100644 src/state/media/ScreenShareViewModel.ts create mode 100644 src/state/media/UserMediaViewModel.ts create mode 100644 src/state/media/observeRtpStreamStats.ts rename src/state/{ => media}/observeSpeaker.test.ts (98%) rename src/state/{ => media}/observeSpeaker.ts (100%) create mode 100644 src/state/observeTrackReference.ts diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 7f34d86f..c19c4818 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -50,11 +50,6 @@ import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/En import { v4 as uuidv4 } from "uuid"; import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager"; -import { - type MediaViewModel, - type ScreenShareViewModel, - type UserMediaViewModel, -} from "../MediaViewModel"; import { createToggle$, filterBehavior, @@ -142,9 +137,12 @@ import { type Connection } from "./remoteMembers/Connection.ts"; import { createLayoutModeSwitch } from "./LayoutSwitch.ts"; import { createWrappedUserMedia, - type WrappedUserMediaViewModel, type MediaItem, -} from "../MediaItem.ts"; + type WrappedUserMediaViewModel, +} from "../media/MediaItem.ts"; +import { type ScreenShareViewModel } from "../media/ScreenShareViewModel.ts"; +import { type UserMediaViewModel } from "../media/UserMediaViewModel.ts"; +import { type MediaViewModel } from "../media/MediaViewModel.ts"; const logger = rootLogger.getChild("[CallViewModel]"); //TODO diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index 8df38743..b7841c49 100644 --- a/src/state/CallViewModel/localMember/Publisher.ts +++ b/src/state/CallViewModel/localMember/Publisher.ts @@ -30,7 +30,7 @@ import { trackProcessorSync, } from "../../../livekit/TrackProcessorContext.tsx"; import { getUrlParams } from "../../../UrlParams.ts"; -import { observeTrackReference$ } from "../../MediaViewModel.ts"; +import { observeTrackReference$ } from "../../observeTrackReference"; import { type Connection } from "../remoteMembers/Connection.ts"; import { ObservableScope } from "../../ObservableScope.ts"; diff --git a/src/state/MediaViewModel.ts b/src/state/MediaViewModel.ts deleted file mode 100644 index c8c468ac..00000000 --- a/src/state/MediaViewModel.ts +++ /dev/null @@ -1,713 +0,0 @@ -/* -Copyright 2023, 2024 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE in the repository root for full details. -*/ - -import { - type AudioSource, - type VideoSource, - type TrackReference, - observeParticipantEvents, - observeParticipantMedia, - roomEventSelector, -} from "@livekit/components-core"; -import { - type LocalParticipant, - LocalTrack, - LocalVideoTrack, - type Participant, - ParticipantEvent, - type RemoteParticipant, - Track, - TrackEvent, - facingModeFromLocalTrack, - type Room as LivekitRoom, - RoomEvent as LivekitRoomEvent, - RemoteTrack, -} from "livekit-client"; -import { logger } from "matrix-js-sdk/lib/logger"; -import { - type Observable, - Subject, - combineLatest, - filter, - fromEvent, - interval, - map, - merge, - of, - startWith, - switchMap, - throttleTime, - distinctUntilChanged, -} from "rxjs"; - -import { alwaysShowSelf } from "../settings/settings"; -import { showConnectionStats } from "../settings/settings"; -import { createToggle$ } from "../utils/observable"; -import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; -import { E2eeType } from "../e2ee/e2eeType"; -import { type ReactionOption } from "../reactions"; -import { platform } from "../Platform"; -import { type MediaDevices } from "./MediaDevices"; -import { type Behavior } from "./Behavior"; -import { type ObservableScope } from "./ObservableScope"; -import { createVolumeControls, type VolumeControls } from "./VolumeControls"; - -export function observeTrackReference$( - participant: Participant, - source: Track.Source, -): Observable { - return observeParticipantMedia(participant).pipe( - map(() => participant.getTrackPublication(source)), - distinctUntilChanged(), - map((publication) => publication && { participant, publication, source }), - ); -} - -export function observeRtpStreamStats$( - participant: Participant, - source: Track.Source, - type: "inbound-rtp" | "outbound-rtp", -): Observable< - RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined -> { - return combineLatest([ - observeTrackReference$(participant, source), - interval(1000).pipe(startWith(0)), - ]).pipe( - switchMap(async ([trackReference]) => { - const track = trackReference?.publication?.track; - if ( - !track || - !(track instanceof RemoteTrack || track instanceof LocalTrack) - ) { - return undefined; - } - const report = await track.getRTCStatsReport(); - if (!report) { - return undefined; - } - - for (const v of report.values()) { - if (v.type === type) { - return v; - } - } - - return undefined; - }), - startWith(undefined), - ); -} - -function observeInboundRtpStreamStats$( - participant: Participant, - source: Track.Source, -): Observable { - return observeRtpStreamStats$(participant, source, "inbound-rtp").pipe( - map((x) => x as RTCInboundRtpStreamStats | undefined), - ); -} - -function observeRemoteTrackReceivingOkay$( - participant: Participant, - source: Track.Source, -): Observable { - let lastStats: { - framesDecoded: number | undefined; - framesDropped: number | undefined; - framesReceived: number | undefined; - } = { - framesDecoded: undefined, - framesDropped: undefined, - framesReceived: undefined, - }; - - return observeInboundRtpStreamStats$(participant, source).pipe( - map((stats) => { - if (!stats) return undefined; - const { framesDecoded, framesDropped, framesReceived } = stats; - return { - framesDecoded, - framesDropped, - framesReceived, - }; - }), - filter((newStats) => !!newStats), - map((newStats): boolean | undefined => { - const oldStats = lastStats; - lastStats = newStats; - if ( - typeof newStats.framesReceived === "number" && - typeof oldStats.framesReceived === "number" && - typeof newStats.framesDecoded === "number" && - typeof oldStats.framesDecoded === "number" - ) { - const framesReceivedDelta = - newStats.framesReceived - oldStats.framesReceived; - const framesDecodedDelta = - newStats.framesDecoded - oldStats.framesDecoded; - - // if we received >0 frames and managed to decode >0 frames then we treat that as success - - if (framesReceivedDelta > 0) { - return framesDecodedDelta > 0; - } - } - - // no change - return undefined; - }), - filter((x) => typeof x === "boolean"), - startWith(undefined), - ); -} - -function encryptionErrorObservable$( - room$: Behavior, - participant: Participant, - encryptionSystem: EncryptionSystem, - criteria: string, -): Observable { - return room$.pipe( - switchMap((room) => { - if (room === undefined) return of(false); - return roomEventSelector(room, LivekitRoomEvent.EncryptionError).pipe( - map((e) => { - const [err] = e; - if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) { - return ( - // Ideally we would pull the participant identity from the field on the error. - // However, it gets lost in the serialization process between workers. - // So, instead we do a string match - (err?.message.includes(participant.identity) && - err?.message.includes(criteria)) ?? - false - ); - } else if (encryptionSystem.kind === E2eeType.SHARED_KEY) { - return !!err?.message.includes(criteria); - } - - return false; - }), - ); - }), - distinctUntilChanged(), - throttleTime(1000), // Throttle to avoid spamming the UI - startWith(false), - ); -} - -export enum EncryptionStatus { - Connecting, - Okay, - KeyMissing, - KeyInvalid, - PasswordInvalid, -} - -interface BaseMediaViewModel { - /** - * An opaque identifier for this media. - */ - id: string; - /** - * The Matrix user to which this media belongs. - */ - userId: string; - displayName$: Behavior; - mxcAvatarUrl$: Behavior; -} - -type BaseMediaInputs = BaseMediaViewModel; - -// This function exists to strip out superfluous data from the input object -function createBaseMedia({ - id, - userId, - displayName$, - mxcAvatarUrl$, -}: BaseMediaInputs): BaseMediaViewModel { - return { id, userId, displayName$, mxcAvatarUrl$ }; -} - -interface MemberMediaViewModel extends BaseMediaViewModel { - /** - * The LiveKit video track for this media. - */ - video$: Behavior; - /** - * The URL of the LiveKit focus on which this member should be publishing. - * Exposed for debugging. - */ - focusUrl$: Behavior; - /** - * Whether there should be a warning that this media is unencrypted. - */ - unencryptedWarning$: Behavior; - encryptionStatus$: Behavior; -} - -interface MemberMediaInputs extends BaseMediaViewModel { - participant$: Behavior; - livekitRoom$: Behavior; - audioSource: AudioSource; - videoSource: VideoSource; - focusUrl$: Behavior; - encryptionSystem: EncryptionSystem; -} - -function createMemberMedia( - scope: ObservableScope, - { - participant$, - livekitRoom$, - audioSource, - videoSource, - focusUrl$, - encryptionSystem, - ...inputs - }: MemberMediaInputs, -): MemberMediaViewModel { - const trackBehavior$ = ( - source: Track.Source, - ): Behavior => - scope.behavior( - participant$.pipe( - switchMap((p) => - !p ? of(undefined) : observeTrackReference$(p, source), - ), - ), - ); - - const audio$ = trackBehavior$(audioSource); - const video$ = trackBehavior$(videoSource); - - return { - ...createBaseMedia(inputs), - video$, - focusUrl$, - unencryptedWarning$: scope.behavior( - combineLatest( - [audio$, video$], - (a, v) => - encryptionSystem.kind !== E2eeType.NONE && - (a?.publication.isEncrypted === false || - v?.publication.isEncrypted === false), - ), - ), - encryptionStatus$: scope.behavior( - participant$.pipe( - switchMap((participant): Observable => { - if (!participant) { - return of(EncryptionStatus.Connecting); - } else if ( - participant.isLocal || - encryptionSystem.kind === E2eeType.NONE - ) { - return of(EncryptionStatus.Okay); - } else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) { - return combineLatest([ - encryptionErrorObservable$( - livekitRoom$, - participant, - encryptionSystem, - "MissingKey", - ), - encryptionErrorObservable$( - livekitRoom$, - participant, - encryptionSystem, - "InvalidKey", - ), - observeRemoteTrackReceivingOkay$(participant, audioSource), - observeRemoteTrackReceivingOkay$(participant, videoSource), - ]).pipe( - map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => { - if (keyMissing) return EncryptionStatus.KeyMissing; - if (keyInvalid) return EncryptionStatus.KeyInvalid; - if (audioOkay || videoOkay) return EncryptionStatus.Okay; - return undefined; // no change - }), - filter((x) => !!x), - startWith(EncryptionStatus.Connecting), - ); - } else { - return combineLatest([ - encryptionErrorObservable$( - livekitRoom$, - participant, - encryptionSystem, - "InvalidKey", - ), - observeRemoteTrackReceivingOkay$(participant, audioSource), - observeRemoteTrackReceivingOkay$(participant, videoSource), - ]).pipe( - map( - ([keyInvalid, audioOkay, videoOkay]): - | EncryptionStatus - | undefined => { - if (keyInvalid) return EncryptionStatus.PasswordInvalid; - if (audioOkay || videoOkay) return EncryptionStatus.Okay; - return undefined; // no change - }, - ), - filter((x) => !!x), - startWith(EncryptionStatus.Connecting), - ); - } - }), - ), - ), - }; -} - -interface BaseUserMediaViewModel extends MemberMediaViewModel { - type: "user"; - speaking$: Behavior; - audioEnabled$: Behavior; - videoEnabled$: Behavior; - cropVideo$: Behavior; - toggleCropVideo: () => void; - /** - * The expected identity of the LiveKit participant. Exposed for debugging. - */ - rtcBackendIdentity: string; - handRaised$: Behavior; - reaction$: Behavior; - audioStreamStats$: Observable< - RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined - >; - videoStreamStats$: Observable< - RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined - >; -} - -interface BaseUserMediaInputs extends Omit< - MemberMediaInputs, - "audioSource" | "videoSource" -> { - rtcBackendIdentity: string; - handRaised$: Behavior; - reaction$: Behavior; - statsType: "inbound-rtp" | "outbound-rtp"; -} - -function createBaseUserMedia( - scope: ObservableScope, - { - rtcBackendIdentity, - handRaised$, - reaction$, - statsType, - ...inputs - }: BaseUserMediaInputs, -): BaseUserMediaViewModel { - const { participant$ } = inputs; - const media$ = scope.behavior( - participant$.pipe( - switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)), - ), - ); - const toggleCropVideo$ = new Subject(); - - return { - ...createMemberMedia(scope, { - ...inputs, - audioSource: Track.Source.Microphone, - videoSource: Track.Source.Camera, - }), - type: "user", - speaking$: scope.behavior( - participant$.pipe( - switchMap((p) => - p - ? observeParticipantEvents( - p, - ParticipantEvent.IsSpeakingChanged, - ).pipe(map((p) => p.isSpeaking)) - : of(false), - ), - ), - ), - audioEnabled$: scope.behavior( - media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)), - ), - videoEnabled$: scope.behavior( - media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)), - ), - cropVideo$: createToggle$(scope, true, toggleCropVideo$), - toggleCropVideo: () => toggleCropVideo$.next(), - rtcBackendIdentity, - handRaised$, - reaction$, - audioStreamStats$: combineLatest([ - participant$, - showConnectionStats.value$, - ]).pipe( - switchMap(([p, showConnectionStats]) => { - // - if (!p || !showConnectionStats) return of(undefined); - return observeRtpStreamStats$(p, Track.Source.Microphone, statsType); - }), - ), - videoStreamStats$: combineLatest([ - participant$, - showConnectionStats.value$, - ]).pipe( - switchMap(([p, showConnectionStats]) => { - if (!p || !showConnectionStats) return of(undefined); - return observeRtpStreamStats$(p, Track.Source.Camera, statsType); - }), - ), - }; -} - -export interface LocalUserMediaViewModel extends BaseUserMediaViewModel { - local: true; - /** - * Whether the video should be mirrored. - */ - mirror$: Behavior; - /** - * Whether to show this tile in a highly visible location near the start of - * the grid. - */ - alwaysShow$: Behavior; - setAlwaysShow: (value: boolean) => void; - switchCamera$: Behavior<(() => void) | null>; -} - -export interface LocalUserMediaInputs extends Omit< - BaseUserMediaInputs, - "statsType" -> { - participant$: Behavior; - mediaDevices: MediaDevices; -} - -export function createLocalUserMedia( - scope: ObservableScope, - { mediaDevices, ...inputs }: LocalUserMediaInputs, -): LocalUserMediaViewModel { - const baseUserMedia = createBaseUserMedia(scope, { - ...inputs, - statsType: "outbound-rtp", - }); - - /** - * The local video track as an observable that emits whenever the track - * changes, the camera is switched, or the track is muted. - */ - const videoTrack$: Observable = - baseUserMedia.video$.pipe( - switchMap((v) => { - const track = v?.publication.track; - if (!(track instanceof LocalVideoTrack)) return of(null); - return merge( - // Watch for track restarts because they indicate a camera switch. - // This event is also emitted when unmuting the track object. - fromEvent(track, TrackEvent.Restarted).pipe( - startWith(null), - map(() => track), - ), - // When the track object is muted, reset it to null. - fromEvent(track, TrackEvent.Muted).pipe(map(() => null)), - ); - }), - ); - - return { - ...baseUserMedia, - local: true, - mirror$: scope.behavior( - videoTrack$.pipe( - // Mirror only front-facing cameras (those that face the user) - map( - (track) => - track !== null && - facingModeFromLocalTrack(track).facingMode === "user", - ), - ), - ), - alwaysShow$: alwaysShowSelf.value$, - setAlwaysShow: alwaysShowSelf.setValue, - switchCamera$: scope.behavior( - platform === "desktop" - ? of(null) - : videoTrack$.pipe( - map((track) => { - if (track === null) return null; - const facingMode = facingModeFromLocalTrack(track).facingMode; - // If the camera isn't front or back-facing, don't provide a switch - // camera shortcut at all - if (facingMode !== "user" && facingMode !== "environment") - return null; - // Restart the track with a camera facing the opposite direction - return (): void => - void track - .restartTrack({ - facingMode: facingMode === "user" ? "environment" : "user", - }) - .then(() => { - // Inform the MediaDevices which camera was chosen - const deviceId = - track.mediaStreamTrack.getSettings().deviceId; - if (deviceId !== undefined) - mediaDevices.videoInput.select(deviceId); - }) - .catch((e) => - logger.error("Failed to switch camera", facingMode, e), - ); - }), - ), - ), - }; -} - -export interface RemoteUserMediaViewModel - extends BaseUserMediaViewModel, VolumeControls { - local: false; - /** - * Whether we are waiting for this user's LiveKit participant to exist. This - * could be because either we or the remote party are still connecting. - */ - waitingForMedia$: Behavior; -} - -export interface RemoteUserMediaInputs extends Omit< - BaseUserMediaInputs, - "statsType" -> { - participant$: Behavior; - pretendToBeDisconnected$: Behavior; -} - -export function createRemoteUserMedia( - scope: ObservableScope, - { pretendToBeDisconnected$, ...inputs }: RemoteUserMediaInputs, -): RemoteUserMediaViewModel { - const baseUserMedia = createBaseUserMedia(scope, { - ...inputs, - statsType: "inbound-rtp", - }); - - return { - ...baseUserMedia, - ...createVolumeControls(scope, { - pretendToBeDisconnected$, - sink$: scope.behavior( - inputs.participant$.pipe(map((p) => (volume) => p?.setVolume(volume))), - ), - }), - local: false, - speaking$: scope.behavior( - pretendToBeDisconnected$.pipe( - switchMap((disconnected) => - disconnected ? of(false) : baseUserMedia.speaking$, - ), - ), - ), - videoEnabled$: scope.behavior( - pretendToBeDisconnected$.pipe( - switchMap((disconnected) => - disconnected ? of(false) : baseUserMedia.videoEnabled$, - ), - ), - ), - waitingForMedia$: scope.behavior( - combineLatest( - [inputs.livekitRoom$, inputs.participant$], - (livekitRoom, participant) => - // If livekitRoom is undefined, the user is not attempting to publish on - // any transport and so we shouldn't expect a participant. (They might - // be a subscribe-only bot for example.) - livekitRoom !== undefined && participant === null, - ), - ), - }; -} - -interface BaseScreenShareViewModel extends MemberMediaViewModel { - type: "screen share"; -} - -type BaseScreenShareInputs = Omit< - MemberMediaInputs, - "audioSource" | "videoSource" ->; - -function createBaseScreenShare( - scope: ObservableScope, - inputs: BaseScreenShareInputs, -): BaseScreenShareViewModel { - return { - ...createMemberMedia(scope, { - ...inputs, - audioSource: Track.Source.ScreenShareAudio, - videoSource: Track.Source.ScreenShare, - }), - type: "screen share", - }; -} - -export interface LocalScreenShareViewModel extends BaseScreenShareViewModel { - local: true; -} - -interface LocalScreenShareInputs extends BaseScreenShareInputs { - participant$: Behavior; -} - -export function createLocalScreenShare( - scope: ObservableScope, - inputs: LocalScreenShareInputs, -): LocalScreenShareViewModel { - return { ...createBaseScreenShare(scope, inputs), local: true }; -} - -export interface RemoteScreenShareViewModel extends BaseScreenShareViewModel { - local: false; - /** - * Whether this screen share's video should be displayed. - */ - videoEnabled$: Behavior; -} - -interface RemoteScreenShareInputs extends BaseScreenShareInputs { - participant$: Behavior; - pretendToBeDisconnected$: Behavior; -} - -export function createRemoteScreenShare( - scope: ObservableScope, - { pretendToBeDisconnected$, ...inputs }: RemoteScreenShareInputs, -): RemoteScreenShareViewModel { - return { - ...createBaseScreenShare(scope, inputs), - local: false, - videoEnabled$: scope.behavior( - pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)), - ), - }; -} - -/** - * Some participant's media. - */ -export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel; -/** - * Some participant's user media (i.e. their microphone and camera feed). - */ -export type UserMediaViewModel = - | LocalUserMediaViewModel - | RemoteUserMediaViewModel; -/** - * Some participant's screen share media. - */ -export type ScreenShareViewModel = - | LocalScreenShareViewModel - | RemoteScreenShareViewModel; diff --git a/src/state/TileStore.ts b/src/state/TileStore.ts index 7b95bd8e..a954eb4e 100644 --- a/src/state/TileStore.ts +++ b/src/state/TileStore.ts @@ -8,10 +8,11 @@ Please see LICENSE in the repository root for full details. import { BehaviorSubject } from "rxjs"; import { logger } from "matrix-js-sdk/lib/logger"; -import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel"; import { GridTileViewModel, SpotlightTileViewModel } from "./TileViewModel"; import { fillGaps } from "../utils/iter"; import { debugTileLayout } from "../settings/settings"; +import { type MediaViewModel } from "./media/MediaViewModel"; +import { type UserMediaViewModel } from "./media/UserMediaViewModel"; function debugEntries(entries: GridTileData[]): string[] { return entries.map((e) => e.media.displayName$.value); diff --git a/src/state/TileViewModel.ts b/src/state/TileViewModel.ts index a645a0d1..8b13c685 100644 --- a/src/state/TileViewModel.ts +++ b/src/state/TileViewModel.ts @@ -5,8 +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 MediaViewModel, type UserMediaViewModel } from "./MediaViewModel"; import { type Behavior } from "./Behavior"; +import { type MediaViewModel } from "./media/MediaViewModel"; +import { type UserMediaViewModel } from "./media/UserMediaViewModel"; let nextId = 0; function createId(): string { diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index f32869df..33796f66 100644 --- a/src/state/layout-types.ts +++ b/src/state/layout-types.ts @@ -5,16 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ +import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts"; +import { type MediaViewModel } from "./media/MediaViewModel.ts"; +import { type RemoteUserMediaViewModel } from "./media/RemoteUserMediaViewModel.ts"; +import { type UserMediaViewModel } from "./media/UserMediaViewModel.ts"; import { type GridTileViewModel, type SpotlightTileViewModel, } from "./TileViewModel.ts"; -import { - type LocalUserMediaViewModel, - type RemoteUserMediaViewModel, - type MediaViewModel, - type UserMediaViewModel, -} from "./MediaViewModel.ts"; export interface GridLayoutMedia { type: "grid"; diff --git a/src/state/media/LocalScreenShareViewModel.ts b/src/state/media/LocalScreenShareViewModel.ts new file mode 100644 index 00000000..b31739d9 --- /dev/null +++ b/src/state/media/LocalScreenShareViewModel.ts @@ -0,0 +1,32 @@ +/* +Copyright 2023, 2024 New Vector Ltd. +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 LocalParticipant } from "livekit-client"; + +import { type Behavior } from "../Behavior"; +import { + type BaseScreenShareInputs, + type BaseScreenShareViewModel, + createBaseScreenShare, +} from "./ScreenShareViewModel"; +import { type ObservableScope } from "../ObservableScope"; + +export interface LocalScreenShareViewModel extends BaseScreenShareViewModel { + local: true; +} + +export interface LocalScreenShareInputs extends BaseScreenShareInputs { + participant$: Behavior; +} + +export function createLocalScreenShare( + scope: ObservableScope, + inputs: LocalScreenShareInputs, +): LocalScreenShareViewModel { + return { ...createBaseScreenShare(scope, inputs), local: true }; +} diff --git a/src/state/media/LocalUserMediaViewModel.ts b/src/state/media/LocalUserMediaViewModel.ts new file mode 100644 index 00000000..fd21428b --- /dev/null +++ b/src/state/media/LocalUserMediaViewModel.ts @@ -0,0 +1,137 @@ +/* +Copyright 2023, 2024 New Vector Ltd. +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 { + facingModeFromLocalTrack, + type LocalParticipant, + LocalVideoTrack, + TrackEvent, +} from "livekit-client"; +import { + fromEvent, + map, + merge, + type Observable, + of, + startWith, + switchMap, +} from "rxjs"; +import { logger } from "matrix-js-sdk/lib/logger"; + +import { type Behavior } from "../Behavior"; +import { + type BaseUserMediaInputs, + type BaseUserMediaViewModel, + createBaseUserMedia, +} from "./UserMediaViewModel"; +import { type ObservableScope } from "../ObservableScope"; +import { alwaysShowSelf } from "../../settings/settings"; +import { platform } from "../../Platform"; +import { type MediaDevices } from "../MediaDevices"; + +export interface LocalUserMediaViewModel extends BaseUserMediaViewModel { + local: true; + /** + * Whether the video should be mirrored. + */ + mirror$: Behavior; + /** + * Whether to show this tile in a highly visible location near the start of + * the grid. + */ + alwaysShow$: Behavior; + setAlwaysShow: (value: boolean) => void; + switchCamera$: Behavior<(() => void) | null>; +} + +export interface LocalUserMediaInputs extends Omit< + BaseUserMediaInputs, + "statsType" +> { + participant$: Behavior; + mediaDevices: MediaDevices; +} + +export function createLocalUserMedia( + scope: ObservableScope, + { mediaDevices, ...inputs }: LocalUserMediaInputs, +): LocalUserMediaViewModel { + const baseUserMedia = createBaseUserMedia(scope, { + ...inputs, + statsType: "outbound-rtp", + }); + + /** + * The local video track as an observable that emits whenever the track + * changes, the camera is switched, or the track is muted. + */ + const videoTrack$: Observable = + baseUserMedia.video$.pipe( + switchMap((v) => { + const track = v?.publication.track; + if (!(track instanceof LocalVideoTrack)) return of(null); + return merge( + // Watch for track restarts because they indicate a camera switch. + // This event is also emitted when unmuting the track object. + fromEvent(track, TrackEvent.Restarted).pipe( + startWith(null), + map(() => track), + ), + // When the track object is muted, reset it to null. + fromEvent(track, TrackEvent.Muted).pipe(map(() => null)), + ); + }), + ); + + return { + ...baseUserMedia, + local: true, + mirror$: scope.behavior( + videoTrack$.pipe( + // Mirror only front-facing cameras (those that face the user) + map( + (track) => + track !== null && + facingModeFromLocalTrack(track).facingMode === "user", + ), + ), + ), + alwaysShow$: alwaysShowSelf.value$, + setAlwaysShow: alwaysShowSelf.setValue, + switchCamera$: scope.behavior( + platform === "desktop" + ? of(null) + : videoTrack$.pipe( + map((track) => { + if (track === null) return null; + const facingMode = facingModeFromLocalTrack(track).facingMode; + // If the camera isn't front or back-facing, don't provide a switch + // camera shortcut at all + if (facingMode !== "user" && facingMode !== "environment") + return null; + // Restart the track with a camera facing the opposite direction + return (): void => + void track + .restartTrack({ + facingMode: facingMode === "user" ? "environment" : "user", + }) + .then(() => { + // Inform the MediaDevices which camera was chosen + const deviceId = + track.mediaStreamTrack.getSettings().deviceId; + if (deviceId !== undefined) + mediaDevices.videoInput.select(deviceId); + }) + .catch((e) => + logger.error("Failed to switch camera", facingMode, e), + ); + }), + ), + ), + }; +} diff --git a/src/state/MediaItem.ts b/src/state/media/MediaItem.ts similarity index 88% rename from src/state/MediaItem.ts rename to src/state/media/MediaItem.ts index 947fdfd4..6cd80045 100644 --- a/src/state/MediaItem.ts +++ b/src/state/media/MediaItem.ts @@ -13,22 +13,24 @@ import { } from "livekit-client"; import { observeParticipantEvents } from "@livekit/components-core"; -import { type ObservableScope } from "./ObservableScope.ts"; -import { - createLocalScreenShare, - createLocalUserMedia, - createRemoteScreenShare, - createRemoteUserMedia, - type ScreenShareViewModel, - type UserMediaViewModel, - type LocalUserMediaInputs, - type RemoteUserMediaInputs, -} from "./MediaViewModel.ts"; -import type { Behavior } from "./Behavior.ts"; -import type { MediaDevices } from "./MediaDevices.ts"; +import { type ObservableScope } from "../ObservableScope.ts"; +import type { Behavior } from "../Behavior.ts"; +import type { MediaDevices } from "../MediaDevices.ts"; import { observeSpeaker$ } from "./observeSpeaker.ts"; -import { generateItems } from "../utils/observable.ts"; -import { type TaggedParticipant } from "./CallViewModel/remoteMembers/MatrixLivekitMembers.ts"; +import { generateItems } from "../../utils/observable.ts"; +import { type TaggedParticipant } from "../CallViewModel/remoteMembers/MatrixLivekitMembers.ts"; +import { type UserMediaViewModel } from "./UserMediaViewModel.ts"; +import { type ScreenShareViewModel } from "./ScreenShareViewModel.ts"; +import { + createLocalUserMedia, + type LocalUserMediaInputs, +} from "./LocalUserMediaViewModel.ts"; +import { + createRemoteUserMedia, + type RemoteUserMediaInputs, +} from "./RemoteUserMediaViewModel.ts"; +import { createLocalScreenShare } from "./LocalScreenShareViewModel.ts"; +import { createRemoteScreenShare } from "./RemoteScreenShareViewModel.ts"; /** * Sorting bins defining the order in which media tiles appear in the layout. diff --git a/src/state/MediaViewModel.test.ts b/src/state/media/MediaViewModel.test.ts similarity index 96% rename from src/state/MediaViewModel.test.ts rename to src/state/media/MediaViewModel.test.ts index 73b396db..71475b8c 100644 --- a/src/state/MediaViewModel.test.ts +++ b/src/state/media/MediaViewModel.test.ts @@ -21,9 +21,8 @@ import { mockRemoteMedia, withTestScheduler, mockRemoteParticipant, -} from "../utils/test"; -import { getValue } from "../utils/observable"; -import { constant } from "./Behavior"; +} from "../../utils/test"; +import { constant } from "../Behavior"; global.MediaStreamTrack = class {} as unknown as { new (): MediaStreamTrack; @@ -35,7 +34,7 @@ global.MediaStream = class {} as unknown as { }; const platformMock = vi.hoisted(() => vi.fn(() => "desktop")); -vi.mock("../Platform", () => ({ +vi.mock("../../Platform", () => ({ get platform(): string { return platformMock(); }, @@ -184,7 +183,7 @@ test("switch cameras", async () => { ); // Switch to back camera - getValue(vm.switchCamera$)!(); + vm.switchCamera$.value!(); expect(restartTrack).toHaveBeenCalledExactlyOnceWith({ facingMode: "environment", }); @@ -195,7 +194,7 @@ test("switch cameras", async () => { expect(deviceId).toBe("back camera"); // Switch to front camera - getValue(vm.switchCamera$)!(); + vm.switchCamera$.value!(); expect(restartTrack).toHaveBeenCalledTimes(2); expect(restartTrack).toHaveBeenLastCalledWith({ facingMode: "user" }); await waitFor(() => { diff --git a/src/state/media/MediaViewModel.ts b/src/state/media/MediaViewModel.ts new file mode 100644 index 00000000..bdc4875b --- /dev/null +++ b/src/state/media/MediaViewModel.ts @@ -0,0 +1,44 @@ +/* +Copyright 2023, 2024 New Vector Ltd. +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 Behavior } from "../Behavior"; +import { type ScreenShareViewModel } from "./ScreenShareViewModel"; +import { type UserMediaViewModel } from "./UserMediaViewModel"; + +/** + * A participant's media. + */ +export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel; + +/** + * Properties which are common to all MediaViewModels. + */ +export interface BaseMediaViewModel { + /** + * An opaque identifier for this media. + */ + id: string; + /** + * The Matrix user to which this media belongs. + */ + userId: string; + displayName$: Behavior; + mxcAvatarUrl$: Behavior; +} + +export type BaseMediaInputs = BaseMediaViewModel; + +// All this function does is strip out superfluous data from the input object +export function createBaseMedia({ + id, + userId, + displayName$, + mxcAvatarUrl$, +}: BaseMediaInputs): BaseMediaViewModel { + return { id, userId, displayName$, mxcAvatarUrl$ }; +} diff --git a/src/state/media/MemberMediaViewModel.ts b/src/state/media/MemberMediaViewModel.ts new file mode 100644 index 00000000..de64d93b --- /dev/null +++ b/src/state/media/MemberMediaViewModel.ts @@ -0,0 +1,280 @@ +/* +Copyright 2023, 2024 New Vector Ltd. +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 Room as LivekitRoom, + RoomEvent as LivekitRoomEvent, + type Participant, + type Track, +} from "livekit-client"; +import { + type AudioSource, + roomEventSelector, + type TrackReference, + type VideoSource, +} from "@livekit/components-core"; +import { type LocalParticipant, type RemoteParticipant } from "livekit-client"; +import { + combineLatest, + distinctUntilChanged, + filter, + map, + type Observable, + of, + startWith, + switchMap, + throttleTime, +} from "rxjs"; + +import { type Behavior } from "../Behavior"; +import { type BaseMediaViewModel, createBaseMedia } from "./MediaViewModel"; +import { type EncryptionSystem } from "../../e2ee/sharedKeyManagement"; +import { type ObservableScope } from "../ObservableScope"; +import { observeTrackReference$ } from "../observeTrackReference"; +import { E2eeType } from "../../e2ee/e2eeType"; +import { observeRtpStreamStats$ } from "./observeRtpStreamStats"; + +export enum EncryptionStatus { + Connecting, + Okay, + KeyMissing, + KeyInvalid, + PasswordInvalid, +} + +/** + * Media belonging to an active member of the RTC session. + */ +export interface MemberMediaViewModel extends BaseMediaViewModel { + /** + * The LiveKit video track for this media. + */ + video$: Behavior; + /** + * The URL of the LiveKit focus on which this member should be publishing. + * Exposed for debugging. + */ + focusUrl$: Behavior; + /** + * Whether there should be a warning that this media is unencrypted. + */ + unencryptedWarning$: Behavior; + encryptionStatus$: Behavior; +} + +export interface MemberMediaInputs extends BaseMediaViewModel { + participant$: Behavior; + livekitRoom$: Behavior; + audioSource: AudioSource; + videoSource: VideoSource; + focusUrl$: Behavior; + encryptionSystem: EncryptionSystem; +} + +export function createMemberMedia( + scope: ObservableScope, + { + participant$, + livekitRoom$, + audioSource, + videoSource, + focusUrl$, + encryptionSystem, + ...inputs + }: MemberMediaInputs, +): MemberMediaViewModel { + const trackBehavior$ = ( + source: Track.Source, + ): Behavior => + scope.behavior( + participant$.pipe( + switchMap((p) => + !p ? of(undefined) : observeTrackReference$(p, source), + ), + ), + ); + + const audio$ = trackBehavior$(audioSource); + const video$ = trackBehavior$(videoSource); + + return { + ...createBaseMedia(inputs), + video$, + focusUrl$, + unencryptedWarning$: scope.behavior( + combineLatest( + [audio$, video$], + (a, v) => + encryptionSystem.kind !== E2eeType.NONE && + (a?.publication.isEncrypted === false || + v?.publication.isEncrypted === false), + ), + ), + encryptionStatus$: scope.behavior( + participant$.pipe( + switchMap((participant): Observable => { + if (!participant) { + return of(EncryptionStatus.Connecting); + } else if ( + participant.isLocal || + encryptionSystem.kind === E2eeType.NONE + ) { + return of(EncryptionStatus.Okay); + } else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) { + return combineLatest([ + encryptionErrorObservable$( + livekitRoom$, + participant, + encryptionSystem, + "MissingKey", + ), + encryptionErrorObservable$( + livekitRoom$, + participant, + encryptionSystem, + "InvalidKey", + ), + observeRemoteTrackReceivingOkay$(participant, audioSource), + observeRemoteTrackReceivingOkay$(participant, videoSource), + ]).pipe( + map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => { + if (keyMissing) return EncryptionStatus.KeyMissing; + if (keyInvalid) return EncryptionStatus.KeyInvalid; + if (audioOkay || videoOkay) return EncryptionStatus.Okay; + return undefined; // no change + }), + filter((x) => !!x), + startWith(EncryptionStatus.Connecting), + ); + } else { + return combineLatest([ + encryptionErrorObservable$( + livekitRoom$, + participant, + encryptionSystem, + "InvalidKey", + ), + observeRemoteTrackReceivingOkay$(participant, audioSource), + observeRemoteTrackReceivingOkay$(participant, videoSource), + ]).pipe( + map( + ([keyInvalid, audioOkay, videoOkay]): + | EncryptionStatus + | undefined => { + if (keyInvalid) return EncryptionStatus.PasswordInvalid; + if (audioOkay || videoOkay) return EncryptionStatus.Okay; + return undefined; // no change + }, + ), + filter((x) => !!x), + startWith(EncryptionStatus.Connecting), + ); + } + }), + ), + ), + }; +} + +function observeInboundRtpStreamStats$( + participant: Participant, + source: Track.Source, +): Observable { + return observeRtpStreamStats$(participant, source, "inbound-rtp").pipe( + map((x) => x as RTCInboundRtpStreamStats | undefined), + ); +} + +function encryptionErrorObservable$( + room$: Behavior, + participant: Participant, + encryptionSystem: EncryptionSystem, + criteria: string, +): Observable { + return room$.pipe( + switchMap((room) => { + if (room === undefined) return of(false); + return roomEventSelector(room, LivekitRoomEvent.EncryptionError).pipe( + map((e) => { + const [err] = e; + if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) { + return ( + // Ideally we would pull the participant identity from the field on the error. + // However, it gets lost in the serialization process between workers. + // So, instead we do a string match + (err?.message.includes(participant.identity) && + err?.message.includes(criteria)) ?? + false + ); + } else if (encryptionSystem.kind === E2eeType.SHARED_KEY) { + return !!err?.message.includes(criteria); + } + + return false; + }), + ); + }), + distinctUntilChanged(), + throttleTime(1000), // Throttle to avoid spamming the UI + startWith(false), + ); +} + +function observeRemoteTrackReceivingOkay$( + participant: Participant, + source: Track.Source, +): Observable { + let lastStats: { + framesDecoded: number | undefined; + framesDropped: number | undefined; + framesReceived: number | undefined; + } = { + framesDecoded: undefined, + framesDropped: undefined, + framesReceived: undefined, + }; + + return observeInboundRtpStreamStats$(participant, source).pipe( + map((stats) => { + if (!stats) return undefined; + const { framesDecoded, framesDropped, framesReceived } = stats; + return { + framesDecoded, + framesDropped, + framesReceived, + }; + }), + filter((newStats) => !!newStats), + map((newStats): boolean | undefined => { + const oldStats = lastStats; + lastStats = newStats; + if ( + typeof newStats.framesReceived === "number" && + typeof oldStats.framesReceived === "number" && + typeof newStats.framesDecoded === "number" && + typeof oldStats.framesDecoded === "number" + ) { + const framesReceivedDelta = + newStats.framesReceived - oldStats.framesReceived; + const framesDecodedDelta = + newStats.framesDecoded - oldStats.framesDecoded; + + // if we received >0 frames and managed to decode >0 frames then we treat that as success + + if (framesReceivedDelta > 0) { + return framesDecodedDelta > 0; + } + } + + // no change + return undefined; + }), + filter((x) => typeof x === "boolean"), + startWith(undefined), + ); +} diff --git a/src/state/media/RemoteScreenShareViewModel.ts b/src/state/media/RemoteScreenShareViewModel.ts new file mode 100644 index 00000000..eff6d9c1 --- /dev/null +++ b/src/state/media/RemoteScreenShareViewModel.ts @@ -0,0 +1,44 @@ +/* +Copyright 2023, 2024 New Vector Ltd. +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 RemoteParticipant } from "livekit-client"; +import { map } from "rxjs"; + +import { type Behavior } from "../Behavior"; +import { + type BaseScreenShareInputs, + type BaseScreenShareViewModel, + createBaseScreenShare, +} from "./ScreenShareViewModel"; +import { type ObservableScope } from "../ObservableScope"; + +export interface RemoteScreenShareViewModel extends BaseScreenShareViewModel { + local: false; + /** + * Whether this screen share's video should be displayed. + */ + videoEnabled$: Behavior; +} + +export interface RemoteScreenShareInputs extends BaseScreenShareInputs { + participant$: Behavior; + pretendToBeDisconnected$: Behavior; +} + +export function createRemoteScreenShare( + scope: ObservableScope, + { pretendToBeDisconnected$, ...inputs }: RemoteScreenShareInputs, +): RemoteScreenShareViewModel { + return { + ...createBaseScreenShare(scope, inputs), + local: false, + videoEnabled$: scope.behavior( + pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)), + ), + }; +} diff --git a/src/state/media/RemoteUserMediaViewModel.ts b/src/state/media/RemoteUserMediaViewModel.ts new file mode 100644 index 00000000..4307dea4 --- /dev/null +++ b/src/state/media/RemoteUserMediaViewModel.ts @@ -0,0 +1,82 @@ +/* +Copyright 2023, 2024 New Vector Ltd. +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 RemoteParticipant } from "livekit-client"; +import { combineLatest, map, of, switchMap } from "rxjs"; + +import { type Behavior } from "../Behavior"; +import { createVolumeControls, type VolumeControls } from "../VolumeControls"; +import { + type BaseUserMediaInputs, + type BaseUserMediaViewModel, + createBaseUserMedia, +} from "./UserMediaViewModel"; +import { type ObservableScope } from "../ObservableScope"; + +export interface RemoteUserMediaViewModel + extends BaseUserMediaViewModel, VolumeControls { + local: false; + /** + * Whether we are waiting for this user's LiveKit participant to exist. This + * could be because either we or the remote party are still connecting. + */ + waitingForMedia$: Behavior; +} + +export interface RemoteUserMediaInputs extends Omit< + BaseUserMediaInputs, + "statsType" +> { + participant$: Behavior; + pretendToBeDisconnected$: Behavior; +} + +export function createRemoteUserMedia( + scope: ObservableScope, + { pretendToBeDisconnected$, ...inputs }: RemoteUserMediaInputs, +): RemoteUserMediaViewModel { + const baseUserMedia = createBaseUserMedia(scope, { + ...inputs, + statsType: "inbound-rtp", + }); + + return { + ...baseUserMedia, + ...createVolumeControls(scope, { + pretendToBeDisconnected$, + sink$: scope.behavior( + inputs.participant$.pipe(map((p) => (volume) => p?.setVolume(volume))), + ), + }), + local: false, + speaking$: scope.behavior( + pretendToBeDisconnected$.pipe( + switchMap((disconnected) => + disconnected ? of(false) : baseUserMedia.speaking$, + ), + ), + ), + videoEnabled$: scope.behavior( + pretendToBeDisconnected$.pipe( + switchMap((disconnected) => + disconnected ? of(false) : baseUserMedia.videoEnabled$, + ), + ), + ), + waitingForMedia$: scope.behavior( + combineLatest( + [inputs.livekitRoom$, inputs.participant$], + (livekitRoom, participant) => + // If livekitRoom is undefined, the user is not attempting to publish on + // any transport and so we shouldn't expect a participant. (They might + // be a subscribe-only bot for example.) + livekitRoom !== undefined && participant === null, + ), + ), + }; +} diff --git a/src/state/media/ScreenShareViewModel.ts b/src/state/media/ScreenShareViewModel.ts new file mode 100644 index 00000000..36cd9440 --- /dev/null +++ b/src/state/media/ScreenShareViewModel.ts @@ -0,0 +1,51 @@ +/* +Copyright 2023, 2024 New Vector Ltd. +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 { Track } from "livekit-client"; + +import { type ObservableScope } from "../ObservableScope"; +import { type LocalScreenShareViewModel } from "./LocalScreenShareViewModel"; +import { + createMemberMedia, + type MemberMediaInputs, + type MemberMediaViewModel, +} from "./MemberMediaViewModel"; +import { type RemoteScreenShareViewModel } from "./RemoteScreenShareViewModel"; + +/** + * A participant's screen share media. + */ +export type ScreenShareViewModel = + | LocalScreenShareViewModel + | RemoteScreenShareViewModel; + +/** + * Properties which are common to all ScreenShareViewModels. + */ +export interface BaseScreenShareViewModel extends MemberMediaViewModel { + type: "screen share"; +} + +export type BaseScreenShareInputs = Omit< + MemberMediaInputs, + "audioSource" | "videoSource" +>; + +export function createBaseScreenShare( + scope: ObservableScope, + inputs: BaseScreenShareInputs, +): BaseScreenShareViewModel { + return { + ...createMemberMedia(scope, { + ...inputs, + audioSource: Track.Source.ScreenShareAudio, + videoSource: Track.Source.ScreenShare, + }), + type: "screen share", + }; +} diff --git a/src/state/media/UserMediaViewModel.ts b/src/state/media/UserMediaViewModel.ts new file mode 100644 index 00000000..8da5e63a --- /dev/null +++ b/src/state/media/UserMediaViewModel.ts @@ -0,0 +1,143 @@ +/* +Copyright 2023, 2024 New Vector Ltd. +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 { + combineLatest, + map, + type Observable, + of, + Subject, + switchMap, +} from "rxjs"; +import { + observeParticipantEvents, + observeParticipantMedia, +} from "@livekit/components-core"; +import { ParticipantEvent, Track } from "livekit-client"; + +import { type ReactionOption } from "../../reactions"; +import { type Behavior } from "../Behavior"; +import { type LocalUserMediaViewModel } from "./LocalUserMediaViewModel"; +import { + createMemberMedia, + type MemberMediaInputs, + type MemberMediaViewModel, +} from "./MemberMediaViewModel"; +import { type RemoteUserMediaViewModel } from "./RemoteUserMediaViewModel"; +import { type ObservableScope } from "../ObservableScope"; +import { createToggle$ } from "../../utils/observable"; +import { showConnectionStats } from "../../settings/settings"; +import { observeRtpStreamStats$ } from "./observeRtpStreamStats"; + +/** + * A participant's user media (i.e. their microphone and camera feed). + */ +export type UserMediaViewModel = + | LocalUserMediaViewModel + | RemoteUserMediaViewModel; + +export interface BaseUserMediaViewModel extends MemberMediaViewModel { + type: "user"; + speaking$: Behavior; + audioEnabled$: Behavior; + videoEnabled$: Behavior; + cropVideo$: Behavior; + toggleCropVideo: () => void; + /** + * The expected identity of the LiveKit participant. Exposed for debugging. + */ + rtcBackendIdentity: string; + handRaised$: Behavior; + reaction$: Behavior; + audioStreamStats$: Observable< + RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined + >; + videoStreamStats$: Observable< + RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined + >; +} + +export interface BaseUserMediaInputs extends Omit< + MemberMediaInputs, + "audioSource" | "videoSource" +> { + rtcBackendIdentity: string; + handRaised$: Behavior; + reaction$: Behavior; + statsType: "inbound-rtp" | "outbound-rtp"; +} + +export function createBaseUserMedia( + scope: ObservableScope, + { + rtcBackendIdentity, + handRaised$, + reaction$, + statsType, + ...inputs + }: BaseUserMediaInputs, +): BaseUserMediaViewModel { + const { participant$ } = inputs; + const media$ = scope.behavior( + participant$.pipe( + switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)), + ), + ); + const toggleCropVideo$ = new Subject(); + + return { + ...createMemberMedia(scope, { + ...inputs, + audioSource: Track.Source.Microphone, + videoSource: Track.Source.Camera, + }), + type: "user", + speaking$: scope.behavior( + participant$.pipe( + switchMap((p) => + p + ? observeParticipantEvents( + p, + ParticipantEvent.IsSpeakingChanged, + ).pipe(map((p) => p.isSpeaking)) + : of(false), + ), + ), + ), + audioEnabled$: scope.behavior( + media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)), + ), + videoEnabled$: scope.behavior( + media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)), + ), + cropVideo$: createToggle$(scope, true, toggleCropVideo$), + toggleCropVideo: () => toggleCropVideo$.next(), + rtcBackendIdentity, + handRaised$, + reaction$, + audioStreamStats$: combineLatest([ + participant$, + showConnectionStats.value$, + ]).pipe( + switchMap(([p, showConnectionStats]) => { + // + if (!p || !showConnectionStats) return of(undefined); + return observeRtpStreamStats$(p, Track.Source.Microphone, statsType); + }), + ), + videoStreamStats$: combineLatest([ + participant$, + showConnectionStats.value$, + ]).pipe( + switchMap(([p, showConnectionStats]) => { + if (!p || !showConnectionStats) return of(undefined); + return observeRtpStreamStats$(p, Track.Source.Camera, statsType); + }), + ), + }; +} diff --git a/src/state/media/observeRtpStreamStats.ts b/src/state/media/observeRtpStreamStats.ts new file mode 100644 index 00000000..695edc65 --- /dev/null +++ b/src/state/media/observeRtpStreamStats.ts @@ -0,0 +1,59 @@ +/* +Copyright 2023, 2024 New Vector Ltd. +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 { + LocalTrack, + type Participant, + RemoteTrack, + type Track, +} from "livekit-client"; +import { + combineLatest, + interval, + type Observable, + startWith, + switchMap, +} from "rxjs"; + +import { observeTrackReference$ } from "../observeTrackReference"; + +export function observeRtpStreamStats$( + participant: Participant, + source: Track.Source, + type: "inbound-rtp" | "outbound-rtp", +): Observable< + RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined +> { + return combineLatest([ + observeTrackReference$(participant, source), + interval(1000).pipe(startWith(0)), + ]).pipe( + switchMap(async ([trackReference]) => { + const track = trackReference?.publication?.track; + if ( + !track || + !(track instanceof RemoteTrack || track instanceof LocalTrack) + ) { + return undefined; + } + const report = await track.getRTCStatsReport(); + if (!report) { + return undefined; + } + + for (const v of report.values()) { + if (v.type === type) { + return v; + } + } + + return undefined; + }), + startWith(undefined), + ); +} diff --git a/src/state/observeSpeaker.test.ts b/src/state/media/observeSpeaker.test.ts similarity index 98% rename from src/state/observeSpeaker.test.ts rename to src/state/media/observeSpeaker.test.ts index 224916d2..18622fb8 100644 --- a/src/state/observeSpeaker.test.ts +++ b/src/state/media/observeSpeaker.test.ts @@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details. import { describe, test } from "vitest"; -import { withTestScheduler } from "../utils/test"; +import { withTestScheduler } from "../../utils/test"; import { observeSpeaker$ } from "./observeSpeaker"; const yesNo = { diff --git a/src/state/observeSpeaker.ts b/src/state/media/observeSpeaker.ts similarity index 100% rename from src/state/observeSpeaker.ts rename to src/state/media/observeSpeaker.ts diff --git a/src/state/observeTrackReference.ts b/src/state/observeTrackReference.ts new file mode 100644 index 00000000..8e295d05 --- /dev/null +++ b/src/state/observeTrackReference.ts @@ -0,0 +1,28 @@ +/* +Copyright 2023, 2024 New Vector Ltd. +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 { + observeParticipantMedia, + type TrackReference, +} from "@livekit/components-core"; +import { type Participant, type Track } from "livekit-client"; +import { distinctUntilChanged, map, type Observable } from "rxjs"; + +/** + * Reactively reads a participant's track reference for a given media source. + */ +export function observeTrackReference$( + participant: Participant, + source: Track.Source, +): Observable { + return observeParticipantMedia(participant).pipe( + map(() => participant.getTrackPublication(source)), + distinctUntilChanged(), + map((publication) => publication && { participant, publication, source }), + ); +} diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 370e0723..9c3adea7 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -39,11 +39,6 @@ import { import { useObservableEagerState } from "observable-hooks"; import styles from "./GridTile.module.css"; -import { - type UserMediaViewModel, - type LocalUserMediaViewModel, - type RemoteUserMediaViewModel, -} from "../state/MediaViewModel"; import { Slider } from "../Slider"; import { MediaView } from "./MediaView"; import { useLatest } from "../useLatest"; @@ -51,6 +46,9 @@ import { type GridTileViewModel } from "../state/TileViewModel"; import { useMergedRefs } from "../useMergedRefs"; import { useReactionsSender } from "../reactions/useReactionsSender"; import { useBehavior } from "../useBehavior"; +import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewModel"; +import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel"; +import { type UserMediaViewModel } from "../state/media/UserMediaViewModel"; interface TileProps { ref?: Ref; diff --git a/src/tile/MediaView.test.tsx b/src/tile/MediaView.test.tsx index fdfdb631..a509d3a5 100644 --- a/src/tile/MediaView.test.tsx +++ b/src/tile/MediaView.test.tsx @@ -18,7 +18,7 @@ import { TrackInfo } from "@livekit/protocol"; import { type ComponentProps } from "react"; import { MediaView } from "./MediaView"; -import { EncryptionStatus } from "../state/MediaViewModel"; +import { EncryptionStatus } from "../state/media/MemberMediaViewModel"; import { mockLocalParticipant } from "../utils/test"; describe("MediaView", () => { diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index c349fc7e..f912c069 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -16,7 +16,7 @@ import { ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/ico import styles from "./MediaView.module.css"; import { Avatar } from "../Avatar"; -import { type EncryptionStatus } from "../state/MediaViewModel"; +import { type EncryptionStatus } from "../state/media/MemberMediaViewModel"; import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator"; import { showConnectionStats as showConnectionStatsSetting, diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index c0f1135f..75c69479 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -32,21 +32,19 @@ import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react"; import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react"; import { MediaView } from "./MediaView"; import styles from "./SpotlightTile.module.css"; -import { - type EncryptionStatus, - type LocalUserMediaViewModel, - type MediaViewModel, - type UserMediaViewModel, - type RemoteUserMediaViewModel, - type ScreenShareViewModel, - type RemoteScreenShareViewModel, -} from "../state/MediaViewModel"; import { useInitial } from "../useInitial"; import { useMergedRefs } from "../useMergedRefs"; import { useReactiveState } from "../useReactiveState"; import { useLatest } from "../useLatest"; import { type SpotlightTileViewModel } from "../state/TileViewModel"; import { useBehavior } from "../useBehavior"; +import { type EncryptionStatus } from "../state/media/MemberMediaViewModel"; +import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewModel"; +import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel"; +import { type UserMediaViewModel } from "../state/media/UserMediaViewModel"; +import { type ScreenShareViewModel } from "../state/media/ScreenShareViewModel"; +import { type RemoteScreenShareViewModel } from "../state/media/RemoteScreenShareViewModel"; +import { type MediaViewModel } from "../state/media/MediaViewModel"; interface SpotlightItemBaseProps { ref?: Ref; diff --git a/src/utils/test.ts b/src/utils/test.ts index 9231a3d1..c1e67927 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -52,12 +52,6 @@ import { } from "matrix-js-sdk/lib/matrixrtc/IKeyTransport"; import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; -import { - createLocalUserMedia, - createRemoteUserMedia, - type LocalUserMediaViewModel, - type RemoteUserMediaViewModel, -} from "../state/MediaViewModel"; import { E2eeType } from "../e2ee/e2eeType"; import { DEFAULT_CONFIG, @@ -68,6 +62,14 @@ import { type MediaDevices } from "../state/MediaDevices"; import { type Behavior, constant } from "../state/Behavior"; import { ObservableScope } from "../state/ObservableScope"; import { MuteStates } from "../state/MuteStates"; +import { + createLocalUserMedia, + type LocalUserMediaViewModel, +} from "../state/media/LocalUserMediaViewModel"; +import { + createRemoteUserMedia, + type RemoteUserMediaViewModel, +} from "../state/media/RemoteUserMediaViewModel"; export function withFakeTimers(continuation: () => void): void { vi.useFakeTimers(); From 1de8d93b4bdb5090f4112aec0af49736b4e00820 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 25 Feb 2026 15:47:25 +0100 Subject: [PATCH 040/119] feat: video auto fit based on video stream size --- src/state/MediaViewModel.test.ts | 15 -- src/state/MediaViewModel.ts | 63 ++++++-- src/tile/GridTile.tsx | 41 ++--- src/tile/SpotlightTile.tsx | 22 ++- src/utils/videoFit.test.ts | 251 +++++++++++++++++++++++++++++++ src/utils/videoFit.ts | 94 ++++++++++++ 6 files changed, 441 insertions(+), 45 deletions(-) create mode 100644 src/utils/videoFit.test.ts create mode 100644 src/utils/videoFit.ts diff --git a/src/state/MediaViewModel.test.ts b/src/state/MediaViewModel.test.ts index 92868216..a7bbb571 100644 --- a/src/state/MediaViewModel.test.ts +++ b/src/state/MediaViewModel.test.ts @@ -92,21 +92,6 @@ test("control a participant's volume", () => { }); }); -test("toggle fit/contain for a participant's video", () => { - const vm = createRemoteMedia(rtcMembership, {}, mockRemoteParticipant({})); - withTestScheduler(({ expectObservable, schedule }) => { - schedule("-ab|", { - a: () => vm.toggleFitContain(), - b: () => vm.toggleFitContain(), - }); - expectObservable(vm.cropVideo$).toBe("abc", { - a: true, - b: false, - c: true, - }); - }); -}); - test("local media remembers whether it should always be shown", () => { const vm1 = createLocalMedia( rtcMembership, diff --git a/src/state/MediaViewModel.ts b/src/state/MediaViewModel.ts index 3da69c46..57b0428a 100644 --- a/src/state/MediaViewModel.ts +++ b/src/state/MediaViewModel.ts @@ -43,6 +43,8 @@ import { switchMap, throttleTime, distinctUntilChanged, + concat, + take, } from "rxjs"; import { alwaysShowSelf } from "../settings/settings"; @@ -55,6 +57,7 @@ import { platform } from "../Platform"; import { type MediaDevices } from "./MediaDevices"; import { type Behavior } from "./Behavior"; import { type ObservableScope } from "./ObservableScope"; +import { videoFit$, videoSizeFromParticipant$ } from "../utils/videoFit.ts"; export function observeTrackReference$( participant: Participant, @@ -67,6 +70,10 @@ export function observeTrackReference$( ); } +/** + * Helper function to observe the RTC stats for a given participant and track source. + * It polls the stats every second and emits the latest stats object. + */ export function observeRtpStreamStats$( participant: Participant, source: Track.Source, @@ -76,7 +83,9 @@ export function observeRtpStreamStats$( > { return combineLatest([ observeTrackReference$(participant, source), - interval(1000).pipe(startWith(0)), + // This is used also for detecting video orientation, + // and we want that to be more responsive than the connection stats, so we poll more frequently at the start. + concat(interval(300).pipe(take(3)), interval(1000)).pipe(startWith(0)), ]).pipe( switchMap(async ([trackReference]) => { const track = trackReference?.publication?.track; @@ -90,7 +99,6 @@ export function observeRtpStreamStats$( if (!report) { return undefined; } - for (const v of report.values()) { if (v.type === type) { return v; @@ -103,6 +111,13 @@ export function observeRtpStreamStats$( ); } +/** + * Helper function to observe the inbound RTP stats for a given participant and track source. + * To be used for remote participants' audio and video tracks. + * It polls the stats every second and emits the latest stats object. + * @param participant - The LiveKit participant whose track stats we want to observe. + * @param source - The source of the track (e.g. Track.Source.Camera or Track.Source.Microphone). + */ export function observeInboundRtpStreamStats$( participant: Participant, source: Track.Source, @@ -112,6 +127,13 @@ export function observeInboundRtpStreamStats$( ); } +/** + * Helper function to observe the outbound RTP stats for a given participant and track source. + * To be used for the local participant's audio and video tracks. + * It polls the stats every second and emits the latest stats object. + * @param participant - The LiveKit participant whose track stats we want to observe. + * @param source - The source of the track (e.g. Track.Source.Camera or Track.Source.Microphone). + */ export function observeOutboundRtpStreamStats$( participant: Participant, source: Track.Source, @@ -263,7 +285,6 @@ abstract class BaseMediaViewModel { protected readonly participant$: Observable< LocalParticipant | RemoteParticipant | null >, - encryptionSystem: EncryptionSystem, audioSource: AudioSource, videoSource: VideoSource, @@ -397,13 +418,12 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { return this._videoEnabled$; } - private readonly _cropVideo$ = new BehaviorSubject(true); /** - * Whether the tile video should be contained inside the tile or be cropped to fit. + * Whether the tile video should be contained inside the tile (video-fit contain) or be cropped to fit (video-fit cover). */ - public readonly cropVideo$: Behavior = this._cropVideo$; + public readonly videoFit$: Behavior<"cover" | "contain">; - public constructor( + protected constructor( scope: ObservableScope, id: string, userId: string, @@ -443,10 +463,12 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { this._videoEnabled$ = this.scope.behavior( media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)), ); - } - public toggleFitContain(): void { - this._cropVideo$.next(!this._cropVideo$.value); + this.videoFit$ = videoFit$( + this.scope, + videoSizeFromParticipant$(participant$), + this.actualSize$, + ); } public get local(): boolean { @@ -456,9 +478,28 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { public abstract get audioStreamStats$(): Observable< RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined >; + public abstract get videoStreamStats$(): Observable< RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined >; + + private readonly _actualSize$ = new BehaviorSubject< + { width: number; height: number } | undefined + >(undefined); + public readonly actualSize$ = this._actualSize$.asObservable(); + + /** + * Set the actual dimensions of the html element. + * This can be used to determine the best video fit (fit to frame / keep ratio). + * @param width - The actual width of the html element displaying the video. + * @param height - The actual height of the html element displaying the video. + */ + public setActualDimensions(width: number, height: number): void { + this._actualSize$.next({ + width, + height, + }); + } } /** @@ -616,6 +657,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { // This private field is used to override the value from the superclass private __speaking$: Behavior; + public get speaking$(): Behavior { return this.__speaking$; } @@ -661,6 +703,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { // This private field is used to override the value from the superclass private __videoEnabled$: Behavior; + public get videoEnabled$(): Behavior { return this.__videoEnabled$; } diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 92262f05..ad158db1 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -11,6 +11,7 @@ import { type ReactNode, type Ref, useCallback, + useEffect, useRef, useState, } from "react"; @@ -26,7 +27,6 @@ import { VolumeOffIcon, VisibilityOnIcon, UserProfileIcon, - ExpandIcon, VolumeOffSolidIcon, SwitchCameraSolidIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; @@ -37,6 +37,7 @@ import { Menu, } from "@vector-im/compound-web"; import { useObservableEagerState } from "observable-hooks"; +import useMeasure from "react-use-measure"; import styles from "./GridTile.module.css"; import { @@ -105,18 +106,26 @@ const UserMediaTile: FC = ({ const audioEnabled = useBehavior(vm.audioEnabled$); const videoEnabled = useBehavior(vm.videoEnabled$); const speaking = useBehavior(vm.speaking$); - const cropVideo = useBehavior(vm.cropVideo$); - const onSelectFitContain = useCallback( - (e: Event) => { - e.preventDefault(); - vm.toggleFitContain(); - }, - [vm], - ); + const videoFit = useBehavior(vm.videoFit$); + const rtcBackendIdentity = vm.rtcBackendIdentity; const handRaised = useBehavior(vm.handRaised$); const reaction = useBehavior(vm.reaction$); + // We need to keep track of the tile size. + // We use this to get the tile ratio, and compare it to the video ratio to decide + // whether to fit the video to frame or keep the ratio. + const [measureRef, bounds] = useMeasure(); + // There is already a ref being passed in, so we need to merge it with the measureRef. + const tileRef = useMergedRefs(ref, measureRef); + + // Whenever bounds change, inform the viewModel + useEffect(() => { + if (bounds.width > 0 && bounds.height > 0) { + vm.setActualDimensions(bounds.width, bounds.height); + } + }, [bounds.width, bounds.height, vm]); + const AudioIcon = locallyMuted ? VolumeOffSolidIcon : audioEnabled @@ -132,12 +141,10 @@ const UserMediaTile: FC = ({ const menu = ( <> {menuStart} - + {/* + No additional menu item (used to be the manual fit to frame. + Placeholder for future menu items that should be placed here. + */} {menuEnd} ); @@ -150,13 +157,13 @@ const UserMediaTile: FC = ({ const tile = ( = ({ vm, ...props }) => { - const cropVideo = useBehavior(vm.cropVideo$); + const videoFit = useBehavior(vm.videoFit$); const baseProps: SpotlightUserMediaItemBaseProps & RefAttributes = { - videoFit: cropVideo ? "cover" : "contain", + videoFit, ...props, }; @@ -147,7 +148,22 @@ const SpotlightItem: FC = ({ "aria-hidden": ariaHidden, }) => { const ourRef = useRef(null); - const ref = useMergedRefs(ourRef, theirRef); + + // We need to keep track of the tile size. + // We use this to get the tile ratio, and compare it to the video ratio to decide + // whether to fit the video to frame or keep the ratio. + const [measureRef, bounds] = useMeasure(); + + // Whenever bounds change, inform the viewModel + useEffect(() => { + if (bounds.width > 0 && bounds.height > 0) { + if (!(vm instanceof ScreenShareViewModel)) { + vm.setActualDimensions(bounds.width, bounds.height); + } + } + }, [bounds.width, bounds.height, vm]); + + const ref = useMergedRefs(ourRef, theirRef, measureRef); const focusUrl = useBehavior(vm.focusUrl$); const displayName = useBehavior(vm.displayName$); const mxcAvatarUrl = useBehavior(vm.mxcAvatarUrl$); diff --git a/src/utils/videoFit.test.ts b/src/utils/videoFit.test.ts new file mode 100644 index 00000000..9390e8d4 --- /dev/null +++ b/src/utils/videoFit.test.ts @@ -0,0 +1,251 @@ +/* +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, test, vi } from "vitest"; +import { + LocalTrack, + type LocalTrackPublication, + type RemoteTrackPublication, + Track, +} from "livekit-client"; + +import { ObservableScope } from "../state/ObservableScope"; +import { videoFit$, videoSizeFromParticipant$ } from "./videoFit"; +import { constant } from "../state/Behavior"; +import { + flushPromises, + mockLocalParticipant, + mockRemoteParticipant, +} from "./test"; + +describe("videoFit$ defaults", () => { + test.each([ + { + videoSize: { width: 1920, height: 1080 }, + tileSize: undefined, + }, + { + videoSize: { width: 1080, height: 1920 }, + tileSize: undefined, + }, + { + videoSize: undefined, + tileSize: { width: 1920, height: 1080 }, + }, + { + videoSize: undefined, + tileSize: { width: 1080, height: 1920 }, + }, + ])( + "videoFit$ returns `cover` when videoSize is $videoSize and tileSize is $tileSize", + ({ videoSize, tileSize }) => { + const scope = new ObservableScope(); + const videoSize$ = constant(videoSize); + const tileSize$ = constant(tileSize); + + const fit = videoFit$(scope, videoSize$, tileSize$); + expect(fit.value).toBe("cover"); + }, + ); +}); + +const VIDEO_480_L = { width: 640, height: 480 }; +const VIDEO_720_L = { width: 1280, height: 720 }; +const VIDEO_1080_L = { width: 1920, height: 1080 }; + +// Some sizes from real world testing, which don't match the standard video sizes exactly +const TILE_SIZE_1_L = { width: 180, height: 135 }; +const TILE_SIZE_3_P = { width: 379, height: 542 }; +const TILE_SIZE_4_L = { width: 957, height: 542 }; +// This is the size of an iPhone Xr in portrait mode +const TILE_SIZE_5_P = { width: 414, height: 896 }; + +export function invertSize(size: { width: number; height: number }): { + width: number; + height: number; +} { + return { + width: size.height, + height: size.width, + }; +} + +test.each([ + { + videoSize: VIDEO_480_L, + tileSize: TILE_SIZE_1_L, + expected: "cover", + }, + { + videoSize: invertSize(VIDEO_480_L), + tileSize: TILE_SIZE_1_L, + expected: "contain", + }, + { + videoSize: VIDEO_720_L, + tileSize: TILE_SIZE_4_L, + expected: "cover", + }, + { + videoSize: invertSize(VIDEO_720_L), + tileSize: TILE_SIZE_4_L, + expected: "contain", + }, + { + videoSize: invertSize(VIDEO_1080_L), + tileSize: TILE_SIZE_3_P, + expected: "cover", + }, + { + videoSize: VIDEO_1080_L, + tileSize: TILE_SIZE_5_P, + expected: "contain", + }, + { + videoSize: invertSize(VIDEO_1080_L), + tileSize: TILE_SIZE_5_P, + expected: "cover", + }, + { + // square video + videoSize: { width: 400, height: 400 }, + tileSize: VIDEO_480_L, + expected: "contain", + }, +])( + "videoFit$ returns $expected when videoSize is $videoSize and tileSize is $tileSize", + ({ videoSize, tileSize, expected }) => { + const scope = new ObservableScope(); + const videoSize$ = constant(videoSize); + const tileSize$ = constant(tileSize); + + const fit = videoFit$(scope, videoSize$, tileSize$); + expect(fit.value).toBe(expected); + }, +); + +describe("extracting video size from participant stats", () => { + function createMockRtpStats( + isInbound: boolean, + props: Partial = {}, + ): RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats { + const baseStats = { + id: "mock-stats-id", + timestamp: Date.now(), + type: isInbound ? "inbound-rtp" : "outbound-rtp", + kind: "video", + ...props, + }; + + return baseStats as RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats; + } + + test("get stats for local user", async () => { + const localParticipant = mockLocalParticipant({ + identity: "@local:example.org:AAAAAA", + }); + + const mockReport: RTCStatsReport = new Map([ + [ + "OT01V639885149", + createMockRtpStats(false, { + frameWidth: 1280, + frameHeight: 720, + }), + ], + ]); + + const track = { + source: Track.Source.Camera, + getRTCStatsReport: vi + .fn() + .mockImplementation(async () => Promise.resolve(mockReport)), + } as Partial as LocalTrack; + + // Set up the prototype chain (there is an instanceof check in getRTCStatsReport) + Object.setPrototypeOf(track, LocalTrack.prototype); + + localParticipant.getTrackPublication = vi + .fn() + .mockImplementation((source: Track.Source) => { + if (source === Track.Source.Camera) { + return { + track, + } as unknown as LocalTrackPublication; + } else { + return undefined; + } + }); + + const videoDimensions$ = videoSizeFromParticipant$( + constant(localParticipant), + ); + + const publishedDimensions: { width: number; height: number }[] = []; + videoDimensions$.subscribe((dimensions) => { + if (dimensions) publishedDimensions.push(dimensions); + }); + + await flushPromises(); + + const dimension = publishedDimensions.pop(); + expect(dimension).toEqual({ width: 1280, height: 720 }); + }); + + test("get stats for remote user", async () => { + // vi.useFakeTimers() + const remoteParticipant = mockRemoteParticipant({ + identity: "@bob:example.org:AAAAAA", + }); + + const mockReport: RTCStatsReport = new Map([ + [ + "OT01V639885149", + createMockRtpStats(true, { + frameWidth: 480, + frameHeight: 640, + }), + ], + ]); + + const track = { + source: Track.Source.Camera, + getRTCStatsReport: vi + .fn() + .mockImplementation(async () => Promise.resolve(mockReport)), + } as Partial as LocalTrack; + + // Set up the prototype chain (there is an instanceof check in getRTCStatsReport) + Object.setPrototypeOf(track, LocalTrack.prototype); + + remoteParticipant.getTrackPublication = vi + .fn() + .mockImplementation((source: Track.Source) => { + if (source === Track.Source.Camera) { + return { + track, + } as unknown as RemoteTrackPublication; + } else { + return undefined; + } + }); + + const videoDimensions$ = videoSizeFromParticipant$( + constant(remoteParticipant), + ); + + const publishedDimensions: { width: number; height: number }[] = []; + videoDimensions$.subscribe((dimensions) => { + if (dimensions) publishedDimensions.push(dimensions); + }); + + await flushPromises(); + + const dimension = publishedDimensions.pop(); + expect(dimension).toEqual({ width: 480, height: 640 }); + }); +}); diff --git a/src/utils/videoFit.ts b/src/utils/videoFit.ts new file mode 100644 index 00000000..fdd91be7 --- /dev/null +++ b/src/utils/videoFit.ts @@ -0,0 +1,94 @@ +/* +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 { combineLatest, map, type Observable, of, switchMap } from "rxjs"; +import { + type LocalParticipant, + type RemoteParticipant, + Track, +} from "livekit-client"; + +import { type ObservableScope } from "../state/ObservableScope.ts"; +import { type Behavior } from "../state/Behavior.ts"; +import { + observeInboundRtpStreamStats$, + observeOutboundRtpStreamStats$, +} from "../state/MediaViewModel.ts"; + +type Size = { + width: number; + height: number; +}; + +export function videoFit$( + scope: ObservableScope, + videoSize$: Observable, + tileSize$: Observable, +): Behavior<"cover" | "contain"> { + const fit$ = combineLatest([videoSize$, tileSize$]).pipe( + map(([videoSize, tileSize]) => { + if (!videoSize || !tileSize) { + // If we don't have the sizes, default to cover to avoid black bars. + // This is a reasonable default as it will ensure the video fills the tile, even if it means cropping. + return "cover"; + } + const videoAspectRatio = videoSize.width / videoSize.height; + const tileAspectRatio = tileSize.width / tileSize.height; + + // If video is landscape (ratio > 1) and tile is portrait (ratio < 1) or vice versa, + // we want to use "contain" (fit) mode to avoid excessive cropping + const videoIsLandscape = videoAspectRatio > 1; + const tileIsLandscape = tileAspectRatio > 1; + + // If the orientations are the same, use the cover mode (Preserves the aspect ratio, and the image fills the container.) + // If they're not the same orientation, use the contain mode (Preserves the aspect ratio, but the image is letterboxed - black bars- to fit within the container.) + return videoIsLandscape === tileIsLandscape ? "cover" : "contain"; + }), + ); + + return scope.behavior(fit$, "cover"); +} + +/** + * Helper function to get the video size from a participant. + * It observes the participant's video track stats and extracts the frame width and height. + * @param participant$ - an Observable of a LocalParticipant or RemoteParticipant, or null if no participant is selected. + * @returns an Observable of the video size (width and height) or undefined if the size cannot be determined. + */ +export function videoSizeFromParticipant$( + participant$: Observable, +): Observable<{ width: number; height: number } | undefined> { + return participant$ + .pipe( + // If we have a participant, observe their video track stats. If not, return undefined. + switchMap((p) => { + if (!p) return of(undefined); + if (p.isLocal) { + return observeOutboundRtpStreamStats$(p, Track.Source.Camera); + } else { + return observeInboundRtpStreamStats$(p, Track.Source.Camera); + } + }), + ) + .pipe( + // Extract the frame width and height from the stats. If we don't have valid stats, return undefined. + map((stats) => { + if (!stats) return undefined; + if ( + // For video tracks, frameWidth and frameHeight should be numbers. If they're not, we can't determine the size. + typeof stats.frameWidth !== "number" || + typeof stats.frameHeight !== "number" + ) { + return undefined; + } + return { + width: stats.frameWidth, + height: stats.frameHeight, + }; + }), + ); +} From 15aa67ebb996119a577ffa0907b2742c1208b1cd Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 26 Feb 2026 16:46:08 +0100 Subject: [PATCH 041/119] remove unused fit to frame translation --- locales/en/app.json | 1 - 1 file changed, 1 deletion(-) diff --git a/locales/en/app.json b/locales/en/app.json index 0b0ac7b4..9a85478f 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -250,7 +250,6 @@ "video_tile": { "always_show": "Always show", "camera_starting": "Video loading...", - "change_fit_contain": "Fit to frame", "collapse": "Collapse", "expand": "Expand", "mute_for_me": "Mute for me", From ae8b1f840f188d6d428cd5f360dae17238a711d1 Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 26 Feb 2026 17:02:43 +0100 Subject: [PATCH 042/119] add missing mocking --- src/tile/GridTile.test.tsx | 18 +++++++++++++++++- src/tile/SpotlightTile.test.tsx | 18 +++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index 9bc0efb2..060119ef 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details. */ import { type RemoteTrackPublication } from "livekit-client"; -import { test, expect } from "vitest"; +import { test, expect, beforeAll } from "vitest"; import { render, screen } from "@testing-library/react"; import { axe } from "vitest-axe"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; @@ -28,6 +28,22 @@ global.IntersectionObserver = class MockIntersectionObserver { public disconnect(): void {} } as unknown as typeof IntersectionObserver; +// Mock ResizeObserver as it is needed by the useMeasure hook used in the GridTile, but is not implemented in JSDOM. +// We just need to mock it with empty methods as we don't need to test its functionality here. +beforeAll(() => { + window.ResizeObserver = class ResizeObserver { + public observe(): void { + // do nothing + } + public unobserve(): void { + // do nothing + } + public disconnect(): void { + // do nothing + } + }; +}); + test("GridTile is accessible", async () => { const vm = createRemoteMedia( mockRtcMembership("@alice:example.org", "AAAA"), diff --git a/src/tile/SpotlightTile.test.tsx b/src/tile/SpotlightTile.test.tsx index 981c0369..441b74c4 100644 --- a/src/tile/SpotlightTile.test.tsx +++ b/src/tile/SpotlightTile.test.tsx @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { test, expect, vi } from "vitest"; +import { test, expect, vi, beforeAll } from "vitest"; import { isInaccessible, render, screen } from "@testing-library/react"; import { axe } from "vitest-axe"; import userEvent from "@testing-library/user-event"; @@ -27,6 +27,22 @@ global.IntersectionObserver = class MockIntersectionObserver { public unobserve(): void {} } as unknown as typeof IntersectionObserver; +// Mock ResizeObserver as it is needed by the useMeasure hook used in the SpotlightTile, but is not implemented in JSDOM. +// We just need to mock it with empty methods as we don't need to test its functionality here. +beforeAll(() => { + window.ResizeObserver = class ResizeObserver { + public observe(): void { + // do nothing + } + public unobserve(): void { + // do nothing + } + public disconnect(): void { + // do nothing + } + }; +}); + test("SpotlightTile is accessible", async () => { const vm1 = createRemoteMedia( mockRtcMembership("@alice:example.org", "AAAA"), From 9930288d1f43254521a1e213de29d03929d37ab7 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 27 Feb 2026 17:08:04 +0100 Subject: [PATCH 043/119] Move observeInboundRtpStreamStats$ into the appropriate file --- src/state/media/MemberMediaViewModel.ts | 11 +---------- src/state/media/observeRtpStreamStats.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/state/media/MemberMediaViewModel.ts b/src/state/media/MemberMediaViewModel.ts index de64d93b..b7c2549d 100644 --- a/src/state/media/MemberMediaViewModel.ts +++ b/src/state/media/MemberMediaViewModel.ts @@ -37,7 +37,7 @@ import { type EncryptionSystem } from "../../e2ee/sharedKeyManagement"; import { type ObservableScope } from "../ObservableScope"; import { observeTrackReference$ } from "../observeTrackReference"; import { E2eeType } from "../../e2ee/e2eeType"; -import { observeRtpStreamStats$ } from "./observeRtpStreamStats"; +import { observeInboundRtpStreamStats$ } from "./observeRtpStreamStats"; export enum EncryptionStatus { Connecting, @@ -181,15 +181,6 @@ export function createMemberMedia( }; } -function observeInboundRtpStreamStats$( - participant: Participant, - source: Track.Source, -): Observable { - return observeRtpStreamStats$(participant, source, "inbound-rtp").pipe( - map((x) => x as RTCInboundRtpStreamStats | undefined), - ); -} - function encryptionErrorObservable$( room$: Behavior, participant: Participant, diff --git a/src/state/media/observeRtpStreamStats.ts b/src/state/media/observeRtpStreamStats.ts index 695edc65..d1640382 100644 --- a/src/state/media/observeRtpStreamStats.ts +++ b/src/state/media/observeRtpStreamStats.ts @@ -18,6 +18,7 @@ import { type Observable, startWith, switchMap, + map, } from "rxjs"; import { observeTrackReference$ } from "../observeTrackReference"; @@ -57,3 +58,12 @@ export function observeRtpStreamStats$( startWith(undefined), ); } + +export function observeInboundRtpStreamStats$( + participant: Participant, + source: Track.Source, +): Observable { + return observeRtpStreamStats$(participant, source, "inbound-rtp").pipe( + map((x) => x as RTCInboundRtpStreamStats | undefined), + ); +} From 0ce24929dc38b7f2caf13fd4bd8d6789508fd9bc Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 27 Feb 2026 17:12:24 +0100 Subject: [PATCH 044/119] Add TODO to clean up encryption status code --- src/state/media/MemberMediaViewModel.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/state/media/MemberMediaViewModel.ts b/src/state/media/MemberMediaViewModel.ts index b7c2549d..e7f57b59 100644 --- a/src/state/media/MemberMediaViewModel.ts +++ b/src/state/media/MemberMediaViewModel.ts @@ -39,6 +39,7 @@ import { observeTrackReference$ } from "../observeTrackReference"; import { E2eeType } from "../../e2ee/e2eeType"; import { observeInboundRtpStreamStats$ } from "./observeRtpStreamStats"; +// TODO: Encryption status is kinda broken and thus unused right now. Remove? export enum EncryptionStatus { Connecting, Okay, From 8e7e0992949f2a3812fc38bd691be2bba2a163b9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 01:11:01 +0000 Subject: [PATCH 045/119] Update docker/login-action digest to c94ce9f --- .github/workflows/build-and-publish-docker.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-publish-docker.yaml b/.github/workflows/build-and-publish-docker.yaml index 68f7131c..f6eeb7c2 100644 --- a/.github/workflows/build-and-publish-docker.yaml +++ b/.github/workflows/build-and-publish-docker.yaml @@ -69,7 +69,7 @@ jobs: services/-repositories/secret/data/oci.element.io password | OCI_PASSWORD ; - name: Login to oci.element.io Registry - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 if: github.event_name != 'pull_request' with: registry: oci-push.vpn.infra.element.io From b2475e4d1d36766f285463458b43fe79c2139c75 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 01:11:41 +0000 Subject: [PATCH 046/119] Update embedded package dependencies --- embedded/android/gradle/libs.versions.toml | 4 ++-- embedded/android/gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/embedded/android/gradle/libs.versions.toml b/embedded/android/gradle/libs.versions.toml index 5a91e19e..a93dc56e 100644 --- a/embedded/android/gradle/libs.versions.toml +++ b/embedded/android/gradle/libs.versions.toml @@ -2,11 +2,11 @@ # https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format [versions] -android_gradle_plugin = "8.13.1" +android_gradle_plugin = "8.13.2" [libraries] android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" } [plugins] android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" } -maven_publish = { id = "com.vanniktech.maven.publish", version = "0.35.0" } \ No newline at end of file +maven_publish = { id = "com.vanniktech.maven.publish", version = "0.36.0" } \ No newline at end of file diff --git a/embedded/android/gradle/wrapper/gradle-wrapper.properties b/embedded/android/gradle/wrapper/gradle-wrapper.properties index 7705927e..de413606 100644 --- a/embedded/android/gradle/wrapper/gradle-wrapper.properties +++ b/embedded/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From adc329a7e7538fd4e5545ce61d5c3dde2206ff66 Mon Sep 17 00:00:00 2001 From: Valere Date: Mon, 2 Mar 2026 14:41:47 +0100 Subject: [PATCH 047/119] post merge fix --- src/tile/SpotlightTile.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index ba99f826..a0b1309b 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -183,7 +183,7 @@ const SpotlightItem: FC = ({ // Whenever bounds change, inform the viewModel useEffect(() => { if (bounds.width > 0 && bounds.height > 0) { - if (!(vm instanceof ScreenShareViewModel)) { + if (vm.type != "screen share") { vm.setActualDimensions(bounds.width, bounds.height); } } From 6e0f48e99b260ece266b84abb79e020854c3f666 Mon Sep 17 00:00:00 2001 From: fkwp Date: Mon, 2 Mar 2026 15:04:18 +0100 Subject: [PATCH 048/119] fix: add id-token permission as its required by tailscale login (#3768) * Push docker images to oci.element.io * prettier * add id-token permission as its required by tailscale login --- .github/workflows/build-and-publish-docker.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-and-publish-docker.yaml b/.github/workflows/build-and-publish-docker.yaml index f6eeb7c2..6e8f01f5 100644 --- a/.github/workflows/build-and-publish-docker.yaml +++ b/.github/workflows/build-and-publish-docker.yaml @@ -20,7 +20,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: write # required to upload release asset - packages: write + packages: write # needed for publishing packages to GHCR + id-token: write # needed for login into tailscale with GitHub OIDC Token steps: - name: Check it out uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 From 5165e95d82c8cb10f84883e35f2f20dc50a609cf Mon Sep 17 00:00:00 2001 From: Valere Date: Mon, 2 Mar 2026 15:38:43 +0100 Subject: [PATCH 049/119] fix: default to cover is size are 0 --- src/utils/videoFit.test.ts | 12 ++++++++++++ src/utils/videoFit.ts | 9 +++++++++ 2 files changed, 21 insertions(+) diff --git a/src/utils/videoFit.test.ts b/src/utils/videoFit.test.ts index 9390e8d4..5068526b 100644 --- a/src/utils/videoFit.test.ts +++ b/src/utils/videoFit.test.ts @@ -116,6 +116,18 @@ test.each([ tileSize: VIDEO_480_L, expected: "contain", }, + { + // Should default to cover if the initial size is 0:0. + // Or else it will cause a flash of "contain" mode until the real size is loaded, which can be jarring. + videoSize: VIDEO_480_L, + tileSize: { width: 0, height: 0 }, + expected: "cover", + }, + { + videoSize: { width: 0, height: 0 }, + tileSize: VIDEO_480_L, + expected: "cover", + }, ])( "videoFit$ returns $expected when videoSize is $videoSize and tileSize is $tileSize", ({ videoSize, tileSize, expected }) => { diff --git a/src/utils/videoFit.ts b/src/utils/videoFit.ts index c7b18f03..5f2cc2ce 100644 --- a/src/utils/videoFit.ts +++ b/src/utils/videoFit.ts @@ -36,6 +36,15 @@ export function videoFit$( // This is a reasonable default as it will ensure the video fills the tile, even if it means cropping. return "cover"; } + if ( + videoSize.width === 0 || + videoSize.height === 0 || + tileSize.width === 0 || + tileSize.height === 0 + ) { + // If we have invalid sizes (e.g. width or height is 0), default to cover to avoid black bars. + return "cover"; + } const videoAspectRatio = videoSize.width / videoSize.height; const tileAspectRatio = tileSize.width / tileSize.height; From 00f880108e29e629b0c4aae835b06fd55f96eb19 Mon Sep 17 00:00:00 2001 From: fkwp Date: Mon, 2 Mar 2026 17:03:01 +0100 Subject: [PATCH 050/119] fix: add id-token permission as its required by tailscale login (part 2) (#3770) * Push docker images to oci.element.io * prettier * add id-token permission as its required by tailscale login --- .github/workflows/build.yaml | 1 + .github/workflows/pr-deploy.yaml | 1 + .github/workflows/publish.yaml | 1 + 3 files changed, 3 insertions(+) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 9b86215e..4f9e80f2 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -49,6 +49,7 @@ jobs: permissions: contents: write packages: write + id-token: write uses: ./.github/workflows/build-and-publish-docker.yaml with: artifact_run_id: ${{ github.run_id }} diff --git a/.github/workflows/pr-deploy.yaml b/.github/workflows/pr-deploy.yaml index fe934162..62b37aca 100644 --- a/.github/workflows/pr-deploy.yaml +++ b/.github/workflows/pr-deploy.yaml @@ -60,6 +60,7 @@ jobs: permissions: contents: write packages: write + id-token: write uses: ./.github/workflows/build-and-publish-docker.yaml with: artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }} diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 7f2c58fe..ade91019 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -55,6 +55,7 @@ jobs: permissions: contents: write packages: write + id-token: write uses: ./.github/workflows/build-and-publish-docker.yaml with: artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }} From 2cf4a38c1743ad2895c5c4ebfba8e1b7d474c76e Mon Sep 17 00:00:00 2001 From: Jake Janicke Date: Tue, 3 Mar 2026 16:47:41 -0600 Subject: [PATCH 051/119] Add volume logic to RemoteScreenShareViewModel Signed-off-by: Jake Janicke --- src/state/media/RemoteScreenShareViewModel.ts | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/state/media/RemoteScreenShareViewModel.ts b/src/state/media/RemoteScreenShareViewModel.ts index eff6d9c1..78fc0494 100644 --- a/src/state/media/RemoteScreenShareViewModel.ts +++ b/src/state/media/RemoteScreenShareViewModel.ts @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type RemoteParticipant } from "livekit-client"; -import { map } from "rxjs"; +import { Track, type RemoteParticipant } from "livekit-client"; +import { map, of, switchMap } from "rxjs"; import { type Behavior } from "../Behavior"; import { @@ -16,13 +16,17 @@ import { createBaseScreenShare, } from "./ScreenShareViewModel"; import { type ObservableScope } from "../ObservableScope"; +import { createVolumeControls, type VolumeControls } from "../VolumeControls"; +import { observeTrackReference$ } from "../observeTrackReference"; -export interface RemoteScreenShareViewModel extends BaseScreenShareViewModel { +export interface RemoteScreenShareViewModel + extends BaseScreenShareViewModel, VolumeControls { local: false; /** * Whether this screen share's video should be displayed. */ videoEnabled$: Behavior; + audioEnabled$: Behavior; } export interface RemoteScreenShareInputs extends BaseScreenShareInputs { @@ -36,9 +40,30 @@ export function createRemoteScreenShare( ): RemoteScreenShareViewModel { return { ...createBaseScreenShare(scope, inputs), + ...createVolumeControls(scope, { + pretendToBeDisconnected$, + sink$: scope.behavior( + inputs.participant$.pipe( + map( + (p) => (volume) => + p?.setVolume(volume, Track.Source.ScreenShareAudio), + ), + ), + ), + }), local: false, videoEnabled$: scope.behavior( pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)), ), + audioEnabled$: scope.behavior( + inputs.participant$.pipe( + switchMap((p) => + p + ? observeTrackReference$(p, Track.Source.ScreenShareAudio) + : of(null), + ), + map(Boolean), + ), + ), }; } From 72520dbb3f8f9a08701c207d79047e38160f4e4a Mon Sep 17 00:00:00 2001 From: Jake Janicke Date: Wed, 4 Mar 2026 01:41:16 -0600 Subject: [PATCH 052/119] Add screen share volume slider UI Signed-off-by: Jake Janicke --- locales/en/app.json | 1 + src/tile/SpotlightTile.module.css | 26 +++++++++ src/tile/SpotlightTile.tsx | 94 +++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) diff --git a/locales/en/app.json b/locales/en/app.json index 0b0ac7b4..6ec4859e 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -256,6 +256,7 @@ "mute_for_me": "Mute for me", "muted_for_me": "Muted for me", "volume": "Volume", + "screen_share_volume": "Screen share volume", "waiting_for_media": "Waiting for media..." } } diff --git a/src/tile/SpotlightTile.module.css b/src/tile/SpotlightTile.module.css index 622496d2..43c3d53c 100644 --- a/src/tile/SpotlightTile.module.css +++ b/src/tile/SpotlightTile.module.css @@ -108,6 +108,32 @@ Please see LICENSE in the repository root for full details. z-index: 1; } +.volumeSlider { + width: 100%; +} + +/* Disable the hover effect for the screen share volume menu button */ +.volumeMenuItem:hover { + background: transparent; + cursor: default; +} + +.volumeMenuItem { + gap: var(--cpd-space-3x); +} + +.menuMuteButton { + background: none; + border: none; + cursor: pointer; + display: flex; +} + +/* Make icons change color with the theme */ +.menuMuteButton > svg { + color: var(--cpd-color-icon-primary); +} + .expand > svg { display: block; color: var(--cpd-color-icon-primary); diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index 75c69479..93a1fbd9 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -20,6 +20,8 @@ import { CollapseIcon, ChevronLeftIcon, ChevronRightIcon, + VolumeOffIcon, + VolumeOnIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { animated } from "@react-spring/web"; import { type Observable, map } from "rxjs"; @@ -27,6 +29,7 @@ import { useObservableRef } from "observable-hooks"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; import { type TrackReferenceOrPlaceholder } from "@livekit/components-core"; +import { Menu, MenuItem } from "@vector-im/compound-web"; import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react"; import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react"; @@ -45,6 +48,8 @@ import { type UserMediaViewModel } from "../state/media/UserMediaViewModel"; import { type ScreenShareViewModel } from "../state/media/ScreenShareViewModel"; import { type RemoteScreenShareViewModel } from "../state/media/RemoteScreenShareViewModel"; import { type MediaViewModel } from "../state/media/MediaViewModel"; +import { Slider } from "../Slider"; +import { constant } from "../state/Behavior"; interface SpotlightItemBaseProps { ref?: Ref; @@ -260,6 +265,33 @@ export const SpotlightTile: FC = ({ const visibleIndex = media.findIndex((vm) => vm.id === visibleId); const canGoBack = visibleIndex > 0; const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1; + const currentMedia = media[visibleIndex]; + // isScreenShare only needs to check "audioEnabled$" but I wanted to be more specific + // just in case more models are added in the future, since screen shares always have video + const isScreenShare = + currentMedia && + "audioEnabled$" in currentMedia && + "videoEnabled$" in currentMedia; + + const hasAudio$ = useBehavior( + isScreenShare && currentMedia?.audioEnabled$ + ? currentMedia.audioEnabled$ + : constant(false), + ); + const isLocalScreenShare = isScreenShare && currentMedia.local; + const screenShareLocallyMuted = useBehavior( + isScreenShare + ? (currentMedia as RemoteScreenShareViewModel).playbackMuted$ + : constant(false), + ); + const ScreenShareVolumeIcon = screenShareLocallyMuted + ? VolumeOffIcon + : VolumeOnIcon; + const screenShareVolume = useBehavior( + isScreenShare + ? (currentMedia as RemoteScreenShareViewModel).playbackVolume$ + : constant(0), + ); const isFullscreen = useCallback((): boolean => { const rootElement = document.body; @@ -328,6 +360,7 @@ export const SpotlightTile: FC = ({ }, [latestVisibleId, latestMedia, setScrollToId]); const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon; + const [openVolumeMenu, setOpenVolumeMenu] = useState(false); return ( = ({ ))}
+ {/* + Show volume slider only when the tile is a screenshare, has audio, + is in spotlight mode, and isn't your own screen share. + */} + {isScreenShare && + hasAudio$ && + onToggleExpanded && + !isLocalScreenShare && ( + + + + } + > + + + + ( + currentMedia as RemoteScreenShareViewModel + ).adjustPlaybackVolume(v) + } + onValueCommit={() => + ( + currentMedia as RemoteScreenShareViewModel + ).commitPlaybackVolume() + } + /> + + + )} + } + > + + + + + + ) + ); +}; + interface Props { ref?: Ref; vm: SpotlightTileViewModel; @@ -263,37 +332,9 @@ export const SpotlightTile: FC = ({ const latestMedia = useLatest(media); const latestVisibleId = useLatest(visibleId); const visibleIndex = media.findIndex((vm) => vm.id === visibleId); + const visibleMedia = media.at(visibleIndex); const canGoBack = visibleIndex > 0; const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1; - const currentMedia = media[visibleIndex]; - // only "audioEnabled$" needs to be checked but I wanted to be more specific just in - // case more models are added in the future, since screen shares always have video - const currentScreenShare = - currentMedia && - "audioEnabled$" in currentMedia && - "videoEnabled$" in currentMedia - ? (currentMedia as RemoteScreenShareViewModel) - : null; - - const isScreenShare = currentScreenShare != null; - - const hasAudio$ = useBehavior( - currentScreenShare?.audioEnabled$ ?? constant(false), - ); - - const isLocalScreenShare = currentScreenShare?.local ?? false; - - const screenShareLocallyMuted = useBehavior( - currentScreenShare?.playbackMuted$ ?? constant(false), - ); - - const ScreenShareVolumeIcon = screenShareLocallyMuted - ? VolumeOffIcon - : VolumeOnIcon; - - const screenShareVolume = useBehavior( - currentScreenShare?.playbackVolume$ ?? constant(0), - ); const isFullscreen = useCallback((): boolean => { const rootElement = document.body; @@ -362,7 +403,6 @@ export const SpotlightTile: FC = ({ }, [latestVisibleId, latestMedia, setScrollToId]); const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon; - const [openVolumeMenu, setOpenVolumeMenu] = useState(false); return ( = ({ /> ))}
-
- {/* - Show volume slider only when the tile is a screenshare, has audio, - is in spotlight mode, and isn't your own screen share. - */} - {isScreenShare && - hasAudio$ && - onToggleExpanded && - !isLocalScreenShare && ( - - - - } - > - - - - ( - currentMedia as RemoteScreenShareViewModel - ).adjustPlaybackVolume(v) - } - onValueCommit={() => - ( - currentMedia as RemoteScreenShareViewModel - ).commitPlaybackVolume() - } - /> - - - )} - +
+ {visibleMedia?.type === "screen share" && !visibleMedia.local && ( + + )} + {platform === "desktop" && ( + + )} {onToggleExpanded && (
@@ -760,12 +726,13 @@ export const InCallView: FC = ({ const allConnections = useBehavior(vm.allConnections$); return ( + // The onClick handler here exists to control the visibility of the footer, + // and the footer is also viewable by moving focus into it, so this is fine. + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
diff --git a/src/room/LayoutToggle.tsx b/src/room/LayoutToggle.tsx index 6cddc95f..ca6aa467 100644 --- a/src/room/LayoutToggle.tsx +++ b/src/room/LayoutToggle.tsx @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type ChangeEvent, type FC, type TouchEvent, useCallback } from "react"; +import { type ChangeEvent, type FC, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Tooltip } from "@vector-im/compound-web"; import { @@ -22,15 +22,9 @@ interface Props { layout: Layout; setLayout: (layout: Layout) => void; className?: string; - onTouchEnd?: (e: TouchEvent) => void; } -export const LayoutToggle: FC = ({ - layout, - setLayout, - className, - onTouchEnd, -}) => { +export const LayoutToggle: FC = ({ layout, setLayout, className }) => { const { t } = useTranslation(); const onChange = useCallback( @@ -47,7 +41,6 @@ export const LayoutToggle: FC = ({ value="spotlight" checked={layout === "spotlight"} onChange={onChange} - onTouchEnd={onTouchEnd} /> @@ -58,7 +51,6 @@ export const LayoutToggle: FC = ({ value="grid" checked={layout === "grid"} onChange={onChange} - onTouchEnd={onTouchEnd} /> diff --git a/src/tile/SpotlightTile.module.css b/src/tile/SpotlightTile.module.css index 178d13f3..fe13f906 100644 --- a/src/tile/SpotlightTile.module.css +++ b/src/tile/SpotlightTile.module.css @@ -84,7 +84,6 @@ Please see LICENSE in the repository root for full details. .expand { appearance: none; cursor: pointer; - opacity: 0; padding: var(--cpd-space-2x); border: none; border-radius: var(--cpd-radius-pill-effect); @@ -148,17 +147,21 @@ Please see LICENSE in the repository root for full details. } } -.expand:active { +.expand:active, .expand[data-state="open"] { background: var(--cpd-color-gray-100); } @media (hover) { + .tile > div > button { + opacity: 0; + } .tile:hover > div > button { opacity: 1; } } -.tile:has(:focus-visible) > div > button { +.tile:has(:focus-visible) > div > button, +.tile > div:has([data-state="open"]) > button { opacity: 1; } From 885a523e9189aa41bb83045f36e7fe9b0bdafe9b Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 9 Mar 2026 10:44:22 +0100 Subject: [PATCH 069/119] Fix formatting --- src/tile/SpotlightTile.module.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tile/SpotlightTile.module.css b/src/tile/SpotlightTile.module.css index fe13f906..af0e0add 100644 --- a/src/tile/SpotlightTile.module.css +++ b/src/tile/SpotlightTile.module.css @@ -147,7 +147,8 @@ Please see LICENSE in the repository root for full details. } } -.expand:active, .expand[data-state="open"] { +.expand:active, +.expand[data-state="open"] { background: var(--cpd-color-gray-100); } From 4922249f41339c6deedc77465bbda3bbd3373a61 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 9 Mar 2026 13:00:43 +0100 Subject: [PATCH 070/119] Unpin Knip and LiveKit track processors These dependencies were held back on old versions because of issues that are now resolved (see 2fca7e37191e81f015426eb3756a19b19277031e and 44eb8acaeec990656e921d954694e459722acb48). --- package.json | 4 +- yarn.lock | 224 ++++++++++++++++++++++++++------------------------- 2 files changed, 117 insertions(+), 111 deletions(-) diff --git a/package.json b/package.json index 705b0f10..9ee0ad26 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@livekit/components-core": "^0.12.0", "@livekit/components-react": "^2.0.0", "@livekit/protocol": "^1.42.2", - "@livekit/track-processors": "^0.6.0 || ^0.7.1", + "@livekit/track-processors": "^0.7.1", "@mediapipe/tasks-vision": "^0.10.18", "@playwright/test": "^1.57.0", "@radix-ui/react-dialog": "^1.0.4", @@ -101,7 +101,7 @@ "i18next-browser-languagedetector": "^8.0.0", "i18next-parser": "^9.1.0", "jsdom": "^26.0.0", - "knip": "5.82.1", + "knip": "^5.86.0", "livekit-client": "^2.13.0", "lodash-es": "^4.17.21", "loglevel": "^1.9.1", diff --git a/yarn.lock b/yarn.lock index 4675d0e1..bd90ded3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3345,15 +3345,15 @@ __metadata: languageName: node linkType: hard -"@livekit/track-processors@npm:^0.6.0 || ^0.7.1": - version: 0.6.1 - resolution: "@livekit/track-processors@npm:0.6.1" +"@livekit/track-processors@npm:^0.7.1": + version: 0.7.2 + resolution: "@livekit/track-processors@npm:0.7.2" dependencies: "@mediapipe/tasks-vision": "npm:0.10.14" peerDependencies: "@types/dom-mediacapture-transform": ^0.1.9 livekit-client: ^1.12.0 || ^2.1.0 - checksum: 10c0/80f54663c7e13de299de9e2565b6cbd2ba74ea0a4a8adf8a366e8cfd0e19dedfb9d699899137f1a6133414f28779877eeb3200074c03893bc63aeb0d8c912a91 + checksum: 10c0/d5638942205ea05a507254f61157696881332b866ff538d1e93bd2267c31ce80c9a81dfc4f8c4b7c96910452b13511ee06be1ae7ab30c299b73fe04baf80a673 languageName: node linkType: hard @@ -3371,7 +3371,7 @@ __metadata: languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^1.1.0": +"@napi-rs/wasm-runtime@npm:^1.1.1": version: 1.1.1 resolution: "@napi-rs/wasm-runtime@npm:1.1.1" dependencies: @@ -3551,144 +3551,144 @@ __metadata: languageName: node linkType: hard -"@oxc-resolver/binding-android-arm-eabi@npm:11.16.2": - version: 11.16.2 - resolution: "@oxc-resolver/binding-android-arm-eabi@npm:11.16.2" +"@oxc-resolver/binding-android-arm-eabi@npm:11.19.1": + version: 11.19.1 + resolution: "@oxc-resolver/binding-android-arm-eabi@npm:11.19.1" conditions: os=android & cpu=arm languageName: node linkType: hard -"@oxc-resolver/binding-android-arm64@npm:11.16.2": - version: 11.16.2 - resolution: "@oxc-resolver/binding-android-arm64@npm:11.16.2" +"@oxc-resolver/binding-android-arm64@npm:11.19.1": + version: 11.19.1 + resolution: "@oxc-resolver/binding-android-arm64@npm:11.19.1" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@oxc-resolver/binding-darwin-arm64@npm:11.16.2": - version: 11.16.2 - resolution: "@oxc-resolver/binding-darwin-arm64@npm:11.16.2" +"@oxc-resolver/binding-darwin-arm64@npm:11.19.1": + version: 11.19.1 + resolution: "@oxc-resolver/binding-darwin-arm64@npm:11.19.1" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@oxc-resolver/binding-darwin-x64@npm:11.16.2": - version: 11.16.2 - resolution: "@oxc-resolver/binding-darwin-x64@npm:11.16.2" +"@oxc-resolver/binding-darwin-x64@npm:11.19.1": + version: 11.19.1 + resolution: "@oxc-resolver/binding-darwin-x64@npm:11.19.1" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@oxc-resolver/binding-freebsd-x64@npm:11.16.2": - version: 11.16.2 - resolution: "@oxc-resolver/binding-freebsd-x64@npm:11.16.2" +"@oxc-resolver/binding-freebsd-x64@npm:11.19.1": + version: 11.19.1 + resolution: "@oxc-resolver/binding-freebsd-x64@npm:11.19.1" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@oxc-resolver/binding-linux-arm-gnueabihf@npm:11.16.2": - version: 11.16.2 - resolution: "@oxc-resolver/binding-linux-arm-gnueabihf@npm:11.16.2" +"@oxc-resolver/binding-linux-arm-gnueabihf@npm:11.19.1": + version: 11.19.1 + resolution: "@oxc-resolver/binding-linux-arm-gnueabihf@npm:11.19.1" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@oxc-resolver/binding-linux-arm-musleabihf@npm:11.16.2": - version: 11.16.2 - resolution: "@oxc-resolver/binding-linux-arm-musleabihf@npm:11.16.2" +"@oxc-resolver/binding-linux-arm-musleabihf@npm:11.19.1": + version: 11.19.1 + resolution: "@oxc-resolver/binding-linux-arm-musleabihf@npm:11.19.1" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@oxc-resolver/binding-linux-arm64-gnu@npm:11.16.2": - version: 11.16.2 - resolution: "@oxc-resolver/binding-linux-arm64-gnu@npm:11.16.2" +"@oxc-resolver/binding-linux-arm64-gnu@npm:11.19.1": + version: 11.19.1 + resolution: "@oxc-resolver/binding-linux-arm64-gnu@npm:11.19.1" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@oxc-resolver/binding-linux-arm64-musl@npm:11.16.2": - version: 11.16.2 - resolution: "@oxc-resolver/binding-linux-arm64-musl@npm:11.16.2" +"@oxc-resolver/binding-linux-arm64-musl@npm:11.19.1": + version: 11.19.1 + resolution: "@oxc-resolver/binding-linux-arm64-musl@npm:11.19.1" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@oxc-resolver/binding-linux-ppc64-gnu@npm:11.16.2": - version: 11.16.2 - resolution: "@oxc-resolver/binding-linux-ppc64-gnu@npm:11.16.2" +"@oxc-resolver/binding-linux-ppc64-gnu@npm:11.19.1": + version: 11.19.1 + resolution: "@oxc-resolver/binding-linux-ppc64-gnu@npm:11.19.1" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@oxc-resolver/binding-linux-riscv64-gnu@npm:11.16.2": - version: 11.16.2 - resolution: "@oxc-resolver/binding-linux-riscv64-gnu@npm:11.16.2" +"@oxc-resolver/binding-linux-riscv64-gnu@npm:11.19.1": + version: 11.19.1 + resolution: "@oxc-resolver/binding-linux-riscv64-gnu@npm:11.19.1" conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard -"@oxc-resolver/binding-linux-riscv64-musl@npm:11.16.2": - version: 11.16.2 - resolution: "@oxc-resolver/binding-linux-riscv64-musl@npm:11.16.2" +"@oxc-resolver/binding-linux-riscv64-musl@npm:11.19.1": + version: 11.19.1 + resolution: "@oxc-resolver/binding-linux-riscv64-musl@npm:11.19.1" conditions: os=linux & cpu=riscv64 & libc=musl languageName: node linkType: hard -"@oxc-resolver/binding-linux-s390x-gnu@npm:11.16.2": - version: 11.16.2 - resolution: "@oxc-resolver/binding-linux-s390x-gnu@npm:11.16.2" +"@oxc-resolver/binding-linux-s390x-gnu@npm:11.19.1": + version: 11.19.1 + resolution: "@oxc-resolver/binding-linux-s390x-gnu@npm:11.19.1" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@oxc-resolver/binding-linux-x64-gnu@npm:11.16.2": - version: 11.16.2 - resolution: "@oxc-resolver/binding-linux-x64-gnu@npm:11.16.2" +"@oxc-resolver/binding-linux-x64-gnu@npm:11.19.1": + version: 11.19.1 + resolution: "@oxc-resolver/binding-linux-x64-gnu@npm:11.19.1" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@oxc-resolver/binding-linux-x64-musl@npm:11.16.2": - version: 11.16.2 - resolution: "@oxc-resolver/binding-linux-x64-musl@npm:11.16.2" +"@oxc-resolver/binding-linux-x64-musl@npm:11.19.1": + version: 11.19.1 + resolution: "@oxc-resolver/binding-linux-x64-musl@npm:11.19.1" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@oxc-resolver/binding-openharmony-arm64@npm:11.16.2": - version: 11.16.2 - resolution: "@oxc-resolver/binding-openharmony-arm64@npm:11.16.2" +"@oxc-resolver/binding-openharmony-arm64@npm:11.19.1": + version: 11.19.1 + resolution: "@oxc-resolver/binding-openharmony-arm64@npm:11.19.1" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@oxc-resolver/binding-wasm32-wasi@npm:11.16.2": - version: 11.16.2 - resolution: "@oxc-resolver/binding-wasm32-wasi@npm:11.16.2" +"@oxc-resolver/binding-wasm32-wasi@npm:11.19.1": + version: 11.19.1 + resolution: "@oxc-resolver/binding-wasm32-wasi@npm:11.19.1" dependencies: - "@napi-rs/wasm-runtime": "npm:^1.1.0" + "@napi-rs/wasm-runtime": "npm:^1.1.1" conditions: cpu=wasm32 languageName: node linkType: hard -"@oxc-resolver/binding-win32-arm64-msvc@npm:11.16.2": - version: 11.16.2 - resolution: "@oxc-resolver/binding-win32-arm64-msvc@npm:11.16.2" +"@oxc-resolver/binding-win32-arm64-msvc@npm:11.19.1": + version: 11.19.1 + resolution: "@oxc-resolver/binding-win32-arm64-msvc@npm:11.19.1" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@oxc-resolver/binding-win32-ia32-msvc@npm:11.16.2": - version: 11.16.2 - resolution: "@oxc-resolver/binding-win32-ia32-msvc@npm:11.16.2" +"@oxc-resolver/binding-win32-ia32-msvc@npm:11.19.1": + version: 11.19.1 + resolution: "@oxc-resolver/binding-win32-ia32-msvc@npm:11.19.1" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@oxc-resolver/binding-win32-x64-msvc@npm:11.16.2": - version: 11.16.2 - resolution: "@oxc-resolver/binding-win32-x64-msvc@npm:11.16.2" +"@oxc-resolver/binding-win32-x64-msvc@npm:11.19.1": + version: 11.19.1 + resolution: "@oxc-resolver/binding-win32-x64-msvc@npm:11.19.1" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -8380,7 +8380,7 @@ __metadata: "@livekit/components-core": "npm:^0.12.0" "@livekit/components-react": "npm:^2.0.0" "@livekit/protocol": "npm:^1.42.2" - "@livekit/track-processors": "npm:^0.6.0 || ^0.7.1" + "@livekit/track-processors": "npm:^0.7.1" "@mediapipe/tasks-vision": "npm:^0.10.18" "@playwright/test": "npm:^1.57.0" "@radix-ui/react-dialog": "npm:^1.0.4" @@ -8433,7 +8433,7 @@ __metadata: i18next-browser-languagedetector: "npm:^8.0.0" i18next-parser: "npm:^9.1.0" jsdom: "npm:^26.0.0" - knip: "npm:5.82.1" + knip: "npm:^5.86.0" livekit-client: "npm:^2.13.0" lodash-es: "npm:^4.17.21" loglevel: "npm:^1.9.1" @@ -11039,17 +11039,6 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:^4.1.1": - version: 4.1.1 - resolution: "js-yaml@npm:4.1.1" - dependencies: - argparse: "npm:^2.0.1" - bin: - js-yaml: bin/js-yaml.js - checksum: 10c0/561c7d7088c40a9bb53cc75becbfb1df6ae49b34b5e6e5a81744b14ae8667ec564ad2527709d1a6e7d5e5fa6d483aa0f373a50ad98d42fde368ec4a190d4fae7 - languageName: node - linkType: hard - "jsbn@npm:1.1.0": version: 1.1.0 resolution: "jsbn@npm:1.1.0" @@ -11232,21 +11221,22 @@ __metadata: languageName: node linkType: hard -"knip@npm:5.82.1": - version: 5.82.1 - resolution: "knip@npm:5.82.1" +"knip@npm:^5.86.0": + version: 5.86.0 + resolution: "knip@npm:5.86.0" dependencies: "@nodelib/fs.walk": "npm:^1.2.3" fast-glob: "npm:^3.3.3" formatly: "npm:^0.3.0" jiti: "npm:^2.6.0" - js-yaml: "npm:^4.1.1" minimist: "npm:^1.2.8" - oxc-resolver: "npm:^11.15.0" + oxc-resolver: "npm:^11.19.1" picocolors: "npm:^1.1.1" picomatch: "npm:^4.0.1" smol-toml: "npm:^1.5.2" strip-json-comments: "npm:5.0.3" + unbash: "npm:^2.2.0" + yaml: "npm:^2.8.2" zod: "npm:^4.1.11" peerDependencies: "@types/node": ">=18" @@ -11254,7 +11244,7 @@ __metadata: bin: knip: bin/knip.js knip-bun: bin/knip-bun.js - checksum: 10c0/c3bfe898fe3103bb6a59ee2ba4297f05ea4d2db474571db89ae199ebbd74eafa5061d05b3bc2c75e4ec2322ba7ffee44493c76132d3d8991fae66ba742b9ccb4 + checksum: 10c0/6905c3c2bd21b1f5d51bf83568d1eff67d9d74dd9547c428f810b0dbc3624225a0c41b8e8caccbb111df2db175933aa853345798a05f91f9344ce3aca26898ff languageName: node linkType: hard @@ -12173,30 +12163,30 @@ __metadata: languageName: node linkType: hard -"oxc-resolver@npm:^11.15.0": - version: 11.16.2 - resolution: "oxc-resolver@npm:11.16.2" +"oxc-resolver@npm:^11.19.1": + version: 11.19.1 + resolution: "oxc-resolver@npm:11.19.1" dependencies: - "@oxc-resolver/binding-android-arm-eabi": "npm:11.16.2" - "@oxc-resolver/binding-android-arm64": "npm:11.16.2" - "@oxc-resolver/binding-darwin-arm64": "npm:11.16.2" - "@oxc-resolver/binding-darwin-x64": "npm:11.16.2" - "@oxc-resolver/binding-freebsd-x64": "npm:11.16.2" - "@oxc-resolver/binding-linux-arm-gnueabihf": "npm:11.16.2" - "@oxc-resolver/binding-linux-arm-musleabihf": "npm:11.16.2" - "@oxc-resolver/binding-linux-arm64-gnu": "npm:11.16.2" - "@oxc-resolver/binding-linux-arm64-musl": "npm:11.16.2" - "@oxc-resolver/binding-linux-ppc64-gnu": "npm:11.16.2" - "@oxc-resolver/binding-linux-riscv64-gnu": "npm:11.16.2" - "@oxc-resolver/binding-linux-riscv64-musl": "npm:11.16.2" - "@oxc-resolver/binding-linux-s390x-gnu": "npm:11.16.2" - "@oxc-resolver/binding-linux-x64-gnu": "npm:11.16.2" - "@oxc-resolver/binding-linux-x64-musl": "npm:11.16.2" - "@oxc-resolver/binding-openharmony-arm64": "npm:11.16.2" - "@oxc-resolver/binding-wasm32-wasi": "npm:11.16.2" - "@oxc-resolver/binding-win32-arm64-msvc": "npm:11.16.2" - "@oxc-resolver/binding-win32-ia32-msvc": "npm:11.16.2" - "@oxc-resolver/binding-win32-x64-msvc": "npm:11.16.2" + "@oxc-resolver/binding-android-arm-eabi": "npm:11.19.1" + "@oxc-resolver/binding-android-arm64": "npm:11.19.1" + "@oxc-resolver/binding-darwin-arm64": "npm:11.19.1" + "@oxc-resolver/binding-darwin-x64": "npm:11.19.1" + "@oxc-resolver/binding-freebsd-x64": "npm:11.19.1" + "@oxc-resolver/binding-linux-arm-gnueabihf": "npm:11.19.1" + "@oxc-resolver/binding-linux-arm-musleabihf": "npm:11.19.1" + "@oxc-resolver/binding-linux-arm64-gnu": "npm:11.19.1" + "@oxc-resolver/binding-linux-arm64-musl": "npm:11.19.1" + "@oxc-resolver/binding-linux-ppc64-gnu": "npm:11.19.1" + "@oxc-resolver/binding-linux-riscv64-gnu": "npm:11.19.1" + "@oxc-resolver/binding-linux-riscv64-musl": "npm:11.19.1" + "@oxc-resolver/binding-linux-s390x-gnu": "npm:11.19.1" + "@oxc-resolver/binding-linux-x64-gnu": "npm:11.19.1" + "@oxc-resolver/binding-linux-x64-musl": "npm:11.19.1" + "@oxc-resolver/binding-openharmony-arm64": "npm:11.19.1" + "@oxc-resolver/binding-wasm32-wasi": "npm:11.19.1" + "@oxc-resolver/binding-win32-arm64-msvc": "npm:11.19.1" + "@oxc-resolver/binding-win32-ia32-msvc": "npm:11.19.1" + "@oxc-resolver/binding-win32-x64-msvc": "npm:11.19.1" dependenciesMeta: "@oxc-resolver/binding-android-arm-eabi": optional: true @@ -12238,7 +12228,7 @@ __metadata: optional: true "@oxc-resolver/binding-win32-x64-msvc": optional: true - checksum: 10c0/b20a0fea18fdf31dbaee51354ce7b987ba8f3e780c6c1de9034628033a69d0b3085f9596d9925797d9340bdf4b98cd72a258b0728d0d5e5de2b1748154921b42 + checksum: 10c0/8ac4eaffa9c0bcbb9f4f4a2b43786457ec5a68684d8776cb78b5a15ce3d1a79d3e67262aa3c635f98a0c1cd6cd56a31fcb05bffb9a286100056e4ab06b928833 languageName: node linkType: hard @@ -15153,6 +15143,13 @@ __metadata: languageName: node linkType: hard +"unbash@npm:^2.2.0": + version: 2.2.0 + resolution: "unbash@npm:2.2.0" + checksum: 10c0/f218a30e2b65147dba16fcea5d9cbfe5af9d9518e98083b9790b9884959c82c5c8f85e7feeea717430e2ea6b352a1d57ad98e90fe488638606de12c9254cbf35 + languageName: node + linkType: hard + "unbox-primitive@npm:^1.1.0": version: 1.1.0 resolution: "unbox-primitive@npm:1.1.0" @@ -16105,6 +16102,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.8.2": + version: 2.8.2 + resolution: "yaml@npm:2.8.2" + bin: + yaml: bin.mjs + checksum: 10c0/703e4dc1e34b324aa66876d63618dcacb9ed49f7e7fe9b70f1e703645be8d640f68ab84f12b86df8ac960bac37acf5513e115de7c970940617ce0343c8c9cd96 + languageName: node + linkType: hard + "yargs-parser@npm:^18.1.2": version: 18.1.3 resolution: "yargs-parser@npm:18.1.3" From 4c7fad30c064bdba8d58f15683827625b4e49f1d Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 9 Mar 2026 13:11:16 +0100 Subject: [PATCH 071/119] Silence new Knip errors --- knip.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/knip.ts b/knip.ts index d23d42fe..3be3e653 100644 --- a/knip.ts +++ b/knip.ts @@ -34,6 +34,12 @@ export default { // then Knip will flag it as a false positive // https://github.com/webpro-nl/knip/issues/766 "@vector-im/compound-web", + // Yarn plugins are allowed to depend on packages provided by the Yarn + // runtime. These shouldn't be listed in package.json, because plugins + // should work before Yarn even installs dependencies for the first time. + // https://yarnpkg.com/advanced/plugin-tutorial#what-does-a-plugin-look-like + "@yarnpkg/core", + "@yarnpkg/parsers", "matrix-widget-api", ], ignoreExportsUsedInFile: true, From 8f418ce9fd5c1d2090b242adf7801368b11231bb Mon Sep 17 00:00:00 2001 From: Valere Date: Mon, 9 Mar 2026 13:59:39 +0100 Subject: [PATCH 072/119] fix cla link --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e68c0f42..787ddc73 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -36,4 +36,4 @@ Uncomment this markdown table below and edit the last line `|||`: - [ ] Pull request includes screenshots or videos if containing UI changes - [ ] Tests written for new code (and old code if feasible). - [ ] Linter and other CI checks pass. -- [ ] I have licensed the changes to Element by completing the [Contributor License Agreement (CLA)](https://cla-assistant.io/element-hq/element-web) +- [ ] I have licensed the changes to Element by completing the [Contributor License Agreement (CLA)](https://cla-assistant.io/element-hq/element-call) From ca3837f44ebdb3340340af1b95d8eae924f67667 Mon Sep 17 00:00:00 2001 From: Valere Date: Mon, 9 Mar 2026 15:07:42 +0100 Subject: [PATCH 073/119] fix merge issue that added back a deprecated test --- src/state/media/MediaViewModel.test.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/state/media/MediaViewModel.test.ts b/src/state/media/MediaViewModel.test.ts index f64dd3ee..9d873ccb 100644 --- a/src/state/media/MediaViewModel.test.ts +++ b/src/state/media/MediaViewModel.test.ts @@ -160,21 +160,6 @@ test("control a participant's screen share volume", () => { }); }); -test("toggle fit/contain for a participant's video", () => { - const vm = mockRemoteMedia(rtcMembership, {}, mockRemoteParticipant({})); - withTestScheduler(({ expectObservable, schedule }) => { - schedule("-ab|", { - a: () => vm.toggleCropVideo(), - b: () => vm.toggleCropVideo(), - }); - expectObservable(vm.cropVideo$).toBe("abc", { - a: true, - b: false, - c: true, - }); - }); -}); - test("local media remembers whether it should always be shown", () => { const vm1 = mockLocalMedia( rtcMembership, From 3da762ab36faf06915053cdab9f91b12fd8a76c8 Mon Sep 17 00:00:00 2001 From: Valere Date: Mon, 9 Mar 2026 17:49:04 +0100 Subject: [PATCH 074/119] fix: typo inverting with/height in PIP spotlight tile --- src/room/InCallView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index aceb07cf..d8803b22 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -584,8 +584,8 @@ export const InCallView: FC = ({ vm={layout.spotlight} expanded onToggleExpanded={null} - targetWidth={gridBounds.height} - targetHeight={gridBounds.width} + targetWidth={gridBounds.width} + targetHeight={gridBounds.height} showIndicators={false} focusable={!contentObscured} aria-hidden={contentObscured} From 699e31f59a00c358f0a9fe6b68ac2fa19c909a45 Mon Sep 17 00:00:00 2001 From: JephDiel Date: Mon, 9 Mar 2026 22:25:54 -0500 Subject: [PATCH 075/119] Download Avatar from relevent source Instead of relying on failures directly use the available method to download the avatar. --- src/Avatar.test.tsx | 50 ++++++--------------- src/Avatar.tsx | 56 ++++++++++-------------- src/ClientContext.tsx | 15 ++++--- src/settings/useSubmitRageshake.test.tsx | 2 +- 4 files changed, 47 insertions(+), 76 deletions(-) diff --git a/src/Avatar.test.tsx b/src/Avatar.test.tsx index 1ad0f4be..3d75d4c1 100644 --- a/src/Avatar.test.tsx +++ b/src/Avatar.test.tsx @@ -17,8 +17,15 @@ import EventEmitter from "events"; import { widget } from "./widget"; const TestComponent: FC< - PropsWithChildren<{ client: MatrixClient; supportsThumbnails?: boolean }> -> = ({ client, children, supportsThumbnails }) => { + PropsWithChildren<{ + client: MatrixClient; + supportsAuthenticatedMedia?: boolean; + }> +> = ({ + client, + children, + supportsAuthenticatedMedia: supportsAuthenticatedMedia, +}) => { return ( { expect(client.mxcUrlToHttp).toBeCalledTimes(0); }); -test("should just render a placeholder when thumbnails are not supported", () => { - const client = vi.mocked({ - getAccessToken: () => "my-access-token", - mxcUrlToHttp: () => vi.fn(), - } as unknown as MatrixClient); - - vi.spyOn(client, "mxcUrlToHttp"); - const member = mockMatrixRoomMember( - mockRtcMembership("@alice:example.org", "AAAA"), - { - getMxcAvatarUrl: () => "mxc://example.org/alice-avatar", - }, - ); - const displayName = "Alice"; - render( - - - , - ); - const element = screen.getByRole("img", { name: "@alice:example.org" }); - expect(element.tagName).toEqual("SPAN"); - expect(client.mxcUrlToHttp).toBeCalledTimes(0); -}); - -test("should attempt to fetch authenticated media", async () => { +test("should attempt to fetch authenticated media if supported", async () => { const expectedAuthUrl = "http://example.org/media/alice-avatar"; const expectedObjectURL = "my-object-url"; const accessToken = "my-access-token"; @@ -142,7 +120,7 @@ test("should attempt to fetch authenticated media", async () => { ); const displayName = "Alice"; render( - + { }); }); -test("should use widget API when unable to authenticate media", async () => { +test("should attempt to use widget API if authenticate media is not supported", async () => { const expectedMXCUrl = "mxc://example.org/alice-avatar"; const expectedObjectURL = "my-object-url"; const theBlob = new Blob([]); @@ -188,7 +166,7 @@ test("should use widget API when unable to authenticate media", async () => { ); const displayName = "Alice"; render( - + = ({ const sizePx = useMemo( () => Object.values(Size).includes(size as Size) - ? sizes.get(size as Size) + ? sizes.get(size as Size)! : (size as number), [size], ); @@ -88,17 +88,29 @@ export const Avatar: FC = ({ const [avatarUrl, setAvatarUrl] = useState(undefined); useEffect(() => { - if (!src) { + if (!src || clientState?.state !== "valid") { + setAvatarUrl(undefined); + return; + } + + const { authenticated, supportedFeatures } = clientState; + let blob: Promise; + + if ( + supportedFeatures.authenticatedMedia && + authenticated?.client && + sizePx + ) { + blob = getAvatarFromServer(authenticated.client, src, sizePx); + } else if (widget?.api) { + blob = getAvatarFromWidgetAPI(widget.api, src); + } else { setAvatarUrl(undefined); return; } let objectUrl: string | undefined; - - getAvatarFromServer(clientState, src, sizePx) // Try to download directly from the mxc:// - .catch((ex) => { - return getAvatarFromWidget(widget?.api, src); // Fallback to trying to use the MSC4039 Widget API - }) + blob .then((blob) => { objectUrl = URL.createObjectURL(blob); setAvatarUrl(objectUrl); @@ -128,26 +140,10 @@ export const Avatar: FC = ({ }; async function getAvatarFromServer( - clientState: ClientState | undefined, + client: MatrixClient, src: string, - sizePx: number | undefined, + sizePx: number, ): Promise { - if (clientState?.state !== "valid") { - throw new Error("Client state must be valid"); - } - if (!sizePx) { - throw new Error("size must be supplied"); - } - - const { authenticated, supportedFeatures } = clientState; - const client = authenticated?.client; - if (!client) { - throw new Error("Client must be supplied"); - } - if (!supportedFeatures.thumbnails) { - throw new Error("Thumbnails are not supported"); - } - const httpSrc = getAvatarUrl(client, src, sizePx); if (!httpSrc) { throw new Error("Failed to get http avatar URL"); @@ -169,14 +165,10 @@ async function getAvatarFromServer( return blob; } -async function getAvatarFromWidget( - api: WidgetApi | undefined, +async function getAvatarFromWidgetAPI( + api: WidgetApi, src: string, ): Promise { - if (!api) { - throw new Error("No widget api given"); - } - const response = await api.downloadFile(src); const file = response.file; diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 1488965a..7059cd69 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -48,7 +48,7 @@ export type ValidClientState = { disconnected: boolean; supportedFeatures: { reactions: boolean; - thumbnails: boolean; + authenticatedMedia: boolean; }; setClient: (client: MatrixClient, session: Session) => void; }; @@ -249,7 +249,8 @@ export const ClientProvider: FC = ({ children }) => { const [isDisconnected, setIsDisconnected] = useState(false); const [supportsReactions, setSupportsReactions] = useState(false); - const [supportsThumbnails, setSupportsThumbnails] = useState(false); + const [supportsAuthenticatedMedia, setSupportsAuthenticatedMedia] = + useState(false); const state: ClientState | undefined = useMemo(() => { if (alreadyOpenedErr) { @@ -275,7 +276,7 @@ export const ClientProvider: FC = ({ children }) => { disconnected: isDisconnected, supportedFeatures: { reactions: supportsReactions, - thumbnails: supportsThumbnails, + authenticatedMedia: supportsAuthenticatedMedia, }, }; }, [ @@ -286,7 +287,7 @@ export const ClientProvider: FC = ({ children }) => { setClient, isDisconnected, supportsReactions, - supportsThumbnails, + supportsAuthenticatedMedia, ]); const onSync = useCallback( @@ -312,8 +313,8 @@ export const ClientProvider: FC = ({ children }) => { } if (initClientState.widgetApi) { - // There is currently no widget API for authenticated media thumbnails. - setSupportsThumbnails(false); + // There is currently no way for widgets to request authenticated media directly from the server. + setSupportsAuthenticatedMedia(false); const reactSend = initClientState.widgetApi.hasCapability( "org.matrix.msc2762.send.event:m.reaction", ); @@ -335,7 +336,7 @@ export const ClientProvider: FC = ({ children }) => { } } else { setSupportsReactions(true); - setSupportsThumbnails(true); + setSupportsAuthenticatedMedia(true); } return (): void => { diff --git a/src/settings/useSubmitRageshake.test.tsx b/src/settings/useSubmitRageshake.test.tsx index b278d4b1..d69255e0 100644 --- a/src/settings/useSubmitRageshake.test.tsx +++ b/src/settings/useSubmitRageshake.test.tsx @@ -78,7 +78,7 @@ function renderWithMockClient( disconnected: false, supportedFeatures: { reactions: true, - thumbnails: true, + authenticatedMedia: true, }, setClient: vi.fn(), authenticated: { From 143b560b20e46ca87b7cd802198d6ec12e65009a Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 10 Mar 2026 09:05:05 +0100 Subject: [PATCH 076/119] Add a playwright test to ensure object fit is correct --- playwright/widget/huddle-call.test.ts | 2 +- playwright/widget/pip-call.test.ts | 74 +++++++++++++++++++++++++++ playwright/widget/test-helpers.ts | 12 +++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 playwright/widget/pip-call.test.ts diff --git a/playwright/widget/huddle-call.test.ts b/playwright/widget/huddle-call.test.ts index b42c0ab2..d4ba0006 100644 --- a/playwright/widget/huddle-call.test.ts +++ b/playwright/widget/huddle-call.test.ts @@ -60,7 +60,7 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => { // The only way to know if it is muted or not is to look at the data-kind attribute.. const videoButton = frame.getByTestId("incall_videomute"); await expect(videoButton).toBeVisible(); - // video should be off by default in a voice call + // video should be on await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/); } diff --git a/playwright/widget/pip-call.test.ts b/playwright/widget/pip-call.test.ts new file mode 100644 index 00000000..49ebec52 --- /dev/null +++ b/playwright/widget/pip-call.test.ts @@ -0,0 +1,74 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { expect, test } from "@playwright/test"; + +import { widgetTest } from "../fixtures/widget-user.ts"; +import { HOST1, TestHelpers } from "./test-helpers.ts"; + +widgetTest("Put call in PIP", async ({ addUser, browserName }) => { + test.skip( + browserName === "firefox", + "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", + ); + + test.slow(); + + const valere = await addUser("Valere", HOST1); + const timo = await addUser("Timo", HOST1); + + const callRoom = "TeamRoom"; + await TestHelpers.createRoom(callRoom, valere.page, [timo.mxId]); + + await TestHelpers.createRoom("DoubleTask", valere.page); + + await TestHelpers.acceptRoomInvite(callRoom, timo.page); + + await TestHelpers.switchToRoomNamed(valere.page, callRoom); + + // Start the call as Valere + await TestHelpers.startCallInCurrentRoom(valere.page, false); + await expect( + valere.page.locator('iframe[title="Element Call"]'), + ).toBeVisible(); + + await TestHelpers.joinCallFromLobby(valere.page); + + await TestHelpers.joinCallInCurrentRoom(timo.page); + + { + const frame = timo.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + + const videoButton = frame.getByTestId("incall_videomute"); + await expect(videoButton).toBeVisible(); + // check that the video is on + await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/); + } + + // Switch to the other room, the call should go to PIP + await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask"); + + // We should see the PIP overlay + await expect(valere.page.locator(".mx_WidgetPip_overlay")).toBeVisible(); + + { + // wait a bit so that the PIP has rendered the video + await valere.page.waitForTimeout(600); + + // Check for a bug where the video had the wrong fit in PIP + const frame = valere.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + const videoElements = await frame.locator("video").all(); + expect(videoElements.length).toBe(1); + + const pipVideo = videoElements[0]; + await expect(pipVideo).toHaveCSS("object-fit", "cover"); + } +}); diff --git a/playwright/widget/test-helpers.ts b/playwright/widget/test-helpers.ts index 6fe4479b..4562ba5a 100644 --- a/playwright/widget/test-helpers.ts +++ b/playwright/widget/test-helpers.ts @@ -276,4 +276,16 @@ export class TestHelpers { }); } } + + /** + * Switches to a room in the room list by its name. + * @param page - The EW page + * @param roomName - The name of the room to switch to + */ + public static async switchToRoomNamed( + page: Page, + roomName: string, + ): Promise { + await page.getByRole("option", { name: `Open room ${roomName}` }).click(); + } } From 8db1c4c37016092b17e0ed281d63234cb9253751 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 4 Mar 2026 16:47:15 +0100 Subject: [PATCH 077/119] Implement new Pip Layout (with control buttons) --- src/button/Button.tsx | 9 ++++- src/room/InCallView.module.css | 43 +++++++++++++----------- src/room/InCallView.tsx | 14 +++++++- src/state/CallViewModel/CallViewModel.ts | 2 +- src/state/PipLayout.ts | 3 +- 5 files changed, 48 insertions(+), 23 deletions(-) diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 3136e2da..4bb0058d 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -23,6 +23,7 @@ import styles from "./Button.module.css"; interface MicButtonProps extends ComponentPropsWithoutRef<"button"> { muted: boolean; + size: "sm" | "lg"; } export const MicButton: FC = ({ muted, ...props }) => { @@ -47,6 +48,7 @@ export const MicButton: FC = ({ muted, ...props }) => { interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> { muted: boolean; + size: "sm" | "lg"; } export const VideoButton: FC = ({ muted, ...props }) => { @@ -71,6 +73,7 @@ export const VideoButton: FC = ({ muted, ...props }) => { interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> { enabled: boolean; + size: "sm" | "lg"; } export const ShareScreenButton: FC = ({ @@ -94,7 +97,11 @@ export const ShareScreenButton: FC = ({ ); }; -export const EndCallButton: FC> = ({ +interface EndCallButtonProps extends ComponentPropsWithoutRef<"button"> { + size: "sm" | "lg"; +} + +export const EndCallButton: FC = ({ className, ...props }) => { diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index 55724932..70f7c73a 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -108,22 +108,9 @@ Please see LICENSE in the repository root for full details. } } -@media (max-width: 370px) { - .shareScreen { - display: none; - } - - @media (max-height: 400px) { - .footer { - display: none; - } - } -} - -@media (max-width: 320px) { - .invite, - .raiseHand { - display: none; +@media (max-height: 800px) { + .footer { + padding-block: var(--cpd-space-8x); } } @@ -133,9 +120,27 @@ Please see LICENSE in the repository root for full details. } } -@media (max-height: 800px) { - .footer { - padding-block: var(--cpd-space-8x); +@media (max-width: 370px) { + .shareScreen { + display: none; + } + + /* PIP custom css */ + @media (max-height: 400px) { + .shareScreen { + display: flex; + } + .footer { + padding-block-start: var(--cpd-space-3x); + padding-block-end: var(--cpd-space-2x); + } + } +} + +@media (max-width: 320px) { + .invite, + .raiseHand { + display: none; } } diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index d8803b22..f1a872a0 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -640,8 +640,10 @@ export const InCallView: FC = ({ const buttons: JSX.Element[] = []; + const buttonSize = layout.type === "pip" ? "sm" : "lg"; buttons.push( = ({ data-testid="incall_mute" />, = ({ if (vm.toggleScreenSharing !== null) { buttons.push( = ({ if (supportsReactions) { buttons.push( = ({ ); } if (layout.type !== "pip") - buttons.push(); + buttons.push( + , + ); buttons.push( { switch (mode) { case "pip": - return of(false); + return of(true); case "normal": case "narrow": return of(true); diff --git a/src/state/PipLayout.ts b/src/state/PipLayout.ts index 56e9aeb2..25a84e94 100644 --- a/src/state/PipLayout.ts +++ b/src/state/PipLayout.ts @@ -16,7 +16,8 @@ export function pipLayout( prevTiles: TileStore, ): [PipLayout, TileStore] { const update = prevTiles.from(0); - update.registerSpotlight(media.spotlight, true); + // Dont maximise in pip since we want the rounded corners and the footer + update.registerSpotlight(media.spotlight, false); const tiles = update.build(); return [ { From 38382539ad22e6e920a001c8b284e0548f876df2 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 9 Mar 2026 13:42:28 +0100 Subject: [PATCH 078/119] fix lints --- src/button/Button.tsx | 7 ++++--- src/button/ReactionToggleButton.tsx | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 4bb0058d..51fc0a1f 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -121,9 +121,10 @@ export const EndCallButton: FC = ({ ); }; -export const SettingsButton: FC> = ( - props, -) => { +interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> { + size: "sm" | "lg"; +} +export const SettingsButton: FC = (props) => { const { t } = useTranslation(); return ( diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx index 0c722baf..3e8f647f 100644 --- a/src/button/ReactionToggleButton.tsx +++ b/src/button/ReactionToggleButton.tsx @@ -166,6 +166,7 @@ export function ReactionPopupMenu({ interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> { identifier: string; vm: CallViewModel; + size: "sm" | "lg"; } export function ReactionToggleButton({ From 273eedd256816599bd6b03063aaa4311941a20e6 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 9 Mar 2026 13:42:47 +0100 Subject: [PATCH 079/119] keep pip as it was before on mobile --- src/state/CallViewModel/CallViewModel.ts | 4 ++-- src/state/PipLayout.ts | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 9a9ac11c..18e49d0a 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -63,7 +63,7 @@ import { playReactionsSound, showReactions, } from "../../settings/settings"; -import { isFirefox } from "../../Platform"; +import { isFirefox, platform } from "../../Platform"; import { setPipEnabled$ } from "../../controls"; import { TileStore } from "../TileStore"; import { gridLikeLayout } from "../GridLikeLayout"; @@ -1271,7 +1271,7 @@ export function createCallViewModel$( switchMap((mode) => { switch (mode) { case "pip": - return of(true); + return of(platform === "desktop" ? true : false); case "normal": case "narrow": return of(true); diff --git a/src/state/PipLayout.ts b/src/state/PipLayout.ts index 25a84e94..6ac1e4f0 100644 --- a/src/state/PipLayout.ts +++ b/src/state/PipLayout.ts @@ -5,6 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ +import { platform } from "../Platform.ts"; import { type PipLayout, type PipLayoutMedia } from "./layout-types.ts"; import { type TileStore } from "./TileStore"; @@ -16,8 +17,11 @@ export function pipLayout( prevTiles: TileStore, ): [PipLayout, TileStore] { const update = prevTiles.from(0); - // Dont maximise in pip since we want the rounded corners and the footer - update.registerSpotlight(media.spotlight, false); + // Dont maximise in pip on EW since we want the rounded corners and the footer + update.registerSpotlight( + media.spotlight, + platform === "desktop" ? false : true, + ); const tiles = update.build(); return [ { From 54bef07b3b895ee391928460d5a9cf98eed5a40b Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 9 Mar 2026 14:40:13 +0100 Subject: [PATCH 080/119] linter --- src/button/Button.tsx | 8 ++++---- src/button/ReactionToggleButton.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 51fc0a1f..00d803f1 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -23,7 +23,7 @@ import styles from "./Button.module.css"; interface MicButtonProps extends ComponentPropsWithoutRef<"button"> { muted: boolean; - size: "sm" | "lg"; + size?: "sm" | "lg"; } export const MicButton: FC = ({ muted, ...props }) => { @@ -48,7 +48,7 @@ export const MicButton: FC = ({ muted, ...props }) => { interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> { muted: boolean; - size: "sm" | "lg"; + size?: "sm" | "lg"; } export const VideoButton: FC = ({ muted, ...props }) => { @@ -98,7 +98,7 @@ export const ShareScreenButton: FC = ({ }; interface EndCallButtonProps extends ComponentPropsWithoutRef<"button"> { - size: "sm" | "lg"; + size?: "sm" | "lg"; } export const EndCallButton: FC = ({ @@ -122,7 +122,7 @@ export const EndCallButton: FC = ({ }; interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> { - size: "sm" | "lg"; + size?: "sm" | "lg"; } export const SettingsButton: FC = (props) => { const { t } = useTranslation(); diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx index 3e8f647f..28163321 100644 --- a/src/button/ReactionToggleButton.tsx +++ b/src/button/ReactionToggleButton.tsx @@ -166,7 +166,7 @@ export function ReactionPopupMenu({ interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> { identifier: string; vm: CallViewModel; - size: "sm" | "lg"; + size?: "sm" | "lg"; } export function ReactionToggleButton({ From 6485da8fff9fbd5c2957f327b251c914d91b2deb Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 10 Mar 2026 15:17:41 +0100 Subject: [PATCH 081/119] add playwright tests for new pip layout --- .../pip-call-button-interaction.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 playwright/widget/pip-call-button-interaction.test.ts diff --git a/playwright/widget/pip-call-button-interaction.test.ts b/playwright/widget/pip-call-button-interaction.test.ts new file mode 100644 index 00000000..77659672 --- /dev/null +++ b/playwright/widget/pip-call-button-interaction.test.ts @@ -0,0 +1,71 @@ +/* +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 { expect, test } from "@playwright/test"; + +import { widgetTest } from "../fixtures/widget-user.ts"; +import { HOST1, TestHelpers } from "./test-helpers.ts"; + +widgetTest( + "Footer interaction in PiP", + async ({ addUser, browserName, page }) => { + test.skip( + browserName === "firefox", + "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", + ); + + test.slow(); + + const valere = await addUser("Valere", HOST1); + + const callRoom = "CallRoom"; + await TestHelpers.createRoom("CallRoom", valere.page); + + await TestHelpers.createRoom("OtherRoom", valere.page); + + await TestHelpers.switchToRoomNamed(valere.page, callRoom); + + // Start the call as Valere + await TestHelpers.startCallInCurrentRoom(valere.page, false); + await expect( + valere.page.locator('iframe[title="Element Call"]'), + ).toBeVisible(); + + await TestHelpers.joinCallFromLobby(valere.page); + // wait a bit so that the PIP has rendered + await valere.page.waitForTimeout(600); + + // Switch to the other room, the call should go to PIP + await TestHelpers.switchToRoomNamed(valere.page, "OtherRoom"); + + // We should see the PIP overlay + const iFrame = valere.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + + { + // Check for a bug where the video had the wrong fit in PIP + const hangupButton = iFrame.getByRole("button", { name: "End call" }); + const videoMuteButton = iFrame.getByRole("button", { + name: "Stop video", + }); + const audioMuteButton = iFrame.getByRole("button", { + name: "Mute microphone", + }); + await expect(hangupButton).toBeVisible(); + await expect(videoMuteButton).toBeVisible(); + await expect(audioMuteButton).toBeVisible(); + + // TODO once we have the EW version that supports the interactive pip element we can activate those checks + // await videoMuteButton.click(); + // await audioMuteButton.click(); + + // await expect(videoMuteButton).toHaveCSS("disabled", "true"); + // await expect(audioMuteButton).toHaveCSS("disabled", "true"); + } + }, +); From 1e400bc5502bee7f46e6a658fe55cb39cc34c369 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 10 Mar 2026 18:26:12 +0100 Subject: [PATCH 082/119] remove unsused import --- .../pip-call-button-interaction.test.ts | 93 +++++++++---------- 1 file changed, 45 insertions(+), 48 deletions(-) diff --git a/playwright/widget/pip-call-button-interaction.test.ts b/playwright/widget/pip-call-button-interaction.test.ts index 77659672..84cee3fa 100644 --- a/playwright/widget/pip-call-button-interaction.test.ts +++ b/playwright/widget/pip-call-button-interaction.test.ts @@ -10,62 +10,59 @@ import { expect, test } from "@playwright/test"; import { widgetTest } from "../fixtures/widget-user.ts"; import { HOST1, TestHelpers } from "./test-helpers.ts"; -widgetTest( - "Footer interaction in PiP", - async ({ addUser, browserName, page }) => { - test.skip( - browserName === "firefox", - "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", - ); +widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => { + test.skip( + browserName === "firefox", + "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", + ); - test.slow(); + test.slow(); - const valere = await addUser("Valere", HOST1); + const valere = await addUser("Valere", HOST1); + await valere.page.pause(); + const callRoom = "CallRoom"; + await TestHelpers.createRoom("CallRoom", valere.page); - const callRoom = "CallRoom"; - await TestHelpers.createRoom("CallRoom", valere.page); + await TestHelpers.createRoom("OtherRoom", valere.page); - await TestHelpers.createRoom("OtherRoom", valere.page); + await TestHelpers.switchToRoomNamed(valere.page, callRoom); - await TestHelpers.switchToRoomNamed(valere.page, callRoom); + // Start the call as Valere + await TestHelpers.startCallInCurrentRoom(valere.page, false); + await expect( + valere.page.locator('iframe[title="Element Call"]'), + ).toBeVisible(); - // Start the call as Valere - await TestHelpers.startCallInCurrentRoom(valere.page, false); - await expect( - valere.page.locator('iframe[title="Element Call"]'), - ).toBeVisible(); + await TestHelpers.joinCallFromLobby(valere.page); + // wait a bit so that the PIP has rendered + await valere.page.waitForTimeout(600); - await TestHelpers.joinCallFromLobby(valere.page); - // wait a bit so that the PIP has rendered - await valere.page.waitForTimeout(600); + // Switch to the other room, the call should go to PIP + await TestHelpers.switchToRoomNamed(valere.page, "OtherRoom"); - // Switch to the other room, the call should go to PIP - await TestHelpers.switchToRoomNamed(valere.page, "OtherRoom"); + // We should see the PIP overlay + const iFrame = valere.page + .locator('iframe[title="Element Call"]') + .contentFrame(); - // We should see the PIP overlay - const iFrame = valere.page - .locator('iframe[title="Element Call"]') - .contentFrame(); + { + // Check for a bug where the video had the wrong fit in PIP + const hangupButton = iFrame.getByRole("button", { name: "End call" }); + const videoMuteButton = iFrame.getByRole("button", { + name: "Stop video", + }); + const audioMuteButton = iFrame.getByRole("button", { + name: "Mute microphone", + }); + await expect(hangupButton).toBeVisible(); + await expect(videoMuteButton).toBeVisible(); + await expect(audioMuteButton).toBeVisible(); - { - // Check for a bug where the video had the wrong fit in PIP - const hangupButton = iFrame.getByRole("button", { name: "End call" }); - const videoMuteButton = iFrame.getByRole("button", { - name: "Stop video", - }); - const audioMuteButton = iFrame.getByRole("button", { - name: "Mute microphone", - }); - await expect(hangupButton).toBeVisible(); - await expect(videoMuteButton).toBeVisible(); - await expect(audioMuteButton).toBeVisible(); + // TODO once we have the EW version that supports the interactive pip element we can activate those checks + // await videoMuteButton.click(); + // await audioMuteButton.click(); - // TODO once we have the EW version that supports the interactive pip element we can activate those checks - // await videoMuteButton.click(); - // await audioMuteButton.click(); - - // await expect(videoMuteButton).toHaveCSS("disabled", "true"); - // await expect(audioMuteButton).toHaveCSS("disabled", "true"); - } - }, -); + // await expect(videoMuteButton).toHaveCSS("disabled", "true"); + // await expect(audioMuteButton).toHaveCSS("disabled", "true"); + } +}); From c9557e91d5453a2085b5c62c034eb6aa5346bafc Mon Sep 17 00:00:00 2001 From: fkwp Date: Wed, 11 Mar 2026 13:06:20 +0100 Subject: [PATCH 083/119] fix: add id-token permission as its required by tailscale login (part 3) (#3793) * Push docker images to oci.element.io * prettier * add id-token permission as its required by tailscale login * pass secrets to reusable workflows --- .github/workflows/build.yaml | 1 + .github/workflows/pr-deploy.yaml | 1 + .github/workflows/publish.yaml | 1 + 3 files changed, 3 insertions(+) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 4f9e80f2..32ce25c9 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -51,6 +51,7 @@ jobs: packages: write id-token: write uses: ./.github/workflows/build-and-publish-docker.yaml + secrets: inherit with: artifact_run_id: ${{ github.run_id }} docker_tags: | diff --git a/.github/workflows/pr-deploy.yaml b/.github/workflows/pr-deploy.yaml index 62b37aca..138ab2b5 100644 --- a/.github/workflows/pr-deploy.yaml +++ b/.github/workflows/pr-deploy.yaml @@ -62,6 +62,7 @@ jobs: packages: write id-token: write uses: ./.github/workflows/build-and-publish-docker.yaml + secrets: inherit with: artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }} docker_tags: | diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index ade91019..ceedf781 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -57,6 +57,7 @@ jobs: packages: write id-token: write uses: ./.github/workflows/build-and-publish-docker.yaml + secrets: inherit with: artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }} docker_tags: | From 3a9d3945293f8baea4f698353cec42c602c765fd Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 11 Mar 2026 14:05:09 +0100 Subject: [PATCH 084/119] activate click tests --- playwright/widget/pip-call-button-interaction.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/playwright/widget/pip-call-button-interaction.test.ts b/playwright/widget/pip-call-button-interaction.test.ts index 84cee3fa..a646db2f 100644 --- a/playwright/widget/pip-call-button-interaction.test.ts +++ b/playwright/widget/pip-call-button-interaction.test.ts @@ -58,11 +58,10 @@ widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => { await expect(videoMuteButton).toBeVisible(); await expect(audioMuteButton).toBeVisible(); - // TODO once we have the EW version that supports the interactive pip element we can activate those checks - // await videoMuteButton.click(); - // await audioMuteButton.click(); + await videoMuteButton.click(); + await audioMuteButton.click(); - // await expect(videoMuteButton).toHaveCSS("disabled", "true"); - // await expect(audioMuteButton).toHaveCSS("disabled", "true"); + await expect(videoMuteButton).toHaveCSS("disabled", "true"); + await expect(audioMuteButton).toHaveCSS("disabled", "true"); } }); From 41f7b643fb1a91ca32d144c53d6dec6831e5e04c Mon Sep 17 00:00:00 2001 From: fkwp Date: Wed, 11 Mar 2026 14:20:05 +0100 Subject: [PATCH 085/119] Add zizmor checks on CI (#3792) * zizmor auto fixes * add github action for security analysis with zizmor * add access token to iOS push action --- .../workflows/build-and-publish-docker.yaml | 2 + .github/workflows/build-element-call.yaml | 2 + .github/workflows/deploy-to-netlify.yaml | 10 ++- .github/workflows/lint.yaml | 2 + .../workflows/publish-embedded-packages.yaml | 69 +++++++++++++------ .github/workflows/publish.yaml | 4 +- .github/workflows/test.yaml | 4 ++ .github/workflows/translations-download.yaml | 2 + .github/workflows/translations-upload.yaml | 2 + .github/workflows/zizmor.yml | 23 +++++++ 10 files changed, 93 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/zizmor.yml diff --git a/.github/workflows/build-and-publish-docker.yaml b/.github/workflows/build-and-publish-docker.yaml index 6e8f01f5..65d499f7 100644 --- a/.github/workflows/build-and-publish-docker.yaml +++ b/.github/workflows/build-and-publish-docker.yaml @@ -25,6 +25,8 @@ jobs: steps: - name: Check it out uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: 📥 Download artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 diff --git a/.github/workflows/build-element-call.yaml b/.github/workflows/build-element-call.yaml index 4ca5ccad..300138e0 100644 --- a/.github/workflows/build-element-call.yaml +++ b/.github/workflows/build-element-call.yaml @@ -33,6 +33,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Enable Corepack run: corepack enable - name: Yarn cache diff --git a/.github/workflows/deploy-to-netlify.yaml b/.github/workflows/deploy-to-netlify.yaml index 3f964ad4..76609328 100644 --- a/.github/workflows/deploy-to-netlify.yaml +++ b/.github/workflows/deploy-to-netlify.yaml @@ -63,11 +63,15 @@ jobs: - name: Add config file run: | - if [ "${{ inputs.package }}" = "full" ]; then - curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview.json" > webapp/config.json + if [ "${INPUTS_PACKAGE}" = "full" ]; then + curl -s "https://raw.githubusercontent.com/${INPUTS_PR_HEAD_FULL_NAME}/${INPUTS_PR_HEAD_REF}/config/config_netlify_preview.json" > webapp/config.json else - curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview_sdk.json" > webapp/config.json + curl -s "https://raw.githubusercontent.com/${INPUTS_PR_HEAD_FULL_NAME}/${INPUTS_PR_HEAD_REF}/config/config_netlify_preview_sdk.json" > webapp/config.json fi + env: + INPUTS_PACKAGE: ${{ inputs.package }} + INPUTS_PR_HEAD_FULL_NAME: ${{ inputs.pr_head_full_name }} + INPUTS_PR_HEAD_REF: ${{ inputs.pr_head_ref }} - name: ☁️ Deploy to Netlify id: netlify uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0 diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 32dde869..763d2eac 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -8,6 +8,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Enable Corepack run: corepack enable - name: Yarn cache diff --git a/.github/workflows/publish-embedded-packages.yaml b/.github/workflows/publish-embedded-packages.yaml index fc8a640f..1c6cd7b1 100644 --- a/.github/workflows/publish-embedded-packages.yaml +++ b/.github/workflows/publish-embedded-packages.yaml @@ -71,7 +71,9 @@ jobs: contents: write # required to upload release asset steps: - name: Determine filename - run: echo "FILENAME_PREFIX=element-call-embedded-${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV" + run: echo "FILENAME_PREFIX=element-call-embedded-${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV" + env: + NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION: ${{ needs.versioning.outputs.UNPREFIXED_VERSION }} - name: 📥 Download built element-call artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: @@ -80,9 +82,9 @@ jobs: name: build-output-embedded path: ${{ env.FILENAME_PREFIX}} - name: Create Tarball - run: tar --numeric-owner -cvzf ${{ env.FILENAME_PREFIX }}.tar.gz ${{ env.FILENAME_PREFIX }} + run: tar --numeric-owner -cvzf ${FILENAME_PREFIX}.tar.gz ${FILENAME_PREFIX} - name: Create Checksum - run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256 + run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256 - name: Upload if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }} uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 @@ -104,6 +106,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: 📥 Download built element-call artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 @@ -123,13 +127,16 @@ jobs: - name: Publish npm working-directory: embedded/web run: | - npm version ${{ needs.versioning.outputs.PREFIXED_VERSION }} --no-git-tag-version + npm version ${NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION} --no-git-tag-version echo "ARTIFACT_VERSION=$(jq '.version' --raw-output package.json)" >> "$GITHUB_ENV" - npm publish --provenance --access public --tag ${{ needs.versioning.outputs.TAG }} ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }} + npm publish --provenance --access public --tag ${NEEDS_VERSIONING_OUTPUTS_TAG} ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }} + env: + NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION: ${{ needs.versioning.outputs.PREFIXED_VERSION }} + NEEDS_VERSIONING_OUTPUTS_TAG: ${{ needs.versioning.outputs.TAG }} - id: artifact_version name: Output artifact version - run: echo "ARTIFACT_VERSION=${{env.ARTIFACT_VERSION}}" >> "$GITHUB_OUTPUT" + run: echo "ARTIFACT_VERSION=${ARTIFACT_VERSION}" >> "$GITHUB_OUTPUT" publish_android: needs: [build_element_call, versioning] @@ -143,6 +150,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: 📥 Download built element-call artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 @@ -161,16 +170,19 @@ jobs: - name: Get artifact version # Anything that is not a final release will be tagged as a snapshot run: | - if [[ "${{ needs.versioning.outputs.TAG }}" == "latest" ]]; then - echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV" - elif [[ "${{ needs.versioning.outputs.TAG }}" == "rc" ]]; then - echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV" + if [[ "${NEEDS_VERSIONING_OUTPUTS_TAG}" == "latest" ]]; then + echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV" + elif [[ "${NEEDS_VERSIONING_OUTPUTS_TAG}" == "rc" ]]; then + echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV" else - echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}-SNAPSHOT" >> "$GITHUB_ENV" + echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}-SNAPSHOT" >> "$GITHUB_ENV" fi + env: + NEEDS_VERSIONING_OUTPUTS_TAG: ${{ needs.versioning.outputs.TAG }} + NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION: ${{ needs.versioning.outputs.UNPREFIXED_VERSION }} - name: Set version string - run: sed -i "s/0.0.0/${{ env.ARTIFACT_VERSION }}/g" embedded/android/lib/src/main/kotlin/io/element/android/call/embedded/Version.kt + run: sed -i "s/0.0.0/${ARTIFACT_VERSION}/g" embedded/android/lib/src/main/kotlin/io/element/android/call/embedded/Version.kt - name: Publish AAR working-directory: embedded/android @@ -184,7 +196,7 @@ jobs: - id: artifact_version name: Output artifact version - run: echo "ARTIFACT_VERSION=${{env.ARTIFACT_VERSION}}" >> "$GITHUB_OUTPUT" + run: echo "ARTIFACT_VERSION=${ARTIFACT_VERSION}" >> "$GITHUB_OUTPUT" publish_ios: needs: [build_element_call, versioning] @@ -200,6 +212,7 @@ jobs: uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: path: element-call + persist-credentials: false - name: 📥 Download built element-call artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 @@ -215,15 +228,18 @@ jobs: repository: element-hq/element-call-swift path: element-call-swift token: ${{ secrets.SWIFT_RELEASE_TOKEN }} + persist-credentials: false - name: Copy files run: rsync -a --delete --exclude .git element-call/embedded/ios/ element-call-swift - name: Get artifact version - run: echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV" + run: echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV" + env: + NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION: ${{ needs.versioning.outputs.UNPREFIXED_VERSION }} - name: Set version string - run: sed -i "s/0.0.0/${{ env.ARTIFACT_VERSION }}/g" element-call-swift/Sources/EmbeddedElementCall/EmbeddedElementCall.swift + run: sed -i "s/0.0.0/${ARTIFACT_VERSION}/g" element-call-swift/Sources/EmbeddedElementCall/EmbeddedElementCall.swift - name: Test build working-directory: element-call-swift @@ -235,17 +251,22 @@ jobs: git config --global user.email "ci@element.io" git config --global user.name "Element CI" git add -A - git commit -am "Release ${{ needs.versioning.outputs.PREFIXED_VERSION }}" - git tag -a ${{ env.ARTIFACT_VERSION }} -m "${{ github.event.release.html_url }}" + git commit -am "Release ${NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION}" + git tag -a ${ARTIFACT_VERSION} -m "${GITHUB_EVENT_RELEASE_HTML_URL}" + env: + NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION: ${{ needs.versioning.outputs.PREFIXED_VERSION }} + GITHUB_EVENT_RELEASE_HTML_URL: ${{ github.event.release.html_url }} - name: Push working-directory: element-call-swift run: | - git push --tags ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }} + git push "https://x-access-token:${SWIFT_RELEASE_TOKEN}@github.com/element-hq/element-call-swift.git" --tags ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }} + env: + SWIFT_RELEASE_TOKEN: ${{ secrets.SWIFT_RELEASE_TOKEN }} - id: artifact_version name: Output artifact version - run: echo "ARTIFACT_VERSION=${{env.ARTIFACT_VERSION}}" >> "$GITHUB_OUTPUT" + run: echo "ARTIFACT_VERSION=${ARTIFACT_VERSION}" >> "$GITHUB_OUTPUT" release_notes: needs: [versioning, publish_npm, publish_android, publish_ios] @@ -257,9 +278,13 @@ jobs: steps: - name: Log versions run: | - echo "NPM: ${{ needs.publish_npm.outputs.ARTIFACT_VERSION }}" - echo "Android: ${{ needs.publish_android.outputs.ARTIFACT_VERSION }}" - echo "iOS: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}" + echo "NPM: ${NEEDS_PUBLISH_NPM_OUTPUTS_ARTIFACT_VERSION}" + echo "Android: ${NEEDS_PUBLISH_ANDROID_OUTPUTS_ARTIFACT_VERSION}" + echo "iOS: ${NEEDS_PUBLISH_IOS_OUTPUTS_ARTIFACT_VERSION}" + env: + NEEDS_PUBLISH_NPM_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_npm.outputs.ARTIFACT_VERSION }} + NEEDS_PUBLISH_ANDROID_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_android.outputs.ARTIFACT_VERSION }} + NEEDS_PUBLISH_IOS_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }} - name: Add release notes if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }} uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index ceedf781..0675b1b1 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -38,9 +38,9 @@ jobs: name: build-output-full path: ${{ env.FILENAME_PREFIX }} - name: Create Tarball - run: tar --numeric-owner --transform "s/dist/${{ env.FILENAME_PREFIX }}/" -cvzf ${{ env.FILENAME_PREFIX }}.tar.gz ${{ env.FILENAME_PREFIX }} + run: tar --numeric-owner --transform "s/dist/${FILENAME_PREFIX}/" -cvzf ${FILENAME_PREFIX}.tar.gz ${FILENAME_PREFIX} - name: Create Checksum - run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256 + run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256 - name: Upload uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 with: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 012de7cb..cd1c94c5 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -10,6 +10,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Enable Corepack run: corepack enable - name: Yarn cache @@ -34,6 +36,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Enable Corepack run: corepack enable - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/.github/workflows/translations-download.yaml b/.github/workflows/translations-download.yaml index 45f366cd..ad9f4652 100644 --- a/.github/workflows/translations-download.yaml +++ b/.github/workflows/translations-download.yaml @@ -14,6 +14,8 @@ jobs: steps: - name: Checkout the code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Enable Corepack run: corepack enable diff --git a/.github/workflows/translations-upload.yaml b/.github/workflows/translations-upload.yaml index 4c062513..daf96895 100644 --- a/.github/workflows/translations-upload.yaml +++ b/.github/workflows/translations-upload.yaml @@ -15,6 +15,8 @@ jobs: steps: - name: Checkout the code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Upload uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 00000000..612adbd1 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,23 @@ +name: GitHub Actions Security Analysis with zizmor 🌈 + +on: + push: + branches: ["livekit", "full-mesh"] + pull_request: {} + +permissions: {} + +jobs: + zizmor: + name: Run zizmor 🌈 + runs-on: ubuntu-latest + permissions: + security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run zizmor 🌈 + uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0 From 839c4dd7388640e2a7796bd5db3deefa326270b1 Mon Sep 17 00:00:00 2001 From: fkwp Date: Wed, 11 Mar 2026 15:17:12 +0100 Subject: [PATCH 086/119] fix: OCI image push to element registry (#3795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Push docker images to oci.element.io * prettier * add id-token permission as its required by tailscale login * pass secrets to reusable workflows * change secret path team -> voip * Update .github/workflows/build-and-publish-docker.yaml Co-authored-by: Gaël Goinvic <97093369+gaelgatelement@users.noreply.github.com> --------- Co-authored-by: Gaël Goinvic <97093369+gaelgatelement@users.noreply.github.com> --- .github/workflows/build-and-publish-docker.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-publish-docker.yaml b/.github/workflows/build-and-publish-docker.yaml index 65d499f7..a3a5cba7 100644 --- a/.github/workflows/build-and-publish-docker.yaml +++ b/.github/workflows/build-and-publish-docker.yaml @@ -68,8 +68,8 @@ jobs: jwtGithubAudience: https://vault.infra.ci.i.element.dev method: jwt secrets: | - services/-repositories/secret/data/oci.element.io username | OCI_USERNAME ; - services/-repositories/secret/data/oci.element.io password | OCI_PASSWORD ; + services/voip-repositories/secret/data/oci.element.io username | OCI_USERNAME ; + services/voip-repositories/secret/data/oci.element.io password | OCI_PASSWORD ; - name: Login to oci.element.io Registry uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 From d00ff78d65ee7583578147e268bbf52c12106b5e Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 11 Mar 2026 15:21:36 +0100 Subject: [PATCH 087/119] fix pip interaction test (button presses) --- .../pip-call-button-interaction.test.ts | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/playwright/widget/pip-call-button-interaction.test.ts b/playwright/widget/pip-call-button-interaction.test.ts index a646db2f..f798545d 100644 --- a/playwright/widget/pip-call-button-interaction.test.ts +++ b/playwright/widget/pip-call-button-interaction.test.ts @@ -19,7 +19,7 @@ widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => { test.slow(); const valere = await addUser("Valere", HOST1); - await valere.page.pause(); + const callRoom = "CallRoom"; await TestHelpers.createRoom("CallRoom", valere.page); @@ -48,20 +48,33 @@ widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => { { // Check for a bug where the video had the wrong fit in PIP const hangupButton = iFrame.getByRole("button", { name: "End call" }); - const videoMuteButton = iFrame.getByRole("button", { - name: "Stop video", - }); - const audioMuteButton = iFrame.getByRole("button", { - name: "Mute microphone", - }); + const audioMuteButton = iFrame.getByTestId("incall_mute"); + const videoMuteButton = iFrame.getByTestId("incall_videomute"); await expect(hangupButton).toBeVisible(); - await expect(videoMuteButton).toBeVisible(); await expect(audioMuteButton).toBeVisible(); - + await expect(videoMuteButton).toBeVisible(); + await expect(audioMuteButton).toHaveCSS( + "background-color", + "rgb(255, 255, 255)", + ); + await expect(videoMuteButton).toHaveCSS( + "background-color", + "rgb(255, 255, 255)", + ); await videoMuteButton.click(); await audioMuteButton.click(); + // stop hovering on any of the buttons + await iFrame.getByTestId("videoTile").hover(); + await valere.page.pause(); - await expect(videoMuteButton).toHaveCSS("disabled", "true"); - await expect(audioMuteButton).toHaveCSS("disabled", "true"); + await expect(audioMuteButton).toHaveCSS( + "background-color", + "rgb(27, 29, 34)", + ); + + await expect(videoMuteButton).toHaveCSS( + "background-color", + "rgb(27, 29, 34)", + ); } }); From a20edca9a12a711777e2cc952b7cd90681b9a973 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 11 Mar 2026 15:36:37 +0100 Subject: [PATCH 088/119] fix pip container query --- playwright/widget/pip-call.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright/widget/pip-call.test.ts b/playwright/widget/pip-call.test.ts index 49ebec52..d57befc1 100644 --- a/playwright/widget/pip-call.test.ts +++ b/playwright/widget/pip-call.test.ts @@ -55,7 +55,7 @@ widgetTest("Put call in PIP", async ({ addUser, browserName }) => { await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask"); // We should see the PIP overlay - await expect(valere.page.locator(".mx_WidgetPip_overlay")).toBeVisible(); + await expect(valere.page.getByTestId("widget-pip-container")).toBeVisible(); { // wait a bit so that the PIP has rendered the video From c7f25feb662c82182c494d79cba09fb184edd31d Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 11 Mar 2026 15:44:47 +0100 Subject: [PATCH 089/119] use better test condition for mute buttons --- .../pip-call-button-interaction.test.ts | 40 +++++++------------ 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/playwright/widget/pip-call-button-interaction.test.ts b/playwright/widget/pip-call-button-interaction.test.ts index f798545d..1dda652d 100644 --- a/playwright/widget/pip-call-button-interaction.test.ts +++ b/playwright/widget/pip-call-button-interaction.test.ts @@ -47,34 +47,22 @@ widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => { { // Check for a bug where the video had the wrong fit in PIP - const hangupButton = iFrame.getByRole("button", { name: "End call" }); - const audioMuteButton = iFrame.getByTestId("incall_mute"); - const videoMuteButton = iFrame.getByTestId("incall_videomute"); - await expect(hangupButton).toBeVisible(); - await expect(audioMuteButton).toBeVisible(); - await expect(videoMuteButton).toBeVisible(); - await expect(audioMuteButton).toHaveCSS( - "background-color", - "rgb(255, 255, 255)", - ); - await expect(videoMuteButton).toHaveCSS( - "background-color", - "rgb(255, 255, 255)", - ); - await videoMuteButton.click(); - await audioMuteButton.click(); + const hangupBtn = iFrame.getByRole("button", { name: "End call" }); + const audioBtn = iFrame.getByTestId("incall_mute"); + const videoBtn = iFrame.getByTestId("incall_videomute"); + await expect(hangupBtn).toBeVisible(); + await expect(audioBtn).toBeVisible(); + await expect(videoBtn).toBeVisible(); + await expect(audioBtn).toHaveAttribute("aria-label", /^Mute microphone$/); + await expect(videoBtn).toHaveAttribute("aria-label", /^Stop video$/); + + await videoBtn.click(); + await audioBtn.click(); + // stop hovering on any of the buttons await iFrame.getByTestId("videoTile").hover(); - await valere.page.pause(); - await expect(audioMuteButton).toHaveCSS( - "background-color", - "rgb(27, 29, 34)", - ); - - await expect(videoMuteButton).toHaveCSS( - "background-color", - "rgb(27, 29, 34)", - ); + await expect(audioBtn).toHaveAttribute("aria-label", /^Unmute microphone$/); + await expect(videoBtn).toHaveAttribute("aria-label", /^Start video$/); } }); From af54b396985ceca538619d9313dc952b9cc32b67 Mon Sep 17 00:00:00 2001 From: fkwp Date: Wed, 11 Mar 2026 16:09:02 +0100 Subject: [PATCH 090/119] fix: typo pushing element registry OCI images now to the correct target (#3796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Push docker images to oci.element.io * prettier * add id-token permission as its required by tailscale login * pass secrets to reusable workflows * change secret path team -> voip * Update .github/workflows/build-and-publish-docker.yaml Co-authored-by: Gaël Goinvic <97093369+gaelgatelement@users.noreply.github.com> * typo --------- Co-authored-by: Gaël Goinvic <97093369+gaelgatelement@users.noreply.github.com> --- .github/workflows/build-and-publish-docker.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-publish-docker.yaml b/.github/workflows/build-and-publish-docker.yaml index a3a5cba7..6447c094 100644 --- a/.github/workflows/build-and-publish-docker.yaml +++ b/.github/workflows/build-and-publish-docker.yaml @@ -85,7 +85,7 @@ jobs: with: images: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - oci-push.vpn.infra.element.io/element-web + oci-push.vpn.infra.element.io/element-call tags: ${{ inputs.docker_tags }} labels: | org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial From 6b8f6e940538893149b73164c8f650cf8c74e5d1 Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 12 Mar 2026 12:10:17 +0100 Subject: [PATCH 091/119] update vite vitest and rollup (rollup needs updating to fix a security alert) --- package.json | 7 +- src/livekit/MatrixAudioRenderer.test.tsx | 8 +- src/useAudioContext.test.tsx | 11 +- src/utils/test.ts | 4 +- yarn.lock | 537 +++++++++++------------ 5 files changed, 267 insertions(+), 300 deletions(-) diff --git a/package.json b/package.json index 9ee0ad26..30d63a2e 100644 --- a/package.json +++ b/package.json @@ -128,17 +128,18 @@ "unique-names-generator": "^4.6.0", "uuid": "^13.0.0", "vaul": "^1.0.0", - "vite": "^7.0.0", + "vite": "^7.3.0", "vite-plugin-generate-file": "^0.3.0", "vite-plugin-html": "^3.2.2", "vite-plugin-node-stdlib-browser": "^0.2.1", "vite-plugin-svgr": "^4.0.0", - "vitest": "^3.0.0", + "vitest": "^4.0.18", "vitest-axe": "^1.0.0-pre.3" }, "resolutions": { "@livekit/components-core/rxjs": "^7.8.1", - "@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18" + "@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18", + "rollup": "4.59.0" }, "packageManager": "yarn@4.7.0" } diff --git a/src/livekit/MatrixAudioRenderer.test.tsx b/src/livekit/MatrixAudioRenderer.test.tsx index c6122b4b..bc6ef668 100644 --- a/src/livekit/MatrixAudioRenderer.test.tsx +++ b/src/livekit/MatrixAudioRenderer.test.tsx @@ -30,7 +30,13 @@ import { } from "../utils/test"; import { initializeWidget } from "../widget"; initializeWidget(); -export const TestAudioContextConstructor = vi.fn(() => testAudioContext); +export const TestAudioContextConstructor = vi.fn( + class { + public constructor() { + return testAudioContext; + } + }, +); const MediaDevicesProvider = MediaDevicesContext.MediaDevicesContext.Provider; diff --git a/src/useAudioContext.test.tsx b/src/useAudioContext.test.tsx index f74cf016..97bfff5c 100644 --- a/src/useAudioContext.test.tsx +++ b/src/useAudioContext.test.tsx @@ -90,11 +90,18 @@ export const testAudioContext = { createStereoPanner: vi.fn().mockReturnValue(panNode), close: vi.fn().mockResolvedValue(undefined), }; -export const TestAudioContextConstructor = vi.fn(() => testAudioContext); + +const TestAudioContext = vi.fn( + class { + public constructor() { + return testAudioContext; + } + }, +); let user: UserEvent; beforeEach(() => { - vi.stubGlobal("AudioContext", TestAudioContextConstructor); + vi.stubGlobal("AudioContext", TestAudioContext); user = userEvent.setup(); }); diff --git a/src/utils/test.ts b/src/utils/test.ts index b99bfa88..f8d420cf 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -443,10 +443,10 @@ export class MockRTCSession extends TypedEventEmitter< public asMockedSession(): MockedObject { const session = this as unknown as MockedObject; - vi.mocked(session).reemitEncryptionKeys = vi + session.reemitEncryptionKeys = vi .fn<() => void>() .mockReturnValue(undefined); - vi.mocked(session).getOldestMembership = vi + session.getOldestMembership = vi .fn<() => CallMembership | undefined>() .mockReturnValue(this.memberships[0]); diff --git a/yarn.lock b/yarn.lock index bd90ded3..552e7768 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5085,149 +5085,177 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-android-arm-eabi@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-android-arm-eabi@npm:4.50.1" +"@rollup/rollup-android-arm-eabi@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.59.0" conditions: os=android & cpu=arm languageName: node linkType: hard -"@rollup/rollup-android-arm64@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-android-arm64@npm:4.50.1" +"@rollup/rollup-android-arm64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-android-arm64@npm:4.59.0" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-darwin-arm64@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-darwin-arm64@npm:4.50.1" +"@rollup/rollup-darwin-arm64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-darwin-arm64@npm:4.59.0" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-darwin-x64@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-darwin-x64@npm:4.50.1" +"@rollup/rollup-darwin-x64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-darwin-x64@npm:4.59.0" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@rollup/rollup-freebsd-arm64@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-freebsd-arm64@npm:4.50.1" +"@rollup/rollup-freebsd-arm64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.59.0" conditions: os=freebsd & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-freebsd-x64@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-freebsd-x64@npm:4.50.1" +"@rollup/rollup-freebsd-x64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-freebsd-x64@npm:4.59.0" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@rollup/rollup-linux-arm-gnueabihf@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.50.1" +"@rollup/rollup-linux-arm-gnueabihf@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.59.0" conditions: os=linux & cpu=arm & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-arm-musleabihf@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.50.1" +"@rollup/rollup-linux-arm-musleabihf@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.59.0" conditions: os=linux & cpu=arm & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-arm64-gnu@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.50.1" +"@rollup/rollup-linux-arm64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.59.0" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-arm64-musl@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-linux-arm64-musl@npm:4.50.1" +"@rollup/rollup-linux-arm64-musl@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.59.0" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-loongarch64-gnu@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.50.1" +"@rollup/rollup-linux-loong64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.59.0" conditions: os=linux & cpu=loong64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-ppc64-gnu@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.50.1" +"@rollup/rollup-linux-loong64-musl@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-loong64-musl@npm:4.59.0" + conditions: os=linux & cpu=loong64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-ppc64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.59.0" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-gnu@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.50.1" +"@rollup/rollup-linux-ppc64-musl@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-ppc64-musl@npm:4.59.0" + conditions: os=linux & cpu=ppc64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-riscv64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.59.0" conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-musl@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.50.1" +"@rollup/rollup-linux-riscv64-musl@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.59.0" conditions: os=linux & cpu=riscv64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-s390x-gnu@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.50.1" +"@rollup/rollup-linux-s390x-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.59.0" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-x64-gnu@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-linux-x64-gnu@npm:4.50.1" +"@rollup/rollup-linux-x64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.59.0" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-x64-musl@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-linux-x64-musl@npm:4.50.1" +"@rollup/rollup-linux-x64-musl@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.59.0" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-openharmony-arm64@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-openharmony-arm64@npm:4.50.1" +"@rollup/rollup-openbsd-x64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-openbsd-x64@npm:4.59.0" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-openharmony-arm64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-openharmony-arm64@npm:4.59.0" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-win32-arm64-msvc@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.50.1" +"@rollup/rollup-win32-arm64-msvc@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.59.0" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-win32-ia32-msvc@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.50.1" +"@rollup/rollup-win32-ia32-msvc@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.59.0" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@rollup/rollup-win32-x64-msvc@npm:4.50.1": - version: 4.50.1 - resolution: "@rollup/rollup-win32-x64-msvc@npm:4.50.1" +"@rollup/rollup-win32-x64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-win32-x64-gnu@npm:4.59.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-x64-msvc@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.59.0" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -5446,6 +5474,13 @@ __metadata: languageName: node linkType: hard +"@standard-schema/spec@npm:^1.0.0": + version: 1.1.0 + resolution: "@standard-schema/spec@npm:1.1.0" + checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526 + languageName: node + linkType: hard + "@stylistic/eslint-plugin@npm:^3.0.0": version: 3.1.0 resolution: "@stylistic/eslint-plugin@npm:3.1.0" @@ -6298,44 +6333,45 @@ __metadata: languageName: node linkType: hard -"@vitest/expect@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/expect@npm:3.2.4" +"@vitest/expect@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/expect@npm:4.0.18" dependencies: + "@standard-schema/spec": "npm:^1.0.0" "@types/chai": "npm:^5.2.2" - "@vitest/spy": "npm:3.2.4" - "@vitest/utils": "npm:3.2.4" - chai: "npm:^5.2.0" - tinyrainbow: "npm:^2.0.0" - checksum: 10c0/7586104e3fd31dbe1e6ecaafb9a70131e4197dce2940f727b6a84131eee3decac7b10f9c7c72fa5edbdb68b6f854353bd4c0fa84779e274207fb7379563b10db + "@vitest/spy": "npm:4.0.18" + "@vitest/utils": "npm:4.0.18" + chai: "npm:^6.2.1" + tinyrainbow: "npm:^3.0.3" + checksum: 10c0/123b0aa111682e82ec5289186df18037b1a1768700e468ee0f9879709aaa320cf790463c15c0d8ee10df92b402f4394baf5d27797e604d78e674766d87bcaadc languageName: node linkType: hard -"@vitest/mocker@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/mocker@npm:3.2.4" +"@vitest/mocker@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/mocker@npm:4.0.18" dependencies: - "@vitest/spy": "npm:3.2.4" + "@vitest/spy": "npm:4.0.18" estree-walker: "npm:^3.0.3" - magic-string: "npm:^0.30.17" + magic-string: "npm:^0.30.21" peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0-0 peerDependenciesMeta: msw: optional: true vite: optional: true - checksum: 10c0/f7a4aea19bbbf8f15905847ee9143b6298b2c110f8b64789224cb0ffdc2e96f9802876aa2ca83f1ec1b6e1ff45e822abb34f0054c24d57b29ab18add06536ccd + checksum: 10c0/fb0a257e7e167759d4ad228d53fa7bad2267586459c4a62188f2043dd7163b4b02e1e496dc3c227837f776e7d73d6c4343613e89e7da379d9d30de8260f1ee4b languageName: node linkType: hard -"@vitest/pretty-format@npm:3.2.4, @vitest/pretty-format@npm:^3.2.4": - version: 3.2.4 - resolution: "@vitest/pretty-format@npm:3.2.4" +"@vitest/pretty-format@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/pretty-format@npm:4.0.18" dependencies: - tinyrainbow: "npm:^2.0.0" - checksum: 10c0/5ad7d4278e067390d7d633e307fee8103958806a419ca380aec0e33fae71b44a64415f7a9b4bc11635d3c13d4a9186111c581d3cef9c65cc317e68f077456887 + tinyrainbow: "npm:^3.0.3" + checksum: 10c0/0086b8c88eeca896d8e4b98fcdef452c8041a1b63eb9e85d3e0bcc96c8aa76d8e9e0b6990ebb0bb0a697c4ebab347e7735888b24f507dbff2742ddce7723fd94 languageName: node linkType: hard @@ -6348,45 +6384,41 @@ __metadata: languageName: node linkType: hard -"@vitest/runner@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/runner@npm:3.2.4" +"@vitest/runner@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/runner@npm:4.0.18" dependencies: - "@vitest/utils": "npm:3.2.4" + "@vitest/utils": "npm:4.0.18" pathe: "npm:^2.0.3" - strip-literal: "npm:^3.0.0" - checksum: 10c0/e8be51666c72b3668ae3ea348b0196656a4a5adb836cb5e270720885d9517421815b0d6c98bfdf1795ed02b994b7bfb2b21566ee356a40021f5bf4f6ed4e418a + checksum: 10c0/fdb4afa411475133c05ba266c8092eaf1e56cbd5fb601f92ec6ccb9bab7ca52e06733ee8626599355cba4ee71cb3a8f28c84d3b69dc972e41047edc50229bc01 languageName: node linkType: hard -"@vitest/snapshot@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/snapshot@npm:3.2.4" +"@vitest/snapshot@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/snapshot@npm:4.0.18" dependencies: - "@vitest/pretty-format": "npm:3.2.4" - magic-string: "npm:^0.30.17" + "@vitest/pretty-format": "npm:4.0.18" + magic-string: "npm:^0.30.21" pathe: "npm:^2.0.3" - checksum: 10c0/f8301a3d7d1559fd3d59ed51176dd52e1ed5c2d23aa6d8d6aa18787ef46e295056bc726a021698d8454c16ed825ecba163362f42fa90258bb4a98cfd2c9424fc + checksum: 10c0/d3bfefa558db9a69a66886ace6575eb96903a5ba59f4d9a5d0fecb4acc2bb8dbb443ef409f5ac1475f2e1add30bd1d71280f98912da35e89c75829df9e84ea43 languageName: node linkType: hard -"@vitest/spy@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/spy@npm:3.2.4" - dependencies: - tinyspy: "npm:^4.0.3" - checksum: 10c0/6ebf0b4697dc238476d6b6a60c76ba9eb1dd8167a307e30f08f64149612fd50227682b876420e4c2e09a76334e73f72e3ebf0e350714dc22474258292e202024 +"@vitest/spy@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/spy@npm:4.0.18" + checksum: 10c0/6de537890b3994fcadb8e8d8ac05942320ae184f071ec395d978a5fba7fa928cbb0c5de85af86a1c165706c466e840de8779eaff8c93450c511c7abaeb9b8a4e languageName: node linkType: hard -"@vitest/utils@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/utils@npm:3.2.4" +"@vitest/utils@npm:4.0.18": + version: 4.0.18 + resolution: "@vitest/utils@npm:4.0.18" dependencies: - "@vitest/pretty-format": "npm:3.2.4" - loupe: "npm:^3.1.4" - tinyrainbow: "npm:^2.0.0" - checksum: 10c0/024a9b8c8bcc12cf40183c246c244b52ecff861c6deb3477cbf487ac8781ad44c68a9c5fd69f8c1361878e55b97c10d99d511f2597f1f7244b5e5101d028ba64 + "@vitest/pretty-format": "npm:4.0.18" + tinyrainbow: "npm:^3.0.3" + checksum: 10c0/4a3c43c1421eb90f38576926496f6c80056167ba111e63f77cf118983902673737a1a38880b890d7c06ec0a12475024587344ee502b3c43093781533022f2aeb languageName: node linkType: hard @@ -6736,13 +6768,6 @@ __metadata: languageName: node linkType: hard -"assertion-error@npm:^2.0.1": - version: 2.0.1 - resolution: "assertion-error@npm:2.0.1" - checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 - languageName: node - linkType: hard - "ast-types-flow@npm:^0.0.8": version: 0.0.8 resolution: "ast-types-flow@npm:0.0.8" @@ -7222,13 +7247,6 @@ __metadata: languageName: node linkType: hard -"cac@npm:^6.7.14": - version: 6.7.14 - resolution: "cac@npm:6.7.14" - checksum: 10c0/4ee06aaa7bab8981f0d54e5f5f9d4adcd64058e9697563ce336d8a3878ed018ee18ebe5359b2430eceae87e0758e62ea2019c3f52ae6e211b1bd2e133856cd10 - languageName: node - linkType: hard - "cacache@npm:^19.0.1": version: 19.0.1 resolution: "cacache@npm:19.0.1" @@ -7353,16 +7371,10 @@ __metadata: languageName: node linkType: hard -"chai@npm:^5.2.0": - version: 5.2.0 - resolution: "chai@npm:5.2.0" - dependencies: - assertion-error: "npm:^2.0.1" - check-error: "npm:^2.1.1" - deep-eql: "npm:^5.0.1" - loupe: "npm:^3.1.0" - pathval: "npm:^2.0.0" - checksum: 10c0/dfd1cb719c7cebb051b727672d382a35338af1470065cb12adb01f4ee451bbf528e0e0f9ab2016af5fc1eea4df6e7f4504dc8443f8f00bd8fb87ad32dc516f7d +"chai@npm:^6.2.1": + version: 6.2.2 + resolution: "chai@npm:6.2.2" + checksum: 10c0/e6c69e5f0c11dffe6ea13d0290936ebb68fcc1ad688b8e952e131df6a6d5797d5e860bc55cef1aca2e950c3e1f96daf79e9d5a70fb7dbaab4e46355e2635ed53 languageName: node linkType: hard @@ -7394,13 +7406,6 @@ __metadata: languageName: node linkType: hard -"check-error@npm:^2.1.1": - version: 2.1.1 - resolution: "check-error@npm:2.1.1" - checksum: 10c0/979f13eccab306cf1785fa10941a590b4e7ea9916ea2a4f8c87f0316fc3eab07eabefb6e587424ef0f88cbcd3805791f172ea739863ca3d7ce2afc54641c7f0e - languageName: node - linkType: hard - "cheerio-select@npm:^2.1.0": version: 2.1.0 resolution: "cheerio-select@npm:2.1.0" @@ -8086,13 +8091,6 @@ __metadata: languageName: node linkType: hard -"deep-eql@npm:^5.0.1": - version: 5.0.2 - resolution: "deep-eql@npm:5.0.2" - checksum: 10c0/7102cf3b7bb719c6b9c0db2e19bf0aa9318d141581befe8c7ce8ccd39af9eaa4346e5e05adef7f9bd7015da0f13a3a25dcfe306ef79dc8668aedbecb658dd247 - languageName: node - linkType: hard - "deep-is@npm:^0.1.3": version: 0.1.4 resolution: "deep-is@npm:0.1.4" @@ -8460,12 +8458,12 @@ __metadata: unique-names-generator: "npm:^4.6.0" uuid: "npm:^13.0.0" vaul: "npm:^1.0.0" - vite: "npm:^7.0.0" + vite: "npm:^7.3.0" vite-plugin-generate-file: "npm:^0.3.0" vite-plugin-html: "npm:^3.2.2" vite-plugin-node-stdlib-browser: "npm:^0.2.1" vite-plugin-svgr: "npm:^4.0.0" - vitest: "npm:^3.0.0" + vitest: "npm:^4.0.18" vitest-axe: "npm:^1.0.0-pre.3" languageName: unknown linkType: soft @@ -9447,10 +9445,10 @@ __metadata: languageName: node linkType: hard -"expect-type@npm:^1.2.1": - version: 1.2.1 - resolution: "expect-type@npm:1.2.1" - checksum: 10c0/b775c9adab3c190dd0d398c722531726cdd6022849b4adba19dceab58dda7e000a7c6c872408cd73d665baa20d381eca36af4f7b393a4ba60dd10232d1fb8898 +"expect-type@npm:^1.2.2": + version: 1.3.0 + resolution: "expect-type@npm:1.3.0" + checksum: 10c0/8412b3fe4f392c420ab41dae220b09700e4e47c639a29ba7ba2e83cc6cffd2b4926f7ac9e47d7e277e8f4f02acda76fd6931cb81fd2b382fa9477ef9ada953fd languageName: node linkType: hard @@ -9533,18 +9531,6 @@ __metadata: languageName: node linkType: hard -"fdir@npm:^6.4.4": - version: 6.4.5 - resolution: "fdir@npm:6.4.5" - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - checksum: 10c0/5d63330a1b97165e9b0fb20369fafc7cf826bc4b3e374efcb650bc77d7145ac01193b5da1a7591eab89ae6fd6b15cdd414085910b2a2b42296b1480c9f2677af - languageName: node - linkType: hard - "fdir@npm:^6.5.0": version: 6.5.0 resolution: "fdir@npm:6.5.0" @@ -11379,20 +11365,6 @@ __metadata: languageName: node linkType: hard -"loupe@npm:^3.1.0": - version: 3.1.3 - resolution: "loupe@npm:3.1.3" - checksum: 10c0/f5dab4144254677de83a35285be1b8aba58b3861439ce4ba65875d0d5f3445a4a496daef63100ccf02b2dbc25bf58c6db84c9cb0b96d6435331e9d0a33b48541 - languageName: node - linkType: hard - -"loupe@npm:^3.1.4": - version: 3.1.4 - resolution: "loupe@npm:3.1.4" - checksum: 10c0/5c2e6aefaad25f812d361c750b8cf4ff91d68de289f141d7c85c2ce9bb79eeefa06a93c85f7b87cba940531ed8f15e492f32681d47eed23842ad1963eb3a154d - languageName: node - linkType: hard - "lower-case@npm:^2.0.2": version: 2.0.2 resolution: "lower-case@npm:2.0.2" @@ -11452,7 +11424,7 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.3": +"magic-string@npm:^0.30.21, magic-string@npm:^0.30.3": version: 0.30.21 resolution: "magic-string@npm:0.30.21" dependencies: @@ -12113,6 +12085,13 @@ __metadata: languageName: node linkType: hard +"obug@npm:^2.1.1": + version: 2.1.1 + resolution: "obug@npm:2.1.1" + checksum: 10c0/59dccd7de72a047e08f8649e94c1015ec72f94eefb6ddb57fb4812c4b425a813bc7e7cd30c9aca20db3c59abc3c85cc7a62bb656a968741d770f4e8e02bc2e78 + languageName: node + linkType: hard + "oidc-client-ts@npm:^3.0.1": version: 3.1.0 resolution: "oidc-client-ts@npm:3.1.0" @@ -12483,13 +12462,6 @@ __metadata: languageName: node linkType: hard -"pathval@npm:^2.0.0": - version: 2.0.0 - resolution: "pathval@npm:2.0.0" - checksum: 10c0/602e4ee347fba8a599115af2ccd8179836a63c925c23e04bd056d0674a64b39e3a081b643cc7bc0b84390517df2d800a46fcc5598d42c155fe4977095c2f77c5 - languageName: node - linkType: hard - "pbkdf2@npm:^3.1.2, pbkdf2@npm:^3.1.5": version: 3.1.5 resolution: "pbkdf2@npm:3.1.5" @@ -13797,31 +13769,35 @@ __metadata: languageName: node linkType: hard -"rollup@npm:^4.43.0": - version: 4.50.1 - resolution: "rollup@npm:4.50.1" +"rollup@npm:4.59.0": + version: 4.59.0 + resolution: "rollup@npm:4.59.0" dependencies: - "@rollup/rollup-android-arm-eabi": "npm:4.50.1" - "@rollup/rollup-android-arm64": "npm:4.50.1" - "@rollup/rollup-darwin-arm64": "npm:4.50.1" - "@rollup/rollup-darwin-x64": "npm:4.50.1" - "@rollup/rollup-freebsd-arm64": "npm:4.50.1" - "@rollup/rollup-freebsd-x64": "npm:4.50.1" - "@rollup/rollup-linux-arm-gnueabihf": "npm:4.50.1" - "@rollup/rollup-linux-arm-musleabihf": "npm:4.50.1" - "@rollup/rollup-linux-arm64-gnu": "npm:4.50.1" - "@rollup/rollup-linux-arm64-musl": "npm:4.50.1" - "@rollup/rollup-linux-loongarch64-gnu": "npm:4.50.1" - "@rollup/rollup-linux-ppc64-gnu": "npm:4.50.1" - "@rollup/rollup-linux-riscv64-gnu": "npm:4.50.1" - "@rollup/rollup-linux-riscv64-musl": "npm:4.50.1" - "@rollup/rollup-linux-s390x-gnu": "npm:4.50.1" - "@rollup/rollup-linux-x64-gnu": "npm:4.50.1" - "@rollup/rollup-linux-x64-musl": "npm:4.50.1" - "@rollup/rollup-openharmony-arm64": "npm:4.50.1" - "@rollup/rollup-win32-arm64-msvc": "npm:4.50.1" - "@rollup/rollup-win32-ia32-msvc": "npm:4.50.1" - "@rollup/rollup-win32-x64-msvc": "npm:4.50.1" + "@rollup/rollup-android-arm-eabi": "npm:4.59.0" + "@rollup/rollup-android-arm64": "npm:4.59.0" + "@rollup/rollup-darwin-arm64": "npm:4.59.0" + "@rollup/rollup-darwin-x64": "npm:4.59.0" + "@rollup/rollup-freebsd-arm64": "npm:4.59.0" + "@rollup/rollup-freebsd-x64": "npm:4.59.0" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.59.0" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.59.0" + "@rollup/rollup-linux-arm64-gnu": "npm:4.59.0" + "@rollup/rollup-linux-arm64-musl": "npm:4.59.0" + "@rollup/rollup-linux-loong64-gnu": "npm:4.59.0" + "@rollup/rollup-linux-loong64-musl": "npm:4.59.0" + "@rollup/rollup-linux-ppc64-gnu": "npm:4.59.0" + "@rollup/rollup-linux-ppc64-musl": "npm:4.59.0" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.59.0" + "@rollup/rollup-linux-riscv64-musl": "npm:4.59.0" + "@rollup/rollup-linux-s390x-gnu": "npm:4.59.0" + "@rollup/rollup-linux-x64-gnu": "npm:4.59.0" + "@rollup/rollup-linux-x64-musl": "npm:4.59.0" + "@rollup/rollup-openbsd-x64": "npm:4.59.0" + "@rollup/rollup-openharmony-arm64": "npm:4.59.0" + "@rollup/rollup-win32-arm64-msvc": "npm:4.59.0" + "@rollup/rollup-win32-ia32-msvc": "npm:4.59.0" + "@rollup/rollup-win32-x64-gnu": "npm:4.59.0" + "@rollup/rollup-win32-x64-msvc": "npm:4.59.0" "@types/estree": "npm:1.0.8" fsevents: "npm:~2.3.2" dependenciesMeta: @@ -13845,10 +13821,14 @@ __metadata: optional: true "@rollup/rollup-linux-arm64-musl": optional: true - "@rollup/rollup-linux-loongarch64-gnu": + "@rollup/rollup-linux-loong64-gnu": + optional: true + "@rollup/rollup-linux-loong64-musl": optional: true "@rollup/rollup-linux-ppc64-gnu": optional: true + "@rollup/rollup-linux-ppc64-musl": + optional: true "@rollup/rollup-linux-riscv64-gnu": optional: true "@rollup/rollup-linux-riscv64-musl": @@ -13859,19 +13839,23 @@ __metadata: optional: true "@rollup/rollup-linux-x64-musl": optional: true + "@rollup/rollup-openbsd-x64": + optional: true "@rollup/rollup-openharmony-arm64": optional: true "@rollup/rollup-win32-arm64-msvc": optional: true "@rollup/rollup-win32-ia32-msvc": optional: true + "@rollup/rollup-win32-x64-gnu": + optional: true "@rollup/rollup-win32-x64-msvc": optional: true fsevents: optional: true bin: rollup: dist/bin/rollup - checksum: 10c0/2029282826d5fb4e308be261b2c28329a4d2bd34304cc3960da69fd21d5acccd0267d6770b1656ffc8f166203ef7e865b4583d5f842a519c8ef059ac71854205 + checksum: 10c0/f38742da34cfee5e899302615fa157aa77cb6a2a1495e5e3ce4cc9c540d3262e235bbe60caa31562bbfe492b01fdb3e7a8c43c39d842d3293bcf843123b766fc languageName: node linkType: hard @@ -14397,6 +14381,13 @@ __metadata: languageName: node linkType: hard +"std-env@npm:^3.10.0": + version: 3.10.0 + resolution: "std-env@npm:3.10.0" + checksum: 10c0/1814927a45004d36dde6707eaf17552a546769bc79a6421be2c16ce77d238158dfe5de30910b78ec30d95135cc1c59ea73ee22d2ca170f8b9753f84da34c427f + languageName: node + linkType: hard + "std-env@npm:^3.9.0": version: 3.9.0 resolution: "std-env@npm:3.9.0" @@ -14627,15 +14618,6 @@ __metadata: languageName: node linkType: hard -"strip-literal@npm:^3.0.0": - version: 3.0.0 - resolution: "strip-literal@npm:3.0.0" - dependencies: - js-tokens: "npm:^9.0.1" - checksum: 10c0/d81657f84aba42d4bbaf2a677f7e7f34c1f3de5a6726db8bc1797f9c0b303ba54d4660383a74bde43df401cf37cce1dff2c842c55b077a4ceee11f9e31fba828 - languageName: node - linkType: hard - "supports-color@npm:^5.3.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" @@ -14779,20 +14761,10 @@ __metadata: languageName: node linkType: hard -"tinyexec@npm:^0.3.2": - version: 0.3.2 - resolution: "tinyexec@npm:0.3.2" - checksum: 10c0/3efbf791a911be0bf0821eab37a3445c2ba07acc1522b1fa84ae1e55f10425076f1290f680286345ed919549ad67527d07281f1c19d584df3b74326909eb1f90 - languageName: node - linkType: hard - -"tinyglobby@npm:^0.2.14": - version: 0.2.14 - resolution: "tinyglobby@npm:0.2.14" - dependencies: - fdir: "npm:^6.4.4" - picomatch: "npm:^4.0.2" - checksum: 10c0/f789ed6c924287a9b7d3612056ed0cda67306cd2c80c249fd280cf1504742b12583a2089b61f4abbd24605f390809017240e250241f09938054c9b363e51c0a6 +"tinyexec@npm:^1.0.2": + version: 1.0.2 + resolution: "tinyexec@npm:1.0.2" + checksum: 10c0/1261a8e34c9b539a9aae3b7f0bb5372045ff28ee1eba035a2a059e532198fe1a182ec61ac60fa0b4a4129f0c4c4b1d2d57355b5cb9aa2d17ac9454ecace502ee languageName: node linkType: hard @@ -14806,13 +14778,6 @@ __metadata: languageName: node linkType: hard -"tinypool@npm:^1.1.1": - version: 1.1.1 - resolution: "tinypool@npm:1.1.1" - checksum: 10c0/bf26727d01443061b04fa863f571016950888ea994ba0cd8cba3a1c51e2458d84574341ab8dbc3664f1c3ab20885c8cf9ff1cc4b18201f04c2cde7d317fff69b - languageName: node - linkType: hard - "tinyrainbow@npm:^2.0.0": version: 2.0.0 resolution: "tinyrainbow@npm:2.0.0" @@ -14820,10 +14785,10 @@ __metadata: languageName: node linkType: hard -"tinyspy@npm:^4.0.3": - version: 4.0.3 - resolution: "tinyspy@npm:4.0.3" - checksum: 10c0/0a92a18b5350945cc8a1da3a22c9ad9f4e2945df80aaa0c43e1b3a3cfb64d8501e607ebf0305e048e3c3d3e0e7f8eb10cea27dc17c21effb73e66c4a3be36373 +"tinyrainbow@npm:^3.0.3": + version: 3.1.0 + resolution: "tinyrainbow@npm:3.1.0" + checksum: 10c0/f11cf387a26c5c9255bec141a90ac511b26172981b10c3e50053bc6700ea7d2336edcc4a3a21dbb8412fe7c013477d2ba4d7e4877800f3f8107be5105aad6511 languageName: node linkType: hard @@ -15530,21 +15495,6 @@ __metadata: languageName: node linkType: hard -"vite-node@npm:3.2.4": - version: 3.2.4 - resolution: "vite-node@npm:3.2.4" - dependencies: - cac: "npm:^6.7.14" - debug: "npm:^4.4.1" - es-module-lexer: "npm:^1.7.0" - pathe: "npm:^2.0.3" - vite: "npm:^5.0.0 || ^6.0.0 || ^7.0.0-0" - bin: - vite-node: vite-node.mjs - checksum: 10c0/6ceca67c002f8ef6397d58b9539f80f2b5d79e103a18367288b3f00a8ab55affa3d711d86d9112fce5a7fa658a212a087a005a045eb8f4758947dd99af2a6c6b - languageName: node - linkType: hard - "vite-plugin-generate-file@npm:^0.3.0": version: 0.3.1 resolution: "vite-plugin-generate-file@npm:0.3.1" @@ -15604,7 +15554,7 @@ __metadata: languageName: node linkType: hard -"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0, vite@npm:^7.0.0": +"vite@npm:^6.0.0 || ^7.0.0, vite@npm:^7.3.0": version: 7.3.1 resolution: "vite@npm:7.3.1" dependencies: @@ -15673,49 +15623,52 @@ __metadata: languageName: node linkType: hard -"vitest@npm:^3.0.0": - version: 3.2.4 - resolution: "vitest@npm:3.2.4" +"vitest@npm:^4.0.18": + version: 4.0.18 + resolution: "vitest@npm:4.0.18" dependencies: - "@types/chai": "npm:^5.2.2" - "@vitest/expect": "npm:3.2.4" - "@vitest/mocker": "npm:3.2.4" - "@vitest/pretty-format": "npm:^3.2.4" - "@vitest/runner": "npm:3.2.4" - "@vitest/snapshot": "npm:3.2.4" - "@vitest/spy": "npm:3.2.4" - "@vitest/utils": "npm:3.2.4" - chai: "npm:^5.2.0" - debug: "npm:^4.4.1" - expect-type: "npm:^1.2.1" - magic-string: "npm:^0.30.17" + "@vitest/expect": "npm:4.0.18" + "@vitest/mocker": "npm:4.0.18" + "@vitest/pretty-format": "npm:4.0.18" + "@vitest/runner": "npm:4.0.18" + "@vitest/snapshot": "npm:4.0.18" + "@vitest/spy": "npm:4.0.18" + "@vitest/utils": "npm:4.0.18" + es-module-lexer: "npm:^1.7.0" + expect-type: "npm:^1.2.2" + magic-string: "npm:^0.30.21" + obug: "npm:^2.1.1" pathe: "npm:^2.0.3" - picomatch: "npm:^4.0.2" - std-env: "npm:^3.9.0" + picomatch: "npm:^4.0.3" + std-env: "npm:^3.10.0" tinybench: "npm:^2.9.0" - tinyexec: "npm:^0.3.2" - tinyglobby: "npm:^0.2.14" - tinypool: "npm:^1.1.1" - tinyrainbow: "npm:^2.0.0" - vite: "npm:^5.0.0 || ^6.0.0 || ^7.0.0-0" - vite-node: "npm:3.2.4" + tinyexec: "npm:^1.0.2" + tinyglobby: "npm:^0.2.15" + tinyrainbow: "npm:^3.0.3" + vite: "npm:^6.0.0 || ^7.0.0" why-is-node-running: "npm:^2.3.0" peerDependencies: "@edge-runtime/vm": "*" - "@types/debug": ^4.1.12 - "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 - "@vitest/browser": 3.2.4 - "@vitest/ui": 3.2.4 + "@opentelemetry/api": ^1.9.0 + "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 4.0.18 + "@vitest/browser-preview": 4.0.18 + "@vitest/browser-webdriverio": 4.0.18 + "@vitest/ui": 4.0.18 happy-dom: "*" jsdom: "*" peerDependenciesMeta: "@edge-runtime/vm": optional: true - "@types/debug": + "@opentelemetry/api": optional: true "@types/node": optional: true - "@vitest/browser": + "@vitest/browser-playwright": + optional: true + "@vitest/browser-preview": + optional: true + "@vitest/browser-webdriverio": optional: true "@vitest/ui": optional: true @@ -15725,7 +15678,7 @@ __metadata: optional: true bin: vitest: vitest.mjs - checksum: 10c0/5bf53ede3ae6a0e08956d72dab279ae90503f6b5a05298a6a5e6ef47d2fd1ab386aaf48fafa61ed07a0ebfe9e371772f1ccbe5c258dd765206a8218bf2eb79eb + checksum: 10c0/b913cd32032c95f29ff08c931f4b4c6fd6d2da498908d6770952c561a1b8d75c62499a1f04cadf82fb89cc0f9a33f29fb5dfdb899f6dbb27686a9d91571be5fa languageName: node linkType: hard From 413329cd261e4f1a579a5c0a2333954933ab0af8 Mon Sep 17 00:00:00 2001 From: fkwp Date: Thu, 12 Mar 2026 13:30:45 +0100 Subject: [PATCH 092/119] Fix: zizmor findings (#3797) * zizmor auto fixes * add github action for security analysis with zizmor * add access token to iOS push action * fix zizmor findings * add exceptions for dangerous-triggers including comments for reasoning * improve comments * prettier --- .github/workflows/blocked.yaml | 9 +++++++++ .github/workflows/build-element-call.yaml | 4 +++- .github/workflows/changelog-label.yml | 8 ++++++++ .github/workflows/pr-deploy.yaml | 11 ++++++++++- .github/workflows/publish-embedded-packages.yaml | 14 ++++++++++++-- 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/.github/workflows/blocked.yaml b/.github/workflows/blocked.yaml index 12a4b020..cc7db747 100644 --- a/.github/workflows/blocked.yaml +++ b/.github/workflows/blocked.yaml @@ -1,7 +1,16 @@ name: Prevent blocked on: + # zizmor: ignore[dangerous-triggers] + # Reason: This workflow does not checkout code or use secrets. + # It only reads labels to set a failure status on the PR. pull_request_target: types: [opened, labeled, unlabeled, synchronize] + +permissions: + pull-requests: read + # Required to fail the check on the PR + statuses: write + jobs: prevent-blocked: name: Prevent blocked diff --git a/.github/workflows/build-element-call.yaml b/.github/workflows/build-element-call.yaml index 300138e0..3ddabb0b 100644 --- a/.github/workflows/build-element-call.yaml +++ b/.github/workflows/build-element-call.yaml @@ -45,7 +45,7 @@ jobs: - name: Install dependencies run: "yarn install --immutable" - name: Build Element Call - run: ${{ format('yarn run build:{0}:{1}', inputs.package, inputs.build_mode) }} + run: yarn run build:"$PACKAGE":"$BUILD_MODE" env: SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} @@ -54,6 +54,8 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} VITE_APP_VERSION: ${{ inputs.vite_app_version }} NODE_OPTIONS: "--max-old-space-size=4096" + PACKAGE: ${{ inputs.package }} + BUILD_MODE: ${{ inputs.build_mode }} - name: Upload Artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: diff --git a/.github/workflows/changelog-label.yml b/.github/workflows/changelog-label.yml index 8d9acbc2..f11d3130 100644 --- a/.github/workflows/changelog-label.yml +++ b/.github/workflows/changelog-label.yml @@ -1,8 +1,16 @@ name: PR changelog label on: + # zizmor: ignore[dangerous-triggers] + # This is safe because we do not use actions/checkout or execute untrusted code. + # Using pull_request_target is necessary to allow status writes for PRs from forks. pull_request_target: types: [labeled, unlabeled, opened] + +permissions: + pull-requests: read + statuses: write + jobs: pr-changelog-label: runs-on: ubuntu-latest diff --git a/.github/workflows/pr-deploy.yaml b/.github/workflows/pr-deploy.yaml index 138ab2b5..0084b76b 100644 --- a/.github/workflows/pr-deploy.yaml +++ b/.github/workflows/pr-deploy.yaml @@ -1,5 +1,7 @@ name: Deploy previews for PRs on: + # zizmor: ignore[dangerous-triggers] + # Reason: This is now restricted to internal PRs only using the 'if' condition below. workflow_run: workflows: ["Build"] types: @@ -7,7 +9,14 @@ on: jobs: prdetails: - if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' }} + # Logic: + # 1. Build must be successful + # 2. Event must be a pull_request + # 3. Head repository must be the SAME as the base repository (No Forks!) + if: > + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.head_repository.full_name == github.repository runs-on: ubuntu-latest outputs: pr_number: ${{ steps.prdetails.outputs.pr_id }} diff --git a/.github/workflows/publish-embedded-packages.yaml b/.github/workflows/publish-embedded-packages.yaml index 1c6cd7b1..3ab275a5 100644 --- a/.github/workflows/publish-embedded-packages.yaml +++ b/.github/workflows/publish-embedded-packages.yaml @@ -22,8 +22,18 @@ jobs: TAG: ${{ steps.tag.outputs.TAG }} steps: - name: Calculate VERSION - # We should only use the hard coded test value for a dry run - run: echo "VERSION=${{ github.event_name == 'release' && github.event.release.tag_name || 'v0.0.0-pre.0' }}" >> "$GITHUB_ENV" + # Safely store dynamic values in environment variables + # to prevent shell injection (template-injection) + run: | + # The logic is executed within the shell using the env variables + if [ "$EVENT_NAME" = "release" ]; then + echo "VERSION=$RELEASE_TAG" >> "$GITHUB_ENV" + else + echo "VERSION=v0.0.0-pre.0" >> "$GITHUB_ENV" + fi + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + EVENT_NAME: ${{ github.event_name }} - id: dry_run name: Set DRY_RUN # We perform a dry run for all events except releases. From 3e171d963948976159597761a6232339ce13f25d Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 12 Mar 2026 12:25:58 +0100 Subject: [PATCH 093/119] bump tar, minimatch security alert --- package.json | 4 +- yarn.lock | 106 +++++++++------------------------------------------ 2 files changed, 21 insertions(+), 89 deletions(-) diff --git a/package.json b/package.json index 9ee0ad26..da086172 100644 --- a/package.json +++ b/package.json @@ -138,7 +138,9 @@ }, "resolutions": { "@livekit/components-core/rxjs": "^7.8.1", - "@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18" + "@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18", + "minimatch": "^10.2.3", + "tar": "^7.5.11" }, "packageManager": "yarn@4.7.0" } diff --git a/yarn.lock b/yarn.lock index bd90ded3..b15aeefc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6861,13 +6861,6 @@ __metadata: languageName: node linkType: hard -"balanced-match@npm:^1.0.0": - version: 1.0.2 - resolution: "balanced-match@npm:1.0.2" - checksum: 10c0/9308baf0a7e4838a82bbfd11e01b1cb0f0cf2893bc1676c27c2a8c0e70cbae1c59120c3268517a8ae7fb6376b4639ef81ca22582611dbee4ed28df945134aaee - languageName: node - linkType: hard - "balanced-match@npm:^4.0.2": version: 4.0.4 resolution: "balanced-match@npm:4.0.4" @@ -6962,25 +6955,6 @@ __metadata: languageName: node linkType: hard -"brace-expansion@npm:^1.1.7": - version: 1.1.11 - resolution: "brace-expansion@npm:1.1.11" - dependencies: - balanced-match: "npm:^1.0.0" - concat-map: "npm:0.0.1" - checksum: 10c0/695a56cd058096a7cb71fb09d9d6a7070113c7be516699ed361317aca2ec169f618e28b8af352e02ab4233fb54eb0168460a40dc320bab0034b36ab59aaad668 - languageName: node - linkType: hard - -"brace-expansion@npm:^2.0.1": - version: 2.0.1 - resolution: "brace-expansion@npm:2.0.1" - dependencies: - balanced-match: "npm:^1.0.0" - checksum: 10c0/b358f2fe060e2d7a87aa015979ecea07f3c37d4018f8d6deb5bd4c229ad3a0384fe6029bb76cd8be63c81e516ee52d1a0673edbe2023d53a5191732ae3c3e49f - languageName: node - linkType: hard - "brace-expansion@npm:^5.0.2": version: 5.0.3 resolution: "brace-expansion@npm:5.0.3" @@ -7636,13 +7610,6 @@ __metadata: languageName: node linkType: hard -"concat-map@npm:0.0.1": - version: 0.0.1 - resolution: "concat-map@npm:0.0.1" - checksum: 10c0/c996b1cfdf95b6c90fee4dae37e332c8b6eb7d106430c17d538034c0ad9a1630cb194d2ab37293b1bdd4d779494beee7786d586a50bd9376fd6f7bcc2bd4c98f - languageName: node - linkType: hard - "connect-history-api-fallback@npm:^1.6.0": version: 1.6.0 resolution: "connect-history-api-fallback@npm:1.6.0" @@ -10447,9 +10414,9 @@ __metadata: linkType: hard "immutable@npm:^5.0.2": - version: 5.0.3 - resolution: "immutable@npm:5.0.3" - checksum: 10c0/3269827789e1026cd25c2ea97f0b2c19be852ffd49eda1b674b20178f73d84fa8d945ad6f5ac5bc4545c2b4170af9f6e1f77129bc1cae7974a4bf9b04a9cdfb9 + version: 5.1.5 + resolution: "immutable@npm:5.1.5" + checksum: 10c0/8017ece1578e3c5939ba3305176aee059def1b8a90c7fa2a347ef583ebbd38cbe77ce1bbd786a5fab57e2da00bbcb0493b92e4332cdc4e1fe5cfb09a4688df31 languageName: node linkType: hard @@ -11633,48 +11600,12 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^10.2.2": - version: 10.2.3 - resolution: "minimatch@npm:10.2.3" +"minimatch@npm:^10.2.3": + version: 10.2.4 + resolution: "minimatch@npm:10.2.4" dependencies: brace-expansion: "npm:^5.0.2" - checksum: 10c0/d9ae5f355e8bb77a42dd8c20b950141cec8773ef8716a2bb6df7a6840cc44a00ed828883884e4f1c7b5cb505fa06a17e3ea9ca2edb18fd1dec865ea7f9fcf0e5 - languageName: node - linkType: hard - -"minimatch@npm:^3.0.2, minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": - version: 3.1.2 - resolution: "minimatch@npm:3.1.2" - dependencies: - brace-expansion: "npm:^1.1.7" - checksum: 10c0/0262810a8fc2e72cca45d6fd86bd349eee435eb95ac6aa45c9ea2180e7ee875ef44c32b55b5973ceabe95ea12682f6e3725cbb63d7a2d1da3ae1163c8b210311 - languageName: node - linkType: hard - -"minimatch@npm:^5.0.1": - version: 5.1.6 - resolution: "minimatch@npm:5.1.6" - dependencies: - brace-expansion: "npm:^2.0.1" - checksum: 10c0/3defdfd230914f22a8da203747c42ee3c405c39d4d37ffda284dac5e45b7e1f6c49aa8be606509002898e73091ff2a3bbfc59c2c6c71d4660609f63aa92f98e3 - languageName: node - linkType: hard - -"minimatch@npm:^8.0.2": - version: 8.0.4 - resolution: "minimatch@npm:8.0.4" - dependencies: - brace-expansion: "npm:^2.0.1" - checksum: 10c0/a0a394c356dd5b4cb7f821720841a82fa6f07c9c562c5b716909d1b6ec5e56a7e4c4b5029da26dd256b7d2b3a3f38cbf9ddd8680e887b9b5282b09c05501c1ca - languageName: node - linkType: hard - -"minimatch@npm:^9.0.4": - version: 9.0.5 - resolution: "minimatch@npm:9.0.5" - dependencies: - brace-expansion: "npm:^2.0.1" - checksum: 10c0/de96cf5e35bdf0eab3e2c853522f98ffbe9a36c37797778d2665231ec1f20a9447a7e567cb640901f89e4daaa95ae5d70c65a9e8aa2bb0019b6facbc3c0575ed + checksum: 10c0/35f3dfb7b99b51efd46afd378486889f590e7efb10e0f6a10ba6800428cf65c9a8dedb74427d0570b318d749b543dc4e85f06d46d2858bc8cac7e1eb49a95945 languageName: node linkType: hard @@ -11769,12 +11700,12 @@ __metadata: languageName: node linkType: hard -"mkdirp@npm:^3.0.1": - version: 3.0.1 - resolution: "mkdirp@npm:3.0.1" - bin: - mkdirp: dist/cjs/src/bin.js - checksum: 10c0/9f2b975e9246351f5e3a40dcfac99fcd0baa31fbfab615fe059fb11e51f10e4803c63de1f384c54d656e4db31d000e4767e9ef076a22e12a641357602e31d57d +"minizlib@npm:^3.1.0": + version: 3.1.0 + resolution: "minizlib@npm:3.1.0" + dependencies: + minipass: "npm:^7.1.2" + checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec languageName: node linkType: hard @@ -14689,17 +14620,16 @@ __metadata: languageName: node linkType: hard -"tar@npm:^7.4.3": - version: 7.4.3 - resolution: "tar@npm:7.4.3" +"tar@npm:^7.5.11": + version: 7.5.11 + resolution: "tar@npm:7.5.11" dependencies: "@isaacs/fs-minipass": "npm:^4.0.0" chownr: "npm:^3.0.0" minipass: "npm:^7.1.2" - minizlib: "npm:^3.0.1" - mkdirp: "npm:^3.0.1" + minizlib: "npm:^3.1.0" yallist: "npm:^5.0.0" - checksum: 10c0/d4679609bb2a9b48eeaf84632b6d844128d2412b95b6de07d53d8ee8baf4ca0857c9331dfa510390a0727b550fd543d4d1a10995ad86cdf078423fbb8d99831d + checksum: 10c0/b6bb420550ef50ef23356018155e956cd83282c97b6128d8d5cfe5740c57582d806a244b2ef0bf686a74ce526babe8b8b9061527623e935e850008d86d838929 languageName: node linkType: hard From a0d5c7999932683e844d50387bb8939fbf039687 Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 12 Mar 2026 14:52:16 +0100 Subject: [PATCH 094/119] also update coverage --- package.json | 5 +- yarn.lock | 170 +++++++++++++++++---------------------------------- 2 files changed, 57 insertions(+), 118 deletions(-) diff --git a/package.json b/package.json index 30d63a2e..19c74d60 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "@vector-im/compound-design-tokens": "^6.0.0", "@vector-im/compound-web": "^8.0.0", "@vitejs/plugin-react": "^4.0.1", - "@vitest/coverage-v8": "^3.0.0", + "@vitest/coverage-v8": "^4.0.18", "babel-plugin-transform-vite-meta-env": "^1.0.3", "classnames": "^2.3.1", "copy-to-clipboard": "^3.3.3", @@ -138,8 +138,7 @@ }, "resolutions": { "@livekit/components-core/rxjs": "^7.8.1", - "@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18", - "rollup": "4.59.0" + "@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18" }, "packageManager": "yarn@4.7.0" } diff --git a/yarn.lock b/yarn.lock index 552e7768..38c2e7ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -60,16 +60,6 @@ __metadata: languageName: node linkType: hard -"@ampproject/remapping@npm:^2.3.0": - version: 2.3.0 - resolution: "@ampproject/remapping@npm:2.3.0" - dependencies: - "@jridgewell/gen-mapping": "npm:^0.3.5" - "@jridgewell/trace-mapping": "npm:^0.3.24" - checksum: 10c0/81d63cca5443e0f0c72ae18b544cc28c7c0ec2cea46e7cb888bb0e0f411a1191d0d6b7af798d54e30777d8d1488b2ec0732aac2be342d3d7d3ffd271c6f489ed - languageName: node - linkType: hard - "@asamuzakjp/css-color@npm:^2.8.2": version: 2.8.2 resolution: "@asamuzakjp/css-color@npm:2.8.2" @@ -585,7 +575,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.25.4, @babel/parser@npm:^7.25.9, @babel/parser@npm:^7.26.9": +"@babel/parser@npm:^7.25.9, @babel/parser@npm:^7.26.9": version: 7.26.9 resolution: "@babel/parser@npm:7.26.9" dependencies: @@ -1743,7 +1733,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.25.4, @babel/types@npm:^7.25.9, @babel/types@npm:^7.26.5, @babel/types@npm:^7.26.9, @babel/types@npm:^7.4.4": +"@babel/types@npm:^7.25.9, @babel/types@npm:^7.26.5, @babel/types@npm:^7.26.9, @babel/types@npm:^7.4.4": version: 7.26.9 resolution: "@babel/types@npm:7.26.9" dependencies: @@ -3188,13 +3178,6 @@ __metadata: languageName: node linkType: hard -"@istanbuljs/schema@npm:^0.1.2": - version: 0.1.3 - resolution: "@istanbuljs/schema@npm:0.1.3" - checksum: 10c0/61c5286771676c9ca3eb2bd8a7310a9c063fb6e0e9712225c8471c582d157392c88f5353581c8c9adbe0dff98892317d2fdfc56c3499aa42e0194405206a963a - languageName: node - linkType: hard - "@jridgewell/gen-mapping@npm:^0.3.12": version: 0.3.12 resolution: "@jridgewell/gen-mapping@npm:0.3.12" @@ -3264,7 +3247,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25": +"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25": version: 0.3.25 resolution: "@jridgewell/trace-mapping@npm:0.3.25" dependencies: @@ -3284,6 +3267,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/trace-mapping@npm:^0.3.31": + version: 0.3.31 + resolution: "@jridgewell/trace-mapping@npm:0.3.31" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10c0/4b30ec8cd56c5fd9a661f088230af01e0c1a3888d11ffb6b47639700f71225be21d1f7e168048d6d4f9449207b978a235c07c8f15c07705685d16dc06280e9d9 + languageName: node + linkType: hard + "@livekit/components-core@npm:0.12.12, @livekit/components-core@npm:^0.12.0": version: 0.12.12 resolution: "@livekit/components-core@npm:0.12.12" @@ -6306,30 +6299,27 @@ __metadata: languageName: node linkType: hard -"@vitest/coverage-v8@npm:^3.0.0": - version: 3.2.4 - resolution: "@vitest/coverage-v8@npm:3.2.4" +"@vitest/coverage-v8@npm:^4.0.18": + version: 4.0.18 + resolution: "@vitest/coverage-v8@npm:4.0.18" dependencies: - "@ampproject/remapping": "npm:^2.3.0" "@bcoe/v8-coverage": "npm:^1.0.2" - ast-v8-to-istanbul: "npm:^0.3.3" - debug: "npm:^4.4.1" + "@vitest/utils": "npm:4.0.18" + ast-v8-to-istanbul: "npm:^0.3.10" istanbul-lib-coverage: "npm:^3.2.2" istanbul-lib-report: "npm:^3.0.1" - istanbul-lib-source-maps: "npm:^5.0.6" - istanbul-reports: "npm:^3.1.7" - magic-string: "npm:^0.30.17" - magicast: "npm:^0.3.5" - std-env: "npm:^3.9.0" - test-exclude: "npm:^7.0.1" - tinyrainbow: "npm:^2.0.0" + istanbul-reports: "npm:^3.2.0" + magicast: "npm:^0.5.1" + obug: "npm:^2.1.1" + std-env: "npm:^3.10.0" + tinyrainbow: "npm:^3.0.3" peerDependencies: - "@vitest/browser": 3.2.4 - vitest: 3.2.4 + "@vitest/browser": 4.0.18 + vitest: 4.0.18 peerDependenciesMeta: "@vitest/browser": optional: true - checksum: 10c0/cae3e58d81d56e7e1cdecd7b5baab7edd0ad9dee8dec9353c52796e390e452377d3f04174d40b6986b17c73241a5e773e422931eaa8102dcba0605ff24b25193 + checksum: 10c0/e23e0da86f0b2a020c51562bc40ebdc7fc7553c24f8071dfb39a6df0161badbd5eaf2eebbf8ceaef18933a18c1934ff52d1c0c4bde77bb87e0c1feb0c8cbee4d languageName: node linkType: hard @@ -6775,14 +6765,14 @@ __metadata: languageName: node linkType: hard -"ast-v8-to-istanbul@npm:^0.3.3": - version: 0.3.3 - resolution: "ast-v8-to-istanbul@npm:0.3.3" +"ast-v8-to-istanbul@npm:^0.3.10": + version: 0.3.12 + resolution: "ast-v8-to-istanbul@npm:0.3.12" dependencies: - "@jridgewell/trace-mapping": "npm:^0.3.25" + "@jridgewell/trace-mapping": "npm:^0.3.31" estree-walker: "npm:^3.0.3" - js-tokens: "npm:^9.0.1" - checksum: 10c0/ffc39bc3ab4b8c1f7aea945960ce6b1e518bab3da7c800277eab2da07d397eeae4a2cb8a5a5f817225646c8ea495c1e4434fbe082c84bae8042abddef53f50b2 + js-tokens: "npm:^10.0.0" + checksum: 10c0/bad6ba222b1073c165c8d65dbf366193d4a90536dabe37f93a3df162269b1c9473975756e4c048f708c235efccc26f8e5321c547b7e9563b64b21b2e0f27cbc9 languageName: node linkType: hard @@ -7990,7 +7980,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.4": +"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.3.1, debug@npm:^4.3.4": version: 4.4.0 resolution: "debug@npm:4.4.0" dependencies: @@ -8032,18 +8022,6 @@ __metadata: languageName: node linkType: hard -"debug@npm:^4.4.1": - version: 4.4.1 - resolution: "debug@npm:4.4.1" - dependencies: - ms: "npm:^2.1.3" - peerDependenciesMeta: - supports-color: - optional: true - checksum: 10c0/d2b44bc1afd912b49bb7ebb0d50a860dc93a4dd7d946e8de94abc957bb63726b7dd5aa48c18c2386c379ec024c46692e15ed3ed97d481729f929201e671fcd55 - languageName: node - linkType: hard - "debug@npm:^4.4.3": version: 4.4.3 resolution: "debug@npm:4.4.3" @@ -8409,7 +8387,7 @@ __metadata: "@vector-im/compound-design-tokens": "npm:^6.0.0" "@vector-im/compound-web": "npm:^8.0.0" "@vitejs/plugin-react": "npm:^4.0.1" - "@vitest/coverage-v8": "npm:^3.0.0" + "@vitest/coverage-v8": "npm:^4.0.18" babel-plugin-transform-vite-meta-env: "npm:^1.0.3" classnames: "npm:^2.3.1" copy-to-clipboard: "npm:^3.3.3" @@ -9954,7 +9932,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7, glob@npm:^10.4.1": +"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7": version: 10.4.5 resolution: "glob@npm:10.4.5" dependencies: @@ -10915,24 +10893,13 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-source-maps@npm:^5.0.6": - version: 5.0.6 - resolution: "istanbul-lib-source-maps@npm:5.0.6" - dependencies: - "@jridgewell/trace-mapping": "npm:^0.3.23" - debug: "npm:^4.1.1" - istanbul-lib-coverage: "npm:^3.0.0" - checksum: 10c0/ffe75d70b303a3621ee4671554f306e0831b16f39ab7f4ab52e54d356a5d33e534d97563e318f1333a6aae1d42f91ec49c76b6cd3f3fb378addcb5c81da0255f - languageName: node - linkType: hard - -"istanbul-reports@npm:^3.1.7": - version: 3.1.7 - resolution: "istanbul-reports@npm:3.1.7" +"istanbul-reports@npm:^3.2.0": + version: 3.2.0 + resolution: "istanbul-reports@npm:3.2.0" dependencies: html-escaper: "npm:^2.0.0" istanbul-lib-report: "npm:^3.0.0" - checksum: 10c0/a379fadf9cf8dc5dfe25568115721d4a7eb82fbd50b005a6672aff9c6989b20cc9312d7865814e0859cd8df58cbf664482e1d3604be0afde1f7fc3ccc1394a51 + checksum: 10c0/d596317cfd9c22e1394f22a8d8ba0303d2074fe2e971887b32d870e4b33f8464b10f8ccbe6847808f7db485f084eba09e6c2ed706b3a978e4b52f07085b8f9bc languageName: node linkType: hard @@ -11000,6 +10967,13 @@ __metadata: languageName: node linkType: hard +"js-tokens@npm:^10.0.0": + version: 10.0.0 + resolution: "js-tokens@npm:10.0.0" + checksum: 10c0/a93498747812ba3e0c8626f95f75ab29319f2a13613a0de9e610700405760931624433a0de59eb7c27ff8836e526768fb20783861b86ef89be96676f2c996b64 + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -11007,13 +10981,6 @@ __metadata: languageName: node linkType: hard -"js-tokens@npm:^9.0.1": - version: 9.0.1 - resolution: "js-tokens@npm:9.0.1" - checksum: 10c0/68dcab8f233dde211a6b5fd98079783cbcd04b53617c1250e3553ee16ab3e6134f5e65478e41d82f6d351a052a63d71024553933808570f04dbf828d7921e80e - languageName: node - linkType: hard - "js-yaml@npm:^4.1.0": version: 4.1.0 resolution: "js-yaml@npm:4.1.0" @@ -11415,15 +11382,6 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.17": - version: 0.30.17 - resolution: "magic-string@npm:0.30.17" - dependencies: - "@jridgewell/sourcemap-codec": "npm:^1.5.0" - checksum: 10c0/16826e415d04b88378f200fe022b53e638e3838b9e496edda6c0e086d7753a44a6ed187adc72d19f3623810589bf139af1a315541cd6a26ae0771a0193eaf7b8 - languageName: node - linkType: hard - "magic-string@npm:^0.30.21, magic-string@npm:^0.30.3": version: 0.30.21 resolution: "magic-string@npm:0.30.21" @@ -11433,14 +11391,14 @@ __metadata: languageName: node linkType: hard -"magicast@npm:^0.3.5": - version: 0.3.5 - resolution: "magicast@npm:0.3.5" +"magicast@npm:^0.5.1": + version: 0.5.2 + resolution: "magicast@npm:0.5.2" dependencies: - "@babel/parser": "npm:^7.25.4" - "@babel/types": "npm:^7.25.4" - source-map-js: "npm:^1.2.0" - checksum: 10c0/a6cacc0a848af84f03e3f5bda7b0de75e4d0aa9ddce5517fd23ed0f31b5ddd51b2d0ff0b7e09b51f7de0f4053c7a1107117edda6b0732dca3e9e39e6c5a68c64 + "@babel/parser": "npm:^7.29.0" + "@babel/types": "npm:^7.29.0" + source-map-js: "npm:^1.2.1" + checksum: 10c0/924af677643c5a0a7d6cdb3247c0eb96fa7611b2ba6a5e720d35d81c503d3d9f5948eb5227f80f90f82ea3e7d38cffd10bb988f3fc09020db428e14f26e960d7 languageName: node linkType: hard @@ -13769,7 +13727,7 @@ __metadata: languageName: node linkType: hard -"rollup@npm:4.59.0": +"rollup@npm:^4.43.0": version: 4.59.0 resolution: "rollup@npm:4.59.0" dependencies: @@ -14290,7 +14248,7 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1": +"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf @@ -14388,13 +14346,6 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^3.9.0": - version: 3.9.0 - resolution: "std-env@npm:3.9.0" - checksum: 10c0/4a6f9218aef3f41046c3c7ecf1f98df00b30a07f4f35c6d47b28329bc2531eef820828951c7d7b39a1c5eb19ad8a46e3ddfc7deb28f0a2f3ceebee11bab7ba50 - languageName: node - linkType: hard - "stop-iteration-iterator@npm:^1.1.0": version: 1.1.0 resolution: "stop-iteration-iterator@npm:1.1.0" @@ -14708,17 +14659,6 @@ __metadata: languageName: node linkType: hard -"test-exclude@npm:^7.0.1": - version: 7.0.1 - resolution: "test-exclude@npm:7.0.1" - dependencies: - "@istanbuljs/schema": "npm:^0.1.2" - glob: "npm:^10.4.1" - minimatch: "npm:^9.0.4" - checksum: 10c0/6d67b9af4336a2e12b26a68c83308c7863534c65f27ed4ff7068a56f5a58f7ac703e8fc80f698a19bb154fd8f705cdf7ec347d9512b2c522c737269507e7b263 - languageName: node - linkType: hard - "text-decoder@npm:^1.1.0": version: 1.2.3 resolution: "text-decoder@npm:1.2.3" From 8ecb1b3dbf2dacc95ea20a731b6367ab81e510aa Mon Sep 17 00:00:00 2001 From: JephDiel Date: Thu, 12 Mar 2026 22:18:38 -0500 Subject: [PATCH 095/119] Simplify widget detection Use the exists check for the widget API directly instead of a feature flag. --- src/Avatar.test.tsx | 21 ++++++++------------- src/Avatar.tsx | 16 ++++++++-------- src/ClientContext.tsx | 8 -------- src/settings/useSubmitRageshake.test.tsx | 1 - 4 files changed, 16 insertions(+), 30 deletions(-) diff --git a/src/Avatar.test.tsx b/src/Avatar.test.tsx index 3d75d4c1..debff26a 100644 --- a/src/Avatar.test.tsx +++ b/src/Avatar.test.tsx @@ -13,19 +13,14 @@ import { type FC, type PropsWithChildren } from "react"; import { ClientContextProvider } from "./ClientContext"; import { Avatar } from "./Avatar"; import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test"; -import EventEmitter from "events"; import { widget } from "./widget"; +import { WidgetApi } from "matrix-widget-api"; const TestComponent: FC< PropsWithChildren<{ client: MatrixClient; - supportsAuthenticatedMedia?: boolean; }> -> = ({ - client, - children, - supportsAuthenticatedMedia: supportsAuthenticatedMedia, -}) => { +> = ({ client, children }) => { return ( ({ widget: { - api: { downloadFile: vi.fn() }, + api: null, // Ideally we'd only mock this in the as a widget test so the whole module is otherwise null, but just nulling `api` by default works well enough }, })); @@ -88,7 +82,7 @@ test("should just render a placeholder when the user has no avatar", () => { expect(client.mxcUrlToHttp).toBeCalledTimes(0); }); -test("should attempt to fetch authenticated media if supported", async () => { +test("should attempt to fetch authenticated media from the server", async () => { const expectedAuthUrl = "http://example.org/media/alice-avatar"; const expectedObjectURL = "my-object-url"; const accessToken = "my-access-token"; @@ -120,7 +114,7 @@ test("should attempt to fetch authenticated media if supported", async () => { ); const displayName = "Alice"; render( - + { }); }); -test("should attempt to use widget API if authenticate media is not supported", async () => { +test("should attempt to use widget API if running as a widget", async () => { const expectedMXCUrl = "mxc://example.org/alice-avatar"; const expectedObjectURL = "my-object-url"; const theBlob = new Blob([]); @@ -157,6 +151,7 @@ test("should attempt to use widget API if authenticate media is not supported", getAccessToken: () => undefined, } as unknown as MatrixClient); + widget!.api = { downloadFile: vi.fn() } as unknown as WidgetApi; vi.spyOn(widget!.api, "downloadFile").mockResolvedValue({ file: theBlob }); const member = mockMatrixRoomMember( mockRtcMembership("@alice:example.org", "AAAA"), @@ -166,7 +161,7 @@ test("should attempt to use widget API if authenticate media is not supported", ); const displayName = "Alice"; render( - + = ({ const [avatarUrl, setAvatarUrl] = useState(undefined); + // In theory, a change in `clientState` or `sizePx` could run extra getAvatarFromWidgetAPI calls, but in practice they should be stable long before this code runs. useEffect(() => { - if (!src || clientState?.state !== "valid") { + if (!src) { setAvatarUrl(undefined); return; } - const { authenticated, supportedFeatures } = clientState; let blob: Promise; - if ( - supportedFeatures.authenticatedMedia && - authenticated?.client && + if (widget?.api) { + blob = getAvatarFromWidgetAPI(widget.api, src); + } else if ( + clientState?.state === "valid" && + clientState.authenticated?.client && sizePx ) { - blob = getAvatarFromServer(authenticated.client, src, sizePx); - } else if (widget?.api) { - blob = getAvatarFromWidgetAPI(widget.api, src); + blob = getAvatarFromServer(clientState.authenticated.client, src, sizePx); } else { setAvatarUrl(undefined); return; diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 7059cd69..f2ff3dd4 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -48,7 +48,6 @@ export type ValidClientState = { disconnected: boolean; supportedFeatures: { reactions: boolean; - authenticatedMedia: boolean; }; setClient: (client: MatrixClient, session: Session) => void; }; @@ -249,8 +248,6 @@ export const ClientProvider: FC = ({ children }) => { const [isDisconnected, setIsDisconnected] = useState(false); const [supportsReactions, setSupportsReactions] = useState(false); - const [supportsAuthenticatedMedia, setSupportsAuthenticatedMedia] = - useState(false); const state: ClientState | undefined = useMemo(() => { if (alreadyOpenedErr) { @@ -276,7 +273,6 @@ export const ClientProvider: FC = ({ children }) => { disconnected: isDisconnected, supportedFeatures: { reactions: supportsReactions, - authenticatedMedia: supportsAuthenticatedMedia, }, }; }, [ @@ -287,7 +283,6 @@ export const ClientProvider: FC = ({ children }) => { setClient, isDisconnected, supportsReactions, - supportsAuthenticatedMedia, ]); const onSync = useCallback( @@ -313,8 +308,6 @@ export const ClientProvider: FC = ({ children }) => { } if (initClientState.widgetApi) { - // There is currently no way for widgets to request authenticated media directly from the server. - setSupportsAuthenticatedMedia(false); const reactSend = initClientState.widgetApi.hasCapability( "org.matrix.msc2762.send.event:m.reaction", ); @@ -336,7 +329,6 @@ export const ClientProvider: FC = ({ children }) => { } } else { setSupportsReactions(true); - setSupportsAuthenticatedMedia(true); } return (): void => { diff --git a/src/settings/useSubmitRageshake.test.tsx b/src/settings/useSubmitRageshake.test.tsx index d69255e0..b5d07553 100644 --- a/src/settings/useSubmitRageshake.test.tsx +++ b/src/settings/useSubmitRageshake.test.tsx @@ -78,7 +78,6 @@ function renderWithMockClient( disconnected: false, supportedFeatures: { reactions: true, - authenticatedMedia: true, }, setClient: vi.fn(), authenticated: { From b1987219690ff9b36bb9c213e1e71e44fd5c66fa Mon Sep 17 00:00:00 2001 From: JephDiel Date: Thu, 12 Mar 2026 22:20:19 -0500 Subject: [PATCH 096/119] Ignore stale downloads If src or sizePx changes while we're downloading, discard the now-stale fetch result so we don't override the fresh one. --- src/Avatar.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Avatar.tsx b/src/Avatar.tsx index 8d89865e..d7768d6f 100644 --- a/src/Avatar.tsx +++ b/src/Avatar.tsx @@ -110,16 +110,24 @@ export const Avatar: FC = ({ } let objectUrl: string | undefined; + let stale = false; blob .then((blob) => { + if (stale) { + return; + } objectUrl = URL.createObjectURL(blob); setAvatarUrl(objectUrl); }) .catch((ex) => { + if (stale) { + return; + } setAvatarUrl(undefined); }); return (): void => { + stale = true; if (objectUrl) { URL.revokeObjectURL(objectUrl); } From 7da9bca08c5c50ae41478296837ab688658510ef Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 13 Mar 2026 07:59:49 +0100 Subject: [PATCH 097/119] update qs, js-yaml, glob for security patches --- package.json | 5 +- yarn.lock | 164 ++++++++++++++------------------------------------- 2 files changed, 49 insertions(+), 120 deletions(-) diff --git a/package.json b/package.json index 04a48dc2..cc8a36eb 100644 --- a/package.json +++ b/package.json @@ -140,7 +140,10 @@ "@livekit/components-core/rxjs": "^7.8.1", "@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18", "minimatch": "^10.2.3", - "tar": "^7.5.11" + "tar": "^7.5.11", + "glob": "^10.5.0", + "qs": "^6.14.1", + "js-yaml": "^4.1.1" }, "packageManager": "yarn@4.7.0" } diff --git a/yarn.lock b/yarn.lock index a3f6b921..12e1b857 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2901,20 +2901,13 @@ __metadata: languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.12.2": +"@eslint-community/regexpp@npm:^4.12.2, @eslint-community/regexpp@npm:^4.6.1": version: 4.12.2 resolution: "@eslint-community/regexpp@npm:4.12.2" checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.6.1": - version: 4.11.1 - resolution: "@eslint-community/regexpp@npm:4.11.1" - checksum: 10c0/fbcc1cb65ef5ed5b92faa8dc542e035269065e7ebcc0b39c81a4fe98ad35cfff20b3c8df048641de15a7757e07d69f85e2579c1a5055f993413ba18c055654f8 - languageName: node - linkType: hard - "@eslint/eslintrc@npm:^2.1.4": version: 2.1.4 resolution: "@eslint/eslintrc@npm:2.1.4" @@ -6218,9 +6211,9 @@ __metadata: linkType: hard "@ungap/structured-clone@npm:^1.2.0": - version: 1.2.0 - resolution: "@ungap/structured-clone@npm:1.2.0" - checksum: 10c0/8209c937cb39119f44eb63cf90c0b73e7c754209a6411c707be08e50e29ee81356dca1a848a405c8bdeebfe2f5e4f831ad310ae1689eeef65e7445c090c6657d + version: 1.3.0 + resolution: "@ungap/structured-clone@npm:1.3.0" + checksum: 10c0/0fc3097c2540ada1fc340ee56d58d96b5b536a2a0dab6e3ec17d4bfc8c4c86db345f61a375a8185f9da96f01c69678f836a2b57eeaa9e4b8eeafd26428e57b0a languageName: node linkType: hard @@ -6437,7 +6430,7 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.16.0": +"acorn@npm:^8.16.0, acorn@npm:^8.9.0": version: 8.16.0 resolution: "acorn@npm:8.16.0" bin: @@ -6446,15 +6439,6 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.9.0": - version: 8.12.1 - resolution: "acorn@npm:8.12.1" - bin: - acorn: bin/acorn - checksum: 10c0/51fb26cd678f914e13287e886da2d7021f8c2bc0ccc95e03d3e0447ee278dd3b40b9c57dc222acd5881adcf26f3edc40901a4953403232129e3876793cd17386 - languageName: node - linkType: hard - "agent-base@npm:6": version: 6.0.2 resolution: "agent-base@npm:6.0.2" @@ -6481,14 +6465,14 @@ __metadata: linkType: hard "ajv@npm:^6.12.4": - version: 6.12.6 - resolution: "ajv@npm:6.12.6" + version: 6.14.0 + resolution: "ajv@npm:6.14.0" dependencies: fast-deep-equal: "npm:^3.1.1" fast-json-stable-stringify: "npm:^2.0.0" json-schema-traverse: "npm:^0.4.1" uri-js: "npm:^4.2.2" - checksum: 10c0/41e23642cbe545889245b9d2a45854ebba51cda6c778ebced9649420d9205f2efb39cb43dbc41e358409223b1ea43303ae4839db682c848b891e4811da1a5a71 + checksum: 10c0/a2bc39b0555dc9802c899f86990eb8eed6e366cddbf65be43d5aa7e4f3c4e1a199d5460fd7ca4fb3d864000dbbc049253b72faa83b3b30e641ca52cb29a68c22 languageName: node linkType: hard @@ -7749,18 +7733,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.2": - version: 7.0.3 - resolution: "cross-spawn@npm:7.0.3" - dependencies: - path-key: "npm:^3.1.0" - shebang-command: "npm:^2.0.0" - which: "npm:^2.0.1" - checksum: 10c0/5738c312387081c98d69c98e105b6327b069197f864a60593245d64c8089c8a0a744e16349281210d56835bb9274130d825a78b2ad6853ca13cfbeffc0c31750 - languageName: node - linkType: hard - -"cross-spawn@npm:^7.0.6": +"cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -9308,16 +9281,7 @@ __metadata: languageName: node linkType: hard -"esquery@npm:^1.4.2, esquery@npm:^1.6.0": - version: 1.6.0 - resolution: "esquery@npm:1.6.0" - dependencies: - estraverse: "npm:^5.1.0" - checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 - languageName: node - linkType: hard - -"esquery@npm:^1.7.0": +"esquery@npm:^1.4.2, esquery@npm:^1.7.0": version: 1.7.0 resolution: "esquery@npm:1.7.0" dependencies: @@ -9326,6 +9290,15 @@ __metadata: languageName: node linkType: hard +"esquery@npm:^1.6.0": + version: 1.6.0 + resolution: "esquery@npm:1.6.0" + dependencies: + estraverse: "npm:^5.1.0" + checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 + languageName: node + linkType: hard + "esrecurse@npm:^4.3.0": version: 4.3.0 resolution: "esrecurse@npm:4.3.0" @@ -9700,13 +9673,6 @@ __metadata: languageName: node linkType: hard -"fs.realpath@npm:^1.0.0": - version: 1.0.0 - resolution: "fs.realpath@npm:1.0.0" - checksum: 10c0/444cf1291d997165dfd4c0d58b69f0e4782bfd9149fd72faa4fe299e68e0e93d6db941660b37dd29153bf7186672ececa3b50b7e7249477b03fdf850f287c948 - languageName: node - linkType: hard - "fsevents@npm:2.3.2": version: 2.3.2 resolution: "fsevents@npm:2.3.2" @@ -9899,9 +9865,9 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7": - version: 10.4.5 - resolution: "glob@npm:10.4.5" +"glob@npm:^10.5.0": + version: 10.5.0 + resolution: "glob@npm:10.5.0" dependencies: foreground-child: "npm:^3.1.0" jackspeak: "npm:^3.1.2" @@ -9911,33 +9877,7 @@ __metadata: path-scurry: "npm:^1.11.1" bin: glob: dist/esm/bin.mjs - checksum: 10c0/19a9759ea77b8e3ca0a43c2f07ecddc2ad46216b786bb8f993c445aee80d345925a21e5280c7b7c6c59e860a0154b84e4b2b60321fea92cd3c56b4a7489f160e - languageName: node - linkType: hard - -"glob@npm:^7.1.3, glob@npm:~7.2.0": - version: 7.2.3 - resolution: "glob@npm:7.2.3" - dependencies: - fs.realpath: "npm:^1.0.0" - inflight: "npm:^1.0.4" - inherits: "npm:2" - minimatch: "npm:^3.1.1" - once: "npm:^1.3.0" - path-is-absolute: "npm:^1.0.0" - checksum: 10c0/65676153e2b0c9095100fe7f25a778bf45608eeb32c6048cf307f579649bcc30353277b3b898a3792602c65764e5baa4f643714dfbdfd64ea271d210c7a425fe - languageName: node - linkType: hard - -"glob@npm:^9.3.2": - version: 9.3.5 - resolution: "glob@npm:9.3.5" - dependencies: - fs.realpath: "npm:^1.0.0" - minimatch: "npm:^8.0.2" - minipass: "npm:^4.2.4" - path-scurry: "npm:^1.6.1" - checksum: 10c0/2f6c2b9ee019ee21dc258ae97a88719614591e4c979cb4580b1b9df6f0f778a3cb38b4bdaf18dfa584637ea10f89a3c5f2533a5e449cf8741514ad18b0951f2e + checksum: 10c0/100705eddbde6323e7b35e1d1ac28bcb58322095bd8e63a7d0bef1a2cdafe0d0f7922a981b2b48369a4f8c1b077be5c171804534c3509dfe950dde15fbe6d828 languageName: node linkType: hard @@ -10384,7 +10324,17 @@ __metadata: languageName: node linkType: hard -"import-fresh@npm:^3.2.1, import-fresh@npm:^3.3.0": +"import-fresh@npm:^3.2.1": + version: 3.3.1 + resolution: "import-fresh@npm:3.3.1" + dependencies: + parent-module: "npm:^1.0.0" + resolve-from: "npm:^4.0.0" + checksum: 10c0/bf8cc494872fef783249709385ae883b447e3eb09db0ebd15dcead7d9afe7224dad7bd7591c6b73b0b19b3c0f9640eb8ee884f01cfaf2887ab995b0b36a0cbec + languageName: node + linkType: hard + +"import-fresh@npm:^3.3.0": version: 3.3.0 resolution: "import-fresh@npm:3.3.0" dependencies: @@ -10408,17 +10358,7 @@ __metadata: languageName: node linkType: hard -"inflight@npm:^1.0.4": - version: 1.0.6 - resolution: "inflight@npm:1.0.6" - dependencies: - once: "npm:^1.3.0" - wrappy: "npm:1" - checksum: 10c0/7faca22584600a9dc5b9fca2cd5feb7135ac8c935449837b315676b4c90aa4f391ec4f42240178244b5a34e8bede1948627fda392ca3191522fc46b34e985ab2 - languageName: node - linkType: hard - -"inherits@npm:2, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": +"inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 @@ -10948,14 +10888,14 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:^4.1.0": - version: 4.1.0 - resolution: "js-yaml@npm:4.1.0" +"js-yaml@npm:^4.1.1": + version: 4.1.1 + resolution: "js-yaml@npm:4.1.1" dependencies: argparse: "npm:^2.0.1" bin: js-yaml: bin/js-yaml.js - checksum: 10c0/184a24b4eaacfce40ad9074c64fd42ac83cf74d8c8cd137718d456ced75051229e5061b8633c3366b8aada17945a7a356b337828c19da92b51ae62126575018f + checksum: 10c0/561c7d7088c40a9bb53cc75becbfb1df6ae49b34b5e6e5a81744b14ae8667ec564ad2527709d1a6e7d5e5fa6d483aa0f373a50ad98d42fde368ec4a190d4fae7 languageName: node linkType: hard @@ -11606,13 +11546,6 @@ __metadata: languageName: node linkType: hard -"minipass@npm:^4.2.4": - version: 4.2.8 - resolution: "minipass@npm:4.2.8" - checksum: 10c0/4ea76b030d97079f4429d6e8a8affd90baf1b6a1898977c8ccce4701c5a2ba2792e033abc6709373f25c2c4d4d95440d9d5e9464b46b7b76ca44d2ce26d939ce - languageName: node - linkType: hard - "minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2": version: 7.1.2 resolution: "minipass@npm:7.1.2" @@ -11990,7 +11923,7 @@ __metadata: languageName: node linkType: hard -"once@npm:^1.3.0, once@npm:^1.4.0": +"once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -12292,13 +12225,6 @@ __metadata: languageName: node linkType: hard -"path-is-absolute@npm:^1.0.0": - version: 1.0.1 - resolution: "path-is-absolute@npm:1.0.1" - checksum: 10c0/127da03c82172a2a50099cddbf02510c1791fc2cc5f7713ddb613a56838db1e8168b121a920079d052e0936c23005562059756d653b7c544c53185efe53be078 - languageName: node - linkType: hard - "path-key@npm:^3.1.0": version: 3.1.1 resolution: "path-key@npm:3.1.1" @@ -12320,7 +12246,7 @@ __metadata: languageName: node linkType: hard -"path-scurry@npm:^1.11.1, path-scurry@npm:^1.6.1": +"path-scurry@npm:^1.11.1": version: 1.11.1 resolution: "path-scurry@npm:1.11.1" dependencies: @@ -13005,12 +12931,12 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.12.3": - version: 6.14.0 - resolution: "qs@npm:6.14.0" +"qs@npm:^6.14.1": + version: 6.15.0 + resolution: "qs@npm:6.15.0" dependencies: side-channel: "npm:^1.1.0" - checksum: 10c0/8ea5d91bf34f440598ee389d4a7d95820e3b837d3fd9f433871f7924801becaa0cd3b3b4628d49a7784d06a8aea9bc4554d2b6d8d584e2d221dc06238a42909c + checksum: 10c0/ff341078a78a991d8a48b4524d52949211447b4b1ad907f489cac0770cbc346a28e47304455c0320e5fb000f8762d64b03331e3b71865f663bf351bcba8cdb4b languageName: node linkType: hard From bf8bf80417483d22dc0a9bce19e9affe332e3fcd Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 13 Mar 2026 08:01:08 +0100 Subject: [PATCH 098/119] Revert "update qs, js-yaml, glob for security patches" This reverts commit 7da9bca08c5c50ae41478296837ab688658510ef. --- package.json | 5 +- yarn.lock | 164 +++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 120 insertions(+), 49 deletions(-) diff --git a/package.json b/package.json index cc8a36eb..04a48dc2 100644 --- a/package.json +++ b/package.json @@ -140,10 +140,7 @@ "@livekit/components-core/rxjs": "^7.8.1", "@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18", "minimatch": "^10.2.3", - "tar": "^7.5.11", - "glob": "^10.5.0", - "qs": "^6.14.1", - "js-yaml": "^4.1.1" + "tar": "^7.5.11" }, "packageManager": "yarn@4.7.0" } diff --git a/yarn.lock b/yarn.lock index 12e1b857..a3f6b921 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2901,13 +2901,20 @@ __metadata: languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.12.2, @eslint-community/regexpp@npm:^4.6.1": +"@eslint-community/regexpp@npm:^4.12.2": version: 4.12.2 resolution: "@eslint-community/regexpp@npm:4.12.2" checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d languageName: node linkType: hard +"@eslint-community/regexpp@npm:^4.6.1": + version: 4.11.1 + resolution: "@eslint-community/regexpp@npm:4.11.1" + checksum: 10c0/fbcc1cb65ef5ed5b92faa8dc542e035269065e7ebcc0b39c81a4fe98ad35cfff20b3c8df048641de15a7757e07d69f85e2579c1a5055f993413ba18c055654f8 + languageName: node + linkType: hard + "@eslint/eslintrc@npm:^2.1.4": version: 2.1.4 resolution: "@eslint/eslintrc@npm:2.1.4" @@ -6211,9 +6218,9 @@ __metadata: linkType: hard "@ungap/structured-clone@npm:^1.2.0": - version: 1.3.0 - resolution: "@ungap/structured-clone@npm:1.3.0" - checksum: 10c0/0fc3097c2540ada1fc340ee56d58d96b5b536a2a0dab6e3ec17d4bfc8c4c86db345f61a375a8185f9da96f01c69678f836a2b57eeaa9e4b8eeafd26428e57b0a + version: 1.2.0 + resolution: "@ungap/structured-clone@npm:1.2.0" + checksum: 10c0/8209c937cb39119f44eb63cf90c0b73e7c754209a6411c707be08e50e29ee81356dca1a848a405c8bdeebfe2f5e4f831ad310ae1689eeef65e7445c090c6657d languageName: node linkType: hard @@ -6430,7 +6437,7 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.16.0, acorn@npm:^8.9.0": +"acorn@npm:^8.16.0": version: 8.16.0 resolution: "acorn@npm:8.16.0" bin: @@ -6439,6 +6446,15 @@ __metadata: languageName: node linkType: hard +"acorn@npm:^8.9.0": + version: 8.12.1 + resolution: "acorn@npm:8.12.1" + bin: + acorn: bin/acorn + checksum: 10c0/51fb26cd678f914e13287e886da2d7021f8c2bc0ccc95e03d3e0447ee278dd3b40b9c57dc222acd5881adcf26f3edc40901a4953403232129e3876793cd17386 + languageName: node + linkType: hard + "agent-base@npm:6": version: 6.0.2 resolution: "agent-base@npm:6.0.2" @@ -6465,14 +6481,14 @@ __metadata: linkType: hard "ajv@npm:^6.12.4": - version: 6.14.0 - resolution: "ajv@npm:6.14.0" + version: 6.12.6 + resolution: "ajv@npm:6.12.6" dependencies: fast-deep-equal: "npm:^3.1.1" fast-json-stable-stringify: "npm:^2.0.0" json-schema-traverse: "npm:^0.4.1" uri-js: "npm:^4.2.2" - checksum: 10c0/a2bc39b0555dc9802c899f86990eb8eed6e366cddbf65be43d5aa7e4f3c4e1a199d5460fd7ca4fb3d864000dbbc049253b72faa83b3b30e641ca52cb29a68c22 + checksum: 10c0/41e23642cbe545889245b9d2a45854ebba51cda6c778ebced9649420d9205f2efb39cb43dbc41e358409223b1ea43303ae4839db682c848b891e4811da1a5a71 languageName: node linkType: hard @@ -7733,7 +7749,18 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.6": +"cross-spawn@npm:^7.0.2": + version: 7.0.3 + resolution: "cross-spawn@npm:7.0.3" + dependencies: + path-key: "npm:^3.1.0" + shebang-command: "npm:^2.0.0" + which: "npm:^2.0.1" + checksum: 10c0/5738c312387081c98d69c98e105b6327b069197f864a60593245d64c8089c8a0a744e16349281210d56835bb9274130d825a78b2ad6853ca13cfbeffc0c31750 + languageName: node + linkType: hard + +"cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -9281,16 +9308,7 @@ __metadata: languageName: node linkType: hard -"esquery@npm:^1.4.2, esquery@npm:^1.7.0": - version: 1.7.0 - resolution: "esquery@npm:1.7.0" - dependencies: - estraverse: "npm:^5.1.0" - checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 - languageName: node - linkType: hard - -"esquery@npm:^1.6.0": +"esquery@npm:^1.4.2, esquery@npm:^1.6.0": version: 1.6.0 resolution: "esquery@npm:1.6.0" dependencies: @@ -9299,6 +9317,15 @@ __metadata: languageName: node linkType: hard +"esquery@npm:^1.7.0": + version: 1.7.0 + resolution: "esquery@npm:1.7.0" + dependencies: + estraverse: "npm:^5.1.0" + checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 + languageName: node + linkType: hard + "esrecurse@npm:^4.3.0": version: 4.3.0 resolution: "esrecurse@npm:4.3.0" @@ -9673,6 +9700,13 @@ __metadata: languageName: node linkType: hard +"fs.realpath@npm:^1.0.0": + version: 1.0.0 + resolution: "fs.realpath@npm:1.0.0" + checksum: 10c0/444cf1291d997165dfd4c0d58b69f0e4782bfd9149fd72faa4fe299e68e0e93d6db941660b37dd29153bf7186672ececa3b50b7e7249477b03fdf850f287c948 + languageName: node + linkType: hard + "fsevents@npm:2.3.2": version: 2.3.2 resolution: "fsevents@npm:2.3.2" @@ -9865,9 +9899,9 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.5.0": - version: 10.5.0 - resolution: "glob@npm:10.5.0" +"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7": + version: 10.4.5 + resolution: "glob@npm:10.4.5" dependencies: foreground-child: "npm:^3.1.0" jackspeak: "npm:^3.1.2" @@ -9877,7 +9911,33 @@ __metadata: path-scurry: "npm:^1.11.1" bin: glob: dist/esm/bin.mjs - checksum: 10c0/100705eddbde6323e7b35e1d1ac28bcb58322095bd8e63a7d0bef1a2cdafe0d0f7922a981b2b48369a4f8c1b077be5c171804534c3509dfe950dde15fbe6d828 + checksum: 10c0/19a9759ea77b8e3ca0a43c2f07ecddc2ad46216b786bb8f993c445aee80d345925a21e5280c7b7c6c59e860a0154b84e4b2b60321fea92cd3c56b4a7489f160e + languageName: node + linkType: hard + +"glob@npm:^7.1.3, glob@npm:~7.2.0": + version: 7.2.3 + resolution: "glob@npm:7.2.3" + dependencies: + fs.realpath: "npm:^1.0.0" + inflight: "npm:^1.0.4" + inherits: "npm:2" + minimatch: "npm:^3.1.1" + once: "npm:^1.3.0" + path-is-absolute: "npm:^1.0.0" + checksum: 10c0/65676153e2b0c9095100fe7f25a778bf45608eeb32c6048cf307f579649bcc30353277b3b898a3792602c65764e5baa4f643714dfbdfd64ea271d210c7a425fe + languageName: node + linkType: hard + +"glob@npm:^9.3.2": + version: 9.3.5 + resolution: "glob@npm:9.3.5" + dependencies: + fs.realpath: "npm:^1.0.0" + minimatch: "npm:^8.0.2" + minipass: "npm:^4.2.4" + path-scurry: "npm:^1.6.1" + checksum: 10c0/2f6c2b9ee019ee21dc258ae97a88719614591e4c979cb4580b1b9df6f0f778a3cb38b4bdaf18dfa584637ea10f89a3c5f2533a5e449cf8741514ad18b0951f2e languageName: node linkType: hard @@ -10324,17 +10384,7 @@ __metadata: languageName: node linkType: hard -"import-fresh@npm:^3.2.1": - version: 3.3.1 - resolution: "import-fresh@npm:3.3.1" - dependencies: - parent-module: "npm:^1.0.0" - resolve-from: "npm:^4.0.0" - checksum: 10c0/bf8cc494872fef783249709385ae883b447e3eb09db0ebd15dcead7d9afe7224dad7bd7591c6b73b0b19b3c0f9640eb8ee884f01cfaf2887ab995b0b36a0cbec - languageName: node - linkType: hard - -"import-fresh@npm:^3.3.0": +"import-fresh@npm:^3.2.1, import-fresh@npm:^3.3.0": version: 3.3.0 resolution: "import-fresh@npm:3.3.0" dependencies: @@ -10358,7 +10408,17 @@ __metadata: languageName: node linkType: hard -"inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": +"inflight@npm:^1.0.4": + version: 1.0.6 + resolution: "inflight@npm:1.0.6" + dependencies: + once: "npm:^1.3.0" + wrappy: "npm:1" + checksum: 10c0/7faca22584600a9dc5b9fca2cd5feb7135ac8c935449837b315676b4c90aa4f391ec4f42240178244b5a34e8bede1948627fda392ca3191522fc46b34e985ab2 + languageName: node + linkType: hard + +"inherits@npm:2, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 @@ -10888,14 +10948,14 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:^4.1.1": - version: 4.1.1 - resolution: "js-yaml@npm:4.1.1" +"js-yaml@npm:^4.1.0": + version: 4.1.0 + resolution: "js-yaml@npm:4.1.0" dependencies: argparse: "npm:^2.0.1" bin: js-yaml: bin/js-yaml.js - checksum: 10c0/561c7d7088c40a9bb53cc75becbfb1df6ae49b34b5e6e5a81744b14ae8667ec564ad2527709d1a6e7d5e5fa6d483aa0f373a50ad98d42fde368ec4a190d4fae7 + checksum: 10c0/184a24b4eaacfce40ad9074c64fd42ac83cf74d8c8cd137718d456ced75051229e5061b8633c3366b8aada17945a7a356b337828c19da92b51ae62126575018f languageName: node linkType: hard @@ -11546,6 +11606,13 @@ __metadata: languageName: node linkType: hard +"minipass@npm:^4.2.4": + version: 4.2.8 + resolution: "minipass@npm:4.2.8" + checksum: 10c0/4ea76b030d97079f4429d6e8a8affd90baf1b6a1898977c8ccce4701c5a2ba2792e033abc6709373f25c2c4d4d95440d9d5e9464b46b7b76ca44d2ce26d939ce + languageName: node + linkType: hard + "minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2": version: 7.1.2 resolution: "minipass@npm:7.1.2" @@ -11923,7 +11990,7 @@ __metadata: languageName: node linkType: hard -"once@npm:^1.4.0": +"once@npm:^1.3.0, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -12225,6 +12292,13 @@ __metadata: languageName: node linkType: hard +"path-is-absolute@npm:^1.0.0": + version: 1.0.1 + resolution: "path-is-absolute@npm:1.0.1" + checksum: 10c0/127da03c82172a2a50099cddbf02510c1791fc2cc5f7713ddb613a56838db1e8168b121a920079d052e0936c23005562059756d653b7c544c53185efe53be078 + languageName: node + linkType: hard + "path-key@npm:^3.1.0": version: 3.1.1 resolution: "path-key@npm:3.1.1" @@ -12246,7 +12320,7 @@ __metadata: languageName: node linkType: hard -"path-scurry@npm:^1.11.1": +"path-scurry@npm:^1.11.1, path-scurry@npm:^1.6.1": version: 1.11.1 resolution: "path-scurry@npm:1.11.1" dependencies: @@ -12931,12 +13005,12 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.14.1": - version: 6.15.0 - resolution: "qs@npm:6.15.0" +"qs@npm:^6.12.3": + version: 6.14.0 + resolution: "qs@npm:6.14.0" dependencies: side-channel: "npm:^1.1.0" - checksum: 10c0/ff341078a78a991d8a48b4524d52949211447b4b1ad907f489cac0770cbc346a28e47304455c0320e5fb000f8762d64b03331e3b71865f663bf351bcba8cdb4b + checksum: 10c0/8ea5d91bf34f440598ee389d4a7d95820e3b837d3fd9f433871f7924801becaa0cd3b3b4628d49a7784d06a8aea9bc4554d2b6d8d584e2d221dc06238a42909c languageName: node linkType: hard From 78240c2ec802ef3f7d2ee9f07ab41f9e8c517bf2 Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 13 Mar 2026 07:59:49 +0100 Subject: [PATCH 099/119] update qs, js-yaml, glob for security patches --- package.json | 5 +- yarn.lock | 164 ++++++++++++++------------------------------------- 2 files changed, 49 insertions(+), 120 deletions(-) diff --git a/package.json b/package.json index 04a48dc2..cc8a36eb 100644 --- a/package.json +++ b/package.json @@ -140,7 +140,10 @@ "@livekit/components-core/rxjs": "^7.8.1", "@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18", "minimatch": "^10.2.3", - "tar": "^7.5.11" + "tar": "^7.5.11", + "glob": "^10.5.0", + "qs": "^6.14.1", + "js-yaml": "^4.1.1" }, "packageManager": "yarn@4.7.0" } diff --git a/yarn.lock b/yarn.lock index a3f6b921..12e1b857 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2901,20 +2901,13 @@ __metadata: languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.12.2": +"@eslint-community/regexpp@npm:^4.12.2, @eslint-community/regexpp@npm:^4.6.1": version: 4.12.2 resolution: "@eslint-community/regexpp@npm:4.12.2" checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.6.1": - version: 4.11.1 - resolution: "@eslint-community/regexpp@npm:4.11.1" - checksum: 10c0/fbcc1cb65ef5ed5b92faa8dc542e035269065e7ebcc0b39c81a4fe98ad35cfff20b3c8df048641de15a7757e07d69f85e2579c1a5055f993413ba18c055654f8 - languageName: node - linkType: hard - "@eslint/eslintrc@npm:^2.1.4": version: 2.1.4 resolution: "@eslint/eslintrc@npm:2.1.4" @@ -6218,9 +6211,9 @@ __metadata: linkType: hard "@ungap/structured-clone@npm:^1.2.0": - version: 1.2.0 - resolution: "@ungap/structured-clone@npm:1.2.0" - checksum: 10c0/8209c937cb39119f44eb63cf90c0b73e7c754209a6411c707be08e50e29ee81356dca1a848a405c8bdeebfe2f5e4f831ad310ae1689eeef65e7445c090c6657d + version: 1.3.0 + resolution: "@ungap/structured-clone@npm:1.3.0" + checksum: 10c0/0fc3097c2540ada1fc340ee56d58d96b5b536a2a0dab6e3ec17d4bfc8c4c86db345f61a375a8185f9da96f01c69678f836a2b57eeaa9e4b8eeafd26428e57b0a languageName: node linkType: hard @@ -6437,7 +6430,7 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.16.0": +"acorn@npm:^8.16.0, acorn@npm:^8.9.0": version: 8.16.0 resolution: "acorn@npm:8.16.0" bin: @@ -6446,15 +6439,6 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.9.0": - version: 8.12.1 - resolution: "acorn@npm:8.12.1" - bin: - acorn: bin/acorn - checksum: 10c0/51fb26cd678f914e13287e886da2d7021f8c2bc0ccc95e03d3e0447ee278dd3b40b9c57dc222acd5881adcf26f3edc40901a4953403232129e3876793cd17386 - languageName: node - linkType: hard - "agent-base@npm:6": version: 6.0.2 resolution: "agent-base@npm:6.0.2" @@ -6481,14 +6465,14 @@ __metadata: linkType: hard "ajv@npm:^6.12.4": - version: 6.12.6 - resolution: "ajv@npm:6.12.6" + version: 6.14.0 + resolution: "ajv@npm:6.14.0" dependencies: fast-deep-equal: "npm:^3.1.1" fast-json-stable-stringify: "npm:^2.0.0" json-schema-traverse: "npm:^0.4.1" uri-js: "npm:^4.2.2" - checksum: 10c0/41e23642cbe545889245b9d2a45854ebba51cda6c778ebced9649420d9205f2efb39cb43dbc41e358409223b1ea43303ae4839db682c848b891e4811da1a5a71 + checksum: 10c0/a2bc39b0555dc9802c899f86990eb8eed6e366cddbf65be43d5aa7e4f3c4e1a199d5460fd7ca4fb3d864000dbbc049253b72faa83b3b30e641ca52cb29a68c22 languageName: node linkType: hard @@ -7749,18 +7733,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.2": - version: 7.0.3 - resolution: "cross-spawn@npm:7.0.3" - dependencies: - path-key: "npm:^3.1.0" - shebang-command: "npm:^2.0.0" - which: "npm:^2.0.1" - checksum: 10c0/5738c312387081c98d69c98e105b6327b069197f864a60593245d64c8089c8a0a744e16349281210d56835bb9274130d825a78b2ad6853ca13cfbeffc0c31750 - languageName: node - linkType: hard - -"cross-spawn@npm:^7.0.6": +"cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -9308,16 +9281,7 @@ __metadata: languageName: node linkType: hard -"esquery@npm:^1.4.2, esquery@npm:^1.6.0": - version: 1.6.0 - resolution: "esquery@npm:1.6.0" - dependencies: - estraverse: "npm:^5.1.0" - checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 - languageName: node - linkType: hard - -"esquery@npm:^1.7.0": +"esquery@npm:^1.4.2, esquery@npm:^1.7.0": version: 1.7.0 resolution: "esquery@npm:1.7.0" dependencies: @@ -9326,6 +9290,15 @@ __metadata: languageName: node linkType: hard +"esquery@npm:^1.6.0": + version: 1.6.0 + resolution: "esquery@npm:1.6.0" + dependencies: + estraverse: "npm:^5.1.0" + checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 + languageName: node + linkType: hard + "esrecurse@npm:^4.3.0": version: 4.3.0 resolution: "esrecurse@npm:4.3.0" @@ -9700,13 +9673,6 @@ __metadata: languageName: node linkType: hard -"fs.realpath@npm:^1.0.0": - version: 1.0.0 - resolution: "fs.realpath@npm:1.0.0" - checksum: 10c0/444cf1291d997165dfd4c0d58b69f0e4782bfd9149fd72faa4fe299e68e0e93d6db941660b37dd29153bf7186672ececa3b50b7e7249477b03fdf850f287c948 - languageName: node - linkType: hard - "fsevents@npm:2.3.2": version: 2.3.2 resolution: "fsevents@npm:2.3.2" @@ -9899,9 +9865,9 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7": - version: 10.4.5 - resolution: "glob@npm:10.4.5" +"glob@npm:^10.5.0": + version: 10.5.0 + resolution: "glob@npm:10.5.0" dependencies: foreground-child: "npm:^3.1.0" jackspeak: "npm:^3.1.2" @@ -9911,33 +9877,7 @@ __metadata: path-scurry: "npm:^1.11.1" bin: glob: dist/esm/bin.mjs - checksum: 10c0/19a9759ea77b8e3ca0a43c2f07ecddc2ad46216b786bb8f993c445aee80d345925a21e5280c7b7c6c59e860a0154b84e4b2b60321fea92cd3c56b4a7489f160e - languageName: node - linkType: hard - -"glob@npm:^7.1.3, glob@npm:~7.2.0": - version: 7.2.3 - resolution: "glob@npm:7.2.3" - dependencies: - fs.realpath: "npm:^1.0.0" - inflight: "npm:^1.0.4" - inherits: "npm:2" - minimatch: "npm:^3.1.1" - once: "npm:^1.3.0" - path-is-absolute: "npm:^1.0.0" - checksum: 10c0/65676153e2b0c9095100fe7f25a778bf45608eeb32c6048cf307f579649bcc30353277b3b898a3792602c65764e5baa4f643714dfbdfd64ea271d210c7a425fe - languageName: node - linkType: hard - -"glob@npm:^9.3.2": - version: 9.3.5 - resolution: "glob@npm:9.3.5" - dependencies: - fs.realpath: "npm:^1.0.0" - minimatch: "npm:^8.0.2" - minipass: "npm:^4.2.4" - path-scurry: "npm:^1.6.1" - checksum: 10c0/2f6c2b9ee019ee21dc258ae97a88719614591e4c979cb4580b1b9df6f0f778a3cb38b4bdaf18dfa584637ea10f89a3c5f2533a5e449cf8741514ad18b0951f2e + checksum: 10c0/100705eddbde6323e7b35e1d1ac28bcb58322095bd8e63a7d0bef1a2cdafe0d0f7922a981b2b48369a4f8c1b077be5c171804534c3509dfe950dde15fbe6d828 languageName: node linkType: hard @@ -10384,7 +10324,17 @@ __metadata: languageName: node linkType: hard -"import-fresh@npm:^3.2.1, import-fresh@npm:^3.3.0": +"import-fresh@npm:^3.2.1": + version: 3.3.1 + resolution: "import-fresh@npm:3.3.1" + dependencies: + parent-module: "npm:^1.0.0" + resolve-from: "npm:^4.0.0" + checksum: 10c0/bf8cc494872fef783249709385ae883b447e3eb09db0ebd15dcead7d9afe7224dad7bd7591c6b73b0b19b3c0f9640eb8ee884f01cfaf2887ab995b0b36a0cbec + languageName: node + linkType: hard + +"import-fresh@npm:^3.3.0": version: 3.3.0 resolution: "import-fresh@npm:3.3.0" dependencies: @@ -10408,17 +10358,7 @@ __metadata: languageName: node linkType: hard -"inflight@npm:^1.0.4": - version: 1.0.6 - resolution: "inflight@npm:1.0.6" - dependencies: - once: "npm:^1.3.0" - wrappy: "npm:1" - checksum: 10c0/7faca22584600a9dc5b9fca2cd5feb7135ac8c935449837b315676b4c90aa4f391ec4f42240178244b5a34e8bede1948627fda392ca3191522fc46b34e985ab2 - languageName: node - linkType: hard - -"inherits@npm:2, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": +"inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 @@ -10948,14 +10888,14 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:^4.1.0": - version: 4.1.0 - resolution: "js-yaml@npm:4.1.0" +"js-yaml@npm:^4.1.1": + version: 4.1.1 + resolution: "js-yaml@npm:4.1.1" dependencies: argparse: "npm:^2.0.1" bin: js-yaml: bin/js-yaml.js - checksum: 10c0/184a24b4eaacfce40ad9074c64fd42ac83cf74d8c8cd137718d456ced75051229e5061b8633c3366b8aada17945a7a356b337828c19da92b51ae62126575018f + checksum: 10c0/561c7d7088c40a9bb53cc75becbfb1df6ae49b34b5e6e5a81744b14ae8667ec564ad2527709d1a6e7d5e5fa6d483aa0f373a50ad98d42fde368ec4a190d4fae7 languageName: node linkType: hard @@ -11606,13 +11546,6 @@ __metadata: languageName: node linkType: hard -"minipass@npm:^4.2.4": - version: 4.2.8 - resolution: "minipass@npm:4.2.8" - checksum: 10c0/4ea76b030d97079f4429d6e8a8affd90baf1b6a1898977c8ccce4701c5a2ba2792e033abc6709373f25c2c4d4d95440d9d5e9464b46b7b76ca44d2ce26d939ce - languageName: node - linkType: hard - "minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2": version: 7.1.2 resolution: "minipass@npm:7.1.2" @@ -11990,7 +11923,7 @@ __metadata: languageName: node linkType: hard -"once@npm:^1.3.0, once@npm:^1.4.0": +"once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -12292,13 +12225,6 @@ __metadata: languageName: node linkType: hard -"path-is-absolute@npm:^1.0.0": - version: 1.0.1 - resolution: "path-is-absolute@npm:1.0.1" - checksum: 10c0/127da03c82172a2a50099cddbf02510c1791fc2cc5f7713ddb613a56838db1e8168b121a920079d052e0936c23005562059756d653b7c544c53185efe53be078 - languageName: node - linkType: hard - "path-key@npm:^3.1.0": version: 3.1.1 resolution: "path-key@npm:3.1.1" @@ -12320,7 +12246,7 @@ __metadata: languageName: node linkType: hard -"path-scurry@npm:^1.11.1, path-scurry@npm:^1.6.1": +"path-scurry@npm:^1.11.1": version: 1.11.1 resolution: "path-scurry@npm:1.11.1" dependencies: @@ -13005,12 +12931,12 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.12.3": - version: 6.14.0 - resolution: "qs@npm:6.14.0" +"qs@npm:^6.14.1": + version: 6.15.0 + resolution: "qs@npm:6.15.0" dependencies: side-channel: "npm:^1.1.0" - checksum: 10c0/8ea5d91bf34f440598ee389d4a7d95820e3b837d3fd9f433871f7924801becaa0cd3b3b4628d49a7784d06a8aea9bc4554d2b6d8d584e2d221dc06238a42909c + checksum: 10c0/ff341078a78a991d8a48b4524d52949211447b4b1ad907f489cac0770cbc346a28e47304455c0320e5fb000f8762d64b03331e3b71865f663bf351bcba8cdb4b languageName: node linkType: hard From af807489f94c21a957480b8b18d492438c9e388f Mon Sep 17 00:00:00 2001 From: JephDiel Date: Fri, 13 Mar 2026 11:31:55 -0500 Subject: [PATCH 100/119] Import order lint fixes Co-authored-by: Timo <16718859+toger5@users.noreply.github.com> --- src/Avatar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avatar.tsx b/src/Avatar.tsx index d7768d6f..d0cb243c 100644 --- a/src/Avatar.tsx +++ b/src/Avatar.tsx @@ -14,10 +14,10 @@ import { } from "react"; import { Avatar as CompoundAvatar } from "@vector-im/compound-web"; import { type MatrixClient } from "matrix-js-sdk"; +import { type WidgetApi } from "matrix-widget-api"; import { useClientState } from "./ClientContext"; import { widget } from "./widget"; -import { WidgetApi } from "matrix-widget-api"; export enum Size { XS = "xs", From fa74aaa81e663ccf355c9d5e3d8a09aaf3689788 Mon Sep 17 00:00:00 2001 From: JephDiel Date: Fri, 13 Mar 2026 11:33:07 -0500 Subject: [PATCH 101/119] Import order lint fixes Co-authored-by: Timo <16718859+toger5@users.noreply.github.com> --- src/Avatar.test.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Avatar.test.tsx b/src/Avatar.test.tsx index debff26a..672aff3b 100644 --- a/src/Avatar.test.tsx +++ b/src/Avatar.test.tsx @@ -14,7 +14,13 @@ import { ClientContextProvider } from "./ClientContext"; import { Avatar } from "./Avatar"; import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test"; import { widget } from "./widget"; -import { WidgetApi } from "matrix-widget-api"; +import { type FC, type PropsWithChildren } from "react"; +import { type WidgetApi } from "matrix-widget-api"; + +import { ClientContextProvider } from "./ClientContext"; +import { Avatar } from "./Avatar"; +import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test"; +import { widget } from "./widget"; const TestComponent: FC< PropsWithChildren<{ From 997cc9bb2717cf7254fbf31bf771f1af780d8f48 Mon Sep 17 00:00:00 2001 From: JephDiel Date: Fri, 13 Mar 2026 11:41:58 -0500 Subject: [PATCH 102/119] Lint fixes v2 --- src/Avatar.test.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Avatar.test.tsx b/src/Avatar.test.tsx index 672aff3b..25d2c42b 100644 --- a/src/Avatar.test.tsx +++ b/src/Avatar.test.tsx @@ -9,12 +9,6 @@ import { afterEach, expect, test, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { type MatrixClient } from "matrix-js-sdk"; import { type FC, type PropsWithChildren } from "react"; - -import { ClientContextProvider } from "./ClientContext"; -import { Avatar } from "./Avatar"; -import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test"; -import { widget } from "./widget"; -import { type FC, type PropsWithChildren } from "react"; import { type WidgetApi } from "matrix-widget-api"; import { ClientContextProvider } from "./ClientContext"; From 6d14f1d06ff007ce8a98f8e4e467fbb650da3796 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:48:44 +0000 Subject: [PATCH 103/119] Update GitHub Actions (#3804) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build-and-publish-docker.yaml | 2 +- .github/workflows/zizmor.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-publish-docker.yaml b/.github/workflows/build-and-publish-docker.yaml index 6447c094..3b18b133 100644 --- a/.github/workflows/build-and-publish-docker.yaml +++ b/.github/workflows/build-and-publish-docker.yaml @@ -44,7 +44,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Connect to Tailscale - uses: tailscale/github-action@53acf823325fe9ca47f4cdaa951f90b4b0de5bb9 # v4 + uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4 if: github.event_name != 'pull_request' with: oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 612adbd1..d3b6e969 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -20,4 +20,4 @@ jobs: persist-credentials: false - name: Run zizmor 🌈 - uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0 + uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 From 32ea8f522c0c753ee41e2a61f0ff9f0f960da92e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 01:47:49 +0000 Subject: [PATCH 104/119] Update Compound --- yarn.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/yarn.lock b/yarn.lock index 12e1b857..cbbbf32f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6236,8 +6236,8 @@ __metadata: linkType: hard "@vector-im/compound-design-tokens@npm:^6.0.0": - version: 6.6.0 - resolution: "@vector-im/compound-design-tokens@npm:6.6.0" + version: 6.10.2 + resolution: "@vector-im/compound-design-tokens@npm:6.10.2" peerDependencies: "@types/react": "*" react: ^17 || ^18 || ^19.0.0 @@ -6246,13 +6246,13 @@ __metadata: optional: true react: optional: true - checksum: 10c0/93b152dd1de96371f9b6b1f7dbcc381d7ab598031dbc900f52d610f015766c0d4426ae6e47d417e723bfb62d1a53099155b4d788848b78232916ba132c03c2fe + checksum: 10c0/bcac6d79fcfb8cc1356d65dff576bdad217edd0df189a5dea032b0fd57cef335b73ad6d8e395709245bc1c6a8c672a83144ecea48550ca560544d2399af8f2d3 languageName: node linkType: hard "@vector-im/compound-web@npm:^8.0.0": - version: 8.3.4 - resolution: "@vector-im/compound-web@npm:8.3.4" + version: 8.4.0 + resolution: "@vector-im/compound-web@npm:8.4.0" dependencies: "@floating-ui/react": "npm:^0.27.0" "@radix-ui/react-context-menu": "npm:^2.2.16" @@ -6272,7 +6272,7 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 10c0/44764fa64b5fce2e7181e25b50ee970eda4d921cf650b92bd5e88df0eb60872f3086b8702d18f55c3e39b3751ac19f10bafda8c4306df65c3605bd44b297d95c + checksum: 10c0/31b73555c47b373d4250872bfe863a030b487197bf1198e3cf3a1ec344f2b02f0c72c1513bb598c1cbd7a91d3c6a334d0c8ae37bd7c90d4859c864fc223e059a languageName: node linkType: hard From 9dfade68eed85e8a6df6c4ca1664b897fbdf1de7 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 16 Mar 2026 13:12:49 +0100 Subject: [PATCH 105/119] New ringing UI This implements the new ringing UI by showing a placeholder tile for the participant being dialed, rather than an overlay. --- locales/en/app.json | 2 + playwright/widget/voice-call-dm.spec.ts | 21 +- src/grid/OneOnOneLayout.tsx | 8 +- src/room/InCallView.tsx | 76 ++--- src/room/WaitingForJoin.module.css | 61 ---- .../CallNotificationLifecycle.ts | 11 +- src/state/CallViewModel/CallViewModel.test.ts | 174 +++++------ src/state/CallViewModel/CallViewModel.ts | 274 ++++++++++-------- .../CallViewModel/CallViewModelTestUtils.ts | 22 +- .../remoteMembers/MatrixMemberMetadata.ts | 25 -- src/state/OneOnOneLayout.ts | 8 +- src/state/TileStore.ts | 19 +- src/state/TileViewModel.ts | 7 +- src/state/layout-types.ts | 10 +- src/state/media/MediaViewModel.ts | 6 +- src/state/media/MemberMediaViewModel.ts | 13 +- src/state/media/RingingMediaViewModel.ts | 51 ++++ src/state/media/ScreenShareViewModel.ts | 4 +- src/state/media/UserMediaViewModel.ts | 4 +- ...iaItem.ts => WrappedUserMediaViewModel.ts} | 2 - src/tile/GridTile.test.tsx | 89 ++++-- src/tile/GridTile.tsx | 70 ++++- src/tile/MediaView.module.css | 28 +- src/tile/MediaView.test.tsx | 2 - src/tile/MediaView.tsx | 27 +- src/tile/SpotlightTile.test.tsx | 46 ++- src/tile/SpotlightTile.tsx | 121 ++++++-- 27 files changed, 703 insertions(+), 478 deletions(-) delete mode 100644 src/room/WaitingForJoin.module.css create mode 100644 src/state/media/RingingMediaViewModel.ts rename src/state/media/{MediaItem.ts => WrappedUserMediaViewModel.ts} (98%) diff --git a/locales/en/app.json b/locales/en/app.json index 9b1a5675..f5749cf7 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -249,6 +249,8 @@ "version": "{{productName}} version: {{version}}", "video_tile": { "always_show": "Always show", + "call_ended": "Call ended", + "calling": "Calling…", "camera_starting": "Video loading...", "collapse": "Collapse", "expand": "Expand", diff --git a/playwright/widget/voice-call-dm.spec.ts b/playwright/widget/voice-call-dm.spec.ts index 6a8473cf..cc0b4e53 100644 --- a/playwright/widget/voice-call-dm.spec.ts +++ b/playwright/widget/voice-call-dm.spec.ts @@ -34,9 +34,12 @@ widgetTest( .locator('iframe[title="Element Call"]') .contentFrame(); - // We should show a ringing overlay, let's check for that + // We should show a ringing tile, let's check for that await expect( - brooksFrame.getByText(`Waiting for ${whistler.displayName} to join…`), + brooksFrame + .getByTestId("videoTile") + .filter({ has: brooksFrame.getByText(whistler.displayName) }) + .filter({ has: brooksFrame.getByText("Calling…") }), ).toBeVisible(); await expect(whistler.page.getByText("Incoming voice call")).toBeVisible(); @@ -125,9 +128,12 @@ widgetTest( .locator('iframe[title="Element Call"]') .contentFrame(); - // We should show a ringing overlay, let's check for that + // We should show a ringing tile, let's check for that await expect( - brooksFrame.getByText(`Waiting for ${whistler.displayName} to join…`), + brooksFrame + .getByTestId("videoTile") + .filter({ has: brooksFrame.getByText(whistler.displayName) }) + .filter({ has: brooksFrame.getByText("Calling…") }), ).toBeVisible(); await expect(whistler.page.getByText("Incoming video call")).toBeVisible(); @@ -216,9 +222,12 @@ widgetTest( .locator('iframe[title="Element Call"]') .contentFrame(); - // We should show a ringing overlay, let's check for that + // We should show a ringing tile, let's check for that await expect( - brooksFrame.getByText(`Waiting for ${whistler.displayName} to join…`), + brooksFrame + .getByTestId("videoTile") + .filter({ has: brooksFrame.getByText(whistler.displayName) }) + .filter({ has: brooksFrame.getByText("Calling…") }), ).toBeVisible(); await expect(whistler.page.getByText("Incoming video call")).toBeVisible(); diff --git a/src/grid/OneOnOneLayout.tsx b/src/grid/OneOnOneLayout.tsx index 6c5ae69f..fd9c0a65 100644 --- a/src/grid/OneOnOneLayout.tsx +++ b/src/grid/OneOnOneLayout.tsx @@ -51,15 +51,15 @@ export const makeOneOnOneLayout: CallLayout = ({ return (
= ({ const { showControls } = useUrlParams(); const muteAllAudio = useBehavior(muteAllAudio$); - // Call pickup state and display names are needed for waiting overlay/sounds - const callPickupState = useBehavior(vm.callPickupState$); // Preload a waiting and decline sounds const pickupPhaseSoundCache = useInitial(async () => { @@ -239,6 +236,7 @@ export const InCallView: FC = ({ latencyHint: "interactive", muted: muteAllAudio, }); + const latestPickupPhaseAudio = useLatest(pickupPhaseAudio); const audioEnabled = useBehavior(muteStates.audio.enabled$); const videoEnabled = useBehavior(muteStates.video.enabled$); @@ -257,6 +255,7 @@ export const InCallView: FC = ({ () => void toggleRaisedHand(), ); + const ringing = useBehavior(vm.ringing$); const audioParticipants = useBehavior(vm.livekitRoomItems$); const participantCount = useBehavior(vm.participantCount$); const reconnecting = useBehavior(vm.reconnecting$); @@ -271,7 +270,6 @@ export const InCallView: FC = ({ const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); const sharingScreen = useBehavior(vm.sharingScreen$); - const ringOverlay = useBehavior(vm.ringOverlay$); const fatalCallError = useBehavior(vm.fatalError$); // Stop the rendering and throw for the error boundary if (fatalCallError) { @@ -279,58 +277,21 @@ export const InCallView: FC = ({ throw fatalCallError; } - // We need to set the proper timings on the animation based upon the sound length. - const ringDuration = pickupPhaseAudio?.soundDuration["waiting"] ?? 1; - useEffect((): (() => void) => { - // The CSS animation includes the delay, so we must double the length of the sound. - window.document.body.style.setProperty( - "--call-ring-duration-s", - `${ringDuration * 2}s`, - ); - window.document.body.style.setProperty( - "--call-ring-delay-s", - `${ringDuration}s`, - ); - // Remove properties when we unload. - return () => { - window.document.body.style.removeProperty("--call-ring-duration-s"); - window.document.body.style.removeProperty("--call-ring-delay-s"); - }; - }, [pickupPhaseAudio?.soundDuration, ringDuration]); - - // When waiting for pickup, loop a waiting sound + // While ringing, loop the ringtone useEffect((): void | (() => void) => { - if (callPickupState !== "ringing" || !pickupPhaseAudio) return; - const endSound = pickupPhaseAudio.playSoundLooping("waiting", ringDuration); - return () => { - void endSound().catch((e) => { - logger.error("Failed to stop ringing sound", e); - }); - }; - }, [callPickupState, pickupPhaseAudio, ringDuration]); - - // Waiting UI overlay - const waitingOverlay: JSX.Element | null = useMemo(() => { - return ringOverlay ? ( -
-
-
- -
- - {ringOverlay.text} - -
-
- ) : null; - }, [ringOverlay]); + const audio = latestPickupPhaseAudio.current; + if (ringing && audio) { + const endSound = audio.playSoundLooping( + "waiting", + audio.soundDuration["waiting"] ?? 1, + ); + return () => { + void endSound().catch((e) => { + logger.error("Failed to stop ringing sound", e); + }); + }; + } + }, [ringing, latestPickupPhaseAudio]); const onViewClick = useCallback( (e: ReactMouseEvent) => { @@ -764,7 +725,6 @@ export const InCallView: FC = ({ {reconnectingToast} {earpieceOverlay} - {waitingOverlay} {footer} {layout.type !== "pip" && ( <> diff --git a/src/room/WaitingForJoin.module.css b/src/room/WaitingForJoin.module.css deleted file mode 100644 index a598e482..00000000 --- a/src/room/WaitingForJoin.module.css +++ /dev/null @@ -1,61 +0,0 @@ -.overlay { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - pointer-events: none; -} - -.content { - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; -} - -.pulse { - position: relative; - height: 90px; -} - -.pulse::before { - content: ""; - position: absolute; - inset: -12px; - border-radius: 9999px; - border: 12px solid rgba(255, 255, 255, 0.6); - animation: pulse var(--call-ring-duration-s) ease-out infinite; - animation-delay: 1s; - opacity: 0; -} - -.text { - color: var(--cpd-color-text-on-solid-primary); -} - -@keyframes pulse { - 0% { - transform: scale(0.95); - opacity: 0.7; - transform: scale(0); - opacity: 1; - } - 35% { - transform: scale(1.15); - opacity: 0.15; - } - 50% { - transform: scale(1.2); - opacity: 0; - } - 50.01% { - transform: scale(0); - } - 85% { - transform: scale(0); - } - 100% { - transform: scale(0); - } -} diff --git a/src/state/CallViewModel/CallNotificationLifecycle.ts b/src/state/CallViewModel/CallNotificationLifecycle.ts index 44ce2e43..3e06108f 100644 --- a/src/state/CallViewModel/CallNotificationLifecycle.ts +++ b/src/state/CallViewModel/CallNotificationLifecycle.ts @@ -89,7 +89,6 @@ export interface Props { * `callPickupState$` The current call pickup state of the call. * - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership. * Then we can conclude if we were the first one to join or not. - * This may also be set if we are disconnected. * - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening). * - "timeout": No-one picked up in the defined time this call should be ringing on others devices. * The call failed. If desired this can be used as a trigger to exit the call. @@ -131,15 +130,9 @@ export function createCallNotificationLifecycle$({ ) as Behavior>; /** - * Whenever the RTC session tells us that it intends to ring the remote - * participant's devices, this emits an Observable tracking the current state of - * that ringing process. + * The state of the current ringing attempt, if the RTC session is indeed + * ringing the remote participant's devices. Otherwise `null`. */ - // This is a behavior since we need to store the latest state for when we subscribe to this after `didSendCallNotification$` - // has already emitted but we still need the latest observable with a timeout timer that only gets created on after receiving `notificationEvent`. - // A behavior will emit the latest observable with the running timer to new subscribers. - // see also: callPickupState$ and in particular the line: `return this.ring$.pipe(mergeAll());` here we otherwise might get an EMPTY observable if - // `ring$` would not be a behavior. const remoteRingState$: Behavior<"ringing" | "timeout" | "decline" | null> = scope.behavior( sentCallNotification$.pipe( diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index 5ee679b0..aca3ee7b 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -46,9 +46,11 @@ import { } from "../../utils/test.ts"; import { E2eeType } from "../../e2ee/e2eeType.ts"; import { + alice, aliceId, aliceParticipant, aliceRtcMember, + aliceUserId, bobId, bobRtcMember, local, @@ -140,8 +142,8 @@ export interface SpotlightExpandedLayoutSummary { export interface OneOnOneLayoutSummary { type: "one-on-one"; - local: string; - remote: string; + spotlight: string; + pip: string; } export interface PipLayoutSummary { @@ -194,11 +196,11 @@ function summarizeLayout$(l$: Observable): Observable { ); case "one-on-one": return combineLatest( - [l.local.media$, l.remote.media$], - (local, remote) => ({ + [l.spotlight.media$, l.pip.media$], + (spotlight, pip) => ({ type: l.type, - local: local.id, - remote: remote.id, + spotlight: spotlight.id, + pip: pip.id, }), ); case "pip": @@ -537,8 +539,8 @@ describe.each([ b: { // In a larger window, expect the normal one-on-one layout type: "one-on-one", - local: `${localId}:0`, - remote: `${aliceId}:0`, + pip: `${localId}:0`, + spotlight: `${aliceId}:0`, }, c: { // In a PiP-sized window, we of course expect a PiP layout @@ -840,8 +842,8 @@ describe.each([ }, b: { type: "one-on-one", - local: `${localId}:0`, - remote: `${aliceId}:0`, + pip: `${localId}:0`, + spotlight: `${aliceId}:0`, }, c: { type: "grid", @@ -883,8 +885,8 @@ describe.each([ }, b: { type: "one-on-one", - local: `${localId}:0`, - remote: `${aliceId}:0`, + pip: `${localId}:0`, + spotlight: `${aliceId}:0`, }, c: { type: "grid", @@ -893,8 +895,8 @@ describe.each([ }, d: { type: "one-on-one", - local: `${localId}:0`, - remote: `${daveId}:0`, + pip: `${localId}:0`, + spotlight: `${daveId}:0`, }, }, ); @@ -1087,83 +1089,81 @@ describe.each([ }); }); - describe("waitForCallPickup$", () => { - it.skip("regression test: does stop ringing in case livekitConnectionState$ emits after didSendCallNotification$ has already emitted", () => { - withTestScheduler(({ schedule, expectObservable, behavior }) => { - withCallViewModel( - { - livekitConnectionState$: behavior("d 9ms c", { - d: ConnectionState.Disconnected, - c: ConnectionState.Connected, - }), - }, - (vm, rtcSession) => { - // Fire a call notification IMMEDIATELY (its important for this test, that this happens before the livekitConnectionState$ emits) - schedule("n", { - n: () => { - rtcSession.emit( - MatrixRTCSessionEvent.DidSendCallNotification, - mockRingEvent("$notif1", 30), - ); - }, - }); + test("recipient has placeholder tile while ringing or timed out", () => { + withTestScheduler(({ schedule, expectObservable }) => { + withCallViewModel( + { + roomMembers: [alice, local], // Simulate a DM + }, + (vm, rtcSession) => { + // Fire a ringing notification + schedule("n", { + n: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + mockRingEvent("$notif1", 30), + ); + }, + }); - expectObservable(vm.callPickupState$).toBe("a 9ms b 19ms c", { - a: "unknown", - b: "ringing", - c: "timeout", - }); - }, - { - waitForCallPickup: true, - encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, - }, - ); - }); + // Should ring for 30ms and then time out + expectObservable(vm.ringing$).toBe("(ny) 26ms n", yesNo); + // Layout should show placeholder media for the participant we're + // ringing the entire time (even once timed out) + expectObservable(summarizeLayout$(vm.layout$)).toBe("a", { + a: { + type: "one-on-one", + spotlight: `${localId}:0`, + pip: `ringing:${aliceUserId}`, + }, + }); + }, + { waitForCallPickup: true }, + ); }); + }); - it.skip("ringing -> unknown if we get disconnected", () => { - withTestScheduler(({ behavior, schedule, expectObservable }) => { - const connectionState$ = new BehaviorSubject(ConnectionState.Connected); - // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) - withCallViewModel( - { - remoteParticipants$: behavior("a 19ms b", { - a: [], - b: [aliceParticipant], - }), - rtcMembers$: behavior("a 19ms b", { - a: [localRtcMember], - b: [localRtcMember, aliceRtcMember], - }), - livekitConnectionState$: connectionState$, - }, - (vm, rtcSession) => { - // Notify at 5ms so we enter ringing, then get disconnected 5ms later - schedule(" 5ms r 5ms d", { - r: () => { - rtcSession.emit( - MatrixRTCSessionEvent.DidSendCallNotification, - mockRingEvent("$notif2", 100), - ); - }, - d: () => { - connectionState$.next(ConnectionState.Disconnected); - }, - }); + test("recipient's placeholder tile is replaced by their real tile once they answer", () => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { + withCallViewModel( + { + // Alice answers after 20ms + rtcMembers$: behavior("a 20ms b", { + a: [localRtcMember], + b: [localRtcMember, aliceRtcMember], + }), + roomMembers: [alice, local], // Simulate a DM + }, + (vm, rtcSession) => { + // Fire a ringing notification + schedule("n", { + n: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + mockRingEvent("$notif1", 30), + ); + }, + }); - expectObservable(vm.callPickupState$).toBe("a 4ms b 5ms c", { - a: "unknown", - b: "ringing", - c: "unknown", - }); - }, - { - waitForCallPickup: true, - encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, - }, - ); - }); + // Should ring until Alice joins + expectObservable(vm.ringing$).toBe("(ny) 17ms n", yesNo); + // Layout should show placeholder media for the participant we're + // ringing the entire time + expectObservable(summarizeLayout$(vm.layout$)).toBe("a 20ms b", { + a: { + type: "one-on-one", + spotlight: `${localId}:0`, + pip: `ringing:${aliceUserId}`, + }, + b: { + type: "one-on-one", + spotlight: `${aliceId}:0`, + pip: `${localId}:0`, + }, + }); + }, + { waitForCallPickup: true }, + ); }); }); diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 18e49d0a..8b4d19fb 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -128,7 +128,6 @@ import { createSentCallNotification$, } from "./CallNotificationLifecycle.ts"; import { - createDMMember$, createMatrixMemberMetadata$, createRoomMembers$, } from "./remoteMembers/MatrixMemberMetadata.ts"; @@ -137,12 +136,17 @@ import { type Connection } from "./remoteMembers/Connection.ts"; import { createLayoutModeSwitch } from "./LayoutSwitch.ts"; import { createWrappedUserMedia, - type MediaItem, type WrappedUserMediaViewModel, -} from "../media/MediaItem.ts"; +} from "../media/WrappedUserMediaViewModel.ts"; import { type ScreenShareViewModel } from "../media/ScreenShareViewModel.ts"; import { type UserMediaViewModel } from "../media/UserMediaViewModel.ts"; import { type MediaViewModel } from "../media/MediaViewModel.ts"; +import { type LocalUserMediaViewModel } from "../media/LocalUserMediaViewModel.ts"; +import { type RemoteUserMediaViewModel } from "../media/RemoteUserMediaViewModel.ts"; +import { + createRingingMedia, + type RingingMediaViewModel, +} from "../media/RingingMediaViewModel.ts"; const logger = rootLogger.getChild("[CallViewModel]"); //TODO @@ -210,11 +214,10 @@ export type LivekitRoomItem = { export interface CallViewModel { // lifecycle autoLeave$: Observable; - // TODO if we are in "unknown" state we need a loading rendering (or empty screen) - // Otherwise it looks like we already connected and only than the ringing starts which is weird. - callPickupState$: Behavior< - "unknown" | "ringing" | "timeout" | "decline" | "success" | null - >; + /** + * Whether we are ringing a call recipient. + */ + ringing$: Behavior; /** Observable that emits when the user should leave the call (hangup pressed, widget action, error). * THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is * - by ending the scope @@ -289,13 +292,6 @@ export interface CallViewModel { /** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/ reactions$: Behavior>; - ringOverlay$: Behavior; // sounds and events joinSoundEffect$: Observable; leaveSoundEffect$: Observable; @@ -611,40 +607,6 @@ export function createCallViewModel$( matrixRoomMembers$, ); - const dmMember$ = createDMMember$(scope, matrixRoomMembers$, matrixRoom); - const noUserToCallInRoom$ = scope.behavior( - matrixRoomMembers$.pipe( - map( - (roomMembersMap) => - roomMembersMap.size === 1 && roomMembersMap.get(userId) !== undefined, - ), - ), - ); - - const ringOverlay$ = scope.behavior( - combineLatest([noUserToCallInRoom$, dmMember$, callPickupState$]).pipe( - map(([noUserToCallInRoom, dmMember, callPickupState]) => { - // No overlay if not in ringing state - if (callPickupState !== "ringing" || noUserToCallInRoom) return null; - - const name = dmMember ? dmMember.rawDisplayName : matrixRoom.name; - const id = dmMember ? dmMember.userId : matrixRoom.roomId; - const text = dmMember - ? `Waiting for ${name} to join…` - : "Waiting for other participants…"; - const avatarMxc = dmMember - ? (dmMember.getMxcAvatarUrl?.() ?? undefined) - : (matrixRoom.getMxcAvatarUrl() ?? undefined); - return { - name: name ?? id, - idForAvatar: id, - text, - avatarMxc, - }; - }), - ), - ); - const allConnections$ = scope.behavior( connectionManager.connectionManagerData$.pipe(map((d) => d.value)), ); @@ -720,7 +682,7 @@ export function createCallViewModel$( matrixLivekitMembers$, duplicateTiles.value$, ]).pipe( - // Generate a collection of MediaItems from the list of expected (whether + // Generate a collection of user media from the list of expected (whether // present or missing) LiveKit participants. generateItems( "CallViewModel userMedia$", @@ -793,32 +755,67 @@ export function createCallViewModel$( ), ); + const ringingMedia$ = scope.behavior( + combineLatest([userMedia$, matrixRoomMembers$, callPickupState$]).pipe( + generateItems( + "CallViewModel ringingMedia$", + function* ([userMedia, roomMembers, callPickupState]) { + if ( + callPickupState === "ringing" || + callPickupState === "timeout" || + callPickupState === "decline" + ) { + for (const member of roomMembers.values()) { + if (!userMedia.some((vm) => vm.userId === member.userId)) + yield { + keys: [member.userId], + data: callPickupState, + }; + } + } + }, + (scope, pickupState$, userId) => + createRingingMedia({ + id: `ringing:${userId}`, + userId, + displayName$: scope.behavior( + matrixRoomMembers$.pipe( + map((members) => members.get(userId)?.rawDisplayName || userId), + ), + ), + mxcAvatarUrl$: + matrixMemberMetadataStore.createAvatarUrlBehavior$(userId), + pickupState$, + muteStates, + }), + ), + distinctUntilChanged(shallowEquals), + tap((ringingMedia) => { + if (ringingMedia.length > 1) + // Warn that UI may do something unexpected in this case + logger.warn( + `Ringing more than one participant is not supported (ringing ${ringingMedia.map((vm) => vm.userId).join(", ")})`, + ); + }), + ), + ); + /** - * List of all media items (user media and screen share media) that we want - * tiles for. + * All screen share media that we want to display. */ - const mediaItems$ = scope.behavior( + const screenShares$ = scope.behavior( userMedia$.pipe( switchMap((userMedia) => userMedia.length === 0 ? of([]) : combineLatest( userMedia.map((m) => m.screenShares$), - (...screenShares) => [...userMedia, ...screenShares.flat(1)], + (...screenShares) => screenShares.flat(1), ), ), ), ); - /** - * List of MediaItems that we want to display, that are of type ScreenShare - */ - const screenShares$ = scope.behavior( - mediaItems$.pipe( - map((mediaItems) => mediaItems.filter((m) => m.type === "screen share")), - ), - ); - const joinSoundEffect$ = userMedia$.pipe( pairwise(), filter( @@ -931,40 +928,20 @@ export function createCallViewModel$( ), ); - const spotlight$ = scope.behavior( - screenShares$.pipe( - switchMap((screenShares) => { - if (screenShares.length > 0) return of(screenShares); - - return spotlightSpeaker$.pipe( - map((speaker) => (speaker ? [speaker] : [])), + /** + * Local user media suitable for displaying in a PiP (undefined if not found + * or if user prefers to not see themselves). + */ + const localUserMediaForPip$ = scope.behavior< + LocalUserMediaViewModel | undefined + >( + userMedia$.pipe( + switchMap((userMedia) => { + const localUserMedia = userMedia.find( + (m): m is WrappedUserMediaViewModel & LocalUserMediaViewModel => + m.type === "user" && m.local, ); - }), - distinctUntilChanged(shallowEquals), - ), - ); - - const pip$ = scope.behavior( - combineLatest([ - // TODO This also needs epoch logic to dedupe the screenshares and mediaItems emits - screenShares$, - spotlightSpeaker$, - mediaItems$, - ]).pipe( - switchMap(([screenShares, spotlight, mediaItems]) => { - if (screenShares.length > 0) { - return spotlightSpeaker$; - } - if (!spotlight || spotlight.local) { - return of(undefined); - } - - const localUserMedia = mediaItems.find( - (m) => m.type === "user" && m.local, - ); - if (!localUserMedia) { - return of(undefined); - } + if (!localUserMedia) return of(undefined); return localUserMedia.alwaysShow$.pipe( map((alwaysShow) => (alwaysShow ? localUserMedia : undefined)), ); @@ -972,6 +949,39 @@ export function createCallViewModel$( ), ); + const spotlightAndPip$ = scope.behavior<{ + spotlight: MediaViewModel[]; + pip$: Behavior; + }>( + ringingMedia$.pipe( + switchMap((ringingMedia) => { + if (ringingMedia.length > 0) + 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] : [], + pip$: localUserMediaForPip$, + })), + ); + }), + ); + }), + ), + ); + + const spotlight$ = scope.behavior( + spotlightAndPip$.pipe( + map(({ spotlight }) => spotlight), + distinctUntilChanged(shallowEquals), + ), + ); + const hasRemoteScreenShares$ = scope.behavior( spotlight$.pipe( map((spotlight) => @@ -1054,24 +1064,61 @@ export function createCallViewModel$( })); const spotlightExpandedLayoutMedia$: Observable = - combineLatest([spotlight$, pip$], (spotlight, pip) => ({ - type: "spotlight-expanded", - spotlight, - pip: pip ?? undefined, - })); + spotlightAndPip$.pipe( + switchMap(({ spotlight, pip$ }) => + pip$.pipe( + map((pip) => ({ + type: "spotlight-expanded" as const, + spotlight, + pip: pip ?? undefined, + })), + ), + ), + ); const oneOnOneLayoutMedia$: Observable = - mediaItems$.pipe( - map((mediaItems) => { - if (mediaItems.length !== 2) return null; - const local = mediaItems.find((vm) => vm.type === "user" && vm.local); - const remote = mediaItems.find((vm) => vm.type === "user" && !vm.local); - // There might not be a remote tile if there are screen shares, or if - // only the local user is in the call and they're using the duplicate - // tiles option - if (!remote || !local) return null; + userMedia$.pipe( + switchMap((userMedia) => { + if (userMedia.length <= 2) { + const local = userMedia.find( + (vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel => + vm.type === "user" && vm.local, + ); - return { type: "one-on-one", local, remote }; + if (local !== undefined) { + const remote = userMedia.find( + ( + vm, + ): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel => + vm.type === "user" && !vm.local, + ); + + if (remote !== undefined) + return of({ + type: "one-on-one" as const, + spotlight: remote, + pip: local, + }); + + // If there's no other user media in the call (could still happen in + // this branch due to the duplicate tiles option), we could possibly + // show ringing media instead + if (userMedia.length === 1) + return ringingMedia$.pipe( + map((ringingMedia) => { + return ringingMedia.length === 1 + ? { + type: "one-on-one" as const, + spotlight: local, + pip: ringingMedia[0], + } + : null; + }), + ); + } + } + + return of(null); }), ); @@ -1482,8 +1529,9 @@ export function createCallViewModel$( return { autoLeave$: autoLeave$, - callPickupState$: callPickupState$, - ringOverlay$: ringOverlay$, + ringing$: scope.behavior( + callPickupState$.pipe(map((state) => state === "ringing")), + ), leave$: leave$, hangup: (): void => userHangup$.next(), join: localMembership.requestJoinAndPublish, diff --git a/src/state/CallViewModel/CallViewModelTestUtils.ts b/src/state/CallViewModel/CallViewModelTestUtils.ts index b6f53275..b6bf8a9a 100644 --- a/src/state/CallViewModel/CallViewModelTestUtils.ts +++ b/src/state/CallViewModel/CallViewModelTestUtils.ts @@ -17,7 +17,7 @@ import { import { SyncState } from "matrix-js-sdk/lib/sync"; import { BehaviorSubject, type Observable, map, of } from "rxjs"; import { onTestFinished, vi } from "vitest"; -import { ClientEvent, type MatrixClient } from "matrix-js-sdk"; +import { ClientEvent, type RoomMember, type MatrixClient } from "matrix-js-sdk"; import EventEmitter from "events"; import * as ComponentsCore from "@livekit/components-core"; @@ -63,15 +63,10 @@ const carol = local; const dave = mockMatrixRoomMember(daveRTLRtcMember, { rawDisplayName: "Dave" }); -const roomMembers = new Map( - [alice, aliceDoppelganger, bob, bobZeroWidthSpace, carol, dave, daveRTL].map( - (p) => [p.userId, p], - ), -); - export interface CallViewModelInputs { remoteParticipants$: Behavior; rtcMembers$: Behavior[]>; + roomMembers: RoomMember[]; livekitConnectionState$: Behavior; speaking: Map>; mediaDevices: MediaDevices; @@ -86,6 +81,15 @@ export function withCallViewModel(mode: MatrixRTCMode) { { remoteParticipants$ = constant([]), rtcMembers$ = constant([localRtcMember]), + roomMembers = [ + alice, + aliceDoppelganger, + bob, + bobZeroWidthSpace, + carol, + dave, + daveRTL, + ], livekitConnectionState$: connectionState$ = constant( ConnectionState.Connected, ), @@ -128,8 +132,8 @@ export function withCallViewModel(mode: MatrixRTCMode) { return syncState; } })() as Partial as MatrixClient, - getMembers: () => Array.from(roomMembers.values()), - getMembersWithMembership: () => Array.from(roomMembers.values()), + getMembers: () => roomMembers, + getMembersWithMembership: () => roomMembers, }); const rtcSession = new MockRTCSession(room, []).withMemberships( rtcMembers$, diff --git a/src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts b/src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts index c1a7a499..d9be2d35 100644 --- a/src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts +++ b/src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts @@ -54,31 +54,6 @@ export function createRoomMembers$( ); } -/** - * creates the member that this DM is with in case it is a DM (two members) otherwise null - */ -export function createDMMember$( - scope: ObservableScope, - roomMembers$: Behavior, - matrixRoom: MatrixRoom, -): Behavior | null> { - // We cannot use the normal direct check from matrix since we do not have access to the account data. - // use primitive member count === 2 check instead. - return scope.behavior( - roomMembers$.pipe( - map((membersMap) => { - // primitive appraoch do to no access to account data. - const isDM = membersMap.size === 2; - if (!isDM) return null; - return matrixRoom.getMember(matrixRoom.guessDMUserId()); - }), - ), - ); -} - /** * Displayname for each member of the call. This will disambiguate * any displayname that clashes with another member. Only members diff --git a/src/state/OneOnOneLayout.ts b/src/state/OneOnOneLayout.ts index 10268945..27fa4439 100644 --- a/src/state/OneOnOneLayout.ts +++ b/src/state/OneOnOneLayout.ts @@ -16,14 +16,14 @@ export function oneOnOneLayout( prevTiles: TileStore, ): [OneOnOneLayout, TileStore] { const update = prevTiles.from(2); - update.registerGridTile(media.local); - update.registerGridTile(media.remote); + update.registerGridTile(media.pip); + update.registerGridTile(media.spotlight); const tiles = update.build(); return [ { type: media.type, - local: tiles.gridTilesByMedia.get(media.local)!, - remote: tiles.gridTilesByMedia.get(media.remote)!, + spotlight: tiles.gridTilesByMedia.get(media.spotlight)!, + pip: tiles.gridTilesByMedia.get(media.pip)!, }, tiles, ]; diff --git a/src/state/TileStore.ts b/src/state/TileStore.ts index a954eb4e..300e6bd2 100644 --- a/src/state/TileStore.ts +++ b/src/state/TileStore.ts @@ -13,6 +13,7 @@ import { fillGaps } from "../utils/iter"; import { debugTileLayout } from "../settings/settings"; import { type MediaViewModel } from "./media/MediaViewModel"; import { type UserMediaViewModel } from "./media/UserMediaViewModel"; +import { type RingingMediaViewModel } from "./media/RingingMediaViewModel"; function debugEntries(entries: GridTileData[]): string[] { return entries.map((e) => e.media.displayName$.value); @@ -48,8 +49,10 @@ class SpotlightTileData { } class GridTileData { - private readonly media$: BehaviorSubject; - public get media(): UserMediaViewModel { + private readonly media$: BehaviorSubject< + UserMediaViewModel | RingingMediaViewModel + >; + public get media(): UserMediaViewModel | RingingMediaViewModel { return this.media$.value; } public set media(value: UserMediaViewModel) { @@ -58,7 +61,7 @@ class GridTileData { public readonly vm: GridTileViewModel; - public constructor(media: UserMediaViewModel) { + public constructor(media: UserMediaViewModel | RingingMediaViewModel) { this.media$ = new BehaviorSubject(media); this.vm = new GridTileViewModel(this.media$); } @@ -178,7 +181,9 @@ export class TileStoreBuilder { * Sets up a grid tile for the given media. If this is never called for some * media, then that media will have no grid tile. */ - public registerGridTile(media: UserMediaViewModel): void { + public registerGridTile( + media: UserMediaViewModel | RingingMediaViewModel, + ): void { if (DEBUG_ENABLED) logger.debug( `[TileStore, ${this.generation}] register grid tile: ${media.displayName$.value}`, @@ -187,7 +192,11 @@ export class TileStoreBuilder { if (this.spotlight !== null) { // We actually *don't* want spotlight speakers to appear in both the // spotlight and the grid, so they're filtered out here - if (!media.local && this.spotlight.media.includes(media)) return; + if ( + !(media.type === "user" && media.local) && + this.spotlight.media.includes(media) + ) + return; // When the spotlight speaker changes, we would see one grid tile appear // and another grid tile disappear. This would be an undesirable layout // shift, so instead what we do is take the speaker's grid tile and swap diff --git a/src/state/TileViewModel.ts b/src/state/TileViewModel.ts index 8b13c685..eeec0c88 100644 --- a/src/state/TileViewModel.ts +++ b/src/state/TileViewModel.ts @@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details. import { type Behavior } from "./Behavior"; import { type MediaViewModel } from "./media/MediaViewModel"; +import { type RingingMediaViewModel } from "./media/RingingMediaViewModel"; import { type UserMediaViewModel } from "./media/UserMediaViewModel"; let nextId = 0; @@ -17,7 +18,11 @@ function createId(): string { export class GridTileViewModel { public readonly id = createId(); - public constructor(public readonly media$: Behavior) {} + public constructor( + public readonly media$: Behavior< + UserMediaViewModel | RingingMediaViewModel + >, + ) {} } export class SpotlightTileViewModel { diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index 33796f66..2e779057 100644 --- a/src/state/layout-types.ts +++ b/src/state/layout-types.ts @@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details. import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts"; import { type MediaViewModel } from "./media/MediaViewModel.ts"; -import { type RemoteUserMediaViewModel } from "./media/RemoteUserMediaViewModel.ts"; +import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts"; import { type UserMediaViewModel } from "./media/UserMediaViewModel.ts"; import { type GridTileViewModel, @@ -40,8 +40,8 @@ export interface SpotlightExpandedLayoutMedia { export interface OneOnOneLayoutMedia { type: "one-on-one"; - local: LocalUserMediaViewModel; - remote: RemoteUserMediaViewModel; + spotlight: UserMediaViewModel; + pip: LocalUserMediaViewModel | RingingMediaViewModel; } export interface PipLayoutMedia { @@ -86,8 +86,8 @@ export interface SpotlightExpandedLayout { export interface OneOnOneLayout { type: "one-on-one"; - local: GridTileViewModel; - remote: GridTileViewModel; + spotlight: GridTileViewModel; + pip: GridTileViewModel; } export interface PipLayout { diff --git a/src/state/media/MediaViewModel.ts b/src/state/media/MediaViewModel.ts index bdc4875b..9a253d81 100644 --- a/src/state/media/MediaViewModel.ts +++ b/src/state/media/MediaViewModel.ts @@ -7,13 +7,17 @@ Please see LICENSE in the repository root for full details. */ import { type Behavior } from "../Behavior"; +import { type RingingMediaViewModel } from "./RingingMediaViewModel"; import { type ScreenShareViewModel } from "./ScreenShareViewModel"; import { type UserMediaViewModel } from "./UserMediaViewModel"; /** * A participant's media. */ -export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel; +export type MediaViewModel = + | UserMediaViewModel + | ScreenShareViewModel + | RingingMediaViewModel; /** * Properties which are common to all MediaViewModels. diff --git a/src/state/media/MemberMediaViewModel.ts b/src/state/media/MemberMediaViewModel.ts index e7f57b59..969da899 100644 --- a/src/state/media/MemberMediaViewModel.ts +++ b/src/state/media/MemberMediaViewModel.ts @@ -38,6 +38,8 @@ import { type ObservableScope } from "../ObservableScope"; import { observeTrackReference$ } from "../observeTrackReference"; import { E2eeType } from "../../e2ee/e2eeType"; import { observeInboundRtpStreamStats$ } from "./observeRtpStreamStats"; +import { type UserMediaViewModel } from "./UserMediaViewModel"; +import { type ScreenShareViewModel } from "./ScreenShareViewModel"; // TODO: Encryption status is kinda broken and thus unused right now. Remove? export enum EncryptionStatus { @@ -49,9 +51,9 @@ export enum EncryptionStatus { } /** - * Media belonging to an active member of the RTC session. + * Properties common to all MemberMediaViewModels. */ -export interface MemberMediaViewModel extends BaseMediaViewModel { +export interface BaseMemberMediaViewModel extends BaseMediaViewModel { /** * The LiveKit video track for this media. */ @@ -88,7 +90,7 @@ export function createMemberMedia( encryptionSystem, ...inputs }: MemberMediaInputs, -): MemberMediaViewModel { +): BaseMemberMediaViewModel { const trackBehavior$ = ( source: Track.Source, ): Behavior => @@ -270,3 +272,8 @@ function observeRemoteTrackReceivingOkay$( startWith(undefined), ); } + +/** + * Media belonging to an active member of the call. + */ +export type MemberMediaViewModel = UserMediaViewModel | ScreenShareViewModel; diff --git a/src/state/media/RingingMediaViewModel.ts b/src/state/media/RingingMediaViewModel.ts new file mode 100644 index 00000000..23291723 --- /dev/null +++ b/src/state/media/RingingMediaViewModel.ts @@ -0,0 +1,51 @@ +/* +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 Behavior } from "../Behavior"; +import { type MuteStates } from "../MuteStates"; +import { + type BaseMediaInputs, + type BaseMediaViewModel, + createBaseMedia, +} from "./MediaViewModel"; + +/** + * Media representing a user who is not yet part of the call — one that we are + * *ringing*. + */ +export interface RingingMediaViewModel extends BaseMediaViewModel { + type: "ringing"; + pickupState$: Behavior<"ringing" | "timeout" | "decline">; + /** + * Whether this media would be expected to have video, were it not simply a + * placeholder. + */ + videoEnabled$: Behavior; +} + +export interface RingingMediaInputs extends BaseMediaInputs { + pickupState$: Behavior<"ringing" | "timeout" | "decline">; + /** + * The local user's own mute states. + */ + muteStates: MuteStates; +} + +export function createRingingMedia({ + pickupState$, + muteStates, + ...inputs +}: RingingMediaInputs): RingingMediaViewModel { + return { + ...createBaseMedia(inputs), + type: "ringing", + pickupState$, + // If our own video is enabled, then this is a video call and we would + // expect remote media to have video as well + videoEnabled$: muteStates.video.enabled$, + }; +} diff --git a/src/state/media/ScreenShareViewModel.ts b/src/state/media/ScreenShareViewModel.ts index 36cd9440..8336f0a6 100644 --- a/src/state/media/ScreenShareViewModel.ts +++ b/src/state/media/ScreenShareViewModel.ts @@ -13,7 +13,7 @@ import { type LocalScreenShareViewModel } from "./LocalScreenShareViewModel"; import { createMemberMedia, type MemberMediaInputs, - type MemberMediaViewModel, + type BaseMemberMediaViewModel, } from "./MemberMediaViewModel"; import { type RemoteScreenShareViewModel } from "./RemoteScreenShareViewModel"; @@ -27,7 +27,7 @@ export type ScreenShareViewModel = /** * Properties which are common to all ScreenShareViewModels. */ -export interface BaseScreenShareViewModel extends MemberMediaViewModel { +export interface BaseScreenShareViewModel extends BaseMemberMediaViewModel { type: "screen share"; } diff --git a/src/state/media/UserMediaViewModel.ts b/src/state/media/UserMediaViewModel.ts index 16af7f26..a20c489e 100644 --- a/src/state/media/UserMediaViewModel.ts +++ b/src/state/media/UserMediaViewModel.ts @@ -27,7 +27,7 @@ import { type LocalUserMediaViewModel } from "./LocalUserMediaViewModel"; import { createMemberMedia, type MemberMediaInputs, - type MemberMediaViewModel, + type BaseMemberMediaViewModel, } from "./MemberMediaViewModel"; import { type RemoteUserMediaViewModel } from "./RemoteUserMediaViewModel"; import { type ObservableScope } from "../ObservableScope"; @@ -42,7 +42,7 @@ export type UserMediaViewModel = | LocalUserMediaViewModel | RemoteUserMediaViewModel; -export interface BaseUserMediaViewModel extends MemberMediaViewModel { +export interface BaseUserMediaViewModel extends BaseMemberMediaViewModel { type: "user"; speaking$: Behavior; audioEnabled$: Behavior; diff --git a/src/state/media/MediaItem.ts b/src/state/media/WrappedUserMediaViewModel.ts similarity index 98% rename from src/state/media/MediaItem.ts rename to src/state/media/WrappedUserMediaViewModel.ts index 6cd80045..e9575d0c 100644 --- a/src/state/media/MediaItem.ts +++ b/src/state/media/WrappedUserMediaViewModel.ts @@ -194,5 +194,3 @@ export function createWrappedUserMedia( ), }; } - -export type MediaItem = WrappedUserMediaViewModel | ScreenShareViewModel; diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index 02f09a17..501f440c 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -7,9 +7,10 @@ Please see LICENSE in the repository root for full details. import { type RemoteTrackPublication } from "livekit-client"; import { test, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import { axe } from "vitest-axe"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; +import { BehaviorSubject } from "rxjs"; import { GridTile } from "./GridTile"; import { @@ -21,6 +22,11 @@ import { GridTileViewModel } from "../state/TileViewModel"; import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; import type { CallViewModel } from "../state/CallViewModel/CallViewModel"; import { constant } from "../state/Behavior"; +import { + createRingingMedia, + type RingingMediaViewModel, +} from "../state/media/RingingMediaViewModel"; +import { type MuteStates } from "../state/MuteStates"; global.IntersectionObserver = class MockIntersectionObserver { public observe(): void {} @@ -28,6 +34,27 @@ global.IntersectionObserver = class MockIntersectionObserver { public disconnect(): void {} } as unknown as typeof IntersectionObserver; +const fakeRtcSession = { + on: () => {}, + off: () => {}, + room: { + on: () => {}, + off: () => {}, + client: { + getUserId: () => null, + getDeviceId: () => null, + on: () => {}, + off: () => {}, + }, + }, + memberships: [], +} as unknown as MatrixRTCSession; + +const callVm = { + reactions$: constant({}), + handsRaised$: constant({}), +} as Partial as CallViewModel; + test("GridTile is accessible", async () => { const vm = mockRemoteMedia( mockRtcMembership("@alice:example.org", "AAAA"), @@ -42,34 +69,15 @@ test("GridTile is accessible", async () => { }), ); - const fakeRtcSession = { - on: () => {}, - off: () => {}, - room: { - on: () => {}, - off: () => {}, - client: { - getUserId: () => null, - getDeviceId: () => null, - on: () => {}, - off: () => {}, - }, - }, - memberships: [], - } as unknown as MatrixRTCSession; - const cVm = { - reactions$: constant({}), - handsRaised$: constant({}), - } as Partial as CallViewModel; const { container } = render( - + {}} targetWidth={300} targetHeight={200} showSpeakingIndicators - focusable={true} + focusable /> , ); @@ -77,3 +85,40 @@ test("GridTile is accessible", async () => { // Name should be visible screen.getByText("Alice"); }); + +test("GridTile displays ringing media", async () => { + const pickupState$ = new BehaviorSubject< + RingingMediaViewModel["pickupState$"]["value"] + >("ringing"); + const vm = createRingingMedia({ + pickupState$, + muteStates: { + video: { enabled$: constant(false) }, + } as unknown as MuteStates, + id: "test", + userId: "@alice:example.org", + displayName$: constant("Alice"), + mxcAvatarUrl$: constant(undefined), + }); + + const { container } = render( + + {}} + targetWidth={300} + targetHeight={200} + showSpeakingIndicators + focusable + /> + , + ); + expect(await axe(container)).toHaveNoViolations(); + // Name and status should be visible + screen.getByText("Alice"); + screen.getByText("Calling…"); + + // Alice declines the call + act(() => pickupState$.next("decline")); + screen.getByText("Call ended"); +}); diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index c8052a65..13cf677f 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -29,6 +29,9 @@ import { UserProfileIcon, VolumeOffSolidIcon, SwitchCameraSolidIcon, + VideoCallSolidIcon, + VoiceCallSolidIcon, + EndCallIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { ContextMenu, @@ -49,6 +52,7 @@ import { useBehavior } from "../useBehavior"; import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewModel"; import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel"; import { type UserMediaViewModel } from "../state/media/UserMediaViewModel"; +import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel"; interface TileProps { ref?: Ref; @@ -56,21 +60,56 @@ interface TileProps { style?: ComponentProps["style"]; targetWidth: number; targetHeight: number; - focusUrl: string | undefined; displayName: string; mxcAvatarUrl: string | undefined; - showSpeakingIndicators: boolean; focusable: boolean; } +interface RingingMediaTileProps extends TileProps { + vm: RingingMediaViewModel; +} + +const RingingMediaTile: FC = ({ + vm, + className, + ...props +}) => { + const { t } = useTranslation(); + const pickupState = useBehavior(vm.pickupState$); + const videoEnabled = useBehavior(vm.videoEnabled$); + + return ( + + ); +}; + interface UserMediaTileProps extends TileProps { vm: UserMediaViewModel; + showSpeakingIndicators: boolean; mirror: boolean; playbackMuted: boolean; waitingForMedia?: boolean; primaryButton?: ReactNode; menuStart?: ReactNode; menuEnd?: ReactNode; + focusUrl: string | undefined; } const UserMediaTile: FC = ({ @@ -95,7 +134,6 @@ const UserMediaTile: FC = ({ const { t } = useTranslation(); const video = useBehavior(vm.video$); const unencryptedWarning = useBehavior(vm.unencryptedWarning$); - const encryptionStatus = useBehavior(vm.encryptionStatus$); const audioStreamStats = useObservableEagerState< RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined >(vm.audioStreamStats$); @@ -153,7 +191,6 @@ const UserMediaTile: FC = ({ video={video} userId={vm.userId} unencryptedWarning={unencryptedWarning} - encryptionStatus={encryptionStatus} videoEnabled={videoEnabled} videoFit={videoFit} className={classNames(className, styles.tile, { @@ -218,6 +255,7 @@ UserMediaTile.displayName = "UserMediaTile"; interface LocalUserMediaTileProps extends TileProps { vm: LocalUserMediaViewModel; + showSpeakingIndicators: boolean; onOpenProfile: (() => void) | null; } @@ -232,6 +270,7 @@ const LocalUserMediaTile: FC = ({ const mirror = useBehavior(vm.mirror$); const alwaysShow = useBehavior(vm.alwaysShow$); const switchCamera = useBehavior(vm.switchCamera$); + const focusUrl = useBehavior(vm.focusUrl$); const latestAlwaysShow = useLatest(alwaysShow); const onSelectAlwaysShow = useCallback( @@ -278,6 +317,7 @@ const LocalUserMediaTile: FC = ({ ) } focusable={focusable} + focusUrl={focusUrl} {...props} /> ); @@ -287,6 +327,7 @@ LocalUserMediaTile.displayName = "LocalUserMediaTile"; interface RemoteUserMediaTileProps extends TileProps { vm: RemoteUserMediaViewModel; + showSpeakingIndicators: boolean; } const RemoteUserMediaTile: FC = ({ @@ -298,6 +339,8 @@ const RemoteUserMediaTile: FC = ({ const waitingForMedia = useBehavior(vm.waitingForMedia$); const playbackMuted = useBehavior(vm.playbackMuted$); const playbackVolume = useBehavior(vm.playbackVolume$); + const focusUrl = useBehavior(vm.focusUrl$); + const onSelectMute = useCallback( (e: Event) => { e.preventDefault(); @@ -338,6 +381,7 @@ const RemoteUserMediaTile: FC = ({ } + focusUrl={focusUrl} {...props} /> ); @@ -360,23 +404,33 @@ interface GridTileProps { export const GridTile: FC = ({ ref: theirRef, vm, + showSpeakingIndicators, onOpenProfile, ...props }) => { const ourRef = useRef(null); const ref = useMergedRefs(ourRef, theirRef); const media = useBehavior(vm.media$); - const focusUrl = useBehavior(media.focusUrl$); const displayName = useBehavior(media.displayName$); const mxcAvatarUrl = useBehavior(media.mxcAvatarUrl$); - if (media.local) { + if (media.type === "ringing") { + return ( + + ); + } else if (media.local) { return ( = ({ 0) { @@ -71,14 +76,15 @@ unconditionally select the container so we can use cqmin units */ .fg { position: absolute; - inset: var( + --fg-inset: var( --media-view-fg-inset, calc(var(--media-view-border-radius) - var(--cpd-space-3x)) ); + inset: var(--fg-inset); display: grid; grid-template-columns: 30px 1fr 30px; grid-template-rows: 1fr auto; - grid-template-areas: "reactions status ." "nameTag nameTag button"; + grid-template-areas: "status status reactions" "nameTag nameTag button"; gap: var(--cpd-space-1x); place-items: start; } @@ -102,21 +108,19 @@ unconditionally select the container so we can use cqmin units */ .status { grid-area: status; - justify-self: center; - align-self: start; - padding: var(--cpd-space-2x); - padding-block: var(--cpd-space-2x); color: var(--cpd-color-text-primary); - background-color: var(--cpd-color-bg-canvas-default); display: flex; + flex-wrap: none; align-items: center; - border-radius: var(--cpd-radius-pill-effect); + gap: 3px; user-select: none; overflow: hidden; - box-shadow: var(--small-drop-shadow); - box-sizing: border-box; - max-inline-size: 100%; - text-align: center; + margin-block-start: calc(var(--cpd-space-3x) - var(--fg-inset)); + margin-inline-start: calc(var(--cpd-space-4x) - var(--fg-inset)); +} + +.status svg { + color: var(--cpd-color-icon-tertiary); } .reactions { diff --git a/src/tile/MediaView.test.tsx b/src/tile/MediaView.test.tsx index a509d3a5..6ef5eb7e 100644 --- a/src/tile/MediaView.test.tsx +++ b/src/tile/MediaView.test.tsx @@ -18,7 +18,6 @@ import { TrackInfo } from "@livekit/protocol"; import { type ComponentProps } from "react"; import { MediaView } from "./MediaView"; -import { EncryptionStatus } from "../state/media/MemberMediaViewModel"; import { mockLocalParticipant } from "../utils/test"; describe("MediaView", () => { @@ -41,7 +40,6 @@ describe("MediaView", () => { videoFit: "contain", targetWidth: 300, targetHeight: 200, - encryptionStatus: EncryptionStatus.Connecting, mirror: false, unencryptedWarning: false, video: trackReference, diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index f912c069..eb6cc6b4 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -7,7 +7,13 @@ Please see LICENSE in the repository root for full details. import { type TrackReferenceOrPlaceholder } from "@livekit/components-core"; import { animated } from "@react-spring/web"; -import { type FC, type ComponentProps, type ReactNode } from "react"; +import { + type FC, + type ComponentProps, + type ReactNode, + type ComponentType, + type SVGAttributes, +} from "react"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; import { VideoTrack } from "@livekit/components-react"; @@ -16,7 +22,6 @@ import { ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/ico import styles from "./MediaView.module.css"; import { Avatar } from "../Avatar"; -import { type EncryptionStatus } from "../state/media/MemberMediaViewModel"; import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator"; import { showConnectionStats as showConnectionStatsSetting, @@ -38,7 +43,7 @@ interface Props extends ComponentProps { userId: string; videoEnabled: boolean; unencryptedWarning: boolean; - encryptionStatus: EncryptionStatus; + status?: { text: string; Icon: ComponentType> }; nameTagLeadingIcon?: ReactNode; displayName: string; mxcAvatarUrl: string | undefined; @@ -72,7 +77,7 @@ export const MediaView: FC = ({ mxcAvatarUrl, focusable, primaryButton, - encryptionStatus, + status, raisedHandTime, currentReaction, raisedHandOnClick, @@ -106,7 +111,11 @@ export const MediaView: FC = ({ name={displayName} size={avatarSize} src={mxcAvatarUrl} - className={styles.avatar} + className={classNames(styles.avatar, { + // When the avatar is overlaid with a status, make it translucent + // for readability + [styles.translucent]: status, + })} style={{ display: video && videoEnabled ? "none" : "initial" }} /> {video?.publication !== undefined && ( @@ -152,6 +161,14 @@ export const MediaView: FC = ({ /> )} + {status && ( +
+ + + {status.text} + +
+ )} {/* TODO: Bring this back once encryption status is less broken */} {/*encryptionStatus !== EncryptionStatus.Okay && (
diff --git a/src/tile/SpotlightTile.test.tsx b/src/tile/SpotlightTile.test.tsx index aac81b9c..533c3b2f 100644 --- a/src/tile/SpotlightTile.test.tsx +++ b/src/tile/SpotlightTile.test.tsx @@ -6,10 +6,11 @@ Please see LICENSE in the repository root for full details. */ import { test, expect, vi } from "vitest"; -import { isInaccessible, render, screen } from "@testing-library/react"; +import { act, isInaccessible, render, screen } from "@testing-library/react"; import { axe } from "vitest-axe"; import userEvent from "@testing-library/user-event"; import { TooltipProvider } from "@vector-im/compound-web"; +import { BehaviorSubject } from "rxjs"; import { SpotlightTile } from "./SpotlightTile"; import { @@ -23,6 +24,11 @@ import { } from "../utils/test"; import { SpotlightTileViewModel } from "../state/TileViewModel"; import { constant } from "../state/Behavior"; +import { + createRingingMedia, + type RingingMediaViewModel, +} from "../state/media/RingingMediaViewModel"; +import { type MuteStates } from "../state/MuteStates"; global.IntersectionObserver = class MockIntersectionObserver { public observe(): void {} @@ -140,3 +146,41 @@ test("Screen share volume UI is hidden when screen share has no audio", async () screen.queryByRole("button", { name: /volume/i }), ).not.toBeInTheDocument(); }); + +test("SpotlightTile displays ringing media", async () => { + const pickupState$ = new BehaviorSubject< + RingingMediaViewModel["pickupState$"]["value"] + >("ringing"); + const vm = createRingingMedia({ + pickupState$, + muteStates: { + video: { enabled$: constant(false) }, + } as unknown as MuteStates, + id: "test", + userId: "@alice:example.org", + displayName$: constant("Alice"), + mxcAvatarUrl$: constant(undefined), + }); + + const toggleExpanded = vi.fn(); + const { container } = render( + , + ); + + expect(await axe(container)).toHaveNoViolations(); + // Alice should be in the spotlight with the right status + screen.getByText("Alice"); + screen.getByText("Calling…"); + + // Now we time out ringing to Alice + act(() => pickupState$.next("timeout")); + screen.getByText("Call ended"); +}); diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index aa66d6b6..c5faba40 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -24,6 +24,9 @@ import { VolumeOnIcon, VolumeOffSolidIcon, VolumeOnSolidIcon, + VideoCallSolidIcon, + VoiceCallSolidIcon, + EndCallIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { animated } from "@react-spring/web"; import { type Observable, map } from "rxjs"; @@ -43,7 +46,7 @@ import { useReactiveState } from "../useReactiveState"; import { useLatest } from "../useLatest"; import { type SpotlightTileViewModel } from "../state/TileViewModel"; import { useBehavior } from "../useBehavior"; -import { type EncryptionStatus } from "../state/media/MemberMediaViewModel"; +import { type MemberMediaViewModel } from "../state/media/MemberMediaViewModel"; import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewModel"; import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel"; import { type UserMediaViewModel } from "../state/media/UserMediaViewModel"; @@ -52,6 +55,7 @@ import { type RemoteScreenShareViewModel } from "../state/media/RemoteScreenShar import { type MediaViewModel } from "../state/media/MediaViewModel"; import { Slider } from "../Slider"; import { platform } from "../Platform"; +import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel"; interface SpotlightItemBaseProps { ref?: Ref; @@ -59,18 +63,20 @@ interface SpotlightItemBaseProps { "data-id": string; targetWidth: number; targetHeight: number; - video: TrackReferenceOrPlaceholder | undefined; userId: string; - unencryptedWarning: boolean; - encryptionStatus: EncryptionStatus; - focusUrl: string | undefined; displayName: string; mxcAvatarUrl: string | undefined; focusable: boolean; "aria-hidden"?: boolean; } -interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps { +interface SpotlightMemberMediaItemBaseProps extends SpotlightItemBaseProps { + video: TrackReferenceOrPlaceholder | undefined; + unencryptedWarning: boolean; + focusUrl: string | undefined; +} + +interface SpotlightUserMediaItemBaseProps extends SpotlightMemberMediaItemBaseProps { videoFit: "contain" | "cover"; videoEnabled: boolean; } @@ -103,21 +109,32 @@ const SpotlightRemoteUserMediaItem: FC = ({ ); }; -interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps { +interface SpotlightUserMediaItemProps extends SpotlightMemberMediaItemBaseProps { vm: UserMediaViewModel; } const SpotlightUserMediaItem: FC = ({ vm, + targetWidth, + targetHeight, ...props }) => { const videoFit = useBehavior(vm.videoFit$); const videoEnabled = useBehavior(vm.videoEnabled$); + // Whenever target bounds change, inform the viewModel + useEffect(() => { + if (targetWidth > 0 && targetHeight > 0) { + vm.setTargetDimensions(targetWidth, targetHeight); + } + }, [targetWidth, targetHeight, vm]); + const baseProps: SpotlightUserMediaItemBaseProps & RefAttributes = { videoFit, videoEnabled, + targetWidth, + targetHeight, ...props, }; @@ -130,7 +147,7 @@ const SpotlightUserMediaItem: FC = ({ SpotlightUserMediaItem.displayName = "SpotlightUserMediaItem"; -interface SpotlightScreenShareItemProps extends SpotlightItemBaseProps { +interface SpotlightScreenShareItemProps extends SpotlightMemberMediaItemBaseProps { vm: ScreenShareViewModel; videoEnabled: boolean; } @@ -142,7 +159,7 @@ const SpotlightScreenShareItem: FC = ({ return ; }; -interface SpotlightRemoteScreenShareItemProps extends SpotlightItemBaseProps { +interface SpotlightRemoteScreenShareItemProps extends SpotlightMemberMediaItemBaseProps { vm: RemoteScreenShareViewModel; } @@ -155,6 +172,67 @@ const SpotlightRemoteScreenShareItem: FC< ); }; +interface SpotlightMemberMediaItemProps extends SpotlightItemBaseProps { + vm: MemberMediaViewModel; +} + +const SpotlightMemberMediaItem: FC = ({ + vm, + ...props +}) => { + const video = useBehavior(vm.video$); + const unencryptedWarning = useBehavior(vm.unencryptedWarning$); + const focusUrl = useBehavior(vm.focusUrl$); + + const baseProps: SpotlightMemberMediaItemBaseProps & + RefAttributes = { + video: video ?? undefined, + unencryptedWarning, + focusUrl, + ...props, + }; + + if (vm.type === "user") + return ; + return vm.local ? ( + + ) : ( + + ); +}; + +interface SpotlightRingingMediaItemProps extends SpotlightItemBaseProps { + vm: RingingMediaViewModel; +} + +const SpotlightRingingMediaItem: FC = ({ + vm, + ...props +}) => { + const { t } = useTranslation(); + const pickupState = useBehavior(vm.pickupState$); + const videoEnabled = useBehavior(vm.videoEnabled$); + + return ( + + ); +}; + interface SpotlightItemProps { ref?: Ref; vm: MediaViewModel; @@ -187,22 +265,9 @@ const SpotlightItem: FC = ({ }) => { const ourRef = useRef(null); - // Whenever target bounds change, inform the viewModel - useEffect(() => { - if (targetWidth > 0 && targetHeight > 0) { - if (vm.type != "screen share") { - vm.setTargetDimensions(targetWidth, targetHeight); - } - } - }, [targetWidth, targetHeight, vm]); - const ref = useMergedRefs(ourRef, theirRef); - const focusUrl = useBehavior(vm.focusUrl$); const displayName = useBehavior(vm.displayName$); const mxcAvatarUrl = useBehavior(vm.mxcAvatarUrl$); - const video = useBehavior(vm.video$); - const unencryptedWarning = useBehavior(vm.unencryptedWarning$); - const encryptionStatus = useBehavior(vm.encryptionStatus$); // Hook this item up to the intersection observer useEffect(() => { @@ -225,23 +290,17 @@ const SpotlightItem: FC = ({ className: classNames(styles.item, { [styles.snap]: snap }), targetWidth, targetHeight, - video: video ?? undefined, userId: vm.userId, - unencryptedWarning, - focusUrl, displayName, mxcAvatarUrl, focusable, - encryptionStatus, "aria-hidden": ariaHidden, }; - if (vm.type === "user") - return ; - return vm.local ? ( - + return vm.type === "ringing" ? ( + ) : ( - + ); }; From fa844446b6a745731ceb20178314bf8ba0b0ca41 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 18 Mar 2026 11:29:55 +0100 Subject: [PATCH 106/119] Invert the colors of the camera and microphone buttons So that they use primary color tokens when unmuted, and secondary color tokens when muted. This makes them work like the screen sharing button. --- src/button/Button.tsx | 28 +++++++++---------- src/room/InCallView.tsx | 4 +-- src/room/LobbyView.tsx | 4 +-- .../__snapshots__/InCallView.test.tsx.snap | 4 +-- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 00d803f1..1aff9fa3 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -22,16 +22,16 @@ import { import styles from "./Button.module.css"; interface MicButtonProps extends ComponentPropsWithoutRef<"button"> { - muted: boolean; + enabled: boolean; size?: "sm" | "lg"; } -export const MicButton: FC = ({ muted, ...props }) => { +export const MicButton: FC = ({ enabled, ...props }) => { const { t } = useTranslation(); - const Icon = muted ? MicOffSolidIcon : MicOnSolidIcon; - const label = muted - ? t("unmute_microphone_button_label") - : t("mute_microphone_button_label"); + const Icon = enabled ? MicOnSolidIcon : MicOffSolidIcon; + const label = enabled + ? t("mute_microphone_button_label") + : t("unmute_microphone_button_label"); return ( @@ -39,7 +39,7 @@ export const MicButton: FC = ({ muted, ...props }) => { iconOnly aria-label={label} Icon={Icon} - kind={muted ? "primary" : "secondary"} + kind={enabled ? "primary" : "secondary"} {...props} /> @@ -47,16 +47,16 @@ export const MicButton: FC = ({ muted, ...props }) => { }; interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> { - muted: boolean; + enabled: boolean; size?: "sm" | "lg"; } -export const VideoButton: FC = ({ muted, ...props }) => { +export const VideoButton: FC = ({ enabled, ...props }) => { const { t } = useTranslation(); - const Icon = muted ? VideoCallOffSolidIcon : VideoCallSolidIcon; - const label = muted - ? t("start_video_button_label") - : t("stop_video_button_label"); + const Icon = enabled ? VideoCallSolidIcon : VideoCallOffSolidIcon; + const label = enabled + ? t("stop_video_button_label") + : t("start_video_button_label"); return ( @@ -64,7 +64,7 @@ export const VideoButton: FC = ({ muted, ...props }) => { iconOnly aria-label={label} Icon={Icon} - kind={muted ? "primary" : "secondary"} + kind={enabled ? "primary" : "secondary"} {...props} /> diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index f1a872a0..ff221329 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -645,7 +645,7 @@ export const InCallView: FC = ({ = ({ = ({ {recentsButtonInFooter && recentsButton}
diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index e4707c5c..1188d0cc 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -289,7 +289,7 @@ exports[`InCallView > rendering > renders 1`] = ` aria-label="Unmute microphone" aria-labelledby="_r_8_" class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53" - data-kind="primary" + data-kind="secondary" data-size="lg" data-testid="incall_mute" role="button" @@ -313,7 +313,7 @@ exports[`InCallView > rendering > renders 1`] = ` aria-label="Start video" aria-labelledby="_r_d_" class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53" - data-kind="primary" + data-kind="secondary" data-size="lg" data-testid="incall_videomute" role="button" From 4be2bc75609e7155ebc0dc4c1eab02f1a51ffec2 Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 12 Mar 2026 19:00:09 +0100 Subject: [PATCH 107/119] android: Select default output device based on callIntent Add comments on existing code Extracted a specific android controller for isolation and better testing lint fixes Fix device update logic and more tests better typescript --- src/controls.ts | 63 +- src/main.tsx | 2 +- .../AndroidControlledAudioOutput.test.ts | 563 ++++++++++++++++++ src/state/AndroidControlledAudioOutput.ts | 358 +++++++++++ src/state/MediaDevices.ts | 123 +++- 5 files changed, 1073 insertions(+), 36 deletions(-) create mode 100644 src/state/AndroidControlledAudioOutput.test.ts create mode 100644 src/state/AndroidControlledAudioOutput.ts diff --git a/src/controls.ts b/src/controls.ts index 6a050cb0..86de0ace 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -33,12 +33,36 @@ export interface Controls { showNativeOutputDevicePicker?: () => void; } +/** + * Output Audio device when using the controlled audio output mode (mobile). + */ export interface OutputDevice { id: string; name: string; + /** + * Reverse engineered: + * - on iOS always true if output is routed to speaker. In other case iOS on declare a `dummy` id device. + * In that case then ElementCalls manually append a earpiece device with id `EARPIECE_CONFIG_ID` anb `{ type: "earpiece" }` + * - on Android this is unused. + */ forEarpiece?: boolean; + /** + * Reverse engineered: + * - on iOS always undefined + * - on Android true for the `TYPE_BUILTIN_EARPIECE` + */ isEarpiece?: boolean; + /** + * Reverse engineered: + * - on iOS always true if output is routed to speaker. In other case iOS on declare a `dummy` id device. + * - on Android true for the `TYPE_BUILTIN_SPEAKER` + */ isSpeaker?: boolean; + /** + * Reverse engineered: + * - on iOS always undefined. + * - on Android true for the `TYPE_BLUETOOTH_SCO` + */ isExternalHeadset?: boolean; } @@ -47,8 +71,16 @@ export interface OutputDevice { */ export const setPipEnabled$ = new Subject(); +/** + * Stores the list of available controlled audio output devices. + * This is set when the native code calls `setAvailableAudioDevices` with the list of available audio output devices. + */ export const availableOutputDevices$ = new Subject(); +/** + * Stores the current audio output device id. + * This is set when the native code calls `setAudioDevice` + */ export const outputDevice$ = new Subject(); /** @@ -80,16 +112,41 @@ window.controls = { setPipEnabled$.next(false); }, + /** + * Reverse engineered: + * + * - on iOS: + * This always a list of one thing. If current route output is speaker it returns + * the single `{"id":"Speaker","name":"Speaker","forEarpiece":true,"isSpeaker":true}` Notice that EC will + * also manually add a virtual earpiece device with id `EARPIECE_CONFIG_ID` and `{ type: "earpiece" }`. + * If the route output is not speaker then it will be `{id: 'dummy', name: 'dummy'}` + * + * + * - on Android: + * This is a list of all available output audio devices. The `id` is the Android AudioDeviceInfo.getId() + * and the `name` is based the Android AudioDeviceInfo.productName (mapped to static strings for known types) + * The `isEarpiece`, `isSpeaker` and `isExternalHeadset` are set based on the Android AudioDeviceInfo.type + * matching the corresponding types for earpiece, speaker and bluetooth headset. + */ setAvailableAudioDevices(devices: OutputDevice[]): void { - logger.info("setAvailableAudioDevices called from native:", devices); + logger.info( + "[MediaDevices controls] setAvailableAudioDevices called from native:", + devices, + ); availableOutputDevices$.next(devices); }, setAudioDevice(id: string): void { - logger.info("setAudioDevice called from native", id); + logger.info( + "[MediaDevices controls] setAudioDevice called from native", + id, + ); outputDevice$.next(id); }, setAudioEnabled(enabled: boolean): void { - logger.info("setAudioEnabled called from native:", enabled); + logger.info( + "[MediaDevices controls] setAudioEnabled called from native:", + enabled, + ); if (!setAudioEnabled$.observed) throw new Error( "Output controls are disabled. No setAudioEnabled$ observer", diff --git a/src/main.tsx b/src/main.tsx index 946e0238..6cbf75fa 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -67,6 +67,6 @@ Initializer.initBeforeReact() ); }) .catch((e) => { - logger.error("Failed to initialize app", e); + logger.error(`Failed to initialize app ${e.message}`, e); root.render(e.message); }); diff --git a/src/state/AndroidControlledAudioOutput.test.ts b/src/state/AndroidControlledAudioOutput.test.ts new file mode 100644 index 00000000..12b74052 --- /dev/null +++ b/src/state/AndroidControlledAudioOutput.test.ts @@ -0,0 +1,563 @@ +/* +Copyright 2026 Element Corp. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { it, vi, expect, beforeEach, afterEach, describe } from "vitest"; +import { firstValueFrom, of, Subject, take, toArray } from "rxjs"; +import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc"; + +import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput.ts"; +import type { Controls, OutputDevice } from "../controls"; +import { ObservableScope } from "./ObservableScope"; +import { withTestScheduler } from "../utils/test"; + +// All the following device types are real device types that have been observed in the wild on Android devices, +// gathered from logs. +// There are no BT Speakers because they are currently filtered out by EXA (native layer) + +// A device type describing the speaker system (i.e. a mono speaker or stereo speakers) built in a device. +const SPEAKER_DEVICE: OutputDevice = { + id: "3", + name: "Built-in speaker", + isEarpiece: false, + isSpeaker: true, + isExternalHeadset: false, +}; + +// A device type describing the attached earphone speaker. +const EARPIECE_DEVICE: OutputDevice = { + id: "2", + name: "Built-in earpiece", + isEarpiece: true, + isSpeaker: false, + isExternalHeadset: false, +}; + +// A device type describing a Bluetooth device typically used for telephony +const BT_HEADSET_DEVICE: OutputDevice = { + id: "2226", + name: "Bluetooth - OpenMove by Shokz", + isEarpiece: false, + isSpeaker: false, + isExternalHeadset: true, +}; + +// A device type describing a USB audio headset. +const USB_HEADSET_DEVICE: OutputDevice = { + id: "29440", + name: "USB headset - USB-Audio - AB13X USB Audio", + isEarpiece: false, + isSpeaker: false, + isExternalHeadset: false, +}; + +// A device type describing a headset, which is the combination of a headphones and microphone +const WIRED_HEADSET_DEVICE: OutputDevice = { + id: "54509", + name: "Wired headset - 23117RA68G", + isEarpiece: false, + isSpeaker: false, + isExternalHeadset: false, +}; + +// A device type describing a pair of wired headphones +const WIRED_HEADPHONE_DEVICE: OutputDevice = { + id: "679", + name: "Wired headphones - TB02", + isEarpiece: false, + isSpeaker: false, + isExternalHeadset: false, +}; + +/** + * The base device list that is always present on Android devices. + * This list is ordered by the OS, the speaker is listed before the earpiece. + */ +const BASE_DEVICE_LIST = [SPEAKER_DEVICE, EARPIECE_DEVICE]; + +const BT_HEADSET_BASE_DEVICE_LIST = [BT_HEADSET_DEVICE, ...BASE_DEVICE_LIST]; + +const WIRED_HEADSET_BASE_DEVICE_LIST = [ + WIRED_HEADSET_DEVICE, + ...BASE_DEVICE_LIST, +]; + +/** + * A full device list containing all the observed device types in the wild on Android devices. + * Ordered as they would be ordered by the OS. + */ +const FULL_DEVICE_LIST = [ + BT_HEADSET_DEVICE, + USB_HEADSET_DEVICE, + WIRED_HEADSET_DEVICE, + WIRED_HEADPHONE_DEVICE, + ...BASE_DEVICE_LIST, +]; + +let testScope: ObservableScope; +let mockControls: Controls; + +beforeEach(() => { + testScope = new ObservableScope(); + mockControls = { + onAudioDeviceSelect: vi.fn(), + onOutputDeviceSelect: vi.fn(), + } as unknown as Controls; +}); + +afterEach(() => { + testScope.end(); +}); + +describe("Default selection", () => { + it("Default to speaker for video calls", async () => { + const controlledAudioOutput = new AndroidControlledAudioOutput( + of(BASE_DEVICE_LIST), + testScope, + "video", + mockControls, + ); + + const emissions = await firstValueFrom( + controlledAudioOutput.selected$.pipe(take(1), toArray()), + ); + + expect(emissions).toEqual([ + { id: SPEAKER_DEVICE.id, virtualEarpiece: false }, + ]); + + [ + mockControls.onAudioDeviceSelect, + mockControls.onOutputDeviceSelect, + ].forEach((mockFn) => { + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(SPEAKER_DEVICE.id); + }); + }); + + it("Default to earpiece for audio calls for base config", async () => { + const controlledAudioOutput = new AndroidControlledAudioOutput( + of(BASE_DEVICE_LIST), + testScope, + "audio", + mockControls, + ); + + const emissions = await firstValueFrom( + controlledAudioOutput.selected$.pipe(take(1), toArray()), + ); + + expect(emissions).toEqual([ + { id: EARPIECE_DEVICE.id, virtualEarpiece: false }, + ]); + + [ + mockControls.onAudioDeviceSelect, + mockControls.onOutputDeviceSelect, + ].forEach((mockFn) => { + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(EARPIECE_DEVICE.id); + }); + }); + + ["audio", "video"].forEach((callIntent) => { + it(`Default to BT headset for ${callIntent} calls if present`, async () => { + const controlledAudioOutput = new AndroidControlledAudioOutput( + of(BT_HEADSET_BASE_DEVICE_LIST), + testScope, + callIntent, + mockControls, + ); + + const emissions = await firstValueFrom( + controlledAudioOutput.selected$.pipe(take(1), toArray()), + ); + + expect(emissions).toEqual([ + { id: BT_HEADSET_DEVICE.id, virtualEarpiece: false }, + ]); + + [ + mockControls.onAudioDeviceSelect, + mockControls.onOutputDeviceSelect, + ].forEach((mockFn) => { + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(BT_HEADSET_DEVICE.id); + }); + }); + }); + + ["audio", "video"].forEach((callIntent) => { + it(`Default to wired headset for ${callIntent} calls if present`, async () => { + const controlledAudioOutput = new AndroidControlledAudioOutput( + of(WIRED_HEADSET_BASE_DEVICE_LIST), + testScope, + callIntent, + mockControls, + ); + + const emissions = await firstValueFrom( + controlledAudioOutput.selected$.pipe(take(1), toArray()), + ); + + expect(emissions).toEqual([ + { id: WIRED_HEADSET_DEVICE.id, virtualEarpiece: false }, + ]); + + expect(mockControls.onAudioDeviceSelect).toHaveBeenCalledExactlyOnceWith( + WIRED_HEADSET_DEVICE.id, + ); + expect(mockControls.onOutputDeviceSelect).toHaveBeenCalledExactlyOnceWith( + WIRED_HEADSET_DEVICE.id, + ); + }); + }); +}); + +describe("Test mappings", () => { + it("Should map output device to correct AudioDeviceLabel", async () => { + const controlledAudioOutput = new AndroidControlledAudioOutput( + of(FULL_DEVICE_LIST), + testScope, + undefined, + mockControls, + ); + + const availableDevices = await firstValueFrom( + controlledAudioOutput.available$.pipe(take(1)), + ); + + expect(availableDevices).toEqual( + new Map([ + [BT_HEADSET_DEVICE.id, { type: "name", name: BT_HEADSET_DEVICE.name }], + [ + USB_HEADSET_DEVICE.id, + { type: "name", name: USB_HEADSET_DEVICE.name }, + ], + [ + WIRED_HEADSET_DEVICE.id, + { type: "name", name: WIRED_HEADSET_DEVICE.name }, + ], + [ + WIRED_HEADPHONE_DEVICE.id, + { type: "name", name: WIRED_HEADPHONE_DEVICE.name }, + ], + [SPEAKER_DEVICE.id, { type: "speaker" }], + [EARPIECE_DEVICE.id, { type: "earpiece" }], + ]), + ); + }); +}); + +describe("Test select a device", () => { + it(`Switch to correct device `, () => { + withTestScheduler(({ cold, schedule, expectObservable, flush }) => { + const controlledAudioOutput = new AndroidControlledAudioOutput( + cold("a", { a: FULL_DEVICE_LIST }), + testScope, + undefined, + mockControls, + ); + + schedule("-abc", { + a: () => controlledAudioOutput.select(EARPIECE_DEVICE.id), + b: () => controlledAudioOutput.select(USB_HEADSET_DEVICE.id), + c: () => controlledAudioOutput.select(SPEAKER_DEVICE.id), + }); + + expectObservable(controlledAudioOutput.selected$).toBe("abcd", { + // virtualEarpiece is always false on android. + // Initially the BT_HEADSET is selected. + a: { id: BT_HEADSET_DEVICE.id, virtualEarpiece: false }, + b: { id: EARPIECE_DEVICE.id, virtualEarpiece: false }, + c: { id: USB_HEADSET_DEVICE.id, virtualEarpiece: false }, + d: { id: SPEAKER_DEVICE.id, virtualEarpiece: false }, + }); + + flush(); + + [ + mockControls.onOutputDeviceSelect, + mockControls.onAudioDeviceSelect, + ].forEach((mockFn) => { + expect(mockFn).toHaveBeenCalledTimes(4); + expect(mockFn).toHaveBeenNthCalledWith(1, BT_HEADSET_DEVICE.id); + expect(mockFn).toHaveBeenNthCalledWith(2, EARPIECE_DEVICE.id); + expect(mockFn).toHaveBeenNthCalledWith(3, USB_HEADSET_DEVICE.id); + expect(mockFn).toHaveBeenNthCalledWith(4, SPEAKER_DEVICE.id); + }); + }); + }); + + it(`manually switch then a bt headset is added`, () => { + withTestScheduler(({ cold, schedule, expectObservable, flush }) => { + const controlledAudioOutput = new AndroidControlledAudioOutput( + cold("a--b", { + a: BASE_DEVICE_LIST, + b: BT_HEADSET_BASE_DEVICE_LIST, + }), + testScope, + "audio", + mockControls, + ); + + // Default was earpiece (audio call), let's switch to speaker + schedule("-a--", { + a: () => controlledAudioOutput.select(SPEAKER_DEVICE.id), + }); + + expectObservable(controlledAudioOutput.selected$).toBe("ab-c", { + // virtualEarpiece is always false on android. + // Initially the BT_HEADSET is selected. + a: { id: EARPIECE_DEVICE.id, virtualEarpiece: false }, + b: { id: SPEAKER_DEVICE.id, virtualEarpiece: false }, + c: { id: BT_HEADSET_DEVICE.id, virtualEarpiece: false }, + }); + + flush(); + + [ + mockControls.onOutputDeviceSelect, + mockControls.onAudioDeviceSelect, + ].forEach((mockFn) => { + expect(mockFn).toHaveBeenCalledTimes(3); + expect(mockFn).toHaveBeenNthCalledWith(1, EARPIECE_DEVICE.id); + expect(mockFn).toHaveBeenNthCalledWith(2, SPEAKER_DEVICE.id); + expect(mockFn).toHaveBeenNthCalledWith(3, BT_HEADSET_DEVICE.id); + }); + }); + }); + + it(`Go back to the previously selected after the auto-switch device goes away`, () => { + withTestScheduler(({ cold, schedule, expectObservable, flush }) => { + const controlledAudioOutput = new AndroidControlledAudioOutput( + cold("a--b-c", { + a: BASE_DEVICE_LIST, + b: BT_HEADSET_BASE_DEVICE_LIST, + c: BASE_DEVICE_LIST, + }), + testScope, + "audio", + mockControls, + ); + + // Default was earpiece (audio call), let's switch to speaker + schedule("-a---", { + a: () => controlledAudioOutput.select(SPEAKER_DEVICE.id), + }); + + expectObservable(controlledAudioOutput.selected$).toBe("ab-c-d", { + // virtualEarpiece is always false on android. + // Initially the BT_HEADSET is selected. + a: { id: EARPIECE_DEVICE.id, virtualEarpiece: false }, + b: { id: SPEAKER_DEVICE.id, virtualEarpiece: false }, + c: { id: BT_HEADSET_DEVICE.id, virtualEarpiece: false }, + d: { id: SPEAKER_DEVICE.id, virtualEarpiece: false }, + }); + + flush(); + + [ + mockControls.onOutputDeviceSelect, + mockControls.onAudioDeviceSelect, + ].forEach((mockFn) => { + expect(mockFn).toHaveBeenCalledTimes(4); + expect(mockFn).toHaveBeenNthCalledWith(1, EARPIECE_DEVICE.id); + expect(mockFn).toHaveBeenNthCalledWith(2, SPEAKER_DEVICE.id); + expect(mockFn).toHaveBeenNthCalledWith(3, BT_HEADSET_DEVICE.id); + expect(mockFn).toHaveBeenNthCalledWith(4, SPEAKER_DEVICE.id); + }); + }); + }); +}); + +describe("Available device changes", () => { + let availableSource$: Subject; + + const createAudioControlledOutput = ( + intent: RTCCallIntent, + ): AndroidControlledAudioOutput => { + return new AndroidControlledAudioOutput( + availableSource$, + testScope, + intent, + mockControls, + ); + }; + + beforeEach(() => { + availableSource$ = new Subject(); + }); + + it("When a BT headset is added, control should switch to use it", () => { + createAudioControlledOutput("video"); + + // Emit the base device list, the speaker should be selected + availableSource$.next(BASE_DEVICE_LIST); + // Initially speaker would be selected + [ + mockControls.onOutputDeviceSelect, + mockControls.onAudioDeviceSelect, + ].forEach((mockFn) => { + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(SPEAKER_DEVICE.id); + }); + + // Emit a new device list with a BT device, the control should switch to it + availableSource$.next([BT_HEADSET_DEVICE, ...BASE_DEVICE_LIST]); + [ + mockControls.onOutputDeviceSelect, + mockControls.onAudioDeviceSelect, + ].forEach((mockFn) => { + expect(mockFn).toHaveBeenCalledTimes(2); + expect(mockFn).toHaveBeenLastCalledWith(BT_HEADSET_DEVICE.id); + }); + }); + + // Android does not set `isExternalHeadset` to true for wired headphones, so we can't test this case.' + it.skip("When a wired headset is added, control should switch to use it", async () => { + const controlledAudioOutput = createAudioControlledOutput("video"); + + // Emit the base device list, the speaker should be selected + availableSource$.next(BASE_DEVICE_LIST); + + await firstValueFrom(controlledAudioOutput.selected$.pipe(take(1))); + // Initially speaker would be selected + [ + mockControls.onOutputDeviceSelect, + mockControls.onAudioDeviceSelect, + ].forEach((mockFn) => { + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(SPEAKER_DEVICE.id); + }); + + // Emit a new device list with a wired headset, the control should switch to it + availableSource$.next([WIRED_HEADPHONE_DEVICE, ...BASE_DEVICE_LIST]); + [ + mockControls.onOutputDeviceSelect, + mockControls.onAudioDeviceSelect, + ].forEach((mockFn) => { + expect(mockFn).toHaveBeenCalledTimes(2); + expect(mockFn).toHaveBeenLastCalledWith(WIRED_HEADPHONE_DEVICE.id); + }); + }); + + it("When the active bt headset is removed on audio call, control should switch to earpiece", () => { + createAudioControlledOutput("audio"); + + // Emit the BT headset device list, the BT headset should be selected + availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST); + // Initially speaker would be selected + [ + mockControls.onOutputDeviceSelect, + mockControls.onAudioDeviceSelect, + ].forEach((mockFn) => { + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(BT_HEADSET_DEVICE.id); + }); + + // Emit a new device list without the BT headset, the control should switch to the earpiece for + // audio calls + availableSource$.next(BASE_DEVICE_LIST); + [ + mockControls.onOutputDeviceSelect, + mockControls.onAudioDeviceSelect, + ].forEach((mockFn) => { + expect(mockFn).toHaveBeenCalledTimes(2); + expect(mockFn).toHaveBeenLastCalledWith(EARPIECE_DEVICE.id); + }); + }); + + it("When the active bt headset is removed on video call, control should switch to speaker", () => { + createAudioControlledOutput("video"); + + availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST); + + // Initially bt headset would be selected + [ + mockControls.onOutputDeviceSelect, + mockControls.onAudioDeviceSelect, + ].forEach((mockFn) => { + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(BT_HEADSET_DEVICE.id); + }); + + // Emit a new device list without the BT headset, the control should switch to speaker for video call + availableSource$.next(BASE_DEVICE_LIST); + [ + mockControls.onOutputDeviceSelect, + mockControls.onAudioDeviceSelect, + ].forEach((mockFn) => { + expect(mockFn).toHaveBeenCalledTimes(2); + expect(mockFn).toHaveBeenLastCalledWith(SPEAKER_DEVICE.id); + }); + }); + + it("Do not repeatidly set the same device", () => { + createAudioControlledOutput("video"); + + availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST); + availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST); + availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST); + availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST); + availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST); + + // Initially bt headset would be selected + [ + mockControls.onOutputDeviceSelect, + mockControls.onAudioDeviceSelect, + ].forEach((mockFn) => { + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(BT_HEADSET_DEVICE.id); + }); + }); +}); + +describe("Scope management", () => { + it("Should stop emitting when scope ends", () => { + const aScope = new ObservableScope(); + const controlledAudioOutput = new AndroidControlledAudioOutput( + of(BASE_DEVICE_LIST), + aScope, + undefined, + mockControls, + ); + + expect(mockControls.onAudioDeviceSelect).toHaveBeenCalledOnce(); + + aScope.end(); + + controlledAudioOutput.select(EARPIECE_DEVICE.id); + + expect(mockControls.onAudioDeviceSelect).not.toHaveBeenCalledTimes(2); + expect(mockControls.onAudioDeviceSelect).toHaveBeenCalledOnce(); + }); + + it("Should stop updating when scope ends", () => { + const aScope = new ObservableScope(); + const availableSource$ = new Subject(); + new AndroidControlledAudioOutput( + availableSource$, + aScope, + undefined, + mockControls, + ); + + availableSource$.next(BT_HEADSET_BASE_DEVICE_LIST); + expect(mockControls.onAudioDeviceSelect).toHaveBeenCalledOnce(); + expect(mockControls.onAudioDeviceSelect).toHaveBeenCalledWith( + BT_HEADSET_DEVICE.id, + ); + + aScope.end(); + + availableSource$.next(BASE_DEVICE_LIST); + + expect(mockControls.onAudioDeviceSelect).not.toHaveBeenCalledTimes(2); + // Should have been called only once with the initial BT_HEADSET_DEVICE.id + expect(mockControls.onAudioDeviceSelect).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/state/AndroidControlledAudioOutput.ts b/src/state/AndroidControlledAudioOutput.ts new file mode 100644 index 00000000..ce4974ff --- /dev/null +++ b/src/state/AndroidControlledAudioOutput.ts @@ -0,0 +1,358 @@ +/* +Copyright 2026 Element Corp. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ +import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; +import { + distinctUntilChanged, + map, + merge, + type Observable, + scan, + startWith, + Subject, + tap, +} from "rxjs"; + +import { + type AudioOutputDeviceLabel, + type MediaDevice, + type SelectedAudioOutputDevice, +} from "./MediaDevices.ts"; +import type { ObservableScope } from "./ObservableScope.ts"; +import type { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc"; +import { type Controls, type OutputDevice } from "../controls.ts"; +import { type Behavior } from "./Behavior.ts"; + +type ControllerState = { + /** + * The list of available output devices, ordered by preference order (most preferred first). + */ + devices: OutputDevice[]; + /** + * Explicit user preference for the selected device. + */ + preferredDeviceId: string | undefined; + /** + * The effective selected device, always valid against available devices. + */ + selectedDeviceId: string | undefined; +}; + +/** + * The possible actions that can be performed on the controller, + * either by the user or by the system. + */ +type ControllerAction = + | { type: "selectDevice"; deviceId: string | undefined } + | { type: "deviceUpdated"; devices: OutputDevice[] }; +/** + * The implementation of the audio output media device for Android when using the controlled audio output mode. + * + * In this mode, the hosting application (e.g. Element Mobile) is responsible for providing the list of available audio output devices. + * There are some android specific logic compared to others: + * - AndroidControlledAudioOutput is the only one responsible for selecting the best output device. + * - On android, we don't listen to the selected device from native code (control.setAudioDevice). + * - If a new device is added or removed, this controller will determine the new selected device based + * on the available devices (that is ordered by preference order) and the user's selection (if any). + * + * Given the differences in how the native code is handling the audio routing on Android compared to iOS, + * we have this separate implementation. It allows us to have proper testing and avoid side effects + * from platform specific logic breaking the other platform's implementation. + */ +export class AndroidControlledAudioOutput implements MediaDevice< + AudioOutputDeviceLabel, + SelectedAudioOutputDevice +> { + private logger = rootLogger.getChild( + "[MediaDevices AndroidControlledAudioOutput]", + ); + + // STATE stream: the current state of the controller, including the list of available devices and the selected device. + private readonly controllerState$: Behavior; + + /** + * @inheritdoc + */ + public readonly available$: Behavior>; + + /** + * Effective selected device, always valid against available devices. + * + * On android, we don't listen to the selected device from native code (control.setAudioDevice). + * Instead, we determine the selected device ourselves based on the available devices and the user's selection (if any). + */ + public readonly selected$: Behavior; + + // COMMAND stream: user asks to select a device + private readonly selectDeviceCommand$ = new Subject(); + + public select(id: string): void { + this.logger.info(`select device: ${id}`); + this.selectDeviceCommand$.next(id); + } + + /** + * Creates an instance of AndroidControlledAudioOutput. + * + * @constructor + * @param controlledDevices$ - The list of available output devices coming from the hosting application, ordered by preference order (most preferred first). + * @param scope - The ObservableScope to create the Behaviors in. + * @param initialIntent - The initial call intent (e.g. "audio" or "video") that can be used to determine the default audio routing (e.g. default to earpiece for audio calls and speaker for video calls). + * @param controls - The controls provided by the hosting application to control the audio routing and notify of user actions. + */ + public constructor( + private readonly controlledDevices$: Observable, + private readonly scope: ObservableScope, + private initialIntent: RTCCallIntent | undefined = undefined, + controls: Controls, + ) { + this.controllerState$ = this.startObservingState$(); + + this.selected$ = this.effectiveSelectionFromState$(this.controllerState$); + + this.available$ = scope.behavior( + this.controllerState$.pipe( + map((state) => { + this.logger.info("available devices updated:", state.devices); + + return new Map( + state.devices.map((outputDevice) => { + return [outputDevice.id, mapDeviceToLabel(outputDevice)]; + }), + ); + }), + ), + ); + + // Effect 1: notify host when effective selection changes + this.selected$ + // It is a behavior so it has built-in distinct until change + .pipe(scope.bind()) + .subscribe((device) => { + // Let the hosting application know which output device has been selected. + if (device !== undefined) { + this.logger.info("onAudioDeviceSelect called:", device); + controls.onAudioDeviceSelect?.(device.id); + // Also invoke the deprecated callback for backward compatibility + // TODO: it appears that on Android the hosting application is only using the deprecated callback (onOutputDeviceSelect) + // and not the new one (onAudioDeviceSelect), we should clean this up and only have one callback for audio device selection. + controls.onOutputDeviceSelect?.(device.id); + } + }); + } + + private startObservingState$(): Behavior { + const initialState: ControllerState = { + devices: [], + preferredDeviceId: undefined, + selectedDeviceId: undefined, + }; + + // Merge the two possible inputs observable as a single + // stream of actions that will update the state of the controller. + const actions$: Observable = merge( + this.controlledDevices$.pipe( + map( + (devices) => + ({ type: "deviceUpdated", devices }) satisfies ControllerAction, + ), + ), + this.selectDeviceCommand$.pipe( + map( + (deviceId) => + ({ type: "selectDevice", deviceId }) satisfies ControllerAction, + ), + ), + ); + + const initialAction: ControllerAction = { + type: "deviceUpdated", + devices: [], + }; + + return this.scope.behavior( + actions$.pipe( + startWith(initialAction), + scan((state, action): ControllerState => { + switch (action.type) { + case "deviceUpdated": { + const chosenDevice = this.chooseEffectiveSelection({ + previousDevices: state.devices, + availableDevices: action.devices, + currentSelectedId: state.selectedDeviceId, + preferredDeviceId: state.preferredDeviceId, + }); + + return { + ...state, + devices: action.devices, + selectedDeviceId: chosenDevice, + }; + } + case "selectDevice": { + const chosenDevice = this.chooseEffectiveSelection({ + previousDevices: state.devices, + availableDevices: state.devices, + currentSelectedId: state.selectedDeviceId, + preferredDeviceId: action.deviceId, + }); + + return { + ...state, + preferredDeviceId: action.deviceId, + selectedDeviceId: chosenDevice, + }; + } + } + }, initialState), + ), + ); + } + + private effectiveSelectionFromState$( + state$: Observable, + ): Behavior { + return this.scope.behavior( + state$ + .pipe( + map((state) => { + if (state.selectedDeviceId) { + return { + id: state.selectedDeviceId, + /** This is an iOS thing, always false for android*/ + virtualEarpiece: false, + }; + } + return undefined; + }), + distinctUntilChanged((a, b) => a?.id === b?.id), + ) + .pipe( + tap((selected) => { + this.logger.debug(`selected device: ${selected?.id}`); + }), + ), + ); + } + + private chooseEffectiveSelection(args: { + previousDevices: OutputDevice[]; + availableDevices: OutputDevice[]; + currentSelectedId: string | undefined; + preferredDeviceId: string | undefined; + }): string | undefined { + const { + previousDevices, + availableDevices, + currentSelectedId, + preferredDeviceId, + } = args; + + this.logger.debug(`chooseEffectiveSelection with args:`, args); + + // Take preferredDeviceId in priority or default to the last effective selection. + const activeSelectedDeviceId = preferredDeviceId || currentSelectedId; + const isAvailable = availableDevices.some( + (device) => device.id === activeSelectedDeviceId, + ); + + // If there is no current device, or it is not available anymore, + // choose the default device selection logic. + if (activeSelectedDeviceId === undefined || !isAvailable) { + this.logger.debug( + `No current device or it is not available, using default selection logic.`, + ); + // use the default selection logic + return this.chooseDefaultDeviceId(availableDevices); + } + + // Is there a new added device? + // If a device is added, we might want to switch to it if it's more preferred than the currently selected device. + const newDeviceWasAdded = availableDevices.some( + (device) => !previousDevices.some((d) => d.id === device.id), + ); + + if (newDeviceWasAdded) { + // TODO only want to check from the added device, not all devices.? + // check if the currently selected device is the most preferred one, if not switch to the most preferred one. + const mostPreferredDevice = availableDevices[0]; + this.logger.debug( + `A new device was added, checking if we should switch to it.`, + mostPreferredDevice, + ); + if (mostPreferredDevice.id !== activeSelectedDeviceId) { + // Given this is automatic switching, we want to be careful and only switch to a more private device + // (e.g. from speaker to a BT headset) but not switch from a more private device to a less private one + // (e.g. from a BT headset to the speaker), as that can be disruptive for the user if it happens unexpectedly. + if (mostPreferredDevice.isExternalHeadset == true) { + this.logger.info( + `The currently selected device ${mostPreferredDevice.id} is not the most preferred one, switching to the most preferred one ${activeSelectedDeviceId} instead.`, + ); + // Let's switch as it is a more private device. + return mostPreferredDevice.id; + } + } + } + + // no changes + return activeSelectedDeviceId; + } + + /** + * The logic for the default is different based on the call type. + * For example for a voice call we want to default to the earpiece if it's available, + * but for a video call we want to default to the speaker. + * If the user is using a BT headset we want to default to that, as it's likely what they want to use for both video and voice calls. + * + * @param available the available audio output devices to choose from, keyed by their id, sorted by likelihood of it being used for communication. + * + */ + private chooseDefaultDeviceId(available: OutputDevice[]): string | undefined { + this.logger.debug( + `Android routing logic intent: ${this.initialIntent} finding best default...`, + ); + if (this.initialIntent === "audio") { + const systemProposed = available[0]; + // If no headset is connected, android will route to the speaker by default, + // but for a voice call we want to route to the earpiece instead, + // so override the system proposed routing in that case. + if (systemProposed?.isSpeaker == true) { + // search for the earpiece + const earpieceDevice = available.find( + (device) => device.isEarpiece == true, + ); + if (earpieceDevice) { + this.logger.debug( + `Android routing: Switch to earpiece instead of speaker for voice call`, + ); + return earpieceDevice.id; + } else { + this.logger.debug( + `Android routing: no earpiece found, cannot switch, use system proposed routing`, + ); + return systemProposed.id; + } + } else { + this.logger.debug( + `Android routing: Use system proposed routing `, + systemProposed, + ); + return systemProposed?.id; + } + } else { + // Use the system best proposed best routing. + return available[0]?.id; + } + } +} + +// Utilities +function mapDeviceToLabel(device: OutputDevice): AudioOutputDeviceLabel { + const { name, isEarpiece, isSpeaker } = device; + if (isEarpiece) return { type: "earpiece" }; + else if (isSpeaker) return { type: "speaker" }; + else return { type: "name", name }; +} diff --git a/src/state/MediaDevices.ts b/src/state/MediaDevices.ts index cea97519..cf578fb4 100644 --- a/src/state/MediaDevices.ts +++ b/src/state/MediaDevices.ts @@ -15,6 +15,7 @@ import { Subject, switchMap, type Observable, + tap, } from "rxjs"; import { createMediaDeviceObserver } from "@livekit/components-core"; import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger"; @@ -34,6 +35,7 @@ import { getUrlParams } from "../UrlParams"; import { platform } from "../Platform"; import { switchWhen } from "../utils/observable"; import { type Behavior, constant } from "./Behavior"; +import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput.ts"; // This hardcoded id is used in EX ios! It can only be changed in coordination with // the ios swift team. @@ -49,10 +51,18 @@ export type AudioOutputDeviceLabel = | { type: "earpiece" } | { type: "default"; name: string | null }; +/** + * Base selected-device value shared by all media kinds. + * + * `id` is the effective device identifier used by browser media APIs. + */ export interface SelectedDevice { id: string; } +/** + * Selected audio input value with audio-input-specific metadata. + */ export interface SelectedAudioInputDevice extends SelectedDevice { /** * Emits whenever we think that this audio input device has logically changed @@ -61,6 +71,9 @@ export interface SelectedAudioInputDevice extends SelectedDevice { hardwareDeviceChange$: Observable; } +/** + * Selected audio output value with output-routing-specific metadata. + */ export interface SelectedAudioOutputDevice extends SelectedDevice { /** * Whether this device is a "virtual earpiece" device. If so, we should output @@ -69,23 +82,42 @@ export interface SelectedAudioOutputDevice extends SelectedDevice { virtualEarpiece: boolean; } +/** + * Common reactive contract for selectable input/output media devices (mic, speaker, camera). + * + * `Label` is the type used to represent a device in UI lists. + * `Selected` is the type used to represent the active selection for a device kind. + */ export interface MediaDevice { /** - * A map from available device IDs to labels. + * Reactive map of currently available devices keyed by device ID. + * + * `Label` defines the UI-facing label data structure for each device type. */ available$: Behavior>; + /** - * The selected device. + * The active device selection. + * Can be `undefined` when no device is yet selected. + * + * When defined, `Selected` contains the selected device ID plus any + * type-specific metadata. */ selected$: Behavior; + /** - * Selects a new device. + * Requests selection of a device by ID. + * + * Implementations typically persist this preference and let `selected$` + * converge to the effective device (which may differ if the requested ID is + * unavailable). */ select(id: string): void; } /** * An observable that represents if we should display the devices menu for iOS. + * * This implies the following * - hide any input devices (they do not work anyhow on ios) * - Show a button to show the native output picker instead. @@ -143,19 +175,29 @@ function buildDeviceMap( function selectDevice$

renders and matches snapshot 1`] = ` class="_inline-field-control_19upo_44" >
renders and matches snapshot 1`] = ` value="legacy" />
@@ -275,11 +275,11 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` class="_inline-field-control_19upo_44" >
renders and matches snapshot 1`] = ` value="compatibility" />
@@ -315,11 +315,11 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` class="_inline-field-control_19upo_44" >
renders and matches snapshot 1`] = ` value="matrix_2_0" />
From c60ed50a9ddcde1eec6090a3c29cb57a454a7d61 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 24 Mar 2026 17:04:28 +0100 Subject: [PATCH 113/119] Attempt to fix end-to-end widget tests --- playwright/widget/test-helpers.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/playwright/widget/test-helpers.ts b/playwright/widget/test-helpers.ts index 4562ba5a..5cdd5506 100644 --- a/playwright/widget/test-helpers.ts +++ b/playwright/widget/test-helpers.ts @@ -152,6 +152,22 @@ export class TestHelpers { } } + private static async maybeDismissKeyBackupToast(page: Page): Promise { + const toast = page + .locator(".mx_Toast_toast") + .getByText("Back up your chats"); + + try { + await expect(toast).toBeVisible({ timeout: 700 }); + await page + .locator(".mx_Toast_toast") + .getByRole("button", { name: "Dismiss" }) + .click(); + } catch { + // toast not visible, continue as normal + } + } + public static async createRoom( name: string, page: Page, @@ -167,6 +183,7 @@ export class TestHelpers { await page.getByRole("button", { name: "Create room" }).click(); await expect(page.getByText("You created this room.")).toBeVisible(); await expect(page.getByText("Encryption enabled")).toBeVisible(); + await TestHelpers.maybeDismissKeyBackupToast(page); // Invite users if any if (andInvite.length > 0) { @@ -201,6 +218,7 @@ export class TestHelpers { await expect( page.getByRole("main").getByRole("heading", { name: roomName }), ).toBeVisible(); + await TestHelpers.maybeDismissKeyBackupToast(page); } /** From 4f518819d387283f99b77a2af9b914532d019029 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 24 Mar 2026 18:02:27 +0100 Subject: [PATCH 114/119] review: extract ControlledAudioOutput in its own file --- src/state/ControlledAudioOutput.ts | 132 +++++++++++++++++++++++++++++ src/state/MediaDevices.ts | 129 ++-------------------------- 2 files changed, 138 insertions(+), 123 deletions(-) create mode 100644 src/state/ControlledAudioOutput.ts diff --git a/src/state/ControlledAudioOutput.ts b/src/state/ControlledAudioOutput.ts new file mode 100644 index 00000000..a3fe9387 --- /dev/null +++ b/src/state/ControlledAudioOutput.ts @@ -0,0 +1,132 @@ +/* +Copyright 2026 Element Corp. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; +import { combineLatest, merge, startWith, Subject, tap } from "rxjs"; + +import { + availableOutputDevices$ as controlledAvailableOutputDevices$, + outputDevice$ as controlledOutputSelection$, +} from "../controls.ts"; +import type { Behavior } from "./Behavior.ts"; +import type { ObservableScope } from "./ObservableScope.ts"; +import { + type AudioOutputDeviceLabel, + availableRawDevices$, + iosDeviceMenu$, + type MediaDevice, + type SelectedAudioOutputDevice, +} from "./MediaDevices.ts"; + +// This hardcoded id is used in EX ios! It can only be changed in coordination with +// the ios swift team. +const EARPIECE_CONFIG_ID = "earpiece-id"; + +/** + * A special implementation of audio output that allows the hosting application + * to have more control over the device selection process. This is used when the + * `controlledAudioDevices` URL parameter is set, which is currently only true on mobile. + */ +export class ControlledAudioOutput implements MediaDevice< + AudioOutputDeviceLabel, + SelectedAudioOutputDevice +> { + private logger = rootLogger.getChild("[MediaDevices ControlledAudioOutput]"); + // We need to subscribe to the raw devices so that the OS does update the input + // back to what it was before. otherwise we will switch back to the default + // whenever we allocate a new stream. + public readonly availableRaw$ = availableRawDevices$( + "audiooutput", + this.usingNames$, + this.scope, + this.logger, + ); + + public readonly available$ = this.scope.behavior( + combineLatest( + [controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$], + (availableRaw, iosDeviceMenu) => { + const available = new Map( + availableRaw.map( + ({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => { + let deviceLabel: AudioOutputDeviceLabel; + // if (isExternalHeadset) // Do we want this? + if (isEarpiece) deviceLabel = { type: "earpiece" }; + else if (isSpeaker) deviceLabel = { type: "speaker" }; + else deviceLabel = { type: "name", name }; + return [id, deviceLabel]; + }, + ), + ); + + // Create a virtual earpiece device in case a non-earpiece device is + // designated for this purpose + if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece)) { + this.logger.info( + `IOS Add virtual earpiece device with id ${EARPIECE_CONFIG_ID}`, + ); + available.set(EARPIECE_CONFIG_ID, { type: "earpiece" }); + } + + return available; + }, + ), + ); + + private readonly deviceSelection$ = new Subject(); + + public select(id: string): void { + this.logger.info(`select device: ${id}`); + this.deviceSelection$.next(id); + } + + public readonly selected$ = this.scope.behavior( + combineLatest( + [ + this.available$, + merge( + controlledOutputSelection$.pipe(startWith(undefined)), + this.deviceSelection$, + ), + ], + (available, preferredId) => { + const id = preferredId ?? available.keys().next().value; + return id === undefined + ? undefined + : { id, virtualEarpiece: id === EARPIECE_CONFIG_ID }; + }, + ).pipe( + tap((selected) => { + this.logger.debug(`selected device: ${selected?.id}`); + }), + ), + ); + + public constructor( + private readonly usingNames$: Behavior, + private readonly scope: ObservableScope, + ) { + this.selected$.subscribe((device) => { + // Let the hosting application know which output device has been selected. + // This information is probably only of interest if the earpiece mode has + // been selected - for example, Element X iOS listens to this to determine + // whether it should enable the proximity sensor. + if (device !== undefined) { + this.logger.info("onAudioDeviceSelect called:", device); + window.controls.onAudioDeviceSelect?.(device.id); + // Also invoke the deprecated callback for backward compatibility + window.controls.onOutputDeviceSelect?.(device.id); + } + }); + this.available$.subscribe((available) => { + this.logger.debug("available devices:", available); + }); + this.availableRaw$.subscribe((availableRaw) => { + this.logger.debug("available raw devices:", availableRaw); + }); + } +} diff --git a/src/state/MediaDevices.ts b/src/state/MediaDevices.ts index 052f55e6..d74002b5 100644 --- a/src/state/MediaDevices.ts +++ b/src/state/MediaDevices.ts @@ -9,37 +9,28 @@ import { combineLatest, filter, map, - merge, + type Observable, pairwise, - startWith, Subject, switchMap, - type Observable, - tap, } from "rxjs"; import { createMediaDeviceObserver } from "@livekit/components-core"; import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { + alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting, audioInput as audioInputSetting, audioOutput as audioOutputSetting, videoInput as videoInputSetting, - alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting, } from "../settings/settings"; import { type ObservableScope } from "./ObservableScope"; -import { - outputDevice$ as controlledOutputSelection$, - availableOutputDevices$ as controlledAvailableOutputDevices$, -} from "../controls"; +import { availableOutputDevices$ as controlledAvailableOutputDevices$ } from "../controls"; import { getUrlParams } from "../UrlParams"; import { platform } from "../Platform"; import { switchWhen } from "../utils/observable"; import { type Behavior, constant } from "./Behavior"; import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput.ts"; - -// This hardcoded id is used in EX ios! It can only be changed in coordination with -// the ios swift team. -const EARPIECE_CONFIG_ID = "earpiece-id"; +import { ControlledAudioOutput } from "./ControlledAudioOutput.ts"; export type DeviceLabel = | { type: "name"; name: string } @@ -127,7 +118,7 @@ export interface MediaDevice { export const iosDeviceMenu$ = platform === "ios" ? constant(true) : alwaysShowIphoneEarpieceSetting.value$; -function availableRawDevices$( +export function availableRawDevices$( kind: MediaDeviceKind, usingNames$: Behavior, scope: ObservableScope, @@ -175,9 +166,6 @@ function buildDeviceMap( function selectDevice$