diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..25d8e3c7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,150 @@ +Contributing code to hydrogen-web +================================== + +Everyone is welcome to contribute code to hydrogen-web, provided that they are +willing to license their contributions under the same license as the project +itself. We follow a simple 'inbound=outbound' model for contributions: the act +of submitting an 'inbound' contribution means that the contributor agrees to +license the code under the same terms as the project's overall 'outbound' +license - in this case, Apache Software License v2 (see +[LICENSE](LICENSE)). + +How to contribute +----------------- + +The preferred and easiest way to contribute changes to the project is to fork +it on github, and then create a pull request to ask us to pull your changes +into our repo (https://help.github.com/articles/using-pull-requests/) + +We use GitHub's pull request workflow to review the contribution, and either +ask you to make any refinements needed or merge it and make them ourselves. + +Things that should go into your PR description: + * References to any bugs fixed by the change (in GitHub's `Fixes` notation) + * Describe the why and what is changing in the PR description so it's easy for + onlookers and reviewers to onboard and context switch. + * If your PR makes visual changes, include both **before** and **after** screenshots + to easily compare and discuss what's changing. + * Include a step-by-step testing strategy so that a reviewer can check out the + code locally and easily get to the point of testing your change. + * Add comments to the diff for the reviewer that might help them to understand + why the change is necessary or how they might better understand and review it. + +We use continuous integration, and all pull requests get automatically tested: +if your change breaks the build, then the PR will show that there are failed +checks, so please check back after a few minutes. + +Tests +----- +If your PR is a feature then we require that the PR also includes tests. +These need to test that your feature works as expected and ideally test edge cases too. + +Tests are written as unit tests by exporting a `tests` function from the file to be tested. +The function returns an object where the key is the test label, and the value is a +function that accepts an [assert](https://nodejs.org/api/assert.html) object, and return a Promise or nothing. + +Note that there is currently a limitation that files that are not indirectly included from `src/platform/web/main.js` won't be found by the runner. + +You can run the tests by running `yarn test`. +This uses the [impunity](https://github.com/bwindels/impunity) runner. + +We don't require tests for bug fixes. + +In the future we may formalise this more. + +Code style +---------- +The js-sdk aims to target TypeScript/ES6. All new files should be written in +TypeScript and existing files should use ES6 principles where possible. + +Please disable any automatic formatting tools you may have active. +If present, you'll be asked to undo any unrelated whitespace changes during code review. + +Members should not be exported as a default export in general. +In general, avoid using `export default`. + +The remaining code-style for hydrogen is [in the process of being documented](codestyle.md), but +contributors are encouraged to read the +[code style document for matrix-react-sdk](https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md) +and follow the principles set out there. + +Please ensure your changes match the cosmetic style of the existing project, +and ***never*** mix cosmetic and functional changes in the same commit, as it +makes it horribly hard to review otherwise. + +Attribution +----------- +If you change or create a file, feel free to add yourself to the copyright holders +in the license header of that file. + +Sign off +-------- +In order to have a concrete record that your contribution is intentional +and you agree to license it under the same terms as the project's license, we've +adopted the same lightweight approach that the Linux Kernel +(https://www.kernel.org/doc/Documentation/SubmittingPatches), Docker +(https://github.com/docker/docker/blob/master/CONTRIBUTING.md), and many other +projects use: the DCO (Developer Certificate of Origin: +http://developercertificate.org/). This is a simple declaration that you wrote +the contribution or otherwise have the right to contribute it to Matrix: + +``` +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. +660 York Street, Suite 102, +San Francisco, CA 94110 USA + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. +``` + +If you agree to this for your contribution, then all that's needed is to +include the line in your commit or pull request comment: + +``` +Signed-off-by: Your Name +``` + +We accept contributions under a legally identifiable name, such as your name on +government documentation or common-law names (names claimed by legitimate usage +or repute). Unfortunately, we cannot accept anonymous contributions at this +time. + +Git allows you to add this signoff automatically when using the `-s` flag to +`git commit`, which uses the name and email set in your `user.name` and +`user.email` git configs. + +If you forgot to sign off your commits before making your pull request and are +on Git 2.17+ you can mass signoff using rebase: + +``` +git rebase --signoff origin/develop +``` diff --git a/package.json b/package.json index cec780ae..17719801 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "lint-ts": "eslint src/ -c .ts-eslintrc.js --ext .ts", "lint-ci": "eslint src/", "test": "impunity --entry-point src/platform/web/main.js src/platform/web/Platform.js --force-esm-dirs lib/ src/ --root-dir src/", + "test:postcss": "impunity --entry-point scripts/postcss/test.js ", "start": "vite --port 3000", "build": "vite build", "build:sdk": "./scripts/sdk/build.sh" @@ -30,6 +31,7 @@ "acorn": "^8.6.0", "acorn-walk": "^8.2.0", "aes-js": "^3.1.2", + "bs58": "^4.0.1", "core-js": "^3.6.5", "es6-promise": "https://github.com/bwindels/es6-promise.git#bwindels/expose-flush", "escodegen": "^2.0.0", @@ -45,13 +47,14 @@ "text-encoding": "^0.7.0", "typescript": "^4.3.5", "vite": "todo: wait for next Vite release", - "xxhashjs": "^0.2.2", - "bs58": "^4.0.1" + "xxhashjs": "^0.2.2" }, "dependencies": { "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz", "another-json": "^0.2.0", "base64-arraybuffer": "^0.2.0", - "dompurify": "^2.3.0" + "dompurify": "^2.3.0", + "off-color": "^2.0.0", + "postcss-value-parser": "^4.2.0" } } diff --git a/scripts/.eslintrc.js b/scripts/.eslintrc.js new file mode 100644 index 00000000..1cdfca84 --- /dev/null +++ b/scripts/.eslintrc.js @@ -0,0 +1,18 @@ +module.exports = { + "env": { + "node": true, + "es6": true + }, + "extends": "eslint:recommended", + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "rules": { + "no-console": "off", + "no-empty": "off", + "no-prototype-builtins": "off", + "no-unused-vars": "warn" + }, +}; + diff --git a/scripts/postcss/color.js b/scripts/postcss/color.js new file mode 100644 index 00000000..f61dac1e --- /dev/null +++ b/scripts/postcss/color.js @@ -0,0 +1,31 @@ +/* +Copyright 2021 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. +*/ + +const offColor = require("off-color").offColor; + +module.exports.derive = function (value, operation, argument) { + const argumentAsNumber = parseInt(argument); + switch (operation) { + case "darker": { + const newColorString = offColor(value).darken(argumentAsNumber / 100).hex(); + return newColorString; + } + case "lighter": { + const newColorString = offColor(value).lighten(argumentAsNumber / 100).hex(); + return newColorString; + } + } +} diff --git a/scripts/postcss/css-compile-variables.js b/scripts/postcss/css-compile-variables.js new file mode 100644 index 00000000..3ed34513 --- /dev/null +++ b/scripts/postcss/css-compile-variables.js @@ -0,0 +1,127 @@ +/* +Copyright 2021 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. +*/ + +const valueParser = require("postcss-value-parser"); + +/** + * This plugin derives new css variables from a given set of base variables. + * A derived css variable has the form --base--operation-argument; meaning that the derived + * variable has a value that is generated from the base variable "base" by applying "operation" + * with given "argument". + * + * eg: given the base variable --foo-color: #40E0D0, --foo-color--darker-20 is a css variable + * derived from foo-color by making it 20% more darker. + * + * All derived variables are added to the :root section. + * + * The actual derivation is done outside the plugin in a callback. + */ + +let aliasMap; +let resolvedMap; +let baseVariables; + +function getValueFromAlias(alias) { + const derivedVariable = aliasMap.get(alias); + return baseVariables.get(derivedVariable) ?? resolvedMap.get(derivedVariable); +} + +function parseDeclarationValue(value) { + const parsed = valueParser(value); + const variables = []; + parsed.walk(node => { + if (node.type !== "function" && node.value !== "var") { + return; + } + const variable = node.nodes[0]; + variables.push(variable.value); + }); + return variables; +} + +function resolveDerivedVariable(decl, derive) { + const RE_VARIABLE_VALUE = /--((.+)--(.+)-(.+))/; + const variableCollection = parseDeclarationValue(decl.value); + for (const variable of variableCollection) { + const matches = variable.match(RE_VARIABLE_VALUE); + if (matches) { + const [, wholeVariable, baseVariable, operation, argument] = matches; + const value = baseVariables.get(baseVariable) ?? getValueFromAlias(baseVariable); + if (!value) { + throw new Error(`Cannot derive from ${baseVariable} because it is neither defined in config nor is it an alias!`); + } + const derivedValue = derive(value, operation, argument); + resolvedMap.set(wholeVariable, derivedValue); + } + } +} + +function extract(decl) { + if (decl.variable) { + // see if right side is of form "var(--foo)" + const wholeVariable = decl.value.match(/var\(--(.+)\)/)?.[1]; + // remove -- from the prop + const prop = decl.prop.substring(2); + if (wholeVariable) { + aliasMap.set(prop, wholeVariable); + // Since this is an alias, we shouldn't store it in baseVariables + return; + } + baseVariables.set(prop, decl.value); + } +} + +function addResolvedVariablesToRootSelector(root, {Rule, Declaration}) { + const newRule = new Rule({ selector: ":root", source: root.source }); + // Add derived css variables to :root + resolvedMap.forEach((value, key) => { + const declaration = new Declaration({prop: `--${key}`, value}); + newRule.append(declaration); + }); + root.append(newRule); +} + +/** + * @callback derive + * @param {string} value - The base value on which an operation is applied + * @param {string} operation - The operation to be applied (eg: darker, lighter...) + * @param {string} argument - The argument for this operation + */ +/** + * + * @param {Object} opts - Options for the plugin + * @param {derive} opts.derive - The callback which contains the logic for resolving derived variables + */ +module.exports = (opts = {}) => { + aliasMap = new Map(); + resolvedMap = new Map(); + baseVariables = new Map(); + return { + postcssPlugin: "postcss-compile-variables", + + Once(root, {Rule, Declaration}) { + /* + Go through the CSS file once to extract all aliases and base variables. + We use these when resolving derived variables later. + */ + root.walkDecls(decl => extract(decl)); + root.walkDecls(decl => resolveDerivedVariable(decl, opts.derive)); + addResolvedVariablesToRootSelector(root, {Rule, Declaration}); + }, + }; +}; + +module.exports.postcss = true; diff --git a/scripts/postcss/test.js b/scripts/postcss/test.js new file mode 100644 index 00000000..36ff9282 --- /dev/null +++ b/scripts/postcss/test.js @@ -0,0 +1,121 @@ +/* +Copyright 2021 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. +*/ + +const offColor = require("off-color").offColor; +const postcss = require("postcss"); +const plugin = require("./css-compile-variables"); +const derive = require("./color").derive; + +async function run(input, output, opts = {}, assert) { + let result = await postcss([plugin({ ...opts, derive })]).process(input, { from: undefined, }); + assert.strictEqual( + result.css.replaceAll(/\s/g, ""), + output.replaceAll(/\s/g, "") + ); + assert.strictEqual(result.warnings().length, 0); +} + +module.exports.tests = function tests() { + return { + "derived variables are resolved": async (assert) => { + const inputCSS = ` + :root { + --foo-color: #ff0; + } + div { + background-color: var(--foo-color--lighter-50); + }`; + const transformedColor = offColor("#ff0").lighten(0.5); + const outputCSS = + inputCSS + + ` + :root { + --foo-color--lighter-50: ${transformedColor.hex()}; + } + `; + await run( inputCSS, outputCSS, {}, assert); + }, + + "derived variables work with alias": async (assert) => { + const inputCSS = ` + :root { + --icon-color: #fff; + } + div { + background: var(--icon-color--darker-20); + --my-alias: var(--icon-color--darker-20); + color: var(--my-alias--lighter-15); + }`; + const colorDarker = offColor("#fff").darken(0.2).hex(); + const aliasLighter = offColor(colorDarker).lighten(0.15).hex(); + const outputCSS = inputCSS + `:root { + --icon-color--darker-20: ${colorDarker}; + --my-alias--lighter-15: ${aliasLighter}; + } + `; + await run(inputCSS, outputCSS, { }, assert); + }, + + "derived variable throws if base not present in config": async (assert) => { + const css = `:root { + color: var(--icon-color--darker-20); + }`; + assert.rejects(async () => await postcss([plugin({ variables: {} })]).process(css, { from: undefined, })); + }, + + "multiple derived variable in single declaration is parsed correctly": async (assert) => { + const inputCSS = ` + :root { + --foo-color: #ff0; + } + div { + background-color: linear-gradient(var(--foo-color--lighter-50), var(--foo-color--darker-20)); + }`; + const transformedColor1 = offColor("#ff0").lighten(0.5); + const transformedColor2 = offColor("#ff0").darken(0.2); + const outputCSS = + inputCSS + + ` + :root { + --foo-color--lighter-50: ${transformedColor1.hex()}; + --foo-color--darker-20: ${transformedColor2.hex()}; + } + `; + await run( inputCSS, outputCSS, { }, assert); + }, + "multiple aliased-derived variable in single declaration is parsed correctly": async (assert) => { + const inputCSS = ` + :root { + --foo-color: #ff0; + } + div { + --my-alias: var(--foo-color); + background-color: linear-gradient(var(--my-alias--lighter-50), var(--my-alias--darker-20)); + }`; + const transformedColor1 = offColor("#ff0").lighten(0.5); + const transformedColor2 = offColor("#ff0").darken(0.2); + const outputCSS = + inputCSS + + ` + :root { + --my-alias--lighter-50: ${transformedColor1.hex()}; + --my-alias--darker-20: ${transformedColor2.hex()}; + } + `; + await run( inputCSS, outputCSS, { }, assert); + } + }; +}; diff --git a/scripts/sdk/base-manifest.json b/scripts/sdk/base-manifest.json index 71ef28e2..fbc2b39c 100644 --- a/scripts/sdk/base-manifest.json +++ b/scripts/sdk/base-manifest.json @@ -1,7 +1,7 @@ { "name": "hydrogen-view-sdk", "description": "Embeddable matrix client library, including view components", - "version": "0.0.5", + "version": "0.0.9", "main": "./hydrogen.cjs.js", "exports": { ".": { diff --git a/src/domain/AccountSetupViewModel.js b/src/domain/AccountSetupViewModel.js index 4ad0d8d5..e7c1301f 100644 --- a/src/domain/AccountSetupViewModel.js +++ b/src/domain/AccountSetupViewModel.js @@ -14,14 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "./ViewModel.js"; +import {ViewModel} from "./ViewModel"; import {KeyType} from "../matrix/ssss/index"; import {Status} from "./session/settings/KeyBackupViewModel.js"; export class AccountSetupViewModel extends ViewModel { - constructor(accountSetup) { - super(); - this._accountSetup = accountSetup; + constructor(options) { + super(options); + this._accountSetup = options.accountSetup; this._dehydratedDevice = undefined; this._decryptDehydratedDeviceViewModel = undefined; if (this._accountSetup.encryptedDehydratedDevice) { @@ -53,7 +53,7 @@ export class AccountSetupViewModel extends ViewModel { // this vm adopts the same shape as KeyBackupViewModel so the same view can be reused. class DecryptDehydratedDeviceViewModel extends ViewModel { constructor(accountSetupViewModel, decryptedCallback) { - super(); + super(accountSetupViewModel.options); this._accountSetupViewModel = accountSetupViewModel; this._isBusy = false; this._status = Status.SetupKey; diff --git a/src/domain/LogoutViewModel.js b/src/domain/LogoutViewModel.ts similarity index 76% rename from src/domain/LogoutViewModel.js rename to src/domain/LogoutViewModel.ts index f22637de..3edfcad5 100644 --- a/src/domain/LogoutViewModel.js +++ b/src/domain/LogoutViewModel.ts @@ -14,11 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "./ViewModel.js"; +import {Options, ViewModel} from "./ViewModel"; import {Client} from "../matrix/Client.js"; -export class LogoutViewModel extends ViewModel { - constructor(options) { +type LogoutOptions = { sessionId: string; } & Options; + +export class LogoutViewModel extends ViewModel { + private _sessionId: string; + private _busy: boolean; + private _showConfirm: boolean; + private _error?: Error; + + constructor(options: LogoutOptions) { super(options); this._sessionId = options.sessionId; this._busy = false; @@ -26,19 +33,19 @@ export class LogoutViewModel extends ViewModel { this._error = undefined; } - get showConfirm() { + get showConfirm(): boolean { return this._showConfirm; } - get busy() { + get busy(): boolean { return this._busy; } - get cancelUrl() { + get cancelUrl(): string { return this.urlCreator.urlForSegment("session", true); } - async logout() { + async logout(): Promise { this._busy = true; this._showConfirm = false; this.emitChange("busy"); @@ -53,7 +60,7 @@ export class LogoutViewModel extends ViewModel { } } - get status() { + get status(): string { if (this._error) { return this.i18n`Could not log out of device: ${this._error.message}`; } else { diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 70f5b554..2711cd2f 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -18,9 +18,9 @@ import {Client} from "../matrix/Client.js"; import {SessionViewModel} from "./session/SessionViewModel.js"; import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; import {LoginViewModel} from "./login/LoginViewModel.js"; -import {LogoutViewModel} from "./LogoutViewModel.js"; +import {LogoutViewModel} from "./LogoutViewModel"; import {SessionPickerViewModel} from "./SessionPickerViewModel.js"; -import {ViewModel} from "./ViewModel.js"; +import {ViewModel} from "./ViewModel"; export class RootViewModel extends ViewModel { constructor(options) { diff --git a/src/domain/SessionLoadViewModel.js b/src/domain/SessionLoadViewModel.js index 24df2546..abc16299 100644 --- a/src/domain/SessionLoadViewModel.js +++ b/src/domain/SessionLoadViewModel.js @@ -17,7 +17,7 @@ limitations under the License. import {AccountSetupViewModel} from "./AccountSetupViewModel.js"; import {LoadStatus} from "../matrix/Client.js"; import {SyncStatus} from "../matrix/Sync.js"; -import {ViewModel} from "./ViewModel.js"; +import {ViewModel} from "./ViewModel"; export class SessionLoadViewModel extends ViewModel { constructor(options) { @@ -43,7 +43,7 @@ export class SessionLoadViewModel extends ViewModel { this.emitChange("loading"); this._waitHandle = this._client.loadStatus.waitFor(s => { if (s === LoadStatus.AccountSetup) { - this._accountSetupViewModel = new AccountSetupViewModel(this._client.accountSetup); + this._accountSetupViewModel = new AccountSetupViewModel(this.childOptions({accountSetup: this._client.accountSetup})); } else { this._accountSetupViewModel = undefined; } diff --git a/src/domain/SessionPickerViewModel.js b/src/domain/SessionPickerViewModel.js index e4bbc7ec..e486c64f 100644 --- a/src/domain/SessionPickerViewModel.js +++ b/src/domain/SessionPickerViewModel.js @@ -15,8 +15,8 @@ limitations under the License. */ import {SortedArray} from "../observable/index.js"; -import {ViewModel} from "./ViewModel.js"; -import {avatarInitials, getIdentifierColorNumber} from "./avatar.js"; +import {ViewModel} from "./ViewModel"; +import {avatarInitials, getIdentifierColorNumber} from "./avatar"; class SessionItemViewModel extends ViewModel { constructor(options, pickerVM) { diff --git a/src/domain/ViewModel.js b/src/domain/ViewModel.ts similarity index 64% rename from src/domain/ViewModel.js rename to src/domain/ViewModel.ts index 0c665194..cfe22326 100644 --- a/src/domain/ViewModel.js +++ b/src/domain/ViewModel.ts @@ -1,5 +1,6 @@ /* Copyright 2020 Bruno Windels +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. @@ -21,54 +22,72 @@ limitations under the License. import {EventEmitter} from "../utils/EventEmitter"; import {Disposables} from "../utils/Disposables"; -export class ViewModel extends EventEmitter { - constructor(options = {}) { +import type {Disposable} from "../utils/Disposables"; +import type {Platform} from "../platform/web/Platform"; +import type {Clock} from "../platform/web/dom/Clock"; +import type {ILogger} from "../logging/types"; +import type {Navigation} from "./navigation/Navigation"; +import type {URLRouter} from "./navigation/URLRouter"; + +export type Options = { + platform: Platform + logger: ILogger + urlCreator: URLRouter + navigation: Navigation + emitChange?: (params: any) => void +} + +export class ViewModel extends EventEmitter<{change: never}> { + private disposables?: Disposables; + private _isDisposed = false; + private _options: O; + + constructor(options: O) { super(); - this.disposables = null; - this._isDisposed = false; this._options = options; } - childOptions(explicitOptions) { - const {navigation, urlCreator, platform} = this._options; - return Object.assign({navigation, urlCreator, platform}, explicitOptions); + childOptions(explicitOptions: T): T & Options { + return Object.assign({}, this._options, explicitOptions); } + get options(): O { return this._options; } + // makes it easier to pass through dependencies of a sub-view model - getOption(name) { + getOption(name: N): O[N] { return this._options[name]; } - track(disposable) { + track(disposable: D): D { if (!this.disposables) { this.disposables = new Disposables(); } return this.disposables.track(disposable); } - untrack(disposable) { + untrack(disposable: Disposable): undefined { if (this.disposables) { return this.disposables.untrack(disposable); } - return null; + return undefined; } - dispose() { + dispose(): void { if (this.disposables) { this.disposables.dispose(); } this._isDisposed = true; } - get isDisposed() { + get isDisposed(): boolean { return this._isDisposed; } - disposeTracked(disposable) { + disposeTracked(disposable: Disposable | undefined): undefined { if (this.disposables) { return this.disposables.disposeTracked(disposable); } - return null; + return undefined; } // TODO: this will need to support binding @@ -76,7 +95,7 @@ export class ViewModel extends EventEmitter { // // translated string should probably always be bindings, unless we're fine with a refresh when changing the language? // we probably are, if we're using routing with a url, we could just refresh. - i18n(parts, ...expr) { + i18n(parts: TemplateStringsArray, ...expr: any[]) { // just concat for now let result = ""; for (let i = 0; i < parts.length; ++i) { @@ -88,11 +107,11 @@ export class ViewModel extends EventEmitter { return result; } - updateOptions(options) { + updateOptions(options: O): void { this._options = Object.assign(this._options, options); } - emitChange(changedProps) { + emitChange(changedProps: any): void { if (this._options.emitChange) { this._options.emitChange(changedProps); } else { @@ -100,27 +119,23 @@ export class ViewModel extends EventEmitter { } } - get platform() { + get platform(): Platform { return this._options.platform; } - get clock() { + get clock(): Clock { return this._options.platform.clock; } - get logger() { + get logger(): ILogger { return this.platform.logger; } - /** - * The url router, only meant to be used to create urls with from view models. - * @return {URLRouter} - */ - get urlCreator() { + get urlCreator(): URLRouter { return this._options.urlCreator; } - get navigation() { + get navigation(): Navigation { return this._options.navigation; } } diff --git a/src/domain/avatar.js b/src/domain/avatar.ts similarity index 74% rename from src/domain/avatar.js rename to src/domain/avatar.ts index 5b32020b..6f1ef8b0 100644 --- a/src/domain/avatar.js +++ b/src/domain/avatar.ts @@ -14,7 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -export function avatarInitials(name) { +import { Platform } from "../platform/web/Platform"; +import { MediaRepository } from "../matrix/net/MediaRepository"; + +export function avatarInitials(name: string): string { let firstChar = name.charAt(0); if (firstChar === "!" || firstChar === "@" || firstChar === "#") { firstChar = name.charAt(1); @@ -29,10 +32,10 @@ export function avatarInitials(name) { * * @return {number} */ -function hashCode(str) { +function hashCode(str: string): number { let hash = 0; - let i; - let chr; + let i: number; + let chr: number; if (str.length === 0) { return hash; } @@ -44,11 +47,11 @@ function hashCode(str) { return Math.abs(hash); } -export function getIdentifierColorNumber(id) { +export function getIdentifierColorNumber(id: string): number { return (hashCode(id) % 8) + 1; } -export function getAvatarHttpUrl(avatarUrl, cssSize, platform, mediaRepository) { +export function getAvatarHttpUrl(avatarUrl: string, cssSize: number, platform: Platform, mediaRepository: MediaRepository): string | null { if (avatarUrl) { const imageSize = cssSize * platform.devicePixelRatio; return mediaRepository.mxcUrlThumbnail(avatarUrl, imageSize, imageSize, "crop"); diff --git a/src/domain/login/CompleteSSOLoginViewModel.js b/src/domain/login/CompleteSSOLoginViewModel.js index daa2aa9f..d41d53ec 100644 --- a/src/domain/login/CompleteSSOLoginViewModel.js +++ b/src/domain/login/CompleteSSOLoginViewModel.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../ViewModel.js"; +import {ViewModel} from "../ViewModel"; import {LoginFailure} from "../../matrix/Client.js"; export class CompleteSSOLoginViewModel extends ViewModel { diff --git a/src/domain/login/LoginViewModel.js b/src/domain/login/LoginViewModel.js index b91df4cc..bf77e624 100644 --- a/src/domain/login/LoginViewModel.js +++ b/src/domain/login/LoginViewModel.js @@ -15,7 +15,7 @@ limitations under the License. */ import {Client} from "../../matrix/Client.js"; -import {ViewModel} from "../ViewModel.js"; +import {ViewModel} from "../ViewModel"; import {PasswordLoginViewModel} from "./PasswordLoginViewModel.js"; import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js"; import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js"; diff --git a/src/domain/login/PasswordLoginViewModel.js b/src/domain/login/PasswordLoginViewModel.js index 5fd8271f..7c4ff78a 100644 --- a/src/domain/login/PasswordLoginViewModel.js +++ b/src/domain/login/PasswordLoginViewModel.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../ViewModel.js"; +import {ViewModel} from "../ViewModel"; import {LoginFailure} from "../../matrix/Client.js"; export class PasswordLoginViewModel extends ViewModel { diff --git a/src/domain/login/StartSSOLoginViewModel.js b/src/domain/login/StartSSOLoginViewModel.js index 54218d22..dba0bcb5 100644 --- a/src/domain/login/StartSSOLoginViewModel.js +++ b/src/domain/login/StartSSOLoginViewModel.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../ViewModel.js"; +import {ViewModel} from "../ViewModel"; export class StartSSOLoginViewModel extends ViewModel{ constructor(options) { diff --git a/src/domain/session/CreateRoomViewModel.js b/src/domain/session/CreateRoomViewModel.js index 51a9b7a4..12b4fbd5 100644 --- a/src/domain/session/CreateRoomViewModel.js +++ b/src/domain/session/CreateRoomViewModel.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../ViewModel.js"; +import {ViewModel} from "../ViewModel"; import {imageToInfo} from "./common.js"; import {RoomType} from "../../matrix/room/common"; diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index d89d821a..a7d19054 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../ViewModel.js"; +import {ViewModel} from "../ViewModel"; import {addPanelIfNeeded} from "../navigation/index.js"; function dedupeSparse(roomIds) { diff --git a/src/domain/session/SessionStatusViewModel.js b/src/domain/session/SessionStatusViewModel.js index 3f2263ac..8f1d0748 100644 --- a/src/domain/session/SessionStatusViewModel.js +++ b/src/domain/session/SessionStatusViewModel.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../ViewModel.js"; +import {ViewModel} from "../ViewModel"; import {createEnum} from "../../utils/enum"; import {ConnectionStatus} from "../../matrix/net/Reconnector"; import {SyncStatus} from "../../matrix/Sync.js"; diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 24276f42..a67df3a7 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -25,7 +25,7 @@ import {SessionStatusViewModel} from "./SessionStatusViewModel.js"; import {RoomGridViewModel} from "./RoomGridViewModel.js"; import {SettingsViewModel} from "./settings/SettingsViewModel.js"; import {CreateRoomViewModel} from "./CreateRoomViewModel.js"; -import {ViewModel} from "../ViewModel.js"; +import {ViewModel} from "../ViewModel"; import {RoomViewModelObservable} from "./RoomViewModelObservable.js"; import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js"; diff --git a/src/domain/session/leftpanel/BaseTileViewModel.js b/src/domain/session/leftpanel/BaseTileViewModel.js index b360b1d4..8f5106bf 100644 --- a/src/domain/session/leftpanel/BaseTileViewModel.js +++ b/src/domain/session/leftpanel/BaseTileViewModel.js @@ -15,8 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; -import {ViewModel} from "../../ViewModel.js"; +import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar"; +import {ViewModel} from "../../ViewModel"; const KIND_ORDER = ["roomBeingCreated", "invite", "room"]; diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index 843ed1ca..2fd3ca7e 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../../ViewModel.js"; +import {ViewModel} from "../../ViewModel"; import {RoomTileViewModel} from "./RoomTileViewModel.js"; import {InviteTileViewModel} from "./InviteTileViewModel.js"; import {RoomBeingCreatedTileViewModel} from "./RoomBeingCreatedTileViewModel.js"; diff --git a/src/domain/session/rightpanel/MemberDetailsViewModel.js b/src/domain/session/rightpanel/MemberDetailsViewModel.js index f6cbd747..b3c8278c 100644 --- a/src/domain/session/rightpanel/MemberDetailsViewModel.js +++ b/src/domain/session/rightpanel/MemberDetailsViewModel.js @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../../ViewModel.js"; +import {ViewModel} from "../../ViewModel"; import {RoomType} from "../../../matrix/room/common"; -import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; +import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar"; export class MemberDetailsViewModel extends ViewModel { constructor(options) { diff --git a/src/domain/session/rightpanel/MemberListViewModel.js b/src/domain/session/rightpanel/MemberListViewModel.js index 5b8bb83e..b75a3d1c 100644 --- a/src/domain/session/rightpanel/MemberListViewModel.js +++ b/src/domain/session/rightpanel/MemberListViewModel.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../../ViewModel.js"; +import {ViewModel} from "../../ViewModel"; import {MemberTileViewModel} from "./MemberTileViewModel.js"; import {createMemberComparator} from "./members/comparator.js"; import {Disambiguator} from "./members/disambiguator.js"; diff --git a/src/domain/session/rightpanel/MemberTileViewModel.js b/src/domain/session/rightpanel/MemberTileViewModel.js index eac6a6d4..153c70c8 100644 --- a/src/domain/session/rightpanel/MemberTileViewModel.js +++ b/src/domain/session/rightpanel/MemberTileViewModel.js @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../../ViewModel.js"; -import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; +import {ViewModel} from "../../ViewModel"; +import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar"; export class MemberTileViewModel extends ViewModel { constructor(options) { diff --git a/src/domain/session/rightpanel/RightPanelViewModel.js b/src/domain/session/rightpanel/RightPanelViewModel.js index 3cfe378b..b4b6b4eb 100644 --- a/src/domain/session/rightpanel/RightPanelViewModel.js +++ b/src/domain/session/rightpanel/RightPanelViewModel.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../../ViewModel.js"; +import {ViewModel} from "../../ViewModel"; import {RoomDetailsViewModel} from "./RoomDetailsViewModel.js"; import {MemberListViewModel} from "./MemberListViewModel.js"; import {MemberDetailsViewModel} from "./MemberDetailsViewModel.js"; diff --git a/src/domain/session/rightpanel/RoomDetailsViewModel.js b/src/domain/session/rightpanel/RoomDetailsViewModel.js index 5e509fd5..4e2735b1 100644 --- a/src/domain/session/rightpanel/RoomDetailsViewModel.js +++ b/src/domain/session/rightpanel/RoomDetailsViewModel.js @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../../ViewModel.js"; -import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; +import {ViewModel} from "../../ViewModel"; +import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar"; export class RoomDetailsViewModel extends ViewModel { constructor(options) { diff --git a/src/domain/session/room/ComposerViewModel.js b/src/domain/session/room/ComposerViewModel.js index 730e1b20..c20f6e86 100644 --- a/src/domain/session/room/ComposerViewModel.js +++ b/src/domain/session/room/ComposerViewModel.js @@ -14,11 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../../ViewModel.js"; +import {ViewModel} from "../../ViewModel"; export class ComposerViewModel extends ViewModel { constructor(roomVM) { - super(); + super(roomVM.options); this._roomVM = roomVM; this._isEmpty = true; this._replyVM = null; diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js index 81a08e44..00697642 100644 --- a/src/domain/session/room/InviteViewModel.js +++ b/src/domain/session/room/InviteViewModel.js @@ -15,8 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; -import {ViewModel} from "../../ViewModel.js"; +import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar"; +import {ViewModel} from "../../ViewModel"; export class InviteViewModel extends ViewModel { constructor(options) { diff --git a/src/domain/session/room/LightboxViewModel.js b/src/domain/session/room/LightboxViewModel.js index f6da39b0..8ce8757a 100644 --- a/src/domain/session/room/LightboxViewModel.js +++ b/src/domain/session/room/LightboxViewModel.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../../ViewModel.js"; +import {ViewModel} from "../../ViewModel"; export class LightboxViewModel extends ViewModel { constructor(options) { diff --git a/src/domain/session/room/RoomBeingCreatedViewModel.js b/src/domain/session/room/RoomBeingCreatedViewModel.js index f98c86f9..b503af73 100644 --- a/src/domain/session/room/RoomBeingCreatedViewModel.js +++ b/src/domain/session/room/RoomBeingCreatedViewModel.js @@ -15,8 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; -import {ViewModel} from "../../ViewModel.js"; +import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar"; +import {ViewModel} from "../../ViewModel"; export class RoomBeingCreatedViewModel extends ViewModel { constructor(options) { diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 2d10c7ca..71060728 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -17,9 +17,9 @@ limitations under the License. import {TimelineViewModel} from "./timeline/TimelineViewModel.js"; import {ComposerViewModel} from "./ComposerViewModel.js" -import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; +import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar"; import {tilesCreator} from "./timeline/tilesCreator.js"; -import {ViewModel} from "../../ViewModel.js"; +import {ViewModel} from "../../ViewModel"; import {imageToInfo} from "../common.js"; export class RoomViewModel extends ViewModel { diff --git a/src/domain/session/room/UnknownRoomViewModel.js b/src/domain/session/room/UnknownRoomViewModel.js index e7969298..8bb5fb0a 100644 --- a/src/domain/session/room/UnknownRoomViewModel.js +++ b/src/domain/session/room/UnknownRoomViewModel.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../../ViewModel.js"; +import {ViewModel} from "../../ViewModel"; export class UnknownRoomViewModel extends ViewModel { constructor(options) { @@ -55,4 +55,4 @@ export class UnknownRoomViewModel extends ViewModel { get kind() { return "unknown"; } -} \ No newline at end of file +} diff --git a/src/domain/session/room/timeline/MessageBody.js b/src/domain/session/room/timeline/MessageBody.js index a8bf2497..65b487a9 100644 --- a/src/domain/session/room/timeline/MessageBody.js +++ b/src/domain/session/room/timeline/MessageBody.js @@ -1,5 +1,5 @@ import { linkify } from "./linkify/linkify.js"; -import { getIdentifierColorNumber, avatarInitials } from "../../../avatar.js"; +import { getIdentifierColorNumber, avatarInitials } from "../../../avatar"; /** * Parse text into parts such as newline, links and text. diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index fa48bec0..4f366af0 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -13,7 +13,7 @@ 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 {ObservableMap} from "../../../../observable/map/ObservableMap.js"; +import {ObservableMap} from "../../../../observable/map/ObservableMap"; export class ReactionsViewModel { constructor(parentTile) { diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index 9c936218..2408146d 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -32,7 +32,7 @@ to the room timeline, which unload entries from memory. when loading, it just reads events from a sortkey backwards or forwards... */ import {TilesCollection} from "./TilesCollection.js"; -import {ViewModel} from "../../../ViewModel.js"; +import {ViewModel} from "../../../ViewModel"; export class TimelineViewModel extends ViewModel { constructor(options) { diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 6b0b4356..3385a587 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -16,7 +16,7 @@ limitations under the License. import {SimpleTile} from "./SimpleTile.js"; import {ReactionsViewModel} from "../ReactionsViewModel.js"; -import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../../../avatar.js"; +import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../../../avatar"; export class BaseMessageTile extends SimpleTile { constructor(options) { diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index 4c1c1de0..af2b0e12 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -15,7 +15,7 @@ limitations under the License. */ import {UpdateAction} from "../UpdateAction.js"; -import {ViewModel} from "../../../../ViewModel.js"; +import {ViewModel} from "../../../../ViewModel"; import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js"; export class SimpleTile extends ViewModel { @@ -44,6 +44,10 @@ export class SimpleTile extends ViewModel { return this._entry.asEventKey(); } + get eventId() { + return this._entry.id; + } + get isPending() { return this._entry.isPending; } diff --git a/src/domain/session/settings/KeyBackupViewModel.js b/src/domain/session/settings/KeyBackupViewModel.js index b44de7e5..243b0d7c 100644 --- a/src/domain/session/settings/KeyBackupViewModel.js +++ b/src/domain/session/settings/KeyBackupViewModel.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../../ViewModel.js"; +import {ViewModel} from "../../ViewModel"; import {KeyType} from "../../../matrix/ssss/index"; import {createEnum} from "../../../utils/enum"; diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index 0b68f168..7464a659 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../../ViewModel.js"; +import {ViewModel} from "../../ViewModel"; import {KeyBackupViewModel} from "./KeyBackupViewModel.js"; class PushNotificationStatus { diff --git a/src/lib.ts b/src/lib.ts index 3e191d45..a0ada84f 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -16,6 +16,7 @@ limitations under the License. export {Platform} from "./platform/web/Platform.js"; export {Client, LoadStatus} from "./matrix/Client.js"; +export {RoomStatus} from "./matrix/room/common"; // export main view & view models export {createNavigation, createRouter} from "./domain/navigation/index.js"; export {RootViewModel} from "./domain/RootViewModel.js"; @@ -30,6 +31,23 @@ export {Navigation} from "./domain/navigation/Navigation.js"; export {ComposerViewModel} from "./domain/session/room/ComposerViewModel.js"; export {MessageComposer} from "./platform/web/ui/session/room/MessageComposer.js"; export {TemplateView} from "./platform/web/ui/general/TemplateView"; -export {ViewModel} from "./domain/ViewModel.js"; +export {ViewModel} from "./domain/ViewModel"; export {LoadingView} from "./platform/web/ui/general/LoadingView.js"; export {AvatarView} from "./platform/web/ui/AvatarView.js"; +export {RoomType} from "./matrix/room/common"; +export {EventEmitter} from "./utils/EventEmitter"; +export {Disposables} from "./utils/Disposables"; +// these should eventually be moved to another library +export { + ObservableArray, + SortedArray, + MappedList, + AsyncMappedList, + ConcatList, + ObservableMap +} from "./observable/index"; +export { + BaseObservableValue, + ObservableValue, + RetainedObservableValue +} from "./observable/ObservableValue"; diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 83a2df02..ae1dea61 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -26,8 +26,8 @@ import {User} from "./User.js"; import {DeviceMessageHandler} from "./DeviceMessageHandler.js"; import {Account as E2EEAccount} from "./e2ee/Account.js"; import {uploadAccountAsDehydratedDevice} from "./e2ee/Dehydration.js"; -import {Decryption as OlmDecryption} from "./e2ee/olm/Decryption.js"; -import {Encryption as OlmEncryption} from "./e2ee/olm/Encryption.js"; +import {Decryption as OlmDecryption} from "./e2ee/olm/Decryption"; +import {Encryption as OlmEncryption} from "./e2ee/olm/Encryption"; import {Decryption as MegOlmDecryption} from "./e2ee/megolm/Decryption"; import {KeyLoader as MegOlmKeyLoader} from "./e2ee/megolm/decryption/KeyLoader"; import {KeyBackup} from "./e2ee/megolm/keybackup/KeyBackup"; @@ -123,25 +123,24 @@ export class Session { // TODO: this should all go in a wrapper in e2ee/ that is bootstrapped by passing in the account // and can create RoomEncryption objects and handle encrypted to_device messages and device list changes. const senderKeyLock = new LockMap(); - const olmDecryption = new OlmDecryption({ - account: this._e2eeAccount, - pickleKey: PICKLE_KEY, - olm: this._olm, - storage: this._storage, - now: this._platform.clock.now, - ownUserId: this._user.id, + const olmDecryption = new OlmDecryption( + this._e2eeAccount, + PICKLE_KEY, + this._platform.clock.now, + this._user.id, + this._olm, senderKeyLock - }); - this._olmEncryption = new OlmEncryption({ - account: this._e2eeAccount, - pickleKey: PICKLE_KEY, - olm: this._olm, - storage: this._storage, - now: this._platform.clock.now, - ownUserId: this._user.id, - olmUtil: this._olmUtil, + ); + this._olmEncryption = new OlmEncryption( + this._e2eeAccount, + PICKLE_KEY, + this._olm, + this._storage, + this._platform.clock.now, + this._user.id, + this._olmUtil, senderKeyLock - }); + ); this._keyLoader = new MegOlmKeyLoader(this._olm, PICKLE_KEY, 20); this._megolmEncryption = new MegOlmEncryption({ account: this._e2eeAccount, diff --git a/src/matrix/e2ee/DecryptionResult.js b/src/matrix/e2ee/DecryptionResult.ts similarity index 66% rename from src/matrix/e2ee/DecryptionResult.js rename to src/matrix/e2ee/DecryptionResult.ts index e1c2bcc4..7735856a 100644 --- a/src/matrix/e2ee/DecryptionResult.js +++ b/src/matrix/e2ee/DecryptionResult.ts @@ -26,35 +26,41 @@ limitations under the License. * see DeviceTracker */ +import type {DeviceIdentity} from "../storage/idb/stores/DeviceIdentityStore"; +type DecryptedEvent = { + type?: string, + content?: Record +} export class DecryptionResult { - constructor(event, senderCurve25519Key, claimedEd25519Key) { - this.event = event; - this.senderCurve25519Key = senderCurve25519Key; - this.claimedEd25519Key = claimedEd25519Key; - this._device = null; - this._roomTracked = true; + private device?: DeviceIdentity; + private roomTracked: boolean = true; + + constructor( + public readonly event: DecryptedEvent, + public readonly senderCurve25519Key: string, + public readonly claimedEd25519Key: string + ) {} + + setDevice(device: DeviceIdentity): void { + this.device = device; } - setDevice(device) { - this._device = device; + setRoomNotTrackedYet(): void { + this.roomTracked = false; } - setRoomNotTrackedYet() { - this._roomTracked = false; - } - - get isVerified() { - if (this._device) { - const comesFromDevice = this._device.ed25519Key === this.claimedEd25519Key; + get isVerified(): boolean { + if (this.device) { + const comesFromDevice = this.device.ed25519Key === this.claimedEd25519Key; return comesFromDevice; } return false; } - get isUnverified() { - if (this._device) { + get isUnverified(): boolean { + if (this.device) { return !this.isVerified; } else if (this.isVerificationUnknown) { return false; @@ -63,8 +69,8 @@ export class DecryptionResult { } } - get isVerificationUnknown() { + get isVerificationUnknown(): boolean { // verification is unknown if we haven't yet fetched the devices for the room - return !this._device && !this._roomTracked; + return !this.device && !this.roomTracked; } } diff --git a/src/matrix/e2ee/megolm/decryption/SessionDecryption.ts b/src/matrix/e2ee/megolm/decryption/SessionDecryption.ts index f56feb47..57ef9a96 100644 --- a/src/matrix/e2ee/megolm/decryption/SessionDecryption.ts +++ b/src/matrix/e2ee/megolm/decryption/SessionDecryption.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {DecryptionResult} from "../../DecryptionResult.js"; +import {DecryptionResult} from "../../DecryptionResult"; import {DecryptionError} from "../../common.js"; import {ReplayDetectionEntry} from "./ReplayDetectionEntry"; import type {RoomKey} from "./RoomKey"; diff --git a/src/matrix/e2ee/olm/Decryption.js b/src/matrix/e2ee/olm/Decryption.ts similarity index 68% rename from src/matrix/e2ee/olm/Decryption.js rename to src/matrix/e2ee/olm/Decryption.ts index 16e617a5..0f96f2fc 100644 --- a/src/matrix/e2ee/olm/Decryption.js +++ b/src/matrix/e2ee/olm/Decryption.ts @@ -16,32 +16,47 @@ limitations under the License. import {DecryptionError} from "../common.js"; import {groupBy} from "../../../utils/groupBy"; -import {MultiLock} from "../../../utils/Lock"; -import {Session} from "./Session.js"; -import {DecryptionResult} from "../DecryptionResult.js"; +import {MultiLock, ILock} from "../../../utils/Lock"; +import {Session} from "./Session"; +import {DecryptionResult} from "../DecryptionResult"; +import {OlmPayloadType} from "./types"; + +import type {OlmMessage, OlmPayload} from "./types"; +import type {Account} from "../Account"; +import type {LockMap} from "../../../utils/LockMap"; +import type {Transaction} from "../../storage/idb/Transaction"; +import type {OlmEncryptedEvent} from "./types"; +import type * as OlmNamespace from "@matrix-org/olm"; +type Olm = typeof OlmNamespace; const SESSION_LIMIT_PER_SENDER_KEY = 4; -function isPreKeyMessage(message) { - return message.type === 0; -} +type DecryptionResults = { + results: DecryptionResult[], + errors: DecryptionError[], + senderKeyDecryption: SenderKeyDecryption +}; -function sortSessions(sessions) { +type CreateAndDecryptResult = { + session: Session, + plaintext: string +}; + +function sortSessions(sessions: Session[]): void { sessions.sort((a, b) => { return b.data.lastUsed - a.data.lastUsed; }); } export class Decryption { - constructor({account, pickleKey, now, ownUserId, storage, olm, senderKeyLock}) { - this._account = account; - this._pickleKey = pickleKey; - this._now = now; - this._ownUserId = ownUserId; - this._storage = storage; - this._olm = olm; - this._senderKeyLock = senderKeyLock; - } + constructor( + private readonly account: Account, + private readonly pickleKey: string, + private readonly now: () => number, + private readonly ownUserId: string, + private readonly olm: Olm, + private readonly senderKeyLock: LockMap + ) {} // we need to lock because both encryption and decryption can't be done in one txn, // so for them not to step on each other toes, we need to lock. @@ -50,8 +65,8 @@ export class Decryption { // - decryptAll below fails (to release the lock as early as we can) // - DecryptionChanges.write succeeds // - Sync finishes the writeSync phase (or an error was thrown, in case we never get to DecryptionChanges.write) - async obtainDecryptionLock(events) { - const senderKeys = new Set(); + async obtainDecryptionLock(events: OlmEncryptedEvent[]): Promise { + const senderKeys = new Set(); for (const event of events) { const senderKey = event.content?.["sender_key"]; if (senderKey) { @@ -61,7 +76,7 @@ export class Decryption { // take a lock on all senderKeys so encryption or other calls to decryptAll (should not happen) // don't modify the sessions at the same time const locks = await Promise.all(Array.from(senderKeys).map(senderKey => { - return this._senderKeyLock.takeLock(senderKey); + return this.senderKeyLock.takeLock(senderKey); })); return new MultiLock(locks); } @@ -83,18 +98,18 @@ export class Decryption { * @param {[type]} events * @return {Promise} [description] */ - async decryptAll(events, lock, txn) { + async decryptAll(events: OlmEncryptedEvent[], lock: ILock, txn: Transaction): Promise { try { - const eventsPerSenderKey = groupBy(events, event => event.content?.["sender_key"]); - const timestamp = this._now(); + const eventsPerSenderKey = groupBy(events, (event: OlmEncryptedEvent) => event.content?.["sender_key"]); + const timestamp = this.now(); // decrypt events for different sender keys in parallel const senderKeyOperations = await Promise.all(Array.from(eventsPerSenderKey.entries()).map(([senderKey, events]) => { - return this._decryptAllForSenderKey(senderKey, events, timestamp, txn); + return this._decryptAllForSenderKey(senderKey!, events, timestamp, txn); })); - const results = senderKeyOperations.reduce((all, r) => all.concat(r.results), []); - const errors = senderKeyOperations.reduce((all, r) => all.concat(r.errors), []); + const results = senderKeyOperations.reduce((all, r) => all.concat(r.results), [] as DecryptionResult[]); + const errors = senderKeyOperations.reduce((all, r) => all.concat(r.errors), [] as DecryptionError[]); const senderKeyDecryptions = senderKeyOperations.map(r => r.senderKeyDecryption); - return new DecryptionChanges(senderKeyDecryptions, results, errors, this._account, lock); + return new DecryptionChanges(senderKeyDecryptions, results, errors, this.account, lock); } catch (err) { // make sure the locks are release if something throws // otherwise they will be released in DecryptionChanges after having written @@ -104,11 +119,11 @@ export class Decryption { } } - async _decryptAllForSenderKey(senderKey, events, timestamp, readSessionsTxn) { + async _decryptAllForSenderKey(senderKey: string, events: OlmEncryptedEvent[], timestamp: number, readSessionsTxn: Transaction): Promise { const sessions = await this._getSessions(senderKey, readSessionsTxn); - const senderKeyDecryption = new SenderKeyDecryption(senderKey, sessions, this._olm, timestamp); - const results = []; - const errors = []; + const senderKeyDecryption = new SenderKeyDecryption(senderKey, sessions, timestamp); + const results: DecryptionResult[] = []; + const errors: DecryptionError[] = []; // events for a single senderKey need to be decrypted one by one for (const event of events) { try { @@ -121,10 +136,10 @@ export class Decryption { return {results, errors, senderKeyDecryption}; } - _decryptForSenderKey(senderKeyDecryption, event, timestamp) { + _decryptForSenderKey(senderKeyDecryption: SenderKeyDecryption, event: OlmEncryptedEvent, timestamp: number): DecryptionResult { const senderKey = senderKeyDecryption.senderKey; const message = this._getMessageAndValidateEvent(event); - let plaintext; + let plaintext: string | undefined; try { plaintext = senderKeyDecryption.decrypt(message); } catch (err) { @@ -132,8 +147,8 @@ export class Decryption { throw new DecryptionError("OLM_BAD_ENCRYPTED_MESSAGE", event, {senderKey, error: err.message}); } // could not decrypt with any existing session - if (typeof plaintext !== "string" && isPreKeyMessage(message)) { - let createResult; + if (typeof plaintext !== "string" && message.type === OlmPayloadType.PreKey) { + let createResult: CreateAndDecryptResult; try { createResult = this._createSessionAndDecrypt(senderKey, message, timestamp); } catch (error) { @@ -143,14 +158,14 @@ export class Decryption { plaintext = createResult.plaintext; } if (typeof plaintext === "string") { - let payload; + let payload: OlmPayload; try { payload = JSON.parse(plaintext); } catch (error) { throw new DecryptionError("PLAINTEXT_NOT_JSON", event, {plaintext, error}); } this._validatePayload(payload, event); - return new DecryptionResult(payload, senderKey, payload.keys.ed25519); + return new DecryptionResult(payload, senderKey, payload.keys!.ed25519!); } else { throw new DecryptionError("OLM_NO_MATCHING_SESSION", event, {knownSessionIds: senderKeyDecryption.sessions.map(s => s.id)}); @@ -158,16 +173,16 @@ export class Decryption { } // only for pre-key messages after having attempted decryption with existing sessions - _createSessionAndDecrypt(senderKey, message, timestamp) { + _createSessionAndDecrypt(senderKey: string, message: OlmMessage, timestamp: number): CreateAndDecryptResult { let plaintext; // if we have multiple messages encrypted with the same new session, // this could create multiple sessions as the OTK isn't removed yet // (this only happens in DecryptionChanges.write) // This should be ok though as we'll first try to decrypt with the new session - const olmSession = this._account.createInboundOlmSession(senderKey, message.body); + const olmSession = this.account.createInboundOlmSession(senderKey, message.body); try { plaintext = olmSession.decrypt(message.type, message.body); - const session = Session.create(senderKey, olmSession, this._olm, this._pickleKey, timestamp); + const session = Session.create(senderKey, olmSession, this.olm, this.pickleKey, timestamp); session.unload(olmSession); return {session, plaintext}; } catch (err) { @@ -176,12 +191,12 @@ export class Decryption { } } - _getMessageAndValidateEvent(event) { + _getMessageAndValidateEvent(event: OlmEncryptedEvent): OlmMessage { const ciphertext = event.content?.ciphertext; if (!ciphertext) { throw new DecryptionError("OLM_MISSING_CIPHERTEXT", event); } - const message = ciphertext?.[this._account.identityKeys.curve25519]; + const message = ciphertext?.[this.account.identityKeys.curve25519]; if (!message) { throw new DecryptionError("OLM_NOT_INCLUDED_IN_RECIPIENTS", event); } @@ -189,22 +204,22 @@ export class Decryption { return message; } - async _getSessions(senderKey, txn) { + async _getSessions(senderKey: string, txn: Transaction): Promise { const sessionEntries = await txn.olmSessions.getAll(senderKey); // sort most recent used sessions first - const sessions = sessionEntries.map(s => new Session(s, this._pickleKey, this._olm)); + const sessions = sessionEntries.map(s => new Session(s, this.pickleKey, this.olm)); sortSessions(sessions); return sessions; } - _validatePayload(payload, event) { + _validatePayload(payload: OlmPayload, event: OlmEncryptedEvent): void { if (payload.sender !== event.sender) { throw new DecryptionError("OLM_FORWARDED_MESSAGE", event, {sentBy: event.sender, encryptedBy: payload.sender}); } - if (payload.recipient !== this._ownUserId) { + if (payload.recipient !== this.ownUserId) { throw new DecryptionError("OLM_BAD_RECIPIENT", event, {recipient: payload.recipient}); } - if (payload.recipient_keys?.ed25519 !== this._account.identityKeys.ed25519) { + if (payload.recipient_keys?.ed25519 !== this.account.identityKeys.ed25519) { throw new DecryptionError("OLM_BAD_RECIPIENT_KEY", event, {key: payload.recipient_keys?.ed25519}); } // TODO: check room_id @@ -219,21 +234,20 @@ export class Decryption { // decryption helper for a single senderKey class SenderKeyDecryption { - constructor(senderKey, sessions, olm, timestamp) { - this.senderKey = senderKey; - this.sessions = sessions; - this._olm = olm; - this._timestamp = timestamp; - } + constructor( + public readonly senderKey: string, + public readonly sessions: Session[], + private readonly timestamp: number + ) {} - addNewSession(session) { + addNewSession(session: Session): void { // add at top as it is most recent this.sessions.unshift(session); } - decrypt(message) { + decrypt(message: OlmMessage): string | undefined { for (const session of this.sessions) { - const plaintext = this._decryptWithSession(session, message); + const plaintext = this.decryptWithSession(session, message); if (typeof plaintext === "string") { // keep them sorted so will try the same session first for other messages // and so we can assume the excess ones are at the end @@ -244,11 +258,11 @@ class SenderKeyDecryption { } } - getModifiedSessions() { + getModifiedSessions(): Session[] { return this.sessions.filter(session => session.isModified); } - get hasNewSessions() { + get hasNewSessions(): boolean { return this.sessions.some(session => session.isNew); } @@ -257,19 +271,22 @@ class SenderKeyDecryption { // if this turns out to be a real cost for IE11, // we could look into adding a less expensive serialization mechanism // for olm sessions to libolm - _decryptWithSession(session, message) { + private decryptWithSession(session: Session, message: OlmMessage): string | undefined { + if (message.type === undefined || message.body === undefined) { + throw new Error("Invalid message without type or body"); + } const olmSession = session.load(); try { - if (isPreKeyMessage(message) && !olmSession.matches_inbound(message.body)) { + if (message.type === OlmPayloadType.PreKey && !olmSession.matches_inbound(message.body)) { return; } try { - const plaintext = olmSession.decrypt(message.type, message.body); + const plaintext = olmSession.decrypt(message.type as number, message.body!); session.save(olmSession); - session.lastUsed = this._timestamp; + session.data.lastUsed = this.timestamp; return plaintext; } catch (err) { - if (isPreKeyMessage(message)) { + if (message.type === OlmPayloadType.PreKey) { throw new Error(`Error decrypting prekey message with existing session id ${session.id}: ${err.message}`); } // decryption failed, bail out @@ -286,27 +303,27 @@ class SenderKeyDecryption { * @property {Array} errors see DecryptionError.event to retrieve the event that failed to decrypt. */ class DecryptionChanges { - constructor(senderKeyDecryptions, results, errors, account, lock) { - this._senderKeyDecryptions = senderKeyDecryptions; - this._account = account; - this.results = results; - this.errors = errors; - this._lock = lock; + constructor( + private readonly senderKeyDecryptions: SenderKeyDecryption[], + public readonly results: DecryptionResult[], + public readonly errors: DecryptionError[], + private readonly account: Account, + private readonly lock: ILock + ) {} + + get hasNewSessions(): boolean { + return this.senderKeyDecryptions.some(skd => skd.hasNewSessions); } - get hasNewSessions() { - return this._senderKeyDecryptions.some(skd => skd.hasNewSessions); - } - - write(txn) { + write(txn: Transaction): void { try { - for (const senderKeyDecryption of this._senderKeyDecryptions) { + for (const senderKeyDecryption of this.senderKeyDecryptions) { for (const session of senderKeyDecryption.getModifiedSessions()) { txn.olmSessions.set(session.data); if (session.isNew) { const olmSession = session.load(); try { - this._account.writeRemoveOneTimeKey(olmSession, txn); + this.account.writeRemoveOneTimeKey(olmSession, txn); } finally { session.unload(olmSession); } @@ -322,7 +339,7 @@ class DecryptionChanges { } } } finally { - this._lock.release(); + this.lock.release(); } } } diff --git a/src/matrix/e2ee/olm/Encryption.js b/src/matrix/e2ee/olm/Encryption.ts similarity index 64% rename from src/matrix/e2ee/olm/Encryption.js rename to src/matrix/e2ee/olm/Encryption.ts index 652c657c..9b754272 100644 --- a/src/matrix/e2ee/olm/Encryption.js +++ b/src/matrix/e2ee/olm/Encryption.ts @@ -16,7 +16,33 @@ limitations under the License. import {groupByWithCreator} from "../../../utils/groupBy"; import {verifyEd25519Signature, OLM_ALGORITHM} from "../common.js"; -import {createSessionEntry} from "./Session.js"; +import {createSessionEntry} from "./Session"; + +import type {OlmMessage, OlmPayload, OlmEncryptedMessageContent} from "./types"; +import type {Account} from "../Account"; +import type {LockMap} from "../../../utils/LockMap"; +import type {Storage} from "../../storage/idb/Storage"; +import type {Transaction} from "../../storage/idb/Transaction"; +import type {DeviceIdentity} from "../../storage/idb/stores/DeviceIdentityStore"; +import type {HomeServerApi} from "../../net/HomeServerApi"; +import type {ILogItem} from "../../../logging/types"; +import type * as OlmNamespace from "@matrix-org/olm"; +type Olm = typeof OlmNamespace; + +type ClaimedOTKResponse = { + [userId: string]: { + [deviceId: string]: { + [algorithmAndOtk: string]: { + key: string, + signatures: { + [userId: string]: { + [algorithmAndDevice: string]: string + } + } + } + } + } +}; function findFirstSessionId(sessionIds) { return sessionIds.reduce((first, sessionId) => { @@ -36,19 +62,19 @@ const OTK_ALGORITHM = "signed_curve25519"; const MAX_BATCH_SIZE = 20; export class Encryption { - constructor({account, olm, olmUtil, ownUserId, storage, now, pickleKey, senderKeyLock}) { - this._account = account; - this._olm = olm; - this._olmUtil = olmUtil; - this._ownUserId = ownUserId; - this._storage = storage; - this._now = now; - this._pickleKey = pickleKey; - this._senderKeyLock = senderKeyLock; - } + constructor( + private readonly account: Account, + private readonly pickleKey: string, + private readonly olm: Olm, + private readonly storage: Storage, + private readonly now: () => number, + private readonly ownUserId: string, + private readonly olmUtil: Olm.Utility, + private readonly senderKeyLock: LockMap + ) {} - async encrypt(type, content, devices, hsApi, log) { - let messages = []; + async encrypt(type: string, content: Record, devices: DeviceIdentity[], hsApi: HomeServerApi, log: ILogItem): Promise { + let messages: EncryptedMessage[] = []; for (let i = 0; i < devices.length ; i += MAX_BATCH_SIZE) { const batchDevices = devices.slice(i, i + MAX_BATCH_SIZE); const batchMessages = await this._encryptForMaxDevices(type, content, batchDevices, hsApi, log); @@ -57,12 +83,12 @@ export class Encryption { return messages; } - async _encryptForMaxDevices(type, content, devices, hsApi, log) { + async _encryptForMaxDevices(type: string, content: Record, devices: DeviceIdentity[], hsApi: HomeServerApi, log: ILogItem): Promise { // TODO: see if we can only hold some of the locks until after the /keys/claim call (if needed) // take a lock on all senderKeys so decryption and other calls to encrypt (should not happen) // don't modify the sessions at the same time const locks = await Promise.all(devices.map(device => { - return this._senderKeyLock.takeLock(device.curve25519Key); + return this.senderKeyLock.takeLock(device.curve25519Key); })); try { const { @@ -70,9 +96,9 @@ export class Encryption { existingEncryptionTargets, } = await this._findExistingSessions(devices); - const timestamp = this._now(); + const timestamp = this.now(); - let encryptionTargets = []; + let encryptionTargets: EncryptionTarget[] = []; try { if (devicesWithoutSession.length) { const newEncryptionTargets = await log.wrap("create sessions", log => this._createNewSessions( @@ -100,8 +126,8 @@ export class Encryption { } } - async _findExistingSessions(devices) { - const txn = await this._storage.readTxn([this._storage.storeNames.olmSessions]); + async _findExistingSessions(devices: DeviceIdentity[]): Promise<{devicesWithoutSession: DeviceIdentity[], existingEncryptionTargets: EncryptionTarget[]}> { + const txn = await this.storage.readTxn([this.storage.storeNames.olmSessions]); const sessionIdsForDevice = await Promise.all(devices.map(async device => { return await txn.olmSessions.getSessionIds(device.curve25519Key); })); @@ -116,18 +142,18 @@ export class Encryption { const sessionId = findFirstSessionId(sessionIds); return EncryptionTarget.fromSessionId(device, sessionId); } - }).filter(target => !!target); + }).filter(target => !!target) as EncryptionTarget[]; return {devicesWithoutSession, existingEncryptionTargets}; } - _encryptForDevice(type, content, target) { + _encryptForDevice(type: string, content: Record, target: EncryptionTarget): OlmEncryptedMessageContent { const {session, device} = target; const plaintext = JSON.stringify(this._buildPlainTextMessageForDevice(type, content, device)); - const message = session.encrypt(plaintext); + const message = session!.encrypt(plaintext); const encryptedContent = { algorithm: OLM_ALGORITHM, - sender_key: this._account.identityKeys.curve25519, + sender_key: this.account.identityKeys.curve25519, ciphertext: { [device.curve25519Key]: message } @@ -135,27 +161,27 @@ export class Encryption { return encryptedContent; } - _buildPlainTextMessageForDevice(type, content, device) { + _buildPlainTextMessageForDevice(type: string, content: Record, device: DeviceIdentity): OlmPayload { return { keys: { - "ed25519": this._account.identityKeys.ed25519 + "ed25519": this.account.identityKeys.ed25519 }, recipient_keys: { "ed25519": device.ed25519Key }, recipient: device.userId, - sender: this._ownUserId, + sender: this.ownUserId, content, type } } - async _createNewSessions(devicesWithoutSession, hsApi, timestamp, log) { + async _createNewSessions(devicesWithoutSession: DeviceIdentity[], hsApi: HomeServerApi, timestamp: number, log: ILogItem): Promise { const newEncryptionTargets = await log.wrap("claim", log => this._claimOneTimeKeys(hsApi, devicesWithoutSession, log)); try { for (const target of newEncryptionTargets) { const {device, oneTimeKey} = target; - target.session = await this._account.createOutboundOlmSession(device.curve25519Key, oneTimeKey); + target.session = await this.account.createOutboundOlmSession(device.curve25519Key, oneTimeKey); } await this._storeSessions(newEncryptionTargets, timestamp); } catch (err) { @@ -167,12 +193,12 @@ export class Encryption { return newEncryptionTargets; } - async _claimOneTimeKeys(hsApi, deviceIdentities, log) { + async _claimOneTimeKeys(hsApi: HomeServerApi, deviceIdentities: DeviceIdentity[], log: ILogItem): Promise { // create a Map> const devicesByUser = groupByWithCreator(deviceIdentities, - device => device.userId, - () => new Map(), - (deviceMap, device) => deviceMap.set(device.deviceId, device) + (device: DeviceIdentity) => device.userId, + (): Map => new Map(), + (deviceMap: Map, device: DeviceIdentity) => deviceMap.set(device.deviceId, device) ); const oneTimeKeys = Array.from(devicesByUser.entries()).reduce((usersObj, [userId, deviceMap]) => { usersObj[userId] = Array.from(deviceMap.values()).reduce((devicesObj, device) => { @@ -188,12 +214,12 @@ export class Encryption { if (Object.keys(claimResponse.failures).length) { log.log({l: "failures", servers: Object.keys(claimResponse.failures)}, log.level.Warn); } - const userKeyMap = claimResponse?.["one_time_keys"]; + const userKeyMap = claimResponse?.["one_time_keys"] as ClaimedOTKResponse; return this._verifyAndCreateOTKTargets(userKeyMap, devicesByUser, log); } - _verifyAndCreateOTKTargets(userKeyMap, devicesByUser, log) { - const verifiedEncryptionTargets = []; + _verifyAndCreateOTKTargets(userKeyMap: ClaimedOTKResponse, devicesByUser: Map>, log: ILogItem): EncryptionTarget[] { + const verifiedEncryptionTargets: EncryptionTarget[] = []; for (const [userId, userSection] of Object.entries(userKeyMap)) { for (const [deviceId, deviceSection] of Object.entries(userSection)) { const [firstPropName, keySection] = Object.entries(deviceSection)[0]; @@ -202,7 +228,7 @@ export class Encryption { const device = devicesByUser.get(userId)?.get(deviceId); if (device) { const isValidSignature = verifyEd25519Signature( - this._olmUtil, userId, deviceId, device.ed25519Key, keySection, log); + this.olmUtil, userId, deviceId, device.ed25519Key, keySection, log); if (isValidSignature) { const target = EncryptionTarget.fromOTK(device, keySection.key); verifiedEncryptionTargets.push(target); @@ -214,8 +240,8 @@ export class Encryption { return verifiedEncryptionTargets; } - async _loadSessions(encryptionTargets) { - const txn = await this._storage.readTxn([this._storage.storeNames.olmSessions]); + async _loadSessions(encryptionTargets: EncryptionTarget[]): Promise { + const txn = await this.storage.readTxn([this.storage.storeNames.olmSessions]); // given we run loading in parallel, there might still be some // storage requests that will finish later once one has failed. // those should not allocate a session anymore. @@ -223,10 +249,10 @@ export class Encryption { try { await Promise.all(encryptionTargets.map(async encryptionTarget => { const sessionEntry = await txn.olmSessions.get( - encryptionTarget.device.curve25519Key, encryptionTarget.sessionId); + encryptionTarget.device.curve25519Key, encryptionTarget.sessionId!); if (sessionEntry && !failed) { - const olmSession = new this._olm.Session(); - olmSession.unpickle(this._pickleKey, sessionEntry.session); + const olmSession = new this.olm.Session(); + olmSession.unpickle(this.pickleKey, sessionEntry.session); encryptionTarget.session = olmSession; } })); @@ -240,12 +266,12 @@ export class Encryption { } } - async _storeSessions(encryptionTargets, timestamp) { - const txn = await this._storage.readWriteTxn([this._storage.storeNames.olmSessions]); + async _storeSessions(encryptionTargets: EncryptionTarget[], timestamp: number): Promise { + const txn = await this.storage.readWriteTxn([this.storage.storeNames.olmSessions]); try { for (const target of encryptionTargets) { const sessionEntry = createSessionEntry( - target.session, target.device.curve25519Key, timestamp, this._pickleKey); + target.session!, target.device.curve25519Key, timestamp, this.pickleKey); txn.olmSessions.set(sessionEntry); } } catch (err) { @@ -261,23 +287,24 @@ export class Encryption { // (and later converted to a session) in case of a new session // or an existing session class EncryptionTarget { - constructor(device, oneTimeKey, sessionId) { - this.device = device; - this.oneTimeKey = oneTimeKey; - this.sessionId = sessionId; - // an olmSession, should probably be called olmSession - this.session = null; - } + + public session: Olm.Session | null = null; - static fromOTK(device, oneTimeKey) { + constructor( + public readonly device: DeviceIdentity, + public readonly oneTimeKey: string | null, + public readonly sessionId: string | null + ) {} + + static fromOTK(device: DeviceIdentity, oneTimeKey: string): EncryptionTarget { return new EncryptionTarget(device, oneTimeKey, null); } - static fromSessionId(device, sessionId) { + static fromSessionId(device: DeviceIdentity, sessionId: string): EncryptionTarget { return new EncryptionTarget(device, null, sessionId); } - dispose() { + dispose(): void { if (this.session) { this.session.free(); } @@ -285,8 +312,8 @@ class EncryptionTarget { } class EncryptedMessage { - constructor(content, device) { - this.content = content; - this.device = device; - } + constructor( + public readonly content: OlmEncryptedMessageContent, + public readonly device: DeviceIdentity + ) {} } diff --git a/src/matrix/e2ee/olm/Session.js b/src/matrix/e2ee/olm/Session.ts similarity index 53% rename from src/matrix/e2ee/olm/Session.js rename to src/matrix/e2ee/olm/Session.ts index 9b5f4db0..f97c8478 100644 --- a/src/matrix/e2ee/olm/Session.js +++ b/src/matrix/e2ee/olm/Session.ts @@ -14,7 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -export function createSessionEntry(olmSession, senderKey, timestamp, pickleKey) { +import type {OlmSessionEntry} from "../../storage/idb/stores/OlmSessionStore"; +import type * as OlmNamespace from "@matrix-org/olm"; +type Olm = typeof OlmNamespace; + +export function createSessionEntry(olmSession: Olm.Session, senderKey: string, timestamp: number, pickleKey: string): OlmSessionEntry { return { session: olmSession.pickle(pickleKey), sessionId: olmSession.session_id(), @@ -24,35 +28,38 @@ export function createSessionEntry(olmSession, senderKey, timestamp, pickleKey) } export class Session { - constructor(data, pickleKey, olm, isNew = false) { - this.data = data; - this._olm = olm; - this._pickleKey = pickleKey; - this.isNew = isNew; + public isModified: boolean; + + constructor( + public readonly data: OlmSessionEntry, + private readonly pickleKey: string, + private readonly olm: Olm, + public isNew: boolean = false + ) { this.isModified = isNew; } - static create(senderKey, olmSession, olm, pickleKey, timestamp) { + static create(senderKey: string, olmSession: Olm.Session, olm: Olm, pickleKey: string, timestamp: number): Session { const data = createSessionEntry(olmSession, senderKey, timestamp, pickleKey); return new Session(data, pickleKey, olm, true); } - get id() { + get id(): string { return this.data.sessionId; } - load() { - const session = new this._olm.Session(); - session.unpickle(this._pickleKey, this.data.session); + load(): Olm.Session { + const session = new this.olm.Session(); + session.unpickle(this.pickleKey, this.data.session); return session; } - unload(olmSession) { + unload(olmSession: Olm.Session): void { olmSession.free(); } - save(olmSession) { - this.data.session = olmSession.pickle(this._pickleKey); + save(olmSession: Olm.Session): void { + this.data.session = olmSession.pickle(this.pickleKey); this.isModified = true; } } diff --git a/src/matrix/e2ee/olm/types.ts b/src/matrix/e2ee/olm/types.ts new file mode 100644 index 00000000..5302dad8 --- /dev/null +++ b/src/matrix/e2ee/olm/types.ts @@ -0,0 +1,48 @@ +/* +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. +*/ + +export const enum OlmPayloadType { + PreKey = 0, + Normal = 1 +} + +export type OlmMessage = { + type?: OlmPayloadType, + body?: string +} + +export type OlmEncryptedMessageContent = { + algorithm?: "m.olm.v1.curve25519-aes-sha2" + sender_key?: string, + ciphertext?: { + [deviceCurve25519Key: string]: OlmMessage + } +} + +export type OlmEncryptedEvent = { + type?: "m.room.encrypted", + content?: OlmEncryptedMessageContent + sender?: string +} + +export type OlmPayload = { + type?: string; + content?: Record; + sender?: string; + recipient?: string; + recipient_keys?: {ed25519?: string}; + keys?: {ed25519?: string}; +} diff --git a/src/matrix/registration/Registration.ts b/src/matrix/registration/Registration.ts index c9c9af87..ded66719 100644 --- a/src/matrix/registration/Registration.ts +++ b/src/matrix/registration/Registration.ts @@ -18,6 +18,7 @@ import type {HomeServerApi} from "../net/HomeServerApi"; import type {BaseRegistrationStage} from "./stages/BaseRegistrationStage"; import {DummyAuth} from "./stages/DummyAuth"; import {TermsAuth} from "./stages/TermsAuth"; +import {TokenAuth} from "./stages/TokenAuth"; import type { AccountDetails, RegistrationFlow, @@ -108,6 +109,9 @@ export class Registration { return new DummyAuth(session, params?.[type]); case "m.login.terms": return new TermsAuth(session, params?.[type]); + case "org.matrix.msc3231.login.registration_token": + case "m.login.registration_token": + return new TokenAuth(session, params?.[type], type); default: throw new Error(`Unknown stage: ${type}`); } diff --git a/src/matrix/registration/stages/TokenAuth.ts b/src/matrix/registration/stages/TokenAuth.ts new file mode 100644 index 00000000..cb238bcb --- /dev/null +++ b/src/matrix/registration/stages/TokenAuth.ts @@ -0,0 +1,48 @@ +/* +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 {AuthenticationData, RegistrationParams} from "../types"; +import {BaseRegistrationStage} from "./BaseRegistrationStage"; + +export class TokenAuth extends BaseRegistrationStage { + private _token?: string; + private readonly _type: string; + + constructor(session: string, params: RegistrationParams | undefined, type: string) { + super(session, params); + this._type = type; + } + + + generateAuthenticationData(): AuthenticationData { + if (!this._token) { + throw new Error("No token provided for TokenAuth"); + } + return { + session: this._session, + type: this._type, + token: this._token, + }; + } + + setToken(token: string) { + this._token = token; + } + + get type(): string { + return this._type; + } +} diff --git a/src/matrix/room/members/MemberList.js b/src/matrix/room/members/MemberList.js index 9923fb87..f32a63d3 100644 --- a/src/matrix/room/members/MemberList.js +++ b/src/matrix/room/members/MemberList.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ObservableMap} from "../../../observable/map/ObservableMap.js"; +import {ObservableMap} from "../../../observable/map/ObservableMap"; import {RetainedValue} from "../../../utils/RetainedValue"; export class MemberList extends RetainedValue { diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index c6852492..3332a5b0 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -24,7 +24,6 @@ import {RoomMember} from "../members/RoomMember.js"; import {getRelation, ANNOTATION_RELATION_TYPE} from "./relations.js"; import {REDACTION_TYPE} from "../common"; import {NonPersistedEventEntry} from "./entries/NonPersistedEventEntry.js"; -import {DecryptionSource} from "../../e2ee/common.js"; import {EVENT_TYPE as MEMBER_EVENT_TYPE} from "../members/RoomMember.js"; export class Timeline { diff --git a/src/matrix/storage/idb/stores/OlmSessionStore.ts b/src/matrix/storage/idb/stores/OlmSessionStore.ts index d5a79de2..1263a649 100644 --- a/src/matrix/storage/idb/stores/OlmSessionStore.ts +++ b/src/matrix/storage/idb/stores/OlmSessionStore.ts @@ -24,19 +24,19 @@ function decodeKey(key: string): { senderKey: string, sessionId: string } { return {senderKey, sessionId}; } -interface OlmSession { +export type OlmSessionEntry = { session: string; sessionId: string; senderKey: string; lastUsed: number; } -type OlmSessionEntry = OlmSession & { key: string }; +type OlmSessionStoredEntry = OlmSessionEntry & { key: string }; export class OlmSessionStore { - private _store: Store; + private _store: Store; - constructor(store: Store) { + constructor(store: Store) { this._store = store; } @@ -55,20 +55,20 @@ export class OlmSessionStore { return sessionIds; } - getAll(senderKey: string): Promise { + getAll(senderKey: string): Promise { const range = this._store.IDBKeyRange.lowerBound(encodeKey(senderKey, "")); return this._store.selectWhile(range, session => { return session.senderKey === senderKey; }); } - get(senderKey: string, sessionId: string): Promise { + get(senderKey: string, sessionId: string): Promise { return this._store.get(encodeKey(senderKey, sessionId)); } - set(session: OlmSession): void { - (session as OlmSessionEntry).key = encodeKey(session.senderKey, session.sessionId); - this._store.put(session as OlmSessionEntry); + set(session: OlmSessionEntry): void { + (session as OlmSessionStoredEntry).key = encodeKey(session.senderKey, session.sessionId); + this._store.put(session as OlmSessionStoredEntry); } remove(senderKey: string, sessionId: string): void { diff --git a/src/observable/index.js b/src/observable/index.js index 4d7f18a3..6057174b 100644 --- a/src/observable/index.js +++ b/src/observable/index.js @@ -18,14 +18,14 @@ import {SortedMapList} from "./list/SortedMapList.js"; import {FilteredMap} from "./map/FilteredMap.js"; import {MappedMap} from "./map/MappedMap.js"; import {JoinedMap} from "./map/JoinedMap.js"; -import {BaseObservableMap} from "./map/BaseObservableMap.js"; +import {BaseObservableMap} from "./map/BaseObservableMap"; // re-export "root" (of chain) collections export { ObservableArray } from "./list/ObservableArray"; export { SortedArray } from "./list/SortedArray"; export { MappedList } from "./list/MappedList"; export { AsyncMappedList } from "./list/AsyncMappedList"; export { ConcatList } from "./list/ConcatList"; -export { ObservableMap } from "./map/ObservableMap.js"; +export { ObservableMap } from "./map/ObservableMap"; // avoid circular dependency between these classes // and BaseObservableMap (as they extend it) diff --git a/src/observable/list/SortedMapList.js b/src/observable/list/SortedMapList.js index 38900380..d74dbade 100644 --- a/src/observable/list/SortedMapList.js +++ b/src/observable/list/SortedMapList.js @@ -133,7 +133,7 @@ export class SortedMapList extends BaseObservableList { } } -import {ObservableMap} from "../map/ObservableMap.js"; +import {ObservableMap} from "../map/ObservableMap"; export function tests() { return { diff --git a/src/observable/map/ApplyMap.js b/src/observable/map/ApplyMap.js index ad345595..6be7278a 100644 --- a/src/observable/map/ApplyMap.js +++ b/src/observable/map/ApplyMap.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableMap} from "./BaseObservableMap.js"; +import {BaseObservableMap} from "./BaseObservableMap"; export class ApplyMap extends BaseObservableMap { constructor(source, apply) { diff --git a/src/observable/map/BaseObservableMap.js b/src/observable/map/BaseObservableMap.ts similarity index 69% rename from src/observable/map/BaseObservableMap.js rename to src/observable/map/BaseObservableMap.ts index d3193931..694c017e 100644 --- a/src/observable/map/BaseObservableMap.js +++ b/src/observable/map/BaseObservableMap.ts @@ -16,7 +16,14 @@ limitations under the License. import {BaseObservable} from "../BaseObservable"; -export class BaseObservableMap extends BaseObservable { +export interface IMapObserver { + onReset(): void; + onAdd(key: K, value:V): void; + onUpdate(key: K, value: V, params: any): void; + onRemove(key: K, value: V): void +} + +export abstract class BaseObservableMap extends BaseObservable> { emitReset() { for(let h of this._handlers) { h.onReset(); @@ -24,15 +31,15 @@ export class BaseObservableMap extends BaseObservable { } // we need batch events, mostly on index based collection though? // maybe we should get started without? - emitAdd(key, value) { + emitAdd(key: K, value: V) { for(let h of this._handlers) { h.onAdd(key, value); } } - emitUpdate(key, value, ...params) { + emitUpdate(key, value, params) { for(let h of this._handlers) { - h.onUpdate(key, value, ...params); + h.onUpdate(key, value, params); } } @@ -42,16 +49,7 @@ export class BaseObservableMap extends BaseObservable { } } - [Symbol.iterator]() { - throw new Error("unimplemented"); - } - - get size() { - throw new Error("unimplemented"); - } - - // eslint-disable-next-line no-unused-vars - get(key) { - throw new Error("unimplemented"); - } + abstract [Symbol.iterator](): Iterator<[K, V]>; + abstract get size(): number; + abstract get(key: K): V | undefined; } diff --git a/src/observable/map/FilteredMap.js b/src/observable/map/FilteredMap.js index f7090502..d7e11fbe 100644 --- a/src/observable/map/FilteredMap.js +++ b/src/observable/map/FilteredMap.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableMap} from "./BaseObservableMap.js"; +import {BaseObservableMap} from "./BaseObservableMap"; export class FilteredMap extends BaseObservableMap { constructor(source, filter) { @@ -166,7 +166,7 @@ class FilterIterator { } } -import {ObservableMap} from "./ObservableMap.js"; +import {ObservableMap} from "./ObservableMap"; export function tests() { return { "filter preloaded list": assert => { diff --git a/src/observable/map/JoinedMap.js b/src/observable/map/JoinedMap.js index 7db04be1..d97c5677 100644 --- a/src/observable/map/JoinedMap.js +++ b/src/observable/map/JoinedMap.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableMap} from "./BaseObservableMap.js"; +import {BaseObservableMap} from "./BaseObservableMap"; export class JoinedMap extends BaseObservableMap { constructor(sources) { @@ -191,7 +191,7 @@ class SourceSubscriptionHandler { } -import { ObservableMap } from "./ObservableMap.js"; +import { ObservableMap } from "./ObservableMap"; export function tests() { diff --git a/src/observable/map/LogMap.js b/src/observable/map/LogMap.js index 4b8bb686..1beb4846 100644 --- a/src/observable/map/LogMap.js +++ b/src/observable/map/LogMap.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableMap} from "./BaseObservableMap.js"; +import {BaseObservableMap} from "./BaseObservableMap"; export class LogMap extends BaseObservableMap { constructor(source, log) { diff --git a/src/observable/map/MappedMap.js b/src/observable/map/MappedMap.js index 2a810058..a6b65c41 100644 --- a/src/observable/map/MappedMap.js +++ b/src/observable/map/MappedMap.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableMap} from "./BaseObservableMap.js"; +import {BaseObservableMap} from "./BaseObservableMap"; /* so a mapped value can emit updates on it's own with this._emitSpontaneousUpdate that is passed in the mapping function how should the mapped value be notified of an update though? and can it then decide to not propagate the update? diff --git a/src/observable/map/ObservableMap.js b/src/observable/map/ObservableMap.ts similarity index 76% rename from src/observable/map/ObservableMap.js rename to src/observable/map/ObservableMap.ts index 8f5a0922..d604ab0a 100644 --- a/src/observable/map/ObservableMap.js +++ b/src/observable/map/ObservableMap.ts @@ -14,15 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableMap} from "./BaseObservableMap.js"; +import {BaseObservableMap} from "./BaseObservableMap"; -export class ObservableMap extends BaseObservableMap { - constructor(initialValues) { +export class ObservableMap extends BaseObservableMap { + private readonly _values: Map; + + constructor(initialValues?: (readonly [K, V])[]) { super(); this._values = new Map(initialValues); } - update(key, params) { + update(key: K, params?: any): boolean { const value = this._values.get(key); if (value !== undefined) { // could be the same value, so it's already updated @@ -34,7 +36,7 @@ export class ObservableMap extends BaseObservableMap { return false; // or return existing value? } - add(key, value) { + add(key: K, value: V): boolean { if (!this._values.has(key)) { this._values.set(key, value); this.emitAdd(key, value); @@ -43,7 +45,7 @@ export class ObservableMap extends BaseObservableMap { return false; // or return existing value? } - remove(key) { + remove(key: K): boolean { const value = this._values.get(key); if (value !== undefined) { this._values.delete(key); @@ -54,39 +56,39 @@ export class ObservableMap extends BaseObservableMap { } } - set(key, value) { + set(key: K, value: V): boolean { if (this._values.has(key)) { // We set the value here because update only supports inline updates this._values.set(key, value); - return this.update(key); + return this.update(key, undefined); } else { return this.add(key, value); } } - reset() { + reset(): void { this._values.clear(); this.emitReset(); } - get(key) { + get(key: K): V | undefined { return this._values.get(key); } - get size() { + get size(): number { return this._values.size; } - [Symbol.iterator]() { + [Symbol.iterator](): Iterator<[K, V]> { return this._values.entries(); } - values() { + values(): Iterator { return this._values.values(); } - keys() { + keys(): Iterator { return this._values.keys(); } } @@ -105,13 +107,16 @@ export function tests() { test_add(assert) { let fired = 0; - const map = new ObservableMap(); + const map = new ObservableMap(); map.subscribe({ onAdd(key, value) { fired += 1; assert.equal(key, 1); assert.deepEqual(value, {value: 5}); - } + }, + onUpdate() {}, + onRemove() {}, + onReset() {} }); map.add(1, {value: 5}); assert.equal(map.size, 1); @@ -120,7 +125,7 @@ export function tests() { test_update(assert) { let fired = 0; - const map = new ObservableMap(); + const map = new ObservableMap(); const value = {number: 5}; map.add(1, value); map.subscribe({ @@ -129,7 +134,10 @@ export function tests() { assert.equal(key, 1); assert.deepEqual(value, {number: 6}); assert.equal(params, "test"); - } + }, + onAdd() {}, + onRemove() {}, + onReset() {} }); value.number = 6; map.update(1, "test"); @@ -138,9 +146,12 @@ export function tests() { test_update_unknown(assert) { let fired = 0; - const map = new ObservableMap(); + const map = new ObservableMap(); map.subscribe({ - onUpdate() { fired += 1; } + onUpdate() { fired += 1; }, + onAdd() {}, + onRemove() {}, + onReset() {} }); const result = map.update(1); assert.equal(fired, 0); @@ -149,7 +160,7 @@ export function tests() { test_set(assert) { let add_fired = 0, update_fired = 0; - const map = new ObservableMap(); + const map = new ObservableMap(); map.subscribe({ onAdd(key, value) { add_fired += 1; @@ -160,7 +171,9 @@ export function tests() { update_fired += 1; assert.equal(key, 1); assert.deepEqual(value, {value: 7}); - } + }, + onRemove() {}, + onReset() {} }); // Add map.set(1, {value: 5}); @@ -174,7 +187,7 @@ export function tests() { test_remove(assert) { let fired = 0; - const map = new ObservableMap(); + const map = new ObservableMap(); const value = {value: 5}; map.add(1, value); map.subscribe({ @@ -182,7 +195,10 @@ export function tests() { fired += 1; assert.equal(key, 1); assert.deepEqual(value, {value: 5}); - } + }, + onAdd() {}, + onUpdate() {}, + onReset() {} }); map.remove(1); assert.equal(map.size, 0); @@ -190,8 +206,8 @@ export function tests() { }, test_iterate(assert) { - const results = []; - const map = new ObservableMap(); + const results: any[] = []; + const map = new ObservableMap(); map.add(1, {number: 5}); map.add(2, {number: 6}); map.add(3, {number: 7}); @@ -204,7 +220,7 @@ export function tests() { assert.equal(results.find(([key]) => key === 3)[1].number, 7); }, test_size(assert) { - const map = new ObservableMap(); + const map = new ObservableMap(); map.add(1, {number: 5}); map.add(2, {number: 6}); assert.equal(map.size, 2); diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 9de3d4ce..984bc45c 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -37,7 +37,7 @@ import {hasReadPixelPermission, ImageHandle, VideoHandle} from "./dom/ImageHandl import {downloadInIframe} from "./dom/download.js"; import {Disposables} from "../../utils/Disposables"; import {parseHTML} from "./parsehtml.js"; -import {handleAvatarError} from "./ui/avatar.js"; +import {handleAvatarError} from "./ui/avatar"; function addScript(src) { return new Promise(function (resolve, reject) { @@ -143,7 +143,10 @@ export class Platform { this._serviceWorkerHandler.registerAndStart(assetPaths.serviceWorker); } this.notificationService = new NotificationService(this._serviceWorkerHandler, config.push); - this.crypto = new Crypto(cryptoExtras); + // Only try to use crypto when olm is provided + if(this._assetPaths.olm) { + this.crypto = new Crypto(cryptoExtras); + } this.storageFactory = new StorageFactory(this._serviceWorkerHandler); this.sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1"); this.estimateStorageUsage = estimateStorageUsage; diff --git a/src/platform/web/ui/AvatarView.js b/src/platform/web/ui/AvatarView.js index f2d94e3b..551f7307 100644 --- a/src/platform/web/ui/AvatarView.js +++ b/src/platform/web/ui/AvatarView.js @@ -15,7 +15,7 @@ limitations under the License. */ import {BaseUpdateView} from "./general/BaseUpdateView"; -import {renderStaticAvatar, renderImg} from "./avatar.js"; +import {renderStaticAvatar, renderImg} from "./avatar"; /* optimization to not use a sub view when changing between img and text diff --git a/src/platform/web/ui/general/html.ts b/src/platform/web/ui/general/html.ts index 9ebcfaaf..44f7476a 100644 --- a/src/platform/web/ui/general/html.ts +++ b/src/platform/web/ui/general/html.ts @@ -104,8 +104,9 @@ export const TAG_NAMES = { "br", "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6", "p", "strong", "em", "span", "img", "section", "main", "article", "aside", "del", "blockquote", "table", "thead", "tbody", "tr", "th", "td", "hr", - "pre", "code", "button", "time", "input", "textarea", "label", "form", "progress", "output", "video"], - [SVG_NS]: ["svg", "circle"] + "pre", "code", "button", "time", "input", "textarea", "select", "option", "label", "form", + "progress", "output", "video"], + [SVG_NS]: ["svg", "g", "path", "circle", "ellipse", "rect", "use"] } as const; export const tag: { [tagName in typeof TAG_NAMES[string][number]]: (attributes?: BasicAttributes | Child | Child[], children?: Child | Child[]) => Element } = {} as any; diff --git a/src/platform/web/ui/session/room/InviteView.js b/src/platform/web/ui/session/room/InviteView.js index 9d808abf..99345360 100644 --- a/src/platform/web/ui/session/room/InviteView.js +++ b/src/platform/web/ui/session/room/InviteView.js @@ -16,7 +16,7 @@ limitations under the License. */ import {TemplateView} from "../../general/TemplateView"; -import {renderStaticAvatar} from "../../avatar.js"; +import {renderStaticAvatar} from "../../avatar"; export class InviteView extends TemplateView { render(t, vm) { diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index a6fbb9be..7356cd2b 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {renderStaticAvatar} from "../../../avatar.js"; +import {renderStaticAvatar} from "../../../avatar"; import {tag} from "../../../general/html"; import {mountView} from "../../../general/utils"; import {TemplateView} from "../../../general/TemplateView"; @@ -40,14 +40,17 @@ export class BaseMessageView extends TemplateView { if (this._interactive) { children.push(t.button({className: "Timeline_messageOptions"}, "⋯")); } - const li = t.el(this._tagName, {className: { - "Timeline_message": true, - own: vm.isOwn, - unsent: vm.isUnsent, - unverified: vm.isUnverified, - disabled: !this._interactive, - continuation: vm => vm.isContinuation, - }}, children); + const li = t.el(this._tagName, { + className: { + "Timeline_message": true, + own: vm.isOwn, + unsent: vm.isUnsent, + unverified: vm.isUnverified, + disabled: !this._interactive, + continuation: vm => vm.isContinuation, + }, + 'data-event-id': vm.eventId + }, children); // given that there can be many tiles, we don't add // unneeded DOM nodes in case of a continuation, and we add it // with a side-effect binding to not have to create sub views, diff --git a/src/utils/Disposables.ts b/src/utils/Disposables.ts index 19a5983c..f7c7eb53 100644 --- a/src/utils/Disposables.ts +++ b/src/utils/Disposables.ts @@ -1,5 +1,6 @@ /* Copyright 2020 Bruno Windels +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. @@ -18,7 +19,7 @@ export interface IDisposable { dispose(): void; } -type Disposable = IDisposable | (() => void); +export type Disposable = IDisposable | (() => void); function disposeValue(value: Disposable): void { if (typeof value === "function") { @@ -33,9 +34,9 @@ function isDisposable(value: Disposable): boolean { } export class Disposables { - private _disposables: Disposable[] | null = []; + private _disposables?: Disposable[] = []; - track(disposable: Disposable): Disposable { + track(disposable: D): D { if (!isDisposable(disposable)) { throw new Error("Not a disposable"); } @@ -48,16 +49,16 @@ export class Disposables { return disposable; } - untrack(disposable: Disposable): null { + untrack(disposable: Disposable): undefined { if (this.isDisposed) { console.warn("Disposables already disposed, cannot untrack"); - return null; + return undefined; } const idx = this._disposables!.indexOf(disposable); if (idx >= 0) { this._disposables!.splice(idx, 1); } - return null; + return undefined; } dispose(): void { @@ -65,17 +66,17 @@ export class Disposables { for (const d of this._disposables) { disposeValue(d); } - this._disposables = null; + this._disposables = undefined; } } get isDisposed(): boolean { - return this._disposables === null; + return this._disposables === undefined; } - disposeTracked(value: Disposable): null { + disposeTracked(value: Disposable | undefined): undefined { if (value === undefined || value === null || this.isDisposed) { - return null; + return undefined; } const idx = this._disposables!.indexOf(value); if (idx !== -1) { @@ -84,6 +85,6 @@ export class Disposables { } else { console.warn("disposable not found, did it leak?", value); } - return null; + return undefined; } } diff --git a/src/utils/Lock.ts b/src/utils/Lock.ts index 238d88f9..ff623eba 100644 --- a/src/utils/Lock.ts +++ b/src/utils/Lock.ts @@ -14,7 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -export class Lock { +export interface ILock { + release(): void; +} + +export class Lock implements ILock { private _promise?: Promise; private _resolve?: (() => void); @@ -52,7 +56,7 @@ export class Lock { } } -export class MultiLock { +export class MultiLock implements ILock { constructor(public readonly locks: Lock[]) { } diff --git a/yarn.lock b/yarn.lock index 87b8ef96..7bcefdd4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1133,6 +1133,13 @@ nth-check@^2.0.0: dependencies: boolbase "^1.0.0" +off-color@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/off-color/-/off-color-2.0.0.tgz#ecf3bda52e9a78dde535db86361e048741a56631" + integrity sha512-JJ9ObbY2CzgT7F8PpdpHGNjQa7QbU8f4DkY3cCxYUq9NezYUMmL/oSofCc5MMaiUnNNBEFCc4w1unMA+R8syvw== + dependencies: + core-js "^3.6.5" + once@^1.3.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -1215,6 +1222,11 @@ postcss-flexbugs-fixes@^5.0.2: resolved "https://registry.yarnpkg.com/postcss-flexbugs-fixes/-/postcss-flexbugs-fixes-5.0.2.tgz#2028e145313074fc9abe276cb7ca14e5401eb49d" integrity sha512-18f9voByak7bTktR2QgDveglpn9DTbBWPUzSOe9g0N4WR/2eSt6Vrcbf0hmspvMI6YWGywz6B9f7jzpFNJJgnQ== +postcss-value-parser@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" + integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== + postcss@^8.3.8: version "8.3.9" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.3.9.tgz#98754caa06c4ee9eb59cc48bd073bb6bd3437c31"