diff --git a/apps/web/src/components/structures/RoomView.tsx b/apps/web/src/components/structures/RoomView.tsx index 3704cf58854..df8a551c1ab 100644 --- a/apps/web/src/components/structures/RoomView.tsx +++ b/apps/web/src/components/structures/RoomView.tsx @@ -1293,23 +1293,30 @@ export class RoomView extends React.Component { } case Action.ComposerInsert: { - if (payload.composerType) break; + const composerInsertPayload = payload as ComposerInsertPayload; + if (composerInsertPayload.composerType) break; - let timelineRenderingType: TimelineRenderingType = payload.timelineRenderingType; + let timelineRenderingType: TimelineRenderingType | undefined; // ThreadView handles Action.ComposerInsert itself due to it having its own editState - if (timelineRenderingType === TimelineRenderingType.Thread) break; + if (composerInsertPayload.timelineRenderingType === TimelineRenderingType.Thread) break; if ( this.state.timelineRenderingType === TimelineRenderingType.Search && - payload.timelineRenderingType === TimelineRenderingType.Search + composerInsertPayload.timelineRenderingType === TimelineRenderingType.Search ) { // we don't have the composer rendered in this state, so bring it back first await this.onCancelSearchClick(); timelineRenderingType = TimelineRenderingType.Room; } + // If the dispatchee didn't request a timeline rendering type, use the current one. + timelineRenderingType = + timelineRenderingType ?? + composerInsertPayload.timelineRenderingType ?? + this.state.timelineRenderingType; + // re-dispatch to the correct composer defaultDispatcher.dispatch({ - ...(payload as ComposerInsertPayload), + ...composerInsertPayload, timelineRenderingType, composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send, }); diff --git a/apps/web/src/components/structures/ThreadView.tsx b/apps/web/src/components/structures/ThreadView.tsx index 8ddbbf6367e..18179e417f8 100644 --- a/apps/web/src/components/structures/ThreadView.tsx +++ b/apps/web/src/components/structures/ThreadView.tsx @@ -167,12 +167,14 @@ export default class ThreadView extends React.Component { } switch (payload.action) { case Action.ComposerInsert: { - if (payload.composerType) break; - if (payload.timelineRenderingType !== TimelineRenderingType.Thread) break; + const insertPayload = payload as ComposerInsertPayload; + if (insertPayload.composerType) break; + if (insertPayload.timelineRenderingType !== TimelineRenderingType.Thread) break; // re-dispatch to the correct composer dis.dispatch({ - ...(payload as ComposerInsertPayload), + ...insertPayload, + timelineRenderingType: TimelineRenderingType.Thread, composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send, }); break; diff --git a/apps/web/src/dispatcher/payloads/ComposerInsertPayload.ts b/apps/web/src/dispatcher/payloads/ComposerInsertPayload.ts index 9712a8303a0..597db9712e8 100644 --- a/apps/web/src/dispatcher/payloads/ComposerInsertPayload.ts +++ b/apps/web/src/dispatcher/payloads/ComposerInsertPayload.ts @@ -17,7 +17,7 @@ export enum ComposerType { interface IBaseComposerInsertPayload extends ActionPayload { action: Action.ComposerInsert; - timelineRenderingType: TimelineRenderingType; + timelineRenderingType?: TimelineRenderingType; // undefined if this should just use the current in-focus type. composerType?: ComposerType; // falsy if should be re-dispatched to the correct composer } diff --git a/apps/web/src/modules/Api.ts b/apps/web/src/modules/Api.ts index bb3c7497d52..5d9eddfab2e 100644 --- a/apps/web/src/modules/Api.ts +++ b/apps/web/src/modules/Api.ts @@ -33,6 +33,8 @@ import { StoresApi } from "./StoresApi.ts"; import { WidgetLifecycleApi } from "./WidgetLifecycleApi.ts"; import { WidgetApi } from "./WidgetApi.ts"; import { CustomisationsApi } from "./customisationsApi.ts"; +import { ComposerApi } from "./ComposerApi.ts"; +import defaultDispatcher from "../dispatcher/dispatcher.ts"; const legacyCustomisationsFactory = (baseCustomisations: T) => { let used = false; @@ -94,6 +96,7 @@ export class ModuleApi implements Api { public readonly rootNode = document.getElementById("matrixchat")!; public readonly client = new ClientApi(); public readonly stores = new StoresApi(); + public readonly composer = new ComposerApi(defaultDispatcher); public createRoot(element: Element): Root { return createRoot(element); diff --git a/apps/web/src/modules/ComposerApi.ts b/apps/web/src/modules/ComposerApi.ts new file mode 100644 index 00000000000..c3cce624851 --- /dev/null +++ b/apps/web/src/modules/ComposerApi.ts @@ -0,0 +1,23 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type ComposerApi as ModuleComposerApi } from "@element-hq/element-web-module-api"; + +import type { MatrixDispatcher } from "../dispatcher/dispatcher"; +import { Action } from "../dispatcher/actions"; +import type { ComposerInsertPayload } from "../dispatcher/payloads/ComposerInsertPayload"; + +export class ComposerApi implements ModuleComposerApi { + public constructor(private readonly dispatcher: MatrixDispatcher) {} + + public insertPlaintextIntoComposer(plaintext: string): void { + this.dispatcher.dispatch({ + action: Action.ComposerInsert, + text: plaintext, + } satisfies ComposerInsertPayload); + } +} diff --git a/apps/web/test/unit-tests/components/structures/RoomView-test.tsx b/apps/web/test/unit-tests/components/structures/RoomView-test.tsx index 77bbdd8d47e..c9092d7aa17 100644 --- a/apps/web/test/unit-tests/components/structures/RoomView-test.tsx +++ b/apps/web/test/unit-tests/components/structures/RoomView-test.tsx @@ -71,6 +71,7 @@ import ErrorDialog from "../../../../src/components/views/dialogs/ErrorDialog.ts import * as pinnedEventHooks from "../../../../src/hooks/usePinnedEvents"; import { TimelineRenderingType } from "../../../../src/contexts/RoomContext"; import { ModuleApi } from "../../../../src/modules/Api"; +import { type ComposerInsertPayload, ComposerType } from "../../../../src/dispatcher/payloads/ComposerInsertPayload.ts"; // Used by group calls jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({ @@ -1075,6 +1076,80 @@ describe("RoomView", () => { expect(onRoomViewUpdateMock).toHaveBeenCalledWith(true); }); + describe("handles Action.ComposerInsert", () => { + it("redispatches an empty composerType, timelineRenderingType with the current state", async () => { + await mountRoomView(); + const promise = untilDispatch((payload) => { + try { + expect(payload).toEqual({ + action: Action.ComposerInsert, + text: "Hello world", + timelineRenderingType: TimelineRenderingType.Room, + composerType: ComposerType.Send, + }); + } catch { + return false; + } + return true; + }, defaultDispatcher); + defaultDispatcher.dispatch({ + action: Action.ComposerInsert, + text: "Hello world", + } satisfies ComposerInsertPayload); + await promise; + }); + it("redispatches an empty composerType with the current state", async () => { + await mountRoomView(); + const promise = untilDispatch((payload) => { + try { + expect(payload).toEqual({ + action: Action.ComposerInsert, + text: "Hello world", + timelineRenderingType: TimelineRenderingType.Room, + composerType: ComposerType.Send, + }); + } catch { + return false; + } + return true; + }, defaultDispatcher); + defaultDispatcher.dispatch({ + action: Action.ComposerInsert, + text: "Hello world", + timelineRenderingType: TimelineRenderingType.Room, + } satisfies ComposerInsertPayload); + await promise; + }); + it("ignores payloads with a timelineRenderingType != TimelineRenderingType.Thread", async () => { + await mountRoomView(); + const promise = untilDispatch( + (payload) => { + try { + expect(payload).toStrictEqual({ + action: Action.ComposerInsert, + text: "Hello world", + timelineRenderingType: TimelineRenderingType.Thread, + composerType: ComposerType.Send, + }); + } catch { + return false; + } + return true; + }, + defaultDispatcher, + 500, + ); + defaultDispatcher.dispatch({ + action: Action.ComposerInsert, + text: "Hello world", + composerType: ComposerType.Send, + timelineRenderingType: TimelineRenderingType.Room, + viaTest: true, + } satisfies ComposerInsertPayload); + await expect(promise).rejects.toThrow(); + }); + }); + describe("when there is a RoomView", () => { const widget1Id = "widget1"; const widget2Id = "widget2"; diff --git a/apps/web/test/unit-tests/components/structures/ThreadView-test.tsx b/apps/web/test/unit-tests/components/structures/ThreadView-test.tsx index 365e72b3dbf..b4f7ed18ae7 100644 --- a/apps/web/test/unit-tests/components/structures/ThreadView-test.tsx +++ b/apps/web/test/unit-tests/components/structures/ThreadView-test.tsx @@ -34,6 +34,9 @@ import { getRoomContext } from "../../../test-utils/room"; import { mkMessage, stubClient } from "../../../test-utils/test-utils"; import { mkThread } from "../../../test-utils/threads"; import { ScopedRoomContextProvider } from "../../../../src/contexts/ScopedRoomContext.tsx"; +import { untilDispatch } from "../../../test-utils/utilities.ts"; +import { TimelineRenderingType } from "../../../../src/contexts/RoomContext.ts"; +import { type ComposerInsertPayload, ComposerType } from "../../../../src/dispatcher/payloads/ComposerInsertPayload.ts"; describe("ThreadView", () => { const ROOM_ID = "!roomId:example.org"; @@ -209,4 +212,87 @@ describe("ThreadView", () => { metricsTrigger: undefined, }); }); + + describe("handles Action.ComposerInsert", () => { + it("redispatches a payload of timelineRenderingType=Thread", async () => { + await getComponent(); + const promise = untilDispatch((payload) => { + try { + expect(payload).toEqual({ + action: Action.ComposerInsert, + text: "Hello world", + timelineRenderingType: TimelineRenderingType.Thread, + composerType: ComposerType.Send, + }); + } catch { + return false; + } + return true; + }, dispatcher); + dispatcher.dispatch({ + action: Action.ComposerInsert, + text: "Hello world", + timelineRenderingType: TimelineRenderingType.Thread, + } satisfies ComposerInsertPayload); + await promise; + }); + it("ignores payloads with a composerType", async () => { + await getComponent(); + const promise = untilDispatch( + (payload) => { + try { + expect(payload).toStrictEqual({ + action: Action.ComposerInsert, + text: "Hello world", + timelineRenderingType: TimelineRenderingType.Thread, + composerType: ComposerType.Send, + }); + } catch { + return false; + } + return true; + }, + dispatcher, + 500, + ); + dispatcher.dispatch({ + action: Action.ComposerInsert, + text: "Hello world", + composerType: ComposerType.Send, + timelineRenderingType: TimelineRenderingType.Thread, + // Ensure we don't accidentally pick up this emit by strictly checking above. + viaTest: true, + } satisfies ComposerInsertPayload); + await expect(promise).rejects.toThrow(); + }); + it("ignores payloads with a timelineRenderingType != TimelineRenderingType.Thread", async () => { + await getComponent(); + const promise = untilDispatch( + (payload) => { + try { + expect(payload).toStrictEqual({ + action: Action.ComposerInsert, + text: "Hello world", + timelineRenderingType: TimelineRenderingType.Thread, + composerType: ComposerType.Send, + }); + } catch { + return false; + } + return true; + }, + dispatcher, + 500, + ); + dispatcher.dispatch({ + action: Action.ComposerInsert, + text: "Hello world", + composerType: ComposerType.Send, + timelineRenderingType: TimelineRenderingType.Room, + // Ensure we don't accidentally pick up this emit by strictly checking above. + viaTest: true, + } satisfies ComposerInsertPayload); + await expect(promise).rejects.toThrow(); + }); + }); }); diff --git a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx index b25dfc5f8b5..32938296860 100644 --- a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx @@ -283,8 +283,10 @@ describe("EditWysiwygComposer", () => { // It adds the composerType fields where the value refers if the composer is in editing or not // The listeners in the RTE ignore the message if the composerType is missing in the payload const dispatcherRef = defaultDispatcher.register((payload: ActionPayload) => { + const insertPayload = payload as ComposerInsertPayload; defaultDispatcher.dispatch({ - ...(payload as ComposerInsertPayload), + ...insertPayload, + timelineRenderingType: insertPayload.timelineRenderingType!, composerType: ComposerType.Edit, }); }); diff --git a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx index 54224c64528..528615939ae 100644 --- a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx @@ -48,11 +48,13 @@ describe("SendWysiwygComposer", () => { const registerId = defaultDispatcher.register((payload) => { switch (payload.action) { case Action.ComposerInsert: { - if (payload.composerType) break; + const insertPayload = payload as ComposerInsertPayload; + if (insertPayload.composerType) break; // re-dispatch to the correct composer defaultDispatcher.dispatch({ - ...(payload as ComposerInsertPayload), + ...insertPayload, + timelineRenderingType: insertPayload.timelineRenderingType!, composerType: ComposerType.Send, }); break; diff --git a/apps/web/test/unit-tests/modules/ComposerApi-test.ts b/apps/web/test/unit-tests/modules/ComposerApi-test.ts new file mode 100644 index 00000000000..af70ce0125e --- /dev/null +++ b/apps/web/test/unit-tests/modules/ComposerApi-test.ts @@ -0,0 +1,24 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { Action } from "../../../src/dispatcher/actions"; +import type { MatrixDispatcher } from "../../../src/dispatcher/dispatcher"; +import { ComposerApi } from "../../../src/modules/ComposerApi"; + +describe("ComposerApi", () => { + it("should be able to insert text via insertTextIntoComposer()", () => { + const dispatcher = { + dispatch: jest.fn(), + } as unknown as MatrixDispatcher; + const api = new ComposerApi(dispatcher); + api.insertPlaintextIntoComposer("Hello world"); + expect(dispatcher.dispatch).toHaveBeenCalledWith({ + action: Action.ComposerInsert, + text: "Hello world", + }); + }); +}); diff --git a/packages/module-api/element-web-module-api.api.md b/packages/module-api/element-web-module-api.api.md index 8632818f5cc..8ac22978f9e 100644 --- a/packages/module-api/element-web-module-api.api.md +++ b/packages/module-api/element-web-module-api.api.md @@ -1,546 +1,553 @@ -## 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) - -``` +## 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; + // @alpha + readonly composer: ComposerApi; + 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; +} + +// @alpha +export interface ComposerApi { + insertPlaintextIntoComposer(plaintext: string): void; +} + +// @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/src/api/composer.ts b/packages/module-api/src/api/composer.ts new file mode 100644 index 00000000000..6080110992a --- /dev/null +++ b/packages/module-api/src/api/composer.ts @@ -0,0 +1,20 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +/** + * API to interact with the message composer. + * @alpha Likely to change + */ +export interface ComposerApi { + /** + * Insert plaintext into the current composer. + * @param plaintext - The plain text to insert + * @returns Returns immediately, does not await action. + * @alpha Likely to change + */ + insertPlaintextIntoComposer(plaintext: string): void; +} diff --git a/packages/module-api/src/api/index.ts b/packages/module-api/src/api/index.ts index 3d60fc2a43f..b8716bbbce9 100644 --- a/packages/module-api/src/api/index.ts +++ b/packages/module-api/src/api/index.ts @@ -23,6 +23,7 @@ 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"; +import { type ComposerApi } from "./composer.ts"; /** * Module interface for modules to implement. @@ -159,6 +160,12 @@ export interface Api */ readonly customisations: CustomisationsApi; + /** + * Allows modules to customise the message composer. + * @alpha Subject to change. + */ + readonly composer: ComposerApi; + /** * Create a ReactDOM root for rendering React components. * Exposed to allow modules to avoid needing to bundle their own ReactDOM. diff --git a/packages/module-api/src/index.ts b/packages/module-api/src/index.ts index a246b12f9e4..ad77e6ad748 100644 --- a/packages/module-api/src/index.ts +++ b/packages/module-api/src/index.ts @@ -11,6 +11,7 @@ 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/composer"; export type * from "./api/custom-components"; export type * from "./api/extras"; export type * from "./api/legacy-modules";