Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c78461d
Implement new builtins api
MidhunSureshR Oct 29, 2025
1c0738b
Add tests
MidhunSureshR Oct 29, 2025
f2104b5
Fix import
MidhunSureshR Oct 29, 2025
620ba92
Fix circular dependency issue
MidhunSureshR Oct 29, 2025
8a875e8
Fix import
MidhunSureshR Oct 30, 2025
a9fed64
Add more tests
MidhunSureshR Oct 30, 2025
61306a1
Improve comment
MidhunSureshR Oct 30, 2025
ebc9e3a
room-id is optional
MidhunSureshR Oct 30, 2025
eebf227
Update license
MidhunSureshR Oct 30, 2025
2449557
Add implementation for AccountDataApi
MidhunSureshR Oct 30, 2025
335491e
Add implementation for Room
MidhunSureshR Oct 30, 2025
3be766d
Add implementation for ClientApi
MidhunSureshR Oct 30, 2025
c2d68f8
Create ClientApi in Api.ts
MidhunSureshR Oct 30, 2025
b94d40f
Write tests
MidhunSureshR Oct 30, 2025
507eaa0
Use nullish coalescing assignment
MidhunSureshR Oct 30, 2025
f4e8e79
Implement openRoom in NavigationApi
MidhunSureshR Oct 30, 2025
6dc1431
Write tests
MidhunSureshR Oct 30, 2025
044a275
Add implementation for StoresApi
MidhunSureshR Oct 30, 2025
353609c
Write tests
MidhunSureshR Oct 30, 2025
fdbe414
Fix circular dependency
MidhunSureshR Oct 30, 2025
6fc6df6
Merge pull request #31141 from element-hq/midhun/module-impl/stores
MidhunSureshR Nov 4, 2025
8a59a16
Merge pull request #31140 from element-hq/midhun/module-impl/navigation
MidhunSureshR Nov 4, 2025
5f8aa32
Change to class field
MidhunSureshR Nov 4, 2025
f688a06
Merge pull request #31138 from element-hq/midhun/module-impl/client
MidhunSureshR Nov 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/modules/AccountDataApi.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> {
const cli = MatrixClientPeg.safeGet();
return new AccountDataWatchable(cli, eventType);
}

public async set(eventType: string, content: any): Promise<void> {
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<void> {
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<unknown> {
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);
}
}
6 changes: 5 additions & 1 deletion src/modules/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ 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";
import { ClientApi } from "./ClientApi.ts";
import { StoresApi } from "./StoresApi.ts";

const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
let used = false;
Expand Down Expand Up @@ -84,6 +86,8 @@ 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 readonly stores = new StoresApi();

public createRoot(element: Element): Root {
return createRoot(element);
Expand Down
33 changes: 0 additions & 33 deletions src/modules/BuiltinsApi.ts

This file was deleted.

75 changes: 75 additions & 0 deletions src/modules/BuiltinsApi.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
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 React from "react";
import { type RoomViewProps, type BuiltinsApi } from "@element-hq/element-web-module-api";

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<RoomViewPropsWithRoomId>;
roomAvatar: React.ComponentType<RoomAvatarProps>;
}

export class ElementWebBuiltinsApi implements BuiltinsApi {
private _roomView?: React.ComponentType<RoomViewPropsWithRoomId>;
private _roomAvatar?: React.ComponentType<RoomAvatarProps>;

/**
* Sets the components used by the API.
*
* 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 components used by the api, see {@link Components}
*/
public setComponents(components: Components): void {
this._roomView = components.roomView;
this._roomAvatar = components.roomAvatar;
}

public getRoomViewComponent(): React.ComponentType<RoomViewPropsWithRoomId> {
if (!this._roomView) {
throw new Error("No RoomView component has been set");
}

return this._roomView;
}

public getRoomAvatarComponent(): React.ComponentType<RoomAvatarProps> {
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 <Component roomId={roomId} />;
}

public renderRoomAvatar(roomId: string, size?: string): React.ReactNode {
const room = MatrixClientPeg.safeGet().getRoom(roomId);
if (!room) {
throw new Error(`No room such room: ${roomId}`);
}
const Component = this.getRoomAvatarComponent();
return <Component room={room} size={size} />;
}
}
20 changes: 20 additions & 0 deletions src/modules/ClientApi.ts
Original file line number Diff line number Diff line change
@@ -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 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 {
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;
}
}
39 changes: 20 additions & 19 deletions src/modules/Navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 changes: 106 additions & 0 deletions src/modules/StoresApi.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading