Merge branch 'master' into madlittlemods/matrix-public-archive-scratch-changes

Conflicts:
	scripts/sdk/base-manifest.json
	scripts/sdk/build.sh
	src/domain/session/room/RoomViewModel.js
	src/platform/web/Platform.js
	src/platform/web/ui/general/html.ts
This commit is contained in:
Eric Eastwood 2022-06-06 15:26:52 -05:00
commit c24ac43e72
153 changed files with 3075 additions and 821 deletions

3
.gitignore vendored
View file

@ -7,4 +7,5 @@ bundle.js
target target
lib lib
*.tar.gz *.tar.gz
.eslintcache .eslintcache
.tmp

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,13 +10,34 @@ Hydrogen's goals are:
- It is a standalone webapp, but can also be easily embedded into an existing website/webapp to add chat capabilities. - It is a standalone webapp, but can also be easily embedded into an existing website/webapp to add chat capabilities.
- Loading (unused) parts of the application after initial page load should be supported - Loading (unused) parts of the application after initial page load should be supported
For embedded usage, see the [SDK instructions](doc/SDK.md).
If you find this interesting, come and discuss on [`#hydrogen:matrix.org`](https://matrix.to/#/#hydrogen:matrix.org). If you find this interesting, come and discuss on [`#hydrogen:matrix.org`](https://matrix.to/#/#hydrogen:matrix.org).
# How to use # How to use
Hydrogen is deployed to [hydrogen.element.io](https://hydrogen.element.io). You can run it locally `yarn install` (only the first time) and `yarn start` in the terminal, and point your browser to `http://localhost:3000`. If you prefer, you can also [use docker](doc/docker.md). Hydrogen is deployed to [hydrogen.element.io](https://hydrogen.element.io). You can also deploy Hydrogen on your own web server:
Hydrogen uses symbolic links in the codebase, so if you are on Windows, have a look at [making git & symlinks work](https://github.com/git-for-windows/git/wiki/Symbolic-Links) there. 1. Download the [latest release package](https://github.com/vector-im/hydrogen-web/releases).
1. Extract the package to the public directory of your web server.
1. If this is your first deploy:
1. copy `config.sample.json` to `config.json` and if needed, make any modifications (unless you've set up your own [sygnal](https://github.com/matrix-org/sygnal) instance, you don't need to change anything in the `push` section).
1. Disable caching entirely on the server for:
- `index.html`
- `sw.js`
- `config.json`
- All theme manifests referenced in the `themeManifests` of `config.json`, these files are typically called `theme-{name}.json`.
These resources will still be cached client-side by the service worker. Because of this; you'll still need to refresh the app twice before config.json changes are applied.
## Set up a dev environment
You can run Hydrogen locally by the following commands in the terminal:
- `yarn install` (only the first time)
- `yarn start` in the terminal
Now point your browser to `http://localhost:3000`. If you prefer, you can also [use docker](doc/docker.md).
# FAQ # FAQ

View file

@ -31,7 +31,8 @@ import {
createNavigation, createNavigation,
createRouter, createRouter,
RoomViewModel, RoomViewModel,
TimelineView TimelineView,
viewClassForTile
} from "hydrogen-view-sdk"; } from "hydrogen-view-sdk";
import downloadSandboxPath from 'hydrogen-view-sdk/download-sandbox.html?url'; import downloadSandboxPath from 'hydrogen-view-sdk/download-sandbox.html?url';
import workerPath from 'hydrogen-view-sdk/main.js?url'; import workerPath from 'hydrogen-view-sdk/main.js?url';
@ -47,12 +48,13 @@ const assetPaths = {
wasmBundle: olmJsPath wasmBundle: olmJsPath
} }
}; };
import "hydrogen-view-sdk/style.css"; import "hydrogen-view-sdk/theme-element-light.css";
// OR import "hydrogen-view-sdk/theme-element-dark.css";
async function main() { async function main() {
const app = document.querySelector<HTMLDivElement>('#app')! const app = document.querySelector<HTMLDivElement>('#app')!
const config = {}; const config = {};
const platform = new Platform(app, assetPaths, config, { development: import.meta.env.DEV }); const platform = new Platform({container: app, assetPaths, config, options: { development: import.meta.env.DEV }});
const navigation = createNavigation(); const navigation = createNavigation();
platform.setNavigation(navigation); platform.setNavigation(navigation);
const urlRouter = createRouter({ const urlRouter = createRouter({
@ -87,7 +89,7 @@ async function main() {
navigation, navigation,
}); });
await vm.load(); await vm.load();
const view = new TimelineView(vm.timelineViewModel); const view = new TimelineView(vm.timelineViewModel, viewClassForTile);
app.appendChild(view.mount()); app.appendChild(view.mount());
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "hydrogen-web", "name": "hydrogen-web",
"version": "0.2.26", "version": "0.2.29",
"description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB",
"directories": { "directories": {
"doc": "doc" "doc": "doc"
@ -10,9 +10,12 @@
"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/tests/css-compile-variables.test.js scripts/postcss/tests/css-url-to-variables.test.js",
"test:sdk": "yarn build:sdk && cd ./scripts/sdk/test/ && yarn --no-lockfile && node test-sdk-in-esm-vite-build-env.js && node test-sdk-in-commonjs-env.js",
"start": "vite --port 3000", "start": "vite --port 3000",
"build": "vite build", "build": "vite build && ./scripts/cleanup.sh",
"build:sdk": "./scripts/sdk/build.sh" "build:sdk": "./scripts/sdk/build.sh",
"watch:sdk": "./scripts/sdk/build.sh && yarn run vite build -c vite.sdk-lib-config.js --watch"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -30,6 +33,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",
@ -41,17 +45,18 @@
"node-html-parser": "^4.0.0", "node-html-parser": "^4.0.0",
"postcss-css-variables": "^0.18.0", "postcss-css-variables": "^0.18.0",
"postcss-flexbugs-fixes": "^5.0.2", "postcss-flexbugs-fixes": "^5.0.2",
"postcss-value-parser": "^4.2.0",
"regenerator-runtime": "^0.13.7", "regenerator-runtime": "^0.13.7",
"text-encoding": "^0.7.0", "text-encoding": "^0.7.0",
"typescript": "^4.3.5", "typescript": "^4.3.5",
"vite": "^2.6.14", "vite": "^2.9.8",
"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"
} }
} }

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

View file

@ -0,0 +1,282 @@
/*
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 path = require('path');
async function readCSSSource(location) {
const fs = require("fs").promises;
const path = require("path");
const resolvedLocation = path.resolve(__dirname, "../../", `${location}/theme.css`);
const data = await fs.readFile(resolvedLocation);
return data;
}
function getRootSectionWithVariables(variables) {
return `:root{\n${Object.entries(variables).reduce((acc, [key, value]) => acc + `--${key}: ${value};\n`, "")} }\n\n`;
}
function appendVariablesToCSS(variables, cssSource) {
return cssSource + getRootSectionWithVariables(variables);
}
function addThemesToConfig(bundle, manifestLocations, defaultThemes) {
for (const [fileName, info] of Object.entries(bundle)) {
if (fileName === "config.json") {
const source = new TextDecoder().decode(info.source);
const config = JSON.parse(source);
config["themeManifests"] = manifestLocations;
config["defaultTheme"] = defaultThemes;
info.source = new TextEncoder().encode(JSON.stringify(config, undefined, 2));
}
}
}
function parseBundle(bundle) {
const chunkMap = new Map();
const assetMap = new Map();
let runtimeThemeChunk;
for (const [fileName, info] of Object.entries(bundle)) {
if (!fileName.endsWith(".css")) {
continue;
}
if (info.type === "asset") {
/**
* So this is the css assetInfo that contains the asset hashed file name.
* We'll store it in a separate map indexed via fileName (unhashed) to avoid
* searching through the bundle array later.
*/
assetMap.set(info.name, info);
continue;
}
if (info.facadeModuleId?.includes("type=runtime")) {
/**
* We have a separate field in manifest.source just for the runtime theme,
* so store this separately.
*/
runtimeThemeChunk = info;
continue;
}
const location = info.facadeModuleId?.match(/(.+)\/.+\.css/)?.[1];
if (!location) {
throw new Error("Cannot find location of css chunk!");
}
const array = chunkMap.get(location);
if (!array) {
chunkMap.set(location, [info]);
}
else {
array.push(info);
}
}
return { chunkMap, assetMap, runtimeThemeChunk };
}
module.exports = function buildThemes(options) {
let manifest, variants, defaultDark, defaultLight, defaultThemes = {};
let isDevelopment = false;
const virtualModuleId = '@theme/'
const resolvedVirtualModuleId = '\0' + virtualModuleId;
return {
name: "build-themes",
enforce: "pre",
configResolved(config) {
if (config.command === "serve") {
isDevelopment = true;
}
},
async buildStart() {
if (isDevelopment) { return; }
const { themeConfig } = options;
for (const [name, location] of Object.entries(themeConfig.themes)) {
manifest = require(`${location}/manifest.json`);
variants = manifest.values.variants;
for (const [variant, details] of Object.entries(variants)) {
const fileName = `theme-${name}-${variant}.css`;
if (name === themeConfig.default && details.default) {
// This is the default theme, stash the file name for later
if (details.dark) {
defaultDark = fileName;
defaultThemes["dark"] = `${name}-${variant}`;
}
else {
defaultLight = fileName;
defaultThemes["light"] = `${name}-${variant}`;
}
}
// emit the css as built theme bundle
this.emitFile({
type: "chunk",
id: `${location}/theme.css?variant=${variant}${details.dark? "&dark=true": ""}`,
fileName,
});
}
// emit the css as runtime theme bundle
this.emitFile({
type: "chunk",
id: `${location}/theme.css?type=runtime`,
fileName: `theme-${name}-runtime.css`,
});
}
},
resolveId(id) {
if (id.startsWith(virtualModuleId)) {
return '\0' + id;
}
},
async load(id) {
if (isDevelopment) {
/**
* To load the theme during dev, we need to take a different approach because emitFile is not supported in dev.
* We solve this by resolving virtual file "@theme/name/variant" into the necessary css import.
* This virtual file import is removed when hydrogen is built (see transform hook).
*/
if (id.startsWith(resolvedVirtualModuleId)) {
let [theme, variant, file] = id.substr(resolvedVirtualModuleId.length).split("/");
if (theme === "default") {
theme = options.themeConfig.default;
}
const location = options.themeConfig.themes[theme];
const manifest = require(`${location}/manifest.json`);
const variants = manifest.values.variants;
if (!variant || variant === "default") {
// choose the first default variant for now
// this will need to support light/dark variants as well
variant = Object.keys(variants).find(variantName => variants[variantName].default);
}
if (!file) {
file = "index.js";
}
switch (file) {
case "index.js": {
const isDark = variants[variant].dark;
return `import "${path.resolve(`${location}/theme.css`)}${isDark? "?dark=true": ""}";` +
`import "@theme/${theme}/${variant}/variables.css"`;
}
case "variables.css": {
const variables = variants[variant].variables;
const css = getRootSectionWithVariables(variables);
return css;
}
}
}
}
else {
const result = id.match(/(.+)\/theme.css\?variant=([^&]+)/);
if (result) {
const [, location, variant] = result;
const cssSource = await readCSSSource(location);
const config = variants[variant];
return appendVariablesToCSS(config.variables, cssSource);
}
return null;
}
},
transform(code, id) {
if (isDevelopment) {
return;
}
/**
* Removes develop-only script tag; this cannot be done in transformIndexHtml hook because
* by the time that hook runs, the import is added to the bundled js file which would
* result in a runtime error.
*/
const devScriptTag =
/<script type="module"> import "@theme\/.+"; <\/script>/;
if (id.endsWith("index.html")) {
const htmlWithoutDevScript = code.replace(devScriptTag, "");
return htmlWithoutDevScript;
}
},
transformIndexHtml(_, ctx) {
if (isDevelopment) {
// Don't add default stylesheets to index.html on dev
return;
}
let darkThemeLocation, lightThemeLocation;
for (const [, bundle] of Object.entries(ctx.bundle)) {
if (bundle.name === defaultDark) {
darkThemeLocation = bundle.fileName;
}
if (bundle.name === defaultLight) {
lightThemeLocation = bundle.fileName;
}
}
return [
{
tag: "link",
attrs: {
rel: "stylesheet",
type: "text/css",
media: "(prefers-color-scheme: dark)",
href: `./${darkThemeLocation}`,
class: "theme",
}
},
{
tag: "link",
attrs: {
rel: "stylesheet",
type: "text/css",
media: "(prefers-color-scheme: light)",
href: `./${lightThemeLocation}`,
class: "theme",
}
},
];
},
generateBundle(_, bundle) {
const { assetMap, chunkMap, runtimeThemeChunk } = parseBundle(bundle);
const manifestLocations = [];
for (const [location, chunkArray] of chunkMap) {
const manifest = require(`${location}/manifest.json`);
const compiledVariables = options.compiledVariables.get(location);
const derivedVariables = compiledVariables["derived-variables"];
const icon = compiledVariables["icon"];
const builtAssets = {};
/**
* Generate a mapping from theme name to asset hashed location of said theme in build output.
* This can be used to enumerate themes during runtime.
*/
for (const chunk of chunkArray) {
const [, name, variant] = chunk.fileName.match(/theme-(.+)-(.+)\.css/);
builtAssets[`${name}-${variant}`] = assetMap.get(chunk.fileName).fileName;
}
manifest.source = {
"built-assets": builtAssets,
"runtime-asset": assetMap.get(runtimeThemeChunk.fileName).fileName,
"derived-variables": derivedVariables,
"icon": icon
};
const name = `theme-${manifest.name}.json`;
manifestLocations.push(`assets/${name}`);
this.emitFile({
type: "asset",
name,
source: JSON.stringify(manifest),
});
}
addThemesToConfig(bundle, manifestLocations, defaultThemes);
},
}
}

