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
@@ -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";
@@ -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();
});
});
});