Merge branch 'master' into madlittlemods/686-682-local-friendly-development-and-commonjs

Conflicts:
	package.json
	scripts/sdk/base-manifest.json
This commit is contained in:
Eric Eastwood 2022-04-05 17:15:30 -05:00
commit d247bc4e28
72 changed files with 1063 additions and 373 deletions

150
CONTRIBUTING.md Normal file
View file

@ -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 <your@email.example.org>
```
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
```

View file

@ -10,6 +10,7 @@
"lint-ts": "eslint src/ -c .ts-eslintrc.js --ext .ts", "lint-ts": "eslint src/ -c .ts-eslintrc.js --ext .ts",
"lint-ci": "eslint src/", "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": "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", "start": "vite --port 3000",
"build": "vite build", "build": "vite build",
"build:sdk": "./scripts/sdk/build.sh" "build:sdk": "./scripts/sdk/build.sh"
@ -30,6 +31,7 @@
"acorn": "^8.6.0", "acorn": "^8.6.0",
"acorn-walk": "^8.2.0", "acorn-walk": "^8.2.0",
"aes-js": "^3.1.2", "aes-js": "^3.1.2",
"bs58": "^4.0.1",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"es6-promise": "https://github.com/bwindels/es6-promise.git#bwindels/expose-flush", "es6-promise": "https://github.com/bwindels/es6-promise.git#bwindels/expose-flush",
"escodegen": "^2.0.0", "escodegen": "^2.0.0",
@ -45,13 +47,14 @@
"text-encoding": "^0.7.0", "text-encoding": "^0.7.0",
"typescript": "^4.3.5", "typescript": "^4.3.5",
"vite": "todo: wait for next Vite release", "vite": "todo: wait for next Vite release",
"xxhashjs": "^0.2.2", "xxhashjs": "^0.2.2"
"bs58": "^4.0.1"
}, },
"dependencies": { "dependencies": {
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz", "@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", "another-json": "^0.2.0",
"base64-arraybuffer": "^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"
} }
} }

18
scripts/.eslintrc.js Normal file
View file

@ -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"
},
};

31
scripts/postcss/color.js Normal file
View file

@ -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;
}
}
}

View file

@ -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;

121
scripts/postcss/test.js Normal file
View file

@ -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);
}
};
};

View file