View file

@ -8,7 +8,7 @@ function contentHash(str) {
return hasher.digest(); return hasher.digest();
} }
function injectServiceWorker(swFile, otherUnhashedFiles, placeholdersPerChunk) { function injectServiceWorker(swFile, findUnhashedFileNamesFromBundle, placeholdersPerChunk) {
const swName = path.basename(swFile); const swName = path.basename(swFile);
let root; let root;
let version; let version;
@ -31,6 +31,7 @@ function injectServiceWorker(swFile, otherUnhashedFiles, placeholdersPerChunk) {
logger = config.logger; logger = config.logger;
}, },
generateBundle: async function(options, bundle) { generateBundle: async function(options, bundle) {
const otherUnhashedFiles = findUnhashedFileNamesFromBundle(bundle);
const unhashedFilenames = [swName].concat(otherUnhashedFiles); const unhashedFilenames = [swName].concat(otherUnhashedFiles);
const unhashedFileContentMap = unhashedFilenames.reduce((map, fileName) => { const unhashedFileContentMap = unhashedFilenames.reduce((map, fileName) => {
const chunkOrAsset = bundle[fileName]; const chunkOrAsset = bundle[fileName];

3
scripts/cleanup.sh Executable file
View file

@ -0,0 +1,3 @@
#!/bin/sh
# Remove icons created in .tmp
rm -rf .tmp

View file

@ -2,6 +2,9 @@ VERSION=$(jq -r ".version" package.json)
PACKAGE=hydrogen-web-$VERSION.tar.gz PACKAGE=hydrogen-web-$VERSION.tar.gz
yarn build yarn build
pushd target pushd target
# move config file so we don't override it
# when deploying a new version
mv config.json config.sample.json
tar -czvf ../$PACKAGE ./ tar -czvf ../$PACKAGE ./
popd popd
echo $PACKAGE echo $PACKAGE

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

@ -0,0 +1,40 @@
/*
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, isDark) {
const argumentAsNumber = parseInt(argument);
if (isDark) {
// For dark themes, invert the operation
if (operation === 'darker') {
operation = "lighter";
}
else if (operation === 'lighter') {
operation = "darker";
}
}
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,177 @@
/*
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;
let isDark;
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") {
return;
}
switch (node.value) {
case "var": {
const variable = node.nodes[0];
variables.push(variable.value);
break;
}
case "url": {
const url = node.nodes[0].value;
// resolve url with some absolute url so that we get the query params without using regex
const params = new URL(url, "file://foo/bar/").searchParams;
const primary = params.get("primary");
const secondary = params.get("secondary");
if (primary) { variables.push(primary); }
if (secondary) { variables.push(secondary); }
break;
}
}
});
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, isDark);
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);
}
function populateMapWithDerivedVariables(map, cssFileLocation) {
const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1];
const derivedVariables = [
...([...resolvedMap.keys()].filter(v => !aliasMap.has(v))),
...([...aliasMap.entries()].map(([alias, variable]) => `${alias}=${variable}`))
];
map.set(location, { "derived-variables": derivedVariables });
}
/**
* @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 {boolean} isDark - Indicates whether this theme is dark
*/
/**
*
* @param {Object} opts - Options for the plugin
* @param {derive} opts.derive - The callback which contains the logic for resolving derived variables
* @param {Map} opts.compiledVariables - A map that stores derived variables so that manifest source sections can be produced
*/
module.exports = (opts = {}) => {
aliasMap = new Map();
resolvedMap = new Map();
baseVariables = new Map();
isDark = false;
return {
postcssPlugin: "postcss-compile-variables",
Once(root, {Rule, Declaration, result}) {
const cssFileLocation = root.source.input.from;
if (cssFileLocation.includes("type=runtime")) {
// If this is a runtime theme, don't derive variables.
return;
}
isDark = cssFileLocation.includes("dark=true");
/*
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});
if (opts.compiledVariables){
populateMapWithDerivedVariables(opts.compiledVariables, cssFileLocation);
}
// Also produce a mapping from alias to completely resolved color
const resolvedAliasMap = new Map();
aliasMap.forEach((value, key) => {
resolvedAliasMap.set(key, resolvedMap.get(value));
});
// Publish the base-variables, derived-variables and resolved aliases to the other postcss-plugins
const combinedMap = new Map([...baseVariables, ...resolvedMap, ...resolvedAliasMap]);
result.messages.push({
type: "resolved-variable-map",
plugin: "postcss-compile-variables",
colorMap: combinedMap,
});
},
};
};
module.exports.postcss = true;

View file

@ -0,0 +1,93 @@
/*
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");
const resolve = require("path").resolve;
let cssPath;
function colorsFromURL(url, colorMap) {
const params = new URL(`file://${url}`).searchParams;
const primary = params.get("primary");
if (!primary) {
return null;
}
const secondary = params.get("secondary");
const primaryColor = colorMap.get(primary);
const secondaryColor = colorMap.get(secondary);
if (!primaryColor) {
throw new Error(`Variable ${primary} not found in resolved color variables!`);
}
if (secondary && !secondaryColor) {
throw new Error(`Variable ${secondary} not found in resolved color variables!`);
}
return [primaryColor, secondaryColor];
}
function processURL(decl, replacer, colorMap) {
const value = decl.value;
const parsed = valueParser(value);
parsed.walk(node => {
if (node.type !== "function" || node.value !== "url") {
return;
}
const urlStringNode = node.nodes[0];
const oldURL = urlStringNode.value;
const oldURLAbsolute = resolve(cssPath, oldURL);
const colors = colorsFromURL(oldURLAbsolute, colorMap);
if (!colors) {
// If no primary color is provided via url params, then this url need not be handled.
return;
}
const newURL = replacer(oldURLAbsolute.replace(/\?.+/, ""), ...colors);
if (!newURL) {
throw new Error("Replacer failed to produce a replacement URL!");
}
urlStringNode.value = newURL;
});
decl.assign({prop: decl.prop, value: parsed.toString()})
}
/* *
* @type {import('postcss').PluginCreator}
*/
module.exports = (opts = {}) => {
return {
postcssPlugin: "postcss-url-to-variable",
Once(root, {result}) {
const cssFileLocation = root.source.input.from;
if (cssFileLocation.includes("type=runtime")) {
// If this is a runtime theme, don't process urls.
return;
}
/*
postcss-compile-variables should have sent the list of resolved colours down via results
*/
const {colorMap} = result.messages.find(m => m.type === "resolved-variable-map");
if (!colorMap) {
throw new Error("Postcss results do not contain resolved colors!");
}
/*
Go through each declaration and if it contains an URL, replace the url with the result
of running replacer(url)
*/
cssPath = root.source?.input.file.replace(/[^/]*$/, "");
root.walkDecls(decl => processURL(decl, opts.replacer, colorMap));
},
};
};
module.exports.postcss = true;

View file

@ -0,0 +1,85 @@
/*
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 extracts content inside url() into css variables and adds the variables to the root section.
* This plugin is used in conjunction with css-url-processor plugin to colorize svg icons.
*/
let counter;
let urlVariables;
const idToPrepend = "icon-url";
function findAndReplaceUrl(decl) {
const value = decl.value;
const parsed = valueParser(value);
parsed.walk(node => {
if (node.type !== "function" || node.value !== "url") {
return;
}
const url = node.nodes[0].value;
if (!url.match(/\.svg\?primary=.+/)) {
return;
}
const variableName = `${idToPrepend}-${counter++}`;
urlVariables.set(variableName, url);
node.value = "var";
node.nodes = [{ type: "word", value: `--${variableName}` }];
});
decl.assign({prop: decl.prop, value: parsed.toString()})
}
function addResolvedVariablesToRootSelector(root, { Rule, Declaration }) {
const newRule = new Rule({ selector: ":root", source: root.source });
// Add derived css variables to :root
urlVariables.forEach((value, key) => {
const declaration = new Declaration({ prop: `--${key}`, value: `url("${value}")`});
newRule.append(declaration);
});
root.append(newRule);
}
function populateMapWithIcons(map, cssFileLocation) {
const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1];
const sharedObject = map.get(location);
sharedObject["icon"] = Object.fromEntries(urlVariables);
}
/* *
* @type {import('postcss').PluginCreator}
*/
module.exports = (opts = {}) => {
urlVariables = new Map();
counter = 0;
return {
postcssPlugin: "postcss-url-to-variable",
Once(root, { Rule, Declaration }) {
root.walkDecls(decl => findAndReplaceUrl(decl));
if (urlVariables.size) {
addResolvedVariablesToRootSelector(root, { Rule, Declaration });
}
if (opts.compiledVariables){
const cssFileLocation = root.source.input.from;
populateMapWithIcons(opts.compiledVariables, cssFileLocation);
}
},
};
};
module.exports.postcss = true;

View file

@ -0,0 +1,54 @@
/*
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 fs = require("fs");
const path = require("path");
const xxhash = require('xxhashjs');
function createHash(content) {
const hasher = new xxhash.h32(0);
hasher.update(content);
return hasher.digest();
}
/**
* Builds a new svg with the colors replaced and returns its location.
* @param {string} svgLocation The location of the input svg file
* @param {string} primaryColor Primary color for the new svg
* @param {string} secondaryColor Secondary color for the new svg
*/
module.exports.buildColorizedSVG = function (svgLocation, primaryColor, secondaryColor) {
const svgCode = fs.readFileSync(svgLocation, { encoding: "utf8"});
let coloredSVGCode = svgCode.replaceAll("#ff00ff", primaryColor);
coloredSVGCode = coloredSVGCode.replaceAll("#00ffff", secondaryColor);
if (svgCode === coloredSVGCode) {
throw new Error("svg-colorizer made no color replacements! The input svg should only contain colors #ff00ff (primary, case-sensitive) and #00ffff (secondary, case-sensitive).");
}
const fileName = svgLocation.match(/.+[/\\](.+\.svg)/)[1];
const outputName = `${fileName.substring(0, fileName.length - 4)}-${createHash(coloredSVGCode)}.svg`;
const outputPath = path.resolve(__dirname, "../../.tmp");
try {
fs.mkdirSync(outputPath);
}
catch (e) {
if (e.code !== "EEXIST") {
throw e;
}
}
const outputFile = `${outputPath}/${outputName}`;
fs.writeFileSync(outputFile, coloredSVGCode);
return outputFile;
}

View file

@ -0,0 +1,30 @@
/*
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 postcss = require("postcss");
module.exports.createTestRunner = function (plugin) {
return async function run(input, output, opts = {}, assert) {
let result = await postcss([plugin(opts)]).process(input, { from: undefined, });
assert.strictEqual(
result.css.replaceAll(/\s/g, ""),
output.replaceAll(/\s/g, "")
);
assert.strictEqual(result.warnings().length, 0);
};
}

View file

@ -0,0 +1,156 @@
/*
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;
const run = require("./common").createTestRunner(plugin);
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, {derive}, 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, {derive}, 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, {derive}, 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, {derive}, assert);
},
"compiledVariables map is populated": async (assert) => {
const compiledVariables = new Map();
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);
}`;
await postcss([plugin({ derive, compiledVariables })]).process(inputCSS, { from: "/foo/bar/test.css", });
const actualArray = compiledVariables.get("/foo/bar")["derived-variables"];
const expectedArray = ["icon-color--darker-20", "my-alias=icon-color--darker-20", "my-alias--lighter-15"];
assert.deepStrictEqual(actualArray.sort(), expectedArray.sort());
},
"derived variable are supported in urls": async (assert) => {
const inputCSS = `
:root {
--foo-color: #ff0;
}
div {
background-color: var(--foo-color--lighter-50);
background: url("./foo/bar/icon.svg?primary=foo-color--darker-5");
}
a {
background: url("foo/bar/icon.svg");
}`;
const transformedColorLighter = offColor("#ff0").lighten(0.5);
const transformedColorDarker = offColor("#ff0").darken(0.05);
const outputCSS =
inputCSS +
`
:root {
--foo-color--lighter-50: ${transformedColorLighter.hex()};
--foo-color--darker-5: ${transformedColorDarker.hex()};
}
`;
await run( inputCSS, outputCSS, {derive}, assert);
}
};
};

View file

@ -0,0 +1,71 @@
/*
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 plugin = require("../css-url-to-variables");
const run = require("./common").createTestRunner(plugin);
const postcss = require("postcss");
module.exports.tests = function tests() {
return {
"url is replaced with variable": async (assert) => {
const inputCSS = `div {
background: no-repeat center/80% url("../img/image.svg?primary=main-color--darker-20");
}
button {
background: url("/home/foo/bar/cool.svg?primary=blue&secondary=green");
}`;
const outputCSS =
`div {
background: no-repeat center/80% var(--icon-url-0);
}
button {
background: var(--icon-url-1);
}`+
`
:root {
--icon-url-0: url("../img/image.svg?primary=main-color--darker-20");
--icon-url-1: url("/home/foo/bar/cool.svg?primary=blue&secondary=green");
}
`;
await run(inputCSS, outputCSS, { }, assert);
},
"non svg urls without query params are not replaced": async (assert) => {
const inputCSS = `div {
background: no-repeat url("./img/foo/bar/image.png");
}`;
await run(inputCSS, inputCSS, {}, assert);
},
"map is populated with icons": async (assert) => {
const compiledVariables = new Map();
compiledVariables.set("/foo/bar", { "derived-variables": ["background-color--darker-20", "accent-color--lighter-15"] });
const inputCSS = `div {
background: no-repeat center/80% url("../img/image.svg?primary=main-color--darker-20");
}
button {
background: url("/home/foo/bar/cool.svg?primary=blue&secondary=green");
}`;
const expectedObject = {
"icon-url-0": "../img/image.svg?primary=main-color--darker-20",
"icon-url-1": "/home/foo/bar/cool.svg?primary=blue&secondary=green",
};
await postcss([plugin({compiledVariables})]).process(inputCSS, { from: "/foo/bar/test.css", });
const sharedVariable = compiledVariables.get("/foo/bar");
assert.deepEqual(["background-color--darker-20", "accent-color--lighter-15"], sharedVariable["derived-variables"]);
assert.deepEqual(expectedObject, sharedVariable["icon"]);
}
};
};

View file

@ -1,7 +1,19 @@
{ {
"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.12",
"main": "./lib-build/hydrogen.es.js", "main": "./lib-build/hydrogen.cjs.js",
"type": "module" "exports": {
".": {
"import": "./lib-build/hydrogen.es.js",
"require": "./lib-build/hydrogen.cjs.js"
},
"./paths/vite": "./paths/vite.js",
"./style.css": "./asset-build/assets/theme-element-light.css",
"./theme-element-light.css": "./asset-build/assets/theme-element-light.css",
"./theme-element-dark.css": "./asset-build/assets/theme-element-dark.css",
"./main.js": "./asset-build/assets/main.js",
"./download-sandbox.html": "./asset-build/assets/download-sandbox.html",
"./assets/*": "./asset-build/assets/*"
}
} }

View file

@ -1,4 +1,12 @@
#!/bin/bash #!/bin/bash
# Exit whenever one of the commands fail with a non-zero exit code
set -e
set -o pipefail
# Enable extended globs so we can use the `!(filename)` glob syntax
shopt -s extglob
# Only remove the directory contents instead of the whole directory to maintain
# the `npm link`/`yarn link` symlink
rm -rf target/* rm -rf target/*
yarn run vite build -c vite.sdk-assets-config.js yarn run vite build -c vite.sdk-assets-config.js
yarn run vite build -c vite.sdk-lib-config.js yarn run vite build -c vite.sdk-lib-config.js
@ -8,15 +16,10 @@ mkdir target/paths
# this doesn't work, the ?url imports need to be in the consuming project, so disable for now # this doesn't work, the ?url imports need to be in the consuming project, so disable for now
# ./scripts/sdk/transform-paths.js ./src/platform/web/sdk/paths/vite.js ./target/paths/vite.js # ./scripts/sdk/transform-paths.js ./src/platform/web/sdk/paths/vite.js ./target/paths/vite.js
cp doc/SDK.md target/README.md cp doc/SDK.md target/README.md
pushd target pushd target/asset-build
pushd asset-build/assets rm index.html
mv main.*.js ../../main.js
mv index.*.css ../../style.css
mv download-sandbox.*.html ../../download-sandbox.html
rm *.js *.wasm
mv ./* ../../
popd popd
rm -rf asset-build pushd target/asset-build/assets
mv lib-build/* . # Remove all `*.wasm` and `*.js` files except for `main.js`
rm -rf lib-build rm !(main).js *.wasm
popd popd

View file

@ -3,21 +3,7 @@ const fs = require("fs");
const appManifest = require("../../package.json"); const appManifest = require("../../package.json");
const baseSDKManifest = require("./base-manifest.json"); const baseSDKManifest = require("./base-manifest.json");
/* /*
need to leave exports out of base-manifest.json because of #vite-bug, Need to leave typescript type definitions out until the
with the downside that we can't support environments that support
both esm and commonjs modules, so we pick just esm.
```
"exports": {
".": {
"import": "./hydrogen.es.js",
"require": "./hydrogen.cjs.js"
},
"./paths/vite": "./paths/vite.js",
"./style.css": "./style.css"
},
```
Also need to leave typescript type definitions out until the
typescript conversion is complete and all imports in the d.ts files typescript conversion is complete and all imports in the d.ts files
exists. exists.
``` ```

3
scripts/sdk/test/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules
dist
yarn.lock

2
scripts/sdk/test/deps.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
// Keep TypeScripts from complaining about hydrogen-view-sdk not having types yet
declare module "hydrogen-view-sdk";

View file

@ -0,0 +1,21 @@
import * as hydrogenViewSdk from "hydrogen-view-sdk";
import downloadSandboxPath from 'hydrogen-view-sdk/download-sandbox.html?url';
import workerPath from 'hydrogen-view-sdk/main.js?url';
import olmWasmPath from '@matrix-org/olm/olm.wasm?url';
import olmJsPath from '@matrix-org/olm/olm.js?url';
import olmLegacyJsPath from '@matrix-org/olm/olm_legacy.js?url';
const assetPaths = {
downloadSandbox: downloadSandboxPath,
worker: workerPath,
olm: {
wasm: olmWasmPath,
legacyBundle: olmLegacyJsPath,
wasmBundle: olmJsPath
}
};
import "hydrogen-view-sdk/theme-element-light.css";
console.log('hydrogenViewSdk', hydrogenViewSdk);
console.log('assetPaths', assetPaths);
console.log('Entry ESM works ✅');

View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app" class="hydrogen"></div>
<script type="module" src="./esm-entry.ts"></script>
</body>
</html>

View file

@ -0,0 +1,8 @@
{
"name": "test-sdk",
"version": "0.0.0",
"description": "",
"dependencies": {
"hydrogen-view-sdk": "link:../../../target"
}
}

View file

@ -0,0 +1,13 @@
// Make sure the SDK can be used in a CommonJS environment.
// Usage: node scripts/sdk/test/test-sdk-in-commonjs-env.js
const hydrogenViewSdk = require('hydrogen-view-sdk');
// Test that the "exports" are available:
// Worker
require.resolve('hydrogen-view-sdk/main.js');
// Styles
require.resolve('hydrogen-view-sdk/theme-element-light.css');
// Can access files in the assets/* directory
require.resolve('hydrogen-view-sdk/assets/main.js');
console.log('SDK works in CommonJS ✅');

View file

@ -0,0 +1,19 @@
const { resolve } = require('path');
const { build } = require('vite');
async function main() {
await build({
outDir: './dist',
build: {
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html')
}
}
}
});
console.log('SDK works in Vite build ✅');
}
main();

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,80 @@ 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: Readonly<O>;
constructor(options: Readonly<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(): Readonly<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) { observeNavigation(type: string, onChange: (value: string | true | undefined, type: string) => void) {
const segmentObservable = this.navigation.observe(type);
const unsubscribe = segmentObservable.subscribe((value: string | true | undefined) => {
onChange(value, type);
})
this.track(unsubscribe);
}
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 +103,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 +115,7 @@ export class ViewModel extends EventEmitter {
return result; return result;
} }
updateOptions(options) { emitChange(changedProps: any): void {
this._options = Object.assign(this._options, options);
}
emitChange(changedProps) {
if (this._options.emitChange) { if (this._options.emitChange) {
this._options.emitChange(changedProps); this._options.emitChange(changedProps);
} else { } else {
@ -100,27 +123,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,18 +17,21 @@ 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 {ViewModel} from "../../ViewModel";
import {ViewModel} from "../../ViewModel.js";
import {imageToInfo} from "../common.js"; import {imageToInfo} from "../common.js";
// TODO: remove fallback so default isn't included in bundle for SDK users that have their custom tileClassForEntry
// this is a breaking SDK change though to make this option mandatory
import {tileClassForEntry as defaultTileClassForEntry} from "./timeline/tiles/index";
export class RoomViewModel extends ViewModel { export class RoomViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
const {room} = options; const {room, tileClassForEntry} = options;
this._room = room; this._room = room;
this._timelineVM = null; this._timelineVM = null;
this._tilesCreator = null; this._tileClassForEntry = tileClassForEntry ?? defaultTileClassForEntry;
this._tileOptions = undefined;
this._onRoomChange = this._onRoomChange.bind(this); this._onRoomChange = this._onRoomChange.bind(this);
this._timelineError = null; this._timelineError = null;
this._sendError = null; this._sendError = null;
@ -47,12 +50,13 @@ export class RoomViewModel extends ViewModel {
try { try {
const timeline = await this._room.openTimeline(); const timeline = await this._room.openTimeline();
console.log('timeline', timeline.entries); console.log('timeline', timeline.entries);
this._tilesCreator = tilesCreator(this.childOptions({ this._tileOptions = this.childOptions({
roomVM: this, roomVM: this,
timeline, timeline,
})); tileClassForEntry: this._tileClassForEntry,
});
this._timelineVM = this.track(new TimelineViewModel(this.childOptions({ this._timelineVM = this.track(new TimelineViewModel(this.childOptions({
tilesCreator: this._tilesCreator, tileOptions: this._tileOptions,
timeline, timeline,
}))); })));
this.emitChange("timelineViewModel"); this.emitChange("timelineViewModel");
@ -162,7 +166,12 @@ export class RoomViewModel extends ViewModel {
} }
_createTile(entry) { _createTile(entry) {
return this._tilesCreator(entry); if (this._tileOptions) {
const Tile = this._tileOptions.tileClassForEntry(entry);
if (Tile) {
return new Tile(entry, this._tileOptions);
}
}
} }
async _sendMessage(message, replyingTo) { async _sendMessage(message, replyingTo) {

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) {
@ -55,4 +55,4 @@ export class UnknownRoomViewModel extends ViewModel {
get kind() { get kind() {
return "unknown"; return "unknown";
} }
} }

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) {
@ -222,7 +222,7 @@ export function tests() {
}; };
const tiles = new MappedList(timeline.entries, entry => { const tiles = new MappedList(timeline.entries, entry => {
if (entry.eventType === "m.room.message") { if (entry.eventType === "m.room.message") {
return new BaseMessageTile({entry, roomVM: {room}, timeline, platform: {logger}}); return new BaseMessageTile(entry, {roomVM: {room}, timeline, platform: {logger}});
} }
return null; return null;
}, (tile, params, entry) => tile?.updateEntry(entry, params, function () {})); }, (tile, params, entry) => tile?.updateEntry(entry, params, function () {}));

View file

@ -18,20 +18,27 @@ import {BaseObservableList} from "../../../../observable/list/BaseObservableList
import {sortedIndex} from "../../../../utils/sortedIndex"; import {sortedIndex} from "../../../../utils/sortedIndex";
// maps 1..n entries to 0..1 tile. Entries are what is stored in the timeline, either an event or fragmentboundary // maps 1..n entries to 0..1 tile. Entries are what is stored in the timeline, either an event or fragmentboundary
// for now, tileCreator should be stable in whether it returns a tile or not. // for now, tileClassForEntry should be stable in whether it returns a tile or not.
// e.g. the decision to create a tile or not should be based on properties // e.g. the decision to create a tile or not should be based on properties
// not updated later on (e.g. event type) // not updated later on (e.g. event type)
// also see big comment in onUpdate // also see big comment in onUpdate
export class TilesCollection extends BaseObservableList { export class TilesCollection extends BaseObservableList {
constructor(entries, tileCreator) { constructor(entries, tileOptions) {
super(); super();
this._entries = entries; this._entries = entries;
this._tiles = null; this._tiles = null;
this._entrySubscription = null; this._entrySubscription = null;
this._tileCreator = tileCreator; this._tileOptions = tileOptions;
this._emitSpontanousUpdate = this._emitSpontanousUpdate.bind(this); this._emitSpontanousUpdate = this._emitSpontanousUpdate.bind(this);
} }
_createTile(entry) {
const Tile = this._tileOptions.tileClassForEntry(entry);
if (Tile) {
return new Tile(entry, this._tileOptions);
}
}
_emitSpontanousUpdate(tile, params) { _emitSpontanousUpdate(tile, params) {
const entry = tile.lowerEntry; const entry = tile.lowerEntry;
const tileIdx = this._findTileIdx(entry); const tileIdx = this._findTileIdx(entry);
@ -48,7 +55,7 @@ export class TilesCollection extends BaseObservableList {
let currentTile = null; let currentTile = null;
for (let entry of this._entries) { for (let entry of this._entries) {
if (!currentTile || !currentTile.tryIncludeEntry(entry)) { if (!currentTile || !currentTile.tryIncludeEntry(entry)) {
currentTile = this._tileCreator(entry); currentTile = this._createTile(entry);
if (currentTile) { if (currentTile) {
this._tiles.push(currentTile); this._tiles.push(currentTile);
} }
@ -121,7 +128,7 @@ export class TilesCollection extends BaseObservableList {
return; return;
} }
const newTile = this._tileCreator(entry); const newTile = this._createTile(entry);
if (newTile) { if (newTile) {
if (prevTile) { if (prevTile) {
prevTile.updateNextSibling(newTile); prevTile.updateNextSibling(newTile);
@ -150,9 +157,9 @@ export class TilesCollection extends BaseObservableList {
const tileIdx = this._findTileIdx(entry); const tileIdx = this._findTileIdx(entry);
const tile = this._findTileAtIdx(entry, tileIdx); const tile = this._findTileAtIdx(entry, tileIdx);
if (tile) { if (tile) {
const action = tile.updateEntry(entry, params, this._tileCreator); const action = tile.updateEntry(entry, params);
if (action.shouldReplace) { if (action.shouldReplace) {
const newTile = this._tileCreator(entry); const newTile = this._createTile(entry);
if (newTile) { if (newTile) {
this._replaceTile(tileIdx, tile, newTile, action.updateParams); this._replaceTile(tileIdx, tile, newTile, action.updateParams);
newTile.setUpdateEmit(this._emitSpontanousUpdate); newTile.setUpdateEmit(this._emitSpontanousUpdate);
@ -303,7 +310,10 @@ export function tests() {
} }
} }
const entries = new ObservableArray([{n: 5}, {n: 10}]); const entries = new ObservableArray([{n: 5}, {n: 10}]);
const tiles = new TilesCollection(entries, entry => new UpdateOnSiblingTile(entry)); const tileOptions = {
tileClassForEntry: () => UpdateOnSiblingTile,
};
const tiles = new TilesCollection(entries, tileOptions);
let receivedAdd = false; let receivedAdd = false;
tiles.subscribe({ tiles.subscribe({
onAdd(idx, tile) { onAdd(idx, tile) {
@ -326,7 +336,10 @@ export function tests() {
} }
} }
const entries = new ObservableArray([{n: 5}, {n: 10}, {n: 15}]); const entries = new ObservableArray([{n: 5}, {n: 10}, {n: 15}]);
const tiles = new TilesCollection(entries, entry => new UpdateOnSiblingTile(entry)); const tileOptions = {
tileClassForEntry: () => UpdateOnSiblingTile,
};
const tiles = new TilesCollection(entries, tileOptions);
const events = []; const events = [];
tiles.subscribe({ tiles.subscribe({
onUpdate(idx, tile) { onUpdate(idx, tile) {

View file

@ -32,15 +32,15 @@ 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) {
console.log('TimelineViewModel asdf', options) console.log('TimelineViewModel asdf', options)
super(options); super(options);
const {timeline, tilesCreator} = options; const {timeline, tileOptions} = options;
this._timeline = this.track(timeline); this._timeline = this.track(timeline);
this._tiles = new TilesCollection(timeline.entries, tilesCreator); this._tiles = new TilesCollection(timeline.entries, tileOptions);
this._startTile = null; this._startTile = null;
this._endTile = null; this._endTile = null;
this._topLoadingPromise = null; this._topLoadingPromise = null;

View file

@ -21,8 +21,8 @@ const MAX_HEIGHT = 300;
const MAX_WIDTH = 400; const MAX_WIDTH = 400;
export class BaseMediaTile extends BaseMessageTile { export class BaseMediaTile extends BaseMessageTile {
constructor(options) { constructor(entry, options) {
super(options); super(entry, options);
this._decryptedThumbnail = null; this._decryptedThumbnail = null;
this._decryptedFile = null; this._decryptedFile = null;
this._isVisible = false; this._isVisible = false;

View file

@ -16,11 +16,11 @@ 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(entry, options) {
super(options); super(entry, options);
this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null; this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null;
this._isContinuation = false; this._isContinuation = false;
this._reactions = null; this._reactions = null;
@ -28,7 +28,7 @@ export class BaseMessageTile extends SimpleTile {
if (this._entry.annotations || this._entry.pendingAnnotations) { if (this._entry.annotations || this._entry.pendingAnnotations) {
this._updateReactions(); this._updateReactions();
} }
this._updateReplyTileIfNeeded(options.tilesCreator, undefined); this._updateReplyTileIfNeeded(undefined);
} }
notifyVisible() { notifyVisible() {
@ -126,23 +126,27 @@ export class BaseMessageTile extends SimpleTile {
} }
} }
updateEntry(entry, param, tilesCreator) { updateEntry(entry, param) {
const action = super.updateEntry(entry, param, tilesCreator); const action = super.updateEntry(entry, param);
if (action.shouldUpdate) { if (action.shouldUpdate) {
this._updateReactions(); this._updateReactions();
} }
this._updateReplyTileIfNeeded(tilesCreator, param); this._updateReplyTileIfNeeded(param);
return action; return action;
} }
_updateReplyTileIfNeeded(tilesCreator, param) { _updateReplyTileIfNeeded(param) {
const replyEntry = this._entry.contextEntry; const replyEntry = this._entry.contextEntry;
if (replyEntry) { if (replyEntry) {
// this is an update to contextEntry used for replyPreview // this is an update to contextEntry used for replyPreview
const action = this._replyTile?.updateEntry(replyEntry, param, tilesCreator); const action = this._replyTile?.updateEntry(replyEntry, param);
if (action?.shouldReplace || !this._replyTile) { if (action?.shouldReplace || !this._replyTile) {
this.disposeTracked(this._replyTile); this.disposeTracked(this._replyTile);
this._replyTile = tilesCreator(replyEntry); const tileClassForEntry = this._options.tileClassForEntry;
const ReplyTile = tileClassForEntry(replyEntry);
if (ReplyTile) {
this._replyTile = new ReplyTile(replyEntry, this._options);
}
} }
if(action?.shouldUpdate) { if(action?.shouldUpdate) {
this._replyTile?.emitChange(); this._replyTile?.emitChange();

View file

@ -21,8 +21,8 @@ import {createEnum} from "../../../../../utils/enum";
export const BodyFormat = createEnum("Plain", "Html"); export const BodyFormat = createEnum("Plain", "Html");
export class BaseTextTile extends BaseMessageTile { export class BaseTextTile extends BaseMessageTile {
constructor(options) { constructor(entry, options) {
super(options); super(entry, options);
this._messageBody = null; this._messageBody = null;
this._format = null this._format = null
} }

View file

@ -18,8 +18,8 @@ import {BaseTextTile} from "./BaseTextTile.js";
import {UpdateAction} from "../UpdateAction.js"; import {UpdateAction} from "../UpdateAction.js";
export class EncryptedEventTile extends BaseTextTile { export class EncryptedEventTile extends BaseTextTile {
updateEntry(entry, params, tilesCreator) { updateEntry(entry, params) {
const parentResult = super.updateEntry(entry, params, tilesCreator); const parentResult = super.updateEntry(entry, params);
// event got decrypted, recreate the tile and replace this one with it // event got decrypted, recreate the tile and replace this one with it
if (entry.eventType !== "m.room.encrypted") { if (entry.eventType !== "m.room.encrypted") {
// the "shape" parameter trigger tile recreation in TimelineView // the "shape" parameter trigger tile recreation in TimelineView

View file

@ -20,8 +20,8 @@ import {formatSize} from "../../../../../utils/formatSize";
import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js"; import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js";
export class FileTile extends BaseMessageTile { export class FileTile extends BaseMessageTile {
constructor(options) { constructor(entry, options) {
super(options); super(entry, options);
this._downloadError = null; this._downloadError = null;
this._downloading = false; this._downloading = false;
} }

View file

@ -18,8 +18,8 @@ import {SimpleTile} from "./SimpleTile.js";
import {UpdateAction} from "../UpdateAction.js"; import {UpdateAction} from "../UpdateAction.js";
export class GapTile extends SimpleTile { export class GapTile extends SimpleTile {
constructor(options) { constructor(entry, options) {
super(options); super(entry, options);
this._loading = false; this._loading = false;
this._error = null; this._error = null;
this._isAtTop = true; this._isAtTop = true;
@ -81,8 +81,8 @@ export class GapTile extends SimpleTile {
this._siblingChanged = true; this._siblingChanged = true;
} }
updateEntry(entry, params, tilesCreator) { updateEntry(entry, params) {
super.updateEntry(entry, params, tilesCreator); super.updateEntry(entry, params);
if (!entry.isGap) { if (!entry.isGap) {
return UpdateAction.Remove(); return UpdateAction.Remove();
} else { } else {
@ -125,7 +125,7 @@ export function tests() {
tile.updateEntry(newEntry); tile.updateEntry(newEntry);
} }
}; };
const tile = new GapTile({entry: new FragmentBoundaryEntry(fragment, true), roomVM: {room}}); const tile = new GapTile(new FragmentBoundaryEntry(fragment, true), {roomVM: {room}});
await tile.fill(); await tile.fill();
await tile.fill(); await tile.fill();
await tile.fill(); await tile.fill();

View file

@ -18,8 +18,8 @@ limitations under the License.
import {BaseMediaTile} from "./BaseMediaTile.js"; import {BaseMediaTile} from "./BaseMediaTile.js";
export class ImageTile extends BaseMediaTile { export class ImageTile extends BaseMediaTile {
constructor(options) { constructor(entry, options) {
super(options); super(entry, options);
this._lightboxUrl = this.urlCreator.urlForSegments([ this._lightboxUrl = this.urlCreator.urlForSegments([
// ensure the right room is active if in grid view // ensure the right room is active if in grid view
this.navigation.segment("room", this._room.id), this.navigation.segment("room", this._room.id),

View file

@ -66,23 +66,25 @@ export class RoomMemberTile extends SimpleTile {
export function tests() { export function tests() {
return { return {
"user removes display name": (assert) => { "user removes display name": (assert) => {
const tile = new RoomMemberTile({ const tile = new RoomMemberTile(
entry: { {
prevContent: {displayname: "foo", membership: "join"}, prevContent: {displayname: "foo", membership: "join"},
content: {membership: "join"}, content: {membership: "join"},
stateKey: "foo@bar.com", stateKey: "foo@bar.com",
}, },
}); {}
);
assert.strictEqual(tile.announcement, "foo@bar.com removed their name (foo)"); assert.strictEqual(tile.announcement, "foo@bar.com removed their name (foo)");
}, },
"user without display name sets a new display name": (assert) => { "user without display name sets a new display name": (assert) => {
const tile = new RoomMemberTile({ const tile = new RoomMemberTile(
entry: { {
prevContent: {membership: "join"}, prevContent: {membership: "join"},
content: {displayname: "foo", membership: "join" }, content: {displayname: "foo", membership: "join" },
stateKey: "foo@bar.com", stateKey: "foo@bar.com",
}, },
}); {}
);
assert.strictEqual(tile.announcement, "foo@bar.com changed their name to foo"); assert.strictEqual(tile.announcement, "foo@bar.com changed their name to foo");
}, },
}; };

View file

@ -15,13 +15,14 @@ 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 {
constructor(options) { constructor(entry, options) {
super(options); super(options);
this._entry = options.entry; this._entry = entry;
this._emitUpdate = undefined;
} }
// view model props for all subclasses // view model props for all subclasses
// hmmm, could also do instanceof ... ? // hmmm, could also do instanceof ... ?
@ -44,6 +45,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;
} }
@ -63,16 +68,20 @@ export class SimpleTile extends ViewModel {
// TilesCollection contract below // TilesCollection contract below
setUpdateEmit(emitUpdate) { setUpdateEmit(emitUpdate) {
this.updateOptions({emitChange: paramName => { this._emitUpdate = emitUpdate;
}
/** overrides the emitChange in ViewModel to also emit the update over the tiles collection */
emitChange(changedProps) {
if (this._emitUpdate) {
// it can happen that after some network call // it can happen that after some network call
// we switched away from the room and the response // we switched away from the room and the response
// comes in, triggering an emitChange in a tile that // comes in, triggering an emitChange in a tile that
// has been disposed already (and hence the change // has been disposed already (and hence the change
// callback has been cleared by dispose) We should just ignore this. // callback has been cleared by dispose) We should just ignore this.
if (emitUpdate) { this._emitUpdate(this, changedProps);
emitUpdate(this, paramName); }
} super.emitChange(changedProps);
}});
} }
get upperEntry() { get upperEntry() {

View file

@ -0,0 +1,94 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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 {GapTile} from "./GapTile.js";
import {TextTile} from "./TextTile.js";
import {RedactedTile} from "./RedactedTile.js";
import {ImageTile} from "./ImageTile.js";
import {VideoTile} from "./VideoTile.js";
import {FileTile} from "./FileTile.js";
import {LocationTile} from "./LocationTile.js";
import {RoomNameTile} from "./RoomNameTile.js";
import {RoomMemberTile} from "./RoomMemberTile.js";
import {EncryptedEventTile} from "./EncryptedEventTile.js";
import {EncryptionEnabledTile} from "./EncryptionEnabledTile.js";
import {MissingAttachmentTile} from "./MissingAttachmentTile.js";
import type {SimpleTile} from "./SimpleTile.js";
import type {Room} from "../../../../../matrix/room/Room";
import type {Timeline} from "../../../../../matrix/room/timeline/Timeline";
import type {FragmentBoundaryEntry} from "../../../../../matrix/room/timeline/entries/FragmentBoundaryEntry";
import type {EventEntry} from "../../../../../matrix/room/timeline/entries/EventEntry";
import type {PendingEventEntry} from "../../../../../matrix/room/timeline/entries/PendingEventEntry";
import type {Options as ViewModelOptions} from "../../../../ViewModel";
export type TimelineEntry = FragmentBoundaryEntry | EventEntry | PendingEventEntry;
export type TileClassForEntryFn = (entry: TimelineEntry) => TileConstructor | undefined;
export type Options = ViewModelOptions & {
room: Room,
timeline: Timeline
tileClassForEntry: TileClassForEntryFn;
};
export type TileConstructor = new (entry: TimelineEntry, options: Options) => SimpleTile;
export function tileClassForEntry(entry: TimelineEntry): TileConstructor | undefined {
if (entry.isGap) {
return GapTile;
} else if (entry.isPending && entry.pendingEvent.isMissingAttachments) {
return MissingAttachmentTile;
} else if (entry.eventType) {
switch (entry.eventType) {
case "m.room.message": {
if (entry.isRedacted) {
return RedactedTile;
}
const content = entry.content;
const msgtype = content && content.msgtype;
switch (msgtype) {
case "m.text":
case "m.notice":
case "m.emote":
return TextTile;
case "m.image":
return ImageTile;
case "m.video":
return VideoTile;
case "m.file":
return FileTile;
case "m.location":
return LocationTile;
default:
// unknown msgtype not rendered
return undefined;
}
}
case "m.room.name":
return RoomNameTile;
case "m.room.member":
return RoomMemberTile;
case "m.room.encrypted":
if (entry.isRedacted) {
return RedactedTile;
}
return EncryptedEventTile;
case "m.room.encryption":
return EncryptionEnabledTile;
default:
// unknown type not rendered
return undefined;
}
}
}

View file

@ -1,81 +0,0 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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 {GapTile} from "./tiles/GapTile.js";
import {TextTile} from "./tiles/TextTile.js";
import {RedactedTile} from "./tiles/RedactedTile.js";
import {ImageTile} from "./tiles/ImageTile.js";
import {VideoTile} from "./tiles/VideoTile.js";
import {FileTile} from "./tiles/FileTile.js";
import {LocationTile} from "./tiles/LocationTile.js";
import {RoomNameTile} from "./tiles/RoomNameTile.js";
import {RoomMemberTile} from "./tiles/RoomMemberTile.js";
import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js";
import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js";
import {MissingAttachmentTile} from "./tiles/MissingAttachmentTile.js";
export function tilesCreator(baseOptions) {
const tilesCreator = function tilesCreator(entry, emitUpdate) {
const options = Object.assign({entry, emitUpdate, tilesCreator}, baseOptions);
if (entry.isGap) {
return new GapTile(options);
} else if (entry.isPending && entry.pendingEvent.isMissingAttachments) {
return new MissingAttachmentTile(options);
} else if (entry.eventType) {
switch (entry.eventType) {
case "m.room.message": {
if (entry.isRedacted) {
return new RedactedTile(options);
}
const content = entry.content;
const msgtype = content && content.msgtype;
switch (msgtype) {
case "m.text":
case "m.notice":
case "m.emote":
return new TextTile(options);
case "m.image":
return new ImageTile(options);
case "m.video":
return new VideoTile(options);
case "m.file":
return new FileTile(options);
case "m.location":
return new LocationTile(options);
default:
// unknown msgtype not rendered
return null;
}
}
case "m.room.name":
return new RoomNameTile(options);
case "m.room.member":
return new RoomMemberTile(options);
case "m.room.encrypted":
if (entry.isRedacted) {
return new RedactedTile(options);
}
return new EncryptedEventTile(options);
case "m.room.encryption":
return new EncryptionEnabledTile(options);
default:
// unknown type not rendered
return null;
}
}
};
return tilesCreator;
}

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 {
@ -50,6 +50,7 @@ export class SettingsViewModel extends ViewModel {
this.minSentImageSizeLimit = 400; this.minSentImageSizeLimit = 400;
this.maxSentImageSizeLimit = 4000; this.maxSentImageSizeLimit = 4000;
this.pushNotifications = new PushNotificationStatus(); this.pushNotifications = new PushNotificationStatus();
this._activeTheme = undefined;
} }
get _session() { get _session() {
@ -76,6 +77,9 @@ export class SettingsViewModel extends ViewModel {
this.sentImageSizeLimit = await this.platform.settingsStorage.getInt("sentImageSizeLimit"); this.sentImageSizeLimit = await this.platform.settingsStorage.getInt("sentImageSizeLimit");
this.pushNotifications.supported = await this.platform.notificationService.supportsPush(); this.pushNotifications.supported = await this.platform.notificationService.supportsPush();
this.pushNotifications.enabled = await this._session.arePushNotificationsEnabled(); this.pushNotifications.enabled = await this._session.arePushNotificationsEnabled();
if (!import.meta.env.DEV) {
this._activeTheme = await this.platform.themeLoader.getActiveTheme();
}
this.emitChange(""); this.emitChange("");
} }
@ -127,6 +131,18 @@ export class SettingsViewModel extends ViewModel {
return this._formatBytes(this._estimate?.usage); return this._formatBytes(this._estimate?.usage);
} }
get themes() {
return this.platform.themeLoader.themes;
}
get activeTheme() {
return this._activeTheme;
}
setTheme(name) {
this.platform.themeLoader.setTheme(name);
}
_formatBytes(n) { _formatBytes(n) {
if (typeof n === "number") { if (typeof n === "number") {
return Math.round(n / (1024 * 1024)).toFixed(1) + " MB"; return Math.round(n / (1024 * 1024)).toFixed(1) + " MB";

View file

@ -2,8 +2,6 @@
<!-- this file contains all references to include in the SDK asset build (using vite.sdk-assets-config.js) --> <!-- this file contains all references to include in the SDK asset build (using vite.sdk-assets-config.js) -->
<html> <html>
<head> <head>
<link rel="stylesheet" type="text/css" href="./platform/web/ui/css/main.css">
<link rel="stylesheet" type="text/css" href="./platform/web/ui/css/themes/element/theme.css">
</head> </head>
<body> <body>
<script type="module"> <script type="module">

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";
@ -27,17 +28,67 @@ export {RoomView} from "./platform/web/ui/session/room/RoomView.js";
export {RightPanelView} from "./platform/web/ui/session/rightpanel/RightPanelView.js"; export {RightPanelView} from "./platform/web/ui/session/rightpanel/RightPanelView.js";
export {MediaRepository} from "./matrix/net/MediaRepository"; export {MediaRepository} from "./matrix/net/MediaRepository";
export {TilesCollection} from "./domain/session/room/timeline/TilesCollection.js"; export {TilesCollection} from "./domain/session/room/timeline/TilesCollection.js";
export {tilesCreator} from "./domain/session/room/timeline/tilesCreator.js";
export {FragmentIdComparer} from "./matrix/room/timeline/FragmentIdComparer.js"; export {FragmentIdComparer} from "./matrix/room/timeline/FragmentIdComparer.js";
export {EventEntry} from "./matrix/room/timeline/entries/EventEntry.js"; export {EventEntry} from "./matrix/room/timeline/entries/EventEntry.js";
export {encodeKey, decodeKey, encodeEventIdKey, decodeEventIdKey} from "./matrix/storage/idb/stores/TimelineEventStore"; export {encodeKey, decodeKey, encodeEventIdKey, decodeEventIdKey} from "./matrix/storage/idb/stores/TimelineEventStore";
export {Timeline} from "./matrix/room/timeline/Timeline.js"; export {Timeline} from "./matrix/room/timeline/Timeline.js";
export {TimelineViewModel} from "./domain/session/room/timeline/TimelineViewModel.js"; export {TimelineViewModel} from "./domain/session/room/timeline/TimelineViewModel.js";
export {tileClassForEntry} from "./domain/session/room/timeline/tiles/index";
export type {TimelineEntry, TileClassForEntryFn, Options, TileConstructor} from "./domain/session/room/timeline/tiles/index";
// export timeline tile view models
export {GapTile} from "./domain/session/room/timeline/tiles/GapTile.js";
export {TextTile} from "./domain/session/room/timeline/tiles/TextTile.js";
export {RedactedTile} from "./domain/session/room/timeline/tiles/RedactedTile.js";
export {ImageTile} from "./domain/session/room/timeline/tiles/ImageTile.js";
export {VideoTile} from "./domain/session/room/timeline/tiles/VideoTile.js";
export {FileTile} from "./domain/session/room/timeline/tiles/FileTile.js";
export {LocationTile} from "./domain/session/room/timeline/tiles/LocationTile.js";
export {RoomNameTile} from "./domain/session/room/timeline/tiles/RoomNameTile.js";
export {RoomMemberTile} from "./domain/session/room/timeline/tiles/RoomMemberTile.js";
export {EncryptedEventTile} from "./domain/session/room/timeline/tiles/EncryptedEventTile.js";
export {EncryptionEnabledTile} from "./domain/session/room/timeline/tiles/EncryptionEnabledTile.js";
export {MissingAttachmentTile} from "./domain/session/room/timeline/tiles/MissingAttachmentTile.js";
export {SimpleTile} from "./domain/session/room/timeline/tiles/SimpleTile.js";
export {TimelineView} from "./platform/web/ui/session/room/TimelineView"; export {TimelineView} from "./platform/web/ui/session/room/TimelineView";
export {viewClassForTile} from "./platform/web/ui/session/room/common";
export type {TileViewConstructor, ViewClassForEntryFn} from "./platform/web/ui/session/room/TimelineView";
// export timeline tile views
export {AnnouncementView} from "./platform/web/ui/session/room/timeline/AnnouncementView.js";
export {BaseMediaView} from "./platform/web/ui/session/room/timeline/BaseMediaView.js";
export {BaseMessageView} from "./platform/web/ui/session/room/timeline/BaseMessageView.js";
export {FileView} from "./platform/web/ui/session/room/timeline/FileView.js";
export {GapView} from "./platform/web/ui/session/room/timeline/GapView.js";
export {ImageView} from "./platform/web/ui/session/room/timeline/ImageView.js";
export {LocationView} from "./platform/web/ui/session/room/timeline/LocationView.js";
export {MissingAttachmentView} from "./platform/web/ui/session/room/timeline/MissingAttachmentView.js";
export {ReactionsView} from "./platform/web/ui/session/room/timeline/ReactionsView.js";
export {RedactedView} from "./platform/web/ui/session/room/timeline/RedactedView.js";
export {ReplyPreviewView} from "./platform/web/ui/session/room/timeline/ReplyPreviewView.js";
export {TextMessageView} from "./platform/web/ui/session/room/timeline/TextMessageView.js";
export {VideoView} from "./platform/web/ui/session/room/timeline/VideoView.js";
export {Navigation} from "./domain/navigation/Navigation.js"; 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

@ -132,14 +132,15 @@ export class Client {
}); });
} }
async startRegistration(homeserver, username, password, initialDeviceDisplayName) { async startRegistration(homeserver, username, password, initialDeviceDisplayName, flowSelector) {
const request = this._platform.request; const request = this._platform.request;
const hsApi = new HomeServerApi({homeserver, request}); const hsApi = new HomeServerApi({homeserver, request});
const registration = new Registration(hsApi, { const registration = new Registration(hsApi, {
username, username,
password, password,
initialDeviceDisplayName, initialDeviceDisplayName,
}); },
flowSelector);
return registration; return registration;
} }

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

@ -214,11 +214,12 @@ export class DeviceTracker {
const allDeviceIdentities = []; const allDeviceIdentities = [];
const deviceIdentitiesToStore = []; const deviceIdentitiesToStore = [];
// filter out devices that have changed their ed25519 key since last time we queried them // filter out devices that have changed their ed25519 key since last time we queried them
deviceIdentities = await Promise.all(deviceIdentities.map(async deviceIdentity => { await Promise.all(deviceIdentities.map(async deviceIdentity => {
if (knownDeviceIds.includes(deviceIdentity.deviceId)) { if (knownDeviceIds.includes(deviceIdentity.deviceId)) {
const existingDevice = await txn.deviceIdentities.get(deviceIdentity.userId, deviceIdentity.deviceId); const existingDevice = await txn.deviceIdentities.get(deviceIdentity.userId, deviceIdentity.deviceId);
if (existingDevice.ed25519Key !== deviceIdentity.ed25519Key) { if (existingDevice.ed25519Key !== deviceIdentity.ed25519Key) {
allDeviceIdentities.push(existingDevice); allDeviceIdentities.push(existingDevice);
return;
} }
} }
allDeviceIdentities.push(deviceIdentity); allDeviceIdentities.push(deviceIdentity);
@ -363,3 +364,154 @@ export class DeviceTracker {
return await txn.deviceIdentities.getByCurve25519Key(curve25519Key); return await txn.deviceIdentities.getByCurve25519Key(curve25519Key);
} }
} }
import {createMockStorage} from "../../mocks/Storage";
import {Instance as NullLoggerInstance} from "../../logging/NullLogger";
export function tests() {
function createUntrackedRoomMock(roomId, joinedUserIds, invitedUserIds = []) {
return {
isTrackingMembers: false,
isEncrypted: true,
loadMemberList: () => {
const joinedMembers = joinedUserIds.map(userId => {return {membership: "join", roomId, userId};});
const invitedMembers = invitedUserIds.map(userId => {return {membership: "invite", roomId, userId};});
const members = joinedMembers.concat(invitedMembers);
const memberMap = members.reduce((map, member) => {
map.set(member.userId, member);
return map;
}, new Map());
return {members: memberMap, release() {}}
},
writeIsTrackingMembers(isTrackingMembers) {
if (this.isTrackingMembers !== isTrackingMembers) {
return isTrackingMembers;
}
return undefined;
},
applyIsTrackingMembersChanges(isTrackingMembers) {
if (isTrackingMembers !== undefined) {
this.isTrackingMembers = isTrackingMembers;
}
},
}
}
function createQueryKeysHSApiMock(createKey = (algorithm, userId, deviceId) => `${algorithm}:${userId}:${deviceId}:key`) {
return {
queryKeys(payload) {
const {device_keys: deviceKeys} = payload;
const userKeys = Object.entries(deviceKeys).reduce((userKeys, [userId, deviceIds]) => {
if (deviceIds.length === 0) {
deviceIds = ["device1"];
}
userKeys[userId] = deviceIds.filter(d => d === "device1").reduce((deviceKeys, deviceId) => {
deviceKeys[deviceId] = {
"algorithms": [
"m.olm.v1.curve25519-aes-sha2",
"m.megolm.v1.aes-sha2"
],
"device_id": deviceId,
"keys": {
[`curve25519:${deviceId}`]: createKey("curve25519", userId, deviceId),
[`ed25519:${deviceId}`]: createKey("ed25519", userId, deviceId),
},
"signatures": {
[userId]: {
[`ed25519:${deviceId}`]: `ed25519:${userId}:${deviceId}:signature`
}
},
"unsigned": {
"device_display_name": `${userId} Phone`
},
"user_id": userId
};
return deviceKeys;
}, {});
return userKeys;
}, {});
const response = {device_keys: userKeys};
return {
async response() {
return response;
}
};
}
};
}
const roomId = "!abc:hs.tld";
return {
"trackRoom only writes joined members": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"], ["@charly:hs.tld"]);
await tracker.trackRoom(room, NullLoggerInstance.item);
const txn = await storage.readTxn([storage.storeNames.userIdentities]);
assert.deepEqual(await txn.userIdentities.get("@alice:hs.tld"), {
userId: "@alice:hs.tld",
roomIds: [roomId],
deviceTrackingStatus: TRACKING_STATUS_OUTDATED
});
assert.deepEqual(await txn.userIdentities.get("@bob:hs.tld"), {
userId: "@bob:hs.tld",
roomIds: [roomId],
deviceTrackingStatus: TRACKING_STATUS_OUTDATED
});
assert.equal(await txn.userIdentities.get("@charly:hs.tld"), undefined);
},
"getting devices for tracked room yields correct keys": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"]);
await tracker.trackRoom(room, NullLoggerInstance.item);
const hsApi = createQueryKeysHSApiMock();
const devices = await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApi, NullLoggerInstance.item);
assert.equal(devices.length, 2);
assert.equal(devices.find(d => d.userId === "@alice:hs.tld").ed25519Key, "ed25519:@alice:hs.tld:device1:key");
assert.equal(devices.find(d => d.userId === "@bob:hs.tld").ed25519Key, "ed25519:@bob:hs.tld:device1:key");
},
"device with changed key is ignored": async assert => {
const storage = await createMockStorage();
const tracker = new DeviceTracker({
storage,
getSyncToken: () => "token",
olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw
ownUserId: "@alice:hs.tld",
ownDeviceId: "ABCD",
});
const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"]);
await tracker.trackRoom(room, NullLoggerInstance.item);
const hsApi = createQueryKeysHSApiMock();
// query devices first time
await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApi, NullLoggerInstance.item);
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities]);
// mark alice as outdated, so keys will be fetched again
tracker.writeDeviceChanges(["@alice:hs.tld"], txn, NullLoggerInstance.item);
await txn.complete();
const hsApiWithChangedAliceKey = createQueryKeysHSApiMock((algo, userId, deviceId) => {
return `${algo}:${userId}:${deviceId}:${userId === "@alice:hs.tld" ? "newKey" : "key"}`;
});
const devices = await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApiWithChangedAliceKey, NullLoggerInstance.item);
assert.equal(devices.length, 2);
assert.equal(devices.find(d => d.userId === "@alice:hs.tld").ed25519Key, "ed25519:@alice:hs.tld:device1:key");
assert.equal(devices.find(d => d.userId === "@bob:hs.tld").ed25519Key, "ed25519:@bob:hs.tld:device1:key");
const txn2 = await storage.readTxn([storage.storeNames.deviceIdentities]);
// also check the modified key was not stored
assert.equal((await txn2.deviceIdentities.get("@alice:hs.tld", "device1")).ed25519Key, "ed25519:@alice:hs.tld:device1:key");
}
}
}

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; public session: Olm.Session | null = null;
this.oneTimeKey = oneTimeKey;
this.sessionId = sessionId;
// an olmSession, should probably be called olmSession
this.session = null;
}
static fromOTK(device, oneTimeKey) { constructor(
public readonly device: DeviceIdentity,
public readonly oneTimeKey: string | null,
public readonly sessionId: string | null
) {}
static fromOTK(device: DeviceIdentity, oneTimeKey: string): EncryptionTarget {
return new EncryptionTarget(device, oneTimeKey, null); 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

@ -27,8 +27,8 @@ class Request implements IHomeServerRequest {
public readonly args: any[]; public readonly args: any[];
private responseResolve: (result: any) => void; private responseResolve: (result: any) => void;
public responseReject: (error: Error) => void; public responseReject: (error: Error) => void;
private responseCodeResolve: (result: any) => void; private responseCodeResolve?: (result: any) => void;
private responseCodeReject: (result: any) => void; private responseCodeReject?: (result: any) => void;
private _requestResult?: IHomeServerRequest; private _requestResult?: IHomeServerRequest;
private readonly _responsePromise: Promise<any>; private readonly _responsePromise: Promise<any>;
private _responseCodePromise: Promise<any>; private _responseCodePromise: Promise<any>;
@ -73,7 +73,7 @@ class Request implements IHomeServerRequest {
const response = await this._requestResult?.response(); const response = await this._requestResult?.response();
this.responseResolve(response); this.responseResolve(response);
const responseCode = await this._requestResult?.responseCode(); const responseCode = await this._requestResult?.responseCode();
this.responseCodeResolve(responseCode); this.responseCodeResolve?.(responseCode);
} }
get requestResult() { get requestResult() {

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

@ -19,6 +19,6 @@ import {hkdf} from "../../utils/crypto/hkdf";
import {Platform as ModernPlatform} from "./Platform.js"; import {Platform as ModernPlatform} from "./Platform.js";
export function Platform(container, assetPaths, config, options = null) { export function Platform({ container, assetPaths, config, configURL, options = null }) {
return new ModernPlatform(container, assetPaths, config, options, {aesjs, hkdf}); return new ModernPlatform({ container, assetPaths, config, configURL, options, cryptoExtras: { aesjs, hkdf }});
} }

Some files were not shown because too many files have changed in this diff Show more