From a084f0234aa3500a43924d1294bbcabc604add9d Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Mon, 13 Apr 2026 10:57:31 +0530 Subject: [PATCH] WIP --- apps/web/src/MatrixClientPeg.ts | 15 ++++ .../src/components/structures/RoomView.tsx | 30 ++++++++ .../views/rooms/MessageComposer.tsx | 15 +++- .../tabs/room/SecurityRoomSettingsTab.tsx | 8 +++ apps/web/src/events/EventTileFactory.tsx | 2 + apps/web/src/modules/ClientApi.ts | 72 ++++++++++++++++++- apps/web/src/modules/ExtrasApi.ts | 23 ++++++ apps/web/src/modules/customComponentApi.ts | 12 ++++ apps/web/src/modules/models/Room.ts | 55 +++++++++++++- 9 files changed, 228 insertions(+), 4 deletions(-) diff --git a/apps/web/src/MatrixClientPeg.ts b/apps/web/src/MatrixClientPeg.ts index 8148509ef1..1665ca4e79 100644 --- a/apps/web/src/MatrixClientPeg.ts +++ b/apps/web/src/MatrixClientPeg.ts @@ -152,6 +152,12 @@ export interface IMatrixClientPeg { * see {@link ICreateClientOpts.tokenRefreshFunction} */ replaceUsingCreds(creds: IMatrixClientCreds, tokenRefreshFunction?: TokenRefreshFunction): void; + + /** + * Returns a promise that resolve with MatrixClient is available. + * Useful when you want to access the matrix client early on the app lifecycle. + */ + clientReadyPromise: Promise; } /** @@ -172,6 +178,8 @@ class MatrixClientPegClass implements IMatrixClientPeg { private matrixClient: MatrixClient | null = null; private justRegisteredUserId: string | null = null; + private matrixClientReady = Promise.withResolvers(); + public get(): MatrixClient | null { return this.matrixClient; } @@ -183,6 +191,10 @@ class MatrixClientPegClass implements IMatrixClientPeg { return this.matrixClient; } + public get clientReadyPromise(): Promise { + return this.matrixClientReady.promise; + } + public unset(): void { this.matrixClient = null; @@ -373,6 +385,9 @@ class MatrixClientPegClass implements IMatrixClientPeg { logger.log(`MatrixClientPeg: really starting MatrixClient`); await this.matrixClient!.startClient(opts); logger.log(`MatrixClientPeg: MatrixClient started`); + + console.log("Resolving client ready promise"); + this.matrixClientReady.resolve(); } private namesToRoomName(names: string[], count: number): string | undefined { diff --git a/apps/web/src/components/structures/RoomView.tsx b/apps/web/src/components/structures/RoomView.tsx index b1fc5d8f18..fec7b12064 100644 --- a/apps/web/src/components/structures/RoomView.tsx +++ b/apps/web/src/components/structures/RoomView.tsx @@ -1617,6 +1617,29 @@ export class RoomView extends React.Component { // ignore if we don't have a room yet if (!this.state.room || this.state.room.roomId !== state.roomId || !this.context.client) return; + // Notify module state event listeners and force a re-render so + // module-provided UI (banners, composer components) updates. + const listeners = ModuleApi.instance.client.stateEventListeners; + if (listeners.length) { + const eventId = ev.getId(); + const roomId = ev.getRoomId(); + const sender = ev.getSender(); + if (eventId && roomId && sender) { + const moduleEvent = { + content: ev.getContent(), + eventId, + originServerTs: ev.getTs(), + roomId, + sender, + stateKey: ev.getStateKey(), + type: ev.getType(), + unsigned: ev.getUnsigned(), + }; + for (const cb of listeners) cb(moduleEvent); + this.forceUpdate(); + } + } + switch (ev.getType()) { case EventType.RoomTombstone: this.setState({ tombstone: this.getRoomTombstone() }); @@ -2735,6 +2758,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..fefe8303ac 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_"; @@ -723,4 +724,16 @@ export class MessageComposer extends React.Component { } const MessageComposerWithMatrixClient = withMatrixClientHOC(MessageComposer); -export default MessageComposerWithMatrixClient; + +const WrappedMessageComposer: React.FC = (props) => { + const renderer = ModuleApi.instance.customComponents.messageComposerRenderer; + if (renderer) { + return renderer({ ...props, roomId: props.room.roomId }, (props) => ( + + )); + } + + return ; +}; + +export default WrappedMessageComposer; diff --git a/apps/web/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx b/apps/web/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx index 15ca6e9788..2c4244cba6 100644 --- a/apps/web/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx +++ b/apps/web/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx @@ -43,6 +43,7 @@ import SdkConfig from "../../../../../SdkConfig"; import { shouldForceDisableEncryption } from "../../../../../utils/crypto/shouldForceDisableEncryption"; import { Caption } from "../../../typography/Caption"; import { MEGOLM_ENCRYPTION_ALGORITHM } from "../../../../../utils/crypto"; +import { ModuleApi } from "../../../../../modules/Api"; interface IProps { room: Room; @@ -532,6 +533,12 @@ export default class SecurityRoomSettingsTab extends React.Component )} + {additionalSettings} {this.renderJoinRule()} {historySection} diff --git a/apps/web/src/events/EventTileFactory.tsx b/apps/web/src/events/EventTileFactory.tsx index 56faffc26c..a6209b0bd2 100644 --- a/apps/web/src/events/EventTileFactory.tsx +++ b/apps/web/src/events/EventTileFactory.tsx @@ -373,6 +373,7 @@ export function renderReplyTile( // Will return null if no custom component can render it. return ModuleApi.instance.customComponents.renderMessage({ mxEvent: props.mxEvent, + isReplyTile: true, }); } @@ -395,6 +396,7 @@ export function renderReplyTile( return ModuleApi.instance.customComponents.renderMessage( { mxEvent: props.mxEvent, + isReplyTile: true, }, (origProps) => factory(ref, { diff --git a/apps/web/src/modules/ClientApi.ts b/apps/web/src/modules/ClientApi.ts index 7b5ccd2828..778693d9e8 100644 --- a/apps/web/src/modules/ClientApi.ts +++ b/apps/web/src/modules/ClientApi.ts @@ -4,7 +4,13 @@ Copyright 2025 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 { ClientApi as IClientApi, Room } from "@element-hq/element-web-module-api"; +import type { + ClientApi as IClientApi, + Room, + MatrixEvent as ModuleMatrixEvent, + EventContentTransformCallback, + UnregisterTransformCallback, +} from "@element-hq/element-web-module-api"; import { Room as ModuleRoom } from "./models/Room"; import { AccountDataApi } from "./AccountDataApi"; import { MatrixClientPeg } from "../MatrixClientPeg"; @@ -17,4 +23,68 @@ export class ClientApi implements IClientApi { if (sdkRoom) return new ModuleRoom(sdkRoom); return null; } + + public async uploadContent(content: Blob | File, contentType?: string): Promise { + const client = MatrixClientPeg.safeGet(); + const { content_uri: mxcUrl } = await client.uploadContent(content, { + includeFilename: false, + type: contentType, + }); + return mxcUrl; + } + + public async sendStateEvent( + roomId: string, + eventType: string, + content: Record, + stateKey: string = "", + ): Promise { + const client = MatrixClientPeg.safeGet(); + await client.sendStateEvent(roomId, eventType, content, stateKey); + } + + public stateEventListeners: Array<(event: ModuleMatrixEvent) => void> = []; + + public onStateEvent(callback: (event: ModuleMatrixEvent) => void): () => void { + this.stateEventListeners.push(callback); + return () => { + const idx = this.stateEventListeners.indexOf(callback); + if (idx >= 0) this.stateEventListeners.splice(idx, 1); + }; + } + + 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(); + } + + public async waitForClient(): Promise { + const clientPromise = MatrixClientPeg.clientReadyPromise; + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("ClientApi.waitForClient timed out.")), 10000); + }); + await Promise.race([clientPromise, timeoutPromise]); + } + + public registerEncryptedEventContentTransform( + transform: EventContentTransformCallback, + ): UnregisterTransformCallback { + const client = MatrixClientPeg.safeGet(); + return client.registerEncryptedEventContentTransform(transform); + } + + public registerEventContentTransform(transform: EventContentTransformCallback): UnregisterTransformCallback { + const client = MatrixClientPeg.safeGet(); + return client.registerEventContentTransform(transform); + } } diff --git a/apps/web/src/modules/ExtrasApi.ts b/apps/web/src/modules/ExtrasApi.ts index c82925889c..3fd82c97a0 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 EventContentTransformCallback, + type RoomSettingsSecurityCallback, } 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 eventContentTransformCallbacks: EventContentTransformCallback[] = []; + public encryptedEnvelopeTransformCallbacks: EventContentTransformCallback[] = []; + public roomSettingsSecurityCallbacks: RoomSettingsSecurityCallback[] = []; public setSpacePanelItem(spacekey: string, item: SpacePanelItemProps): void { this.spacePanelItems.set(spacekey, item); @@ -45,6 +52,22 @@ export class ElementWebExtrasApi extends TypedEventEmitter; @@ -22,6 +26,10 @@ export class Room implements IRoom { public get id(): string { return this.sdkRoom.roomId; } + + public getStateEvent(eventType: string, stateKey: string = ""): WatchableStateEvent { + return new WatchableStateEvent(eventType, stateKey, this.sdkRoom); + } } /** @@ -43,3 +51,46 @@ class WatchableName extends Watchable { this.sdkRoom.off(RoomEvent.Name, this.onNameUpdate); } } + +class WatchableStateEvent extends Watchable { + public constructor( + private eventType: string, + private stateKey: string, + private sdkRoom: SdkRoom, + ) { + const event = sdkRoom.currentState.getStateEvents(eventType, stateKey); + super(WatchableStateEvent.sdkEventToModuleEvent(event)); + } + + protected onFirstWatch(): void { + this.sdkRoom.on(RoomStateEvent.Events, this.updateEvent); + } + + protected onLastWatch(): void { + this.sdkRoom.off(RoomStateEvent.Events, this.updateEvent); + } + + private updateEvent = (event: MatrixEvent): void => { + if (event.isState() && event.getType() === this.eventType && event.getStateKey() === this.stateKey) { + this.value = WatchableStateEvent.sdkEventToModuleEvent(event); + } + }; + + public static sdkEventToModuleEvent(sdkEvent: MatrixEvent | null): ModuleMatrixEvent | null { + if (!sdkEvent) return null; + const eventId = sdkEvent.getId(); + const roomId = sdkEvent.getRoomId(); + const sender = sdkEvent.getSender(); + if (!eventId || !roomId || !sender) return null; + return { + content: sdkEvent.getContent(), + eventId, + originServerTs: sdkEvent.getTs(), + roomId, + sender, + stateKey: sdkEvent.getStateKey(), + type: sdkEvent.getType(), + unsigned: sdkEvent.getUnsigned(), + }; + } +}