From bd3e91738e7849d3d884e9a7e73b5008ee8861cc Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 27 Nov 2025 15:06:36 +0100 Subject: [PATCH 01/91] 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 1ff066ea6..582910272 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 254aaf0f4..77775ad73 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 f85e1414b..85af45792 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 0a85bbc13..8926e97be 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 02/91] 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 77775ad73..7f90c4c16 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 85af45792..03008dca8 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 03/91] 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 8926e97be..0a85bbc13 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 04/91] i18n --- locales/en/app.json | 1 - 1 file changed, 1 deletion(-) diff --git a/locales/en/app.json b/locales/en/app.json index 582910272..1ff066ea6 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 05/91] 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 c18cf23bb..7bdf7091d 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 7f90c4c16..d16e3ef7f 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 2b4078aa5..30ac36185 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 06/91] 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 d16e3ef7f..0b77f0957 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 07/91] 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 7bdf7091d..51255666d 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 08/91] 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 9e3ae0c2e..4820d14c0 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 09/91] 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 fe539571b..d10e33622 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 10/91] 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 1ddedc147..77ea81d63 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 73245e875dd674e7fb3ad8b9714f2f9252aeffd5 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 10 Feb 2026 15:11:43 +0100 Subject: [PATCH 11/91] 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 4b7ba22fc..3f964ad47 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 000000000..784f0c7ef --- /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 12/91] 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 82bcaedb7..5f7303c6a 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 13/91] 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 26d0839b0..c288f73eb 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 14/91] 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 82bcaedb7..e2c6e46ee 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 2f38ad822..eb641ca7c 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 733640941..0b566ba07 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 => { - /** - * The transport over which we should be actively publishing our media. - * undefined when not joined. - */ - 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, - ); - +}: Props): LocalTransport => { /** - * The transport that we would personally prefer to publish on (if not for the - * transport preferences of others, perhaps). - * - * @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken + * The LiveKit transport in use by the oldest RTC membership. `null` when the + * oldest member has no such transport. */ - 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]) => { + 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( - "Creating preferred transport based on: ", - "customUrl: ", - customUrl, - "delayId: ", - delayId, - "forceEndpoint: ", - forceEndpoint, - ); - return from( - makeTransport( - client, - ownMembershipIdentity, - roomId, - customUrl, - forceEndpoint, - delayId ?? undefined, - ), + "Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has valid transport", ); + return transport; }), + distinctUntilChanged(areLivekitTransportsEqual), ), - null, ); /** - * The chosen transport we should advertise in our MatrixRTC membership. + * The transport that we would personally prefer to publish on (if not for the + * transport preferences of others, perhaps). `null` until fetched and + * validated. + * + * @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken */ - 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, - ); - }), + 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), + ), + ), + ), + ), + ), + ); + + 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 e4376c12b..727f68bcc 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 15/91] 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 000000000..9c3f14869 --- /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 16/91] 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 6c6fa5fbb..b1d27dec7 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 17/91] 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 e2c6e46ee..90bf71feb 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 d4182021b..2e19748b1 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 18/91] 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 b228cd08a..e5e9f3277 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 2476923a5..e63f7c72a 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 19/91] 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 e63f7c72a..8454b09af 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 20/91] 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 4ad1a551b..dbde6c76d 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 275397b52..fc8a640fa 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 3251f50e7..012de7cb8 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 76fe418c8..45f366cdd 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 21/91] 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 3796715cf..f32869df1 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 22/91] 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 3da69c462..872884245 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 e4f5de1f3..0a241cdfc 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 74d24e2f9..2adc9134a 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 23/91] 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 872884245..7f8066973 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 0a241cdfc..6c908b1f6 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 24/91] 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 a474daed4..e24e9823f 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 f8ee22fbc..311011976 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 25/91] 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 | 757 ++++++++++------------- 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, 843 insertions(+), 895 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 61afb7b9e..7f34d86fc 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 000000000..947fdfd44 --- /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 92868216a..73b396db6 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 7f8066973..c8c468acf 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 { /** - * The LiveKit video track for this media. + * An opaque identifier for this media. */ - public readonly video$: Behavior; + id: string; /** - * Whether there should be a warning that this media is unencrypted. + * The Matrix user to which this media belongs. */ - public readonly unencryptedWarning$: Behavior; + userId: string; + displayName$: Behavior; + mxcAvatarUrl$: Behavior; +} + +type BaseMediaInputs = BaseMediaViewModel; - public readonly encryptionStatus$: Behavior; +// 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; /** - * Whether this media corresponds to the local participant. + * The URL of the LiveKit focus on which this member should be publishing. + * Exposed for debugging. */ - public abstract readonly local: boolean; + 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; +} - 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 - >, - - 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( + + const audio$ = trackBehavior$(audioSource); + const video$ = trackBehavior$(videoSource); + + 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; - -/** - * 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), - ), ), - ); - /** - * Whether the participant is speaking. - */ - // Getter backed by a private field so that subclasses can override it - public get speaking$(): Behavior { - return this._speaking$; - } + }; +} +interface BaseUserMediaViewModel extends MemberMediaViewModel { + type: "user"; + speaking$: Behavior; + audioEnabled$: Behavior; + videoEnabled$: Behavior; + cropVideo$: Behavior; + toggleCropVideo: () => void; /** - * Whether this participant is sending audio (i.e. is unmuted on their side). + * The expected identity of the LiveKit participant. Exposed for debugging. */ - public readonly audioEnabled$: Behavior; + rtcBackendIdentity: string; + handRaised$: Behavior; + reaction$: Behavior; + audioStreamStats$: Observable< + RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined + >; + videoStreamStats$: Observable< + RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined + >; +} - 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$; - } +interface BaseUserMediaInputs extends Omit< + MemberMediaInputs, + "audioSource" | "videoSource" +> { + rtcBackendIdentity: string; + handRaised$: Behavior; + reaction$: Behavior; + statsType: "inbound-rtp" | "outbound-rtp"; +} - 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$, - ); +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(); - 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); - } + ), + 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); + }), + ), + }; +} - public get local(): boolean { - return this instanceof LocalUserMediaViewModel; - } +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>; +} - public abstract get audioStreamStats$(): Observable< - RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined - >; - public abstract get videoStreamStats$(): Observable< - RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined - >; +export interface LocalUserMediaInputs extends Omit< + BaseUserMediaInputs, + "statsType" +> { + participant$: Behavior; + mediaDevices: MediaDevices; } -/** - * The local participant's user media. - */ -export class LocalUserMediaViewModel extends BaseUserMediaViewModel { +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)), - ); + waitingForMedia$: Behavior; +} - 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$, - ); +export interface RemoteUserMediaInputs extends Omit< + BaseUserMediaInputs, + "statsType" +> { + participant$: Behavior; + pretendToBeDisconnected$: Behavior; +} - this.__speaking$ = this.scope.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) : super.speaking$, + disconnected ? of(false) : baseUserMedia.speaking$, ), ), - ); - - this.__videoEnabled$ = this.scope.behavior( + ), + videoEnabled$: scope.behavior( pretendToBeDisconnected$.pipe( switchMap((disconnected) => - disconnected ? of(false) : super.videoEnabled$, + disconnected ? of(false) : baseUserMedia.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(), + ), + 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, ), - ]).subscribe(([p, volume]) => p?.setVolume(volume)); - } + ), + }; +} - public toggleLocallyMuted(): void { - this.locallyMutedToggle$.next(); - } +interface BaseScreenShareViewModel extends MemberMediaViewModel { + type: "screen share"; +} - public setLocalVolume(value: number): void { - this.localVolumeAdjustment$.next(value); - } +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", + }; +} - public commitLocalVolume(): void { - this.localVolumeCommit$.next(); - } +export interface LocalScreenShareViewModel extends BaseScreenShareViewModel { + local: true; +} - public audioStreamStats$ = combineLatest([ - this.participant$, - showConnectionStats.value$, - ]).pipe( - switchMap(([p, showConnectionStats]) => { - if (!p || !showConnectionStats) return of(undefined); - return observeInboundRtpStreamStats$(p, Track.Source.Microphone); - }), - ); +interface LocalScreenShareInputs extends BaseScreenShareInputs { + participant$: Behavior; +} - public videoStreamStats$ = combineLatest([ - this.participant$, - showConnectionStats.value$, - ]).pipe( - switchMap(([p, showConnectionStats]) => { - if (!p || !showConnectionStats) return of(undefined); - return observeInboundRtpStreamStats$(p, Track.Source.Camera); - }), - ); +export function createLocalScreenShare( + scope: ObservableScope, + inputs: LocalScreenShareInputs, +): LocalScreenShareViewModel { + return { ...createBaseScreenShare(scope, inputs), local: true }; } -/** - * Some participant's screen share media. - */ -export class ScreenShareViewModel extends BaseMediaViewModel { +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)), - ); + videoEnabled$: Behavior; +} - 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$, - ); - } +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 6c908b1f6..000000000 --- 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 2adc9134a..000000000 --- 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 000000000..beb7ae006 --- /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 9bc0efb29..02f09a17b 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 92262f05e..370e07232 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 e685327e6..c0f1135f2 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 2e19748b1..353dc877b 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 d78bdf423..9231a3d1c 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 26/91] 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 dbde6c76d..68f7131cb 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 27/91] 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 28682a33e..8d885399d 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 28/91] Update all non-major dependencies --- package.json | 6 +- yarn.lock | 1437 +++++++++++++++++++++++++++----------------------- 2 files changed, 773 insertions(+), 670 deletions(-) diff --git a/package.json b/package.json index 496121204..8602516ff 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 b1d27dec7..d8416b4df 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" - 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" + version: 7.29.0 + resolution: "@babel/core@npm:7.29.0" + dependencies: + "@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,29 +189,29 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.28.0": - version: 7.28.0 - resolution: "@babel/generator@npm:7.28.0" +"@babel/generator@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/generator@npm:7.28.5" dependencies: - "@babel/parser": "npm:^7.28.0" - "@babel/types": "npm:^7.28.0" + "@babel/parser": "npm:^7.28.5" + "@babel/types": "npm:^7.28.5" "@jridgewell/gen-mapping": "npm:^0.3.12" "@jridgewell/trace-mapping": "npm:^0.3.28" jsesc: "npm:^3.0.2" - checksum: 10c0/1b3d122268ea3df50fde707ad864d9a55c72621357d5cebb972db3dd76859c45810c56e16ad23123f18f80cc2692f5a015d2858361300f0f224a05dc43d36a92 + checksum: 10c0/9f219fe1d5431b6919f1a5c60db8d5d34fe546c0d8f5a8511b32f847569234ffc8032beb9e7404649a143f54e15224ecb53a3d11b6bb85c3203e573d91fca752 languageName: node linkType: hard -"@babel/generator@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/generator@npm:7.28.5" +"@babel/generator@npm:^7.29.0": + version: 7.29.1 + resolution: "@babel/generator@npm:7.29.1" dependencies: - "@babel/parser": "npm:^7.28.5" - "@babel/types": "npm:^7.28.5" + "@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/9f219fe1d5431b6919f1a5c60db8d5d34fe546c0d8f5a8511b32f847569234ffc8032beb9e7404649a143f54e15224ecb53a3d11b6bb85c3203e573d91fca752 + checksum: 10c0/349086e6876258ef3fb2823030fee0f6c0eb9c3ebe35fc572e16997f8c030d765f636ddc6299edae63e760ea6658f8ee9a2edfa6d6b24c9a80c917916b973551 languageName: node linkType: hard @@ -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,25 +618,25 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.28.0": - version: 7.28.0 - resolution: "@babel/parser@npm:7.28.0" +"@babel/parser@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/parser@npm:7.28.5" dependencies: - "@babel/types": "npm:^7.28.0" + "@babel/types": "npm:^7.28.5" bin: parser: ./bin/babel-parser.js - checksum: 10c0/c2ef81d598990fa949d1d388429df327420357cb5200271d0d0a2784f1e6d54afc8301eb8bdf96d8f6c77781e402da93c7dc07980fcc136ac5b9d5f1fce701b5 + checksum: 10c0/5bbe48bf2c79594ac02b490a41ffde7ef5aa22a9a88ad6bcc78432a6ba8a9d638d531d868bd1f104633f1f6bba9905746e15185b8276a3756c42b765d131b1ef languageName: node linkType: hard -"@babel/parser@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/parser@npm:7.28.5" +"@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.28.5" + "@babel/types": "npm:^7.29.0" bin: parser: ./bin/babel-parser.js - checksum: 10c0/5bbe48bf2c79594ac02b490a41ffde7ef5aa22a9a88ad6bcc78432a6ba8a9d638d531d868bd1f104633f1f6bba9905746e15185b8276a3756c42b765d131b1ef + checksum: 10c0/333b2aa761264b91577a74bee86141ef733f9f9f6d4fc52548e4847dc35dfbf821f58c46832c637bfa761a6d9909d6a68f7d1ed59e17e4ffbb958dc510c17b62 languageName: node linkType: hard @@ -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" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10c0/76687ed37216ff012c599870dc00183fb716f22e1a02fe9481943664c0e4d0d88c3da347dc3fe290d4728f4d47cd594ffa621d23845e2bb8ab446e586308e066 - 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" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.27.1" - "@babel/template": "npm:^7.27.1" + "@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/e09a12f8c8ae0e6a6144c102956947b4ec05f6c844169121d0ec4529c2d30ad1dc59fee67736193b87a402f44552c888a519a680a31853bdb4d34788c28af3b0 + checksum: 10c0/dc22f1f6eadab17305128fbf9cc5f30e87a51a77dd0a6d5498097994e8a9b9a90ab298c11edf2342acbeaac9edc9c601cad72eedcf4b592cd465a787d7f41490 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" +"@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/traverse": "npm:^7.28.0" + "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/template": "npm:^7.28.6" 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-module-transforms": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.28.6" + peerDependencies: + "@babel/core": ^7.0.0-0 + 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.28.5" + "@babel/traverse": "npm:^7.29.0" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/7e8c0bcff79689702b974f6a0fedb5d0c6eeb5a5e3384deb7028e7cfe92a5242cc80e981e9c1817aad29f2ecc01841753365dd38d877aa0b91737ceec2acfd07 + 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,23 +1773,23 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.28.0": - version: 7.28.2 - resolution: "@babel/types@npm:7.28.2" +"@babel/types@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/types@npm:7.28.5" dependencies: "@babel/helper-string-parser": "npm:^7.27.1" - "@babel/helper-validator-identifier": "npm:^7.27.1" - checksum: 10c0/24b11c9368e7e2c291fe3c1bcd1ed66f6593a3975f479cbb9dd7b8c8d8eab8a962b0d2fca616c043396ce82500ac7d23d594fbbbd013828182c01596370a0b10 + "@babel/helper-validator-identifier": "npm:^7.28.5" + checksum: 10c0/a5a483d2100befbf125793640dec26b90b95fd233a94c19573325898a5ce1e52cdfa96e495c7dcc31b5eca5b66ce3e6d4a0f5a4a62daec271455959f208ab08a languageName: node linkType: hard -"@babel/types@npm:^7.28.4, @babel/types@npm:^7.28.5": - version: 7.28.5 - resolution: "@babel/types@npm:7.28.5" +"@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/a5a483d2100befbf125793640dec26b90b95fd233a94c19573325898a5ce1e52cdfa96e495c7dcc31b5eca5b66ce3e6d4a0f5a4a62daec271455959f208ab08a + checksum: 10c0/23cc3466e83bcbfab8b9bd0edaafdb5d4efdb88b82b3be6728bbade5ba2f0996f84f63b1c5f7a8c0d67efded28300898a5f930b171bb40b311bca2029c4e9b4f languageName: node linkType: hard @@ -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" - 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" + version: 8.56.1 + resolution: "@typescript-eslint/eslint-plugin@npm:8.56.1" + dependencies: + "@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 29/91] 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 8602516ff..e5ba595d3 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 d8416b4df..32903d88a 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 30/91] 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 e5ba595d3..705b0f103 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 32903d88a..4675d0e1a 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 31/91] 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 | 26 +- 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, 954 insertions(+), 770 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 7f34d86fc..c19c4818d 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 8df38743f..b7841c498 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 c8c468acf..000000000 --- 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 7b95bd8e5..a954eb4e1 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 a645a0d19..8b13c685a 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 f32869df1..33796f668 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 000000000..b31739d92 --- /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 000000000..fd21428bc --- /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 947fdfd44..6cd80045a 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 { 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 { type UserMediaViewModel } from "./UserMediaViewModel.ts"; +import { type ScreenShareViewModel } from "./ScreenShareViewModel.ts"; import { - createLocalScreenShare, createLocalUserMedia, - createRemoteScreenShare, - createRemoteUserMedia, - type ScreenShareViewModel, - type UserMediaViewModel, type LocalUserMediaInputs, +} from "./LocalUserMediaViewModel.ts"; +import { + createRemoteUserMedia, 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"; +} 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 73b396db6..71475b8c9 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 000000000..bdc4875b7 --- /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 000000000..de64d93b1 --- /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 000000000..eff6d9c14 --- /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 000000000..4307dea41 --- /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 000000000..36cd94404 --- /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 000000000..8da5e63aa --- /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 000000000..695edc657 --- /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 224916d20..18622fb80 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 000000000..8e295d057 --- /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 370e07232..9c3adea78 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 fdfdb631e..a509d3a5a 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 c349fc7e3..f912c069f 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 c0f1135f2..75c69479a 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 9231a3d1c..c1e679271 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 32/91] 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 92868216a..a7bbb571c 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 3da69c462..57b0428ad 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 92262f05e..ad158db12 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 000000000..9390e8d44 --- /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 000000000..fdd91be72 --- /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 33/91] 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 0b0ac7b4d..9a85478f8 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 34/91] 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 9bc0efb29..060119efb 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 981c03694..441b74c4d 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 35/91] 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 de64d93b1..b7c2549d3 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 695edc657..d1640382a 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 36/91] 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 b7c2549d3..e7f57b59e 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 37/91] 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 68f7131cb..f6eeb7c23 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 38/91] 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 5a91e19e6..a93dc56e8 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 7705927e9..de413606b 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 39/91] 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 ba99f8263..a0b1309be 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 40/91] 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 f6eeb7c23..6e8f01f54 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 41/91] 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 9390e8d44..5068526ba 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 c7b18f03c..5f2cc2cee 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 42/91] 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 9b86215e8..4f9e80f2b 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 fe934162f..62b37acae 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 7f2c58fe2..ade91019d 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 43/91] 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 eff6d9c14..78fc0494b 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 44/91] 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 0b0ac7b4d..6ec4859e0 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 622496d23..43c3d53c7 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 75c69479a..93a1fbd9d 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 6cddc95f6..ca6aa467a 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 178d13f3b..fe13f906b 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 60/91] 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 fe13f906b..af0e0add5 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 61/91] 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 | 226 ++++++++++++++++++++++++++------------------------- 2 files changed, 118 insertions(+), 112 deletions(-) diff --git a/package.json b/package.json index 705b0f103..9ee0ad263 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 4675d0e1a..bd90ded3e 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" - 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@npm:^11.19.1": + version: 11.19.1 + resolution: "oxc-resolver@npm:11.19.1" + dependencies: + "@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 62/91] Silence new Knip errors --- knip.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/knip.ts b/knip.ts index d23d42fec..3be3e6539 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 63/91] 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 e68c0f42f..787ddc730 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 64/91] 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 f64dd3eea..9d873ccba 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 65/91] 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 aceb07cf5..d8803b22e 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 143b560b20e46ca87b7cd802198d6ec12e65009a Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 10 Mar 2026 09:05:05 +0100 Subject: [PATCH 66/91] 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 b42c0ab25..d4ba00068 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 000000000..49ebec524 --- /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 6fe4479be..4562ba5a1 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 67/91] Implement new Pip Layout (with control buttons) --- src/button/Button.tsx | 9 ++++++- src/room/InCallView.module.css | 31 ++++++++++++++---------- src/room/InCallView.tsx | 14 ++++++++++- src/state/CallViewModel/CallViewModel.ts | 2 +- src/state/PipLayout.ts | 3 ++- 5 files changed, 42 insertions(+), 17 deletions(-) diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 3136e2da2..4bb0058de 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 55724932d..70f7c73a4 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -108,14 +108,31 @@ Please see LICENSE in the repository root for full details. } } +@media (max-height: 800px) { + .footer { + padding-block: var(--cpd-space-8x); + } +} + +@media (max-height: 400px) { + .footer { + padding-block: var(--cpd-space-4x); + } +} + @media (max-width: 370px) { .shareScreen { display: none; } + /* PIP custom css */ @media (max-height: 400px) { + .shareScreen { + display: flex; + } .footer { - display: none; + padding-block-start: var(--cpd-space-3x); + padding-block-end: var(--cpd-space-2x); } } } @@ -127,18 +144,6 @@ Please see LICENSE in the repository root for full details. } } -@media (max-height: 400px) { - .footer { - padding-block: var(--cpd-space-4x); - } -} - -@media (max-height: 800px) { - .footer { - padding-block: var(--cpd-space-8x); - } -} - @media (min-width: 800px) { .buttons { gap: var(--cpd-space-4x); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index d8803b22e..f1a872a08 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 56e9aeb22..25a84e949 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 68/91] 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 4bb0058de..51fc0a1f4 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 0c722bafa..3e8f647fa 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 69/91] 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 9a9ac11cc..18e49d0a9 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 25a84e949..6ac1e4f02 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 70/91] 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 51fc0a1f4..00d803f1f 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 3e8f647fa..28163321c 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 71/91] 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 000000000..776596725 --- /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 72/91] remove unsused import --- .../pip-call-button-interaction.test.ts | 115 +++++++++--------- 1 file changed, 56 insertions(+), 59 deletions(-) diff --git a/playwright/widget/pip-call-button-interaction.test.ts b/playwright/widget/pip-call-button-interaction.test.ts index 776596725..84cee3faf 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", - ); - - 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"); - } - }, -); +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(); + + const valere = await addUser("Valere", HOST1); + await valere.page.pause(); + 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 c9557e91d5453a2085b5c62c034eb6aa5346bafc Mon Sep 17 00:00:00 2001 From: fkwp Date: Wed, 11 Mar 2026 13:06:20 +0100 Subject: [PATCH 73/91] 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 4f9e80f2b..32ce25c98 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 62b37acae..138ab2b5f 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 ade91019d..ceedf7813 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 74/91] 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 84cee3faf..a646db2f1 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 75/91] 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 6e8f01f54..65d499f75 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 4ca5ccade..300138e0a 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 3f964ad47..766093289 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 32dde8690..763d2eacb 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 fc8a640fa..1c6cd7b1c 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 ceedf7813..0675b1b1e 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 012de7cb8..cd1c94c56 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 45f366cdd..ad9f46526 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 4c0625131..daf968958 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 000000000..612adbd14 --- /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 76/91] 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 65d499f75..a3a5cba77 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 77/91] 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 a646db2f1..f798545d4 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(audioMuteButton).toHaveCSS( + "background-color", + "rgb(27, 29, 34)", + ); - await expect(videoMuteButton).toHaveCSS("disabled", "true"); - await expect(audioMuteButton).toHaveCSS("disabled", "true"); + 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 78/91] 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 49ebec524..d57befc1a 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 79/91] 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 f798545d4..1dda652df 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 80/91] 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 a3a5cba77..6447c094c 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 81/91] 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 | 539 +++++++++++------------ 5 files changed, 268 insertions(+), 301 deletions(-) diff --git a/package.json b/package.json index 9ee0ad263..30d63a2ed 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 c6122b4bf..bc6ef6687 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 f74cf0160..97bfff5cb 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 b99bfa885..f8d420cf2 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 bd90ded3e..552e77688 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" - 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@npm:4.59.0": + version: 4.59.0 + resolution: "rollup@npm:4.59.0" + dependencies: + "@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 82/91] 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 12a4b0204..cc7db7477 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 300138e0a..3ddabb0b6 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 8d9acbc2a..f11d3130a 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 138ab2b5f..0084b76b9 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 1c6cd7b1c..3ab275a5d 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 83/91] 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 9ee0ad263..da0861729 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 bd90ded3e..b15aeefcd 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 84/91] 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 30d63a2ed..19c74d605 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 552e77688..38c2e7aca 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 7da9bca08c5c50ae41478296837ab688658510ef Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 13 Mar 2026 07:59:49 +0100 Subject: [PATCH 85/91] update qs, js-yaml, glob for security patches --- package.json | 5 +- yarn.lock | 160 ++++++++++++++------------------------------------- 2 files changed, 47 insertions(+), 118 deletions(-) diff --git a/package.json b/package.json index 04a48dc2b..cc8a36eb1 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 a3f6b921b..12e1b8578 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,21 +9281,21 @@ __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" +"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/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 + checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 languageName: node linkType: hard -"esquery@npm:^1.7.0": - version: 1.7.0 - resolution: "esquery@npm:1.7.0" +"esquery@npm:^1.6.0": + version: 1.6.0 + resolution: "esquery@npm:1.6.0" dependencies: estraverse: "npm:^5.1.0" - checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 + checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 languageName: node linkType: hard @@ -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 86/91] Revert "update qs, js-yaml, glob for security patches" This reverts commit 7da9bca08c5c50ae41478296837ab688658510ef. --- package.json | 5 +- yarn.lock | 160 +++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 118 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index cc8a36eb1..04a48dc2b 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 12e1b8578..a3f6b921b 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,21 +9308,21 @@ __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" +"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/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 + checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 languageName: node linkType: hard -"esquery@npm:^1.6.0": - version: 1.6.0 - resolution: "esquery@npm:1.6.0" +"esquery@npm:^1.7.0": + version: 1.7.0 + resolution: "esquery@npm:1.7.0" dependencies: estraverse: "npm:^5.1.0" - checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 + checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 languageName: node linkType: hard @@ -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 87/91] update qs, js-yaml, glob for security patches --- package.json | 5 +- yarn.lock | 160 ++++++++++++++------------------------------------- 2 files changed, 47 insertions(+), 118 deletions(-) diff --git a/package.json b/package.json index 04a48dc2b..cc8a36eb1 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 a3f6b921b..12e1b8578 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,21 +9281,21 @@ __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" +"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/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 + checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 languageName: node linkType: hard -"esquery@npm:^1.7.0": - version: 1.7.0 - resolution: "esquery@npm:1.7.0" +"esquery@npm:^1.6.0": + version: 1.6.0 + resolution: "esquery@npm:1.6.0" dependencies: estraverse: "npm:^5.1.0" - checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 + checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 languageName: node linkType: hard @@ -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 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 88/91] 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 6447c094c..3b18b133b 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 612adbd14..d3b6e9698 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 3de9d498b016ca8ba12142e9ac231032fb3f2ac4 Mon Sep 17 00:00:00 2001 From: melogale Date: Mon, 16 Mar 2026 02:02:36 -0400 Subject: [PATCH 89/91] format --- src/Slider.tsx | 9 +++- src/room/VideoPreview.tsx | 5 +-- src/settings/SettingsModal.tsx | 35 ++++++++------- src/settings/settings.ts | 43 +++++++++---------- .../CallViewModel/localMember/LocalMember.ts | 4 +- 5 files changed, 52 insertions(+), 44 deletions(-) diff --git a/src/Slider.tsx b/src/Slider.tsx index c2465874b..29f9ef426 100644 --- a/src/Slider.tsx +++ b/src/Slider.tsx @@ -77,7 +77,14 @@ export const Slider: FC = ({ {/* Note: This is expected not to be visible on mobile.*/} - + diff --git a/src/room/VideoPreview.tsx b/src/room/VideoPreview.tsx index a79359b1f..bfd656b83 100644 --- a/src/room/VideoPreview.tsx +++ b/src/room/VideoPreview.tsx @@ -11,10 +11,7 @@ import { facingModeFromLocalTrack, type LocalVideoTrack } from "livekit-client"; import classNames from "classnames"; import { useTranslation } from "react-i18next"; -import { - allowPipSetting, - useSetting, -} from "../settings/settings"; +import { allowPipSetting, useSetting } from "../settings/settings"; import { TileAvatar } from "../tile/TileAvatar"; import styles from "./VideoPreview.module.css"; diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 86d39e66d..0daad7be9 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -223,9 +223,7 @@ export const SettingsModal: FC = ({