Skip to content
This repository has been archived by the owner on Jan 30, 2025. It is now read-only.

Initial surface for module API (enough for an ILAG module) #1

Merged
merged 7 commits into from
May 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .babelrc
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
"sourceMaps": true,
"presets": [
"@babel/preset-env",
"@babel/preset-react",
"@babel/preset-typescript"
],
"plugins": [
"@babel/plugin-proposal-class-properties"
"@babel/plugin-proposal-class-properties",
"@babel/plugin-transform-runtime"
]
}
121 changes: 114 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,117 @@
# matrix-react-sdk-module-api
Proof of concept API surface for writing Modules for the react-sdk

## TODO
API surface for interacting with the [matrix-react-sdk](https://github.com/matrix-org/matrix-react-sdk) in a safe
and predictable way.

* [ ] Write a better intro/readme
* [ ] Proof of concept
* [ ] If approved, make it a real npm package
* [ ] If approved, fix access controls
* [ ] If approved, maintain this
Modules are simply additional functionality added at compile time for the application and can do things like register
custom translations, translation overrides, open dialogs, and add/modify UI.

**Note**: This project is still considered alpha/beta quality due to the API surface not being extensive. Please reach
out in [#element-dev:matrix.org](https://matrix.to/#/#element-dev:matrix.org) on Matrix for guidance on how to add to
this API surface.

In general, new code should target a generalized interface. An example would be the `openDialog()` function: while the
first module to use it didn't need custom `props`, it is expected that a dialog would at some point, so we expose it.
On the other hand, we deliberately do not expose the complexity of the react-sdk's dialog stack to this layer until
we need it. We might choose to open sticky dialogs with a new `openStickyDialog()` function instead of appending more
arguments to the existing function.

## Using the API

Modules are simply standalone npm packages which get installed/included in the app at compile time. To start, we
recommend using a simple module as a template, such as [element-web-ilag-module](https://github.com/vector-im/element-web-ilag-module).

The package's `main` entrypoint MUST point to an instance of `RuntimeModule`. That class must be a `default` export
for the module loader to reference correctly.

The `RuntimeModule` instance MUST have a constructor which accepts a single `ModuleApi` parameter. This is supplied
to the `super()` constructor.

Otherwise, simply `npm install --save @matrix-org/react-sdk-module-api` and start coding!

### Custom translations / string overrides

Custom translation strings (used within your module) or string overrides can be specified using the `registerTranslations`
function on a `ModuleApi` instance. For example:

```typescript
this.moduleApi.registerTranslations({
// If you use the translation utilities within your module, register your strings
"My custom string": {
"en": "My custom string",
"fr": "Ma chaîne personnalisée",
},

// If you want to override a string already in the app, such as the power level role
// names, use the base string here and redefine the values for each applicable language.
"A string that might already exist in the app": {
"en": "Replacement value for that string",
"fr": "Valeur de remplacement pour cette chaîne",
},
});
```

If you are within a class provided by the module API then translations are generally accessible with `this.t("my string")`.
This is a shortcut to `this.moduleApi.translateString()` which in turn calls into the translation engine at runtime to
determine which appropriately-translated string should be returned.

### Opening dialogs

Dialogs are opened through the `openDialog()` function on a `ModuleApi` instance. They accept a return model, component
properties definition, and a dialog component type. The dialog component itself must extend `DialogContent<>` from
the module API in order to open correctly.

The dialog component overrides `trySubmit()` and returns a promise for the return model, which is then passed back through
to the promise returned by `openDialog()`.

The `DialogContent<>` component is supplied with supporting components at the react-sdk layer to make dialog handling
generic: all a module needs to do is supply the content that goes into the dialog.

### Using standard UI elements

The react-sdk provides a number of components for building Matrix clients as well as some supporting components to make
it easier to have standardized styles on things like text inputs. Modules are naturally interested in these components
so their UI looks nearly indistinguishable from the rest of the app, however the react-sdk's components are not able to
be accessed directly.

Instead, similar to dialogs and translations, modules use a proxy component which gets replaced by the real thing at
runtime. For example, there is a `TextInputField` component supplied by the module API which gets translated into a
decorated field at runtime for the module.

**Note for react-sdk maintainers:** Don't forget to set the `renderFactory` of these components, otherwise the UI will
be subpar.

### Account management

Modules can register for an account without overriding the logged-in user's auth data with the `registerSimpleAccount()`
function on a `ModuleApi` instance. If the module would like to use that auth data, or has a different set of
authentication information in mind, it can call `overwriteAccountAuth()` on a `ModuleApi` instance to overwrite
(**without warning**) the current user's session.

### View management

From the `RuntimeModule` instance, modules can listen to various events that happen within the client to override
a small bit of the UI behaviour. For example, listening for `RoomViewLifecycle.PreviewRoomNotLoggedIn` allows the module
to change the behaviour of the "room preview bar" to enable future cases of `RoomViewLifecycle.JoinFromRoomPreview`
being raised for additional handling.

The module can also change what room/user/entity the user is looking at, and join it (if it's a room), with
`navigatePermalink` on a `ModuleApi` instance.

## Contributing / developing

Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for the mechanics of the contribution process.

For development, it is recommended to set up a normal element-web development environment and `yarn link` the
module API into both the react-sdk and element-web layers.

Visit [#element-dev:matrix.org](https://matrix.to/#/#element-dev:matrix.org) for support with getting a development
environment going.

## Releases

Because this is a scoped package, it needs to be published in a special way:

```bash
npm publish --access public
```
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"clean": "rimraf lib",
"build": "yarn clean && yarn build:compile && yarn build:types",
"build:types": "tsc -p ./tsconfig.build.json --emitDeclarationOnly",
"build:compile": "babel -d lib --verbose --extensions \".ts\" src",
"build:compile": "babel -d lib --verbose --extensions \".ts,.tsx\" src",
"start": "tsc -p ./tsconfig.build.json -w",
"test": "jest",
"lint": "eslint src test && tsc --noEmit",
Expand All @@ -30,9 +30,12 @@
"@babel/eslint-parser": "^7.17.0",
"@babel/eslint-plugin": "^7.17.7",
"@babel/plugin-proposal-class-properties": "^7.16.7",
"@babel/plugin-transform-runtime": "^7.17.0",
"@babel/preset-env": "^7.16.11",
"@babel/preset-react": "^7.16.7",
"@babel/preset-typescript": "^7.16.7",
"@types/jest": "^27.4.1",
"@types/react": "^17",
"@typescript-eslint/eslint-plugin": "^5.18.0",
"@typescript-eslint/parser": "^5.18.0",
"eslint": "^8.12.0",
Expand All @@ -43,5 +46,8 @@
"rimraf": "^3.0.2",
"ts-jest": "^27.1.4",
"typescript": "^4.6.3"
},
"dependencies": {
"@babel/runtime": "^7.17.9"
}
}
92 changes: 92 additions & 0 deletions src/ModuleApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
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 { PlainSubstitution, TranslationStringsObject } from "./types/translations";
import { DialogProps } from "./components/DialogContent";
import { AccountAuthInfo } from "./types/AccountAuthInfo";

/**
* A module API surface for the react-sdk. Provides a stable API for modules to
* interact with the internals of the react-sdk without having to update themselves
* for refactorings or code changes within the react-sdk.
*
* An instance of a ModuleApi is provided to all modules at runtime.
*/
export interface ModuleApi {
/**
* Register strings with the translation engine. This supports overriding strings which
* the system is already aware of.
* @param translations The translations to load.
*/
registerTranslations(translations: TranslationStringsObject): void;

/**
* Runs a string through the translation engine. If variables are needed, use %(varName)s
* as a placeholder for varName in the variables object.
* @param s The string. Should already be known to the engine.
* @param variables The variables to replace, if any.
* @returns The translated string.
*/
translateString(s: string, variables?: Record<string, PlainSubstitution>): string;

/**
* Opens a dialog in the client.
* @param title The title of the dialog
* @param body The function which creates a body component for the dialog.
* @param props Optional props to provide to the dialog.
* @returns Whether the user submitted the dialog or closed it, and the model returned by the
* dialog component if submitted.
*/
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,
props?: Omit<P, keyof DialogProps>,
): Promise<{ didOkOrSubmit: boolean, model: M }>;

/**
* Registers for an account on the currently connected homeserver. This requires that the homeserver
* offer a password-only flow without other flows. This means it is not traditionally compatible with
* homeservers like matrix.org which also generally require a combination of reCAPTCHA, email address,
* terms of service acceptance, etc.
* @param username The username to register.
* @param password The password to register.
* @param displayName Optional display name to set.
* @returns Resolves to the authentication info for the created account.
*/
registerSimpleAccount(username: string, password: string, displayName?: string): Promise<AccountAuthInfo>;

/**
* Switches the user's currently logged-in account to the one specified. The user will not
* be warned.
* @param accountAuthInfo The authentication info to log in with.
* @returns Resolves when complete.
*/
overwriteAccountAuth(accountAuthInfo: AccountAuthInfo): Promise<void>;

/**
* Switches the user's current view to look at the given permalink. If the permalink is
* a room, it can optionally be joined automatically if required.
*
* Permalink must be a matrix.to permalink at this time.
* @param uri The URI to navigate to.
* @param andJoin True to also join the room if needed. Does nothing if the link isn't to
* a room.
* @returns Resolves when complete.
*/
navigatePermalink(uri: string, andJoin?: boolean): Promise<void>;
}
44 changes: 44 additions & 0 deletions src/RuntimeModule.ts
Original file line number Diff line number Diff line change
@@ -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 { EventEmitter } from "events";

import { ModuleApi } from "./ModuleApi";
import { PlainSubstitution } from "./types/translations";

// TODO: Type the event emitter with AnyLifecycle (extract TypedEventEmitter from js-sdk somehow?)
// See https://github.com/matrix-org/matrix-react-sdk-module-api/issues/4
Comment on lines +22 to +23
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't this just grow a peer-dep to js-sdk given it knows it'll have a js-sdk from react-sdk?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potentially. I've been trying to avoid the peer dependency style as it'll end up being a slew of warnings for developers (because we won't update the dependency very often)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's fine though because its a peer-dep, you could even specify the min version and say any version from there. >=12.4.1 (replacing with the actual version we introduced TypedEventEmitter)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

until we release a breaking change of the js-sdk and then it is considered unmatched (both logically and by the semver checks - we can't assume it'll continue to compile).

For now I'm not that concerned with typed event emitter support. Documentation can supplement it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then why not add yet another layer called matrix-js-common or something :P?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if serious, but it's quite literally on the table for consideration.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be a good home for some things for element-desktop to re-use too, like fetchdep.sh

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably for a larger conversation outside of this PR though ;)


/**
* Represents a module which is loaded at runtime. Modules which implement this class
* will be provided information about the application state and can react to it.
*/
export abstract class RuntimeModule extends EventEmitter {
protected constructor(protected readonly moduleApi: ModuleApi) {
super();
}

/**
* Run a string through the translation engine. Shortcut to ModuleApi#translateString().
* @param s The string.
* @param variables The variables, if any.
* @returns The translated string.
* @protected
*/
protected t(s: string, variables?: Record<string, PlainSubstitution>): string {
return this.moduleApi.translateString(s, variables);
}
}
62 changes: 62 additions & 0 deletions src/components/DialogContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
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 * as React from "react";
import { ModuleApi } from "../ModuleApi";
import { PlainSubstitution } from "../types/translations";

export interface DialogProps {
moduleApi: ModuleApi;
}

export interface DialogState {
busy: boolean;
error?: string;
}

export abstract class DialogContent<P extends DialogProps = DialogProps, S extends DialogState = DialogState, M extends object = {}>
extends React.PureComponent<P, S> {

protected constructor(props: P, state?: S) {
super(props);

this.state = {
busy: false,
...state,
};
}

/**
* Run a string through the translation engine. Shortcut to ModuleApi#translateString().
* @param s The string.
* @param variables The variables, if any.
* @returns The translated string.
* @protected
*/
protected t(s: string, variables?: Record<string, PlainSubstitution>): string {
return this.props.moduleApi.translateString(s, variables);
}

/**
* Called when the dialog is submitted. Note that calling this will not submit the
* dialog by default - this component will be wrapped in a form which handles keyboard
* submission and buttons on its own.
*
* If the returned promise resolves then the dialog will be closed, otherwise the dialog
* will stay open.
*/
public abstract trySubmit(): Promise<M>;
}
29 changes: 29 additions & 0 deletions src/components/Spinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
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 * as React from "react";

export class Spinner extends React.PureComponent {
/**
* The factory this component uses to render itself. Set to a different value to override.
* @returns The component, rendered.
*/
public static renderFactory = (): React.ReactNode => null;

public render() {
return Spinner.renderFactory();
}
}
Loading