Skip to content

Commit 8c772f3

Browse files
Merge pull request #31132 from element-hq/midhun/module-impl/builtin
Module API Implementation: Builtins
2 parents 124f52b + f688a06 commit 8c772f3

File tree

17 files changed

+629
-74
lines changed

17 files changed

+629
-74
lines changed

src/modules/AccountDataApi.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
Copyright 2025 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { Watchable, type AccountDataApi as IAccountDataApi } from "@element-hq/element-web-module-api";
9+
import { ClientEvent, type MatrixEvent, type MatrixClient } from "matrix-js-sdk/src/matrix";
10+
11+
import { MatrixClientPeg } from "../MatrixClientPeg";
12+
13+
export class AccountDataApi implements IAccountDataApi {
14+
public get(eventType: string): Watchable<unknown> {
15+
const cli = MatrixClientPeg.safeGet();
16+
return new AccountDataWatchable(cli, eventType);
17+
}
18+
19+
public async set(eventType: string, content: any): Promise<void> {
20+
const cli = MatrixClientPeg.safeGet();
21+
//@ts-expect-error: JS-SDK accepts known event-types, intentionally allow arbitrary types.
22+
await cli.setAccountData(eventType, content);
23+
}
24+
25+
public async delete(eventType: string): Promise<void> {
26+
const cli = MatrixClientPeg.safeGet();
27+
//@ts-expect-error: JS-SDK accepts known event-types, intentionally allow arbitrary types.
28+
await cli.deleteAccountData(eventType);
29+
}
30+
}
31+
32+
class AccountDataWatchable extends Watchable<unknown> {
33+
public constructor(
34+
private cli: MatrixClient,
35+
private eventType: string,
36+
) {
37+
//@ts-expect-error: JS-SDK accepts known event-types, intentionally allow arbitrary types.
38+
super(cli.getAccountData(eventType)?.getContent());
39+
}
40+
41+
private onAccountData = (event: MatrixEvent): void => {
42+
if (event.getType() === this.eventType) {
43+
this.value = event.getContent();
44+
}
45+
};
46+
47+
protected onFirstWatch(): void {
48+
this.cli.on(ClientEvent.AccountData, this.onAccountData);
49+
}
50+
51+
protected onLastWatch(): void {
52+
this.cli.off(ClientEvent.AccountData, this.onAccountData);
53+
}
54+
}

src/modules/Api.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ import { NavigationApi } from "./Navigation.ts";
2727
import { openDialog } from "./Dialog.tsx";
2828
import { overwriteAccountAuth } from "./Auth.ts";
2929
import { ElementWebExtrasApi } from "./ExtrasApi.ts";
30-
import { ElementWebBuiltinsApi } from "./BuiltinsApi.ts";
30+
import { ElementWebBuiltinsApi } from "./BuiltinsApi.tsx";
31+
import { ClientApi } from "./ClientApi.ts";
32+
import { StoresApi } from "./StoresApi.ts";
3133

