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
This commit is contained in:
parent
2dd683a42f
commit
7f5bb61a79
22 changed files with 906 additions and 34 deletions
|
@ -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",
|
||||
|
|
|
@ -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 = <OverwriteLoginPayload>payload;
|
||||
// noinspection JSIgnoredPromiseFromCall - we don't care if it fails
|
||||
doSetLoggedIn(typed.credentials, true);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
66
src/components/views/dialogs/ModuleUiDialog.tsx
Normal file
66
src/components/views/dialogs/ModuleUiDialog.tsx
Normal file
|
@ -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<DialogContent>) => React.ReactNode;
|
||||
contentProps: DialogProps;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface IState extends IScrollableBaseState {
|
||||
// nothing special
|
||||
}
|
||||
|
||||
export class ModuleUiDialog extends ScrollableBaseModal<IProps, IState> {
|
||||
private contentRef = createRef<DialogContent>();
|
||||
|
||||
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 <div className="mx_ModuleUiDialog">
|
||||
{ this.props.contentFactory(this.props.contentProps, this.contentRef) }
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -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<IProps, IState> {
|
|||
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 = (
|
||||
<div>
|
||||
|
|
|
@ -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.
|
||||
|
|
25
src/dispatcher/payloads/OverwriteLoginPayload.ts
Normal file
25
src/dispatcher/payloads/OverwriteLoginPayload.ts
Normal file
|
@ -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;
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
45
src/modules/AppModule.ts
Normal file
45
src/modules/AppModule.ts
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
40
src/modules/ModuleComponents.tsx
Normal file
40
src/modules/ModuleComponents.tsx
Normal file
|
@ -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) => (
|
||||
<Field
|
||||
type="text"
|
||||
value={props.value}
|
||||
onChange={e => props.onChange(e.target.value)}
|
||||
label={props.label}
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
ModuleSpinner.renderFactory = () => <Spinner />;
|
20
src/modules/ModuleFactory.ts
Normal file
20
src/modules/ModuleFactory.ts
Normal file
|
@ -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;
|
85
src/modules/ModuleRunner.ts
Normal file
85
src/modules/ModuleRunner.ts
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
191
src/modules/ProxiedModuleApi.ts
Normal file
191
src/modules/ProxiedModuleApi.ts
Normal file
|
@ -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<TranslationStringsObject>;
|
||||
|
||||
/**
|
||||
* All custom translations used by the associated module.
|
||||
*/
|
||||
public get translations(): Optional<TranslationStringsObject> {
|
||||
return this.cachedTranslations;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
public registerTranslations(translations: TranslationStringsObject): void {
|
||||
this.cachedTranslations = translations;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
public translateString(s: string, variables?: Record<string, PlainSubstitution>): 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<C>) => React.ReactNode,
|
||||
): Promise<{ didOkOrSubmit: boolean, model: M }> {
|
||||
return new Promise<{ didOkOrSubmit: boolean, model: M }>((resolve) => {
|
||||
Modal.createDialog(ModuleUiDialog, {
|
||||
title: title,
|
||||
contentFactory: body,
|
||||
contentProps: <DialogProps>{
|
||||
moduleApi: this,
|
||||
},
|
||||
}, "mx_CompoundDialog").finished.then(([didOkOrSubmit, model]) => {
|
||||
resolve({ didOkOrSubmit, model });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
public async registerSimpleAccount(
|
||||
username: string,
|
||||
password: string,
|
||||
displayName?: string,
|
||||
): Promise<AccountAuthInfo> {
|
||||
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<void> {
|
||||
dispatcher.dispatch<OverwriteLoginPayload>({
|
||||
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<void> {
|
||||
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<T>(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];
|
||||
}
|
||||
}
|
|
@ -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<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
|
||||
navigateToPermalink(uri);
|
||||
}
|
||||
}
|
||||
|
|
30
src/utils/permalinks/navigator.ts
Normal file
30
src/utils/permalinks/navigator.ts
Normal file
|
@ -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
|
||||
}
|
36
test/modules/AppModule-test.ts
Normal file
36
test/modules/AppModule-test.ts
Normal file
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
45
test/modules/MockModule.ts
Normal file
45
test/modules/MockModule.ts
Normal file
|
@ -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;
|
||||
}
|
41
test/modules/ModuleComponents-test.tsx
Normal file
41
test/modules/ModuleComponents-test.tsx
Normal file
|
@ -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(<TextInputField label="My Label" value="My Value" onChange={() => {}} />);
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should override the factory for a ModuleSpinner", () => {
|
||||
const component = mount(<ModuleSpinner />);
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
});
|
54
test/modules/ModuleRunner-test.ts
Normal file
54
test/modules/ModuleRunner-test.ts
Normal file
|
@ -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
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
79
test/modules/ProxiedModuleApi-test.ts
Normal file
79
test/modules/ProxiedModuleApi-test.ts
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
66
test/modules/__snapshots__/ModuleComponents-test.tsx.snap
Normal file
66
test/modules/__snapshots__/ModuleComponents-test.tsx.snap
Normal file
|
@ -0,0 +1,66 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Module Components should override the factory for a ModuleSpinner 1`] = `
|
||||
<Spinner>
|
||||
<Spinner
|
||||
h={32}
|
||||
w={32}
|
||||
>
|
||||
<div
|
||||
className="mx_Spinner"
|
||||
>
|
||||
<div
|
||||
aria-label="Loading..."
|
||||
className="mx_Spinner_icon"
|
||||
style={
|
||||
Object {
|
||||
"height": 32,
|
||||
"width": 32,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Spinner>
|
||||
</Spinner>
|
||||
`;
|
||||
|
||||
exports[`Module Components should override the factory for a TextInputField 1`] = `
|
||||
<TextInputField
|
||||
label="My Label"
|
||||
onChange={[Function]}
|
||||
value="My Value"
|
||||
>
|
||||
<Field
|
||||
autoComplete="off"
|
||||
element="input"
|
||||
label="My Label"
|
||||
onChange={[Function]}
|
||||
type="text"
|
||||
validateOnBlur={true}
|
||||
validateOnChange={true}
|
||||
validateOnFocus={true}
|
||||
value="My Value"
|
||||
>
|
||||
<div
|
||||
className="mx_Field mx_Field_input"
|
||||
>
|
||||
<input
|
||||
autoComplete="off"
|
||||
id="mx_Field_1"
|
||||
label="My Label"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
placeholder="My Label"
|
||||
type="text"
|
||||
value="My Value"
|
||||
/>
|
||||
<label
|
||||
htmlFor="mx_Field_1"
|
||||
>
|
||||
My Label
|
||||
</label>
|
||||
</div>
|
||||
</Field>
|
||||
</TextInputField>
|
||||
`;
|
14
yarn.lock
14
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"
|
||||
|
|
Loading…
Reference in a new issue