diff --git a/packages/element-web-module-api/element-web-module-api.api.md b/packages/element-web-module-api/element-web-module-api.api.md index 5aeb3eb2ed..a6cb726991 100644 --- a/packages/element-web-module-api/element-web-module-api.api.md +++ b/packages/element-web-module-api/element-web-module-api.api.md @@ -24,6 +24,13 @@ export interface AccountAuthInfo { userId: string; } +// @public +export interface AccountDataApi { + delete(eventType: string): Promise; + get(eventType: string): Watchable; + set(eventType: string, content: unknown): Promise; +} + // @alpha @deprecated (undocumented) export interface AliasCustomisations { // (undocumented) @@ -37,6 +44,7 @@ export interface AliasCustomisations { export interface Api extends LegacyModuleApiExtension, LegacyCustomisationsApiExtension, DialogApiExtension, AccountAuthApiExtension, ProfileApiExtension { // @alpha readonly builtins: BuiltinsApi; + readonly client: ClientApi; readonly config: ConfigApi; createRoot(element: Element): Root; // @alpha @@ -46,6 +54,7 @@ export interface Api extends LegacyModuleApiExtension, LegacyCustomisationsApiEx readonly i18n: I18nApi; readonly navigation: NavigationApi; readonly rootNode: HTMLElement; + readonly stores: StoresApi; } // @alpha @@ -65,6 +74,12 @@ export interface ChatExportCustomisations { }; } +// @public +export interface ClientApi { + accountData: AccountDataApi; + getRoom: (id: string) => Room | null; +} + // @alpha @deprecated (undocumented) export interface ComponentVisibilityCustomisations { shouldShowComponent?(component: "UIComponent.sendInvites" | "UIComponent.roomCreation" | "UIComponent.spaceCreation" | "UIComponent.exploreRooms" | "UIComponent.addIntegrations" | "UIComponent.filterContainer" | "UIComponent.roomOptionsMenu"): boolean; @@ -312,11 +327,24 @@ export interface ProfileApiExtension { readonly profile: Watchable; } +// @public +export interface Room { + getLastActiveTimestamp: () => number; + id: string; + name: Watchable; +} + // @alpha @deprecated (undocumented) export interface RoomListCustomisations { isRoomVisible?(room: Room): boolean; } +// @public +export interface RoomListStoreApi { + getRooms(): Watchable; + waitForReady(): Promise; +} + // @alpha export interface RoomViewProps { roomId?: string; @@ -335,6 +363,11 @@ export interface SpacePanelItemProps { tooltip?: string; } +// @public +export interface StoresApi { + roomListStore: RoomListStoreApi; +} + // @public export type Translations = Record { constructor(currentValue: T); + // Warning: (ae-forgotten-export) The symbol "WatchFn" needs to be exported by the entry point index.d.ts + // + // (undocumented) + protected readonly listeners: Set>; + protected onFirstWatch(): void; + protected onLastWatch(): void; // (undocumented) unwatch(listener: (value: T) => void): void; - // (undocumented) get value(): T; set value(value: T); // (undocumented) diff --git a/packages/element-web-module-api/src/api/client.ts b/packages/element-web-module-api/src/api/client.ts new file mode 100644 index 0000000000..45f8e77e30 --- /dev/null +++ b/packages/element-web-module-api/src/api/client.ts @@ -0,0 +1,46 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import type { Room } from "../models/Room"; +import { Watchable } from "./watchable"; + +/** + * Modify account data stored on the homeserver. + * @public + */ +export interface AccountDataApi { + /** + * Returns a watchable with account data for this event type. + */ + get(eventType: string): Watchable; + /** + * Set account data on the homeserver. + */ + set(eventType: string, content: unknown): Promise; + /** + * Changes the content of this event to be empty. + */ + delete(eventType: string): Promise; +} + +/** + * Access some limited functionality from the SDK. + * @public + */ +export interface ClientApi { + /** + * Use this to modify account data on the homeserver. + */ + accountData: AccountDataApi; + + /** + * Fetch room by id from SDK. + * @param id - Id of the room to get + * @returns Room object from SDK + */ + getRoom: (id: string) => Room | null; +} diff --git a/packages/element-web-module-api/src/api/index.ts b/packages/element-web-module-api/src/api/index.ts index dacc7501be..f99a8bbbe8 100644 --- a/packages/element-web-module-api/src/api/index.ts +++ b/packages/element-web-module-api/src/api/index.ts @@ -17,6 +17,8 @@ import { AccountAuthApiExtension } from "./auth.ts"; import { ProfileApiExtension } from "./profile.ts"; import { ExtrasApi } from "./extras.ts"; import { BuiltinsApi } from "./builtins.ts"; +import { StoresApi } from "./stores.ts"; +import { ClientApi } from "./client.ts"; /** * Module interface for modules to implement. @@ -123,6 +125,16 @@ export interface Api */ readonly extras: ExtrasApi; + /** + * Allows modules to access a limited functionality of certain stores from Element Web. + */ + readonly stores: StoresApi; + + /** + * Access some very specific functionality from the client. + */ + readonly client: ClientApi; + /** * Create a ReactDOM root for rendering React components. * Exposed to allow modules to avoid needing to bundle their own ReactDOM. diff --git a/packages/element-web-module-api/src/api/stores.ts b/packages/element-web-module-api/src/api/stores.ts new file mode 100644 index 0000000000..057caec87c --- /dev/null +++ b/packages/element-web-module-api/src/api/stores.ts @@ -0,0 +1,36 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import type { Room } from "../models/Room"; +import { Watchable } from "./watchable"; + +/** + * Provides some basic functionality of the Room List Store from element-web. + * @public + */ +export interface RoomListStoreApi { + /** + * Returns a watchable holding a flat list of sorted room. + */ + getRooms(): Watchable; + + /** + * Returns a promise that resolves when RLS is ready. + */ + waitForReady(): Promise; +} + +/** + * Provides access to certain stores from element-web. + * @public + */ +export interface StoresApi { + /** + * Use this to access limited functionality of the RLS from element-web. + */ + roomListStore: RoomListStoreApi; +} diff --git a/packages/element-web-module-api/src/api/watchable.test.ts b/packages/element-web-module-api/src/api/watchable.test.ts index c045286e2c..e55695c29c 100644 --- a/packages/element-web-module-api/src/api/watchable.test.ts +++ b/packages/element-web-module-api/src/api/watchable.test.ts @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ -import { expect, test, vitest } from "vitest"; +import { expect, test, vi, vitest } from "vitest"; import { Watchable } from "./watchable"; @@ -56,3 +56,44 @@ test("when value is an object, shallow comparison works", () => { watchable.unwatch(listener); // Clean up after the test }); + +test("onFirstWatch and onLastWatch are called when appropriate", () => { + const onFirstWatch = vi.fn(); + const onLastWatch = vi.fn(); + class CustomWatchable extends Watchable { + protected onFirstWatch(): void { + onFirstWatch(); + } + protected onLastWatch(): void { + onLastWatch(); + } + } + + const watchable = new CustomWatchable(10); + // No listeners yet, so expect no calls + expect(onFirstWatch).not.toHaveBeenCalled(); + expect(onLastWatch).not.toHaveBeenCalled(); + + // Let's say that we have three listeners + const listeners = [vi.fn(), vi.fn(), vi.fn()]; + + // Let's add all of them via watch + for (const listener of listeners) { + watchable.watch(listener); + } + + // Only expect onFirstWatch() to have been called once + expect(onFirstWatch).toHaveBeenCalledOnce(); + + // Let's remove all the listeners + for (const listener of listeners) { + watchable.unwatch(listener); + } + + // Only expect onLastWatch to have been called once + expect(onLastWatch).toHaveBeenCalledOnce(); + + // Should call onFirstWatch again once we have more listeners + watchable.watch(vi.fn()); + expect(onFirstWatch).toHaveBeenCalledTimes(2); +}); diff --git a/packages/element-web-module-api/src/api/watchable.ts b/packages/element-web-module-api/src/api/watchable.ts index 2f82a9b732..69296571c8 100644 --- a/packages/element-web-module-api/src/api/watchable.ts +++ b/packages/element-web-module-api/src/api/watchable.ts @@ -26,10 +26,14 @@ function isObject(value: unknown): value is object { * @public */ export class Watchable { - private readonly listeners = new Set>(); + protected readonly listeners = new Set>(); public constructor(private currentValue: T) {} + /** + * The value stored in this watchable. + * Warning: Could potentially return stale data if you haven't called {@link Watchable#watch}. + */ public get value(): T { return this.currentValue; } @@ -50,12 +54,32 @@ export class Watchable { } public watch(listener: (value: T) => void): void { + // Call onFirstWatch if there was no listener before. + if (this.listeners.size === 0) { + this.onFirstWatch(); + } this.listeners.add(listener); } public unwatch(listener: (value: T) => void): void { - this.listeners.delete(listener); + const hasDeleted = this.listeners.delete(listener); + // Call onLastWatch if every listener has been removed. + if (hasDeleted && this.listeners.size === 0) { + this.onLastWatch(); + } } + + /** + * This is called when the number of listeners go from zero to one. + * Could be used to add external event listeners. + */ + protected onFirstWatch(): void {} + + /** + * This is called when the number of listeners go from one to zero. + * Could be used to remove external event listeners. + */ + protected onLastWatch(): void {} } /** diff --git a/packages/element-web-module-api/src/index.ts b/packages/element-web-module-api/src/index.ts index e89adc482d..52a0593bc5 100644 --- a/packages/element-web-module-api/src/index.ts +++ b/packages/element-web-module-api/src/index.ts @@ -10,6 +10,7 @@ export type { Api, Module, ModuleFactory } from "./api"; export type { Config, ConfigApi } from "./api/config"; export type { I18nApi, Variables, Translations } from "./api/i18n"; export type * from "./models/event"; +export type * from "./models/Room"; export type * from "./api/custom-components"; export type * from "./api/extras"; export type * from "./api/legacy-modules"; @@ -19,4 +20,6 @@ export type * from "./api/dialog"; export type * from "./api/profile"; export type * from "./api/navigation"; export type * from "./api/builtins"; +export type * from "./api/stores"; +export type * from "./api/client"; export * from "./api/watchable"; diff --git a/packages/element-web-module-api/src/models/Room.ts b/packages/element-web-module-api/src/models/Room.ts new file mode 100644 index 0000000000..df2f1c8643 --- /dev/null +++ b/packages/element-web-module-api/src/models/Room.ts @@ -0,0 +1,28 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { Watchable } from "../api/watchable"; + +/** + * Represents a room from element-web. + * @public + */ +export interface Room { + /** + * Id of this room. + */ + id: string; + /** + * {@link Watchable} holding the name for this room. + */ + name: Watchable; + /** + * Get the timestamp of the last message in this room. + * @returns last active timestamp + */ + getLastActiveTimestamp: () => number; +}