forked from mystiq/hydrogen-web
Compare commits
256 commits
Author | SHA1 | Date | |
---|---|---|---|
a33d9981bd | |||
8335a50308 | |||
ee9e73d8c7 | |||
63f77feb7b | |||
04de39596f | |||
25b634bb78 | |||
96c9ea8de7 | |||
d80e970117 | |||
6db5f34ac2 | |||
df0000783d | |||
|
c898bcb46a | ||
|
97391663d3 | ||
|
7d3f22c106 | ||
|
832597447a | ||
|
236a4ab49b | ||
|
ba8cdea6b4 | ||
|
ef9f90bc36 | ||
|
67e94bd642 | ||
|
f7839135a4 | ||
|
4571ecd851 | ||
|
5091090795 | ||
|
db2b4e693c | ||
|
eee8412621 | ||
|
5e83eca3b9 | ||
|
041e628520 | ||
|
4838e19c92 | ||
|
cb0ac846c7 | ||
|
b40ce6137e | ||
|
fdefea5b88 | ||
|
39817dc36b | ||
|
708637e390 | ||
|
b6f795505d | ||
|
10522cacef | ||
|
02116103a1 | ||
|
06da5a8ae4 | ||
|
02bc7d1d7e | ||
|
09bc77073b | ||
|
4a2e14925a | ||
|
224ab2672a | ||
|
170460f5a9 | ||
|
2a5e0302dc | ||
|
f512bfcfc1 | ||
|
5b5c852401 | ||
|
58a2d1f34c | ||
|
d937b9b14b | ||
|
d3e93196e3 | ||
|
62b3a67e33 | ||
|
319ec37864 | ||
|
f5dacb4e42 | ||
|
302131c447 | ||
|
fb79326747 | ||
|
3c64f7d49b | ||
|
a82df95b82 | ||
|
cadca70946 | ||
|
8b91d8fac8 | ||
|
a5b9cb6b95 | ||
|
aeed978789 | ||
|
7b7b19476c | ||
|
ad0bd82bda | ||
|
d7657dcc4d | ||
|
176caf340f | ||
|
a40bb59dc0 | ||
|
ab64ce02b2 | ||
|
2d3b6fe973 | ||
|
550b9db4dc | ||
|
0df66b5aea | ||
|
f18520a2fe | ||
|
50b6ee91d7 | ||
|
9b0ab0c8f1 | ||
|
402cf17d22 | ||
|
bfaba63f47 | ||
|
544afef902 | ||
|
dd878bb8d6 | ||
|
dea3852425 | ||
|
4c17612b05 | ||
|
f9f49b7640 | ||
|
0718f1e77e | ||
|
09fd1a5113 | ||
|
832b840a15 | ||
|
adfecf0778 | ||
|
5fa6793958 | ||
|
1e5179f835 | ||
|
bc385e2cdc | ||
|
0bf021ea87 | ||
|
fdd60a7516 | ||
|
63bdbee39c | ||
|
8a976861fb | ||
|
a23df8a545 | ||
|
17f42f523a | ||
|
f6011f3f34 | ||
|
86c0e9e669 | ||
|
f337940202 | ||
|
b7fd22c7f9 | ||
|
66a59e6f4d | ||
|
e345d0b33e | ||
|
be8962cec2 | ||
|
8b39346409 | ||
|
fb58d9c9ef | ||
|
22831e710c | ||
|
faa8cae532 | ||
|
8d766ac504 | ||
|
c8a8eb10b5 | ||
|
d79e5f7806 | ||
|
7feaa479c0 | ||
|
1456e308a8 | ||
|
313e65e00c | ||
|
612b878793 | ||
|
8aa96e8031 | ||
|
7ac2c7c7fa | ||
|
de02456641 | ||
|
994667205f | ||
|
ecb3a66dfc | ||
|
e1ee258630 | ||
|
83b5d3b68e | ||
|
7a1591e0ce | ||
|
07db5450b7 | ||
|
081de5afa8 | ||
|
dece42dce3 | ||
|
b29287c47e | ||
|
9bdf9c500b | ||
|
9e2d355573 | ||
|
ce5db47708 | ||
|
da0a918c18 | ||
|
043cc9f12c | ||
|
80fb953688 | ||
|
f15e23762a | ||
|
f440457875 | ||
|
a8cab98666 | ||
|
ac7be0c7a1 | ||
|
d731eab51c | ||
|
f7b302d34f | ||
|
5ba74b1d75 | ||
|
c5f4a75d4b | ||
|
2f3db89e0a | ||
|
1ef382f3a9 | ||
|
161e29b36e | ||
|
2947f9f6ff | ||
|
c873804543 | ||
|
43e8cc9e52 | ||
|
bf87ed7eae | ||
|
8c02541b69 | ||
|
599e519f22 | ||
|
d5e24bf6e8 | ||
|
bb5711db7e | ||
|
88808b0b06 | ||
|
c9bca52e82 | ||
|
6718198d9c | ||
|
7b9e681d55 | ||
|
8291aea2f7 | ||
|
f073f40e31 | ||
|
963324c767 | ||
|
eac75644e7 | ||
|
0bdbb96036 | ||
|
d292e1f5ad | ||
|
cd9e00b847 | ||
|
3941b7e3f0 | ||
|
efd9f70e92 | ||
|
204948db64 | ||
|
a85d2c96d6 | ||
|
28b686dae7 | ||
|
dd82469ab4 | ||
|
3bf6a46a39 | ||
|
e42e76a21c | ||
|
8ec0bd7295 | ||
|
ff2129f36a | ||
|
1aa2ff5c10 | ||
|
34ce8a8e3c | ||
|
652e2c6d3b | ||
|
c0445f2182 | ||
|
b76fd1d792 | ||
|
751dfa66a8 | ||
|
a3c6d744f5 | ||
|
b9f316e7c3 | ||
|
d448ee1722 | ||
|
da87470996 | ||
|
b319c0acb0 | ||
|
e90e573bf9 | ||
|
a68f0bba39 | ||
|
ca94c65dac | ||
|
fba3275f5b | ||
|
fc93acfd8d | ||
|
d398e490eb | ||
|
0ab611b013 | ||
|
bb923b8eb9 | ||
|
73cd96fe3a | ||
|
4929839fe9 | ||
|
c59f65e43b | ||
|
fd3a0f0126 | ||
|
ccfd63dfeb | ||
|
5b54280ac2 | ||
|
bd5bf7d456 | ||
|
ad8ad22cc1 | ||
|
3369bda2f0 | ||
|
7430aa7aab | ||
|
3bc453d5ca | ||
|
84bac0afe9 | ||
|
9cb7d89097 | ||
|
d688fa4737 | ||
|
0dfd24af22 | ||
|
34eac94da3 | ||
|
fbdd512e06 | ||
|
5eec724712 | ||
|
93165cb947 | ||
|
e3372f0f2b | ||
|
5a3cf03f0b | ||
|
c050ade03c | ||
|
cc29dc045d | ||
|
09b2437e72 | ||
|
cfd347335b | ||
|
d322f380ad | ||
|
f658dc2e4b | ||
|
7a3eabf39c | ||
|
48da6c782c | ||
|
b00bbc7daf | ||
|
9fbe8a4e32 | ||
|
623939c671 | ||
|
fccc41f4b9 | ||
|
3b66ed8c17 | ||
|
8fe8981ffa | ||
|
375d8b066c | ||
|
69ada73dd4 | ||
|
2129a97588 | ||
|
4caabae895 | ||
|
d0375141f8 | ||
|
a644621889 | ||
|
d31f127982 | ||
|
9d8a578dce | ||
|
38c3774869 | ||
|
ba647d012d | ||
|
fc873757d8 | ||
|
ec1cc89cf9 | ||
|
a336623f3a | ||
|
9300347e9b | ||
|
f49d580d49 | ||
|
263948faa3 | ||
|
52f0690c70 | ||
|
7a24059337 | ||
|
4fd1918202 | ||
|
4ae3a5bf7a | ||
|
5be00f051f | ||
|
e7f4ce6175 | ||
|
09bc0f1b60 | ||
|
76d04ee277 | ||
|
f28dfc6964 | ||
|
c14e4f3eed | ||
|
5d42f372f6 | ||
|
4c3e0a6ff0 | ||
|
d9bfca10e1 | ||
|
bf2fb52691 | ||
|
646cbe0fff | ||
|
92e8fc8ad3 | ||
|
92c79c853d | ||
|
55229252d7 | ||
|
3efc426fed | ||
|
04d5b9bfda | ||
|
66f6c4aba1 |
86 changed files with 3236 additions and 754 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,5 +1,6 @@
|
||||||
*.sublime-project
|
*.sublime-project
|
||||||
*.sublime-workspace
|
*.sublime-workspace
|
||||||
|
.DS_Store
|
||||||
node_modules
|
node_modules
|
||||||
fetchlogs
|
fetchlogs
|
||||||
sessionexports
|
sessionexports
|
||||||
|
@ -9,3 +10,4 @@ lib
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
.eslintcache
|
.eslintcache
|
||||||
.tmp
|
.tmp
|
||||||
|
tmp/
|
||||||
|
|
|
@ -19,6 +19,7 @@ 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"]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
18
.woodpecker.yml
Normal file
18
.woodpecker.yml
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
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
Normal file
14
Makefile
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
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}'
|
11
doc/IMPORT-ISSUES.md
Normal file
11
doc/IMPORT-ISSUES.md
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
## 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.
|
|
@ -48,8 +48,8 @@ const assetPaths = {
|
||||||
wasmBundle: olmJsPath
|
wasmBundle: olmJsPath
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
import "hydrogen-view-sdk/theme-element-light.css";
|
import "hydrogen-view-sdk/assets/theme-element-light.css";
|
||||||
// OR import "hydrogen-view-sdk/theme-element-dark.css";
|
// OR import "hydrogen-view-sdk/assets/theme-element-dark.css";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const app = document.querySelector<HTMLDivElement>('#app')!
|
const app = document.querySelector<HTMLDivElement>('#app')!
|
||||||
|
|
204
doc/THEMING.md
Normal file
204
doc/THEMING.md
Normal file
|
@ -0,0 +1,204 @@
|
||||||
|
# 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
Normal file
206
doc/UI/ui.md
Normal file
|
@ -0,0 +1,206 @@
|
||||||
|
## 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.
|
BIN
doc/images/coloring-process.png
Normal file
BIN
doc/images/coloring-process.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.8 KiB |
BIN
doc/images/svg-icon-example.png
Normal file
BIN
doc/images/svg-icon-example.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
BIN
doc/images/theming-architecture.png
Normal file
BIN
doc/images/theming-architecture.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 19 KiB |
10
package.json
10
package.json
|
@ -1,10 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "hydrogen-web",
|
"name": "hydrogen-web",
|
||||||
"version": "0.2.31",
|
"version": "0.3.1",
|
||||||
"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",
|
||||||
|
@ -47,13 +50,14 @@
|
||||||
"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.3.5",
|
"typescript": "^4.7.0",
|
||||||
"vite": "^2.9.8",
|
"vite": "^2.9.8",
|
||||||
"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.3.tgz",
|
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.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",
|
||||||
|
|
|
@ -14,10 +14,10 @@ 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').posix;
|
||||||
|
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;
|
||||||
|
@ -43,29 +43,54 @@ function addThemesToConfig(bundle, manifestLocations, defaultThemes) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseBundle(bundle) {
|
/**
|
||||||
|
* 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")) {
|
if (!fileName.endsWith(".css") || info.type === "asset" || info.facadeModuleId?.includes("type=runtime")) {
|
||||||
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];
|
||||||
|
@ -80,7 +105,56 @@ function parseBundle(bundle) {
|
||||||
array.push(info);
|
array.push(info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { chunkMap, assetMap, runtimeThemeChunk };
|
return chunkMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
@ -88,6 +162,7 @@ module.exports = function buildThemes(options) {
|
||||||
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",
|
||||||
|
@ -100,37 +175,34 @@ module.exports = function buildThemes(options) {
|
||||||
},
|
},
|
||||||
|
|
||||||
async buildStart() {
|
async buildStart() {
|
||||||
if (isDevelopment) { return; }
|
|
||||||
const { themeConfig } = options;
|
const { themeConfig } = options;
|
||||||
for (const [name, location] of Object.entries(themeConfig.themes)) {
|
for (const location of 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-${name}-${variant}.css`;
|
const fileName = `theme-${themeCollectionId}-${variant}.css`;
|
||||||
if (name === themeConfig.default && details.default) {
|
if (themeCollectionId === 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"] = `${name}-${variant}`;
|
defaultThemes["dark"] = `${themeCollectionId}-${variant}`;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
defaultLight = fileName;
|
defaultLight = fileName;
|
||||||
defaultThemes["light"] = `${name}-${variant}`;
|
defaultThemes["light"] = `${themeCollectionId}-${variant}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// emit the css as built theme bundle
|
// emit the css as built theme bundle
|
||||||
this.emitFile({
|
if (!isDevelopment) {
|
||||||
type: "chunk",
|
this.emitFile({ type: "chunk", id: `${location}/theme.css?variant=${variant}${details.dark ? "&dark=true" : ""}`, fileName, });
|
||||||
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
|
||||||
this.emitFile({
|
if (!isDevelopment) {
|
||||||
type: "chunk",
|
this.emitFile({ type: "chunk", id: `${location}/theme.css?type=runtime`, fileName: `theme-${themeCollectionId}-runtime.css`, });
|
||||||
id: `${location}/theme.css?type=runtime`,
|
}
|
||||||
fileName: `theme-${name}-runtime.css`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -152,7 +224,7 @@ module.exports = function buildThemes(options) {
|
||||||
if (theme === "default") {
|
if (theme === "default") {
|
||||||
theme = options.themeConfig.default;
|
theme = options.themeConfig.default;
|
||||||
}
|
}
|
||||||
const location = options.themeConfig.themes[theme];
|
const location = themeToManifestLocation.get(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") {
|
||||||
|
@ -245,30 +317,53 @@ module.exports = function buildThemes(options) {
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
generateBundle(_, bundle) {
|
async generateBundle(_, bundle) {
|
||||||
// assetMap: Mapping from asset-name (eg: element-dark.css) to AssetInfo
|
const assetMap = getMappingFromFileNameToAssetInfo(bundle);
|
||||||
// chunkMap: Mapping from theme-location (eg: hydrogen-web/src/.../css/themes/element) to a list of ChunkInfo
|
const chunkMap = getMappingFromLocationToChunkArray(bundle);
|
||||||
// types of AssetInfo and ChunkInfo can be found at https://rollupjs.org/guide/en/#generatebundle
|
const runtimeThemeChunkMap = getMappingFromLocationToRuntimeChunk(bundle);
|
||||||
const { assetMap, chunkMap, runtimeThemeChunk } = parseBundle(bundle);
|
|
||||||
const manifestLocations = [];
|
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 = {};
|
const builtAssets = {};
|
||||||
|
let themeKey;
|
||||||
for (const chunk of chunkArray) {
|
for (const chunk of chunkArray) {
|
||||||
const [, name, variant] = chunk.fileName.match(/theme-(.+)-(.+)\.css/);
|
const [, name, variant] = chunk.fileName.match(/theme-(.+)-(.+)\.css/);
|
||||||
builtAssets[`${name}-${variant}`] = assetMap.get(chunk.fileName).fileName;
|
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-assets": builtAssets,
|
||||||
"runtime-asset": assetMap.get(runtimeThemeChunk.fileName).fileName,
|
"runtime-asset": runtimeAssetLocation,
|
||||||
"derived-variables": derivedVariables,
|
"derived-variables": derivedVariables,
|
||||||
"icon": icon
|
"icon": icon,
|
||||||
};
|
};
|
||||||
const name = `theme-${manifest.name}.json`;
|
const name = `theme-${themeKey}.json`;
|
||||||
manifestLocations.push(`assets/${name}`);
|
manifestLocations.push(`${manifestLocation}/${name}`);
|
||||||
this.emitFile({
|
this.emitFile({
|
||||||
type: "asset",
|
type: "asset",
|
||||||
name,
|
name,
|
||||||
|
|
165
scripts/ci.sh
Executable file
165
scripts/ci.sh
Executable file
|
@ -0,0 +1,165 @@
|
||||||
|
#!/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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -30,12 +30,7 @@ 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let aliasMap;
|
function getValueFromAlias(alias, {aliasMap, baseVariables, resolvedMap}) {
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
@ -68,14 +63,15 @@ function parseDeclarationValue(value) {
|
||||||
return variables;
|
return variables;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDerivedVariable(decl, derive) {
|
function resolveDerivedVariable(decl, derive, maps, isDark) {
|
||||||
|
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);
|
const value = baseVariables.get(baseVariable) ?? getValueFromAlias(baseVariable, maps);
|
||||||
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!`);
|
||||||
}
|
}
|
||||||
|
@ -85,7 +81,7 @@ function resolveDerivedVariable(decl, derive) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extract(decl) {
|
function extract(decl, {aliasMap, baseVariables}) {
|
||||||
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];
|
||||||
|
@ -100,7 +96,7 @@ function extract(decl) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addResolvedVariablesToRootSelector(root, {Rule, Declaration}) {
|
function addResolvedVariablesToRootSelector(root, {Rule, Declaration}, {resolvedMap}) {
|
||||||
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) => {
|
||||||
|
@ -110,13 +106,20 @@ function addResolvedVariablesToRootSelector(root, {Rule, Declaration}) {
|
||||||
root.append(newRule);
|
root.append(newRule);
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateMapWithDerivedVariables(map, cssFileLocation) {
|
function populateMapWithDerivedVariables(map, cssFileLocation, {resolvedMap, aliasMap}) {
|
||||||
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}`))
|
||||||
];
|
];
|
||||||
map.set(location, { "derived-variables": derivedVariables });
|
const sharedObject = map.get(location);
|
||||||
|
const output = { "derived-variables": derivedVariables };
|
||||||
|
if (sharedObject) {
|
||||||
|
Object.assign(sharedObject, output);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
map.set(location, output);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -133,10 +136,10 @@ function populateMapWithDerivedVariables(map, cssFileLocation) {
|
||||||
* @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 = {}) => {
|
||||||
aliasMap = new Map();
|
const aliasMap = new Map();
|
||||||
resolvedMap = new Map();
|
const resolvedMap = new Map();
|
||||||
baseVariables = new Map();
|
const baseVariables = new Map();
|
||||||
isDark = false;
|
const maps = { aliasMap, resolvedMap, baseVariables };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
postcssPlugin: "postcss-compile-variables",
|
postcssPlugin: "postcss-compile-variables",
|
||||||
|
@ -147,16 +150,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;
|
||||||
}
|
}
|
||||||
isDark = cssFileLocation.includes("dark=true");
|
const 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));
|
root.walkDecls(decl => extract(decl, maps));
|
||||||
root.walkDecls(decl => resolveDerivedVariable(decl, opts.derive));
|
root.walkDecls(decl => resolveDerivedVariable(decl, opts.derive, maps, isDark));
|
||||||
addResolvedVariablesToRootSelector(root, {Rule, Declaration});
|
addResolvedVariablesToRootSelector(root, {Rule, Declaration}, maps);
|
||||||
if (opts.compiledVariables){
|
if (opts.compiledVariables){
|
||||||
populateMapWithDerivedVariables(opts.compiledVariables, cssFileLocation);
|
populateMapWithDerivedVariables(opts.compiledVariables, cssFileLocation, maps);
|
||||||
}
|
}
|
||||||
// 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,7 +16,6 @@ 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;
|
||||||
|
@ -36,7 +35,7 @@ function colorsFromURL(url, colorMap) {
|
||||||
return [primaryColor, secondaryColor];
|
return [primaryColor, secondaryColor];
|
||||||
}
|
}
|
||||||
|
|
||||||
function processURL(decl, replacer, colorMap) {
|
function processURL(decl, replacer, colorMap, cssPath) {
|
||||||
const value = decl.value;
|
const value = decl.value;
|
||||||
const parsed = valueParser(value);
|
const parsed = valueParser(value);
|
||||||
parsed.walk(node => {
|
parsed.walk(node => {
|
||||||
|
@ -84,8 +83,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)
|
||||||
*/
|
*/
|
||||||
cssPath = root.source?.input.file.replace(/[^/]*$/, "");
|
const cssPath = root.source?.input.file.replace(/[^/]*$/, "");
|
||||||
root.walkDecls(decl => processURL(decl, opts.replacer, colorMap));
|
root.walkDecls(decl => processURL(decl, opts.replacer, colorMap, cssPath));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -20,11 +20,9 @@ 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) {
|
function findAndReplaceUrl(decl, urlVariables, counter) {
|
||||||
const value = decl.value;
|
const value = decl.value;
|
||||||
const parsed = valueParser(value);
|
const parsed = valueParser(value);
|
||||||
parsed.walk(node => {
|
parsed.walk(node => {
|
||||||
|
@ -35,7 +33,8 @@ function findAndReplaceUrl(decl) {
|
||||||
if (!url.match(/\.svg\?primary=.+/)) {
|
if (!url.match(/\.svg\?primary=.+/)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const variableName = `${idToPrepend}-${counter++}`;
|
const count = counter.next().value;
|
||||||
|
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}` }];
|
||||||
|
@ -43,7 +42,7 @@ function findAndReplaceUrl(decl) {
|
||||||
decl.assign({prop: decl.prop, value: parsed.toString()})
|
decl.assign({prop: decl.prop, value: parsed.toString()})
|
||||||
}
|
}
|
||||||
|
|
||||||
function addResolvedVariablesToRootSelector(root, { Rule, Declaration }) {
|
function addResolvedVariablesToRootSelector(root, { Rule, Declaration }, urlVariables) {
|
||||||
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) => {
|
||||||
|
@ -53,29 +52,42 @@ function addResolvedVariablesToRootSelector(root, { Rule, Declaration }) {
|
||||||
root.append(newRule);
|
root.append(newRule);
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateMapWithIcons(map, cssFileLocation) {
|
function populateMapWithIcons(map, cssFileLocation, urlVariables) {
|
||||||
const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1];
|
const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1];
|
||||||
const sharedObject = map.get(location);
|
const sharedObject = map.get(location);
|
||||||
sharedObject["icon"] = Object.fromEntries(urlVariables);
|
const output = {"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 }) {
|
||||||
root.walkDecls(decl => findAndReplaceUrl(decl));
|
const urlVariables = new Map();
|
||||||
if (urlVariables.size) {
|
const counter = createCounter();
|
||||||
addResolvedVariablesToRootSelector(root, { Rule, Declaration });
|
root.walkDecls(decl => findAndReplaceUrl(decl, urlVariables, counter));
|
||||||
|
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);
|
populateMapWithIcons(opts.compiledVariables, cssFileLocation, urlVariables);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -14,12 +14,13 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require("fs");
|
import {readFileSync, mkdirSync, writeFileSync} from "fs";
|
||||||
const path = require("path");
|
import {resolve} from "path";
|
||||||
const xxhash = require('xxhashjs');
|
import {h32} from "xxhashjs";
|
||||||
|
import {getColoredSvgString} from "../../src/platform/web/theming/shared/svg-colorizer.mjs";
|
||||||
|
|
||||||
function createHash(content) {
|
function createHash(content) {
|
||||||
const hasher = new xxhash.h32(0);
|
const hasher = new h32(0);
|
||||||
hasher.update(content);
|
hasher.update(content);
|
||||||
return hasher.digest();
|
return hasher.digest();
|
||||||
}
|
}
|
||||||
|
@ -30,18 +31,14 @@ 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
|
||||||
*/
|
*/
|
||||||
module.exports.buildColorizedSVG = function (svgLocation, primaryColor, secondaryColor) {
|
export function buildColorizedSVG(svgLocation, primaryColor, secondaryColor) {
|
||||||
const svgCode = fs.readFileSync(svgLocation, { encoding: "utf8"});
|
const svgCode = readFileSync(svgLocation, { encoding: "utf8"});
|
||||||
let coloredSVGCode = svgCode.replaceAll("#ff00ff", primaryColor);
|
const coloredSVGCode = getColoredSvgString(svgCode, primaryColor, secondaryColor);
|
||||||
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 = path.resolve(__dirname, "../../.tmp");
|
const outputPath = resolve(__dirname, "./.tmp");
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(outputPath);
|
mkdirSync(outputPath);
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
if (e.code !== "EEXIST") {
|
if (e.code !== "EEXIST") {
|
||||||
|
@ -49,6 +46,6 @@ module.exports.buildColorizedSVG = function (svgLocation, primaryColor, secondar
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const outputFile = `${outputPath}/${outputName}`;
|
const outputFile = `${outputPath}/${outputName}`;
|
||||||
fs.writeFileSync(outputFile, coloredSVGCode);
|
writeFileSync(outputFile, coloredSVGCode);
|
||||||
return outputFile;
|
return outputFile;
|
||||||
}
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
|
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,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "hydrogen-view-sdk",
|
"name": "hydrogen-view-sdk",
|
||||||
"description": "Embeddable matrix client library, including view components",
|
"description": "Embeddable matrix client library, including view components",
|
||||||
"version": "0.0.12",
|
"version": "0.1.0",
|
||||||
"main": "./lib-build/hydrogen.cjs.js",
|
"main": "./lib-build/hydrogen.cjs.js",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
|
|
@ -13,7 +13,7 @@ const assetPaths = {
|
||||||
wasmBundle: olmJsPath
|
wasmBundle: olmJsPath
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
import "hydrogen-view-sdk/theme-element-light.css";
|
import "hydrogen-view-sdk/assets/theme-element-light.css";
|
||||||
|
|
||||||
console.log('hydrogenViewSdk', hydrogenViewSdk);
|
console.log('hydrogenViewSdk', hydrogenViewSdk);
|
||||||
console.log('assetPaths', assetPaths);
|
console.log('assetPaths', assetPaths);
|
||||||
|
|
|
@ -6,7 +6,7 @@ const hydrogenViewSdk = require('hydrogen-view-sdk');
|
||||||
// Worker
|
// Worker
|
||||||
require.resolve('hydrogen-view-sdk/main.js');
|
require.resolve('hydrogen-view-sdk/main.js');
|
||||||
// Styles
|
// Styles
|
||||||
require.resolve('hydrogen-view-sdk/theme-element-light.css');
|
require.resolve('hydrogen-view-sdk/assets/theme-element-light.css');
|
||||||
// Can access files in the assets/* directory
|
// Can access files in the assets/* directory
|
||||||
require.resolve('hydrogen-view-sdk/assets/main.js');
|
require.resolve('hydrogen-view-sdk/assets/main.js');
|
||||||
|
|
||||||
|
|
5
scripts/test-derived-theme/test-theme.sh
Executable file
5
scripts/test-derived-theme/test-theme.sh
Executable file
|
@ -0,0 +1,5 @@
|
||||||
|
#!/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
|
51
scripts/test-derived-theme/theme.json
Normal file
51
scripts/test-derived-theme/theme.json
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,18 +14,19 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Options, ViewModel} from "./ViewModel";
|
import {Options as BaseOptions, ViewModel} from "./ViewModel";
|
||||||
import {Client} from "../matrix/Client.js";
|
import {Client} from "../matrix/Client.js";
|
||||||
|
import {SegmentType} from "./navigation/index";
|
||||||
|
|
||||||
type LogoutOptions = { sessionId: string; } & Options;
|
type Options = { sessionId: string; } & BaseOptions;
|
||||||
|
|
||||||
export class LogoutViewModel extends ViewModel<LogoutOptions> {
|
export class LogoutViewModel extends ViewModel<SegmentType, Options> {
|
||||||
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: LogoutOptions) {
|
constructor(options: Options) {
|
||||||
super(options);
|
super(options);
|
||||||
this._sessionId = options.sessionId;
|
this._sessionId = options.sessionId;
|
||||||
this._busy = false;
|
this._busy = false;
|
||||||
|
@ -41,7 +42,7 @@ export class LogoutViewModel extends ViewModel<LogoutOptions> {
|
||||||
return this._busy;
|
return this._busy;
|
||||||
}
|
}
|
||||||
|
|
||||||
get cancelUrl(): string {
|
get cancelUrl(): string | undefined {
|
||||||
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.js";
|
import {LoginViewModel} from "./login/LoginViewModel";
|
||||||
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";
|
||||||
|
@ -118,7 +118,7 @@ export class RootViewModel extends ViewModel {
|
||||||
// but we also want the change of screen to go through the navigation
|
// but we also want the change of screen to go through the navigation
|
||||||
// so we store the session container in a temporary variable that will be
|
// so we store the session container in a temporary variable that will be
|
||||||
// consumed by _applyNavigation, triggered by the navigation change
|
// consumed by _applyNavigation, triggered by the navigation change
|
||||||
//
|
//
|
||||||
// Also, we should not call _setSection before the navigation is in the correct state,
|
// Also, we should not call _setSection before the navigation is in the correct state,
|
||||||
// as url creation (e.g. in RoomTileViewModel)
|
// as url creation (e.g. in RoomTileViewModel)
|
||||||
// won't be using the correct navigation base path.
|
// won't be using the correct navigation base path.
|
||||||
|
|
|
@ -27,17 +27,19 @@ 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 {URLRouter} from "./navigation/URLRouter";
|
import type {SegmentType} from "./navigation/index";
|
||||||
|
import type {IURLRouter} from "./navigation/URLRouter";
|
||||||
|
|
||||||
export type Options = {
|
export type Options<T extends object = SegmentType> = {
|
||||||
platform: Platform
|
platform: Platform;
|
||||||
logger: ILogger
|
logger: ILogger;
|
||||||
urlCreator: URLRouter
|
urlCreator: IURLRouter<T>;
|
||||||
navigation: Navigation
|
navigation: Navigation<T>;
|
||||||
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: Readonly<O>;
|
||||||
|
@ -47,7 +49,7 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
|
||||||
this._options = options;
|
this._options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
childOptions<T extends Object>(explicitOptions: T): T & Options {
|
childOptions<T extends Object>(explicitOptions: T): T & Options<N> {
|
||||||
return Object.assign({}, this._options, explicitOptions);
|
return Object.assign({}, this._options, explicitOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,11 +60,11 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
|
||||||
return this._options[name];
|
return this._options[name];
|
||||||
}
|
}
|
||||||
|
|
||||||
observeNavigation(type: string, onChange: (value: string | true | undefined, type: string) => void) {
|
observeNavigation<T extends keyof N>(type: T, onChange: (value: N[T], type: T) => void): void {
|
||||||
const segmentObservable = this.navigation.observe(type);
|
const segmentObservable = this.navigation.observe(type);
|
||||||
const unsubscribe = segmentObservable.subscribe((value: string | true | undefined) => {
|
const unsubscribe = segmentObservable.subscribe((value: N[T]) => {
|
||||||
onChange(value, type);
|
onChange(value, type);
|
||||||
})
|
});
|
||||||
this.track(unsubscribe);
|
this.track(unsubscribe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -100,10 +102,10 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
|
||||||
|
|
||||||
// TODO: this will need to support binding
|
// TODO: this will need to support binding
|
||||||
// if any of the expr is a function, assume the function is a binding, and return a binding function ourselves
|
// if any of the expr is a function, assume the function is a binding, and return a binding function ourselves
|
||||||
//
|
//
|
||||||
// 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[]) {
|
i18n(parts: TemplateStringsArray, ...expr: any[]): string {
|
||||||
// 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) {
|
||||||
|
@ -135,11 +137,12 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
|
||||||
return this.platform.logger;
|
return this.platform.logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
get urlCreator(): URLRouter {
|
get urlCreator(): IURLRouter<N> {
|
||||||
return this._options.urlCreator;
|
return this._options.urlCreator;
|
||||||
}
|
}
|
||||||
|
|
||||||
get navigation(): Navigation {
|
get navigation(): Navigation<N> {
|
||||||
return this._options.navigation;
|
// typescript needs a little help here
|
||||||
|
return this._options.navigation as unknown as Navigation<N>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,101 +15,145 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Client} from "../../matrix/Client.js";
|
import {Client} from "../../matrix/Client.js";
|
||||||
import {ViewModel} from "../ViewModel";
|
import {Options as BaseOptions, 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";
|
||||||
|
|
||||||
export class LoginViewModel extends ViewModel {
|
import type {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod} from "../../matrix/login";
|
||||||
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() { return this._passwordLoginViewModel; }
|
get passwordLoginViewModel(): PasswordLoginViewModel {
|
||||||
get startSSOLoginViewModel() { return this._startSSOLoginViewModel; }
|
return this._passwordLoginViewModel;
|
||||||
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; }
|
|
||||||
|
|
||||||
goBack() {
|
get startSSOLoginViewModel(): StartSSOLoginViewModel {
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
async _initViewModels() {
|
private _initViewModels(): void {
|
||||||
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 => this.attemptLogin(loginMethod),
|
attemptLogin: (loginMethod: TokenLoginMethod) => this.attemptLogin(loginMethod),
|
||||||
loginToken: this._loginToken
|
loginToken: this._loginToken
|
||||||
})));
|
})));
|
||||||
this.emitChange("completeSSOLoginViewModel");
|
this.emitChange("completeSSOLoginViewModel");
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
await this.queryHomeserver();
|
void this.queryHomeserver();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_showPasswordLogin() {
|
private _showPasswordLogin(): void {
|
||||||
this._passwordLoginViewModel = this.track(new PasswordLoginViewModel(
|
this._passwordLoginViewModel = this.track(new PasswordLoginViewModel(
|
||||||
this.childOptions({
|
this.childOptions({
|
||||||
loginOptions: this._loginOptions,
|
loginOptions: this._loginOptions,
|
||||||
attemptLogin: loginMethod => this.attemptLogin(loginMethod)
|
attemptLogin: (loginMethod: PasswordLoginMethod) => this.attemptLogin(loginMethod)
|
||||||
})));
|
})));
|
||||||
this.emitChange("passwordLoginViewModel");
|
this.emitChange("passwordLoginViewModel");
|
||||||
}
|
}
|
||||||
|
|
||||||
_showSSOLogin() {
|
private _showSSOLogin(): void {
|
||||||
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
_showError(message) {
|
private _showError(message: string): void {
|
||||||
this._errorMessage = message;
|
this._errorMessage = message;
|
||||||
this.emitChange("errorMessage");
|
this.emitChange("errorMessage");
|
||||||
}
|
}
|
||||||
|
|
||||||
_setBusy(status) {
|
private _setBusy(status: boolean): void {
|
||||||
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) {
|
async attemptLogin(loginMethod: ILoginMethod): Promise<null> {
|
||||||
this._setBusy(true);
|
this._setBusy(true);
|
||||||
this._client.startWithLogin(loginMethod, {inspectAccountSetup: true});
|
void this._client.startWithLogin(loginMethod, {inspectAccountSetup: true});
|
||||||
const loadStatus = this._client.loadStatus;
|
const loadStatus = this._client.loadStatus;
|
||||||
const handle = loadStatus.waitFor(status => status !== LoadStatus.Login);
|
const handle = loadStatus.waitFor((status: LoadStatus) => status !== LoadStatus.Login);
|
||||||
await handle.promise;
|
await handle.promise;
|
||||||
this._setBusy(false);
|
this._setBusy(false);
|
||||||
const status = loadStatus.get();
|
const status = loadStatus.get();
|
||||||
|
@ -119,11 +163,11 @@ export class LoginViewModel extends ViewModel {
|
||||||
this._hideHomeserver = true;
|
this._hideHomeserver = true;
|
||||||
this.emitChange("hideHomeserver");
|
this.emitChange("hideHomeserver");
|
||||||
this._disposeViewModels();
|
this._disposeViewModels();
|
||||||
this._createLoadViewModel();
|
void this._createLoadViewModel();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_createLoadViewModel() {
|
private _createLoadViewModel(): void {
|
||||||
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(
|
||||||
|
@ -139,7 +183,7 @@ export class LoginViewModel extends ViewModel {
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
this._loadViewModel.start();
|
void 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", () => {
|
||||||
|
@ -151,22 +195,22 @@ export class LoginViewModel extends ViewModel {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_disposeViewModels() {
|
private _disposeViewModels(): void {
|
||||||
this._startSSOLoginViewModel = this.disposeTracked(this._ssoLoginViewModel);
|
this._startSSOLoginViewModel = this.disposeTracked(this._startSSOLoginViewModel);
|
||||||
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) {
|
async setHomeserver(newHomeserver: string): Promise<void> {
|
||||||
this._homeserver = newHomeserver;
|
this._homeserver = newHomeserver;
|
||||||
// clear everything set by queryHomeserver
|
// clear everything set by queryHomeserver
|
||||||
this._loginOptions = null;
|
this._loginOptions = undefined;
|
||||||
this._queriedHomeserver = null;
|
this._queriedHomeserver = undefined;
|
||||||
this._showError("");
|
this._showError("");
|
||||||
this._disposeViewModels();
|
this._disposeViewModels();
|
||||||
this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation);
|
this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation);
|
||||||
this.emitChange(); // multiple fields changing
|
this.emitChange("loginViewModels"); // 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);
|
||||||
|
@ -181,10 +225,10 @@ export class LoginViewModel extends ViewModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout);
|
this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout);
|
||||||
this.queryHomeserver();
|
void this.queryHomeserver();
|
||||||
}
|
}
|
||||||
|
|
||||||
async queryHomeserver() {
|
async queryHomeserver(): Promise<void> {
|
||||||
// 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;
|
||||||
|
@ -210,7 +254,7 @@ export class LoginViewModel extends ViewModel {
|
||||||
if (e.name === "AbortError") {
|
if (e.name === "AbortError") {
|
||||||
return; //aborted, bail out
|
return; //aborted, bail out
|
||||||
} else {
|
} else {
|
||||||
this._loginOptions = null;
|
this._loginOptions = undefined;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation);
|
this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation);
|
||||||
|
@ -221,19 +265,29 @@ export class LoginViewModel extends ViewModel {
|
||||||
if (this._loginOptions.password) { this._showPasswordLogin(); }
|
if (this._loginOptions.password) { this._showPasswordLogin(); }
|
||||||
if (!this._loginOptions.sso && !this._loginOptions.password) {
|
if (!this._loginOptions.sso && !this._loginOptions.password) {
|
||||||
this._showError("This homeserver supports neither SSO nor password based login flows");
|
this._showError("This homeserver supports neither SSO nor password based login flows");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
this._showError(`Could not query login methods supported by ${this.homeserver}`);
|
this._showError(`Could not query login methods supported by ${this.homeserver}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose(): void {
|
||||||
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
|
||||||
this._client.deleteSession();
|
void 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;
|
||||||
|
};
|
|
@ -16,27 +16,49 @@ limitations under the License.
|
||||||
|
|
||||||
import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue";
|
import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue";
|
||||||
|
|
||||||
export class Navigation {
|
|
||||||
constructor(allowsChild) {
|
type AllowsChild<T> = (parent: Segment<T> | undefined, child: Segment<T>) => boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
get pathObservable(): ObservableValue<Path<T>> {
|
||||||
return this._pathObservable;
|
return this._pathObservable;
|
||||||
}
|
}
|
||||||
|
|
||||||
get path() {
|
get path(): Path<T> {
|
||||||
return this._path;
|
return this._path;
|
||||||
}
|
}
|
||||||
|
|
||||||
push(type, value = undefined) {
|
push<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): void {
|
||||||
return this.applyPath(this.path.with(new Segment(type, value)));
|
const newPath = this.path.with(new Segment(type, ...value));
|
||||||
|
if (newPath) {
|
||||||
|
this.applyPath(newPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyPath(path) {
|
applyPath(path: Path<T>): void {
|
||||||
// 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;
|
||||||
|
@ -60,7 +82,7 @@ export class Navigation {
|
||||||
this._pathObservable.set(this._path);
|
this._pathObservable.set(this._path);
|
||||||
}
|
}
|
||||||
|
|
||||||
observe(type) {
|
observe(type: keyof T): SegmentObservable<T> {
|
||||||
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);
|
||||||
|
@ -69,9 +91,9 @@ export class Navigation {
|
||||||
return observable;
|
return observable;
|
||||||
}
|
}
|
||||||
|
|
||||||
pathFrom(segments) {
|
pathFrom(segments: Segment<any>[]): Path<T> {
|
||||||
let parent;
|
let parent: Segment<any> | undefined;
|
||||||
let i;
|
let i: number;
|
||||||
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);
|
||||||
|
@ -81,12 +103,12 @@ export class Navigation {
|
||||||
return new Path(segments, this._allowsChild);
|
return new Path(segments, this._allowsChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
segment(type, value) {
|
segment<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): Segment<T> {
|
||||||
return new Segment(type, value);
|
return new Segment(type, ...value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function segmentValueEqual(a, b) {
|
function segmentValueEqual<T>(a?: T[keyof T], b?: T[keyof T]): boolean {
|
||||||
if (a === b) {
|
if (a === b) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -103,24 +125,29 @@ function segmentValueEqual(a, b) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Segment {
|
|
||||||
constructor(type, value) {
|
export class Segment<T, K extends keyof T = any> {
|
||||||
this.type = type;
|
public value: T[K];
|
||||||
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 {
|
class Path<T> {
|
||||||
constructor(segments = [], allowsChild) {
|
private readonly _segments: Segment<T, any>[];
|
||||||
|
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() {
|
clone(): Path<T> {
|
||||||
return new Path(this._segments.slice(), this._allowsChild);
|
return new Path(this._segments.slice(), this._allowsChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
with(segment) {
|
with(segment: Segment<T>): Path<T> | undefined {
|
||||||
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)) {
|
||||||
|
@ -132,10 +159,10 @@ class Path {
|
||||||
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 null;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
until(type) {
|
until(type: keyof T): Path<T> {
|
||||||
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)
|
||||||
|
@ -143,11 +170,11 @@ class Path {
|
||||||
return new Path([], this._allowsChild);
|
return new Path([], this._allowsChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(type) {
|
get(type: keyof T): Segment<T> | undefined {
|
||||||
return this._segments.find(s => s.type === type);
|
return this._segments.find(s => s.type === type);
|
||||||
}
|
}
|
||||||
|
|
||||||
replace(segment) {
|
replace(segment: Segment<T>): Path<T> | undefined {
|
||||||
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];
|
||||||
|
@ -160,10 +187,10 @@ class Path {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
get segments() {
|
get segments(): Segment<T>[] {
|
||||||
return this._segments;
|
return this._segments;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -172,43 +199,49 @@ class Path {
|
||||||
* 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 extends BaseObservableValue {
|
class SegmentObservable<T extends object> extends BaseObservableValue<T[keyof T] | undefined> {
|
||||||
constructor(navigation, type) {
|
private readonly _navigation: Navigation<T>;
|
||||||
|
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() {
|
get(): T[keyof T] | undefined {
|
||||||
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() {
|
emitIfChanged(): void {
|
||||||
const newValue = this.get();
|
const newValue = this.get();
|
||||||
if (!segmentValueEqual(newValue, this._lastSetValue)) {
|
if (!segmentValueEqual<T>(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" || "2";
|
return type === "1" || type === "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" || "2.2";
|
return type === "2.1" || type === "2.2";
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -216,7 +249,7 @@ export function tests() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function observeTypes(nav, types) {
|
function observeTypes(nav, types) {
|
||||||
const changes = [];
|
const changes: {type:string, value:any}[] = [];
|
||||||
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});
|
||||||
|
@ -225,6 +258,12 @@ 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();
|
||||||
|
@ -242,18 +281,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([new Segment("foo", 5), new Segment("bar", 6)], () => true);
|
const path = new Path<SegmentType>([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([new Segment("foo", 5), new Segment("bar", 6)], () => true);
|
const path = new Path<SegmentType>([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([new Segment("foo", 5), new Segment("bar", 6)], () => true);
|
const path = new Path<SegmentType>([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,28 +14,55 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class URLRouter {
|
import type {History} from "../../platform/web/dom/History.js";
|
||||||
constructor({history, navigation, parseUrlPath, stringifyPath}) {
|
import type {Navigation, Segment, Path, OptionalValue} from "./Navigation";
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
_getLastSessionId() {
|
private _getLastSessionId(): string | undefined {
|
||||||
const navPath = this._urlAsNavPath(this._history.getLastUrl() || "");
|
const navPath = this._urlAsNavPath(this._history.getLastSessionUrl() || "");
|
||||||
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 null;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
attach() {
|
attach(): void {
|
||||||
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
|
||||||
|
@ -43,12 +70,12 @@ export class URLRouter {
|
||||||
this._applyUrl(this._history.get());
|
this._applyUrl(this._history.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose(): void {
|
||||||
this._subscription = this._subscription();
|
if (this._subscription) { this._subscription = this._subscription(); }
|
||||||
this._pathSubscription = this._pathSubscription();
|
if (this._pathSubscription) { this._pathSubscription = this._pathSubscription(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
_applyNavPathToHistory(path) {
|
private _applyNavPathToHistory(path: Path<T>): void {
|
||||||
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) {
|
||||||
|
@ -60,7 +87,7 @@ export class URLRouter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_applyNavPathToNavigation(navPath) {
|
private _applyNavPathToNavigation(navPath: Path<T>): void {
|
||||||
// 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)
|
||||||
|
@ -69,22 +96,22 @@ export class URLRouter {
|
||||||
this._isApplyingUrl = false;
|
this._isApplyingUrl = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_urlAsNavPath(url) {
|
private _urlAsNavPath(url: string): Path<T> {
|
||||||
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
_applyUrl(url) {
|
private _applyUrl(url: string): void {
|
||||||
const navPath = this._urlAsNavPath(url);
|
const navPath = this._urlAsNavPath(url);
|
||||||
this._applyNavPathToNavigation(navPath);
|
this._applyNavPathToNavigation(navPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
pushUrl(url) {
|
pushUrl(url: string): void {
|
||||||
this._history.pushUrl(url);
|
this._history.pushUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
tryRestoreLastUrl() {
|
tryRestoreLastUrl(): boolean {
|
||||||
const lastNavPath = this._urlAsNavPath(this._history.getLastUrl() || "");
|
const lastNavPath = this._urlAsNavPath(this._history.getLastSessionUrl() || "");
|
||||||
if (lastNavPath.segments.length !== 0) {
|
if (lastNavPath.segments.length !== 0) {
|
||||||
this._applyNavPathToNavigation(lastNavPath);
|
this._applyNavPathToNavigation(lastNavPath);
|
||||||
return true;
|
return true;
|
||||||
|
@ -92,8 +119,8 @@ export class URLRouter {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
urlForSegments(segments) {
|
urlForSegments(segments: Segment<T>[]): string | undefined {
|
||||||
let path = this._navigation.path;
|
let path: Path<T> | undefined = 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) {
|
||||||
|
@ -103,29 +130,29 @@ export class URLRouter {
|
||||||
return this.urlForPath(path);
|
return this.urlForPath(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
urlForSegment(type, value) {
|
urlForSegment<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): string | undefined {
|
||||||
return this.urlForSegments([this._navigation.segment(type, value)]);
|
return this.urlForSegments([this._navigation.segment(type, ...value)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
urlUntilSegment(type) {
|
urlUntilSegment(type: keyof T): string {
|
||||||
return this.urlForPath(this._navigation.path.until(type));
|
return this.urlForPath(this._navigation.path.until(type));
|
||||||
}
|
}
|
||||||
|
|
||||||
urlForPath(path) {
|
urlForPath(path: Path<T>): string {
|
||||||
return this._history.pathAsUrl(this._stringifyPath(path));
|
return this._history.pathAsUrl(this._stringifyPath(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
openRoomActionUrl(roomId) {
|
openRoomActionUrl(roomId: string): string {
|
||||||
// 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() {
|
createSSOCallbackURL(): string {
|
||||||
return window.location.origin;
|
return window.location.origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizeUrl() {
|
normalizeUrl(): void {
|
||||||
// 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,18 +14,36 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Navigation, Segment} from "./Navigation.js";
|
import {Navigation, Segment} from "./Navigation";
|
||||||
import {URLRouter} from "./URLRouter.js";
|
import {URLRouter} from "./URLRouter";
|
||||||
|
import type {Path, OptionalValue} from "./Navigation";
|
||||||
|
|
||||||
export function createNavigation() {
|
export type SegmentType = {
|
||||||
|
"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}) {
|
export function createRouter({history, navigation}: {history: History, navigation: Navigation<SegmentType>}): URLRouter<SegmentType> {
|
||||||
return new URLRouter({history, navigation, stringifyPath, parseUrlPath});
|
return new URLRouter(history, navigation, parseUrlPath, stringifyPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
function allowsChild(parent, child) {
|
function allowsChild(parent: Segment<SegmentType> | undefined, child: Segment<SegmentType>): boolean {
|
||||||
const {type} = child;
|
const {type} = child;
|
||||||
switch (parent?.type) {
|
switch (parent?.type) {
|
||||||
case undefined:
|
case undefined:
|
||||||
|
@ -45,8 +63,9 @@ function allowsChild(parent, child) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeRoomFromPath(path, roomId) {
|
export function removeRoomFromPath(path: Path<SegmentType>, roomId: string): Path<SegmentType> | undefined {
|
||||||
const rooms = path.get("rooms");
|
let newPath: Path<SegmentType> | undefined = path;
|
||||||
|
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) {
|
||||||
|
@ -54,22 +73,22 @@ export function removeRoomFromPath(path, roomId) {
|
||||||
if (roomIdGridIndex !== -1) {
|
if (roomIdGridIndex !== -1) {
|
||||||
const idsWithoutRoom = rooms.value.slice();
|
const idsWithoutRoom = rooms.value.slice();
|
||||||
idsWithoutRoom[roomIdGridIndex] = "";
|
idsWithoutRoom[roomIdGridIndex] = "";
|
||||||
path = path.replace(new Segment("rooms", idsWithoutRoom));
|
newPath = newPath.replace(new Segment("rooms", idsWithoutRoom));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const room = path.get("room");
|
const room = newPath!.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) {
|
||||||
path = path.with(new Segment("empty-grid-tile", roomIdGridIndex));
|
newPath = newPath!.with(new Segment("empty-grid-tile", roomIdGridIndex));
|
||||||
} else {
|
} else {
|
||||||
path = path.until("session");
|
newPath = newPath!.until("session");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return path;
|
return newPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
function roomsSegmentWithRoom(rooms, roomId, path) {
|
function roomsSegmentWithRoom(rooms: Segment<SegmentType, "rooms">, roomId: string, path: Path<SegmentType>): Segment<SegmentType, "rooms"> {
|
||||||
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");
|
||||||
|
@ -87,28 +106,28 @@ function roomsSegmentWithRoom(rooms, roomId, path) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushRightPanelSegment(array, segment, value = true) {
|
function pushRightPanelSegment<T extends keyof SegmentType>(array: Segment<SegmentType>[], segment: T, ...value: OptionalValue<SegmentType[T]>): void {
|
||||||
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(navigation, path) {
|
export function addPanelIfNeeded<T extends SegmentType>(navigation: Navigation<T>, path: Path<T>): Path<T> {
|
||||||
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, currentNavPath, defaultSessionId) {
|
export function parseUrlPath(urlPath: string, currentNavPath: Path<SegmentType>, defaultSessionId?: string): Segment<SegmentType>[] {
|
||||||
// substr(1) to take of initial /
|
// substring(1) to take of initial /
|
||||||
const parts = urlPath.substr(1).split("/");
|
const parts = urlPath.substring(1).split("/");
|
||||||
const iterator = parts[Symbol.iterator]();
|
const iterator = parts[Symbol.iterator]();
|
||||||
const segments = [];
|
const segments: Segment<SegmentType>[] = [];
|
||||||
let next;
|
let next;
|
||||||
while (!(next = iterator.next()).done) {
|
while (!(next = iterator.next()).done) {
|
||||||
const type = next.value;
|
const type = next.value;
|
||||||
|
@ -170,9 +189,9 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) {
|
||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stringifyPath(path) {
|
export function stringifyPath(path: Path<SegmentType>): string {
|
||||||
let urlPath = "";
|
let urlPath = "";
|
||||||
let prevSegment;
|
let prevSegment: Segment<SegmentType> | undefined;
|
||||||
for (const segment of path.segments) {
|
for (const segment of path.segments) {
|
||||||
switch (segment.type) {
|
switch (segment.type) {
|
||||||
case "rooms":
|
case "rooms":
|
||||||
|
@ -205,9 +224,15 @@ export function stringifyPath(path) {
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = new Navigation(allowsChild);
|
const nav: Navigation<SegmentType> = 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"]),
|
||||||
|
@ -217,7 +242,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 = new Navigation(allowsChild);
|
const nav: Navigation<SegmentType> = 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"]),
|
||||||
|
@ -227,7 +252,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 = new Navigation(allowsChild);
|
const nav: Navigation<SegmentType> = 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"]),
|
||||||
|
@ -239,13 +264,15 @@ 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 segments = parseUrlPath("?loginToken=a1232aSD123");
|
const path = createEmptyPath();
|
||||||
|
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 segments = parseUrlPath("/session/1/rooms/a,b,c/3");
|
const path = createEmptyPath();
|
||||||
|
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");
|
||||||
|
@ -255,7 +282,8 @@ 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 segments = parseUrlPath("/session/1/rooms/a,b,c/1");
|
const path = createEmptyPath();
|
||||||
|
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");
|
||||||
|
@ -265,7 +293,8 @@ 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 segments = parseUrlPath("/session/1/rooms/");
|
const path = createEmptyPath();
|
||||||
|
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");
|
||||||
|
@ -275,7 +304,8 @@ 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 segments = parseUrlPath("/session/1/rooms//1");
|
const path = createEmptyPath();
|
||||||
|
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");
|
||||||
|
@ -285,7 +315,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 = new Navigation(allowsChild);
|
const nav: Navigation<SegmentType> = 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"]),
|
||||||
|
@ -301,7 +331,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 = new Navigation(allowsChild);
|
const nav: Navigation<SegmentType> = 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"]),
|
||||||
|
@ -317,7 +347,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 = new Navigation(allowsChild);
|
const nav: Navigation<SegmentType> = 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"]),
|
||||||
|
@ -339,7 +369,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 = new Navigation(allowsChild);
|
const nav: Navigation<SegmentType> = 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"]),
|
||||||
|
@ -361,7 +391,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 = new Navigation(allowsChild);
|
const nav: Navigation<SegmentType> = 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"]),
|
||||||
|
@ -377,82 +407,83 @@ 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 segments = parseUrlPath("/session");
|
const path = createEmptyPath();
|
||||||
|
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 = new Navigation(allowsChild);
|
const nav: Navigation<SegmentType> = 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 = new Navigation(allowsChild);
|
const nav: Navigation<SegmentType> = 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 = new Navigation(allowsChild);
|
const nav: Navigation<SegmentType> = 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 = new Navigation(allowsChild);
|
const nav: Navigation<SegmentType> = 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 = new Navigation(allowsChild);
|
const nav: Navigation<SegmentType> = 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");
|
||||||
},
|
},
|
||||||
|
|
||||||
}
|
}
|
65
src/domain/rageshake.ts
Normal file
65
src/domain/rageshake.ts
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
/*
|
||||||
|
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.js";
|
import {addPanelIfNeeded} from "../navigation/index";
|
||||||
|
|
||||||
function dedupeSparse(roomIds) {
|
function dedupeSparse(roomIds) {
|
||||||
return roomIds.map((id, idx) => {
|
return roomIds.map((id, idx) => {
|
||||||
|
@ -185,7 +185,7 @@ export class RoomGridViewModel extends ViewModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
import {createNavigation} from "../navigation/index.js";
|
import {createNavigation} from "../navigation/index";
|
||||||
import {ObservableValue} from "../../observable/ObservableValue";
|
import {ObservableValue} from "../../observable/ObservableValue";
|
||||||
|
|
||||||
export function tests() {
|
export function tests() {
|
||||||
|
|
|
@ -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.js";
|
import {addPanelIfNeeded} from "../../navigation/index";
|
||||||
|
|
||||||
export class LeftPanelViewModel extends ViewModel {
|
export class LeftPanelViewModel extends ViewModel {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
|
|
|
@ -23,6 +23,7 @@ import {imageToInfo} from "../common.js";
|
||||||
// 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) {
|
||||||
|
@ -37,9 +38,9 @@ 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 = new ArchivedViewModel(this.childOptions({archivedRoom: room}));
|
this._composerVM = this.track(new ArchivedViewModel(this.childOptions({archivedRoom: room})));
|
||||||
} else {
|
} else {
|
||||||
this._composerVM = new ComposerViewModel(this);
|
this._recreateComposerOnPowerLevelChange();
|
||||||
}
|
}
|
||||||
this._clearUnreadTimout = null;
|
this._clearUnreadTimout = null;
|
||||||
this._closeUrl = this.urlCreator.urlUntilSegment("session");
|
this._closeUrl = this.urlCreator.urlUntilSegment("session");
|
||||||
|
@ -67,6 +68,30 @@ 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;
|
||||||
|
@ -173,18 +198,89 @@ 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 {
|
||||||
let msgtype = "m.text";
|
const msgtype = messinfo.type;
|
||||||
if (message.startsWith("/me ")) {
|
const message = messinfo.message;
|
||||||
message = message.substr(4).trim();
|
if (msgtype && message) {
|
||||||
msgtype = "m.emote";
|
if (replyingTo) {
|
||||||
}
|
await replyingTo.reply(msgtype, message);
|
||||||
if (replyingTo) {
|
} else {
|
||||||
await replyingTo.reply(msgtype, message);
|
await this._room.sendEvent("m.room.message", {msgtype, body: 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}`);
|
||||||
|
@ -329,6 +425,11 @@ export class RoomViewModel extends ViewModel {
|
||||||
this._composerVM.setReplyingTo(entry);
|
this._composerVM.setReplyingTo(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dismissError() {
|
||||||
|
this._sendError = null;
|
||||||
|
this.emitChange("error");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function videoToInfo(video) {
|
function videoToInfo(video) {
|
||||||
|
@ -362,6 +463,16 @@ class ArchivedViewModel extends ViewModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
get kind() {
|
get kind() {
|
||||||
return "archived";
|
return "disabled";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LowerPowerLevelViewModel extends ViewModel {
|
||||||
|
get description() {
|
||||||
|
return this.i18n`You do not have the powerlevel necessary to send messages`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get kind() {
|
||||||
|
return "disabled";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,6 +27,29 @@ 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() {
|
||||||
|
@ -38,7 +61,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 sendStatus() {
|
get status() {
|
||||||
const {pendingEvent} = this._entry;
|
const {pendingEvent} = this._entry;
|
||||||
switch (pendingEvent?.status) {
|
switch (pendingEvent?.status) {
|
||||||
case SendStatus.Waiting:
|
case SendStatus.Waiting:
|
||||||
|
@ -53,6 +76,12 @@ 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 "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@ 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,6 +52,7 @@ export class SettingsViewModel extends ViewModel {
|
||||||
this.maxSentImageSizeLimit = 4000;
|
this.maxSentImageSizeLimit = 4000;
|
||||||
this.pushNotifications = new PushNotificationStatus();
|
this.pushNotifications = new PushNotificationStatus();
|
||||||
this._activeTheme = undefined;
|
this._activeTheme = undefined;
|
||||||
|
this._logsFeedbackMessage = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
get _session() {
|
get _session() {
|
||||||
|
@ -152,6 +154,51 @@ export class SettingsViewModel extends ViewModel {
|
||||||
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;
|
||||||
|
|
|
@ -18,7 +18,7 @@ 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.js";
|
export {createNavigation, createRouter} from "./domain/navigation/index";
|
||||||
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";
|
||||||
|
|
|
@ -100,6 +100,8 @@ 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
|
||||||
|
@ -136,7 +138,7 @@ export class Client {
|
||||||
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,
|
||||||
},
|
},
|
||||||
|
@ -196,7 +198,7 @@ export class Client {
|
||||||
sessionInfo.deviceId = dehydratedDevice.deviceId;
|
sessionInfo.deviceId = dehydratedDevice.deviceId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this._platform.sessionInfoStorage.add(sessionInfo);
|
await this._platform.sessionInfoStorage.add(sessionInfo);
|
||||||
// loading the session can only lead to
|
// loading the session can only lead to
|
||||||
// LoadStatus.Error in case of an error,
|
// LoadStatus.Error in case of an error,
|
||||||
// so separate try/catch
|
// so separate try/catch
|
||||||
|
@ -266,7 +268,7 @@ export class Client {
|
||||||
this._status.set(LoadStatus.SessionSetup);
|
this._status.set(LoadStatus.SessionSetup);
|
||||||
await log.wrap("createIdentity", log => this._session.createIdentity(log));
|
await log.wrap("createIdentity", log => this._session.createIdentity(log));
|
||||||
}
|
}
|
||||||
|
|
||||||
this._sync = new Sync({hsApi: this._requestScheduler.hsApi, storage: this._storage, session: this._session, logger: this._platform.logger});
|
this._sync = new Sync({hsApi: this._requestScheduler.hsApi, storage: this._storage, session: this._session, logger: this._platform.logger});
|
||||||
// notify sync and session when back online
|
// notify sync and session when back online
|
||||||
this._reconnectSubscription = this._reconnector.connectionStatus.subscribe(state => {
|
this._reconnectSubscription = this._reconnector.connectionStatus.subscribe(state => {
|
||||||
|
@ -311,7 +313,7 @@ export class Client {
|
||||||
this._waitForFirstSyncHandle = this._sync.status.waitFor(s => {
|
this._waitForFirstSyncHandle = this._sync.status.waitFor(s => {
|
||||||
if (s === SyncStatus.Stopped) {
|
if (s === SyncStatus.Stopped) {
|
||||||
// keep waiting if there is a ConnectionError
|
// keep waiting if there is a ConnectionError
|
||||||
// as the reconnector above will call
|
// as the reconnector above will call
|
||||||
// sync.start again to retry in this case
|
// sync.start again to retry in this case
|
||||||
return this._sync.error?.name !== "ConnectionError";
|
return this._sync.error?.name !== "ConnectionError";
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,11 +15,13 @@ 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;
|
||||||
|
|
||||||
export function addRoomToIdentity(identity, userId, roomId) {
|
function addRoomToIdentity(identity, userId, roomId) {
|
||||||
if (!identity) {
|
if (!identity) {
|
||||||
identity = {
|
identity = {
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
@ -79,28 +81,57 @@ export class DeviceTracker {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
writeMemberChanges(room, memberChanges, txn) {
|
/** @return Promise<{added: string[], removed: string[]}> the user ids for who the room was added or removed to the userIdentity,
|
||||||
return Promise.all(Array.from(memberChanges.values()).map(async memberChange => {
|
* and with who a key should be now be shared
|
||||||
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, log) {
|
async trackRoom(room, historyVisibility, log) {
|
||||||
if (room.isTrackingMembers || !room.isEncrypted) {
|
if (room.isTrackingMembers || !room.isEncrypted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const memberList = await room.loadMemberList(log);
|
const memberList = await room.loadMemberList(undefined, 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 this._writeJoinedMembers(members, txn);
|
await Promise.all(members.map(async member => {
|
||||||
|
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;
|
||||||
|
@ -112,21 +143,43 @@ export class DeviceTracker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _writeJoinedMembers(members, txn) {
|
async writeHistoryVisibility(room, historyVisibility, syncTxn, log) {
|
||||||
await Promise.all(members.map(async member => {
|
const added = [];
|
||||||
if (member.membership === "join") {
|
const removed = [];
|
||||||
await this._writeMember(member, txn);
|
if (room.isTrackingMembers && room.isEncrypted) {
|
||||||
}
|
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 _writeMember(member, txn) {
|
async _addRoomToUserIdentity(roomId, userId, txn) {
|
||||||
const {userIdentities} = txn;
|
const {userIdentities} = txn;
|
||||||
const identity = await userIdentities.get(member.userId);
|
const identity = await userIdentities.get(userId);
|
||||||
const updatedIdentity = addRoomToIdentity(identity, member.userId, member.roomId);
|
const updatedIdentity = addRoomToIdentity(identity, userId, 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) {
|
||||||
|
@ -141,28 +194,9 @@ 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) {
|
||||||
|
@ -367,16 +401,18 @@ export class DeviceTracker {
|
||||||
|
|
||||||
import {createMockStorage} from "../../mocks/Storage";
|
import {createMockStorage} from "../../mocks/Storage";
|
||||||
import {Instance as NullLoggerInstance} from "../../logging/NullLogger";
|
import {Instance as NullLoggerInstance} from "../../logging/NullLogger";
|
||||||
|
import {MemberChange} from "../room/members/RoomMember";
|
||||||
|
|
||||||
export function tests() {
|
export function tests() {
|
||||||
|
|
||||||
function createUntrackedRoomMock(roomId, joinedUserIds, invitedUserIds = []) {
|
function createUntrackedRoomMock(roomId, joinedUserIds, invitedUserIds = []) {
|
||||||
return {
|
return {
|
||||||
|
id: roomId,
|
||||||
isTrackingMembers: false,
|
isTrackingMembers: false,
|
||||||
isEncrypted: true,
|
isEncrypted: true,
|
||||||
loadMemberList: () => {
|
loadMemberList: () => {
|
||||||
const joinedMembers = joinedUserIds.map(userId => {return {membership: "join", roomId, userId};});
|
const joinedMembers = joinedUserIds.map(userId => {return RoomMember.fromUserId(roomId, userId, "join");});
|
||||||
const invitedMembers = invitedUserIds.map(userId => {return {membership: "invite", roomId, userId};});
|
const invitedMembers = invitedUserIds.map(userId => {return RoomMember.fromUserId(roomId, userId, "invite");});
|
||||||
const members = joinedMembers.concat(invitedMembers);
|
const members = joinedMembers.concat(invitedMembers);
|
||||||
const memberMap = members.reduce((map, member) => {
|
const memberMap = members.reduce((map, member) => {
|
||||||
map.set(member.userId, member);
|
map.set(member.userId, member);
|
||||||
|
@ -440,10 +476,29 @@ export function tests() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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";
|
const roomId = "!abc:hs.tld";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"trackRoom only writes joined members": async assert => {
|
"trackRoom only writes joined members with history visibility of joined": async assert => {
|
||||||
const storage = await createMockStorage();
|
const storage = await createMockStorage();
|
||||||
const tracker = new DeviceTracker({
|
const tracker = new DeviceTracker({
|
||||||
storage,
|
storage,
|
||||||
|
@ -453,7 +508,7 @@ export function tests() {
|
||||||
ownDeviceId: "ABCD",
|
ownDeviceId: "ABCD",
|
||||||
});
|
});
|
||||||
const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"], ["@charly:hs.tld"]);
|
const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"], ["@charly:hs.tld"]);
|
||||||
await tracker.trackRoom(room, NullLoggerInstance.item);
|
await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item);
|
||||||
const txn = await storage.readTxn([storage.storeNames.userIdentities]);
|
const txn = await storage.readTxn([storage.storeNames.userIdentities]);
|
||||||
assert.deepEqual(await txn.userIdentities.get("@alice:hs.tld"), {
|
assert.deepEqual(await txn.userIdentities.get("@alice:hs.tld"), {
|
||||||
userId: "@alice:hs.tld",
|
userId: "@alice:hs.tld",
|
||||||
|
@ -477,7 +532,7 @@ export function tests() {
|
||||||
ownDeviceId: "ABCD",
|
ownDeviceId: "ABCD",
|
||||||
});
|
});
|
||||||
const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"]);
|
const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"]);
|
||||||
await tracker.trackRoom(room, NullLoggerInstance.item);
|
await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item);
|
||||||
const hsApi = createQueryKeysHSApiMock();
|
const hsApi = createQueryKeysHSApiMock();
|
||||||
const devices = await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApi, NullLoggerInstance.item);
|
const devices = await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApi, NullLoggerInstance.item);
|
||||||
assert.equal(devices.length, 2);
|
assert.equal(devices.length, 2);
|
||||||
|
@ -494,7 +549,7 @@ export function tests() {
|
||||||
ownDeviceId: "ABCD",
|
ownDeviceId: "ABCD",
|
||||||
});
|
});
|
||||||
const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"]);
|
const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"]);
|
||||||
await tracker.trackRoom(room, NullLoggerInstance.item);
|
await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item);
|
||||||
const hsApi = createQueryKeysHSApiMock();
|
const hsApi = createQueryKeysHSApiMock();
|
||||||
// query devices first time
|
// query devices first time
|
||||||
await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApi, NullLoggerInstance.item);
|
await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApi, NullLoggerInstance.item);
|
||||||
|
@ -512,6 +567,169 @@ export function tests() {
|
||||||
const txn2 = await storage.readTxn([storage.storeNames.deviceIdentities]);
|
const txn2 = await storage.readTxn([storage.storeNames.deviceIdentities]);
|
||||||
// also check the modified key was not stored
|
// 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");
|
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"]);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,8 +19,10 @@ 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} 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
|
||||||
|
@ -45,6 +47,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,22 +80,68 @@ export class RoomEncryption {
|
||||||
this._senderDeviceCache = new Map(); // purge the sender device cache
|
this._senderDeviceCache = new Map(); // purge the sender device cache
|
||||||
}
|
}
|
||||||
|
|
||||||
async writeMemberChanges(memberChanges, txn, log) {
|
async writeSync(roomResponse, memberChanges, txn, log) {
|
||||||
let shouldFlush = false;
|
let historyVisibility = await this._loadHistoryVisibilityIfNeeded(this._historyVisibility, txn);
|
||||||
const memberChangesArray = Array.from(memberChanges.values());
|
const addedMembers = [];
|
||||||
// this also clears our session if we leave the room ourselves
|
const removedMembers = [];
|
||||||
if (memberChangesArray.some(m => m.hasLeft)) {
|
// update the historyVisibility if needed
|
||||||
|
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: memberChangesArray.filter(m => m.hasLeft).map(m => m.userId),
|
leftUsers: removedMembers,
|
||||||
});
|
});
|
||||||
this._megolmEncryption.discardOutboundSession(this._room.id, txn);
|
this._megolmEncryption.discardOutboundSession(this._room.id, txn);
|
||||||
}
|
}
|
||||||
if (memberChangesArray.some(m => m.hasJoined)) {
|
let shouldFlush = false;
|
||||||
shouldFlush = await this._addShareRoomKeyOperationForNewMembers(memberChangesArray, txn, log);
|
// add room to userIdentities if needed, and share the current key with them
|
||||||
|
if (addedMembers.length) {
|
||||||
|
shouldFlush = await this._addShareRoomKeyOperationForMembers(addedMembers, txn, log);
|
||||||
}
|
}
|
||||||
await this._deviceTracker.writeMemberChanges(this._room, memberChanges, txn);
|
return {shouldFlush, historyVisibility};
|
||||||
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) {
|
||||||
|
@ -274,10 +323,15 @@ 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, null, writeOpTxn);
|
operation = this._writeRoomKeyShareOperation(roomKeyMessage, userIds, writeOpTxn);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
writeOpTxn.abort();
|
writeOpTxn.abort();
|
||||||
throw err;
|
throw err;
|
||||||
|
@ -288,8 +342,7 @@ export class RoomEncryption {
|
||||||
await this._processShareRoomKeyOperation(operation, hsApi, log);
|
await this._processShareRoomKeyOperation(operation, hsApi, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _addShareRoomKeyOperationForNewMembers(memberChangesArray, txn, log) {
|
async _addShareRoomKeyOperationForMembers(userIds, 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) {
|
||||||
|
@ -342,18 +395,9 @@ 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, log);
|
await this._deviceTracker.trackRoom(this._room, this._historyVisibility, log);
|
||||||
let devices;
|
const devices = await this._deviceTracker.devicesForRoomMembers(this._room.id, operation.userIds, hsApi, log);
|
||||||
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));
|
||||||
|
@ -507,3 +551,143 @@ 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,3 +69,28 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
7
src/matrix/login/index.ts
Normal file
7
src/matrix/login/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import {ILoginMethod} from "./LoginMethod";
|
||||||
|
import {PasswordLoginMethod} from "./PasswordLoginMethod";
|
||||||
|
import {SSOLoginHelper} from "./SSOLoginHelper";
|
||||||
|
import {TokenLoginMethod} from "./TokenLoginMethod";
|
||||||
|
|
||||||
|
|
||||||
|
export {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod};
|
|
@ -17,9 +17,12 @@ 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;
|
||||||
body: BlobHandle | string;
|
// the map gets transformed to a FormData object on the web
|
||||||
|
body: RequestBody
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encodeQueryParams(queryParams?: object): string {
|
export function encodeQueryParams(queryParams?: object): string {
|
||||||
|
@ -41,6 +44,11 @@ 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 {
|
||||||
|
|
|
@ -243,7 +243,7 @@ export class BaseRoom extends EventEmitter {
|
||||||
|
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
async loadMemberList(log = null) {
|
async loadMemberList(txn = undefined, 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,6 +254,9 @@ 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,
|
||||||
|
|
|
@ -139,11 +139,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 shouldFlushKeyShares = false;
|
let encryptionChanges;
|
||||||
// pass member changes to device tracker
|
// pass member changes to device tracker
|
||||||
if (roomEncryption && this.isTrackingMembers && memberChanges?.size) {
|
if (roomEncryption) {
|
||||||
shouldFlushKeyShares = await roomEncryption.writeMemberChanges(memberChanges, txn, log);
|
encryptionChanges = await roomEncryption.writeSync(roomResponse, memberChanges, txn, log);
|
||||||
log.set("shouldFlushKeyShares", shouldFlushKeyShares);
|
log.set("shouldFlushKeyShares", encryptionChanges.shouldFlush);
|
||||||
}
|
}
|
||||||
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
|
||||||
|
@ -188,7 +188,7 @@ export class Room extends BaseRoom {
|
||||||
memberChanges,
|
memberChanges,
|
||||||
heroChanges,
|
heroChanges,
|
||||||
powerLevelsEvent,
|
powerLevelsEvent,
|
||||||
shouldFlushKeyShares,
|
encryptionChanges,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -201,11 +201,14 @@ export class Room extends BaseRoom {
|
||||||
const {
|
const {
|
||||||
summaryChanges, newEntries, updatedEntries, newLiveKey,
|
summaryChanges, newEntries, updatedEntries, newLiveKey,
|
||||||
removedPendingEvents, memberChanges, powerLevelsEvent,
|
removedPendingEvents, memberChanges, powerLevelsEvent,
|
||||||
heroChanges, roomEncryption
|
heroChanges, roomEncryption, encryptionChanges
|
||||||
} = 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()) {
|
||||||
|
@ -288,8 +291,8 @@ export class Room extends BaseRoom {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
needsAfterSyncCompleted({shouldFlushKeyShares}) {
|
needsAfterSyncCompleted({encryptionChanges}) {
|
||||||
return shouldFlushKeyShares;
|
return encryptionChanges?.shouldFlush;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -37,7 +37,8 @@ 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 = {
|
||||||
|
@ -62,6 +63,7 @@ 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 {
|
||||||
|
@ -151,6 +153,9 @@ 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,6 +14,8 @@ 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
|
||||||
|
@ -40,3 +42,83 @@ 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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -137,6 +137,10 @@ export class MemberChange {
|
||||||
return this.member.membership;
|
return this.member.membership;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get wasInvited() {
|
||||||
|
return this.previousMembership === "invite" && this.membership !== "invite";
|
||||||
|
}
|
||||||
|
|
||||||
get hasLeft() {
|
get hasLeft() {
|
||||||
return this.previousMembership === "join" && this.membership !== "join";
|
return this.previousMembership === "join" && this.membership !== "join";
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,10 +17,12 @@ limitations under the License.
|
||||||
|
|
||||||
import {RoomMember} from "./RoomMember.js";
|
import {RoomMember} from "./RoomMember.js";
|
||||||
|
|
||||||
async function loadMembers({roomId, storage}) {
|
async function loadMembers({roomId, storage, txn}) {
|
||||||
const txn = await storage.readTxn([
|
if (!txn) {
|
||||||
storage.storeNames.roomMembers,
|
txn = await storage.readTxn([
|
||||||
]);
|
storage.storeNames.roomMembers,
|
||||||
|
]);
|
||||||
|
}
|
||||||
const memberDatas = await txn.roomMembers.getAll(roomId);
|
const memberDatas = await txn.roomMembers.getAll(roomId);
|
||||||
return memberDatas.map(d => new RoomMember(d));
|
return memberDatas.map(d => new RoomMember(d));
|
||||||
}
|
}
|
||||||
|
|
|
@ -163,7 +163,7 @@ export class GapWriter {
|
||||||
if (!Array.isArray(chunk)) {
|
if (!Array.isArray(chunk)) {
|
||||||
throw new Error("Invalid chunk in response");
|
throw new Error("Invalid chunk in response");
|
||||||
}
|
}
|
||||||
if (typeof end !== "string") {
|
if (typeof end !== "string" && typeof end !== "undefined") {
|
||||||
throw new Error("Invalid end token in response");
|
throw new Error("Invalid end token in response");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -42,6 +42,7 @@ async function requestPersistedStorage(): Promise<boolean> {
|
||||||
await glob.document.requestStorageAccess();
|
await glob.document.requestStorageAccess();
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.warn("requestStorageAccess threw an error:", err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -2,7 +2,6 @@ import {IDOMStorage} from "./types";
|
||||||
import {ITransaction} from "./QueryTarget";
|
import {ITransaction} from "./QueryTarget";
|
||||||
import {iterateCursor, NOT_DONE, reqAsPromise} from "./utils";
|
import {iterateCursor, NOT_DONE, reqAsPromise} from "./utils";
|
||||||
import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/members/RoomMember.js";
|
import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/members/RoomMember.js";
|
||||||
import {addRoomToIdentity} from "../../e2ee/DeviceTracker.js";
|
|
||||||
import {SESSION_E2EE_KEY_PREFIX} from "../../e2ee/common.js";
|
import {SESSION_E2EE_KEY_PREFIX} from "../../e2ee/common.js";
|
||||||
import {SummaryData} from "../../room/RoomSummary";
|
import {SummaryData} from "../../room/RoomSummary";
|
||||||
import {RoomMemberStore, MemberData} from "./stores/RoomMemberStore";
|
import {RoomMemberStore, MemberData} from "./stores/RoomMemberStore";
|
||||||
|
@ -183,51 +182,12 @@ function createTimelineRelationsStore(db: IDBDatabase) : void {
|
||||||
db.createObjectStore("timelineRelations", {keyPath: "key"});
|
db.createObjectStore("timelineRelations", {keyPath: "key"});
|
||||||
}
|
}
|
||||||
|
|
||||||
//v11 doesn't change the schema, but ensures all userIdentities have all the roomIds they should (see #470)
|
//v11 doesn't change the schema,
|
||||||
async function fixMissingRoomsInUserIdentities(db: IDBDatabase, txn: IDBTransaction, localStorage: IDOMStorage, log: ILogItem) {
|
// but ensured all userIdentities have all the roomIds they should (see #470)
|
||||||
const roomSummaryStore = txn.objectStore("roomSummary");
|
|
||||||
const trackedRoomIds: string[] = [];
|
// 2022-07-20: The fix dated from August 2021, and have removed it now because of a
|
||||||
await iterateCursor<SummaryData>(roomSummaryStore.openCursor(), roomSummary => {
|
// refactoring needed in the device tracker, which made it inconvenient to expose addRoomToIdentity
|
||||||
if (roomSummary.isTrackingMembers) {
|
function fixMissingRoomsInUserIdentities() {}
|
||||||
trackedRoomIds.push(roomSummary.roomId);
|
|
||||||
}
|
|
||||||
return NOT_DONE;
|
|
||||||
});
|
|
||||||
const outboundGroupSessionsStore = txn.objectStore("outboundGroupSessions");
|
|
||||||
const userIdentitiesStore: IDBObjectStore = txn.objectStore("userIdentities");
|
|
||||||
const roomMemberStore = txn.objectStore("roomMembers");
|
|
||||||
for (const roomId of trackedRoomIds) {
|
|
||||||
let foundMissing = false;
|
|
||||||
const joinedUserIds: string[] = [];
|
|
||||||
const memberRange = IDBKeyRange.bound(roomId, `${roomId}|${MAX_UNICODE}`, true, true);
|
|
||||||
await log.wrap({l: "room", id: roomId}, async log => {
|
|
||||||
await iterateCursor<MemberData>(roomMemberStore.openCursor(memberRange), member => {
|
|
||||||
if (member.membership === "join") {
|
|
||||||
joinedUserIds.push(member.userId);
|
|
||||||
}
|
|
||||||
return NOT_DONE;
|
|
||||||
});
|
|
||||||
log.set("joinedUserIds", joinedUserIds.length);
|
|
||||||
for (const userId of joinedUserIds) {
|
|
||||||
const identity = await reqAsPromise(userIdentitiesStore.get(userId));
|
|
||||||
const originalRoomCount = identity?.roomIds?.length;
|
|
||||||
const updatedIdentity = addRoomToIdentity(identity, userId, roomId);
|
|
||||||
if (updatedIdentity) {
|
|
||||||
log.log({l: `fixing up`, id: userId,
|
|
||||||
roomsBefore: originalRoomCount, roomsAfter: updatedIdentity.roomIds.length});
|
|
||||||
userIdentitiesStore.put(updatedIdentity);
|
|
||||||
foundMissing = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.set("foundMissing", foundMissing);
|
|
||||||
if (foundMissing) {
|
|
||||||
// clear outbound megolm session,
|
|
||||||
// so we'll create a new one on the next message that will be properly shared
|
|
||||||
outboundGroupSessionsStore.delete(roomId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// v12 move ssssKey to e2ee:ssssKey so it will get backed up in the next step
|
// v12 move ssssKey to e2ee:ssssKey so it will get backed up in the next step
|
||||||
async function changeSSSSKeyPrefix(db: IDBDatabase, txn: IDBTransaction) {
|
async function changeSSSSKeyPrefix(db: IDBDatabase, txn: IDBTransaction) {
|
||||||
|
|
64
src/platform/types/config.ts
Normal file
64
src/platform/types/config.ts
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
/*
|
||||||
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Config = {
|
||||||
|
/**
|
||||||
|
* The default homeserver used by Hydrogen; auto filled in the login UI.
|
||||||
|
* eg: https://matrix.org
|
||||||
|
* REQUIRED
|
||||||
|
*/
|
||||||
|
defaultHomeServer: string;
|
||||||
|
/**
|
||||||
|
* The submit endpoint for your preferred rageshake server.
|
||||||
|
* eg: https://element.io/bugreports/submit
|
||||||
|
* Read more about rageshake at https://github.com/matrix-org/rageshake
|
||||||
|
* OPTIONAL
|
||||||
|
*/
|
||||||
|
bugReportEndpointUrl?: string;
|
||||||
|
/**
|
||||||
|
* Paths to theme-manifests
|
||||||
|
* eg: ["assets/theme-element.json", "assets/theme-awesome.json"]
|
||||||
|
* REQUIRED
|
||||||
|
*/
|
||||||
|
themeManifests: string[];
|
||||||
|
/**
|
||||||
|
* This configures the default theme(s) used by Hydrogen.
|
||||||
|
* These themes appear as "Default" option in the theme chooser UI and are also
|
||||||
|
* used as a fallback when other themes fail to load.
|
||||||
|
* Whether the dark or light variant is used depends on the system preference.
|
||||||
|
* OPTIONAL
|
||||||
|
*/
|
||||||
|
defaultTheme?: {
|
||||||
|
// id of light theme
|
||||||
|
light: string;
|
||||||
|
// id of dark theme
|
||||||
|
dark: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Configuration for push notifications.
|
||||||
|
* See https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3pushersset
|
||||||
|
* and https://github.com/matrix-org/sygnal/blob/main/docs/applications.md#webpush
|
||||||
|
* OPTIONAL
|
||||||
|
*/
|
||||||
|
push?: {
|
||||||
|
// See app_id in the request body in above link
|
||||||
|
appId: string;
|
||||||
|
// The host used for pushing notification
|
||||||
|
gatewayUrl: string;
|
||||||
|
// See pushkey in above link
|
||||||
|
applicationServerKey: string;
|
||||||
|
};
|
||||||
|
};
|
83
src/platform/types/theme.ts
Normal file
83
src/platform/types/theme.ts
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ThemeManifest = Partial<{
|
||||||
|
/**
|
||||||
|
* Version number of the theme manifest.
|
||||||
|
* This must be incremented when backwards incompatible changes are introduced.
|
||||||
|
*/
|
||||||
|
version: number;
|
||||||
|
// A user-facing string that is the name for this theme-collection.
|
||||||
|
name: string;
|
||||||
|
// An identifier for this theme
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Id of the theme that this theme derives from.
|
||||||
|
* Only present for derived/runtime themes.
|
||||||
|
*/
|
||||||
|
extends: string;
|
||||||
|
/**
|
||||||
|
* This is added to the manifest during the build process and includes data
|
||||||
|
* that is needed to load themes at runtime.
|
||||||
|
*/
|
||||||
|
source: {
|
||||||
|
/**
|
||||||
|
* This is a mapping from theme-id to location of css file relative to the location of the
|
||||||
|
* manifest.
|
||||||
|
* eg: {"element-light": "theme-element-light.10f9bb22.css", ...}
|
||||||
|
*
|
||||||
|
* Here theme-id is 'theme-variant' where 'theme' is the key used to specify the manifest
|
||||||
|
* location for this theme-collection in vite.config.js (where the themeBuilder plugin is
|
||||||
|
* initialized) and 'variant' is the key used to specify the variant details in the values
|
||||||
|
* section below.
|
||||||
|
*/
|
||||||
|
"built-asset": Record<string, string>;
|
||||||
|
// Location of css file that will be used for themes derived from this theme.
|
||||||
|
"runtime-asset": string;
|
||||||
|
// Array of derived-variables
|
||||||
|
"derived-variables": Array<string>;
|
||||||
|
/**
|
||||||
|
* Mapping from icon variable to location of icon in build output with query parameters
|
||||||
|
* indicating how it should be colored for this particular theme.
|
||||||
|
* eg: "icon-url-1": "element-logo.86bc8565.svg?primary=accent-color"
|
||||||
|
*/
|
||||||
|
icon: Record<string, string>;
|
||||||
|
};
|
||||||
|
values: {
|
||||||
|
/**
|
||||||
|
* Mapping from variant key to details pertaining to this theme-variant.
|
||||||
|
* This variant key is used for forming theme-id as mentioned above.
|
||||||
|
*/
|
||||||
|
variants: Record<string, Variant>;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type Variant = Partial<{
|
||||||
|
/**
|
||||||
|
* If true, this variant is used a default dark/light variant and will be the selected theme
|
||||||
|
* when "Match system theme" is selected for this theme collection in settings.
|
||||||
|
*/
|
||||||
|
default: boolean;
|
||||||
|
// A user-facing string that is the name for this variant.
|
||||||
|
name: string;
|
||||||
|
// A boolean indicating whether this is a dark theme or not
|
||||||
|
dark: boolean;
|
||||||
|
/**
|
||||||
|
* Mapping from css variable to its value.
|
||||||
|
* eg: {"background-color-primary": "#21262b", ...}
|
||||||
|
*/
|
||||||
|
variables: Record<string, string>;
|
||||||
|
}>;
|
|
@ -15,13 +15,13 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {RequestResult} from "../web/dom/request/fetch.js";
|
import type {RequestResult} from "../web/dom/request/fetch.js";
|
||||||
import type {EncodedBody} from "../../matrix/net/common";
|
import type {RequestBody} from "../../matrix/net/common";
|
||||||
import type {ILogItem} from "../../logging/types";
|
import type {ILogItem} from "../../logging/types";
|
||||||
|
|
||||||
export interface IRequestOptions {
|
export interface IRequestOptions {
|
||||||
uploadProgress?: (loadedBytes: number) => void;
|
uploadProgress?: (loadedBytes: number) => void;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
body?: EncodedBody;
|
body?: RequestBody;
|
||||||
headers?: Map<string, string|number>;
|
headers?: Map<string, string|number>;
|
||||||
cache?: boolean;
|
cache?: boolean;
|
||||||
method?: string;
|
method?: string;
|
||||||
|
|
|
@ -38,7 +38,7 @@ import {downloadInIframe} from "./dom/download.js";
|
||||||
import {Disposables} from "../../utils/Disposables";
|
import {Disposables} from "../../utils/Disposables";
|
||||||
import {parseHTML} from "./parsehtml.js";
|
import {parseHTML} from "./parsehtml.js";
|
||||||
import {handleAvatarError} from "./ui/avatar";
|
import {handleAvatarError} from "./ui/avatar";
|
||||||
import {ThemeLoader} from "./ThemeLoader";
|
import {ThemeLoader} from "./theming/ThemeLoader";
|
||||||
|
|
||||||
function addScript(src) {
|
function addScript(src) {
|
||||||
return new Promise(function (resolve, reject) {
|
return new Promise(function (resolve, reject) {
|
||||||
|
@ -338,13 +338,17 @@ export class Platform {
|
||||||
document.querySelectorAll(".theme").forEach(e => e.remove());
|
document.querySelectorAll(".theme").forEach(e => e.remove());
|
||||||
// add new theme
|
// add new theme
|
||||||
const styleTag = document.createElement("link");
|
const styleTag = document.createElement("link");
|
||||||
styleTag.href = `./${newPath}`;
|
styleTag.href = newPath;
|
||||||
styleTag.rel = "stylesheet";
|
styleTag.rel = "stylesheet";
|
||||||
styleTag.type = "text/css";
|
styleTag.type = "text/css";
|
||||||
styleTag.className = "theme";
|
styleTag.className = "theme";
|
||||||
head.appendChild(styleTag);
|
head.appendChild(styleTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get description() {
|
||||||
|
return navigator.userAgent ?? "<unknown>";
|
||||||
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
this._disposables.dispose();
|
this._disposables.dispose();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,207 +0,0 @@
|
||||||
/*
|
|
||||||
Copyright 2020 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 {ILogItem} from "../../logging/types.js";
|
|
||||||
import type {Platform} from "./Platform.js";
|
|
||||||
|
|
||||||
type NormalVariant = {
|
|
||||||
id: string;
|
|
||||||
cssLocation: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DefaultVariant = {
|
|
||||||
dark: {
|
|
||||||
id: string;
|
|
||||||
cssLocation: string;
|
|
||||||
variantName: string;
|
|
||||||
};
|
|
||||||
light: {
|
|
||||||
id: string;
|
|
||||||
cssLocation: string;
|
|
||||||
variantName: string;
|
|
||||||
};
|
|
||||||
default: {
|
|
||||||
id: string;
|
|
||||||
cssLocation: string;
|
|
||||||
variantName: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type ThemeInformation = NormalVariant | DefaultVariant;
|
|
||||||
|
|
||||||
export enum ColorSchemePreference {
|
|
||||||
Dark,
|
|
||||||
Light
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ThemeLoader {
|
|
||||||
private _platform: Platform;
|
|
||||||
private _themeMapping: Record<string, ThemeInformation>;
|
|
||||||
|
|
||||||
constructor(platform: Platform) {
|
|
||||||
this._platform = platform;
|
|
||||||
}
|
|
||||||
|
|
||||||
async init(manifestLocations: string[], log?: ILogItem): Promise<void> {
|
|
||||||
await this._platform.logger.wrapOrRun(log, "ThemeLoader.init", async (log) => {
|
|
||||||
this._themeMapping = {};
|
|
||||||
const results = await Promise.all(
|
|
||||||
manifestLocations.map( location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response())
|
|
||||||
);
|
|
||||||
results.forEach(({ body }) => this._populateThemeMap(body, log));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private _populateThemeMap(manifest, log: ILogItem) {
|
|
||||||
log.wrap("populateThemeMap", (l) => {
|
|
||||||
/*
|
|
||||||
After build has finished, the source section of each theme manifest
|
|
||||||
contains `built-assets` which is a mapping from the theme-id to
|
|
||||||
cssLocation of theme
|
|
||||||
*/
|
|
||||||
const builtAssets: Record<string, string> = manifest.source?.["built-assets"];
|
|
||||||
const themeName = manifest.name;
|
|
||||||
let defaultDarkVariant: any = {}, defaultLightVariant: any = {};
|
|
||||||
for (const [themeId, cssLocation] of Object.entries(builtAssets)) {
|
|
||||||
const variant = themeId.match(/.+-(.+)/)?.[1];
|
|
||||||
const { name: variantName, default: isDefault, dark } = manifest.values.variants[variant!];
|
|
||||||
const themeDisplayName = `${themeName} ${variantName}`;
|
|
||||||
if (isDefault) {
|
|
||||||
/**
|
|
||||||
* This is a default variant!
|
|
||||||
* We'll add these to the themeMapping (separately) keyed with just the
|
|
||||||
* theme-name (i.e "Element" instead of "Element Dark").
|
|
||||||
* We need to be able to distinguish them from other variants!
|
|
||||||
*
|
|
||||||
* This allows us to render radio-buttons with "dark" and
|
|
||||||
* "light" options.
|
|
||||||
*/
|
|
||||||
const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant;
|
|
||||||
defaultVariant.variantName = variantName;
|
|
||||||
defaultVariant.id = themeId
|
|
||||||
defaultVariant.cssLocation = cssLocation;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Non-default variants are keyed in themeMapping with "theme_name variant_name"
|
|
||||||
// eg: "Element Dark"
|
|
||||||
this._themeMapping[themeDisplayName] = {
|
|
||||||
cssLocation,
|
|
||||||
id: themeId
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (defaultDarkVariant.id && defaultLightVariant.id) {
|
|
||||||
/**
|
|
||||||
* As mentioned above, if there's both a default dark and a default light variant,
|
|
||||||
* add them to themeMapping separately.
|
|
||||||
*/
|
|
||||||
const defaultVariant = this.preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant;
|
|
||||||
this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant };
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
/**
|
|
||||||
* If only one default variant is found (i.e only dark default or light default but not both),
|
|
||||||
* treat it like any other variant.
|
|
||||||
*/
|
|
||||||
const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant;
|
|
||||||
this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation };
|
|
||||||
}
|
|
||||||
//Add the default-theme as an additional option to the mapping
|
|
||||||
const defaultThemeId = this.getDefaultTheme();
|
|
||||||
if (defaultThemeId) {
|
|
||||||
const themeDetails = this._findThemeDetailsFromId(defaultThemeId);
|
|
||||||
if (themeDetails) {
|
|
||||||
this._themeMapping["Default"] = { id: "default", cssLocation: themeDetails.cssLocation };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
l.log({ l: "Default Theme", theme: defaultThemeId});
|
|
||||||
l.log({ l: "Preferred colorscheme", scheme: this.preferredColorScheme === ColorSchemePreference.Dark ? "dark" : "light" });
|
|
||||||
l.log({ l: "Result", themeMapping: this._themeMapping });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setTheme(themeName: string, themeVariant?: "light" | "dark" | "default", log?: ILogItem) {
|
|
||||||
this._platform.logger.wrapOrRun(log, { l: "change theme", name: themeName, variant: themeVariant }, () => {
|
|
||||||
let cssLocation: string;
|
|
||||||
let themeDetails = this._themeMapping[themeName];
|
|
||||||
if ("id" in themeDetails) {
|
|
||||||
cssLocation = themeDetails.cssLocation;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
if (!themeVariant) {
|
|
||||||
throw new Error("themeVariant is undefined!");
|
|
||||||
}
|
|
||||||
cssLocation = themeDetails[themeVariant].cssLocation;
|
|
||||||
}
|
|
||||||
this._platform.replaceStylesheet(cssLocation);
|
|
||||||
this._platform.settingsStorage.setString("theme-name", themeName);
|
|
||||||
if (themeVariant) {
|
|
||||||
this._platform.settingsStorage.setString("theme-variant", themeVariant);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this._platform.settingsStorage.remove("theme-variant");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Maps theme display name to theme information */
|
|
||||||
get themeMapping(): Record<string, ThemeInformation> {
|
|
||||||
return this._themeMapping;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getActiveTheme(): Promise<{themeName: string, themeVariant?: string}> {
|
|
||||||
let themeName = await this._platform.settingsStorage.getString("theme-name");
|
|
||||||
let themeVariant = await this._platform.settingsStorage.getString("theme-variant");
|
|
||||||
if (!themeName || !this._themeMapping[themeName]) {
|
|
||||||
themeName = "Default" in this._themeMapping ? "Default" : Object.keys(this._themeMapping)[0];
|
|
||||||
if (!this._themeMapping[themeName][themeVariant]) {
|
|
||||||
themeVariant = "default" in this._themeMapping[themeName] ? "default" : undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { themeName, themeVariant };
|
|
||||||
}
|
|
||||||
|
|
||||||
getDefaultTheme(): string | undefined {
|
|
||||||
switch (this.preferredColorScheme) {
|
|
||||||
case ColorSchemePreference.Dark:
|
|
||||||
return this._platform.config["defaultTheme"]?.dark;
|
|
||||||
case ColorSchemePreference.Light:
|
|
||||||
return this._platform.config["defaultTheme"]?.light;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _findThemeDetailsFromId(themeId: string): {themeName: string, cssLocation: string, variant?: string} | undefined {
|
|
||||||
for (const [themeName, themeData] of Object.entries(this._themeMapping)) {
|
|
||||||
if ("id" in themeData && themeData.id === themeId) {
|
|
||||||
return { themeName, cssLocation: themeData.cssLocation };
|
|
||||||
}
|
|
||||||
else if ("light" in themeData && themeData.light?.id === themeId) {
|
|
||||||
return { themeName, cssLocation: themeData.light.cssLocation, variant: "light" };
|
|
||||||
}
|
|
||||||
else if ("dark" in themeData && themeData.dark?.id === themeId) {
|
|
||||||
return { themeName, cssLocation: themeData.dark.cssLocation, variant: "dark" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get preferredColorScheme(): ColorSchemePreference | undefined {
|
|
||||||
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
|
||||||
return ColorSchemePreference.Dark;
|
|
||||||
}
|
|
||||||
else if (window.matchMedia("(prefers-color-scheme: light)").matches) {
|
|
||||||
return ColorSchemePreference.Light;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,5 +4,6 @@
|
||||||
"gatewayUrl": "https://matrix.org",
|
"gatewayUrl": "https://matrix.org",
|
||||||
"applicationServerKey": "BC-gpSdVHEXhvHSHS0AzzWrQoukv2BE7KzpoPO_FfPacqOo3l1pdqz7rSgmB04pZCWaHPz7XRe6fjLaC-WPDopM"
|
"applicationServerKey": "BC-gpSdVHEXhvHSHS0AzzWrQoukv2BE7KzpoPO_FfPacqOo3l1pdqz7rSgmB04pZCWaHPz7XRe6fjLaC-WPDopM"
|
||||||
},
|
},
|
||||||
"defaultHomeServer": "matrix.org"
|
"defaultHomeServer": "matrix.org",
|
||||||
|
"bugReportEndpointUrl": "https://element.io/bugreports/submit"
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,12 @@ limitations under the License.
|
||||||
import {BaseObservableValue} from "../../../observable/ObservableValue";
|
import {BaseObservableValue} from "../../../observable/ObservableValue";
|
||||||
|
|
||||||
export class History extends BaseObservableValue {
|
export class History extends BaseObservableValue {
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._lastSessionHash = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
handleEvent(event) {
|
handleEvent(event) {
|
||||||
if (event.type === "hashchange") {
|
if (event.type === "hashchange") {
|
||||||
this.emit(this.get());
|
this.emit(this.get());
|
||||||
|
@ -65,6 +71,7 @@ export class History extends BaseObservableValue {
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubscribeFirst() {
|
onSubscribeFirst() {
|
||||||
|
this._lastSessionHash = window.localStorage?.getItem("hydrogen_last_url_hash");
|
||||||
window.addEventListener('hashchange', this);
|
window.addEventListener('hashchange', this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,7 +83,7 @@ export class History extends BaseObservableValue {
|
||||||
window.localStorage?.setItem("hydrogen_last_url_hash", hash);
|
window.localStorage?.setItem("hydrogen_last_url_hash", hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
getLastUrl() {
|
getLastSessionUrl() {
|
||||||
return window.localStorage?.getItem("hydrogen_last_url_hash");
|
return this._lastSessionHash;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,6 +27,20 @@ export function addCacheBuster(urlStr, random = Math.random) {
|
||||||
return urlStr + `_cacheBuster=${Math.ceil(random() * Number.MAX_SAFE_INTEGER)}`;
|
return urlStr + `_cacheBuster=${Math.ceil(random() * Number.MAX_SAFE_INTEGER)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mapAsFormData(map) {
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const [name, value] of map) {
|
||||||
|
// Special case {name: string, blob: BlobHandle} to set a filename.
|
||||||
|
// This is the format returned by platform.openFile
|
||||||
|
if (value.blob?.nativeBlob && value.name) {
|
||||||
|
formData.set(name, value.blob.nativeBlob, value.name);
|
||||||
|
} else {
|
||||||
|
formData.set(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
|
||||||
export function tests() {
|
export function tests() {
|
||||||
return {
|
return {
|
||||||
"add cache buster": assert => {
|
"add cache buster": assert => {
|
||||||
|
|
|
@ -20,7 +20,7 @@ import {
|
||||||
ConnectionError
|
ConnectionError
|
||||||
} from "../../../../matrix/error.js";
|
} from "../../../../matrix/error.js";
|
||||||
import {abortOnTimeout} from "../../../../utils/timeout";
|
import {abortOnTimeout} from "../../../../utils/timeout";
|
||||||
import {addCacheBuster} from "./common.js";
|
import {addCacheBuster, mapAsFormData} from "./common.js";
|
||||||
import {xhrRequest} from "./xhr.js";
|
import {xhrRequest} from "./xhr.js";
|
||||||
|
|
||||||
class RequestResult {
|
class RequestResult {
|
||||||
|
@ -70,6 +70,9 @@ export function createFetchRequest(createTimeout, serviceWorkerHandler) {
|
||||||
if (body?.nativeBlob) {
|
if (body?.nativeBlob) {
|
||||||
body = body.nativeBlob;
|
body = body.nativeBlob;
|
||||||
}
|
}
|
||||||
|
if (body instanceof Map) {
|
||||||
|
body = mapAsFormData(body);
|
||||||
|
}
|
||||||
let options = {method, body};
|
let options = {method, body};
|
||||||
if (controller) {
|
if (controller) {
|
||||||
options = Object.assign(options, {
|
options = Object.assign(options, {
|
||||||
|
@ -112,6 +115,9 @@ export function createFetchRequest(createTimeout, serviceWorkerHandler) {
|
||||||
} else if (format === "buffer") {
|
} else if (format === "buffer") {
|
||||||
body = await response.arrayBuffer();
|
body = await response.arrayBuffer();
|
||||||
}
|
}
|
||||||
|
else if (format === "text") {
|
||||||
|
body = await response.text();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// some error pages return html instead of json, ignore error
|
// some error pages return html instead of json, ignore error
|
||||||
if (!(err.name === "SyntaxError" && status >= 400)) {
|
if (!(err.name === "SyntaxError" && status >= 400)) {
|
||||||
|
|
|
@ -18,7 +18,7 @@ import {
|
||||||
AbortError,
|
AbortError,
|
||||||
ConnectionError
|
ConnectionError
|
||||||
} from "../../../../matrix/error.js";
|
} from "../../../../matrix/error.js";
|
||||||
import {addCacheBuster} from "./common.js";
|
import {addCacheBuster, mapAsFormData} from "./common.js";
|
||||||
|
|
||||||
class RequestResult {
|
class RequestResult {
|
||||||
constructor(promise, xhr) {
|
constructor(promise, xhr) {
|
||||||
|
@ -94,6 +94,9 @@ export function xhrRequest(url, options) {
|
||||||
if (body?.nativeBlob) {
|
if (body?.nativeBlob) {
|
||||||
body = body.nativeBlob;
|
body = body.nativeBlob;
|
||||||
}
|
}
|
||||||
|
if (body instanceof Map) {
|
||||||
|
body = mapAsFormData(body);
|
||||||
|
}
|
||||||
xhr.send(body || null);
|
xhr.send(body || null);
|
||||||
|
|
||||||
return new RequestResult(promise, xhr);
|
return new RequestResult(promise, xhr);
|
||||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
||||||
|
|
||||||
// import {RecordRequester, ReplayRequester} from "./matrix/net/request/replay";
|
// import {RecordRequester, ReplayRequester} from "./matrix/net/request/replay";
|
||||||
import {RootViewModel} from "../../domain/RootViewModel.js";
|
import {RootViewModel} from "../../domain/RootViewModel.js";
|
||||||
import {createNavigation, createRouter} from "../../domain/navigation/index.js";
|
import {createNavigation, createRouter} from "../../domain/navigation/index";
|
||||||
// Don't use a default export here, as we use multiple entries during legacy build,
|
// Don't use a default export here, as we use multiple entries during legacy build,
|
||||||
// which does not support default exports,
|
// which does not support default exports,
|
||||||
// see https://github.com/rollup/plugins/tree/master/packages/multi-entry
|
// see https://github.com/rollup/plugins/tree/master/packages/multi-entry
|
||||||
|
|
131
src/platform/web/theming/DerivedVariables.ts
Normal file
131
src/platform/web/theming/DerivedVariables.ts
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 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 {derive} from "./shared/color.mjs";
|
||||||
|
|
||||||
|
export class DerivedVariables {
|
||||||
|
private _baseVariables: Record<string, string>;
|
||||||
|
private _variablesToDerive: string[]
|
||||||
|
private _isDark: boolean
|
||||||
|
private _aliases: Record<string, string> = {};
|
||||||
|
private _derivedAliases: string[] = [];
|
||||||
|
|
||||||
|
constructor(baseVariables: Record<string, string>, variablesToDerive: string[], isDark: boolean) {
|
||||||
|
this._baseVariables = baseVariables;
|
||||||
|
this._variablesToDerive = variablesToDerive;
|
||||||
|
this._isDark = isDark;
|
||||||
|
}
|
||||||
|
|
||||||
|
toVariables(): Record<string, string> {
|
||||||
|
const resolvedVariables: any = {};
|
||||||
|
this._detectAliases();
|
||||||
|
for (const variable of this._variablesToDerive) {
|
||||||
|
const resolvedValue = this._derive(variable);
|
||||||
|
if (resolvedValue) {
|
||||||
|
resolvedVariables[variable] = resolvedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [alias, variable] of Object.entries(this._aliases) as any) {
|
||||||
|
resolvedVariables[alias] = this._baseVariables[variable] ?? resolvedVariables[variable];
|
||||||
|
}
|
||||||
|
for (const variable of this._derivedAliases) {
|
||||||
|
const resolvedValue = this._deriveAlias(variable, resolvedVariables);
|
||||||
|
if (resolvedValue) {
|
||||||
|
resolvedVariables[variable] = resolvedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolvedVariables;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _detectAliases(): void {
|
||||||
|
const newVariablesToDerive: string[] = [];
|
||||||
|
for (const variable of this._variablesToDerive) {
|
||||||
|
const [alias, value] = variable.split("=");
|
||||||
|
if (value) {
|
||||||
|
this._aliases[alias] = value;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
newVariablesToDerive.push(variable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._variablesToDerive = newVariablesToDerive;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _derive(variable: string): string | undefined {
|
||||||
|
const RE_VARIABLE_VALUE = /(.+)--(.+)-(.+)/;
|
||||||
|
const matches = variable.match(RE_VARIABLE_VALUE);
|
||||||
|
if (matches) {
|
||||||
|
const [, baseVariable, operation, argument] = matches;
|
||||||
|
const value = this._baseVariables[baseVariable];
|
||||||
|
if (!value ) {
|
||||||
|
if (this._aliases[baseVariable]) {
|
||||||
|
this._derivedAliases.push(variable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new Error(`Cannot find value for base variable "${baseVariable}"!`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const resolvedValue = derive(value, operation, argument, this._isDark);
|
||||||
|
return resolvedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _deriveAlias(variable: string, resolvedVariables: Record<string, string>): string | undefined {
|
||||||
|
const RE_VARIABLE_VALUE = /(.+)--(.+)-(.+)/;
|
||||||
|
const matches = variable.match(RE_VARIABLE_VALUE);
|
||||||
|
if (matches) {
|
||||||
|
const [, baseVariable, operation, argument] = matches;
|
||||||
|
const value = resolvedVariables[baseVariable];
|
||||||
|
if (!value ) {
|
||||||
|
throw new Error(`Cannot find value for alias "${baseVariable}" when trying to derive ${variable}!`);
|
||||||
|
}
|
||||||
|
const resolvedValue = derive(value, operation, argument, this._isDark);
|
||||||
|
return resolvedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import * as pkg from "off-color";
|
||||||
|
// @ts-ignore
|
||||||
|
const offColor = pkg.offColor ?? pkg.default.offColor;
|
||||||
|
|
||||||
|
export function tests() {
|
||||||
|
return {
|
||||||
|
"Simple variable derivation": assert => {
|
||||||
|
const deriver = new DerivedVariables({ "background-color": "#ff00ff" }, ["background-color--darker-5"], false);
|
||||||
|
const result = deriver.toVariables();
|
||||||
|
const resultColor = offColor("#ff00ff").darken(5/100).hex();
|
||||||
|
assert.deepEqual(result, {"background-color--darker-5": resultColor});
|
||||||
|
},
|
||||||
|
|
||||||
|
"For dark themes, lighten and darken are inverted": assert => {
|
||||||
|
const deriver = new DerivedVariables({ "background-color": "#ff00ff" }, ["background-color--darker-5"], true);
|
||||||
|
const result = deriver.toVariables();
|
||||||
|
const resultColor = offColor("#ff00ff").lighten(5/100).hex();
|
||||||
|
assert.deepEqual(result, {"background-color--darker-5": resultColor});
|
||||||
|
},
|
||||||
|
|
||||||
|
"Aliases can be derived": assert => {
|
||||||
|
const deriver = new DerivedVariables({ "background-color": "#ff00ff" }, ["my-awesome-alias=background-color","my-awesome-alias--darker-5"], false);
|
||||||
|
const result = deriver.toVariables();
|
||||||
|
const resultColor = offColor("#ff00ff").darken(5/100).hex();
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
"my-awesome-alias": "#ff00ff",
|
||||||
|
"my-awesome-alias--darker-5": resultColor,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
79
src/platform/web/theming/IconColorizer.ts
Normal file
79
src/platform/web/theming/IconColorizer.ts
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 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 {Platform} from "../Platform.js";
|
||||||
|
import {getColoredSvgString} from "./shared/svg-colorizer.mjs";
|
||||||
|
|
||||||
|
type ParsedStructure = {
|
||||||
|
[variableName: string]: {
|
||||||
|
svg: Promise<{ status: number; body: string }>;
|
||||||
|
primary: string | null;
|
||||||
|
secondary: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export class IconColorizer {
|
||||||
|
private _iconVariables: Record<string, string>;
|
||||||
|
private _resolvedVariables: Record<string, string>;
|
||||||
|
private _manifestLocation: string;
|
||||||
|
private _platform: Platform;
|
||||||
|
|
||||||
|
constructor(platform: Platform, iconVariables: Record<string, string>, resolvedVariables: Record<string, string>, manifestLocation: string) {
|
||||||
|
this._platform = platform;
|
||||||
|
this._iconVariables = iconVariables;
|
||||||
|
this._resolvedVariables = resolvedVariables;
|
||||||
|
this._manifestLocation = manifestLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async toVariables(): Promise<Record<string, string>> {
|
||||||
|
const { parsedStructure, promises } = await this._fetchAndParseIcons();
|
||||||
|
await Promise.all(promises);
|
||||||
|
return this._produceColoredIconVariables(parsedStructure);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _fetchAndParseIcons(): Promise<{ parsedStructure: ParsedStructure, promises: any[] }> {
|
||||||
|
const promises: any[] = [];
|
||||||
|
const parsedStructure: ParsedStructure = {};
|
||||||
|
for (const [variable, url] of Object.entries(this._iconVariables)) {
|
||||||
|
const urlObject = new URL(`https://${url}`);
|
||||||
|
const pathWithoutQueryParams = urlObject.hostname;
|
||||||
|
const relativePath = new URL(pathWithoutQueryParams, new URL(this._manifestLocation, window.location.origin));
|
||||||
|
const responsePromise = this._platform.request(relativePath, { method: "GET", format: "text", cache: true, }).response()
|
||||||
|
promises.push(responsePromise);
|
||||||
|
const searchParams = urlObject.searchParams;
|
||||||
|
parsedStructure[variable] = {
|
||||||
|
svg: responsePromise,
|
||||||
|
primary: searchParams.get("primary"),
|
||||||
|
secondary: searchParams.get("secondary")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { parsedStructure, promises };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _produceColoredIconVariables(parsedStructure: ParsedStructure): Promise<Record<string, string>> {
|
||||||
|
let coloredVariables: Record<string, string> = {};
|
||||||
|
for (const [variable, { svg, primary, secondary }] of Object.entries(parsedStructure)) {
|
||||||
|
const { body: svgCode } = await svg;
|
||||||
|
if (!primary) {
|
||||||
|
throw new Error(`Primary color variable ${primary} not in list of variables!`);
|
||||||
|
}
|
||||||
|
const primaryColor = this._resolvedVariables[primary], secondaryColor = this._resolvedVariables[secondary!];
|
||||||
|
const coloredSvgCode = getColoredSvgString(svgCode, primaryColor, secondaryColor);
|
||||||
|
const dataURI = `url('data:image/svg+xml;utf8,${encodeURIComponent(coloredSvgCode)}')`;
|
||||||
|
coloredVariables[variable] = dataURI;
|
||||||
|
}
|
||||||
|
return coloredVariables;
|
||||||
|
}
|
||||||
|
}
|
188
src/platform/web/theming/ThemeLoader.ts
Normal file
188
src/platform/web/theming/ThemeLoader.ts
Normal file
|
@ -0,0 +1,188 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 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 {ILogItem} from "../../../logging/types";
|
||||||
|
import type {Platform} from "../Platform.js";
|
||||||
|
import {RuntimeThemeParser} from "./parsers/RuntimeThemeParser";
|
||||||
|
import type {Variant, ThemeInformation} from "./parsers/types";
|
||||||
|
import {ColorSchemePreference} from "./parsers/types";
|
||||||
|
import {BuiltThemeParser} from "./parsers/BuiltThemeParser";
|
||||||
|
|
||||||
|
export class ThemeLoader {
|
||||||
|
private _platform: Platform;
|
||||||
|
private _themeMapping: Record<string, ThemeInformation>;
|
||||||
|
private _injectedVariables?: Record<string, string>;
|
||||||
|
|
||||||
|
constructor(platform: Platform) {
|
||||||
|
this._platform = platform;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(manifestLocations: string[], log?: ILogItem): Promise<void> {
|
||||||
|
await this._platform.logger.wrapOrRun(log, "ThemeLoader.init", async (log) => {
|
||||||
|
const results = await Promise.all(
|
||||||
|
manifestLocations.map(location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response())
|
||||||
|
);
|
||||||
|
const runtimeThemeParser = new RuntimeThemeParser(this._platform, this.preferredColorScheme);
|
||||||
|
const builtThemeParser = new BuiltThemeParser(this.preferredColorScheme);
|
||||||
|
const runtimeThemePromises: Promise<void>[] = [];
|
||||||
|
for (let i = 0; i < results.length; ++i) {
|
||||||
|
const { body } = results[i];
|
||||||
|
try {
|
||||||
|
if (body.extends) {
|
||||||
|
const indexOfBaseManifest = results.findIndex(manifest => manifest.body.id === body.extends);
|
||||||
|
if (indexOfBaseManifest === -1) {
|
||||||
|
throw new Error(`Base manifest for derived theme at ${manifestLocations[i]} not found!`);
|
||||||
|
}
|
||||||
|
const {body: baseManifest} = results[indexOfBaseManifest];
|
||||||
|
const baseManifestLocation = manifestLocations[indexOfBaseManifest];
|
||||||
|
const promise = runtimeThemeParser.parse(body, baseManifest, baseManifestLocation, log);
|
||||||
|
runtimeThemePromises.push(promise);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
builtThemeParser.parse(body, manifestLocations[i], log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(runtimeThemePromises);
|
||||||
|
this._themeMapping = { ...builtThemeParser.themeMapping, ...runtimeThemeParser.themeMapping };
|
||||||
|
Object.assign(this._themeMapping, builtThemeParser.themeMapping, runtimeThemeParser.themeMapping);
|
||||||
|
this._addDefaultThemeToMapping(log);
|
||||||
|
log.log({ l: "Preferred colorscheme", scheme: this.preferredColorScheme === ColorSchemePreference.Dark ? "dark" : "light" });
|
||||||
|
log.log({ l: "Result", themeMapping: this._themeMapping });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setTheme(themeName: string, themeVariant?: "light" | "dark" | "default", log?: ILogItem) {
|
||||||
|
this._platform.logger.wrapOrRun(log, { l: "change theme", name: themeName, variant: themeVariant }, () => {
|
||||||
|
let cssLocation: string, variables: Record<string, string>;
|
||||||
|
let themeDetails = this._themeMapping[themeName];
|
||||||
|
if ("id" in themeDetails) {
|
||||||
|
cssLocation = themeDetails.cssLocation;
|
||||||
|
variables = themeDetails.variables;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (!themeVariant) {
|
||||||
|
throw new Error("themeVariant is undefined!");
|
||||||
|
}
|
||||||
|
cssLocation = themeDetails[themeVariant].cssLocation;
|
||||||
|
variables = themeDetails[themeVariant].variables;
|
||||||
|
}
|
||||||
|
this._platform.replaceStylesheet(cssLocation);
|
||||||
|
if (variables) {
|
||||||
|
log?.log({l: "Derived Theme", variables});
|
||||||
|
this._injectCSSVariables(variables);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this._removePreviousCSSVariables();
|
||||||
|
}
|
||||||
|
this._platform.settingsStorage.setString("theme-name", themeName);
|
||||||
|
if (themeVariant) {
|
||||||
|
this._platform.settingsStorage.setString("theme-variant", themeVariant);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this._platform.settingsStorage.remove("theme-variant");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _injectCSSVariables(variables: Record<string, string>): void {
|
||||||
|
const root = document.documentElement;
|
||||||
|
for (const [variable, value] of Object.entries(variables)) {
|
||||||
|
root.style.setProperty(`--${variable}`, value);
|
||||||
|
}
|
||||||
|
this._injectedVariables = variables;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _removePreviousCSSVariables(): void {
|
||||||
|
if (!this._injectedVariables) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const root = document.documentElement;
|
||||||
|
for (const variable of Object.keys(this._injectedVariables)) {
|
||||||
|
root.style.removeProperty(`--${variable}`);
|
||||||
|
}
|
||||||
|
this._injectedVariables = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps theme display name to theme information */
|
||||||
|
get themeMapping(): Record<string, ThemeInformation> {
|
||||||
|
return this._themeMapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveTheme(): Promise<{themeName: string, themeVariant?: string}> {
|
||||||
|
let themeName = await this._platform.settingsStorage.getString("theme-name");
|
||||||
|
let themeVariant = await this._platform.settingsStorage.getString("theme-variant");
|
||||||
|
if (!themeName || !this._themeMapping[themeName]) {
|
||||||
|
themeName = "Default" in this._themeMapping ? "Default" : Object.keys(this._themeMapping)[0];
|
||||||
|
if (!this._themeMapping[themeName][themeVariant]) {
|
||||||
|
themeVariant = "default" in this._themeMapping[themeName] ? "default" : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { themeName, themeVariant };
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultTheme(): string | undefined {
|
||||||
|
switch (this.preferredColorScheme) {
|
||||||
|
case ColorSchemePreference.Dark:
|
||||||
|
return this._platform.config["defaultTheme"]?.dark;
|
||||||
|
case ColorSchemePreference.Light:
|
||||||
|
return this._platform.config["defaultTheme"]?.light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _findThemeDetailsFromId(themeId: string): {themeName: string, themeData: Partial<Variant>} | undefined {
|
||||||
|
for (const [themeName, themeData] of Object.entries(this._themeMapping)) {
|
||||||
|
if ("id" in themeData && themeData.id === themeId) {
|
||||||
|
return { themeName, themeData };
|
||||||
|
}
|
||||||
|
else if ("light" in themeData && themeData.light?.id === themeId) {
|
||||||
|
return { themeName, themeData: themeData.light };
|
||||||
|
}
|
||||||
|
else if ("dark" in themeData && themeData.dark?.id === themeId) {
|
||||||
|
return { themeName, themeData: themeData.dark };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _addDefaultThemeToMapping(log: ILogItem) {
|
||||||
|
log.wrap("addDefaultThemeToMapping", l => {
|
||||||
|
const defaultThemeId = this.getDefaultTheme();
|
||||||
|
if (defaultThemeId) {
|
||||||
|
const themeDetails = this._findThemeDetailsFromId(defaultThemeId);
|
||||||
|
if (themeDetails) {
|
||||||
|
this._themeMapping["Default"] = { id: "default", cssLocation: themeDetails.themeData.cssLocation! };
|
||||||
|
const variables = themeDetails.themeData.variables;
|
||||||
|
if (variables) {
|
||||||
|
this._themeMapping["Default"].variables = variables;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l.log({ l: "Default Theme", theme: defaultThemeId});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get preferredColorScheme(): ColorSchemePreference | undefined {
|
||||||
|
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||||
|
return ColorSchemePreference.Dark;
|
||||||
|
}
|
||||||
|
else if (window.matchMedia("(prefers-color-scheme: light)").matches) {
|
||||||
|
return ColorSchemePreference.Light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
106
src/platform/web/theming/parsers/BuiltThemeParser.ts
Normal file
106
src/platform/web/theming/parsers/BuiltThemeParser.ts
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 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 {ThemeInformation} from "./types";
|
||||||
|
import type {ThemeManifest} from "../../../types/theme";
|
||||||
|
import type {ILogItem} from "../../../../logging/types";
|
||||||
|
import {ColorSchemePreference} from "./types";
|
||||||
|
|
||||||
|
export class BuiltThemeParser {
|
||||||
|
private _themeMapping: Record<string, ThemeInformation> = {};
|
||||||
|
private _preferredColorScheme?: ColorSchemePreference;
|
||||||
|
|
||||||
|
constructor(preferredColorScheme?: ColorSchemePreference) {
|
||||||
|
this._preferredColorScheme = preferredColorScheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
parse(manifest: ThemeManifest, manifestLocation: string, log: ILogItem) {
|
||||||
|
log.wrap("BuiltThemeParser.parse", () => {
|
||||||
|
/*
|
||||||
|
After build has finished, the source section of each theme manifest
|
||||||
|
contains `built-assets` which is a mapping from the theme-id to
|
||||||
|
cssLocation of theme
|
||||||
|
*/
|
||||||
|
const builtAssets: Record<string, string> = manifest.source?.["built-assets"];
|
||||||
|
const themeName = manifest.name;
|
||||||
|
if (!themeName) {
|
||||||
|
throw new Error(`Theme name not found in manifest at ${manifestLocation}`);
|
||||||
|
}
|
||||||
|
let defaultDarkVariant: any = {}, defaultLightVariant: any = {};
|
||||||
|
for (let [themeId, cssLocation] of Object.entries(builtAssets)) {
|
||||||
|
try {
|
||||||
|
/**
|
||||||
|
* This cssLocation is relative to the location of the manifest file.
|
||||||
|
* So we first need to resolve it relative to the root of this hydrogen instance.
|
||||||
|
*/
|
||||||
|
cssLocation = new URL(cssLocation, new URL(manifestLocation, window.location.origin)).href;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const variant = themeId.match(/.+-(.+)/)?.[1];
|
||||||
|
const variantDetails = manifest.values?.variants[variant!];
|
||||||
|
if (!variantDetails) {
|
||||||
|
throw new Error(`Variant ${variant} is missing in manifest at ${manifestLocation}`);
|
||||||
|
}
|
||||||
|
const { name: variantName, default: isDefault, dark } = variantDetails;
|
||||||
|
const themeDisplayName = `${themeName} ${variantName}`;
|
||||||
|
if (isDefault) {
|
||||||
|
/**
|
||||||
|
* This is a default variant!
|
||||||
|
* We'll add these to the themeMapping (separately) keyed with just the
|
||||||
|
* theme-name (i.e "Element" instead of "Element Dark").
|
||||||
|
* We need to be able to distinguish them from other variants!
|
||||||
|
*
|
||||||
|
* This allows us to render radio-buttons with "dark" and
|
||||||
|
* "light" options.
|
||||||
|
*/
|
||||||
|
const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant;
|
||||||
|
defaultVariant.variantName = variantName;
|
||||||
|
defaultVariant.id = themeId
|
||||||
|
defaultVariant.cssLocation = cssLocation;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Non-default variants are keyed in themeMapping with "theme_name variant_name"
|
||||||
|
// eg: "Element Dark"
|
||||||
|
this._themeMapping[themeDisplayName] = {
|
||||||
|
cssLocation,
|
||||||
|
id: themeId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (defaultDarkVariant.id && defaultLightVariant.id) {
|
||||||
|
/**
|
||||||
|
* As mentioned above, if there's both a default dark and a default light variant,
|
||||||
|
* add them to themeMapping separately.
|
||||||
|
*/
|
||||||
|
const defaultVariant = this._preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant;
|
||||||
|
this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant };
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
/**
|
||||||
|
* If only one default variant is found (i.e only dark default or light default but not both),
|
||||||
|
* treat it like any other variant.
|
||||||
|
*/
|
||||||
|
const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant;
|
||||||
|
this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get themeMapping(): Record<string, ThemeInformation> {
|
||||||
|
return this._themeMapping;
|
||||||
|
}
|
||||||
|
}
|
98
src/platform/web/theming/parsers/RuntimeThemeParser.ts
Normal file
98
src/platform/web/theming/parsers/RuntimeThemeParser.ts
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 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 {ThemeInformation} from "./types";
|
||||||
|
import type {Platform} from "../../Platform.js";
|
||||||
|
import type {ThemeManifest} from "../../../types/theme";
|
||||||
|
import {ColorSchemePreference} from "./types";
|
||||||
|
import {IconColorizer} from "../IconColorizer";
|
||||||
|
import {DerivedVariables} from "../DerivedVariables";
|
||||||
|
import {ILogItem} from "../../../../logging/types";
|
||||||
|
|
||||||
|
export class RuntimeThemeParser {
|
||||||
|
private _themeMapping: Record<string, ThemeInformation> = {};
|
||||||
|
private _preferredColorScheme?: ColorSchemePreference;
|
||||||
|
private _platform: Platform;
|
||||||
|
|
||||||
|
constructor(platform: Platform, preferredColorScheme?: ColorSchemePreference) {
|
||||||
|
this._preferredColorScheme = preferredColorScheme;
|
||||||
|
this._platform = platform;
|
||||||
|
}
|
||||||
|
|
||||||
|
async parse(manifest: ThemeManifest, baseManifest: ThemeManifest, baseManifestLocation: string, log: ILogItem): Promise<void> {
|
||||||
|
await log.wrap("RuntimeThemeParser.parse", async () => {
|
||||||
|
const {cssLocation, derivedVariables, icons} = this._getSourceData(baseManifest, baseManifestLocation, log);
|
||||||
|
const themeName = manifest.name;
|
||||||
|
if (!themeName) {
|
||||||
|
throw new Error(`Theme name not found in manifest!`);
|
||||||
|
}
|
||||||
|
let defaultDarkVariant: any = {}, defaultLightVariant: any = {};
|
||||||
|
for (const [variant, variantDetails] of Object.entries(manifest.values?.variants!) as [string, any][]) {
|
||||||
|
try {
|
||||||
|
const themeId = `${manifest.id}-${variant}`;
|
||||||
|
const { name: variantName, default: isDefault, dark, variables } = variantDetails;
|
||||||
|
const resolvedVariables = new DerivedVariables(variables, derivedVariables, dark).toVariables();
|
||||||
|
Object.assign(variables, resolvedVariables);
|
||||||
|
const iconVariables = await new IconColorizer(this._platform, icons, variables, baseManifestLocation).toVariables();
|
||||||
|
Object.assign(variables, resolvedVariables, iconVariables);
|
||||||
|
const themeDisplayName = `${themeName} ${variantName}`;
|
||||||
|
if (isDefault) {
|
||||||
|
const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant;
|
||||||
|
Object.assign(defaultVariant, { variantName, id: themeId, cssLocation, variables });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this._themeMapping[themeDisplayName] = { cssLocation, id: themeId, variables: variables, };
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (defaultDarkVariant.id && defaultLightVariant.id) {
|
||||||
|
const defaultVariant = this._preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant;
|
||||||
|
this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant };
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant;
|
||||||
|
this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getSourceData(manifest: ThemeManifest, location: string, log: ILogItem)
|
||||||
|
: { cssLocation: string, derivedVariables: string[], icons: Record<string, string>} {
|
||||||
|
return log.wrap("getSourceData", () => {
|
||||||
|
const runtimeCSSLocation = manifest.source?.["runtime-asset"];
|
||||||
|
if (!runtimeCSSLocation) {
|
||||||
|
throw new Error(`Run-time asset not found in source section for theme at ${location}`);
|
||||||
|
}
|
||||||
|
const cssLocation = new URL(runtimeCSSLocation, new URL(location, window.location.origin)).href;
|
||||||
|
const derivedVariables = manifest.source?.["derived-variables"];
|
||||||
|
if (!derivedVariables) {
|
||||||
|
throw new Error(`Derived variables not found in source section for theme at ${location}`);
|
||||||
|
}
|
||||||
|
const icons = manifest.source?.["icon"];
|
||||||
|
if (!icons) {
|
||||||
|
throw new Error(`Icon mapping not found in source section for theme at ${location}`);
|
||||||
|
}
|
||||||
|
return { cssLocation, derivedVariables, icons };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get themeMapping(): Record<string, ThemeInformation> {
|
||||||
|
return this._themeMapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
38
src/platform/web/theming/parsers/types.ts
Normal file
38
src/platform/web/theming/parsers/types.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type NormalVariant = {
|
||||||
|
id: string;
|
||||||
|
cssLocation: string;
|
||||||
|
variables?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
|
@ -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;
|
||||||
|
|
||||||
const offColor = require("off-color").offColor;
|
export function derive(value, operation, argument, isDark) {
|
||||||
|
|
||||||
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
|
24
src/platform/web/theming/shared/svg-colorizer.mjs
Normal file
24
src/platform/web/theming/shared/svg-colorizer.mjs
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function getColoredSvgString(svgString, primaryColor, secondaryColor) {
|
||||||
|
let coloredSVGCode = svgString.replaceAll("#ff00ff", primaryColor);
|
||||||
|
coloredSVGCode = coloredSVGCode.replaceAll("#00ffff", secondaryColor);
|
||||||
|
if (svgString === 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).");
|
||||||
|
}
|
||||||
|
return coloredSVGCode;
|
||||||
|
}
|
|
@ -31,7 +31,7 @@ export function renderStaticAvatar(vm, size, extraClasses = undefined) {
|
||||||
avatarClasses += ` ${extraClasses}`;
|
avatarClasses += ` ${extraClasses}`;
|
||||||
}
|
}
|
||||||
const avatarContent = hasAvatar ? renderImg(vm, size) : text(vm.avatarLetter);
|
const avatarContent = hasAvatar ? renderImg(vm, size) : text(vm.avatarLetter);
|
||||||
const avatar = tag.div({className: avatarClasses}, [avatarContent]);
|
const avatar = tag.div({className: avatarClasses, "data-testid": "avatar"}, [avatarContent]);
|
||||||
if (hasAvatar) {
|
if (hasAvatar) {
|
||||||
setAttribute(avatar, "data-avatar-letter", vm.avatarLetter);
|
setAttribute(avatar, "data-avatar-letter", vm.avatarLetter);
|
||||||
setAttribute(avatar, "data-avatar-color", vm.avatarColorNumber);
|
setAttribute(avatar, "data-avatar-color", vm.avatarColorNumber);
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"name": "Element",
|
"name": "Element",
|
||||||
|
"id": "element",
|
||||||
"values": {
|
"values": {
|
||||||
"variants": {
|
"variants": {
|
||||||
"light": {
|
"light": {
|
||||||
|
|
|
@ -521,6 +521,62 @@ a {
|
||||||
|
|
||||||
.RoomView_error {
|
.RoomView_error {
|
||||||
color: var(--error-color);
|
color: var(--error-color);
|
||||||
|
background : #efefef;
|
||||||
|
height : 0px;
|
||||||
|
font-weight : bold;
|
||||||
|
transition : 0.25s all ease-out;
|
||||||
|
padding-right : 20px;
|
||||||
|
padding-left : 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView_error div{
|
||||||
|
overflow : hidden;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
position : relative;
|
||||||
|
display : flex;
|
||||||
|
align-items : center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView_error:not(:empty) {
|
||||||
|
height : auto;
|
||||||
|
padding-top : 20px;
|
||||||
|
padding-bottom : 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView_error p {
|
||||||
|
position : relative;
|
||||||
|
display : block;
|
||||||
|
width : 100%;
|
||||||
|
height : auto;
|
||||||
|
margin : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView_error button {
|
||||||
|
width : 40px;
|
||||||
|
padding-top : 20px;
|
||||||
|
padding-bottom : 20px;
|
||||||
|
background : none;
|
||||||
|
border : none;
|
||||||
|
position : relative;
|
||||||
|
border-radius : 5px;
|
||||||
|
transition: 0.1s all ease-out;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView_error button:hover {
|
||||||
|
background : #cfcfcf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView_error button:before {
|
||||||
|
content:"\274c";
|
||||||
|
position : absolute;
|
||||||
|
top : 15px;
|
||||||
|
left: 9px;
|
||||||
|
width : 20px;
|
||||||
|
height : 10px;
|
||||||
|
font-size : 10px;
|
||||||
|
align-self : middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.MessageComposer_replyPreview .Timeline_message {
|
.MessageComposer_replyPreview .Timeline_message {
|
||||||
|
@ -894,12 +950,12 @@ button.link {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.RoomArchivedView {
|
.DisabledComposerView {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background-color: var(--background-color-secondary);
|
background-color: var(--background-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.RoomArchivedView h3 {
|
.DisabledComposerView h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -233,7 +233,7 @@ only loads when the top comes into view*/
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Timeline_messageBody .media > .sendStatus {
|
.Timeline_messageBody .media > .status {
|
||||||
align-self: end;
|
align-self: end;
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
|
@ -251,7 +251,7 @@ only loads when the top comes into view*/
|
||||||
}
|
}
|
||||||
|
|
||||||
.Timeline_messageBody .media > time,
|
.Timeline_messageBody .media > time,
|
||||||
.Timeline_messageBody .media > .sendStatus {
|
.Timeline_messageBody .media > .status {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
display: block;
|
display: block;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
|
|
|
@ -54,3 +54,11 @@ export function insertAt(parentNode: Element, idx: number, childNode: Node): voi
|
||||||
export function removeChildren(parentNode: Element): void {
|
export function removeChildren(parentNode: Element): void {
|
||||||
parentNode.innerHTML = '';
|
parentNode.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function disableTargetCallback(callback: (evt: Event) => Promise<void>): (evt: Event) => Promise<void> {
|
||||||
|
return async (evt: Event) => {
|
||||||
|
(evt.target as HTMLElement)?.setAttribute("disabled", "disabled");
|
||||||
|
await callback(evt);
|
||||||
|
(evt.target as HTMLElement)?.removeAttribute("disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -16,8 +16,8 @@ limitations under the License.
|
||||||
|
|
||||||
import {TemplateView} from "../../general/TemplateView";
|
import {TemplateView} from "../../general/TemplateView";
|
||||||
|
|
||||||
export class RoomArchivedView extends TemplateView {
|
export class DisabledComposerView extends TemplateView {
|
||||||
render(t) {
|
render(t) {
|
||||||
return t.div({className: "RoomArchivedView"}, t.h3(vm => vm.description));
|
return t.div({className: "DisabledComposerView"}, t.h3(vm => vm.description));
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -21,7 +21,7 @@ import {Menu} from "../../general/Menu.js";
|
||||||
import {TimelineView} from "./TimelineView";
|
import {TimelineView} from "./TimelineView";
|
||||||
import {TimelineLoadingView} from "./TimelineLoadingView.js";
|
import {TimelineLoadingView} from "./TimelineLoadingView.js";
|
||||||
import {MessageComposer} from "./MessageComposer.js";
|
import {MessageComposer} from "./MessageComposer.js";
|
||||||
import {RoomArchivedView} from "./RoomArchivedView.js";
|
import {DisabledComposerView} from "./DisabledComposerView.js";
|
||||||
import {AvatarView} from "../../AvatarView.js";
|
import {AvatarView} from "../../AvatarView.js";
|
||||||
|
|
||||||
export class RoomView extends TemplateView {
|
export class RoomView extends TemplateView {
|
||||||
|
@ -32,12 +32,6 @@ export class RoomView extends TemplateView {
|
||||||
}
|
}
|
||||||
|
|
||||||
render(t, vm) {
|
render(t, vm) {
|
||||||
let bottomView;
|
|
||||||
if (vm.composerViewModel.kind === "composer") {
|
|
||||||
bottomView = new MessageComposer(vm.composerViewModel, this._viewClassForTile);
|
|
||||||
} else if (vm.composerViewModel.kind === "archived") {
|
|
||||||
bottomView = new RoomArchivedView(vm.composerViewModel);
|
|
||||||
}
|
|
||||||
return t.main({className: "RoomView middle"}, [
|
return t.main({className: "RoomView middle"}, [
|
||||||
t.div({className: "RoomHeader middle-header"}, [
|
t.div({className: "RoomHeader middle-header"}, [
|
||||||
t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close room`}),
|
t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close room`}),
|
||||||
|
@ -52,17 +46,31 @@ export class RoomView extends TemplateView {
|
||||||
})
|
})
|
||||||
]),
|
]),
|
||||||
t.div({className: "RoomView_body"}, [
|
t.div({className: "RoomView_body"}, [
|
||||||
t.div({className: "RoomView_error"}, vm => vm.error),
|
t.div({className: "RoomView_error"}, [
|
||||||
|
t.if(vm => vm.error, t => t.div(
|
||||||
|
[
|
||||||
|
t.p({}, vm => vm.error),
|
||||||
|
t.button({ className: "RoomView_error_closerButton", onClick: evt => vm.dismissError(evt) })
|
||||||
|
])
|
||||||
|
)]),
|
||||||
t.mapView(vm => vm.timelineViewModel, timelineViewModel => {
|
t.mapView(vm => vm.timelineViewModel, timelineViewModel => {
|
||||||
return timelineViewModel ?
|
return timelineViewModel ?
|
||||||
new TimelineView(timelineViewModel, this._viewClassForTile) :
|
new TimelineView(timelineViewModel, this._viewClassForTile) :
|
||||||
new TimelineLoadingView(vm); // vm is just needed for i18n
|
new TimelineLoadingView(vm); // vm is just needed for i18n
|
||||||
}),
|
}),
|
||||||
t.view(bottomView),
|
t.mapView(vm => vm.composerViewModel,
|
||||||
|
composerViewModel => {
|
||||||
|
switch (composerViewModel?.kind) {
|
||||||
|
case "composer":
|
||||||
|
return new MessageComposer(vm.composerViewModel, this._viewClassForTile);
|
||||||
|
case "disabled":
|
||||||
|
return new DisabledComposerView(vm.composerViewModel);
|
||||||
|
}
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
_toggleOptionsMenu(evt) {
|
_toggleOptionsMenu(evt) {
|
||||||
if (this._optionsPopup && this._optionsPopup.isOpen) {
|
if (this._optionsPopup && this._optionsPopup.isOpen) {
|
||||||
this._optionsPopup.close();
|
this._optionsPopup.close();
|
||||||
|
|
|
@ -15,6 +15,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {BaseMessageView} from "./BaseMessageView.js";
|
import {BaseMessageView} from "./BaseMessageView.js";
|
||||||
|
import {Menu} from "../../../general/Menu.js";
|
||||||
|
|
||||||
export class BaseMediaView extends BaseMessageView {
|
export class BaseMediaView extends BaseMessageView {
|
||||||
renderMessageBody(t, vm) {
|
renderMessageBody(t, vm) {
|
||||||
|
@ -35,24 +36,39 @@ export class BaseMediaView extends BaseMessageView {
|
||||||
this.renderMedia(t, vm),
|
this.renderMedia(t, vm),
|
||||||
t.time(vm.date + " " + vm.time),
|
t.time(vm.date + " " + vm.time),
|
||||||
];
|
];
|
||||||
|
const status = t.div({
|
||||||
|
className: {
|
||||||
|
status: true,
|
||||||
|
hidden: vm => !vm.status
|
||||||
|
},
|
||||||
|
}, vm => vm.status);
|
||||||
|
children.push(status);
|
||||||
if (vm.isPending) {
|
if (vm.isPending) {
|
||||||
const sendStatus = t.div({
|
|
||||||
className: {
|
|
||||||
sendStatus: true,
|
|
||||||
hidden: vm => !vm.sendStatus
|
|
||||||
},
|
|
||||||
}, vm => vm.sendStatus);
|
|
||||||
const progress = t.progress({
|
const progress = t.progress({
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
value: vm => vm.uploadPercentage,
|
value: vm => vm.uploadPercentage,
|
||||||
className: {hidden: vm => !vm.isUploading}
|
className: {hidden: vm => !vm.isUploading}
|
||||||
});
|
});
|
||||||
children.push(sendStatus, progress);
|
children.push(progress);
|
||||||
}
|
}
|
||||||
return t.div({className: "Timeline_messageBody"}, [
|
return t.div({className: "Timeline_messageBody"}, [
|
||||||
t.div({className: "media", style: `max-width: ${vm.width}px`}, children),
|
t.div({className: "media", style: `max-width: ${vm.width}px`, "data-testid": "media"}, children),
|
||||||
t.if(vm => vm.error, t => t.p({className: "error"}, vm.error))
|
t.if(vm => vm.error, t => t.p({className: "error"}, vm.error))
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createMenuOptions(vm) {
|
||||||
|
const options = super.createMenuOptions(vm);
|
||||||
|
if (!vm.isPending) {
|
||||||
|
let label;
|
||||||
|
switch (vm.shape) {
|
||||||
|
case "image": label = vm.i18n`Download image`; break;
|
||||||
|
case "video": label = vm.i18n`Download video`; break;
|
||||||
|
default: label = vm.i18n`Download media`; break;
|
||||||
|
}
|
||||||
|
options.push(Menu.option(label, () => vm.downloadMedia()));
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,6 @@ export class ImageView extends BaseMediaView {
|
||||||
title: vm => vm.label,
|
title: vm => vm.label,
|
||||||
style: `max-width: ${vm.width}px; max-height: ${vm.height}px;`
|
style: `max-width: ${vm.width}px; max-height: ${vm.height}px;`
|
||||||
});
|
});
|
||||||
return vm.isPending ? img : t.a({href: vm.lightboxUrl}, img);
|
return vm.isPending || !vm.lightboxUrl ? img : t.a({href: vm.lightboxUrl}, img);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {TemplateView} from "../../general/TemplateView";
|
import {TemplateView} from "../../general/TemplateView";
|
||||||
|
import {disableTargetCallback} from "../../general/utils";
|
||||||
import {KeyBackupSettingsView} from "./KeyBackupSettingsView.js"
|
import {KeyBackupSettingsView} from "./KeyBackupSettingsView.js"
|
||||||
|
|
||||||
export class SettingsView extends TemplateView {
|
export class SettingsView extends TemplateView {
|
||||||
|
@ -101,11 +102,17 @@ export class SettingsView extends TemplateView {
|
||||||
return row(t, vm.i18n`Use the following theme`, this._themeOptions(t, vm));
|
return row(t, vm.i18n`Use the following theme`, this._themeOptions(t, vm));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const logButtons = [];
|
||||||
|
if (vm.canSendLogsToServer) {
|
||||||
|
logButtons.push(t.button({onClick: disableTargetCallback(() => vm.sendLogsToServer())}, `Submit logs to ${vm.logsServer}`));
|
||||||
|
}
|
||||||
|
logButtons.push(t.button({onClick: () => vm.exportLogs()}, "Download logs"));
|
||||||
settingNodes.push(
|
settingNodes.push(
|
||||||
t.h3("Application"),
|
t.h3("Application"),
|
||||||
row(t, vm.i18n`Version`, version),
|
row(t, vm.i18n`Version`, version),
|
||||||
row(t, vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`),
|
row(t, vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`),
|
||||||
row(t, vm.i18n`Debug logs`, t.button({onClick: () => vm.exportLogs()}, "Export")),
|
row(t, vm.i18n`Debug logs`, logButtons),
|
||||||
|
t.p({className: {hidden: vm => !vm.logsFeedbackMessage}}, vm => vm.logsFeedbackMessage),
|
||||||
t.p(["Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited, the usernames of other users and the names of files you send. They do not contain messages. For more information, review our ",
|
t.p(["Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited, the usernames of other users and the names of files you send. They do not contain messages. For more information, review our ",
|
||||||
t.a({href: "https://element.io/privacy", target: "_blank", rel: "noopener"}, "privacy policy"), "."]),
|
t.a({href: "https://element.io/privacy", target: "_blank", rel: "noopener"}, "privacy policy"), "."]),
|
||||||
);
|
);
|
||||||
|
|
|
@ -8,8 +8,8 @@ const path = require("path");
|
||||||
const manifest = require("./package.json");
|
const manifest = require("./package.json");
|
||||||
const version = manifest.version;
|
const version = manifest.version;
|
||||||
const compiledVariables = new Map();
|
const compiledVariables = new Map();
|
||||||
const derive = require("./scripts/postcss/color").derive;
|
import {buildColorizedSVG as replacer} from "./scripts/postcss/svg-builder.mjs";
|
||||||
const replacer = require("./scripts/postcss/svg-colorizer").buildColorizedSVG;
|
import {derive} from "./src/platform/web/theming/shared/color.mjs";
|
||||||
|
|
||||||
const commonOptions = {
|
const commonOptions = {
|
||||||
logLevel: "warn",
|
logLevel: "warn",
|
||||||
|
@ -40,7 +40,7 @@ const commonOptions = {
|
||||||
postcss: {
|
postcss: {
|
||||||
plugins: [
|
plugins: [
|
||||||
compileVariables({derive, compiledVariables}),
|
compileVariables({derive, compiledVariables}),
|
||||||
urlVariables({compileVariables}),
|
urlVariables({compiledVariables}),
|
||||||
urlProcessor({replacer}),
|
urlProcessor({replacer}),
|
||||||
// cssvariables({
|
// cssvariables({
|
||||||
// preserve: (declaration) => {
|
// preserve: (declaration) => {
|
||||||
|
|
|
@ -33,9 +33,7 @@ export default defineConfig(({mode}) => {
|
||||||
plugins: [
|
plugins: [
|
||||||
themeBuilder({
|
themeBuilder({
|
||||||
themeConfig: {
|
themeConfig: {
|
||||||
themes: {
|
themes: ["./src/platform/web/ui/css/themes/element"],
|
||||||
element: "./src/platform/web/ui/css/themes/element",
|
|
||||||
},
|
|
||||||
default: "element",
|
default: "element",
|
||||||
},
|
},
|
||||||
compiledVariables,
|
compiledVariables,
|
||||||
|
|
|
@ -3,8 +3,9 @@ const mergeOptions = require('merge-options');
|
||||||
const themeBuilder = require("./scripts/build-plugins/rollup-plugin-build-themes");
|
const themeBuilder = require("./scripts/build-plugins/rollup-plugin-build-themes");
|
||||||
const {commonOptions, compiledVariables} = require("./vite.common-config.js");
|
const {commonOptions, compiledVariables} = require("./vite.common-config.js");
|
||||||
|
|
||||||
// These paths will be saved without their hash so they havea consisent path to
|
// These paths will be saved without their hash so they have a consisent path
|
||||||
// reference
|
// that we can reference in our `package.json` `exports`. And so people can import
|
||||||
|
// them with a consistent path.
|
||||||
const pathsToExport = [
|
const pathsToExport = [
|
||||||
"main.js",
|
"main.js",
|
||||||
"download-sandbox.html",
|
"download-sandbox.html",
|
||||||
|
@ -21,7 +22,8 @@ export default mergeOptions(commonOptions, {
|
||||||
output: {
|
output: {
|
||||||
assetFileNames: (chunkInfo) => {
|
assetFileNames: (chunkInfo) => {
|
||||||
// Get rid of the hash so we can consistently reference these
|
// Get rid of the hash so we can consistently reference these
|
||||||
// files in our `package.json` `exports`
|
// files in our `package.json` `exports`. And so people can
|
||||||
|
// import them with a consistent path.
|
||||||
if(pathsToExport.includes(path.basename(chunkInfo.name))) {
|
if(pathsToExport.includes(path.basename(chunkInfo.name))) {
|
||||||
return "assets/[name].[ext]";
|
return "assets/[name].[ext]";
|
||||||
}
|
}
|
||||||
|
@ -34,7 +36,7 @@ export default mergeOptions(commonOptions, {
|
||||||
plugins: [
|
plugins: [
|
||||||
themeBuilder({
|
themeBuilder({
|
||||||
themeConfig: {
|
themeConfig: {
|
||||||
themes: { element: "./src/platform/web/ui/css/themes/element" },
|
themes: ["./src/platform/web/ui/css/themes/element"],
|
||||||
default: "element",
|
default: "element",
|
||||||
},
|
},
|
||||||
compiledVariables,
|
compiledVariables,
|
||||||
|
|
64
yarn.lock
64
yarn.lock
|
@ -52,9 +52,9 @@
|
||||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz#87de7af9c231826fdd68ac7258f77c429e0e5fcf"
|
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz#87de7af9c231826fdd68ac7258f77c429e0e5fcf"
|
||||||
integrity sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==
|
integrity sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==
|
||||||
|
|
||||||
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz":
|
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz":
|
||||||
version "3.2.3"
|
version "3.2.8"
|
||||||
resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz#cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4"
|
resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz#8d53636d045e1776e2a2ec6613e57330dd9ce856"
|
||||||
|
|
||||||
"@nodelib/fs.scandir@2.1.5":
|
"@nodelib/fs.scandir@2.1.5":
|
||||||
version "2.1.5"
|
version "2.1.5"
|
||||||
|
@ -77,6 +77,11 @@
|
||||||
"@nodelib/fs.scandir" "2.1.5"
|
"@nodelib/fs.scandir" "2.1.5"
|
||||||
fastq "^1.6.0"
|
fastq "^1.6.0"
|
||||||
|
|
||||||
|
"@trysound/sax@0.2.0":
|
||||||
|
version "0.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad"
|
||||||
|
integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==
|
||||||
|
|
||||||
"@types/json-schema@^7.0.7":
|
"@types/json-schema@^7.0.7":
|
||||||
version "7.0.9"
|
version "7.0.9"
|
||||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d"
|
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d"
|
||||||
|
@ -347,6 +352,11 @@ commander@^6.1.0:
|
||||||
resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
|
resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
|
||||||
integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==
|
integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==
|
||||||
|
|
||||||
|
commander@^7.2.0:
|
||||||
|
version "7.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
|
||||||
|
integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
|
||||||
|
|
||||||
concat-map@0.0.1:
|
concat-map@0.0.1:
|
||||||
version "0.0.1"
|
version "0.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
|
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
|
||||||
|
@ -382,11 +392,26 @@ css-select@^4.1.3:
|
||||||
domutils "^2.6.0"
|
domutils "^2.6.0"
|
||||||
nth-check "^2.0.0"
|
nth-check "^2.0.0"
|
||||||
|
|
||||||
|
css-tree@^1.1.2, css-tree@^1.1.3:
|
||||||
|
version "1.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d"
|
||||||
|
integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==
|
||||||
|
dependencies:
|
||||||
|
mdn-data "2.0.14"
|
||||||
|
source-map "^0.6.1"
|
||||||
|
|
||||||
css-what@^5.0.0:
|
css-what@^5.0.0:
|
||||||
version "5.0.1"
|
version "5.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/css-what/-/css-what-5.0.1.tgz#3efa820131f4669a8ac2408f9c32e7c7de9f4cad"
|
resolved "https://registry.yarnpkg.com/css-what/-/css-what-5.0.1.tgz#3efa820131f4669a8ac2408f9c32e7c7de9f4cad"
|
||||||
integrity sha512-FYDTSHb/7KXsWICVsxdmiExPjCfRC4qRFBdVwv7Ax9hMnvMmEjP9RfxTEZ3qPZGmADDn2vAKSo9UcN1jKVYscg==
|
integrity sha512-FYDTSHb/7KXsWICVsxdmiExPjCfRC4qRFBdVwv7Ax9hMnvMmEjP9RfxTEZ3qPZGmADDn2vAKSo9UcN1jKVYscg==
|
||||||
|
|
||||||
|
csso@^4.2.0:
|
||||||
|
version "4.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/csso/-/csso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529"
|
||||||
|
integrity sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==
|
||||||
|
dependencies:
|
||||||
|
css-tree "^1.1.2"
|
||||||
|
|
||||||
cuint@^0.2.2:
|
cuint@^0.2.2:
|
||||||
version "0.2.2"
|
version "0.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b"
|
resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b"
|
||||||
|
@ -1197,6 +1222,11 @@ lru-cache@^6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
yallist "^4.0.0"
|
yallist "^4.0.0"
|
||||||
|
|
||||||
|
mdn-data@2.0.14:
|
||||||
|
version "2.0.14"
|
||||||
|
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50"
|
||||||
|
integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==
|
||||||
|
|
||||||
mdn-polyfills@^5.20.0:
|
mdn-polyfills@^5.20.0:
|
||||||
version "5.20.0"
|
version "5.20.0"
|
||||||
resolved "https://registry.yarnpkg.com/mdn-polyfills/-/mdn-polyfills-5.20.0.tgz#ca8247edf20a4f60dec6804372229812b348260b"
|
resolved "https://registry.yarnpkg.com/mdn-polyfills/-/mdn-polyfills-5.20.0.tgz#ca8247edf20a4f60dec6804372229812b348260b"
|
||||||
|
@ -1500,7 +1530,7 @@ source-map-js@^1.0.2:
|
||||||
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
|
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
|
||||||
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
|
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
|
||||||
|
|
||||||
source-map@~0.6.1:
|
source-map@^0.6.1, source-map@~0.6.1:
|
||||||
version "0.6.1"
|
version "0.6.1"
|
||||||
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
|
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
|
||||||
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
|
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
|
||||||
|
@ -1510,6 +1540,11 @@ sprintf-js@~1.0.2:
|
||||||
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
|
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
|
||||||
integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
|
integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
|
||||||
|
|
||||||
|
stable@^0.1.8:
|
||||||
|
version "0.1.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf"
|
||||||
|
integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==
|
||||||
|
|
||||||
string-width@^4.2.0:
|
string-width@^4.2.0:
|
||||||
version "4.2.2"
|
version "4.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5"
|
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5"
|
||||||
|
@ -1550,6 +1585,19 @@ supports-preserve-symlinks-flag@^1.0.0:
|
||||||
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
|
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
|
||||||
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
|
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
|
||||||
|
|
||||||
|
svgo@^2.8.0:
|
||||||
|
version "2.8.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/svgo/-/svgo-2.8.0.tgz#4ff80cce6710dc2795f0c7c74101e6764cfccd24"
|
||||||
|
integrity sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==
|
||||||
|
dependencies:
|
||||||
|
"@trysound/sax" "0.2.0"
|
||||||
|
commander "^7.2.0"
|
||||||
|
css-select "^4.1.3"
|
||||||
|
css-tree "^1.1.3"
|
||||||
|
csso "^4.2.0"
|
||||||
|
picocolors "^1.0.0"
|
||||||
|
stable "^0.1.8"
|
||||||
|
|
||||||
table@^6.0.9:
|
table@^6.0.9:
|
||||||
version "6.7.1"
|
version "6.7.1"
|
||||||
resolved "https://registry.yarnpkg.com/table/-/table-6.7.1.tgz#ee05592b7143831a8c94f3cee6aae4c1ccef33e2"
|
resolved "https://registry.yarnpkg.com/table/-/table-6.7.1.tgz#ee05592b7143831a8c94f3cee6aae4c1ccef33e2"
|
||||||
|
@ -1617,10 +1665,10 @@ type-fest@^0.20.2:
|
||||||
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
|
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
|
||||||
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
|
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
|
||||||
|
|
||||||
typescript@^4.3.5:
|
typescript@^4.7.0:
|
||||||
version "4.3.5"
|
version "4.7.4"
|
||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.5.tgz#4d1c37cc16e893973c45a06886b7113234f119f4"
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235"
|
||||||
integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==
|
integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==
|
||||||
|
|
||||||
typeson-registry@^1.0.0-alpha.20:
|
typeson-registry@^1.0.0-alpha.20:
|
||||||
version "1.0.0-alpha.39"
|
version "1.0.0-alpha.39"
|
||||||
|
|
Loading…
Reference in a new issue