diff --git a/.github/workflows/npm-publish.yaml b/.github/workflows/npm-publish.yaml index 708d233d26..8ea61e4efa 100644 --- a/.github/workflows/npm-publish.yaml +++ b/.github/workflows/npm-publish.yaml @@ -10,6 +10,7 @@ on: options: - playwright-common - shared-components + - module-api concurrency: release jobs: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 60730451f6..c153d8bf85 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -100,7 +100,7 @@ jobs: complete: name: jest-tests - needs: [jest_ew, vitest_sc] + needs: [jest_ew, vitest] if: always() runs-on: ubuntu-24.04 permissions: @@ -120,8 +120,13 @@ jobs: sha: ${{ github.sha }} target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - vitest_sc: - name: Vitest (Shared Components) + vitest: + name: Vitest + strategy: + matrix: + package: + - shared-components + - module-api runs-on: ubuntu-24.04 steps: - name: Checkout code @@ -137,32 +142,32 @@ jobs: node-version: "lts/*" cache: "pnpm" - - name: Install Shared Component Deps - working-directory: "packages/shared-components" + - name: Install Deps run: "pnpm install" - name: Cache storybook & vitest uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 with: path: | - packages/shared-components/node_modules/.cache - packages/shared-components/node_modules/.vite/vitest + packages/${{ matrix.package }}/node_modules/.cache + packages/${{ matrix.package }}/node_modules/.vite/vitest key: ${{ hashFiles('pnpm-lock.yaml') }} - name: Setup playwright uses: ./.github/actions/setup-playwright + if: matrix.package == 'shared-components' with: write-cache: ${{ github.event_name != 'merge_group' }} - name: Run tests - working-directory: "packages/shared-components" + working-directory: "packages/${{ matrix.package }}" run: pnpm test:unit --coverage=$ENABLE_COVERAGE - name: Upload Artifact if: env.ENABLE_COVERAGE == 'true' uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: - name: coverage-sharedcomponents + name: coverage-${{ matrix.package }} path: | - packages/shared-components/coverage - !packages/shared-components/coverage/lcov-report + packages/${{ matrix.package }}/coverage + !packages/${{ matrix.package }}/coverage/lcov-report diff --git a/.prettierignore b/.prettierignore index ca5fe9afd8..b6f2c7a1e7 100644 --- a/.prettierignore +++ b/.prettierignore @@ -14,7 +14,8 @@ webpack-stats.json .vscode/ .env coverage -# Auto-generated file +# Auto-generated files +*.api.md /apps/web/src/modules.ts /apps/web/src/modules.js src/i18n/strings diff --git a/apps/web/Dockerfile.dockerignore b/apps/web/Dockerfile.dockerignore index 403d667eaa..c1af93fc7e 100644 --- a/apps/web/Dockerfile.dockerignore +++ b/apps/web/Dockerfile.dockerignore @@ -10,6 +10,7 @@ **/.pnpm-store **/tsconfig.node.tsbuildinfo **/*.md +!**/*.api.md **/*.rst .idea/ diff --git a/apps/web/package.json b/apps/web/package.json index 6640de4ff9..a39463e2b5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,7 +38,7 @@ }, "dependencies": { "@babel/runtime": "^7.12.5", - "@element-hq/element-web-module-api": "catalog:", + "@element-hq/element-web-module-api": "workspace:*", "@element-hq/web-shared-components": "workspace:*", "@fontsource/fira-code": "^5", "@fontsource/inter": "catalog:", diff --git a/knip.ts b/knip.ts index 6efd0ee3f2..53718f2e15 100644 --- a/knip.ts +++ b/knip.ts @@ -14,6 +14,7 @@ export default { ], ignoreBinaries: ["awk"], }, + "packages/module-api": {}, "apps/web": { entry: [ "src/serviceworker/index.ts", diff --git a/packages/module-api/.gitignore b/packages/module-api/.gitignore new file mode 100644 index 0000000000..f3868f90cd --- /dev/null +++ b/packages/module-api/.gitignore @@ -0,0 +1,2 @@ +lib/ +temp/ diff --git a/packages/module-api/Dockerfile b/packages/module-api/Dockerfile new file mode 100644 index 0000000000..d79a573dd1 --- /dev/null +++ b/packages/module-api/Dockerfile @@ -0,0 +1,24 @@ +ARG ELEMENT_VERSION=latest@sha256:a84f294ce46e4327ebacecb78bfc94cf6a45c7ffa5104a28f06b5ac69d0b2548 + +FROM --platform=$BUILDPLATFORM node:lts-alpine@sha256:01743339035a5c3c11a373cd7c83aeab6ed1457b55da6a69e014a95ac4e4700b AS builder + +ARG BUILD_CONTEXT + +RUN apk add --no-cache jq + +WORKDIR /app +COPY package.json yarn.lock ./ +# Copy the package.json files of all modules & packages to ensure the frozen workspace lockfile holds up +RUN --mount=type=bind,target=/docker-context \ + cd /docker-context/; \ + find . -path ./node_modules -prune -o -name "package.json" -mindepth 0 -maxdepth 4 -exec cp --parents "{}" /app/ \; +RUN yarn install --frozen-lockfile --ignore-scripts +COPY tsconfig.json ./ +COPY ./$BUILD_CONTEXT ./$BUILD_CONTEXT +RUN cd $BUILD_CONTEXT && yarn vite build +RUN mkdir /modules +RUN cp -r ./$BUILD_CONTEXT/lib/ /modules/$(jq -r '"\(.name)-v\(.version)"' ./$BUILD_CONTEXT/package.json) + +FROM ghcr.io/element-hq/element-web:${ELEMENT_VERSION} + +COPY --from=builder /modules /modules/ \ No newline at end of file diff --git a/packages/module-api/README.md b/packages/module-api/README.md new file mode 100644 index 0000000000..93186ed438 --- /dev/null +++ b/packages/module-api/README.md @@ -0,0 +1,68 @@ +# @element-hq/element-web-module-api + +API surface for extending Element Web in a safe & predictable way. + +This project is still in early development but aims to replace matrix-react-sdk-module-api and Element Web deprecated customisations. + +## Using the API + +Modules are loaded by Element Web at runtime via a dynamic ecmascript import, but can be bundled into a webapp for deployment convenience. + +The module's default export MUST be a class which accepts a single argument, an instance of `ModuleApi`. +This class must also bear a static property `moduleApiVersion` which is a semver range string +and a `load` method which is called when the module is to be loaded. + +```typescript +import type { Module, Api, ModuleFactory } from "@element-hq/element-web-module-api"; + +class ExampleModule implements Module { + public static readonly moduleApiVersion = "^0.1.0"; + + public constructor(private api: Api) {} + + public async load(): Promise { + // Your extension code goes here + } +} + +export default ExampleModule satisfies ModuleFactory; +``` + +### Accessing application configuration + +The `api` object passed to the module constructor provides access to the application configuration. +You can extend the Config types using declaration merging, though please ensure that you do not trust the types you specify, +and opt for runtime validation due to the dynamic nature of the configuration. + +```typescript +// ... +declare module "@element-hq/element-web-module-api" { + interface Config { + "this.is.my.config.key": string; + } +} + +class ExampleModule implements Module { + // ... + public async load(): Promise { + const configValue = this.api.config.get("this.is.my.config.key"); + // Your extension code goes here + } +} +// ... +``` + +## Releases + +The API is versioned using semver, with the major version incremented for breaking changes. + +## Copyright & License + +Copyright (c) 2025 New Vector Ltd + +This software is multi licensed by New Vector Ltd (Element). It can be used either: + +(1) for free under the terms of the GNU Affero General Public License (as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version); OR + +(2) under the terms of a paid-for Element Commercial License agreement between you and Element (the terms of which may vary depending on what you and Element have agreed to). +Unless required by applicable law or agreed to in writing, software distributed under the Licenses is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the Licenses for the specific language governing permissions and limitations under the Licenses. diff --git a/packages/module-api/api-extractor.json b/packages/module-api/api-extractor.json new file mode 100644 index 0000000000..e945efc023 --- /dev/null +++ b/packages/module-api/api-extractor.json @@ -0,0 +1,454 @@ +/** + * Config file for API Extractor. For more info, please visit: https://api-extractor.com + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + + /** + * Optionally specifies another JSON config file that this file extends from. This provides a way for + * standard settings to be shared across multiple projects. + * + * If the path starts with "./" or "../", the path is resolved relative to the folder of the file that contains + * the "extends" field. Otherwise, the first path segment is interpreted as an NPM package name, and will be + * resolved using NodeJS require(). + * + * SUPPORTED TOKENS: none + * DEFAULT VALUE: "" + */ + // "extends": "./shared/api-extractor-base.json" + // "extends": "my-package/include/api-extractor-base.json" + + /** + * Determines the "" token that can be used with other config file settings. The project folder + * typically contains the tsconfig.json and package.json config files, but the path is user-defined. + * + * The path is resolved relative to the folder of the config file that contains the setting. + * + * The default value for "projectFolder" is the token "", which means the folder is determined by traversing + * parent folders, starting from the folder containing api-extractor.json, and stopping at the first folder + * that contains a tsconfig.json file. If a tsconfig.json file cannot be found in this way, then an error + * will be reported. + * + * SUPPORTED TOKENS: + * DEFAULT VALUE: "" + */ + // "projectFolder": "..", + + /** + * (REQUIRED) Specifies the .d.ts file to be used as the starting point for analysis. API Extractor + * analyzes the symbols exported by this module. + * + * The file extension must be ".d.ts" and not ".ts". + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + */ + "mainEntryPointFilePath": "/lib/index.d.ts", + + /** + * A list of NPM package names whose exports should be treated as part of this package. + * + * For example, suppose that Webpack is used to generate a distributed bundle for the project "library1", + * and another NPM package "library2" is embedded in this bundle. Some types from library2 may become part + * of the exported API for library1, but by default API Extractor would generate a .d.ts rollup that explicitly + * imports library2. To avoid this, we might specify: + * + * "bundledPackages": [ "library2" ], + * + * This would direct API Extractor to embed those types directly in the .d.ts rollup, as if they had been + * local files for library1. + * + * The "bundledPackages" elements may specify glob patterns using minimatch syntax. To ensure deterministic + * output, globs are expanded by matching explicitly declared top-level dependencies only. For example, + * the pattern below will NOT match "@my-company/example" unless it appears in a field such as "dependencies" + * or "devDependencies" of the project's package.json file: + * + * "bundledPackages": [ "@my-company/*" ], + */ + "bundledPackages": [], + + /** + * Specifies what type of newlines API Extractor should use when writing output files. By default, the output files + * will be written with Windows-style newlines. To use POSIX-style newlines, specify "lf" instead. + * To use the OS's default newline kind, specify "os". + * + * DEFAULT VALUE: "crlf" + */ + // "newlineKind": "crlf", + + /** + * Specifies how API Extractor sorts members of an enum when generating the .api.json file. By default, the output + * files will be sorted alphabetically, which is "by-name". To keep the ordering in the source code, specify + * "preserve". + * + * DEFAULT VALUE: "by-name" + */ + // "enumMemberOrder": "by-name", + + /** + * Set to true when invoking API Extractor's test harness. When `testMode` is true, the `toolVersion` field in the + * .api.json file is assigned an empty string to prevent spurious diffs in output files tracked for tests. + * + * DEFAULT VALUE: "false" + */ + // "testMode": false, + + /** + * Determines how the TypeScript compiler engine will be invoked by API Extractor. + */ + "compiler": { + /** + * Specifies the path to the tsconfig.json file to be used by API Extractor when analyzing the project. + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * Note: This setting will be ignored if "overrideTsconfig" is used. + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "/tsconfig.json" + */ + // "tsconfigFilePath": "/tsconfig.json", + /** + * Provides a compiler configuration that will be used instead of reading the tsconfig.json file from disk. + * The object must conform to the TypeScript tsconfig schema: + * + * http://json.schemastore.org/tsconfig + * + * If omitted, then the tsconfig.json file will be read from the "projectFolder". + * + * DEFAULT VALUE: no overrideTsconfig section + */ + // "overrideTsconfig": { + // . . . + // } + /** + * This option causes the compiler to be invoked with the --skipLibCheck option. This option is not recommended + * and may cause API Extractor to produce incomplete or incorrect declarations, but it may be required when + * dependencies contain declarations that are incompatible with the TypeScript engine that API Extractor uses + * for its analysis. Where possible, the underlying issue should be fixed rather than relying on skipLibCheck. + * + * DEFAULT VALUE: false + */ + // "skipLibCheck": true, + }, + + /** + * Configures how the API report file (*.api.md) will be generated. + */ + "apiReport": { + /** + * (REQUIRED) Whether to generate an API report. + */ + "enabled": true, + + /** + * The base filename for the API report files, to be combined with "reportFolder" or "reportTempFolder" + * to produce the full file path. The "reportFileName" should not include any path separators such as + * "\" or "/". The "reportFileName" should not include a file extension, since API Extractor will automatically + * append an appropriate file extension such as ".api.md". If the "reportVariants" setting is used, then the + * file extension includes the variant name, for example "my-report.public.api.md" or "my-report.beta.api.md". + * The "complete" variant always uses the simple extension "my-report.api.md". + * + * Previous versions of API Extractor required "reportFileName" to include the ".api.md" extension explicitly; + * for backwards compatibility, that is still accepted but will be discarded before applying the above rules. + * + * SUPPORTED TOKENS: , + * DEFAULT VALUE: "" + */ + // "reportFileName": "", + + /** + * To support different approval requirements for different API levels, multiple "variants" of the API report can + * be generated. The "reportVariants" setting specifies a list of variants to be generated. If omitted, + * by default only the "complete" variant will be generated, which includes all @internal, @alpha, @beta, + * and @public items. Other possible variants are "alpha" (@alpha + @beta + @public), "beta" (@beta + @public), + * and "public" (@public only). + * + * DEFAULT VALUE: [ "complete" ] + */ + // "reportVariants": ["public", "beta"], + + /** + * Specifies the folder where the API report file is written. The file name portion is determined by + * the "reportFileName" setting. + * + * The API report file is normally tracked by Git. Changes to it can be used to trigger a branch policy, + * e.g. for an API review. + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "/etc/" + */ + "reportFolder": "/" + + /** + * Specifies the folder where the temporary report file is written. The file name portion is determined by + * the "reportFileName" setting. + * + * After the temporary file is written to disk, it is compared with the file in the "reportFolder". + * If they are different, a production build will fail. + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "/temp/" + */ + // "reportTempFolder": "/temp/", + + /** + * Whether "forgotten exports" should be included in the API report file. Forgotten exports are declarations + * flagged with `ae-forgotten-export` warnings. See https://api-extractor.com/pages/messages/ae-forgotten-export/ to + * learn more. + * + * DEFAULT VALUE: "false" + */ + // "includeForgottenExports": false + }, + + /** + * Configures how the doc model file (*.api.json) will be generated. + */ + "docModel": { + /** + * (REQUIRED) Whether to generate a doc model file. + */ + "enabled": true + + /** + * The output path for the doc model file. The file extension should be ".api.json". + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "/temp/.api.json" + */ + // "apiJsonFilePath": "/temp/.api.json", + + /** + * Whether "forgotten exports" should be included in the doc model file. Forgotten exports are declarations + * flagged with `ae-forgotten-export` warnings. See https://api-extractor.com/pages/messages/ae-forgotten-export/ to + * learn more. + * + * DEFAULT VALUE: "false" + */ + // "includeForgottenExports": false, + + /** + * The base URL where the project's source code can be viewed on a website such as GitHub or + * Azure DevOps. This URL path corresponds to the `` path on disk. + * + * This URL is concatenated with the file paths serialized to the doc model to produce URL file paths to individual API items. + * For example, if the `projectFolderUrl` is "https://github.com/microsoft/rushstack/tree/main/apps/api-extractor" and an API + * item's file path is "api/ExtractorConfig.ts", the full URL file path would be + * "https://github.com/microsoft/rushstack/tree/main/apps/api-extractor/api/ExtractorConfig.js". + * + * This setting can be omitted if you don't need source code links in your API documentation reference. + * + * SUPPORTED TOKENS: none + * DEFAULT VALUE: "" + */ + // "projectFolderUrl": "http://github.com/path/to/your/projectFolder" + }, + + /** + * Configures how the .d.ts rollup file will be generated. + */ + "dtsRollup": { + /** + * (REQUIRED) Whether to generate the .d.ts rollup file. + */ + "enabled": true, + + /** + * Specifies the output path for a .d.ts rollup file to be generated without any trimming. + * This file will include all declarations that are exported by the main entry point. + * + * If the path is an empty string, then this file will not be written. + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "/dist/.d.ts" + */ + "untrimmedFilePath": "/lib/.d.ts", + + /** + * Specifies the output path for a .d.ts rollup file to be generated with trimming for an "alpha" release. + * This file will include only declarations that are marked as "@public", "@beta", or "@alpha". + * + * If the path is an empty string, then this file will not be written. + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "" + */ + "alphaTrimmedFilePath": "/lib/-alpha.d.ts", + + /** + * Specifies the output path for a .d.ts rollup file to be generated with trimming for a "beta" release. + * This file will include only declarations that are marked as "@public" or "@beta". + * + * If the path is an empty string, then this file will not be written. + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "" + */ + "betaTrimmedFilePath": "/lib/-beta.d.ts", + + /** + * Specifies the output path for a .d.ts rollup file to be generated with trimming for a "public" release. + * This file will include only declarations that are marked as "@public". + * + * If the path is an empty string, then this file will not be written. + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "" + */ + "publicTrimmedFilePath": "/lib/-public.d.ts" + + /** + * When a declaration is trimmed, by default it will be replaced by a code comment such as + * "Excluded from this release type: exampleMember". Set "omitTrimmingComments" to true to remove the + * declaration completely. + * + * DEFAULT VALUE: false + */ + // "omitTrimmingComments": true + }, + + /** + * Configures how the tsdoc-metadata.json file will be generated. + */ + "tsdocMetadata": { + /** + * Whether to generate the tsdoc-metadata.json file. + * + * DEFAULT VALUE: true + */ + // "enabled": true, + /** + * Specifies where the TSDoc metadata file should be written. + * + * The path is resolved relative to the folder of the config file that contains the setting; to change this, + * prepend a folder token such as "". + * + * The default value is "", which causes the path to be automatically inferred from the "tsdocMetadata", + * "typings" or "main" fields of the project's package.json. If none of these fields are set, the lookup + * falls back to "tsdoc-metadata.json" in the package folder. + * + * SUPPORTED TOKENS: , , + * DEFAULT VALUE: "" + */ + // "tsdocMetadataFilePath": "/dist/tsdoc-metadata.json" + }, + + /** + * Configures how API Extractor reports error and warning messages produced during analysis. + * + * There are three sources of messages: compiler messages, API Extractor messages, and TSDoc messages. + */ + "messages": { + /** + * Configures handling of diagnostic messages reported by the TypeScript compiler engine while analyzing + * the input .d.ts files. + * + * TypeScript message identifiers start with "TS" followed by an integer. For example: "TS2551" + * + * DEFAULT VALUE: A single "default" entry with logLevel=warning. + */ + "compilerMessageReporting": { + /** + * Configures the default routing for messages that don't match an explicit rule in this table. + */ + "default": { + /** + * Specifies whether the message should be written to the the tool's output log. Note that + * the "addToApiReportFile" property may supersede this option. + * + * Possible values: "error", "warning", "none" + * + * Errors cause the build to fail and return a nonzero exit code. Warnings cause a production build fail + * and return a nonzero exit code. For a non-production build (e.g. when "api-extractor run" includes + * the "--local" option), the warning is displayed but the build will not fail. + * + * DEFAULT VALUE: "warning" + */ + "logLevel": "warning" + + /** + * When addToApiReportFile is true: If API Extractor is configured to write an API report file (.api.md), + * then the message will be written inside that file; otherwise, the message is instead logged according to + * the "logLevel" option. + * + * DEFAULT VALUE: false + */ + // "addToApiReportFile": false + } + + // "TS2551": { + // "logLevel": "warning", + // "addToApiReportFile": true + // }, + // + // . . . + }, + + /** + * Configures handling of messages reported by API Extractor during its analysis. + * + * API Extractor message identifiers start with "ae-". For example: "ae-extra-release-tag" + * + * DEFAULT VALUE: See api-extractor-defaults.json for the complete table of extractorMessageReporting mappings + */ + "extractorMessageReporting": { + "default": { + "logLevel": "warning" + // "addToApiReportFile": false + } + + // "ae-extra-release-tag": { + // "logLevel": "warning", + // "addToApiReportFile": true + // }, + // + // . . . + }, + + /** + * Configures handling of messages reported by the TSDoc parser when analyzing code comments. + * + * TSDoc message identifiers start with "tsdoc-". For example: "tsdoc-link-tag-unescaped-text" + * + * DEFAULT VALUE: A single "default" entry with logLevel=warning. + */ + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + // "addToApiReportFile": false + } + + // "tsdoc-link-tag-unescaped-text": { + // "logLevel": "warning", + // "addToApiReportFile": true + // }, + // + // . . . + } + } +} diff --git a/packages/module-api/element-web-module-api.api.md b/packages/module-api/element-web-module-api.api.md new file mode 100644 index 0000000000..8632818f5c --- /dev/null +++ b/packages/module-api/element-web-module-api.api.md @@ -0,0 +1,546 @@ +## API Report File for "@element-hq/element-web-module-api" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ComponentType } from 'react'; +import { IWidget } from 'matrix-widget-api'; +import { JSX } from 'react'; +import { ModuleApi } from '@matrix-org/react-sdk-module-api'; +import { ReactNode } from 'react'; +import { Root } from 'react-dom/client'; +import { RuntimeModule } from '@matrix-org/react-sdk-module-api'; + +// @public +export interface AccountAuthApiExtension { + overwriteAccountAuth(accountInfo: AccountAuthInfo): Promise; +} + +// @public +export interface AccountAuthInfo { + accessToken: string; + deviceId: string; + homeserverUrl: string; + refreshToken?: string; + 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) + getDisplayAliasForAliasSet?(canonicalAlias: string | null, altAliases: string[]): string | null; +} + +// Warning: (ae-incompatible-release-tags) The symbol "Api" is marked as @public, but its signature references "LegacyModuleApiExtension" which is marked as @alpha +// Warning: (ae-incompatible-release-tags) The symbol "Api" is marked as @public, but its signature references "LegacyCustomisationsApiExtension" which is marked as @alpha +// +// @public +export interface Api extends LegacyModuleApiExtension, LegacyCustomisationsApiExtension, DialogApiExtension, AccountAuthApiExtension, ProfileApiExtension { + // @alpha + readonly builtins: BuiltinsApi; + readonly client: ClientApi; + readonly config: ConfigApi; + createRoot(element: Element): Root; + // @alpha + readonly customComponents: CustomComponentsApi; + // @alpha + readonly customisations: CustomisationsApi; + // @alpha + readonly extras: ExtrasApi; + readonly i18n: I18nApi; + readonly navigation: NavigationApi; + readonly rootNode: HTMLElement; + readonly stores: StoresApi; + // @alpha + readonly widget: WidgetApi; + // @alpha + readonly widgetLifecycle: WidgetLifecycleApi; +} + +// @alpha +export interface BuiltinsApi { + renderNotificationDecoration(roomId: string): React.ReactNode; + renderRoomAvatar(roomId: string, size?: string): React.ReactNode; + renderRoomView(roomId: string, props?: RoomViewProps): React.ReactNode; +} + +// @alpha +export type CapabilitiesApprover = (widget: WidgetDescriptor, requestedCapabilities: Set) => MaybePromise | undefined>; + +// @alpha @deprecated (undocumented) +export interface ChatExportCustomisations { + getForceChatExportParameters(): { + format?: ExportFormat; + range?: ExportType; + numberOfMessages?: number; + includeAttachments?: boolean; + sizeMb?: number; + }; +} + +// @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; +} + +// @public +export interface Config { + // (undocumented) + brand: string; +} + +// @public +export interface ConfigApi { + // (undocumented) + get(): Config; + // (undocumented) + get(key: K): Config[K]; + // (undocumented) + get(key?: K): Config | Config[K]; +} + +// @alpha +export type Container = "top" | "right" | "center"; + +// @alpha +export interface CustomComponentsApi { + registerLoginComponent(renderer: CustomLoginRenderFunction): void; + registerMessageRenderer(eventTypeOrFilter: string | ((mxEvent: MatrixEvent) => boolean), renderer: CustomMessageRenderFunction, hints?: CustomMessageRenderHints): void; + registerRoomPreviewBar(renderer: CustomRoomPreviewBarRenderFunction): void; +} + +// @alpha +export interface CustomisationsApi { + registerShouldShowComponent(fn: (this: void, component: UIComponent) => boolean | void): void; +} + +// @alpha +export type CustomLoginComponentProps = { + serverConfig: CustomLoginComponentPropsServerConfig; + fragmentAfterLogin?: string; + children?: ReactNode; + onLoggedIn(data: AccountAuthInfo): void; + onServerConfigChange(config: CustomLoginComponentPropsServerConfig): void; +}; + +// @alpha +export interface CustomLoginComponentPropsServerConfig { + hsName: string; + hsUrl: string; +} + +// @alpha +export type CustomLoginRenderFunction = ExtendablePropsRenderFunction; + +// @alpha +export type CustomMessageComponentProps = { + mxEvent: MatrixEvent; +}; + +// @alpha +export type CustomMessageRenderFunction = ( +props: CustomMessageComponentProps, +originalComponent?: (props?: OriginalMessageComponentProps) => React.JSX.Element) => JSX.Element; + +// @alpha +export type CustomMessageRenderHints = { + allowEditingEvent?: boolean; + allowDownloadingMedia?: (mxEvent: MatrixEvent) => Promise; +}; + +// @alpha +export type CustomRoomPreviewBarComponentProps = { + roomId?: string; + roomAlias?: string; +}; + +// @alpha +export type CustomRoomPreviewBarRenderFunction = ( +props: CustomRoomPreviewBarComponentProps, +originalComponent: (props: CustomRoomPreviewBarComponentProps) => JSX.Element) => JSX.Element; + +// @public +export interface DialogApiExtension { + openDialog(initialOptions: DialogOptions, dialog: ComponentType

