diff --git a/src/modules/Api.ts b/src/modules/Api.ts index 8e4ab554c75..2ff85c968f6 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/Navigation.ts b/src/modules/Navigation.ts index 52bdb5aee9b..fff87156b69 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, + }); + } } diff --git a/src/modules/StoresApi.ts b/src/modules/StoresApi.ts new file mode 100644 index 00000000000..f1d6add95e8 --- /dev/null +++ b/src/modules/StoresApi.ts @@ -0,0 +1,106 @@ +/* +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 type { RoomListStoreV3Class, RoomListStoreV3Event } from "../stores/room-list-v3/RoomListStoreV3"; +import { Room as ModuleRoom } from "./models/Room"; + +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(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 { + // Wait for the module to load first + await this.moduleLoadPromise; + + // 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( + private readonly rls: RoomListStoreV3Class, + private readonly events: RlsEvents, + ) { + super(rls.getSortedRooms().map((sdkRoom) => new ModuleRoom(sdkRoom))); + } + + private onRlsUpdate = (): void => { + this.value = this.rls.getSortedRooms().map((sdkRoom) => new ModuleRoom(sdkRoom)); + }; + + protected onFirstWatch(): void { + this.rls.on(this.events.LISTS_UPDATE_EVENT, this.onRlsUpdate); + } + + protected onLastWatch(): void { + this.rls.off(this.events.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; + } +} diff --git a/test/unit-tests/modules/Navigation-test.ts b/test/unit-tests/modules/Navigation-test.ts index 3fafdf0fa62..ee0a70e9cf7 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", + }), + ); + }); }); }); diff --git a/test/unit-tests/modules/StoresApi-test.ts b/test/unit-tests/modules/StoresApi-test.ts new file mode 100644 index 00000000000..ba8a0c83da9 --- /dev/null +++ b/test/unit-tests/modules/StoresApi-test.ts @@ -0,0 +1,84 @@ +/* +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 { type RoomListStoreApi, 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); + + // 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. + await waitFor(() => { + expect(hasResolved).toStrictEqual(true); + }); + }); + + describe("getRooms()", () => { + 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", 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); + 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); + }); + }); + }); +});