3234
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
3335
let used = false;
@@ -84,6 +86,8 @@ export class ModuleApi implements Api {
8486
public readonly extras = new ElementWebExtrasApi();
8587
public readonly builtins = new ElementWebBuiltinsApi();
8688
public readonly rootNode = document.getElementById("matrixchat")!;
89+
public readonly client = new ClientApi();
90+
public readonly stores = new StoresApi();
8791

8892
public createRoot(element: Element): Root {
8993
return createRoot(element);

src/modules/BuiltinsApi.ts

Lines changed: 0 additions & 33 deletions
This file was deleted.

src/modules/BuiltinsApi.tsx

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/*
2+
Copyright 2025 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import React from "react";
9+
import { type RoomViewProps, type BuiltinsApi } from "@element-hq/element-web-module-api";
10+
11+
import { MatrixClientPeg } from "../MatrixClientPeg";
12+
import type { Room } from "matrix-js-sdk/src/matrix";
13+
14+
interface RoomViewPropsWithRoomId extends RoomViewProps {
15+
roomId?: string;
16+
}
17+
18+
interface RoomAvatarProps {
19+
room: Room;
20+
size?: string;
21+
}
22+
23+
interface Components {
24+
roomView: React.ComponentType<RoomViewPropsWithRoomId>;
25+
roomAvatar: React.ComponentType<RoomAvatarProps>;
26+
}
27+
28+
export class ElementWebBuiltinsApi implements BuiltinsApi {
29+
private _roomView?: React.ComponentType<RoomViewPropsWithRoomId>;
30+
private _roomAvatar?: React.ComponentType<RoomAvatarProps>;
31+
32+
/**
33+
* Sets the components used by the API.
34+
*
35+
* This only really exists here because referencing these components directly causes a nightmare of
36+
* circular dependencies that break the whole app, so instead we avoid referencing it here
37+
* and pass it in from somewhere it's already referenced (see related comment in app.tsx).
38+
*
39+
* @param component The components used by the api, see {@link Components}
40+
*/
41+
public setComponents(components: Components): void {
42+
this._roomView = components.roomView;
43+
this._roomAvatar = components.roomAvatar;
44+
}
45+
46+
public getRoomViewComponent(): React.ComponentType<RoomViewPropsWithRoomId> {
47+
if (!this._roomView) {
48+
throw new Error("No RoomView component has been set");
49+
}
50+
51+
return this._roomView;
52+
}
53+
54+
public getRoomAvatarComponent(): React.ComponentType<RoomAvatarProps> {
55+
if (!this._roomAvatar) {
56+
throw new Error("No RoomAvatar component has been set");
57+
}
58+
59+
return this._roomAvatar;
60+
}
61+
62+
public renderRoomView(roomId: string): React.ReactNode {
63+
const Component = this.getRoomViewComponent();
64+
return <Component roomId={roomId} />;
65+
}
66+
67+
public renderRoomAvatar(roomId: string, size?: string): React.ReactNode {
68+
const room = MatrixClientPeg.safeGet().getRoom(roomId);
69+
if (!room) {
70+
throw new Error(`No room such room: ${roomId}`);
71+
}
72+
const Component = this.getRoomAvatarComponent();
73+
return <Component room={room} size={size} />;
74+
}
75+
}

src/modules/ClientApi.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
Copyright 2025 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
import type { ClientApi as IClientApi, Room } from "@element-hq/element-web-module-api";
8+
import { Room as ModuleRoom } from "./models/Room";
9+
import { AccountDataApi } from "./AccountDataApi";
10+
import { MatrixClientPeg } from "../MatrixClientPeg";
11+
12+
export class ClientApi implements IClientApi {
13+
public readonly accountData = new AccountDataApi();
14+
15+
public getRoom(roomId: string): Room | null {
16+
const sdkRoom = MatrixClientPeg.safeGet().getRoom(roomId);
17+
if (sdkRoom) return new ModuleRoom(sdkRoom);
18+
return null;
19+
}
20+
}

src/modules/Navigation.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
55
Please see LICENSE files in the repository root for full details.
66
*/
77

8-
import { type LocationRenderFunction, type NavigationApi as INavigationApi } from "@element-hq/element-web-module-api";
9-
8+
import type {
9+
LocationRenderFunction,
10+
NavigationApi as INavigationApi,
11+
OpenRoomOptions,
12+
} from "@element-hq/element-web-module-api";
1013
import { navigateToPermalink } from "../utils/permalinks/navigator.ts";
1114
import { parsePermalink } from "../utils/permalinks/Permalinks.ts";
1215
import dispatcher from "../dispatcher/dispatcher.ts";
@@ -21,27 +24,25 @@ export class NavigationApi implements INavigationApi {
2124

2225
const parts = parsePermalink(link);
2326
if (parts?.roomIdOrAlias) {
24-
if (parts.roomIdOrAlias.startsWith("#")) {
25-
dispatcher.dispatch<ViewRoomPayload>({
26-
action: Action.ViewRoom,
27-
room_alias: parts.roomIdOrAlias,
28-
via_servers: parts.viaServers ?? undefined,
29-
auto_join: join,
30-
metricsTrigger: undefined,
31-
});
32-
} else {
33-
dispatcher.dispatch<ViewRoomPayload>({
34-
action: Action.ViewRoom,
35-
room_id: parts.roomIdOrAlias,
36-
via_servers: parts.viaServers ?? undefined,
37-
auto_join: join,
38-
metricsTrigger: undefined,
39-
});
40-
}
27+
this.openRoom(parts.roomIdOrAlias, {
28+
viaServers: parts.viaServers ?? undefined,
29+
autoJoin: join,
30+
});
4131
}
4232
}
4333

4434
public registerLocationRenderer(path: string, renderer: LocationRenderFunction): void {
4535
this.locationRenderers.set(path, renderer);
4636
}
37+
38+
public openRoom(roomIdOrAlias: string, opts: OpenRoomOptions = {}): void {
39+
const key = roomIdOrAlias.startsWith("#") ? "room_alias" : "room_id";
40+
dispatcher.dispatch<ViewRoomPayload>({
41+
action: Action.ViewRoom,
42+
[key]: roomIdOrAlias,
43+
via_servers: opts.viaServers,
44+
auto_join: opts.autoJoin,
45+
metricsTrigger: undefined,
46+
});
47+
}
4748
}

