From 40fefa05ea2f25c252f1c01b48389333a3c31fc0 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 5 Apr 2022 17:55:51 -0600 Subject: [PATCH 01/14] Early implementation of module API surface + functions for ILAG module --- src/components/views/rooms/RoomPreviewBar.tsx | 27 +++++++-- src/i18n/strings/en_EN.json | 2 + src/languageHandler.tsx | 53 ++++++++++------- src/modules/AppModule.ts | 28 +++++++++ src/modules/ModuleFactory.ts | 20 +++++++ src/modules/ModuleRunner.ts | 58 +++++++++++++++++++ src/modules/ProxiedModuleApi.ts | 36 ++++++++++++ 7 files changed, 196 insertions(+), 28 deletions(-) create mode 100644 src/modules/AppModule.ts create mode 100644 src/modules/ModuleFactory.ts create mode 100644 src/modules/ModuleRunner.ts create mode 100644 src/modules/ProxiedModuleApi.ts diff --git a/src/components/views/rooms/RoomPreviewBar.tsx b/src/components/views/rooms/RoomPreviewBar.tsx index e0e1f13f059..4d25a77507b 100644 --- a/src/components/views/rooms/RoomPreviewBar.tsx +++ b/src/components/views/rooms/RoomPreviewBar.tsx @@ -34,6 +34,11 @@ import AccessibleButton from "../elements/AccessibleButton"; import RoomAvatar from "../avatars/RoomAvatar"; import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; +import { ModuleRunner } from "../../../modules/ModuleRunner"; +import { + RoomPreviewOpts, + RoomViewLifecycle, +} from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; const MemberEventHtmlReasonField = "io.element.html_reason"; @@ -313,13 +318,23 @@ export default class RoomPreviewBar extends React.Component { break; } case MessageCase.NotLoggedIn: { - title = _t("Join the conversation with an account"); - if (SettingsStore.getValue(UIFeature.Registration)) { - primaryActionLabel = _t("Sign Up"); - primaryActionHandler = this.onRegisterClick; + const opts: RoomPreviewOpts = { canJoin: false }; + ModuleRunner.instance.invoke(RoomViewLifecycle.PreviewRoomNotLoggedIn, opts, this.props.room.roomId); + if (opts.canJoin) { + title = _t("Join the room to participate"); + primaryActionLabel = _t("Join"); + primaryActionHandler = () => { + ModuleRunner.instance.invoke(RoomViewLifecycle.JoinFromRoomPreview, this.props.room.roomId); + }; + } else { + title = _t("Join the conversation with an account"); + if (SettingsStore.getValue(UIFeature.Registration)) { + primaryActionLabel = _t("Sign Up"); + primaryActionHandler = this.onRegisterClick; + } + secondaryActionLabel = _t("Sign In"); + secondaryActionHandler = this.onLoginClick; } - secondaryActionLabel = _t("Sign In"); - secondaryActionHandler = this.onLoginClick; if (this.props.previewLoading) { footer = (
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 6a7e3f77f3e..0e7f3caf3b9 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1817,6 +1817,8 @@ "Joining …": "Joining …", "Loading …": "Loading …", "Rejecting invite …": "Rejecting invite …", + "Join the room to participate": "Join the room to participate", + "Join": "Join", "Join the conversation with an account": "Join the conversation with an account", "Sign Up": "Sign Up", "Loading preview": "Loading preview", diff --git a/src/languageHandler.tsx b/src/languageHandler.tsx index 1d3fef16668..2797683c413 100644 --- a/src/languageHandler.tsx +++ b/src/languageHandler.tsx @@ -31,6 +31,7 @@ import SdkConfig from "./SdkConfig"; // @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config import webpackLangJsonUrl from "$webapp/i18n/languages.json"; +import { ModuleRunner } from "./modules/ModuleRunner"; const i18nFolder = 'i18n/'; @@ -609,15 +610,40 @@ export class CustomTranslationOptions { } } +function doRegisterTranslations(customTranslations: ICustomTranslations) { + // We convert the operator-friendly version into something counterpart can + // consume. + const langs: { + // same structure, just flipped key order + [lang: string]: { + [str: string]: string; + }; + } = {}; + for (const [str, translations] of Object.entries(customTranslations)) { + for (const [lang, newStr] of Object.entries(translations)) { + if (!langs[lang]) langs[lang] = {}; + langs[lang][str] = newStr; + } + } + + // Finally, tell counterpart about our translations + for (const [lang, translations] of Object.entries(langs)) { + counterpart.registerTranslations(lang, translations); + } +} + /** - * If a custom translations file is configured, it will be parsed and registered. - * If no customization is made, or the file can't be parsed, no action will be - * taken. + * Any custom modules with translations to load are parsed first, followed by an + * optionally defined translations file in the config. If no customization is made, + * or the file can't be parsed, no action will be taken. * * This function should be called *after* registering other translations data to * ensure it overrides strings properly. */ export async function registerCustomTranslations() { + const moduleTranslations = ModuleRunner.instance.allTranslations; + doRegisterTranslations(moduleTranslations); + const lookupUrl = SdkConfig.get().custom_translations_url; if (!lookupUrl) return; // easy - nothing to do @@ -639,25 +665,8 @@ export async function registerCustomTranslations() { // If the (potentially cached) json is invalid, don't use it. if (!json) return; - // We convert the operator-friendly version into something counterpart can - // consume. - const langs: { - // same structure, just flipped key order - [lang: string]: { - [str: string]: string; - }; - } = {}; - for (const [str, translations] of Object.entries(json)) { - for (const [lang, newStr] of Object.entries(translations)) { - if (!langs[lang]) langs[lang] = {}; - langs[lang][str] = newStr; - } - } - - // Finally, tell counterpart about our translations - for (const [lang, translations] of Object.entries(langs)) { - counterpart.registerTranslations(lang, translations); - } + // Finally, register it. + doRegisterTranslations(json); } catch (e) { // We consume all exceptions because it's considered non-fatal for custom // translations to break. Most failures will be during initial development diff --git a/src/modules/AppModule.ts b/src/modules/AppModule.ts new file mode 100644 index 00000000000..cdfc51defea --- /dev/null +++ b/src/modules/AppModule.ts @@ -0,0 +1,28 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ModuleFactory } from "./ModuleFactory"; +import { RuntimeModule } from "@matrix-org/react-sdk-module-api/lib/RuntimeModule"; +import { ProxiedModuleApi } from "./ProxiedModuleApi"; + +export class AppModule { + public readonly module: RuntimeModule; + public readonly api = new ProxiedModuleApi(); + + public constructor(factory: ModuleFactory) { + this.module = factory(this.api); + } +} diff --git a/src/modules/ModuleFactory.ts b/src/modules/ModuleFactory.ts new file mode 100644 index 00000000000..947c14cb012 --- /dev/null +++ b/src/modules/ModuleFactory.ts @@ -0,0 +1,20 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { RuntimeModule } from "@matrix-org/react-sdk-module-api/lib/RuntimeModule"; +import { ModuleApi } from "@matrix-org/react-sdk-module-api/lib/ModuleApi"; + +export type ModuleFactory = (api: ModuleApi) => RuntimeModule; diff --git a/src/modules/ModuleRunner.ts b/src/modules/ModuleRunner.ts new file mode 100644 index 00000000000..4002407c3a5 --- /dev/null +++ b/src/modules/ModuleRunner.ts @@ -0,0 +1,58 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { TranslationStringsObject } from "@matrix-org/react-sdk-module-api/lib/types/translations"; +import { AppModule } from "./AppModule"; +import { ModuleFactory } from "./ModuleFactory"; +import { AnyLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/types"; + +export class ModuleRunner { + public static readonly instance = new ModuleRunner(); + + private modules: AppModule[] = []; + + private constructor() { + // we only want one instance + } + + 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)) { + if (!merged[lang]) merged[lang] = {}; + for (const [str, val] of Object.entries(strings)) { + merged[lang][str] = val; + } + } + } + + return merged; + } + + public registerModule(factory: ModuleFactory) { + this.modules.push(new AppModule(factory)); + } + + public invoke(lifecycleEvent: AnyLifecycle, ...args: any[]): void { + for (const module of this.modules) { + module.module.emit(lifecycleEvent, ...args); + } + } +} diff --git a/src/modules/ProxiedModuleApi.ts b/src/modules/ProxiedModuleApi.ts new file mode 100644 index 00000000000..39c7e5c498d --- /dev/null +++ b/src/modules/ProxiedModuleApi.ts @@ -0,0 +1,36 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ModuleApi } from "@matrix-org/react-sdk-module-api/lib/ModuleApi"; +import { TranslationStringsObject } from "@matrix-org/react-sdk-module-api/lib/types/translations"; +import { Optional } from "matrix-events-sdk"; +import { _t } from "../languageHandler"; + +export class ProxiedModuleApi implements ModuleApi { + private cachedTranslations: Optional; + + public get translations(): Optional { + return this.cachedTranslations; + } + + public registerTranslations(translations: TranslationStringsObject): void { + this.cachedTranslations = translations; + } + + public translateString(s: string, variables?: Record): string { + return _t(s, variables); + } +} From fc1d36dd6c9db600c387e4619d8aaf0c784402de Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 6 Apr 2022 14:18:25 -0600 Subject: [PATCH 02/14] Wire up dialog functions and ILAG-needed surface --- src/Lifecycle.ts | 2 +- .../views/dialogs/ModuleUiDialog.tsx | 66 +++++++++++++++ src/components/views/rooms/RoomPreviewBar.tsx | 4 +- src/modules/ProxiedModuleApi.ts | 80 +++++++++++++++++++ 4 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 src/components/views/dialogs/ModuleUiDialog.tsx diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 4915d17e3f9..cdc40bf35ef 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -558,7 +558,7 @@ export async function hydrateSession(credentials: IMatrixClientCreds): Promise { diff --git a/src/components/views/dialogs/ModuleUiDialog.tsx b/src/components/views/dialogs/ModuleUiDialog.tsx new file mode 100644 index 00000000000..769c1e30ef8 --- /dev/null +++ b/src/components/views/dialogs/ModuleUiDialog.tsx @@ -0,0 +1,66 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { createRef } from "react"; + +import ScrollableBaseModal, { IScrollableBaseState } from "./ScrollableBaseModal"; +import { IDialogProps } from "./IDialogProps"; +import { DialogContent, DialogProps } from "@matrix-org/react-sdk-module-api/lib/components/DialogContent"; +import { _t } from "../../../languageHandler"; +import { logger } from "matrix-js-sdk/src/logger"; + +interface IProps extends IDialogProps { + contentFactory: (props: DialogProps, ref: React.Ref) => React.ReactNode; + contentProps: DialogProps; + title: string; +} + +interface IState extends IScrollableBaseState { + // nothing special +} + +export class ModuleUiDialog extends ScrollableBaseModal { + private contentRef = createRef(); + + public constructor(props: IProps) { + super(props); + + this.state = { + title: this.props.title, + canSubmit: true, + actionLabel: _t("OK"), + }; + } + + protected async submit() { + try { + const model = await this.contentRef.current.trySubmit(); + this.props.onFinished(true, model); + } catch (e) { + logger.error("Error during submission of module dialog:", e); + } + } + + protected cancel(): void { + this.props.onFinished(false); + } + + protected renderContent(): React.ReactNode { + return
+ { this.props.contentFactory(this.props.contentProps, this.contentRef) } +
; + } +} diff --git a/src/components/views/rooms/RoomPreviewBar.tsx b/src/components/views/rooms/RoomPreviewBar.tsx index 4d25a77507b..9b7c49214ed 100644 --- a/src/components/views/rooms/RoomPreviewBar.tsx +++ b/src/components/views/rooms/RoomPreviewBar.tsx @@ -319,7 +319,9 @@ export default class RoomPreviewBar extends React.Component { } case MessageCase.NotLoggedIn: { const opts: RoomPreviewOpts = { canJoin: false }; - ModuleRunner.instance.invoke(RoomViewLifecycle.PreviewRoomNotLoggedIn, opts, this.props.room.roomId); + if (this.props.room?.roomId) { + ModuleRunner.instance.invoke(RoomViewLifecycle.PreviewRoomNotLoggedIn, opts, this.props.room.roomId); + } if (opts.canJoin) { title = _t("Join the room to participate"); primaryActionLabel = _t("Join"); diff --git a/src/modules/ProxiedModuleApi.ts b/src/modules/ProxiedModuleApi.ts index 39c7e5c498d..9c8b74ee813 100644 --- a/src/modules/ProxiedModuleApi.ts +++ b/src/modules/ProxiedModuleApi.ts @@ -18,6 +18,16 @@ import { ModuleApi } from "@matrix-org/react-sdk-module-api/lib/ModuleApi"; import { TranslationStringsObject } from "@matrix-org/react-sdk-module-api/lib/types/translations"; import { Optional } from "matrix-events-sdk"; import { _t } from "../languageHandler"; +import { DialogProps } from "@matrix-org/react-sdk-module-api/lib/components/DialogContent"; +import Modal from "../Modal"; +import { ModuleUiDialog } from "../components/views/dialogs/ModuleUiDialog"; +import React from "react"; +import { AccountCredentials } from "@matrix-org/react-sdk-module-api/lib/types/credentials"; +import * as Matrix from "matrix-js-sdk/src/matrix"; +import SdkConfig from "../SdkConfig"; +import PlatformPeg from "../PlatformPeg"; +import { doSetLoggedIn } from "../Lifecycle"; +import dispatcher from "../dispatcher/dispatcher"; export class ProxiedModuleApi implements ModuleApi { private cachedTranslations: Optional; @@ -33,4 +43,74 @@ export class ProxiedModuleApi implements ModuleApi { public translateString(s: string, variables?: Record): string { return _t(s, variables); } + + public openDialog(title: string, body: (props: P, ref: React.RefObject) => React.ReactNode): Promise<{ didSubmit: boolean, model: M }> { + return new Promise<{ didSubmit: boolean, model: M }>((resolve) => { + Modal.createTrackedDialog("ModuleDialog", "", ModuleUiDialog, { + title: title, + contentFactory: body, + contentProps: { + moduleApi: this, + }, + }, "mx_CompoundDialog").finished.then(([didSubmit, model]) => { + resolve({ didSubmit, model }); + }); + }); + } + + public async registerAccount(username: string, password: string, displayName?: string): Promise { + const hsUrl = SdkConfig.get("validated_server_config").hsUrl; + const client = Matrix.createClient({ baseUrl: hsUrl }); + const req = { + username, + password, + initial_device_display_name: SdkConfig.get("default_device_display_name") || PlatformPeg.get().getDefaultDeviceDisplayName(), + 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, + }; + } + + public async useAccount(credentials: AccountCredentials): Promise { + await doSetLoggedIn({ + ...credentials, + guest: false, + }, true); + } + + public async switchToRoom(roomId: string, andJoin?: boolean): Promise { + dispatcher.dispatch({ + action: "view_room", + room_id: roomId, + }); + + if (andJoin) { + dispatcher.dispatch({ + action: "join_room", + }); + } + } } From c7b0eac9c1b8dd1c432ff7f3b18c4f3a822678c5 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 6 Apr 2022 14:31:56 -0600 Subject: [PATCH 03/14] Ensure component renders for modules get overridden --- src/modules/ModuleComponents.tsx | 33 ++++++++++++++++++++++++++++++++ src/modules/ModuleRunner.ts | 1 + 2 files changed, 34 insertions(+) create mode 100644 src/modules/ModuleComponents.tsx diff --git a/src/modules/ModuleComponents.tsx b/src/modules/ModuleComponents.tsx new file mode 100644 index 00000000000..919a6f9ed7d --- /dev/null +++ b/src/modules/ModuleComponents.tsx @@ -0,0 +1,33 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// TODO: @@ Is this future-proof enough? Will we remember to do this for new components? +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 from "react"; +import Field from "../components/views/elements/Field"; +import Spinner from "../components/views/elements/Spinner"; + +TextInputField.renderFactory = (props) => ( + props.onChange(e.target.value)} + label={props.label} + autoComplete="off" + /> +); +ModuleSpinner.renderFactory = () => ; diff --git a/src/modules/ModuleRunner.ts b/src/modules/ModuleRunner.ts index 4002407c3a5..3b03f55d90f 100644 --- a/src/modules/ModuleRunner.ts +++ b/src/modules/ModuleRunner.ts @@ -18,6 +18,7 @@ import { TranslationStringsObject } from "@matrix-org/react-sdk-module-api/lib/t import { AppModule } from "./AppModule"; import { ModuleFactory } from "./ModuleFactory"; import { AnyLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/types"; +import "./ModuleComponents"; export class ModuleRunner { public static readonly instance = new ModuleRunner(); From 71d5b09db7102f2cc963a4807b66babb5b9282af Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 25 May 2022 10:27:21 -0600 Subject: [PATCH 04/14] Respond to changes from module API interface --- src/modules/ProxiedModuleApi.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/modules/ProxiedModuleApi.ts b/src/modules/ProxiedModuleApi.ts index 9c8b74ee813..af4bfb74f7d 100644 --- a/src/modules/ProxiedModuleApi.ts +++ b/src/modules/ProxiedModuleApi.ts @@ -22,12 +22,13 @@ import { DialogProps } from "@matrix-org/react-sdk-module-api/lib/components/Dia import Modal from "../Modal"; import { ModuleUiDialog } from "../components/views/dialogs/ModuleUiDialog"; import React from "react"; -import { AccountCredentials } from "@matrix-org/react-sdk-module-api/lib/types/credentials"; +import { AccountInformation } from "@matrix-org/react-sdk-module-api/lib/types/credentials"; import * as Matrix from "matrix-js-sdk/src/matrix"; import SdkConfig from "../SdkConfig"; import PlatformPeg from "../PlatformPeg"; import { doSetLoggedIn } from "../Lifecycle"; import dispatcher from "../dispatcher/dispatcher"; +import { PlainSubstitution } from "@matrix-org/react-sdk-module-api/src/types/translations"; export class ProxiedModuleApi implements ModuleApi { private cachedTranslations: Optional; @@ -40,25 +41,25 @@ export class ProxiedModuleApi implements ModuleApi { this.cachedTranslations = translations; } - public translateString(s: string, variables?: Record): string { + public translateString(s: string, variables?: Record): string { return _t(s, variables); } - public openDialog(title: string, body: (props: P, ref: React.RefObject) => React.ReactNode): Promise<{ didSubmit: boolean, model: M }> { - return new Promise<{ didSubmit: boolean, model: M }>((resolve) => { + public openDialog(title: string, body: (props: P, ref: React.RefObject) => React.ReactNode): Promise<{ didOkOrSubmit: boolean, model: M }> { + return new Promise<{ didOkOrSubmit: boolean, model: M }>((resolve) => { Modal.createTrackedDialog("ModuleDialog", "", ModuleUiDialog, { title: title, contentFactory: body, contentProps: { moduleApi: this, }, - }, "mx_CompoundDialog").finished.then(([didSubmit, model]) => { - resolve({ didSubmit, model }); + }, "mx_CompoundDialog").finished.then(([didOkOrSubmit, model]) => { + resolve({ didOkOrSubmit, model }); }); }); } - public async registerAccount(username: string, password: string, displayName?: string): Promise { + public async registerAccount(username: string, password: string, displayName?: string): Promise { const hsUrl = SdkConfig.get("validated_server_config").hsUrl; const client = Matrix.createClient({ baseUrl: hsUrl }); const req = { @@ -94,9 +95,9 @@ export class ProxiedModuleApi implements ModuleApi { }; } - public async useAccount(credentials: AccountCredentials): Promise { + public async useAccount(accountInfo: AccountInformation): Promise { await doSetLoggedIn({ - ...credentials, + ...accountInfo, guest: false, }, true); } From 1039ae8a834d819be956944406db6ce905199d6d Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 25 May 2022 16:25:02 -0600 Subject: [PATCH 05/14] Use a real module-api dependency --- package.json | 1 + yarn.lock | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/package.json b/package.json index b16cf864615..8fc4b175f73 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "dependencies": { "@babel/runtime": "^7.12.5", "@matrix-org/analytics-events": "^0.1.1", + "@matrix-org/react-sdk-module-api": "^0.0.3", "@sentry/browser": "^6.11.0", "@sentry/tracing": "^6.11.0", "@testing-library/react": "^12.1.5", diff --git a/yarn.lock b/yarn.lock index 6925d34d2a7..f0b76683e35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1049,6 +1049,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.17.9": + version "7.18.3" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.3.tgz#c7b654b57f6f63cf7f8b418ac9ca04408c4579f4" + integrity sha512-38Y8f7YUhce/K7RMwTp7m0uCumpv9hZkitCbBClqQIow1qSbCvGkcegKOXpEWCQLfWmevgRiWokZ1GkpfhbZug== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.16.7", "@babel/template@^7.3.3": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.7.tgz#8d126c8701fde4d66b264b3eba3d96f07666d155" @@ -1477,6 +1484,13 @@ version "3.2.8" resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz#8d53636d045e1776e2a2ec6613e57330dd9ce856" +"@matrix-org/react-sdk-module-api@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@matrix-org/react-sdk-module-api/-/react-sdk-module-api-0.0.3.tgz#a7ac1b18a72d18d08290b81fa33b0d8d00a77d2b" + integrity sha512-jQmLhVIanuX0g7Jx1OIqlzs0kp72PfSpv3umi55qVPYcAPQmO252AUs0vncatK8O4e013vohdnNhly19a/kmLQ== + dependencies: + "@babel/runtime" "^7.17.9" + "@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3": version "2.1.8-no-fsevents.3" resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz#323d72dd25103d0c4fbdce89dadf574a787b1f9b" From 7ebd357b4293e1d020a9f40e360d4435ac59e51b Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 14 Jun 2022 22:29:09 -0600 Subject: [PATCH 06/14] Update for new Dialogs interface --- src/modules/ProxiedModuleApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/ProxiedModuleApi.ts b/src/modules/ProxiedModuleApi.ts index af4bfb74f7d..9d58523c478 100644 --- a/src/modules/ProxiedModuleApi.ts +++ b/src/modules/ProxiedModuleApi.ts @@ -47,7 +47,7 @@ export class ProxiedModuleApi implements ModuleApi { public openDialog(title: string, body: (props: P, ref: React.RefObject) => React.ReactNode): Promise<{ didOkOrSubmit: boolean, model: M }> { return new Promise<{ didOkOrSubmit: boolean, model: M }>((resolve) => { - Modal.createTrackedDialog("ModuleDialog", "", ModuleUiDialog, { + Modal.createDialog(ModuleUiDialog, { title: title, contentFactory: body, contentProps: { From 7c33b169347485cc2236bdb8ebc21bbb1fe8c224 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 14 Jun 2022 22:29:20 -0600 Subject: [PATCH 07/14] Add support for getConfigValue from module API --- src/modules/ProxiedModuleApi.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/modules/ProxiedModuleApi.ts b/src/modules/ProxiedModuleApi.ts index 9d58523c478..a9200a00439 100644 --- a/src/modules/ProxiedModuleApi.ts +++ b/src/modules/ProxiedModuleApi.ts @@ -114,4 +114,11 @@ export class ProxiedModuleApi implements ModuleApi { }); } } + + public getConfigValue(namespace: string, key: string): T { + // 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]; + } } From 417a72b6352e20909b62577ce61542bc1e47d6d2 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 14 Jun 2022 22:40:35 -0600 Subject: [PATCH 08/14] Update the remainder of the module API interface --- src/modules/ProxiedModuleApi.ts | 54 ++++++++++++++++------- src/stores/widgets/StopGapWidgetDriver.ts | 7 +-- src/utils/permalinks/navigator.ts | 30 +++++++++++++ 3 files changed, 71 insertions(+), 20 deletions(-) create mode 100644 src/utils/permalinks/navigator.ts diff --git a/src/modules/ProxiedModuleApi.ts b/src/modules/ProxiedModuleApi.ts index a9200a00439..8fe86c9ad58 100644 --- a/src/modules/ProxiedModuleApi.ts +++ b/src/modules/ProxiedModuleApi.ts @@ -17,18 +17,24 @@ limitations under the License. import { ModuleApi } from "@matrix-org/react-sdk-module-api/lib/ModuleApi"; import { TranslationStringsObject } from "@matrix-org/react-sdk-module-api/lib/types/translations"; import { Optional } from "matrix-events-sdk"; -import { _t } from "../languageHandler"; import { DialogProps } from "@matrix-org/react-sdk-module-api/lib/components/DialogContent"; -import Modal from "../Modal"; -import { ModuleUiDialog } from "../components/views/dialogs/ModuleUiDialog"; import React from "react"; -import { AccountInformation } from "@matrix-org/react-sdk-module-api/lib/types/credentials"; +import { AccountAuthInfo } from "@matrix-org/react-sdk-module-api/lib/types/AccountAuthInfo"; +import { PlainSubstitution } from "@matrix-org/react-sdk-module-api/src/types/translations"; import * as Matrix from "matrix-js-sdk/src/matrix"; + +import Modal from "../Modal"; +import { _t } from "../languageHandler"; +import { ModuleUiDialog } from "../components/views/dialogs/ModuleUiDialog"; import SdkConfig from "../SdkConfig"; import PlatformPeg from "../PlatformPeg"; import { doSetLoggedIn } from "../Lifecycle"; import dispatcher from "../dispatcher/dispatcher"; -import { PlainSubstitution } from "@matrix-org/react-sdk-module-api/src/types/translations"; +import { navigateToPermalink } from "../utils/permalinks/navigator"; +import { parsePermalink } from "../utils/permalinks/Permalinks"; +import { MatrixClientPeg } from "../MatrixClientPeg"; +import { getCachedRoomIDForAlias } from "../RoomAliasCache"; +import { Action } from "../dispatcher/actions"; export class ProxiedModuleApi implements ModuleApi { private cachedTranslations: Optional; @@ -59,7 +65,7 @@ export class ProxiedModuleApi implements ModuleApi { }); } - public async registerAccount(username: string, password: string, displayName?: string): Promise { + public async registerSimpleAccount(username: string, password: string, displayName?: string): Promise { const hsUrl = SdkConfig.get("validated_server_config").hsUrl; const client = Matrix.createClient({ baseUrl: hsUrl }); const req = { @@ -95,23 +101,41 @@ export class ProxiedModuleApi implements ModuleApi { }; } - public async useAccount(accountInfo: AccountInformation): Promise { + public async overwriteAccountAuth(accountInfo: AccountAuthInfo): Promise { await doSetLoggedIn({ ...accountInfo, guest: false, }, true); } - public async switchToRoom(roomId: string, andJoin?: boolean): Promise { - dispatcher.dispatch({ - action: "view_room", - room_id: roomId, - }); - - if (andJoin) { + public async navigatePermalink(uri: string, andJoin?: boolean): Promise { + navigateToPermalink(uri); + + const parts = parsePermalink(uri); + if (parts.roomIdOrAlias) + if (parts.roomIdOrAlias && andJoin) { + let roomId = parts.roomIdOrAlias; + let servers = parts.viaServers; + if (roomId.startsWith("#")) { + roomId = getCachedRoomIDForAlias(parts.roomIdOrAlias); + if (!roomId) { + // alias resolution failed + const result = await MatrixClientPeg.get().getRoomIdForAlias(parts.roomIdOrAlias); + roomId = result.room_id; + if (!servers) servers = result.servers; // use provided servers first, if available + } + } dispatcher.dispatch({ - action: "join_room", + action: Action.ViewRoom, + room_id: roomId, + via_servers: servers, }); + + if (andJoin) { + dispatcher.dispatch({ + action: Action.JoinRoom, + }); + } } } diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 3fcc10283eb..5ef8b1ed6db 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -50,6 +50,7 @@ import { tryTransformPermalinkToLocalHref } from "../../utils/permalinks/Permali import SettingsStore from "../../settings/SettingsStore"; import { RoomViewStore } from "../RoomViewStore"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; +import { navigateToPermalink } from "../../utils/permalinks/navigator"; // TODO: Purge this from the universe @@ -280,10 +281,6 @@ export class StopGapWidgetDriver extends WidgetDriver { } public async navigate(uri: string): Promise { - const localUri = tryTransformPermalinkToLocalHref(uri); - if (!localUri || localUri === uri) { // parse failure can lead to an unmodified URL - throw new Error("Failed to transform URI"); - } - window.location.hash = localUri; // it'll just be a fragment + navigateToPermalink(uri); } } diff --git a/src/utils/permalinks/navigator.ts b/src/utils/permalinks/navigator.ts new file mode 100644 index 00000000000..ffa4678dbea --- /dev/null +++ b/src/utils/permalinks/navigator.ts @@ -0,0 +1,30 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { tryTransformPermalinkToLocalHref } from "./Permalinks"; + +/** + * Converts a permalink to a local HREF and navigates accordingly. Throws if the permalink + * cannot be transformed. + * @param uri The permalink to navigate to. + */ +export function navigateToPermalink(uri: string): void { + const localUri = tryTransformPermalinkToLocalHref(uri); + if (!localUri || localUri === uri) { // parse failure can lead to an unmodified URL + throw new Error("Failed to transform URI"); + } + window.location.hash = localUri; // it'll just be a fragment +} From 69e36a7c76b1222f0633dd63300f8e037f2a6970 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 15 Jun 2022 15:27:46 -0600 Subject: [PATCH 09/14] Docs & cleanup --- src/modules/AppModule.ts | 19 ++++++++++++++- src/modules/ModuleComponents.tsx | 4 +++- src/modules/ModuleRunner.ts | 19 ++++++++++++++- src/modules/ProxiedModuleApi.ts | 28 +++++++++++++++++++++++ src/stores/widgets/StopGapWidgetDriver.ts | 1 - 5 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/modules/AppModule.ts b/src/modules/AppModule.ts index cdfc51defea..b5ccf5f63fb 100644 --- a/src/modules/AppModule.ts +++ b/src/modules/AppModule.ts @@ -14,14 +14,31 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ModuleFactory } from "./ModuleFactory"; import { RuntimeModule } from "@matrix-org/react-sdk-module-api/lib/RuntimeModule"; + +import { 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); } diff --git a/src/modules/ModuleComponents.tsx b/src/modules/ModuleComponents.tsx index 919a6f9ed7d..539ecd683cf 100644 --- a/src/modules/ModuleComponents.tsx +++ b/src/modules/ModuleComponents.tsx @@ -14,13 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -// TODO: @@ Is this future-proof enough? Will we remember to do this for new components? 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 from "react"; + import Field from "../components/views/elements/Field"; import Spinner from "../components/views/elements/Spinner"; +// TODO: @@ Is this future-proof enough? Will we remember to do this for new components? + TextInputField.renderFactory = (props) => ( ; + /** + * All custom translations used by the associated module. + */ public get translations(): Optional { return this.cachedTranslations; } + /** + * @override + */ public registerTranslations(translations: TranslationStringsObject): void { this.cachedTranslations = translations; } + /** + * @override + */ public translateString(s: string, variables?: Record): string { return _t(s, variables); } + /** + * @override + */ public openDialog(title: string, body: (props: P, ref: React.RefObject) => React.ReactNode): Promise<{ didOkOrSubmit: boolean, model: M }> { return new Promise<{ didOkOrSubmit: boolean, model: M }>((resolve) => { Modal.createDialog(ModuleUiDialog, { @@ -65,6 +81,9 @@ export class ProxiedModuleApi implements ModuleApi { }); } + /** + * @override + */ public async registerSimpleAccount(username: string, password: string, displayName?: string): Promise { const hsUrl = SdkConfig.get("validated_server_config").hsUrl; const client = Matrix.createClient({ baseUrl: hsUrl }); @@ -101,6 +120,9 @@ export class ProxiedModuleApi implements ModuleApi { }; } + /** + * @override + */ public async overwriteAccountAuth(accountInfo: AccountAuthInfo): Promise { await doSetLoggedIn({ ...accountInfo, @@ -108,6 +130,9 @@ export class ProxiedModuleApi implements ModuleApi { }, true); } + /** + * @override + */ public async navigatePermalink(uri: string, andJoin?: boolean): Promise { navigateToPermalink(uri); @@ -139,6 +164,9 @@ export class ProxiedModuleApi implements ModuleApi { } } + /** + * @override + */ public getConfigValue(namespace: string, key: string): T { // Force cast to `any` because the namespace won't be known to the SdkConfig types const maybeObj = SdkConfig.get(namespace as any); diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 5ef8b1ed6db..3b617e6f314 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -46,7 +46,6 @@ import { WidgetType } from "../../widgets/WidgetType"; import { CHAT_EFFECTS } from "../../effects"; import { containsEmoji } from "../../effects/utils"; import dis from "../../dispatcher/dispatcher"; -import { tryTransformPermalinkToLocalHref } from "../../utils/permalinks/Permalinks"; import SettingsStore from "../../settings/SettingsStore"; import { RoomViewStore } from "../RoomViewStore"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; From b352a25139737533db833e928a430f7d538acfaa Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 15 Jun 2022 17:18:01 -0600 Subject: [PATCH 10/14] Add some unit tests around module stuff Needs end-to-end tests still. --- src/modules/ModuleComponents.tsx | 7 +- src/modules/ModuleRunner.ts | 9 +++ src/modules/ProxiedModuleApi.ts | 3 +- test/modules/AppModule-test.ts | 36 +++++++++ test/modules/MockModule.ts | 44 +++++++++++ test/modules/ModuleComponents-test.tsx | 41 ++++++++++ test/modules/ModuleRunner-test.ts | 54 +++++++++++++ test/modules/ProxiedModuleApi-test.ts | 79 +++++++++++++++++++ .../ModuleComponents-test.tsx.snap | 66 ++++++++++++++++ 9 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 test/modules/AppModule-test.ts create mode 100644 test/modules/MockModule.ts create mode 100644 test/modules/ModuleComponents-test.tsx create mode 100644 test/modules/ModuleRunner-test.ts create mode 100644 test/modules/ProxiedModuleApi-test.ts create mode 100644 test/modules/__snapshots__/ModuleComponents-test.tsx.snap diff --git a/src/modules/ModuleComponents.tsx b/src/modules/ModuleComponents.tsx index 539ecd683cf..c9c7a90b2c4 100644 --- a/src/modules/ModuleComponents.tsx +++ b/src/modules/ModuleComponents.tsx @@ -21,7 +21,12 @@ import React from "react"; import Field from "../components/views/elements/Field"; import Spinner from "../components/views/elements/Spinner"; -// TODO: @@ Is this future-proof enough? Will we remember to do this for new components? +// 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) => ( { + describe("constructor", () => { + it("should call the factory immediately", () => { + let module: MockModule; + const appModule = new AppModule((api) => { + if (module) { + throw new Error("State machine error: Factory called twice"); + } + module = new MockModule(api); + return module; + }); + expect(appModule.module).toBeDefined(); + expect(appModule.module).toBe(module); + expect(appModule.api).toBeDefined(); + }); + }); +}); diff --git a/test/modules/MockModule.ts b/test/modules/MockModule.ts new file mode 100644 index 00000000000..c5d8c493e40 --- /dev/null +++ b/test/modules/MockModule.ts @@ -0,0 +1,44 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { RuntimeModule } from "@matrix-org/react-sdk-module-api/lib/RuntimeModule"; +import { ModuleApi } from "@matrix-org/react-sdk-module-api/lib/ModuleApi"; +import { ModuleRunner } from "../../src/modules/ModuleRunner"; + +export class MockModule extends RuntimeModule { + public get apiInstance(): ModuleApi { + return this.moduleApi; + } + + public constructor(moduleApi: ModuleApi) { + super(moduleApi); + } +} + +export function registerMockModule(): MockModule { + let module: MockModule; + ModuleRunner.instance.registerModule(api => { + if (module) { + throw new Error("State machine error: ModuleRunner created the module twice"); + } + module = new MockModule(api); + return module; + }); + if (!module) { + throw new Error("State machine error: ModuleRunner did not create module"); + } + return module; +} diff --git a/test/modules/ModuleComponents-test.tsx b/test/modules/ModuleComponents-test.tsx new file mode 100644 index 00000000000..3bb39a9012a --- /dev/null +++ b/test/modules/ModuleComponents-test.tsx @@ -0,0 +1,41 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { mount } from "enzyme"; +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 "../../src/modules/ModuleRunner"; + +describe("Module Components", () => { + // Note: we're not testing to see if there's components that are missing a renderFactory() + // but rather that the renderFactory() for components we do know about is actually defined + // and working. + // + // We do this by deliberately not importing the ModuleComponents file itself, relying on the + // ModuleRunner import to do its job (as per documentation in ModuleComponents). + + it("should override the factory for a TextInputField", () => { + const component = mount( {}} />); + expect(component).toMatchSnapshot(); + }); + + it("should override the factory for a ModuleSpinner", () => { + const component = mount(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/test/modules/ModuleRunner-test.ts b/test/modules/ModuleRunner-test.ts new file mode 100644 index 00000000000..400d9705192 --- /dev/null +++ b/test/modules/ModuleRunner-test.ts @@ -0,0 +1,54 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { RoomPreviewOpts, RoomViewLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; + +import { MockModule, registerMockModule } from "./MockModule"; +import { ModuleRunner } from "../../src/modules/ModuleRunner"; + +describe("ModuleRunner", () => { + afterEach(() => { + ModuleRunner.instance.reset(); + }); + + // Translations implicitly tested by ProxiedModuleApi integration tests. + + describe("invoke", () => { + it("should invoke to every registered module", async () => { + const module1 = registerMockModule(); + const module2 = registerMockModule(); + + const wrapEmit = (module: MockModule) => new Promise((resolve) => { + module.on(RoomViewLifecycle.PreviewRoomNotLoggedIn, (val1, val2) => { + resolve([val1, val2]); + }); + }); + const promises = Promise.all([ + wrapEmit(module1), + wrapEmit(module2), + ]); + + const roomId = "!room:example.org"; + const opts: RoomPreviewOpts = { canJoin: false }; + ModuleRunner.instance.invoke(RoomViewLifecycle.PreviewRoomNotLoggedIn, opts, roomId); + const results = await promises; + expect(results).toEqual([ + [opts, roomId], // module 1 + [opts, roomId], // module 2 + ]); + }); + }); +}); diff --git a/test/modules/ProxiedModuleApi-test.ts b/test/modules/ProxiedModuleApi-test.ts new file mode 100644 index 00000000000..80890acfb1d --- /dev/null +++ b/test/modules/ProxiedModuleApi-test.ts @@ -0,0 +1,79 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { TranslationStringsObject } from "@matrix-org/react-sdk-module-api/lib/types/translations"; + +import { ProxiedModuleApi } from "../../src/modules/ProxiedModuleApi"; +import { stubClient } from "../test-utils"; +import { setLanguage } from "../../src/languageHandler"; +import { ModuleRunner } from "../../src/modules/ModuleRunner"; +import { registerMockModule } from "./MockModule"; + +describe("ProxiedApiModule", () => { + afterEach(() => { + ModuleRunner.instance.reset(); + }); + + // Note: Remainder is implicitly tested from end-to-end tests of modules. + + describe("translations", () => { + it("should cache translations", () => { + const api = new ProxiedModuleApi(); + expect(api.translations).toBeFalsy(); + + const translations: TranslationStringsObject = { + ["custom string"]: { + "en": "custom string", + "fr": "custom french string", + }, + }; + api.registerTranslations(translations); + expect(api.translations).toBe(translations); + }); + + describe("integration", () => { + it("should translate strings using translation system", async () => { + // Test setup + stubClient(); + + // Set up a module to pull translations through + const module = registerMockModule(); + const en = "custom string"; + const de = "custom german string"; + const enVars = "custom variable %(var)s"; + const varVal = "string"; + const deVars = "custom german variable %(var)s"; + const deFull = `custom german variable ${varVal}`; + expect(module.apiInstance).toBeInstanceOf(ProxiedModuleApi); + module.apiInstance.registerTranslations({ + [en]: { + "en": en, + "de": de, + }, + [enVars]: { + "en": enVars, + "de": deVars, + }, + }); + await setLanguage("de"); // calls `registerCustomTranslations()` for us + + // See if we can pull the German string out + expect(module.apiInstance.translateString(en)).toEqual(de); + expect(module.apiInstance.translateString(enVars, { var: varVal })).toEqual(deFull); + }); + }); + }); +}); diff --git a/test/modules/__snapshots__/ModuleComponents-test.tsx.snap b/test/modules/__snapshots__/ModuleComponents-test.tsx.snap new file mode 100644 index 00000000000..4dbad141d17 --- /dev/null +++ b/test/modules/__snapshots__/ModuleComponents-test.tsx.snap @@ -0,0 +1,66 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Module Components should override the factory for a ModuleSpinner 1`] = ` + + +
+
+
+ + +`; + +exports[`Module Components should override the factory for a TextInputField 1`] = ` + + +
+ + +
+
+
+`; From e644b2b46d63af9cf7eefb5d81ac9a7f6f19da8d Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 27 Jun 2022 13:57:43 -0600 Subject: [PATCH 11/14] Appease early linters --- .../views/dialogs/ModuleUiDialog.tsx | 4 ++-- src/components/views/rooms/RoomPreviewBar.tsx | 11 ++++++----- src/languageHandler.tsx | 2 +- src/modules/ProxiedModuleApi.ts | 18 +++++++++++++++--- test/modules/MockModule.ts | 1 + 5 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/components/views/dialogs/ModuleUiDialog.tsx b/src/components/views/dialogs/ModuleUiDialog.tsx index 769c1e30ef8..44109038bd3 100644 --- a/src/components/views/dialogs/ModuleUiDialog.tsx +++ b/src/components/views/dialogs/ModuleUiDialog.tsx @@ -15,12 +15,12 @@ limitations under the License. */ import React, { createRef } from "react"; +import { DialogContent, DialogProps } from "@matrix-org/react-sdk-module-api/lib/components/DialogContent"; +import { logger } from "matrix-js-sdk/src/logger"; import ScrollableBaseModal, { IScrollableBaseState } from "./ScrollableBaseModal"; import { IDialogProps } from "./IDialogProps"; -import { DialogContent, DialogProps } from "@matrix-org/react-sdk-module-api/lib/components/DialogContent"; import { _t } from "../../../languageHandler"; -import { logger } from "matrix-js-sdk/src/logger"; interface IProps extends IDialogProps { contentFactory: (props: DialogProps, ref: React.Ref) => React.ReactNode; diff --git a/src/components/views/rooms/RoomPreviewBar.tsx b/src/components/views/rooms/RoomPreviewBar.tsx index 9b7c49214ed..6c724440cfa 100644 --- a/src/components/views/rooms/RoomPreviewBar.tsx +++ b/src/components/views/rooms/RoomPreviewBar.tsx @@ -21,6 +21,10 @@ import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; import { IJoinRuleEventContent, JoinRule } from "matrix-js-sdk/src/@types/partials"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import classNames from 'classnames'; +import { + RoomPreviewOpts, + RoomViewLifecycle, +} from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import dis from '../../../dispatcher/dispatcher'; @@ -35,10 +39,6 @@ import RoomAvatar from "../avatars/RoomAvatar"; import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; import { ModuleRunner } from "../../../modules/ModuleRunner"; -import { - RoomPreviewOpts, - RoomViewLifecycle, -} from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; const MemberEventHtmlReasonField = "io.element.html_reason"; @@ -320,7 +320,8 @@ export default class RoomPreviewBar extends React.Component { case MessageCase.NotLoggedIn: { const opts: RoomPreviewOpts = { canJoin: false }; if (this.props.room?.roomId) { - ModuleRunner.instance.invoke(RoomViewLifecycle.PreviewRoomNotLoggedIn, opts, this.props.room.roomId); + ModuleRunner.instance + .invoke(RoomViewLifecycle.PreviewRoomNotLoggedIn, opts, this.props.room.roomId); } if (opts.canJoin) { title = _t("Join the room to participate"); diff --git a/src/languageHandler.tsx b/src/languageHandler.tsx index 2797683c413..2caf5c1639e 100644 --- a/src/languageHandler.tsx +++ b/src/languageHandler.tsx @@ -28,10 +28,10 @@ import PlatformPeg from "./PlatformPeg"; import { SettingLevel } from "./settings/SettingLevel"; import { retry } from "./utils/promise"; import SdkConfig from "./SdkConfig"; +import { ModuleRunner } from "./modules/ModuleRunner"; // @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config import webpackLangJsonUrl from "$webapp/i18n/languages.json"; -import { ModuleRunner } from "./modules/ModuleRunner"; const i18nFolder = 'i18n/'; diff --git a/src/modules/ProxiedModuleApi.ts b/src/modules/ProxiedModuleApi.ts index 514797aa8af..bba6e2cceac 100644 --- a/src/modules/ProxiedModuleApi.ts +++ b/src/modules/ProxiedModuleApi.ts @@ -67,7 +67,13 @@ export class ProxiedModuleApi implements ModuleApi { /** * @override */ - public openDialog(title: string, body: (props: P, ref: React.RefObject) => React.ReactNode): Promise<{ didOkOrSubmit: boolean, model: M }> { + public openDialog< + M extends object, + P extends DialogProps = DialogProps, + C extends React.Component = React.Component>( + title: string, + body: (props: P, ref: React.RefObject) => React.ReactNode, + ): Promise<{ didOkOrSubmit: boolean, model: M }> { return new Promise<{ didOkOrSubmit: boolean, model: M }>((resolve) => { Modal.createDialog(ModuleUiDialog, { title: title, @@ -84,13 +90,19 @@ export class ProxiedModuleApi implements ModuleApi { /** * @override */ - public async registerSimpleAccount(username: string, password: string, displayName?: string): Promise { + public async registerSimpleAccount( + username: string, + password: string, + displayName?: string, + ): Promise { const hsUrl = SdkConfig.get("validated_server_config").hsUrl; const client = Matrix.createClient({ baseUrl: hsUrl }); + const deviceName = SdkConfig.get("default_device_display_name") + || PlatformPeg.get().getDefaultDeviceDisplayName(); const req = { username, password, - initial_device_display_name: SdkConfig.get("default_device_display_name") || PlatformPeg.get().getDefaultDeviceDisplayName(), + initial_device_display_name: deviceName, auth: undefined, inhibit_login: false, }; diff --git a/test/modules/MockModule.ts b/test/modules/MockModule.ts index c5d8c493e40..64964379893 100644 --- a/test/modules/MockModule.ts +++ b/test/modules/MockModule.ts @@ -16,6 +16,7 @@ limitations under the License. import { RuntimeModule } from "@matrix-org/react-sdk-module-api/lib/RuntimeModule"; import { ModuleApi } from "@matrix-org/react-sdk-module-api/lib/ModuleApi"; + import { ModuleRunner } from "../../src/modules/ModuleRunner"; export class MockModule extends RuntimeModule { From b607da5b142a0eb810a0dcaf942bd8e1d94ac502 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 27 Jun 2022 15:19:33 -0600 Subject: [PATCH 12/14] Break import cycles by not directly depending on Lifecycle --- src/Lifecycle.ts | 7 +++++- src/dispatcher/actions.ts | 5 ++++ .../payloads/OverwriteLoginPayload.ts | 25 +++++++++++++++++++ src/modules/ProxiedModuleApi.ts | 13 ++++++---- 4 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 src/dispatcher/payloads/OverwriteLoginPayload.ts diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index cdc40bf35ef..915bc85dd72 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -63,6 +63,7 @@ import VideoChannelStore from "./stores/VideoChannelStore"; import { fixStuckDevices } from "./utils/VideoChannelUtils"; import { Action } from "./dispatcher/actions"; import AbstractLocalStorageSettingsHandler from "./settings/handlers/AbstractLocalStorageSettingsHandler"; +import { OverwriteLoginPayload } from "./dispatcher/payloads/OverwriteLoginPayload"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -71,6 +72,10 @@ dis.register((payload) => { if (payload.action === Action.TriggerLogout) { // noinspection JSIgnoredPromiseFromCall - we don't care if it fails onLoggedOut(); + } else if (payload.action === Action.OverwriteLogin) { + const typed = payload; + // noinspection JSIgnoredPromiseFromCall - we don't care if it fails + doSetLoggedIn(typed.credentials, true); } }); @@ -558,7 +563,7 @@ export async function hydrateSession(credentials: IMatrixClientCreds): Promise { diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index c400b175743..75205176be1 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -315,4 +315,9 @@ export enum Action { * Fired when the client was logged in. No additional payload information required. */ OnLoggedIn = "on_logged_in", + + /** + * Overwrites the existing login with fresh session credentials. Use with a OverwriteLoginPayload. + */ + OverwriteLogin = "overwrite_login", } diff --git a/src/dispatcher/payloads/OverwriteLoginPayload.ts b/src/dispatcher/payloads/OverwriteLoginPayload.ts new file mode 100644 index 00000000000..ec5b83c1de7 --- /dev/null +++ b/src/dispatcher/payloads/OverwriteLoginPayload.ts @@ -0,0 +1,25 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ActionPayload } from "../payloads"; +import { Action } from "../actions"; +import { IMatrixClientCreds } from "../../MatrixClientPeg"; + +export interface OverwriteLoginPayload extends ActionPayload { + action: Action.OverwriteLogin; + + credentials: IMatrixClientCreds; +} diff --git a/src/modules/ProxiedModuleApi.ts b/src/modules/ProxiedModuleApi.ts index bba6e2cceac..93c194721c3 100644 --- a/src/modules/ProxiedModuleApi.ts +++ b/src/modules/ProxiedModuleApi.ts @@ -28,13 +28,13 @@ import { _t } from "../languageHandler"; import { ModuleUiDialog } from "../components/views/dialogs/ModuleUiDialog"; import SdkConfig from "../SdkConfig"; import PlatformPeg from "../PlatformPeg"; -import { doSetLoggedIn } from "../Lifecycle"; import dispatcher from "../dispatcher/dispatcher"; import { navigateToPermalink } from "../utils/permalinks/navigator"; import { parsePermalink } from "../utils/permalinks/Permalinks"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { getCachedRoomIDForAlias } from "../RoomAliasCache"; import { Action } from "../dispatcher/actions"; +import { OverwriteLoginPayload } from "../dispatcher/payloads/OverwriteLoginPayload"; /** * Glue between the `ModuleApi` interface and the react-sdk. Anticipates one instance @@ -136,10 +136,13 @@ export class ProxiedModuleApi implements ModuleApi { * @override */ public async overwriteAccountAuth(accountInfo: AccountAuthInfo): Promise { - await doSetLoggedIn({ - ...accountInfo, - guest: false, - }, true); + dispatcher.dispatch({ + action: Action.OverwriteLogin, + credentials: { + ...accountInfo, + guest: false, + }, + }, true); // require to be sync to match inherited interface behaviour } /** From a2f15acfde8ce6c4d194b3ad1cb6042852d275a8 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 27 Jun 2022 16:35:14 -0600 Subject: [PATCH 13/14] Appease the linter --- src/i18n/strings/en_EN.json | 1 - src/modules/ProxiedModuleApi.ts | 7 ++++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 0e7f3caf3b9..9f17f29031c 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1818,7 +1818,6 @@ "Loading …": "Loading …", "Rejecting invite …": "Rejecting invite …", "Join the room to participate": "Join the room to participate", - "Join": "Join", "Join the conversation with an account": "Join the conversation with an account", "Sign Up": "Sign Up", "Loading preview": "Loading preview", diff --git a/src/modules/ProxiedModuleApi.ts b/src/modules/ProxiedModuleApi.ts index 93c194721c3..008a09527d7 100644 --- a/src/modules/ProxiedModuleApi.ts +++ b/src/modules/ProxiedModuleApi.ts @@ -70,9 +70,10 @@ export class ProxiedModuleApi implements ModuleApi { public openDialog< M extends object, P extends DialogProps = DialogProps, - C extends React.Component = React.Component>( - title: string, - body: (props: P, ref: React.RefObject) => React.ReactNode, + C extends React.Component = React.Component, + >( + title: string, + body: (props: P, ref: React.RefObject) => React.ReactNode, ): Promise<{ didOkOrSubmit: boolean, model: M }> { return new Promise<{ didOkOrSubmit: boolean, model: M }>((resolve) => { Modal.createDialog(ModuleUiDialog, { From fd4991e199f950474ed0020b58bf1785e02cf570 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 5 Jul 2022 11:13:23 -0600 Subject: [PATCH 14/14] Fix bad merge --- src/dispatcher/actions.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index b472ea8eec4..3d1ac9969b6 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -321,6 +321,7 @@ export enum Action { */ OverwriteLogin = "overwrite_login", + /** * Fired when the PlatformPeg gets a new platform set upon it, should only happen once per app load lifecycle. * Fires with the PlatformSetPayload. */