From c78461db0b30da8c8fda706b47af4d49b6b8ab66 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 30 Oct 2025 00:15:52 +0530 Subject: [PATCH 01/21] Implement new builtins api --- .../{BuiltinsApi.ts => BuiltinsApi.tsx} | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) rename src/modules/{BuiltinsApi.ts => BuiltinsApi.tsx} (57%) diff --git a/src/modules/BuiltinsApi.ts b/src/modules/BuiltinsApi.tsx similarity index 57% rename from src/modules/BuiltinsApi.ts rename to src/modules/BuiltinsApi.tsx index 64c2dc4728..7d1d27c6f1 100644 --- a/src/modules/BuiltinsApi.ts +++ b/src/modules/BuiltinsApi.tsx @@ -5,10 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +import React from "react"; import { type RoomViewProps, type BuiltinsApi } from "@element-hq/element-web-module-api"; +import RoomAvatar from "../components/views/avatars/RoomAvatar"; +import { MatrixClientPeg } from "../MatrixClientPeg"; + +interface RoomViewPropsWithRoomId extends RoomViewProps { + roomId: string; +} + export class ElementWebBuiltinsApi implements BuiltinsApi { - private _roomView?: React.ComponentType; + private _roomView?: React.ComponentType; /** * Sets the components used to render a RoomView @@ -19,15 +27,28 @@ export class ElementWebBuiltinsApi implements BuiltinsApi { * * @param component The RoomView component */ - public setRoomViewComponent(component: React.ComponentType): void { + public setRoomViewComponent(component: React.ComponentType): void { this._roomView = component; } - public getRoomViewComponent(): React.ComponentType { + public getRoomViewComponent(): React.ComponentType { if (!this._roomView) { throw new Error("No RoomView component has been set"); } return this._roomView; } + + public renderRoomView(roomId: string): React.ReactNode { + const Component = this.getRoomViewComponent(); + return ; + } + + public renderRoomAvatar(roomId: string, size?: string): React.ReactNode { + const room = MatrixClientPeg.safeGet().getRoom(roomId); + if (!room) { + throw new Error(`No room such room: ${roomId}`); + } + return ; + } } From 1c0738be0f1b6ae958a1f0497c21c7464edc6f44 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 30 Oct 2025 00:16:11 +0530 Subject: [PATCH 02/21] Add tests --- test/unit-tests/modules/BuiltinsApi-test.ts | 17 ------- test/unit-tests/modules/BuiltinsApi-test.tsx | 51 ++++++++++++++++++++ 2 files changed, 51 insertions(+), 17 deletions(-) delete mode 100644 test/unit-tests/modules/BuiltinsApi-test.ts create mode 100644 test/unit-tests/modules/BuiltinsApi-test.tsx diff --git a/test/unit-tests/modules/BuiltinsApi-test.ts b/test/unit-tests/modules/BuiltinsApi-test.ts deleted file mode 100644 index 38ddec0c56..0000000000 --- a/test/unit-tests/modules/BuiltinsApi-test.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* -Copyright 2025 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { ElementWebBuiltinsApi } from "../../../src/modules/BuiltinsApi"; - -describe("ElementWebBuiltinsApi", () => { - it("returns the RoomView component thats been set", () => { - const builtinsApi = new ElementWebBuiltinsApi(); - const sentinel = {}; - builtinsApi.setRoomViewComponent(sentinel as any); - expect(builtinsApi.getRoomViewComponent()).toBe(sentinel); - }); -}); diff --git a/test/unit-tests/modules/BuiltinsApi-test.tsx b/test/unit-tests/modules/BuiltinsApi-test.tsx new file mode 100644 index 0000000000..aa1ca4c333 --- /dev/null +++ b/test/unit-tests/modules/BuiltinsApi-test.tsx @@ -0,0 +1,51 @@ +/* +Copyright 2025 New Vector Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { render } from "jest-matrix-react"; + +import { ElementWebBuiltinsApi } from "../../../src/modules/BuiltinsApi"; +import { stubClient } from "../../test-utils/test-utils"; + +jest.mock("../../../src/components/views/avatars/RoomAvatar", () => { + const Avatar: React.FC<{ room: { roomId: string }; size: string }> = ({ room, size }) => { + return ( +
+ Avatar, {room.roomId}, {size} +
+ ); + }; + return { + __esModule: true, + default: Avatar, + }; +}); + +describe("ElementWebBuiltinsApi", () => { + it("returns the RoomView component thats been set", () => { + const builtinsApi = new ElementWebBuiltinsApi(); + const sentinel = {}; + builtinsApi.setRoomViewComponent(sentinel as any); + expect(builtinsApi.getRoomViewComponent()).toBe(sentinel); + }); + + it("returns rendered RoomView component", () => { + const builtinsApi = new ElementWebBuiltinsApi(); + const RoomView = () =>
hello world
; + builtinsApi.setRoomViewComponent(RoomView as any); + const { container } = render(<> {builtinsApi.renderRoomView("!foo:m.org")}); + expect(container).toHaveTextContent("hello world"); + }); + + it("returns rendered RoomAvatar component", () => { + stubClient(); + const builtinsApi = new ElementWebBuiltinsApi(); + const { container } = render(<> {builtinsApi.renderRoomAvatar("!foo:m.org", "50")}); + expect(container).toHaveTextContent("Avatar"); + expect(container).toHaveTextContent("!foo:m.org"); + expect(container).toHaveTextContent("50"); + }); +}); From f2104b5ec0168bc687469c1d7f10fe880049637e Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 30 Oct 2025 00:22:40 +0530 Subject: [PATCH 03/21] Fix import --- src/modules/Api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/Api.ts b/src/modules/Api.ts index e463f6c085..d51f051df1 100644 --- a/src/modules/Api.ts +++ b/src/modules/Api.ts @@ -27,7 +27,7 @@ import { NavigationApi } from "./Navigation.ts"; import { openDialog } from "./Dialog.tsx"; import { overwriteAccountAuth } from "./Auth.ts"; import { ElementWebExtrasApi } from "./ExtrasApi.ts"; -import { ElementWebBuiltinsApi } from "./BuiltinsApi.ts"; +import { ElementWebBuiltinsApi } from "./BuiltinsApi.tsx"; const legacyCustomisationsFactory = (baseCustomisations: T) => { let used = false; From 620ba9231d6b7c75e0fc024695bbc315350f8078 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 30 Oct 2025 01:53:50 +0530 Subject: [PATCH 04/21] Fix circular dependency issue --- src/modules/BuiltinsApi.tsx | 33 ++++++++++++++++---- src/vector/app.tsx | 8 ++--- test/unit-tests/modules/BuiltinsApi-test.tsx | 25 ++++++--------- 3 files changed, 41 insertions(+), 25 deletions(-) diff --git a/src/modules/BuiltinsApi.tsx b/src/modules/BuiltinsApi.tsx index 7d1d27c6f1..555d6c5ec3 100644 --- a/src/modules/BuiltinsApi.tsx +++ b/src/modules/BuiltinsApi.tsx @@ -8,27 +8,39 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { type RoomViewProps, type BuiltinsApi } from "@element-hq/element-web-module-api"; -import RoomAvatar from "../components/views/avatars/RoomAvatar"; import { MatrixClientPeg } from "../MatrixClientPeg"; +import type { Room } from "matrix-js-sdk/src/matrix"; interface RoomViewPropsWithRoomId extends RoomViewProps { roomId: string; } +interface RoomAvatarProps { + room: Room; + size?: string; +} + +interface Components { + roomView: React.ComponentType; + roomAvatar: React.ComponentType; +} + export class ElementWebBuiltinsApi implements BuiltinsApi { private _roomView?: React.ComponentType; + private _roomAvatar?: React.ComponentType; /** - * Sets the components used to render a RoomView + * Sets the components used by the API. * - * This only really exists here because referencing RoomView directly causes a nightmare of + * This only really exists here because referencing these components directly causes a nightmare of * circular dependencies that break the whole app, so instead we avoid referencing it here * and pass it in from somewhere it's already referenced (see related comment in app.tsx). * * @param component The RoomView component */ - public setRoomViewComponent(component: React.ComponentType): void { - this._roomView = component; + public setComponents(components: Components): void { + this._roomView = components.roomView; + this._roomAvatar = components.roomAvatar; } public getRoomViewComponent(): React.ComponentType { @@ -39,6 +51,14 @@ export class ElementWebBuiltinsApi implements BuiltinsApi { return this._roomView; } + public getRoomAvatarComponent(): React.ComponentType { + if (!this._roomAvatar) { + throw new Error("No RoomAvatar component has been set"); + } + + return this._roomAvatar; + } + public renderRoomView(roomId: string): React.ReactNode { const Component = this.getRoomViewComponent(); return ; @@ -49,6 +69,7 @@ export class ElementWebBuiltinsApi implements BuiltinsApi { if (!room) { throw new Error(`No room such room: ${roomId}`); } - return ; + const Component = this.getRoomAvatarComponent(); + return ; } } diff --git a/src/vector/app.tsx b/src/vector/app.tsx index 21b21e9252..870b51aff8 100644 --- a/src/vector/app.tsx +++ b/src/vector/app.tsx @@ -32,6 +32,7 @@ import { getInitialScreenAfterLogin, getScreenFromLocation, init as initRouting, import { UserFriendlyError } from "../languageHandler"; import { ModuleApi } from "../modules/Api"; import { RoomView } from "../components/structures/RoomView"; +import RoomAvatar from "../components/views/avatars/RoomAvatar"; logger.log(`Application is running in ${process.env.NODE_ENV} mode`); @@ -55,10 +56,9 @@ function onTokenLoginCompleted(): void { } export async function loadApp(fragParams: QueryDict, matrixChatRef: React.Ref): Promise { - // XXX: This lives here because RoomVew import so many things that importing it in a sensible place (eg. - // the builtins module or init.tsx) causes a circular dependency. Instead, we reference RoomView here where we - // already reference it indirectly via MatrixChat. - ModuleApi.instance.builtins.setRoomViewComponent(RoomView); + // XXX: This lives here because certain components import so many things that importing it in a sensible place (eg. + // the builtins module or init.tsx) causes a circular dependency. + ModuleApi.instance.builtins.setComponents({ roomView: RoomView, roomAvatar: RoomAvatar }); initRouting(); const platform = PlatformPeg.get(); diff --git a/test/unit-tests/modules/BuiltinsApi-test.tsx b/test/unit-tests/modules/BuiltinsApi-test.tsx index aa1ca4c333..c616d81cee 100644 --- a/test/unit-tests/modules/BuiltinsApi-test.tsx +++ b/test/unit-tests/modules/BuiltinsApi-test.tsx @@ -10,32 +10,26 @@ import { render } from "jest-matrix-react"; import { ElementWebBuiltinsApi } from "../../../src/modules/BuiltinsApi"; import { stubClient } from "../../test-utils/test-utils"; -jest.mock("../../../src/components/views/avatars/RoomAvatar", () => { - const Avatar: React.FC<{ room: { roomId: string }; size: string }> = ({ room, size }) => { - return ( -
- Avatar, {room.roomId}, {size} -
- ); - }; - return { - __esModule: true, - default: Avatar, - }; -}); +const Avatar: React.FC<{ room: { roomId: string }; size: string }> = ({ room, size }) => { + return ( +
+ Avatar, {room.roomId}, {size} +
+ ); +}; describe("ElementWebBuiltinsApi", () => { it("returns the RoomView component thats been set", () => { const builtinsApi = new ElementWebBuiltinsApi(); const sentinel = {}; - builtinsApi.setRoomViewComponent(sentinel as any); + builtinsApi.setComponents({ roomView: sentinel, roomAvatar: Avatar } as any); expect(builtinsApi.getRoomViewComponent()).toBe(sentinel); }); it("returns rendered RoomView component", () => { const builtinsApi = new ElementWebBuiltinsApi(); const RoomView = () =>
hello world
; - builtinsApi.setRoomViewComponent(RoomView as any); + builtinsApi.setComponents({ roomView: RoomView, roomAvatar: Avatar } as any); const { container } = render(<> {builtinsApi.renderRoomView("!foo:m.org")}); expect(container).toHaveTextContent("hello world"); }); @@ -43,6 +37,7 @@ describe("ElementWebBuiltinsApi", () => { it("returns rendered RoomAvatar component", () => { stubClient(); const builtinsApi = new ElementWebBuiltinsApi(); + builtinsApi.setComponents({ roomView: {}, roomAvatar: Avatar } as any); const { container } = render(<> {builtinsApi.renderRoomAvatar("!foo:m.org", "50")}); expect(container).toHaveTextContent("Avatar"); expect(container).toHaveTextContent("!foo:m.org"); From 8a875e8c6d9ae80419399bd54d088943bb86d6ba Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 30 Oct 2025 16:12:23 +0530 Subject: [PATCH 05/21] Fix import --- test/unit-tests/modules/BuiltinsApi-test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit-tests/modules/BuiltinsApi-test.tsx b/test/unit-tests/modules/BuiltinsApi-test.tsx index c616d81cee..473b2e4538 100644 --- a/test/unit-tests/modules/BuiltinsApi-test.tsx +++ b/test/unit-tests/modules/BuiltinsApi-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { render } from "jest-matrix-react"; -import { ElementWebBuiltinsApi } from "../../../src/modules/BuiltinsApi"; +import { ElementWebBuiltinsApi } from "../../../src/modules/BuiltinsApi.tsx"; import { stubClient } from "../../test-utils/test-utils"; const Avatar: React.FC<{ room: { roomId: string }; size: string }> = ({ room, size }) => { From a9fed64637a84d1e3073de1a93f185ba7240f96b Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 30 Oct 2025 16:29:50 +0530 Subject: [PATCH 06/21] Add more tests --- test/unit-tests/modules/BuiltinsApi-test.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/unit-tests/modules/BuiltinsApi-test.tsx b/test/unit-tests/modules/BuiltinsApi-test.tsx index 473b2e4538..3741733254 100644 --- a/test/unit-tests/modules/BuiltinsApi-test.tsx +++ b/test/unit-tests/modules/BuiltinsApi-test.tsx @@ -43,4 +43,11 @@ describe("ElementWebBuiltinsApi", () => { expect(container).toHaveTextContent("!foo:m.org"); expect(container).toHaveTextContent("50"); }); + + it("should throw error if called before components are set", () => { + stubClient(); + const builtinsApi = new ElementWebBuiltinsApi(); + expect(() => builtinsApi.renderRoomAvatar("!foo:m.org")).toThrow("No RoomAvatar component has been set"); + expect(() => builtinsApi.renderRoomView("!foo:m.org")).toThrow("No RoomView component has been set"); + }); }); From 61306a1e4a7d0928daf4583aad1816b419921e26 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 30 Oct 2025 16:31:59 +0530 Subject: [PATCH 07/21] Improve comment --- src/modules/BuiltinsApi.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/BuiltinsApi.tsx b/src/modules/BuiltinsApi.tsx index 555d6c5ec3..c26c6343b7 100644 --- a/src/modules/BuiltinsApi.tsx +++ b/src/modules/BuiltinsApi.tsx @@ -36,7 +36,7 @@ export class ElementWebBuiltinsApi implements BuiltinsApi { * circular dependencies that break the whole app, so instead we avoid referencing it here * and pass it in from somewhere it's already referenced (see related comment in app.tsx). * - * @param component The RoomView component + * @param component The components used by the api, see {@link Components} */ public setComponents(components: Components): void { this._roomView = components.roomView; From ebc9e3ace69d8f9aa343176391e0fab420976d74 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 30 Oct 2025 16:48:45 +0530 Subject: [PATCH 08/21] room-id is optional --- src/modules/BuiltinsApi.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/BuiltinsApi.tsx b/src/modules/BuiltinsApi.tsx index c26c6343b7..2554f2864e 100644 --- a/src/modules/BuiltinsApi.tsx +++ b/src/modules/BuiltinsApi.tsx @@ -12,7 +12,7 @@ import { MatrixClientPeg } from "../MatrixClientPeg"; import type { Room } from "matrix-js-sdk/src/matrix"; interface RoomViewPropsWithRoomId extends RoomViewProps { - roomId: string; + roomId?: string; } interface RoomAvatarProps { From eebf227cf4ccda7290575b80a984a95a8c27747a Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 30 Oct 2025 16:56:37 +0530 Subject: [PATCH 09/21] Update license --- src/modules/BuiltinsApi.tsx | 2 +- test/unit-tests/modules/BuiltinsApi-test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/BuiltinsApi.tsx b/src/modules/BuiltinsApi.tsx index 2554f2864e..b1225f56cb 100644 --- a/src/modules/BuiltinsApi.tsx +++ b/src/modules/BuiltinsApi.tsx @@ -1,5 +1,5 @@ /* -Copyright 2025 New Vector Ltd. +Copyright 2025 Element Creations Ltd. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. diff --git a/test/unit-tests/modules/BuiltinsApi-test.tsx b/test/unit-tests/modules/BuiltinsApi-test.tsx index 3741733254..2b3b1139a5 100644 --- a/test/unit-tests/modules/BuiltinsApi-test.tsx +++ b/test/unit-tests/modules/BuiltinsApi-test.tsx @@ -1,5 +1,5 @@ /* -Copyright 2025 New Vector Ltd. +Copyright 2025 Element Creations Ltd. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ From 2449557aa8a4f8b7073d6793b9df882e284b1819 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 30 Oct 2025 17:12:32 +0530 Subject: [PATCH 10/21] Add implementation for AccountDataApi --- src/modules/AccountDataApi.ts | 54 +++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/modules/AccountDataApi.ts diff --git a/src/modules/AccountDataApi.ts b/src/modules/AccountDataApi.ts new file mode 100644 index 0000000000..cf1dba513d --- /dev/null +++ b/src/modules/AccountDataApi.ts @@ -0,0 +1,54 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { Watchable, type AccountDataApi as IAccountDataApi } from "@element-hq/element-web-module-api"; +import { ClientEvent, type MatrixEvent, type MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { MatrixClientPeg } from "../MatrixClientPeg"; + +export class AccountDataApi implements IAccountDataApi { + public get(eventType: string): Watchable { + const cli = MatrixClientPeg.safeGet(); + return new AccountDataWatchable(cli, eventType); + } + + public async set(eventType: string, content: any): Promise { + const cli = MatrixClientPeg.safeGet(); + //@ts-expect-error: JS-SDK accepts known event-types, intentionally allow arbitrary types. + await cli.setAccountData(eventType, content); + } + + public async delete(eventType: string): Promise { + const cli = MatrixClientPeg.safeGet(); + //@ts-expect-error: JS-SDK accepts known event-types, intentionally allow arbitrary types. + await cli.deleteAccountData(eventType); + } +} + +class AccountDataWatchable extends Watchable { + public constructor( + private cli: MatrixClient, + private eventType: string, + ) { + //@ts-expect-error: JS-SDK accepts known event-types, intentionally allow arbitrary types. + super(cli.getAccountData(eventType)?.getContent()); + } + + private onAccountData = (event: MatrixEvent): void => { + if (event.getType() === this.eventType) { + this.value = event.getContent(); + } + }; + + protected onFirstWatch(): void { + this.cli.on(ClientEvent.AccountData, this.onAccountData); + } + + protected onLastWatch(): void { + this.cli.off(ClientEvent.AccountData, this.onAccountData); + } +} From 335491eabc88ab25bf6fe989e530a1446102c7c5 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 30 Oct 2025 17:13:14 +0530 Subject: [PATCH 11/21] Add implementation for Room --- src/modules/models/Room.ts | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/modules/models/Room.ts diff --git a/src/modules/models/Room.ts b/src/modules/models/Room.ts new file mode 100644 index 0000000000..e317c0dc03 --- /dev/null +++ b/src/modules/models/Room.ts @@ -0,0 +1,45 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type Room as IRoom, Watchable } from "@element-hq/element-web-module-api"; +import { RoomEvent, type Room as SdkRoom } from "matrix-js-sdk/src/matrix"; + +export class Room implements IRoom { + public name: Watchable; + + public constructor(private sdkRoom: SdkRoom) { + this.name = new WatchableName(sdkRoom); + } + + public getLastActiveTimestamp(): number { + return this.sdkRoom.getLastActiveTimestamp(); + } + + public get id(): string { + return this.sdkRoom.roomId; + } +} + +/** + * A custom watchable for room name. + */ +class WatchableName extends Watchable { + public constructor(private sdkRoom: SdkRoom) { + super(sdkRoom.name); + } + + private onNameUpdate = (): void => { + super.value = this.sdkRoom.name; + }; + protected onFirstWatch(): void { + this.sdkRoom.on(RoomEvent.Name, this.onNameUpdate); + } + + protected onLastWatch(): void { + this.sdkRoom.off(RoomEvent.Name, this.onNameUpdate); + } +} From 3be766d79c96d781bf936be8f373595052ded8fc Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 30 Oct 2025 17:13:39 +0530 Subject: [PATCH 12/21] Add implementation for ClientApi --- src/modules/ClientApi.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/modules/ClientApi.ts diff --git a/src/modules/ClientApi.ts b/src/modules/ClientApi.ts new file mode 100644 index 0000000000..3f5272bb67 --- /dev/null +++ b/src/modules/ClientApi.ts @@ -0,0 +1,27 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ +import type { ClientApi as IClientApi, Room } from "@element-hq/element-web-module-api"; +import { Room as ModuleRoom } from "./models/Room"; +import { AccountDataApi } from "./AccountDataApi"; +import { MatrixClientPeg } from "../MatrixClientPeg"; + +export class ClientApi implements IClientApi { + private accountDataApi?: AccountDataApi; + + public getRoom(roomId: string): Room | null { + const sdkRoom = MatrixClientPeg.safeGet().getRoom(roomId); + if (sdkRoom) return new ModuleRoom(sdkRoom); + return null; + } + + public get accountData(): AccountDataApi { + if (!this.accountDataApi) { + this.accountDataApi = new AccountDataApi(); + } + return this.accountDataApi; + } +} From c2d68f8dc0cbdfb31f2ed40cc142ba1876c5f7ff Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 30 Oct 2025 17:13:57 +0530 Subject: [PATCH 13/21] Create ClientApi in Api.ts --- src/modules/Api.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/modules/Api.ts b/src/modules/Api.ts index d51f051df1..8e4ab554c7 100644 --- a/src/modules/Api.ts +++ b/src/modules/Api.ts @@ -28,6 +28,7 @@ import { openDialog } from "./Dialog.tsx"; import { overwriteAccountAuth } from "./Auth.ts"; import { ElementWebExtrasApi } from "./ExtrasApi.ts"; import { ElementWebBuiltinsApi } from "./BuiltinsApi.tsx"; +import { ClientApi } from "./ClientApi.ts"; const legacyCustomisationsFactory = (baseCustomisations: T) => { let used = false; @@ -84,6 +85,7 @@ export class ModuleApi implements Api { public readonly extras = new ElementWebExtrasApi(); public readonly builtins = new ElementWebBuiltinsApi(); public readonly rootNode = document.getElementById("matrixchat")!; + public readonly client = new ClientApi(); public createRoot(element: Element): Root { return createRoot(element); From b94d40f166623fe0350a31b20b7e8914a4495730 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 30 Oct 2025 17:14:14 +0530 Subject: [PATCH 14/21] Write tests --- test/test-utils/test-utils.ts | 1 + .../unit-tests/modules/AccountDataApi-test.ts | 72 +++++++++++++++++++ test/unit-tests/modules/ClientApi-test.ts | 20 ++++++ test/unit-tests/modules/models/Room-test.ts | 50 +++++++++++++ 4 files changed, 143 insertions(+) create mode 100644 test/unit-tests/modules/AccountDataApi-test.ts create mode 100644 test/unit-tests/modules/ClientApi-test.ts create mode 100644 test/unit-tests/modules/models/Room-test.ts diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index d96ee1d045..8f70d089dd 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -650,6 +650,7 @@ export function mkStubRoom( getJoinedMembers: jest.fn().mockReturnValue([]), getLiveTimeline: jest.fn().mockReturnValue(stubTimeline), getLastLiveEvent: jest.fn().mockReturnValue(undefined), + getLastActiveTimestamp: jest.fn().mockReturnValue(1183140000), getMember: jest.fn().mockReturnValue({ userId: "@member:domain.bla", name: "Member", diff --git a/test/unit-tests/modules/AccountDataApi-test.ts b/test/unit-tests/modules/AccountDataApi-test.ts new file mode 100644 index 0000000000..d7b72d7bce --- /dev/null +++ b/test/unit-tests/modules/AccountDataApi-test.ts @@ -0,0 +1,72 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { ClientEvent } from "matrix-js-sdk/src/matrix"; + +import { AccountDataApi } from "../../../src/modules/AccountDataApi"; +import { mkEvent, stubClient } from "../../test-utils/test-utils"; + +describe("AccountDataApi", () => { + describe("AccountDataWatchable", () => { + it("should return content of account data event on get()", () => { + const cli = stubClient(); + const api = new AccountDataApi(); + // Mock cli to return a event + const content = { foo: "bar" }; + const event = mkEvent({ content, type: "m.test", user: "@foobar:matrix.org", event: true }); + cli.getAccountData = () => event; + expect(api.get("m.test").value).toStrictEqual(content); + }); + + it("should update value on event", () => { + const cli = stubClient(); + const api = new AccountDataApi(); + // Mock cli to return a event + const content = { foo: "bar" }; + const event = mkEvent({ content, type: "m.test", user: "@foobar:matrix.org", event: true }); + cli.getAccountData = () => event; + + const watchable = api.get("m.test"); + expect(watchable.value).toStrictEqual(content); + + const fn = jest.fn(); + watchable.watch(fn); + + // Let's say that the account data event changed + const event2 = mkEvent({ + content: { foo: "abc" }, + type: "m.test", + user: "@foobar:matrix.org", + event: true, + }); + cli.emit(ClientEvent.AccountData, event2); + // Watchable value should have been updated + expect(watchable.value).toStrictEqual({ foo: "abc" }); + // Watched callbacks should be called + expect(fn).toHaveBeenCalledTimes(1); + + // Make sure unwatch removed the event listener + cli.off = jest.fn(); + watchable.unwatch(fn); + expect(cli.off).toHaveBeenCalledTimes(1); + }); + }); + + it("should set account data via js-sdk on set()", async () => { + const cli = stubClient(); + const api = new AccountDataApi(); + await api.set("m.test", { foo: "bar" }); + expect(cli.setAccountData).toHaveBeenCalledTimes(1); + }); + + it("should delete account data via js-sdk on set()", async () => { + const cli = stubClient(); + const api = new AccountDataApi(); + await api.delete("m.test"); + expect(cli.deleteAccountData).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/unit-tests/modules/ClientApi-test.ts b/test/unit-tests/modules/ClientApi-test.ts new file mode 100644 index 0000000000..22f55d7d37 --- /dev/null +++ b/test/unit-tests/modules/ClientApi-test.ts @@ -0,0 +1,20 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { ClientApi } from "../../../src/modules/ClientApi"; +import { Room } from "../../../src/modules/models/Room"; +import { stubClient } from "../../test-utils/test-utils"; + +describe("ClientApi", () => { + it("should return module room from getRoom()", () => { + stubClient(); + const client = new ClientApi(); + const moduleRoom = client.getRoom("!foo:matrix.org"); + expect(moduleRoom).toBeInstanceOf(Room); + expect(moduleRoom?.id).toStrictEqual("!foo:matrix.org"); + }); +}); diff --git a/test/unit-tests/modules/models/Room-test.ts b/test/unit-tests/modules/models/Room-test.ts new file mode 100644 index 0000000000..d149c8cdf0 --- /dev/null +++ b/test/unit-tests/modules/models/Room-test.ts @@ -0,0 +1,50 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { Room } from "../../../../src/modules/models/Room"; +import { mkRoom, stubClient } from "../../../test-utils"; + +describe("Room", () => { + it("should return id from sdk room", () => { + const cli = stubClient(); + const sdkRoom = mkRoom(cli, "!foo:m.org"); + const room = new Room(sdkRoom); + expect(room.id).toStrictEqual("!foo:m.org"); + }); + + it("should return last timestamp from sdk room", () => { + const cli = stubClient(); + const sdkRoom = mkRoom(cli, "!foo:m.org"); + const room = new Room(sdkRoom); + expect(room.getLastActiveTimestamp()).toStrictEqual(sdkRoom.getLastActiveTimestamp()); + }); + + describe("watchableName", () => { + it("should return name from sdkRoom", () => { + const cli = stubClient(); + const sdkRoom = mkRoom(cli, "!foo:m.org"); + sdkRoom.name = "Foo Name"; + const room = new Room(sdkRoom); + expect(room.name.value).toStrictEqual("Foo Name"); + }); + + it("should add/remove event listener on sdk room", () => { + const cli = stubClient(); + const sdkRoom = mkRoom(cli, "!foo:m.org"); + sdkRoom.name = "Foo Name"; + + const room = new Room(sdkRoom); + const fn = jest.fn(); + + room.name.watch(fn); + expect(sdkRoom.on).toHaveBeenCalledTimes(1); + + room.name.unwatch(fn); + expect(sdkRoom.off).toHaveBeenCalledTimes(1); + }); + }); +}); From 507eaa02dfe026a5108759ecf3914cdbdba92305 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 30 Oct 2025 18:02:19 +0530 Subject: [PATCH 15/21] Use nullish coalescing assignment --- src/modules/ClientApi.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/modules/ClientApi.ts b/src/modules/ClientApi.ts index 3f5272bb67..ef692fbad6 100644 --- a/src/modules/ClientApi.ts +++ b/src/modules/ClientApi.ts @@ -19,9 +19,7 @@ export class ClientApi implements IClientApi { } public get accountData(): AccountDataApi { - if (!this.accountDataApi) { - this.accountDataApi = new AccountDataApi(); - } + this.accountDataApi ??= new AccountDataApi(); return this.accountDataApi; } } From f4e8e79af829d11f3fb89c65f4fec659ce08da0a Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 30 Oct 2025 18:32:53 +0530 Subject: [PATCH 16/21] Implement openRoom in NavigationApi --- src/modules/Navigation.ts | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/src/modules/Navigation.ts b/src/modules/Navigation.ts index 52bdb5aee9..fff87156b6 100644 --- a/src/modules/Navigation.ts +++ b/src/modules/Navigation.ts @@ -5,8 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { type LocationRenderFunction, type NavigationApi as INavigationApi } from "@element-hq/element-web-module-api"; - +import type { + LocationRenderFunction, + NavigationApi as INavigationApi, + OpenRoomOptions, +} from "@element-hq/element-web-module-api"; import { navigateToPermalink } from "../utils/permalinks/navigator.ts"; import { parsePermalink } from "../utils/permalinks/Permalinks.ts"; import dispatcher from "../dispatcher/dispatcher.ts"; @@ -21,27 +24,25 @@ export class NavigationApi implements INavigationApi { const parts = parsePermalink(link); if (parts?.roomIdOrAlias) { - if (parts.roomIdOrAlias.startsWith("#")) { - dispatcher.dispatch({ - action: Action.ViewRoom, - room_alias: parts.roomIdOrAlias, - via_servers: parts.viaServers ?? undefined, - auto_join: join, - metricsTrigger: undefined, - }); - } else { - dispatcher.dispatch({ - action: Action.ViewRoom, - room_id: parts.roomIdOrAlias, - via_servers: parts.viaServers ?? undefined, - auto_join: join, - metricsTrigger: undefined, - }); - } + this.openRoom(parts.roomIdOrAlias, { + viaServers: parts.viaServers ?? undefined, + autoJoin: join, + }); } } public registerLocationRenderer(path: string, renderer: LocationRenderFunction): void { this.locationRenderers.set(path, renderer); } + + public openRoom(roomIdOrAlias: string, opts: OpenRoomOptions = {}): void { + const key = roomIdOrAlias.startsWith("#") ? "room_alias" : "room_id"; + dispatcher.dispatch({ + action: Action.ViewRoom, + [key]: roomIdOrAlias, + via_servers: opts.viaServers, + auto_join: opts.autoJoin, + metricsTrigger: undefined, + }); + } } From 6dc1431270b303eb014993ed242d29ceaffed5a9 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 30 Oct 2025 18:33:07 +0530 Subject: [PATCH 17/21] Write tests --- test/unit-tests/modules/Navigation-test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/unit-tests/modules/Navigation-test.ts b/test/unit-tests/modules/Navigation-test.ts index 3fafdf0fa6..ee0a70e9cf 100644 --- a/test/unit-tests/modules/Navigation-test.ts +++ b/test/unit-tests/modules/Navigation-test.ts @@ -37,5 +37,25 @@ describe("NavigationApi", () => { }), ); }); + + it("should dispatch correct action on openRoom", () => { + const spy = jest.spyOn(defaultDispatcher, "dispatch"); + // Non alias + api.openRoom("!foo:m.org"); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + action: "view_room", + room_id: "!foo:m.org", + }), + ); + // Alias + api.openRoom("#bar:m.org"); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + action: "view_room", + room_alias: "#bar:m.org", + }), + ); + }); }); }); From 044a275135ef4e140c2a6f575001482c9ec0cacd Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 30 Oct 2025 19:14:37 +0530 Subject: [PATCH 18/21] Add implementation for StoresApi --- src/modules/Api.ts | 2 ++ src/modules/StoresApi.ts | 65 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 src/modules/StoresApi.ts diff --git a/src/modules/Api.ts b/src/modules/Api.ts index 8e4ab554c7..2ff85c968f 100644 --- a/src/modules/Api.ts +++ b/src/modules/Api.ts @@ -29,6 +29,7 @@ import { overwriteAccountAuth } from "./Auth.ts"; import { ElementWebExtrasApi } from "./ExtrasApi.ts"; import { ElementWebBuiltinsApi } from "./BuiltinsApi.tsx"; import { ClientApi } from "./ClientApi.ts"; +import { StoresApi } from "./StoresApi.ts"; const legacyCustomisationsFactory = (baseCustomisations: T) => { let used = false; @@ -86,6 +87,7 @@ export class ModuleApi implements Api { public readonly builtins = new ElementWebBuiltinsApi(); public readonly rootNode = document.getElementById("matrixchat")!; public readonly client = new ClientApi(); + public readonly stores = new StoresApi(); public createRoot(element: Element): Root { return createRoot(element); diff --git a/src/modules/StoresApi.ts b/src/modules/StoresApi.ts new file mode 100644 index 0000000000..2c1341e3b9 --- /dev/null +++ b/src/modules/StoresApi.ts @@ -0,0 +1,65 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ +import { + type StoresApi as IStoresApi, + type RoomListStoreApi as IRoomListStore, + type Room, + Watchable, +} from "@element-hq/element-web-module-api"; + +import RoomListStoreV3, { LISTS_LOADED_EVENT, LISTS_UPDATE_EVENT } from "../stores/room-list-v3/RoomListStoreV3"; +import { Room as ModuleRoom } from "./models/Room"; + +class RoomListStoreApi implements IRoomListStore { + public getRooms(): RoomsWatchable { + return new RoomsWatchable(); + } + + public async waitForReady(): Promise { + // Check if RLS is already loaded + if (!RoomListStoreV3.instance.isLoadingRooms) return; + + // Return a promise that resolves when RLS has loaded + let resolve: () => void; + const promise: Promise = new Promise((_resolve) => { + resolve = _resolve; + }); + RoomListStoreV3.instance.once(LISTS_LOADED_EVENT, () => { + resolve(); + }); + return promise; + } +} + +class RoomsWatchable extends Watchable { + public constructor() { + super(RoomListStoreV3.instance.getSortedRooms().map((sdkRoom) => new ModuleRoom(sdkRoom))); + } + + private onRlsUpdate = (): void => { + this.value = RoomListStoreV3.instance.getSortedRooms().map((sdkRoom) => new ModuleRoom(sdkRoom)); + }; + + protected onFirstWatch(): void { + RoomListStoreV3.instance.on(LISTS_UPDATE_EVENT, this.onRlsUpdate); + } + + protected onLastWatch(): void { + RoomListStoreV3.instance.off(LISTS_UPDATE_EVENT, this.onRlsUpdate); + } +} + +export class StoresApi implements IStoresApi { + private roomListStoreApi?: IRoomListStore; + + public get roomListStore(): IRoomListStore { + if (!this.roomListStoreApi) { + this.roomListStoreApi = new RoomListStoreApi(); + } + return this.roomListStoreApi; + } +} From 353609c05d602467f029ccc7a7058b6f6d4abd9c Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 30 Oct 2025 19:16:38 +0530 Subject: [PATCH 19/21] Write tests --- test/unit-tests/modules/StoresApi-test.ts | 77 +++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 test/unit-tests/modules/StoresApi-test.ts diff --git a/test/unit-tests/modules/StoresApi-test.ts b/test/unit-tests/modules/StoresApi-test.ts new file mode 100644 index 0000000000..10d0cd6078 --- /dev/null +++ b/test/unit-tests/modules/StoresApi-test.ts @@ -0,0 +1,77 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { waitFor } from "jest-matrix-react"; + +import { StoresApi } from "../../../src/modules/StoresApi"; +import RoomListStoreV3, { + LISTS_LOADED_EVENT, + LISTS_UPDATE_EVENT, +} from "../../../src/stores/room-list-v3/RoomListStoreV3"; +import { mkRoom, stubClient } from "../../test-utils/test-utils"; +import { Room } from "../../../src/modules/models/Room"; +import {} from "../../../src/stores/room-list/algorithms/Algorithm"; + +describe("StoresApi", () => { + describe("RoomListStoreApi", () => { + it("should return promise that resolves when RLS is ready", async () => { + jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(true); + const store = new StoresApi(); + let hasResolved = false; + // The following async function will set hasResolved to false + // only when waitForReady resolves. + (async () => { + await store.roomListStore.waitForReady(); + hasResolved = true; + })(); + // Shouldn't have resolved yet. + expect(hasResolved).toStrictEqual(false); + // Emit the loaded event. + RoomListStoreV3.instance.emit(LISTS_LOADED_EVENT); + // Should resolve now. + await waitFor(() => { + expect(hasResolved).toStrictEqual(true); + }); + }); + + describe("getRooms()", () => { + it("should return rooms from RLS", () => { + const cli = stubClient(); + const room1 = mkRoom(cli, "!foo1:m.org"); + const room2 = mkRoom(cli, "!foo2:m.org"); + const room3 = mkRoom(cli, "!foo3:m.org"); + jest.spyOn(RoomListStoreV3.instance, "getSortedRooms").mockReturnValue([room1, room2, room3]); + + const store = new StoresApi(); + const watchable = store.roomListStore.getRooms(); + expect(watchable.value).toHaveLength(3); + expect(watchable.value[0]).toBeInstanceOf(Room); + }); + + it("should update from RLS", () => { + const cli = stubClient(); + const room1 = mkRoom(cli, "!foo1:m.org"); + const room2 = mkRoom(cli, "!foo2:m.org"); + const rooms = [room1, room2]; + + jest.spyOn(RoomListStoreV3.instance, "getSortedRooms").mockReturnValue(rooms); + + const store = new StoresApi(); + const watchable = store.roomListStore.getRooms(); + const fn = jest.fn(); + watchable.watch(fn); + expect(watchable.value).toHaveLength(2); + + const room3 = mkRoom(cli, "!foo3:m.org"); + rooms.push(room3); + RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT); + expect(fn).toHaveBeenCalledTimes(1); + expect(watchable.value).toHaveLength(3); + }); + }); + }); +}); From fdbe41415240bac9e205e7602063e635cee0c0a3 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Thu, 30 Oct 2025 21:30:32 +0530 Subject: [PATCH 20/21] Fix circular dependency --- src/modules/StoresApi.ts | 79 +++++++++++++++++------ test/unit-tests/modules/StoresApi-test.ts | 13 +++- 2 files changed, 70 insertions(+), 22 deletions(-) diff --git a/src/modules/StoresApi.ts b/src/modules/StoresApi.ts index 2c1341e3b9..f1d6add95e 100644 --- a/src/modules/StoresApi.ts +++ b/src/modules/StoresApi.ts @@ -11,45 +11,86 @@ import { Watchable, } from "@element-hq/element-web-module-api"; -import RoomListStoreV3, { LISTS_LOADED_EVENT, LISTS_UPDATE_EVENT } from "../stores/room-list-v3/RoomListStoreV3"; +import type { RoomListStoreV3Class, RoomListStoreV3Event } from "../stores/room-list-v3/RoomListStoreV3"; import { Room as ModuleRoom } from "./models/Room"; -class RoomListStoreApi implements IRoomListStore { +interface RlsEvents { + LISTS_LOADED_EVENT: RoomListStoreV3Event.ListsLoaded; + LISTS_UPDATE_EVENT: RoomListStoreV3Event.ListsUpdate; +} + +export class RoomListStoreApi implements IRoomListStore { + private rls?: RoomListStoreV3Class; + private LISTS_LOADED_EVENT?: RoomListStoreV3Event.ListsLoaded; + private LISTS_UPDATE_EVENT?: RoomListStoreV3Event.ListsUpdate; + public readonly moduleLoadPromise: Promise; + + public constructor() { + this.moduleLoadPromise = this.init(); + } + + /** + * Load the RLS through a dynamic import. This is necessary to prevent + * circular dependency issues. + */ + private async init(): Promise { + const module = await import("../stores/room-list-v3/RoomListStoreV3"); + this.rls = module.default.instance; + this.LISTS_LOADED_EVENT = module.LISTS_LOADED_EVENT; + this.LISTS_UPDATE_EVENT = module.LISTS_UPDATE_EVENT; + } + public getRooms(): RoomsWatchable { - return new RoomsWatchable(); + return new RoomsWatchable(this.roomListStore, this.events); + } + + private get events(): RlsEvents { + if (!this.LISTS_LOADED_EVENT || !this.LISTS_UPDATE_EVENT) { + throw new Error("Event type was not loaded correctly, did you forget to await waitForReady()?"); + } + return { LISTS_LOADED_EVENT: this.LISTS_LOADED_EVENT, LISTS_UPDATE_EVENT: this.LISTS_UPDATE_EVENT }; + } + + private get roomListStore(): RoomListStoreV3Class { + if (!this.rls) { + throw new Error("rls is undefined, did you forget to await waitForReady()?"); + } + return this.rls; } public async waitForReady(): Promise { - // Check if RLS is already loaded - if (!RoomListStoreV3.instance.isLoadingRooms) return; + // Wait for the module to load first + await this.moduleLoadPromise; - // Return a promise that resolves when RLS has loaded - let resolve: () => void; - const promise: Promise = new Promise((_resolve) => { - resolve = _resolve; - }); - RoomListStoreV3.instance.once(LISTS_LOADED_EVENT, () => { - resolve(); - }); - return promise; + // Check if RLS is already loaded + if (!this.roomListStore.isLoadingRooms) return; + + // Await a promise that resolves when RLS has loaded + const { promise, resolve } = Promise.withResolvers(); + const { LISTS_LOADED_EVENT } = this.events; + this.roomListStore.once(LISTS_LOADED_EVENT, resolve); + await promise; } } class RoomsWatchable extends Watchable { - public constructor() { - super(RoomListStoreV3.instance.getSortedRooms().map((sdkRoom) => new ModuleRoom(sdkRoom))); + public constructor( + private readonly rls: RoomListStoreV3Class, + private readonly events: RlsEvents, + ) { + super(rls.getSortedRooms().map((sdkRoom) => new ModuleRoom(sdkRoom))); } private onRlsUpdate = (): void => { - this.value = RoomListStoreV3.instance.getSortedRooms().map((sdkRoom) => new ModuleRoom(sdkRoom)); + this.value = this.rls.getSortedRooms().map((sdkRoom) => new ModuleRoom(sdkRoom)); }; protected onFirstWatch(): void { - RoomListStoreV3.instance.on(LISTS_UPDATE_EVENT, this.onRlsUpdate); + this.rls.on(this.events.LISTS_UPDATE_EVENT, this.onRlsUpdate); } protected onLastWatch(): void { - RoomListStoreV3.instance.off(LISTS_UPDATE_EVENT, this.onRlsUpdate); + this.rls.off(this.events.LISTS_UPDATE_EVENT, this.onRlsUpdate); } } diff --git a/test/unit-tests/modules/StoresApi-test.ts b/test/unit-tests/modules/StoresApi-test.ts index 10d0cd6078..ba8a0c83da 100644 --- a/test/unit-tests/modules/StoresApi-test.ts +++ b/test/unit-tests/modules/StoresApi-test.ts @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. import { waitFor } from "jest-matrix-react"; -import { StoresApi } from "../../../src/modules/StoresApi"; +import { type RoomListStoreApi, StoresApi } from "../../../src/modules/StoresApi"; import RoomListStoreV3, { LISTS_LOADED_EVENT, LISTS_UPDATE_EVENT, @@ -30,6 +30,9 @@ describe("StoresApi", () => { })(); // Shouldn't have resolved yet. expect(hasResolved).toStrictEqual(false); + + // Wait for the module to load so that we can test the listener. + await (store.roomListStore as RoomListStoreApi).moduleLoadPromise; // Emit the loaded event. RoomListStoreV3.instance.emit(LISTS_LOADED_EVENT); // Should resolve now. @@ -39,28 +42,32 @@ describe("StoresApi", () => { }); describe("getRooms()", () => { - it("should return rooms from RLS", () => { + it("should return rooms from RLS", async () => { const cli = stubClient(); const room1 = mkRoom(cli, "!foo1:m.org"); const room2 = mkRoom(cli, "!foo2:m.org"); const room3 = mkRoom(cli, "!foo3:m.org"); jest.spyOn(RoomListStoreV3.instance, "getSortedRooms").mockReturnValue([room1, room2, room3]); + jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(false); const store = new StoresApi(); + await store.roomListStore.waitForReady(); const watchable = store.roomListStore.getRooms(); expect(watchable.value).toHaveLength(3); expect(watchable.value[0]).toBeInstanceOf(Room); }); - it("should update from RLS", () => { + it("should update from RLS", async () => { const cli = stubClient(); const room1 = mkRoom(cli, "!foo1:m.org"); const room2 = mkRoom(cli, "!foo2:m.org"); const rooms = [room1, room2]; jest.spyOn(RoomListStoreV3.instance, "getSortedRooms").mockReturnValue(rooms); + jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(false); const store = new StoresApi(); + await store.roomListStore.waitForReady(); const watchable = store.roomListStore.getRooms(); const fn = jest.fn(); watchable.watch(fn); From 5f8aa3201539ecd923713b413198f07307ba643b Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Tue, 4 Nov 2025 16:58:27 +0530 Subject: [PATCH 21/21] Change to class field --- src/modules/ClientApi.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/modules/ClientApi.ts b/src/modules/ClientApi.ts index ef692fbad6..7b5ccd2828 100644 --- a/src/modules/ClientApi.ts +++ b/src/modules/ClientApi.ts @@ -10,16 +10,11 @@ import { AccountDataApi } from "./AccountDataApi"; import { MatrixClientPeg } from "../MatrixClientPeg"; export class ClientApi implements IClientApi { - private accountDataApi?: AccountDataApi; + public readonly accountData = new AccountDataApi(); public getRoom(roomId: string): Room | null { const sdkRoom = MatrixClientPeg.safeGet().getRoom(roomId); if (sdkRoom) return new ModuleRoom(sdkRoom); return null; } - - public get accountData(): AccountDataApi { - this.accountDataApi ??= new AccountDataApi(); - return this.accountDataApi; - } }