Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions apps/web/src/components/structures/RoomView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1293,23 +1293,30 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}

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<ComposerInsertPayload>({
...(payload as ComposerInsertPayload),
...composerInsertPayload,
timelineRenderingType,
composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send,
});
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/components/structures/ThreadView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,12 +167,14 @@ export default class ThreadView extends React.Component<IProps, IState> {
}
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<ComposerInsertPayload>({
...(payload as ComposerInsertPayload),
...insertPayload,
timelineRenderingType: TimelineRenderingType.Thread,
composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send,
});
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/modules/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T extends object>(baseCustomisations: T) => {
let used = false;
Expand Down Expand Up @@ -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);
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/modules/ComposerApi.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComposerInsertPayload>({
...(payload as ComposerInsertPayload),
...insertPayload,
timelineRenderingType: insertPayload.timelineRenderingType!,
composerType: ComposerType.Edit,
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComposerInsertPayload>({
...(payload as ComposerInsertPayload),
...insertPayload,
timelineRenderingType: insertPayload.timelineRenderingType!,
composerType: ComposerType.Send,
});
break;
Expand Down
24 changes: 24 additions & 0 deletions apps/web/test/unit-tests/modules/ComposerApi-test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
Loading
Loading