Add Module Composer API (#33284)

* Spec composer API

* Add composer api implementation

* Tests

* Copyright

* update sigs

* cleanup

* a snap

* cleanup

* linting

* Tidy up

* Adjust
This commit is contained in:
Will Hunt
2026-04-27 10:33:20 +00:00
committed by GitHub
parent cb6c141580
commit 2ea0c4106b
14 changed files with 817 additions and 558 deletions
@@ -1293,23 +1293,30 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
} }
case Action.ComposerInsert: { 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 // ThreadView handles Action.ComposerInsert itself due to it having its own editState
if (timelineRenderingType === TimelineRenderingType.Thread) break; if (composerInsertPayload.timelineRenderingType === TimelineRenderingType.Thread) break;
if ( if (
this.state.timelineRenderingType === TimelineRenderingType.Search && 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 // we don't have the composer rendered in this state, so bring it back first
await this.onCancelSearchClick(); await this.onCancelSearchClick();
timelineRenderingType = TimelineRenderingType.Room; 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 // re-dispatch to the correct composer
defaultDispatcher.dispatch<ComposerInsertPayload>({ defaultDispatcher.dispatch<ComposerInsertPayload>({
...(payload as ComposerInsertPayload), ...composerInsertPayload,
timelineRenderingType, timelineRenderingType,
composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send, composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send,
}); });
@@ -167,12 +167,14 @@ export default class ThreadView extends React.Component<IProps, IState> {
} }
switch (payload.action) { switch (payload.action) {
case Action.ComposerInsert: { case Action.ComposerInsert: {
if (payload.composerType) break; const insertPayload = payload as ComposerInsertPayload;
if (payload.timelineRenderingType !== TimelineRenderingType.Thread) break; if (insertPayload.composerType) break;
if (insertPayload.timelineRenderingType !== TimelineRenderingType.Thread) break;
// re-dispatch to the correct composer // re-dispatch to the correct composer
dis.dispatch<ComposerInsertPayload>({ dis.dispatch<ComposerInsertPayload>({
...(payload as ComposerInsertPayload), ...insertPayload,
timelineRenderingType: TimelineRenderingType.Thread,
composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send, composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send,
}); });
break; break;
@@ -17,7 +17,7 @@ export enum ComposerType {
interface IBaseComposerInsertPayload extends ActionPayload { interface IBaseComposerInsertPayload extends ActionPayload {
action: Action.ComposerInsert; 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 composerType?: ComposerType; // falsy if should be re-dispatched to the correct composer
} }
+3
View File
@@ -33,6 +33,8 @@ import { StoresApi } from "./StoresApi.ts";
import { WidgetLifecycleApi } from "./WidgetLifecycleApi.ts"; import { WidgetLifecycleApi } from "./WidgetLifecycleApi.ts";
import { WidgetApi } from "./WidgetApi.ts"; import { WidgetApi } from "./WidgetApi.ts";
import { CustomisationsApi } from "./customisationsApi.ts"; import { CustomisationsApi } from "./customisationsApi.ts";
import { ComposerApi } from "./ComposerApi.ts";
import defaultDispatcher from "../dispatcher/dispatcher.ts";
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => { const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
let used = false; let used = false;
@@ -94,6 +96,7 @@ export class ModuleApi implements Api {
public readonly rootNode = document.getElementById("matrixchat")!; public readonly rootNode = document.getElementById("matrixchat")!;
public readonly client = new ClientApi(); public readonly client = new ClientApi();
public readonly stores = new StoresApi(); public readonly stores = new StoresApi();
public readonly composer = new ComposerApi(defaultDispatcher);
public createRoot(element: Element): Root { public createRoot(element: Element): Root {
return createRoot(element); return createRoot(element);
+23
View File
@@ -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);
}
}
@@ -71,6 +71,7 @@ import ErrorDialog from "../../../../src/components/views/dialogs/ErrorDialog.ts
import * as pinnedEventHooks from "../../../../src/hooks/usePinnedEvents"; import * as pinnedEventHooks from "../../../../src/hooks/usePinnedEvents";
import { TimelineRenderingType } from "../../../../src/contexts/RoomContext"; import { TimelineRenderingType } from "../../../../src/contexts/RoomContext";
import { ModuleApi } from "../../../../src/modules/Api"; import { ModuleApi } from "../../../../src/modules/Api";
import { type ComposerInsertPayload, ComposerType } from "../../../../src/dispatcher/payloads/ComposerInsertPayload.ts";
// Used by group calls // Used by group calls
jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({ jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({
@@ -1075,6 +1076,80 @@ describe("RoomView", () => {
expect(onRoomViewUpdateMock).toHaveBeenCalledWith(true); 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", () => { describe("when there is a RoomView", () => {
const widget1Id = "widget1"; const widget1Id = "widget1";
const widget2Id = "widget2"; const widget2Id = "widget2";
@@ -34,6 +34,9 @@ import { getRoomContext } from "../../../test-utils/room";
import { mkMessage, stubClient } from "../../../test-utils/test-utils"; import { mkMessage, stubClient } from "../../../test-utils/test-utils";
import { mkThread } from "../../../test-utils/threads"; import { mkThread } from "../../../test-utils/threads";
import { ScopedRoomContextProvider } from "../../../../src/contexts/ScopedRoomContext.tsx"; 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", () => { describe("ThreadView", () => {
const ROOM_ID = "!roomId:example.org"; const ROOM_ID = "!roomId:example.org";
@@ -209,4 +212,87 @@ describe("ThreadView", () => {
metricsTrigger: undefined, 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();
});
});
}); });
@@ -283,8 +283,10 @@ describe("EditWysiwygComposer", () => {
// It adds the composerType fields where the value refers if the composer is in editing or not // 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 // The listeners in the RTE ignore the message if the composerType is missing in the payload
const dispatcherRef = defaultDispatcher.register((payload: ActionPayload) => { const dispatcherRef = defaultDispatcher.register((payload: ActionPayload) => {
const insertPayload = payload as ComposerInsertPayload;
defaultDispatcher.dispatch<ComposerInsertPayload>({ defaultDispatcher.dispatch<ComposerInsertPayload>({
...(payload as ComposerInsertPayload), ...insertPayload,
timelineRenderingType: insertPayload.timelineRenderingType!,
composerType: ComposerType.Edit, composerType: ComposerType.Edit,
}); });
}); });
@@ -48,11 +48,13 @@ describe("SendWysiwygComposer", () => {
const registerId = defaultDispatcher.register((payload) => { const registerId = defaultDispatcher.register((payload) => {
switch (payload.action) { switch (payload.action) {
case Action.ComposerInsert: { case Action.ComposerInsert: {
if (payload.composerType) break; const insertPayload = payload as ComposerInsertPayload;
if (insertPayload.composerType) break;
// re-dispatch to the correct composer // re-dispatch to the correct composer
defaultDispatcher.dispatch<ComposerInsertPayload>({ defaultDispatcher.dispatch<ComposerInsertPayload>({
...(payload as ComposerInsertPayload), ...insertPayload,
timelineRenderingType: insertPayload.timelineRenderingType!,
composerType: ComposerType.Send, composerType: ComposerType.Send,
}); });
break; break;
@@ -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",
});
});
});
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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;
}
+7
View File
@@ -23,6 +23,7 @@ import { type ClientApi } from "./client.ts";
import { type WidgetLifecycleApi } from "./widget-lifecycle.ts"; import { type WidgetLifecycleApi } from "./widget-lifecycle.ts";
import { type WidgetApi } from "./widget.ts"; import { type WidgetApi } from "./widget.ts";
import { type CustomisationsApi } from "./customisations.ts"; import { type CustomisationsApi } from "./customisations.ts";
import { type ComposerApi } from "./composer.ts";
/** /**
* Module interface for modules to implement. * Module interface for modules to implement.
@@ -159,6 +160,12 @@ export interface Api
*/ */
readonly customisations: CustomisationsApi; 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. * Create a ReactDOM root for rendering React components.
* Exposed to allow modules to avoid needing to bundle their own ReactDOM. * Exposed to allow modules to avoid needing to bundle their own ReactDOM.
+1
View File
@@ -11,6 +11,7 @@ export type { Config, ConfigApi } from "./api/config";
export type { I18nApi, Variables, Translations, SubstitutionValue, Tags } from "./api/i18n"; export type { I18nApi, Variables, Translations, SubstitutionValue, Tags } from "./api/i18n";
export type * from "./models/event"; export type * from "./models/event";
export type * from "./models/Room"; export type * from "./models/Room";
export type * from "./api/composer";
export type * from "./api/custom-components"; export type * from "./api/custom-components";
export type * from "./api/extras"; export type * from "./api/extras";
export type * from "./api/legacy-modules"; export type * from "./api/legacy-modules";