some experimental module APIs

This commit is contained in:
Matthew Hodgson 2026-04-10 13:03:35 +01:00
parent fc391169da
commit 6b4a7833db
9 changed files with 127 additions and 1 deletions

View File

@ -2736,6 +2736,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
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 (
<ScopedRoomContextProvider {...this.state} roomViewStore={this.roomViewStore}>
<div
@ -2767,6 +2773,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
extraButtons={<>{extraButtons}</>}
/>
)}
{roomBanners}
{mainSplitBody}
</div>
</MainSplit>

View File

@ -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<IProps, IState> {
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<IProps, IState> {
/>
<div className="mx_MessageComposer_row">
{leftIcon}
{composerLeftComponents}
{composer}
<div className="mx_MessageComposer_actions">
{controls}

View File

@ -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<ISendMessageComposerPro
// don't bother sending an empty message
if (!content.body.trim()) return;
// Apply module event content transforms
for (const cb of ModuleApi.instance.extras.eventContentTransformCallbacks) {
content = cb(roomId, content as Record<string, unknown>) as RoomMessageEventContent;
}
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
decorateStartSendingTime(content);
}

View File

@ -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<string, unknown>) as RoomMessageEventContent;
}
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
decorateStartSendingTime(content);
}

View File

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

View File

@ -17,4 +17,19 @@ export class ClientApi implements IClientApi {
if (sdkRoom) return new ModuleRoom(sdkRoom);
return null;
}
public async downloadMxc(mxcUrl: string): Promise<string> {
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();
}
}

View File

@ -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<keyof EmittedEvents,
public spacePanelItems = new Map<string, SpacePanelItemProps>();
public visibleRoomBySpaceKey = new Map<string, () => 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<keyof EmittedEvents,
public addRoomHeaderButtonCallback(cb: RoomHeaderButtonsCallback): void {
this.roomHeaderButtonsCallbacks.push(cb);
}
public addRoomBannerCallback(cb: RoomBannerCallback): void {
this.roomBannerCallbacks.push(cb);
}
public addComposerLeftComponentCallback(cb: ComposerLeftComponentCallback): void {
this.composerLeftComponentCallbacks.push(cb);
}
public addEventContentTransformCallback(cb: EventContentTransformCallback): void {
this.eventContentTransformCallbacks.push(cb);
}
public addEncryptedEnvelopeTransformCallback(cb: EventContentTransformCallback): void {
this.encryptedEnvelopeTransformCallbacks.push(cb);
}
}
export function useModuleSpacePanelItems(api: ElementWebExtrasApi): ModuleSpacePanelItem[] {

View File

@ -55,6 +55,7 @@ export class CustomComponentsApi implements ICustomComponentsApi {
}
return {
content: mxEvent.getContent(),
wireContent: mxEvent.getWireContent(),
eventId,
originServerTs: mxEvent.getTs(),
roomId,

View File

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type Room as IRoom, Watchable } from "@element-hq/element-web-module-api";
import { type Room as IRoom, type MatrixEvent as ModuleMatrixEvent, Watchable } from "@element-hq/element-web-module-api";
import { RoomEvent, type Room as SdkRoom } from "matrix-js-sdk/src/matrix";
export class Room implements IRoom {
@ -22,6 +22,25 @@ export class Room implements IRoom {
public get id(): string {
return this.sdkRoom.roomId;
}
public getStateEvent(eventType: string, stateKey: string = ""): ModuleMatrixEvent | null {
const event = this.sdkRoom.currentState.getStateEvents(eventType, stateKey);
if (!event) return null;
const eventId = event.getId();
const roomId = event.getRoomId();
const sender = event.getSender();
if (!eventId || !roomId || !sender) return null;
return {
content: event.getContent(),
eventId,
originServerTs: event.getTs(),
roomId,
sender,
stateKey: event.getStateKey(),
type: event.getType(),
unsigned: event.getUnsigned(),
};
}
}
/**