Initial stable release of the Module API

Primarily to get away from semver treating every update as breaking in the 0.x.y series.
This commit is contained in:
Michael Telatynski 2025-05-13 10:40:26 +01:00
parent afcf4593bc
commit d7736db1af
9 changed files with 519 additions and 7 deletions

View File

@ -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<Translations>): void;
translate(key: keyof Translations, variables?: Variables): string;
}
// @alpha @deprecated (undocumented)
export type LegacyCustomisations<T extends object> = (customisations: T) => void;
@ -179,6 +189,11 @@ export interface RoomListCustomisations<Room> {
// @alpha @deprecated (undocumented)
export type RuntimeModuleConstructor = new (api: ModuleApi) => RuntimeModule;
// @public
export type Translations = Record<string, {
[ietfLanguageTag: string]: string;
}>;
// @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<Widget, Capability> {
preapproveCapabilities?(widget: Widget, requestedCapabilities: Set<Capability>): Promise<Set<Capability>>;

View File

@ -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",

View File

@ -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<K extends keyof Config>(key: K): Config[K];
get<K extends keyof Config = never>(key?: K): Config | Config[K];
}

View File

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

View File

@ -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<void> {}
},
};
test("isModule correctly identifies valid modules", () => {
expect(isModule(TestModule)).toBe(true);
});

View File

@ -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<void>;
}
const moduleSignature: Record<keyof Module, Type> = {
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<keyof ModuleFactory, Type> = {
moduleApiVersion: "string",
prototype: "object",
};
export interface ModuleExport {
default: ModuleFactory;
}
const moduleExportSignature: Record<keyof ModuleExport, Type> = {
default: "function",
};
type Type = "function" | "string" | "number" | "boolean" | "object";
function isInterface<T>(obj: unknown, type: "object" | "function", keys: Record<keyof T, Type>): obj is T {
if (obj === null || typeof obj !== type) return false;
for (const key in keys) {
if (typeof (obj as Record<keyof T, unknown>)[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;
}

View File

@ -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<ExportFormat, ExportType> {
/**
* 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<Response>;
}
/**
* @alpha
* @deprecated in favour of the new Module API
*/
export interface MediaContructable<PreparedMedia> {
new (prepared: PreparedMedia): Media;
}
/**
* @alpha
* @deprecated in favour of the new Module API
*/
export interface MediaCustomisations<Content, Client, PreparedMedia> {
readonly Media: MediaContructable<PreparedMedia>;
mediaFromContent(content: Content, client?: Client): Media;
mediaFromMxc(mxc?: string, client?: Client): Media;
}
/**
* @alpha
* @deprecated in favour of the new Module API
*/
export interface RoomListCustomisations<Room> {
/**
* 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<Widget, Capability> {
/**
* 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<Capability>): Promise<Set<Capability>>;
}
/**
* @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<void>;
}
/**
* @alpha
* @deprecated in favour of the new Module API
*/
export type LegacyCustomisations<T extends object> = (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<AliasCustomisations>;
/**
* @deprecated in favour of the new Module API
*/
readonly _registerLegacyChatExportCustomisations: LegacyCustomisations<ChatExportCustomisations<never, never>>;
/**
* @deprecated in favour of the new Module API
*/
readonly _registerLegacyComponentVisibilityCustomisations: LegacyCustomisations<ComponentVisibilityCustomisations>;
/**
* @deprecated in favour of the new Module API
*/
readonly _registerLegacyDirectoryCustomisations: LegacyCustomisations<DirectoryCustomisations>;
/**
* @deprecated in favour of the new Module API
*/
readonly _registerLegacyLifecycleCustomisations: LegacyCustomisations<LifecycleCustomisations>;
/**
* @deprecated in favour of the new Module API
*/
readonly _registerLegacyMediaCustomisations: LegacyCustomisations<MediaCustomisations<never, never, never>>;
/**
* @deprecated in favour of the new Module API
*/
readonly _registerLegacyRoomListCustomisations: LegacyCustomisations<RoomListCustomisations<never>>;
/**
* @deprecated in favour of the new Module API
*/
readonly _registerLegacyUserIdentifierCustomisations: LegacyCustomisations<UserIdentifierCustomisations>;
/**
* @deprecated in favour of the new Module API
*/
readonly _registerLegacyWidgetPermissionsCustomisations: LegacyCustomisations<
WidgetPermissionsCustomisations<never, never>
>;
/**
* @deprecated in favour of the new Module API
*/
readonly _registerLegacyWidgetVariablesCustomisations: LegacyCustomisations<WidgetVariablesCustomisations>;
}

View File

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

View File

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