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 e47e7428f8..a4b9456cb6 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 @@ -5,6 +5,7 @@ ```ts import { ModuleApi } from '@matrix-org/react-sdk-module-api'; +import { Root } from 'react-dom/client'; import { RuntimeModule } from '@matrix-org/react-sdk-module-api'; // @alpha @deprecated (undocumented) @@ -18,8 +19,10 @@ export interface AliasCustomisations { // // @public export interface Api extends LegacyModuleApiExtension, LegacyCustomisationsApiExtension { - // (undocumented) - config: ConfigApi; + readonly config: ConfigApi; + createRoot(element: Element): Root; + readonly i18n: I18nApi; + readonly rootNode: HTMLElement; } // @alpha @deprecated (undocumented) @@ -60,6 +63,13 @@ export interface DirectoryCustomisations { requireCanonicalAliasAccessToPublish?(): boolean; } +// @public +export interface I18nApi { + get language(): string; + register(translations: Partial): void; + translate(key: keyof Translations, variables?: Variables): string; +} + // @alpha @deprecated (undocumented) export type LegacyCustomisations = (customisations: T) => void; @@ -179,6 +189,11 @@ export interface RoomListCustomisations { // @alpha @deprecated (undocumented) export type RuntimeModuleConstructor = new (api: ModuleApi) => RuntimeModule; +// @public +export type Translations = Record; + // @alpha @deprecated (undocumented) export interface UserIdentifierCustomisations { getDisplayUserIdentifier(userId: string, opts: { @@ -187,6 +202,12 @@ export interface UserIdentifierCustomisations { }): string | null; } +// @public +export type Variables = { + count?: number; + [key: string]: number | string | undefined; +}; + // @alpha @deprecated (undocumented) export interface WidgetPermissionsCustomisations { preapproveCapabilities?(widget: Widget, requestedCapabilities: Set): Promise>; diff --git a/packages/element-web-module-api/package.json b/packages/element-web-module-api/package.json index ad1df4a164..9c0e0cc364 100644 --- a/packages/element-web-module-api/package.json +++ b/packages/element-web-module-api/package.json @@ -1,7 +1,7 @@ { "name": "@element-hq/element-web-module-api", "type": "module", - "version": "0.1.5", + "version": "1.0.0", "description": "Module API surface for element-web", "repository": { "type": "git", @@ -26,7 +26,8 @@ ], "scripts": { "prepare": "vite build && api-extractor run", - "lint": "tsc --noEmit", + "lint:types": "tsc --noEmit", + "lint:codestyle": "echo 'handled by lint:eslint'", "test": "vitest --coverage" }, "devDependencies": { @@ -34,6 +35,7 @@ "@microsoft/api-extractor": "^7.49.1", "@types/node": "^22.10.7", "@types/react": "^19", + "@types/react-dom": "^19.0.4", "@types/semver": "^7.5.8", "@vitest/coverage-v8": "^3.0.4", "matrix-web-i18n": "^3.3.0", diff --git a/packages/element-web-module-api/src/api/config.ts b/packages/element-web-module-api/src/api/config.ts new file mode 100644 index 0000000000..5f708b4983 --- /dev/null +++ b/packages/element-web-module-api/src/api/config.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. +*/ + +/** + * The configuration for the application. + * Should be extended via declaration merging. + * @public + */ +export interface Config { + // The branding name of the application + brand: string; + // Other config options are available but not specified in the types as that would make it difficult to change for element-web + // they are accessible at runtime all the same, see list at https://github.com/element-hq/element-web/blob/develop/docs/config.md +} + +/** + * API for accessing the configuration. + * @public + */ +export interface ConfigApi { + get(): Config; + get(key: K): Config[K]; + get(key?: K): Config | Config[K]; +} diff --git a/packages/element-web-module-api/src/api/i18n.ts b/packages/element-web-module-api/src/api/i18n.ts new file mode 100644 index 0000000000..a93c247d4b --- /dev/null +++ b/packages/element-web-module-api/src/api/i18n.ts @@ -0,0 +1,52 @@ +/* +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. +*/ + +/** + * The translations for the module. + * @public + */ +export type Translations = Record< + string, + { + [ietfLanguageTag: string]: string; + } +>; + +/** + * Variables to interpolate into a translation. + * @public + */ +export type Variables = { + /** + * The number of items to count for pluralised translations + */ + count?: number; + [key: string]: number | string | undefined; +}; + +/** + * The API for interacting with translations. + * @public + */ +export interface I18nApi { + /** + * Read the current language of the user in IETF Language Tag format + */ + get language(): string; + + /** + * Register translations for the module, may override app's existing translations + */ + register(translations: Partial): void; + + /** + * Perform a translation, with optional variables + * @param key - The key to translate + * @param variables - Optional variables to interpolate into the translation + */ + translate(key: keyof Translations, variables?: Variables): string; +} diff --git a/packages/element-web-module-api/src/api/index.test.ts b/packages/element-web-module-api/src/api/index.test.ts new file mode 100644 index 0000000000..25f34768a8 --- /dev/null +++ b/packages/element-web-module-api/src/api/index.test.ts @@ -0,0 +1,22 @@ +/* +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 { expect, test } from "vitest"; + +import { Api, isModule } from "."; + +const TestModule = { + default: class TestModule { + public static moduleApiVersion = "1.0.0"; + public constructor(private readonly api: Api) {} + public async load(): Promise {} + }, +}; + +test("isModule correctly identifies valid modules", () => { + expect(isModule(TestModule)).toBe(true); +}); diff --git a/packages/element-web-module-api/src/api/index.ts b/packages/element-web-module-api/src/api/index.ts new file mode 100644 index 0000000000..f20d9db300 --- /dev/null +++ b/packages/element-web-module-api/src/api/index.ts @@ -0,0 +1,96 @@ +/* +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 { Root } from "react-dom/client"; +import { LegacyModuleApiExtension } from "./legacy-modules"; +import { LegacyCustomisationsApiExtension } from "./legacy-customisations"; +import { ConfigApi } from "./config"; +import { I18nApi } from "./i18n"; + +/** + * Module interface for modules to implement. + * @public + */ +export interface Module { + load(): Promise; +} + +const moduleSignature: Record = { + load: "function", +}; + +/** + * Module interface for modules to export as the default export. + * @public + */ +export interface ModuleFactory { + readonly moduleApiVersion: string; + new (api: Api): Module; + readonly prototype: Module; +} + +const moduleFactorySignature: Record = { + moduleApiVersion: "string", + prototype: "object", +}; + +export interface ModuleExport { + default: ModuleFactory; +} + +const moduleExportSignature: Record = { + default: "function", +}; + +type Type = "function" | "string" | "number" | "boolean" | "object"; + +function isInterface(obj: unknown, type: "object" | "function", keys: Record): obj is T { + if (obj === null || typeof obj !== type) return false; + for (const key in keys) { + if (typeof (obj as Record)[key] !== keys[key]) return false; + } + return true; +} + +export function isModule(module: unknown): module is ModuleExport { + return ( + isInterface(module, "object", moduleExportSignature) && + isInterface(module.default, "function", moduleFactorySignature) && + isInterface(module.default.prototype, "object", moduleSignature) + ); +} + +/** + * The API for modules to interact with the application. + * @public + */ +export interface Api extends LegacyModuleApiExtension, LegacyCustomisationsApiExtension { + /** + * The API to read config.json values. + * Keys should be scoped to the module in reverse domain name notation. + * @public + */ + readonly config: ConfigApi; + /** + * The internationalisation API. + * @public + */ + readonly i18n: I18nApi; + /** + * The root node the main application is rendered to. + * Intended for rendering sibling React trees. + * @public + */ + readonly rootNode: HTMLElement; + /** + * Create a ReactDOM root for rendering React components. + * Exposed to allow modules to avoid needing to bundle their own ReactDOM. + * @param element - the element to render use as the root. + * @public + */ + createRoot(element: Element): Root; +} diff --git a/packages/element-web-module-api/src/api/legacy-customisations.ts b/packages/element-web-module-api/src/api/legacy-customisations.ts new file mode 100644 index 0000000000..6374789b5b --- /dev/null +++ b/packages/element-web-module-api/src/api/legacy-customisations.ts @@ -0,0 +1,259 @@ +/* +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. +*/ + +/** + * The types here suck but these customisations are deprecated and will be removed soon. + */ + +/** + * @alpha + * @deprecated in favour of the new Module API + */ +export interface AliasCustomisations { + // E.g. prefer one of the aliases over another + getDisplayAliasForAliasSet?(canonicalAlias: string | null, altAliases: string[]): string | null; +} + +/** + * @alpha + * @deprecated in favour of the new Module API + */ +export interface ChatExportCustomisations { + /** + * Force parameters in room chat export fields returned here are forced + * and not allowed to be edited in the chat export form + */ + getForceChatExportParameters(): { + format?: ExportFormat; + range?: ExportType; + // must be < 10**8 + // only used when range is 'LastNMessages' + // default is 100 + numberOfMessages?: number; + includeAttachments?: boolean; + // maximum size of exported archive + // must be > 0 and < 8000 + sizeMb?: number; + }; +} + +/** + * @alpha + * @deprecated in favour of the new Module API + */ +export interface ComponentVisibilityCustomisations { + /** + * Determines whether or not the active MatrixClient user should be able to use + * the given UI component. If shown, the user might still not be able to use the + * component depending on their contextual permissions. For example, invite options + * might be shown to the user but they won't have permission to invite users to + * the current room: the button will appear disabled. + * @param component - The component to check visibility for. + * @returns True (default) if the user is able to see the component, false otherwise. + */ + shouldShowComponent?( + component: + | "UIComponent.sendInvites" + | "UIComponent.roomCreation" + | "UIComponent.spaceCreation" + | "UIComponent.exploreRooms" + | "UIComponent.addIntegrations" + | "UIComponent.filterContainer" + | "UIComponent.roomOptionsMenu", + ): boolean; +} + +/** + * @alpha + * @deprecated in favour of the new Module API + */ +export interface DirectoryCustomisations { + requireCanonicalAliasAccessToPublish?(): boolean; +} + +/** + * @alpha + * @deprecated in favour of the new Module API + */ +export interface LifecycleCustomisations { + onLoggedOutAndStorageCleared?(): void; +} + +/** + * @alpha + * @deprecated in favour of the new Module API + */ +export interface Media { + readonly isEncrypted: boolean; + readonly srcMxc: string; + readonly thumbnailMxc: string | null | undefined; + readonly hasThumbnail: boolean; + readonly srcHttp: string | null; + readonly thumbnailHttp: string | null; + getThumbnailHttp(width: number, height: number, mode?: "scale" | "crop"): string | null; + getThumbnailOfSourceHttp(width: number, height: number, mode?: "scale" | "crop"): string | null; + getSquareThumbnailHttp(dim: number): string | null; + downloadSource(): Promise; +} + +/** + * @alpha + * @deprecated in favour of the new Module API + */ +export interface MediaContructable { + new (prepared: PreparedMedia): Media; +} + +/** + * @alpha + * @deprecated in favour of the new Module API + */ +export interface MediaCustomisations { + readonly Media: MediaContructable; + mediaFromContent(content: Content, client?: Client): Media; + mediaFromMxc(mxc?: string, client?: Client): Media; +} + +/** + * @alpha + * @deprecated in favour of the new Module API + */ +export interface RoomListCustomisations { + /** + * Determines if a room is visible in the room list or not. By default, + * all rooms are visible. Where special handling is performed by Element, + * those rooms will not be able to override their visibility in the room + * list - Element will make the decision without calling this function. + * + * This function should be as fast as possible to avoid slowing down the + * client. + * @param room - The room to check the visibility of. + * @returns True if the room should be visible, false otherwise. + */ + isRoomVisible?(room: Room): boolean; +} + +/** + * @alpha + * @deprecated in favour of the new Module API + */ +export interface UserIdentifierCustomisations { + /** + * Customise display of the user identifier + * hide userId for guests, display 3pid + * + * Set withDisplayName to true when user identifier will be displayed alongside user name + */ + getDisplayUserIdentifier(userId: string, opts: { roomId?: string; withDisplayName?: boolean }): string | null; +} + +/** + * @alpha + * @deprecated in favour of the new Module API + */ +export interface WidgetPermissionsCustomisations { + /** + * Approves the widget for capabilities that it requested, if any can be + * approved. Typically this will be used to give certain widgets capabilities + * without having to prompt the user to approve them. This cannot reject + * capabilities that Element will be automatically granting, such as the + * ability for Jitsi widgets to stay on screen - those will be approved + * regardless. + * @param widget - The widget to approve capabilities for. + * @param requestedCapabilities - The capabilities the widget requested. + * @returns Resolves to the capabilities that are approved for use + * by the widget. If none are approved, this should return an empty Set. + */ + preapproveCapabilities?(widget: Widget, requestedCapabilities: Set): Promise>; +} + +/** + * @alpha + * @deprecated in favour of the new Module API + */ +export interface WidgetVariablesCustomisations { + /** + * Provides a partial set of the variables needed to render any widget. If + * variables are missing or not provided then they will be filled with the + * application-determined defaults. + * + * This will not be called until after isReady() resolves. + * @returns The variables. + */ + provideVariables?(): { + currentUserId: string; + userDisplayName?: string; + userHttpAvatarUrl?: string; + clientId?: string; + clientTheme?: string; + clientLanguage?: string; + deviceId?: string; + baseUrl?: string; + }; + /** + * Resolves to whether or not the customisation point is ready for variables + * to be provided. This will block widgets being rendered. + * If not provided, the app will assume that the customisation is always ready. + * @returns a promise which resolves when ready. + */ + isReady?(): Promise; +} + +/** + * @alpha + * @deprecated in favour of the new Module API + */ +export type LegacyCustomisations = (customisations: T) => void; + +/** + * @alpha + * @deprecated in favour of the new Module API + */ +export interface LegacyCustomisationsApiExtension { + /** + * @deprecated in favour of the new Module API + */ + readonly _registerLegacyAliasCustomisations: LegacyCustomisations; + /** + * @deprecated in favour of the new Module API + */ + readonly _registerLegacyChatExportCustomisations: LegacyCustomisations>; + /** + * @deprecated in favour of the new Module API + */ + readonly _registerLegacyComponentVisibilityCustomisations: LegacyCustomisations; + /** + * @deprecated in favour of the new Module API + */ + readonly _registerLegacyDirectoryCustomisations: LegacyCustomisations; + /** + * @deprecated in favour of the new Module API + */ + readonly _registerLegacyLifecycleCustomisations: LegacyCustomisations; + /** + * @deprecated in favour of the new Module API + */ + readonly _registerLegacyMediaCustomisations: LegacyCustomisations>; + /** + * @deprecated in favour of the new Module API + */ + readonly _registerLegacyRoomListCustomisations: LegacyCustomisations>; + /** + * @deprecated in favour of the new Module API + */ + readonly _registerLegacyUserIdentifierCustomisations: LegacyCustomisations; + /** + * @deprecated in favour of the new Module API + */ + readonly _registerLegacyWidgetPermissionsCustomisations: LegacyCustomisations< + WidgetPermissionsCustomisations + >; + /** + * @deprecated in favour of the new Module API + */ + readonly _registerLegacyWidgetVariablesCustomisations: LegacyCustomisations; +} diff --git a/packages/element-web-module-api/src/api/legacy-modules.ts b/packages/element-web-module-api/src/api/legacy-modules.ts new file mode 100644 index 0000000000..baaad96b01 --- /dev/null +++ b/packages/element-web-module-api/src/api/legacy-modules.ts @@ -0,0 +1,30 @@ +/* +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. +*/ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore -- optional interface, will gracefully degrade to `any` if `react-sdk-module-api` isn't installed +import type { ModuleApi, RuntimeModule } from "@matrix-org/react-sdk-module-api"; + +/** + * @alpha + * @deprecated in favour of the new module API + */ +export type RuntimeModuleConstructor = new (api: ModuleApi) => RuntimeModule; + +/** + * @alpha + * @deprecated in favour of the new module API + */ +/* eslint-disable @typescript-eslint/naming-convention */ +export interface LegacyModuleApiExtension { + /** + * Register a legacy module based on \@matrix-org/react-sdk-module-api + * @param LegacyModule - the module class to register + * @deprecated provided only as a transition path for legacy modules + */ + _registerLegacyModule(LegacyModule: RuntimeModuleConstructor): Promise; +} diff --git a/packages/element-web-module-api/src/index.ts b/packages/element-web-module-api/src/index.ts index 0b2bf9435c..b503c88188 100644 --- a/packages/element-web-module-api/src/index.ts +++ b/packages/element-web-module-api/src/index.ts @@ -6,6 +6,8 @@ Please see LICENSE files in the repository root for full details. */ export { ModuleLoader, ModuleIncompatibleError } from "./loader"; -export type { Api, Module, ModuleFactory, Config, ConfigApi } from "./api"; -export type * from "./legacy-modules"; -export type * from "./legacy-customisations"; +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 "./api/legacy-modules"; +export type * from "./api/legacy-customisations";