@ -1,7 +1,7 @@
{ {
"name": "hydrogen-view-sdk", "name": "hydrogen-view-sdk",
"description": "Embeddable matrix client library, including view components", "description": "Embeddable matrix client library, including view components",
"version": "0.0.5", "version": "0.0.9",
"main": "./hydrogen.cjs.js", "main": "./hydrogen.cjs.js",
"exports": { "exports": {
".": { ".": {

View file

@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "./ViewModel.js"; import {ViewModel} from "./ViewModel";
import {KeyType} from "../matrix/ssss/index"; import {KeyType} from "../matrix/ssss/index";
import {Status} from "./session/settings/KeyBackupViewModel.js"; import {Status} from "./session/settings/KeyBackupViewModel.js";
export class AccountSetupViewModel extends ViewModel { export class AccountSetupViewModel extends ViewModel {
constructor(accountSetup) { constructor(options) {
super(); super(options);
this._accountSetup = accountSetup; this._accountSetup = options.accountSetup;
this._dehydratedDevice = undefined; this._dehydratedDevice = undefined;
this._decryptDehydratedDeviceViewModel = undefined; this._decryptDehydratedDeviceViewModel = undefined;
if (this._accountSetup.encryptedDehydratedDevice) { 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. // this vm adopts the same shape as KeyBackupViewModel so the same view can be reused.
class DecryptDehydratedDeviceViewModel extends ViewModel { class DecryptDehydratedDeviceViewModel extends ViewModel {
constructor(accountSetupViewModel, decryptedCallback) { constructor(accountSetupViewModel, decryptedCallback) {
super(); super(accountSetupViewModel.options);
this._accountSetupViewModel = accountSetupViewModel; this._accountSetupViewModel = accountSetupViewModel;
this._isBusy = false; this._isBusy = false;
this._status = Status.SetupKey; this._status = Status.SetupKey;

View file

@ -14,11 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "./ViewModel.js"; import {Options, ViewModel} from "./ViewModel";
import {Client} from "../matrix/Client.js"; import {Client} from "../matrix/Client.js";
export class LogoutViewModel extends ViewModel { type LogoutOptions = { sessionId: string; } & Options;
constructor(options) {
export class LogoutViewModel extends ViewModel<LogoutOptions> {
private _sessionId: string;
private _busy: boolean;
private _showConfirm: boolean;
private _error?: Error;
constructor(options: LogoutOptions) {
super(options); super(options);
this._sessionId = options.sessionId; this._sessionId = options.sessionId;
this._busy = false; this._busy = false;
@ -26,19 +33,19 @@ export class LogoutViewModel extends ViewModel {
this._error = undefined; this._error = undefined;
} }
get showConfirm() { get showConfirm(): boolean {
return this._showConfirm; return this._showConfirm;
} }
get busy() { get busy(): boolean {
return this._busy; return this._busy;
} }
get cancelUrl() { get cancelUrl(): string {
return this.urlCreator.urlForSegment("session", true); return this.urlCreator.urlForSegment("session", true);
} }
async logout() { async logout(): Promise<void> {
this._busy = true; this._busy = true;
this._showConfirm = false; this._showConfirm = false;
this.emitChange("busy"); this.emitChange("busy");
@ -53,7 +60,7 @@ export class LogoutViewModel extends ViewModel {
} }
} }
get status() { get status(): string {
if (this._error) { if (this._error) {
return this.i18n`Could not log out of device: ${this._error.message}`; return this.i18n`Could not log out of device: ${this._error.message}`;
} else { } else {

View file

@ -18,9 +18,9 @@ import {Client} from "../matrix/Client.js";
import {SessionViewModel} from "./session/SessionViewModel.js"; import {SessionViewModel} from "./session/SessionViewModel.js";
import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; import {SessionLoadViewModel} from "./SessionLoadViewModel.js";
import {LoginViewModel} from "./login/LoginViewModel.js"; import {LoginViewModel} from "./login/LoginViewModel.js";
import {LogoutViewModel} from "./LogoutViewModel.js"; import {LogoutViewModel} from "./LogoutViewModel";
import {SessionPickerViewModel} from "./SessionPickerViewModel.js"; import {SessionPickerViewModel} from "./SessionPickerViewModel.js";
import {ViewModel} from "./ViewModel.js"; import {ViewModel} from "./ViewModel";
export class RootViewModel extends ViewModel { export class RootViewModel extends ViewModel {
constructor(options) { constructor(options) {

View file

@ -17,7 +17,7 @@ limitations under the License.
import {AccountSetupViewModel} from "./AccountSetupViewModel.js"; import {AccountSetupViewModel} from "./AccountSetupViewModel.js";
import {LoadStatus} from "../matrix/Client.js"; import {LoadStatus} from "../matrix/Client.js";
import {SyncStatus} from "../matrix/Sync.js"; import {SyncStatus} from "../matrix/Sync.js";
import {ViewModel} from "./ViewModel.js"; import {ViewModel} from "./ViewModel";
export class SessionLoadViewModel extends ViewModel { export class SessionLoadViewModel extends ViewModel {
constructor(options) { constructor(options) {
@ -43,7 +43,7 @@ export class SessionLoadViewModel extends ViewModel {
this.emitChange("loading"); this.emitChange("loading");
this._waitHandle = this._client.loadStatus.waitFor(s => { this._waitHandle = this._client.loadStatus.waitFor(s => {
if (s === LoadStatus.AccountSetup) { if (s === LoadStatus.AccountSetup) {
this._accountSetupViewModel = new AccountSetupViewModel(this._client.accountSetup); this._accountSetupViewModel = new AccountSetupViewModel(this.childOptions({accountSetup: this._client.accountSetup}));
} else { } else {
this._accountSetupViewModel = undefined; this._accountSetupViewModel = undefined;
} }

View file

@ -15,8 +15,8 @@ limitations under the License.
*/ */
import {SortedArray} from "../observable/index.js"; import {SortedArray} from "../observable/index.js";
import {ViewModel} from "./ViewModel.js"; import {ViewModel} from "./ViewModel";
import {avatarInitials, getIdentifierColorNumber} from "./avatar.js"; import {avatarInitials, getIdentifierColorNumber} from "./avatar";
class SessionItemViewModel extends ViewModel { class SessionItemViewModel extends ViewModel {
constructor(options, pickerVM) { constructor(options, pickerVM) {

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud> Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 {EventEmitter} from "../utils/EventEmitter";
import {Disposables} from "../utils/Disposables"; import {Disposables} from "../utils/Disposables";
export class ViewModel extends EventEmitter { import type {Disposable} from "../utils/Disposables";
constructor(options = {}) { 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<O extends Options = Options> extends EventEmitter<{change: never}> {
private disposables?: Disposables;
private _isDisposed = false;
private _options: O;
constructor(options: O) {
super(); super();
this.disposables = null;
this._isDisposed = false;
this._options = options; this._options = options;
} }
childOptions(explicitOptions) { childOptions<T extends Object>(explicitOptions: T): T & Options {
const {navigation, urlCreator, platform} = this._options; return Object.assign({}, this._options, explicitOptions);
return Object.assign({navigation, urlCreator, platform}, explicitOptions);
} }
get options(): O { return this._options; }
// makes it easier to pass through dependencies of a sub-view model // makes it easier to pass through dependencies of a sub-view model
getOption(name) { getOption<N extends keyof O>(name: N): O[N] {
return this._options[name]; return this._options[name];
} }
track(disposable) { track<D extends Disposable>(disposable: D): D {
if (!this.disposables) { if (!this.disposables) {
this.disposables = new Disposables(); this.disposables = new Disposables();
} }
return this.disposables.track(disposable); return this.disposables.track(disposable);
} }
untrack(disposable) { untrack(disposable: Disposable): undefined {
if (this.disposables) { if (this.disposables) {
return this.disposables.untrack(disposable); return this.disposables.untrack(disposable);
} }
return null; return undefined;
} }
dispose() { dispose(): void {
if (this.disposables) { if (this.disposables) {
this.disposables.dispose(); this.disposables.dispose();
} }
this._isDisposed = true; this._isDisposed = true;
} }
get isDisposed() { get isDisposed(): boolean {
return this._isDisposed; return this._isDisposed;
} }
disposeTracked(disposable) { disposeTracked(disposable: Disposable | undefined): undefined {
if (this.disposables) { if (this.disposables) {
return this.disposables.disposeTracked(disposable); return this.disposables.disposeTracked(disposable);
} }
return null; return undefined;
} }
// TODO: this will need to support binding // 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? // 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. // 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 // just concat for now
let result = ""; let result = "";
for (let i = 0; i < parts.length; ++i) { for (let i = 0; i < parts.length; ++i) {
@ -88,11 +107,11 @@ export class ViewModel extends EventEmitter {
return result; return result;
} }
updateOptions(options) { updateOptions(options: O): void {
this._options = Object.assign(this._options, options); this._options = Object.assign(this._options, options);
} }
emitChange(changedProps) { emitChange(changedProps: any): void {
if (this._options.emitChange) { if (this._options.emitChange) {
this._options.emitChange(changedProps); this._options.emitChange(changedProps);
} else { } else {
@ -100,27 +119,23 @@ export class ViewModel extends EventEmitter {
} }
} }
get platform() { get platform(): Platform {
return this._options.platform; return this._options.platform;
} }
get clock() { get clock(): Clock {
return this._options.platform.clock; return this._options.platform.clock;
} }
get logger() { get logger(): ILogger {
return this.platform.logger; return this.platform.logger;
} }
/** get urlCreator(): URLRouter {
* The url router, only meant to be used to create urls with from view models.
* @return {URLRouter}
*/
get urlCreator() {
return this._options.urlCreator; return this._options.urlCreator;
} }
get navigation() { get navigation(): Navigation {
return this._options.navigation; return this._options.navigation;
} }
} }

View file

@ -14,7 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. 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); let firstChar = name.charAt(0);
if (firstChar === "!" || firstChar === "@" || firstChar === "#") { if (firstChar === "!" || firstChar === "@" || firstChar === "#") {
firstChar = name.charAt(1); firstChar = name.charAt(1);
@ -29,10 +32,10 @@ export function avatarInitials(name) {
* *
* @return {number} * @return {number}
*/ */
function hashCode(str) { function hashCode(str: string): number {
let hash = 0; let hash = 0;
let i; let i: number;
let chr; let chr: number;
if (str.length === 0) { if (str.length === 0) {
return hash; return hash;
} }
@ -44,11 +47,11 @@ function hashCode(str) {
return Math.abs(hash); return Math.abs(hash);
} }
export function getIdentifierColorNumber(id) { export function getIdentifierColorNumber(id: string): number {
return (hashCode(id) % 8) + 1; 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) { if (avatarUrl) {
const imageSize = cssSize * platform.devicePixelRatio; const imageSize = cssSize * platform.devicePixelRatio;
return mediaRepository.mxcUrlThumbnail(avatarUrl, imageSize, imageSize, "crop"); return mediaRepository.mxcUrlThumbnail(avatarUrl, imageSize, imageSize, "crop");

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../ViewModel.js"; import {ViewModel} from "../ViewModel";
import {LoginFailure} from "../../matrix/Client.js"; import {LoginFailure} from "../../matrix/Client.js";
export class CompleteSSOLoginViewModel extends ViewModel { export class CompleteSSOLoginViewModel extends ViewModel {

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import {Client} from "../../matrix/Client.js"; import {Client} from "../../matrix/Client.js";
import {ViewModel} from "../ViewModel.js"; import {ViewModel} from "../ViewModel";
import {PasswordLoginViewModel} from "./PasswordLoginViewModel.js"; import {PasswordLoginViewModel} from "./PasswordLoginViewModel.js";
import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js"; import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js";
import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js"; import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js";

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../ViewModel.js"; import {ViewModel} from "../ViewModel";
import {LoginFailure} from "../../matrix/Client.js"; import {LoginFailure} from "../../matrix/Client.js";
export class PasswordLoginViewModel extends ViewModel { export class PasswordLoginViewModel extends ViewModel {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../ViewModel.js"; import {ViewModel} from "../ViewModel";
export class StartSSOLoginViewModel extends ViewModel{ export class StartSSOLoginViewModel extends ViewModel{
constructor(options) { constructor(options) {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../ViewModel.js"; import {ViewModel} from "../ViewModel";
import {imageToInfo} from "./common.js"; import {imageToInfo} from "./common.js";
import {RoomType} from "../../matrix/room/common"; import {RoomType} from "../../matrix/room/common";

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../ViewModel.js"; import {ViewModel} from "../ViewModel";
import {addPanelIfNeeded} from "../navigation/index.js"; import {addPanelIfNeeded} from "../navigation/index.js";
function dedupeSparse(roomIds) { function dedupeSparse(roomIds) {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../ViewModel.js"; import {ViewModel} from "../ViewModel";
import {createEnum} from "../../utils/enum"; import {createEnum} from "../../utils/enum";
import {ConnectionStatus} from "../../matrix/net/Reconnector"; import {ConnectionStatus} from "../../matrix/net/Reconnector";
import {SyncStatus} from "../../matrix/Sync.js"; import {SyncStatus} from "../../matrix/Sync.js";

View file

@ -25,7 +25,7 @@ import {SessionStatusViewModel} from "./SessionStatusViewModel.js";
import {RoomGridViewModel} from "./RoomGridViewModel.js"; import {RoomGridViewModel} from "./RoomGridViewModel.js";
import {SettingsViewModel} from "./settings/SettingsViewModel.js"; import {SettingsViewModel} from "./settings/SettingsViewModel.js";
import {CreateRoomViewModel} from "./CreateRoomViewModel.js"; import {CreateRoomViewModel} from "./CreateRoomViewModel.js";
import {ViewModel} from "../ViewModel.js"; import {ViewModel} from "../ViewModel";
import {RoomViewModelObservable} from "./RoomViewModelObservable.js"; import {RoomViewModelObservable} from "./RoomViewModelObservable.js";
import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js"; import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js";

View file

@ -15,8 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel";
const KIND_ORDER = ["roomBeingCreated", "invite", "room"]; const KIND_ORDER = ["roomBeingCreated", "invite", "room"];

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel";
import {RoomTileViewModel} from "./RoomTileViewModel.js"; import {RoomTileViewModel} from "./RoomTileViewModel.js";
import {InviteTileViewModel} from "./InviteTileViewModel.js"; import {InviteTileViewModel} from "./InviteTileViewModel.js";
import {RoomBeingCreatedTileViewModel} from "./RoomBeingCreatedTileViewModel.js"; import {RoomBeingCreatedTileViewModel} from "./RoomBeingCreatedTileViewModel.js";

View file

@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel";
import {RoomType} from "../../../matrix/room/common"; import {RoomType} from "../../../matrix/room/common";
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
export class MemberDetailsViewModel extends ViewModel { export class MemberDetailsViewModel extends ViewModel {
constructor(options) { constructor(options) {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel";
import {MemberTileViewModel} from "./MemberTileViewModel.js"; import {MemberTileViewModel} from "./MemberTileViewModel.js";
import {createMemberComparator} from "./members/comparator.js"; import {createMemberComparator} from "./members/comparator.js";
import {Disambiguator} from "./members/disambiguator.js"; import {Disambiguator} from "./members/disambiguator.js";

View file

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel";
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
export class MemberTileViewModel extends ViewModel { export class MemberTileViewModel extends ViewModel {
constructor(options) { constructor(options) {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel";
import {RoomDetailsViewModel} from "./RoomDetailsViewModel.js"; import {RoomDetailsViewModel} from "./RoomDetailsViewModel.js";
import {MemberListViewModel} from "./MemberListViewModel.js"; import {MemberListViewModel} from "./MemberListViewModel.js";
import {MemberDetailsViewModel} from "./MemberDetailsViewModel.js"; import {MemberDetailsViewModel} from "./MemberDetailsViewModel.js";

View file

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel";
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
export class RoomDetailsViewModel extends ViewModel { export class RoomDetailsViewModel extends ViewModel {
constructor(options) { constructor(options) {

View file

@ -14,11 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel";
export class ComposerViewModel extends ViewModel { export class ComposerViewModel extends ViewModel {
constructor(roomVM) { constructor(roomVM) {
super(); super(roomVM.options);
this._roomVM = roomVM; this._roomVM = roomVM;
this._isEmpty = true; this._isEmpty = true;
this._replyVM = null; this._replyVM = null;

View file

@ -15,8 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel";
export class InviteViewModel extends ViewModel { export class InviteViewModel extends ViewModel {
constructor(options) { constructor(options) {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel";
export class LightboxViewModel extends ViewModel { export class LightboxViewModel extends ViewModel {
constructor(options) { constructor(options) {

View file

@ -15,8 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel";
export class RoomBeingCreatedViewModel extends ViewModel { export class RoomBeingCreatedViewModel extends ViewModel {
constructor(options) { constructor(options) {

View file

@ -17,9 +17,9 @@ limitations under the License.
import {TimelineViewModel} from "./timeline/TimelineViewModel.js"; import {TimelineViewModel} from "./timeline/TimelineViewModel.js";
import {ComposerViewModel} from "./ComposerViewModel.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 {tilesCreator} from "./timeline/tilesCreator.js";
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel";
import {imageToInfo} from "../common.js"; import {imageToInfo} from "../common.js";
export class RoomViewModel extends ViewModel { export class RoomViewModel extends ViewModel {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel";
export class UnknownRoomViewModel extends ViewModel { export class UnknownRoomViewModel extends ViewModel {
constructor(options) { constructor(options) {

View file

@ -1,5 +1,5 @@
import { linkify } from "./linkify/linkify.js"; 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. * Parse text into parts such as newline, links and text.

View file

@ -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 See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ObservableMap} from "../../../../observable/map/ObservableMap.js"; import {ObservableMap} from "../../../../observable/map/ObservableMap";
export class ReactionsViewModel { export class ReactionsViewModel {
constructor(parentTile) { constructor(parentTile) {

View file

@ -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... when loading, it just reads events from a sortkey backwards or forwards...
*/ */
import {TilesCollection} from "./TilesCollection.js"; import {TilesCollection} from "./TilesCollection.js";
import {ViewModel} from "../../../ViewModel.js"; import {ViewModel} from "../../../ViewModel";
export class TimelineViewModel extends ViewModel { export class TimelineViewModel extends ViewModel {
constructor(options) { constructor(options) {

View file

@ -16,7 +16,7 @@ limitations under the License.
import {SimpleTile} from "./SimpleTile.js"; import {SimpleTile} from "./SimpleTile.js";
import {ReactionsViewModel} from "../ReactionsViewModel.js"; import {ReactionsViewModel} from "../ReactionsViewModel.js";
import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../../../avatar.js"; import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../../../avatar";
export class BaseMessageTile extends SimpleTile { export class BaseMessageTile extends SimpleTile {
constructor(options) { constructor(options) {

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import {UpdateAction} from "../UpdateAction.js"; import {UpdateAction} from "../UpdateAction.js";
import {ViewModel} from "../../../../ViewModel.js"; import {ViewModel} from "../../../../ViewModel";
import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js"; import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js";
export class SimpleTile extends ViewModel { export class SimpleTile extends ViewModel {
@ -44,6 +44,10 @@ export class SimpleTile extends ViewModel {
return this._entry.asEventKey(); return this._entry.asEventKey();
} }
get eventId() {
return this._entry.id;
}
get isPending() { get isPending() {
return this._entry.isPending; return this._entry.isPending;
} }

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel";
import {KeyType} from "../../../matrix/ssss/index"; import {KeyType} from "../../../matrix/ssss/index";
import {createEnum} from "../../../utils/enum"; import {createEnum} from "../../../utils/enum";

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel";
import {KeyBackupViewModel} from "./KeyBackupViewModel.js"; import {KeyBackupViewModel} from "./KeyBackupViewModel.js";
class PushNotificationStatus { class PushNotificationStatus {

View file

@ -16,6 +16,7 @@ limitations under the License.
export {Platform} from "./platform/web/Platform.js"; export {Platform} from "./platform/web/Platform.js";
export {Client, LoadStatus} from "./matrix/Client.js"; export {Client, LoadStatus} from "./matrix/Client.js";
export {RoomStatus} from "./matrix/room/common";
// export main view & view models // export main view & view models
export {createNavigation, createRouter} from "./domain/navigation/index.js"; export {createNavigation, createRouter} from "./domain/navigation/index.js";
export {RootViewModel} from "./domain/RootViewModel.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 {ComposerViewModel} from "./domain/session/room/ComposerViewModel.js";
export {MessageComposer} from "./platform/web/ui/session/room/MessageComposer.js"; export {MessageComposer} from "./platform/web/ui/session/room/MessageComposer.js";
export {TemplateView} from "./platform/web/ui/general/TemplateView"; 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 {LoadingView} from "./platform/web/ui/general/LoadingView.js";
export {AvatarView} from "./platform/web/ui/AvatarView.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";

View file

@ -26,8 +26,8 @@ import {User} from "./User.js";
import {DeviceMessageHandler} from "./DeviceMessageHandler.js"; import {DeviceMessageHandler} from "./DeviceMessageHandler.js";
import {Account as E2EEAccount} from "./e2ee/Account.js"; import {Account as E2EEAccount} from "./e2ee/Account.js";
import {uploadAccountAsDehydratedDevice} from "./e2ee/Dehydration.js"; import {uploadAccountAsDehydratedDevice} from "./e2ee/Dehydration.js";
import {Decryption as OlmDecryption} from "./e2ee/olm/Decryption.js"; import {Decryption as OlmDecryption} from "./e2ee/olm/Decryption";
import {Encryption as OlmEncryption} from "./e2ee/olm/Encryption.js"; import {Encryption as OlmEncryption} from "./e2ee/olm/Encryption";
import {Decryption as MegOlmDecryption} from "./e2ee/megolm/Decryption"; import {Decryption as MegOlmDecryption} from "./e2ee/megolm/Decryption";
import {KeyLoader as MegOlmKeyLoader} from "./e2ee/megolm/decryption/KeyLoader"; import {KeyLoader as MegOlmKeyLoader} from "./e2ee/megolm/decryption/KeyLoader";
import {KeyBackup} from "./e2ee/megolm/keybackup/KeyBackup"; 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 // 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. // and can create RoomEncryption objects and handle encrypted to_device messages and device list changes.
const senderKeyLock = new LockMap(); const senderKeyLock = new LockMap();
const olmDecryption = new OlmDecryption({ const olmDecryption = new OlmDecryption(
account: this._e2eeAccount, this._e2eeAccount,
pickleKey: PICKLE_KEY, PICKLE_KEY,
olm: this._olm, this._platform.clock.now,
storage: this._storage, this._user.id,
now: this._platform.clock.now, this._olm,
ownUserId: this._user.id,
senderKeyLock senderKeyLock
}); );
this._olmEncryption = new OlmEncryption({ this._olmEncryption = new OlmEncryption(
account: this._e2eeAccount, this._e2eeAccount,
pickleKey: PICKLE_KEY, PICKLE_KEY,
olm: this._olm, this._olm,
storage: this._storage, this._storage,
now: this._platform.clock.now, this._platform.clock.now,
ownUserId: this._user.id, this._user.id,
olmUtil: this._olmUtil, this._olmUtil,
senderKeyLock senderKeyLock
}); );
this._keyLoader = new MegOlmKeyLoader(this._olm, PICKLE_KEY, 20); this._keyLoader = new MegOlmKeyLoader(this._olm, PICKLE_KEY, 20);
this._megolmEncryption = new MegOlmEncryption({ this._megolmEncryption = new MegOlmEncryption({
account: this._e2eeAccount, account: this._e2eeAccount,

View file

@ -26,35 +26,41 @@ limitations under the License.
* see DeviceTracker * see DeviceTracker
*/ */
import type {DeviceIdentity} from "../storage/idb/stores/DeviceIdentityStore";
type DecryptedEvent = {
type?: string,
content?: Record<string, any>
}
export class DecryptionResult { export class DecryptionResult {
constructor(event, senderCurve25519Key, claimedEd25519Key) { private device?: DeviceIdentity;
this.event = event; private roomTracked: boolean = true;
this.senderCurve25519Key = senderCurve25519Key;
this.claimedEd25519Key = claimedEd25519Key; constructor(
this._device = null; public readonly event: DecryptedEvent,
this._roomTracked = true; public readonly senderCurve25519Key: string,
public readonly claimedEd25519Key: string
) {}
setDevice(device: DeviceIdentity): void {
this.device = device;
} }
setDevice(device) { setRoomNotTrackedYet(): void {
this._device = device; this.roomTracked = false;
} }
setRoomNotTrackedYet() { get isVerified(): boolean {
this._roomTracked = false; if (this.device) {
} const comesFromDevice = this.device.ed25519Key === this.claimedEd25519Key;
get isVerified() {
if (this._device) {
const comesFromDevice = this._device.ed25519Key === this.claimedEd25519Key;
return comesFromDevice; return comesFromDevice;
} }
return false; return false;
} }
get isUnverified() { get isUnverified(): boolean {
if (this._device) { if (this.device) {
return !this.isVerified; return !this.isVerified;
} else if (this.isVerificationUnknown) { } else if (this.isVerificationUnknown) {
return false; 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 // verification is unknown if we haven't yet fetched the devices for the room
return !this._device && !this._roomTracked; return !this.device && !this.roomTracked;
} }
} }

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {DecryptionResult} from "../../DecryptionResult.js"; import {DecryptionResult} from "../../DecryptionResult";
import {DecryptionError} from "../../common.js"; import {DecryptionError} from "../../common.js";
import {ReplayDetectionEntry} from "./ReplayDetectionEntry"; import {ReplayDetectionEntry} from "./ReplayDetectionEntry";
import type {RoomKey} from "./RoomKey"; import type {RoomKey} from "./RoomKey";

View file

@ -16,32 +16,47 @@ limitations under the License.
import {DecryptionError} from "../common.js"; import {DecryptionError} from "../common.js";
import {groupBy} from "../../../utils/groupBy"; import {groupBy} from "../../../utils/groupBy";
import {MultiLock} from "../../../utils/Lock"; import {MultiLock, ILock} from "../../../utils/Lock";
import {Session} from "./Session.js"; import {Session} from "./Session";
import {DecryptionResult} from "../DecryptionResult.js"; 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; const SESSION_LIMIT_PER_SENDER_KEY = 4;
function isPreKeyMessage(message) { type DecryptionResults = {
return message.type === 0; results: DecryptionResult[],
} errors: DecryptionError[],
senderKeyDecryption: SenderKeyDecryption
};
function sortSessions(sessions) { type CreateAndDecryptResult = {
session: Session,
plaintext: string
};
function sortSessions(sessions: Session[]): void {
sessions.sort((a, b) => { sessions.sort((a, b) => {
return b.data.lastUsed - a.data.lastUsed; return b.data.lastUsed - a.data.lastUsed;
}); });
} }
export class Decryption { export class Decryption {
constructor({account, pickleKey, now, ownUserId, storage, olm, senderKeyLock}) { constructor(
this._account = account; private readonly account: Account,
this._pickleKey = pickleKey; private readonly pickleKey: string,
this._now = now; private readonly now: () => number,
this._ownUserId = ownUserId; private readonly ownUserId: string,
this._storage = storage; private readonly olm: Olm,
this._olm = olm; private readonly senderKeyLock: LockMap<string>
this._senderKeyLock = senderKeyLock; ) {}
}
// we need to lock because both encryption and decryption can't be done in one txn, // 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. // 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) // - decryptAll below fails (to release the lock as early as we can)
// - DecryptionChanges.write succeeds // - DecryptionChanges.write succeeds
// - Sync finishes the writeSync phase (or an error was thrown, in case we never get to DecryptionChanges.write) // - Sync finishes the writeSync phase (or an error was thrown, in case we never get to DecryptionChanges.write)
async obtainDecryptionLock(events) { async obtainDecryptionLock(events: OlmEncryptedEvent[]): Promise<ILock> {
const senderKeys = new Set(); const senderKeys = new Set<string>();
for (const event of events) { for (const event of events) {
const senderKey = event.content?.["sender_key"]; const senderKey = event.content?.["sender_key"];
if (senderKey) { 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) // 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 // don't modify the sessions at the same time
const locks = await Promise.all(Array.from(senderKeys).map(senderKey => { const locks = await Promise.all(Array.from(senderKeys).map(senderKey => {
return this._senderKeyLock.takeLock(senderKey); return this.senderKeyLock.takeLock(senderKey);
})); }));
return new MultiLock(locks); return new MultiLock(locks);
} }
@ -83,18 +98,18 @@ export class Decryption {
* @param {[type]} events * @param {[type]} events
* @return {Promise<DecryptionChanges>} [description] * @return {Promise<DecryptionChanges>} [description]
*/ */
async decryptAll(events, lock, txn) { async decryptAll(events: OlmEncryptedEvent[], lock: ILock, txn: Transaction): Promise<DecryptionChanges> {
try { try {
const eventsPerSenderKey = groupBy(events, event => event.content?.["sender_key"]); const eventsPerSenderKey = groupBy(events, (event: OlmEncryptedEvent) => event.content?.["sender_key"]);
const timestamp = this._now(); const timestamp = this.now();
// decrypt events for different sender keys in parallel // decrypt events for different sender keys in parallel
const senderKeyOperations = await Promise.all(Array.from(eventsPerSenderKey.entries()).map(([senderKey, events]) => { 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 results = senderKeyOperations.reduce((all, r) => all.concat(r.results), [] as DecryptionResult[]);
const errors = senderKeyOperations.reduce((all, r) => all.concat(r.errors), []); const errors = senderKeyOperations.reduce((all, r) => all.concat(r.errors), [] as DecryptionError[]);
const senderKeyDecryptions = senderKeyOperations.map(r => r.senderKeyDecryption); 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) { } catch (err) {
// make sure the locks are release if something throws // make sure the locks are release if something throws
// otherwise they will be released in DecryptionChanges after having written // 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<DecryptionResults> {
const sessions = await this._getSessions(senderKey, readSessionsTxn); const sessions = await this._getSessions(senderKey, readSessionsTxn);
const senderKeyDecryption = new SenderKeyDecryption(senderKey, sessions, this._olm, timestamp); const senderKeyDecryption = new SenderKeyDecryption(senderKey, sessions, timestamp);
const results = []; const results: DecryptionResult[] = [];
const errors = []; const errors: DecryptionError[] = [];
// events for a single senderKey need to be decrypted one by one // events for a single senderKey need to be decrypted one by one
for (const event of events) { for (const event of events) {
try { try {
@ -121,10 +136,10 @@ export class Decryption {
return {results, errors, senderKeyDecryption}; return {results, errors, senderKeyDecryption};
} }
_decryptForSenderKey(senderKeyDecryption, event, timestamp) { _decryptForSenderKey(senderKeyDecryption: SenderKeyDecryption, event: OlmEncryptedEvent, timestamp: number): DecryptionResult {
const senderKey = senderKeyDecryption.senderKey; const senderKey = senderKeyDecryption.senderKey;
const message = this._getMessageAndValidateEvent(event); const message = this._getMessageAndValidateEvent(event);
let plaintext; let plaintext: string | undefined;
try { try {
plaintext = senderKeyDecryption.decrypt(message); plaintext = senderKeyDecryption.decrypt(message);
} catch (err) { } catch (err) {
@ -132,8 +147,8 @@ export class Decryption {
throw new DecryptionError("OLM_BAD_ENCRYPTED_MESSAGE", event, {senderKey, error: err.message}); throw new DecryptionError("OLM_BAD_ENCRYPTED_MESSAGE", event, {senderKey, error: err.message});
} }
// could not decrypt with any existing session // could not decrypt with any existing session
if (typeof plaintext !== "string" && isPreKeyMessage(message)) { if (typeof plaintext !== "string" && message.type === OlmPayloadType.PreKey) {
let createResult; let createResult: CreateAndDecryptResult;
try { try {
createResult = this._createSessionAndDecrypt(senderKey, message, timestamp); createResult = this._createSessionAndDecrypt(senderKey, message, timestamp);
} catch (error) { } catch (error) {
@ -143,14 +158,14 @@ export class Decryption {
plaintext = createResult.plaintext; plaintext = createResult.plaintext;
} }
if (typeof plaintext === "string") { if (typeof plaintext === "string") {
let payload; let payload: OlmPayload;
try { try {
payload = JSON.parse(plaintext); payload = JSON.parse(plaintext);
} catch (error) { } catch (error) {
throw new DecryptionError("PLAINTEXT_NOT_JSON", event, {plaintext, error}); throw new DecryptionError("PLAINTEXT_NOT_JSON", event, {plaintext, error});
} }
this._validatePayload(payload, event); this._validatePayload(payload, event);
return new DecryptionResult(payload, senderKey, payload.keys.ed25519); return new DecryptionResult(payload, senderKey, payload.keys!.ed25519!);
} else { } else {
throw new DecryptionError("OLM_NO_MATCHING_SESSION", event, throw new DecryptionError("OLM_NO_MATCHING_SESSION", event,
{knownSessionIds: senderKeyDecryption.sessions.map(s => s.id)}); {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 // 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; let plaintext;
// if we have multiple messages encrypted with the same new session, // if we have multiple messages encrypted with the same new session,
// this could create multiple sessions as the OTK isn't removed yet // this could create multiple sessions as the OTK isn't removed yet
// (this only happens in DecryptionChanges.write) // (this only happens in DecryptionChanges.write)
// This should be ok though as we'll first try to decrypt with the new session // 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 { try {
plaintext = olmSession.decrypt(message.type, message.body); 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); session.unload(olmSession);
return {session, plaintext}; return {session, plaintext};
} catch (err) { } catch (err) {
@ -176,12 +191,12 @@ export class Decryption {
} }
} }
_getMessageAndValidateEvent(event) { _getMessageAndValidateEvent(event: OlmEncryptedEvent): OlmMessage {
const ciphertext = event.content?.ciphertext; const ciphertext = event.content?.ciphertext;
if (!ciphertext) { if (!ciphertext) {
throw new DecryptionError("OLM_MISSING_CIPHERTEXT", event); throw new DecryptionError("OLM_MISSING_CIPHERTEXT", event);
} }
const message = ciphertext?.[this._account.identityKeys.curve25519]; const message = ciphertext?.[this.account.identityKeys.curve25519];
if (!message) { if (!message) {
throw new DecryptionError("OLM_NOT_INCLUDED_IN_RECIPIENTS", event); throw new DecryptionError("OLM_NOT_INCLUDED_IN_RECIPIENTS", event);
} }
@ -189,22 +204,22 @@ export class Decryption {
return message; return message;
} }
async _getSessions(senderKey, txn) { async _getSessions(senderKey: string, txn: Transaction): Promise<Session[]> {
const sessionEntries = await txn.olmSessions.getAll(senderKey); const sessionEntries = await txn.olmSessions.getAll(senderKey);
// sort most recent used sessions first // 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); sortSessions(sessions);
return sessions; return sessions;
} }
_validatePayload(payload, event) { _validatePayload(payload: OlmPayload, event: OlmEncryptedEvent): void {
if (payload.sender !== event.sender) { if (payload.sender !== event.sender) {
throw new DecryptionError("OLM_FORWARDED_MESSAGE", event, {sentBy: event.sender, encryptedBy: payload.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}); 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}); throw new DecryptionError("OLM_BAD_RECIPIENT_KEY", event, {key: payload.recipient_keys?.ed25519});
} }
// TODO: check room_id // TODO: check room_id
@ -219,21 +234,20 @@ export class Decryption {
// decryption helper for a single senderKey // decryption helper for a single senderKey
class SenderKeyDecryption { class SenderKeyDecryption {
constructor(senderKey, sessions, olm, timestamp) { constructor(
this.senderKey = senderKey; public readonly senderKey: string,
this.sessions = sessions; public readonly sessions: Session[],
this._olm = olm; private readonly timestamp: number
this._timestamp = timestamp; ) {}
}
addNewSession(session) { addNewSession(session: Session): void {
// add at top as it is most recent // add at top as it is most recent
this.sessions.unshift(session); this.sessions.unshift(session);
} }
decrypt(message) { decrypt(message: OlmMessage): string | undefined {
for (const session of this.sessions) { for (const session of this.sessions) {
const plaintext = this._decryptWithSession(session, message); const plaintext = this.decryptWithSession(session, message);
if (typeof plaintext === "string") { if (typeof plaintext === "string") {
// keep them sorted so will try the same session first for other messages // 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 // 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); return this.sessions.filter(session => session.isModified);
} }
get hasNewSessions() { get hasNewSessions(): boolean {
return this.sessions.some(session => session.isNew); return this.sessions.some(session => session.isNew);
} }
@ -257,19 +271,22 @@ class SenderKeyDecryption {
// if this turns out to be a real cost for IE11, // if this turns out to be a real cost for IE11,
// we could look into adding a less expensive serialization mechanism // we could look into adding a less expensive serialization mechanism
// for olm sessions to libolm // 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(); const olmSession = session.load();
try { try {
if (isPreKeyMessage(message) && !olmSession.matches_inbound(message.body)) { if (message.type === OlmPayloadType.PreKey && !olmSession.matches_inbound(message.body)) {
return; return;
} }
try { try {
const plaintext = olmSession.decrypt(message.type, message.body); const plaintext = olmSession.decrypt(message.type as number, message.body!);
session.save(olmSession); session.save(olmSession);
session.lastUsed = this._timestamp; session.data.lastUsed = this.timestamp;
return plaintext; return plaintext;
} catch (err) { } 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}`); throw new Error(`Error decrypting prekey message with existing session id ${session.id}: ${err.message}`);
} }
// decryption failed, bail out // decryption failed, bail out
@ -286,27 +303,27 @@ class SenderKeyDecryption {
* @property {Array<DecryptionError>} errors see DecryptionError.event to retrieve the event that failed to decrypt. * @property {Array<DecryptionError>} errors see DecryptionError.event to retrieve the event that failed to decrypt.
*/ */
class DecryptionChanges { class DecryptionChanges {
constructor(senderKeyDecryptions, results, errors, account, lock) { constructor(
this._senderKeyDecryptions = senderKeyDecryptions; private readonly senderKeyDecryptions: SenderKeyDecryption[],
this._account = account; public readonly results: DecryptionResult[],
this.results = results; public readonly errors: DecryptionError[],
this.errors = errors; private readonly account: Account,
this._lock = lock; private readonly lock: ILock
) {}
get hasNewSessions(): boolean {
return this.senderKeyDecryptions.some(skd => skd.hasNewSessions);
} }
get hasNewSessions() { write(txn: Transaction): void {
return this._senderKeyDecryptions.some(skd => skd.hasNewSessions);
}
write(txn) {
try { try {
for (const senderKeyDecryption of this._senderKeyDecryptions) { for (const senderKeyDecryption of this.senderKeyDecryptions) {
for (const session of senderKeyDecryption.getModifiedSessions()) { for (const session of senderKeyDecryption.getModifiedSessions()) {
txn.olmSessions.set(session.data); txn.olmSessions.set(session.data);
if (session.isNew) { if (session.isNew) {
const olmSession = session.load(); const olmSession = session.load();
try { try {
this._account.writeRemoveOneTimeKey(olmSession, txn); this.account.writeRemoveOneTimeKey(olmSession, txn);
} finally { } finally {
session.unload(olmSession); session.unload(olmSession);
} }
@ -322,7 +339,7 @@ class DecryptionChanges {
} }
} }
} finally { } finally {
this._lock.release(); this.lock.release();
} }
} }
} }

View file

@ -16,7 +16,33 @@ limitations under the License.
import {groupByWithCreator} from "../../../utils/groupBy"; import {groupByWithCreator} from "../../../utils/groupBy";
import {verifyEd25519Signature, OLM_ALGORITHM} from "../common.js"; 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) { function findFirstSessionId(sessionIds) {
return sessionIds.reduce((first, sessionId) => { return sessionIds.reduce((first, sessionId) => {
@ -36,19 +62,19 @@ const OTK_ALGORITHM = "signed_curve25519";
const MAX_BATCH_SIZE = 20; const MAX_BATCH_SIZE = 20;
export class Encryption { export class Encryption {
constructor({account, olm, olmUtil, ownUserId, storage, now, pickleKey, senderKeyLock}) { constructor(
this._account = account; private readonly account: Account,
this._olm = olm; private readonly pickleKey: string,
this._olmUtil = olmUtil; private readonly olm: Olm,
this._ownUserId = ownUserId; private readonly storage: Storage,
this._storage = storage; private readonly now: () => number,
this._now = now; private readonly ownUserId: string,
this._pickleKey = pickleKey; private readonly olmUtil: Olm.Utility,
this._senderKeyLock = senderKeyLock; private readonly senderKeyLock: LockMap<string>
} ) {}
async encrypt(type, content, devices, hsApi, log) { async encrypt(type: string, content: Record<string, any>, devices: DeviceIdentity[], hsApi: HomeServerApi, log: ILogItem): Promise<EncryptedMessage[]> {
let messages = []; let messages: EncryptedMessage[] = [];
for (let i = 0; i < devices.length ; i += MAX_BATCH_SIZE) { for (let i = 0; i < devices.length ; i += MAX_BATCH_SIZE) {
const batchDevices = devices.slice(i, i + MAX_BATCH_SIZE); const batchDevices = devices.slice(i, i + MAX_BATCH_SIZE);
const batchMessages = await this._encryptForMaxDevices(type, content, batchDevices, hsApi, log); const batchMessages = await this._encryptForMaxDevices(type, content, batchDevices, hsApi, log);
@ -57,12 +83,12 @@ export class Encryption {
return messages; return messages;
} }
async _encryptForMaxDevices(type, content, devices, hsApi, log) { async _encryptForMaxDevices(type: string, content: Record<string, any>, devices: DeviceIdentity[], hsApi: HomeServerApi, log: ILogItem): Promise<EncryptedMessage[]> {
// TODO: see if we can only hold some of the locks until after the /keys/claim call (if needed) // 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) // 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 // don't modify the sessions at the same time
const locks = await Promise.all(devices.map(device => { const locks = await Promise.all(devices.map(device => {
return this._senderKeyLock.takeLock(device.curve25519Key); return this.senderKeyLock.takeLock(device.curve25519Key);
})); }));
try { try {
const { const {
@ -70,9 +96,9 @@ export class Encryption {
existingEncryptionTargets, existingEncryptionTargets,
} = await this._findExistingSessions(devices); } = await this._findExistingSessions(devices);
const timestamp = this._now(); const timestamp = this.now();
let encryptionTargets = []; let encryptionTargets: EncryptionTarget[] = [];
try { try {
if (devicesWithoutSession.length) { if (devicesWithoutSession.length) {
const newEncryptionTargets = await log.wrap("create sessions", log => this._createNewSessions( const newEncryptionTargets = await log.wrap("create sessions", log => this._createNewSessions(
@ -100,8 +126,8 @@ export class Encryption {
} }
} }
async _findExistingSessions(devices) { async _findExistingSessions(devices: DeviceIdentity[]): Promise<{devicesWithoutSession: DeviceIdentity[], existingEncryptionTargets: EncryptionTarget[]}> {
const txn = await this._storage.readTxn([this._storage.storeNames.olmSessions]); const txn = await this.storage.readTxn([this.storage.storeNames.olmSessions]);
const sessionIdsForDevice = await Promise.all(devices.map(async device => { const sessionIdsForDevice = await Promise.all(devices.map(async device => {
return await txn.olmSessions.getSessionIds(device.curve25519Key); return await txn.olmSessions.getSessionIds(device.curve25519Key);
})); }));
@ -116,18 +142,18 @@ export class Encryption {
const sessionId = findFirstSessionId(sessionIds); const sessionId = findFirstSessionId(sessionIds);
return EncryptionTarget.fromSessionId(device, sessionId); return EncryptionTarget.fromSessionId(device, sessionId);
} }
}).filter(target => !!target); }).filter(target => !!target) as EncryptionTarget[];
return {devicesWithoutSession, existingEncryptionTargets}; return {devicesWithoutSession, existingEncryptionTargets};
} }
_encryptForDevice(type, content, target) { _encryptForDevice(type: string, content: Record<string, any>, target: EncryptionTarget): OlmEncryptedMessageContent {
const {session, device} = target; const {session, device} = target;
const plaintext = JSON.stringify(this._buildPlainTextMessageForDevice(type, content, device)); const plaintext = JSON.stringify(this._buildPlainTextMessageForDevice(type, content, device));
const message = session.encrypt(plaintext); const message = session!.encrypt(plaintext);
const encryptedContent = { const encryptedContent = {
algorithm: OLM_ALGORITHM, algorithm: OLM_ALGORITHM,
sender_key: this._account.identityKeys.curve25519, sender_key: this.account.identityKeys.curve25519,
ciphertext: { ciphertext: {
[device.curve25519Key]: message [device.curve25519Key]: message
} }
@ -135,27 +161,27 @@ export class Encryption {
return encryptedContent; return encryptedContent;
} }
_buildPlainTextMessageForDevice(type, content, device) { _buildPlainTextMessageForDevice(type: string, content: Record<string, any>, device: DeviceIdentity): OlmPayload {
return { return {
keys: { keys: {
"ed25519": this._account.identityKeys.ed25519 "ed25519": this.account.identityKeys.ed25519
}, },
recipient_keys: { recipient_keys: {
"ed25519": device.ed25519Key "ed25519": device.ed25519Key
}, },
recipient: device.userId, recipient: device.userId,
sender: this._ownUserId, sender: this.ownUserId,
content, content,
type type
} }
} }
async _createNewSessions(devicesWithoutSession, hsApi, timestamp, log) { async _createNewSessions(devicesWithoutSession: DeviceIdentity[], hsApi: HomeServerApi, timestamp: number, log: ILogItem): Promise<EncryptionTarget[]> {
const newEncryptionTargets = await log.wrap("claim", log => this._claimOneTimeKeys(hsApi, devicesWithoutSession, log)); const newEncryptionTargets = await log.wrap("claim", log => this._claimOneTimeKeys(hsApi, devicesWithoutSession, log));
try { try {
for (const target of newEncryptionTargets) { for (const target of newEncryptionTargets) {
const {device, oneTimeKey} = target; 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); await this._storeSessions(newEncryptionTargets, timestamp);
} catch (err) { } catch (err) {
@ -167,12 +193,12 @@ export class Encryption {
return newEncryptionTargets; return newEncryptionTargets;
} }
async _claimOneTimeKeys(hsApi, deviceIdentities, log) { async _claimOneTimeKeys(hsApi: HomeServerApi, deviceIdentities: DeviceIdentity[], log: ILogItem): Promise<EncryptionTarget[]> {
// create a Map<userId, Map<deviceId, deviceIdentity>> // create a Map<userId, Map<deviceId, deviceIdentity>>
const devicesByUser = groupByWithCreator(deviceIdentities, const devicesByUser = groupByWithCreator(deviceIdentities,
device => device.userId, (device: DeviceIdentity) => device.userId,
() => new Map(), (): Map<string, DeviceIdentity> => new Map(),
(deviceMap, device) => deviceMap.set(device.deviceId, device) (deviceMap: Map<string, DeviceIdentity>, device: DeviceIdentity) => deviceMap.set(device.deviceId, device)
); );
const oneTimeKeys = Array.from(devicesByUser.entries()).reduce((usersObj, [userId, deviceMap]) => { const oneTimeKeys = Array.from(devicesByUser.entries()).reduce((usersObj, [userId, deviceMap]) => {
usersObj[userId] = Array.from(deviceMap.values()).reduce((devicesObj, device) => { usersObj[userId] = Array.from(deviceMap.values()).reduce((devicesObj, device) => {
@ -188,12 +214,12 @@ export class Encryption {
if (Object.keys(claimResponse.failures).length) { if (Object.keys(claimResponse.failures).length) {
log.log({l: "failures", servers: Object.keys(claimResponse.failures)}, log.level.Warn); 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); return this._verifyAndCreateOTKTargets(userKeyMap, devicesByUser, log);
} }
_verifyAndCreateOTKTargets(userKeyMap, devicesByUser, log) { _verifyAndCreateOTKTargets(userKeyMap: ClaimedOTKResponse, devicesByUser: Map<string, Map<string, DeviceIdentity>>, log: ILogItem): EncryptionTarget[] {
const verifiedEncryptionTargets = []; const verifiedEncryptionTargets: EncryptionTarget[] = [];
for (const [userId, userSection] of Object.entries(userKeyMap)) { for (const [userId, userSection] of Object.entries(userKeyMap)) {
for (const [deviceId, deviceSection] of Object.entries(userSection)) { for (const [deviceId, deviceSection] of Object.entries(userSection)) {
const [firstPropName, keySection] = Object.entries(deviceSection)[0]; const [firstPropName, keySection] = Object.entries(deviceSection)[0];
@ -202,7 +228,7 @@ export class Encryption {
const device = devicesByUser.get(userId)?.get(deviceId); const device = devicesByUser.get(userId)?.get(deviceId);
if (device) { if (device) {
const isValidSignature = verifyEd25519Signature( const isValidSignature = verifyEd25519Signature(
this._olmUtil, userId, deviceId, device.ed25519Key, keySection, log); this.olmUtil, userId, deviceId, device.ed25519Key, keySection, log);
if (isValidSignature) { if (isValidSignature) {
const target = EncryptionTarget.fromOTK(device, keySection.key); const target = EncryptionTarget.fromOTK(device, keySection.key);
verifiedEncryptionTargets.push(target); verifiedEncryptionTargets.push(target);
@ -214,8 +240,8 @@ export class Encryption {
return verifiedEncryptionTargets; return verifiedEncryptionTargets;
} }
async _loadSessions(encryptionTargets) { async _loadSessions(encryptionTargets: EncryptionTarget[]): Promise<void> {
const txn = await this._storage.readTxn([this._storage.storeNames.olmSessions]); const txn = await this.storage.readTxn([this.storage.storeNames.olmSessions]);
// given we run loading in parallel, there might still be some // given we run loading in parallel, there might still be some
// storage requests that will finish later once one has failed. // storage requests that will finish later once one has failed.
// those should not allocate a session anymore. // those should not allocate a session anymore.
@ -223,10 +249,10 @@ export class Encryption {
try { try {
await Promise.all(encryptionTargets.map(async encryptionTarget => { await Promise.all(encryptionTargets.map(async encryptionTarget => {
const sessionEntry = await txn.olmSessions.get( const sessionEntry = await txn.olmSessions.get(
encryptionTarget.device.curve25519Key, encryptionTarget.sessionId); encryptionTarget.device.curve25519Key, encryptionTarget.sessionId!);
if (sessionEntry && !failed) { if (sessionEntry && !failed) {
const olmSession = new this._olm.Session(); const olmSession = new this.olm.Session();
olmSession.unpickle(this._pickleKey, sessionEntry.session); olmSession.unpickle(this.pickleKey, sessionEntry.session);
encryptionTarget.session = olmSession; encryptionTarget.session = olmSession;
} }
})); }));
@ -240,12 +266,12 @@ export class Encryption {
} }
} }
async _storeSessions(encryptionTargets, timestamp) { async _storeSessions(encryptionTargets: EncryptionTarget[], timestamp: number): Promise<void> {
const txn = await this._storage.readWriteTxn([this._storage.storeNames.olmSessions]); const txn = await this.storage.readWriteTxn([this.storage.storeNames.olmSessions]);
try { try {
for (const target of encryptionTargets) { for (const target of encryptionTargets) {
const sessionEntry = createSessionEntry( const sessionEntry = createSessionEntry(
target.session, target.device.curve25519Key, timestamp, this._pickleKey); target.session!, target.device.curve25519Key, timestamp, this.pickleKey);
txn.olmSessions.set(sessionEntry); txn.olmSessions.set(sessionEntry);
} }
} catch (err) { } catch (err) {
@ -261,23 +287,24 @@ export class Encryption {
// (and later converted to a session) in case of a new session // (and later converted to a session) in case of a new session
// or an existing session // or an existing session
class EncryptionTarget { 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;
}
static fromOTK(device, oneTimeKey) { public session: Olm.Session | null = null;
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); return new EncryptionTarget(device, oneTimeKey, null);
} }
static fromSessionId(device, sessionId) { static fromSessionId(device: DeviceIdentity, sessionId: string): EncryptionTarget {
return new EncryptionTarget(device, null, sessionId); return new EncryptionTarget(device, null, sessionId);
} }
dispose() { dispose(): void {
if (this.session) { if (this.session) {
this.session.free(); this.session.free();
} }
@ -285,8 +312,8 @@ class EncryptionTarget {
} }
class EncryptedMessage { class EncryptedMessage {
constructor(content, device) { constructor(
this.content = content; public readonly content: OlmEncryptedMessageContent,
this.device = device; public readonly device: DeviceIdentity
} ) {}
} }

View file

@ -14,7 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License. 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 { return {
session: olmSession.pickle(pickleKey), session: olmSession.pickle(pickleKey),
sessionId: olmSession.session_id(), sessionId: olmSession.session_id(),
@ -24,35 +28,38 @@ export function createSessionEntry(olmSession, senderKey, timestamp, pickleKey)
} }
export class Session { export class Session {
constructor(data, pickleKey, olm, isNew = false) { public isModified: boolean;
this.data = data;
this._olm = olm; constructor(
this._pickleKey = pickleKey; public readonly data: OlmSessionEntry,
this.isNew = isNew; private readonly pickleKey: string,
private readonly olm: Olm,
public isNew: boolean = false
) {
this.isModified = isNew; 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); const data = createSessionEntry(olmSession, senderKey, timestamp, pickleKey);
return new Session(data, pickleKey, olm, true); return new Session(data, pickleKey, olm, true);
} }
get id() { get id(): string {
return this.data.sessionId; return this.data.sessionId;
} }
load() { load(): Olm.Session {
const session = new this._olm.Session(); const session = new this.olm.Session();
session.unpickle(this._pickleKey, this.data.session); session.unpickle(this.pickleKey, this.data.session);
return session; return session;
} }
unload(olmSession) { unload(olmSession: Olm.Session): void {
olmSession.free(); olmSession.free();
} }
save(olmSession) { save(olmSession: Olm.Session): void {
this.data.session = olmSession.pickle(this._pickleKey); this.data.session = olmSession.pickle(this.pickleKey);
this.isModified = true; this.isModified = true;
} }
} }

View file

@ -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<string, any>;
sender?: string;
recipient?: string;
recipient_keys?: {ed25519?: string};
keys?: {ed25519?: string};
}

View file

@ -18,6 +18,7 @@ import type {HomeServerApi} from "../net/HomeServerApi";
import type {BaseRegistrationStage} from "./stages/BaseRegistrationStage"; import type {BaseRegistrationStage} from "./stages/BaseRegistrationStage";
import {DummyAuth} from "./stages/DummyAuth"; import {DummyAuth} from "./stages/DummyAuth";
import {TermsAuth} from "./stages/TermsAuth"; import {TermsAuth} from "./stages/TermsAuth";
import {TokenAuth} from "./stages/TokenAuth";
import type { import type {
AccountDetails, AccountDetails,
RegistrationFlow, RegistrationFlow,
@ -108,6 +109,9 @@ export class Registration {
return new DummyAuth(session, params?.[type]); return new DummyAuth(session, params?.[type]);
case "m.login.terms": case "m.login.terms":
return new TermsAuth(session, params?.[type]); 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: default:
throw new Error(`Unknown stage: ${type}`); throw new Error(`Unknown stage: ${type}`);
} }

View file

@ -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;
}
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ObservableMap} from "../../../observable/map/ObservableMap.js"; import {ObservableMap} from "../../../observable/map/ObservableMap";
import {RetainedValue} from "../../../utils/RetainedValue"; import {RetainedValue} from "../../../utils/RetainedValue";
export class MemberList extends RetainedValue { export class MemberList extends RetainedValue {

View file

@ -24,7 +24,6 @@ import {RoomMember} from "../members/RoomMember.js";
import {getRelation, ANNOTATION_RELATION_TYPE} from "./relations.js"; import {getRelation, ANNOTATION_RELATION_TYPE} from "./relations.js";
import {REDACTION_TYPE} from "../common"; import {REDACTION_TYPE} from "../common";
import {NonPersistedEventEntry} from "./entries/NonPersistedEventEntry.js"; import {NonPersistedEventEntry} from "./entries/NonPersistedEventEntry.js";
import {DecryptionSource} from "../../e2ee/common.js";
import {EVENT_TYPE as MEMBER_EVENT_TYPE} from "../members/RoomMember.js"; import {EVENT_TYPE as MEMBER_EVENT_TYPE} from "../members/RoomMember.js";
export class Timeline { export class Timeline {

View file

@ -24,19 +24,19 @@ function decodeKey(key: string): { senderKey: string, sessionId: string } {
return {senderKey, sessionId}; return {senderKey, sessionId};
} }
interface OlmSession { export type OlmSessionEntry = {
session: string; session: string;
sessionId: string; sessionId: string;
senderKey: string; senderKey: string;
lastUsed: number; lastUsed: number;
} }
type OlmSessionEntry = OlmSession & { key: string }; type OlmSessionStoredEntry = OlmSessionEntry & { key: string };
export class OlmSessionStore { export class OlmSessionStore {
private _store: Store<OlmSessionEntry>; private _store: Store<OlmSessionStoredEntry>;
constructor(store: Store<OlmSessionEntry>) { constructor(store: Store<OlmSessionStoredEntry>) {
this._store = store; this._store = store;
} }
@ -55,20 +55,20 @@ export class OlmSessionStore {
return sessionIds; return sessionIds;
} }
getAll(senderKey: string): Promise<OlmSession[]> { getAll(senderKey: string): Promise<OlmSessionEntry[]> {
const range = this._store.IDBKeyRange.lowerBound(encodeKey(senderKey, "")); const range = this._store.IDBKeyRange.lowerBound(encodeKey(senderKey, ""));
return this._store.selectWhile(range, session => { return this._store.selectWhile(range, session => {
return session.senderKey === senderKey; return session.senderKey === senderKey;
}); });
} }
get(senderKey: string, sessionId: string): Promise<OlmSession | undefined> { get(senderKey: string, sessionId: string): Promise<OlmSessionEntry | undefined> {
return this._store.get(encodeKey(senderKey, sessionId)); return this._store.get(encodeKey(senderKey, sessionId));
} }
set(session: OlmSession): void { set(session: OlmSessionEntry): void {
(session as OlmSessionEntry).key = encodeKey(session.senderKey, session.sessionId); (session as OlmSessionStoredEntry).key = encodeKey(session.senderKey, session.sessionId);
this._store.put(session as OlmSessionEntry); this._store.put(session as OlmSessionStoredEntry);
} }
remove(senderKey: string, sessionId: string): void { remove(senderKey: string, sessionId: string): void {

View file

@ -18,14 +18,14 @@ import {SortedMapList} from "./list/SortedMapList.js";
import {FilteredMap} from "./map/FilteredMap.js"; import {FilteredMap} from "./map/FilteredMap.js";
import {MappedMap} from "./map/MappedMap.js"; import {MappedMap} from "./map/MappedMap.js";
import {JoinedMap} from "./map/JoinedMap.js"; import {JoinedMap} from "./map/JoinedMap.js";
import {BaseObservableMap} from "./map/BaseObservableMap.js"; import {BaseObservableMap} from "./map/BaseObservableMap";
// re-export "root" (of chain) collections // re-export "root" (of chain) collections
export { ObservableArray } from "./list/ObservableArray"; export { ObservableArray } from "./list/ObservableArray";
export { SortedArray } from "./list/SortedArray"; export { SortedArray } from "./list/SortedArray";
export { MappedList } from "./list/MappedList"; export { MappedList } from "./list/MappedList";
export { AsyncMappedList } from "./list/AsyncMappedList"; export { AsyncMappedList } from "./list/AsyncMappedList";
export { ConcatList } from "./list/ConcatList"; export { ConcatList } from "./list/ConcatList";
export { ObservableMap } from "./map/ObservableMap.js"; export { ObservableMap } from "./map/ObservableMap";
// avoid circular dependency between these classes // avoid circular dependency between these classes
// and BaseObservableMap (as they extend it) // and BaseObservableMap (as they extend it)

View file

@ -133,7 +133,7 @@ export class SortedMapList extends BaseObservableList {
} }
} }
import {ObservableMap} from "../map/ObservableMap.js"; import {ObservableMap} from "../map/ObservableMap";
export function tests() { export function tests() {
return { return {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {BaseObservableMap} from "./BaseObservableMap.js"; import {BaseObservableMap} from "./BaseObservableMap";
export class ApplyMap extends BaseObservableMap { export class ApplyMap extends BaseObservableMap {
constructor(source, apply) { constructor(source, apply) {

View file

@ -16,7 +16,14 @@ limitations under the License.
import {BaseObservable} from "../BaseObservable"; import {BaseObservable} from "../BaseObservable";
export class BaseObservableMap extends BaseObservable { export interface IMapObserver<K, V> {
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<K, V> extends BaseObservable<IMapObserver<K, V>> {
emitReset() { emitReset() {
for(let h of this._handlers) { for(let h of this._handlers) {
h.onReset(); h.onReset();
@ -24,15 +31,15 @@ export class BaseObservableMap extends BaseObservable {
} }
// we need batch events, mostly on index based collection though? // we need batch events, mostly on index based collection though?
// maybe we should get started without? // maybe we should get started without?
emitAdd(key, value) { emitAdd(key: K, value: V) {
for(let h of this._handlers) { for(let h of this._handlers) {
h.onAdd(key, value); h.onAdd(key, value);
} }
} }
emitUpdate(key, value, ...params) { emitUpdate(key, value, params) {
for(let h of this._handlers) { 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]() { abstract [Symbol.iterator](): Iterator<[K, V]>;
throw new Error("unimplemented"); abstract get size(): number;
} abstract get(key: K): V | undefined;
get size() {
throw new Error("unimplemented");
}
// eslint-disable-next-line no-unused-vars
get(key) {
throw new Error("unimplemented");
}
} }

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {BaseObservableMap} from "./BaseObservableMap.js"; import {BaseObservableMap} from "./BaseObservableMap";
export class FilteredMap extends BaseObservableMap { export class FilteredMap extends BaseObservableMap {
constructor(source, filter) { constructor(source, filter) {
@ -166,7 +166,7 @@ class FilterIterator {
} }
} }
import {ObservableMap} from "./ObservableMap.js"; import {ObservableMap} from "./ObservableMap";
export function tests() { export function tests() {
return { return {
"filter preloaded list": assert => { "filter preloaded list": assert => {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {BaseObservableMap} from "./BaseObservableMap.js"; import {BaseObservableMap} from "./BaseObservableMap";
export class JoinedMap extends BaseObservableMap { export class JoinedMap extends BaseObservableMap {
constructor(sources) { constructor(sources) {
@ -191,7 +191,7 @@ class SourceSubscriptionHandler {
} }
import { ObservableMap } from "./ObservableMap.js"; import { ObservableMap } from "./ObservableMap";
export function tests() { export function tests() {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {BaseObservableMap} from "./BaseObservableMap.js"; import {BaseObservableMap} from "./BaseObservableMap";
export class LogMap extends BaseObservableMap { export class LogMap extends BaseObservableMap {
constructor(source, log) { constructor(source, log) {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. 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 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? how should the mapped value be notified of an update though? and can it then decide to not propagate the update?

View file

@ -14,15 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {BaseObservableMap} from "./BaseObservableMap.js"; import {BaseObservableMap} from "./BaseObservableMap";
export class ObservableMap extends BaseObservableMap { export class ObservableMap<K, V> extends BaseObservableMap<K, V> {
constructor(initialValues) { private readonly _values: Map<K, V>;
constructor(initialValues?: (readonly [K, V])[]) {
super(); super();
this._values = new Map(initialValues); this._values = new Map(initialValues);
} }
update(key, params) { update(key: K, params?: any): boolean {
const value = this._values.get(key); const value = this._values.get(key);
if (value !== undefined) { if (value !== undefined) {
// could be the same value, so it's already updated // 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? return false; // or return existing value?
} }
add(key, value) { add(key: K, value: V): boolean {
if (!this._values.has(key)) { if (!this._values.has(key)) {
this._values.set(key, value); this._values.set(key, value);
this.emitAdd(key, value); this.emitAdd(key, value);
@ -43,7 +45,7 @@ export class ObservableMap extends BaseObservableMap {
return false; // or return existing value? return false; // or return existing value?
} }
remove(key) { remove(key: K): boolean {
const value = this._values.get(key); const value = this._values.get(key);
if (value !== undefined) { if (value !== undefined) {
this._values.delete(key); 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)) { if (this._values.has(key)) {
// We set the value here because update only supports inline updates // We set the value here because update only supports inline updates
this._values.set(key, value); this._values.set(key, value);
return this.update(key); return this.update(key, undefined);
} }
else { else {
return this.add(key, value); return this.add(key, value);
} }
} }
reset() { reset(): void {
this._values.clear(); this._values.clear();
this.emitReset(); this.emitReset();
} }
get(key) { get(key: K): V | undefined {
return this._values.get(key); return this._values.get(key);
} }
get size() { get size(): number {
return this._values.size; return this._values.size;
} }
[Symbol.iterator]() { [Symbol.iterator](): Iterator<[K, V]> {
return this._values.entries(); return this._values.entries();
} }
values() { values(): Iterator<V> {
return this._values.values(); return this._values.values();
} }
keys() { keys(): Iterator<K> {
return this._values.keys(); return this._values.keys();
} }
} }
@ -105,13 +107,16 @@ export function tests() {
test_add(assert) { test_add(assert) {
let fired = 0; let fired = 0;
const map = new ObservableMap(); const map = new ObservableMap<number, {value: number}>();
map.subscribe({ map.subscribe({
onAdd(key, value) { onAdd(key, value) {
fired += 1; fired += 1;
assert.equal(key, 1); assert.equal(key, 1);
assert.deepEqual(value, {value: 5}); assert.deepEqual(value, {value: 5});
} },
onUpdate() {},
onRemove() {},
onReset() {}
}); });
map.add(1, {value: 5}); map.add(1, {value: 5});
assert.equal(map.size, 1); assert.equal(map.size, 1);
@ -120,7 +125,7 @@ export function tests() {
test_update(assert) { test_update(assert) {
let fired = 0; let fired = 0;
const map = new ObservableMap(); const map = new ObservableMap<number, {number: number}>();
const value = {number: 5}; const value = {number: 5};
map.add(1, value); map.add(1, value);
map.subscribe({ map.subscribe({
@ -129,7 +134,10 @@ export function tests() {
assert.equal(key, 1); assert.equal(key, 1);
assert.deepEqual(value, {number: 6}); assert.deepEqual(value, {number: 6});
assert.equal(params, "test"); assert.equal(params, "test");
} },
onAdd() {},
onRemove() {},
onReset() {}
}); });
value.number = 6; value.number = 6;
map.update(1, "test"); map.update(1, "test");
@ -138,9 +146,12 @@ export function tests() {
test_update_unknown(assert) { test_update_unknown(assert) {
let fired = 0; let fired = 0;
const map = new ObservableMap(); const map = new ObservableMap<number, {number: number}>();
map.subscribe({ map.subscribe({
onUpdate() { fired += 1; } onUpdate() { fired += 1; },
onAdd() {},
onRemove() {},
onReset() {}
}); });
const result = map.update(1); const result = map.update(1);
assert.equal(fired, 0); assert.equal(fired, 0);
@ -149,7 +160,7 @@ export function tests() {
test_set(assert) { test_set(assert) {
let add_fired = 0, update_fired = 0; let add_fired = 0, update_fired = 0;
const map = new ObservableMap(); const map = new ObservableMap<number, {value: number}>();
map.subscribe({ map.subscribe({
onAdd(key, value) { onAdd(key, value) {
add_fired += 1; add_fired += 1;
@ -160,7 +171,9 @@ export function tests() {
update_fired += 1; update_fired += 1;
assert.equal(key, 1); assert.equal(key, 1);
assert.deepEqual(value, {value: 7}); assert.deepEqual(value, {value: 7});
} },
onRemove() {},
onReset() {}
}); });
// Add // Add
map.set(1, {value: 5}); map.set(1, {value: 5});
@ -174,7 +187,7 @@ export function tests() {
test_remove(assert) { test_remove(assert) {
let fired = 0; let fired = 0;
const map = new ObservableMap(); const map = new ObservableMap<number, {value: number}>();
const value = {value: 5}; const value = {value: 5};
map.add(1, value); map.add(1, value);
map.subscribe({ map.subscribe({
@ -182,7 +195,10 @@ export function tests() {
fired += 1; fired += 1;
assert.equal(key, 1); assert.equal(key, 1);
assert.deepEqual(value, {value: 5}); assert.deepEqual(value, {value: 5});
} },
onAdd() {},
onUpdate() {},
onReset() {}
}); });
map.remove(1); map.remove(1);
assert.equal(map.size, 0); assert.equal(map.size, 0);
@ -190,8 +206,8 @@ export function tests() {
}, },
test_iterate(assert) { test_iterate(assert) {
const results = []; const results: any[] = [];
const map = new ObservableMap(); const map = new ObservableMap<number, {number: number}>();
map.add(1, {number: 5}); map.add(1, {number: 5});
map.add(2, {number: 6}); map.add(2, {number: 6});
map.add(3, {number: 7}); map.add(3, {number: 7});
@ -204,7 +220,7 @@ export function tests() {
assert.equal(results.find(([key]) => key === 3)[1].number, 7); assert.equal(results.find(([key]) => key === 3)[1].number, 7);
}, },
test_size(assert) { test_size(assert) {
const map = new ObservableMap(); const map = new ObservableMap<number, {number: number}>();
map.add(1, {number: 5}); map.add(1, {number: 5});
map.add(2, {number: 6}); map.add(2, {number: 6});
assert.equal(map.size, 2); assert.equal(map.size, 2);

View file

@ -37,7 +37,7 @@ import {hasReadPixelPermission, ImageHandle, VideoHandle} from "./dom/ImageHandl
import {downloadInIframe} from "./dom/download.js"; import {downloadInIframe} from "./dom/download.js";
import {Disposables} from "../../utils/Disposables"; import {Disposables} from "../../utils/Disposables";
import {parseHTML} from "./parsehtml.js"; import {parseHTML} from "./parsehtml.js";
import {handleAvatarError} from "./ui/avatar.js"; import {handleAvatarError} from "./ui/avatar";
function addScript(src) { function addScript(src) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
@ -143,7 +143,10 @@ export class Platform {
this._serviceWorkerHandler.registerAndStart(assetPaths.serviceWorker); this._serviceWorkerHandler.registerAndStart(assetPaths.serviceWorker);
} }
this.notificationService = new NotificationService(this._serviceWorkerHandler, config.push); this.notificationService = new NotificationService(this._serviceWorkerHandler, config.push);
// Only try to use crypto when olm is provided
if(this._assetPaths.olm) {
this.crypto = new Crypto(cryptoExtras); this.crypto = new Crypto(cryptoExtras);
}
this.storageFactory = new StorageFactory(this._serviceWorkerHandler); this.storageFactory = new StorageFactory(this._serviceWorkerHandler);
this.sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1"); this.sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1");
this.estimateStorageUsage = estimateStorageUsage; this.estimateStorageUsage = estimateStorageUsage;

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import {BaseUpdateView} from "./general/BaseUpdateView"; 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 optimization to not use a sub view when changing between img and text

View file

@ -104,8 +104,9 @@ export const TAG_NAMES = {
"br", "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6", "br", "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6",
"p", "strong", "em", "span", "img", "section", "main", "article", "aside", "del", "blockquote", "p", "strong", "em", "span", "img", "section", "main", "article", "aside", "del", "blockquote",
"table", "thead", "tbody", "tr", "th", "td", "hr", "table", "thead", "tbody", "tr", "th", "td", "hr",
"pre", "code", "button", "time", "input", "textarea", "label", "form", "progress", "output", "video"], "pre", "code", "button", "time", "input", "textarea", "select", "option", "label", "form",
[SVG_NS]: ["svg", "circle"] "progress", "output", "video"],
[SVG_NS]: ["svg", "g", "path", "circle", "ellipse", "rect", "use"]
} as const; } as const;
export const tag: { [tagName in typeof TAG_NAMES[string][number]]: (attributes?: BasicAttributes<never> | Child | Child[], children?: Child | Child[]) => Element } = {} as any; export const tag: { [tagName in typeof TAG_NAMES[string][number]]: (attributes?: BasicAttributes<never> | Child | Child[], children?: Child | Child[]) => Element } = {} as any;

View file

@ -16,7 +16,7 @@ limitations under the License.
*/ */
import {TemplateView} from "../../general/TemplateView"; import {TemplateView} from "../../general/TemplateView";
import {renderStaticAvatar} from "../../avatar.js"; import {renderStaticAvatar} from "../../avatar";
export class InviteView extends TemplateView { export class InviteView extends TemplateView {
render(t, vm) { render(t, vm) {

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {renderStaticAvatar} from "../../../avatar.js"; import {renderStaticAvatar} from "../../../avatar";
import {tag} from "../../../general/html"; import {tag} from "../../../general/html";
import {mountView} from "../../../general/utils"; import {mountView} from "../../../general/utils";
import {TemplateView} from "../../../general/TemplateView"; import {TemplateView} from "../../../general/TemplateView";
@ -40,14 +40,17 @@ export class BaseMessageView extends TemplateView {
if (this._interactive) { if (this._interactive) {
children.push(t.button({className: "Timeline_messageOptions"}, "⋯")); children.push(t.button({className: "Timeline_messageOptions"}, "⋯"));
} }
const li = t.el(this._tagName, {className: { const li = t.el(this._tagName, {
className: {
"Timeline_message": true, "Timeline_message": true,
own: vm.isOwn, own: vm.isOwn,
unsent: vm.isUnsent, unsent: vm.isUnsent,
unverified: vm.isUnverified, unverified: vm.isUnverified,
disabled: !this._interactive, disabled: !this._interactive,
continuation: vm => vm.isContinuation, continuation: vm => vm.isContinuation,
}}, children); },
'data-event-id': vm.eventId
}, children);
// given that there can be many tiles, we don't add // given that there can be many tiles, we don't add
// unneeded DOM nodes in case of a continuation, and we add it // unneeded DOM nodes in case of a continuation, and we add it
// with a side-effect binding to not have to create sub views, // with a side-effect binding to not have to create sub views,

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud> Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -18,7 +19,7 @@ export interface IDisposable {
dispose(): void; dispose(): void;
} }
type Disposable = IDisposable | (() => void); export type Disposable = IDisposable | (() => void);
function disposeValue(value: Disposable): void { function disposeValue(value: Disposable): void {
if (typeof value === "function") { if (typeof value === "function") {
@ -33,9 +34,9 @@ function isDisposable(value: Disposable): boolean {
} }
export class Disposables { export class Disposables {
private _disposables: Disposable[] | null = []; private _disposables?: Disposable[] = [];
track(disposable: Disposable): Disposable { track<D extends Disposable>(disposable: D): D {
if (!isDisposable(disposable)) { if (!isDisposable(disposable)) {
throw new Error("Not a disposable"); throw new Error("Not a disposable");
} }
@ -48,16 +49,16 @@ export class Disposables {
return disposable; return disposable;
} }
untrack(disposable: Disposable): null { untrack(disposable: Disposable): undefined {
if (this.isDisposed) { if (this.isDisposed) {
console.warn("Disposables already disposed, cannot untrack"); console.warn("Disposables already disposed, cannot untrack");
return null; return undefined;
} }
const idx = this._disposables!.indexOf(disposable); const idx = this._disposables!.indexOf(disposable);
if (idx >= 0) { if (idx >= 0) {
this._disposables!.splice(idx, 1); this._disposables!.splice(idx, 1);
} }
return null; return undefined;
} }
dispose(): void { dispose(): void {
@ -65,17 +66,17 @@ export class Disposables {
for (const d of this._disposables) { for (const d of this._disposables) {
disposeValue(d); disposeValue(d);
} }
this._disposables = null; this._disposables = undefined;
} }
} }
get isDisposed(): boolean { 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) { if (value === undefined || value === null || this.isDisposed) {
return null; return undefined;
} }
const idx = this._disposables!.indexOf(value); const idx = this._disposables!.indexOf(value);
if (idx !== -1) { if (idx !== -1) {
@ -84,6 +85,6 @@ export class Disposables {
} else { } else {
console.warn("disposable not found, did it leak?", value); console.warn("disposable not found, did it leak?", value);
} }
return null; return undefined;
} }
} }

View file

@ -14,7 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export class Lock { export interface ILock {
release(): void;
}
export class Lock implements ILock {
private _promise?: Promise<void>; private _promise?: Promise<void>;
private _resolve?: (() => void); private _resolve?: (() => void);
@ -52,7 +56,7 @@ export class Lock {
} }
} }
export class MultiLock { export class MultiLock implements ILock {
constructor(public readonly locks: Lock[]) { constructor(public readonly locks: Lock[]) {
} }

View file

@ -1133,6 +1133,13 @@ nth-check@^2.0.0:
dependencies: dependencies:
boolbase "^1.0.0" 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: once@^1.3.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" 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" resolved "https://registry.yarnpkg.com/postcss-flexbugs-fixes/-/postcss-flexbugs-fixes-5.0.2.tgz#2028e145313074fc9abe276cb7ca14e5401eb49d"
integrity sha512-18f9voByak7bTktR2QgDveglpn9DTbBWPUzSOe9g0N4WR/2eSt6Vrcbf0hmspvMI6YWGywz6B9f7jzpFNJJgnQ== 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: postcss@^8.3.8:
version "8.3.9" version "8.3.9"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.3.9.tgz#98754caa06c4ee9eb59cc48bd073bb6bd3437c31" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.3.9.tgz#98754caa06c4ee9eb59cc48bd073bb6bd3437c31"