mv element.io @types __mocks__/ debian docker module_system/ playwright res src test webapp Dockerfile .dockerignore .eslintignore .stylelintrc.cjs babel.config.cjs recorder-worklet-loader.cjs .modernizr.json components.json config.json config.sample.json package.json project.json tsconfig.json tsconfig.module_system.json jest.config.ts playwright.config.ts webpack.config.ts build_config.sample.yaml apps/web/

mkdir apps/web/scripts
mv scripts/{cleanup.sh,ci_package.sh,copy-res.ts,deploy.py,package.sh} apps/web/scripts

And a couple of gitignore tweaks

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski
2026-02-24 15:43:58 +00:00
parent e7509c92a1
commit 91a3cb03c1
3408 changed files with 28 additions and 32 deletions
+54
View File
@@ -0,0 +1,54 @@
/*
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 { Watchable, type AccountDataApi as IAccountDataApi } from "@element-hq/element-web-module-api";
import { ClientEvent, type MatrixEvent, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../MatrixClientPeg";
export class AccountDataApi implements IAccountDataApi {
public get(eventType: string): Watchable<unknown> {
const cli = MatrixClientPeg.safeGet();
return new AccountDataWatchable(cli, eventType);
}
public async set(eventType: string, content: any): Promise<void> {
const cli = MatrixClientPeg.safeGet();
//@ts-expect-error: JS-SDK accepts known event-types, intentionally allow arbitrary types.
await cli.setAccountData(eventType, content);
}
public async delete(eventType: string): Promise<void> {
const cli = MatrixClientPeg.safeGet();
//@ts-expect-error: JS-SDK accepts known event-types, intentionally allow arbitrary types.
await cli.deleteAccountData(eventType);
}
}
class AccountDataWatchable extends Watchable<unknown> {
public constructor(
private cli: MatrixClient,
private eventType: string,
) {
//@ts-expect-error: JS-SDK accepts known event-types, intentionally allow arbitrary types.
super(cli.getAccountData(eventType)?.getContent());
}
private onAccountData = (event: MatrixEvent): void => {
if (event.getType() === this.eventType) {
this.value = event.getContent();
}
};
protected onFirstWatch(): void {
this.cli.on(ClientEvent.AccountData, this.onAccountData);
}
protected onLastWatch(): void {
this.cli.off(ClientEvent.AccountData, this.onAccountData);
}
}
+97
View File
@@ -0,0 +1,97 @@
/*
Copyright 2025 New Vector 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 { createRoot, type Root } from "react-dom/client";
import { type Api, type RuntimeModuleConstructor } from "@element-hq/element-web-module-api";
import { I18nApi } from "@element-hq/web-shared-components";
import { ModuleRunner } from "./ModuleRunner.ts";
import AliasCustomisations from "../customisations/Alias.ts";
import { RoomListCustomisations } from "../customisations/RoomList.ts";
import ChatExportCustomisations from "../customisations/ChatExport.ts";
import { ComponentVisibilityCustomisations } from "../customisations/ComponentVisibility.ts";
import DirectoryCustomisations from "../customisations/Directory.ts";
import LifecycleCustomisations from "../customisations/Lifecycle.ts";
import * as MediaCustomisations from "../customisations/Media.ts";
import UserIdentifierCustomisations from "../customisations/UserIdentifier.ts";
import { WidgetPermissionCustomisations } from "../customisations/WidgetPermissions.ts";
import { WidgetVariableCustomisations } from "../customisations/WidgetVariables.ts";
import { ConfigApi } from "./ConfigApi.ts";
import { CustomComponentsApi } from "./customComponentApi";
import { WatchableProfile } from "./Profile.ts";
import { NavigationApi } from "./Navigation.ts";
import { openDialog } from "./Dialog.tsx";
import { overwriteAccountAuth } from "./Auth.ts";
import { ElementWebExtrasApi } from "./ExtrasApi.ts";
import { ElementWebBuiltinsApi } from "./BuiltinsApi.tsx";
import { ClientApi } from "./ClientApi.ts";
import { StoresApi } from "./StoresApi.ts";
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
let used = false;
return (customisations: T) => {
if (used) throw new Error("Legacy customisations can only be registered by one module");
Object.assign(baseCustomisations, customisations);
used = true;
};
};
/**
* Implementation of the @element-hq/element-web-module-api runtime module API.
*/
export class ModuleApi implements Api {
private static _instance: ModuleApi;
public static get instance(): ModuleApi {
if (!ModuleApi._instance) {
ModuleApi._instance = new ModuleApi();
window.mxModuleApi = ModuleApi._instance;
}
return ModuleApi._instance;
}
/* eslint-disable @typescript-eslint/naming-convention */
public async _registerLegacyModule(LegacyModule: RuntimeModuleConstructor): Promise<void> {
ModuleRunner.instance.registerModule((api) => new LegacyModule(api));
}
public readonly _registerLegacyAliasCustomisations = legacyCustomisationsFactory(AliasCustomisations);
public readonly _registerLegacyChatExportCustomisations = legacyCustomisationsFactory(ChatExportCustomisations);
public readonly _registerLegacyComponentVisibilityCustomisations = legacyCustomisationsFactory(
ComponentVisibilityCustomisations,
);
public readonly _registerLegacyDirectoryCustomisations = legacyCustomisationsFactory(DirectoryCustomisations);
public readonly _registerLegacyLifecycleCustomisations = legacyCustomisationsFactory(LifecycleCustomisations);
public readonly _registerLegacyMediaCustomisations = legacyCustomisationsFactory(MediaCustomisations);
public readonly _registerLegacyRoomListCustomisations = legacyCustomisationsFactory(RoomListCustomisations);
public readonly _registerLegacyUserIdentifierCustomisations =
legacyCustomisationsFactory(UserIdentifierCustomisations);
public readonly _registerLegacyWidgetPermissionsCustomisations =
legacyCustomisationsFactory(WidgetPermissionCustomisations);
public readonly _registerLegacyWidgetVariablesCustomisations =
legacyCustomisationsFactory(WidgetVariableCustomisations);
/* eslint-enable @typescript-eslint/naming-convention */
public readonly navigation = new NavigationApi();
public readonly openDialog = openDialog;
public readonly overwriteAccountAuth = overwriteAccountAuth;
public readonly profile = new WatchableProfile();
public readonly config = new ConfigApi();
public readonly i18n = new I18nApi();
public readonly customComponents = new CustomComponentsApi();
public readonly extras = new ElementWebExtrasApi();
public readonly builtins = new ElementWebBuiltinsApi();
public readonly rootNode = document.getElementById("matrixchat")!;
public readonly client = new ClientApi();
public readonly stores = new StoresApi();
public createRoot(element: Element): Root {
return createRoot(element);
}
}
export type ModuleApiType = ModuleApi;
+37
View File
@@ -0,0 +1,37 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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 RuntimeModule } from "@matrix-org/react-sdk-module-api/lib/RuntimeModule";
import { type ModuleFactory } from "./ModuleFactory";
import { ProxiedModuleApi } from "./ProxiedModuleApi";
/**
* Wraps a module factory into a usable module. Acts as a simple container
* for the constructs needed to operate a module.
*/
export class AppModule {
/**
* The module instance.
*/
public readonly module: RuntimeModule;
/**
* The API instance used by the module.
*/
public readonly api = new ProxiedModuleApi();
/**
* Converts a factory into an AppModule. The factory will be called
* immediately.
* @param factory The module factory.
*/
public constructor(factory: ModuleFactory) {
this.module = factory(this.api);
}
}
+43
View File
@@ -0,0 +1,43 @@
/*
Copyright 2025 New Vector 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 AccountAuthInfo } from "@element-hq/element-web-module-api";
import { sleep } from "matrix-js-sdk/src/utils";
import type { OverwriteLoginPayload } from "../dispatcher/payloads/OverwriteLoginPayload.ts";
import { Action } from "../dispatcher/actions.ts";
import defaultDispatcher from "../dispatcher/dispatcher.ts";
import type { ActionPayload } from "../dispatcher/payloads.ts";
export async function overwriteAccountAuth(accountInfo: AccountAuthInfo): Promise<void> {
const { promise, resolve } = Promise.withResolvers<void>();
const onAction = (payload: ActionPayload): void => {
if (payload.action === Action.OnLoggedIn) {
// We want to wait for the new login to complete before returning.
// See `Action.OnLoggedIn` in dispatcher.
resolve();
}
};
const dispatcherRef = defaultDispatcher.register(onAction);
defaultDispatcher.dispatch<OverwriteLoginPayload>(
{
action: Action.OverwriteLogin,
credentials: {
...accountInfo,
guest: false,
},
},
true,
); // require to be sync to match inherited interface behaviour
// wait for login to complete
await promise;
defaultDispatcher.unregister(dispatcherRef);
await sleep(0); // wait for the next tick to ensure the login is fully processed
}
+96
View File
@@ -0,0 +1,96 @@
/*
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 React from "react";
import { type RoomViewProps, type BuiltinsApi } from "@element-hq/element-web-module-api";
import { MatrixClientPeg } from "../MatrixClientPeg";
import type { Room } from "matrix-js-sdk/src/matrix";
import type { ModuleNotificationDecorationProps } from "./components/ModuleNotificationDecoration";
interface RoomViewPropsWithRoomId extends RoomViewProps {
/**
* The ID of the room to display
*/
roomId?: string;
}
interface RoomAvatarProps {
room: Room;
size?: string;
}
interface Components {
roomView: React.ComponentType<RoomViewPropsWithRoomId>;
roomAvatar: React.ComponentType<RoomAvatarProps>;
notificationDecoration: React.ComponentType<ModuleNotificationDecorationProps>;
}
export class ElementWebBuiltinsApi implements BuiltinsApi {
private _roomView?: Components["roomView"];
private _roomAvatar?: Components["roomAvatar"];
private _notificationDecoration?: Components["notificationDecoration"];
/**
* Sets the components used by the API.
*
* This only really exists here because referencing these components directly causes a nightmare of
* circular dependencies that break the whole app, so instead we avoid referencing it here
* and pass it in from somewhere it's already referenced (see related comment in app.tsx).
*
* @param component The components used by the api, see {@link Components}
*/
public setComponents(components: Components): void {
this._roomView = components.roomView;
this._roomAvatar = components.roomAvatar;
this._notificationDecoration = components.notificationDecoration;
}
public getRoomViewComponent(): React.ComponentType<RoomViewPropsWithRoomId> {
if (!this._roomView) {
throw new Error("No RoomView component has been set");
}
return this._roomView;
}
public getRoomAvatarComponent(): React.ComponentType<RoomAvatarProps> {
if (!this._roomAvatar) {
throw new Error("No RoomAvatar component has been set");
}
return this._roomAvatar;
}
public getNotificationDecorationComponent(): React.ComponentType<ModuleNotificationDecorationProps> {
if (!this._notificationDecoration) {
throw new Error("No NotificationDecoration component has been set");
}
return this._notificationDecoration;
}
public renderRoomView(roomId: string, props?: RoomViewProps): React.ReactNode {
const Component = this.getRoomViewComponent();
return <Component roomId={roomId} {...props} />;
}
public renderRoomAvatar(roomId: string, size?: string): React.ReactNode {
const room = MatrixClientPeg.safeGet().getRoom(roomId);
if (!room) {
throw new Error(`No room such room: ${roomId}`);
}
const Component = this.getRoomAvatarComponent();
return <Component room={room} size={size} />;
}
public renderNotificationDecoration(roomId: string): React.ReactNode {
const room = MatrixClientPeg.safeGet().getRoom(roomId);
if (!room) {
throw new Error(`No room such room: ${roomId}`);
}
const Component = this.getNotificationDecorationComponent();
return <Component room={room} />;
}
}
+20
View File
@@ -0,0 +1,20 @@
/*
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 { Room as ModuleRoom } from "./models/Room";
import { AccountDataApi } from "./AccountDataApi";
import { MatrixClientPeg } from "../MatrixClientPeg";
export class ClientApi implements IClientApi {
public readonly accountData = new AccountDataApi();
public getRoom(roomId: string): Room | null {
const sdkRoom = MatrixClientPeg.safeGet().getRoom(roomId);
if (sdkRoom) return new ModuleRoom(sdkRoom);
return null;
}
}
+20
View File
@@ -0,0 +1,20 @@
/*
Copyright 2025 New Vector 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 { ConfigApi as IConfigApi, Config } from "@element-hq/element-web-module-api";
import SdkConfig from "../SdkConfig.ts";
export class ConfigApi implements IConfigApi {
public get(): Config;
public get<K extends keyof Config>(key: K): Config[K];
public get<K extends keyof Config = never>(key?: K): Config | Config[K] {
if (key === undefined) {
return SdkConfig.get() as Config;
}
return SdkConfig.get(key);
}
}
+52
View File
@@ -0,0 +1,52 @@
/*
Copyright 2025 New Vector 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 React, { type ComponentType, type JSX, useCallback } from "react";
import { type DialogProps, type DialogOptions, type DialogHandle } from "@element-hq/element-web-module-api";
import Modal from "../Modal";
import BaseDialog from "../components/views/dialogs/BaseDialog.tsx";
const OuterDialog = <M, P extends object>({
title,
Dialog,
props,
onFinished,
}: {
title: string;
Dialog: ComponentType<DialogProps<M> & P>;
props: P;
onFinished(this: void, ok: boolean, model: M | null): void;
}): JSX.Element => {
const close = useCallback(() => onFinished(false, null), [onFinished]);
const submit = useCallback((model: M) => onFinished(true, model), [onFinished]);
return (
<BaseDialog onFinished={close} title={title}>
<Dialog {...props} onSubmit={submit} onCancel={close} />
</BaseDialog>
);
};
export function openDialog<M, P extends object>(
initialOptions: DialogOptions,
Dialog: ComponentType<P & DialogProps<M>>,
props: P,
): DialogHandle<M> {
const { close, finished } = Modal.createDialog(OuterDialog<M, P>, {
title: initialOptions.title,
Dialog,
props,
});
return {
finished: finished.then(([ok, model]) => ({
ok: ok ?? false,
model: model ?? null,
})),
close: () => close(false, null),
};
}
+55
View File
@@ -0,0 +1,55 @@
/*
Copyright 2025 New Vector 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 { useState } from "react";
import { type SpacePanelItemProps, type ExtrasApi } from "@element-hq/element-web-module-api";
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import { useTypedEventEmitter } from "../hooks/useEventEmitter";
export interface ModuleSpacePanelItem extends SpacePanelItemProps {
spaceKey: string;
}
enum ExtrasApiEvent {
SpacePanelItemsChanged = "SpacePanelItemsChanged",
}
interface EmittedEvents {
[ExtrasApiEvent.SpacePanelItemsChanged]: () => void;
}
export class ElementWebExtrasApi extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents> implements ExtrasApi {
public spacePanelItems = new Map<string, SpacePanelItemProps>();
public visibleRoomBySpaceKey = new Map<string, () => string[]>();
public setSpacePanelItem(spacekey: string, item: SpacePanelItemProps): void {
this.spacePanelItems.set(spacekey, item);
this.emit(ExtrasApiEvent.SpacePanelItemsChanged);
}
public getVisibleRoomBySpaceKey(spaceKey: string, cb: () => string[]): void {
this.visibleRoomBySpaceKey.set(spaceKey, cb);
}
}
export function useModuleSpacePanelItems(api: ElementWebExtrasApi): ModuleSpacePanelItem[] {
const getItems = (): ModuleSpacePanelItem[] => {
return Array.from(api.spacePanelItems.entries()).map(([spaceKey, item]) => ({
spaceKey,
...item,
}));
};
const [items, setItems] = useState<ModuleSpacePanelItem[]>(getItems);
useTypedEventEmitter(api, ExtrasApiEvent.SpacePanelItemsChanged, () => {
setItems(getItems());
});
return items;
}
+32
View File
@@ -0,0 +1,32 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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 { TextInputField } from "@matrix-org/react-sdk-module-api/lib/components/TextInputField";
import { Spinner as ModuleSpinner } from "@matrix-org/react-sdk-module-api/lib/components/Spinner";
import React, { type ChangeEvent } from "react";
import Field from "../components/views/elements/Field";
import Spinner from "../components/views/elements/Spinner";
// Here we define all the render factories for the module API components. This file should be
// imported by the ModuleRunner to load them into the call stack at runtime.
//
// If a new component is added to the module API, it should be added here too.
//
// Don't forget to add a test to ensure the renderFactory is overridden! See ModuleComponents-test.tsx
TextInputField.renderFactory = (props) => (
<Field
type="text"
value={props.value}
onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
label={props.label}
autoComplete="off"
/>
);
ModuleSpinner.renderFactory = () => <Spinner />;
+12
View File
@@ -0,0 +1,12 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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 RuntimeModule } from "@matrix-org/react-sdk-module-api/lib/RuntimeModule";
import { type ModuleApi } from "@matrix-org/react-sdk-module-api/lib/ModuleApi";
export type ModuleFactory = (api: ModuleApi) => RuntimeModule;
+185
View File
@@ -0,0 +1,185 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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 { safeSet } from "matrix-js-sdk/src/utils";
import { type TranslationStringsObject } from "@matrix-org/react-sdk-module-api/lib/types/translations";
import { type AnyLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/types";
import {
DefaultCryptoSetupExtensions,
type ProvideCryptoSetupExtensions,
} from "@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions";
import {
DefaultExperimentalExtensions,
type ProvideExperimentalExtensions,
} from "@matrix-org/react-sdk-module-api/lib/lifecycles/ExperimentalExtensions";
import { AppModule } from "./AppModule";
import { type ModuleFactory } from "./ModuleFactory";
import "./ModuleComponents";
/**
* Handles and manages extensions provided by modules.
*/
class ExtensionsManager {
// Private backing fields for extensions
private cryptoSetupExtension: ProvideCryptoSetupExtensions;
private experimentalExtension: ProvideExperimentalExtensions;
/** `true` if `cryptoSetupExtension` is the default implementation; `false` if it is implemented by a module. */
private hasDefaultCryptoSetupExtension = true;
/** `true` if `experimentalExtension` is the default implementation; `false` if it is implemented by a module. */
private hasDefaultExperimentalExtension = true;
/**
* Create a new instance.
*/
public constructor() {
// Set up defaults
this.cryptoSetupExtension = new DefaultCryptoSetupExtensions();
this.experimentalExtension = new DefaultExperimentalExtensions();
}
/**
* Provides a crypto setup extension.
*
* @returns The registered extension. If no module provides this extension, a default implementation is returned.
*/
public get cryptoSetup(): ProvideCryptoSetupExtensions {
return this.cryptoSetupExtension;
}
/**
* Provides an experimental extension.
*
* @remarks
* This method extension is provided to simplify experimentation and development, and is not intended for production code.
*
* @returns The registered extension. If no module provides this extension, a default implementation is returned.
*/
public get experimental(): ProvideExperimentalExtensions {
return this.experimentalExtension;
}
/**
* Add any extensions provided by the module.
*
* @param module - The appModule to check for extensions.
*
* @throws if an extension is provided by more than one module.
*/
public addExtensions(module: AppModule): void {
const runtimeModule = module.module;
/* Add the cryptoSetup extension if any */
if (runtimeModule.extensions?.cryptoSetup) {
if (this.hasDefaultCryptoSetupExtension) {
this.cryptoSetupExtension = runtimeModule.extensions?.cryptoSetup;
this.hasDefaultCryptoSetupExtension = false;
} else {
throw new Error(
`adding cryptoSetup extension implementation from module ${runtimeModule.moduleName} but an implementation was already provided.`,
);
}
}
/* Add the experimental extension if any */
if (runtimeModule.extensions?.experimental) {
if (this.hasDefaultExperimentalExtension) {
this.experimentalExtension = runtimeModule.extensions?.experimental;
this.hasDefaultExperimentalExtension = false;
} else {
throw new Error(
`adding experimental extension implementation from module ${runtimeModule.moduleName} but an implementation was already provided.`,
);
}
}
}
}
/**
* Handles and coordinates the operation of modules.
*/
export class ModuleRunner {
public static readonly instance = new ModuleRunner();
private extensionsManager = new ExtensionsManager();
private modules: AppModule[] = [];
private constructor() {
// we only want one instance
}
/**
* Exposes all extensions which may be overridden/provided by modules.
*
* @returns An `ExtensionsManager` which exposes the extensions.
*/
public get extensions(): ExtensionsManager {
return this.extensionsManager;
}
/**
* Resets the runner, clearing all known modules, and all extensions
*
* Intended for test usage only.
*/
public reset(): void {
this.modules = [];
this.extensionsManager = new ExtensionsManager();
}
/**
* All custom translations from all registered modules.
*/
public get allTranslations(): TranslationStringsObject {
const merged: TranslationStringsObject = {};
for (const module of this.modules) {
const i18n = module.api.translations;
if (!i18n) continue;
for (const [lang, strings] of Object.entries(i18n)) {
safeSet(merged, lang, merged[lang] || {});
for (const [str, val] of Object.entries(strings)) {
safeSet(merged[lang], str, val);
}
}
}
return merged;
}
/**
* Registers a factory which creates a module for later loading. The factory
* will be called immediately.
* @param factory The module factory.
*/
public registerModule(factory: ModuleFactory): void {
const appModule = new AppModule(factory);
this.modules.push(appModule);
// Check if the new module provides any extensions, and also ensure a given extension is only provided by a single runtime module.
this.extensionsManager.addExtensions(appModule);
}
/**
* Invokes a lifecycle event, notifying registered modules.
* @param lifecycleEvent The lifecycle event.
* @param args The arguments for the lifecycle event.
*/
public invoke(lifecycleEvent: AnyLifecycle, ...args: any[]): void {
for (const module of this.modules) {
module.module.emit(lifecycleEvent, ...args);
}
}
}
+48
View File
@@ -0,0 +1,48 @@
/*
Copyright 2025 New Vector 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 {
LocationRenderFunction,
NavigationApi as INavigationApi,
OpenRoomOptions,
} from "@element-hq/element-web-module-api";
import { navigateToPermalink } from "../utils/permalinks/navigator.ts";
import { parsePermalink } from "../utils/permalinks/Permalinks.ts";
import dispatcher from "../dispatcher/dispatcher.ts";
import { Action } from "../dispatcher/actions.ts";
import type { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload.ts";
export class NavigationApi implements INavigationApi {
public locationRenderers = new Map<string, LocationRenderFunction>();
public async toMatrixToLink(link: string, join = false): Promise<void> {
navigateToPermalink(link);
const parts = parsePermalink(link);
if (parts?.roomIdOrAlias) {
this.openRoom(parts.roomIdOrAlias, {
viaServers: parts.viaServers ?? undefined,
autoJoin: join,
});
}
}
public registerLocationRenderer(path: string, renderer: LocationRenderFunction): void {
this.locationRenderers.set(path, renderer);
}
public openRoom(roomIdOrAlias: string, opts: OpenRoomOptions = {}): void {
const key = roomIdOrAlias.startsWith("#") ? "room_alias" : "room_id";
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
[key]: roomIdOrAlias,
via_servers: opts.viaServers,
auto_join: opts.autoJoin,
metricsTrigger: undefined,
});
}
}
+32
View File
@@ -0,0 +1,32 @@
/*
Copyright 2025 New Vector 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 Profile, Watchable } from "@element-hq/element-web-module-api";
import { OwnProfileStore } from "../stores/OwnProfileStore.ts";
import { UPDATE_EVENT } from "../stores/AsyncStore.ts";
export class WatchableProfile extends Watchable<Profile> {
public constructor() {
super({});
this.value = this.profile;
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileChange);
}
private get profile(): Profile {
return {
isGuest: OwnProfileStore.instance.matrixClient?.isGuest() ?? false,
userId: OwnProfileStore.instance.matrixClient?.getUserId() ?? undefined,
displayName: OwnProfileStore.instance.displayName ?? undefined,
};
}
private readonly onProfileChange = (): void => {
this.value = this.profile;
};
}
+248
View File
@@ -0,0 +1,248 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
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 ModuleApi } from "@matrix-org/react-sdk-module-api/lib/ModuleApi";
import {
type TranslationStringsObject,
type PlainSubstitution,
} from "@matrix-org/react-sdk-module-api/lib/types/translations";
import { type DialogContent, type DialogProps } from "@matrix-org/react-sdk-module-api/lib/components/DialogContent";
import { type AccountAuthInfo } from "@matrix-org/react-sdk-module-api/lib/types/AccountAuthInfo";
import * as Matrix from "matrix-js-sdk/src/matrix";
import { type IRegisterRequestParams } from "matrix-js-sdk/src/matrix";
import { type ModuleUiDialogOptions } from "@matrix-org/react-sdk-module-api/lib/types/ModuleUiDialogOptions";
import type React from "react";
import Modal from "../Modal";
import { _t } from "../languageHandler";
import { ModuleUiDialog } from "../components/views/dialogs/ModuleUiDialog";
import SdkConfig from "../SdkConfig";
import PlatformPeg from "../PlatformPeg";
import dispatcher from "../dispatcher/dispatcher";
import { navigateToPermalink } from "../utils/permalinks/navigator";
import { parsePermalink } from "../utils/permalinks/Permalinks";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { Action } from "../dispatcher/actions";
import { type OverwriteLoginPayload } from "../dispatcher/payloads/OverwriteLoginPayload";
import { type ActionPayload } from "../dispatcher/payloads";
import WidgetStore, { type IApp } from "../stores/WidgetStore";
import { type Container, WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
import type { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload.ts";
/**
* Glue between the `ModuleApi` interface and the react-sdk. Anticipates one instance
* to be assigned to a single module.
*/
export class ProxiedModuleApi implements ModuleApi {
private cachedTranslations?: TranslationStringsObject;
private overrideLoginResolve?: () => void;
public constructor() {
dispatcher.register(this.onAction);
}
private onAction = (payload: ActionPayload): void => {
if (payload.action === Action.OnLoggedIn) {
this.overrideLoginResolve?.();
}
};
/**
* All custom translations used by the associated module.
*/
public get translations(): TranslationStringsObject | undefined {
return this.cachedTranslations;
}
/**
* @override
*/
public registerTranslations(translations: TranslationStringsObject): void {
this.cachedTranslations = translations;
}
/**
* @override
*/
public translateString(s: TranslationKey, variables?: Record<string, PlainSubstitution>): string {
return _t(s, variables);
}
/**
* @override
*/
public openDialog<M extends object, P extends DialogProps, C extends DialogContent<P>>(
initialTitleOrOptions: string | ModuleUiDialogOptions,
body: (props: P, ref: React.RefObject<C | null>) => React.ReactNode,
props?: Omit<P, keyof DialogProps>,
): Promise<{ didOkOrSubmit: boolean; model: M }> {
const initialOptions: ModuleUiDialogOptions =
typeof initialTitleOrOptions === "string" ? { title: initialTitleOrOptions } : initialTitleOrOptions;
return new Promise<{ didOkOrSubmit: boolean; model: M }>((resolve) => {
Modal.createDialog(
ModuleUiDialog<P, C>,
{
initialOptions,
contentFactory: body,
moduleApi: this,
additionalContentProps: props,
},
"mx_CompoundDialog",
).finished.then(([didOkOrSubmit, model]) => {
resolve({ didOkOrSubmit: !!didOkOrSubmit, model: model as M });
});
});
}
/**
* @override
*/
public async registerSimpleAccount(
username: string,
password: string,
displayName?: string,
): Promise<AccountAuthInfo> {
const hsUrl = SdkConfig.get("validated_server_config")?.hsUrl;
if (!hsUrl) throw new Error("Could not get homeserver url");
const client = Matrix.createClient({ baseUrl: hsUrl });
const deviceName =
SdkConfig.get("default_device_display_name") || PlatformPeg.get()?.getDefaultDeviceDisplayName();
const req: IRegisterRequestParams = {
username,
password,
initial_device_display_name: deviceName,
auth: undefined,
inhibit_login: false,
};
const creds = await client.registerRequest(req).catch((resp) =>
client.registerRequest({
...req,
auth: {
session: resp.data.session,
type: "m.login.dummy",
},
}),
);
if (displayName) {
const profileClient = Matrix.createClient({
baseUrl: hsUrl,
userId: creds.user_id,
deviceId: creds.device_id,
accessToken: creds.access_token,
});
await profileClient.setDisplayName(displayName);
}
return {
homeserverUrl: hsUrl,
userId: creds.user_id!,
deviceId: creds.device_id!,
accessToken: creds.access_token!,
};
}
/**
* @override
*/
public async overwriteAccountAuth(accountInfo: AccountAuthInfo): Promise<void> {
// We want to wait for the new login to complete before returning.
// See `Action.OnLoggedIn` in dispatcher.
const awaitNewLogin = new Promise<void>((resolve) => {
this.overrideLoginResolve = resolve;
});
dispatcher.dispatch<OverwriteLoginPayload>(
{
action: Action.OverwriteLogin,
credentials: {
...accountInfo,
guest: false,
},
},
true,
); // require to be sync to match inherited interface behaviour
// wait for login to complete
await awaitNewLogin;
}
/**
* @override
*/
public async navigatePermalink(uri: string, andJoin?: boolean): Promise<void> {
navigateToPermalink(uri);
const parts = parsePermalink(uri);
if (parts?.roomIdOrAlias) {
if (parts.roomIdOrAlias.startsWith("#")) {
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_alias: parts.roomIdOrAlias,
via_servers: parts.viaServers ?? undefined,
auto_join: andJoin ?? false,
metricsTrigger: undefined,
});
} else {
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: parts.roomIdOrAlias,
via_servers: parts.viaServers ?? undefined,
auto_join: andJoin ?? false,
metricsTrigger: undefined,
});
}
}
}
/**
* @override
*/
public getConfigValue<T>(namespace: string, key: string): T | undefined {
// Force cast to `any` because the namespace won't be known to the SdkConfig types
const maybeObj = SdkConfig.get(namespace as any);
if (!maybeObj || !(typeof maybeObj === "object")) return undefined;
return maybeObj[key];
}
/**
* @override
*/
public getApps(roomId: string): IApp[] {
return WidgetStore.instance.getApps(roomId);
}
/**
* @override
*/
public getAppAvatarUrl(app: IApp, width?: number, height?: number, resizeMethod?: string): string | null {
if (!app.avatar_url) return null;
// eslint-disable-next-line no-restricted-properties
return MatrixClientPeg.safeGet().mxcUrlToHttp(app.avatar_url, width, height, resizeMethod);
}
/**
* @override
*/
public isAppInContainer(app: IApp, container: Container, roomId: string): boolean {
const room = MatrixClientPeg.safeGet().getRoom(roomId);
if (!room) return false;
return WidgetLayoutStore.instance.isInContainer(room, app, container);
}
/**
* @override
*/
public moveAppToContainer(app: IApp, container: Container, roomId: string): void {
const room = MatrixClientPeg.safeGet().getRoom(roomId);
if (!room) return;
WidgetLayoutStore.instance.moveToContainer(room, app, container);
}
}
+106
View File
@@ -0,0 +1,106 @@
/*
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 StoresApi as IStoresApi,
type RoomListStoreApi as IRoomListStore,
type Room,
Watchable,
} from "@element-hq/element-web-module-api";
import type { RoomListStoreV3Class, RoomListStoreV3Event } from "../stores/room-list-v3/RoomListStoreV3";
import { Room as ModuleRoom } from "./models/Room";
interface RlsEvents {
LISTS_LOADED_EVENT: RoomListStoreV3Event.ListsLoaded;
LISTS_UPDATE_EVENT: RoomListStoreV3Event.ListsUpdate;
}
export class RoomListStoreApi implements IRoomListStore {
private rls?: RoomListStoreV3Class;
private LISTS_LOADED_EVENT?: RoomListStoreV3Event.ListsLoaded;
private LISTS_UPDATE_EVENT?: RoomListStoreV3Event.ListsUpdate;
public readonly moduleLoadPromise: Promise<void>;
public constructor() {
this.moduleLoadPromise = this.init();
}
/**
* Load the RLS through a dynamic import. This is necessary to prevent
* circular dependency issues.
*/
private async init(): Promise<void> {
const module = await import("../stores/room-list-v3/RoomListStoreV3");
this.rls = module.default.instance;
this.LISTS_LOADED_EVENT = module.LISTS_LOADED_EVENT;
this.LISTS_UPDATE_EVENT = module.LISTS_UPDATE_EVENT;
}
public getRooms(): RoomsWatchable {
return new RoomsWatchable(this.roomListStore, this.events);
}
private get events(): RlsEvents {
if (!this.LISTS_LOADED_EVENT || !this.LISTS_UPDATE_EVENT) {
throw new Error("Event type was not loaded correctly, did you forget to await waitForReady()?");
}
return { LISTS_LOADED_EVENT: this.LISTS_LOADED_EVENT, LISTS_UPDATE_EVENT: this.LISTS_UPDATE_EVENT };
}
private get roomListStore(): RoomListStoreV3Class {
if (!this.rls) {
throw new Error("rls is undefined, did you forget to await waitForReady()?");
}
return this.rls;
}
public async waitForReady(): Promise<void> {
// Wait for the module to load first
await this.moduleLoadPromise;
// Check if RLS is already loaded
if (!this.roomListStore.isLoadingRooms) return;
// Await a promise that resolves when RLS has loaded
const { promise, resolve } = Promise.withResolvers<void>();
const { LISTS_LOADED_EVENT } = this.events;
this.roomListStore.once(LISTS_LOADED_EVENT, resolve);
await promise;
}
}
class RoomsWatchable extends Watchable<Room[]> {
public constructor(
private readonly rls: RoomListStoreV3Class,
private readonly events: RlsEvents,
) {
super(rls.getSortedRooms().map((sdkRoom) => new ModuleRoom(sdkRoom)));
}
private onRlsUpdate = (): void => {
this.value = this.rls.getSortedRooms().map((sdkRoom) => new ModuleRoom(sdkRoom));
};
protected onFirstWatch(): void {
this.rls.on(this.events.LISTS_UPDATE_EVENT, this.onRlsUpdate);
}
protected onLastWatch(): void {
this.rls.off(this.events.LISTS_UPDATE_EVENT, this.onRlsUpdate);
}
}
export class StoresApi implements IStoresApi {
private roomListStoreApi?: IRoomListStore;
public get roomListStore(): IRoomListStore {
if (!this.roomListStoreApi) {
this.roomListStoreApi = new RoomListStoreApi();
}
return this.roomListStoreApi;
}
}
@@ -0,0 +1,29 @@
/*
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 React, { useMemo } from "react";
import type { Room } from "matrix-js-sdk/src/matrix";
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
import { useCall } from "../../hooks/useCall";
import { NotificationDecoration } from "../../components/views/rooms/NotificationDecoration";
export interface ModuleNotificationDecorationProps {
/**
* The room for which the decoration is rendered.
*/
room: Room;
}
/**
* React component that takes a room as prop and renders {@link NotificationDecoration} with it.
* Used by the module API to render notification decoration without having to expose a bunch of stores.
*/
export const ModuleNotificationDecoration: React.FC<ModuleNotificationDecorationProps> = ({ room }) => {
const notificationState = useMemo(() => RoomNotificationStateStore.instance.getRoomState(room), [room]);
const call = useCall(room.roomId);
return <NotificationDecoration notificationState={notificationState} callType={call?.callType} />;
};
+156
View File
@@ -0,0 +1,156 @@
/*
Copyright 2025 New Vector 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 MatrixEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import type {
CustomComponentsApi as ICustomComponentsApi,
CustomMessageRenderFunction,
CustomMessageComponentProps as ModuleCustomMessageComponentProps,
OriginalMessageComponentProps,
CustomMessageRenderHints as ModuleCustomCustomMessageRenderHints,
MatrixEvent as ModuleMatrixEvent,
CustomRoomPreviewBarRenderFunction,
} from "@element-hq/element-web-module-api";
import type React from "react";
type EventTypeOrFilter = Parameters<ICustomComponentsApi["registerMessageRenderer"]>[0];
type EventRenderer = {
eventTypeOrFilter: EventTypeOrFilter;
renderer: CustomMessageRenderFunction;
hints: ModuleCustomCustomMessageRenderHints;
};
interface CustomMessageComponentProps extends Omit<ModuleCustomMessageComponentProps, "mxEvent"> {
mxEvent: MatrixEvent;
}
interface CustomMessageRenderHints extends Omit<ModuleCustomCustomMessageRenderHints, "allowDownloadingMedia"> {
// Note. This just makes it easier to use this API on Element Web as we already have the moduleized event stored.
allowDownloadingMedia?: () => Promise<boolean>;
}
export class CustomComponentsApi implements ICustomComponentsApi {
/**
* Convert a matrix-js-sdk event into a ModuleMatrixEvent.
* @param mxEvent
* @returns An event object, or `null` if the event was not a message event.
*/
private static getModuleMatrixEvent(mxEvent: MatrixEvent): ModuleMatrixEvent | null {
const eventId = mxEvent.getId();
const roomId = mxEvent.getRoomId();
const sender = mxEvent.sender;
// Typically we wouldn't expect messages without these keys to be rendered
// by the timeline, but for the sake of type safety.
if (!eventId || !roomId || !sender) {
// Not a message event.
return null;
}
return {
content: mxEvent.getContent(),
eventId,
originServerTs: mxEvent.getTs(),
roomId,
sender: sender.userId,
stateKey: mxEvent.getStateKey(),
type: mxEvent.getType(),
unsigned: mxEvent.getUnsigned(),
};
}
private readonly registeredMessageRenderers: EventRenderer[] = [];
public registerMessageRenderer(
eventTypeOrFilter: EventTypeOrFilter,
renderer: CustomMessageRenderFunction,
hints: ModuleCustomCustomMessageRenderHints = {},
): void {
this.registeredMessageRenderers.push({ eventTypeOrFilter: eventTypeOrFilter, renderer, hints });
}
/**
* Select the correct renderer based on the event information.
* @param mxEvent The message event being rendered.
* @returns The registered renderer.
*/
private selectRenderer(mxEvent: ModuleMatrixEvent): EventRenderer | undefined {
return this.registeredMessageRenderers.find((renderer) => {
if (typeof renderer.eventTypeOrFilter === "string") {
return renderer.eventTypeOrFilter === mxEvent.type;
} else {
try {
return renderer.eventTypeOrFilter(mxEvent);
} catch (ex) {
logger.warn("Message renderer failed to process filter", ex);
return false; // Skip erroring renderers.
}
}
});
}
/**
* Render the component for a message event.
* @param props Props to be passed to the custom renderer.
* @param originalComponent Function that will be rendered if no custom renderers are present, or as a child of a custom component.
* @returns A component if a custom renderer exists, or originalComponent returns a value. Otherwise null.
*/
public renderMessage(
props: CustomMessageComponentProps,
originalComponent?: (props?: OriginalMessageComponentProps) => React.JSX.Element,
): React.JSX.Element | null {
const moduleEv = CustomComponentsApi.getModuleMatrixEvent(props.mxEvent);
const renderer = moduleEv && this.selectRenderer(moduleEv);
if (renderer) {
try {
return renderer.renderer({ ...props, mxEvent: moduleEv }, originalComponent);
} catch (ex) {
logger.warn("Message renderer failed to render", ex);
// Fall through to original component. If the module encounters an error we still want to display messages to the user!
}
}
return originalComponent?.() ?? null;
}
/**
* Get hints about an message before rendering it.
* @param mxEvent The message event being rendered.
* @returns A component if a custom renderer exists, or originalComponent returns a value. Otherwise null.
*/
public getHintsForMessage(mxEvent: MatrixEvent): CustomMessageRenderHints | null {
const moduleEv = CustomComponentsApi.getModuleMatrixEvent(mxEvent);
const renderer = moduleEv && this.selectRenderer(moduleEv);
if (renderer) {
return {
...renderer.hints,
// Convert from js-sdk style events to module events automatically.
allowDownloadingMedia: renderer.hints.allowDownloadingMedia
? () => renderer.hints.allowDownloadingMedia!(moduleEv)
: undefined,
};
}
return null;
}
private _roomPreviewBarRenderer?: CustomRoomPreviewBarRenderFunction;
/**
* Get the custom room preview bar renderer, if any has been registered.
*/
public get roomPreviewBarRenderer(): CustomRoomPreviewBarRenderFunction | undefined {
return this._roomPreviewBarRenderer;
}
/**
* Register a custom room preview bar renderer.
* @param renderer - the function that will render the custom room preview bar.
*/
public registerRoomPreviewBar(renderer: CustomRoomPreviewBarRenderFunction): void {
this._roomPreviewBarRenderer = renderer;
}
}
+45
View File
@@ -0,0 +1,45 @@
/*
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 Room as IRoom, 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 {
public name: Watchable<string>;
public constructor(private sdkRoom: SdkRoom) {
this.name = new WatchableName(sdkRoom);
}
public getLastActiveTimestamp(): number {
return this.sdkRoom.getLastActiveTimestamp();
}
public get id(): string {
return this.sdkRoom.roomId;
}
}
/**
* A custom watchable for room name.
*/
class WatchableName extends Watchable<string> {
public constructor(private sdkRoom: SdkRoom) {
super(sdkRoom.name);
}
private onNameUpdate = (): void => {
super.value = this.sdkRoom.name;
};
protected onFirstWatch(): void {
this.sdkRoom.on(RoomEvent.Name, this.onNameUpdate);
}
protected onLastWatch(): void {
this.sdkRoom.off(RoomEvent.Name, this.onNameUpdate);
}
}