Compare commits
130 commits
master
...
bwindels/c
Author | SHA1 | Date | |
---|---|---|---|
|
add1a2c52f | ||
|
a2a17dbf7a | ||
|
cd8fac2872 | ||
|
8140e4f2c3 | ||
|
814cee214c | ||
|
d69b1dc3e2 | ||
|
fc08fc3744 | ||
|
0fdc6b1c3a | ||
|
1a08616df1 | ||
|
1a0b11ff7e | ||
|
c1c08e9eb0 | ||
|
9938071e1c | ||
|
bb92d2e30d | ||
|
8e2e92cd2c | ||
|
e1974711f3 | ||
|
d346f4a3fb | ||
|
3d83fda69f | ||
|
2d9b69751f | ||
|
0be75d9c59 | ||
|
a91bcf5d22 | ||
|
aa709ee6e9 | ||
|
b03b296391 | ||
|
4f2999f8d8 | ||
|
bffce7fafe | ||
|
6394138c4a | ||
|
be04eeded0 | ||
|
230ccd95ab | ||
|
6b22078140 | ||
|
beeb191588 | ||
|
da654a8c59 | ||
|
eea3830146 | ||
|
9ab75e8ed4 | ||
|
b46ec8bac4 | ||
|
f61064c462 | ||
|
433dc957ee | ||
|
c7f7d24273 | ||
|
330f234b5a | ||
|
3198ca6a92 | ||
|
3767f6a420 | ||
|
6e1174e03d | ||
|
14dbe340c7 | ||
|
a52423856d | ||
|
22df062bbb | ||
|
8b16782270 | ||
|
39ecc6cc6d | ||
|
cdb2a79b62 | ||
|
ac60d1b61d | ||
|
baa884e9d0 | ||
|
10a6269147 | ||
|
55c6dcf613 | ||
|
99769eb84e | ||
|
82ffb557e5 | ||
|
d6b239e58f | ||
|
4a8af83c8f | ||
|
c42292f1b0 | ||
|
382fba88bd | ||
|
468a0a9698 | ||
|
ea1c3a2b86 | ||
|
021b8cdcdc | ||
|
ff856d843c | ||
|
55097e4154 | ||
|
2d00d10161 | ||
|
bc118b5c0b | ||
|
2d4301fe5a | ||
|
36dc463d23 | ||
|
0e9307608b | ||
|
2635adb232 | ||
|
797cb23cc7 | ||
|
fd5b2aa7bb | ||
|
d734a61447 | ||
|
a710f394eb | ||
|
517e796e90 | ||
|
5cacdcfee0 | ||
|
c99fc2ad70 | ||
|
e0efbaeb4e | ||
|
387bad73b0 | ||
|
9be64730b6 | ||
|
b84c90891c | ||
|
c02e1de001 | ||
|
8e82aad86b | ||
|
8153060831 | ||
|
302d4bc02d | ||
|
1b0abebe8f | ||
|
156f5b78bf | ||
|
8a06663023 | ||
|
ad140d5af1 | ||
|
a78ae52a54 | ||
|
b133f58f7a | ||
|
bade40acc6 | ||
|
1dc46127c3 | ||
|
79411437cf | ||
|
6472800387 | ||
|
fe6e7b09b5 | ||
|
ad1cceac86 | ||
|
2852834ce3 | ||
|
1ad5db73a9 | ||
|
42b470b06b | ||
|
d7360e7741 | ||
|
c54ffd4fc3 | ||
|
ba45178e04 | ||
|
11a9177592 | ||
|
4bf171def9 | ||
|
eaf92b382b | ||
|
a0a07355d4 | ||
|
0a37fd561e | ||
|
9efd191f4e | ||
|
cad2aa760d | ||
|
4be82cd472 | ||
|
e760b8e556 | ||
|
e482e3aeef | ||
|
6daae797e5 | ||
|
07bc0a2376 | ||
|
1bccbbfa08 | ||
|
f674492685 | ||
|
3c160c8a37 | ||
|
b213a45c5c | ||
|
b2ac4bc291 | ||
|
6da4a4209c | ||
|
4bedd4737b | ||
|
60da85d641 | ||
|
6fe90e60db | ||
|
ecf7eab3ee | ||
|
25b0148073 | ||
|
98b77fc761 | ||
|
179c7e74b5 | ||
|
98e1dcf799 | ||
|
e5f44aecfb | ||
|
468841ecea | ||
|
b12bc52c4a | ||
|
46ebd55092 |
164 changed files with 5778 additions and 4368 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,6 +1,5 @@
|
||||||
*.sublime-project
|
*.sublime-project
|
||||||
*.sublime-workspace
|
*.sublime-workspace
|
||||||
.DS_Store
|
|
||||||
node_modules
|
node_modules
|
||||||
fetchlogs
|
fetchlogs
|
||||||
sessionexports
|
sessionexports
|
||||||
|
@ -10,4 +9,3 @@ lib
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
.eslintcache
|
.eslintcache
|
||||||
.tmp
|
.tmp
|
||||||
tmp/
|
|
||||||
|
|
|
@ -19,7 +19,6 @@ module.exports = {
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
"@typescript-eslint/no-floating-promises": 2,
|
"@typescript-eslint/no-floating-promises": 2,
|
||||||
"@typescript-eslint/no-misused-promises": 2,
|
"@typescript-eslint/no-misused-promises": 2
|
||||||
"semi": ["error", "always"]
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
pipeline:
|
|
||||||
buildfrontend:
|
|
||||||
image: node:16
|
|
||||||
commands:
|
|
||||||
- yarn install --prefer-offline --frozen-lockfile
|
|
||||||
- yarn test
|
|
||||||
- yarn run lint-ci
|
|
||||||
- yarn run tsc
|
|
||||||
- yarn build
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
image: python
|
|
||||||
when:
|
|
||||||
event: push
|
|
||||||
branch: master
|
|
||||||
commands:
|
|
||||||
- make ci-deploy
|
|
||||||
secrets: [ GITEA_WRITE_DEPLOY_KEY, LIBREPAGES_DEPLOY_SECRET ]
|
|
14
Makefile
14
Makefile
|
@ -1,14 +0,0 @@
|
||||||
ci-deploy: ## Deploy from CI/CD. Only call from within CI
|
|
||||||
@if [ "${CI}" != "woodpecker" ]; \
|
|
||||||
then echo "Only call from within CI. Will re-write your local Git configuration. To override, set export CI=woodpecker"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
git config --global user.email "${CI_COMMIT_AUTHOR_EMAIL}"
|
|
||||||
git config --global user.name "${CI_COMMIT_AUTHOR}"
|
|
||||||
./scripts/ci.sh --commit-files librepages target "${CI_COMMIT_AUTHOR} <${CI_COMMIT_AUTHOR_EMAIL}>"
|
|
||||||
./scripts/ci.sh --init "$$GITEA_WRITE_DEPLOY_KEY"
|
|
||||||
./scripts/ci.sh --deploy ${LIBREPAGES_DEPLOY_SECRET} librepages
|
|
||||||
./scripts/ci.sh --clean
|
|
||||||
|
|
||||||
help: ## Prints help for targets with comments
|
|
||||||
@cat $(MAKEFILE_LIST) | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
|
27
README.md
27
README.md
|
@ -1,5 +1,3 @@
|
||||||
[![status-badge](https://ci.batsense.net/api/badges/mystiq/hydrogen-web/status.svg)](https://ci.batsense.net/mystiq/hydrogen-web)
|
|
||||||
|
|
||||||
# Hydrogen
|
# Hydrogen
|
||||||
|
|
||||||
A minimal [Matrix](https://matrix.org/) chat client, focused on performance, offline functionality, and broad browser support. This is work in progress and not yet ready for primetime. Bug reports are welcome, but please don't file any feature requests or other missing things to be on par with Element Web.
|
A minimal [Matrix](https://matrix.org/) chat client, focused on performance, offline functionality, and broad browser support. This is work in progress and not yet ready for primetime. Bug reports are welcome, but please don't file any feature requests or other missing things to be on par with Element Web.
|
||||||
|
@ -12,34 +10,13 @@ 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 also deploy Hydrogen on your own web server:
|
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).
|
||||||
|
|
||||||
1. Download the [latest release package](https://github.com/vector-im/hydrogen-web/releases).
|
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. 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
|
||||||
|
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
## How to import common-js dependency using ES6 syntax
|
|
||||||
---
|
|
||||||
Until [#6632](https://github.com/vitejs/vite/issues/6632) is fixed, such imports should be done as follows:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import * as pkg from "off-color";
|
|
||||||
// @ts-ignore
|
|
||||||
const offColor = pkg.offColor ?? pkg.default.offColor;
|
|
||||||
```
|
|
||||||
|
|
||||||
This way build, dev server and unit tests should all work.
|
|
11
doc/SDK.md
11
doc/SDK.md
|
@ -31,8 +31,7 @@ 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';
|
||||||
|
@ -48,13 +47,13 @@ const assetPaths = {
|
||||||
wasmBundle: olmJsPath
|
wasmBundle: olmJsPath
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
import "hydrogen-view-sdk/assets/theme-element-light.css";
|
import "hydrogen-view-sdk/theme-element-light.css";
|
||||||
// OR import "hydrogen-view-sdk/assets/theme-element-dark.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({container: app, assetPaths, config, options: { development: import.meta.env.DEV }});
|
const platform = new Platform(app, assetPaths, config, { development: import.meta.env.DEV });
|
||||||
const navigation = createNavigation();
|
const navigation = createNavigation();
|
||||||
platform.setNavigation(navigation);
|
platform.setNavigation(navigation);
|
||||||
const urlRouter = createRouter({
|
const urlRouter = createRouter({
|
||||||
|
@ -89,7 +88,7 @@ async function main() {
|
||||||
navigation,
|
navigation,
|
||||||
});
|
});
|
||||||
await vm.load();
|
await vm.load();
|
||||||
const view = new TimelineView(vm.timelineViewModel, viewClassForTile);
|
const view = new TimelineView(vm.timelineViewModel);
|
||||||
app.appendChild(view.mount());
|
app.appendChild(view.mount());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
204
doc/THEMING.md
204
doc/THEMING.md
|
@ -1,204 +0,0 @@
|
||||||
# Theming Documentation
|
|
||||||
## Basic Architecture
|
|
||||||
A **theme collection** in Hydrogen is represented by a `manifest.json` file and a `theme.css` file.
|
|
||||||
The manifest specifies variants (eg: dark,light ...) each of which is a **theme** and maps to a single css file in the build output.
|
|
||||||
|
|
||||||
Each such theme is produced by changing the values of variables in the base `theme.css` file with those specified in the variant section of the manifest:
|
|
||||||
|
|
||||||
![](images/theming-architecture.png)
|
|
||||||
|
|
||||||
More in depth explanations can be found in later sections.
|
|
||||||
|
|
||||||
## Structure of `manifest.json`
|
|
||||||
[See theme.ts](../src/platform/types/theme.ts)
|
|
||||||
|
|
||||||
## Variables
|
|
||||||
CSS variables specific to a particular variant are specified in the `variants` section of the manifest:
|
|
||||||
```json=
|
|
||||||
"variants": {
|
|
||||||
"light": {
|
|
||||||
...
|
|
||||||
"variables": {
|
|
||||||
"background-color-primary": "#fff",
|
|
||||||
"text-color": "#2E2F32",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dark": {
|
|
||||||
...
|
|
||||||
"variables": {
|
|
||||||
"background-color-primary": "#21262b",
|
|
||||||
"text-color": "#fff",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
These variables will appear in the css file (theme.css):
|
|
||||||
```css=
|
|
||||||
body {
|
|
||||||
background-color: var(--background-color-primary);
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
During the build process, this would result in the creation of two css files (one for each variant) where the variables are substitued with the corresponding values specified in the manifest:
|
|
||||||
|
|
||||||
*element-light.css*:
|
|
||||||
```css=
|
|
||||||
body {
|
|
||||||
background-color: #fff;
|
|
||||||
color: #2E2F32;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
*element-dark.css*:
|
|
||||||
```css=
|
|
||||||
body {
|
|
||||||
background-color: #21262b;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Derived Variables
|
|
||||||
In addition to simple substitution of variables in the stylesheet, it is also possible to instruct the build system to first produce a new value from the base variable value before the substitution.
|
|
||||||
|
|
||||||
Such derived variables have the form `base_css_variable--operation-arg` and can be read as:
|
|
||||||
apply `operation` to `base_css_variable` with argument `arg`.
|
|
||||||
|
|
||||||
Continuing with the previous example, it possible to specify:
|
|
||||||
```css=
|
|
||||||
.left-panel {
|
|
||||||
/* background color should be 20% more darker
|
|
||||||
than background-color-primary */
|
|
||||||
background-color: var(--background-color-primary--darker-20);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Currently supported operations are:
|
|
||||||
|
|
||||||
| Operation | Argument | Operates On |
|
|
||||||
| -------- | -------- | -------- |
|
|
||||||
| darker | percentage | color |
|
|
||||||
| lighter | percentage | color |
|
|
||||||
|
|
||||||
## Aliases
|
|
||||||
It is possible give aliases to variables in the `theme.css` file:
|
|
||||||
```css=
|
|
||||||
:root {
|
|
||||||
font-size: 10px;
|
|
||||||
/* Theme aliases */
|
|
||||||
--icon-color: var(--background-color-secondary--darker-40);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
It is possible to further derive from these aliased variables:
|
|
||||||
```css=
|
|
||||||
div {
|
|
||||||
background: var(--icon-color--darker-20);
|
|
||||||
--my-alias: var(--icon-color--darker-20);
|
|
||||||
/* Derive from aliased variable */
|
|
||||||
color: var(--my-alias--lighter-15);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Colorizing svgs
|
|
||||||
Along with a change in color-scheme, it may be necessary to change the colors in the svg icons and images.
|
|
||||||
This can be done by supplying the preferred colors with query parameters:
|
|
||||||
`my-awesome-logo.svg?primary=base-variable-1&secondary=base-variable-2`
|
|
||||||
|
|
||||||
This instructs the build system to colorize the svg with the given primary and secondary colors.
|
|
||||||
`base-variable-1` and `base-variable-2` are the css-variables specified in the `variables` section of the manifest.
|
|
||||||
|
|
||||||
For colorizing svgs, the source svg must use `#ff00ff` as the primary color and `#00ffff` as the secondary color:
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
| ![](images/svg-icon-example.png) | ![](images/coloring-process.png) |
|
|
||||||
| :--: |:--: |
|
|
||||||
| **original source image** | **transformation process** |
|
|
||||||
|
|
||||||
## Creating your own theme variant in Hydrogen
|
|
||||||
If you're looking to change the color-scheme of the existing Element theme, you only need to add your own variant to the existing `manifest.json`.
|
|
||||||
|
|
||||||
The steps are fairly simple:
|
|
||||||
1. Copy over an existing variant to the variants section of the manifest.
|
|
||||||
2. Change `dark`, `default` and `name` fields.
|
|
||||||
3. Give new values to each variable in the `variables` section.
|
|
||||||
4. Build hydrogen.
|
|
||||||
|
|
||||||
## Creating your own theme collection in Hydrogen
|
|
||||||
If a theme variant does not solve your needs, you can create a new theme collection with a different base `theme.css` file.
|
|
||||||
1. Create a directory for your new theme-collection under `src/platform/web/ui/css/themes/`.
|
|
||||||
2. Create `manifest.json` and `theme.css` files within the newly created directory.
|
|
||||||
3. Populate `manifest.json` with the base css variables you wish to use.
|
|
||||||
4. Write styles in your `theme.css` file using the base variables, derived variables and colorized svg icons.
|
|
||||||
5. Tell the build system where to find this theme-collection by providing the location of this directory to the `themeBuilder` plugin in `vite.config.js`:
|
|
||||||
```json=
|
|
||||||
...
|
|
||||||
themeBuilder({
|
|
||||||
themeConfig: {
|
|
||||||
themes: {
|
|
||||||
element: "./src/platform/web/ui/css/themes/element",
|
|
||||||
awesome: "path/to/theme-directory"
|
|
||||||
},
|
|
||||||
default: "element",
|
|
||||||
},
|
|
||||||
compiledVariables,
|
|
||||||
}),
|
|
||||||
...
|
|
||||||
```
|
|
||||||
6. Build Hydrogen.
|
|
||||||
|
|
||||||
## Changing the default theme
|
|
||||||
To change the default theme used in Hydrogen, modify the `defaultTheme` field in `config.json` file (which can be found in the build output):
|
|
||||||
```json=
|
|
||||||
"defaultTheme": {
|
|
||||||
"light": theme-id,
|
|
||||||
"dark": theme-id
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Here *theme-id* is of the form `theme-variant` where `theme` is the key used when specifying the manifest location of the theme collection in `vite.config.js` and `variant` is the key used in variants section of the manifest.
|
|
||||||
|
|
||||||
Some examples of theme-ids are `element-dark` and `element-light`.
|
|
||||||
|
|
||||||
To find the theme-id of some theme, you can look at the built-asset section of the manifest in the build output.
|
|
||||||
|
|
||||||
This default theme will render as "Default" option in the theme-chooser dropdown. If the device preference is for dark theme, the dark default is selected and vice versa.
|
|
||||||
|
|
||||||
**You'll need to reload twice so that Hydrogen picks up the config changes!**
|
|
||||||
|
|
||||||
# Derived Theme(Collection)
|
|
||||||
This allows users to theme Hydrogen without the need for rebuilding. Derived theme collections can be thought of as extensions (derivations) of some existing build time theme.
|
|
||||||
|
|
||||||
## Creating a derived theme:
|
|
||||||
Here's how you create a new derived theme:
|
|
||||||
1. You create a new theme manifest file (eg: theme-awesome.json) and mention which build time theme you're basing your new theme on using the `extends` field. The base css file of the mentioned theme is used for your new theme.
|
|
||||||
2. You configure the theme manifest as usual by populating the `variants` field with your desired colors.
|
|
||||||
3. You add your new theme manifest to the list of themes in `config.json`.
|
|
||||||
|
|
||||||
Refresh Hydrogen twice (once to refresh cache, and once to load) and the new theme should show up in the theme chooser.
|
|
||||||
|
|
||||||
## How does it work?
|
|
||||||
|
|
||||||
For every theme collection in hydrogen, the build process emits a runtime css file which like the built theme css file contains variables in the css code. But unlike the theme css file, the runtime css file lacks the definition for these variables:
|
|
||||||
|
|
||||||
CSS for the built theme:
|
|
||||||
```css
|
|
||||||
:root {
|
|
||||||
--background-color-primary: #f2f20f;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--background-color-primary);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
and the corresponding runtime theme:
|
|
||||||
```css
|
|
||||||
/* Notice the lack of definiton for --background-color-primary here! */
|
|
||||||
body {
|
|
||||||
background-color: var(--background-color-primary);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
When hydrogen loads a derived theme, it takes the runtime css file of the extended theme and dynamically adds the variable definition based on the values specified in the manifest. Icons are also colored dynamically and injected as variables using Data URIs.
|
|
206
doc/UI/ui.md
206
doc/UI/ui.md
|
@ -1,206 +0,0 @@
|
||||||
## IView components
|
|
||||||
|
|
||||||
The [interface](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/types.ts) adopted by view components is agnostic of how they are rendered to the DOM. This has several benefits:
|
|
||||||
- it allows Hydrogen to not ship a [heavy view framework](https://bundlephobia.com/package/react-dom@18.2.0) that may or may not be used by its SDK users, and also keep bundle size of the app down.
|
|
||||||
- Given the interface is quite simple, is should be easy to integrate this interface into the render lifecycle of other frameworks.
|
|
||||||
- The main implementations used in Hydrogen are [`ListView`](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/ListView.ts) (rendering [`ObservableList`](https://github.com/vector-im/hydrogen-web/blob/master/src/observable/list/BaseObservableList.ts)s) and [`TemplateView`](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/TemplateView.ts) (templating and one-way databinding), each only a few 100 lines of code and tailored towards their specific use-case. They work straight with the DOM API and have no other dependencies.
|
|
||||||
- a common inteface allows us to mix and match between these different implementations (and gradually shift if need be in the future) with the code.
|
|
||||||
|
|
||||||
## Templates
|
|
||||||
|
|
||||||
### Template language
|
|
||||||
|
|
||||||
Templates use a mini-DSL language in pure javascript to express declarative templates. This is basically a very thin wrapper around `document.createElement`, `document.createTextNode`, `node.setAttribute` and `node.appendChild` to quickly create DOM trees. The general syntax is as follows:
|
|
||||||
```js
|
|
||||||
t.tag_name({attribute1: value, attribute2: value, ...}, [child_elements]);
|
|
||||||
t.tag_name(child_element);
|
|
||||||
t.tag_name([child_elements]);
|
|
||||||
```
|
|
||||||
**tag_name** can be [most HTML or SVG tags](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/html.ts#L102-L110).
|
|
||||||
|
|
||||||
eg:
|
|
||||||
Here is an example HTML segment followed with the code to create it in Hydrogen.
|
|
||||||
```html
|
|
||||||
<section class="main-section">
|
|
||||||
<h1>Demo</h1>
|
|
||||||
<button class="btn_cool">Click me</button>
|
|
||||||
</section>
|
|
||||||
```
|
|
||||||
```js
|
|
||||||
t.section({className: "main-section"},[
|
|
||||||
t.h1("Demo"),
|
|
||||||
t.button({className:"btn_cool"}, "Click me")
|
|
||||||
]);
|
|
||||||
```
|
|
||||||
|
|
||||||
All these functions return DOM element nodes, e.g. the result of `document.createElement`.
|
|
||||||
|
|
||||||
### TemplateView
|
|
||||||
|
|
||||||
`TemplateView` builds on top of templating by adopting the IView component model and adding event handling attributes, sub views and one-way databinding.
|
|
||||||
In views based on `TemplateView`, you will see a render method with a `t` argument.
|
|
||||||
`t` is `TemplateBuilder` object passed to the render function in `TemplateView`. It also takes a data object to render and bind to, often called `vm`, short for view model from the MVVM pattern Hydrogen uses.
|
|
||||||
|
|
||||||
You either subclass `TemplateView` and override the `render` method:
|
|
||||||
```js
|
|
||||||
class MyView extends TemplateView {
|
|
||||||
render(t, vm) {
|
|
||||||
return t.div(...);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Or you pass a render function to `InlineTemplateView`:
|
|
||||||
```js
|
|
||||||
new InlineTemplateView(vm, (t, vm) => {
|
|
||||||
return t.div(...);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** the render function is only called once to build the initial DOM tree and setup bindings, etc ... Any subsequent updates to the DOM of a component happens through bindings.
|
|
||||||
|
|
||||||
#### Event handlers
|
|
||||||
|
|
||||||
Any attribute starting with `on` and having a function as a value will be attached as an event listener on the given node. The event handler will be removed during unmounting.
|
|
||||||
|
|
||||||
```js
|
|
||||||
t.button({onClick: evt => {
|
|
||||||
vm.doSomething(evt.target.value);
|
|
||||||
}}, "Click me");
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Subviews
|
|
||||||
|
|
||||||
`t.view(instance)` will mount the sub view (can be any IView) and return its root node so it can be attached in the DOM tree.
|
|
||||||
All subviews will be unmounted when the parent view gets unmounted.
|
|
||||||
|
|
||||||
```js
|
|
||||||
t.div({className: "Container"}, t.view(new ChildView(vm.childViewModel)));
|
|
||||||
```
|
|
||||||
|
|
||||||
#### One-way data-binding
|
|
||||||
|
|
||||||
A binding couples a part of the DOM to a value on the view model. The view model emits an update when any of its properties change, to which the view can subscribe. When an update is received by the view, it will reevaluate all the bindings, and update the DOM accordingly.
|
|
||||||
|
|
||||||
A binding can appear in many places where a static value can usually be used in the template tree.
|
|
||||||
To create a binding, you pass a function that maps the view value to a static value.
|
|
||||||
|
|
||||||
##### Text binding
|
|
||||||
|
|
||||||
```js
|
|
||||||
t.p(["I've got ", vm => vm.counter, " beans"])
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Attribute binding
|
|
||||||
|
|
||||||
```js
|
|
||||||
t.button({disabled: vm => vm.isBusy}, "Submit");
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Class-name binding
|
|
||||||
```js
|
|
||||||
t.div({className: {
|
|
||||||
button: true,
|
|
||||||
active: vm => vm.isActive
|
|
||||||
}})
|
|
||||||
```
|
|
||||||
##### Subview binding
|
|
||||||
|
|
||||||
So far, all the bindings can only change node values within our tree, but don't change the structure of the DOM. A sub view binding allows you to conditionally add a subview based on the result of a binding function.
|
|
||||||
|
|
||||||
All sub view bindings return a DOM (element or comment) node and can be directly added to the DOM tree by including them in your template.
|
|
||||||
|
|
||||||
###### map
|
|
||||||
|
|
||||||
`t.mapView` allows you to choose a view based on the result of the binding function:
|
|
||||||
|
|
||||||
```js
|
|
||||||
t.mapView(vm => vm.count, count => {
|
|
||||||
return count > 5 ? new LargeView(count) : new SmallView(count);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Every time the first or binding function returns a different value, the second function is run to create a new view to replace the previous view.
|
|
||||||
|
|
||||||
You can also return `null` or `undefined` from the second function to indicate a view should not be rendered. In this case a comment node will be used as a placeholder.
|
|
||||||
|
|
||||||
There is also a `t.map` which will create a new template view (with the same value) and you directly provide a render function for it:
|
|
||||||
|
|
||||||
```js
|
|
||||||
t.map(vm => vm.shape, (shape, t, vm) => {
|
|
||||||
switch (shape) {
|
|
||||||
case "rect": return t.rect();
|
|
||||||
case "circle": return t.circle();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
###### if
|
|
||||||
|
|
||||||
`t.ifView` will render the subview if the binding returns a truthy value:
|
|
||||||
|
|
||||||
```js
|
|
||||||
t.ifView(vm => vm.isActive, vm => new View(vm.someValue));
|
|
||||||
```
|
|
||||||
|
|
||||||
You equally have `t.if`, which creates a `TemplateView` and passes you the `TemplateBuilder`:
|
|
||||||
|
|
||||||
```js
|
|
||||||
t.if(vm => vm.isActive, (t, vm) => t.div("active!"));
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Side-effects
|
|
||||||
|
|
||||||
Sometimes you want to imperatively modify your DOM tree based on the value of a binding.
|
|
||||||
`mapSideEffect` makes this easy to do:
|
|
||||||
|
|
||||||
```js
|
|
||||||
let node = t.div();
|
|
||||||
t.mapSideEffect(vm => vm.color, (color, oldColor) => node.style.background = color);
|
|
||||||
return node;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** you shouldn't add any bindings, subviews or event handlers from the side-effect callback,
|
|
||||||
the safest is to not use the `t` argument at all.
|
|
||||||
If you do, they will be added every time the callback is run and only cleaned up when the view is unmounted.
|
|
||||||
|
|
||||||
#### `tag` vs `t`
|
|
||||||
|
|
||||||
If you don't need a view component with data-binding, sub views and event handler attributes, the template language also is available in `ui/general/html.js` without any of these bells and whistles, exported as `tag`. As opposed to static templates with `tag`, you always use
|
|
||||||
`TemplateView` as an instance of a class, as there is some extra state to keep track (bindings, event handlers and subviews).
|
|
||||||
|
|
||||||
Although syntactically similar, `TemplateBuilder` and `tag` are not functionally equivalent.
|
|
||||||
Primarily `t` **supports** bindings and event handlers while `tag` **does not**. This is because to remove event listeners, we need to keep track of them, and thus we need to keep this state somewhere which
|
|
||||||
we can't do with a simple function call but we can insite the TemplateView class.
|
|
||||||
|
|
||||||
```js
|
|
||||||
// The onClick here wont work!!
|
|
||||||
tag.button({className:"awesome-btn", onClick: () => this.foo()});
|
|
||||||
|
|
||||||
class MyView extends TemplateView {
|
|
||||||
render(t, vm){
|
|
||||||
// The onClick works here.
|
|
||||||
t.button({className:"awesome-btn", onClick: () => this.foo()});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## ListView
|
|
||||||
|
|
||||||
A view component that renders and updates a list of sub views for every item in a `ObservableList`.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const list = new ListView({
|
|
||||||
list: someObservableList
|
|
||||||
}, listValue => return new ChildView(listValue))
|
|
||||||
```
|
|
||||||
|
|
||||||
As items are added, removed, moved (change position) and updated, the DOM will be kept in sync.
|
|
||||||
|
|
||||||
There is also a `LazyListView` that only renders items in and around the current viewport, with the restriction that all items in the list must be rendered with the same height.
|
|
||||||
|
|
||||||
### Sub view updates
|
|
||||||
|
|
||||||
Unless the `parentProvidesUpdates` option in the constructor is set to `false`, the ListView will call the `update` method on the child `IView` component when it receives an update event for one of the items in the `ObservableList`.
|
|
||||||
|
|
||||||
This way, not every sub view has to have an individual listener on it's view model (a value from the observable list), and all updates go from the observable list to the list view, who then notifies the correct sub view.
|
|
Binary file not shown.
Before Width: | Height: | Size: 7.8 KiB |
Binary file not shown.
Before Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
Before Width: | Height: | Size: 19 KiB |
18
package.json
18
package.json
|
@ -1,24 +1,19 @@
|
||||||
{
|
{
|
||||||
"name": "hydrogen-web",
|
"name": "hydrogen-web",
|
||||||
"version": "0.3.1",
|
"version": "0.2.28",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"enginesStrict": {
|
|
||||||
"node": ">=15"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "eslint --cache src/",
|
"lint": "eslint --cache src/",
|
||||||
"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: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 && ./scripts/cleanup.sh",
|
"build": "vite build",
|
||||||
"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",
|
||||||
|
@ -50,14 +45,13 @@
|
||||||
"postcss-flexbugs-fixes": "^5.0.2",
|
"postcss-flexbugs-fixes": "^5.0.2",
|
||||||
"postcss-value-parser": "^4.2.0",
|
"postcss-value-parser": "^4.2.0",
|
||||||
"regenerator-runtime": "^0.13.7",
|
"regenerator-runtime": "^0.13.7",
|
||||||
"svgo": "^2.8.0",
|
|
||||||
"text-encoding": "^0.7.0",
|
"text-encoding": "^0.7.0",
|
||||||
"typescript": "^4.7.0",
|
"typescript": "^4.4",
|
||||||
"vite": "^2.9.8",
|
"vite": "^2.6.14",
|
||||||
"xxhashjs": "^0.2.2"
|
"xxhashjs": "^0.2.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.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",
|
||||||
|
|
|
@ -13,11 +13,11 @@ 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.
|
||||||
*/
|
*/
|
||||||
const path = require('path').posix;
|
const path = require('path');
|
||||||
const {optimize} = require('svgo');
|
|
||||||
|
|
||||||
async function readCSSSource(location) {
|
async function readCSSSource(location) {
|
||||||
const fs = require("fs").promises;
|
const fs = require("fs").promises;
|
||||||
|
const path = require("path");
|
||||||
const resolvedLocation = path.resolve(__dirname, "../../", `${location}/theme.css`);
|
const resolvedLocation = path.resolve(__dirname, "../../", `${location}/theme.css`);
|
||||||
const data = await fs.readFile(resolvedLocation);
|
const data = await fs.readFile(resolvedLocation);
|
||||||
return data;
|
return data;
|
||||||
|
@ -31,66 +31,29 @@ function appendVariablesToCSS(variables, cssSource) {
|
||||||
return cssSource + getRootSectionWithVariables(variables);
|
return cssSource + getRootSectionWithVariables(variables);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addThemesToConfig(bundle, manifestLocations, defaultThemes) {
|
function parseBundle(bundle) {
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an object where keys are the svg file names and the values
|
|
||||||
* are the svg code (optimized)
|
|
||||||
* @param {*} icons Object where keys are css variable names and values are locations of the svg
|
|
||||||
* @param {*} manifestLocation Location of manifest used for resolving path
|
|
||||||
*/
|
|
||||||
async function generateIconSourceMap(icons, manifestLocation) {
|
|
||||||
const sources = {};
|
|
||||||
const fileNames = [];
|
|
||||||
const promises = [];
|
|
||||||
const fs = require("fs").promises;
|
|
||||||
for (const icon of Object.values(icons)) {
|
|
||||||
const [location] = icon.split("?");
|
|
||||||
// resolve location against manifestLocation
|
|
||||||
const resolvedLocation = path.resolve(manifestLocation, location);
|
|
||||||
const iconData = fs.readFile(resolvedLocation);
|
|
||||||
promises.push(iconData);
|
|
||||||
const fileName = path.basename(resolvedLocation);
|
|
||||||
fileNames.push(fileName);
|
|
||||||
}
|
|
||||||
const results = await Promise.all(promises);
|
|
||||||
for (let i = 0; i < results.length; ++i) {
|
|
||||||
const svgString = results[i].toString();
|
|
||||||
const result = optimize(svgString, {
|
|
||||||
plugins: [
|
|
||||||
{
|
|
||||||
name: "preset-default",
|
|
||||||
params: {
|
|
||||||
overrides: { convertColors: false, },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const optimizedSvgString = result.data;
|
|
||||||
sources[fileNames[i]] = optimizedSvgString;
|
|
||||||
}
|
|
||||||
return sources;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a mapping from location (of manifest file) to an array containing all the chunks (of css files) generated from that location.
|
|
||||||
* To understand what chunk means in this context, see https://rollupjs.org/guide/en/#generatebundle.
|
|
||||||
* @param {*} bundle Mapping from fileName to AssetInfo | ChunkInfo
|
|
||||||
*/
|
|
||||||
function getMappingFromLocationToChunkArray(bundle) {
|
|
||||||
const chunkMap = new Map();
|
const chunkMap = new Map();
|
||||||
|
const assetMap = new Map();
|
||||||
|
let runtimeThemeChunk;
|
||||||
for (const [fileName, info] of Object.entries(bundle)) {
|
for (const [fileName, info] of Object.entries(bundle)) {
|
||||||
if (!fileName.endsWith(".css") || info.type === "asset" || info.facadeModuleId?.includes("type=runtime")) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
const location = info.facadeModuleId?.match(/(.+)\/.+\.css/)?.[1];
|
const location = info.facadeModuleId?.match(/(.+)\/.+\.css/)?.[1];
|
||||||
|
@ -105,64 +68,14 @@ function getMappingFromLocationToChunkArray(bundle) {
|
||||||
array.push(info);
|
array.push(info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return chunkMap;
|
return { chunkMap, assetMap, runtimeThemeChunk };
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a mapping from unhashed file name (of css files) to AssetInfo.
|
|
||||||
* To understand what AssetInfo means in this context, see https://rollupjs.org/guide/en/#generatebundle.
|
|
||||||
* @param {*} bundle Mapping from fileName to AssetInfo | ChunkInfo
|
|
||||||
*/
|
|
||||||
function getMappingFromFileNameToAssetInfo(bundle) {
|
|
||||||
const assetMap = new Map();
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return assetMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a mapping from location (of manifest file) to ChunkInfo of the runtime css asset
|
|
||||||
* To understand what ChunkInfo means in this context, see https://rollupjs.org/guide/en/#generatebundle.
|
|
||||||
* @param {*} bundle Mapping from fileName to AssetInfo | ChunkInfo
|
|
||||||
*/
|
|
||||||
function getMappingFromLocationToRuntimeChunk(bundle) {
|
|
||||||
let runtimeThemeChunkMap = new Map();
|
|
||||||
for (const [fileName, info] of Object.entries(bundle)) {
|
|
||||||
if (!fileName.endsWith(".css") || info.type === "asset") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const location = info.facadeModuleId?.match(/(.+)\/.+\.css/)?.[1];
|
|
||||||
if (!location) {
|
|
||||||
throw new Error("Cannot find location of css chunk!");
|
|
||||||
}
|
|
||||||
if (info.facadeModuleId?.includes("type=runtime")) {
|
|
||||||
/**
|
|
||||||
* We have a separate field in manifest.source just for the runtime theme,
|
|
||||||
* so store this separately.
|
|
||||||
*/
|
|
||||||
runtimeThemeChunkMap.set(location, info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return runtimeThemeChunkMap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = function buildThemes(options) {
|
module.exports = function buildThemes(options) {
|
||||||
let manifest, variants, defaultDark, defaultLight, defaultThemes = {};
|
let manifest, variants, defaultDark, defaultLight;
|
||||||
let isDevelopment = false;
|
let isDevelopment = false;
|
||||||
const virtualModuleId = '@theme/'
|
const virtualModuleId = '@theme/'
|
||||||
const resolvedVirtualModuleId = '\0' + virtualModuleId;
|
const resolvedVirtualModuleId = '\0' + virtualModuleId;
|
||||||
const themeToManifestLocation = new Map();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "build-themes",
|
name: "build-themes",
|
||||||
|
@ -175,34 +88,35 @@ module.exports = function buildThemes(options) {
|
||||||
},
|
},
|
||||||
|
|
||||||
async buildStart() {
|
async buildStart() {
|
||||||
|
if (isDevelopment) { return; }
|
||||||
const { themeConfig } = options;
|
const { themeConfig } = options;
|
||||||
for (const location of themeConfig.themes) {
|
for (const [name, location] of Object.entries(themeConfig.themes)) {
|
||||||
manifest = require(`${location}/manifest.json`);
|
manifest = require(`${location}/manifest.json`);
|
||||||
const themeCollectionId = manifest.id;
|
|
||||||
themeToManifestLocation.set(themeCollectionId, location);
|
|
||||||
variants = manifest.values.variants;
|
variants = manifest.values.variants;
|
||||||
for (const [variant, details] of Object.entries(variants)) {
|
for (const [variant, details] of Object.entries(variants)) {
|
||||||
const fileName = `theme-${themeCollectionId}-${variant}.css`;
|
const fileName = `theme-${name}-${variant}.css`;
|
||||||
if (themeCollectionId === themeConfig.default && details.default) {
|
if (name === themeConfig.default && details.default) {
|
||||||
// This is the default theme, stash the file name for later
|
// This is the default theme, stash the file name for later
|
||||||
if (details.dark) {
|
if (details.dark) {
|
||||||
defaultDark = fileName;
|
defaultDark = fileName;
|
||||||
defaultThemes["dark"] = `${themeCollectionId}-${variant}`;
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
defaultLight = fileName;
|
defaultLight = fileName;
|
||||||
defaultThemes["light"] = `${themeCollectionId}-${variant}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// emit the css as built theme bundle
|
// emit the css as built theme bundle
|
||||||
if (!isDevelopment) {
|
this.emitFile({
|
||||||
this.emitFile({ type: "chunk", id: `${location}/theme.css?variant=${variant}${details.dark ? "&dark=true" : ""}`, fileName, });
|
type: "chunk",
|
||||||
}
|
id: `${location}/theme.css?variant=${variant}${details.dark? "&dark=true": ""}`,
|
||||||
|
fileName,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// emit the css as runtime theme bundle
|
// emit the css as runtime theme bundle
|
||||||
if (!isDevelopment) {
|
this.emitFile({
|
||||||
this.emitFile({ type: "chunk", id: `${location}/theme.css?type=runtime`, fileName: `theme-${themeCollectionId}-runtime.css`, });
|
type: "chunk",
|
||||||
}
|
id: `${location}/theme.css?type=runtime`,
|
||||||
|
fileName: `theme-${name}-runtime.css`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -224,7 +138,7 @@ module.exports = function buildThemes(options) {
|
||||||
if (theme === "default") {
|
if (theme === "default") {
|
||||||
theme = options.themeConfig.default;
|
theme = options.themeConfig.default;
|
||||||
}
|
}
|
||||||
const location = themeToManifestLocation.get(theme);
|
const location = options.themeConfig.themes[theme];
|
||||||
const manifest = require(`${location}/manifest.json`);
|
const manifest = require(`${location}/manifest.json`);
|
||||||
const variants = manifest.values.variants;
|
const variants = manifest.values.variants;
|
||||||
if (!variant || variant === "default") {
|
if (!variant || variant === "default") {
|
||||||
|
@ -301,7 +215,6 @@ module.exports = function buildThemes(options) {
|
||||||
type: "text/css",
|
type: "text/css",
|
||||||
media: "(prefers-color-scheme: dark)",
|
media: "(prefers-color-scheme: dark)",
|
||||||
href: `./${darkThemeLocation}`,
|
href: `./${darkThemeLocation}`,
|
||||||
class: "theme",
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -311,66 +224,31 @@ module.exports = function buildThemes(options) {
|
||||||
type: "text/css",
|
type: "text/css",
|
||||||
media: "(prefers-color-scheme: light)",
|
media: "(prefers-color-scheme: light)",
|
||||||
href: `./${lightThemeLocation}`,
|
href: `./${lightThemeLocation}`,
|
||||||
class: "theme",
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
async generateBundle(_, bundle) {
|
generateBundle(_, bundle) {
|
||||||
const assetMap = getMappingFromFileNameToAssetInfo(bundle);
|
const { assetMap, chunkMap, runtimeThemeChunk } = parseBundle(bundle);
|
||||||
const chunkMap = getMappingFromLocationToChunkArray(bundle);
|
|
||||||
const runtimeThemeChunkMap = getMappingFromLocationToRuntimeChunk(bundle);
|
|
||||||
const manifestLocations = [];
|
|
||||||
// Location of the directory containing manifest relative to the root of the build output
|
|
||||||
const manifestLocation = "assets";
|
|
||||||
for (const [location, chunkArray] of chunkMap) {
|
for (const [location, chunkArray] of chunkMap) {
|
||||||
const manifest = require(`${location}/manifest.json`);
|
const manifest = require(`${location}/manifest.json`);
|
||||||
const compiledVariables = options.compiledVariables.get(location);
|
const compiledVariables = options.compiledVariables.get(location);
|
||||||
const derivedVariables = compiledVariables["derived-variables"];
|
const derivedVariables = compiledVariables["derived-variables"];
|
||||||
const icon = compiledVariables["icon"];
|
const icon = compiledVariables["icon"];
|
||||||
const builtAssets = {};
|
|
||||||
let themeKey;
|
|
||||||
for (const chunk of chunkArray) {
|
|
||||||
const [, name, variant] = chunk.fileName.match(/theme-(.+)-(.+)\.css/);
|
|
||||||
themeKey = name;
|
|
||||||
const locationRelativeToBuildRoot = assetMap.get(chunk.fileName).fileName;
|
|
||||||
const locationRelativeToManifest = path.relative(manifestLocation, locationRelativeToBuildRoot);
|
|
||||||
builtAssets[`${name}-${variant}`] = locationRelativeToManifest;
|
|
||||||
}
|
|
||||||
// Emit the base svg icons as asset
|
|
||||||
const nameToAssetHashedLocation = [];
|
|
||||||
const nameToSource = await generateIconSourceMap(icon, location);
|
|
||||||
for (const [name, source] of Object.entries(nameToSource)) {
|
|
||||||
const ref = this.emitFile({ type: "asset", name, source });
|
|
||||||
const assetHashedName = this.getFileName(ref);
|
|
||||||
nameToAssetHashedLocation[name] = assetHashedName;
|
|
||||||
}
|
|
||||||
// Update icon section in output manifest with paths to the icon in build output
|
|
||||||
for (const [variable, location] of Object.entries(icon)) {
|
|
||||||
const [locationWithoutQueryParameters, queryParameters] = location.split("?");
|
|
||||||
const name = path.basename(locationWithoutQueryParameters);
|
|
||||||
const locationRelativeToBuildRoot = nameToAssetHashedLocation[name];
|
|
||||||
const locationRelativeToManifest = path.relative(manifestLocation, locationRelativeToBuildRoot);
|
|
||||||
icon[variable] = `${locationRelativeToManifest}?${queryParameters}`;
|
|
||||||
}
|
|
||||||
const runtimeThemeChunk = runtimeThemeChunkMap.get(location);
|
|
||||||
const runtimeAssetLocation = path.relative(manifestLocation, assetMap.get(runtimeThemeChunk.fileName).fileName);
|
|
||||||
manifest.source = {
|
manifest.source = {
|
||||||
"built-assets": builtAssets,
|
"built-asset": chunkArray.map(chunk => assetMap.get(chunk.fileName).fileName),
|
||||||
"runtime-asset": runtimeAssetLocation,
|
"runtime-asset": assetMap.get(runtimeThemeChunk.fileName).fileName,
|
||||||
"derived-variables": derivedVariables,
|
"derived-variables": derivedVariables,
|
||||||
"icon": icon,
|
"icon": icon
|
||||||
};
|
};
|
||||||
const name = `theme-${themeKey}.json`;
|
const name = `theme-${manifest.name}.json`;
|
||||||
manifestLocations.push(`${manifestLocation}/${name}`);
|
|
||||||
this.emitFile({
|
this.emitFile({
|
||||||
type: "asset",
|
type: "asset",
|
||||||
name,
|
name,
|
||||||
source: JSON.stringify(manifest),
|
source: JSON.stringify(manifest),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
addThemesToConfig(bundle, manifestLocations, defaultThemes);
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ function contentHash(str) {
|
||||||
return hasher.digest();
|
return hasher.digest();
|
||||||
}
|
}
|
||||||
|
|
||||||
function injectServiceWorker(swFile, findUnhashedFileNamesFromBundle, placeholdersPerChunk) {
|
function injectServiceWorker(swFile, otherUnhashedFiles, placeholdersPerChunk) {
|
||||||
const swName = path.basename(swFile);
|
const swName = path.basename(swFile);
|
||||||
let root;
|
let root;
|
||||||
let version;
|
let version;
|
||||||
|
@ -31,7 +31,6 @@ function injectServiceWorker(swFile, findUnhashedFileNamesFromBundle, placeholde
|
||||||
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];
|
||||||
|
|
165
scripts/ci.sh
165
scripts/ci.sh
|
@ -1,165 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# ci.sh: Helper script to automate deployment operations on CI/CD
|
|
||||||
# Copyright © 2022 Aravinth Manivannan <realaravinth@batsense.net>
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as
|
|
||||||
# published by the Free Software Foundation, either version 3 of the
|
|
||||||
# License, or (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
set -xEeuo pipefail
|
|
||||||
#source $(pwd)/scripts/lib.sh
|
|
||||||
|
|
||||||
readonly SSH_ID_FILE=/tmp/ci-ssh-id
|
|
||||||
readonly SSH_REMOTE_NAME=origin-ssh
|
|
||||||
readonly PROJECT_ROOT=$(pwd)
|
|
||||||
|
|
||||||
match_arg() {
|
|
||||||
if [ $1 == $2 ] || [ $1 == $3 ]
|
|
||||||
then
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
help() {
|
|
||||||
cat << EOF
|
|
||||||
USAGE: ci.sh [SUBCOMMAND]
|
|
||||||
Helper script to automate deployment operations on CI/CD
|
|
||||||
|
|
||||||
Subcommands
|
|
||||||
|
|
||||||
-c --clean cleanup secrets, SSH key and other runtime data
|
|
||||||
-i --init <SSH_PRIVATE_KEY> initialize environment, write SSH private to file
|
|
||||||
-d --deploy <PAGES-SECRET> <TARGET BRANCH> push branch to Gitea and call Pages server
|
|
||||||
-h --help print this help menu
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
# $1: SSH private key
|
|
||||||
write_ssh(){
|
|
||||||
truncate --size 0 $SSH_ID_FILE
|
|
||||||
echo "$1" > $SSH_ID_FILE
|
|
||||||
chmod 600 $SSH_ID_FILE
|
|
||||||
}
|
|
||||||
|
|
||||||
set_ssh_remote() {
|
|
||||||
http_remote_url=$(git remote get-url origin)
|
|
||||||
remote_hostname=$(echo $http_remote_url | cut -d '/' -f 3)
|
|
||||||
repository_owner=$(echo $http_remote_url | cut -d '/' -f 4)
|
|
||||||
repository_name=$(echo $http_remote_url | cut -d '/' -f 5)
|
|
||||||
ssh_remote="git@$remote_hostname:$repository_owner/$repository_name"
|
|
||||||
ssh_remote="git@git.batsense.net:mystiq/hydrogen-web.git"
|
|
||||||
git remote add $SSH_REMOTE_NAME $ssh_remote
|
|
||||||
}
|
|
||||||
|
|
||||||
clean() {
|
|
||||||
if [ -f $SSH_ID_FILE ]
|
|
||||||
then
|
|
||||||
shred $SSH_ID_FILE
|
|
||||||
rm $SSH_ID_FILE
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# $1: branch name
|
|
||||||
# $2: directory containing build assets
|
|
||||||
# $3: Author in <author-name author@example.com> format
|
|
||||||
commit_files() {
|
|
||||||
cd $PROJECT_ROOT
|
|
||||||
original_branch=$(git branch --show-current)
|
|
||||||
tmp_dir=$(mktemp -d)
|
|
||||||
cp -r $2/* $tmp_dir
|
|
||||||
|
|
||||||
if [[ -z $(git ls-remote --heads origin ${1}) ]]
|
|
||||||
then
|
|
||||||
echo "[*] Creating deployment branch $1"
|
|
||||||
git checkout --orphan $1
|
|
||||||
else
|
|
||||||
echo "[*] Deployment branch $1 exists, pulling changes from remote"
|
|
||||||
git fetch origin $1
|
|
||||||
git switch $1
|
|
||||||
fi
|
|
||||||
|
|
||||||
git rm -rf .
|
|
||||||
/bin/rm -rf *
|
|
||||||
cp -r $tmp_dir/* .
|
|
||||||
git add --all
|
|
||||||
if [ $(git status --porcelain | xargs | sed '/^$/d' | wc -l) -gt 0 ];
|
|
||||||
then
|
|
||||||
echo "[*] Repository has changed, committing changes"
|
|
||||||
git commit \
|
|
||||||
--author="$3" \
|
|
||||||
--message="new deploy: $(date --iso-8601=seconds)"
|
|
||||||
fi
|
|
||||||
git checkout $original_branch
|
|
||||||
}
|
|
||||||
|
|
||||||
# $1: Pages API secret
|
|
||||||
# $2: Deployment target branch
|
|
||||||
deploy() {
|
|
||||||
if (( "$#" < 2 ))
|
|
||||||
then
|
|
||||||
help
|
|
||||||
else
|
|
||||||
git -c core.sshCommand="/usr/bin/ssh -oStrictHostKeyChecking=no -i $SSH_ID_FILE"\
|
|
||||||
push --force $SSH_REMOTE_NAME $2
|
|
||||||
curl -vv --location --request \
|
|
||||||
POST "https://deploy.batsense.net/api/v1/update"\
|
|
||||||
--header 'Content-Type: application/json' \
|
|
||||||
--data-raw "{ \"secret\": \"$1\", \"branch\": \"$2\" }"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
if (( "$#" < 1 ))
|
|
||||||
then
|
|
||||||
help
|
|
||||||
exit -1
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
if match_arg $1 '-i' '--init'
|
|
||||||
then
|
|
||||||
if (( "$#" < 2 ))
|
|
||||||
then
|
|
||||||
help
|
|
||||||
exit -1
|
|
||||||
fi
|
|
||||||
set_ssh_remote
|
|
||||||
write_ssh "$2"
|
|
||||||
elif match_arg $1 '-c' '--clean'
|
|
||||||
then
|
|
||||||
clean
|
|
||||||
elif match_arg $1 '-cf' '--commit-files'
|
|
||||||
then
|
|
||||||
if (( "$#" < 4 ))
|
|
||||||
then
|
|
||||||
help
|
|
||||||
exit -1
|
|
||||||
fi
|
|
||||||
commit_files $2 $3 $4
|
|
||||||
elif match_arg $1 '-d' '--deploy'
|
|
||||||
then
|
|
||||||
if (( "$#" < 3 ))
|
|
||||||
then
|
|
||||||
help
|
|
||||||
exit -1
|
|
||||||
fi
|
|
||||||
deploy $2 $3
|
|
||||||
elif match_arg $1 '-h' '--help'
|
|
||||||
then
|
|
||||||
help
|
|
||||||
else
|
|
||||||
help
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
# Remove icons created in .tmp
|
|
||||||
rm -rf .tmp
|
|
|
@ -162,6 +162,10 @@
|
||||||
--hue: 30deg;
|
--hue: 30deg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeline .item.type-call {
|
||||||
|
--hue: 50deg;
|
||||||
|
}
|
||||||
|
|
||||||
.timeline .item.type-navigation {
|
.timeline .item.type-navigation {
|
||||||
--hue: 200deg;
|
--hue: 200deg;
|
||||||
}
|
}
|
||||||
|
@ -188,19 +192,28 @@
|
||||||
nav form {
|
nav form {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#filename {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav>
|
<nav>
|
||||||
<button id="openFile">Open log file</button>
|
<div>
|
||||||
<button id="collapseAll">Collapse all</button>
|
<button id="openFile">Open log file</button>
|
||||||
<button id="hideCollapsed">Hide collapsed root items</button>
|
<button id="collapseAll">Collapse all</button>
|
||||||
<button id="hideHighlightedSiblings" title="Hide collapsed siblings of highlighted">Hide non-highlighted</button>
|
<button id="hideCollapsed">Hide collapsed root items</button>
|
||||||
<button id="showAll">Show all</button>
|
<button id="hideHighlightedSiblings" title="Hide collapsed siblings of highlighted">Hide non-highlighted</button>
|
||||||
<form id="highlightForm">
|
<button id="showAll">Show all</button>
|
||||||
<input type="text" id="highlight" name="highlight" placeholder="Highlight a search term" autocomplete="on">
|
<form id="highlightForm">
|
||||||
<output id="highlightMatches"></output>
|
<input type="text" id="highlight" name="highlight" placeholder="Highlight a search term" autocomplete="on">
|
||||||
</form>
|
<output id="highlightMatches"></output>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<h2 id="filename"></h2>
|
||||||
</nav>
|
</nav>
|
||||||
<main></main>
|
<main></main>
|
||||||
<aside></aside>
|
<aside></aside>
|
||||||
|
|
|
@ -22,6 +22,7 @@ const main = document.querySelector("main");
|
||||||
let selectedItemNode;
|
let selectedItemNode;
|
||||||
let rootItem;
|
let rootItem;
|
||||||
let itemByRef;
|
let itemByRef;
|
||||||
|
let itemsRefFrom;
|
||||||
|
|
||||||
const logLevels = [undefined, "All", "Debug", "Detail", "Info", "Warn", "Error", "Fatal", "Off"];
|
const logLevels = [undefined, "All", "Debug", "Detail", "Info", "Warn", "Error", "Fatal", "Off"];
|
||||||
|
|
||||||
|
@ -49,6 +50,7 @@ window.addEventListener("hashchange", () => {
|
||||||
const id = window.location.hash.substr(1);
|
const id = window.location.hash.substr(1);
|
||||||
const itemNode = document.getElementById(id);
|
const itemNode = document.getElementById(id);
|
||||||
if (itemNode && itemNode.closest("main")) {
|
if (itemNode && itemNode.closest("main")) {
|
||||||
|
ensureParentsExpanded(itemNode);
|
||||||
selectNode(itemNode);
|
selectNode(itemNode);
|
||||||
itemNode.scrollIntoView({behavior: "smooth", block: "nearest"});
|
itemNode.scrollIntoView({behavior: "smooth", block: "nearest"});
|
||||||
}
|
}
|
||||||
|
@ -70,6 +72,14 @@ function selectNode(itemNode) {
|
||||||
showItemDetails(item, parent, selectedItemNode);
|
showItemDetails(item, parent, selectedItemNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureParentsExpanded(itemNode) {
|
||||||
|
let li = itemNode.parentElement.parentElement;
|
||||||
|
while (li.tagName === "LI") {
|
||||||
|
li.classList.add("expanded");
|
||||||
|
li = li.parentElement.parentElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function stringifyItemValue(value) {
|
function stringifyItemValue(value) {
|
||||||
if (typeof value === "object" && value !== null) {
|
if (typeof value === "object" && value !== null) {
|
||||||
return JSON.stringify(value, undefined, 2);
|
return JSON.stringify(value, undefined, 2);
|
||||||
|
@ -102,6 +112,11 @@ function showItemDetails(item, parent, itemNode) {
|
||||||
} else {
|
} else {
|
||||||
valueNode = `unknown ref ${value}`;
|
valueNode = `unknown ref ${value}`;
|
||||||
}
|
}
|
||||||
|
} else if (key === "refId") {
|
||||||
|
const refSources = itemsRefFrom.get(value) ?? [];
|
||||||
|
valueNode = t.div([t.p([`${value}`, t.br(),`Found these references:`]),t.ul(refSources.map(item => {
|
||||||
|
return t.li(t.a({href: `#${item.id}`}, itemCaption(item)));
|
||||||
|
}))]);
|
||||||
} else {
|
} else {
|
||||||
valueNode = stringifyItemValue(value);
|
valueNode = stringifyItemValue(value);
|
||||||
}
|
}
|
||||||
|
@ -148,12 +163,14 @@ function getRootItemHeader(prevItem, item) {
|
||||||
|
|
||||||
async function loadFile() {
|
async function loadFile() {
|
||||||
const file = await openFile();
|
const file = await openFile();
|
||||||
|
document.getElementById("filename").innerText = file.name;
|
||||||
const json = await readFileAsText(file);
|
const json = await readFileAsText(file);
|
||||||
const logs = JSON.parse(json);
|
const logs = JSON.parse(json);
|
||||||
logs.items.sort((a, b) => itemStart(a) - itemStart(b));
|
logs.items.sort((a, b) => itemStart(a) - itemStart(b));
|
||||||
rootItem = {c: logs.items};
|
rootItem = {c: logs.items};
|
||||||
itemByRef = new Map();
|
itemByRef = new Map();
|
||||||
preprocessRecursively(rootItem, null, itemByRef, []);
|
itemsRefFrom = new Map();
|
||||||
|
preprocessRecursively(rootItem, null, itemByRef, itemsRefFrom, []);
|
||||||
|
|
||||||
const fragment = logs.items.reduce((fragment, item, i, items) => {
|
const fragment = logs.items.reduce((fragment, item, i, items) => {
|
||||||
const prevItem = i === 0 ? null : items[i - 1];
|
const prevItem = i === 0 ? null : items[i - 1];
|
||||||
|
@ -167,18 +184,26 @@ async function loadFile() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: make this use processRecursively
|
// TODO: make this use processRecursively
|
||||||
function preprocessRecursively(item, parentElement, refsMap, path) {
|
function preprocessRecursively(item, parentElement, refsMap, refsFromMap, path) {
|
||||||
item.s = (parentElement?.s || 0) + item.s;
|
item.s = (parentElement?.s || 0) + item.s;
|
||||||
if (itemRefSource(item)) {
|
if (itemRefSource(item)) {
|
||||||
refsMap.set(itemRefSource(item), item);
|
refsMap.set(itemRefSource(item), item);
|
||||||
}
|
}
|
||||||
|
if (itemRef(item)) {
|
||||||
|
let refs = refsFromMap.get(itemRef(item));
|
||||||
|
if (!refs) {
|
||||||
|
refs = [];
|
||||||
|
refsFromMap.set(itemRef(item), refs);
|
||||||
|
}
|
||||||
|
refs.push(item);
|
||||||
|
}
|
||||||
if (itemChildren(item)) {
|
if (itemChildren(item)) {
|
||||||
for (let i = 0; i < itemChildren(item).length; i += 1) {
|
for (let i = 0; i < itemChildren(item).length; i += 1) {
|
||||||
// do it in advance for a child as we don't want to do it for the rootItem
|
// do it in advance for a child as we don't want to do it for the rootItem
|
||||||
const child = itemChildren(item)[i];
|
const child = itemChildren(item)[i];
|
||||||
const childPath = path.concat(i);
|
const childPath = path.concat(i);
|
||||||
child.id = childPath.join("/");
|
child.id = childPath.join("/");
|
||||||
preprocessRecursively(child, item, refsMap, childPath);
|
preprocessRecursively(child, item, refsMap, refsFromMap, childPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -233,14 +258,16 @@ function itemShortErrorMessage(item) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function itemCaption(item) {
|
function itemCaption(item) {
|
||||||
if (itemType(item) === "network") {
|
if (itemLabel(item) && itemError(item)) {
|
||||||
|
return `${itemLabel(item)} (${itemShortErrorMessage(item)})`;
|
||||||
|
} if (itemType(item) === "network") {
|
||||||
return `${itemValues(item)?.method} ${itemValues(item)?.url}`;
|
return `${itemValues(item)?.method} ${itemValues(item)?.url}`;
|
||||||
} else if (itemLabel(item) && itemValues(item)?.id) {
|
} else if (itemLabel(item) && itemValues(item)?.id) {
|
||||||
return `${itemLabel(item)} ${itemValues(item).id}`;
|
return `${itemLabel(item)} ${itemValues(item).id}`;
|
||||||
} else if (itemLabel(item) && itemValues(item)?.status) {
|
} else if (itemLabel(item) && itemValues(item)?.status) {
|
||||||
return `${itemLabel(item)} (${itemValues(item).status})`;
|
return `${itemLabel(item)} (${itemValues(item).status})`;
|
||||||
} else if (itemLabel(item) && itemError(item)) {
|
} else if (itemLabel(item) && itemValues(item)?.type) {
|
||||||
return `${itemLabel(item)} (${itemShortErrorMessage(item)})`;
|
return `${itemLabel(item)} (${itemValues(item)?.type})`;
|
||||||
} else if (itemRef(item)) {
|
} else if (itemRef(item)) {
|
||||||
const refItem = itemByRef.get(itemRef(item));
|
const refItem = itemByRef.get(itemRef(item));
|
||||||
if (refItem) {
|
if (refItem) {
|
||||||
|
|
|
@ -2,9 +2,6 @@ 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
|
||||||
|
|
|
@ -13,10 +13,10 @@ 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 * as pkg from 'off-color';
|
|
||||||
const offColor = pkg.offColor ?? pkg.default.offColor;
|
|
||||||
|
|
||||||
export function derive(value, operation, argument, isDark) {
|
const offColor = require("off-color").offColor;
|
||||||
|
|
||||||
|
module.exports.derive = function (value, operation, argument, isDark) {
|
||||||
const argumentAsNumber = parseInt(argument);
|
const argumentAsNumber = parseInt(argument);
|
||||||
if (isDark) {
|
if (isDark) {
|
||||||
// For dark themes, invert the operation
|
// For dark themes, invert the operation
|
|
@ -30,7 +30,12 @@ const valueParser = require("postcss-value-parser");
|
||||||
* The actual derivation is done outside the plugin in a callback.
|
* The actual derivation is done outside the plugin in a callback.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function getValueFromAlias(alias, {aliasMap, baseVariables, resolvedMap}) {
|
let aliasMap;
|
||||||
|
let resolvedMap;
|
||||||
|
let baseVariables;
|
||||||
|
let isDark;
|
||||||
|
|
||||||
|
function getValueFromAlias(alias) {
|
||||||
const derivedVariable = aliasMap.get(alias);
|
const derivedVariable = aliasMap.get(alias);
|
||||||
return baseVariables.get(derivedVariable) ?? resolvedMap.get(derivedVariable);
|
return baseVariables.get(derivedVariable) ?? resolvedMap.get(derivedVariable);
|
||||||
}
|
}
|
||||||
|
@ -63,15 +68,14 @@ function parseDeclarationValue(value) {
|
||||||
return variables;
|
return variables;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDerivedVariable(decl, derive, maps, isDark) {
|
function resolveDerivedVariable(decl, derive) {
|
||||||
const { baseVariables, resolvedMap } = maps;
|
|
||||||
const RE_VARIABLE_VALUE = /(?:--)?((.+)--(.+)-(.+))/;
|
const RE_VARIABLE_VALUE = /(?:--)?((.+)--(.+)-(.+))/;
|
||||||
const variableCollection = parseDeclarationValue(decl.value);
|
const variableCollection = parseDeclarationValue(decl.value);
|
||||||
for (const variable of variableCollection) {
|
for (const variable of variableCollection) {
|
||||||
const matches = variable.match(RE_VARIABLE_VALUE);
|
const matches = variable.match(RE_VARIABLE_VALUE);
|
||||||
if (matches) {
|
if (matches) {
|
||||||
const [, wholeVariable, baseVariable, operation, argument] = matches;
|
const [, wholeVariable, baseVariable, operation, argument] = matches;
|
||||||
const value = baseVariables.get(baseVariable) ?? getValueFromAlias(baseVariable, maps);
|
const value = baseVariables.get(baseVariable) ?? getValueFromAlias(baseVariable);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
throw new Error(`Cannot derive from ${baseVariable} because it is neither defined in config nor is it an alias!`);
|
throw new Error(`Cannot derive from ${baseVariable} because it is neither defined in config nor is it an alias!`);
|
||||||
}
|
}
|
||||||
|
@ -81,7 +85,7 @@ function resolveDerivedVariable(decl, derive, maps, isDark) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extract(decl, {aliasMap, baseVariables}) {
|
function extract(decl) {
|
||||||
if (decl.variable) {
|
if (decl.variable) {
|
||||||
// see if right side is of form "var(--foo)"
|
// see if right side is of form "var(--foo)"
|
||||||
const wholeVariable = decl.value.match(/var\(--(.+)\)/)?.[1];
|
const wholeVariable = decl.value.match(/var\(--(.+)\)/)?.[1];
|
||||||
|
@ -96,7 +100,7 @@ function extract(decl, {aliasMap, baseVariables}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addResolvedVariablesToRootSelector(root, {Rule, Declaration}, {resolvedMap}) {
|
function addResolvedVariablesToRootSelector(root, {Rule, Declaration}) {
|
||||||
const newRule = new Rule({ selector: ":root", source: root.source });
|
const newRule = new Rule({ selector: ":root", source: root.source });
|
||||||
// Add derived css variables to :root
|
// Add derived css variables to :root
|
||||||
resolvedMap.forEach((value, key) => {
|
resolvedMap.forEach((value, key) => {
|
||||||
|
@ -106,20 +110,13 @@ function addResolvedVariablesToRootSelector(root, {Rule, Declaration}, {resolved
|
||||||
root.append(newRule);
|
root.append(newRule);
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateMapWithDerivedVariables(map, cssFileLocation, {resolvedMap, aliasMap}) {
|
function populateMapWithDerivedVariables(map, cssFileLocation) {
|
||||||
const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1];
|
const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1];
|
||||||
const derivedVariables = [
|
const derivedVariables = [
|
||||||
...([...resolvedMap.keys()].filter(v => !aliasMap.has(v))),
|
...([...resolvedMap.keys()].filter(v => !aliasMap.has(v))),
|
||||||
...([...aliasMap.entries()].map(([alias, variable]) => `${alias}=${variable}`))
|
...([...aliasMap.entries()].map(([alias, variable]) => `${alias}=${variable}`))
|
||||||
];
|
];
|
||||||
const sharedObject = map.get(location);
|
map.set(location, { "derived-variables": derivedVariables });
|
||||||
const output = { "derived-variables": derivedVariables };
|
|
||||||
if (sharedObject) {
|
|
||||||
Object.assign(sharedObject, output);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
map.set(location, output);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -136,10 +133,10 @@ function populateMapWithDerivedVariables(map, cssFileLocation, {resolvedMap, ali
|
||||||
* @param {Map} opts.compiledVariables - A map that stores derived variables so that manifest source sections can be produced
|
* @param {Map} opts.compiledVariables - A map that stores derived variables so that manifest source sections can be produced
|
||||||
*/
|
*/
|
||||||
module.exports = (opts = {}) => {
|
module.exports = (opts = {}) => {
|
||||||
const aliasMap = new Map();
|
aliasMap = new Map();
|
||||||
const resolvedMap = new Map();
|
resolvedMap = new Map();
|
||||||
const baseVariables = new Map();
|
baseVariables = new Map();
|
||||||
const maps = { aliasMap, resolvedMap, baseVariables };
|
isDark = false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
postcssPlugin: "postcss-compile-variables",
|
postcssPlugin: "postcss-compile-variables",
|
||||||
|
@ -150,16 +147,16 @@ module.exports = (opts = {}) => {
|
||||||
// If this is a runtime theme, don't derive variables.
|
// If this is a runtime theme, don't derive variables.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const isDark = cssFileLocation.includes("dark=true");
|
isDark = cssFileLocation.includes("dark=true");
|
||||||
/*
|
/*
|
||||||
Go through the CSS file once to extract all aliases and base variables.
|
Go through the CSS file once to extract all aliases and base variables.
|
||||||
We use these when resolving derived variables later.
|
We use these when resolving derived variables later.
|
||||||
*/
|
*/
|
||||||
root.walkDecls(decl => extract(decl, maps));
|
root.walkDecls(decl => extract(decl));
|
||||||
root.walkDecls(decl => resolveDerivedVariable(decl, opts.derive, maps, isDark));
|
root.walkDecls(decl => resolveDerivedVariable(decl, opts.derive));
|
||||||
addResolvedVariablesToRootSelector(root, {Rule, Declaration}, maps);
|
addResolvedVariablesToRootSelector(root, {Rule, Declaration});
|
||||||
if (opts.compiledVariables){
|
if (opts.compiledVariables){
|
||||||
populateMapWithDerivedVariables(opts.compiledVariables, cssFileLocation, maps);
|
populateMapWithDerivedVariables(opts.compiledVariables, cssFileLocation);
|
||||||
}
|
}
|
||||||
// Also produce a mapping from alias to completely resolved color
|
// Also produce a mapping from alias to completely resolved color
|
||||||
const resolvedAliasMap = new Map();
|
const resolvedAliasMap = new Map();
|
||||||
|
|
|
@ -16,6 +16,7 @@ limitations under the License.
|
||||||
|
|
||||||
const valueParser = require("postcss-value-parser");
|
const valueParser = require("postcss-value-parser");
|
||||||
const resolve = require("path").resolve;
|
const resolve = require("path").resolve;
|
||||||
|
let cssPath;
|
||||||
|
|
||||||
function colorsFromURL(url, colorMap) {
|
function colorsFromURL(url, colorMap) {
|
||||||
const params = new URL(`file://${url}`).searchParams;
|
const params = new URL(`file://${url}`).searchParams;
|
||||||
|
@ -35,7 +36,7 @@ function colorsFromURL(url, colorMap) {
|
||||||
return [primaryColor, secondaryColor];
|
return [primaryColor, secondaryColor];
|
||||||
}
|
}
|
||||||
|
|
||||||
function processURL(decl, replacer, colorMap, cssPath) {
|
function processURL(decl, replacer, colorMap) {
|
||||||
const value = decl.value;
|
const value = decl.value;
|
||||||
const parsed = valueParser(value);
|
const parsed = valueParser(value);
|
||||||
parsed.walk(node => {
|
parsed.walk(node => {
|
||||||
|
@ -83,8 +84,8 @@ module.exports = (opts = {}) => {
|
||||||
Go through each declaration and if it contains an URL, replace the url with the result
|
Go through each declaration and if it contains an URL, replace the url with the result
|
||||||
of running replacer(url)
|
of running replacer(url)
|
||||||
*/
|
*/
|
||||||
const cssPath = root.source?.input.file.replace(/[^/]*$/, "");
|
cssPath = root.source?.input.file.replace(/[^/]*$/, "");
|
||||||
root.walkDecls(decl => processURL(decl, opts.replacer, colorMap, cssPath));
|
root.walkDecls(decl => processURL(decl, opts.replacer, colorMap));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -20,9 +20,11 @@ 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 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.
|
* This plugin is used in conjunction with css-url-processor plugin to colorize svg icons.
|
||||||
*/
|
*/
|
||||||
|
let counter;
|
||||||
|
let urlVariables;
|
||||||
const idToPrepend = "icon-url";
|
const idToPrepend = "icon-url";
|
||||||
|
|
||||||
function findAndReplaceUrl(decl, urlVariables, counter) {
|
function findAndReplaceUrl(decl) {
|
||||||
const value = decl.value;
|
const value = decl.value;
|
||||||
const parsed = valueParser(value);
|
const parsed = valueParser(value);
|
||||||
parsed.walk(node => {
|
parsed.walk(node => {
|
||||||
|
@ -33,8 +35,7 @@ function findAndReplaceUrl(decl, urlVariables, counter) {
|
||||||
if (!url.match(/\.svg\?primary=.+/)) {
|
if (!url.match(/\.svg\?primary=.+/)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const count = counter.next().value;
|
const variableName = `${idToPrepend}-${counter++}`;
|
||||||
const variableName = `${idToPrepend}-${count}`;
|
|
||||||
urlVariables.set(variableName, url);
|
urlVariables.set(variableName, url);
|
||||||
node.value = "var";
|
node.value = "var";
|
||||||
node.nodes = [{ type: "word", value: `--${variableName}` }];
|
node.nodes = [{ type: "word", value: `--${variableName}` }];
|
||||||
|
@ -42,7 +43,7 @@ function findAndReplaceUrl(decl, urlVariables, counter) {
|
||||||
decl.assign({prop: decl.prop, value: parsed.toString()})
|
decl.assign({prop: decl.prop, value: parsed.toString()})
|
||||||
}
|
}
|
||||||
|
|
||||||
function addResolvedVariablesToRootSelector(root, { Rule, Declaration }, urlVariables) {
|
function addResolvedVariablesToRootSelector(root, { Rule, Declaration }) {
|
||||||
const newRule = new Rule({ selector: ":root", source: root.source });
|
const newRule = new Rule({ selector: ":root", source: root.source });
|
||||||
// Add derived css variables to :root
|
// Add derived css variables to :root
|
||||||
urlVariables.forEach((value, key) => {
|
urlVariables.forEach((value, key) => {
|
||||||
|
@ -52,42 +53,29 @@ function addResolvedVariablesToRootSelector(root, { Rule, Declaration }, urlVari
|
||||||
root.append(newRule);
|
root.append(newRule);
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateMapWithIcons(map, cssFileLocation, urlVariables) {
|
function populateMapWithIcons(map, cssFileLocation) {
|
||||||
const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1];
|
const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1];
|
||||||
const sharedObject = map.get(location);
|
const sharedObject = map.get(location);
|
||||||
const output = {"icon": Object.fromEntries(urlVariables)};
|
sharedObject["icon"] = Object.fromEntries(urlVariables);
|
||||||
if (sharedObject) {
|
|
||||||
Object.assign(sharedObject, output);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
map.set(location, output);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function *createCounter() {
|
|
||||||
for (let i = 0; ; ++i) {
|
|
||||||
yield i;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* *
|
/* *
|
||||||
* @type {import('postcss').PluginCreator}
|
* @type {import('postcss').PluginCreator}
|
||||||
*/
|
*/
|
||||||
module.exports = (opts = {}) => {
|
module.exports = (opts = {}) => {
|
||||||
|
urlVariables = new Map();
|
||||||
|
counter = 0;
|
||||||
return {
|
return {
|
||||||
postcssPlugin: "postcss-url-to-variable",
|
postcssPlugin: "postcss-url-to-variable",
|
||||||
|
|
||||||
Once(root, { Rule, Declaration }) {
|
Once(root, { Rule, Declaration }) {
|
||||||
const urlVariables = new Map();
|
root.walkDecls(decl => findAndReplaceUrl(decl));
|
||||||
const counter = createCounter();
|
if (urlVariables.size) {
|
||||||
root.walkDecls(decl => findAndReplaceUrl(decl, urlVariables, counter));
|
addResolvedVariablesToRootSelector(root, { Rule, Declaration });
|
||||||
const cssFileLocation = root.source.input.from;
|
|
||||||
if (urlVariables.size && !cssFileLocation.includes("type=runtime")) {
|
|
||||||
addResolvedVariablesToRootSelector(root, { Rule, Declaration }, urlVariables);
|
|
||||||
}
|
}
|
||||||
if (opts.compiledVariables){
|
if (opts.compiledVariables){
|
||||||
const cssFileLocation = root.source.input.from;
|
const cssFileLocation = root.source.input.from;
|
||||||
populateMapWithIcons(opts.compiledVariables, cssFileLocation, urlVariables);
|
populateMapWithIcons(opts.compiledVariables, cssFileLocation);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -14,13 +14,12 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {readFileSync, mkdirSync, writeFileSync} from "fs";
|
const fs = require("fs");
|
||||||
import {resolve} from "path";
|
const path = require("path");
|
||||||
import {h32} from "xxhashjs";
|
const xxhash = require('xxhashjs');
|
||||||
import {getColoredSvgString} from "../../src/platform/web/theming/shared/svg-colorizer.mjs";
|
|
||||||
|
|
||||||
function createHash(content) {
|
function createHash(content) {
|
||||||
const hasher = new h32(0);
|
const hasher = new xxhash.h32(0);
|
||||||
hasher.update(content);
|
hasher.update(content);
|
||||||
return hasher.digest();
|
return hasher.digest();
|
||||||
}
|
}
|
||||||
|
@ -31,14 +30,18 @@ function createHash(content) {
|
||||||
* @param {string} primaryColor Primary color for the new svg
|
* @param {string} primaryColor Primary color for the new svg
|
||||||
* @param {string} secondaryColor Secondary color for the new svg
|
* @param {string} secondaryColor Secondary color for the new svg
|
||||||
*/
|
*/
|
||||||
export function buildColorizedSVG(svgLocation, primaryColor, secondaryColor) {
|
module.exports.buildColorizedSVG = function (svgLocation, primaryColor, secondaryColor) {
|
||||||
const svgCode = readFileSync(svgLocation, { encoding: "utf8"});
|
const svgCode = fs.readFileSync(svgLocation, { encoding: "utf8"});
|
||||||
const coloredSVGCode = getColoredSvgString(svgCode, primaryColor, secondaryColor);
|
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 fileName = svgLocation.match(/.+[/\\](.+\.svg)/)[1];
|
||||||
const outputName = `${fileName.substring(0, fileName.length - 4)}-${createHash(coloredSVGCode)}.svg`;
|
const outputName = `${fileName.substring(0, fileName.length - 4)}-${createHash(coloredSVGCode)}.svg`;
|
||||||
const outputPath = resolve(__dirname, "./.tmp");
|
const outputPath = path.resolve(__dirname, "../../.tmp");
|
||||||
try {
|
try {
|
||||||
mkdirSync(outputPath);
|
fs.mkdirSync(outputPath);
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
if (e.code !== "EEXIST") {
|
if (e.code !== "EEXIST") {
|
||||||
|
@ -46,6 +49,6 @@ export function buildColorizedSVG(svgLocation, primaryColor, secondaryColor) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const outputFile = `${outputPath}/${outputName}`;
|
const outputFile = `${outputPath}/${outputName}`;
|
||||||
writeFileSync(outputFile, coloredSVGCode);
|
fs.writeFileSync(outputFile, coloredSVGCode);
|
||||||
return outputFile;
|
return outputFile;
|
||||||
}
|
}
|
|
@ -1,4 +1,3 @@
|
||||||
set -e
|
|
||||||
if [ -z "$1" ]; then
|
if [ -z "$1" ]; then
|
||||||
echo "provide a new version, current version is $(jq '.version' package.json)"
|
echo "provide a new version, current version is $(jq '.version' package.json)"
|
||||||
exit 1
|
exit 1
|
||||||
|
|
|
@ -1,19 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "hydrogen-view-sdk",
|
"name": "hydrogen-view-sdk",
|
||||||
"description": "Embeddable matrix client library, including view components",
|
"description": "Embeddable matrix client library, including view components",
|
||||||
"version": "0.1.0",
|
"version": "0.0.10",
|
||||||
"main": "./lib-build/hydrogen.cjs.js",
|
"main": "./hydrogen.es.js",
|
||||||
"exports": {
|
"type": "module"
|
||||||
".": {
|
|
||||||
"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/*"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,12 +2,8 @@
|
||||||
# Exit whenever one of the commands fail with a non-zero exit code
|
# Exit whenever one of the commands fail with a non-zero exit code
|
||||||
set -e
|
set -e
|
||||||
set -o pipefail
|
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
|
rm -rf target
|
||||||
# the `npm link`/`yarn link` symlink
|
|
||||||
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
|
||||||
yarn tsc -p tsconfig-declaration.json
|
yarn tsc -p tsconfig-declaration.json
|
||||||
|
@ -16,10 +12,19 @@ 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/asset-build
|
pushd target
|
||||||
rm index.html
|
pushd asset-build/assets
|
||||||
|
mv main.*.js ../../main.js
|
||||||
|
# Create a copy of light theme for backwards compatibility
|
||||||
|
cp theme-element-light.*.css ../../style.css
|
||||||
|
# Remove asset hash from css files
|
||||||
|
mv theme-element-light.*.css ../../theme-element-light.css
|
||||||
|
mv theme-element-dark.*.css ../../theme-element-dark.css
|
||||||
|
mv download-sandbox.*.html ../../download-sandbox.html
|
||||||
|
rm *.js *.wasm
|
||||||
|
mv ./* ../../
|
||||||
popd
|
popd
|
||||||
pushd target/asset-build/assets
|
rm -rf asset-build
|
||||||
# Remove all `*.wasm` and `*.js` files except for `main.js`
|
mv lib-build/* .
|
||||||
rm !(main).js *.wasm
|
rm -rf lib-build
|
||||||
popd
|
popd
|
||||||
|
|
|
@ -3,7 +3,21 @@ 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 typescript type definitions out until the
|
need to leave exports out of base-manifest.json because of #vite-bug,
|
||||||
|
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
3
scripts/sdk/test/.gitignore
vendored
|
@ -1,3 +0,0 @@
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
yarn.lock
|
|
2
scripts/sdk/test/deps.d.ts
vendored
2
scripts/sdk/test/deps.d.ts
vendored
|
@ -1,2 +0,0 @@
|
||||||
// Keep TypeScripts from complaining about hydrogen-view-sdk not having types yet
|
|
||||||
declare module "hydrogen-view-sdk";
|
|
|
@ -1,21 +0,0 @@
|
||||||
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/assets/theme-element-light.css";
|
|
||||||
|
|
||||||
console.log('hydrogenViewSdk', hydrogenViewSdk);
|
|
||||||
console.log('assetPaths', assetPaths);
|
|
||||||
|
|
||||||
console.log('Entry ESM works ✅');
|
|
|
@ -1,12 +0,0 @@
|
||||||
<!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>
|
|
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"name": "test-sdk",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"description": "",
|
|
||||||
"dependencies": {
|
|
||||||
"hydrogen-view-sdk": "link:../../../target"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
// 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/assets/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 ✅');
|
|
|
@ -1,19 +0,0 @@
|
||||||
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();
|
|
|
@ -1,5 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
cp scripts/test-derived-theme/theme.json target/assets/theme-customer.json
|
|
||||||
cat target/config.json | jq '.themeManifests += ["assets/theme-customer.json"]' | cat > target/config.temp.json
|
|
||||||
rm target/config.json
|
|
||||||
mv target/config.temp.json target/config.json
|
|
|
@ -1,51 +0,0 @@
|
||||||
{
|
|
||||||
"name": "Customer",
|
|
||||||
"extends": "element",
|
|
||||||
"id": "customer",
|
|
||||||
"values": {
|
|
||||||
"variants": {
|
|
||||||
"dark": {
|
|
||||||
"dark": true,
|
|
||||||
"default": true,
|
|
||||||
"name": "Dark",
|
|
||||||
"variables": {
|
|
||||||
"background-color-primary": "#21262b",
|
|
||||||
"background-color-secondary": "#2D3239",
|
|
||||||
"text-color": "#fff",
|
|
||||||
"accent-color": "#F03F5B",
|
|
||||||
"error-color": "#FF4B55",
|
|
||||||
"fixed-white": "#fff",
|
|
||||||
"room-badge": "#61708b",
|
|
||||||
"link-color": "#238cf5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"light": {
|
|
||||||
"default": true,
|
|
||||||
"name": "Dark",
|
|
||||||
"variables": {
|
|
||||||
"background-color-primary": "#21262b",
|
|
||||||
"background-color-secondary": "#2D3239",
|
|
||||||
"text-color": "#fff",
|
|
||||||
"accent-color": "#F03F5B",
|
|
||||||
"error-color": "#FF4B55",
|
|
||||||
"fixed-white": "#fff",
|
|
||||||
"room-badge": "#61708b",
|
|
||||||
"link-color": "#238cf5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"red": {
|
|
||||||
"name": "Gruvbox",
|
|
||||||
"variables": {
|
|
||||||
"background-color-primary": "#282828",
|
|
||||||
"background-color-secondary": "#3c3836",
|
|
||||||
"text-color": "#fbf1c7",
|
|
||||||
"accent-color": "#8ec07c",
|
|
||||||
"error-color": "#fb4934",
|
|
||||||
"fixed-white": "#fff",
|
|
||||||
"room-badge": "#cc241d",
|
|
||||||
"link-color": "#fe8019"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,5 +1,6 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||||
|
Copyright 2020, 2021 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.
|
||||||
|
@ -14,25 +15,9 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type NormalVariant = {
|
export interface AvatarSource {
|
||||||
id: string;
|
get avatarLetter(): string;
|
||||||
cssLocation: string;
|
get avatarColorNumber(): number;
|
||||||
variables?: any;
|
avatarUrl(size: number): string | undefined;
|
||||||
};
|
get avatarTitle(): string;
|
||||||
|
|
||||||
export type Variant = NormalVariant & {
|
|
||||||
variantName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DefaultVariant = {
|
|
||||||
dark: Variant;
|
|
||||||
light: Variant;
|
|
||||||
default: Variant;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ThemeInformation = NormalVariant | DefaultVariant;
|
|
||||||
|
|
||||||
export enum ColorSchemePreference {
|
|
||||||
Dark,
|
|
||||||
Light
|
|
||||||
};
|
|
|
@ -14,19 +14,18 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Options as BaseOptions, ViewModel} from "./ViewModel";
|
import {Options, ViewModel} from "./ViewModel";
|
||||||
import {Client} from "../matrix/Client.js";
|
import {Client} from "../matrix/Client.js";
|
||||||
import {SegmentType} from "./navigation/index";
|
|
||||||
|
|
||||||
type Options = { sessionId: string; } & BaseOptions;
|
type LogoutOptions = { sessionId: string; } & Options;
|
||||||
|
|
||||||
export class LogoutViewModel extends ViewModel<SegmentType, Options> {
|
export class LogoutViewModel extends ViewModel<LogoutOptions> {
|
||||||
private _sessionId: string;
|
private _sessionId: string;
|
||||||
private _busy: boolean;
|
private _busy: boolean;
|
||||||
private _showConfirm: boolean;
|
private _showConfirm: boolean;
|
||||||
private _error?: Error;
|
private _error?: Error;
|
||||||
|
|
||||||
constructor(options: Options) {
|
constructor(options: LogoutOptions) {
|
||||||
super(options);
|
super(options);
|
||||||
this._sessionId = options.sessionId;
|
this._sessionId = options.sessionId;
|
||||||
this._busy = false;
|
this._busy = false;
|
||||||
|
@ -42,7 +41,7 @@ export class LogoutViewModel extends ViewModel<SegmentType, Options> {
|
||||||
return this._busy;
|
return this._busy;
|
||||||
}
|
}
|
||||||
|
|
||||||
get cancelUrl(): string | undefined {
|
get cancelUrl(): string {
|
||||||
return this.urlCreator.urlForSegment("session", true);
|
return this.urlCreator.urlForSegment("session", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
||||||
import {Client} from "../matrix/Client.js";
|
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";
|
import {LoginViewModel} from "./login/LoginViewModel.js";
|
||||||
import {LogoutViewModel} from "./LogoutViewModel";
|
import {LogoutViewModel} from "./LogoutViewModel";
|
||||||
import {SessionPickerViewModel} from "./SessionPickerViewModel.js";
|
import {SessionPickerViewModel} from "./SessionPickerViewModel.js";
|
||||||
import {ViewModel} from "./ViewModel";
|
import {ViewModel} from "./ViewModel";
|
||||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {SortedArray} from "../observable/index.js";
|
import {SortedArray} from "../observable/index";
|
||||||
import {ViewModel} from "./ViewModel";
|
import {ViewModel} from "./ViewModel";
|
||||||
import {avatarInitials, getIdentifierColorNumber} from "./avatar";
|
import {avatarInitials, getIdentifierColorNumber} from "./avatar";
|
||||||
|
|
||||||
|
|
|
@ -27,44 +27,42 @@ import type {Platform} from "../platform/web/Platform";
|
||||||
import type {Clock} from "../platform/web/dom/Clock";
|
import type {Clock} from "../platform/web/dom/Clock";
|
||||||
import type {ILogger} from "../logging/types";
|
import type {ILogger} from "../logging/types";
|
||||||
import type {Navigation} from "./navigation/Navigation";
|
import type {Navigation} from "./navigation/Navigation";
|
||||||
import type {SegmentType} from "./navigation/index";
|
import type {URLRouter} from "./navigation/URLRouter";
|
||||||
import type {IURLRouter} from "./navigation/URLRouter";
|
|
||||||
|
|
||||||
export type Options<T extends object = SegmentType> = {
|
export type Options = {
|
||||||
platform: Platform;
|
platform: Platform
|
||||||
logger: ILogger;
|
logger: ILogger
|
||||||
urlCreator: IURLRouter<T>;
|
urlCreator: URLRouter
|
||||||
navigation: Navigation<T>;
|
navigation: Navigation
|
||||||
emitChange?: (params: any) => void;
|
emitChange?: (params: any) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ViewModel<O extends Options = Options> extends EventEmitter<{change: never}> {
|
||||||
export class ViewModel<N extends object = SegmentType, O extends Options<N> = Options<N>> extends EventEmitter<{change: never}> {
|
|
||||||
private disposables?: Disposables;
|
private disposables?: Disposables;
|
||||||
private _isDisposed = false;
|
private _isDisposed = false;
|
||||||
private _options: Readonly<O>;
|
private _options: O;
|
||||||
|
|
||||||
constructor(options: Readonly<O>) {
|
constructor(options: O) {
|
||||||
super();
|
super();
|
||||||
this._options = options;
|
this._options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
childOptions<T extends Object>(explicitOptions: T): T & Options<N> {
|
childOptions<T extends Object>(explicitOptions: T): T & Options {
|
||||||
return Object.assign({}, this._options, explicitOptions);
|
return Object.assign({}, this._options, explicitOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
get options(): Readonly<O> { return this._options; }
|
get options(): O { return this._options; }
|
||||||
|
|
||||||
// makes it easier to pass through dependencies of a sub-view model
|
// makes it easier to pass through dependencies of a sub-view model
|
||||||
getOption<N extends keyof O>(name: N): O[N] {
|
getOption<N extends keyof O>(name: N): O[N] {
|
||||||
return this._options[name];
|
return this._options[name];
|
||||||
}
|
}
|
||||||
|
|
||||||
observeNavigation<T extends keyof N>(type: T, onChange: (value: N[T], type: T) => void): void {
|
observeNavigation(type: string, onChange: (value: string | true | undefined, type: string) => void) {
|
||||||
const segmentObservable = this.navigation.observe(type);
|
const segmentObservable = this.navigation.observe(type);
|
||||||
const unsubscribe = segmentObservable.subscribe((value: N[T]) => {
|
const unsubscribe = segmentObservable.subscribe((value: string | true | undefined) => {
|
||||||
onChange(value, type);
|
onChange(value, type);
|
||||||
});
|
})
|
||||||
this.track(unsubscribe);
|
this.track(unsubscribe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -105,7 +103,7 @@ export class ViewModel<N extends object = SegmentType, O extends Options<N> = Op
|
||||||
//
|
//
|
||||||
// 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: TemplateStringsArray, ...expr: any[]): string {
|
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) {
|
||||||
|
@ -117,6 +115,10 @@ export class ViewModel<N extends object = SegmentType, O extends Options<N> = Op
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateOptions(options: O): void {
|
||||||
|
this._options = Object.assign(this._options, options);
|
||||||
|
}
|
||||||
|
|
||||||
emitChange(changedProps: any): void {
|
emitChange(changedProps: any): void {
|
||||||
if (this._options.emitChange) {
|
if (this._options.emitChange) {
|
||||||
this._options.emitChange(changedProps);
|
this._options.emitChange(changedProps);
|
||||||
|
@ -137,12 +139,11 @@ export class ViewModel<N extends object = SegmentType, O extends Options<N> = Op
|
||||||
return this.platform.logger;
|
return this.platform.logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
get urlCreator(): IURLRouter<N> {
|
get urlCreator(): URLRouter {
|
||||||
return this._options.urlCreator;
|
return this._options.urlCreator;
|
||||||
}
|
}
|
||||||
|
|
||||||
get navigation(): Navigation<N> {
|
get navigation(): Navigation {
|
||||||
// typescript needs a little help here
|
return this._options.navigation;
|
||||||
return this._options.navigation as unknown as Navigation<N>;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,10 +51,10 @@ export function getIdentifierColorNumber(id: string): number {
|
||||||
return (hashCode(id) % 8) + 1;
|
return (hashCode(id) % 8) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAvatarHttpUrl(avatarUrl: string, cssSize: number, platform: Platform, mediaRepository: MediaRepository): string | null {
|
export function getAvatarHttpUrl(avatarUrl: string | undefined, cssSize: number, platform: Platform, mediaRepository: MediaRepository): string | undefined {
|
||||||
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");
|
||||||
}
|
}
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,145 +15,101 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Client} from "../../matrix/Client.js";
|
import {Client} from "../../matrix/Client.js";
|
||||||
import {Options as BaseOptions, ViewModel} from "../ViewModel";
|
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";
|
||||||
import {LoadStatus} from "../../matrix/Client.js";
|
import {LoadStatus} from "../../matrix/Client.js";
|
||||||
import {SessionLoadViewModel} from "../SessionLoadViewModel.js";
|
import {SessionLoadViewModel} from "../SessionLoadViewModel.js";
|
||||||
import {SegmentType} from "../navigation/index";
|
|
||||||
|
|
||||||
import type {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod} from "../../matrix/login";
|
export class LoginViewModel extends ViewModel {
|
||||||
|
constructor(options) {
|
||||||
type Options = {
|
|
||||||
defaultHomeserver: string;
|
|
||||||
ready: ReadyFn;
|
|
||||||
loginToken?: string;
|
|
||||||
} & BaseOptions;
|
|
||||||
|
|
||||||
export class LoginViewModel extends ViewModel<SegmentType, Options> {
|
|
||||||
private _ready: ReadyFn;
|
|
||||||
private _loginToken?: string;
|
|
||||||
private _client: Client;
|
|
||||||
private _loginOptions?: LoginOptions;
|
|
||||||
private _passwordLoginViewModel?: PasswordLoginViewModel;
|
|
||||||
private _startSSOLoginViewModel?: StartSSOLoginViewModel;
|
|
||||||
private _completeSSOLoginViewModel?: CompleteSSOLoginViewModel;
|
|
||||||
private _loadViewModel?: SessionLoadViewModel;
|
|
||||||
private _loadViewModelSubscription?: () => void;
|
|
||||||
private _homeserver: string;
|
|
||||||
private _queriedHomeserver?: string;
|
|
||||||
private _abortHomeserverQueryTimeout?: () => void;
|
|
||||||
private _abortQueryOperation?: () => void;
|
|
||||||
|
|
||||||
private _hideHomeserver: boolean = false;
|
|
||||||
private _isBusy: boolean = false;
|
|
||||||
private _errorMessage: string = "";
|
|
||||||
|
|
||||||
constructor(options: Readonly<Options>) {
|
|
||||||
super(options);
|
super(options);
|
||||||
const {ready, defaultHomeserver, loginToken} = options;
|
const {ready, defaultHomeserver, loginToken} = options;
|
||||||
this._ready = ready;
|
this._ready = ready;
|
||||||
this._loginToken = loginToken;
|
this._loginToken = loginToken;
|
||||||
this._client = new Client(this.platform);
|
this._client = new Client(this.platform);
|
||||||
|
this._loginOptions = null;
|
||||||
|
this._passwordLoginViewModel = null;
|
||||||
|
this._startSSOLoginViewModel = null;
|
||||||
|
this._completeSSOLoginViewModel = null;
|
||||||
|
this._loadViewModel = null;
|
||||||
|
this._loadViewModelSubscription = null;
|
||||||
this._homeserver = defaultHomeserver;
|
this._homeserver = defaultHomeserver;
|
||||||
|
this._queriedHomeserver = null;
|
||||||
|
this._errorMessage = "";
|
||||||
|
this._hideHomeserver = false;
|
||||||
|
this._isBusy = false;
|
||||||
|
this._abortHomeserverQueryTimeout = null;
|
||||||
|
this._abortQueryOperation = null;
|
||||||
this._initViewModels();
|
this._initViewModels();
|
||||||
}
|
}
|
||||||
|
|
||||||
get passwordLoginViewModel(): PasswordLoginViewModel {
|
get passwordLoginViewModel() { return this._passwordLoginViewModel; }
|
||||||
return this._passwordLoginViewModel;
|
get startSSOLoginViewModel() { return this._startSSOLoginViewModel; }
|
||||||
}
|
get completeSSOLoginViewModel(){ return this._completeSSOLoginViewModel; }
|
||||||
|
get homeserver() { return this._homeserver; }
|
||||||
|
get resolvedHomeserver() { return this._loginOptions?.homeserver; }
|
||||||
|
get errorMessage() { return this._errorMessage; }
|
||||||
|
get showHomeserver() { return !this._hideHomeserver; }
|
||||||
|
get loadViewModel() {return this._loadViewModel; }
|
||||||
|
get isBusy() { return this._isBusy; }
|
||||||
|
get isFetchingLoginOptions() { return !!this._abortQueryOperation; }
|
||||||
|
|
||||||
get startSSOLoginViewModel(): StartSSOLoginViewModel {
|
goBack() {
|
||||||
return this._startSSOLoginViewModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
get completeSSOLoginViewModel(): CompleteSSOLoginViewModel {
|
|
||||||
return this._completeSSOLoginViewModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
get homeserver(): string {
|
|
||||||
return this._homeserver;
|
|
||||||
}
|
|
||||||
|
|
||||||
get resolvedHomeserver(): string | undefined {
|
|
||||||
return this._loginOptions?.homeserver;
|
|
||||||
}
|
|
||||||
|
|
||||||
get errorMessage(): string {
|
|
||||||
return this._errorMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
get showHomeserver(): boolean {
|
|
||||||
return !this._hideHomeserver;
|
|
||||||
}
|
|
||||||
|
|
||||||
get loadViewModel(): SessionLoadViewModel {
|
|
||||||
return this._loadViewModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isBusy(): boolean {
|
|
||||||
return this._isBusy;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isFetchingLoginOptions(): boolean {
|
|
||||||
return !!this._abortQueryOperation;
|
|
||||||
}
|
|
||||||
|
|
||||||
goBack(): void {
|
|
||||||
this.navigation.push("session");
|
this.navigation.push("session");
|
||||||
}
|
}
|
||||||
|
|
||||||
private _initViewModels(): void {
|
async _initViewModels() {
|
||||||
if (this._loginToken) {
|
if (this._loginToken) {
|
||||||
this._hideHomeserver = true;
|
this._hideHomeserver = true;
|
||||||
this._completeSSOLoginViewModel = this.track(new CompleteSSOLoginViewModel(
|
this._completeSSOLoginViewModel = this.track(new CompleteSSOLoginViewModel(
|
||||||
this.childOptions(
|
this.childOptions(
|
||||||
{
|
{
|
||||||
client: this._client,
|
client: this._client,
|
||||||
attemptLogin: (loginMethod: TokenLoginMethod) => this.attemptLogin(loginMethod),
|
attemptLogin: loginMethod => this.attemptLogin(loginMethod),
|
||||||
loginToken: this._loginToken
|
loginToken: this._loginToken
|
||||||
})));
|
})));
|
||||||
this.emitChange("completeSSOLoginViewModel");
|
this.emitChange("completeSSOLoginViewModel");
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
void this.queryHomeserver();
|
await this.queryHomeserver();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _showPasswordLogin(): void {
|
_showPasswordLogin() {
|
||||||
this._passwordLoginViewModel = this.track(new PasswordLoginViewModel(
|
this._passwordLoginViewModel = this.track(new PasswordLoginViewModel(
|
||||||
this.childOptions({
|
this.childOptions({
|
||||||
loginOptions: this._loginOptions,
|
loginOptions: this._loginOptions,
|
||||||
attemptLogin: (loginMethod: PasswordLoginMethod) => this.attemptLogin(loginMethod)
|
attemptLogin: loginMethod => this.attemptLogin(loginMethod)
|
||||||
})));
|
})));
|
||||||
this.emitChange("passwordLoginViewModel");
|
this.emitChange("passwordLoginViewModel");
|
||||||
}
|
}
|
||||||
|
|
||||||
private _showSSOLogin(): void {
|
_showSSOLogin() {
|
||||||
this._startSSOLoginViewModel = this.track(
|
this._startSSOLoginViewModel = this.track(
|
||||||
new StartSSOLoginViewModel(this.childOptions({loginOptions: this._loginOptions}))
|
new StartSSOLoginViewModel(this.childOptions({loginOptions: this._loginOptions}))
|
||||||
);
|
);
|
||||||
this.emitChange("startSSOLoginViewModel");
|
this.emitChange("startSSOLoginViewModel");
|
||||||
}
|
}
|
||||||
|
|
||||||
private _showError(message: string): void {
|
_showError(message) {
|
||||||
this._errorMessage = message;
|
this._errorMessage = message;
|
||||||
this.emitChange("errorMessage");
|
this.emitChange("errorMessage");
|
||||||
}
|
}
|
||||||
|
|
||||||
private _setBusy(status: boolean): void {
|
_setBusy(status) {
|
||||||
this._isBusy = status;
|
this._isBusy = status;
|
||||||
this._passwordLoginViewModel?.setBusy(status);
|
this._passwordLoginViewModel?.setBusy(status);
|
||||||
this._startSSOLoginViewModel?.setBusy(status);
|
this._startSSOLoginViewModel?.setBusy(status);
|
||||||
this.emitChange("isBusy");
|
this.emitChange("isBusy");
|
||||||
}
|
}
|
||||||
|
|
||||||
async attemptLogin(loginMethod: ILoginMethod): Promise<null> {
|
async attemptLogin(loginMethod) {
|
||||||
this._setBusy(true);
|
this._setBusy(true);
|
||||||
void this._client.startWithLogin(loginMethod, {inspectAccountSetup: true});
|
this._client.startWithLogin(loginMethod, {inspectAccountSetup: true});
|
||||||
const loadStatus = this._client.loadStatus;
|
const loadStatus = this._client.loadStatus;
|
||||||
const handle = loadStatus.waitFor((status: LoadStatus) => status !== LoadStatus.Login);
|
const handle = loadStatus.waitFor(status => status !== LoadStatus.Login);
|
||||||
await handle.promise;
|
await handle.promise;
|
||||||
this._setBusy(false);
|
this._setBusy(false);
|
||||||
const status = loadStatus.get();
|
const status = loadStatus.get();
|
||||||
|
@ -163,11 +119,11 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
|
||||||
this._hideHomeserver = true;
|
this._hideHomeserver = true;
|
||||||
this.emitChange("hideHomeserver");
|
this.emitChange("hideHomeserver");
|
||||||
this._disposeViewModels();
|
this._disposeViewModels();
|
||||||
void this._createLoadViewModel();
|
this._createLoadViewModel();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createLoadViewModel(): void {
|
_createLoadViewModel() {
|
||||||
this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription);
|
this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription);
|
||||||
this._loadViewModel = this.disposeTracked(this._loadViewModel);
|
this._loadViewModel = this.disposeTracked(this._loadViewModel);
|
||||||
this._loadViewModel = this.track(
|
this._loadViewModel = this.track(
|
||||||
|
@ -183,7 +139,7 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
void this._loadViewModel.start();
|
this._loadViewModel.start();
|
||||||
this.emitChange("loadViewModel");
|
this.emitChange("loadViewModel");
|
||||||
this._loadViewModelSubscription = this.track(
|
this._loadViewModelSubscription = this.track(
|
||||||
this._loadViewModel.disposableOn("change", () => {
|
this._loadViewModel.disposableOn("change", () => {
|
||||||
|
@ -195,22 +151,22 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _disposeViewModels(): void {
|
_disposeViewModels() {
|
||||||
this._startSSOLoginViewModel = this.disposeTracked(this._startSSOLoginViewModel);
|
this._startSSOLoginViewModel = this.disposeTracked(this._ssoLoginViewModel);
|
||||||
this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel);
|
this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel);
|
||||||
this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel);
|
this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel);
|
||||||
this.emitChange("disposeViewModels");
|
this.emitChange("disposeViewModels");
|
||||||
}
|
}
|
||||||
|
|
||||||
async setHomeserver(newHomeserver: string): Promise<void> {
|
async setHomeserver(newHomeserver) {
|
||||||
this._homeserver = newHomeserver;
|
this._homeserver = newHomeserver;
|
||||||
// clear everything set by queryHomeserver
|
// clear everything set by queryHomeserver
|
||||||
this._loginOptions = undefined;
|
this._loginOptions = null;
|
||||||
this._queriedHomeserver = undefined;
|
this._queriedHomeserver = null;
|
||||||
this._showError("");
|
this._showError("");
|
||||||
this._disposeViewModels();
|
this._disposeViewModels();
|
||||||
this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation);
|
this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation);
|
||||||
this.emitChange("loginViewModels"); // multiple fields changing
|
this.emitChange(); // multiple fields changing
|
||||||
// also clear the timeout if it is still running
|
// also clear the timeout if it is still running
|
||||||
this.disposeTracked(this._abortHomeserverQueryTimeout);
|
this.disposeTracked(this._abortHomeserverQueryTimeout);
|
||||||
const timeout = this.clock.createTimeout(1000);
|
const timeout = this.clock.createTimeout(1000);
|
||||||
|
@ -225,10 +181,10 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout);
|
this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout);
|
||||||
void this.queryHomeserver();
|
this.queryHomeserver();
|
||||||
}
|
}
|
||||||
|
|
||||||
async queryHomeserver(): Promise<void> {
|
async queryHomeserver() {
|
||||||
// don't repeat a query we've just done
|
// don't repeat a query we've just done
|
||||||
if (this._homeserver === this._queriedHomeserver || this._homeserver === "") {
|
if (this._homeserver === this._queriedHomeserver || this._homeserver === "") {
|
||||||
return;
|
return;
|
||||||
|
@ -254,7 +210,7 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
|
||||||
if (e.name === "AbortError") {
|
if (e.name === "AbortError") {
|
||||||
return; //aborted, bail out
|
return; //aborted, bail out
|
||||||
} else {
|
} else {
|
||||||
this._loginOptions = undefined;
|
this._loginOptions = null;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation);
|
this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation);
|
||||||
|
@ -272,22 +228,12 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose() {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
if (this._client) {
|
if (this._client) {
|
||||||
// if we move away before we're done with initial sync
|
// if we move away before we're done with initial sync
|
||||||
// delete the session
|
// delete the session
|
||||||
void this._client.deleteSession();
|
this._client.deleteSession();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReadyFn = (client: Client) => void;
|
|
||||||
|
|
||||||
// TODO: move to Client.js when its converted to typescript.
|
|
||||||
type LoginOptions = {
|
|
||||||
homeserver: string;
|
|
||||||
password?: (username: string, password: string) => PasswordLoginMethod;
|
|
||||||
sso?: SSOLoginHelper;
|
|
||||||
token?: (loginToken: string) => TokenLoginMethod;
|
|
||||||
};
|
|
|
@ -14,51 +14,30 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue";
|
import {ObservableValue} from "../../observable/value/ObservableValue";
|
||||||
|
import {BaseObservableValue} from "../../observable/value/BaseObservableValue";
|
||||||
|
|
||||||
|
export class Navigation {
|
||||||
type AllowsChild<T> = (parent: Segment<T> | undefined, child: Segment<T>) => boolean;
|
constructor(allowsChild) {
|
||||||
|
|
||||||
/**
|
|
||||||
* OptionalValue is basically stating that if SegmentType[type] = true:
|
|
||||||
* - Allow this type to be optional
|
|
||||||
* - Give it a default value of undefined
|
|
||||||
* - Also allow it to be true
|
|
||||||
* This lets us do:
|
|
||||||
* const s: Segment<SegmentType> = new Segment("create-room");
|
|
||||||
* instead of
|
|
||||||
* const s: Segment<SegmentType> = new Segment("create-room", undefined);
|
|
||||||
*/
|
|
||||||
export type OptionalValue<T> = T extends true? [(undefined | true)?]: [T];
|
|
||||||
|
|
||||||
export class Navigation<T extends object> {
|
|
||||||
private readonly _allowsChild: AllowsChild<T>;
|
|
||||||
private _path: Path<T>;
|
|
||||||
private readonly _observables: Map<keyof T, SegmentObservable<T>> = new Map();
|
|
||||||
private readonly _pathObservable: ObservableValue<Path<T>>;
|
|
||||||
|
|
||||||
constructor(allowsChild: AllowsChild<T>) {
|
|
||||||
this._allowsChild = allowsChild;
|
this._allowsChild = allowsChild;
|
||||||
this._path = new Path([], allowsChild);
|
this._path = new Path([], allowsChild);
|
||||||
|
this._observables = new Map();
|
||||||
this._pathObservable = new ObservableValue(this._path);
|
this._pathObservable = new ObservableValue(this._path);
|
||||||
}
|
}
|
||||||
|
|
||||||
get pathObservable(): ObservableValue<Path<T>> {
|
get pathObservable() {
|
||||||
return this._pathObservable;
|
return this._pathObservable;
|
||||||
}
|
}
|
||||||
|
|
||||||
get path(): Path<T> {
|
get path() {
|
||||||
return this._path;
|
return this._path;
|
||||||
}
|
}
|
||||||
|
|
||||||
push<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): void {
|
push(type, value = undefined) {
|
||||||
const newPath = this.path.with(new Segment(type, ...value));
|
return this.applyPath(this.path.with(new Segment(type, value)));
|
||||||
if (newPath) {
|
|
||||||
this.applyPath(newPath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applyPath(path: Path<T>): void {
|
applyPath(path) {
|
||||||
// Path is not exported, so you can only create a Path through Navigation,
|
// Path is not exported, so you can only create a Path through Navigation,
|
||||||
// so we assume it respects the allowsChild rules
|
// so we assume it respects the allowsChild rules
|
||||||
const oldPath = this._path;
|
const oldPath = this._path;
|
||||||
|
@ -82,7 +61,7 @@ export class Navigation<T extends object> {
|
||||||
this._pathObservable.set(this._path);
|
this._pathObservable.set(this._path);
|
||||||
}
|
}
|
||||||
|
|
||||||
observe(type: keyof T): SegmentObservable<T> {
|
observe(type) {
|
||||||
let observable = this._observables.get(type);
|
let observable = this._observables.get(type);
|
||||||
if (!observable) {
|
if (!observable) {
|
||||||
observable = new SegmentObservable(this, type);
|
observable = new SegmentObservable(this, type);
|
||||||
|
@ -91,9 +70,9 @@ export class Navigation<T extends object> {
|
||||||
return observable;
|
return observable;
|
||||||
}
|
}
|
||||||
|
|
||||||
pathFrom(segments: Segment<any>[]): Path<T> {
|
pathFrom(segments) {
|
||||||
let parent: Segment<any> | undefined;
|
let parent;
|
||||||
let i: number;
|
let i;
|
||||||
for (i = 0; i < segments.length; i += 1) {
|
for (i = 0; i < segments.length; i += 1) {
|
||||||
if (!this._allowsChild(parent, segments[i])) {
|
if (!this._allowsChild(parent, segments[i])) {
|
||||||
return new Path(segments.slice(0, i), this._allowsChild);
|
return new Path(segments.slice(0, i), this._allowsChild);
|
||||||
|
@ -103,12 +82,12 @@ export class Navigation<T extends object> {
|
||||||
return new Path(segments, this._allowsChild);
|
return new Path(segments, this._allowsChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
segment<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): Segment<T> {
|
segment(type, value) {
|
||||||
return new Segment(type, ...value);
|
return new Segment(type, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function segmentValueEqual<T>(a?: T[keyof T], b?: T[keyof T]): boolean {
|
function segmentValueEqual(a, b) {
|
||||||
if (a === b) {
|
if (a === b) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -125,29 +104,24 @@ function segmentValueEqual<T>(a?: T[keyof T], b?: T[keyof T]): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class Segment {
|
||||||
export class Segment<T, K extends keyof T = any> {
|
constructor(type, value) {
|
||||||
public value: T[K];
|
this.type = type;
|
||||||
|
this.value = value === undefined ? true : value;
|
||||||
constructor(public type: K, ...value: OptionalValue<T[K]>) {
|
|
||||||
this.value = (value[0] === undefined ? true : value[0]) as unknown as T[K];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Path<T> {
|
class Path {
|
||||||
private readonly _segments: Segment<T, any>[];
|
constructor(segments = [], allowsChild) {
|
||||||
private readonly _allowsChild: AllowsChild<T>;
|
|
||||||
|
|
||||||
constructor(segments: Segment<T>[] = [], allowsChild: AllowsChild<T>) {
|
|
||||||
this._segments = segments;
|
this._segments = segments;
|
||||||
this._allowsChild = allowsChild;
|
this._allowsChild = allowsChild;
|
||||||
}
|
}
|
||||||
|
|
||||||
clone(): Path<T> {
|
clone() {
|
||||||
return new Path(this._segments.slice(), this._allowsChild);
|
return new Path(this._segments.slice(), this._allowsChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
with(segment: Segment<T>): Path<T> | undefined {
|
with(segment) {
|
||||||
let index = this._segments.length - 1;
|
let index = this._segments.length - 1;
|
||||||
do {
|
do {
|
||||||
if (this._allowsChild(this._segments[index], segment)) {
|
if (this._allowsChild(this._segments[index], segment)) {
|
||||||
|
@ -159,10 +133,10 @@ class Path<T> {
|
||||||
index -= 1;
|
index -= 1;
|
||||||
} while(index >= -1);
|
} while(index >= -1);
|
||||||
// allow -1 as well so we check if the segment is allowed as root
|
// allow -1 as well so we check if the segment is allowed as root
|
||||||
return undefined;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
until(type: keyof T): Path<T> {
|
until(type) {
|
||||||
const index = this._segments.findIndex(s => s.type === type);
|
const index = this._segments.findIndex(s => s.type === type);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
return new Path(this._segments.slice(0, index + 1), this._allowsChild)
|
return new Path(this._segments.slice(0, index + 1), this._allowsChild)
|
||||||
|
@ -170,11 +144,11 @@ class Path<T> {
|
||||||
return new Path([], this._allowsChild);
|
return new Path([], this._allowsChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(type: keyof T): Segment<T> | undefined {
|
get(type) {
|
||||||
return this._segments.find(s => s.type === type);
|
return this._segments.find(s => s.type === type);
|
||||||
}
|
}
|
||||||
|
|
||||||
replace(segment: Segment<T>): Path<T> | undefined {
|
replace(segment) {
|
||||||
const index = this._segments.findIndex(s => s.type === segment.type);
|
const index = this._segments.findIndex(s => s.type === segment.type);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
const parent = this._segments[index - 1];
|
const parent = this._segments[index - 1];
|
||||||
|
@ -187,10 +161,10 @@ class Path<T> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get segments(): Segment<T>[] {
|
get segments() {
|
||||||
return this._segments;
|
return this._segments;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -199,49 +173,43 @@ class Path<T> {
|
||||||
* custom observable so it always returns what is in navigation.path, even if we haven't emitted the change yet.
|
* custom observable so it always returns what is in navigation.path, even if we haven't emitted the change yet.
|
||||||
* This ensures that observers of a segment can also read the most recent value of other segments.
|
* This ensures that observers of a segment can also read the most recent value of other segments.
|
||||||
*/
|
*/
|
||||||
class SegmentObservable<T extends object> extends BaseObservableValue<T[keyof T] | undefined> {
|
class SegmentObservable extends BaseObservableValue {
|
||||||
private readonly _navigation: Navigation<T>;
|
constructor(navigation, type) {
|
||||||
private _type: keyof T;
|
|
||||||
private _lastSetValue?: T[keyof T];
|
|
||||||
|
|
||||||
constructor(navigation: Navigation<T>, type: keyof T) {
|
|
||||||
super();
|
super();
|
||||||
this._navigation = navigation;
|
this._navigation = navigation;
|
||||||
this._type = type;
|
this._type = type;
|
||||||
this._lastSetValue = navigation.path.get(type)?.value;
|
this._lastSetValue = navigation.path.get(type)?.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
get(): T[keyof T] | undefined {
|
get() {
|
||||||
const path = this._navigation.path;
|
const path = this._navigation.path;
|
||||||
const segment = path.get(this._type);
|
const segment = path.get(this._type);
|
||||||
const value = segment?.value;
|
const value = segment?.value;
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
emitIfChanged(): void {
|
emitIfChanged() {
|
||||||
const newValue = this.get();
|
const newValue = this.get();
|
||||||
if (!segmentValueEqual<T>(newValue, this._lastSetValue)) {
|
if (!segmentValueEqual(newValue, this._lastSetValue)) {
|
||||||
this._lastSetValue = newValue;
|
this._lastSetValue = newValue;
|
||||||
this.emit(newValue);
|
this.emit(newValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type {Path};
|
|
||||||
|
|
||||||
export function tests() {
|
export function tests() {
|
||||||
|
|
||||||
function createMockNavigation() {
|
function createMockNavigation() {
|
||||||
return new Navigation((parent, {type}) => {
|
return new Navigation((parent, {type}) => {
|
||||||
switch (parent?.type) {
|
switch (parent?.type) {
|
||||||
case undefined:
|
case undefined:
|
||||||
return type === "1" || type === "2";
|
return type === "1" || "2";
|
||||||
case "1":
|
case "1":
|
||||||
return type === "1.1";
|
return type === "1.1";
|
||||||
case "1.1":
|
case "1.1":
|
||||||
return type === "1.1.1";
|
return type === "1.1.1";
|
||||||
case "2":
|
case "2":
|
||||||
return type === "2.1" || type === "2.2";
|
return type === "2.1" || "2.2";
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -249,7 +217,7 @@ export function tests() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function observeTypes(nav, types) {
|
function observeTypes(nav, types) {
|
||||||
const changes: {type:string, value:any}[] = [];
|
const changes = [];
|
||||||
for (const type of types) {
|
for (const type of types) {
|
||||||
nav.observe(type).subscribe(value => {
|
nav.observe(type).subscribe(value => {
|
||||||
changes.push({type, value});
|
changes.push({type, value});
|
||||||
|
@ -258,12 +226,6 @@ export function tests() {
|
||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SegmentType = {
|
|
||||||
"foo": number;
|
|
||||||
"bar": number;
|
|
||||||
"baz": number;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"applying a path emits an event on the observable": assert => {
|
"applying a path emits an event on the observable": assert => {
|
||||||
const nav = createMockNavigation();
|
const nav = createMockNavigation();
|
||||||
|
@ -281,18 +243,18 @@ export function tests() {
|
||||||
assert.equal(changes[1].value, 8);
|
assert.equal(changes[1].value, 8);
|
||||||
},
|
},
|
||||||
"path.get": assert => {
|
"path.get": assert => {
|
||||||
const path = new Path<SegmentType>([new Segment("foo", 5), new Segment("bar", 6)], () => true);
|
const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true);
|
||||||
assert.equal(path.get("foo")!.value, 5);
|
assert.equal(path.get("foo").value, 5);
|
||||||
assert.equal(path.get("bar")!.value, 6);
|
assert.equal(path.get("bar").value, 6);
|
||||||
},
|
},
|
||||||
"path.replace success": assert => {
|
"path.replace success": assert => {
|
||||||
const path = new Path<SegmentType>([new Segment("foo", 5), new Segment("bar", 6)], () => true);
|
const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true);
|
||||||
const newPath = path.replace(new Segment("foo", 1));
|
const newPath = path.replace(new Segment("foo", 1));
|
||||||
assert.equal(newPath!.get("foo")!.value, 1);
|
assert.equal(newPath.get("foo").value, 1);
|
||||||
assert.equal(newPath!.get("bar")!.value, 6);
|
assert.equal(newPath.get("bar").value, 6);
|
||||||
},
|
},
|
||||||
"path.replace not found": assert => {
|
"path.replace not found": assert => {
|
||||||
const path = new Path<SegmentType>([new Segment("foo", 5), new Segment("bar", 6)], () => true);
|
const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true);
|
||||||
const newPath = path.replace(new Segment("baz", 1));
|
const newPath = path.replace(new Segment("baz", 1));
|
||||||
assert.equal(newPath, null);
|
assert.equal(newPath, null);
|
||||||
}
|
}
|
|
@ -14,55 +14,28 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {History} from "../../platform/web/dom/History.js";
|
export class URLRouter {
|
||||||
import type {Navigation, Segment, Path, OptionalValue} from "./Navigation";
|
constructor({history, navigation, parseUrlPath, stringifyPath}) {
|
||||||
import type {SubscriptionHandle} from "../../observable/BaseObservable";
|
|
||||||
|
|
||||||
type ParseURLPath<T> = (urlPath: string, currentNavPath: Path<T>, defaultSessionId?: string) => Segment<T>[];
|
|
||||||
type StringifyPath<T> = (path: Path<T>) => string;
|
|
||||||
|
|
||||||
export interface IURLRouter<T> {
|
|
||||||
attach(): void;
|
|
||||||
dispose(): void;
|
|
||||||
pushUrl(url: string): void;
|
|
||||||
tryRestoreLastUrl(): boolean;
|
|
||||||
urlForSegments(segments: Segment<T>[]): string | undefined;
|
|
||||||
urlForSegment<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): string | undefined;
|
|
||||||
urlUntilSegment(type: keyof T): string;
|
|
||||||
urlForPath(path: Path<T>): string;
|
|
||||||
openRoomActionUrl(roomId: string): string;
|
|
||||||
createSSOCallbackURL(): string;
|
|
||||||
normalizeUrl(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class URLRouter<T extends {session: string | boolean}> implements IURLRouter<T> {
|
|
||||||
private readonly _history: History;
|
|
||||||
private readonly _navigation: Navigation<T>;
|
|
||||||
private readonly _parseUrlPath: ParseURLPath<T>;
|
|
||||||
private readonly _stringifyPath: StringifyPath<T>;
|
|
||||||
private _subscription?: SubscriptionHandle;
|
|
||||||
private _pathSubscription?: SubscriptionHandle;
|
|
||||||
private _isApplyingUrl: boolean = false;
|
|
||||||
private _defaultSessionId?: string;
|
|
||||||
|
|
||||||
constructor(history: History, navigation: Navigation<T>, parseUrlPath: ParseURLPath<T>, stringifyPath: StringifyPath<T>) {
|
|
||||||
this._history = history;
|
this._history = history;
|
||||||
this._navigation = navigation;
|
this._navigation = navigation;
|
||||||
this._parseUrlPath = parseUrlPath;
|
this._parseUrlPath = parseUrlPath;
|
||||||
this._stringifyPath = stringifyPath;
|
this._stringifyPath = stringifyPath;
|
||||||
|
this._subscription = null;
|
||||||
|
this._pathSubscription = null;
|
||||||
|
this._isApplyingUrl = false;
|
||||||
this._defaultSessionId = this._getLastSessionId();
|
this._defaultSessionId = this._getLastSessionId();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _getLastSessionId(): string | undefined {
|
_getLastSessionId() {
|
||||||
const navPath = this._urlAsNavPath(this._history.getLastSessionUrl() || "");
|
const navPath = this._urlAsNavPath(this._history.getLastUrl() || "");
|
||||||
const sessionId = navPath.get("session")?.value;
|
const sessionId = navPath.get("session")?.value;
|
||||||
if (typeof sessionId === "string") {
|
if (typeof sessionId === "string") {
|
||||||
return sessionId;
|
return sessionId;
|
||||||
}
|
}
|
||||||
return undefined;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
attach(): void {
|
attach() {
|
||||||
this._subscription = this._history.subscribe(url => this._applyUrl(url));
|
this._subscription = this._history.subscribe(url => this._applyUrl(url));
|
||||||
// subscribe to path before applying initial url
|
// subscribe to path before applying initial url
|
||||||
// so redirects in _applyNavPathToHistory are reflected in url bar
|
// so redirects in _applyNavPathToHistory are reflected in url bar
|
||||||
|
@ -70,12 +43,12 @@ export class URLRouter<T extends {session: string | boolean}> implements IURLRou
|
||||||
this._applyUrl(this._history.get());
|
this._applyUrl(this._history.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose() {
|
||||||
if (this._subscription) { this._subscription = this._subscription(); }
|
this._subscription = this._subscription();
|
||||||
if (this._pathSubscription) { this._pathSubscription = this._pathSubscription(); }
|
this._pathSubscription = this._pathSubscription();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _applyNavPathToHistory(path: Path<T>): void {
|
_applyNavPathToHistory(path) {
|
||||||
const url = this.urlForPath(path);
|
const url = this.urlForPath(path);
|
||||||
if (url !== this._history.get()) {
|
if (url !== this._history.get()) {
|
||||||
if (this._isApplyingUrl) {
|
if (this._isApplyingUrl) {
|
||||||
|
@ -87,7 +60,7 @@ export class URLRouter<T extends {session: string | boolean}> implements IURLRou
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _applyNavPathToNavigation(navPath: Path<T>): void {
|
_applyNavPathToNavigation(navPath) {
|
||||||
// this will cause _applyNavPathToHistory to be called,
|
// this will cause _applyNavPathToHistory to be called,
|
||||||
// so set a flag whether this request came from ourselves
|
// so set a flag whether this request came from ourselves
|
||||||
// (in which case it is a redirect if the url does not match the current one)
|
// (in which case it is a redirect if the url does not match the current one)
|
||||||
|
@ -96,22 +69,22 @@ export class URLRouter<T extends {session: string | boolean}> implements IURLRou
|
||||||
this._isApplyingUrl = false;
|
this._isApplyingUrl = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _urlAsNavPath(url: string): Path<T> {
|
_urlAsNavPath(url) {
|
||||||
const urlPath = this._history.urlAsPath(url);
|
const urlPath = this._history.urlAsPath(url);
|
||||||
return this._navigation.pathFrom(this._parseUrlPath(urlPath, this._navigation.path, this._defaultSessionId));
|
return this._navigation.pathFrom(this._parseUrlPath(urlPath, this._navigation.path, this._defaultSessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
private _applyUrl(url: string): void {
|
_applyUrl(url) {
|
||||||
const navPath = this._urlAsNavPath(url);
|
const navPath = this._urlAsNavPath(url);
|
||||||
this._applyNavPathToNavigation(navPath);
|
this._applyNavPathToNavigation(navPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
pushUrl(url: string): void {
|
pushUrl(url) {
|
||||||
this._history.pushUrl(url);
|
this._history.pushUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
tryRestoreLastUrl(): boolean {
|
tryRestoreLastUrl() {
|
||||||
const lastNavPath = this._urlAsNavPath(this._history.getLastSessionUrl() || "");
|
const lastNavPath = this._urlAsNavPath(this._history.getLastUrl() || "");
|
||||||
if (lastNavPath.segments.length !== 0) {
|
if (lastNavPath.segments.length !== 0) {
|
||||||
this._applyNavPathToNavigation(lastNavPath);
|
this._applyNavPathToNavigation(lastNavPath);
|
||||||
return true;
|
return true;
|
||||||
|
@ -119,8 +92,8 @@ export class URLRouter<T extends {session: string | boolean}> implements IURLRou
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
urlForSegments(segments: Segment<T>[]): string | undefined {
|
urlForSegments(segments) {
|
||||||
let path: Path<T> | undefined = this._navigation.path;
|
let path = this._navigation.path;
|
||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
path = path.with(segment);
|
path = path.with(segment);
|
||||||
if (!path) {
|
if (!path) {
|
||||||
|
@ -130,29 +103,29 @@ export class URLRouter<T extends {session: string | boolean}> implements IURLRou
|
||||||
return this.urlForPath(path);
|
return this.urlForPath(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
urlForSegment<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): string | undefined {
|
urlForSegment(type, value) {
|
||||||
return this.urlForSegments([this._navigation.segment(type, ...value)]);
|
return this.urlForSegments([this._navigation.segment(type, value)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
urlUntilSegment(type: keyof T): string {
|
urlUntilSegment(type) {
|
||||||
return this.urlForPath(this._navigation.path.until(type));
|
return this.urlForPath(this._navigation.path.until(type));
|
||||||
}
|
}
|
||||||
|
|
||||||
urlForPath(path: Path<T>): string {
|
urlForPath(path) {
|
||||||
return this._history.pathAsUrl(this._stringifyPath(path));
|
return this._history.pathAsUrl(this._stringifyPath(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
openRoomActionUrl(roomId: string): string {
|
openRoomActionUrl(roomId) {
|
||||||
// not a segment to navigation knowns about, so append it manually
|
// not a segment to navigation knowns about, so append it manually
|
||||||
const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`;
|
const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`;
|
||||||
return this._history.pathAsUrl(urlPath);
|
return this._history.pathAsUrl(urlPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
createSSOCallbackURL(): string {
|
createSSOCallbackURL() {
|
||||||
return window.location.origin;
|
return window.location.origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizeUrl(): void {
|
normalizeUrl() {
|
||||||
// Remove any queryParameters from the URL
|
// Remove any queryParameters from the URL
|
||||||
// Gets rid of the loginToken after SSO
|
// Gets rid of the loginToken after SSO
|
||||||
this._history.replaceUrlSilently(`${window.location.origin}/${window.location.hash}`);
|
this._history.replaceUrlSilently(`${window.location.origin}/${window.location.hash}`);
|
|
@ -14,36 +14,18 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Navigation, Segment} from "./Navigation";
|
import {Navigation, Segment} from "./Navigation.js";
|
||||||
import {URLRouter} from "./URLRouter";
|
import {URLRouter} from "./URLRouter.js";
|
||||||
import type {Path, OptionalValue} from "./Navigation";
|
|
||||||
|
|
||||||
export type SegmentType = {
|
export function createNavigation() {
|
||||||
"login": true;
|
|
||||||
"session": string | boolean;
|
|
||||||
"sso": string;
|
|
||||||
"logout": true;
|
|
||||||
"room": string;
|
|
||||||
"rooms": string[];
|
|
||||||
"settings": true;
|
|
||||||
"create-room": true;
|
|
||||||
"empty-grid-tile": number;
|
|
||||||
"lightbox": string;
|
|
||||||
"right-panel": true;
|
|
||||||
"details": true;
|
|
||||||
"members": true;
|
|
||||||
"member": string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createNavigation(): Navigation<SegmentType> {
|
|
||||||
return new Navigation(allowsChild);
|
return new Navigation(allowsChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRouter({history, navigation}: {history: History, navigation: Navigation<SegmentType>}): URLRouter<SegmentType> {
|
export function createRouter({history, navigation}) {
|
||||||
return new URLRouter(history, navigation, parseUrlPath, stringifyPath);
|
return new URLRouter({history, navigation, stringifyPath, parseUrlPath});
|
||||||
}
|
}
|
||||||
|
|
||||||
function allowsChild(parent: Segment<SegmentType> | undefined, child: Segment<SegmentType>): boolean {
|
function allowsChild(parent, child) {
|
||||||
const {type} = child;
|
const {type} = child;
|
||||||
switch (parent?.type) {
|
switch (parent?.type) {
|
||||||
case undefined:
|
case undefined:
|
||||||
|
@ -63,9 +45,8 @@ function allowsChild(parent: Segment<SegmentType> | undefined, child: Segment<Se
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeRoomFromPath(path: Path<SegmentType>, roomId: string): Path<SegmentType> | undefined {
|
export function removeRoomFromPath(path, roomId) {
|
||||||
let newPath: Path<SegmentType> | undefined = path;
|
const rooms = path.get("rooms");
|
||||||
const rooms = newPath.get("rooms");
|
|
||||||
let roomIdGridIndex = -1;
|
let roomIdGridIndex = -1;
|
||||||
// first delete from rooms segment
|
// first delete from rooms segment
|
||||||
if (rooms) {
|
if (rooms) {
|
||||||
|
@ -73,22 +54,22 @@ export function removeRoomFromPath(path: Path<SegmentType>, roomId: string): Pat
|
||||||
if (roomIdGridIndex !== -1) {
|
if (roomIdGridIndex !== -1) {
|
||||||
const idsWithoutRoom = rooms.value.slice();
|
const idsWithoutRoom = rooms.value.slice();
|
||||||
idsWithoutRoom[roomIdGridIndex] = "";
|
idsWithoutRoom[roomIdGridIndex] = "";
|
||||||
newPath = newPath.replace(new Segment("rooms", idsWithoutRoom));
|
path = path.replace(new Segment("rooms", idsWithoutRoom));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const room = newPath!.get("room");
|
const room = path.get("room");
|
||||||
// then from room (which occurs with or without rooms)
|
// then from room (which occurs with or without rooms)
|
||||||
if (room && room.value === roomId) {
|
if (room && room.value === roomId) {
|
||||||
if (roomIdGridIndex !== -1) {
|
if (roomIdGridIndex !== -1) {
|
||||||
newPath = newPath!.with(new Segment("empty-grid-tile", roomIdGridIndex));
|
path = path.with(new Segment("empty-grid-tile", roomIdGridIndex));
|
||||||
} else {
|
} else {
|
||||||
newPath = newPath!.until("session");
|
path = path.until("session");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return newPath;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
function roomsSegmentWithRoom(rooms: Segment<SegmentType, "rooms">, roomId: string, path: Path<SegmentType>): Segment<SegmentType, "rooms"> {
|
function roomsSegmentWithRoom(rooms, roomId, path) {
|
||||||
if(!rooms.value.includes(roomId)) {
|
if(!rooms.value.includes(roomId)) {
|
||||||
const emptyGridTile = path.get("empty-grid-tile");
|
const emptyGridTile = path.get("empty-grid-tile");
|
||||||
const oldRoom = path.get("room");
|
const oldRoom = path.get("room");
|
||||||
|
@ -106,28 +87,28 @@ function roomsSegmentWithRoom(rooms: Segment<SegmentType, "rooms">, roomId: stri
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushRightPanelSegment<T extends keyof SegmentType>(array: Segment<SegmentType>[], segment: T, ...value: OptionalValue<SegmentType[T]>): void {
|
function pushRightPanelSegment(array, segment, value = true) {
|
||||||
array.push(new Segment("right-panel"));
|
array.push(new Segment("right-panel"));
|
||||||
array.push(new Segment(segment, ...value));
|
array.push(new Segment(segment, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addPanelIfNeeded<T extends SegmentType>(navigation: Navigation<T>, path: Path<T>): Path<T> {
|
export function addPanelIfNeeded(navigation, path) {
|
||||||
const segments = navigation.path.segments;
|
const segments = navigation.path.segments;
|
||||||
const i = segments.findIndex(segment => segment.type === "right-panel");
|
const i = segments.findIndex(segment => segment.type === "right-panel");
|
||||||
let _path = path;
|
let _path = path;
|
||||||
if (i !== -1) {
|
if (i !== -1) {
|
||||||
_path = path.until("room");
|
_path = path.until("room");
|
||||||
_path = _path.with(segments[i])!;
|
_path = _path.with(segments[i]);
|
||||||
_path = _path.with(segments[i + 1])!;
|
_path = _path.with(segments[i + 1]);
|
||||||
}
|
}
|
||||||
return _path;
|
return _path;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseUrlPath(urlPath: string, currentNavPath: Path<SegmentType>, defaultSessionId?: string): Segment<SegmentType>[] {
|
export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) {
|
||||||
// substring(1) to take of initial /
|
// substr(1) to take of initial /
|
||||||
const parts = urlPath.substring(1).split("/");
|
const parts = urlPath.substr(1).split("/");
|
||||||
const iterator = parts[Symbol.iterator]();
|
const iterator = parts[Symbol.iterator]();
|
||||||
const segments: Segment<SegmentType>[] = [];
|
const segments = [];
|
||||||
let next;
|
let next;
|
||||||
while (!(next = iterator.next()).done) {
|
while (!(next = iterator.next()).done) {
|
||||||
const type = next.value;
|
const type = next.value;
|
||||||
|
@ -189,9 +170,9 @@ export function parseUrlPath(urlPath: string, currentNavPath: Path<SegmentType>,
|
||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stringifyPath(path: Path<SegmentType>): string {
|
export function stringifyPath(path) {
|
||||||
let urlPath = "";
|
let urlPath = "";
|
||||||
let prevSegment: Segment<SegmentType> | undefined;
|
let prevSegment;
|
||||||
for (const segment of path.segments) {
|
for (const segment of path.segments) {
|
||||||
switch (segment.type) {
|
switch (segment.type) {
|
||||||
case "rooms":
|
case "rooms":
|
||||||
|
@ -224,15 +205,9 @@ export function stringifyPath(path: Path<SegmentType>): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tests() {
|
export function tests() {
|
||||||
function createEmptyPath() {
|
|
||||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
|
||||||
const path = nav.pathFrom([]);
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"stringify grid url with focused empty tile": assert => {
|
"stringify grid url with focused empty tile": assert => {
|
||||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
const nav = new Navigation(allowsChild);
|
||||||
const path = nav.pathFrom([
|
const path = nav.pathFrom([
|
||||||
new Segment("session", 1),
|
new Segment("session", 1),
|
||||||
new Segment("rooms", ["a", "b", "c"]),
|
new Segment("rooms", ["a", "b", "c"]),
|
||||||
|
@ -242,7 +217,7 @@ export function tests() {
|
||||||
assert.equal(urlPath, "/session/1/rooms/a,b,c/3");
|
assert.equal(urlPath, "/session/1/rooms/a,b,c/3");
|
||||||
},
|
},
|
||||||
"stringify grid url with focused room": assert => {
|
"stringify grid url with focused room": assert => {
|
||||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
const nav = new Navigation(allowsChild);
|
||||||
const path = nav.pathFrom([
|
const path = nav.pathFrom([
|
||||||
new Segment("session", 1),
|
new Segment("session", 1),
|
||||||
new Segment("rooms", ["a", "b", "c"]),
|
new Segment("rooms", ["a", "b", "c"]),
|
||||||
|
@ -252,7 +227,7 @@ export function tests() {
|
||||||
assert.equal(urlPath, "/session/1/rooms/a,b,c/1");
|
assert.equal(urlPath, "/session/1/rooms/a,b,c/1");
|
||||||
},
|
},
|
||||||
"stringify url with right-panel and details segment": assert => {
|
"stringify url with right-panel and details segment": assert => {
|
||||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
const nav = new Navigation(allowsChild);
|
||||||
const path = nav.pathFrom([
|
const path = nav.pathFrom([
|
||||||
new Segment("session", 1),
|
new Segment("session", 1),
|
||||||
new Segment("rooms", ["a", "b", "c"]),
|
new Segment("rooms", ["a", "b", "c"]),
|
||||||
|
@ -264,15 +239,13 @@ export function tests() {
|
||||||
assert.equal(urlPath, "/session/1/rooms/a,b,c/1/details");
|
assert.equal(urlPath, "/session/1/rooms/a,b,c/1/details");
|
||||||
},
|
},
|
||||||
"Parse loginToken query parameter into SSO segment": assert => {
|
"Parse loginToken query parameter into SSO segment": assert => {
|
||||||
const path = createEmptyPath();
|
const segments = parseUrlPath("?loginToken=a1232aSD123");
|
||||||
const segments = parseUrlPath("?loginToken=a1232aSD123", path);
|
|
||||||
assert.equal(segments.length, 1);
|
assert.equal(segments.length, 1);
|
||||||
assert.equal(segments[0].type, "sso");
|
assert.equal(segments[0].type, "sso");
|
||||||
assert.equal(segments[0].value, "a1232aSD123");
|
assert.equal(segments[0].value, "a1232aSD123");
|
||||||
},
|
},
|
||||||
"parse grid url path with focused empty tile": assert => {
|
"parse grid url path with focused empty tile": assert => {
|
||||||
const path = createEmptyPath();
|
const segments = parseUrlPath("/session/1/rooms/a,b,c/3");
|
||||||
const segments = parseUrlPath("/session/1/rooms/a,b,c/3", path);
|
|
||||||
assert.equal(segments.length, 3);
|
assert.equal(segments.length, 3);
|
||||||
assert.equal(segments[0].type, "session");
|
assert.equal(segments[0].type, "session");
|
||||||
assert.equal(segments[0].value, "1");
|
assert.equal(segments[0].value, "1");
|
||||||
|
@ -282,8 +255,7 @@ export function tests() {
|
||||||
assert.equal(segments[2].value, 3);
|
assert.equal(segments[2].value, 3);
|
||||||
},
|
},
|
||||||
"parse grid url path with focused room": assert => {
|
"parse grid url path with focused room": assert => {
|
||||||
const path = createEmptyPath();
|
const segments = parseUrlPath("/session/1/rooms/a,b,c/1");
|
||||||
const segments = parseUrlPath("/session/1/rooms/a,b,c/1", path);
|
|
||||||
assert.equal(segments.length, 3);
|
assert.equal(segments.length, 3);
|
||||||
assert.equal(segments[0].type, "session");
|
assert.equal(segments[0].type, "session");
|
||||||
assert.equal(segments[0].value, "1");
|
assert.equal(segments[0].value, "1");
|
||||||
|
@ -293,8 +265,7 @@ export function tests() {
|
||||||
assert.equal(segments[2].value, "b");
|
assert.equal(segments[2].value, "b");
|
||||||
},
|
},
|
||||||
"parse empty grid url": assert => {
|
"parse empty grid url": assert => {
|
||||||
const path = createEmptyPath();
|
const segments = parseUrlPath("/session/1/rooms/");
|
||||||
const segments = parseUrlPath("/session/1/rooms/", path);
|
|
||||||
assert.equal(segments.length, 3);
|
assert.equal(segments.length, 3);
|
||||||
assert.equal(segments[0].type, "session");
|
assert.equal(segments[0].type, "session");
|
||||||
assert.equal(segments[0].value, "1");
|
assert.equal(segments[0].value, "1");
|
||||||
|
@ -304,8 +275,7 @@ export function tests() {
|
||||||
assert.equal(segments[2].value, 0);
|
assert.equal(segments[2].value, 0);
|
||||||
},
|
},
|
||||||
"parse empty grid url with focus": assert => {
|
"parse empty grid url with focus": assert => {
|
||||||
const path = createEmptyPath();
|
const segments = parseUrlPath("/session/1/rooms//1");
|
||||||
const segments = parseUrlPath("/session/1/rooms//1", path);
|
|
||||||
assert.equal(segments.length, 3);
|
assert.equal(segments.length, 3);
|
||||||
assert.equal(segments[0].type, "session");
|
assert.equal(segments[0].type, "session");
|
||||||
assert.equal(segments[0].value, "1");
|
assert.equal(segments[0].value, "1");
|
||||||
|
@ -315,7 +285,7 @@ export function tests() {
|
||||||
assert.equal(segments[2].value, 1);
|
assert.equal(segments[2].value, 1);
|
||||||
},
|
},
|
||||||
"parse open-room action replacing the current focused room": assert => {
|
"parse open-room action replacing the current focused room": assert => {
|
||||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
const nav = new Navigation(allowsChild);
|
||||||
const path = nav.pathFrom([
|
const path = nav.pathFrom([
|
||||||
new Segment("session", 1),
|
new Segment("session", 1),
|
||||||
new Segment("rooms", ["a", "b", "c"]),
|
new Segment("rooms", ["a", "b", "c"]),
|
||||||
|
@ -331,7 +301,7 @@ export function tests() {
|
||||||
assert.equal(segments[2].value, "d");
|
assert.equal(segments[2].value, "d");
|
||||||
},
|
},
|
||||||
"parse open-room action changing focus to an existing room": assert => {
|
"parse open-room action changing focus to an existing room": assert => {
|
||||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
const nav = new Navigation(allowsChild);
|
||||||
const path = nav.pathFrom([
|
const path = nav.pathFrom([
|
||||||
new Segment("session", 1),
|
new Segment("session", 1),
|
||||||
new Segment("rooms", ["a", "b", "c"]),
|
new Segment("rooms", ["a", "b", "c"]),
|
||||||
|
@ -347,7 +317,7 @@ export function tests() {
|
||||||
assert.equal(segments[2].value, "a");
|
assert.equal(segments[2].value, "a");
|
||||||
},
|
},
|
||||||
"parse open-room action changing focus to an existing room with details open": assert => {
|
"parse open-room action changing focus to an existing room with details open": assert => {
|
||||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
const nav = new Navigation(allowsChild);
|
||||||
const path = nav.pathFrom([
|
const path = nav.pathFrom([
|
||||||
new Segment("session", 1),
|
new Segment("session", 1),
|
||||||
new Segment("rooms", ["a", "b", "c"]),
|
new Segment("rooms", ["a", "b", "c"]),
|
||||||
|
@ -369,7 +339,7 @@ export function tests() {
|
||||||
assert.equal(segments[4].value, true);
|
assert.equal(segments[4].value, true);
|
||||||
},
|
},
|
||||||
"open-room action should only copy over previous segments if there are no parts after open-room": assert => {
|
"open-room action should only copy over previous segments if there are no parts after open-room": assert => {
|
||||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
const nav = new Navigation(allowsChild);
|
||||||
const path = nav.pathFrom([
|
const path = nav.pathFrom([
|
||||||
new Segment("session", 1),
|
new Segment("session", 1),
|
||||||
new Segment("rooms", ["a", "b", "c"]),
|
new Segment("rooms", ["a", "b", "c"]),
|
||||||
|
@ -391,7 +361,7 @@ export function tests() {
|
||||||
assert.equal(segments[4].value, "foo");
|
assert.equal(segments[4].value, "foo");
|
||||||
},
|
},
|
||||||
"parse open-room action setting a room in an empty tile": assert => {
|
"parse open-room action setting a room in an empty tile": assert => {
|
||||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
const nav = new Navigation(allowsChild);
|
||||||
const path = nav.pathFrom([
|
const path = nav.pathFrom([
|
||||||
new Segment("session", 1),
|
new Segment("session", 1),
|
||||||
new Segment("rooms", ["a", "b", "c"]),
|
new Segment("rooms", ["a", "b", "c"]),
|
||||||
|
@ -407,83 +377,82 @@ export function tests() {
|
||||||
assert.equal(segments[2].value, "d");
|
assert.equal(segments[2].value, "d");
|
||||||
},
|
},
|
||||||
"parse session url path without id": assert => {
|
"parse session url path without id": assert => {
|
||||||
const path = createEmptyPath();
|
const segments = parseUrlPath("/session");
|
||||||
const segments = parseUrlPath("/session", path);
|
|
||||||
assert.equal(segments.length, 1);
|
assert.equal(segments.length, 1);
|
||||||
assert.equal(segments[0].type, "session");
|
assert.equal(segments[0].type, "session");
|
||||||
assert.strictEqual(segments[0].value, true);
|
assert.strictEqual(segments[0].value, true);
|
||||||
},
|
},
|
||||||
"remove active room from grid path turns it into empty tile": assert => {
|
"remove active room from grid path turns it into empty tile": assert => {
|
||||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
const nav = new Navigation(allowsChild);
|
||||||
const path = nav.pathFrom([
|
const path = nav.pathFrom([
|
||||||
new Segment("session", 1),
|
new Segment("session", 1),
|
||||||
new Segment("rooms", ["a", "b", "c"]),
|
new Segment("rooms", ["a", "b", "c"]),
|
||||||
new Segment("room", "b")
|
new Segment("room", "b")
|
||||||
]);
|
]);
|
||||||
const newPath = removeRoomFromPath(path, "b");
|
const newPath = removeRoomFromPath(path, "b");
|
||||||
assert.equal(newPath?.segments.length, 3);
|
assert.equal(newPath.segments.length, 3);
|
||||||
assert.equal(newPath?.segments[0].type, "session");
|
assert.equal(newPath.segments[0].type, "session");
|
||||||
assert.equal(newPath?.segments[0].value, 1);
|
assert.equal(newPath.segments[0].value, 1);
|
||||||
assert.equal(newPath?.segments[1].type, "rooms");
|
assert.equal(newPath.segments[1].type, "rooms");
|
||||||
assert.deepEqual(newPath?.segments[1].value, ["a", "", "c"]);
|
assert.deepEqual(newPath.segments[1].value, ["a", "", "c"]);
|
||||||
assert.equal(newPath?.segments[2].type, "empty-grid-tile");
|
assert.equal(newPath.segments[2].type, "empty-grid-tile");
|
||||||
assert.equal(newPath?.segments[2].value, 1);
|
assert.equal(newPath.segments[2].value, 1);
|
||||||
},
|
},
|
||||||
"remove inactive room from grid path": assert => {
|
"remove inactive room from grid path": assert => {
|
||||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
const nav = new Navigation(allowsChild);
|
||||||
const path = nav.pathFrom([
|
const path = nav.pathFrom([
|
||||||
new Segment("session", 1),
|
new Segment("session", 1),
|
||||||
new Segment("rooms", ["a", "b", "c"]),
|
new Segment("rooms", ["a", "b", "c"]),
|
||||||
new Segment("room", "b")
|
new Segment("room", "b")
|
||||||
]);
|
]);
|
||||||
const newPath = removeRoomFromPath(path, "a");
|
const newPath = removeRoomFromPath(path, "a");
|
||||||
assert.equal(newPath?.segments.length, 3);
|
assert.equal(newPath.segments.length, 3);
|
||||||
assert.equal(newPath?.segments[0].type, "session");
|
assert.equal(newPath.segments[0].type, "session");
|
||||||
assert.equal(newPath?.segments[0].value, 1);
|
assert.equal(newPath.segments[0].value, 1);
|
||||||
assert.equal(newPath?.segments[1].type, "rooms");
|
assert.equal(newPath.segments[1].type, "rooms");
|
||||||
assert.deepEqual(newPath?.segments[1].value, ["", "b", "c"]);
|
assert.deepEqual(newPath.segments[1].value, ["", "b", "c"]);
|
||||||
assert.equal(newPath?.segments[2].type, "room");
|
assert.equal(newPath.segments[2].type, "room");
|
||||||
assert.equal(newPath?.segments[2].value, "b");
|
assert.equal(newPath.segments[2].value, "b");
|
||||||
},
|
},
|
||||||
"remove inactive room from grid path with empty tile": assert => {
|
"remove inactive room from grid path with empty tile": assert => {
|
||||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
const nav = new Navigation(allowsChild);
|
||||||
const path = nav.pathFrom([
|
const path = nav.pathFrom([
|
||||||
new Segment("session", 1),
|
new Segment("session", 1),
|
||||||
new Segment("rooms", ["a", "b", ""]),
|
new Segment("rooms", ["a", "b", ""]),
|
||||||
new Segment("empty-grid-tile", 3)
|
new Segment("empty-grid-tile", 3)
|
||||||
]);
|
]);
|
||||||
const newPath = removeRoomFromPath(path, "b");
|
const newPath = removeRoomFromPath(path, "b");
|
||||||
assert.equal(newPath?.segments.length, 3);
|
assert.equal(newPath.segments.length, 3);
|
||||||
assert.equal(newPath?.segments[0].type, "session");
|
assert.equal(newPath.segments[0].type, "session");
|
||||||
assert.equal(newPath?.segments[0].value, 1);
|
assert.equal(newPath.segments[0].value, 1);
|
||||||
assert.equal(newPath?.segments[1].type, "rooms");
|
assert.equal(newPath.segments[1].type, "rooms");
|
||||||
assert.deepEqual(newPath?.segments[1].value, ["a", "", ""]);
|
assert.deepEqual(newPath.segments[1].value, ["a", "", ""]);
|
||||||
assert.equal(newPath?.segments[2].type, "empty-grid-tile");
|
assert.equal(newPath.segments[2].type, "empty-grid-tile");
|
||||||
assert.equal(newPath?.segments[2].value, 3);
|
assert.equal(newPath.segments[2].value, 3);
|
||||||
},
|
},
|
||||||
"remove active room": assert => {
|
"remove active room": assert => {
|
||||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
const nav = new Navigation(allowsChild);
|
||||||
const path = nav.pathFrom([
|
const path = nav.pathFrom([
|
||||||
new Segment("session", 1),
|
new Segment("session", 1),
|
||||||
new Segment("room", "b")
|
new Segment("room", "b")
|
||||||
]);
|
]);
|
||||||
const newPath = removeRoomFromPath(path, "b");
|
const newPath = removeRoomFromPath(path, "b");
|
||||||
assert.equal(newPath?.segments.length, 1);
|
assert.equal(newPath.segments.length, 1);
|
||||||
assert.equal(newPath?.segments[0].type, "session");
|
assert.equal(newPath.segments[0].type, "session");
|
||||||
assert.equal(newPath?.segments[0].value, 1);
|
assert.equal(newPath.segments[0].value, 1);
|
||||||
},
|
},
|
||||||
"remove inactive room doesn't do anything": assert => {
|
"remove inactive room doesn't do anything": assert => {
|
||||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
const nav = new Navigation(allowsChild);
|
||||||
const path = nav.pathFrom([
|
const path = nav.pathFrom([
|
||||||
new Segment("session", 1),
|
new Segment("session", 1),
|
||||||
new Segment("room", "b")
|
new Segment("room", "b")
|
||||||
]);
|
]);
|
||||||
const newPath = removeRoomFromPath(path, "a");
|
const newPath = removeRoomFromPath(path, "a");
|
||||||
assert.equal(newPath?.segments.length, 2);
|
assert.equal(newPath.segments.length, 2);
|
||||||
assert.equal(newPath?.segments[0].type, "session");
|
assert.equal(newPath.segments[0].type, "session");
|
||||||
assert.equal(newPath?.segments[0].value, 1);
|
assert.equal(newPath.segments[0].value, 1);
|
||||||
assert.equal(newPath?.segments[1].type, "room");
|
assert.equal(newPath.segments[1].type, "room");
|
||||||
assert.equal(newPath?.segments[1].value, "b");
|
assert.equal(newPath.segments[1].value, "b");
|
||||||
},
|
},
|
||||||
|
|
||||||
}
|
}
|
|
@ -1,65 +0,0 @@
|
||||||
/*
|
|
||||||
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 type {BlobHandle} from "../platform/web/dom/BlobHandle";
|
|
||||||
import type {RequestFunction} from "../platform/types/types";
|
|
||||||
|
|
||||||
// see https://github.com/matrix-org/rageshake#readme
|
|
||||||
type RageshakeData = {
|
|
||||||
// A textual description of the problem. Included in the details.log.gz file.
|
|
||||||
text: string | undefined;
|
|
||||||
// Application user-agent. Included in the details.log.gz file.
|
|
||||||
userAgent: string;
|
|
||||||
// Identifier for the application (eg 'riot-web'). Should correspond to a mapping configured in the configuration file for github issue reporting to work.
|
|
||||||
app: string;
|
|
||||||
// Application version. Included in the details.log.gz file.
|
|
||||||
version: string;
|
|
||||||
// Label to attach to the github issue, and include in the details file.
|
|
||||||
label: string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function submitLogsToRageshakeServer(data: RageshakeData, logsBlob: BlobHandle, submitUrl: string, request: RequestFunction): Promise<void> {
|
|
||||||
const formData = new Map<string, string | {name: string, blob: BlobHandle}>();
|
|
||||||
if (data.text) {
|
|
||||||
formData.set("text", data.text);
|
|
||||||
}
|
|
||||||
formData.set("user_agent", data.userAgent);
|
|
||||||
formData.set("app", data.app);
|
|
||||||
formData.set("version", data.version);
|
|
||||||
if (data.label) {
|
|
||||||
formData.set("label", data.label);
|
|
||||||
}
|
|
||||||
formData.set("file", {name: "logs.json", blob: logsBlob});
|
|
||||||
const headers: Map<string, string> = new Map();
|
|
||||||
headers.set("Accept", "application/json");
|
|
||||||
const result = request(submitUrl, {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
headers
|
|
||||||
});
|
|
||||||
let response;
|
|
||||||
try {
|
|
||||||
response = await result.response();
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(`Could not submit logs to ${submitUrl}, got error ${err.message}`);
|
|
||||||
}
|
|
||||||
const {status, body} = response;
|
|
||||||
if (status < 200 || status >= 300) {
|
|
||||||
throw new Error(`Could not submit logs to ${submitUrl}, got status code ${status} with body ${body}`);
|
|
||||||
}
|
|
||||||
// we don't bother with reading report_url from the body as the rageshake server doesn't always return it
|
|
||||||
// and would have to have CORS setup properly for us to be able to read it.
|
|
||||||
}
|
|
|
@ -15,7 +15,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {ViewModel} from "../ViewModel";
|
import {ViewModel} from "../ViewModel";
|
||||||
import {addPanelIfNeeded} from "../navigation/index";
|
import {addPanelIfNeeded} from "../navigation/index.js";
|
||||||
|
|
||||||
function dedupeSparse(roomIds) {
|
function dedupeSparse(roomIds) {
|
||||||
return roomIds.map((id, idx) => {
|
return roomIds.map((id, idx) => {
|
||||||
|
@ -185,8 +185,8 @@ export class RoomGridViewModel extends ViewModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
import {createNavigation} from "../navigation/index";
|
import {createNavigation} from "../navigation/index.js";
|
||||||
import {ObservableValue} from "../../observable/ObservableValue";
|
import {ObservableValue} from "../../observable/value/ObservableValue";
|
||||||
|
|
||||||
export function tests() {
|
export function tests() {
|
||||||
class RoomVMMock {
|
class RoomVMMock {
|
||||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {ObservableValue} from "../../observable/ObservableValue";
|
import {ObservableValue} from "../../observable/value/ObservableValue";
|
||||||
import {RoomStatus} from "../../matrix/room/common";
|
import {RoomStatus} from "../../matrix/room/common";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -99,6 +99,9 @@ export class SessionViewModel extends ViewModel {
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
this._sessionStatusViewModel.start();
|
this._sessionStatusViewModel.start();
|
||||||
|
this._client.session.callHandler.loadCalls("m.ring");
|
||||||
|
// TODO: only do this when opening the room
|
||||||
|
this._client.session.callHandler.loadCalls("m.prompt");
|
||||||
}
|
}
|
||||||
|
|
||||||
get activeMiddleViewModel() {
|
get activeMiddleViewModel() {
|
||||||
|
@ -174,7 +177,7 @@ export class SessionViewModel extends ViewModel {
|
||||||
_createRoomViewModelInstance(roomId) {
|
_createRoomViewModelInstance(roomId) {
|
||||||
const room = this._client.session.rooms.get(roomId);
|
const room = this._client.session.rooms.get(roomId);
|
||||||
if (room) {
|
if (room) {
|
||||||
const roomVM = new RoomViewModel(this.childOptions({room}));
|
const roomVM = new RoomViewModel(this.childOptions({room, session: this._client.session}));
|
||||||
roomVM.load();
|
roomVM.load();
|
||||||
return roomVM;
|
return roomVM;
|
||||||
}
|
}
|
||||||
|
@ -191,7 +194,7 @@ export class SessionViewModel extends ViewModel {
|
||||||
async _createArchivedRoomViewModel(roomId) {
|
async _createArchivedRoomViewModel(roomId) {
|
||||||
const room = await this._client.session.loadArchivedRoom(roomId);
|
const room = await this._client.session.loadArchivedRoom(roomId);
|
||||||
if (room) {
|
if (room) {
|
||||||
const roomVM = new RoomViewModel(this.childOptions({room}));
|
const roomVM = new RoomViewModel(this.childOptions({room, session: this._client.session}));
|
||||||
roomVM.load();
|
roomVM.load();
|
||||||
return roomVM;
|
return roomVM;
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@ import {InviteTileViewModel} from "./InviteTileViewModel.js";
|
||||||
import {RoomBeingCreatedTileViewModel} from "./RoomBeingCreatedTileViewModel.js";
|
import {RoomBeingCreatedTileViewModel} from "./RoomBeingCreatedTileViewModel.js";
|
||||||
import {RoomFilter} from "./RoomFilter.js";
|
import {RoomFilter} from "./RoomFilter.js";
|
||||||
import {ApplyMap} from "../../../observable/map/ApplyMap.js";
|
import {ApplyMap} from "../../../observable/map/ApplyMap.js";
|
||||||
import {addPanelIfNeeded} from "../../navigation/index";
|
import {addPanelIfNeeded} from "../../navigation/index.js";
|
||||||
|
|
||||||
export class LeftPanelViewModel extends ViewModel {
|
export class LeftPanelViewModel extends ViewModel {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
|
|
|
@ -16,14 +16,16 @@ 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 {CallViewModel} from "./call/CallViewModel";
|
||||||
|
import {PickMapObservableValue} from "../../../observable/value/PickMapObservableValue";
|
||||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
|
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
|
||||||
import {ViewModel} from "../../ViewModel";
|
import {ViewModel} from "../../ViewModel";
|
||||||
import {imageToInfo} from "../common.js";
|
import {imageToInfo} from "../common.js";
|
||||||
|
import {LocalMedia} from "../../../matrix/calls/LocalMedia";
|
||||||
// TODO: remove fallback so default isn't included in bundle for SDK users that have their custom tileClassForEntry
|
// 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
|
// this is a breaking SDK change though to make this option mandatory
|
||||||
import {tileClassForEntry as defaultTileClassForEntry} from "./timeline/tiles/index";
|
import {tileClassForEntry as defaultTileClassForEntry} from "./timeline/tiles/index";
|
||||||
import {RoomStatus} from "../../../matrix/room/common";
|
|
||||||
|
|
||||||
export class RoomViewModel extends ViewModel {
|
export class RoomViewModel extends ViewModel {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
|
@ -38,12 +40,36 @@ export class RoomViewModel extends ViewModel {
|
||||||
this._sendError = null;
|
this._sendError = null;
|
||||||
this._composerVM = null;
|
this._composerVM = null;
|
||||||
if (room.isArchived) {
|
if (room.isArchived) {
|
||||||
this._composerVM = this.track(new ArchivedViewModel(this.childOptions({archivedRoom: room})));
|
this._composerVM = new ArchivedViewModel(this.childOptions({archivedRoom: room}));
|
||||||
} else {
|
} else {
|
||||||
this._recreateComposerOnPowerLevelChange();
|
this._composerVM = new ComposerViewModel(this);
|
||||||
}
|
}
|
||||||
this._clearUnreadTimout = null;
|
this._clearUnreadTimout = null;
|
||||||
this._closeUrl = this.urlCreator.urlUntilSegment("session");
|
this._closeUrl = this.urlCreator.urlUntilSegment("session");
|
||||||
|
this._setupCallViewModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
_setupCallViewModel() {
|
||||||
|
// pick call for this room with lowest key
|
||||||
|
const calls = this.getOption("session").callHandler.calls;
|
||||||
|
this._callObservable = new PickMapObservableValue(calls.filterValues(c => {
|
||||||
|
return c.roomId === this._room.id && c.hasJoined;
|
||||||
|
}));
|
||||||
|
this._callViewModel = undefined;
|
||||||
|
this.track(this._callObservable.subscribe(call => {
|
||||||
|
if (call && this._callViewModel && call.id === this._callViewModel.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._callViewModel = this.disposeTracked(this._callViewModel);
|
||||||
|
if (call) {
|
||||||
|
this._callViewModel = this.track(new CallViewModel(this.childOptions({call, mediaRepository: this._room.mediaRepository})));
|
||||||
|
}
|
||||||
|
this.emitChange("callViewModel");
|
||||||
|
}));
|
||||||
|
const call = this._callObservable.get();
|
||||||
|
if (call) {
|
||||||
|
this._callViewModel = new CallViewModel(this.childOptions({call}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
|
@ -51,6 +77,7 @@ export class RoomViewModel extends ViewModel {
|
||||||
try {
|
try {
|
||||||
const timeline = await this._room.openTimeline();
|
const timeline = await this._room.openTimeline();
|
||||||
this._tileOptions = this.childOptions({
|
this._tileOptions = this.childOptions({
|
||||||
|
session: this.getOption("session"),
|
||||||
roomVM: this,
|
roomVM: this,
|
||||||
timeline,
|
timeline,
|
||||||
tileClassForEntry: this._tileClassForEntry,
|
tileClassForEntry: this._tileClassForEntry,
|
||||||
|
@ -68,30 +95,6 @@ export class RoomViewModel extends ViewModel {
|
||||||
this._clearUnreadAfterDelay();
|
this._clearUnreadAfterDelay();
|
||||||
}
|
}
|
||||||
|
|
||||||
async _recreateComposerOnPowerLevelChange() {
|
|
||||||
const powerLevelObservable = await this._room.observePowerLevels();
|
|
||||||
const canSendMessage = () => powerLevelObservable.get().canSendType("m.room.message");
|
|
||||||
let oldCanSendMessage = canSendMessage();
|
|
||||||
const recreateComposer = newCanSendMessage => {
|
|
||||||
this._composerVM = this.disposeTracked(this._composerVM);
|
|
||||||
if (newCanSendMessage) {
|
|
||||||
this._composerVM = this.track(new ComposerViewModel(this));
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this._composerVM = this.track(new LowerPowerLevelViewModel(this.childOptions()));
|
|
||||||
}
|
|
||||||
this.emitChange("powerLevelObservable")
|
|
||||||
};
|
|
||||||
this.track(powerLevelObservable.subscribe(() => {
|
|
||||||
const newCanSendMessage = canSendMessage();
|
|
||||||
if (oldCanSendMessage !== newCanSendMessage) {
|
|
||||||
recreateComposer(newCanSendMessage);
|
|
||||||
oldCanSendMessage = newCanSendMessage;
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
recreateComposer(oldCanSendMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
async _clearUnreadAfterDelay() {
|
async _clearUnreadAfterDelay() {
|
||||||
if (this._room.isArchived || this._clearUnreadTimout) {
|
if (this._room.isArchived || this._clearUnreadTimout) {
|
||||||
return;
|
return;
|
||||||
|
@ -198,89 +201,18 @@ export class RoomViewModel extends ViewModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _processCommandJoin(roomName) {
|
|
||||||
try {
|
|
||||||
const roomId = await this._options.client.session.joinRoom(roomName);
|
|
||||||
const roomStatusObserver = await this._options.client.session.observeRoomStatus(roomId);
|
|
||||||
await roomStatusObserver.waitFor(status => status === RoomStatus.Joined);
|
|
||||||
this.navigation.push("room", roomId);
|
|
||||||
} catch (err) {
|
|
||||||
let exc;
|
|
||||||
if ((err.statusCode ?? err.status) === 400) {
|
|
||||||
exc = new Error(`/join : '${roomName}' was not legal room ID or room alias`);
|
|
||||||
} else if ((err.statusCode ?? err.status) === 404 || (err.statusCode ?? err.status) === 502 || err.message == "Internal Server Error") {
|
|
||||||
exc = new Error(`/join : room '${roomName}' not found`);
|
|
||||||
} else if ((err.statusCode ?? err.status) === 403) {
|
|
||||||
exc = new Error(`/join : you're not invited to join '${roomName}'`);
|
|
||||||
} else {
|
|
||||||
exc = err;
|
|
||||||
}
|
|
||||||
this._sendError = exc;
|
|
||||||
this._timelineError = null;
|
|
||||||
this.emitChange("error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _processCommand (message) {
|
|
||||||
let msgtype;
|
|
||||||
const [commandName, ...args] = message.substring(1).split(" ");
|
|
||||||
switch (commandName) {
|
|
||||||
case "me":
|
|
||||||
message = args.join(" ");
|
|
||||||
msgtype = "m.emote";
|
|
||||||
break;
|
|
||||||
case "join":
|
|
||||||
if (args.length === 1) {
|
|
||||||
const roomName = args[0];
|
|
||||||
await this._processCommandJoin(roomName);
|
|
||||||
} else {
|
|
||||||
this._sendError = new Error("join syntax: /join <room-id>");
|
|
||||||
this._timelineError = null;
|
|
||||||
this.emitChange("error");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "shrug":
|
|
||||||
message = "¯\\_(ツ)_/¯ " + args.join(" ");
|
|
||||||
msgtype = "m.text";
|
|
||||||
break;
|
|
||||||
case "tableflip":
|
|
||||||
message = "(╯°□°)╯︵ ┻━┻ " + args.join(" ");
|
|
||||||
msgtype = "m.text";
|
|
||||||
break;
|
|
||||||
case "unflip":
|
|
||||||
message = "┬──┬ ノ( ゜-゜ノ) " + args.join(" ");
|
|
||||||
msgtype = "m.text";
|
|
||||||
break;
|
|
||||||
case "lenny":
|
|
||||||
message = "( ͡° ͜ʖ ͡°) " + args.join(" ");
|
|
||||||
msgtype = "m.text";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
this._sendError = new Error(`no command name "${commandName}". To send the message instead of executing, please type "/${message}"`);
|
|
||||||
this._timelineError = null;
|
|
||||||
this.emitChange("error");
|
|
||||||
message = undefined;
|
|
||||||
}
|
|
||||||
return {type: msgtype, message: message};
|
|
||||||
}
|
|
||||||
|
|
||||||
async _sendMessage(message, replyingTo) {
|
async _sendMessage(message, replyingTo) {
|
||||||
if (!this._room.isArchived && message) {
|
if (!this._room.isArchived && message) {
|
||||||
let messinfo = {type : "m.text", message : message};
|
|
||||||
if (message.startsWith("//")) {
|
|
||||||
messinfo.message = message.substring(1).trim();
|
|
||||||
} else if (message.startsWith("/")) {
|
|
||||||
messinfo = await this._processCommand(message);
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const msgtype = messinfo.type;
|
let msgtype = "m.text";
|
||||||
const message = messinfo.message;
|
if (message.startsWith("/me ")) {
|
||||||
if (msgtype && message) {
|
message = message.substr(4).trim();
|
||||||
if (replyingTo) {
|
msgtype = "m.emote";
|
||||||
await replyingTo.reply(msgtype, message);
|
}
|
||||||
} else {
|
if (replyingTo) {
|
||||||
await this._room.sendEvent("m.room.message", {msgtype, body: message});
|
await replyingTo.reply(msgtype, message);
|
||||||
}
|
} else {
|
||||||
|
await this._room.sendEvent("m.room.message", {msgtype, body: message});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`);
|
console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`);
|
||||||
|
@ -413,6 +345,10 @@ export class RoomViewModel extends ViewModel {
|
||||||
return this._composerVM;
|
return this._composerVM;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get callViewModel() {
|
||||||
|
return this._callViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
openDetailsPanel() {
|
openDetailsPanel() {
|
||||||
let path = this.navigation.path.until("room");
|
let path = this.navigation.path.until("room");
|
||||||
path = path.with(this.navigation.segment("right-panel", true));
|
path = path.with(this.navigation.segment("right-panel", true));
|
||||||
|
@ -426,9 +362,17 @@ export class RoomViewModel extends ViewModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dismissError() {
|
async startCall() {
|
||||||
this._sendError = null;
|
try {
|
||||||
this.emitChange("error");
|
const session = this.getOption("session");
|
||||||
|
const stream = await this.platform.mediaDevices.getMediaTracks(false, true);
|
||||||
|
const localMedia = new LocalMedia().withUserMedia(stream);
|
||||||
|
// this will set the callViewModel above as a call will be added to callHandler.calls
|
||||||
|
const call = await session.callHandler.createCall(this._room.id, "m.video", "A call " + Math.round(this.platform.random() * 100));
|
||||||
|
await call.join(localMedia);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err.stack);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -463,16 +407,6 @@ class ArchivedViewModel extends ViewModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
get kind() {
|
get kind() {
|
||||||
return "disabled";
|
return "archived";
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class LowerPowerLevelViewModel extends ViewModel {
|
|
||||||
get description() {
|
|
||||||
return this.i18n`You do not have the powerlevel necessary to send messages`;
|
|
||||||
}
|
|
||||||
|
|
||||||
get kind() {
|
|
||||||
return "disabled";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
181
src/domain/session/room/call/CallViewModel.ts
Normal file
181
src/domain/session/room/call/CallViewModel.ts
Normal file
|
@ -0,0 +1,181 @@
|
||||||
|
/*
|
||||||
|
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 {AvatarSource} from "../../../AvatarSource";
|
||||||
|
import {ViewModel, Options as BaseOptions} from "../../../ViewModel";
|
||||||
|
import {getStreamVideoTrack, getStreamAudioTrack} from "../../../../matrix/calls/common";
|
||||||
|
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../../avatar";
|
||||||
|
import {EventObservableValue} from "../../../../observable/value/EventObservableValue";
|
||||||
|
import {ObservableValueMap} from "../../../../observable/map/ObservableValueMap";
|
||||||
|
import type {GroupCall} from "../../../../matrix/calls/group/GroupCall";
|
||||||
|
import type {Member} from "../../../../matrix/calls/group/Member";
|
||||||
|
import type {BaseObservableList} from "../../../../observable/list/BaseObservableList";
|
||||||
|
import type {Stream} from "../../../../platform/types/MediaDevices";
|
||||||
|
import type {MediaRepository} from "../../../../matrix/net/MediaRepository";
|
||||||
|
|
||||||
|
export type Options = BaseOptions & {
|
||||||
|
call: GroupCall,
|
||||||
|
mediaRepository: MediaRepository
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CallViewModel extends ViewModel<Options> {
|
||||||
|
public readonly memberViewModels: BaseObservableList<IStreamViewModel>;
|
||||||
|
|
||||||
|
constructor(options: Options) {
|
||||||
|
super(options);
|
||||||
|
const ownMemberViewModelMap = new ObservableValueMap("self", new EventObservableValue(this.call, "change"))
|
||||||
|
.mapValues(call => new OwnMemberViewModel(this.childOptions({call: this.call, mediaRepository: this.getOption("mediaRepository")})), () => {});
|
||||||
|
this.memberViewModels = this.call.members
|
||||||
|
.filterValues(member => member.isConnected)
|
||||||
|
.mapValues(member => new CallMemberViewModel(this.childOptions({member, mediaRepository: this.getOption("mediaRepository")})))
|
||||||
|
.join(ownMemberViewModelMap)
|
||||||
|
.sortValues((a, b) => a.compare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
private get call(): GroupCall {
|
||||||
|
return this.getOption("call");
|
||||||
|
}
|
||||||
|
|
||||||
|
get name(): string {
|
||||||
|
return this.call.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get id(): string {
|
||||||
|
return this.call.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get stream(): Stream | undefined {
|
||||||
|
return this.call.localMedia?.userMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
leave() {
|
||||||
|
if (this.call.hasJoined) {
|
||||||
|
this.call.leave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleVideo() {
|
||||||
|
if (this.call.muteSettings) {
|
||||||
|
this.call.setMuted(this.call.muteSettings.toggleCamera());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type OwnMemberOptions = BaseOptions & {
|
||||||
|
call: GroupCall,
|
||||||
|
mediaRepository: MediaRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
class OwnMemberViewModel extends ViewModel<OwnMemberOptions> implements IStreamViewModel {
|
||||||
|
get stream(): Stream | undefined {
|
||||||
|
return this.call.localMedia?.userMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get call(): GroupCall {
|
||||||
|
return this.getOption("call");
|
||||||
|
}
|
||||||
|
|
||||||
|
get isCameraMuted(): boolean {
|
||||||
|
return isMuted(this.call.muteSettings?.camera, !!getStreamVideoTrack(this.stream));
|
||||||
|
}
|
||||||
|
|
||||||
|
get isMicrophoneMuted(): boolean {
|
||||||
|
return isMuted(this.call.muteSettings?.microphone, !!getStreamAudioTrack(this.stream));
|
||||||
|
}
|
||||||
|
|
||||||
|
get avatarLetter(): string {
|
||||||
|
return "I";
|
||||||
|
}
|
||||||
|
|
||||||
|
get avatarColorNumber(): number {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
avatarUrl(size: number): string | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
get avatarTitle(): string {
|
||||||
|
return "Me";
|
||||||
|
}
|
||||||
|
|
||||||
|
compare(other: OwnMemberViewModel | CallMemberViewModel): number {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemberOptions = BaseOptions & {member: Member, mediaRepository: MediaRepository};
|
||||||
|
|
||||||
|
export class CallMemberViewModel extends ViewModel<MemberOptions> implements IStreamViewModel {
|
||||||
|
get stream(): Stream | undefined {
|
||||||
|
return this.member.remoteMedia?.userMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get member(): Member {
|
||||||
|
return this.getOption("member");
|
||||||
|
}
|
||||||
|
|
||||||
|
get isCameraMuted(): boolean {
|
||||||
|
return isMuted(this.member.remoteMuteSettings?.camera, !!getStreamVideoTrack(this.stream));
|
||||||
|
}
|
||||||
|
|
||||||
|
get isMicrophoneMuted(): boolean {
|
||||||
|
return isMuted(this.member.remoteMuteSettings?.microphone, !!getStreamAudioTrack(this.stream));
|
||||||
|
}
|
||||||
|
|
||||||
|
get avatarLetter(): string {
|
||||||
|
return avatarInitials(this.member.member.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
get avatarColorNumber(): number {
|
||||||
|
return getIdentifierColorNumber(this.member.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
avatarUrl(size: number): string | undefined {
|
||||||
|
const {avatarUrl} = this.member.member;
|
||||||
|
return getAvatarHttpUrl(avatarUrl, size, this.platform, this.options.mediaRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
get avatarTitle(): string {
|
||||||
|
return this.member.member.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
compare(other: OwnMemberViewModel | CallMemberViewModel): number {
|
||||||
|
if (other instanceof OwnMemberViewModel) {
|
||||||
|
return -other.compare(this);
|
||||||
|
}
|
||||||
|
const myUserId = this.member.member.userId;
|
||||||
|
const otherUserId = other.member.member.userId;
|
||||||
|
if(myUserId === otherUserId) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return myUserId < otherUserId ? -1 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IStreamViewModel extends AvatarSource, ViewModel {
|
||||||
|
get stream(): Stream | undefined;
|
||||||
|
get isCameraMuted(): boolean;
|
||||||
|
get isMicrophoneMuted(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMuted(muted: boolean | undefined, hasTrack: boolean) {
|
||||||
|
if (muted) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return !hasTrack;
|
||||||
|
}
|
||||||
|
}
|
128
src/domain/session/room/call/PrepareCallViewModel.ts
Normal file
128
src/domain/session/room/call/PrepareCallViewModel.ts
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
/*
|
||||||
|
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 {AvatarSource} from "../../../AvatarSource";
|
||||||
|
import {ViewModel, Options as BaseOptions} from "../../../ViewModel";
|
||||||
|
import {getStreamVideoTrack, getStreamAudioTrack} from "../../../../matrix/calls/common";
|
||||||
|
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../../avatar";
|
||||||
|
import {EventObservableValue} from "../../../../observable/value/EventObservableValue";
|
||||||
|
import {ObservableValueMap} from "../../../../observable/map/ObservableValueMap";
|
||||||
|
import type {GroupCall} from "../../../../matrix/calls/group/GroupCall";
|
||||||
|
import type {Member} from "../../../../matrix/calls/group/Member";
|
||||||
|
import type {BaseObservableList} from "../../../../observable/list/BaseObservableList";
|
||||||
|
import type {BaseObservableMap} from "../../../../observable/map/BaseObservableMap";
|
||||||
|
import type {Stream, VolumeMeasurer, MediaDevices} from "../../../../platform/types/MediaDevices";
|
||||||
|
import type {MediaRepository} from "../../../../matrix/net/MediaRepository";
|
||||||
|
import type {Options as CallViewModelOptions} from "./CallViewModel";
|
||||||
|
|
||||||
|
type Options = Omit<CallViewModelOptions, "call"> & {
|
||||||
|
call?: GroupCall
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PrepareCallViewModel extends ViewModel<Options> {
|
||||||
|
private _availableVideoDevices?: BaseObservableList<VideoDeviceViewModel>;
|
||||||
|
private _availableAudioDevices?: BaseObservableList<AudioDeviceViewModel>;
|
||||||
|
private _audioDevice?: AudioDeviceViewModel;
|
||||||
|
private _videoDevice?: VideoDeviceViewModel;
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
const devices = await (this.platform.mediaDevices as MediaDevices).observeDevices();
|
||||||
|
const sortByNameAndId = (a: BaseDeviceViewModel, b: BaseDeviceViewModel) => {
|
||||||
|
const labelCmp = a.label.localeCompare(b.label);
|
||||||
|
if (labelCmp === 0) {
|
||||||
|
return a.deviceId.localeCompare(b.deviceId);
|
||||||
|
} else {
|
||||||
|
return labelCmp;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._availableAudioDevices = devices
|
||||||
|
.filterValues(v => v.kind === "videoinput")
|
||||||
|
.mapValues((device, emitChange) => {
|
||||||
|
const vm = new VideoDeviceViewModel(this.childOptions({device, emitChange}));
|
||||||
|
vm.init();
|
||||||
|
return vm;
|
||||||
|
}).sortValues(sortByNameAndId);
|
||||||
|
this._availableAudioDevices = devices
|
||||||
|
.filterValues(v => v.kind === "audioinput")
|
||||||
|
.mapValues((device, emitChange) => {
|
||||||
|
const vm = new AudioDeviceViewModel(this.childOptions({device, emitChange}));
|
||||||
|
vm.init();
|
||||||
|
return vm;
|
||||||
|
}).sortValues(sortByNameAndId);
|
||||||
|
}
|
||||||
|
|
||||||
|
get availableVideoDevices(): BaseObservableList<VideoDeviceViewModel> | undefined {
|
||||||
|
return this._availableVideoDevices;
|
||||||
|
}
|
||||||
|
|
||||||
|
get availableAudioDevices(): BaseObservableList<AudioDeviceViewModel> | undefined {
|
||||||
|
return this._availableAudioDevices;
|
||||||
|
}
|
||||||
|
|
||||||
|
get videoDevice() : DeviceViewModel | undefined {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
setVideoDeviceId(deviceId: string) {
|
||||||
|
this.videoDevice = this._availableVideoDevices.get(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
get audioDevice() : DeviceViewModel | undefined {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceOptions = BaseOptions & {
|
||||||
|
device: MediaDeviceInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaseDeviceViewModel extends ViewModel<DeviceOptions> {
|
||||||
|
get label(): string { return this.options.device.label; }
|
||||||
|
get deviceId(): string { return this.options.device.deviceId; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class VideoDeviceViewModel extends BaseDeviceViewModel {
|
||||||
|
private _stream: Stream;
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
try {
|
||||||
|
this._stream = await (this.platform.mediaDevices as MediaDevices).getMediaTracks(false, {deviceId: this.deviceId});
|
||||||
|
this.track(() => getStreamVideoTrack(this._stream)?.stop());
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
get stream(): Stream {
|
||||||
|
return this._stream;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AudioDeviceViewModel extends BaseDeviceViewModel {
|
||||||
|
private volumeMeasurer?: VolumeMeasurer;
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
try {
|
||||||
|
const stream = await (this.platform.mediaDevices as MediaDevices).getMediaTracks({deviceId: this.deviceId}, false);
|
||||||
|
this.track(() => getStreamAudioTrack(stream)?.stop());
|
||||||
|
this.volumeMeasurer = this.track((this.platform.mediaDevices as MediaDevices).createVolumeMeasurer(stream, () => {
|
||||||
|
this.emitChange("volume");
|
||||||
|
}));
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
get volume(): number {
|
||||||
|
return this.volumeMeasurer?.volume ?? 0;
|
||||||
|
}
|
||||||
|
}
|
|
@ -189,7 +189,7 @@ import {HomeServer as MockHomeServer} from "../../../../mocks/HomeServer.js";
|
||||||
// other imports
|
// other imports
|
||||||
import {BaseMessageTile} from "./tiles/BaseMessageTile.js";
|
import {BaseMessageTile} from "./tiles/BaseMessageTile.js";
|
||||||
import {MappedList} from "../../../../observable/list/MappedList";
|
import {MappedList} from "../../../../observable/list/MappedList";
|
||||||
import {ObservableValue} from "../../../../observable/ObservableValue";
|
import {ObservableValue} from "../../../../observable/value/ObservableValue";
|
||||||
import {PowerLevels} from "../../../../matrix/room/PowerLevels.js";
|
import {PowerLevels} from "../../../../matrix/room/PowerLevels.js";
|
||||||
|
|
||||||
export function tests() {
|
export function tests() {
|
||||||
|
|
|
@ -27,29 +27,6 @@ export class BaseMediaTile extends BaseMessageTile {
|
||||||
this._decryptedFile = null;
|
this._decryptedFile = null;
|
||||||
this._isVisible = false;
|
this._isVisible = false;
|
||||||
this._error = null;
|
this._error = null;
|
||||||
this._downloading = false;
|
|
||||||
this._downloadError = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async downloadMedia() {
|
|
||||||
if (this._downloading || this.isPending) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const content = this._getContent();
|
|
||||||
const filename = content.body;
|
|
||||||
this._downloading = true;
|
|
||||||
this.emitChange("status");
|
|
||||||
let blob;
|
|
||||||
try {
|
|
||||||
blob = await this._mediaRepository.downloadAttachment(content);
|
|
||||||
this.platform.saveFileAs(blob, filename);
|
|
||||||
} catch (err) {
|
|
||||||
this._downloadError = err;
|
|
||||||
} finally {
|
|
||||||
blob?.dispose();
|
|
||||||
this._downloading = false;
|
|
||||||
}
|
|
||||||
this.emitChange("status");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get isUploading() {
|
get isUploading() {
|
||||||
|
@ -61,7 +38,7 @@ export class BaseMediaTile extends BaseMessageTile {
|
||||||
return pendingEvent && Math.round((pendingEvent.attachmentsSentBytes / pendingEvent.attachmentsTotalBytes) * 100);
|
return pendingEvent && Math.round((pendingEvent.attachmentsSentBytes / pendingEvent.attachmentsTotalBytes) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
get status() {
|
get sendStatus() {
|
||||||
const {pendingEvent} = this._entry;
|
const {pendingEvent} = this._entry;
|
||||||
switch (pendingEvent?.status) {
|
switch (pendingEvent?.status) {
|
||||||
case SendStatus.Waiting:
|
case SendStatus.Waiting:
|
||||||
|
@ -76,12 +53,6 @@ export class BaseMediaTile extends BaseMessageTile {
|
||||||
case SendStatus.Error:
|
case SendStatus.Error:
|
||||||
return this.i18n`Error: ${pendingEvent.error.message}`;
|
return this.i18n`Error: ${pendingEvent.error.message}`;
|
||||||
default:
|
default:
|
||||||
if (this._downloadError) {
|
|
||||||
return `Download failed`;
|
|
||||||
}
|
|
||||||
if (this._downloading) {
|
|
||||||
return this.i18n`Downloading…`;
|
|
||||||
}
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,14 +49,6 @@ export class BaseMessageTile extends SimpleTile {
|
||||||
return `https://matrix.to/#/${encodeURIComponent(this.sender)}`;
|
return `https://matrix.to/#/${encodeURIComponent(this.sender)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
get displayName() {
|
|
||||||
return this._entry.displayName || this.sender;
|
|
||||||
}
|
|
||||||
|
|
||||||
get sender() {
|
|
||||||
return this._entry.sender;
|
|
||||||
}
|
|
||||||
|
|
||||||
get memberPanelLink() {
|
get memberPanelLink() {
|
||||||
return `${this.urlCreator.urlUntilSegment("room")}/member/${this.sender}`;
|
return `${this.urlCreator.urlUntilSegment("room")}/member/${this.sender}`;
|
||||||
}
|
}
|
||||||
|
|
94
src/domain/session/room/timeline/tiles/CallTile.js
Normal file
94
src/domain/session/room/timeline/tiles/CallTile.js
Normal 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 {SimpleTile} from "./SimpleTile.js";
|
||||||
|
import {LocalMedia} from "../../../../../matrix/calls/LocalMedia";
|
||||||
|
|
||||||
|
// TODO: timeline entries for state events with the same state key and type
|
||||||
|
// should also update previous entries in the timeline, so we can update the name of the call, whether it is terminated, etc ...
|
||||||
|
|
||||||
|
// alternatively, we could just subscribe to the GroupCall and spontanously emit an update when it updates
|
||||||
|
|
||||||
|
export class CallTile extends SimpleTile {
|
||||||
|
constructor(entry, options) {
|
||||||
|
super(entry, options);
|
||||||
|
const calls = this.getOption("session").callHandler.calls;
|
||||||
|
this._call = calls.get(this._entry.stateKey);
|
||||||
|
this._callSubscription = undefined;
|
||||||
|
if (this._call) {
|
||||||
|
this._callSubscription = this._call.disposableOn("change", () => {
|
||||||
|
// unsubscribe when terminated
|
||||||
|
if (this._call.isTerminated) {
|
||||||
|
this._callSubscription = this._callSubscription();
|
||||||
|
this._call = undefined;
|
||||||
|
}
|
||||||
|
this.emitChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get confId() {
|
||||||
|
return this._entry.stateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
get shape() {
|
||||||
|
return "call";
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return this._entry.content["m.name"];
|
||||||
|
}
|
||||||
|
|
||||||
|
get canJoin() {
|
||||||
|
return this._call && !this._call.hasJoined;
|
||||||
|
}
|
||||||
|
|
||||||
|
get canLeave() {
|
||||||
|
return this._call && this._call.hasJoined;
|
||||||
|
}
|
||||||
|
|
||||||
|
get label() {
|
||||||
|
if (this._call) {
|
||||||
|
if (this._call.hasJoined) {
|
||||||
|
return `Ongoing call (${this.name}, ${this.confId})`;
|
||||||
|
} else {
|
||||||
|
return `${this.displayName} started a call (${this.name}, ${this.confId})`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return `Call finished, started by ${this.displayName} (${this.name}, ${this.confId})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async join() {
|
||||||
|
if (this.canJoin) {
|
||||||
|
const stream = await this.platform.mediaDevices.getMediaTracks(false, true);
|
||||||
|
const localMedia = new LocalMedia().withUserMedia(stream);
|
||||||
|
await this._call.join(localMedia);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async leave() {
|
||||||
|
if (this.canLeave) {
|
||||||
|
this._call.leave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this._callSubscription) {
|
||||||
|
this._callSubscription = this._callSubscription();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -22,7 +22,6 @@ export class SimpleTile extends ViewModel {
|
||||||
constructor(entry, options) {
|
constructor(entry, options) {
|
||||||
super(options);
|
super(options);
|
||||||
this._entry = 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 ... ?
|
||||||
|
@ -68,20 +67,16 @@ export class SimpleTile extends ViewModel {
|
||||||
|
|
||||||
// TilesCollection contract below
|
// TilesCollection contract below
|
||||||
setUpdateEmit(emitUpdate) {
|
setUpdateEmit(emitUpdate) {
|
||||||
this._emitUpdate = emitUpdate;
|
this.updateOptions({emitChange: paramName => {
|
||||||
}
|
|
||||||
|
|
||||||
/** 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.
|
||||||
this._emitUpdate(this, changedProps);
|
if (emitUpdate) {
|
||||||
}
|
emitUpdate(this, paramName);
|
||||||
super.emitChange(changedProps);
|
}
|
||||||
|
}});
|
||||||
}
|
}
|
||||||
|
|
||||||
get upperEntry() {
|
get upperEntry() {
|
||||||
|
@ -159,4 +154,12 @@ export class SimpleTile extends ViewModel {
|
||||||
get _ownMember() {
|
get _ownMember() {
|
||||||
return this._options.timeline.me;
|
return this._options.timeline.me;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get displayName() {
|
||||||
|
return this._entry.displayName || this.sender;
|
||||||
|
}
|
||||||
|
|
||||||
|
get sender() {
|
||||||
|
return this._entry.sender;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,9 +26,11 @@ import {RoomMemberTile} from "./RoomMemberTile.js";
|
||||||
import {EncryptedEventTile} from "./EncryptedEventTile.js";
|
import {EncryptedEventTile} from "./EncryptedEventTile.js";
|
||||||
import {EncryptionEnabledTile} from "./EncryptionEnabledTile.js";
|
import {EncryptionEnabledTile} from "./EncryptionEnabledTile.js";
|
||||||
import {MissingAttachmentTile} from "./MissingAttachmentTile.js";
|
import {MissingAttachmentTile} from "./MissingAttachmentTile.js";
|
||||||
|
import {CallTile} from "./CallTile.js";
|
||||||
|
|
||||||
import type {SimpleTile} from "./SimpleTile.js";
|
import type {SimpleTile} from "./SimpleTile.js";
|
||||||
import type {Room} from "../../../../../matrix/room/Room";
|
import type {Room} from "../../../../../matrix/room/Room";
|
||||||
|
import type {Session} from "../../../../../matrix/Session";
|
||||||
import type {Timeline} from "../../../../../matrix/room/timeline/Timeline";
|
import type {Timeline} from "../../../../../matrix/room/timeline/Timeline";
|
||||||
import type {FragmentBoundaryEntry} from "../../../../../matrix/room/timeline/entries/FragmentBoundaryEntry";
|
import type {FragmentBoundaryEntry} from "../../../../../matrix/room/timeline/entries/FragmentBoundaryEntry";
|
||||||
import type {EventEntry} from "../../../../../matrix/room/timeline/entries/EventEntry";
|
import type {EventEntry} from "../../../../../matrix/room/timeline/entries/EventEntry";
|
||||||
|
@ -38,6 +40,7 @@ import type {Options as ViewModelOptions} from "../../../../ViewModel";
|
||||||
export type TimelineEntry = FragmentBoundaryEntry | EventEntry | PendingEventEntry;
|
export type TimelineEntry = FragmentBoundaryEntry | EventEntry | PendingEventEntry;
|
||||||
export type TileClassForEntryFn = (entry: TimelineEntry) => TileConstructor | undefined;
|
export type TileClassForEntryFn = (entry: TimelineEntry) => TileConstructor | undefined;
|
||||||
export type Options = ViewModelOptions & {
|
export type Options = ViewModelOptions & {
|
||||||
|
session: Session,
|
||||||
room: Room,
|
room: Room,
|
||||||
timeline: Timeline
|
timeline: Timeline
|
||||||
tileClassForEntry: TileClassForEntryFn;
|
tileClassForEntry: TileClassForEntryFn;
|
||||||
|
@ -86,6 +89,14 @@ export function tileClassForEntry(entry: TimelineEntry): TileConstructor | undef
|
||||||
return EncryptedEventTile;
|
return EncryptedEventTile;
|
||||||
case "m.room.encryption":
|
case "m.room.encryption":
|
||||||
return EncryptionEnabledTile;
|
return EncryptionEnabledTile;
|
||||||
|
case "org.matrix.msc3401.call": {
|
||||||
|
// if prevContent is present, it's an update to a call event, which we don't render
|
||||||
|
// as the original event is updated through the call object which receive state event updates
|
||||||
|
if (entry.stateKey && !entry.prevContent) {
|
||||||
|
return CallTile;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
// unknown type not rendered
|
// unknown type not rendered
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
||||||
import {ViewModel} from "../../ViewModel";
|
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";
|
||||||
|
import {FlatMapObservableValue} from "../../../observable/value/FlatMapObservableValue";
|
||||||
|
|
||||||
export const Status = createEnum("Enabled", "SetupKey", "SetupPhrase", "Pending", "NewVersionAvailable");
|
export const Status = createEnum("Enabled", "SetupKey", "SetupPhrase", "Pending", "NewVersionAvailable");
|
||||||
export const BackupWriteStatus = createEnum("Writing", "Stopped", "Done", "Pending");
|
export const BackupWriteStatus = createEnum("Writing", "Stopped", "Done", "Pending");
|
||||||
|
@ -29,8 +30,8 @@ export class KeyBackupViewModel extends ViewModel {
|
||||||
this._isBusy = false;
|
this._isBusy = false;
|
||||||
this._dehydratedDeviceId = undefined;
|
this._dehydratedDeviceId = undefined;
|
||||||
this._status = undefined;
|
this._status = undefined;
|
||||||
this._backupOperation = this._session.keyBackup.flatMap(keyBackup => keyBackup.operationInProgress);
|
this._backupOperation = new FlatMapObservableValue(this._session.keyBackup, keyBackup => keyBackup.operationInProgress);
|
||||||
this._progress = this._backupOperation.flatMap(op => op.progress);
|
this._progress = new FlatMapObservableValue(this._backupOperation, op => op.progress);
|
||||||
this.track(this._backupOperation.subscribe(() => {
|
this.track(this._backupOperation.subscribe(() => {
|
||||||
// see if needsNewKey might be set
|
// see if needsNewKey might be set
|
||||||
this._reevaluateStatus();
|
this._reevaluateStatus();
|
||||||
|
|
|
@ -16,7 +16,6 @@ limitations under the License.
|
||||||
|
|
||||||
import {ViewModel} from "../../ViewModel";
|
import {ViewModel} from "../../ViewModel";
|
||||||
import {KeyBackupViewModel} from "./KeyBackupViewModel.js";
|
import {KeyBackupViewModel} from "./KeyBackupViewModel.js";
|
||||||
import {submitLogsToRageshakeServer} from "../../../domain/rageshake";
|
|
||||||
|
|
||||||
class PushNotificationStatus {
|
class PushNotificationStatus {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
@ -51,8 +50,6 @@ 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;
|
|
||||||
this._logsFeedbackMessage = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get _session() {
|
get _session() {
|
||||||
|
@ -79,9 +76,6 @@ 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("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -133,14 +127,6 @@ export class SettingsViewModel extends ViewModel {
|
||||||
return this._formatBytes(this._estimate?.usage);
|
return this._formatBytes(this._estimate?.usage);
|
||||||
}
|
}
|
||||||
|
|
||||||
get themeMapping() {
|
|
||||||
return this.platform.themeLoader.themeMapping;
|
|
||||||
}
|
|
||||||
|
|
||||||
get activeTheme() {
|
|
||||||
return this._activeTheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
_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";
|
||||||
|
@ -150,55 +136,11 @@ export class SettingsViewModel extends ViewModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
async exportLogs() {
|
async exportLogs() {
|
||||||
const logExport = await this.logger.export();
|
const persister = this.logger.reporters.find(r => typeof r.export === "function");
|
||||||
|
const logExport = await persister.export();
|
||||||
this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`);
|
this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
get canSendLogsToServer() {
|
|
||||||
return !!this.platform.config.bugReportEndpointUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
get logsServer() {
|
|
||||||
const {bugReportEndpointUrl} = this.platform.config;
|
|
||||||
try {
|
|
||||||
if (bugReportEndpointUrl) {
|
|
||||||
return new URL(bugReportEndpointUrl).hostname;
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendLogsToServer() {
|
|
||||||
const {bugReportEndpointUrl} = this.platform.config;
|
|
||||||
if (bugReportEndpointUrl) {
|
|
||||||
this._logsFeedbackMessage = this.i18n`Sending logs…`;
|
|
||||||
this.emitChange();
|
|
||||||
try {
|
|
||||||
const logExport = await this.logger.export();
|
|
||||||
await submitLogsToRageshakeServer(
|
|
||||||
{
|
|
||||||
app: "hydrogen",
|
|
||||||
userAgent: this.platform.description,
|
|
||||||
version: DEFINE_VERSION,
|
|
||||||
text: `Submit logs from settings for user ${this._session.userId} on device ${this._session.deviceId}`,
|
|
||||||
},
|
|
||||||
logExport.asBlob(),
|
|
||||||
bugReportEndpointUrl,
|
|
||||||
this.platform.request
|
|
||||||
);
|
|
||||||
this._logsFeedbackMessage = this.i18n`Logs sent succesfully!`;
|
|
||||||
this.emitChange();
|
|
||||||
} catch (err) {
|
|
||||||
this._logsFeedbackMessage = err.message;
|
|
||||||
this.emitChange();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get logsFeedbackMessage() {
|
|
||||||
return this._logsFeedbackMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
async togglePushNotifications() {
|
async togglePushNotifications() {
|
||||||
this.pushNotifications.updating = true;
|
this.pushNotifications.updating = true;
|
||||||
this.pushNotifications.enabledOnServer = null;
|
this.pushNotifications.enabledOnServer = null;
|
||||||
|
@ -228,11 +170,5 @@ export class SettingsViewModel extends ViewModel {
|
||||||
this.emitChange("pushNotifications.serverError");
|
this.emitChange("pushNotifications.serverError");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
changeThemeOption(themeName, themeVariant) {
|
|
||||||
this.platform.themeLoader.setTheme(themeName, themeVariant);
|
|
||||||
// emit so that radio-buttons become displayed/hidden
|
|
||||||
this.emitChange("themeOption");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
14
src/lib.ts
14
src/lib.ts
|
@ -14,11 +14,14 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export {Logger} from "./logging/Logger";
|
||||||
|
export {IDBLogPersister} from "./logging/IDBLogPersister";
|
||||||
|
export {ConsoleReporter} from "./logging/ConsoleReporter";
|
||||||
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 {RoomStatus} from "./matrix/room/common";
|
||||||
// export main view & view models
|
// export main view & view models
|
||||||
export {createNavigation, createRouter} from "./domain/navigation/index";
|
export {createNavigation, createRouter} from "./domain/navigation/index.js";
|
||||||
export {RootViewModel} from "./domain/RootViewModel.js";
|
export {RootViewModel} from "./domain/RootViewModel.js";
|
||||||
export {RootView} from "./platform/web/ui/RootView.js";
|
export {RootView} from "./platform/web/ui/RootView.js";
|
||||||
export {SessionViewModel} from "./domain/session/SessionViewModel.js";
|
export {SessionViewModel} from "./domain/session/SessionViewModel.js";
|
||||||
|
@ -71,6 +74,7 @@ export {AvatarView} from "./platform/web/ui/AvatarView.js";
|
||||||
export {RoomType} from "./matrix/room/common";
|
export {RoomType} from "./matrix/room/common";
|
||||||
export {EventEmitter} from "./utils/EventEmitter";
|
export {EventEmitter} from "./utils/EventEmitter";
|
||||||
export {Disposables} from "./utils/Disposables";
|
export {Disposables} from "./utils/Disposables";
|
||||||
|
export {LocalMedia} from "./matrix/calls/LocalMedia";
|
||||||
// these should eventually be moved to another library
|
// these should eventually be moved to another library
|
||||||
export {
|
export {
|
||||||
ObservableArray,
|
ObservableArray,
|
||||||
|
@ -80,8 +84,6 @@ export {
|
||||||
ConcatList,
|
ConcatList,
|
||||||
ObservableMap
|
ObservableMap
|
||||||
} from "./observable/index";
|
} from "./observable/index";
|
||||||
export {
|
export {BaseObservableValue} from "./observable/value/BaseObservableValue";
|
||||||
BaseObservableValue,
|
export {ObservableValue} from "./observable/value/ObservableValue";
|
||||||
ObservableValue,
|
export {RetainedObservableValue} from "./observable/value/RetainedObservableValue";
|
||||||
RetainedObservableValue
|
|
||||||
} from "./observable/ObservableValue";
|
|
||||||
|
|
|
@ -13,17 +13,28 @@ 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 {BaseLogger} from "./BaseLogger";
|
|
||||||
import {LogItem} from "./LogItem";
|
|
||||||
import type {ILogItem, LogItemValues, ILogExport} from "./types";
|
|
||||||
|
|
||||||
export class ConsoleLogger extends BaseLogger {
|
import type {ILogger, ILogItem, LogItemValues, ILogReporter} from "./types";
|
||||||
_persistItem(item: LogItem): void {
|
import type {LogItem} from "./LogItem";
|
||||||
printToConsole(item);
|
|
||||||
|
export class ConsoleReporter implements ILogReporter {
|
||||||
|
private logger?: ILogger;
|
||||||
|
|
||||||
|
reportItem(item: ILogItem): void {
|
||||||
|
printToConsole(item as LogItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
async export(): Promise<ILogExport | undefined> {
|
setLogger(logger: ILogger) {
|
||||||
return undefined;
|
this.logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
printOpenItems(): void {
|
||||||
|
if (!this.logger) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const item of this.logger.getOpenRootItems()) {
|
||||||
|
this.reportItem(item);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -39,7 +50,7 @@ function filterValues(values: LogItemValues): LogItemValues | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
function printToConsole(item: LogItem): void {
|
function printToConsole(item: LogItem): void {
|
||||||
const label = `${itemCaption(item)} (${item.duration}ms)`;
|
const label = `${itemCaption(item)} (@${item.start}ms, duration: ${item.duration}ms)`;
|
||||||
const filteredValues = filterValues(item.values);
|
const filteredValues = filterValues(item.values);
|
||||||
const shouldGroup = item.children || filteredValues;
|
const shouldGroup = item.children || filteredValues;
|
||||||
if (shouldGroup) {
|
if (shouldGroup) {
|
||||||
|
@ -78,6 +89,8 @@ function itemCaption(item: ILogItem): string {
|
||||||
return `${item.values.l} ${item.values.id}`;
|
return `${item.values.l} ${item.values.id}`;
|
||||||
} else if (item.values.l && typeof item.values.status !== "undefined") {
|
} else if (item.values.l && typeof item.values.status !== "undefined") {
|
||||||
return `${item.values.l} (${item.values.status})`;
|
return `${item.values.l} (${item.values.status})`;
|
||||||
|
} else if (item.values.l && typeof item.values.type !== "undefined") {
|
||||||
|
return `${item.values.l} (${item.values.type})`;
|
||||||
} else if (item.values.l && item.error) {
|
} else if (item.values.l && item.error) {
|
||||||
return `${item.values.l} failed`;
|
return `${item.values.l} failed`;
|
||||||
} else if (typeof item.values.ref !== "undefined") {
|
} else if (typeof item.values.ref !== "undefined") {
|
|
@ -22,36 +22,69 @@ import {
|
||||||
iterateCursor,
|
iterateCursor,
|
||||||
fetchResults,
|
fetchResults,
|
||||||
} from "../matrix/storage/idb/utils";
|
} from "../matrix/storage/idb/utils";
|
||||||
import {BaseLogger} from "./BaseLogger";
|
|
||||||
import type {Interval} from "../platform/web/dom/Clock";
|
import type {Interval} from "../platform/web/dom/Clock";
|
||||||
import type {Platform} from "../platform/web/Platform.js";
|
import type {Platform} from "../platform/web/Platform.js";
|
||||||
import type {BlobHandle} from "../platform/web/dom/BlobHandle.js";
|
import type {BlobHandle} from "../platform/web/dom/BlobHandle.js";
|
||||||
import type {ILogItem, ILogExport, ISerializedItem} from "./types";
|
import type {ILogItem, ILogger, ILogReporter, ISerializedItem} from "./types";
|
||||||
import type {LogFilter} from "./LogFilter";
|
import {LogFilter} from "./LogFilter";
|
||||||
|
|
||||||
type QueuedItem = {
|
type QueuedItem = {
|
||||||
json: string;
|
json: string;
|
||||||
id?: number;
|
id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IDBLogger extends BaseLogger {
|
type Options = {
|
||||||
private readonly _name: string;
|
name: string,
|
||||||
private readonly _limit: number;
|
flushInterval?: number,
|
||||||
|
limit?: number,
|
||||||
|
platform: Platform,
|
||||||
|
serializedTransformer?: (item: ISerializedItem) => ISerializedItem
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IDBLogPersister implements ILogReporter {
|
||||||
private readonly _flushInterval: Interval;
|
private readonly _flushInterval: Interval;
|
||||||
private _queuedItems: QueuedItem[];
|
private _queuedItems: QueuedItem[];
|
||||||
|
private readonly options: Options;
|
||||||
|
private logger?: ILogger;
|
||||||
|
|
||||||
constructor(options: {name: string, flushInterval?: number, limit?: number, platform: Platform, serializedTransformer?: (item: ISerializedItem) => ISerializedItem}) {
|
constructor(options: Options) {
|
||||||
super(options);
|
this.options = options;
|
||||||
const {name, flushInterval = 60 * 1000, limit = 3000} = options;
|
|
||||||
this._name = name;
|
|
||||||
this._limit = limit;
|
|
||||||
this._queuedItems = this._loadQueuedItems();
|
this._queuedItems = this._loadQueuedItems();
|
||||||
// TODO: also listen for unload just in case sync keeps on running after pagehide is fired?
|
// TODO: also listen for unload just in case sync keeps on running after pagehide is fired?
|
||||||
window.addEventListener("pagehide", this, false);
|
window.addEventListener("pagehide", this, false);
|
||||||
this._flushInterval = this._platform.clock.createInterval(() => this._tryFlush(), flushInterval);
|
this._flushInterval = this.options.platform.clock.createInterval(
|
||||||
|
() => this._tryFlush(),
|
||||||
|
this.options.flushInterval ?? 60 * 1000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogger(logger: ILogger): void {
|
||||||
|
this.logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
reportItem(logItem: ILogItem, filter: LogFilter, forced: boolean): void {
|
||||||
|
const queuedItem = this.prepareItemForQueue(logItem, filter, forced);
|
||||||
|
if (queuedItem) {
|
||||||
|
this._queuedItems.push(queuedItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async export(): Promise<IDBLogExport> {
|
||||||
|
const db = await this._openDB();
|
||||||
|
try {
|
||||||
|
const txn = db.transaction(["logs"], "readonly");
|
||||||
|
const logs = txn.objectStore("logs");
|
||||||
|
const storedItems: QueuedItem[] = await fetchResults(logs.openCursor(), () => false);
|
||||||
|
const openItems = this.getSerializedOpenItems();
|
||||||
|
const allItems = storedItems.concat(this._queuedItems).concat(openItems);
|
||||||
|
return new IDBLogExport(allItems, this, this.options.platform);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
db.close();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: move dispose to ILogger, listen to pagehide elsewhere and call dispose from there, which calls _finishAllAndFlush
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
window.removeEventListener("pagehide", this, false);
|
window.removeEventListener("pagehide", this, false);
|
||||||
this._flushInterval.dispose();
|
this._flushInterval.dispose();
|
||||||
|
@ -63,7 +96,7 @@ export class IDBLogger extends BaseLogger {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _tryFlush(): Promise<void> {
|
private async _tryFlush(): Promise<void> {
|
||||||
const db = await this._openDB();
|
const db = await this._openDB();
|
||||||
try {
|
try {
|
||||||
const txn = db.transaction(["logs"], "readwrite");
|
const txn = db.transaction(["logs"], "readwrite");
|
||||||
|
@ -73,9 +106,10 @@ export class IDBLogger extends BaseLogger {
|
||||||
logs.add(i);
|
logs.add(i);
|
||||||
}
|
}
|
||||||
const itemCount = await reqAsPromise(logs.count());
|
const itemCount = await reqAsPromise(logs.count());
|
||||||
if (itemCount > this._limit) {
|
const limit = this.options.limit ?? 3000;
|
||||||
|
if (itemCount > limit) {
|
||||||
// delete an extra 10% so we don't need to delete every time we flush
|
// delete an extra 10% so we don't need to delete every time we flush
|
||||||
let deleteAmount = (itemCount - this._limit) + Math.round(0.1 * this._limit);
|
let deleteAmount = (itemCount - limit) + Math.round(0.1 * limit);
|
||||||
await iterateCursor(logs.openCursor(), (_, __, cursor) => {
|
await iterateCursor(logs.openCursor(), (_, __, cursor) => {
|
||||||
cursor.delete();
|
cursor.delete();
|
||||||
deleteAmount -= 1;
|
deleteAmount -= 1;
|
||||||
|
@ -93,14 +127,16 @@ export class IDBLogger extends BaseLogger {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_finishAllAndFlush(): void {
|
private _finishAllAndFlush(): void {
|
||||||
this._finishOpenItems();
|
if (this.logger) {
|
||||||
this.log({l: "pagehide, closing logs", t: "navigation"});
|
this.logger.log({l: "pagehide, closing logs", t: "navigation"});
|
||||||
|
this.logger.forceFinish();
|
||||||
|
}
|
||||||
this._persistQueuedItems(this._queuedItems);
|
this._persistQueuedItems(this._queuedItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
_loadQueuedItems(): QueuedItem[] {
|
private _loadQueuedItems(): QueuedItem[] {
|
||||||
const key = `${this._name}_queuedItems`;
|
const key = `${this.options.name}_queuedItems`;
|
||||||
try {
|
try {
|
||||||
const json = window.localStorage.getItem(key);
|
const json = window.localStorage.getItem(key);
|
||||||
if (json) {
|
if (json) {
|
||||||
|
@ -113,44 +149,32 @@ export class IDBLogger extends BaseLogger {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
_openDB(): Promise<IDBDatabase> {
|
private _openDB(): Promise<IDBDatabase> {
|
||||||
return openDatabase(this._name, db => db.createObjectStore("logs", {keyPath: "id", autoIncrement: true}), 1);
|
return openDatabase(this.options.name, db => db.createObjectStore("logs", {keyPath: "id", autoIncrement: true}), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
_persistItem(logItem: ILogItem, filter: LogFilter, forced: boolean): void {
|
private prepareItemForQueue(logItem: ILogItem, filter: LogFilter, forced: boolean): QueuedItem | undefined {
|
||||||
const serializedItem = logItem.serialize(filter, undefined, forced);
|
let serializedItem = logItem.serialize(filter, undefined, forced);
|
||||||
if (serializedItem) {
|
if (serializedItem) {
|
||||||
const transformedSerializedItem = this._serializedTransformer(serializedItem);
|
if (this.options.serializedTransformer) {
|
||||||
this._queuedItems.push({
|
serializedItem = this.options.serializedTransformer(serializedItem);
|
||||||
json: JSON.stringify(transformedSerializedItem)
|
}
|
||||||
});
|
return {
|
||||||
|
json: JSON.stringify(serializedItem)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_persistQueuedItems(items: QueuedItem[]): void {
|
private _persistQueuedItems(items: QueuedItem[]): void {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(`${this._name}_queuedItems`, JSON.stringify(items));
|
window.localStorage.setItem(`${this.options.name}_queuedItems`, JSON.stringify(items));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Could not persist queued log items in localStorage, they will likely be lost", e);
|
console.error("Could not persist queued log items in localStorage, they will likely be lost", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async export(): Promise<ILogExport> {
|
/** @internal called by ILogExport.removeFromStore */
|
||||||
const db = await this._openDB();
|
async removeItems(items: QueuedItem[]): Promise<void> {
|
||||||
try {
|
|
||||||
const txn = db.transaction(["logs"], "readonly");
|
|
||||||
const logs = txn.objectStore("logs");
|
|
||||||
const storedItems: QueuedItem[] = await fetchResults(logs.openCursor(), () => false);
|
|
||||||
const allItems = storedItems.concat(this._queuedItems);
|
|
||||||
return new IDBLogExport(allItems, this, this._platform);
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
db.close();
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _removeItems(items: QueuedItem[]): Promise<void> {
|
|
||||||
const db = await this._openDB();
|
const db = await this._openDB();
|
||||||
try {
|
try {
|
||||||
const txn = db.transaction(["logs"], "readwrite");
|
const txn = db.transaction(["logs"], "readwrite");
|
||||||
|
@ -173,14 +197,29 @@ export class IDBLogger extends BaseLogger {
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getSerializedOpenItems(): QueuedItem[] {
|
||||||
|
const openItems: QueuedItem[] = [];
|
||||||
|
if (!this.logger) {
|
||||||
|
return openItems;
|
||||||
|
}
|
||||||
|
const filter = new LogFilter();
|
||||||
|
for(const item of this.logger!.getOpenRootItems()) {
|
||||||
|
const openItem = this.prepareItemForQueue(item, filter, false);
|
||||||
|
if (openItem) {
|
||||||
|
openItems.push(openItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return openItems;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class IDBLogExport implements ILogExport {
|
export class IDBLogExport {
|
||||||
private readonly _items: QueuedItem[];
|
private readonly _items: QueuedItem[];
|
||||||
private readonly _logger: IDBLogger;
|
private readonly _logger: IDBLogPersister;
|
||||||
private readonly _platform: Platform;
|
private readonly _platform: Platform;
|
||||||
|
|
||||||
constructor(items: QueuedItem[], logger: IDBLogger, platform: Platform) {
|
constructor(items: QueuedItem[], logger: IDBLogPersister, platform: Platform) {
|
||||||
this._items = items;
|
this._items = items;
|
||||||
this._logger = logger;
|
this._logger = logger;
|
||||||
this._platform = platform;
|
this._platform = platform;
|
||||||
|
@ -194,18 +233,23 @@ class IDBLogExport implements ILogExport {
|
||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
*/
|
*/
|
||||||
removeFromStore(): Promise<void> {
|
removeFromStore(): Promise<void> {
|
||||||
return this._logger._removeItems(this._items);
|
return this._logger.removeItems(this._items);
|
||||||
}
|
}
|
||||||
|
|
||||||
asBlob(): BlobHandle {
|
asBlob(): BlobHandle {
|
||||||
|
const json = this.toJSON();
|
||||||
|
const buffer: Uint8Array = this._platform.encoding.utf8.encode(json);
|
||||||
|
const blob: BlobHandle = this._platform.createBlob(buffer, "application/json");
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON(): string {
|
||||||
const log = {
|
const log = {
|
||||||
formatVersion: 1,
|
formatVersion: 1,
|
||||||
appVersion: this._platform.updateService?.version,
|
appVersion: this._platform.updateService?.version,
|
||||||
items: this._items.map(i => JSON.parse(i.json))
|
items: this._items.map(i => JSON.parse(i.json))
|
||||||
};
|
};
|
||||||
const json = JSON.stringify(log);
|
const json = JSON.stringify(log);
|
||||||
const buffer: Uint8Array = this._platform.encoding.utf8.encode(json);
|
return json;
|
||||||
const blob: BlobHandle = this._platform.createBlob(buffer, "application/json");
|
|
||||||
return blob;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -16,7 +16,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {LogLevel, LogFilter} from "./LogFilter";
|
import {LogLevel, LogFilter} from "./LogFilter";
|
||||||
import type {BaseLogger} from "./BaseLogger";
|
import type {Logger} from "./Logger";
|
||||||
import type {ISerializedItem, ILogItem, LogItemValues, LabelOrValues, FilterCreator, LogCallback} from "./types";
|
import type {ISerializedItem, ILogItem, LogItemValues, LabelOrValues, FilterCreator, LogCallback} from "./types";
|
||||||
|
|
||||||
export class LogItem implements ILogItem {
|
export class LogItem implements ILogItem {
|
||||||
|
@ -25,11 +25,11 @@ export class LogItem implements ILogItem {
|
||||||
public error?: Error;
|
public error?: Error;
|
||||||
public end?: number;
|
public end?: number;
|
||||||
private _values: LogItemValues;
|
private _values: LogItemValues;
|
||||||
private _logger: BaseLogger;
|
protected _logger: Logger;
|
||||||
private _filterCreator?: FilterCreator;
|
private _filterCreator?: FilterCreator;
|
||||||
private _children?: Array<LogItem>;
|
private _children?: Array<LogItem>;
|
||||||
|
|
||||||
constructor(labelOrValues: LabelOrValues, logLevel: LogLevel, logger: BaseLogger, filterCreator?: FilterCreator) {
|
constructor(labelOrValues: LabelOrValues, logLevel: LogLevel, logger: Logger, filterCreator?: FilterCreator) {
|
||||||
this._logger = logger;
|
this._logger = logger;
|
||||||
this.start = logger._now();
|
this.start = logger._now();
|
||||||
// (l)abel
|
// (l)abel
|
||||||
|
@ -38,7 +38,7 @@ export class LogItem implements ILogItem {
|
||||||
this._filterCreator = filterCreator;
|
this._filterCreator = filterCreator;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** start a new root log item and run it detached mode, see BaseLogger.runDetached */
|
/** start a new root log item and run it detached mode, see Logger.runDetached */
|
||||||
runDetached(labelOrValues: LabelOrValues, callback: LogCallback<unknown>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem {
|
runDetached(labelOrValues: LabelOrValues, callback: LogCallback<unknown>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem {
|
||||||
return this._logger.runDetached(labelOrValues, callback, logLevel, filterCreator);
|
return this._logger.runDetached(labelOrValues, callback, logLevel, filterCreator);
|
||||||
}
|
}
|
||||||
|
@ -221,6 +221,11 @@ export class LogItem implements ILogItem {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
forceFinish(): void {
|
||||||
|
this.finish();
|
||||||
|
}
|
||||||
|
|
||||||
// expose log level without needing import everywhere
|
// expose log level without needing import everywhere
|
||||||
get level(): typeof LogLevel {
|
get level(): typeof LogLevel {
|
||||||
return LogLevel;
|
return LogLevel;
|
||||||
|
@ -235,7 +240,7 @@ export class LogItem implements ILogItem {
|
||||||
|
|
||||||
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): LogItem {
|
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): LogItem {
|
||||||
if (this.end) {
|
if (this.end) {
|
||||||
console.trace("log item is finished, additional logs will likely not be recorded");
|
console.trace(`log item ${this.values.l} finished, additional log ${JSON.stringify(labelOrValues)} will likely not be recorded`);
|
||||||
}
|
}
|
||||||
if (!logLevel) {
|
if (!logLevel) {
|
||||||
logLevel = this.logLevel || LogLevel.Info;
|
logLevel = this.logLevel || LogLevel.Info;
|
||||||
|
@ -248,7 +253,7 @@ export class LogItem implements ILogItem {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
get logger(): BaseLogger {
|
get logger(): Logger {
|
||||||
return this._logger;
|
return this._logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,17 +17,17 @@ limitations under the License.
|
||||||
|
|
||||||
import {LogItem} from "./LogItem";
|
import {LogItem} from "./LogItem";
|
||||||
import {LogLevel, LogFilter} from "./LogFilter";
|
import {LogLevel, LogFilter} from "./LogFilter";
|
||||||
import type {ILogger, ILogExport, FilterCreator, LabelOrValues, LogCallback, ILogItem, ISerializedItem} from "./types";
|
import type {ILogger, ILogReporter, FilterCreator, LabelOrValues, LogCallback, ILogItem, ISerializedItem} from "./types";
|
||||||
import type {Platform} from "../platform/web/Platform.js";
|
import type {Platform} from "../platform/web/Platform.js";
|
||||||
|
|
||||||
export abstract class BaseLogger implements ILogger {
|
export class Logger implements ILogger {
|
||||||
protected _openItems: Set<LogItem> = new Set();
|
protected _openItems: Set<LogItem> = new Set();
|
||||||
protected _platform: Platform;
|
protected _platform: Platform;
|
||||||
protected _serializedTransformer: (item: ISerializedItem) => ISerializedItem;
|
protected _serializedTransformer: (item: ISerializedItem) => ISerializedItem;
|
||||||
|
public readonly reporters: ILogReporter[] = [];
|
||||||
|
|
||||||
constructor({platform, serializedTransformer = (item: ISerializedItem) => item}) {
|
constructor({platform}) {
|
||||||
this._platform = platform;
|
this._platform = platform;
|
||||||
this._serializedTransformer = serializedTransformer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log(labelOrValues: LabelOrValues, logLevel: LogLevel = LogLevel.Info): void {
|
log(labelOrValues: LabelOrValues, logLevel: LogLevel = LogLevel.Info): void {
|
||||||
|
@ -36,6 +36,15 @@ export abstract class BaseLogger implements ILogger {
|
||||||
this._persistItem(item, undefined, false);
|
this._persistItem(item, undefined, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Prefer `run()` or `log()` above this method; only use it if you have a long-running operation
|
||||||
|
* *without* a single call stack that should be logged into one sub-tree.
|
||||||
|
* You need to call `finish()` on the returned item or it will stay open until the app unloads. */
|
||||||
|
child(labelOrValues: LabelOrValues, logLevel: LogLevel = LogLevel.Info, filterCreator?: FilterCreator): ILogItem {
|
||||||
|
const item = new DeferredPersistRootLogItem(labelOrValues, logLevel, this, filterCreator);
|
||||||
|
this._openItems.add(item);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
/** if item is a log item, wrap the callback in a child of it, otherwise start a new root log item. */
|
/** if item is a log item, wrap the callback in a child of it, otherwise start a new root log item. */
|
||||||
wrapOrRun<T>(item: ILogItem | undefined, labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T {
|
wrapOrRun<T>(item: ILogItem | undefined, labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T {
|
||||||
if (item) {
|
if (item) {
|
||||||
|
@ -70,10 +79,10 @@ export abstract class BaseLogger implements ILogger {
|
||||||
return this._run(item, callback, logLevel, true, filterCreator);
|
return this._run(item, callback, logLevel, true, filterCreator);
|
||||||
}
|
}
|
||||||
|
|
||||||
_run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: true, filterCreator?: FilterCreator): T;
|
private _run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: true, filterCreator?: FilterCreator): T;
|
||||||
// we don't return if we don't throw, as we don't have anything to return when an error is caught but swallowed for the fire-and-forget case.
|
// we don't return if we don't throw, as we don't have anything to return when an error is caught but swallowed for the fire-and-forget case.
|
||||||
_run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: false, filterCreator?: FilterCreator): void;
|
private _run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: false, filterCreator?: FilterCreator): void;
|
||||||
_run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: boolean, filterCreator?: FilterCreator): T | void {
|
private _run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: boolean, filterCreator?: FilterCreator): T | void {
|
||||||
this._openItems.add(item);
|
this._openItems.add(item);
|
||||||
|
|
||||||
const finishItem = () => {
|
const finishItem = () => {
|
||||||
|
@ -125,9 +134,18 @@ export abstract class BaseLogger implements ILogger {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_finishOpenItems() {
|
addReporter(reporter: ILogReporter): void {
|
||||||
|
reporter.setLogger(this);
|
||||||
|
this.reporters.push(reporter);
|
||||||
|
}
|
||||||
|
|
||||||
|
getOpenRootItems(): Iterable<ILogItem> {
|
||||||
|
return this._openItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
forceFinish() {
|
||||||
for (const openItem of this._openItems) {
|
for (const openItem of this._openItems) {
|
||||||
openItem.finish();
|
openItem.forceFinish();
|
||||||
try {
|
try {
|
||||||
// for now, serialize with an all-permitting filter
|
// for now, serialize with an all-permitting filter
|
||||||
// as the createFilter function would get a distorted image anyway
|
// as the createFilter function would get a distorted image anyway
|
||||||
|
@ -141,20 +159,37 @@ export abstract class BaseLogger implements ILogger {
|
||||||
this._openItems.clear();
|
this._openItems.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract _persistItem(item: LogItem, filter?: LogFilter, forced?: boolean): void;
|
/** @internal */
|
||||||
|
_persistItem(item: LogItem, filter?: LogFilter, forced?: boolean): void {
|
||||||
abstract export(): Promise<ILogExport | undefined>;
|
for (var i = 0; i < this.reporters.length; i += 1) {
|
||||||
|
this.reporters[i].reportItem(item, filter, forced);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// expose log level without needing
|
// expose log level without needing
|
||||||
get level(): typeof LogLevel {
|
get level(): typeof LogLevel {
|
||||||
return LogLevel;
|
return LogLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
_now(): number {
|
_now(): number {
|
||||||
return this._platform.clock.now();
|
return this._platform.clock.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
_createRefId(): number {
|
_createRefId(): number {
|
||||||
return Math.round(this._platform.random() * Number.MAX_SAFE_INTEGER);
|
return Math.round(this._platform.random() * Number.MAX_SAFE_INTEGER);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class DeferredPersistRootLogItem extends LogItem {
|
||||||
|
finish() {
|
||||||
|
super.finish();
|
||||||
|
(this._logger as Logger)._persistItem(this, undefined, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
forceFinish() {
|
||||||
|
super.finish();
|
||||||
|
/// no need to persist when force-finishing as _finishOpenItems above will do it
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
import {LogLevel} from "./LogFilter";
|
import {LogLevel} from "./LogFilter";
|
||||||
import type {ILogger, ILogExport, ILogItem, LabelOrValues, LogCallback, LogItemValues} from "./types";
|
import type {ILogger, ILogItem, ILogReporter, LabelOrValues, LogCallback, LogItemValues} from "./types";
|
||||||
|
|
||||||
function noop (): void {}
|
function noop (): void {}
|
||||||
|
|
||||||
|
@ -23,6 +23,22 @@ export class NullLogger implements ILogger {
|
||||||
|
|
||||||
log(): void {}
|
log(): void {}
|
||||||
|
|
||||||
|
addReporter() {}
|
||||||
|
|
||||||
|
get reporters(): ReadonlyArray<ILogReporter> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getOpenRootItems(): Iterable<ILogItem> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
forceFinish(): void {}
|
||||||
|
|
||||||
|
child(): ILogItem {
|
||||||
|
return this.item;
|
||||||
|
}
|
||||||
|
|
||||||
run<T>(_, callback: LogCallback<T>): T {
|
run<T>(_, callback: LogCallback<T>): T {
|
||||||
return callback(this.item);
|
return callback(this.item);
|
||||||
}
|
}
|
||||||
|
@ -40,10 +56,6 @@ export class NullLogger implements ILogger {
|
||||||
return this.item;
|
return this.item;
|
||||||
}
|
}
|
||||||
|
|
||||||
async export(): Promise<ILogExport | undefined> {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
get level(): typeof LogLevel {
|
get level(): typeof LogLevel {
|
||||||
return LogLevel;
|
return LogLevel;
|
||||||
}
|
}
|
||||||
|
@ -61,12 +73,18 @@ export class NullLogItem implements ILogItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
wrap<T>(_: LabelOrValues, callback: LogCallback<T>): T {
|
wrap<T>(_: LabelOrValues, callback: LogCallback<T>): T {
|
||||||
|
return this.run(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
run<T>(callback: LogCallback<T>): T {
|
||||||
return callback(this);
|
return callback(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
log(): ILogItem {
|
log(): ILogItem {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
set(): ILogItem { return this; }
|
set(): ILogItem { return this; }
|
||||||
|
|
||||||
runDetached(_: LabelOrValues, callback: LogCallback<unknown>): ILogItem {
|
runDetached(_: LabelOrValues, callback: LogCallback<unknown>): ILogItem {
|
||||||
|
@ -99,6 +117,7 @@ export class NullLogItem implements ILogItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
finish(): void {}
|
finish(): void {}
|
||||||
|
forceFinish(): void {}
|
||||||
|
|
||||||
serialize(): undefined {
|
serialize(): undefined {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
@ -16,7 +16,6 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {LogLevel, LogFilter} from "./LogFilter";
|
import {LogLevel, LogFilter} from "./LogFilter";
|
||||||
import type {BaseLogger} from "./BaseLogger";
|
|
||||||
import type {BlobHandle} from "../platform/web/dom/BlobHandle.js";
|
import type {BlobHandle} from "../platform/web/dom/BlobHandle.js";
|
||||||
|
|
||||||
export interface ISerializedItem {
|
export interface ISerializedItem {
|
||||||
|
@ -40,8 +39,10 @@ export interface ILogItem {
|
||||||
readonly level: typeof LogLevel;
|
readonly level: typeof LogLevel;
|
||||||
readonly end?: number;
|
readonly end?: number;
|
||||||
readonly start?: number;
|
readonly start?: number;
|
||||||
readonly values: LogItemValues;
|
readonly values: Readonly<LogItemValues>;
|
||||||
wrap<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
|
wrap<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
|
||||||
|
/*** This is sort of low-level, you probably want to use wrap. If you do use it, it should only be called once. */
|
||||||
|
run<T>(callback: LogCallback<T>): T;
|
||||||
log(labelOrValues: LabelOrValues, logLevel?: LogLevel): ILogItem;
|
log(labelOrValues: LabelOrValues, logLevel?: LogLevel): ILogItem;
|
||||||
set(key: string | object, value: unknown): ILogItem;
|
set(key: string | object, value: unknown): ILogItem;
|
||||||
runDetached(labelOrValues: LabelOrValues, callback: LogCallback<unknown>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
|
runDetached(labelOrValues: LabelOrValues, callback: LogCallback<unknown>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
|
||||||
|
@ -51,22 +52,41 @@ export interface ILogItem {
|
||||||
catch(err: Error): Error;
|
catch(err: Error): Error;
|
||||||
serialize(filter: LogFilter, parentStartTime: number | undefined, forced: boolean): ISerializedItem | undefined;
|
serialize(filter: LogFilter, parentStartTime: number | undefined, forced: boolean): ISerializedItem | undefined;
|
||||||
finish(): void;
|
finish(): void;
|
||||||
|
forceFinish(): void;
|
||||||
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
|
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
extend both ILogger and ILogItem from this interface, but need to rename ILogger.run => wrap then. Or both to `span`?
|
||||||
|
|
||||||
|
export interface ILogItemCreator {
|
||||||
|
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
|
||||||
|
refDetached(logItem: ILogItem, logLevel?: LogLevel): void;
|
||||||
|
log(labelOrValues: LabelOrValues, logLevel?: LogLevel): ILogItem;
|
||||||
|
wrap<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
|
||||||
|
get level(): typeof LogLevel;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
export interface ILogger {
|
export interface ILogger {
|
||||||
log(labelOrValues: LabelOrValues, logLevel?: LogLevel): void;
|
log(labelOrValues: LabelOrValues, logLevel?: LogLevel): void;
|
||||||
|
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
|
||||||
wrapOrRun<T>(item: ILogItem | undefined, labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
|
wrapOrRun<T>(item: ILogItem | undefined, labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
|
||||||
runDetached<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
|
runDetached<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
|
||||||
run<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
|
run<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
|
||||||
export(): Promise<ILogExport | undefined>;
|
|
||||||
get level(): typeof LogLevel;
|
get level(): typeof LogLevel;
|
||||||
|
getOpenRootItems(): Iterable<ILogItem>;
|
||||||
|
addReporter(reporter: ILogReporter): void;
|
||||||
|
get reporters(): ReadonlyArray<ILogReporter>;
|
||||||
|
/**
|
||||||
|
* force-finishes any open items and passes them to the reporter, with the forced flag set.
|
||||||
|
* Good think to do when the page is being closed to not lose any logs.
|
||||||
|
**/
|
||||||
|
forceFinish(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ILogExport {
|
export interface ILogReporter {
|
||||||
get count(): number;
|
setLogger(logger: ILogger): void;
|
||||||
removeFromStore(): Promise<void>;
|
reportItem(item: ILogItem, filter?: LogFilter, forced?: boolean): void;
|
||||||
asBlob(): BlobHandle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LogItemValues = {
|
export type LogItemValues = {
|
||||||
|
|
|
@ -18,7 +18,7 @@ limitations under the License.
|
||||||
import {createEnum} from "../utils/enum";
|
import {createEnum} from "../utils/enum";
|
||||||
import {lookupHomeserver} from "./well-known.js";
|
import {lookupHomeserver} from "./well-known.js";
|
||||||
import {AbortableOperation} from "../utils/AbortableOperation";
|
import {AbortableOperation} from "../utils/AbortableOperation";
|
||||||
import {ObservableValue} from "../observable/ObservableValue";
|
import {ObservableValue} from "../observable/value/ObservableValue";
|
||||||
import {HomeServerApi} from "./net/HomeServerApi";
|
import {HomeServerApi} from "./net/HomeServerApi";
|
||||||
import {Reconnector, ConnectionStatus} from "./net/Reconnector";
|
import {Reconnector, ConnectionStatus} from "./net/Reconnector";
|
||||||
import {ExponentialRetryDelay} from "./net/ExponentialRetryDelay";
|
import {ExponentialRetryDelay} from "./net/ExponentialRetryDelay";
|
||||||
|
@ -100,8 +100,6 @@ export class Client {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: When converted to typescript this should return the same type
|
|
||||||
// as this._loginOptions is in LoginViewModel.ts (LoginOptions).
|
|
||||||
_parseLoginOptions(options, homeserver) {
|
_parseLoginOptions(options, homeserver) {
|
||||||
/*
|
/*
|
||||||
Take server response and return new object which has two props password and sso which
|
Take server response and return new object which has two props password and sso which
|
||||||
|
@ -134,15 +132,14 @@ export class Client {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async startRegistration(homeserver, username, password, initialDeviceDisplayName, flowSelector) {
|
async startRegistration(homeserver, username, password, initialDeviceDisplayName) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,12 +16,15 @@ limitations under the License.
|
||||||
|
|
||||||
import {OLM_ALGORITHM} from "./e2ee/common.js";
|
import {OLM_ALGORITHM} from "./e2ee/common.js";
|
||||||
import {countBy, groupBy} from "../utils/groupBy";
|
import {countBy, groupBy} from "../utils/groupBy";
|
||||||
|
import {LRUCache} from "../utils/LRUCache";
|
||||||
|
|
||||||
export class DeviceMessageHandler {
|
export class DeviceMessageHandler {
|
||||||
constructor({storage}) {
|
constructor({storage, callHandler}) {
|
||||||
this._storage = storage;
|
this._storage = storage;
|
||||||
this._olmDecryption = null;
|
this._olmDecryption = null;
|
||||||
this._megolmDecryption = null;
|
this._megolmDecryption = null;
|
||||||
|
this._callHandler = callHandler;
|
||||||
|
this._senderDeviceCache = new LRUCache(10, di => di.curve25519Key);
|
||||||
}
|
}
|
||||||
|
|
||||||
enableEncryption({olmDecryption, megolmDecryption}) {
|
enableEncryption({olmDecryption, megolmDecryption}) {
|
||||||
|
@ -35,6 +38,7 @@ export class DeviceMessageHandler {
|
||||||
|
|
||||||
async prepareSync(toDeviceEvents, lock, txn, log) {
|
async prepareSync(toDeviceEvents, lock, txn, log) {
|
||||||
log.set("messageTypes", countBy(toDeviceEvents, e => e.type));
|
log.set("messageTypes", countBy(toDeviceEvents, e => e.type));
|
||||||
|
this._handleUnencryptedCallEvents(toDeviceEvents, log);
|
||||||
const encryptedEvents = toDeviceEvents.filter(e => e.type === "m.room.encrypted");
|
const encryptedEvents = toDeviceEvents.filter(e => e.type === "m.room.encrypted");
|
||||||
if (!this._olmDecryption) {
|
if (!this._olmDecryption) {
|
||||||
log.log("can't decrypt, encryption not enabled", log.level.Warn);
|
log.log("can't decrypt, encryption not enabled", log.level.Warn);
|
||||||
|
@ -49,10 +53,38 @@ export class DeviceMessageHandler {
|
||||||
log.child("decrypt_error").catch(err);
|
log.child("decrypt_error").catch(err);
|
||||||
}
|
}
|
||||||
const newRoomKeys = this._megolmDecryption.roomKeysFromDeviceMessages(olmDecryptChanges.results, log);
|
const newRoomKeys = this._megolmDecryption.roomKeysFromDeviceMessages(olmDecryptChanges.results, log);
|
||||||
|
|
||||||
|
// const callMessages = olmDecryptChanges.results.filter(dr => this._callHandler.handlesDeviceMessageEventType(dr.event?.type));
|
||||||
|
// // load devices by sender key
|
||||||
|
// await Promise.all(callMessages.map(async dr => {
|
||||||
|
// dr.setDevice(await this._getDevice(dr.senderCurve25519Key, txn));
|
||||||
|
// }));
|
||||||
|
// // TODO: pass this in the prep and run it in afterSync or afterSyncComplete (as callHandler can send events as well)?
|
||||||
|
// for (const dr of callMessages) {
|
||||||
|
// if (dr.device) {
|
||||||
|
// this._callHandler.handleDeviceMessage(dr.event, dr.device.userId, dr.device.deviceId, log);
|
||||||
|
// } else {
|
||||||
|
// console.error("could not deliver message because don't have device for sender key", dr.event);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// TODO: somehow include rooms that received a call to_device message in the sync state?
|
||||||
|
// or have updates flow through event emitter?
|
||||||
|
// well, we don't really need to update the room other then when a call starts or stops
|
||||||
|
// any changes within the call will be emitted on the call object?
|
||||||
return new SyncPreparation(olmDecryptChanges, newRoomKeys);
|
return new SyncPreparation(olmDecryptChanges, newRoomKeys);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_handleUnencryptedCallEvents(toDeviceEvents, log) {
|
||||||
|
const callMessages = toDeviceEvents.filter(e => this._callHandler.handlesDeviceMessageEventType(e.type));
|
||||||
|
for (const event of callMessages) {
|
||||||
|
const userId = event.sender;
|
||||||
|
const deviceId = event.content.device_id;
|
||||||
|
this._callHandler.handleDeviceMessage(event, userId, deviceId, log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** check that prep is not undefined before calling this */
|
/** check that prep is not undefined before calling this */
|
||||||
async writeSync(prep, txn) {
|
async writeSync(prep, txn) {
|
||||||
// write olm changes
|
// write olm changes
|
||||||
|
@ -60,6 +92,18 @@ export class DeviceMessageHandler {
|
||||||
const didWriteValues = await Promise.all(prep.newRoomKeys.map(key => this._megolmDecryption.writeRoomKey(key, txn)));
|
const didWriteValues = await Promise.all(prep.newRoomKeys.map(key => this._megolmDecryption.writeRoomKey(key, txn)));
|
||||||
return didWriteValues.some(didWrite => !!didWrite);
|
return didWriteValues.some(didWrite => !!didWrite);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async _getDevice(senderKey, txn) {
|
||||||
|
let device = this._senderDeviceCache.get(senderKey);
|
||||||
|
if (!device) {
|
||||||
|
device = await txn.deviceIdentities.getByCurve25519Key(senderKey);
|
||||||
|
if (device) {
|
||||||
|
this._senderDeviceCache.set(device);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return device;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SyncPreparation {
|
class SyncPreparation {
|
||||||
|
|
|
@ -21,7 +21,7 @@ import {RoomStatus} from "./room/common";
|
||||||
import {RoomBeingCreated} from "./room/RoomBeingCreated";
|
import {RoomBeingCreated} from "./room/RoomBeingCreated";
|
||||||
import {Invite} from "./room/Invite.js";
|
import {Invite} from "./room/Invite.js";
|
||||||
import {Pusher} from "./push/Pusher";
|
import {Pusher} from "./push/Pusher";
|
||||||
import { ObservableMap } from "../observable/index.js";
|
import { ObservableMap } from "../observable/index";
|
||||||
import {User} from "./User.js";
|
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";
|
||||||
|
@ -45,7 +45,9 @@ import {
|
||||||
keyFromDehydratedDeviceKey as createSSSSKeyFromDehydratedDeviceKey
|
keyFromDehydratedDeviceKey as createSSSSKeyFromDehydratedDeviceKey
|
||||||
} from "./ssss/index";
|
} from "./ssss/index";
|
||||||
import {SecretStorage} from "./ssss/SecretStorage";
|
import {SecretStorage} from "./ssss/SecretStorage";
|
||||||
import {ObservableValue, RetainedObservableValue} from "../observable/ObservableValue";
|
import {ObservableValue} from "../observable/value/ObservableValue";
|
||||||
|
import {RetainedObservableValue} from "../observable/value/RetainedObservableValue";
|
||||||
|
import {CallHandler} from "./calls/CallHandler";
|
||||||
|
|
||||||
const PICKLE_KEY = "DEFAULT_KEY";
|
const PICKLE_KEY = "DEFAULT_KEY";
|
||||||
const PUSHER_KEY = "pusher";
|
const PUSHER_KEY = "pusher";
|
||||||
|
@ -73,7 +75,33 @@ export class Session {
|
||||||
};
|
};
|
||||||
this._roomsBeingCreated = new ObservableMap();
|
this._roomsBeingCreated = new ObservableMap();
|
||||||
this._user = new User(sessionInfo.userId);
|
this._user = new User(sessionInfo.userId);
|
||||||
this._deviceMessageHandler = new DeviceMessageHandler({storage});
|
this._callHandler = new CallHandler({
|
||||||
|
clock: this._platform.clock,
|
||||||
|
hsApi: this._hsApi,
|
||||||
|
encryptDeviceMessage: async (roomId, userId, message, log) => {
|
||||||
|
if (!this._deviceTracker || !this._olmEncryption) {
|
||||||
|
throw new Error("encryption is not enabled");
|
||||||
|
}
|
||||||
|
// TODO: just get the devices we're sending the message to, not all the room devices
|
||||||
|
// although we probably already fetched all devices to send messages in the likely e2ee room
|
||||||
|
const devices = await log.wrap("get device keys", async log => {
|
||||||
|
await this._deviceTracker.trackRoom(this.rooms.get(roomId), log);
|
||||||
|
return this._deviceTracker.devicesForRoomMembers(roomId, [userId], this._hsApi, log);
|
||||||
|
});
|
||||||
|
const encryptedMessage = await this._olmEncryption.encrypt(message.type, message.content, devices, this._hsApi, log);
|
||||||
|
return encryptedMessage;
|
||||||
|
},
|
||||||
|
storage: this._storage,
|
||||||
|
webRTC: this._platform.webRTC,
|
||||||
|
ownDeviceId: sessionInfo.deviceId,
|
||||||
|
ownUserId: sessionInfo.userId,
|
||||||
|
logger: this._platform.logger,
|
||||||
|
turnServers: [{
|
||||||
|
urls: ["stun:turn.matrix.org"],
|
||||||
|
}],
|
||||||
|
forceTURN: false,
|
||||||
|
});
|
||||||
|
this._deviceMessageHandler = new DeviceMessageHandler({storage, callHandler: this._callHandler});
|
||||||
this._olm = olm;
|
this._olm = olm;
|
||||||
this._olmUtil = null;
|
this._olmUtil = null;
|
||||||
this._e2eeAccount = null;
|
this._e2eeAccount = null;
|
||||||
|
@ -118,6 +146,10 @@ export class Session {
|
||||||
return this._sessionInfo.userId;
|
return this._sessionInfo.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get callHandler() {
|
||||||
|
return this._callHandler;
|
||||||
|
}
|
||||||
|
|
||||||
// called once this._e2eeAccount is assigned
|
// called once this._e2eeAccount is assigned
|
||||||
_setupEncryption() {
|
_setupEncryption() {
|
||||||
// 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
|
||||||
|
@ -562,7 +594,8 @@ export class Session {
|
||||||
pendingEvents,
|
pendingEvents,
|
||||||
user: this._user,
|
user: this._user,
|
||||||
createRoomEncryption: this._createRoomEncryption,
|
createRoomEncryption: this._createRoomEncryption,
|
||||||
platform: this._platform
|
platform: this._platform,
|
||||||
|
callHandler: this._callHandler
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -983,9 +1016,18 @@ export function tests() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"session data is not modified until after sync": async (assert) => {
|
"session data is not modified until after sync": async (assert) => {
|
||||||
const session = new Session({storage: createStorageMock({
|
const storage = createStorageMock({
|
||||||
sync: {token: "a", filterId: 5}
|
sync: {token: "a", filterId: 5}
|
||||||
}), sessionInfo: {userId: ""}});
|
});
|
||||||
|
const session = new Session({
|
||||||
|
storage,
|
||||||
|
sessionInfo: {userId: ""},
|
||||||
|
platform: {
|
||||||
|
clock: {
|
||||||
|
createTimeout: () => undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
await session.load();
|
await session.load();
|
||||||
let syncSet = false;
|
let syncSet = false;
|
||||||
const syncTxn = {
|
const syncTxn = {
|
||||||
|
|
|
@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {ObservableValue} from "../observable/ObservableValue";
|
import {ObservableValue} from "../observable/value/ObservableValue";
|
||||||
import {createEnum} from "../utils/enum";
|
import {createEnum} from "../utils/enum";
|
||||||
|
|
||||||
const INCREMENTAL_TIMEOUT = 30000;
|
const INCREMENTAL_TIMEOUT = 30000;
|
||||||
|
@ -224,6 +224,7 @@ export class Sync {
|
||||||
_openPrepareSyncTxn() {
|
_openPrepareSyncTxn() {
|
||||||
const storeNames = this._storage.storeNames;
|
const storeNames = this._storage.storeNames;
|
||||||
return this._storage.readTxn([
|
return this._storage.readTxn([
|
||||||
|
storeNames.deviceIdentities, // to read device from olm messages
|
||||||
storeNames.olmSessions,
|
storeNames.olmSessions,
|
||||||
storeNames.inboundGroupSessions,
|
storeNames.inboundGroupSessions,
|
||||||
// to read fragments when loading sync writer when rejoining archived room
|
// to read fragments when loading sync writer when rejoining archived room
|
||||||
|
@ -343,6 +344,7 @@ export class Sync {
|
||||||
// to decrypt and store new room keys
|
// to decrypt and store new room keys
|
||||||
storeNames.olmSessions,
|
storeNames.olmSessions,
|
||||||
storeNames.inboundGroupSessions,
|
storeNames.inboundGroupSessions,
|
||||||
|
storeNames.calls,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
224
src/matrix/calls/CallHandler.ts
Normal file
224
src/matrix/calls/CallHandler.ts
Normal file
|
@ -0,0 +1,224 @@
|
||||||
|
/*
|
||||||
|
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 {ObservableMap} from "../../observable/map/ObservableMap";
|
||||||
|
import {WebRTC, PeerConnection} from "../../platform/types/WebRTC";
|
||||||
|
import {MediaDevices, Track} from "../../platform/types/MediaDevices";
|
||||||
|
import {handlesEventType} from "./PeerCall";
|
||||||
|
import {EventType, CallIntent} from "./callEventTypes";
|
||||||
|
import {GroupCall} from "./group/GroupCall";
|
||||||
|
import {makeId} from "../common";
|
||||||
|
import {CALL_LOG_TYPE} from "./common";
|
||||||
|
|
||||||
|
import type {LocalMedia} from "./LocalMedia";
|
||||||
|
import type {Room} from "../room/Room";
|
||||||
|
import type {MemberChange} from "../room/members/RoomMember";
|
||||||
|
import type {StateEvent} from "../storage/types";
|
||||||
|
import type {ILogItem, ILogger} from "../../logging/types";
|
||||||
|
import type {Platform} from "../../platform/web/Platform";
|
||||||
|
import type {BaseObservableMap} from "../../observable/map/BaseObservableMap";
|
||||||
|
import type {SignallingMessage, MGroupCallBase} from "./callEventTypes";
|
||||||
|
import type {Options as GroupCallOptions} from "./group/GroupCall";
|
||||||
|
import type {Transaction} from "../storage/idb/Transaction";
|
||||||
|
import type {CallEntry} from "../storage/idb/stores/CallStore";
|
||||||
|
import type {Clock} from "../../platform/web/dom/Clock";
|
||||||
|
|
||||||
|
export type Options = Omit<GroupCallOptions, "emitUpdate" | "createTimeout"> & {
|
||||||
|
clock: Clock
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CallHandler {
|
||||||
|
// group calls by call id
|
||||||
|
private readonly _calls: ObservableMap<string, GroupCall> = new ObservableMap<string, GroupCall>();
|
||||||
|
// map of userId to set of conf_id's they are in
|
||||||
|
private memberToCallIds: Map<string, Set<string>> = new Map();
|
||||||
|
private groupCallOptions: GroupCallOptions;
|
||||||
|
private sessionId = makeId("s");
|
||||||
|
|
||||||
|
constructor(private readonly options: Options) {
|
||||||
|
this.groupCallOptions = Object.assign({}, this.options, {
|
||||||
|
emitUpdate: (groupCall, params) => this._calls.update(groupCall.id, params),
|
||||||
|
createTimeout: this.options.clock.createTimeout,
|
||||||
|
sessionId: this.sessionId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCalls(intent: CallIntent = CallIntent.Ring) {
|
||||||
|
const txn = await this._getLoadTxn();
|
||||||
|
const callEntries = await txn.calls.getByIntent(intent);
|
||||||
|
this._loadCallEntries(callEntries, txn);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCallsForRoom(intent: CallIntent, roomId: string) {
|
||||||
|
const txn = await this._getLoadTxn();
|
||||||
|
const callEntries = await txn.calls.getByIntentAndRoom(intent, roomId);
|
||||||
|
this._loadCallEntries(callEntries, txn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _getLoadTxn(): Promise<Transaction> {
|
||||||
|
const names = this.options.storage.storeNames;
|
||||||
|
const txn = await this.options.storage.readTxn([
|
||||||
|
names.calls,
|
||||||
|
names.roomState
|
||||||
|
]);
|
||||||
|
return txn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _loadCallEntries(callEntries: CallEntry[], txn: Transaction): Promise<void> {
|
||||||
|
return this.options.logger.run({l: "loading calls", t: CALL_LOG_TYPE}, async log => {
|
||||||
|
log.set("entries", callEntries.length);
|
||||||
|
await Promise.all(callEntries.map(async callEntry => {
|
||||||
|
if (this._calls.get(callEntry.callId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const event = await txn.roomState.get(callEntry.roomId, EventType.GroupCall, callEntry.callId);
|
||||||
|
if (event) {
|
||||||
|
const call = new GroupCall(event.event.state_key, false, event.event.content, event.roomId, this.groupCallOptions);
|
||||||
|
this._calls.set(call.id, call);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const roomIds = Array.from(new Set(callEntries.map(e => e.roomId)));
|
||||||
|
await Promise.all(roomIds.map(async roomId => {
|
||||||
|
// const ownCallsMemberEvent = await txn.roomState.get(roomId, EventType.GroupCallMember, this.options.ownUserId);
|
||||||
|
// if (ownCallsMemberEvent) {
|
||||||
|
// this.handleCallMemberEvent(ownCallsMemberEvent.event, log);
|
||||||
|
// }
|
||||||
|
const callsMemberEvents = await txn.roomState.getAllForType(roomId, EventType.GroupCallMember);
|
||||||
|
for (const entry of callsMemberEvents) {
|
||||||
|
this.handleCallMemberEvent(entry.event, log);
|
||||||
|
}
|
||||||
|
// TODO: we should be loading the other members as well at some point
|
||||||
|
}));
|
||||||
|
log.set("newSize", this._calls.size);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCall(roomId: string, type: "m.video" | "m.voice", name: string, intent: CallIntent = CallIntent.Ring): Promise<GroupCall> {
|
||||||
|
const call = new GroupCall(makeId("conf-"), true, {
|
||||||
|
"m.name": name,
|
||||||
|
"m.intent": intent
|
||||||
|
}, roomId, this.groupCallOptions);
|
||||||
|
this._calls.set(call.id, call);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await call.create(type);
|
||||||
|
// store call info so it will ring again when reopening the app
|
||||||
|
const txn = await this.options.storage.readWriteTxn([this.options.storage.storeNames.calls]);
|
||||||
|
txn.calls.add({
|
||||||
|
intent: call.intent,
|
||||||
|
callId: call.id,
|
||||||
|
timestamp: this.options.clock.now(),
|
||||||
|
roomId: roomId
|
||||||
|
});
|
||||||
|
await txn.complete();
|
||||||
|
} catch (err) {
|
||||||
|
//if (err.name === "ConnectionError") {
|
||||||
|
// if we're offline, give up and remove the call again
|
||||||
|
this._calls.remove(call.id);
|
||||||
|
//}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return call;
|
||||||
|
}
|
||||||
|
|
||||||
|
get calls(): BaseObservableMap<string, GroupCall> { return this._calls; }
|
||||||
|
|
||||||
|
// TODO: check and poll turn server credentials here
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
handleRoomState(room: Room, events: StateEvent[], txn: Transaction, log: ILogItem) {
|
||||||
|
// first update call events
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.type === EventType.GroupCall) {
|
||||||
|
this.handleCallEvent(event, room.id, txn, log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// then update members
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.type === EventType.GroupCallMember) {
|
||||||
|
this.handleCallMemberEvent(event, log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
updateRoomMembers(room: Room, memberChanges: Map<string, MemberChange>) {
|
||||||
|
// TODO: also have map for roomId to calls, so we can easily update members
|
||||||
|
// we will also need this to get the call for a room
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
handlesDeviceMessageEventType(eventType: string): boolean {
|
||||||
|
return handlesEventType(eventType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
handleDeviceMessage(message: SignallingMessage<MGroupCallBase>, userId: string, deviceId: string, log: ILogItem) {
|
||||||
|
// TODO: buffer messages for calls we haven't received the state event for yet?
|
||||||
|
const call = this._calls.get(message.content.conf_id);
|
||||||
|
call?.handleDeviceMessage(message, userId, deviceId, log);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleCallEvent(event: StateEvent, roomId: string, txn: Transaction, log: ILogItem) {
|
||||||
|
const callId = event.state_key;
|
||||||
|
let call = this._calls.get(callId);
|
||||||
|
if (call) {
|
||||||
|
call.updateCallEvent(event.content, log);
|
||||||
|
if (call.isTerminated) {
|
||||||
|
call.disconnect(log);
|
||||||
|
this._calls.remove(call.id);
|
||||||
|
txn.calls.remove(call.intent, roomId, call.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
call = new GroupCall(event.state_key, false, event.content, roomId, this.groupCallOptions);
|
||||||
|
this._calls.set(call.id, call);
|
||||||
|
txn.calls.add({
|
||||||
|
intent: call.intent,
|
||||||
|
callId: call.id,
|
||||||
|
timestamp: event.origin_server_ts,
|
||||||
|
roomId: roomId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleCallMemberEvent(event: StateEvent, log: ILogItem) {
|
||||||
|
const userId = event.state_key;
|
||||||
|
const calls = event.content["m.calls"] ?? [];
|
||||||
|
for (const call of calls) {
|
||||||
|
const callId = call["m.call_id"];
|
||||||
|
const groupCall = this._calls.get(callId);
|
||||||
|
// TODO: also check the member when receiving the m.call event
|
||||||
|
groupCall?.updateMembership(userId, call, log);
|
||||||
|
};
|
||||||
|
const newCallIdsMemberOf = new Set<string>(calls.map(call => call["m.call_id"]));
|
||||||
|
let previousCallIdsMemberOf = this.memberToCallIds.get(userId);
|
||||||
|
// remove user as member of any calls not present anymore
|
||||||
|
if (previousCallIdsMemberOf) {
|
||||||
|
for (const previousCallId of previousCallIdsMemberOf) {
|
||||||
|
if (!newCallIdsMemberOf.has(previousCallId)) {
|
||||||
|
const groupCall = this._calls.get(previousCallId);
|
||||||
|
groupCall?.removeMembership(userId, log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newCallIdsMemberOf.size === 0) {
|
||||||
|
this.memberToCallIds.delete(userId);
|
||||||
|
} else {
|
||||||
|
this.memberToCallIds.set(userId, newCallIdsMemberOf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
81
src/matrix/calls/LocalMedia.ts
Normal file
81
src/matrix/calls/LocalMedia.ts
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
/*
|
||||||
|
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 {SDPStreamMetadataPurpose} from "./callEventTypes";
|
||||||
|
import {Stream} from "../../platform/types/MediaDevices";
|
||||||
|
import {SDPStreamMetadata} from "./callEventTypes";
|
||||||
|
import {getStreamVideoTrack, getStreamAudioTrack} from "./common";
|
||||||
|
|
||||||
|
export class LocalMedia {
|
||||||
|
constructor(
|
||||||
|
public readonly userMedia?: Stream,
|
||||||
|
public readonly screenShare?: Stream,
|
||||||
|
public readonly dataChannelOptions?: RTCDataChannelInit,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
withUserMedia(stream: Stream) {
|
||||||
|
return new LocalMedia(stream, this.screenShare, this.dataChannelOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
withScreenShare(stream: Stream) {
|
||||||
|
return new LocalMedia(this.userMedia, stream, this.dataChannelOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
withDataChannel(options: RTCDataChannelInit): LocalMedia {
|
||||||
|
return new LocalMedia(this.userMedia, this.screenShare, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
replaceClone(oldClone: LocalMedia | undefined, oldOriginal: LocalMedia | undefined): LocalMedia {
|
||||||
|
let userMedia;
|
||||||
|
let screenShare;
|
||||||
|
const cloneOrAdoptStream = (oldOriginalStream: Stream | undefined, oldCloneStream: Stream | undefined, newStream: Stream | undefined): Stream | undefined => {
|
||||||
|
let stream;
|
||||||
|
if (oldOriginalStream?.id === newStream?.id) {
|
||||||
|
stream = oldCloneStream;
|
||||||
|
} else {
|
||||||
|
stream = newStream?.clone();
|
||||||
|
getStreamAudioTrack(oldCloneStream)?.stop();
|
||||||
|
getStreamVideoTrack(oldCloneStream)?.stop();
|
||||||
|
}
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
return new LocalMedia(
|
||||||
|
cloneOrAdoptStream(oldOriginal?.userMedia, oldClone?.userMedia, this.userMedia),
|
||||||
|
cloneOrAdoptStream(oldOriginal?.screenShare, oldClone?.screenShare, this.screenShare),
|
||||||
|
this.dataChannelOptions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
clone(): LocalMedia {
|
||||||
|
return new LocalMedia(this.userMedia?.clone(),this.screenShare?.clone(), this.dataChannelOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.stopExcept(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopExcept(newMedia: LocalMedia | undefined) {
|
||||||
|
if(newMedia?.userMedia?.id !== this.userMedia?.id) {
|
||||||
|
getStreamAudioTrack(this.userMedia)?.stop();
|
||||||
|
getStreamVideoTrack(this.userMedia)?.stop();
|
||||||
|
}
|
||||||
|
if(newMedia?.screenShare?.id !== this.screenShare?.id) {
|
||||||
|
getStreamVideoTrack(this.screenShare)?.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1164
src/matrix/calls/PeerCall.ts
Normal file
1164
src/matrix/calls/PeerCall.ts
Normal file
File diff suppressed because it is too large
Load diff
225
src/matrix/calls/TODO.md
Normal file
225
src/matrix/calls/TODO.md
Normal file
|
@ -0,0 +1,225 @@
|
||||||
|
- relevant MSCs next to spec:
|
||||||
|
- https://github.com/matrix-org/matrix-doc/pull/2746 Improved Signalling for 1:1 VoIP
|
||||||
|
- https://github.com/matrix-org/matrix-doc/pull/2747 Transferring VoIP Calls
|
||||||
|
- https://github.com/matrix-org/matrix-doc/pull/3077 Support for multi-stream VoIP
|
||||||
|
- https://github.com/matrix-org/matrix-doc/pull/3086 Asserted identity on VoIP calls
|
||||||
|
- https://github.com/matrix-org/matrix-doc/pull/3291 Muting in VoIP calls
|
||||||
|
- https://github.com/matrix-org/matrix-doc/pull/3401 Native Group VoIP Signalling
|
||||||
|
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
- DONE: implement receiving hangup
|
||||||
|
- DONE: implement cloning the localMedia so it works in safari?
|
||||||
|
- DONE: implement 3 retries per peer
|
||||||
|
- DONE: implement muting tracks with m.call.sdp_stream_metadata_changed
|
||||||
|
- DONE: implement renegotiation
|
||||||
|
- DONE: finish session id support
|
||||||
|
- call peers are essentially identified by (userid, deviceid, sessionid). If see a new session id, we first disconnect from the current member so we're ready to connect with a clean slate again (in a member event, also in to_device? no harm I suppose, given olm encryption ensures you can't spoof the deviceid).
|
||||||
|
- DONE: making logging better
|
||||||
|
- figure out why sometimes leave button does not work
|
||||||
|
- get correct members and avatars in call
|
||||||
|
- improve UI while in a call
|
||||||
|
- allow toggling audio
|
||||||
|
- support active speaker, sort speakers by last active
|
||||||
|
- close muted media stream after a while
|
||||||
|
- support highlight mode where we show active speaker and thumbnails for other participants
|
||||||
|
- better grid mode:
|
||||||
|
- we report the call view size to the view model with ResizeObserver, we calculate the A/R
|
||||||
|
- we calculate the grid based on view A/R, taking into account minimal stream size
|
||||||
|
- show name on stream view
|
||||||
|
- when you start a call, or join one, first you go to a SelectCallMedia screen where you can pick whether you want to use camera, audio or both:
|
||||||
|
- if you are joining a call, we'll default to the call intent
|
||||||
|
- if you are creating a call, we'll default to video
|
||||||
|
- when creating a call, adjust the navigation path to room/room_id/call
|
||||||
|
- when selecting a call, adjust the navigation path to room/room_id/call/call_id
|
||||||
|
- implement to_device messages arriving before m.call(.member) state event
|
||||||
|
- DONE for m.call.member, not for m.call and not for to_device other than m.call.invite arriving before invite
|
||||||
|
- reeable crypto & implement fetching olm keys before sending encrypted signalling message
|
||||||
|
- local echo for join/leave buttons?
|
||||||
|
- batch outgoing to_device messages in one request to homeserver for operations that will send out an event to all participants (e.g. mute)
|
||||||
|
- implement call ringing and rejecting a ringing call
|
||||||
|
- support screen sharing
|
||||||
|
- add button to enable, disable
|
||||||
|
- support showing stream view with large screen share video element and small camera video element (if present)
|
||||||
|
- don't load all members when loading calls to know whether they are ringing and joined by ourself
|
||||||
|
- only load our own member once, then have a way to load additional members on a call.
|
||||||
|
- see if we remove partyId entirely, it is only used for detecting remote echo which is not an issue for group calls? see https://github.com/matrix-org/matrix-spec-proposals/blob/dbkr/msc2746/proposals/2746-reliable-voip.md#add-party_id-to-all-voip-events
|
||||||
|
- remove PeerCall.waitForState ?
|
||||||
|
- invite glare is completely untested, does it work?
|
||||||
|
- how to remove call from m.call.member when just closing client?
|
||||||
|
- when closing client and still in call, tell service worker to send event on our behalf?
|
||||||
|
```js
|
||||||
|
// dispose when leaving call
|
||||||
|
this.track(platform.registerExitHandler(unloadActions => {
|
||||||
|
// batch requests will resolve immediately,
|
||||||
|
// so we can reuse the same send code that does awaits without awaiting?
|
||||||
|
const batch = new RequestBatch();
|
||||||
|
const hsApi = this.hsApi.withBatch(batch);
|
||||||
|
// _leaveCallMemberContent will need to become sync,
|
||||||
|
// so we'll need to keep track of own member event rather than rely on storage
|
||||||
|
hsApi.sendStateEvent("m.call.member", this._leaveCallMemberContent());
|
||||||
|
// does this internally: serviceWorkerHandler.trySend("sendRequestBatch", batch.toJSON());
|
||||||
|
unloadActions.sendRequestBatch(batch);
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
## TODO (old)
|
||||||
|
- DONE: PeerCall
|
||||||
|
- send invite
|
||||||
|
- implement terminate
|
||||||
|
- implement waitForState
|
||||||
|
|
||||||
|
- find out if we need to do something different when renegotation is triggered (a subsequent onnegotiationneeded event) whether
|
||||||
|
we sent the invite/offer or answer. e.g. do we always do createOffer/setLocalDescription and then send it over a matrix negotiation event? even if we before called createAnswer.
|
||||||
|
- handle receiving offer and send anwser
|
||||||
|
- handle sending ice candidates
|
||||||
|
- handle ice candidates finished (iceGatheringState === 'complete')
|
||||||
|
- handle receiving ice candidates
|
||||||
|
- handle sending renegotiation
|
||||||
|
- handle receiving renegotiation
|
||||||
|
- reject call
|
||||||
|
- hangup call
|
||||||
|
- handle muting tracks
|
||||||
|
- handle remote track being muted
|
||||||
|
- handle adding/removing tracks to an ongoing call
|
||||||
|
- handle sdp metadata
|
||||||
|
- DONE: Participant
|
||||||
|
- handle glare
|
||||||
|
- encrypt to_device message with olm
|
||||||
|
- batch outgoing to_device messages in one request to homeserver for operations that will send out an event to all participants (e.g. mute)
|
||||||
|
- find out if we should start muted or not?
|
||||||
|
|
||||||
|
## Store ongoing calls
|
||||||
|
|
||||||
|
DONE: Add store with all ongoing calls so when we quit and start again, we don't have to go through all the past calls to know which ones might still be ongoing.
|
||||||
|
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
we send m.call as state event in room
|
||||||
|
|
||||||
|
we add m.call.participant for our own device
|
||||||
|
|
||||||
|
we wait for other participants to add their user and device (in the sources)
|
||||||
|
|
||||||
|
for each (userid, deviceid)
|
||||||
|
- if userId < ourUserId
|
||||||
|
- get local media
|
||||||
|
- we setup a peer connection
|
||||||
|
- add local tracks
|
||||||
|
- we wait for negotation event to get sdp
|
||||||
|
- peerConn.createOffer
|
||||||
|
- peerConn.setLocalDescription
|
||||||
|
- we send an m.call.invite
|
||||||
|
- else
|
||||||
|
- wait for invite from other side
|
||||||
|
|
||||||
|
on local ice candidate:
|
||||||
|
- if we haven't ... sent invite yet? or received answer? buffer candidate
|
||||||
|
- otherwise send candidate (without buffering?)
|
||||||
|
|
||||||
|
on incoming call:
|
||||||
|
- ring, offer to answer
|
||||||
|
|
||||||
|
answering incoming call
|
||||||
|
- get local media
|
||||||
|
- peerConn.setRemoteDescription
|
||||||
|
- add local tracks to peerConn
|
||||||
|
- peerConn.createAnswer()
|
||||||
|
- peerConn.setLocalDescription
|
||||||
|
|
||||||
|
in some cases, we will actually send the invite to all devices (e.g. SFU), so
|
||||||
|
we probably still need to handle multiple anwsers?
|
||||||
|
|
||||||
|
so we would send an invite to multiple devices and pick the one for which we
|
||||||
|
received the anwser first. between invite and anwser, we could already receive
|
||||||
|
ice candidates that we need to buffer.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
updating the metadata:
|
||||||
|
|
||||||
|
if we're renegotiating: use m.call.negotatie
|
||||||
|
if just muting: use m.call.sdp_stream_metadata_changed
|
||||||
|
|
||||||
|
|
||||||
|
party identification
|
||||||
|
- for 1:1 calls, we identify with a party_id
|
||||||
|
- for group calls, we identify with a device_id
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
Build basic version of PeerCall
|
||||||
|
- add candidates code
|
||||||
|
DONE: Build basic version of GroupCall
|
||||||
|
- DONE: add state, block invalid actions
|
||||||
|
DONE: Make it possible to olm encrypt the messages
|
||||||
|
Do work needed for state events
|
||||||
|
- DONEish: receiving (almost done?)
|
||||||
|
- DONEish: sending
|
||||||
|
logging
|
||||||
|
DONE: Expose call objects
|
||||||
|
expose volume events from audiotrack to group call
|
||||||
|
DONE: Write view model
|
||||||
|
DONE: write view
|
||||||
|
- handle glare edge-cases (not yet sent): https://spec.matrix.org/latest/client-server-api/#glare
|
||||||
|
|
||||||
|
## Calls questions
|
||||||
|
- how do we handle glare between group calls (e.g. different state events with different call ids?)
|
||||||
|
- Split up DOM part into platform code? What abstractions to choose?
|
||||||
|
Does it make sense to come up with our own API very similar to DOM api?
|
||||||
|
- what code do we copy over vs what do we implement ourselves?
|
||||||
|
- MatrixCall: perhaps we can copy it over and modify it to our needs? Seems to have a lot of edge cases implemented.
|
||||||
|
- what is partyId about?
|
||||||
|
- CallFeed: I need better understand where it is used. It's basically a wrapper around a MediaStream with volume detection. Could it make sense to put this in platform for example?
|
||||||
|
|
||||||
|
- which parts of MSC2746 are still relevant for group calls?
|
||||||
|
- which parts of MSC2747 are still relevant for group calls? it seems mostly orthogonal?
|
||||||
|
- SOLVED: how does switching channels work? This was only enabled by MSC 2746
|
||||||
|
- you do getUserMedia()/getDisplayMedia() to get the stream(s)
|
||||||
|
- you call removeTrack/addTrack on the peerConnection
|
||||||
|
- you receive a negotiationneeded event
|
||||||
|
- you call createOffer
|
||||||
|
- you send m.call.negotiate
|
||||||
|
- SOLVED: wrt to MSC2746, is the screen share track and the audio track (and video track) part of the same stream? or do screen share tracks need to go in a different stream? it sounds incompatible with the MSC2746 requirement.
|
||||||
|
- SOLVED: how does muting work? MediaStreamTrack.enabled
|
||||||
|
- SOLVED: so, what's the difference between the call_id and the conf_id in group call events?
|
||||||
|
- call_id is the specific 1:1 call, conf_id is the thing in the m.call state event key
|
||||||
|
- so a group call has a conf_id with MxN peer calls, each having their call_id.
|
||||||
|
|
||||||
|
I think we need to synchronize the negotiation needed because we don't use a CallState to guard it...
|
||||||
|
|
||||||
|
## Thursday 3-3 notes
|
||||||
|
|
||||||
|
we probably best keep the perfect negotiation flags, as they are needed for both starting the call AND renegotiation? if only for the former, it would make sense as it is a step in setting up the call, but if the call is ongoing, does it make sense to have a MakingOffer state? it actually looks like they are only needed for renegotiation! for call setup we compare the call_ids. What does that mean for these flags?
|
||||||
|
|
||||||
|
|
||||||
|
## Peer call state transitions
|
||||||
|
|
||||||
|
FROM CALLER FROM CALLEE
|
||||||
|
|
||||||
|
Fledgling Fledgling
|
||||||
|
V `call()` V `handleInvite()`: setRemoteDescription(event.offer), add buffered candidates
|
||||||
|
V Ringing
|
||||||
|
V V `answer()`
|
||||||
|
CreateOffer V
|
||||||
|
V add local tracks V
|
||||||
|
V wait for negotionneeded events V add local tracks
|
||||||
|
V setLocalDescription() CreateAnswer
|
||||||
|
V send invite event V setLocalDescription(createAnswer())
|
||||||
|
InviteSent |
|
||||||
|
V receive anwser, setRemoteDescription() |
|
||||||
|
\___________________________________________________/
|
||||||
|
V
|
||||||
|
Connecting
|
||||||
|
V receive ice candidates and iceConnectionState becomes 'connected'
|
||||||
|
Connected
|
||||||
|
V `hangup()` or some terminate condition
|
||||||
|
Ended
|
||||||
|
|
||||||
|
so if we don't want to bother with having two call objects, we can make the existing call hangup his old call_id? That way we keep the old peerConnection.
|
||||||
|
|
||||||
|
|
||||||
|
when glare, won't we drop both calls? No: https://github.com/matrix-org/matrix-spec-proposals/pull/2746#discussion_r819388754
|
227
src/matrix/calls/callEventTypes.ts
Normal file
227
src/matrix/calls/callEventTypes.ts
Normal file
|
@ -0,0 +1,227 @@
|
||||||
|
// allow non-camelcase as these are events type that go onto the wire
|
||||||
|
/* eslint-disable camelcase */
|
||||||
|
import type {StateEvent} from "../storage/types";
|
||||||
|
import type {SessionDescription} from "../../platform/types/WebRTC";
|
||||||
|
export enum EventType {
|
||||||
|
GroupCall = "org.matrix.msc3401.call",
|
||||||
|
GroupCallMember = "org.matrix.msc3401.call.member",
|
||||||
|
Invite = "m.call.invite",
|
||||||
|
Candidates = "m.call.candidates",
|
||||||
|
Answer = "m.call.answer",
|
||||||
|
Hangup = "m.call.hangup",
|
||||||
|
Reject = "m.call.reject",
|
||||||
|
SelectAnswer = "m.call.select_answer",
|
||||||
|
Negotiate = "m.call.negotiate",
|
||||||
|
SDPStreamMetadataChanged = "m.call.sdp_stream_metadata_changed",
|
||||||
|
SDPStreamMetadataChangedPrefix = "org.matrix.call.sdp_stream_metadata_changed",
|
||||||
|
Replaces = "m.call.replaces",
|
||||||
|
AssertedIdentity = "m.call.asserted_identity",
|
||||||
|
AssertedIdentityPrefix = "org.matrix.call.asserted_identity",
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Change to "sdp_stream_metadata" when MSC3077 is merged
|
||||||
|
export const SDPStreamMetadataKey = "org.matrix.msc3077.sdp_stream_metadata";
|
||||||
|
|
||||||
|
export interface CallDeviceMembership {
|
||||||
|
device_id: string,
|
||||||
|
session_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallMembership {
|
||||||
|
["m.call_id"]: string,
|
||||||
|
["m.devices"]: CallDeviceMembership[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallMemberContent {
|
||||||
|
["m.calls"]: CallMembership[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SDPStreamMetadataPurpose {
|
||||||
|
Usermedia = "m.usermedia",
|
||||||
|
Screenshare = "m.screenshare",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SDPStreamMetadataObject {
|
||||||
|
purpose: SDPStreamMetadataPurpose;
|
||||||
|
audio_muted: boolean;
|
||||||
|
video_muted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SDPStreamMetadata {
|
||||||
|
[key: string]: SDPStreamMetadataObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallCapabilities {
|
||||||
|
'm.call.transferee': boolean;
|
||||||
|
'm.call.dtmf': boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallReplacesTarget {
|
||||||
|
id: string;
|
||||||
|
display_name: string;
|
||||||
|
avatar_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MCallBase = {
|
||||||
|
call_id: string;
|
||||||
|
version: string | number;
|
||||||
|
seq: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MGroupCallBase = MCallBase & {
|
||||||
|
conf_id: string;
|
||||||
|
device_id: string;
|
||||||
|
sender_session_id: string;
|
||||||
|
dest_session_id: string;
|
||||||
|
party_id: string; // Should not need this?
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MCallAnswer<Base extends MCallBase> = Base & {
|
||||||
|
answer: SessionDescription;
|
||||||
|
capabilities?: CallCapabilities;
|
||||||
|
[SDPStreamMetadataKey]: SDPStreamMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MCallSelectAnswer<Base extends MCallBase> = Base & {
|
||||||
|
selected_party_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MCallInvite<Base extends MCallBase> = Base & {
|
||||||
|
offer: SessionDescription;
|
||||||
|
lifetime: number;
|
||||||
|
[SDPStreamMetadataKey]: SDPStreamMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MCallNegotiate<Base extends MCallBase> = Base & {
|
||||||
|
description: SessionDescription;
|
||||||
|
lifetime: number;
|
||||||
|
[SDPStreamMetadataKey]: SDPStreamMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MCallSDPStreamMetadataChanged<Base extends MCallBase> = Base & {
|
||||||
|
[SDPStreamMetadataKey]: SDPStreamMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MCallReplacesEvent<Base extends MCallBase> = Base & {
|
||||||
|
replacement_id: string;
|
||||||
|
target_user: CallReplacesTarget;
|
||||||
|
create_call: string;
|
||||||
|
await_call: string;
|
||||||
|
target_room: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MCAllAssertedIdentity<Base extends MCallBase> = Base & {
|
||||||
|
asserted_identity: {
|
||||||
|
id: string;
|
||||||
|
display_name: string;
|
||||||
|
avatar_url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MCallCandidates<Base extends MCallBase> = Base & {
|
||||||
|
candidates: RTCIceCandidate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MCallHangupReject<Base extends MCallBase> = Base & {
|
||||||
|
reason?: CallErrorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CallErrorCode {
|
||||||
|
/** The user chose to end the call */
|
||||||
|
UserHangup = 'user_hangup',
|
||||||
|
|
||||||
|
/** An error code when the local client failed to create an offer. */
|
||||||
|
LocalOfferFailed = 'local_offer_failed',
|
||||||
|
/**
|
||||||
|
* An error code when there is no local mic/camera to use. This may be because
|
||||||
|
* the hardware isn't plugged in, or the user has explicitly denied access.
|
||||||
|
*/
|
||||||
|
NoUserMedia = 'no_user_media',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error code used when a call event failed to send
|
||||||
|
* because unknown devices were present in the room
|
||||||
|
*/
|
||||||
|
UnknownDevices = 'unknown_devices',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error code used when we fail to send the invite
|
||||||
|
* for some reason other than there being unknown devices
|
||||||
|
*/
|
||||||
|
SendInvite = 'send_invite',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An answer could not be created
|
||||||
|
*/
|
||||||
|
CreateAnswer = 'create_answer',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error code used when we fail to send the answer
|
||||||
|
* for some reason other than there being unknown devices
|
||||||
|
*/
|
||||||
|
SendAnswer = 'send_answer',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session description from the other side could not be set
|
||||||
|
*/
|
||||||
|
SetRemoteDescription = 'set_remote_description',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session description from this side could not be set
|
||||||
|
*/
|
||||||
|
SetLocalDescription = 'set_local_description',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A different device answered the call
|
||||||
|
*/
|
||||||
|
AnsweredElsewhere = 'answered_elsewhere',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No media connection could be established to the other party
|
||||||
|
*/
|
||||||
|
IceFailed = 'ice_failed',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The invite timed out whilst waiting for an answer
|
||||||
|
*/
|
||||||
|
InviteTimeout = 'invite_timeout',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The call was replaced by another call
|
||||||
|
*/
|
||||||
|
Replaced = 'replaced',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signalling for the call could not be sent (other than the initial invite)
|
||||||
|
*/
|
||||||
|
SignallingFailed = 'signalling_timeout',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The remote party is busy
|
||||||
|
*/
|
||||||
|
UserBusy = 'user_busy',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We transferred the call off to somewhere else
|
||||||
|
*/
|
||||||
|
Transfered = 'transferred',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A call from the same user was found with a new session id
|
||||||
|
*/
|
||||||
|
NewSession = 'new_session',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignallingMessage<Base extends MCallBase> =
|
||||||
|
{type: EventType.Invite, content: MCallInvite<Base>} |
|
||||||
|
{type: EventType.Negotiate, content: MCallNegotiate<Base>} |
|
||||||
|
{type: EventType.Answer, content: MCallAnswer<Base>} |
|
||||||
|
{type: EventType.SDPStreamMetadataChanged | EventType.SDPStreamMetadataChangedPrefix, content: MCallSDPStreamMetadataChanged<Base>} |
|
||||||
|
{type: EventType.Candidates, content: MCallCandidates<Base>} |
|
||||||
|
{type: EventType.Hangup | EventType.Reject, content: MCallHangupReject<Base>};
|
||||||
|
|
||||||
|
export enum CallIntent {
|
||||||
|
Ring = "m.ring",
|
||||||
|
Prompt = "m.prompt",
|
||||||
|
Room = "m.room",
|
||||||
|
};
|
39
src/matrix/calls/common.ts
Normal file
39
src/matrix/calls/common.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
/*
|
||||||
|
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 type {Track, Stream} from "../../platform/types/MediaDevices";
|
||||||
|
|
||||||
|
export function getStreamAudioTrack(stream: Stream | undefined): Track | undefined {
|
||||||
|
return stream?.getAudioTracks()[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStreamVideoTrack(stream: Stream | undefined): Track | undefined {
|
||||||
|
return stream?.getVideoTracks()[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MuteSettings {
|
||||||
|
constructor (public readonly microphone: boolean = false, public readonly camera: boolean = false) {}
|
||||||
|
|
||||||
|
toggleCamera(): MuteSettings {
|
||||||
|
return new MuteSettings(this.microphone, !this.camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMicrophone(): MuteSettings {
|
||||||
|
return new MuteSettings(!this.microphone, this.camera);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CALL_LOG_TYPE = "call";
|
494
src/matrix/calls/group/GroupCall.ts
Normal file
494
src/matrix/calls/group/GroupCall.ts
Normal file
|
@ -0,0 +1,494 @@
|
||||||
|
/*
|
||||||
|
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 {ObservableMap} from "../../../observable/map/ObservableMap";
|
||||||
|
import {Member} from "./Member";
|
||||||
|
import {LocalMedia} from "../LocalMedia";
|
||||||
|
import {MuteSettings, CALL_LOG_TYPE} from "../common";
|
||||||
|
import {RoomMember} from "../../room/members/RoomMember";
|
||||||
|
import {EventEmitter} from "../../../utils/EventEmitter";
|
||||||
|
import {EventType, CallIntent} from "../callEventTypes";
|
||||||
|
|
||||||
|
import type {Options as MemberOptions} from "./Member";
|
||||||
|
import type {BaseObservableMap} from "../../../observable/map/BaseObservableMap";
|
||||||
|
import type {Track} from "../../../platform/types/MediaDevices";
|
||||||
|
import type {SignallingMessage, MGroupCallBase, CallMembership} from "../callEventTypes";
|
||||||
|
import type {Room} from "../../room/Room";
|
||||||
|
import type {StateEvent} from "../../storage/types";
|
||||||
|
import type {Platform} from "../../../platform/web/Platform";
|
||||||
|
import type {EncryptedMessage} from "../../e2ee/olm/Encryption";
|
||||||
|
import type {ILogItem, ILogger} from "../../../logging/types";
|
||||||
|
import type {Storage} from "../../storage/idb/Storage";
|
||||||
|
|
||||||
|
export enum GroupCallState {
|
||||||
|
Fledgling = "fledgling",
|
||||||
|
Creating = "creating",
|
||||||
|
Created = "created",
|
||||||
|
Joining = "joining",
|
||||||
|
Joined = "joined",
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMemberKey(userId: string, deviceId: string) {
|
||||||
|
return JSON.stringify(userId)+`,`+JSON.stringify(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberKeyIsForUser(key: string, userId: string) {
|
||||||
|
return key.startsWith(JSON.stringify(userId)+`,`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeviceFromMemberKey(key: string): string {
|
||||||
|
return JSON.parse(`[${key}]`)[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Options = Omit<MemberOptions, "emitUpdate" | "confId" | "encryptDeviceMessage"> & {
|
||||||
|
emitUpdate: (call: GroupCall, params?: any) => void;
|
||||||
|
encryptDeviceMessage: (roomId: string, userId: string, message: SignallingMessage<MGroupCallBase>, log: ILogItem) => Promise<EncryptedMessage>,
|
||||||
|
storage: Storage,
|
||||||
|
logger: ILogger,
|
||||||
|
};
|
||||||
|
|
||||||
|
class JoinedData {
|
||||||
|
constructor(
|
||||||
|
public readonly logItem: ILogItem,
|
||||||
|
public readonly membersLogItem: ILogItem,
|
||||||
|
public localMedia: LocalMedia,
|
||||||
|
public localMuteSettings: MuteSettings
|
||||||
|
) {}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.localMedia.dispose();
|
||||||
|
this.logItem.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GroupCall extends EventEmitter<{change: never}> {
|
||||||
|
private readonly _members: ObservableMap<string, Member> = new ObservableMap();
|
||||||
|
private _memberOptions: MemberOptions;
|
||||||
|
private _state: GroupCallState;
|
||||||
|
private bufferedDeviceMessages = new Map<string, Set<SignallingMessage<MGroupCallBase>>>();
|
||||||
|
private joinedData?: JoinedData;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly id: string,
|
||||||
|
newCall: boolean,
|
||||||
|
private callContent: Record<string, any>,
|
||||||
|
public readonly roomId: string,
|
||||||
|
private readonly options: Options,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this._state = newCall ? GroupCallState.Fledgling : GroupCallState.Created;
|
||||||
|
this._memberOptions = Object.assign({}, options, {
|
||||||
|
confId: this.id,
|
||||||
|
emitUpdate: member => this._members.update(getMemberKey(member.userId, member.deviceId), member),
|
||||||
|
encryptDeviceMessage: (userId: string, message: SignallingMessage<MGroupCallBase>, log) => {
|
||||||
|
return this.options.encryptDeviceMessage(this.roomId, userId, message, log);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get localMedia(): LocalMedia | undefined { return this.joinedData?.localMedia; }
|
||||||
|
get members(): BaseObservableMap<string, Member> { return this._members; }
|
||||||
|
|
||||||
|
get isTerminated(): boolean {
|
||||||
|
return this.callContent?.["m.terminated"] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isRinging(): boolean {
|
||||||
|
return this._state === GroupCallState.Created && this.intent === "m.ring" && !this.isMember(this.options.ownUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
get name(): string {
|
||||||
|
return this.callContent?.["m.name"];
|
||||||
|
}
|
||||||
|
|
||||||
|
get intent(): CallIntent {
|
||||||
|
return this.callContent?.["m.intent"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives access the log item for this call while joined.
|
||||||
|
* Can be used for call diagnostics while in the call.
|
||||||
|
**/
|
||||||
|
get logItem(): ILogItem | undefined {
|
||||||
|
return this.joinedData?.logItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
async join(localMedia: LocalMedia): Promise<void> {
|
||||||
|
if (this._state !== GroupCallState.Created || this.joinedData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const logItem = this.options.logger.child({
|
||||||
|
l: "answer call",
|
||||||
|
t: CALL_LOG_TYPE,
|
||||||
|
id: this.id,
|
||||||
|
ownSessionId: this.options.sessionId
|
||||||
|
});
|
||||||
|
const membersLogItem = logItem.child("member connections");
|
||||||
|
const joinedData = new JoinedData(
|
||||||
|
logItem,
|
||||||
|
membersLogItem,
|
||||||
|
localMedia,
|
||||||
|
new MuteSettings()
|
||||||
|
);
|
||||||
|
this.joinedData = joinedData;
|
||||||
|
await joinedData.logItem.wrap("join", async log => {
|
||||||
|
this._state = GroupCallState.Joining;
|
||||||
|
this.emitChange();
|
||||||
|
const memberContent = await this._createJoinPayload();
|
||||||
|
// send m.call.member state event
|
||||||
|
const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCallMember, this.options.ownUserId, memberContent, {log});
|
||||||
|
await request.response();
|
||||||
|
this.emitChange();
|
||||||
|
// send invite to all members that are < my userId
|
||||||
|
for (const [,member] of this._members) {
|
||||||
|
this.connectToMember(member, joinedData, log);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setMedia(localMedia: LocalMedia): Promise<void> {
|
||||||
|
if ((this._state === GroupCallState.Joining || this._state === GroupCallState.Joined) && this.joinedData) {
|
||||||
|
const oldMedia = this.joinedData.localMedia;
|
||||||
|
this.joinedData.localMedia = localMedia;
|
||||||
|
await Promise.all(Array.from(this._members.values()).map(m => {
|
||||||
|
return m.setMedia(localMedia, oldMedia);
|
||||||
|
}));
|
||||||
|
oldMedia?.stopExcept(localMedia);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setMuted(muteSettings: MuteSettings): Promise<void> {
|
||||||
|
const {joinedData} = this;
|
||||||
|
if (!joinedData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
joinedData.localMuteSettings = muteSettings;
|
||||||
|
await Promise.all(Array.from(this._members.values()).map(m => {
|
||||||
|
return m.setMuted(joinedData.localMuteSettings);
|
||||||
|
}));
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
get muteSettings(): MuteSettings | undefined {
|
||||||
|
return this.joinedData?.localMuteSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasJoined() {
|
||||||
|
return this._state === GroupCallState.Joining || this._state === GroupCallState.Joined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async leave(): Promise<void> {
|
||||||
|
const {joinedData} = this;
|
||||||
|
if (!joinedData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await joinedData.logItem.wrap("leave", async log => {
|
||||||
|
try {
|
||||||
|
const memberContent = await this._leaveCallMemberContent();
|
||||||
|
// send m.call.member state event
|
||||||
|
if (memberContent) {
|
||||||
|
const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCallMember, this.options.ownUserId, memberContent, {log});
|
||||||
|
await request.response();
|
||||||
|
// our own user isn't included in members, so not in the count
|
||||||
|
if (this.intent === CallIntent.Ring && this._members.size === 0) {
|
||||||
|
await this.terminate(log);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.set("already_left", true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.disconnect(log);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate(log?: ILogItem): Promise<void> {
|
||||||
|
return this.options.logger.wrapOrRun(log, {l: "terminate call", t: CALL_LOG_TYPE}, async log => {
|
||||||
|
if (this._state === GroupCallState.Fledgling) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCall, this.id, Object.assign({}, this.callContent, {
|
||||||
|
"m.terminated": true
|
||||||
|
}), {log});
|
||||||
|
await request.response();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
create(type: "m.video" | "m.voice", log?: ILogItem): Promise<void> {
|
||||||
|
return this.options.logger.wrapOrRun(log, {l: "create call", t: CALL_LOG_TYPE}, async log => {
|
||||||
|
if (this._state !== GroupCallState.Fledgling) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._state = GroupCallState.Creating;
|
||||||
|
this.emitChange();
|
||||||
|
this.callContent = Object.assign({
|
||||||
|
"m.type": type,
|
||||||
|
}, this.callContent);
|
||||||
|
const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCall, this.id, this.callContent!, {log});
|
||||||
|
await request.response();
|
||||||
|
this._state = GroupCallState.Created;
|
||||||
|
this.emitChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
updateCallEvent(callContent: Record<string, any>, syncLog: ILogItem) {
|
||||||
|
syncLog.wrap({l: "update call", t: CALL_LOG_TYPE, id: this.id}, log => {
|
||||||
|
this.callContent = callContent;
|
||||||
|
if (this._state === GroupCallState.Creating) {
|
||||||
|
this._state = GroupCallState.Created;
|
||||||
|
}
|
||||||
|
log.set("status", this._state);
|
||||||
|
this.emitChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
updateMembership(userId: string, callMembership: CallMembership, syncLog: ILogItem) {
|
||||||
|
syncLog.wrap({l: "update call membership", t: CALL_LOG_TYPE, id: this.id, userId}, log => {
|
||||||
|
const devices = callMembership["m.devices"];
|
||||||
|
const previousDeviceIds = this.getDeviceIdsForUserId(userId);
|
||||||
|
for (const device of devices) {
|
||||||
|
const deviceId = device.device_id;
|
||||||
|
const memberKey = getMemberKey(userId, deviceId);
|
||||||
|
log.wrap({l: "update device membership", id: memberKey, sessionId: device.session_id}, log => {
|
||||||
|
if (userId === this.options.ownUserId && deviceId === this.options.ownDeviceId) {
|
||||||
|
if (this._state === GroupCallState.Joining) {
|
||||||
|
log.set("update_own", true);
|
||||||
|
this._state = GroupCallState.Joined;
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let member = this._members.get(memberKey);
|
||||||
|
const sessionIdChanged = member && member.sessionId !== device.session_id;
|
||||||
|
if (member && !sessionIdChanged) {
|
||||||
|
log.set("update", true);
|
||||||
|
member.updateCallInfo(device, log);
|
||||||
|
} else {
|
||||||
|
if (member && sessionIdChanged) {
|
||||||
|
log.set("removedSessionId", member.sessionId);
|
||||||
|
const disconnectLogItem = member.disconnect(false);
|
||||||
|
if (disconnectLogItem) {
|
||||||
|
log.refDetached(disconnectLogItem);
|
||||||
|
}
|
||||||
|
this._members.remove(memberKey);
|
||||||
|
member = undefined;
|
||||||
|
}
|
||||||
|
log.set("add", true);
|
||||||
|
member = new Member(
|
||||||
|
RoomMember.fromUserId(this.roomId, userId, "join"),
|
||||||
|
device, this._memberOptions,
|
||||||
|
);
|
||||||
|
this._members.add(memberKey, member);
|
||||||
|
if (this.joinedData) {
|
||||||
|
this.connectToMember(member, this.joinedData, log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// flush pending messages, either after having created the member,
|
||||||
|
// or updated the session id with updateCallInfo
|
||||||
|
this.flushPendingIncomingDeviceMessages(member, log);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newDeviceIds = new Set<string>(devices.map(call => call.device_id));
|
||||||
|
// remove user as member of any calls not present anymore
|
||||||
|
for (const previousDeviceId of previousDeviceIds) {
|
||||||
|
if (!newDeviceIds.has(previousDeviceId)) {
|
||||||
|
this.removeMemberDevice(userId, previousDeviceId, log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (userId === this.options.ownUserId && !newDeviceIds.has(this.options.ownDeviceId)) {
|
||||||
|
this.removeOwnDevice(log);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
removeMembership(userId: string, syncLog: ILogItem) {
|
||||||
|
const deviceIds = this.getDeviceIdsForUserId(userId);
|
||||||
|
syncLog.wrap({
|
||||||
|
l: "remove call member",
|
||||||
|
t: CALL_LOG_TYPE,
|
||||||
|
id: this.id,
|
||||||
|
userId
|
||||||
|
}, log => {
|
||||||
|
for (const deviceId of deviceIds) {
|
||||||
|
this.removeMemberDevice(userId, deviceId, log);
|
||||||
|
}
|
||||||
|
if (userId === this.options.ownUserId) {
|
||||||
|
this.removeOwnDevice(log);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private flushPendingIncomingDeviceMessages(member: Member, log: ILogItem) {
|
||||||
|
const memberKey = getMemberKey(member.userId, member.deviceId);
|
||||||
|
const bufferedMessages = this.bufferedDeviceMessages.get(memberKey);
|
||||||
|
// check if we have any pending message for the member with (userid, deviceid, sessionid)
|
||||||
|
if (bufferedMessages) {
|
||||||
|
for (const message of bufferedMessages) {
|
||||||
|
if (message.content.sender_session_id === member.sessionId) {
|
||||||
|
member.handleDeviceMessage(message, log);
|
||||||
|
bufferedMessages.delete(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bufferedMessages.size === 0) {
|
||||||
|
this.bufferedDeviceMessages.delete(memberKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDeviceIdsForUserId(userId: string): string[] {
|
||||||
|
return Array.from(this._members.keys())
|
||||||
|
.filter(key => memberKeyIsForUser(key, userId))
|
||||||
|
.map(key => getDeviceFromMemberKey(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
private isMember(userId: string): boolean {
|
||||||
|
return Array.from(this._members.keys()).some(key => memberKeyIsForUser(key, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeOwnDevice(log: ILogItem) {
|
||||||
|
log.set("leave_own", true);
|
||||||
|
this.disconnect(log);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
disconnect(log: ILogItem) {
|
||||||
|
if (this._state === GroupCallState.Joined) {
|
||||||
|
for (const [,member] of this._members) {
|
||||||
|
const disconnectLogItem = member.disconnect(true);
|
||||||
|
if (disconnectLogItem) {
|
||||||
|
log.refDetached(disconnectLogItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._state = GroupCallState.Created;
|
||||||
|
}
|
||||||
|
this.joinedData?.dispose();
|
||||||
|
this.joinedData = undefined;
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
private removeMemberDevice(userId: string, deviceId: string, log: ILogItem) {
|
||||||
|
const memberKey = getMemberKey(userId, deviceId);
|
||||||
|
log.wrap({l: "remove device member", id: memberKey}, log => {
|
||||||
|
const member = this._members.get(memberKey);
|
||||||
|
if (member) {
|
||||||
|
log.set("leave", true);
|
||||||
|
this._members.remove(memberKey);
|
||||||
|
const disconnectLogItem = member.disconnect(false);
|
||||||
|
if (disconnectLogItem) {
|
||||||
|
log.refDetached(disconnectLogItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.emitChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
handleDeviceMessage(message: SignallingMessage<MGroupCallBase>, userId: string, deviceId: string, syncLog: ILogItem) {
|
||||||
|
// TODO: return if we are not membering to the call
|
||||||
|
const key = getMemberKey(userId, deviceId);
|
||||||
|
let member = this._members.get(key);
|
||||||
|
if (member && message.content.sender_session_id === member.sessionId) {
|
||||||
|
member.handleDeviceMessage(message, syncLog);
|
||||||
|
} else {
|
||||||
|
const item = syncLog.log({
|
||||||
|
l: "call: buffering to_device message, member not found",
|
||||||
|
t: CALL_LOG_TYPE,
|
||||||
|
id: this.id,
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
sessionId: message.content.sender_session_id,
|
||||||
|
type: message.type
|
||||||
|
});
|
||||||
|
syncLog.refDetached(item);
|
||||||
|
// we haven't received the m.call.member yet for this caller (or with this session id).
|
||||||
|
// buffer the device messages or create the member/call as it should arrive in a moment
|
||||||
|
let messages = this.bufferedDeviceMessages.get(key);
|
||||||
|
if (!messages) {
|
||||||
|
messages = new Set();
|
||||||
|
this.bufferedDeviceMessages.set(key, messages);
|
||||||
|
}
|
||||||
|
messages.add(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _createJoinPayload() {
|
||||||
|
const {storage} = this.options;
|
||||||
|
const txn = await storage.readTxn([storage.storeNames.roomState]);
|
||||||
|
const stateEvent = await txn.roomState.get(this.roomId, EventType.GroupCallMember, this.options.ownUserId);
|
||||||
|
const stateContent = stateEvent?.event?.content ?? {
|
||||||
|
["m.calls"]: []
|
||||||
|
};
|
||||||
|
const callsInfo = stateContent["m.calls"];
|
||||||
|
let callInfo = callsInfo.find(c => c["m.call_id"] === this.id);
|
||||||
|
if (!callInfo) {
|
||||||
|
callInfo = {
|
||||||
|
["m.call_id"]: this.id,
|
||||||
|
["m.devices"]: []
|
||||||
|
};
|
||||||
|
callsInfo.push(callInfo);
|
||||||
|
}
|
||||||
|
callInfo["m.devices"] = callInfo["m.devices"].filter(d => d["device_id"] !== this.options.ownDeviceId);
|
||||||
|
callInfo["m.devices"].push({
|
||||||
|
["device_id"]: this.options.ownDeviceId,
|
||||||
|
["session_id"]: this.options.sessionId,
|
||||||
|
feeds: [{purpose: "m.usermedia"}]
|
||||||
|
});
|
||||||
|
return stateContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _leaveCallMemberContent(): Promise<Record<string, any> | undefined> {
|
||||||
|
const {storage} = this.options;
|
||||||
|
const txn = await storage.readTxn([storage.storeNames.roomState]);
|
||||||
|
const stateEvent = await txn.roomState.get(this.roomId, EventType.GroupCallMember, this.options.ownUserId);
|
||||||
|
if (stateEvent) {
|
||||||
|
const content = stateEvent.event.content;
|
||||||
|
const callInfo = content["m.calls"]?.find(c => c["m.call_id"] === this.id);
|
||||||
|
if (callInfo) {
|
||||||
|
const devicesInfo = callInfo["m.devices"];
|
||||||
|
const deviceIndex = devicesInfo.findIndex(d => d["device_id"] === this.options.ownDeviceId);
|
||||||
|
if (deviceIndex !== -1) {
|
||||||
|
devicesInfo.splice(deviceIndex, 1);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private connectToMember(member: Member, joinedData: JoinedData, log: ILogItem) {
|
||||||
|
const memberKey = getMemberKey(member.userId, member.deviceId);
|
||||||
|
const logItem = joinedData.membersLogItem.child({l: "member", id: memberKey});
|
||||||
|
logItem.set("sessionId", member.sessionId);
|
||||||
|
log.wrap({l: "connect", id: memberKey}, log => {
|
||||||
|
// Safari can't send a MediaStream to multiple sources, so clone it
|
||||||
|
const connectItem = member.connect(joinedData.localMedia.clone(), joinedData.localMuteSettings, logItem);
|
||||||
|
if (connectItem) {
|
||||||
|
log.refDetached(connectItem);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
protected emitChange() {
|
||||||
|
this.emit("change");
|
||||||
|
this.options.emitUpdate(this);
|
||||||
|
}
|
||||||
|
}
|
288
src/matrix/calls/group/Member.ts
Normal file
288
src/matrix/calls/group/Member.ts
Normal file
|
@ -0,0 +1,288 @@
|
||||||
|
/*
|
||||||
|
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 {PeerCall, CallState} from "../PeerCall";
|
||||||
|
import {makeTxnId, makeId} from "../../common";
|
||||||
|
import {EventType, CallErrorCode} from "../callEventTypes";
|
||||||
|
import {formatToDeviceMessagesPayload} from "../../common";
|
||||||
|
|
||||||
|
import type {MuteSettings} from "../common";
|
||||||
|
import type {Options as PeerCallOptions, RemoteMedia} from "../PeerCall";
|
||||||
|
import type {LocalMedia} from "../LocalMedia";
|
||||||
|
import type {HomeServerApi} from "../../net/HomeServerApi";
|
||||||
|
import type {MCallBase, MGroupCallBase, SignallingMessage, CallDeviceMembership} from "../callEventTypes";
|
||||||
|
import type {GroupCall} from "./GroupCall";
|
||||||
|
import type {RoomMember} from "../../room/members/RoomMember";
|
||||||
|
import type {EncryptedMessage} from "../../e2ee/olm/Encryption";
|
||||||
|
import type {ILogItem} from "../../../logging/types";
|
||||||
|
|
||||||
|
export type Options = Omit<PeerCallOptions, "emitUpdate" | "sendSignallingMessage"> & {
|
||||||
|
confId: string,
|
||||||
|
ownUserId: string,
|
||||||
|
ownDeviceId: string,
|
||||||
|
// local session id of our client
|
||||||
|
sessionId: string,
|
||||||
|
hsApi: HomeServerApi,
|
||||||
|
encryptDeviceMessage: (userId: string, message: SignallingMessage<MGroupCallBase>, log: ILogItem) => Promise<EncryptedMessage>,
|
||||||
|
emitUpdate: (participant: Member, params?: any) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorCodesWithoutRetry = [
|
||||||
|
CallErrorCode.UserHangup,
|
||||||
|
CallErrorCode.AnsweredElsewhere,
|
||||||
|
CallErrorCode.Replaced,
|
||||||
|
CallErrorCode.UserBusy,
|
||||||
|
CallErrorCode.Transfered,
|
||||||
|
CallErrorCode.NewSession
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
class MemberConnection {
|
||||||
|
public retryCount: number = 0;
|
||||||
|
public peerCall?: PeerCall;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public localMedia: LocalMedia,
|
||||||
|
public localMuteSettings: MuteSettings,
|
||||||
|
public readonly logItem: ILogItem
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Member {
|
||||||
|
private connection?: MemberConnection;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly member: RoomMember,
|
||||||
|
private callDeviceMembership: CallDeviceMembership,
|
||||||
|
private readonly options: Options,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives access the log item for this item once joined to the group call.
|
||||||
|
* The signalling for this member will be log in this item.
|
||||||
|
* Can be used for call diagnostics while in the call.
|
||||||
|
**/
|
||||||
|
get logItem(): ILogItem | undefined {
|
||||||
|
return this.connection?.logItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
get remoteMedia(): RemoteMedia | undefined {
|
||||||
|
return this.connection?.peerCall?.remoteMedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
get remoteMuteSettings(): MuteSettings | undefined {
|
||||||
|
return this.connection?.peerCall?.remoteMuteSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConnected(): boolean {
|
||||||
|
return this.connection?.peerCall?.state === CallState.Connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
get userId(): string {
|
||||||
|
return this.member.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get deviceId(): string {
|
||||||
|
return this.callDeviceMembership.device_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** session id of the member */
|
||||||
|
get sessionId(): string {
|
||||||
|
return this.callDeviceMembership.session_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get dataChannel(): any | undefined {
|
||||||
|
return this.connection?.peerCall?.dataChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
connect(localMedia: LocalMedia, localMuteSettings: MuteSettings, memberLogItem: ILogItem): ILogItem | undefined {
|
||||||
|
if (this.connection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const connection = new MemberConnection(localMedia, localMuteSettings, memberLogItem);
|
||||||
|
this.connection = connection;
|
||||||
|
let connectLogItem;
|
||||||
|
connection.logItem.wrap("connect", async log => {
|
||||||
|
connectLogItem = log;
|
||||||
|
await this.callIfNeeded(log);
|
||||||
|
});
|
||||||
|
return connectLogItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
private callIfNeeded(log: ILogItem): Promise<void> {
|
||||||
|
return log.wrap("callIfNeeded", async log => {
|
||||||
|
// otherwise wait for it to connect
|
||||||
|
let shouldInitiateCall;
|
||||||
|
// the lexicographically lower side initiates the call
|
||||||
|
if (this.member.userId === this.options.ownUserId) {
|
||||||
|
shouldInitiateCall = this.deviceId > this.options.ownDeviceId;
|
||||||
|
} else {
|
||||||
|
shouldInitiateCall = this.member.userId > this.options.ownUserId;
|
||||||
|
}
|
||||||
|
if (shouldInitiateCall) {
|
||||||
|
const connection = this.connection!;
|
||||||
|
connection.peerCall = this._createPeerCall(makeId("c"));
|
||||||
|
await connection.peerCall.call(
|
||||||
|
connection.localMedia,
|
||||||
|
connection.localMuteSettings,
|
||||||
|
log
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log.set("wait_for_invite", true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
disconnect(hangup: boolean): ILogItem | undefined {
|
||||||
|
const {connection} = this;
|
||||||
|
if (!connection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let disconnectLogItem;
|
||||||
|
connection.logItem.wrap("disconnect", async log => {
|
||||||
|
disconnectLogItem = log;
|
||||||
|
if (hangup) {
|
||||||
|
connection.peerCall?.hangup(CallErrorCode.UserHangup, log);
|
||||||
|
} else {
|
||||||
|
await connection.peerCall?.close(undefined, log);
|
||||||
|
}
|
||||||
|
connection.peerCall?.dispose();
|
||||||
|
connection.localMedia?.dispose();
|
||||||
|
this.connection = undefined;
|
||||||
|
});
|
||||||
|
connection.logItem.finish();
|
||||||
|
return disconnectLogItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
updateCallInfo(callDeviceMembership: CallDeviceMembership, causeItem: ILogItem) {
|
||||||
|
this.callDeviceMembership = callDeviceMembership;
|
||||||
|
if (this.connection) {
|
||||||
|
this.connection.logItem.refDetached(causeItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
emitUpdateFromPeerCall = (peerCall: PeerCall, params: any, log: ILogItem): void => {
|
||||||
|
const connection = this.connection!;
|
||||||
|
if (peerCall.state === CallState.Ringing) {
|
||||||
|
connection.logItem.wrap("ringing, answer peercall", answerLog => {
|
||||||
|
log.refDetached(answerLog);
|
||||||
|
return peerCall.answer(connection.localMedia, connection.localMuteSettings, answerLog);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (peerCall.state === CallState.Ended) {
|
||||||
|
const hangupReason = peerCall.hangupReason;
|
||||||
|
peerCall.dispose();
|
||||||
|
connection.peerCall = undefined;
|
||||||
|
if (hangupReason && !errorCodesWithoutRetry.includes(hangupReason)) {
|
||||||
|
connection.retryCount += 1;
|
||||||
|
const {retryCount} = connection;
|
||||||
|
connection.logItem.wrap({l: "retry connection", retryCount}, async retryLog => {
|
||||||
|
log.refDetached(retryLog);
|
||||||
|
if (retryCount <= 3) {
|
||||||
|
await this.callIfNeeded(retryLog);
|
||||||
|
} else {
|
||||||
|
const disconnectLogItem = this.disconnect(false);
|
||||||
|
if (disconnectLogItem) {
|
||||||
|
retryLog.refDetached(disconnectLogItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.options.emitUpdate(this, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
sendSignallingMessage = async (message: SignallingMessage<MCallBase>, log: ILogItem): Promise<void> => {
|
||||||
|
const groupMessage = message as SignallingMessage<MGroupCallBase>;
|
||||||
|
groupMessage.content.conf_id = this.options.confId;
|
||||||
|
groupMessage.content.device_id = this.options.ownDeviceId;
|
||||||
|
groupMessage.content.party_id = this.options.ownDeviceId;
|
||||||
|
groupMessage.content.sender_session_id = this.options.sessionId;
|
||||||
|
groupMessage.content.dest_session_id = this.sessionId;
|
||||||
|
// const encryptedMessages = await this.options.encryptDeviceMessage(this.member.userId, groupMessage, log);
|
||||||
|
// const payload = formatToDeviceMessagesPayload(encryptedMessages);
|
||||||
|
const payload = {
|
||||||
|
messages: {
|
||||||
|
[this.member.userId]: {
|
||||||
|
[this.deviceId]: groupMessage.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// TODO: remove this for release
|
||||||
|
log.set("payload", groupMessage.content);
|
||||||
|
const request = this.options.hsApi.sendToDevice(
|
||||||
|
message.type,
|
||||||
|
//"m.room.encrypted",
|
||||||
|
payload,
|
||||||
|
makeTxnId(),
|
||||||
|
{log}
|
||||||
|
);
|
||||||
|
await request.response();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
handleDeviceMessage(message: SignallingMessage<MGroupCallBase>, syncLog: ILogItem): void{
|
||||||
|
const {connection} = this;
|
||||||
|
if (connection) {
|
||||||
|
const destSessionId = message.content.dest_session_id;
|
||||||
|
if (destSessionId !== this.options.sessionId) {
|
||||||
|
const logItem = connection.logItem.log({l: "ignoring to_device event with wrong session_id", destSessionId, type: message.type});
|
||||||
|
syncLog.refDetached(logItem);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.type === EventType.Invite && !connection.peerCall) {
|
||||||
|
connection.peerCall = this._createPeerCall(message.content.call_id);
|
||||||
|
}
|
||||||
|
if (connection.peerCall) {
|
||||||
|
const item = connection.peerCall.handleIncomingSignallingMessage(message, this.deviceId, connection.logItem);
|
||||||
|
syncLog.refDetached(item);
|
||||||
|
} else {
|
||||||
|
// TODO: need to buffer events until invite comes?
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
syncLog.log({l: "member not connected", userId: this.userId, deviceId: this.deviceId});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
async setMedia(localMedia: LocalMedia, previousMedia: LocalMedia): Promise<void> {
|
||||||
|
const {connection} = this;
|
||||||
|
if (connection) {
|
||||||
|
connection.localMedia = connection.localMedia.replaceClone(connection.localMedia, previousMedia);
|
||||||
|
await connection.peerCall?.setMedia(connection.localMedia, connection.logItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setMuted(muteSettings: MuteSettings): Promise<void> {
|
||||||
|
const {connection} = this;
|
||||||
|
if (connection) {
|
||||||
|
connection.localMuteSettings = muteSettings;
|
||||||
|
await connection.peerCall?.setMuted(muteSettings, connection.logItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _createPeerCall(callId: string): PeerCall {
|
||||||
|
return new PeerCall(callId, Object.assign({}, this.options, {
|
||||||
|
emitUpdate: this.emitUpdateFromPeerCall,
|
||||||
|
sendSignallingMessage: this.sendSignallingMessage
|
||||||
|
}), this.connection!.logItem);
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,16 +15,37 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {groupBy} from "../utils/groupBy";
|
||||||
|
|
||||||
|
|
||||||
export function makeTxnId() {
|
export function makeTxnId() {
|
||||||
|
return makeId("t");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeId(prefix) {
|
||||||
const n = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
|
const n = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
|
||||||
const str = n.toString(16);
|
const str = n.toString(16);
|
||||||
return "t" + "0".repeat(14 - str.length) + str;
|
return prefix + "0".repeat(14 - str.length) + str;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isTxnId(txnId) {
|
export function isTxnId(txnId) {
|
||||||
return txnId.startsWith("t") && txnId.length === 15;
|
return txnId.startsWith("t") && txnId.length === 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatToDeviceMessagesPayload(messages) {
|
||||||
|
const messagesByUser = groupBy(messages, message => message.device.userId);
|
||||||
|
const payload = {
|
||||||
|
messages: Array.from(messagesByUser.entries()).reduce((userMap, [userId, messages]) => {
|
||||||
|
userMap[userId] = messages.reduce((deviceMap, message) => {
|
||||||
|
deviceMap[message.device.deviceId] = message.content;
|
||||||
|
return deviceMap;
|
||||||
|
}, {});
|
||||||
|
return userMap;
|
||||||
|
}, {})
|
||||||
|
};
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
export function tests() {
|
export function tests() {
|
||||||
return {
|
return {
|
||||||
"isTxnId succeeds on result of makeTxnId": assert => {
|
"isTxnId succeeds on result of makeTxnId": assert => {
|
||||||
|
|
|
@ -69,6 +69,14 @@ export class DecryptionResult {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get userId(): string | undefined {
|
||||||
|
return this.device?.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get deviceId(): string | undefined {
|
||||||
|
return this.device?.deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
get isVerificationUnknown(): boolean {
|
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;
|
||||||
|
|
|
@ -15,13 +15,11 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {verifyEd25519Signature, SIGNATURE_ALGORITHM} from "./common.js";
|
import {verifyEd25519Signature, SIGNATURE_ALGORITHM} from "./common.js";
|
||||||
import {HistoryVisibility, shouldShareKey} from "./common.js";
|
|
||||||
import {RoomMember} from "../room/members/RoomMember.js";
|
|
||||||
|
|
||||||
const TRACKING_STATUS_OUTDATED = 0;
|
const TRACKING_STATUS_OUTDATED = 0;
|
||||||
const TRACKING_STATUS_UPTODATE = 1;
|
const TRACKING_STATUS_UPTODATE = 1;
|
||||||
|
|
||||||
function addRoomToIdentity(identity, userId, roomId) {
|
export function addRoomToIdentity(identity, userId, roomId) {
|
||||||
if (!identity) {
|
if (!identity) {
|
||||||
identity = {
|
identity = {
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
@ -81,57 +79,28 @@ export class DeviceTracker {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return Promise<{added: string[], removed: string[]}> the user ids for who the room was added or removed to the userIdentity,
|
writeMemberChanges(room, memberChanges, txn) {
|
||||||
* and with who a key should be now be shared
|
return Promise.all(Array.from(memberChanges.values()).map(async memberChange => {
|
||||||
**/
|
return this._applyMemberChange(memberChange, txn);
|
||||||
async writeMemberChanges(room, memberChanges, historyVisibility, txn) {
|
|
||||||
const added = [];
|
|
||||||
const removed = [];
|
|
||||||
await Promise.all(Array.from(memberChanges.values()).map(async memberChange => {
|
|
||||||
// keys should now be shared with this member?
|
|
||||||
// add the room to the userIdentity if so
|
|
||||||
if (shouldShareKey(memberChange.membership, historyVisibility)) {
|
|
||||||
if (await this._addRoomToUserIdentity(memberChange.roomId, memberChange.userId, txn)) {
|
|
||||||
added.push(memberChange.userId);
|
|
||||||
}
|
|
||||||
} else if (shouldShareKey(memberChange.previousMembership, historyVisibility)) {
|
|
||||||
// try to remove room we were previously sharing the key with the member but not anymore
|
|
||||||
const {roomId} = memberChange;
|
|
||||||
// if we left the room, remove room from all user identities in the room
|
|
||||||
if (memberChange.userId === this._ownUserId) {
|
|
||||||
const userIds = await txn.roomMembers.getAllUserIds(roomId);
|
|
||||||
await Promise.all(userIds.map(userId => {
|
|
||||||
return this._removeRoomFromUserIdentity(roomId, userId, txn);
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
await this._removeRoomFromUserIdentity(roomId, memberChange.userId, txn);
|
|
||||||
}
|
|
||||||
removed.push(memberChange.userId);
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
return {added, removed};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async trackRoom(room, historyVisibility, log) {
|
async trackRoom(room, log) {
|
||||||
if (room.isTrackingMembers || !room.isEncrypted) {
|
if (room.isTrackingMembers || !room.isEncrypted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const memberList = await room.loadMemberList(undefined, log);
|
const memberList = await room.loadMemberList(log);
|
||||||
const txn = await this._storage.readWriteTxn([
|
|
||||||
this._storage.storeNames.roomSummary,
|
|
||||||
this._storage.storeNames.userIdentities,
|
|
||||||
]);
|
|
||||||
try {
|
try {
|
||||||
|
const txn = await this._storage.readWriteTxn([
|
||||||
|
this._storage.storeNames.roomSummary,
|
||||||
|
this._storage.storeNames.userIdentities,
|
||||||
|
]);
|
||||||
let isTrackingChanges;
|
let isTrackingChanges;
|
||||||
try {
|
try {
|
||||||
isTrackingChanges = room.writeIsTrackingMembers(true, txn);
|
isTrackingChanges = room.writeIsTrackingMembers(true, txn);
|
||||||
const members = Array.from(memberList.members.values());
|
const members = Array.from(memberList.members.values());
|
||||||
log.set("members", members.length);
|
log.set("members", members.length);
|
||||||
await Promise.all(members.map(async member => {
|
await this._writeJoinedMembers(members, txn);
|
||||||
if (shouldShareKey(member.membership, historyVisibility)) {
|
|
||||||
await this._addRoomToUserIdentity(member.roomId, member.userId, txn);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
txn.abort();
|
txn.abort();
|
||||||
throw err;
|
throw err;
|
||||||
|
@ -143,43 +112,21 @@ export class DeviceTracker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async writeHistoryVisibility(room, historyVisibility, syncTxn, log) {
|
async _writeJoinedMembers(members, txn) {
|
||||||
const added = [];
|
await Promise.all(members.map(async member => {
|
||||||
const removed = [];
|
if (member.membership === "join") {
|
||||||
if (room.isTrackingMembers && room.isEncrypted) {
|
await this._writeMember(member, txn);
|
||||||
await log.wrap("rewriting userIdentities", async log => {
|
}
|
||||||
const memberList = await room.loadMemberList(syncTxn, log);
|
}));
|
||||||
try {
|
|
||||||
const members = Array.from(memberList.members.values());
|
|
||||||
log.set("members", members.length);
|
|
||||||
await Promise.all(members.map(async member => {
|
|
||||||
if (shouldShareKey(member.membership, historyVisibility)) {
|
|
||||||
if (await this._addRoomToUserIdentity(member.roomId, member.userId, syncTxn)) {
|
|
||||||
added.push(member.userId);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (await this._removeRoomFromUserIdentity(member.roomId, member.userId, syncTxn)) {
|
|
||||||
removed.push(member.userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
} finally {
|
|
||||||
memberList.release();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {added, removed};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async _addRoomToUserIdentity(roomId, userId, txn) {
|
async _writeMember(member, txn) {
|
||||||
const {userIdentities} = txn;
|
const {userIdentities} = txn;
|
||||||
const identity = await userIdentities.get(userId);
|
const identity = await userIdentities.get(member.userId);
|
||||||
const updatedIdentity = addRoomToIdentity(identity, userId, roomId);
|
const updatedIdentity = addRoomToIdentity(identity, member.userId, member.roomId);
|
||||||
if (updatedIdentity) {
|
if (updatedIdentity) {
|
||||||
userIdentities.set(updatedIdentity);
|
userIdentities.set(updatedIdentity);
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async _removeRoomFromUserIdentity(roomId, userId, txn) {
|
async _removeRoomFromUserIdentity(roomId, userId, txn) {
|
||||||
|
@ -194,9 +141,28 @@ export class DeviceTracker {
|
||||||
} else {
|
} else {
|
||||||
userIdentities.set(identity);
|
userIdentities.set(identity);
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
}
|
||||||
|
|
||||||
|
async _applyMemberChange(memberChange, txn) {
|
||||||
|
// TODO: depends whether we encrypt for invited users??
|
||||||
|
// add room
|
||||||
|
if (memberChange.hasJoined) {
|
||||||
|
await this._writeMember(memberChange.member, txn);
|
||||||
|
}
|
||||||
|
// remove room
|
||||||
|
else if (memberChange.hasLeft) {
|
||||||
|
const {roomId} = memberChange;
|
||||||
|
// if we left the room, remove room from all user identities in the room
|
||||||
|
if (memberChange.userId === this._ownUserId) {
|
||||||
|
const userIds = await txn.roomMembers.getAllUserIds(roomId);
|
||||||
|
await Promise.all(userIds.map(userId => {
|
||||||
|
return this._removeRoomFromUserIdentity(roomId, userId, txn);
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
await this._removeRoomFromUserIdentity(roomId, memberChange.userId, txn);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _queryKeys(userIds, hsApi, log) {
|
async _queryKeys(userIds, hsApi, log) {
|
||||||
|
@ -248,12 +214,11 @@ 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
|
||||||
await Promise.all(deviceIdentities.map(async deviceIdentity => {
|
deviceIdentities = 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);
|
||||||
|
@ -398,338 +363,3 @@ 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";
|
|
||||||
import {MemberChange} from "../room/members/RoomMember";
|
|
||||||
|
|
||||||
export function tests() {
|
|
||||||
|
|
||||||
function createUntrackedRoomMock(roomId, joinedUserIds, invitedUserIds = []) {
|
|
||||||
return {
|
|
||||||
id: roomId,
|
|
||||||
isTrackingMembers: false,
|
|
||||||
isEncrypted: true,
|
|
||||||
loadMemberList: () => {
|
|
||||||
const joinedMembers = joinedUserIds.map(userId => {return RoomMember.fromUserId(roomId, userId, "join");});
|
|
||||||
const invitedMembers = invitedUserIds.map(userId => {return RoomMember.fromUserId(roomId, userId, "invite");});
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeMemberListToStorage(room, storage) {
|
|
||||||
const txn = await storage.readWriteTxn([
|
|
||||||
storage.storeNames.roomMembers,
|
|
||||||
]);
|
|
||||||
const memberList = await room.loadMemberList(txn);
|
|
||||||
try {
|
|
||||||
for (const member of memberList.members.values()) {
|
|
||||||
txn.roomMembers.set(member.serialize());
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
txn.abort();
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
memberList.release();
|
|
||||||
}
|
|
||||||
await txn.complete();
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomId = "!abc:hs.tld";
|
|
||||||
|
|
||||||
return {
|
|
||||||
"trackRoom only writes joined members with history visibility of joined": 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, HistoryVisibility.Joined, 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, HistoryVisibility.Joined, 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, HistoryVisibility.Joined, 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");
|
|
||||||
},
|
|
||||||
"change history visibility from joined to invited adds invitees": 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",
|
|
||||||
});
|
|
||||||
// alice is joined, bob is invited
|
|
||||||
const room = await createUntrackedRoomMock(roomId,
|
|
||||||
["@alice:hs.tld"], ["@bob:hs.tld"]);
|
|
||||||
await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item);
|
|
||||||
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]);
|
|
||||||
assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined);
|
|
||||||
const {added, removed} = await tracker.writeHistoryVisibility(room, HistoryVisibility.Invited, txn, NullLoggerInstance.item);
|
|
||||||
assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld");
|
|
||||||
assert.deepEqual(added, ["@bob:hs.tld"]);
|
|
||||||
assert.deepEqual(removed, []);
|
|
||||||
},
|
|
||||||
"change history visibility from invited to joined removes invitees": 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",
|
|
||||||
});
|
|
||||||
// alice is joined, bob is invited
|
|
||||||
const room = await createUntrackedRoomMock(roomId,
|
|
||||||
["@alice:hs.tld"], ["@bob:hs.tld"]);
|
|
||||||
await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item);
|
|
||||||
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]);
|
|
||||||
assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld");
|
|
||||||
const {added, removed} = await tracker.writeHistoryVisibility(room, HistoryVisibility.Joined, txn, NullLoggerInstance.item);
|
|
||||||
assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined);
|
|
||||||
assert.deepEqual(added, []);
|
|
||||||
assert.deepEqual(removed, ["@bob:hs.tld"]);
|
|
||||||
},
|
|
||||||
"adding invitee with history visibility of invited adds room to userIdentities": 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 = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"]);
|
|
||||||
await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item);
|
|
||||||
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]);
|
|
||||||
// inviting a new member
|
|
||||||
const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "invite"));
|
|
||||||
const {added, removed} = await tracker.writeMemberChanges(room, [inviteChange], HistoryVisibility.Invited, txn);
|
|
||||||
assert.deepEqual(added, ["@bob:hs.tld"]);
|
|
||||||
assert.deepEqual(removed, []);
|
|
||||||
assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld");
|
|
||||||
},
|
|
||||||
"adding invitee with history visibility of joined doesn't add room": 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 = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"]);
|
|
||||||
await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item);
|
|
||||||
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]);
|
|
||||||
// inviting a new member
|
|
||||||
const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "invite"));
|
|
||||||
const memberChanges = new Map([[inviteChange.userId, inviteChange]]);
|
|
||||||
const {added, removed} = await tracker.writeMemberChanges(room, memberChanges, HistoryVisibility.Joined, txn);
|
|
||||||
assert.deepEqual(added, []);
|
|
||||||
assert.deepEqual(removed, []);
|
|
||||||
assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined);
|
|
||||||
},
|
|
||||||
"getting all devices after changing history visibility now includes invitees": 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, HistoryVisibility.Invited, NullLoggerInstance.item);
|
|
||||||
const hsApi = createQueryKeysHSApiMock();
|
|
||||||
// write memberlist from room mock to mock storage,
|
|
||||||
// as devicesForTrackedRoom reads directly from roomMembers store.
|
|
||||||
await writeMemberListToStorage(room, storage);
|
|
||||||
const devices = await tracker.devicesForTrackedRoom(roomId, 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");
|
|
||||||
},
|
|
||||||
"rejecting invite with history visibility of invited removes room from user identity": 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",
|
|
||||||
});
|
|
||||||
// alice is joined, bob is invited
|
|
||||||
const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"], ["@bob:hs.tld"]);
|
|
||||||
await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item);
|
|
||||||
const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]);
|
|
||||||
// reject invite
|
|
||||||
const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "leave"), "invite");
|
|
||||||
const memberChanges = new Map([[inviteChange.userId, inviteChange]]);
|
|
||||||
const {added, removed} = await tracker.writeMemberChanges(room, memberChanges, HistoryVisibility.Invited, txn);
|
|
||||||
assert.deepEqual(added, []);
|
|
||||||
assert.deepEqual(removed, ["@bob:hs.tld"]);
|
|
||||||
assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined);
|
|
||||||
},
|
|
||||||
"remove room from user identity sharing multiple rooms with us preserves other room": 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",
|
|
||||||
});
|
|
||||||
// alice is joined, bob is invited
|
|
||||||
const room1 = await createUntrackedRoomMock("!abc:hs.tld", ["@alice:hs.tld", "@bob:hs.tld"]);
|
|
||||||
const room2 = await createUntrackedRoomMock("!def:hs.tld", ["@alice:hs.tld", "@bob:hs.tld"]);
|
|
||||||
await tracker.trackRoom(room1, HistoryVisibility.Joined, NullLoggerInstance.item);
|
|
||||||
await tracker.trackRoom(room2, HistoryVisibility.Joined, NullLoggerInstance.item);
|
|
||||||
const txn1 = await storage.readTxn([storage.storeNames.userIdentities]);
|
|
||||||
assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld", "!def:hs.tld"]);
|
|
||||||
const leaveChange = new MemberChange(RoomMember.fromUserId(room2.id, "@bob:hs.tld", "leave"), "join");
|
|
||||||
const memberChanges = new Map([[leaveChange.userId, leaveChange]]);
|
|
||||||
const txn2 = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]);
|
|
||||||
await tracker.writeMemberChanges(room2, memberChanges, HistoryVisibility.Joined, txn2);
|
|
||||||
await txn2.complete();
|
|
||||||
const txn3 = await storage.readTxn([storage.storeNames.userIdentities]);
|
|
||||||
assert.deepEqual((await txn3.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld"]);
|
|
||||||
},
|
|
||||||
"add room to user identity sharing multiple rooms with us preserves other room": 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",
|
|
||||||
});
|
|
||||||
// alice is joined, bob is invited
|
|
||||||
const room1 = await createUntrackedRoomMock("!abc:hs.tld", ["@alice:hs.tld", "@bob:hs.tld"]);
|
|
||||||
const room2 = await createUntrackedRoomMock("!def:hs.tld", ["@alice:hs.tld", "@bob:hs.tld"]);
|
|
||||||
await tracker.trackRoom(room1, HistoryVisibility.Joined, NullLoggerInstance.item);
|
|
||||||
const txn1 = await storage.readTxn([storage.storeNames.userIdentities]);
|
|
||||||
assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld"]);
|
|
||||||
await tracker.trackRoom(room2, HistoryVisibility.Joined, NullLoggerInstance.item);
|
|
||||||
const txn2 = await storage.readTxn([storage.storeNames.userIdentities]);
|
|
||||||
assert.deepEqual((await txn2.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld", "!def:hs.tld"]);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -18,11 +18,9 @@ import {MEGOLM_ALGORITHM, DecryptionSource} from "./common.js";
|
||||||
import {groupEventsBySession} from "./megolm/decryption/utils";
|
import {groupEventsBySession} from "./megolm/decryption/utils";
|
||||||
import {mergeMap} from "../../utils/mergeMap";
|
import {mergeMap} from "../../utils/mergeMap";
|
||||||
import {groupBy} from "../../utils/groupBy";
|
import {groupBy} from "../../utils/groupBy";
|
||||||
import {makeTxnId} from "../common.js";
|
import {makeTxnId, formatToDeviceMessagesPayload} from "../common.js";
|
||||||
import {iterateResponseStateEvents} from "../room/common";
|
|
||||||
|
|
||||||
const ENCRYPTED_TYPE = "m.room.encrypted";
|
const ENCRYPTED_TYPE = "m.room.encrypted";
|
||||||
const ROOM_HISTORY_VISIBILITY_TYPE = "m.room.history_visibility";
|
|
||||||
// how often ensureMessageKeyIsShared can check if it needs to
|
// how often ensureMessageKeyIsShared can check if it needs to
|
||||||
// create a new outbound session
|
// create a new outbound session
|
||||||
// note that encrypt could still create a new session
|
// note that encrypt could still create a new session
|
||||||
|
@ -47,7 +45,6 @@ export class RoomEncryption {
|
||||||
this._isFlushingRoomKeyShares = false;
|
this._isFlushingRoomKeyShares = false;
|
||||||
this._lastKeyPreShareTime = null;
|
this._lastKeyPreShareTime = null;
|
||||||
this._keySharePromise = null;
|
this._keySharePromise = null;
|
||||||
this._historyVisibility = undefined;
|
|
||||||
this._disposed = false;
|
this._disposed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,68 +77,22 @@ export class RoomEncryption {
|
||||||
this._senderDeviceCache = new Map(); // purge the sender device cache
|
this._senderDeviceCache = new Map(); // purge the sender device cache
|
||||||
}
|
}
|
||||||
|
|
||||||
async writeSync(roomResponse, memberChanges, txn, log) {
|
async writeMemberChanges(memberChanges, txn, log) {
|
||||||
let historyVisibility = await this._loadHistoryVisibilityIfNeeded(this._historyVisibility, txn);
|
let shouldFlush = false;
|
||||||
const addedMembers = [];
|
const memberChangesArray = Array.from(memberChanges.values());
|
||||||
const removedMembers = [];
|
// this also clears our session if we leave the room ourselves
|
||||||
// update the historyVisibility if needed
|
if (memberChangesArray.some(m => m.hasLeft)) {
|
||||||
await iterateResponseStateEvents(roomResponse, event => {
|
|
||||||
// TODO: can the same state event appear twice? Hence we would be rewriting the useridentities twice...
|
|
||||||
// we'll see in the logs
|
|
||||||
if(event.state_key === "" && event.type === ROOM_HISTORY_VISIBILITY_TYPE) {
|
|
||||||
const newHistoryVisibility = event?.content?.history_visibility;
|
|
||||||
if (newHistoryVisibility !== historyVisibility) {
|
|
||||||
return log.wrap({
|
|
||||||
l: "history_visibility changed",
|
|
||||||
from: historyVisibility,
|
|
||||||
to: newHistoryVisibility
|
|
||||||
}, async log => {
|
|
||||||
historyVisibility = newHistoryVisibility;
|
|
||||||
const result = await this._deviceTracker.writeHistoryVisibility(this._room, historyVisibility, txn, log);
|
|
||||||
addedMembers.push(...result.added);
|
|
||||||
removedMembers.push(...result.removed);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// process member changes
|
|
||||||
if (memberChanges.size) {
|
|
||||||
const result = await this._deviceTracker.writeMemberChanges(
|
|
||||||
this._room, memberChanges, historyVisibility, txn);
|
|
||||||
addedMembers.push(...result.added);
|
|
||||||
removedMembers.push(...result.removed);
|
|
||||||
}
|
|
||||||
// discard key if somebody (including ourselves) left
|
|
||||||
if (removedMembers.length) {
|
|
||||||
log.log({
|
log.log({
|
||||||
l: "discardOutboundSession",
|
l: "discardOutboundSession",
|
||||||
leftUsers: removedMembers,
|
leftUsers: memberChangesArray.filter(m => m.hasLeft).map(m => m.userId),
|
||||||
});
|
});
|
||||||
this._megolmEncryption.discardOutboundSession(this._room.id, txn);
|
this._megolmEncryption.discardOutboundSession(this._room.id, txn);
|
||||||
}
|
}
|
||||||
let shouldFlush = false;
|
if (memberChangesArray.some(m => m.hasJoined)) {
|
||||||
// add room to userIdentities if needed, and share the current key with them
|
shouldFlush = await this._addShareRoomKeyOperationForNewMembers(memberChangesArray, txn, log);
|
||||||
if (addedMembers.length) {
|
|
||||||
shouldFlush = await this._addShareRoomKeyOperationForMembers(addedMembers, txn, log);
|
|
||||||
}
|
}
|
||||||
return {shouldFlush, historyVisibility};
|
await this._deviceTracker.writeMemberChanges(this._room, memberChanges, txn);
|
||||||
}
|
return shouldFlush;
|
||||||
|
|
||||||
afterSync({historyVisibility}) {
|
|
||||||
this._historyVisibility = historyVisibility;
|
|
||||||
}
|
|
||||||
|
|
||||||
async _loadHistoryVisibilityIfNeeded(historyVisibility, txn = undefined) {
|
|
||||||
if (!historyVisibility) {
|
|
||||||
if (!txn) {
|
|
||||||
txn = await this._storage.readTxn([this._storage.storeNames.roomState]);
|
|
||||||
}
|
|
||||||
const visibilityEntry = await txn.roomState.get(this._room.id, ROOM_HISTORY_VISIBILITY_TYPE, "");
|
|
||||||
if (visibilityEntry) {
|
|
||||||
return visibilityEntry.event?.content?.history_visibility;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return historyVisibility;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async prepareDecryptAll(events, newKeys, source, txn) {
|
async prepareDecryptAll(events, newKeys, source, txn) {
|
||||||
|
@ -323,15 +274,10 @@ export class RoomEncryption {
|
||||||
}
|
}
|
||||||
|
|
||||||
async _shareNewRoomKey(roomKeyMessage, hsApi, log) {
|
async _shareNewRoomKey(roomKeyMessage, hsApi, log) {
|
||||||
this._historyVisibility = await this._loadHistoryVisibilityIfNeeded(this._historyVisibility);
|
|
||||||
await this._deviceTracker.trackRoom(this._room, this._historyVisibility, log);
|
|
||||||
const devices = await this._deviceTracker.devicesForTrackedRoom(this._room.id, hsApi, log);
|
|
||||||
const userIds = Array.from(devices.reduce((set, device) => set.add(device.userId), new Set()));
|
|
||||||
|
|
||||||
let writeOpTxn = await this._storage.readWriteTxn([this._storage.storeNames.operations]);
|
let writeOpTxn = await this._storage.readWriteTxn([this._storage.storeNames.operations]);
|
||||||
let operation;
|
let operation;
|
||||||
try {
|
try {
|
||||||
operation = this._writeRoomKeyShareOperation(roomKeyMessage, userIds, writeOpTxn);
|
operation = this._writeRoomKeyShareOperation(roomKeyMessage, null, writeOpTxn);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
writeOpTxn.abort();
|
writeOpTxn.abort();
|
||||||
throw err;
|
throw err;
|
||||||
|
@ -342,7 +288,8 @@ export class RoomEncryption {
|
||||||
await this._processShareRoomKeyOperation(operation, hsApi, log);
|
await this._processShareRoomKeyOperation(operation, hsApi, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _addShareRoomKeyOperationForMembers(userIds, txn, log) {
|
async _addShareRoomKeyOperationForNewMembers(memberChangesArray, txn, log) {
|
||||||
|
const userIds = memberChangesArray.filter(m => m.hasJoined).map(m => m.userId);
|
||||||
const roomKeyMessage = await this._megolmEncryption.createRoomKeyMessage(
|
const roomKeyMessage = await this._megolmEncryption.createRoomKeyMessage(
|
||||||
this._room.id, txn);
|
this._room.id, txn);
|
||||||
if (roomKeyMessage) {
|
if (roomKeyMessage) {
|
||||||
|
@ -395,9 +342,18 @@ export class RoomEncryption {
|
||||||
|
|
||||||
async _processShareRoomKeyOperation(operation, hsApi, log) {
|
async _processShareRoomKeyOperation(operation, hsApi, log) {
|
||||||
log.set("id", operation.id);
|
log.set("id", operation.id);
|
||||||
this._historyVisibility = await this._loadHistoryVisibilityIfNeeded(this._historyVisibility);
|
|
||||||
await this._deviceTracker.trackRoom(this._room, this._historyVisibility, log);
|
await this._deviceTracker.trackRoom(this._room, log);
|
||||||
const devices = await this._deviceTracker.devicesForRoomMembers(this._room.id, operation.userIds, hsApi, log);
|
let devices;
|
||||||
|
if (operation.userIds === null) {
|
||||||
|
devices = await this._deviceTracker.devicesForTrackedRoom(this._room.id, hsApi, log);
|
||||||
|
const userIds = Array.from(devices.reduce((set, device) => set.add(device.userId), new Set()));
|
||||||
|
operation.userIds = userIds;
|
||||||
|
await this._updateOperationsStore(operations => operations.update(operation));
|
||||||
|
} else {
|
||||||
|
devices = await this._deviceTracker.devicesForRoomMembers(this._room.id, operation.userIds, hsApi, log);
|
||||||
|
}
|
||||||
|
|
||||||
const messages = await log.wrap("olm encrypt", log => this._olmEncryption.encrypt(
|
const messages = await log.wrap("olm encrypt", log => this._olmEncryption.encrypt(
|
||||||
"m.room_key", operation.roomKeyMessage, devices, hsApi, log));
|
"m.room_key", operation.roomKeyMessage, devices, hsApi, log));
|
||||||
const missingDevices = devices.filter(d => !messages.some(m => m.device === d));
|
const missingDevices = devices.filter(d => !messages.some(m => m.device === d));
|
||||||
|
@ -430,6 +386,7 @@ export class RoomEncryption {
|
||||||
await writeTxn.complete();
|
await writeTxn.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: make this use _sendMessagesToDevices
|
||||||
async _sendSharedMessageToDevices(type, message, devices, hsApi, log) {
|
async _sendSharedMessageToDevices(type, message, devices, hsApi, log) {
|
||||||
const devicesByUser = groupBy(devices, device => device.userId);
|
const devicesByUser = groupBy(devices, device => device.userId);
|
||||||
const payload = {
|
const payload = {
|
||||||
|
@ -447,16 +404,7 @@ export class RoomEncryption {
|
||||||
|
|
||||||
async _sendMessagesToDevices(type, messages, hsApi, log) {
|
async _sendMessagesToDevices(type, messages, hsApi, log) {
|
||||||
log.set("messages", messages.length);
|
log.set("messages", messages.length);
|
||||||
const messagesByUser = groupBy(messages, message => message.device.userId);
|
const payload = formatToDeviceMessagesPayload(messages);
|
||||||
const payload = {
|
|
||||||
messages: Array.from(messagesByUser.entries()).reduce((userMap, [userId, messages]) => {
|
|
||||||
userMap[userId] = messages.reduce((deviceMap, message) => {
|
|
||||||
deviceMap[message.device.deviceId] = message.content;
|
|
||||||
return deviceMap;
|
|
||||||
}, {});
|
|
||||||
return userMap;
|
|
||||||
}, {})
|
|
||||||
};
|
|
||||||
const txnId = makeTxnId();
|
const txnId = makeTxnId();
|
||||||
await hsApi.sendToDevice(type, payload, txnId, {log}).response();
|
await hsApi.sendToDevice(type, payload, txnId, {log}).response();
|
||||||
}
|
}
|
||||||
|
@ -551,143 +499,3 @@ class BatchDecryptionResult {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
import {createMockStorage} from "../../mocks/Storage";
|
|
||||||
import {Clock as MockClock} from "../../mocks/Clock";
|
|
||||||
import {poll} from "../../mocks/poll";
|
|
||||||
import {Instance as NullLoggerInstance} from "../../logging/NullLogger";
|
|
||||||
import {ConsoleLogger} from "../../logging/ConsoleLogger";
|
|
||||||
import {HomeServer as MockHomeServer} from "../../mocks/HomeServer.js";
|
|
||||||
|
|
||||||
export function tests() {
|
|
||||||
const roomId = "!abc:hs.tld";
|
|
||||||
return {
|
|
||||||
"ensureMessageKeyIsShared tracks room and passes correct history visibility to deviceTracker": async assert => {
|
|
||||||
const storage = await createMockStorage();
|
|
||||||
const megolmMock = {
|
|
||||||
async ensureOutboundSession() { return { }; }
|
|
||||||
};
|
|
||||||
const olmMock = {
|
|
||||||
async encrypt() { return []; }
|
|
||||||
}
|
|
||||||
let isRoomTracked = false;
|
|
||||||
let isDevicesRequested = false;
|
|
||||||
const deviceTracker = {
|
|
||||||
async trackRoom(room, historyVisibility) {
|
|
||||||
// only assert on first call
|
|
||||||
if (isRoomTracked) { return; }
|
|
||||||
assert(!isDevicesRequested);
|
|
||||||
assert.equal(room.id, roomId);
|
|
||||||
assert.equal(historyVisibility, "invited");
|
|
||||||
isRoomTracked = true;
|
|
||||||
},
|
|
||||||
async devicesForTrackedRoom() {
|
|
||||||
assert(isRoomTracked);
|
|
||||||
isDevicesRequested = true;
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
async devicesForRoomMembers() {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const writeTxn = await storage.readWriteTxn([storage.storeNames.roomState]);
|
|
||||||
writeTxn.roomState.set(roomId, {state_key: "", type: ROOM_HISTORY_VISIBILITY_TYPE, content: {
|
|
||||||
history_visibility: "invited"
|
|
||||||
}});
|
|
||||||
await writeTxn.complete();
|
|
||||||
const roomEncryption = new RoomEncryption({
|
|
||||||
room: {id: roomId},
|
|
||||||
megolmEncryption: megolmMock,
|
|
||||||
olmEncryption: olmMock,
|
|
||||||
storage,
|
|
||||||
deviceTracker,
|
|
||||||
clock: new MockClock()
|
|
||||||
});
|
|
||||||
const homeServer = new MockHomeServer();
|
|
||||||
const promise = roomEncryption.ensureMessageKeyIsShared(homeServer.api, NullLoggerInstance.item);
|
|
||||||
// need to poll because sendToDevice isn't first async step
|
|
||||||
const request = await poll(() => homeServer.requests.sendToDevice?.[0]);
|
|
||||||
request.respond({});
|
|
||||||
await promise;
|
|
||||||
assert(isRoomTracked);
|
|
||||||
assert(isDevicesRequested);
|
|
||||||
},
|
|
||||||
"encrypt tracks room and passes correct history visibility to deviceTracker": async assert => {
|
|
||||||
const storage = await createMockStorage();
|
|
||||||
const megolmMock = {
|
|
||||||
async encrypt() { return { roomKeyMessage: {} }; }
|
|
||||||
};
|
|
||||||
const olmMock = {
|
|
||||||
async encrypt() { return []; }
|
|
||||||
}
|
|
||||||
let isRoomTracked = false;
|
|
||||||
let isDevicesRequested = false;
|
|
||||||
const deviceTracker = {
|
|
||||||
async trackRoom(room, historyVisibility) {
|
|
||||||
// only assert on first call
|
|
||||||
if (isRoomTracked) { return; }
|
|
||||||
assert(!isDevicesRequested);
|
|
||||||
assert.equal(room.id, roomId);
|
|
||||||
assert.equal(historyVisibility, "invited");
|
|
||||||
isRoomTracked = true;
|
|
||||||
},
|
|
||||||
async devicesForTrackedRoom() {
|
|
||||||
assert(isRoomTracked);
|
|
||||||
isDevicesRequested = true;
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
async devicesForRoomMembers() {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const writeTxn = await storage.readWriteTxn([storage.storeNames.roomState]);
|
|
||||||
writeTxn.roomState.set(roomId, {state_key: "", type: ROOM_HISTORY_VISIBILITY_TYPE, content: {
|
|
||||||
history_visibility: "invited"
|
|
||||||
}});
|
|
||||||
await writeTxn.complete();
|
|
||||||
const roomEncryption = new RoomEncryption({
|
|
||||||
room: {id: roomId},
|
|
||||||
megolmEncryption: megolmMock,
|
|
||||||
olmEncryption: olmMock,
|
|
||||||
storage,
|
|
||||||
deviceTracker
|
|
||||||
});
|
|
||||||
const homeServer = new MockHomeServer();
|
|
||||||
const promise = roomEncryption.encrypt("m.room.message", {body: "hello"}, homeServer.api, NullLoggerInstance.item);
|
|
||||||
// need to poll because sendToDevice isn't first async step
|
|
||||||
const request = await poll(() => homeServer.requests.sendToDevice?.[0]);
|
|
||||||
request.respond({});
|
|
||||||
await promise;
|
|
||||||
assert(isRoomTracked);
|
|
||||||
assert(isDevicesRequested);
|
|
||||||
},
|
|
||||||
"writeSync passes correct history visibility to deviceTracker": async assert => {
|
|
||||||
const storage = await createMockStorage();
|
|
||||||
let isMemberChangesCalled = false;
|
|
||||||
const deviceTracker = {
|
|
||||||
async writeMemberChanges(room, memberChanges, historyVisibility, txn) {
|
|
||||||
assert.equal(historyVisibility, "invited");
|
|
||||||
isMemberChangesCalled = true;
|
|
||||||
return {removed: [], added: []};
|
|
||||||
},
|
|
||||||
async devicesForRoomMembers() {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const writeTxn = await storage.readWriteTxn([storage.storeNames.roomState]);
|
|
||||||
writeTxn.roomState.set(roomId, {state_key: "", type: ROOM_HISTORY_VISIBILITY_TYPE, content: {
|
|
||||||
history_visibility: "invited"
|
|
||||||
}});
|
|
||||||
const memberChanges = new Map([["@alice:hs.tld", {}]]);
|
|
||||||
const roomEncryption = new RoomEncryption({
|
|
||||||
room: {id: roomId},
|
|
||||||
storage,
|
|
||||||
deviceTracker
|
|
||||||
});
|
|
||||||
const roomResponse = {};
|
|
||||||
const txn = await storage.readWriteTxn([storage.storeNames.roomState]);
|
|
||||||
await roomEncryption.writeSync(roomResponse, memberChanges, txn, NullLoggerInstance.item);
|
|
||||||
assert(isMemberChangesCalled);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -69,28 +69,3 @@ export function createRoomEncryptionEvent() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Use enum when converting to TS
|
|
||||||
export const HistoryVisibility = Object.freeze({
|
|
||||||
Joined: "joined",
|
|
||||||
Invited: "invited",
|
|
||||||
WorldReadable: "world_readable",
|
|
||||||
Shared: "shared",
|
|
||||||
});
|
|
||||||
|
|
||||||
export function shouldShareKey(membership, historyVisibility) {
|
|
||||||
switch (historyVisibility) {
|
|
||||||
case HistoryVisibility.WorldReadable:
|
|
||||||
return true;
|
|
||||||
case HistoryVisibility.Shared:
|
|
||||||
// was part of room at some time
|
|
||||||
return membership !== undefined;
|
|
||||||
case HistoryVisibility.Joined:
|
|
||||||
return membership === "join";
|
|
||||||
case HistoryVisibility.Invited:
|
|
||||||
return membership === "invite" || membership === "join";
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -19,7 +19,7 @@ import {StoredRoomKey, keyFromBackup} from "../decryption/RoomKey";
|
||||||
import {MEGOLM_ALGORITHM} from "../../common";
|
import {MEGOLM_ALGORITHM} from "../../common";
|
||||||
import * as Curve25519 from "./Curve25519";
|
import * as Curve25519 from "./Curve25519";
|
||||||
import {AbortableOperation} from "../../../../utils/AbortableOperation";
|
import {AbortableOperation} from "../../../../utils/AbortableOperation";
|
||||||
import {ObservableValue} from "../../../../observable/ObservableValue";
|
import {ObservableValue} from "../../../../observable/value/ObservableValue";
|
||||||
|
|
||||||
import {SetAbortableFn} from "../../../../utils/AbortableOperation";
|
import {SetAbortableFn} from "../../../../utils/AbortableOperation";
|
||||||
import type {BackupInfo, SessionData, SessionKeyInfo, SessionInfo, KeyBackupPayload} from "./types";
|
import type {BackupInfo, SessionData, SessionKeyInfo, SessionInfo, KeyBackupPayload} from "./types";
|
||||||
|
|
|
@ -311,7 +311,7 @@ class EncryptionTarget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class EncryptedMessage {
|
export class EncryptedMessage {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly content: OlmEncryptedMessageContent,
|
public readonly content: OlmEncryptedMessageContent,
|
||||||
public readonly device: DeviceIdentity
|
public readonly device: DeviceIdentity
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
import {ILoginMethod} from "./LoginMethod";
|
|
||||||
import {PasswordLoginMethod} from "./PasswordLoginMethod";
|
|
||||||
import {SSOLoginHelper} from "./SSOLoginHelper";
|
|
||||||
import {TokenLoginMethod} from "./TokenLoginMethod";
|
|
||||||
|
|
||||||
|
|
||||||
export {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod};
|
|
|
@ -160,6 +160,10 @@ export class HomeServerApi {
|
||||||
return this._get(`/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/${encodeURIComponent(stateKey)}`, {}, undefined, options);
|
return this._get(`/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/${encodeURIComponent(stateKey)}`, {}, undefined, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendState(roomId: string, eventType: string, stateKey: string, content: Record<string, any>, options?: BaseRequestOptions): IHomeServerRequest {
|
||||||
|
return this._put(`/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/${encodeURIComponent(stateKey)}`, {}, content, options);
|
||||||
|
}
|
||||||
|
|
||||||
getLoginFlows(): IHomeServerRequest {
|
getLoginFlows(): IHomeServerRequest {
|
||||||
return this._unauthedRequest("GET", this._url("/login"));
|
return this._unauthedRequest("GET", this._url("/login"));
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,32 +29,31 @@ export class MediaRepository {
|
||||||
this._platform = platform;
|
this._platform = platform;
|
||||||
}
|
}
|
||||||
|
|
||||||
mxcUrlThumbnail(url: string, width: number, height: number, method: "crop" | "scale"): string | null {
|
mxcUrlThumbnail(url: string, width: number, height: number, method: "crop" | "scale"): string | undefined {
|
||||||
const parts = this._parseMxcUrl(url);
|
const parts = this._parseMxcUrl(url);
|
||||||
if (parts) {
|
if (parts) {
|
||||||
const [serverName, mediaId] = parts;
|
const [serverName, mediaId] = parts;
|
||||||
const httpUrl = `${this._homeserver}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
|
const httpUrl = `${this._homeserver}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
|
||||||
return httpUrl + "?" + encodeQueryParams({width: Math.round(width), height: Math.round(height), method});
|
return httpUrl + "?" + encodeQueryParams({width: Math.round(width), height: Math.round(height), method});
|
||||||
}
|
}
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
mxcUrl(url: string): string | null {
|
mxcUrl(url: string): string | undefined {
|
||||||
const parts = this._parseMxcUrl(url);
|
const parts = this._parseMxcUrl(url);
|
||||||
if (parts) {
|
if (parts) {
|
||||||
const [serverName, mediaId] = parts;
|
const [serverName, mediaId] = parts;
|
||||||
return `${this._homeserver}/_matrix/media/r0/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
|
return `${this._homeserver}/_matrix/media/r0/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _parseMxcUrl(url: string): string[] | null {
|
private _parseMxcUrl(url: string): string[] | undefined {
|
||||||
const prefix = "mxc://";
|
const prefix = "mxc://";
|
||||||
if (url.startsWith(prefix)) {
|
if (url.startsWith(prefix)) {
|
||||||
return url.substr(prefix.length).split("/", 2);
|
return url.substr(prefix.length).split("/", 2);
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {ObservableValue} from "../../observable/ObservableValue";
|
import {ObservableValue} from "../../observable/value/ObservableValue";
|
||||||
import type {ExponentialRetryDelay} from "./ExponentialRetryDelay";
|
import type {ExponentialRetryDelay} from "./ExponentialRetryDelay";
|
||||||
import type {TimeMeasure} from "../../platform/web/dom/Clock.js";
|
import type {TimeMeasure} from "../../platform/web/dom/Clock.js";
|
||||||
import type {OnlineStatus} from "../../platform/web/dom/OnlineStatus.js";
|
import type {OnlineStatus} from "../../platform/web/dom/OnlineStatus.js";
|
||||||
|
|
|
@ -17,12 +17,9 @@ limitations under the License.
|
||||||
|
|
||||||
import {BlobHandle} from "../../platform/web/dom/BlobHandle.js";
|
import {BlobHandle} from "../../platform/web/dom/BlobHandle.js";
|
||||||
|
|
||||||
export type RequestBody = BlobHandle | string | Map<string, string | {blob: BlobHandle, name: string}>;
|
|
||||||
|
|
||||||
export type EncodedBody = {
|
export type EncodedBody = {
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
// the map gets transformed to a FormData object on the web
|
body: BlobHandle | string;
|
||||||
body: RequestBody
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encodeQueryParams(queryParams?: object): string {
|
export function encodeQueryParams(queryParams?: object): string {
|
||||||
|
@ -44,11 +41,6 @@ export function encodeBody(body: BlobHandle | object): EncodedBody {
|
||||||
mimeType: blob.mimeType,
|
mimeType: blob.mimeType,
|
||||||
body: blob // will be unwrapped in request fn
|
body: blob // will be unwrapped in request fn
|
||||||
};
|
};
|
||||||
} else if (body instanceof Map) {
|
|
||||||
return {
|
|
||||||
mimeType: "multipart/form-data",
|
|
||||||
body: body
|
|
||||||
}
|
|
||||||
} else if (typeof body === "object") {
|
} else if (typeof body === "object") {
|
||||||
const json = JSON.stringify(body);
|
const json = JSON.stringify(body);
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -29,7 +29,7 @@ import {ObservedEventMap} from "./ObservedEventMap.js";
|
||||||
import {DecryptionSource} from "../e2ee/common.js";
|
import {DecryptionSource} from "../e2ee/common.js";
|
||||||
import {ensureLogItem} from "../../logging/utils";
|
import {ensureLogItem} from "../../logging/utils";
|
||||||
import {PowerLevels} from "./PowerLevels.js";
|
import {PowerLevels} from "./PowerLevels.js";
|
||||||
import {RetainedObservableValue} from "../../observable/ObservableValue";
|
import {RetainedObservableValue} from "../../observable/value/RetainedObservableValue";
|
||||||
import {TimelineReader} from "./timeline/persistence/TimelineReader";
|
import {TimelineReader} from "./timeline/persistence/TimelineReader";
|
||||||
|
|
||||||
const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
|
const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
|
||||||
|
@ -243,7 +243,7 @@ export class BaseRoom extends EventEmitter {
|
||||||
|
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
async loadMemberList(txn = undefined, log = null) {
|
async loadMemberList(log = null) {
|
||||||
if (this._memberList) {
|
if (this._memberList) {
|
||||||
// TODO: also await fetchOrLoadMembers promise here
|
// TODO: also await fetchOrLoadMembers promise here
|
||||||
this._memberList.retain();
|
this._memberList.retain();
|
||||||
|
@ -254,9 +254,6 @@ export class BaseRoom extends EventEmitter {
|
||||||
roomId: this._roomId,
|
roomId: this._roomId,
|
||||||
hsApi: this._hsApi,
|
hsApi: this._hsApi,
|
||||||
storage: this._storage,
|
storage: this._storage,
|
||||||
// pass in a transaction if we know we won't need to fetch (which would abort the transaction)
|
|
||||||
// and we want to make this operation part of the larger transaction
|
|
||||||
txn,
|
|
||||||
syncToken: this._getSyncToken(),
|
syncToken: this._getSyncToken(),
|
||||||
// to handle race between /members and /sync
|
// to handle race between /members and /sync
|
||||||
setChangedMembersMap: map => this._changedMembersDuringSync = map,
|
setChangedMembersMap: map => this._changedMembersDuringSync = map,
|
||||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {BaseObservableValue} from "../../observable/ObservableValue";
|
import {BaseObservableValue} from "../../observable/value/BaseObservableValue";
|
||||||
|
|
||||||
export class ObservedEventMap {
|
export class ObservedEventMap {
|
||||||
constructor(notifyEmpty) {
|
constructor(notifyEmpty) {
|
||||||
|
|
|
@ -30,6 +30,7 @@ const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
|
||||||
export class Room extends BaseRoom {
|
export class Room extends BaseRoom {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
super(options);
|
super(options);
|
||||||
|
this._callHandler = options.callHandler;
|
||||||
// TODO: pass pendingEvents to start like pendingOperations?
|
// TODO: pass pendingEvents to start like pendingOperations?
|
||||||
const {pendingEvents} = options;
|
const {pendingEvents} = options;
|
||||||
const relationWriter = new RelationWriter({
|
const relationWriter = new RelationWriter({
|
||||||
|
@ -139,11 +140,11 @@ export class Room extends BaseRoom {
|
||||||
}
|
}
|
||||||
log.set("newEntries", newEntries.length);
|
log.set("newEntries", newEntries.length);
|
||||||
log.set("updatedEntries", updatedEntries.length);
|
log.set("updatedEntries", updatedEntries.length);
|
||||||
let encryptionChanges;
|
let shouldFlushKeyShares = false;
|
||||||
// pass member changes to device tracker
|
// pass member changes to device tracker
|
||||||
if (roomEncryption) {
|
if (roomEncryption && this.isTrackingMembers && memberChanges?.size) {
|
||||||
encryptionChanges = await roomEncryption.writeSync(roomResponse, memberChanges, txn, log);
|
shouldFlushKeyShares = await roomEncryption.writeMemberChanges(memberChanges, txn, log);
|
||||||
log.set("shouldFlushKeyShares", encryptionChanges.shouldFlush);
|
log.set("shouldFlushKeyShares", shouldFlushKeyShares);
|
||||||
}
|
}
|
||||||
const allEntries = newEntries.concat(updatedEntries);
|
const allEntries = newEntries.concat(updatedEntries);
|
||||||
// also apply (decrypted) timeline entries to the summary changes
|
// also apply (decrypted) timeline entries to the summary changes
|
||||||
|
@ -178,6 +179,7 @@ export class Room extends BaseRoom {
|
||||||
removedPendingEvents = await this._sendQueue.removeRemoteEchos(roomResponse.timeline.events, txn, log);
|
removedPendingEvents = await this._sendQueue.removeRemoteEchos(roomResponse.timeline.events, txn, log);
|
||||||
}
|
}
|
||||||
const powerLevelsEvent = this._getPowerLevelsEvent(roomResponse);
|
const powerLevelsEvent = this._getPowerLevelsEvent(roomResponse);
|
||||||
|
this._updateCallHandler(roomResponse, txn, log);
|
||||||
return {
|
return {
|
||||||
summaryChanges,
|
summaryChanges,
|
||||||
roomEncryption,
|
roomEncryption,
|
||||||
|
@ -188,7 +190,7 @@ export class Room extends BaseRoom {
|
||||||
memberChanges,
|
memberChanges,
|
||||||
heroChanges,
|
heroChanges,
|
||||||
powerLevelsEvent,
|
powerLevelsEvent,
|
||||||
encryptionChanges,
|
shouldFlushKeyShares,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -201,14 +203,11 @@ export class Room extends BaseRoom {
|
||||||
const {
|
const {
|
||||||
summaryChanges, newEntries, updatedEntries, newLiveKey,
|
summaryChanges, newEntries, updatedEntries, newLiveKey,
|
||||||
removedPendingEvents, memberChanges, powerLevelsEvent,
|
removedPendingEvents, memberChanges, powerLevelsEvent,
|
||||||
heroChanges, roomEncryption, encryptionChanges
|
heroChanges, roomEncryption
|
||||||
} = changes;
|
} = changes;
|
||||||
log.set("id", this.id);
|
log.set("id", this.id);
|
||||||
this._syncWriter.afterSync(newLiveKey);
|
this._syncWriter.afterSync(newLiveKey);
|
||||||
this._setEncryption(roomEncryption);
|
this._setEncryption(roomEncryption);
|
||||||
if (this._roomEncryption) {
|
|
||||||
this._roomEncryption.afterSync(encryptionChanges);
|
|
||||||
}
|
|
||||||
if (memberChanges.size) {
|
if (memberChanges.size) {
|
||||||
if (this._changedMembersDuringSync) {
|
if (this._changedMembersDuringSync) {
|
||||||
for (const [userId, memberChange] of memberChanges.entries()) {
|
for (const [userId, memberChange] of memberChanges.entries()) {
|
||||||
|
@ -218,6 +217,9 @@ export class Room extends BaseRoom {
|
||||||
if (this._memberList) {
|
if (this._memberList) {
|
||||||
this._memberList.afterSync(memberChanges);
|
this._memberList.afterSync(memberChanges);
|
||||||
}
|
}
|
||||||
|
if (this._callHandler) {
|
||||||
|
this._callHandler.updateRoomMembers(this, memberChanges);
|
||||||
|
}
|
||||||
if (this._observedMembers) {
|
if (this._observedMembers) {
|
||||||
this._updateObservedMembers(memberChanges);
|
this._updateObservedMembers(memberChanges);
|
||||||
}
|
}
|
||||||
|
@ -291,8 +293,8 @@ export class Room extends BaseRoom {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
needsAfterSyncCompleted({encryptionChanges}) {
|
needsAfterSyncCompleted({shouldFlushKeyShares}) {
|
||||||
return encryptionChanges?.shouldFlush;
|
return shouldFlushKeyShares;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -445,6 +447,22 @@ export class Room extends BaseRoom {
|
||||||
return this._sendQueue.pendingEvents;
|
return this._sendQueue.pendingEvents;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_updateCallHandler(roomResponse, txn, log) {
|
||||||
|
if (this._callHandler) {
|
||||||
|
const stateEvents = roomResponse.state?.events;
|
||||||
|
if (stateEvents?.length) {
|
||||||
|
this._callHandler.handleRoomState(this, stateEvents, txn, log);
|
||||||
|
}
|
||||||
|
let timelineEvents = roomResponse.timeline?.events;
|
||||||
|
if (timelineEvents) {
|
||||||
|
const timelineStateEvents = timelineEvents.filter(e => typeof e.state_key === "string");
|
||||||
|
if (timelineEvents.length !== 0) {
|
||||||
|
this._callHandler.handleRoomState(this, timelineStateEvents, txn, log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @package */
|
/** @package */
|
||||||
writeIsTrackingMembers(value, txn) {
|
writeIsTrackingMembers(value, txn) {
|
||||||
return this._summary.writeIsTrackingMembers(value, txn);
|
return this._summary.writeIsTrackingMembers(value, txn);
|
||||||
|
|
|
@ -37,8 +37,7 @@ type CreateRoomPayload = {
|
||||||
invite?: string[];
|
invite?: string[];
|
||||||
room_alias_name?: string;
|
room_alias_name?: string;
|
||||||
creation_content?: {"m.federate": boolean};
|
creation_content?: {"m.federate": boolean};
|
||||||
initial_state: { type: string; state_key: string; content: Record<string, any> }[];
|
initial_state: {type: string; state_key: string; content: Record<string, any>}[]
|
||||||
power_level_content_override?: Record<string, any>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ImageInfo = {
|
type ImageInfo = {
|
||||||
|
@ -63,7 +62,6 @@ type Options = {
|
||||||
invites?: string[];
|
invites?: string[];
|
||||||
avatar?: Avatar;
|
avatar?: Avatar;
|
||||||
alias?: string;
|
alias?: string;
|
||||||
powerLevelContentOverride?: Record<string, any>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultE2EEStatusForType(type: RoomType): boolean {
|
function defaultE2EEStatusForType(type: RoomType): boolean {
|
||||||
|
@ -153,9 +151,6 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> {
|
||||||
"m.federate": false
|
"m.federate": false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (this.options.powerLevelContentOverride) {
|
|
||||||
createOptions.power_level_content_override = this.options.powerLevelContentOverride;
|
|
||||||
}
|
|
||||||
if (this.isEncrypted) {
|
if (this.isEncrypted) {
|
||||||
createOptions.initial_state.push(createRoomEncryptionEvent());
|
createOptions.initial_state.push(createRoomEncryptionEvent());
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {StateEvent} from "../storage/types";
|
|
||||||
|
|
||||||
export function getPrevContentFromStateEvent(event) {
|
export function getPrevContentFromStateEvent(event) {
|
||||||
// where to look for prev_content is a bit of a mess,
|
// where to look for prev_content is a bit of a mess,
|
||||||
// see https://matrix.to/#/!NasysSDfxKxZBzJJoE:matrix.org/$DvrAbZJiILkOmOIuRsNoHmh2v7UO5CWp_rYhlGk34fQ?via=matrix.org&via=pixie.town&via=amorgan.xyz
|
// see https://matrix.to/#/!NasysSDfxKxZBzJJoE:matrix.org/$DvrAbZJiILkOmOIuRsNoHmh2v7UO5CWp_rYhlGk34fQ?via=matrix.org&via=pixie.town&via=amorgan.xyz
|
||||||
|
@ -42,83 +40,3 @@ export enum RoomType {
|
||||||
Private,
|
Private,
|
||||||
Public
|
Public
|
||||||
}
|
}
|
||||||
|
|
||||||
type RoomResponse = {
|
|
||||||
state?: {
|
|
||||||
events?: Array<StateEvent>
|
|
||||||
},
|
|
||||||
timeline?: {
|
|
||||||
events?: Array<StateEvent>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** iterates over any state events in a sync room response, in the order that they should be applied (from older to younger events) */
|
|
||||||
export function iterateResponseStateEvents(roomResponse: RoomResponse, callback: (StateEvent) => Promise<void> | void): Promise<void> | void {
|
|
||||||
let promises: Promise<void>[] | undefined = undefined;
|
|
||||||
const callCallback = stateEvent => {
|
|
||||||
const result = callback(stateEvent);
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
promises = promises ?? [];
|
|
||||||
promises.push(result);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// first iterate over state events, they precede the timeline
|
|
||||||
const stateEvents = roomResponse.state?.events;
|
|
||||||
if (stateEvents) {
|
|
||||||
for (let i = 0; i < stateEvents.length; i++) {
|
|
||||||
callCallback(stateEvents[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// now see if there are any state events within the timeline
|
|
||||||
let timelineEvents = roomResponse.timeline?.events;
|
|
||||||
if (timelineEvents) {
|
|
||||||
for (let i = 0; i < timelineEvents.length; i++) {
|
|
||||||
const event = timelineEvents[i];
|
|
||||||
if (typeof event.state_key === "string") {
|
|
||||||
callCallback(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (promises) {
|
|
||||||
return Promise.all(promises).then(() => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tests() {
|
|
||||||
return {
|
|
||||||
"test iterateResponseStateEvents with both state and timeline sections": assert => {
|
|
||||||
const roomResponse = {
|
|
||||||
state: {
|
|
||||||
events: [
|
|
||||||
{type: "m.room.member", state_key: "1"},
|
|
||||||
{type: "m.room.member", state_key: "2", content: {a: 1}},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
timeline: {
|
|
||||||
events: [
|
|
||||||
{type: "m.room.message"},
|
|
||||||
{type: "m.room.member", state_key: "3"},
|
|
||||||
{type: "m.room.message"},
|
|
||||||
{type: "m.room.member", state_key: "2", content: {a: 2}},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
} as unknown as RoomResponse;
|
|
||||||
const expectedStateKeys = ["1", "2", "3", "2"];
|
|
||||||
const expectedAForMember2 = [1, 2];
|
|
||||||
iterateResponseStateEvents(roomResponse, event => {
|
|
||||||
assert.strictEqual(event.type, "m.room.member");
|
|
||||||
assert.strictEqual(expectedStateKeys.shift(), event.state_key);
|
|
||||||
if (event.state_key === "2") {
|
|
||||||
assert.strictEqual(expectedAForMember2.shift(), event.content.a);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
assert.strictEqual(expectedStateKeys.length, 0);
|
|
||||||
assert.strictEqual(expectedAForMember2.length, 0);
|
|
||||||
},
|
|
||||||
"test iterateResponseStateEvents with empty response": assert => {
|
|
||||||
iterateResponseStateEvents({}, () => {
|
|
||||||
assert.fail("no events expected");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Reference in a new issue