Experimental Module API Additions (#30863)

* Module API experiments

* Move ResizerNotifier into SDKContext

so we don't have to pass it into RoomView

* Add the MultiRoomViewStore

* Make RoomViewStore able to take a roomId prop

* Different interface to add space panel items

A bit less flexible but probably simpler and will help keep things
actually consistent rather than just allowing modules to stick any
JSX into the space panel (which means they also have to worry about
styling if they *do* want it to be consistent).

* Allow space panel items to be updated

and manage which one is selected, allowing module "spaces" to be
considered spaces

* Remove fetchRoomFn from SpaceNotificationStore

which didn't really seem to have any point as it was only called from
one place

* Switch to using module api via .instance

* Fairly awful workaround

to actually break the dependency nightmare

* Add test for multiroomviewstore

* add test

* Make room names deterministic

So the tests don't fail if you add other tests or run them individually

* Add test for builtinsapi

* Update module api

* RVS is not needed as prop anymore

Since it's passed through context

* Add roomId to prop

* Remove RoomViewStore from state

This is now accessed through class field

* Fix test

* No need to pass RVS from LoggedInView

* Add RoomContextType

* Implement new builtins api

* Add tests

* Fix import

* Fix circular dependency issue

* Fix import

* Add more tests

* Improve comment

* room-id is optional

* Update license

* Add implementation for AccountDataApi

* Add implementation for Room

* Add implementation for ClientApi

* Create ClientApi in Api.ts

* Write tests

* Use nullish coalescing assignment

* Implement openRoom in NavigationApi

* Write tests

* Add implementation for StoresApi

* Write tests

* Fix circular dependency

* Add comments in lieu of type

and fix else block

* Change to class field

---------

Co-authored-by: R Midhun Suresh <hi@midhun.dev>
This commit is contained in:
David Baker
2025-11-05 07:24:26 +00:00
committed by GitHub
parent 514dd07a28
commit 42f8247c2e
51 changed files with 1088 additions and 154 deletions

View File

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

View File

@@ -26,6 +26,10 @@ import { WatchableProfile } from "./Profile.ts";
import { NavigationApi } from "./Navigation.ts";
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";
import { StoresApi } from "./StoresApi.ts";
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
let used = false;
@@ -79,7 +83,11 @@ export class ModuleApi implements Api {
public readonly config = new ConfigApi();
public readonly i18n = new I18nApi();
public readonly customComponents = new CustomComponentsApi();
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);

View File

@@ -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
src/modules/ClientApi.ts Normal file
View File

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

50
src/modules/ExtrasApi.ts Normal file
View File

@@ -0,0 +1,50 @@
/*
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 { useState } from "react";
import { type SpacePanelItemProps, type ExtrasApi } from "@element-hq/element-web-module-api";
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import { useTypedEventEmitter } from "../hooks/useEventEmitter";
export interface ModuleSpacePanelItem extends SpacePanelItemProps {
spaceKey: string;
}
enum ExtrasApiEvent {
SpacePanelItemsChanged = "SpacePanelItemsChanged",
}
interface EmittedEvents {
[ExtrasApiEvent.SpacePanelItemsChanged]: () => void;
}
export class ElementWebExtrasApi extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents> implements ExtrasApi {
public spacePanelItems = new Map<string, SpacePanelItemProps>();
public setSpacePanelItem(spacekey: string, item: SpacePanelItemProps): void {
this.spacePanelItems.set(spacekey, item);
this.emit(ExtrasApiEvent.SpacePanelItemsChanged);
}
}
export function useModuleSpacePanelItems(api: ElementWebExtrasApi): ModuleSpacePanelItem[] {
const getItems = (): ModuleSpacePanelItem[] => {
return Array.from(api.spacePanelItems.entries()).map(([spaceKey, item]) => ({
spaceKey,
...item,
}));
};
const [items, setItems] = useState<ModuleSpacePanelItem[]>(getItems);
useTypedEventEmitter(api, ExtrasApiEvent.SpacePanelItemsChanged, () => {
setItems(getItems());
});
return items;
}

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 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";
@@ -14,28 +17,32 @@ import { Action } from "../dispatcher/actions.ts";
import type { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload.ts";
export class NavigationApi implements INavigationApi {
public locationRenderers = new Map<string, LocationRenderFunction>();
public async toMatrixToLink(link: string, join = false): Promise<void> {
navigateToPermalink(link);
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

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