From 7f5bb61a79b20c8642bb5aeec5869dc658bc5147 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 5 Jul 2022 20:26:44 +0200 Subject: [PATCH] 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 --- package.json | 1 + src/Lifecycle.ts | 5 + .../views/dialogs/ModuleUiDialog.tsx | 66 ++++++ src/components/views/rooms/RoomPreviewBar.tsx | 30 ++- src/dispatcher/actions.ts | 5 + .../payloads/OverwriteLoginPayload.ts | 25 +++ src/i18n/strings/en_EN.json | 1 + src/languageHandler.tsx | 53 +++-- src/modules/AppModule.ts | 45 +++++ src/modules/ModuleComponents.tsx | 40 ++++ src/modules/ModuleFactory.ts | 20 ++ src/modules/ModuleRunner.ts | 85 ++++++++ src/modules/ProxiedModuleApi.ts | 191 ++++++++++++++++++ src/stores/widgets/StopGapWidgetDriver.ts | 8 +- src/utils/permalinks/navigator.ts | 30 +++ test/modules/AppModule-test.ts | 36 ++++ test/modules/MockModule.ts | 45 +++++ test/modules/ModuleComponents-test.tsx | 41 ++++ test/modules/ModuleRunner-test.ts | 54 +++++ test/modules/ProxiedModuleApi-test.ts | 79 ++++++++ .../ModuleComponents-test.tsx.snap | 66 ++++++ yarn.lock | 14 ++ 22 files changed, 906 insertions(+), 34 deletions(-) create mode 100644 src/components/views/dialogs/ModuleUiDialog.tsx create mode 100644 src/dispatcher/payloads/OverwriteLoginPayload.ts create mode 100644 src/modules/AppModule.ts create mode 100644 src/modules/ModuleComponents.tsx create mode 100644 src/modules/ModuleFactory.ts create mode 100644 src/modules/ModuleRunner.ts create mode 100644 src/modules/ProxiedModuleApi.ts create mode 100644 src/utils/permalinks/navigator.ts 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/package.json b/package.json index 6ad4c4c036..8b1aa4a4ca 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/src/Lifecycle.ts b/src/Lifecycle.ts index 4915d17e3f..915bc85dd7 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); } }); diff --git a/src/components/views/dialogs/ModuleUiDialog.tsx b/src/components/views/dialogs/ModuleUiDialog.tsx new file mode 100644 index 0000000000..44109038bd --- /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 { 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 { _t } from "../../../languageHandler"; + +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 e0e1f13f05..6c724440cf 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'; @@ -34,6 +38,7 @@ 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"; const MemberEventHtmlReasonField = "io.element.html_reason"; @@ -313,13 +318,26 @@ 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 }; + 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"); + 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/dispatcher/actions.ts b/src/dispatcher/actions.ts index 8f46c9398f..3d1ac9969b 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -316,6 +316,11 @@ export enum Action { */ OnLoggedIn = "on_logged_in", + /** + * Overwrites the existing login with fresh session credentials. Use with a OverwriteLoginPayload. + */ + 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. diff --git a/src/dispatcher/payloads/OverwriteLoginPayload.ts b/src/dispatcher/payloads/OverwriteLoginPayload.ts new file mode 100644 index 0000000000..ec5b83c1de --- /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/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 444c871a2b..693299f921 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1805,6 +1805,7 @@ "Joining …": "Joining …", "Loading …": "Loading …", "Rejecting invite …": "Rejecting invite …", + "Join the room to participate": "Join the room to participate", "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 1d3fef1666..2caf5c1639 100644 --- a/src/languageHandler.tsx +++ b/src/languageHandler.tsx @@ -28,6 +28,7 @@ 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"; @@ -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 0000000000..b5ccf5f63f --- /dev/null +++ b/src/modules/AppModule.ts @@ -0,0 +1,45 @@ +/* +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 { 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 new file mode 100644 index 0000000000..c9c7a90b2c --- /dev/null +++ b/src/modules/ModuleComponents.tsx @@ -0,0 +1,40 @@ +/* +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 { 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"; + +// 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) => ( + props.onChange(e.target.value)} + label={props.label} + autoComplete="off" + /> +); +ModuleSpinner.renderFactory = () => ; diff --git a/src/modules/ModuleFactory.ts b/src/modules/ModuleFactory.ts new file mode 100644 index 0000000000..947c14cb01 --- /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 0000000000..e573852a88 --- /dev/null +++ b/src/modules/ModuleRunner.ts @@ -0,0 +1,85 @@ +/* +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 { AnyLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/types"; + +import { AppModule } from "./AppModule"; +import { ModuleFactory } from "./ModuleFactory"; +import "./ModuleComponents"; + +/** + * Handles and coordinates the operation of modules. + */ +export class ModuleRunner { + public static readonly instance = new ModuleRunner(); + + private modules: AppModule[] = []; + + private constructor() { + // we only want one instance + } + + /** + * Resets the runner, clearing all known modules. + * + * Intended for test usage only. + */ + public reset() { + this.modules = []; + } + + /** + * All custom translations from all registered modules. + */ + public get allTranslations(): TranslationStringsObject { + const merged: TranslationStringsObject = {}; + + for (const module of this.modules) { + const i18n = module.api.translations; + if (!i18n) continue; + + for (const [lang, strings] of Object.entries(i18n)) { + if (!merged[lang]) merged[lang] = {}; + for (const [str, val] of Object.entries(strings)) { + merged[lang][str] = val; + } + } + } + + return merged; + } + + /** + * Registers a factory which creates a module for later loading. The factory + * will be called immediately. + * @param factory The module factory. + */ + public registerModule(factory: ModuleFactory) { + this.modules.push(new AppModule(factory)); + } + + /** + * Invokes a lifecycle event, notifying registered modules. + * @param lifecycleEvent The lifecycle event. + * @param args The arguments for the lifecycle event. + */ + public invoke(lifecycleEvent: AnyLifecycle, ...args: any[]): void { + for (const module of this.modules) { + module.module.emit(lifecycleEvent, ...args); + } + } +} diff --git a/src/modules/ProxiedModuleApi.ts b/src/modules/ProxiedModuleApi.ts new file mode 100644 index 0000000000..008a09527d --- /dev/null +++ b/src/modules/ProxiedModuleApi.ts @@ -0,0 +1,191 @@ +/* +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 { DialogProps } from "@matrix-org/react-sdk-module-api/lib/components/DialogContent"; +import React from "react"; +import { AccountAuthInfo } from "@matrix-org/react-sdk-module-api/lib/types/AccountAuthInfo"; +import { PlainSubstitution } from "@matrix-org/react-sdk-module-api/lib/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 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 + * to be assigned to a single module. + */ +export class ProxiedModuleApi implements ModuleApi { + private cachedTranslations: Optional; + + /** + * 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< + 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, + contentFactory: body, + contentProps: { + moduleApi: this, + }, + }, "mx_CompoundDialog").finished.then(([didOkOrSubmit, model]) => { + resolve({ didOkOrSubmit, model }); + }); + }); + } + + /** + * @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 }); + const deviceName = SdkConfig.get("default_device_display_name") + || PlatformPeg.get().getDefaultDeviceDisplayName(); + const req = { + username, + password, + initial_device_display_name: deviceName, + auth: undefined, + inhibit_login: false, + }; + const creds = await (client.registerRequest(req).catch(resp => client.registerRequest({ + ...req, + auth: { + session: resp.data.session, + type: "m.login.dummy", + }, + }))); + + if (displayName) { + const profileClient = Matrix.createClient({ + baseUrl: hsUrl, + userId: creds.user_id, + deviceId: creds.device_id, + accessToken: creds.access_token, + }); + await profileClient.setDisplayName(displayName); + } + + return { + homeserverUrl: hsUrl, + userId: creds.user_id, + deviceId: creds.device_id, + accessToken: creds.access_token, + }; + } + + /** + * @override + */ + public async overwriteAccountAuth(accountInfo: AccountAuthInfo): Promise { + dispatcher.dispatch({ + action: Action.OverwriteLogin, + credentials: { + ...accountInfo, + guest: false, + }, + }, true); // require to be sync to match inherited interface behaviour + } + + /** + * @override + */ + public async navigatePermalink(uri: string, andJoin?: boolean): Promise { + navigateToPermalink(uri); + + const parts = parsePermalink(uri); + 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: Action.ViewRoom, + room_id: roomId, + via_servers: servers, + }); + + if (andJoin) { + dispatcher.dispatch({ + action: Action.JoinRoom, + }); + } + } + } + + /** + * @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); + if (!maybeObj || !(typeof maybeObj === "object")) return undefined; + return maybeObj[key]; + } +} diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 3fcc10283e..3b617e6f31 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -46,10 +46,10 @@ 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"; +import { navigateToPermalink } from "../../utils/permalinks/navigator"; // TODO: Purge this from the universe @@ -280,10 +280,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 0000000000..ffa4678dbe --- /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 +} diff --git a/test/modules/AppModule-test.ts b/test/modules/AppModule-test.ts new file mode 100644 index 0000000000..6fdf16d694 --- /dev/null +++ b/test/modules/AppModule-test.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 { MockModule } from "./MockModule"; +import { AppModule } from "../../src/modules/AppModule"; + +describe("AppModule", () => { + 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 0000000000..6496437989 --- /dev/null +++ b/test/modules/MockModule.ts @@ -0,0 +1,45 @@ +/* +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 0000000000..3bb39a9012 --- /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 0000000000..400d970519 --- /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 0000000000..80890acfb1 --- /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 0000000000..4dbad141d1 --- /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`] = ` + + +
+ + +
+
+
+`; diff --git a/yarn.lock b/yarn.lock index 26a80252e6..a7131a96e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1111,6 +1111,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" @@ -1572,6 +1579,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"