Merge pull request #31140 from element-hq/midhun/module-impl/navigation

Module API Implementation: Navigation
This commit is contained in:
R Midhun Suresh 2025-11-04 16:41:29 +05:30 committed by GitHub
commit 8a59a16ccb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 232 additions and 19 deletions

View File

@ -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 = <T extends object>(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);

View File

@ -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<ViewRoomPayload>({
action: Action.ViewRoom,
room_alias: parts.roomIdOrAlias,
via_servers: parts.viaServers ?? undefined,
auto_join: join,
metricsTrigger: undefined,
});
} else {
dispatcher.dispatch<ViewRoomPayload>({
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<ViewRoomPayload>({
action: Action.ViewRoom,
[key]: roomIdOrAlias,
via_servers: opts.viaServers,
auto_join: opts.autoJoin,
metricsTrigger: undefined,
});
}
}

106
src/modules/StoresApi.ts Normal file
View File

@ -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<void>;
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<void> {
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<void> {
// 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<void>();
const { LISTS_LOADED_EVENT } = this.events;
this.roomListStore.once(LISTS_LOADED_EVENT, resolve);
await promise;
}
}
class RoomsWatchable extends Watchable<Room[]> {
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;
}
}

View File

@ -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",
}),
);
});
});
});

View File

@ -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);
});
});
});
});