>, props: P): DialogHandle; +} + +// @public +export type DialogHandle = { + finished: Promise<{ + ok: boolean; + model: M | null; + }>; + close(): void; +}; + +// @public +export interface DialogOptions { + title: string; +} + +// @public +export type DialogProps = { + onSubmit(model: M): void; + onCancel(): void; +}; + +// @alpha @deprecated (undocumented) +export interface DirectoryCustomisations { + // (undocumented) + requireCanonicalAliasAccessToPublish?(): boolean; +} + +// @alpha +export type ExtendablePropsRenderFunction =

( +props: P, +originalComponent: (props: P) => JSX.Element) => JSX.Element; + +// @alpha +export interface ExtrasApi { + addRoomHeaderButtonCallback(cb: RoomHeaderButtonsCallback): void; + getVisibleRoomBySpaceKey(spaceKey: string, cb: () => string[]): void; + setSpacePanelItem(spaceKey: string, props: SpacePanelItemProps): void; +} + +// @public +export interface I18nApi { + humanizeTime(this: void, timeMillis: number): string; + get language(): string; + register(this: void, translations: Partial): void; + translate(this: void, key: keyof Translations, variables?: Variables): string; + translate(this: void, key: keyof Translations, variables: Variables | undefined, tags: Tags): ReactNode; +} + +// @alpha +export type IdentityApprover = (widget: WidgetDescriptor) => MaybePromise; + +// @alpha @deprecated (undocumented) +export type LegacyCustomisations = (customisations: T) => void; + +// @alpha @deprecated (undocumented) +export interface LegacyCustomisationsApiExtension { + // @deprecated (undocumented) + readonly _registerLegacyAliasCustomisations: LegacyCustomisations; + // @deprecated (undocumented) + readonly _registerLegacyChatExportCustomisations: LegacyCustomisations>; + // @deprecated (undocumented) + readonly _registerLegacyComponentVisibilityCustomisations: LegacyCustomisations; + // @deprecated (undocumented) + readonly _registerLegacyDirectoryCustomisations: LegacyCustomisations; + // @deprecated (undocumented) + readonly _registerLegacyLifecycleCustomisations: LegacyCustomisations; + // @deprecated (undocumented) + readonly _registerLegacyMediaCustomisations: LegacyCustomisations>; + // @deprecated (undocumented) + readonly _registerLegacyRoomListCustomisations: LegacyCustomisations>; + // @deprecated (undocumented) + readonly _registerLegacyUserIdentifierCustomisations: LegacyCustomisations; + // @deprecated (undocumented) + readonly _registerLegacyWidgetPermissionsCustomisations: LegacyCustomisations>; + // @deprecated (undocumented) + readonly _registerLegacyWidgetVariablesCustomisations: LegacyCustomisations; +} + +// @alpha @deprecated (undocumented) +export interface LegacyModuleApiExtension { + // @deprecated + _registerLegacyModule(LegacyModule: RuntimeModuleConstructor): Promise; +} + +// @alpha @deprecated (undocumented) +export interface LifecycleCustomisations { + // (undocumented) + onLoggedOutAndStorageCleared?(): void; +} + +// @alpha +export type LocationRenderFunction = () => JSX.Element; + +// @alpha +export interface MatrixEvent { + content: Record; + eventId: string; + originServerTs: number; + roomId: string; + sender: string; + stateKey?: string; + type: string; + unsigned: Record; +} + +// @public +export type MaybePromise = T | PromiseLike; + +// @alpha @deprecated (undocumented) +export interface Media { + // (undocumented) + downloadSource(): Promise; + // (undocumented) + getSquareThumbnailHttp(dim: number): string | null; + // (undocumented) + getThumbnailHttp(width: number, height: number, mode?: "scale" | "crop"): string | null; + // (undocumented) + getThumbnailOfSourceHttp(width: number, height: number, mode?: "scale" | "crop"): string | null; + // (undocumented) + readonly hasThumbnail: boolean; + // (undocumented) + readonly isEncrypted: boolean; + // (undocumented) + readonly srcHttp: string | null; + // (undocumented) + readonly srcMxc: string; + // (undocumented) + readonly thumbnailHttp: string | null; + // (undocumented) + readonly thumbnailMxc: string | null | undefined; +} + +// @alpha @deprecated (undocumented) +export interface MediaContructable { + // (undocumented) + new (prepared: PreparedMedia): Media; +} + +// @alpha @deprecated (undocumented) +export interface MediaCustomisations { + // (undocumented) + readonly Media: MediaContructable; + // (undocumented) + mediaFromContent(content: Content, client?: Client): Media; + // (undocumented) + mediaFromMxc(mxc?: string, client?: Client): Media; +} + +// @public +export interface Module { + // (undocumented) + load(): Promise; +} + +// @public +export interface ModuleFactory { + // (undocumented) + new (api: Api): Module; + // (undocumented) + readonly moduleApiVersion: string; + // (undocumented) + readonly prototype: Module; +} + +// @public +export class ModuleIncompatibleError extends Error { + constructor(pluginVersion: string); +} + +// @public +export class ModuleLoader { + constructor(api: Api); + // Warning: (ae-forgotten-export) The symbol "ModuleExport" needs to be exported by the entry point index.d.ts + // + // (undocumented) + load(moduleExport: ModuleExport): Promise; + // (undocumented) + start(): Promise; +} + +// @public +export interface NavigationApi { + openRoom(roomIdOrAlias: string, opts?: OpenRoomOptions): void; + // @alpha + registerLocationRenderer(path: string, renderer: LocationRenderFunction): void; + toMatrixToLink(link: string, join?: boolean): Promise; +} + +// @public +export interface OpenRoomOptions { + autoJoin?: boolean; + viaServers?: string[]; +} + +// @alpha +export type OriginalMessageComponentProps = { + showUrlPreview?: boolean; +}; + +// @alpha +export type PreloadApprover = (widget: WidgetDescriptor) => MaybePromise; + +// @public +export interface Profile { + displayName?: string; + isGuest?: boolean; + userId?: string; +} + +// @public +export interface ProfileApiExtension { + readonly profile: Watchable; +} + +// @public +export interface Room { + getLastActiveTimestamp: () => number; + id: string; + name: Watchable; +} + +// @alpha +export type RoomHeaderButtonsCallback = (roomId: string) => JSX.Element | undefined; + +// @alpha @deprecated (undocumented) +export interface RoomListCustomisations { + isRoomVisible?(room: Room): boolean; +} + +// @public +export interface RoomListStoreApi { + getRooms(): Watchable; + waitForReady(): Promise; +} + +// @alpha +export interface RoomViewProps { + enableReadReceiptsAndMarkersOnActivity?: boolean; + hideComposer?: boolean; + hideHeader?: boolean; + hidePinnedMessageBanner?: boolean; + hideRightPanel?: boolean; + hideWidgets?: boolean; +} + +// @alpha @deprecated (undocumented) +export type RuntimeModuleConstructor = new (api: ModuleApi) => RuntimeModule; + +// @alpha +export interface SpacePanelItemProps { + className?: string; + icon?: JSX.Element; + label: string; + onSelected: () => void; + style?: React.CSSProperties; + tooltip?: string; +} + +// @public +export interface StoresApi { + roomListStore: RoomListStoreApi; +} + +// @public +export type SubstitutionValue = number | string | ReactNode | ((sub: string) => ReactNode); + +// @public +export type Tags = Record; + +// @public +export type Translations = Record; + +// @alpha +export const enum UIComponent { + AddIntegrations = "UIComponent.addIntegrations", + CreateRooms = "UIComponent.roomCreation", + CreateSpaces = "UIComponent.spaceCreation", + ExploreRooms = "UIComponent.exploreRooms", + FilterContainer = "UIComponent.filterContainer", + InviteUsers = "UIComponent.sendInvites", + RoomOptionsMenu = "UIComponent.roomOptionsMenu" +} + +// @alpha @deprecated (undocumented) +export interface UserIdentifierCustomisations { + getDisplayUserIdentifier(userId: string, opts: { + roomId?: string; + withDisplayName?: boolean; + }): string | null; +} + +// @public +export function useWatchable(watchable: Watchable): T; + +// @public +export type Variables = { + count?: number; + [key: string]: SubstitutionValue; +}; + +// @public +export class Watchable { + 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; + get value(): T; + set value(value: T); + // (undocumented) + watch(listener: (value: T) => void): void; +} + +// @alpha +export interface WidgetApi { + getAppAvatarUrl(app: IWidget, width?: number, height?: number, resizeMethod?: string): string | null; + getWidgetsInRoom(roomId: string): IWidget[]; + isAppInContainer(app: IWidget, container: Container, roomId: string): boolean; + moveAppToContainer(app: IWidget, container: Container, roomId: string): void; +} + +// @alpha +export type WidgetDescriptor = { + id: string; + templateUrl: string; + creatorUserId: string; + type: string; + origin: string; + roomId?: string; +}; + +// @alpha +export interface WidgetLifecycleApi { + registerCapabilitiesApprover(approver: CapabilitiesApprover): void; + registerIdentityApprover(approver: IdentityApprover): void; + registerPreloadApprover(approver: PreloadApprover): void; +} + +// @alpha @deprecated (undocumented) +export interface WidgetPermissionsCustomisations { + preapproveCapabilities?(widget: Widget, requestedCapabilities: Set): Promise>; +} + +// @alpha @deprecated (undocumented) +export interface WidgetVariablesCustomisations { + isReady?(): Promise; + provideVariables?(): { + currentUserId: string; + userDisplayName?: string; + userHttpAvatarUrl?: string; + clientId?: string; + clientTheme?: string; + clientLanguage?: string; + deviceId?: string; + baseUrl?: string; + }; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/module-api/package.json b/packages/module-api/package.json new file mode 100644 index 0000000000..167e4232b0 --- /dev/null +++ b/packages/module-api/package.json @@ -0,0 +1,69 @@ +{ + "name": "@element-hq/element-web-module-api", + "type": "module", + "version": "1.13.0", + "description": "Module API surface for element-web", + "repository": { + "type": "git", + "url": "git+https://github.com/element-hq/element-modules.git", + "directory": "packages/element-web-module-api" + }, + "author": "element-hq", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=20.0.0" + }, + "types": "./lib/element-web-module-api-alpha.d.ts", + "exports": { + ".": { + "types": "./lib/element-web-module-api-alpha.d.ts", + "import": "./lib/element-web-plugin-engine.js", + "require": "./lib/element-web-plugin-engine.umd.cjs" + } + }, + "files": [ + "lib" + ], + "scripts": { + "prepack": "nx build", + "lint:types": "nx lint:types", + "test:unit": "vitest" + }, + "devDependencies": { + "@matrix-org/react-sdk-module-api": "^2.5.0", + "@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": "^4.0.0", + "matrix-widget-api": "^1.17.0", + "rollup-plugin-external-globals": "^0.13.0", + "semver": "^7.6.3", + "typescript": "^5.7.3", + "vite": "^7.3.2", + "vite-plugin-dts": "^4.5.0", + "vitest": "^4.0.0", + "vitest-sonar-reporter": "^3.0.0" + }, + "peerDependencies": { + "@matrix-org/react-sdk-module-api": "*", + "@types/react": "*", + "@types/react-dom": "*", + "matrix-widget-api": "*", + "matrix-web-i18n": "*", + "react": "^19" + }, + "peerDependenciesMeta": { + "@matrix-org/react-sdk-module-api": { + "optional": true + }, + "matrix-widget-api": { + "optional": true + }, + "matrix-web-i18n": { + "optional": true + } + }, + "dependencies": {} +} diff --git a/packages/module-api/project.json b/packages/module-api/project.json new file mode 100644 index 0000000000..56f41507ae --- /dev/null +++ b/packages/module-api/project.json @@ -0,0 +1,22 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "root": "packages/module-api", + "targets": { + "build": { + "cache": true, + "executor": "nx:run-commands", + "inputs": ["src"], + "outputs": ["{projectRoot}/lib"], + "options": { + "commands": ["vite build", "api-extractor run"], + "parallel": false, + "cwd": "packages/module-api" + } + }, + "lint:types": { + "command": "pnpm exec tsc --noEmit", + "options": { "cwd": "packages/module-api" } + } + } +} diff --git a/packages/module-api/src/@types/global.d.ts b/packages/module-api/src/@types/global.d.ts new file mode 100644 index 0000000000..82ae781eb5 --- /dev/null +++ b/packages/module-api/src/@types/global.d.ts @@ -0,0 +1,13 @@ +/* +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. +*/ + +declare global { + // eslint-disable-next-line no-var + var __VERSION__: string; // injected by vite +} + +export {}; diff --git a/packages/module-api/src/api/auth.ts b/packages/module-api/src/api/auth.ts new file mode 100644 index 0000000000..5ecdcd3c25 --- /dev/null +++ b/packages/module-api/src/api/auth.ts @@ -0,0 +1,45 @@ +/* +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. +*/ + +/** + * Interface for account authentication information, used for overwriting the current account's authentication state. + * @public + */ +export interface AccountAuthInfo { + /** + * The user ID. + */ + userId: string; + /** + * The device ID. + */ + deviceId: string; + /** + * The access token belonging to this device ID and user ID. + */ + accessToken: string; + /** + * The refresh token belonging to this device ID and user ID. + */ + refreshToken?: string; + /** + * The homeserver URL where the credentials are valid. + */ + homeserverUrl: string; +} + +/** + * Methods to manage authentication in the application. + * @public + */ +export interface AccountAuthApiExtension { + /** + * Overwrite the current account's authentication state with the provided account information. + * @param accountInfo - The account authentication information to overwrite the current state with. + */ + overwriteAccountAuth(accountInfo: AccountAuthInfo): Promise; +} diff --git a/packages/module-api/src/api/builtins.ts b/packages/module-api/src/api/builtins.ts new file mode 100644 index 0000000000..d4c461d098 --- /dev/null +++ b/packages/module-api/src/api/builtins.ts @@ -0,0 +1,74 @@ +/* +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 props that must be passed to a RoomView component. + * @alpha Subject to change. + */ +export interface RoomViewProps { + /** + * If true, the room header will be hidden. + */ + hideHeader?: boolean; + /** + * If true, the message composer will be hidden. + */ + hideComposer?: boolean; + /** + * If true, the right panel will be hidden. + */ + hideRightPanel?: boolean; + /** + * If true, the pinned message banner will be hidden. + */ + hidePinnedMessageBanner?: boolean; + /** + * If true, the widgets will be hidden. + */ + hideWidgets?: boolean; + /** + * If true, enable sending read receipts and markers on user activity in the room view. When the user interacts with the room view, read receipts and markers are sent. + * If false, the read receipts and markers are only send when the room view is focused. The user has to focus the room view in order to clear any unreads and to move the unread marker to the bottom of the view. + * @defaultValue true + */ + enableReadReceiptsAndMarkersOnActivity?: boolean; +} + +/** + * Exposes components and classes that are part of Element Web to allow modules to + * render the components as part of their custom components or use the classes + * (because they can't import the components from Element Web since it would cause + * a dependency cycle) + * @alpha + */ +export interface BuiltinsApi { + /** + * Render room avatar component from element-web. + * + * @alpha + * @param roomId - Id of the room + * @param size - Size of the avatar to render + */ + renderRoomAvatar(roomId: string, size?: string): React.ReactNode; + + /** + * Render room view component from element-web. + * + * @alpha + * @param roomId - Id of the room + * @param props - Additional props to pass to the room view + */ + renderRoomView(roomId: string, props?: RoomViewProps): React.ReactNode; + + /** + * Render notification decoration component from element-web. + * + * @alpha + * @param roomId - Id of the room + */ + renderNotificationDecoration(roomId: string): React.ReactNode; +} diff --git a/packages/module-api/src/api/client.ts b/packages/module-api/src/api/client.ts new file mode 100644 index 0000000000..64e2274e89 --- /dev/null +++ b/packages/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 { type 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/module-api/src/api/config.ts b/packages/module-api/src/api/config.ts new file mode 100644 index 0000000000..5f708b4983 --- /dev/null +++ b/packages/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/module-api/src/api/custom-components.ts b/packages/module-api/src/api/custom-components.ts new file mode 100644 index 0000000000..0265c9c63d --- /dev/null +++ b/packages/module-api/src/api/custom-components.ts @@ -0,0 +1,227 @@ +/* +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 { JSX, ReactNode } from "react"; +import type { MatrixEvent } from "../models/event"; +import type { AccountAuthInfo } from "./auth.ts"; + +/** + * Properties for all message components. + * @alpha Subject to change. + */ +export type CustomMessageComponentProps = { + /** + * The Matrix event for this textual body. + * @alpha + */ + mxEvent: MatrixEvent; +}; + +/** + * Properties to alter the render function of the original component. + * @alpha Subject to change. + */ +export type OriginalMessageComponentProps = { + /** + * Should previews be shown for this event. + * This may be overriden by user preferences. + */ + showUrlPreview?: boolean; +}; + +/** + * Hints to specify to Element when rendering events. + * @alpha Subject to change. + */ +export type CustomMessageRenderHints = { + /** + * Should the event be allowed to be edited in the client. This should + * be set to false if you override the render function, as the module + * API has no way to display message editing at the moment. + * Default is true. + */ + allowEditingEvent?: boolean; + /** + * If an event contains media, this function will be called to check + * if the media can be prompted to be downloaded as a file. + * If this function is not supplied, media downloads are allowed. + */ + allowDownloadingMedia?: (mxEvent: MatrixEvent) => Promise; +}; + +/** + * Function used to render a message component. + * @alpha Subject to change. + */ +export type CustomMessageRenderFunction = ( + /** + * Properties for the message to be renderered. + */ + props: CustomMessageComponentProps, + /** + * Render function for the original component. This may be omitted if the message would not normally be rendered. + */ + originalComponent?: (props?: OriginalMessageComponentProps) => React.JSX.Element, +) => JSX.Element; + +/** + * Properties for all message components. + * @alpha Subject to change. + */ +export type CustomRoomPreviewBarComponentProps = { + roomId?: string; + roomAlias?: string; +}; + +/** + * Function used to render a room preview bar component. + * @alpha Unlikely to change + */ +export type CustomRoomPreviewBarRenderFunction = ( + /** + * Properties for the room preview bar to be rendered. + */ + props: CustomRoomPreviewBarComponentProps, + /** + * Render function for the original component. + */ + originalComponent: (props: CustomRoomPreviewBarComponentProps) => JSX.Element, +) => JSX.Element; + +/** + * Authentication server config object. + * @alpha Subject to change. + */ +export interface CustomLoginComponentPropsServerConfig { + /** + * The URL of the homeserver's client-server API + */ + hsUrl: string; + /** + * The name of the homeserver to present to the user + */ + hsName: string; +} + +/** + * Properties for login component. + * @alpha Subject to change. + */ +export type CustomLoginComponentProps = { + /** + * The details of the currently chosen Matrix homeserver + */ + serverConfig: CustomLoginComponentPropsServerConfig; + /** + * The URL fragment to send the user to after authentication is complete + */ + fragmentAfterLogin?: string; + /** + * Additional components to render as children + */ + children?: ReactNode; + /** + * Function to complete login + * @param data - the data to authenticate the user with + */ + onLoggedIn(data: AccountAuthInfo): void; + /** + * Function to change the selected server + * @param config - new server configuration details + */ + onServerConfigChange(config: CustomLoginComponentPropsServerConfig): void; +}; + +/** + * Function used to render a component with a superset of the known props. + * @alpha Unlikely to change + */ +export type ExtendablePropsRenderFunction =

