Implement customisations & login component Module API 1.11.0 (#32687)

* Update to Module API v1.11.0

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Remove stale state field to make CQL happy

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Bump npm dep

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Improve coverage

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Improve coverage

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update comment

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski
2026-03-10 13:30:48 +00:00
committed by GitHub
parent 78b40a6fed
commit 35afc2fdf8
10 changed files with 159 additions and 63 deletions
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type ReactNode } from "react";
import React, { type JSX, memo, type ReactNode } from "react";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { type SSOFlow, SSOAction } from "matrix-js-sdk/src/matrix";
@@ -32,6 +32,7 @@ import AccessibleButton, { type ButtonEvent } from "../../views/elements/Accessi
import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
import { filterBoolean } from "../../../utils/arrays";
import { startOidcLogin } from "../../../utils/oidc/authorize";
import { ModuleApi } from "../../../modules/Api.ts";
interface IProps {
serverConfig: ValidatedServerConfig;
@@ -45,13 +46,15 @@ interface IProps {
defaultDeviceDisplayName?: string;
fragmentAfterLogin?: string;
defaultUsername?: string;
// Any additional content to show, will be rendered between main actions & footer actions
children?: ReactNode;
// Called when the user has logged in. Params:
// - The object returned by the login API
onLoggedIn(data: IMatrixClientCreds): void;
// login shouldn't know or care how registration, password recovery, etc is done.
onRegisterClick(): void;
onRegisterClick?(): void;
onForgotPasswordClick?(): void;
onServerConfigChange(config: ValidatedServerConfig): void;
}
@@ -61,8 +64,6 @@ interface IState {
busyLoggingIn?: boolean;
errorText?: ReactNode;
loginIncorrect: boolean;
// can we attempt to log in or are there validation errors?
canTryLogin: boolean;
flows?: ClientLoginFlow[];
@@ -88,7 +89,7 @@ type OnPasswordLogin = {
/*
* A wire component which glues together login UI components and Login logic
*/
export default class LoginComponent extends React.PureComponent<IProps, IState> {
class LoginComponent extends React.PureComponent<IProps, IState> {
private unmounted = false;
private loginLogic!: Login;
@@ -101,7 +102,6 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
busy: false,
errorText: null,
loginIncorrect: false,
canTryLogin: true,
username: props.defaultUsername ? props.defaultUsername : "",
phoneCountry: "",
@@ -229,7 +229,6 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
username: username,
busy: doWellknownLookup,
errorText: null,
canTryLogin: true,
});
if (doWellknownLookup) {
const serverName = username.split(":").slice(1).join(":");
@@ -281,7 +280,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
public onRegisterClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
this.props.onRegisterClick();
this.props.onRegisterClick?.();
};
public onTryRegisterClick = (ev: ButtonEvent): void => {
@@ -379,7 +378,6 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
this.setState({
errorText: messageForConnectionError(err, this.props.serverConfig),
loginIncorrect: false,
canTryLogin: false,
});
},
)
@@ -512,7 +510,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
)}
</div>
);
} else if (SettingsStore.getValue(UIFeature.Registration)) {
} else if (this.props.onRegisterClick && SettingsStore.getValue(UIFeature.Registration)) {
footer = (
<span className="mx_AuthBody_changeFlow">
{_t(
@@ -546,9 +544,21 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
disabled={this.isBusy()}
/>
{this.renderLoginComponentForFlows()}
{this.props.children}
{footer}
</AuthBody>
</AuthPage>
);
}
}
const WrappedLoginComponent = memo((props: IProps): JSX.Element => {
const moduleRenderer = ModuleApi.instance.customComponents.loginComponentRenderer;
if (moduleRenderer) {
return moduleRenderer(props, (props) => <LoginComponent {...props} />);
}
return <LoginComponent {...props} />;
});
export default WrappedLoginComponent;
@@ -8,7 +8,12 @@ Please see LICENSE files in the repository root for full details.
import { type UIComponent } from "../../settings/UIFeature";
import { ComponentVisibilityCustomisations } from "../ComponentVisibility";
import { ModuleApi } from "../../modules/Api.ts";
export function shouldShowComponent(component: UIComponent): boolean {
return ComponentVisibilityCustomisations.shouldShowComponent?.(component) ?? true;
return (
ModuleApi.instance.customisations.shouldShowComponent(component) ??
ComponentVisibilityCustomisations.shouldShowComponent?.(component) ??
true
);
}
+2
View File
@@ -31,6 +31,7 @@ import { ElementWebBuiltinsApi } from "./BuiltinsApi.tsx";
import { ClientApi } from "./ClientApi.ts";
import { StoresApi } from "./StoresApi.ts";
import { WidgetLifecycleApi } from "./WidgetLifecycleApi.ts";
import { CustomisationsApi } from "./customisationsApi.ts";
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
let used = false;
@@ -84,6 +85,7 @@ export class ModuleApi implements Api {
public readonly config = new ConfigApi();
public readonly i18n = new I18nApi();
public readonly customComponents = new CustomComponentsApi();
public readonly customisations = new CustomisationsApi();
public readonly extras = new ElementWebExtrasApi();
public readonly builtins = new ElementWebBuiltinsApi();
public readonly widgetLifecycle = new WidgetLifecycleApi();
@@ -16,6 +16,7 @@ import type {
CustomMessageRenderHints as ModuleCustomCustomMessageRenderHints,
MatrixEvent as ModuleMatrixEvent,
CustomRoomPreviewBarRenderFunction,
CustomLoginRenderFunction,
} from "@element-hq/element-web-module-api";
import type React from "react";
@@ -153,4 +154,21 @@ export class CustomComponentsApi implements ICustomComponentsApi {
public registerRoomPreviewBar(renderer: CustomRoomPreviewBarRenderFunction): void {
this._roomPreviewBarRenderer = renderer;
}
private _loginRenderer?: CustomLoginRenderFunction;
/**
* Get the custom login component renderer, if any has been registered.
*/
public get loginComponentRenderer(): CustomLoginRenderFunction | undefined {
return this._loginRenderer;
}
/**
* Register a custom login component renderer.
* @param renderer - the function that will render the login component.
*/
public registerLoginComponent(renderer: CustomLoginRenderFunction): void {
this._loginRenderer = renderer;
}
}
+35
View File
@@ -0,0 +1,35 @@
/*
Copyright 2026 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 { UIComponent, CustomisationsApi as ICustomisationsApi } from "@element-hq/element-web-module-api";
export class CustomisationsApi implements ICustomisationsApi {
private shouldShowComponentFunctions = new Set<(component: UIComponent) => boolean | void>();
/**
* Method to register a callback which can affect whether a given component is drawn or not.
* @param fn - the callback, if it returns true the component will be rendered, if false it will not be.
* If undefined will defer to the next callback, ultimately falling through to `true` if none return false.
* The next callback is decided in FIFO call order to this register function.
*/
public registerShouldShowComponent(fn: (this: void, component: UIComponent) => boolean | void): void {
this.shouldShowComponentFunctions.add(fn);
}
/**
* Method to check whether, according to any registered modules, a given component should be rendered.
* @param component - the component to check
*/
public shouldShowComponent(component: UIComponent): boolean | void {
for (const fn of this.shouldShowComponentFunctions) {
const v = fn(component);
if (typeof v === "boolean") {
return v;
}
}
}
}
+1 -41
View File
@@ -28,44 +28,4 @@ export const enum UIFeature {
AllowCreatingPublicSpaces = "UIFeature.allowCreatingPublicSpaces",
}
export enum UIComponent {
/**
* Components that lead to a user being invited.
*/
InviteUsers = "UIComponent.sendInvites",
/**
* Components that lead to a room being created that aren't already
* guarded by some other condition (ie: "only if you can edit this
* space" is *not* guarded by this component, but "start DM" is).
*/
CreateRooms = "UIComponent.roomCreation",
/**
* Components that lead to a Space being created that aren't already
* guarded by some other condition (ie: "only if you can add subspaces"
* is *not* guarded by this component, but "create new space" is).
*/
CreateSpaces = "UIComponent.spaceCreation",
/**
* Components that lead to the public room directory.
*/
ExploreRooms = "UIComponent.exploreRooms",
/**
* Components that lead to the user being able to easily add widgets
* and integrations to the room, such as from the room information card.
*/
AddIntegrations = "UIComponent.addIntegrations",
/**
* Component that lead to the user being able to search, dial, explore rooms
*/
FilterContainer = "UIComponent.filterContainer",
/**
* Components that lead the user to room options menu.
*/
RoomOptionsMenu = "UIComponent.roomOptionsMenu",
}
export { UIComponent } from "@element-hq/element-web-module-api";
@@ -20,6 +20,7 @@ import Login from "../../../../../src/components/structures/auth/Login";
import type BasePlatform from "../../../../../src/BasePlatform";
import * as registerClientUtils from "../../../../../src/utils/oidc/registerClient";
import { makeDelegatedAuthConfig } from "../../../../test-utils/oidc";
import { ModuleApi } from "../../../../../src/modules/Api.ts";
jest.useRealTimers();
@@ -100,6 +101,35 @@ describe("Login", function () {
expect(container.querySelector(".mx_ServerPicker_change")).toBeTruthy();
});
it("should show register button", async () => {
const onRegisterClick = jest.fn();
const { getByText } = render(
<Login
serverConfig={mkServerConfig("https://matrix.org", "https://vector.im")}
onLoggedIn={() => {}}
onRegisterClick={onRegisterClick}
onServerConfigChange={() => {}}
/>,
);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
fireEvent.click(getByText("Create an account"));
expect(onRegisterClick).toHaveBeenCalled();
});
it("should hide register button", async () => {
const { queryByText } = render(
<Login
serverConfig={mkServerConfig("https://matrix.org", "https://vector.im")}
onLoggedIn={() => {}}
onServerConfigChange={() => {}}
/>,
);
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
expect(queryByText("Create an account")).not.toBeInTheDocument();
});
it("should show form without change server link when custom URLs disabled", async () => {
const { container } = getComponent();
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
@@ -417,4 +447,16 @@ describe("Login", function () {
expect(screen.getByText("Continue")).toBeInTheDocument();
});
});
describe("Module API", () => {
afterEach(() => {
ModuleApi.instance.customComponents.registerLoginComponent(undefined as any);
});
it("should use registered module renderer", async () => {
ModuleApi.instance.customComponents.registerLoginComponent(() => <>Test component</>);
const { getByText } = getComponent();
expect(getByText("Test component")).toBeTruthy();
});
});
});
@@ -0,0 +1,24 @@
/*
Copyright 2026 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 { CustomisationsApi } from "../../../src/modules/customisationsApi";
import { UIComponent } from "../../../src/settings/UIFeature.ts";
describe("CustomisationsApi", () => {
let api: CustomisationsApi;
beforeEach(() => {
api = new CustomisationsApi();
});
it("should register a shouldShowComponent callback", () => {
const shouldShowComponent = jest.fn().mockReturnValue(true);
api.registerShouldShowComponent(shouldShowComponent);
expect(api.shouldShowComponent(UIComponent.CreateRooms)).toBe(true);
expect(shouldShowComponent).toHaveBeenCalledWith("UIComponent.roomCreation");
});
});
+10 -10
View File
@@ -7,8 +7,8 @@ settings:
catalogs:
default:
'@element-hq/element-web-module-api':
specifier: 1.10.0
version: 1.10.0
specifier: 1.11.0
version: 1.11.0
'@element-hq/element-web-playwright-common':
specifier: 2.2.7
version: 2.2.7
@@ -149,7 +149,7 @@ importers:
version: 7.28.6
'@element-hq/element-web-module-api':
specifier: 'catalog:'
version: 1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
version: 1.11.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
'@element-hq/web-shared-components':
specifier: workspace:*
version: link:../../packages/shared-components
@@ -423,7 +423,7 @@ importers:
version: 0.16.3
'@element-hq/element-web-playwright-common':
specifier: 'catalog:'
version: 2.2.7(@element-hq/element-web-module-api@1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2)
version: 2.2.7(@element-hq/element-web-module-api@1.11.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2)
'@element-hq/element-web-playwright-common-local':
specifier: workspace:*
version: link:../../packages/playwright-common
@@ -762,7 +762,7 @@ importers:
dependencies:
'@element-hq/element-web-module-api':
specifier: 'catalog:'
version: 1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
version: 1.11.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
'@matrix-org/spec':
specifier: ^1.7.0
version: 1.16.0
@@ -2016,8 +2016,8 @@ packages:
'@element-hq/element-call-embedded@0.16.3':
resolution: {integrity: sha512-OViKJonDaDNVBUW9WdV9mk78/Ruh34C7XsEgt3O8D9z+64C39elbIgllHSoH5S12IRlv9RYrrV37FZLo6QWsDQ==}
'@element-hq/element-web-module-api@1.10.0':
resolution: {integrity: sha512-XIl6E73dn0cmR/03TRCpq7epyFQAa93GUz1j7EBP2pv5Erh59gq788ajFM2XYl7W2afRU0aasvJoI3iZIHbRig==}
'@element-hq/element-web-module-api@1.11.0':
resolution: {integrity: sha512-RBkvt+Z32CGLkiPtYcQTryQBjG01ZrSVV98CS1cPz/kTeBtxEbVBpqDoOLdGvpmVe0dWo4DLaFcldw2iK39TPA==}
engines: {node: '>=20.0.0'}
peerDependencies:
'@matrix-org/react-sdk-module-api': '*'
@@ -11980,7 +11980,7 @@ snapshots:
'@element-hq/element-call-embedded@0.16.3': {}
'@element-hq/element-web-module-api@1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)':
'@element-hq/element-web-module-api@1.11.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)':
dependencies:
'@types/react': 19.2.10
'@types/react-dom': 19.2.3(@types/react@19.2.10)
@@ -11989,10 +11989,10 @@ snapshots:
'@matrix-org/react-sdk-module-api': 2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4)
matrix-web-i18n: 3.6.0
'@element-hq/element-web-playwright-common@2.2.7(@element-hq/element-web-module-api@1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2)':
'@element-hq/element-web-playwright-common@2.2.7(@element-hq/element-web-module-api@1.11.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2)':
dependencies:
'@axe-core/playwright': 4.11.1(playwright-core@1.58.2)
'@element-hq/element-web-module-api': 1.10.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
'@element-hq/element-web-module-api': 1.11.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
'@playwright/test': 1.58.2
'@testcontainers/postgresql': 11.11.0
glob: 13.0.6
+1 -1
View File
@@ -17,7 +17,7 @@ catalog:
"@playwright/test": 1.58.2
"playwright-core": 1.58.2
# Module API
"@element-hq/element-web-module-api": 1.10.0
"@element-hq/element-web-module-api": 1.11.0
# Compound
"@vector-im/compound-design-tokens": 6.10.1
"@vector-im/compound-web": 8.4.0