From 6b4a7833db2e6d42663985e2ea8f223c770da9f6 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Fri, 10 Apr 2026 13:03:35 +0100 Subject: [PATCH] some experimental module APIs --- .../src/components/structures/RoomView.tsx | 7 ++++ .../views/rooms/MessageComposer.tsx | 10 +++++ .../views/rooms/SendMessageComposer.tsx | 6 +++ .../rooms/wysiwyg_composer/utils/message.ts | 6 +++ apps/web/src/modules/Api.ts | 39 +++++++++++++++++++ apps/web/src/modules/ClientApi.ts | 15 +++++++ apps/web/src/modules/ExtrasApi.ts | 23 +++++++++++ apps/web/src/modules/customComponentApi.ts | 1 + apps/web/src/modules/models/Room.ts | 21 +++++++++- 9 files changed, 127 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/structures/RoomView.tsx b/apps/web/src/components/structures/RoomView.tsx index 327e472e8b..1ade60664a 100644 --- a/apps/web/src/components/structures/RoomView.tsx +++ b/apps/web/src/components/structures/RoomView.tsx @@ -2736,6 +2736,12 @@ export class RoomView extends React.Component { if (b) extraButtons.push(b); } + const roomBanners: JSX.Element[] = []; + for (const cb of ModuleApi.instance.extras.roomBannerCallbacks) { + const b = cb(this.state.room.roomId); + if (b) roomBanners.push(b); + } + return (
{ extraButtons={<>{extraButtons}} /> )} + {roomBanners} {mainSplitBody}
diff --git a/apps/web/src/components/views/rooms/MessageComposer.tsx b/apps/web/src/components/views/rooms/MessageComposer.tsx index 06c843f190..a78a505a29 100644 --- a/apps/web/src/components/views/rooms/MessageComposer.tsx +++ b/apps/web/src/components/views/rooms/MessageComposer.tsx @@ -54,6 +54,7 @@ import { type MatrixClientProps, withMatrixClientHOC } from "../../../contexts/M import { UIFeature } from "../../../settings/UIFeature"; import { formatTimeLeft } from "../../../DateUtils"; import RoomReplacedSvg from "../../../../res/img/room_replaced.svg"; +import { ModuleApi } from "../../../modules/Api"; // The prefix used when persisting editor drafts to localstorage. export const WYSIWYG_EDITOR_STATE_STORAGE_PREFIX = "mx_wysiwyg_state_"; @@ -665,6 +666,14 @@ export class MessageComposer extends React.Component { const showSendButton = canSendMessages && (!this.state.isComposerEmpty || this.state.haveRecording); + const composerLeftComponents: JSX.Element[] = []; + if (canSendMessages) { + for (const cb of ModuleApi.instance.extras.composerLeftComponentCallbacks) { + const c = cb(this.props.room.roomId); + if (c) composerLeftComponents.push(c); + } + } + const classes = classNames({ "mx_MessageComposer": true, "mx_MessageComposer--compact": this.props.compact, @@ -682,6 +691,7 @@ export class MessageComposer extends React.Component { />
{leftIcon} + {composerLeftComponents} {composer}
{controls} diff --git a/apps/web/src/components/views/rooms/SendMessageComposer.tsx b/apps/web/src/components/views/rooms/SendMessageComposer.tsx index 0d9a68c45d..1d50de4948 100644 --- a/apps/web/src/components/views/rooms/SendMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/SendMessageComposer.tsx @@ -60,6 +60,7 @@ import { type IDiff } from "../../../editor/diff"; import { getBlobSafeMimeType } from "../../../utils/blobs"; import { EMOJI_REGEX } from "../../../HtmlUtils"; import { attachMentions, attachRelation } from "../../../utils/messages"; +import { ModuleApi } from "../../../modules/Api"; // The prefix used when persisting editor drafts to localstorage. export const EDITOR_STATE_STORAGE_PREFIX = "mx_cider_state_"; @@ -416,6 +417,11 @@ export class SendMessageComposer extends React.Component) as RoomMessageEventContent; + } + if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) { decorateStartSendingTime(content); } diff --git a/apps/web/src/components/views/rooms/wysiwyg_composer/utils/message.ts b/apps/web/src/components/views/rooms/wysiwyg_composer/utils/message.ts index 2594ac4841..df8e1b37ce 100644 --- a/apps/web/src/components/views/rooms/wysiwyg_composer/utils/message.ts +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/utils/message.ts @@ -28,6 +28,7 @@ import { createRedactEventDialog } from "../../../dialogs/ConfirmRedactDialog"; import { endEditing, cancelPreviousPendingEdit } from "./editing"; import type EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; import { createMessageContent, EMOTE_PREFIX } from "./createMessageContent"; +import { ModuleApi } from "../../../../../modules/Api"; import { isContentModified } from "./isContentModified"; import { CommandCategories, getCommand } from "../../../../../slash-commands/SlashCommands"; import { runSlashCommand, shouldSendAnyway } from "../../../../../editor/commands"; @@ -122,6 +123,11 @@ export async function sendMessage( return; } + // Apply module event content transforms + for (const cb of ModuleApi.instance.extras.eventContentTransformCallbacks) { + content = cb(roomId, content as Record) as RoomMessageEventContent; + } + if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) { decorateStartSendingTime(content); } diff --git a/apps/web/src/modules/Api.ts b/apps/web/src/modules/Api.ts index bb3c7497d5..858c4c5b81 100644 --- a/apps/web/src/modules/Api.ts +++ b/apps/web/src/modules/Api.ts @@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details. import { createRoot, type Root } from "react-dom/client"; import { type Api, type RuntimeModuleConstructor } from "@element-hq/element-web-module-api"; +import { MatrixClient, EventType } from "matrix-js-sdk/src/matrix"; import { I18nApi } from "@element-hq/web-shared-components"; import { ModuleRunner } from "./ModuleRunner.ts"; @@ -52,6 +53,7 @@ export class ModuleApi implements Api { public static get instance(): ModuleApi { if (!ModuleApi._instance) { ModuleApi._instance = new ModuleApi(); + ModuleApi.patchClientForEnvelopeTransforms(); window.mxModuleApi = ModuleApi._instance; } return ModuleApi._instance; @@ -98,6 +100,43 @@ export class ModuleApi implements Api { public createRoot(element: Element): Root { return createRoot(element); } + + /** + * Patches MatrixClient.sendEventHttpRequest to apply encrypted envelope transforms. + * Must be called once at startup. + * + * XXX: TODO: FIXME: this is a horrific workaround to avoid touching js-sdk + * We should expose the hook in js-sdk instead, obviously. + */ + public static patchClientForEnvelopeTransforms(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const proto = MatrixClient.prototype as any; + const original = proto.sendEventHttpRequest; + proto.sendEventHttpRequest = function ( + this: MatrixClient, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + event: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...args: any[] + ) { + // If the event was encrypted and there are envelope transform callbacks, apply them + if ( + event.isEncrypted?.() && + event.getWireType?.() === EventType.RoomMessageEncrypted && + ModuleApi._instance?.extras.encryptedEnvelopeTransformCallbacks.length + ) { + const roomId = event.getRoomId(); + if (roomId) { + let content = event.event.content; + for (const cb of ModuleApi._instance.extras.encryptedEnvelopeTransformCallbacks) { + content = cb(roomId, content); + } + event.event.content = content; + } + } + return original.call(this, event, ...args); + }; + } } export type ModuleApiType = ModuleApi; diff --git a/apps/web/src/modules/ClientApi.ts b/apps/web/src/modules/ClientApi.ts index 7b5ccd2828..9004b52114 100644 --- a/apps/web/src/modules/ClientApi.ts +++ b/apps/web/src/modules/ClientApi.ts @@ -17,4 +17,19 @@ export class ClientApi implements IClientApi { if (sdkRoom) return new ModuleRoom(sdkRoom); return null; } + + public async downloadMxc(mxcUrl: string): Promise { + const client = MatrixClientPeg.safeGet(); + // useAuthentication=true produces the authenticated /_matrix/client/v1/media/download URL + const httpUrl = client.mxcUrlToHttp(mxcUrl, undefined, undefined, undefined, false, true); + if (!httpUrl) throw new Error(`Cannot resolve mxc URL: ${mxcUrl}`); + const accessToken = client.getAccessToken(); + const response = await fetch(httpUrl, { + headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {}, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} downloading ${mxcUrl}`); + } + return response.text(); + } } diff --git a/apps/web/src/modules/ExtrasApi.ts b/apps/web/src/modules/ExtrasApi.ts index c82925889c..7638de77c0 100644 --- a/apps/web/src/modules/ExtrasApi.ts +++ b/apps/web/src/modules/ExtrasApi.ts @@ -11,6 +11,9 @@ import { type SpacePanelItemProps, type ExtrasApi, type RoomHeaderButtonsCallback, + type RoomBannerCallback, + type ComposerLeftComponentCallback, + type EventContentTransformCallback, } from "@element-hq/element-web-module-api"; import { TypedEventEmitter } from "matrix-js-sdk/src/matrix"; @@ -32,6 +35,10 @@ export class ElementWebExtrasApi extends TypedEventEmitter(); public visibleRoomBySpaceKey = new Map string[]>(); public roomHeaderButtonsCallbacks: RoomHeaderButtonsCallback[] = []; + public roomBannerCallbacks: RoomBannerCallback[] = []; + public composerLeftComponentCallbacks: ComposerLeftComponentCallback[] = []; + public eventContentTransformCallbacks: EventContentTransformCallback[] = []; + public encryptedEnvelopeTransformCallbacks: EventContentTransformCallback[] = []; public setSpacePanelItem(spacekey: string, item: SpacePanelItemProps): void { this.spacePanelItems.set(spacekey, item); @@ -45,6 +52,22 @@ export class ElementWebExtrasApi extends TypedEventEmitter