( + /** + * Properties for the component to be rendered. + */ + props: P, + /** + * Render function for the original component. + */ + originalComponent: (props: P) => JSX.Element, +) => JSX.Element; + +/** + * Function used to render a login component. + * @alpha Unlikely to change + */ +export type CustomLoginRenderFunction = ExtendablePropsRenderFunction; + +/** + * API for inserting custom components into Element. + * @alpha Subject to change. + */ +export interface CustomComponentsApi { + /** + * Register a renderer for a message type in the timeline. + * + * The render function should return a rendered component. + * + * Multiple render function may be registered for a single event type, however the first matching + * result will be used. If no events match or are registered then the originalComponent is rendered. + * + * @param eventTypeOrFilter - The event type this renderer is for. Use a function for more complex filtering. + * @param renderer - The render function. + * @param hints - Hints that alter the way the tile is handled. + * @example + * ``` + * customComponents.registerMessageRenderer("m.room.message", (props, originalComponent) => { + * return ; + * }); + * customComponents.registerMessageRenderer( + * (mxEvent) => mxEvent.getType().matches(/m\.room\.(topic|name)/) && mxEvent.isState(), + * (props, originalComponent) => { + * return ; + * } + * ); + * ``` + */ + registerMessageRenderer( + eventTypeOrFilter: string | ((mxEvent: MatrixEvent) => boolean), + renderer: CustomMessageRenderFunction, + hints?: CustomMessageRenderHints, + ): void; + + /** + * Register a renderer for the room preview bar. + * + * The render function should return a rendered component. + * + * @param renderer - The render function for the room preview bar. + * @example + * ``` + * customComponents.registerRoomPreviewBar((props, OriginalComponent) => { + * if (props.roomId === "!some_special_room_id:server") { + * return ; + * } + * return ; + * }); + * ``` + */ + registerRoomPreviewBar(renderer: CustomRoomPreviewBarRenderFunction): void; + + /** + * Register a renderer for the login component. + * + * The render function should return a rendered component. + * + * @param renderer - The render function for the login component. + * @example + * ``` + * customComponents.registerLoginComponent((props, OriginalComponent) => { + * return ; + * }); + * ``` + */ + registerLoginComponent(renderer: CustomLoginRenderFunction): void; +} diff --git a/packages/module-api/src/api/customisations.ts b/packages/module-api/src/api/customisations.ts new file mode 100644 index 0000000000..1a02c68ed0 --- /dev/null +++ b/packages/module-api/src/api/customisations.ts @@ -0,0 +1,65 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +/** + * Enum of UI components which can have their behaviour tweaked + * @alpha + */ +export const enum UIComponent { + /** + * Components that lead to a user being invited. + */ + InviteUsers = "UIComponent.sendInvites", + + /** + * Components that lead to a room being created that aren't already + * guarded by some other condition (ie: "only if you can edit this + * space" is *not* guarded by this component, but "start DM" is). + */ + CreateRooms = "UIComponent.roomCreation", + + /** + * Components that lead to a Space being created that aren't already + * guarded by some other condition (ie: "only if you can add subspaces" + * is *not* guarded by this component, but "create new space" is). + */ + CreateSpaces = "UIComponent.spaceCreation", + + /** + * Components that lead to the public room directory. + */ + ExploreRooms = "UIComponent.exploreRooms", + + /** + * Components that lead to the user being able to easily add widgets + * and integrations to the room, such as from the room information card. + */ + AddIntegrations = "UIComponent.addIntegrations", + + /** + * Component that lead to the user being able to search, dial, explore rooms + */ + FilterContainer = "UIComponent.filterContainer", + + /** + * Components that lead the user to room options menu. + */ + RoomOptionsMenu = "UIComponent.roomOptionsMenu", +} + +/** + * API for customising Element Web's components + * @alpha Subject to change. + */ +export interface CustomisationsApi { + /** + * Method to register a callback which can affect whether a given component is drawn or not. + * @param fn - the callback, if it returns true the component will be rendered, if false it will not be. + * If undefined will defer to next callback, ultimately falling through to `true` if none return false. + */ + registerShouldShowComponent(fn: (this: void, component: UIComponent) => boolean | void): void; +} diff --git a/packages/module-api/src/api/dialog.ts b/packages/module-api/src/api/dialog.ts new file mode 100644 index 0000000000..cd9a2c867c --- /dev/null +++ b/packages/module-api/src/api/dialog.ts @@ -0,0 +1,68 @@ +/* +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 ComponentType } from "react"; + +/** + * Options for {@link Api#openDialog}. + * @public + */ +export interface DialogOptions { + /** + * The title of the dialog. + */ + title: string; +} + +/** + * Handle returned by {@link Api#openDialog}. + * @public + */ +export type DialogHandle = { + /** + * Promise that resolves when the dialog is finished. + */ + finished: Promise<{ ok: boolean; model: M | null }>; + /** + * Method to close the dialog. + */ + close(): void; +}; + +/** + * Props passed to the dialog body component. + * @public + */ +export type DialogProps = { + /** + * Callback to submit the dialog. + * @param model - The model to submit with the dialog. This is typically the data collected. + */ + onSubmit(model: M): void; + /** + * Cancel the dialog programmatically. + */ + onCancel(): void; +}; + +/** + * Methods to manage dialogs in the application. + * @public + */ +export interface DialogApiExtension { + /** + * Open a dialog with the given options and body component and return a handle to it. + * @param initialOptions - The initial options for the dialog, such as title and action label. + * @param dialog - The body component to render in the dialog. This component should accept props of type `P`. + * @param props - Additional props to pass to the body + */ + openDialog( + initialOptions: DialogOptions, + dialog: ComponentType

>, + props: P, + ): DialogHandle; +} diff --git a/packages/module-api/src/api/extras.ts b/packages/module-api/src/api/extras.ts new file mode 100644 index 0000000000..ad437c4986 --- /dev/null +++ b/packages/module-api/src/api/extras.ts @@ -0,0 +1,86 @@ +/* +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 JSX } from "react"; + +/** + * Properties of an item added to the Space panel + * @alpha + */ +export interface SpacePanelItemProps { + /** + * A CSS class name for the item + */ + className?: string; + + /** + * An icon to show in the item. If not provided, no icon will be shown. + */ + icon?: JSX.Element; + + /** + * The label to show in the item + */ + label: string; + + /** + * A tooltip to show when hovering over the item + */ + tooltip?: string; + + /** + * Styles to apply to the item + */ + style?: React.CSSProperties; + + /** + * Callback when the item is selected + */ + onSelected: () => void; +} + +/** + * A callback that returns a JSX element representing the buttons. + * + * @alpha + * @param roomId - The ID of the room for which the header is being rendered. + * @returns A JSX element representing the buttons to be rendered in the room header, or undefined if no buttons should be rendered. + */ +export type RoomHeaderButtonsCallback = (roomId: string) => JSX.Element | undefined; + +/** + * API for inserting extra UI into Element Web. + * @alpha Subject to change. + */ +export interface ExtrasApi { + /** + * Inserts an item into the space panel as if it were a space button, below + * buttons for other spaces. + * If called again with the same spaceKey, will update the existing item. + * @param spaceKey - A key to identify this space-like item. + * @param props - Properties of the item to add. + */ + setSpacePanelItem(spaceKey: string, props: SpacePanelItemProps): void; + + /** + * Registers a callback to get the list of visible rooms for a given space. + * + * Element Web will call this callback when checking if a room is displayed for the given space. For example in case of message editing or replying. + * If the space added by the module displays a room view and doesn't provide this callback, Element Web won't be able to determine if a room is visible in that space and will redirect to display the room in its vanilla space/metaspace. + * + * @param spaceKey - The space key to get visible rooms for. + * @param cb - A callback that returns the list of visible room IDs. + */ + getVisibleRoomBySpaceKey(spaceKey: string, cb: () => string[]): void; + + /** + * Adds a callback to get extra buttons in the room header (which can vary depending on the room being displayed). + * + * @param cb - A callback that returns a JSX element representing the buttons (see {@link RoomHeaderButtonsCallback}). + */ + addRoomHeaderButtonCallback(cb: RoomHeaderButtonsCallback): void; +} diff --git a/packages/module-api/src/api/i18n.ts b/packages/module-api/src/api/i18n.ts new file mode 100644 index 0000000000..030081ae9d --- /dev/null +++ b/packages/module-api/src/api/i18n.ts @@ -0,0 +1,86 @@ +/* +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 ReactNode } from "react"; + +/** + * The translations for the module. + * @public + */ +export type Translations = Record< + string, + { + [ietfLanguageTag: string]: string; + } +>; + +/** + * The value a variable or tag can take for a translation interpolation. + * + * When used as a function, `sub` is the text content wrapped between the tag + * in the translation string. For example, given `"Click here"`, the + * function receives `"here"` and should return a `ReactNode` wrapping it. + * + * @public + */ +export type SubstitutionValue = number | string | ReactNode | ((sub: string) => ReactNode); + +/** + * Variables to interpolate into a translation. + * @public + */ +export type Variables = { + /** + * The number of items to count for pluralised translations + */ + count?: number; + [key: string]: SubstitutionValue; +}; + +/** + * Tags to interpolate into a translation, where the value is a ReactNode or a function that returns a ReactNode. + * This allows for more complex interpolations, such as links or formatted text. + * @public + */ +export type Tags = Record; + +/** + * 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(this: void, 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(this: void, key: keyof Translations, variables?: Variables): string; + /** + * Perform a translation, with optional variables + * @param key - The key to translate + * @param variables - Optional variables to interpolate into the translation + * @param tags - Optional tags to interpolate into the translation + */ + translate(this: void, key: keyof Translations, variables: Variables | undefined, tags: Tags): ReactNode; + + /** + * Convert a timestamp into a translated, human-readable time, + * using the current system time as a reference, eg. "5 minutes ago". + * @param timeMillis - The time in milliseconds since epoch + */ + humanizeTime(this: void, timeMillis: number): string; +} diff --git a/packages/module-api/src/api/index.test.ts b/packages/module-api/src/api/index.test.ts new file mode 100644 index 0000000000..8cd7a879fb --- /dev/null +++ b/packages/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 { type 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/module-api/src/api/index.ts b/packages/module-api/src/api/index.ts new file mode 100644 index 0000000000..3d60fc2a43 --- /dev/null +++ b/packages/module-api/src/api/index.ts @@ -0,0 +1,169 @@ +/* +Copyright 2025 New Vector Ltd. +Copyright 2026 Element Creations 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 { type LegacyModuleApiExtension } from "./legacy-modules"; +import { type LegacyCustomisationsApiExtension } from "./legacy-customisations"; +import { type ConfigApi } from "./config"; +import { type I18nApi } from "./i18n"; +import { type CustomComponentsApi } from "./custom-components"; +import { type NavigationApi } from "./navigation.ts"; +import { type DialogApiExtension } from "./dialog.ts"; +import { type AccountAuthApiExtension } from "./auth.ts"; +import { type ProfileApiExtension } from "./profile.ts"; +import { type ExtrasApi } from "./extras.ts"; +import { type BuiltinsApi } from "./builtins.ts"; +import { type StoresApi } from "./stores.ts"; +import { type ClientApi } from "./client.ts"; +import { type WidgetLifecycleApi } from "./widget-lifecycle.ts"; +import { type WidgetApi } from "./widget.ts"; +import { type CustomisationsApi } from "./customisations.ts"; + +/** + * 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, + DialogApiExtension, + AccountAuthApiExtension, + ProfileApiExtension { + /** + * 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; + + /** + * The custom message component API. + * @alpha + */ + readonly customComponents: CustomComponentsApi; + + /** + * Allows modules to render components that are part of Element Web. + * @alpha + */ + readonly builtins: BuiltinsApi; + + /** + * API to navigate the application. + * @public + */ + readonly navigation: NavigationApi; + + /** + * Allows modules to insert extra UI into Element Web. + * @alpha + */ + 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; + + /** + * API for modules to auto-approve widget preloading, identity token requests, and capability requests. + * @alpha Subject to change. + */ + readonly widgetLifecycle: WidgetLifecycleApi; + + /** + * API for modules to interact with widgets in Element Web, including getting what widgets + * are active in a given room. + * @alpha Subject to change. + */ + readonly widget: WidgetApi; + + /** + * Allows modules to customise behaviour of app's components. + * @alpha Subject to change. + */ + readonly customisations: CustomisationsApi; + + /** + * 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/module-api/src/api/legacy-customisations.ts b/packages/module-api/src/api/legacy-customisations.ts new file mode 100644 index 0000000000..6374789b5b --- /dev/null +++ b/packages/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/module-api/src/api/legacy-modules.ts b/packages/module-api/src/api/legacy-modules.ts new file mode 100644 index 0000000000..baaad96b01 --- /dev/null +++ b/packages/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/module-api/src/api/navigation.ts b/packages/module-api/src/api/navigation.ts new file mode 100644 index 0000000000..76ff59f1c3 --- /dev/null +++ b/packages/module-api/src/api/navigation.ts @@ -0,0 +1,59 @@ +/* +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 JSX } from "react"; + +/** + * A function called to render a component when a user navigates to the corresponding + * location. Currently renders alongside just the SpacePanel. + * @alpha + */ +export type LocationRenderFunction = () => JSX.Element; + +/** + * The options available for changing the open behaviour. + * @public + */ +export interface OpenRoomOptions { + /** + * The list of servers to join via. + */ + viaServers?: string[]; + + /** + * Whether to automatically join the room if we are not already in it. + */ + autoJoin?: boolean; +} + +/** + * API methods to navigate the application. + * @public + */ +export interface NavigationApi { + /** + * Navigate to a permalink, optionally causing a join if the user is not already a member of the room/space. + * @param link - The permalink to navigate to, e.g. `https://matrix.to/#/!roomId:example.com`. + * @param join - If true, the user will be made to attempt to join the room/space if they are not already a member. + */ + toMatrixToLink(link: string, join?: boolean): Promise; + + /** + * Register a renderer for a given location path. + * @param path - The location path to register the renderer for. + * @param renderer - The function that will render the component for the location. + * @alpha + */ + registerLocationRenderer(path: string, renderer: LocationRenderFunction): void; + + /** + * Open a room in element-web. + * @param roomIdOrAlias - id/alias of the room to open + * @param opts - Options to control the open action, see {@link OpenRoomOptions} + */ + openRoom(roomIdOrAlias: string, opts?: OpenRoomOptions): void; +} diff --git a/packages/module-api/src/api/profile.ts b/packages/module-api/src/api/profile.ts new file mode 100644 index 0000000000..6be444f05f --- /dev/null +++ b/packages/module-api/src/api/profile.ts @@ -0,0 +1,38 @@ +/* +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 Watchable } from "./watchable.ts"; + +/** + * The profile of the user currently logged in. + * @public + */ +export interface Profile { + /** + * Indicates whether the user is a guest user. + */ + isGuest?: boolean; + /** + * The user ID of the logged-in user, if undefined then no user is logged in. + */ + userId?: string; + /** + * The display name of the logged-in user. + */ + displayName?: string; +} + +/** + * API extensions for modules to access the profile of the logged-in user. + * @public + */ +export interface ProfileApiExtension { + /** + * The profile of the user currently logged in. + */ + readonly profile: Watchable; +} diff --git a/packages/module-api/src/api/stores.ts b/packages/module-api/src/api/stores.ts new file mode 100644 index 0000000000..9e5bfa252d --- /dev/null +++ b/packages/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 { type 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/module-api/src/api/watchable.test.ts b/packages/module-api/src/api/watchable.test.ts new file mode 100644 index 0000000000..e55695c29c --- /dev/null +++ b/packages/module-api/src/api/watchable.test.ts @@ -0,0 +1,99 @@ +/* +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, vi, vitest } from "vitest"; + +import { Watchable } from "./watchable"; + +test("initial value is set correctly", () => { + const watchable = new Watchable(42); + expect(watchable.value).toBe(42); +}); + +test("value can be updated", () => { + const watchable = new Watchable(100); + watchable.value = 200; + expect(watchable.value).toBe(200); +}); + +test("watchers are notified on value change", () => { + const watchable = new Watchable(1); + const listener = vitest.fn(); + + watchable.watch(listener); + watchable.value = 2; // This should trigger the listener + expect(listener).toHaveBeenCalledExactlyOnceWith(2); + + watchable.unwatch(listener); // Clean up after the test +}); + +test("watchers are not notified if value does not change", () => { + const watchable = new Watchable(10); + const listener = vitest.fn(); + + watchable.watch(listener); + watchable.value = 10; // This should not trigger the listener + expect(listener).not.toHaveBeenCalled(); + + watchable.unwatch(listener); // Clean up after the test +}); + +test("when value is an object, shallow comparison works", () => { + const watchable = new Watchable({ a: 1, b: 2 }); + const listener = vitest.fn(); + watchable.watch(listener); + + // Update with a new object that has the same properties + watchable.value = { a: 3, b: 2 }; // This should trigger the listener + expect(listener).toHaveBeenCalledExactlyOnceWith({ a: 3, b: 2 }); + listener.mockClear(); + watchable.value = { a: 3, b: 2 }; // This should not trigger the listener again + expect(listener).not.toHaveBeenCalled(); + + 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/module-api/src/api/watchable.ts b/packages/module-api/src/api/watchable.ts new file mode 100644 index 0000000000..69296571c8 --- /dev/null +++ b/packages/module-api/src/api/watchable.ts @@ -0,0 +1,101 @@ +/* +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 { useEffect, useState } from "react"; + +type WatchFn = (value: T) => void; + +function shallowCompare(obj1: T, obj2: T): boolean { + return ( + Object.keys(obj1).length === Object.keys(obj2).length && + Object.keys(obj1).every((key) => obj1[key as keyof T] === obj2[key as keyof T]) + ); +} + +function isObject(value: unknown): value is object { + return value !== null && typeof value === "object"; +} + +/** + * Utility class to wrap a value and allow listeners to be notified when the value changes. + * If T is an object, it will use a shallow comparison to determine if the value has changed. + * @public + */ +export class Watchable { + 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; + } + + public set value(value: T) { + // If the value hasn't changed, do nothing. + if (value === this.currentValue) { + return; + } + if (isObject(value) && isObject(this.currentValue) && shallowCompare(this.currentValue as object, value)) { + return; + } + + this.currentValue = value; + for (const listener of this.listeners) { + listener(this.currentValue); + } + } + + 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 { + 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 {} +} + +/** + * A React hook to use an updated Watchable value. + * @param watchable - The Watchable instance to watch. + * @returns The live value of the Watchable. + * @public + */ +export function useWatchable(watchable: Watchable): T { + const [value, setValue] = useState(watchable.value); + useEffect(() => { + setValue(watchable.value); + watchable.watch(setValue); + return (): void => { + watchable.unwatch(setValue); + }; + }, [watchable]); + return value; +} diff --git a/packages/module-api/src/api/widget-lifecycle.ts b/packages/module-api/src/api/widget-lifecycle.ts new file mode 100644 index 0000000000..6258629eab --- /dev/null +++ b/packages/module-api/src/api/widget-lifecycle.ts @@ -0,0 +1,76 @@ +/* +Copyright 2026 Element Creations 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 { MaybePromise } from "../utils"; + +/** + * A description of a widget passed to approver callbacks. + * Contains the information needed to make approval decisions. + * @alpha Subject to change. + */ +export type WidgetDescriptor = { + /** The unique identifier of the widget. */ + id: string; + /** The template URL of the widget, which may contain `$matrix_*` placeholder variables. */ + templateUrl: string; + /** The Matrix user ID of the user who created the widget. */ + creatorUserId: string; + /** The widget type, e.g. `m.custom`, `m.jitsi`, `m.stickerpicker`. */ + type: string; + /** The origin of the widget URL. */ + origin: string; + /** The room ID the widget belongs to, if it is a room widget. */ + roomId?: string; +}; + +/** + * Callback that decides whether a widget should be auto-approved for preloading + * (i.e. loaded without the user clicking "Continue"). + * Return `true` to auto-approve, or any other value to defer to the default consent flow. + * @alpha Subject to change. + */ +export type PreloadApprover = (widget: WidgetDescriptor) => MaybePromise; +/** + * Callback that decides whether a widget should be auto-approved to receive + * the user's OpenID identity token. + * Return `true` to auto-approve, or any other value to defer to the default consent flow. + * @alpha Subject to change. + */ +export type IdentityApprover = (widget: WidgetDescriptor) => MaybePromise; +/** + * Callback that decides which of a widget's requested capabilities should be auto-approved. + * Return a `Set` of approved capability strings, or `undefined` to defer to the default consent flow. + * @alpha Subject to change. + */ +export type CapabilitiesApprover = ( + widget: WidgetDescriptor, + requestedCapabilities: Set, +) => MaybePromise | undefined>; + +/** + * API for modules to auto-approve widget preloading, identity token requests, and capability requests. + * @alpha Subject to change. + */ +export interface WidgetLifecycleApi { + /** + * Register a handler that can auto-approve widget preloading. + * Returning true auto-approves; any other value results in no auto-approval. + */ + registerPreloadApprover(approver: PreloadApprover): void; + + /** + * Register a handler that can auto-approve identity token requests. + * Returning true auto-approves; any other value results in no auto-approval. + */ + registerIdentityApprover(approver: IdentityApprover): void; + + /** + * Register a handler that can auto-approve widget capabilities. + * Return a set containing the capabilities to approve. + */ + registerCapabilitiesApprover(approver: CapabilitiesApprover): void; +} diff --git a/packages/module-api/src/api/widget.ts b/packages/module-api/src/api/widget.ts new file mode 100644 index 0000000000..66c8b31e73 --- /dev/null +++ b/packages/module-api/src/api/widget.ts @@ -0,0 +1,68 @@ +/* +Copyright 2026 Element Creations 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 IWidget } from "matrix-widget-api"; + +/** + * Containers that control where a widget is displayed on the screen. + * + * "top" is the app drawer, and currently the only sensible value. + * + * "right" is the right panel, and the default for widgets. Setting + * this as a container on a widget is essentially like saying "no + * changes needed", though this may change in the future. + * + * "center" was uncodumented at time of porting this from an enum. + * Possibly when a widget replaces the main chat view like element call. + * + * @alpha Subject to change. + */ +export type Container = "top" | "right" | "center"; + +/** + * An API for interfacing with widgets in Element Web, including getting what widgets + * are active in a given room. + * @alpha Subject to change. + */ +export interface WidgetApi { + /** + * Gets the widgets active in a given room. + * + * @param roomId - The room to get the widgets for. + */ + getWidgetsInRoom(roomId: string): IWidget[]; + + /** + * Gets the URL of a widget's avatar, if it has one. + * + * @param app - The widget to get the avatar URL for. + * @param width - Optional width to resize the avatar to. + * @param height - Optional height to resize the avatar to. + * @param resizeMethod - Optional method to use when resizing the avatar. + * @returns The URL of the widget's avatar, or null if it doesn't have one. + */ + getAppAvatarUrl(app: IWidget, width?: number, height?: number, resizeMethod?: string): string | null; + + /** + * Checks if a widget is in a specific container in a given room. + * + * @param app - The widget to check. + * @param container - The container to check. + * @param roomId - The room to check in. + * @returns True if the widget is in the specified container, false otherwise. + */ + isAppInContainer(app: IWidget, container: Container, roomId: string): boolean; + + /** + * Moves a widget to a specific container in a given room. + * + * @param app - The widget to move. + * @param container - The container to move the widget to. + * @param roomId - The room to move the widget in. + */ + moveAppToContainer(app: IWidget, container: Container, roomId: string): void; +} diff --git a/packages/module-api/src/index.ts b/packages/module-api/src/index.ts new file mode 100644 index 0000000000..a246b12f9e --- /dev/null +++ b/packages/module-api/src/index.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. +*/ + +export { ModuleLoader, ModuleIncompatibleError } from "./loader"; +export type { Api, Module, ModuleFactory } from "./api"; +export type { Config, ConfigApi } from "./api/config"; +export type { I18nApi, Variables, Translations, SubstitutionValue, Tags } 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"; +export type * from "./api/legacy-customisations"; +export type * from "./api/auth"; +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 type * from "./api/widget-lifecycle"; +export type * from "./api/widget"; +export type * from "./api/customisations"; +export { UIComponent } from "./api/customisations"; +export * from "./api/watchable"; +export type * from "./utils"; diff --git a/packages/module-api/src/loader.test.ts b/packages/module-api/src/loader.test.ts new file mode 100644 index 0000000000..8010c72af4 --- /dev/null +++ b/packages/module-api/src/loader.test.ts @@ -0,0 +1,58 @@ +/* +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, describe, vi, beforeEach } from "vitest"; + +import { type Api, ModuleIncompatibleError, ModuleLoader } from "."; + +describe("ModuleIncompatibleError", () => { + test("should extend Error", () => { + expect(new ModuleIncompatibleError("1.0.0")).toBeInstanceOf(Error); + }); +}); + +describe("ModuleLoader", () => { + const mockApi = {} as Api; + + beforeEach(() => { + vi.stubGlobal("__VERSION__", "1.0.1"); + }); + + test("should load a module", async () => { + const TestModule = { + default: class TestModule { + public static moduleApiVersion = "^1.0.0"; + public constructor(private readonly api: Api) {} + public async load(): Promise {} + }, + }; + + const spy = vi.spyOn(TestModule.default.prototype, "load"); + + const loader = new ModuleLoader(mockApi); + await loader.load(TestModule); + await loader.start(); + expect(spy).toHaveBeenCalledWith(); + }); + + test("should fail to load an incompatible module", async () => { + const TestModule = { + default: class TestModule { + public static moduleApiVersion = "^2"; + public constructor(private readonly api: Api) {} + public async load(): Promise {} + }, + }; + + const spy = vi.spyOn(TestModule.default.prototype, "load"); + + const loader = new ModuleLoader(mockApi); + await expect(loader.load(TestModule)).rejects.toThrowError(ModuleIncompatibleError); + await loader.start(); + expect(spy).not.toHaveBeenCalledWith(); + }); +}); diff --git a/packages/module-api/src/loader.ts b/packages/module-api/src/loader.ts new file mode 100644 index 0000000000..c057109f28 --- /dev/null +++ b/packages/module-api/src/loader.ts @@ -0,0 +1,55 @@ +/* +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 { satisfies } from "semver"; + +import { type Api, isModule, type Module, type ModuleExport } from "./api"; + +/** + * Error thrown when a module is incompatible with the engine version. + * @public + */ +export class ModuleIncompatibleError extends Error { + public constructor(pluginVersion: string) { + super(`Plugin version ${pluginVersion} is incompatible with engine version ${__VERSION__}`); + } +} + +/** + * A module loader for loading and starting modules. + * @public + */ +export class ModuleLoader { + private modules: Module[] = []; + private started = false; + + public constructor(private api: Api) {} + + public async load(moduleExport: ModuleExport): Promise { + if (this.started) { + throw new Error("PluginEngine.start() has already been called"); + } + + if (!isModule(moduleExport)) { + throw new Error("Invalid plugin"); + } + if (!satisfies(__VERSION__, moduleExport.default.moduleApiVersion)) { + throw new ModuleIncompatibleError(moduleExport.default.moduleApiVersion); + } + const { default: Module } = moduleExport; + this.modules.push(new Module(this.api)); + } + + public async start(): Promise { + if (this.started) { + throw new Error("PluginEngine.start() has already been called"); + } + this.started = true; + + await Promise.all(this.modules.map((plugin) => plugin.load())); + } +} diff --git a/packages/module-api/src/models/Room.ts b/packages/module-api/src/models/Room.ts new file mode 100644 index 0000000000..eb67d8054e --- /dev/null +++ b/packages/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 { type 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; +} diff --git a/packages/module-api/src/models/event.ts b/packages/module-api/src/models/event.ts new file mode 100644 index 0000000000..b75606090e --- /dev/null +++ b/packages/module-api/src/models/event.ts @@ -0,0 +1,48 @@ +/* +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. +*/ + +/** + * Representation of a Matrix event, as specified by the client server specification. + * @alpha Subject to change. + * @see https://spec.matrix.org/v1.14/client-server-api/#room-event-format + */ +export interface MatrixEvent { + /** + * The event ID of this event. + */ + eventId: string; + /** + * The room ID which contains this event. + */ + roomId: string; + /** + * The Matrix ID of the user who sent this event. + */ + sender: string; + /** + * The content of the event. + * If the event was encrypted, this is the decrypted content. + */ + content: Record; + /** + * Contains optional extra information about the event. + */ + unsigned: Record; + /** + * The type of the event. + */ + type: string; + /** + * The state key of the event. + * If this key is set, including `""` then the event is a state event. + */ + stateKey?: string; + /** + * Timestamp (in milliseconds since the unix epoch) on originating homeserver when this event was sent. + */ + originServerTs: number; +} diff --git a/packages/module-api/src/utils.ts b/packages/module-api/src/utils.ts new file mode 100644 index 0000000000..8692b180c8 --- /dev/null +++ b/packages/module-api/src/utils.ts @@ -0,0 +1,13 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +/** + * A value that may be a direct value or a Promise resolving to that value. + * Useful for callback APIs that can operate synchronously or asynchronously. + * @public + */ +export type MaybePromise = T | PromiseLike; diff --git a/packages/module-api/tsconfig.json b/packages/module-api/tsconfig.json new file mode 100644 index 0000000000..b8136d3517 --- /dev/null +++ b/packages/module-api/tsconfig.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "esnext", + "lib": ["dom", "es2022", "esnext"], + "esModuleInterop": true, + "strict": true, + "declaration": true, + "module": "es2022", + "moduleResolution": "bundler", + "types": [], + "outDir": "lib", + "jsx": "react-jsx", + "declarationMap": true, + "allowImportingTsExtensions": true + }, + "include": ["src"] +} diff --git a/packages/module-api/vite.config.ts b/packages/module-api/vite.config.ts new file mode 100644 index 0000000000..61f8fc5389 --- /dev/null +++ b/packages/module-api/vite.config.ts @@ -0,0 +1,59 @@ +/* +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 { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; +import dts from "vite-plugin-dts"; +import externalGlobals from "rollup-plugin-external-globals"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, "src/index.ts"), + name: "element-web-plugin-engine", + fileName: "element-web-plugin-engine", + }, + outDir: "lib", + target: "esnext", + sourcemap: true, + }, + plugins: [ + dts(), + externalGlobals({ + // Reuse React from the host app + react: "window.React", + }), + ], + define: { + __VERSION__: JSON.stringify(process.env.npm_package_version), + // Use production mode for the build as it is tested against production builds of Element Web, + // this is required for React JSX versions to be compatible. + process: { env: { NODE_ENV: "production" } }, + }, + test: { + coverage: { + provider: "v8", + include: ["src/**/*"], + reporter: [["lcov", { projectRoot: "../../" }]], + }, + reporters: [ + ["default", { summary: false }], + [ + "vitest-sonar-reporter", + { + outputFile: "coverage/sonar-report.xml", + onWritePath(path: string): string { + return `packages/element-web-module-api/${path}`; + }, + }, + ], + ], + }, +}); diff --git a/packages/playwright-common/package.json b/packages/playwright-common/package.json index 46a11f94a2..9aa51eef68 100644 --- a/packages/playwright-common/package.json +++ b/packages/playwright-common/package.json @@ -19,10 +19,10 @@ }, "scripts": { "prepack": "nx build:playwright", - "lint:types": "tsc --noEmit" + "lint:types": "nx lint:types" }, "devDependencies": { - "@element-hq/element-web-module-api": "*", + "@element-hq/element-web-module-api": "workspace:*", "@types/lodash-es": "^4.17.12", "typescript": "^5.8.2", "wait-on": "^9.0.4" diff --git a/packages/playwright-common/project.json b/packages/playwright-common/project.json index b3e491b9d6..bf6ef190c2 100644 --- a/packages/playwright-common/project.json +++ b/packages/playwright-common/project.json @@ -8,7 +8,13 @@ "command": "tsc", "inputs": ["src"], "outputs": ["{projectRoot}/lib"], - "options": { "cwd": "packages/playwright-common" } + "options": { "cwd": "packages/playwright-common" }, + "dependsOn": ["^build"] + }, + "lint:types": { + "command": "pnpm exec tsc --noEmit", + "options": { "cwd": "packages/playwright-common" }, + "dependsOn": ["^build"] }, "docker:prebuild": { "cache": true, diff --git a/packages/shared-components/package.json b/packages/shared-components/package.json index 29a3ded4d6..2cfe4a0fab 100644 --- a/packages/shared-components/package.json +++ b/packages/shared-components/package.json @@ -51,7 +51,7 @@ "lint:types": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json" }, "dependencies": { - "@element-hq/element-web-module-api": "catalog:", + "@element-hq/element-web-module-api": "workspace:*", "@matrix-org/spec": "^1.7.0", "@vector-im/compound-design-tokens": "catalog:", "classnames": "^2.5.1", diff --git a/packages/shared-components/project.json b/packages/shared-components/project.json index 0c9dc50527..b4401cc5a0 100644 --- a/packages/shared-components/project.json +++ b/packages/shared-components/project.json @@ -7,7 +7,8 @@ "command": "vite build", "inputs": ["src"], "outputs": ["{projectRoot}/dist"], - "options": { "cwd": "packages/shared-components" } + "options": { "cwd": "packages/shared-components" }, + "dependsOn": ["^build"] }, "start": { "command": "vite build --watch", @@ -19,7 +20,8 @@ "command": "typedoc", "inputs": ["src"], "outputs": ["{projectRoot}/typedoc"], - "options": { "cwd": "packages/shared-components" } + "options": { "cwd": "packages/shared-components" }, + "dependsOn": ["^build"] }, "storybook": { "cache": "true", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bf4434d24..7826bab3cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,9 +6,6 @@ settings: catalogs: default: - '@element-hq/element-web-module-api': - specifier: 1.13.0 - version: 1.13.0 '@fontsource/inter': specifier: 5.2.8 version: 5.2.8 @@ -329,8 +326,8 @@ importers: specifier: ^7.12.5 version: 7.28.6 '@element-hq/element-web-module-api': - specifier: 'catalog:' - version: 1.13.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) + specifier: workspace:* + version: link:../../packages/module-api '@element-hq/web-shared-components': specifier: workspace:* version: link:../../packages/shared-components @@ -918,6 +915,61 @@ importers: specifier: 2.8.3 version: 2.8.3 + packages/module-api: + dependencies: + matrix-web-i18n: + specifier: '*' + version: 3.6.0 + react: + specifier: ^19 + version: 19.2.4 + devDependencies: + '@matrix-org/react-sdk-module-api': + specifier: ^2.5.0 + version: 2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4) + '@microsoft/api-extractor': + specifier: ^7.49.1 + version: 7.56.0(@types/node@18.19.130) + '@types/node': + specifier: 18.19.130 + version: 18.19.130 + '@types/react': + specifier: ^19.2.10 + version: 19.2.10 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.10) + '@types/semver': + specifier: ^7.5.8 + version: 7.7.1 + '@vitest/coverage-v8': + specifier: ^4.0.0 + version: 4.1.2(@vitest/browser@4.1.2(vite@7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2))(vitest@4.1.2) + matrix-widget-api: + specifier: ^1.17.0 + version: 1.17.0 + rollup-plugin-external-globals: + specifier: ^0.13.0 + version: 0.13.0(rollup@4.60.1(patch_hash=603340e49399c6044e41a3998891667387d5ec1acbd38d4e5862f2ba3ef58de8)) + semver: + specifier: ^7.6.3 + version: 7.7.4 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vite: + specifier: ^7.3.2 + version: 7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vite-plugin-dts: + specifier: ^4.5.0 + version: 4.5.4(@types/node@18.19.130)(rollup@4.60.1(patch_hash=603340e49399c6044e41a3998891667387d5ec1acbd38d4e5862f2ba3ef58de8))(typescript@5.9.3)(vite@7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: + specifier: ^4.0.0 + version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.2)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest-sonar-reporter: + specifier: ^3.0.0 + version: 3.0.0(vitest@4.1.2) + packages/playwright-common: dependencies: '@axe-core/playwright': @@ -952,8 +1004,8 @@ importers: version: 2.8.3 devDependencies: '@element-hq/element-web-module-api': - specifier: '*' - version: 1.13.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) + specifier: workspace:* + version: link:../module-api '@types/lodash-es': specifier: ^4.17.12 version: 4.17.12 @@ -967,8 +1019,8 @@ importers: packages/shared-components: dependencies: '@element-hq/element-web-module-api': - specifier: 'catalog:' - version: 1.13.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) + specifier: workspace:* + version: link:../module-api '@matrix-org/spec': specifier: ^1.7.0 version: 1.16.0 @@ -2426,21 +2478,6 @@ packages: '@element-hq/element-call-embedded@0.18.0': resolution: {integrity: sha512-Fg2VlORZWkQ9t9OJTcWFXCwVzlHVLtkaiCF0qFTCOZSYYHlA3kXDRM8TagjLkIoOVR6y+9xZldbwejgKYUS9xw==} - '@element-hq/element-web-module-api@1.13.0': - resolution: {integrity: sha512-3QXejLpXHK52e/BM61zeFQt1pnmKEfhFsooKI3OOXa5M9io683q1eA986TquZTDHoorm0Q+4TyxjYD3j2Nkp8A==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@matrix-org/react-sdk-module-api': '*' - '@types/react': ^19.2.10 - '@types/react-dom': ^19.2.3 - matrix-web-i18n: '*' - react: ^19 - peerDependenciesMeta: - '@matrix-org/react-sdk-module-api': - optional: true - matrix-web-i18n: - optional: true - '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -9134,6 +9171,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -11660,6 +11700,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup-plugin-external-globals@0.13.0: + resolution: {integrity: sha512-wBS3hmoF0OtEnA0lWsmTC6Nhnkk2zjZbfhaX2gLo8VnfNGFdGhiYKwMpIPQPrYbAw+mAYUYmoHYktAl1eZHgVw==} + peerDependencies: + rollup: 4.60.1 + rollup@4.60.1: resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -12887,6 +12932,46 @@ packages: terser: optional: true + vite@7.3.2: + resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': 18.19.130 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: 2.8.3 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vite@8.0.5: resolution: {integrity: sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -14931,15 +15016,6 @@ snapshots: '@element-hq/element-call-embedded@0.18.0': {} - '@element-hq/element-web-module-api@1.13.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)': - dependencies: - '@types/react': 19.2.10 - '@types/react-dom': 19.2.3(@types/react@19.2.10) - react: 19.2.4 - optionalDependencies: - '@matrix-org/react-sdk-module-api': 2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4) - matrix-web-i18n: 3.6.0 - '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -18618,6 +18694,20 @@ snapshots: vite: 5.4.21(@types/node@18.19.130)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1) vue: 3.5.31(typescript@5.9.3) + '@vitest/browser-playwright@4.1.2(playwright@1.59.1)(vite@7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2)': + dependencies: + '@vitest/browser': 4.1.2(vite@7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2) + '@vitest/mocker': 4.1.2(vite@7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + playwright: 1.59.1 + tinyrainbow: 3.1.0 + vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.2)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/browser-playwright@4.1.2(playwright@1.59.1)(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2)': dependencies: '@vitest/browser': 4.1.2(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2) @@ -18631,6 +18721,24 @@ snapshots: - utf-8-validate - vite + '@vitest/browser@4.1.2(vite@7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.2(vite@7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/utils': 4.1.2 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.2)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/browser@4.1.2(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2)': dependencies: '@blazediff/core': 1.9.1 @@ -18648,6 +18756,22 @@ snapshots: - utf-8-validate - vite + '@vitest/coverage-v8@4.1.2(@vitest/browser@4.1.2(vite@7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2))(vitest@4.1.2)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.2 + ast-v8-to-istanbul: 1.0.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 4.0.0 + tinyrainbow: 3.1.0 + vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.2)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + optionalDependencies: + '@vitest/browser': 4.1.2(vite@7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2) + '@vitest/coverage-v8@4.1.2(@vitest/browser@4.1.2(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2))(vitest@4.1.2)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -18681,6 +18805,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@4.1.2(vite@7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.1.2 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + '@vitest/mocker@4.1.2(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.2 @@ -22517,6 +22649,10 @@ snapshots: is-promise@4.0.0: {} + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.8 + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -25592,6 +25728,14 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' + rollup-plugin-external-globals@0.13.0(rollup@4.60.1(patch_hash=603340e49399c6044e41a3998891667387d5ec1acbd38d4e5862f2ba3ef58de8)): + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.1(patch_hash=603340e49399c6044e41a3998891667387d5ec1acbd38d4e5862f2ba3ef58de8)) + estree-walker: 3.0.3 + is-reference: 3.0.3 + magic-string: 0.30.21 + rollup: 4.60.1(patch_hash=603340e49399c6044e41a3998891667387d5ec1acbd38d4e5862f2ba3ef58de8) + rollup@4.60.1(patch_hash=603340e49399c6044e41a3998891667387d5ec1acbd38d4e5862f2ba3ef58de8): dependencies: '@types/estree': 1.0.8 @@ -27059,6 +27203,25 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-plugin-dts@4.5.4(@types/node@18.19.130)(rollup@4.60.1(patch_hash=603340e49399c6044e41a3998891667387d5ec1acbd38d4e5862f2ba3ef58de8))(typescript@5.9.3)(vite@7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@microsoft/api-extractor': 7.56.0(@types/node@18.19.130) + '@rollup/pluginutils': 5.3.0(rollup@4.60.1(patch_hash=603340e49399c6044e41a3998891667387d5ec1acbd38d4e5862f2ba3ef58de8)) + '@volar/typescript': 2.4.28 + '@vue/language-core': 2.2.0(typescript@5.9.3) + compare-versions: 6.1.1 + debug: 4.4.3 + kolorist: 1.8.0 + local-pkg: 1.1.2 + magic-string: 0.30.21 + typescript: 5.9.3 + optionalDependencies: + vite: 7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - '@types/node' + - rollup + - supports-color + vite-plugin-dts@4.5.4(@types/node@18.19.130)(rollup@4.60.1(patch_hash=603340e49399c6044e41a3998891667387d5ec1acbd38d4e5862f2ba3ef58de8))(typescript@5.9.3)(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@microsoft/api-extractor': 7.56.0(@types/node@18.19.130) @@ -27098,6 +27261,24 @@ snapshots: sugarss: 5.0.1(postcss@8.5.8) terser: 5.46.1 + vite@7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + esbuild: 0.27.4 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.1(patch_hash=603340e49399c6044e41a3998891667387d5ec1acbd38d4e5862f2ba3ef58de8) + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 18.19.130 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + sugarss: 5.0.1(postcss@8.5.8) + terser: 5.46.1 + tsx: 4.21.0 + yaml: 2.8.3 + vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 @@ -27195,7 +27376,37 @@ snapshots: vitest-sonar-reporter@3.0.0(vitest@4.1.2): dependencies: - vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.2)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.2)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + + vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.2)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.2 + '@vitest/mocker': 4.1.2(vite@7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.2 + '@vitest/runner': 4.1.2 + '@vitest/snapshot': 4.1.2 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 18.19.130 + '@vitest/browser-playwright': 4.1.2(playwright@1.59.1)(vite@7.3.2(@types/node@18.19.130)(jiti@2.6.1)(lightningcss@1.32.0)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.2) + jsdom: 26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f) + transitivePeerDependencies: + - msw vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@18.19.130)(@vitest/browser-playwright@4.1.2)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@18.19.130)(esbuild@0.27.4)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.8))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5d458e9b97..5e366ea824 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -15,8 +15,6 @@ catalog: # playwright "@playwright/test": 1.59.1 "playwright-core": 1.59.1 - # Module API - "@element-hq/element-web-module-api": 1.13.0 # Compound "@vector-im/compound-design-tokens": 8.0.0 "@vector-im/compound-web": 8.4.0 diff --git a/sonar-project.properties b/sonar-project.properties index e3040eaeae..0729966d8b 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -26,5 +26,6 @@ sonar.coverage.exclusions=\ apps/web/src/vector/mobile_guide/**/*,\ packages/shared-components/src/test/**/*,\ packages/shared-components/src/**/*.stories.tsx,\ - packages/playwright-common/**/* + packages/playwright-common/**/*,\ + **/*.config.ts sonar.testExecutionReportPaths=apps/web/coverage/jest-sonar-report.xml