src/modules/StoresApi.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
Copyright 2025 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
import {
8+
type StoresApi as IStoresApi,
9+
type RoomListStoreApi as IRoomListStore,
10+
type Room,
11+
Watchable,
12+
} from "@element-hq/element-web-module-api";
13+
14+
import type { RoomListStoreV3Class, RoomListStoreV3Event } from "../stores/room-list-v3/RoomListStoreV3";
15+
import { Room as ModuleRoom } from "./models/Room";
16+
17+
interface RlsEvents {
18+
LISTS_LOADED_EVENT: RoomListStoreV3Event.ListsLoaded;
19+
LISTS_UPDATE_EVENT: RoomListStoreV3Event.ListsUpdate;
20+
}
21+
22+
export class RoomListStoreApi implements IRoomListStore {
23+
private rls?: RoomListStoreV3Class;
24+
private LISTS_LOADED_EVENT?: RoomListStoreV3Event.ListsLoaded;
25+
private LISTS_UPDATE_EVENT?: RoomListStoreV3Event.ListsUpdate;
26+
public readonly moduleLoadPromise: Promise<void>;
27+
28+
public constructor() {
29+
this.moduleLoadPromise = this.init();
30+
}
31+
32+
/**
33+
* Load the RLS through a dynamic import. This is necessary to prevent
34+
* circular dependency issues.
35+
*/
36+
private async init(): Promise<void> {
37+
const module = await import("../stores/room-list-v3/RoomListStoreV3");
38+
this.rls = module.default.instance;
39+
this.LISTS_LOADED_EVENT = module.LISTS_LOADED_EVENT;
40+
this.LISTS_UPDATE_EVENT = module.LISTS_UPDATE_EVENT;
41+
}
42+
43+
public getRooms(): RoomsWatchable {
44+
return new RoomsWatchable(this.roomListStore, this.events);
45+
}
46+
47+
private get events(): RlsEvents {
48+
if (!this.LISTS_LOADED_EVENT || !this.LISTS_UPDATE_EVENT) {
49+
throw new Error("Event type was not loaded correctly, did you forget to await waitForReady()?");
50+
}
51+
return { LISTS_LOADED_EVENT: this.LISTS_LOADED_EVENT, LISTS_UPDATE_EVENT: this.LISTS_UPDATE_EVENT };
52+
}
53+
54+
private get roomListStore(): RoomListStoreV3Class {
55+
if (!this.rls) {
56+
throw new Error("rls is undefined, did you forget to await waitForReady()?");
57+
}
58+
return this.rls;
59+
}
60+
61+
public async waitForReady(): Promise<void> {
62+
// Wait for the module to load first
63+
await this.moduleLoadPromise;
64+
65+
// Check if RLS is already loaded
66+
if (!this.roomListStore.isLoadingRooms) return;
67+
68+
// Await a promise that resolves when RLS has loaded
69+
const { promise, resolve } = Promise.withResolvers<void>();
70+
const { LISTS_LOADED_EVENT } = this.events;
71+
this.roomListStore.once(LISTS_LOADED_EVENT, resolve);
72+
await promise;
73+
}
74+
}
75+
76+
class RoomsWatchable extends Watchable<Room[]> {
77+
public constructor(
78+
private readonly rls: RoomListStoreV3Class,
79+
private readonly events: RlsEvents,
80+
) {
81+
super(rls.getSortedRooms().map((sdkRoom) => new ModuleRoom(sdkRoom)));
82+
}
83+
84+
private onRlsUpdate = (): void => {
85+
this.value = this.rls.getSortedRooms().map((sdkRoom) => new ModuleRoom(sdkRoom));
86+
};
87+
88+
protected onFirstWatch(): void {
89+
this.rls.on(this.events.LISTS_UPDATE_EVENT, this.onRlsUpdate);
90+
}
91+
92+
protected onLastWatch(): void {
93+
this.rls.off(this.events.LISTS_UPDATE_EVENT, this.onRlsUpdate);
94+
}
95+
}
96+
97+
export class StoresApi implements IStoresApi {
98+
private roomListStoreApi?: IRoomListStore;
99+
100+
public get roomListStore(): IRoomListStore {
101+
if (!this.roomListStoreApi) {
102+
this.roomListStoreApi = new RoomListStoreApi();
103+
}
104+
return this.roomListStoreApi;
105+
}
106+
}

0 commit comments

Comments
 (0)