This commit is contained in:
Half-Shot 2026-04-23 15:18:08 +01:00
parent 72b2e2865c
commit fd7a76f9b2
7 changed files with 110 additions and 17 deletions

View File

@ -1293,14 +1293,17 @@ 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;
// If the dispatchee didn't request a timeline rendering type, use the current one.
let timelineRenderingType: TimelineRenderingType =
composerInsertPayload.timelineRenderingType ?? this.state.timelineRenderingType;
// ThreadView handles Action.ComposerInsert itself due to it having its own editState
if (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();
@ -1309,7 +1312,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
// re-dispatch to the correct composer
defaultDispatcher.dispatch<ComposerInsertPayload>({
...(payload as ComposerInsertPayload),
...composerInsertPayload,
timelineRenderingType,
composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send,
});

View File

@ -15,18 +15,32 @@ export enum ComposerType {
Edit = "edit",
}
interface IBaseComposerInsertPayload extends ActionPayload {
/**
* Explicit composer insert to target
*/
interface IBaseComposerInsertPayloadExplicit extends ActionPayload {
action: Action.ComposerInsert;
timelineRenderingType: TimelineRenderingType;
composerType?: ComposerType; // falsy if should be re-dispatched to the correct composer
composerType: ComposerType;
}
interface IComposerInsertMentionPayload extends IBaseComposerInsertPayload {
/**
* Explicit composer insert to current target.
*/
interface IBaseComposerInsertPayloadImplicit extends ActionPayload {
action: Action.ComposerInsert;
composerType?: undefined; // undefined if this should be re-dispatched to the correct composer
timelineRenderingType?: TimelineRenderingType; // undefined if this should just use the current in-focus type.
}
type IBaseComposerInsertPayload = IBaseComposerInsertPayloadExplicit | IBaseComposerInsertPayloadImplicit;
type IComposerInsertMentionPayload = IBaseComposerInsertPayload & {
userId: string;
}
};
interface IComposerInsertPlaintextPayload extends IBaseComposerInsertPayload {
type IComposerInsertPlaintextPayload = IBaseComposerInsertPayload & {
text: string;
}
};
export type ComposerInsertPayload = IComposerInsertMentionPayload | IComposerInsertPlaintextPayload;

View File

@ -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;
@ -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);

View File

@ -7,17 +7,17 @@ Please see LICENSE files in the repository root for full details.
import { type ComposerApi as ModuleComposerApi } from "@element-hq/element-web-module-api";
import defaultDispatcher from "../dispatcher/dispatcher";
import type { MatrixDispatcher } from "../dispatcher/dispatcher";
import { Action } from "../dispatcher/actions";
import type { ComposerInsertPayload } from "../dispatcher/payloads/ComposerInsertPayload";
import { TimelineRenderingType } from "../contexts/RoomContext";
export class ComposerApi implements ModuleComposerApi {
public constructor(private readonly dispatcher: MatrixDispatcher) {}
public insertTextIntoComposer(text: string): void {
defaultDispatcher.dispatch({
this.dispatcher.dispatch({
action: Action.ComposerInsert,
text,
timelineRenderingType: TimelineRenderingType.Room,
} satisfies ComposerInsertPayload);
}
}

View File

@ -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 { ComposerInsertPayload, ComposerType } from "../../../../src/dispatcher/payloads/ComposerInsertPayload.ts";
// Used by group calls
jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({
@ -1075,6 +1076,54 @@ describe("RoomView", () => {
expect(onRoomViewUpdateMock).toHaveBeenCalledWith(true);
});
describe("handles Action.ComposerInsert", () => {
it("redispatches an empty composerType, timelineRenderingType with the current state", async () => {
jest.spyOn(defaultDispatcher, "dispatch");
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 () => {
jest.spyOn(defaultDispatcher, "dispatch");
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;
});
});
describe("when there is a RoomView", () => {
const widget1Id = "widget1";
const widget2Id = "widget2";

View File

@ -247,9 +247,9 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
class="_actions_n7ud0_61"
>
<button
class="_button_1nw83_8 _primaryAction_1xryk_20 _has-icon_1nw83_60"
class="_button_13vu4_8 _primaryAction_1xryk_20 _has-icon_13vu4_60"
data-kind="primary"
data-size="md"
data-size="sm"
role="button"
tabindex="0"
>
@ -1062,7 +1062,7 @@ exports[`RoomView invites renders an invite room 1`] = `
Decline
</div>
<button
class="_button_1nw83_8 _destructive_1nw83_110"
class="_button_13vu4_8 _destructive_13vu4_110"
data-kind="tertiary"
data-size="lg"
role="button"

View File

@ -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.insertTextIntoComposer("Hello world");
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: Action.ComposerInsert,
text: "Hello world",
});
});
});