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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user