Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 7f5bb61

Browse files
authored
Support a module API surface for custom functionality (#8246)
* Early implementation of module API surface + functions for ILAG module * Wire up dialog functions and ILAG-needed surface * Ensure component renders for modules get overridden * Respond to changes from module API interface * Use a real module-api dependency * Update for new Dialogs interface * Add support for getConfigValue from module API * Update the remainder of the module API interface * Docs & cleanup * Add some unit tests around module stuff Needs end-to-end tests still. * Appease early linters * Break import cycles by not directly depending on Lifecycle * Appease the linter * Fix bad merge
1 parent 2dd683a commit 7f5bb61

22 files changed

+906
-34
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"dependencies": {
5858
"@babel/runtime": "^7.12.5",
5959
"@matrix-org/analytics-events": "^0.1.1",
60+
"@matrix-org/react-sdk-module-api": "^0.0.3",
6061
"@sentry/browser": "^6.11.0",
6162
"@sentry/tracing": "^6.11.0",
6263
"@testing-library/react": "^12.1.5",

src/Lifecycle.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import VideoChannelStore from "./stores/VideoChannelStore";
6363
import { fixStuckDevices } from "./utils/VideoChannelUtils";
6464
import { Action } from "./dispatcher/actions";
6565
import AbstractLocalStorageSettingsHandler from "./settings/handlers/AbstractLocalStorageSettingsHandler";
66+
import { OverwriteLoginPayload } from "./dispatcher/payloads/OverwriteLoginPayload";
6667

6768
const HOMESERVER_URL_KEY = "mx_hs_url";
6869
const ID_SERVER_URL_KEY = "mx_is_url";
@@ -71,6 +72,10 @@ dis.register((payload) => {
7172
if (payload.action === Action.TriggerLogout) {
7273
// noinspection JSIgnoredPromiseFromCall - we don't care if it fails
7374
onLoggedOut();
75+
} else if (payload.action === Action.OverwriteLogin) {
76+
const typed = <OverwriteLoginPayload>payload;
77+
// noinspection JSIgnoredPromiseFromCall - we don't care if it fails
78+
doSetLoggedIn(typed.credentials, true);
7479
}
7580
});
7681

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React, { createRef } from "react";
18+
import { DialogContent, DialogProps } from "@matrix-org/react-sdk-module-api/lib/components/DialogContent";
19+
import { logger } from "matrix-js-sdk/src/logger";
20+
21+
import ScrollableBaseModal, { IScrollableBaseState } from "./ScrollableBaseModal";
22+
import { IDialogProps } from "./IDialogProps";
23+
import { _t } from "../../../languageHandler";
24+
25+
interface IProps extends IDialogProps {
26+
contentFactory: (props: DialogProps, ref: React.Ref<DialogContent>) => React.ReactNode;
27+
contentProps: DialogProps;
28+
title: string;
29+
}
30+
31+
interface IState extends IScrollableBaseState {
32+
// nothing special
33+
}
34+
35+
export class ModuleUiDialog extends ScrollableBaseModal<IProps, IState> {
36+
private contentRef = createRef<DialogContent>();
37+
38+
public constructor(props: IProps) {
39+
super(props);
40+
41+
this.state = {
42+
title: this.props.title,
43+
canSubmit: true,
44+
actionLabel: _t("OK"),
45+
};
46+
}
47+
48+
protected async submit() {
49+
try {
50+
const model = await this.contentRef.current.trySubmit();
51+
this.props.onFinished(true, model);
52+
} catch (e) {
53+
logger.error("Error during submission of module dialog:", e);
54+
}
55+
}
56+
57+
protected cancel(): void {
58+
this.props.onFinished(false);
59+
}
60+
61+
protected renderContent(): React.ReactNode {
62+
return <div className="mx_ModuleUiDialog">
63+
{ this.props.contentFactory(this.props.contentProps, this.contentRef) }
64+
</div>;
65+
}
66+
}

src/components/views/rooms/RoomPreviewBar.tsx

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
2121
import { IJoinRuleEventContent, JoinRule } from "matrix-js-sdk/src/@types/partials";
2222
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
2323
import classNames from 'classnames';
24+
import {
25+
RoomPreviewOpts,
26+
RoomViewLifecycle,
27+
} from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
2428

2529
import { MatrixClientPeg } from '../../../MatrixClientPeg';
2630
import dis from '../../../dispatcher/dispatcher';
@@ -34,6 +38,7 @@ import AccessibleButton from "../elements/AccessibleButton";
3438
import RoomAvatar from "../avatars/RoomAvatar";
3539
import SettingsStore from "../../../settings/SettingsStore";
3640
import { UIFeature } from "../../../settings/UIFeature";
41+
import { ModuleRunner } from "../../../modules/ModuleRunner";
3742

3843
const MemberEventHtmlReasonField = "io.element.html_reason";
3944

@@ -313,13 +318,26 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
313318
break;
314319
}
315320
case MessageCase.NotLoggedIn: {
316-
title = _t("Join the conversation with an account");
317-
if (SettingsStore.getValue(UIFeature.Registration)) {
318-
primaryActionLabel = _t("Sign Up");
319-
primaryActionHandler = this.onRegisterClick;
321+
const opts: RoomPreviewOpts = { canJoin: false };
322+
if (this.props.room?.roomId) {
323+
ModuleRunner.instance
324+
.invoke(RoomViewLifecycle.PreviewRoomNotLoggedIn, opts, this.props.room.roomId);
325+
}
326+
if (opts.canJoin) {
327+
title = _t("Join the room to participate");
328+
primaryActionLabel = _t("Join");
329+
primaryActionHandler = () => {
330+
ModuleRunner.instance.invoke(RoomViewLifecycle.JoinFromRoomPreview, this.props.room.roomId);
331+
};
332+
} else {
333+
title = _t("Join the conversation with an account");
334+
if (SettingsStore.getValue(UIFeature.Registration)) {
335+
primaryActionLabel = _t("Sign Up");
336+
primaryActionHandler = this.onRegisterClick;
337+
}
338+
secondaryActionLabel = _t("Sign In");
339+
secondaryActionHandler = this.onLoginClick;
320340
}
321-
secondaryActionLabel = _t("Sign In");
322-
secondaryActionHandler = this.onLoginClick;
323341
if (this.props.previewLoading) {
324342
footer = (
325343
<div>

src/dispatcher/actions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,11 @@ export enum Action {
316316
*/
317317
OnLoggedIn = "on_logged_in",
318318

319+
/**
320+
* Overwrites the existing login with fresh session credentials. Use with a OverwriteLoginPayload.
321+
*/
322+
OverwriteLogin = "overwrite_login",
323+
319324
/**
320325
* Fired when the PlatformPeg gets a new platform set upon it, should only happen once per app load lifecycle.
321326
* Fires with the PlatformSetPayload.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { ActionPayload } from "../payloads";
18+
import { Action } from "../actions";
19+
import { IMatrixClientCreds } from "../../MatrixClientPeg";
20+
21+
export interface OverwriteLoginPayload extends ActionPayload {
22+
action: Action.OverwriteLogin;
23+
24+
credentials: IMatrixClientCreds;
25+
}

src/i18n/strings/en_EN.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1805,6 +1805,7 @@
18051805
"Joining …": "Joining …",
18061806
"Loading …": "Loading …",
18071807
"Rejecting invite …": "Rejecting invite …",
1808+
"Join the room to participate": "Join the room to participate",
18081809
"Join the conversation with an account": "Join the conversation with an account",
18091810
"Sign Up": "Sign Up",
18101811
"Loading preview": "Loading preview",

src/languageHandler.tsx

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import PlatformPeg from "./PlatformPeg";
2828
import { SettingLevel } from "./settings/SettingLevel";
2929
import { retry } from "./utils/promise";
3030
import SdkConfig from "./SdkConfig";
31+
import { ModuleRunner } from "./modules/ModuleRunner";
3132

3233
// @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config
3334
import webpackLangJsonUrl from "$webapp/i18n/languages.json";
@@ -609,15 +610,40 @@ export class CustomTranslationOptions {
609610
}
610611
}
611612

613+
function doRegisterTranslations(customTranslations: ICustomTranslations) {
614+
// We convert the operator-friendly version into something counterpart can
615+
// consume.
616+
const langs: {
617+
// same structure, just flipped key order
618+
[lang: string]: {
619+
[str: string]: string;
620+
};
621+
} = {};
622+
for (const [str, translations] of Object.entries(customTranslations)) {
623+
for (const [lang, newStr] of Object.entries(translations)) {
624+
if (!langs[lang]) langs[lang] = {};
625+
langs[lang][str] = newStr;
626+
}
627+
}
628+
629+
// Finally, tell counterpart about our translations
630+
for (const [lang, translations] of Object.entries(langs)) {
631+
counterpart.registerTranslations(lang, translations);
632+
}
633+
}
634+
612635
/**
613-
* If a custom translations file is configured, it will be parsed and registered.
614-
* If no customization is made, or the file can't be parsed, no action will be
615-
* taken.
636+
* Any custom modules with translations to load are parsed first, followed by an
637+
* optionally defined translations file in the config. If no customization is made,
638+
* or the file can't be parsed, no action will be taken.
616639
*
617640
* This function should be called *after* registering other translations data to
618641
* ensure it overrides strings properly.
619642
*/
620643
export async function registerCustomTranslations() {
644+
const moduleTranslations = ModuleRunner.instance.allTranslations;
645+
doRegisterTranslations(moduleTranslations);
646+
621647
const lookupUrl = SdkConfig.get().custom_translations_url;
622648
if (!lookupUrl) return; // easy - nothing to do
623649

@@ -639,25 +665,8 @@ export async function registerCustomTranslations() {
639665
// If the (potentially cached) json is invalid, don't use it.
640666
if (!json) return;
641667

642-
// We convert the operator-friendly version into something counterpart can
643-
// consume.
644-
const langs: {
645-
// same structure, just flipped key order
646-
[lang: string]: {
647-
[str: string]: string;
648-
};
649-
} = {};
650-
for (const [str, translations] of Object.entries(json)) {
651-
for (const [lang, newStr] of Object.entries(translations)) {
652-
if (!langs[lang]) langs[lang] = {};
653-
langs[lang][str] = newStr;
654-
}
655-
}
656-
657-
// Finally, tell counterpart about our translations
658-
for (const [lang, translations] of Object.entries(langs)) {
659-
counterpart.registerTranslations(lang, translations);
660-
}
668+
// Finally, register it.
669+
doRegisterTranslations(json);
661670
} catch (e) {
662671
// We consume all exceptions because it's considered non-fatal for custom
663672
// translations to break. Most failures will be during initial development

src/modules/AppModule.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { RuntimeModule } from "@matrix-org/react-sdk-module-api/lib/RuntimeModule";
18+
19+
import { ModuleFactory } from "./ModuleFactory";
20+
import { ProxiedModuleApi } from "./ProxiedModuleApi";
21+
22+
/**
23+
* Wraps a module factory into a usable module. Acts as a simple container
24+
* for the constructs needed to operate a module.
25+
*/
26+
export class AppModule {
27+
/**
28+
* The module instance.
29+
*/
30+
public readonly module: RuntimeModule;
31+
32+
/**
33+
* The API instance used by the module.
34+
*/
35+
public readonly api = new ProxiedModuleApi();
36+
37+
/**
38+
* Converts a factory into an AppModule. The factory will be called
39+
* immediately.
40+
* @param factory The module factory.
41+
*/
42+
public constructor(factory: ModuleFactory) {
43+
this.module = factory(this.api);
44+
}
45+
}

src/modules/ModuleComponents.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { TextInputField } from "@matrix-org/react-sdk-module-api/lib/components/TextInputField";
18+
import { Spinner as ModuleSpinner } from "@matrix-org/react-sdk-module-api/lib/components/Spinner";
19+
import React from "react";
20+
21+
import Field from "../components/views/elements/Field";
22+
import Spinner from "../components/views/elements/Spinner";
23+
24+
// Here we define all the render factories for the module API components. This file should be
25+
// imported by the ModuleRunner to load them into the call stack at runtime.
26+
//
27+
// If a new component is added to the module API, it should be added here too.
28+
//
29+
// Don't forget to add a test to ensure the renderFactory is overridden! See ModuleComponents-test.tsx
30+
31+
TextInputField.renderFactory = (props) => (
32+
<Field
33+
type="text"
34+
value={props.value}
35+
onChange={e => props.onChange(e.target.value)}
36+
label={props.label}
37+
autoComplete="off"
38+
/>
39+
);
40+
ModuleSpinner.renderFactory = () => <Spinner />;

src/modules/ModuleFactory.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { RuntimeModule } from "@matrix-org/react-sdk-module-api/lib/RuntimeModule";
18+
import { ModuleApi } from "@matrix-org/react-sdk-module-api/lib/ModuleApi";
19+
20+
export type ModuleFactory = (api: ModuleApi) => RuntimeModule;

0 commit comments

Comments
 (0)