Skip to content

Commit 35afc2f

Browse files
authored
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>
1 parent 78b40a6 commit 35afc2f

File tree

10 files changed

+159
-63
lines changed

10 files changed

+159
-63
lines changed

apps/web/src/components/structures/auth/Login.tsx

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
66
Please see LICENSE files in the repository root for full details.
77
*/
88

9-
import React, { type JSX, type ReactNode } from "react";
9+
import React, { type JSX, memo, type ReactNode } from "react";
1010
import classNames from "classnames";
1111
import { logger } from "matrix-js-sdk/src/logger";
1212
import { type SSOFlow, SSOAction } from "matrix-js-sdk/src/matrix";
@@ -32,6 +32,7 @@ import AccessibleButton, { type ButtonEvent } from "../../views/elements/Accessi
3232
import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
3333
import { filterBoolean } from "../../../utils/arrays";
3434
import { startOidcLogin } from "../../../utils/oidc/authorize";
35+
import { ModuleApi } from "../../../modules/Api.ts";
3536

3637
interface IProps {
3738
serverConfig: ValidatedServerConfig;
@@ -45,13 +46,15 @@ interface IProps {
4546
defaultDeviceDisplayName?: string;
4647
fragmentAfterLogin?: string;
4748
defaultUsername?: string;
49+
// Any additional content to show, will be rendered between main actions & footer actions
50+
children?: ReactNode;
4851

4952
// Called when the user has logged in. Params:
5053
// - The object returned by the login API
5154
onLoggedIn(data: IMatrixClientCreds): void;
5255

5356
// login shouldn't know or care how registration, password recovery, etc is done.
54-
onRegisterClick(): void;
57+
onRegisterClick?(): void;
5558
onForgotPasswordClick?(): void;
5659
onServerConfigChange(config: ValidatedServerConfig): void;
5760
}
@@ -61,8 +64,6 @@ interface IState {
6164
busyLoggingIn?: boolean;
6265
errorText?: ReactNode;
6366
loginIncorrect: boolean;
64-
// can we attempt to log in or are there validation errors?
65-
canTryLogin: boolean;
6667

6768
flows?: ClientLoginFlow[];
6869

@@ -88,7 +89,7 @@ type OnPasswordLogin = {
8889
/*
8990
* A wire component which glues together login UI components and Login logic
9091
*/
91-
export default class LoginComponent extends React.PureComponent<IProps, IState> {
92+
class LoginComponent extends React.PureComponent<IProps, IState> {
9293
private unmounted = false;
9394
private loginLogic!: Login;
9495

@@ -101,7 +102,6 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
101102
busy: false,
102103
errorText: null,
103104
loginIncorrect: false,
104-
canTryLogin: true,
105105

106106
username: props.defaultUsername ? props.defaultUsername : "",
107107
phoneCountry: "",
@@ -229,7 +229,6 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
229229
username: username,
230230
busy: doWellknownLookup,
231231
errorText: null,
232-
canTryLogin: true,
233232
});
234233
if (doWellknownLookup) {
235234
const serverName = username.split(":").slice(1).join(":");
@@ -281,7 +280,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
281280
public onRegisterClick = (ev: ButtonEvent): void => {
282281
ev.preventDefault();
283282
ev.stopPropagation();
284-
this.props.onRegisterClick();
283+
this.props.onRegisterClick?.();
285284
};
286285

287286
public onTryRegisterClick = (ev: ButtonEvent): void => {
@@ -379,7 +378,6 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
379378
this.setState({
380379
errorText: messageForConnectionError(err, this.props.serverConfig),
381380
loginIncorrect: false,
382-
canTryLogin: false,
383381
});
384382
},
385383
)
@@ -512,7 +510,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
512510
)}
513511
</div>
514512
);
515-
} else if (SettingsStore.getValue(UIFeature.Registration)) {
513+
} else if (this.props.onRegisterClick && SettingsStore.getValue(UIFeature.Registration)) {
516514
footer = (
517515
<span className="mx_AuthBody_changeFlow">
518516
{_t(
@@ -546,9 +544,21 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
546544
disabled={this.isBusy()}
547545
/>
548546
{this.renderLoginComponentForFlows()}
547+
{this.props.children}
549548
{footer}
550549
</AuthBody>
551550
</AuthPage>
552551
);
553552
}
554553
}
554+
555+
const WrappedLoginComponent = memo((props: IProps): JSX.Element => {
556+
const moduleRenderer = ModuleApi.instance.customComponents.loginComponentRenderer;
557+
if (moduleRenderer) {
558+
return moduleRenderer(props, (props) => <LoginComponent {...props} />);
559+
}
560+
561+
return <LoginComponent {...props} />;
562+
});
563+
564+
export default WrappedLoginComponent;

apps/web/src/customisations/helpers/UIComponents.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ Please see LICENSE files in the repository root for full details.
88

99
import { type UIComponent } from "../../settings/UIFeature";
1010
import { ComponentVisibilityCustomisations } from "../ComponentVisibility";
11+
import { ModuleApi } from "../../modules/Api.ts";
1112

1213
export function shouldShowComponent(component: UIComponent): boolean {
13-
return ComponentVisibilityCustomisations.shouldShowComponent?.(component) ?? true;
14+
return (
15+
ModuleApi.instance.customisations.shouldShowComponent(component) ??
16+
ComponentVisibilityCustomisations.shouldShowComponent?.(component) ??
17+
true
18+
);
1419
}

apps/web/src/modules/Api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { ElementWebBuiltinsApi } from "./BuiltinsApi.tsx";
3131
import { ClientApi } from "./ClientApi.ts";
3232
import { StoresApi } from "./StoresApi.ts";
3333
import { WidgetLifecycleApi } from "./WidgetLifecycleApi.ts";
34+
import { CustomisationsApi } from "./customisationsApi.ts";
3435

3536
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
3637
let used = false;
@@ -84,6 +85,7 @@ export class ModuleApi implements Api {
8485
public readonly config = new ConfigApi();
8586
public readonly i18n = new I18nApi();
8687
public readonly customComponents = new CustomComponentsApi();
88+
public readonly customisations = new CustomisationsApi();
8789
public readonly extras = new ElementWebExtrasApi();
8890
public readonly builtins = new ElementWebBuiltinsApi();
8991
public readonly widgetLifecycle = new WidgetLifecycleApi();

apps/web/src/modules/customComponentApi.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
CustomMessageRenderHints as ModuleCustomCustomMessageRenderHints,
1717
MatrixEvent as ModuleMatrixEvent,
1818
CustomRoomPreviewBarRenderFunction,
19+
CustomLoginRenderFunction,
1920
} from "@element-hq/element-web-module-api";
2021
import type React from "react";
2122

@@ -153,4 +154,21 @@ export class CustomComponentsApi implements ICustomComponentsApi {
153154
public registerRoomPreviewBar(renderer: CustomRoomPreviewBarRenderFunction): void {
154155
this._roomPreviewBarRenderer = renderer;
155156
}
157+
158+
private _loginRenderer?: CustomLoginRenderFunction;
159+
160+
/**
161+
* Get the custom login component renderer, if any has been registered.
162+
*/
163+
public get loginComponentRenderer(): CustomLoginRenderFunction | undefined {
164+
return this._loginRenderer;
165+
}
166+
167+
/**
168+
* Register a custom login component renderer.
169+
* @param renderer - the function that will render the login component.
170+
*/
171+
public registerLoginComponent(renderer: CustomLoginRenderFunction): void {
172+
this._loginRenderer = renderer;
173+
}
156174
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
Copyright 2026 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import type { UIComponent, CustomisationsApi as ICustomisationsApi } from "@element-hq/element-web-module-api";
9+
10+
export class CustomisationsApi implements ICustomisationsApi {
11+
private shouldShowComponentFunctions = new Set<(component: UIComponent) => boolean | void>();
12+
13+
/**
14+
* Method to register a callback which can affect whether a given component is drawn or not.
15+
* @param fn - the callback, if it returns true the component will be rendered, if false it will not be.
16+
* If undefined will defer to the next callback, ultimately falling through to `true` if none return false.
17+
* The next callback is decided in FIFO call order to this register function.
18+
*/
19+
public registerShouldShowComponent(fn: (this: void, component: UIComponent) => boolean | void): void {
20+
this.shouldShowComponentFunctions.add(fn);
21+
}
22+
23+
/**
24+
* Method to check whether, according to any registered modules, a given component should be rendered.
25+
* @param component - the component to check
26+
*/
27+
public shouldShowComponent(component: UIComponent): boolean | void {
28+
for (const fn of this.shouldShowComponentFunctions) {
29+
const v = fn(component);
30+
if (typeof v === "boolean") {
31+
return v;
32+
}
33+
}
34+
}
35+
}

apps/web/src/settings/UIFeature.ts

Lines changed: 1 addition & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -28,44 +28,4 @@ export const enum UIFeature {
2828
AllowCreatingPublicSpaces = "UIFeature.allowCreatingPublicSpaces",
2929
}
3030

31-
export enum UIComponent {
32-
/**
33-
* Components that lead to a user being invited.
34-
*/
35-
InviteUsers = "UIComponent.sendInvites",
36-
37-
/**
38-
* Components that lead to a room being created that aren't already
39-
* guarded by some other condition (ie: "only if you can edit this
40-
* space" is *not* guarded by this component, but "start DM" is).
41-
*/
42-
CreateRooms = "UIComponent.roomCreation",
43-
44-
/**
45-
* Components that lead to a Space being created that aren't already
46-
* guarded by some other condition (ie: "only if you can add subspaces"
47-
* is *not* guarded by this component, but "create new space" is).
48-
*/
49-
CreateSpaces = "UIComponent.spaceCreation",
50-
51-
/**
52-
* Components that lead to the public room directory.
53-
*/
54-
ExploreRooms = "UIComponent.exploreRooms",
55-
56-
/**
57-
* Components that lead to the user being able to easily add widgets
58-
* and integrations to the room, such as from the room information card.
59-
*/
60-
AddIntegrations = "UIComponent.addIntegrations",
61-
62-
/**
63-
* Component that lead to the user being able to search, dial, explore rooms
64-
*/
65-
FilterContainer = "UIComponent.filterContainer",
66-
67-
/**
68-
* Components that lead the user to room options menu.
69-
*/
70-
RoomOptionsMenu = "UIComponent.roomOptionsMenu",
71-
}
31+
export { UIComponent } from "@element-hq/element-web-module-api";

apps/web/test/unit-tests/components/structures/auth/Login-test.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import Login from "../../../../../src/components/structures/auth/Login";
2020
import type BasePlatform from "../../../../../src/BasePlatform";
2121
import * as registerClientUtils from "../../../../../src/utils/oidc/registerClient";
2222
import { makeDelegatedAuthConfig } from "../../../../test-utils/oidc";
23+
import { ModuleApi } from "../../../../../src/modules/Api.ts";
2324

2425
jest.useRealTimers();
2526

@@ -100,6 +101,35 @@ describe("Login", function () {
100101
expect(container.querySelector(".mx_ServerPicker_change")).toBeTruthy();
101102
});
102103

104+
it("should show register button", async () => {
105+
const onRegisterClick = jest.fn();
106+
const { getByText } = render(
107+
<Login
108+
serverConfig={mkServerConfig("https://matrix.org", "https://vector.im")}
109+
onLoggedIn={() => {}}
110+
onRegisterClick={onRegisterClick}
111+
onServerConfigChange={() => {}}
112+
/>,
113+
);
114+
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
115+
116+
fireEvent.click(getByText("Create an account"));
117+
expect(onRegisterClick).toHaveBeenCalled();
118+
});
119+
120+
it("should hide register button", async () => {
121+
const { queryByText } = render(
122+
<Login
123+
serverConfig={mkServerConfig("https://matrix.org", "https://vector.im")}
124+
onLoggedIn={() => {}}
125+
onServerConfigChange={() => {}}
126+
/>,
127+
);
128+
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
129+
130+
expect(queryByText("Create an account")).not.toBeInTheDocument();
131+
});
132+
103133
it("should show form without change server link when custom URLs disabled", async () => {
104134
const { container } = getComponent();
105135
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));
@@ -417,4 +447,16 @@ describe("Login", function () {
417447
expect(screen.getByText("Continue")).toBeInTheDocument();
418448
});
419449
});
450+
451+
describe("Module API", () => {
452+
afterEach(() => {
453+
ModuleApi.instance.customComponents.registerLoginComponent(undefined as any);
454+
});
455+
456+
it("should use registered module renderer", async () => {
457+
ModuleApi.instance.customComponents.registerLoginComponent(() => <>Test component</>);
458+
const { getByText } = getComponent();
459+
expect(getByText("Test component")).toBeTruthy();
460+
});
461+
});
420462
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
Copyright 2026 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { CustomisationsApi } from "../../../src/modules/customisationsApi";
9+
import { UIComponent } from "../../../src/settings/UIFeature.ts";
10+
11+
describe("CustomisationsApi", () => {
12+
let api: CustomisationsApi;
13+
14+
beforeEach(() => {
15+
api = new CustomisationsApi();
16+
});
17+
18+
it("should register a shouldShowComponent callback", () => {
19+
const shouldShowComponent = jest.fn().mockReturnValue(true);
20+
api.registerShouldShowComponent(shouldShowComponent);
21+
expect(api.shouldShowComponent(UIComponent.CreateRooms)).toBe(true);
22+
expect(shouldShowComponent).toHaveBeenCalledWith("UIComponent.roomCreation");
23+
});
24+
});

0 commit comments

Comments
 (0)