Compare commits

..

1 commit

Author SHA1 Message Date
Bruno Windels
fdc59a8e02 split up project in 4 sub packages in a monorepo 2021-10-01 18:19:56 +02:00
548 changed files with 10396 additions and 16350 deletions

View file

@ -13,13 +13,5 @@ module.exports = {
"no-empty": "off", "no-empty": "off",
"no-prototype-builtins": "off", "no-prototype-builtins": "off",
"no-unused-vars": "warn" "no-unused-vars": "warn"
},
"globals": {
"DEFINE_VERSION": "readonly",
"DEFINE_GLOBAL_HASH": "readonly",
// only available in sw.js
"DEFINE_UNHASHED_PRECACHED_ASSETS": "readonly",
"DEFINE_HASHED_PRECACHED_ASSETS": "readonly",
"DEFINE_HASHED_CACHED_ON_REQUEST_ASSETS": "readonly"
} }
}; };

View file

@ -1,44 +0,0 @@
name: Container Image
on:
push:
branches: [ master ]
tags: [ 'v*' ]
pull_request:
branches: [ master ]
env:
IMAGE_NAME: ${{ github.repository }}
REGISTRY: ghcr.io
jobs:
push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v3
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v2
with:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

3
.gitignore vendored
View file

@ -1,6 +1,5 @@
*.sublime-project *.sublime-project
*.sublime-workspace *.sublime-workspace
.DS_Store
node_modules node_modules
fetchlogs fetchlogs
sessionexports sessionexports
@ -9,5 +8,3 @@ target
lib lib
*.tar.gz *.tar.gz
.eslintcache .eslintcache
.tmp
tmp/

View file

@ -19,7 +19,6 @@ module.exports = {
], ],
rules: { rules: {
"@typescript-eslint/no-floating-promises": 2, "@typescript-eslint/no-floating-promises": 2,
"@typescript-eslint/no-misused-promises": 2, "@typescript-eslint/no-misused-promises": 2
"semi": ["error", "always"]
} }
}; };

View file

@ -1,18 +0,0 @@
pipeline:
buildfrontend:
image: node:16
commands:
- yarn install --prefer-offline --frozen-lockfile
- yarn test
- yarn run lint-ci
- yarn run tsc
- yarn build
deploy:
image: python
when:
event: push
branch: master
commands:
- make ci-deploy
secrets: [ GITEA_WRITE_DEPLOY_KEY, LIBREPAGES_DEPLOY_SECRET ]

View file

@ -1,150 +0,0 @@
Contributing code to hydrogen-web
==================================
Everyone is welcome to contribute code to hydrogen-web, provided that they are
willing to license their contributions under the same license as the project
itself. We follow a simple 'inbound=outbound' model for contributions: the act
of submitting an 'inbound' contribution means that the contributor agrees to
license the code under the same terms as the project's overall 'outbound'
license - in this case, Apache Software License v2 (see
[LICENSE](LICENSE)).
How to contribute
-----------------
The preferred and easiest way to contribute changes to the project is to fork
it on github, and then create a pull request to ask us to pull your changes
into our repo (https://help.github.com/articles/using-pull-requests/)
We use GitHub's pull request workflow to review the contribution, and either
ask you to make any refinements needed or merge it and make them ourselves.
Things that should go into your PR description:
* References to any bugs fixed by the change (in GitHub's `Fixes` notation)
* Describe the why and what is changing in the PR description so it's easy for
onlookers and reviewers to onboard and context switch.
* If your PR makes visual changes, include both **before** and **after** screenshots
to easily compare and discuss what's changing.
* Include a step-by-step testing strategy so that a reviewer can check out the
code locally and easily get to the point of testing your change.
* Add comments to the diff for the reviewer that might help them to understand
why the change is necessary or how they might better understand and review it.
We use continuous integration, and all pull requests get automatically tested:
if your change breaks the build, then the PR will show that there are failed
checks, so please check back after a few minutes.
Tests
-----
If your PR is a feature then we require that the PR also includes tests.
These need to test that your feature works as expected and ideally test edge cases too.
Tests are written as unit tests by exporting a `tests` function from the file to be tested.
The function returns an object where the key is the test label, and the value is a
function that accepts an [assert](https://nodejs.org/api/assert.html) object, and return a Promise or nothing.
Note that there is currently a limitation that files that are not indirectly included from `src/platform/web/main.js` won't be found by the runner.
You can run the tests by running `yarn test`.
This uses the [impunity](https://github.com/bwindels/impunity) runner.
We don't require tests for bug fixes.
In the future we may formalise this more.
Code style
----------
The js-sdk aims to target TypeScript/ES6. All new files should be written in
TypeScript and existing files should use ES6 principles where possible.
Please disable any automatic formatting tools you may have active.
If present, you'll be asked to undo any unrelated whitespace changes during code review.
Members should not be exported as a default export in general.
In general, avoid using `export default`.
The remaining code-style for hydrogen is [in the process of being documented](codestyle.md), but
contributors are encouraged to read the
[code style document for matrix-react-sdk](https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md)
and follow the principles set out there.
Please ensure your changes match the cosmetic style of the existing project,
and ***never*** mix cosmetic and functional changes in the same commit, as it
makes it horribly hard to review otherwise.
Attribution
-----------
If you change or create a file, feel free to add yourself to the copyright holders
in the license header of that file.
Sign off
--------
In order to have a concrete record that your contribution is intentional
and you agree to license it under the same terms as the project's license, we've
adopted the same lightweight approach that the Linux Kernel
(https://www.kernel.org/doc/Documentation/SubmittingPatches), Docker
(https://github.com/docker/docker/blob/master/CONTRIBUTING.md), and many other
projects use: the DCO (Developer Certificate of Origin:
http://developercertificate.org/). This is a simple declaration that you wrote
the contribution or otherwise have the right to contribute it to Matrix:
```
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
660 York Street, Suite 102,
San Francisco, CA 94110 USA
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
```
If you agree to this for your contribution, then all that's needed is to
include the line in your commit or pull request comment:
```
Signed-off-by: Your Name <your@email.example.org>
```
We accept contributions under a legally identifiable name, such as your name on
government documentation or common-law names (names claimed by legitimate usage
or repute). Unfortunately, we cannot accept anonymous contributions at this
time.
Git allows you to add this signoff automatically when using the `-s` flag to
`git commit`, which uses the name and email set in your `user.name` and
`user.email` git configs.
If you forgot to sign off your commits before making your pull request and are
on Git 2.17+ you can mass signoff using rebase:
```
git rebase --signoff origin/develop
```

View file

@ -1,14 +0,0 @@
ci-deploy: ## Deploy from CI/CD. Only call from within CI
@if [ "${CI}" != "woodpecker" ]; \
then echo "Only call from within CI. Will re-write your local Git configuration. To override, set export CI=woodpecker"; \
exit 1; \
fi
git config --global user.email "${CI_COMMIT_AUTHOR_EMAIL}"
git config --global user.name "${CI_COMMIT_AUTHOR}"
./scripts/ci.sh --commit-files librepages target "${CI_COMMIT_AUTHOR} <${CI_COMMIT_AUTHOR_EMAIL}>"
./scripts/ci.sh --init "$$GITEA_WRITE_DEPLOY_KEY"
./scripts/ci.sh --deploy ${LIBREPAGES_DEPLOY_SECRET} librepages
./scripts/ci.sh --clean
help: ## Prints help for targets with comments
@cat $(MAKEFILE_LIST) | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

View file

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

View file

@ -8,5 +8,3 @@
otherwise it becomes hard to remember what was a default/named export otherwise it becomes hard to remember what was a default/named export
- should we return promises from storage mutation calls? probably not, as we don't await them anywhere. only read calls should return promises? - should we return promises from storage mutation calls? probably not, as we don't await them anywhere. only read calls should return promises?
- we don't anymore - we don't anymore
- don't use these features, as they are not widely enough supported.
- [lookbehind in regular expressions](https://caniuse.com/js-regexp-lookbehind)

View file

@ -28,7 +28,7 @@ You can only verify by comparing keys manually currently. In Element, go to your
## I want to host my own Hydrogen, how do I do that? ## I want to host my own Hydrogen, how do I do that?
Published builds can be found at https://github.com/vector-im/hydrogen-web/releases. For building your own, you need to checkout the version you want to build, or master if you want to run bleeding edge, and run `yarn install` and then `yarn build` in a console (and install nodejs >= 15 and yarn if you haven't yet). Now you should find all the files needed to host Hydrogen in the `target/` folder, just copy them all over to your server. As always, don't host your client on the same [origin](https://web.dev/same-origin-policy/#what's-considered-same-origin) as your homeserver. There are no published builds at this point. You need to checkout the version you want to build, or master if you want to run bleeding edge, and run `yarn install` and then `yarn build` in a console (and install nodejs > 14 and yarn if you haven't yet). Now you should find all the files needed to host Hydrogen in the `target/` folder, just copy them all over to your server. As always, don't host your client on the same [origin](https://web.dev/same-origin-policy/#what's-considered-same-origin) as your homeserver.
## I want to embed Hydrogen in my website, how should I do that? ## I want to embed Hydrogen in my website, how should I do that?

View file

@ -1,11 +0,0 @@
## How to import common-js dependency using ES6 syntax
---
Until [#6632](https://github.com/vitejs/vite/issues/6632) is fixed, such imports should be done as follows:
```ts
import * as pkg from "off-color";
// @ts-ignore
const offColor = pkg.offColor ?? pkg.default.offColor;
```
This way build, dev server and unit tests should all work.

View file

@ -1,116 +1,78 @@
# Hydrogen View SDK # How to use Hydrogen as an SDK
If you want to use end-to-end encryption, it is recommended to use a [supported build system](../src/sdk/paths/) (currently only vite) to be able to locate the olm library files.
The Hydrogen view SDK allows developers to integrate parts of the Hydrogen application into the UI of their own application. Hydrogen is written with the MVVM pattern, so to construct a view, you'd first construct a view model, which you then pass into the view. For most view models, you will first need a running client. You can create a project using the following commands
## Example
The Hydrogen SDK requires some assets to be shipped along with your app for things like downloading attachments, and end-to-end encryption. A convenient way to make this happen is provided by the SDK (importing `hydrogen-view-sdk/paths/vite`) but depends on your build system. Currently, only [vite](https://vitejs.dev/) is supported, so that's what we'll be using in the example below.
You can create a vite project using the following commands:
```sh ```sh
# you can pick "vanilla-ts" here for project type if you're not using react or vue # you can pick "vanilla-ts" here for project type if you're not using react or vue
yarn create vite yarn create vite
cd <your-project-name> cd <your-project-name>
yarn yarn
yarn add hydrogen-view-sdk yarn add https://github.com/vector-im/hydrogen-web.git
``` ```
You should see a `index.html` in the project root directory, containing an element with `id="app"`. Add the attribute `class="hydrogen"` to this element, as the CSS we'll include from the SDK assumes for now that the app is rendered in an element with this classname. If you go into the `src` directory, you should see a `main.ts` file. If you put this code in there, you should see a basic timeline after login and initial sync have finished.
If you go into the `src` directory, you should see a `main.ts` file. If you put this code in there, you should see a basic timeline after login and initial sync have finished (might take a while before you see anything on the screen actually).
You'll need to provide the username and password of a user that is already in the [#element-dev:matrix.org](https://matrix.to/#/#element-dev:matrix.org) room (or change the room id).
```ts ```ts
import { import {
Platform, Platform,
Client, SessionContainer,
LoadStatus, LoadStatus,
createNavigation, createNavigation,
createRouter, createRouter,
RoomViewModel, RoomViewModel,
TimelineView, TimelineView
viewClassForTile } from "hydrogen-web";
} from "hydrogen-view-sdk"; import {olmPaths, downloadSandboxPath} from "hydrogen-web/src/sdk/paths/vite";
import downloadSandboxPath from 'hydrogen-view-sdk/download-sandbox.html?url';
import workerPath from 'hydrogen-view-sdk/main.js?url'; const app = document.querySelector<HTMLDivElement>('#app')!
import olmWasmPath from '@matrix-org/olm/olm.wasm?url';
import olmJsPath from '@matrix-org/olm/olm.js?url'; // bootstrap a session container
import olmLegacyJsPath from '@matrix-org/olm/olm_legacy.js?url'; const platform = new Platform(app, {
const assetPaths = {
downloadSandbox: downloadSandboxPath, downloadSandbox: downloadSandboxPath,
worker: workerPath, olm: olmPaths,
olm: { }, null, { development: true });
wasm: olmWasmPath, const navigation = createNavigation();
legacyBundle: olmLegacyJsPath, platform.setNavigation(navigation);
wasmBundle: olmJsPath const urlRouter = createRouter({
} navigation: navigation,
}; history: platform.history
import "hydrogen-view-sdk/assets/theme-element-light.css"; });
// OR import "hydrogen-view-sdk/assets/theme-element-dark.css"; urlRouter.attach();
const sessionContainer = new SessionContainer({
platform,
olmPromise: platform.loadOlm(),
workerPromise: platform.loadOlmWorker()
});
async function main() { // wait for login and first sync to finish
const app = document.querySelector<HTMLDivElement>('#app')! const loginOptions = await sessionContainer.queryLogin("matrix.org").result;
const config = {}; sessionContainer.startWithLogin(loginOptions.password("user", "password"));
const platform = new Platform({container: app, assetPaths, config, options: { development: import.meta.env.DEV }}); await sessionContainer.loadStatus.waitFor((status: string) => {
const navigation = createNavigation(); return status === LoadStatus.Ready ||
platform.setNavigation(navigation); status === LoadStatus.Error ||
const urlRouter = createRouter({ status === LoadStatus.LoginFailed;
navigation: navigation, }).promise;
history: platform.history // check the result
if (sessionContainer.loginFailure) {
alert("login failed: " + sessionContainer.loginFailure);
} else if (sessionContainer.loadError) {
alert("load failed: " + sessionContainer.loadError.message);
} else {
// we're logged in, we can access the room now
const {session} = sessionContainer;
// room id for #element-dev:matrix.org
const room = session.rooms.get("!bEWtlqtDwCLFIAKAcv:matrix.org");
const vm = new RoomViewModel({
room,
ownUserId: session.userId,
platform,
urlCreator: urlRouter,
navigation,
}); });
urlRouter.attach(); await vm.load();
const client = new Client(platform); const view = new TimelineView(vm.timelineViewModel);
app.appendChild(view.mount());
const loginOptions = await client.queryLogin("matrix.org").result;
client.startWithLogin(loginOptions.password("username", "password"));
await client.loadStatus.waitFor((status: string) => {
return status === LoadStatus.Ready ||
status === LoadStatus.Error ||
status === LoadStatus.LoginFailed;
}).promise;
if (client.loginFailure) {
alert("login failed: " + client.loginFailure);
} else if (client.loadError) {
alert("load failed: " + client.loadError.message);
} else {
const {session} = client;
// looks for room corresponding to #element-dev:matrix.org, assuming it is already joined
const room = session.rooms.get("!bEWtlqtDwCLFIAKAcv:matrix.org");
const vm = new RoomViewModel({
room,
ownUserId: session.userId,
platform,
urlCreator: urlRouter,
navigation,
});
await vm.load();
const view = new TimelineView(vm.timelineViewModel, viewClassForTile);
app.appendChild(view.mount());
}
} }
main();
``` ```
## Typescript support
Typescript support is not yet available while we're converting the Hydrogen codebase to Typescript.
In your `src` directory, you'll need to add a `.d.ts` (can be called anything, e.g. `deps.d.ts`)
containing this snippet to make Typescript not complain that `hydrogen-view-sdk` doesn't have types:
```ts
declare module "hydrogen-view-sdk";
```
## API Stability
This library follows semantic versioning; there is no API stability promised as long as the major version is still 0. Once 1.0.0 is released, breaking changes will be released with a change in major versioning.
## Third-party licenses
This package bundles the bs58 package ([license](https://github.com/cryptocoinjs/bs58/blob/master/LICENSE)), and the Inter font ([license](https://github.com/rsms/inter/blob/master/LICENSE.txt)).

View file

@ -1,204 +0,0 @@
# Theming Documentation
## Basic Architecture
A **theme collection** in Hydrogen is represented by a `manifest.json` file and a `theme.css` file.
The manifest specifies variants (eg: dark,light ...) each of which is a **theme** and maps to a single css file in the build output.
Each such theme is produced by changing the values of variables in the base `theme.css` file with those specified in the variant section of the manifest:
![](images/theming-architecture.png)
More in depth explanations can be found in later sections.
## Structure of `manifest.json`
[See theme.ts](../src/platform/types/theme.ts)
## Variables
CSS variables specific to a particular variant are specified in the `variants` section of the manifest:
```json=
"variants": {
"light": {
...
"variables": {
"background-color-primary": "#fff",
"text-color": "#2E2F32",
}
},
"dark": {
...
"variables": {
"background-color-primary": "#21262b",
"text-color": "#fff",
}
}
}
```
These variables will appear in the css file (theme.css):
```css=
body {
background-color: var(--background-color-primary);
color: var(--text-color);
}
```
During the build process, this would result in the creation of two css files (one for each variant) where the variables are substitued with the corresponding values specified in the manifest:
*element-light.css*:
```css=
body {
background-color: #fff;
color: #2E2F32;
}
```
*element-dark.css*:
```css=
body {
background-color: #21262b;
color: #fff;
}
```
## Derived Variables
In addition to simple substitution of variables in the stylesheet, it is also possible to instruct the build system to first produce a new value from the base variable value before the substitution.
Such derived variables have the form `base_css_variable--operation-arg` and can be read as:
apply `operation` to `base_css_variable` with argument `arg`.
Continuing with the previous example, it possible to specify:
```css=
.left-panel {
/* background color should be 20% more darker
than background-color-primary */
background-color: var(--background-color-primary--darker-20);
}
```
Currently supported operations are:
| Operation | Argument | Operates On |
| -------- | -------- | -------- |
| darker | percentage | color |
| lighter | percentage | color |
## Aliases
It is possible give aliases to variables in the `theme.css` file:
```css=
:root {
font-size: 10px;
/* Theme aliases */
--icon-color: var(--background-color-secondary--darker-40);
}
```
It is possible to further derive from these aliased variables:
```css=
div {
background: var(--icon-color--darker-20);
--my-alias: var(--icon-color--darker-20);
/* Derive from aliased variable */
color: var(--my-alias--lighter-15);
}
```
## Colorizing svgs
Along with a change in color-scheme, it may be necessary to change the colors in the svg icons and images.
This can be done by supplying the preferred colors with query parameters:
`my-awesome-logo.svg?primary=base-variable-1&secondary=base-variable-2`
This instructs the build system to colorize the svg with the given primary and secondary colors.
`base-variable-1` and `base-variable-2` are the css-variables specified in the `variables` section of the manifest.
For colorizing svgs, the source svg must use `#ff00ff` as the primary color and `#00ffff` as the secondary color:
| ![](images/svg-icon-example.png) | ![](images/coloring-process.png) |
| :--: |:--: |
| **original source image** | **transformation process** |
## Creating your own theme variant in Hydrogen
If you're looking to change the color-scheme of the existing Element theme, you only need to add your own variant to the existing `manifest.json`.
The steps are fairly simple:
1. Copy over an existing variant to the variants section of the manifest.
2. Change `dark`, `default` and `name` fields.
3. Give new values to each variable in the `variables` section.
4. Build hydrogen.
## Creating your own theme collection in Hydrogen
If a theme variant does not solve your needs, you can create a new theme collection with a different base `theme.css` file.
1. Create a directory for your new theme-collection under `src/platform/web/ui/css/themes/`.
2. Create `manifest.json` and `theme.css` files within the newly created directory.
3. Populate `manifest.json` with the base css variables you wish to use.
4. Write styles in your `theme.css` file using the base variables, derived variables and colorized svg icons.
5. Tell the build system where to find this theme-collection by providing the location of this directory to the `themeBuilder` plugin in `vite.config.js`:
```json=
...
themeBuilder({
themeConfig: {
themes: {
element: "./src/platform/web/ui/css/themes/element",
awesome: "path/to/theme-directory"
},
default: "element",
},
compiledVariables,
}),
...
```
6. Build Hydrogen.
## Changing the default theme
To change the default theme used in Hydrogen, modify the `defaultTheme` field in `config.json` file (which can be found in the build output):
```json=
"defaultTheme": {
"light": theme-id,
"dark": theme-id
}
```
Here *theme-id* is of the form `theme-variant` where `theme` is the key used when specifying the manifest location of the theme collection in `vite.config.js` and `variant` is the key used in variants section of the manifest.
Some examples of theme-ids are `element-dark` and `element-light`.
To find the theme-id of some theme, you can look at the built-asset section of the manifest in the build output.
This default theme will render as "Default" option in the theme-chooser dropdown. If the device preference is for dark theme, the dark default is selected and vice versa.
**You'll need to reload twice so that Hydrogen picks up the config changes!**
# Derived Theme(Collection)
This allows users to theme Hydrogen without the need for rebuilding. Derived theme collections can be thought of as extensions (derivations) of some existing build time theme.
## Creating a derived theme:
Here's how you create a new derived theme:
1. You create a new theme manifest file (eg: theme-awesome.json) and mention which build time theme you're basing your new theme on using the `extends` field. The base css file of the mentioned theme is used for your new theme.
2. You configure the theme manifest as usual by populating the `variants` field with your desired colors.
3. You add your new theme manifest to the list of themes in `config.json`.
Refresh Hydrogen twice (once to refresh cache, and once to load) and the new theme should show up in the theme chooser.
## How does it work?
For every theme collection in hydrogen, the build process emits a runtime css file which like the built theme css file contains variables in the css code. But unlike the theme css file, the runtime css file lacks the definition for these variables:
CSS for the built theme:
```css
:root {
--background-color-primary: #f2f20f;
}
body {
background-color: var(--background-color-primary);
}
```
and the corresponding runtime theme:
```css
/* Notice the lack of definiton for --background-color-primary here! */
body {
background-color: var(--background-color-primary);
}
```
When hydrogen loads a derived theme, it takes the runtime css file of the extended theme and dynamically adds the variable definition based on the values specified in the manifest. Icons are also colored dynamically and injected as variables using Data URIs.

View file

@ -1,38 +1,7 @@
# Typescript style guide # Typescript migration
## Use `type` rather than `interface` for named parameters and POJO return values. ## Introduce `abstract` & `override`
`type` and `interface` can be used somewhat interchangeably, but let's use `type` to describe data and `interface` to describe (polymorphic) behaviour. - find all methods and getters that throw or are empty in base classes and turn into abstract method or if all methods are abstract, into an interface.
- change child impls to not call super.method and to add override
Good examples of data are option objects to have named parameters, and POJO (plain old javascript objects) without any methods, just fields. - don't allow implicit override in ts config
Also see [this playground](https://www.typescriptlang.org/play?#code/C4TwDgpgBACghgJwgO2AeTMAlge2QZygF4oBvAKCiqmTgFsIAuKfYBLZAcwG5LqATCABs4IAPzNkAVzoAjCAl4BfcuVCQoAYQAWWIfwzY8hEvCSpDuAlABkZPlQDGOITgTNW7LstWOR+QjMUYHtqKGcCNilHYDcAChxMK3xmIIsk4wBKewcoFRVyPzgArV19KAgAD2AUfkDEYNDqCM9o2IQEjIJmHT0DLvxsijCw-ClIDsSjAkzeEebjEIYAuE5oEgADABJSKeSAOloGJSgsQh29433nVwQlDbnqfKA)
## Use `type foo = { [key: string]: any }` for types that you intend to fill in later.
For instance, if you have a method such as:
```js
function load(options) {
// ...
}
```
and you intend to type options at some later point, do:
```ts
type Options = { [key: string]: any}
```
This makes it much easier to add the necessary type information at a later time.
## Use `object` or `Record<string, any>` to describe a type that accepts any javascript object.
Sometimes a function or method may genuinely need to accept any object; eg:
```js
function encodeBody(body) {
// ...
}
```
In this scenario:
- Use `object` if you know that you will not access any property
- Use `Record<string, any>` if you need to access some property
Both usages prevent the type from accepting primitives (eg: string, boolean...).
If using `Record`, ensure that you have guards to check that the properties really do exist.

View file

@ -1,206 +0,0 @@
## IView components
The [interface](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/types.ts) adopted by view components is agnostic of how they are rendered to the DOM. This has several benefits:
- it allows Hydrogen to not ship a [heavy view framework](https://bundlephobia.com/package/react-dom@18.2.0) that may or may not be used by its SDK users, and also keep bundle size of the app down.
- Given the interface is quite simple, is should be easy to integrate this interface into the render lifecycle of other frameworks.
- The main implementations used in Hydrogen are [`ListView`](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/ListView.ts) (rendering [`ObservableList`](https://github.com/vector-im/hydrogen-web/blob/master/src/observable/list/BaseObservableList.ts)s) and [`TemplateView`](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/TemplateView.ts) (templating and one-way databinding), each only a few 100 lines of code and tailored towards their specific use-case. They work straight with the DOM API and have no other dependencies.
- a common inteface allows us to mix and match between these different implementations (and gradually shift if need be in the future) with the code.
## Templates
### Template language
Templates use a mini-DSL language in pure javascript to express declarative templates. This is basically a very thin wrapper around `document.createElement`, `document.createTextNode`, `node.setAttribute` and `node.appendChild` to quickly create DOM trees. The general syntax is as follows:
```js
t.tag_name({attribute1: value, attribute2: value, ...}, [child_elements]);
t.tag_name(child_element);
t.tag_name([child_elements]);
```
**tag_name** can be [most HTML or SVG tags](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/html.ts#L102-L110).
eg:
Here is an example HTML segment followed with the code to create it in Hydrogen.
```html
<section class="main-section">
<h1>Demo</h1>
<button class="btn_cool">Click me</button>
</section>
```
```js
t.section({className: "main-section"},[
t.h1("Demo"),
t.button({className:"btn_cool"}, "Click me")
]);
```
All these functions return DOM element nodes, e.g. the result of `document.createElement`.
### TemplateView
`TemplateView` builds on top of templating by adopting the IView component model and adding event handling attributes, sub views and one-way databinding.
In views based on `TemplateView`, you will see a render method with a `t` argument.
`t` is `TemplateBuilder` object passed to the render function in `TemplateView`. It also takes a data object to render and bind to, often called `vm`, short for view model from the MVVM pattern Hydrogen uses.
You either subclass `TemplateView` and override the `render` method:
```js
class MyView extends TemplateView {
render(t, vm) {
return t.div(...);
}
}
```
Or you pass a render function to `InlineTemplateView`:
```js
new InlineTemplateView(vm, (t, vm) => {
return t.div(...);
});
```
**Note:** the render function is only called once to build the initial DOM tree and setup bindings, etc ... Any subsequent updates to the DOM of a component happens through bindings.
#### Event handlers
Any attribute starting with `on` and having a function as a value will be attached as an event listener on the given node. The event handler will be removed during unmounting.
```js
t.button({onClick: evt => {
vm.doSomething(evt.target.value);
}}, "Click me");
```
#### Subviews
`t.view(instance)` will mount the sub view (can be any IView) and return its root node so it can be attached in the DOM tree.
All subviews will be unmounted when the parent view gets unmounted.
```js
t.div({className: "Container"}, t.view(new ChildView(vm.childViewModel)));
```
#### One-way data-binding
A binding couples a part of the DOM to a value on the view model. The view model emits an update when any of its properties change, to which the view can subscribe. When an update is received by the view, it will reevaluate all the bindings, and update the DOM accordingly.
A binding can appear in many places where a static value can usually be used in the template tree.
To create a binding, you pass a function that maps the view value to a static value.
##### Text binding
```js
t.p(["I've got ", vm => vm.counter, " beans"])
```
##### Attribute binding
```js
t.button({disabled: vm => vm.isBusy}, "Submit");
```
##### Class-name binding
```js
t.div({className: {
button: true,
active: vm => vm.isActive
}})
```
##### Subview binding
So far, all the bindings can only change node values within our tree, but don't change the structure of the DOM. A sub view binding allows you to conditionally add a subview based on the result of a binding function.
All sub view bindings return a DOM (element or comment) node and can be directly added to the DOM tree by including them in your template.
###### map
`t.mapView` allows you to choose a view based on the result of the binding function:
```js
t.mapView(vm => vm.count, count => {
return count > 5 ? new LargeView(count) : new SmallView(count);
});
```
Every time the first or binding function returns a different value, the second function is run to create a new view to replace the previous view.
You can also return `null` or `undefined` from the second function to indicate a view should not be rendered. In this case a comment node will be used as a placeholder.
There is also a `t.map` which will create a new template view (with the same value) and you directly provide a render function for it:
```js
t.map(vm => vm.shape, (shape, t, vm) => {
switch (shape) {
case "rect": return t.rect();
case "circle": return t.circle();
}
})
```
###### if
`t.ifView` will render the subview if the binding returns a truthy value:
```js
t.ifView(vm => vm.isActive, vm => new View(vm.someValue));
```
You equally have `t.if`, which creates a `TemplateView` and passes you the `TemplateBuilder`:
```js
t.if(vm => vm.isActive, (t, vm) => t.div("active!"));
```
##### Side-effects
Sometimes you want to imperatively modify your DOM tree based on the value of a binding.
`mapSideEffect` makes this easy to do:
```js
let node = t.div();
t.mapSideEffect(vm => vm.color, (color, oldColor) => node.style.background = color);
return node;
```
**Note:** you shouldn't add any bindings, subviews or event handlers from the side-effect callback,
the safest is to not use the `t` argument at all.
If you do, they will be added every time the callback is run and only cleaned up when the view is unmounted.
#### `tag` vs `t`
If you don't need a view component with data-binding, sub views and event handler attributes, the template language also is available in `ui/general/html.js` without any of these bells and whistles, exported as `tag`. As opposed to static templates with `tag`, you always use
`TemplateView` as an instance of a class, as there is some extra state to keep track (bindings, event handlers and subviews).
Although syntactically similar, `TemplateBuilder` and `tag` are not functionally equivalent.
Primarily `t` **supports** bindings and event handlers while `tag` **does not**. This is because to remove event listeners, we need to keep track of them, and thus we need to keep this state somewhere which
we can't do with a simple function call but we can insite the TemplateView class.
```js
// The onClick here wont work!!
tag.button({className:"awesome-btn", onClick: () => this.foo()});
class MyView extends TemplateView {
render(t, vm){
// The onClick works here.
t.button({className:"awesome-btn", onClick: () => this.foo()});
}
}
```
## ListView
A view component that renders and updates a list of sub views for every item in a `ObservableList`.
```js
const list = new ListView({
list: someObservableList
}, listValue => return new ChildView(listValue))
```
As items are added, removed, moved (change position) and updated, the DOM will be kept in sync.
There is also a `LazyListView` that only renders items in and around the current viewport, with the restriction that all items in the list must be rendered with the same height.
### Sub view updates
Unless the `parentProvidesUpdates` option in the constructor is set to `false`, the ListView will call the `update` method on the child `IView` component when it receives an update event for one of the items in the `ObservableList`.
This way, not every sub view has to have an individual listener on it's view model (a value from the observable list), and all updates go from the observable list to the list view, who then notifies the correct sub view.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

View file

@ -1,109 +0,0 @@
SDK:
- we need to compile src/lib.ts to javascript, with a d.ts file generated as well. We need to compile to javascript once for cjs and once of es modules. The package.json looks like this:
```
"main": "./dist/index.cjs",
"exports": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"types": "dist/index.d.ts",
```
we don't need to bundle for the sdk case! we might need to do some transpilation to just plain ES6 (e.g. don't assume ?. and ??) we could use a browserslist query for this e.g. `node 14`. esbuild seems to support this as well, tldraw uses esbuild for their build.
one advantage of not bundling the files for the sdk is that you can still use import overrides in the consuming project build settings. is that an idiomatic way of doing things though?
this way we will support typescript, non-esm javascript and esm javascript using libhydrogen as an SDK
got this from https://medium.com/dazn-tech/publishing-npm-packages-as-native-es-modules-41ffbc0a9dea
how about the assets?
we also need to build the app
we need to be able to version libhydrogen independently from hydrogen the app? as any api breaking changes will need a major version increase. we probably want to end up with a monorepo where the app uses the sdk as well and we just use the local code with yarn link?
## Assets
we want to provide scss/sass files, but also css that can be included
https://github.com/webpack/webpack/issues/7353 seems to imply that we just need to include the assets in the published files and from there on it is the consumer of libhydrogen's problem.
how does all of this tie in with vite?
we want to have hydrogenapp be a consumer of libhydrogen, potentially as two packages in a monorepo ... but we want the SDK to expose views and stylesheets... without having an index.html (which would be in hydrogenapp). this seems a bit odd...?
what would be in hydrogenapp actually? just an index.html file?
I'm not sure it makes sense to have them be 2 different packages in a monorepo, they should really be two artifacts from the same directory.
the stylesheets included in libhydrogen are from the same main.css file as is used in the app
https://www.freecodecamp.org/news/build-a-css-library-with-vitejs/
basically, we import the sass file from src/lib.ts so it is included in the assets there too, and we also create a plugin that emits a file for every sass file as suggested in the link above?
we probably want two different build commands for the app and the sdk though, we could have a parent vite config that both build configs extend from?
### Dependency assets
our dependencies should not be bundled for the SDK case. So if we import aesjs, it would be up to the build system of the consuming project to make that import work.
the paths.ts thingy ... we want to make it easy for people to setup the assets for our dependencies (olm), some assets are also part of the sdk itself. it might make sense to make all of the assets there part of the sdk (e.g. bundle olm.wasm and friends?) although shipping crypto, etc ...
perhaps we should have an include file per build system that treats own assets and dep assets the same by including the package name as wel for our own deps:
```js
import _downloadSandboxPath from "@matrix-org/hydrogen-sdk/download-sandbox.html?url";
import _serviceWorkerPath from "@matrix-org/hydrogen-sdk/sw.js?url"; // not yet sure this is the way to do it
import olmWasmPath from "@matrix-org/olm/olm.wasm?url";
import olmJsPath from "@matrix-org/olm/olm.js?url";
import olmLegacyJsPath from "@matrix-org/olm/olm_legacy.js?url";
export const olmPaths = {
wasm: olmWasmPath,
legacyBundle: olmLegacyJsPath,
wasmBundle: olmJsPath,
};
export const downloadSandboxPath = _downloadSandboxPath;
```
we could put this file per build system, as ESM, in dist as well so you can include it to get the paths
## Tooling
- `vite` a more high-level build tool that takes your index.html and turns it into optimized assets that you can host for production, as well as a very fast dev server. is used to have good default settings for our tools, typescript support, and also deals with asset compiling. good dev server. Would be nice to have the same tool for dev and prod. vite has good support for using `import` for anything that is not javascript, where we had an issue with `snowpack` (to get the prod path of an asset).
- `rollup`: inlines
- `lerna` is used to handle multi-package monorepos
- `esbuild`: a js/ts build tool that we could use for building the lower level sdk where no other assets are involved, `vite` uses it for fast dev builds (`rollup` for prod). For now we won't extract a lower level sdk though.
## TODO
- finish vite app build (without IE11 for now?)
- create vite config to build src/lib.ts in cjs and esm, inheriting from a common base config with the app config
- this will create a dist folder with
- the whole source tree in es and cjs format
- an es file to import get the asset paths as they are expected by Platform, per build system
- assets from hydrogen itself:
- css files and any resource used therein
- download-sandbox.html
- a type declaration file (index.d.ts)
## Questions
- can rollup not bundle the source tree and leave modules intact?
- if we can use a function that creates a chunk per file to pass to manualChunks and disable chunk hashing we can probably do this. See https://rollupjs.org/guide/en/#outputmanualchunks
looks like we should be able to disable chunk name hashing with chunkFileNames https://rollupjs.org/guide/en/#outputoptions-object
we should test this with a vite test config
we also need to compile down to ES6, both for the app and for the sdk

72
old.package.json Normal file
View file

@ -0,0 +1,72 @@
{
"name": "hydrogen-web",
"version": "0.2.16",
"description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB",
"main": "src/lib.ts",
"directories": {
"doc": "doc"
},
"scripts": {
"lint": "eslint --cache src/",
"lint-ts": "eslint src/ -c .ts-eslintrc.js --ext .ts",
"lint-ci": "eslint src/",
"test": "impunity --entry-point src/main.js --force-esm-dirs lib/ src/",
"start": "snowpack dev --port 3000",
"build": "node --experimental-modules scripts/build.mjs",
"postinstall": "node ./scripts/post-install.js"
},
"repository": {
"type": "git",
"url": "git@github.com:vector-im/hydrogen-web.git"
},
"author": "matrix.org",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/vector-im/hydrogen-web/issues"
},
"homepage": "https://github.com/vector-im/hydrogen-web/#readme",
"devDependencies": {
"@babel/core": "^7.11.1",
"@babel/preset-env": "^7.11.0",
"@rollup/plugin-babel": "^5.1.0",
"@rollup/plugin-multi-entry": "^4.0.0",
"@typescript-eslint/eslint-plugin": "^4.29.2",
"@typescript-eslint/parser": "^4.29.2",
"autoprefixer": "^10.2.6",
"cheerio": "^1.0.0-rc.3",
"commander": "^6.0.0",
"core-js": "^3.6.5",
"eslint": "^7.32.0",
"fake-indexeddb": "^3.1.2",
"finalhandler": "^1.1.1",
"impunity": "^1.0.1",
"mdn-polyfills": "^5.20.0",
"node-html-parser": "^4.0.0",
"postcss": "^8.1.1",
"postcss-css-variables": "^0.17.0",
"postcss-flexbugs-fixes": "^4.2.1",
"postcss-import": "^12.0.1",
"postcss-url": "^8.0.0",
"regenerator-runtime": "^0.13.7",
"rollup-plugin-cleanup": "^3.1.1",
"serve-static": "^1.13.2",
"snowpack": "^3.8.3",
"typescript": "^4.3.5",
"xxhashjs": "^0.2.2"
},
"dependencies": {
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz",
"@rollup/plugin-commonjs": "^15.0.0",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^9.0.0",
"aes-js": "^3.1.2",
"another-json": "^0.2.0",
"base64-arraybuffer": "^0.2.0",
"bs58": "^4.0.1",
"dompurify": "^2.3.0",
"es6-promise": "https://github.com/bwindels/es6-promise.git#bwindels/expose-flush",
"rollup": "^2.26.4",
"text-encoding": "^0.7.0",
"vite": "^2.6.2"
}
}

View file

@ -1,66 +1,9 @@
{ {
"name": "hydrogen-web", "private": true,
"version": "0.3.1", "workspaces": [
"description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "packages/common",
"directories": { "packages/matrix",
"doc": "doc" "packages/domain",
}, "packages/web"
"enginesStrict": { ]
"node": ">=15"
},
"scripts": {
"lint": "eslint --cache src/",
"lint-ts": "eslint src/ -c .ts-eslintrc.js --ext .ts",
"lint-ci": "eslint src/",
"test": "impunity --entry-point src/platform/web/main.js src/platform/web/Platform.js --force-esm-dirs lib/ src/ --root-dir src/",
"test:postcss": "impunity --entry-point scripts/postcss/tests/css-compile-variables.test.js scripts/postcss/tests/css-url-to-variables.test.js",
"test:sdk": "yarn build:sdk && cd ./scripts/sdk/test/ && yarn --no-lockfile && node test-sdk-in-esm-vite-build-env.js && node test-sdk-in-commonjs-env.js",
"start": "vite --port 3000",
"build": "vite build && ./scripts/cleanup.sh",
"build:sdk": "./scripts/sdk/build.sh",
"watch:sdk": "./scripts/sdk/build.sh && yarn run vite build -c vite.sdk-lib-config.js --watch"
},
"repository": {
"type": "git",
"url": "git@github.com:vector-im/hydrogen-web.git"
},
"author": "matrix.org",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/vector-im/hydrogen-web/issues"
},
"homepage": "https://github.com/vector-im/hydrogen-web/#readme",
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^4.29.2",
"@typescript-eslint/parser": "^4.29.2",
"acorn": "^8.6.0",
"acorn-walk": "^8.2.0",
"aes-js": "^3.1.2",
"bs58": "^4.0.1",
"core-js": "^3.6.5",
"es6-promise": "https://github.com/bwindels/es6-promise.git#bwindels/expose-flush",
"escodegen": "^2.0.0",
"eslint": "^7.32.0",
"fake-indexeddb": "^3.1.2",
"impunity": "^1.0.9",
"mdn-polyfills": "^5.20.0",
"merge-options": "^3.0.4",
"node-html-parser": "^4.0.0",
"postcss-css-variables": "^0.18.0",
"postcss-flexbugs-fixes": "^5.0.2",
"postcss-value-parser": "^4.2.0",
"regenerator-runtime": "^0.13.7",
"svgo": "^2.8.0",
"text-encoding": "^0.7.0",
"typescript": "^4.7.0",
"vite": "^2.9.8",
"xxhashjs": "^0.2.2"
},
"dependencies": {
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
"another-json": "^0.2.0",
"base64-arraybuffer": "^0.2.0",
"dompurify": "^2.3.0",
"off-color": "^2.0.0"
}
} }

View file

@ -0,0 +1,10 @@
{
"name": "hydrogen-common",
"version": "0.0.1",
"main": "src/lib.ts",
"homepage": "https://github.com/vector-im/hydrogen-web/#readme",
"devDependencies": {
"vite": "^2.6.2",
"typescript": "^4.3.5"
}
}

View file

@ -0,0 +1,22 @@
/*
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 * from "./utils/index";
export * from "./observable/index";
export {IDBLogger} from "./logging/IDBLogger.js";
export {NullLogger} from "./logging/NullLogger.js";
export {ConsoleLogger} from "./logging/ConsoleLogger.js";
export type {LogItem} from "./logging/LogItem.js";

View file

@ -15,29 +15,23 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {LogItem} from "./LogItem"; import {LogItem} from "./LogItem.js";
import {LogLevel, LogFilter} from "./LogFilter"; import {LogLevel, LogFilter} from "./LogFilter.js";
import type {ILogger, ILogExport, FilterCreator, LabelOrValues, LogCallback, ILogItem, ISerializedItem} from "./types";
import type {Platform} from "../platform/web/Platform.js";
export abstract class BaseLogger implements ILogger { export class BaseLogger {
protected _openItems: Set<LogItem> = new Set(); constructor({platform}) {
protected _platform: Platform; this._openItems = new Set();
protected _serializedTransformer: (item: ISerializedItem) => ISerializedItem;
constructor({platform, serializedTransformer = (item: ISerializedItem) => item}) {
this._platform = platform; this._platform = platform;
this._serializedTransformer = serializedTransformer;
} }
log(labelOrValues: LabelOrValues, logLevel: LogLevel = LogLevel.Info): void { log(labelOrValues, logLevel = LogLevel.Info) {
const item = new LogItem(labelOrValues, logLevel, this); const item = new LogItem(labelOrValues, logLevel, null, this);
item.end = item.start; item._end = item._start;
this._persistItem(item, undefined, false); this._persistItem(item, null, false);
} }
/** if item is a log item, wrap the callback in a child of it, otherwise start a new root log item. */ /** if item is a log item, wrap the callback in a child of it, otherwise start a new root log item. */
wrapOrRun<T>(item: ILogItem | undefined, labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T { wrapOrRun(item, labelOrValues, callback, logLevel = null, filterCreator = null) {
if (item) { if (item) {
return item.wrap(labelOrValues, callback, logLevel, filterCreator); return item.wrap(labelOrValues, callback, logLevel, filterCreator);
} else { } else {
@ -49,31 +43,28 @@ export abstract class BaseLogger implements ILogger {
where the (async) result or errors are not propagated but still logged. where the (async) result or errors are not propagated but still logged.
Useful to pair with LogItem.refDetached. Useful to pair with LogItem.refDetached.
@return {ILogItem} the log item added, useful to pass to LogItem.refDetached */ @return {LogItem} the log item added, useful to pass to LogItem.refDetached */
runDetached<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem { runDetached(labelOrValues, callback, logLevel = null, filterCreator = null) {
if (!logLevel) { if (logLevel === null) {
logLevel = LogLevel.Info; logLevel = LogLevel.Info;
} }
const item = new LogItem(labelOrValues, logLevel, this); const item = new LogItem(labelOrValues, logLevel, null, this);
this._run(item, callback, logLevel, false /* don't throw, nobody is awaiting */, filterCreator); this._run(item, callback, logLevel, filterCreator, false /* don't throw, nobody is awaiting */);
return item; return item;
} }
/** run a callback wrapped in a log operation. /** run a callback wrapped in a log operation.
Errors and duration are transparently logged, also for async operations. Errors and duration are transparently logged, also for async operations.
Whatever the callback returns is returned here. */ Whatever the callback returns is returned here. */
run<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T { run(labelOrValues, callback, logLevel = null, filterCreator = null) {
if (logLevel === undefined) { if (logLevel === null) {
logLevel = LogLevel.Info; logLevel = LogLevel.Info;
} }
const item = new LogItem(labelOrValues, logLevel, this); const item = new LogItem(labelOrValues, logLevel, null, this);
return this._run(item, callback, logLevel, true, filterCreator); return this._run(item, callback, logLevel, filterCreator, true);
} }
_run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: true, filterCreator?: FilterCreator): T; _run(item, callback, logLevel, filterCreator, shouldThrow) {
// we don't return if we don't throw, as we don't have anything to return when an error is caught but swallowed for the fire-and-forget case.
_run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: false, filterCreator?: FilterCreator): void;
_run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: boolean, filterCreator?: FilterCreator): T | void {
this._openItems.add(item); this._openItems.add(item);
const finishItem = () => { const finishItem = () => {
@ -97,29 +88,24 @@ export abstract class BaseLogger implements ILogger {
}; };
try { try {
let result = item.run(callback); const result = item.run(callback);
if (result instanceof Promise) { if (result instanceof Promise) {
result = result.then(promiseResult => { return result.then(promiseResult => {
finishItem(); finishItem();
return promiseResult; return promiseResult;
}, err => { }, err => {
finishItem(); finishItem();
if (wantResult) { if (shouldThrow) {
throw err; throw err;
} }
}) as unknown as T; });
if (wantResult) {
return result;
}
} else { } else {
finishItem(); finishItem();
if(wantResult) { return result;
return result;
}
} }
} catch (err) { } catch (err) {
finishItem(); finishItem();
if (wantResult) { if (shouldThrow) {
throw err; throw err;
} }
} }
@ -141,20 +127,24 @@ export abstract class BaseLogger implements ILogger {
this._openItems.clear(); this._openItems.clear();
} }
abstract _persistItem(item: LogItem, filter?: LogFilter, forced?: boolean): void; _persistItem() {
throw new Error("not implemented");
}
abstract export(): Promise<ILogExport | undefined>; async export() {
throw new Error("not implemented");
}
// expose log level without needing // expose log level without needing
get level(): typeof LogLevel { get level() {
return LogLevel; return LogLevel;
} }
_now(): number { _now() {
return this._platform.clock.now(); return this._platform.clock.now();
} }
_createRefId(): number { _createRefId() {
return Math.round(this._platform.random() * Number.MAX_SAFE_INTEGER); return Math.round(this._platform.random() * Number.MAX_SAFE_INTEGER);
} }
} }

View file

@ -13,35 +13,33 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {BaseLogger} from "./BaseLogger"; import {BaseLogger} from "./BaseLogger.js";
import {LogItem} from "./LogItem";
import type {ILogItem, LogItemValues, ILogExport} from "./types";
export class ConsoleLogger extends BaseLogger { export class ConsoleLogger extends BaseLogger {
_persistItem(item: LogItem): void { _persistItem(item) {
printToConsole(item); printToConsole(item);
} }
async export(): Promise<ILogExport | undefined> {
return undefined;
}
} }
const excludedKeysFromTable = ["l", "id"]; const excludedKeysFromTable = ["l", "id"];
function filterValues(values: LogItemValues): LogItemValues | null { function filterValues(values) {
if (!values) {
return null;
}
return Object.entries(values) return Object.entries(values)
.filter(([key]) => !excludedKeysFromTable.includes(key)) .filter(([key]) => !excludedKeysFromTable.includes(key))
.reduce((obj: LogItemValues, [key, value]) => { .reduce((obj, [key, value]) => {
obj = obj || {}; obj = obj || {};
obj[key] = value; obj[key] = value;
return obj; return obj;
}, null); }, null);
} }
function printToConsole(item: LogItem): void { function printToConsole(item) {
const label = `${itemCaption(item)} (${item.duration}ms)`; const label = `${itemCaption(item)} (${item.duration}ms)`;
const filteredValues = filterValues(item.values); const filteredValues = filterValues(item._values);
const shouldGroup = item.children || filteredValues; const shouldGroup = item._children || filteredValues;
if (shouldGroup) { if (shouldGroup) {
if (item.error) { if (item.error) {
console.group(label); console.group(label);
@ -61,8 +59,8 @@ function printToConsole(item: LogItem): void {
if (filteredValues) { if (filteredValues) {
console.table(filteredValues); console.table(filteredValues);
} }
if (item.children) { if (item._children) {
for(const c of item.children) { for(const c of item._children) {
printToConsole(c); printToConsole(c);
} }
} }
@ -71,18 +69,18 @@ function printToConsole(item: LogItem): void {
} }
} }
function itemCaption(item: ILogItem): string { function itemCaption(item) {
if (item.values.t === "network") { if (item._values.t === "network") {
return `${item.values.method} ${item.values.url}`; return `${item._values.method} ${item._values.url}`;
} else if (item.values.l && typeof item.values.id !== "undefined") { } else if (item._values.l && typeof item._values.id !== "undefined") {
return `${item.values.l} ${item.values.id}`; return `${item._values.l} ${item._values.id}`;
} else if (item.values.l && typeof item.values.status !== "undefined") { } else if (item._values.l && typeof item._values.status !== "undefined") {
return `${item.values.l} (${item.values.status})`; return `${item._values.l} (${item._values.status})`;
} else if (item.values.l && item.error) { } else if (item._values.l && item.error) {
return `${item.values.l} failed`; return `${item._values.l} failed`;
} else if (typeof item.values.ref !== "undefined") { } else if (typeof item._values.ref !== "undefined") {
return `ref ${item.values.ref}`; return `ref ${item._values.ref}`
} else { } else {
return item.values.l || item.values.type; return item._values.l || item._values.type;
} }
} }

View file

@ -22,25 +22,10 @@ import {
iterateCursor, iterateCursor,
fetchResults, fetchResults,
} from "../matrix/storage/idb/utils"; } from "../matrix/storage/idb/utils";
import {BaseLogger} from "./BaseLogger"; import {BaseLogger} from "./BaseLogger.js";
import type {Interval} from "../platform/web/dom/Clock";
import type {Platform} from "../platform/web/Platform.js";
import type {BlobHandle} from "../platform/web/dom/BlobHandle.js";
import type {ILogItem, ILogExport, ISerializedItem} from "./types";
import type {LogFilter} from "./LogFilter";
type QueuedItem = {
json: string;
id?: number;
}
export class IDBLogger extends BaseLogger { export class IDBLogger extends BaseLogger {
private readonly _name: string; constructor(options) {
private readonly _limit: number;
private readonly _flushInterval: Interval;
private _queuedItems: QueuedItem[];
constructor(options: {name: string, flushInterval?: number, limit?: number, platform: Platform, serializedTransformer?: (item: ISerializedItem) => ISerializedItem}) {
super(options); super(options);
const {name, flushInterval = 60 * 1000, limit = 3000} = options; const {name, flushInterval = 60 * 1000, limit = 3000} = options;
this._name = name; this._name = name;
@ -51,19 +36,18 @@ export class IDBLogger extends BaseLogger {
this._flushInterval = this._platform.clock.createInterval(() => this._tryFlush(), flushInterval); this._flushInterval = this._platform.clock.createInterval(() => this._tryFlush(), flushInterval);
} }
// TODO: move dispose to ILogger, listen to pagehide elsewhere and call dispose from there, which calls _finishAllAndFlush dispose() {
dispose(): void {
window.removeEventListener("pagehide", this, false); window.removeEventListener("pagehide", this, false);
this._flushInterval.dispose(); this._flushInterval.dispose();
} }
handleEvent(evt: Event): void { handleEvent(evt) {
if (evt.type === "pagehide") { if (evt.type === "pagehide") {
this._finishAllAndFlush(); this._finishAllAndFlush();
} }
} }
async _tryFlush(): Promise<void> { async _tryFlush() {
const db = await this._openDB(); const db = await this._openDB();
try { try {
const txn = db.transaction(["logs"], "readwrite"); const txn = db.transaction(["logs"], "readwrite");
@ -93,13 +77,13 @@ export class IDBLogger extends BaseLogger {
} }
} }
_finishAllAndFlush(): void { _finishAllAndFlush() {
this._finishOpenItems(); this._finishOpenItems();
this.log({l: "pagehide, closing logs", t: "navigation"}); this.log({l: "pagehide, closing logs", t: "navigation"});
this._persistQueuedItems(this._queuedItems); this._persistQueuedItems(this._queuedItems);
} }
_loadQueuedItems(): QueuedItem[] { _loadQueuedItems() {
const key = `${this._name}_queuedItems`; const key = `${this._name}_queuedItems`;
try { try {
const json = window.localStorage.getItem(key); const json = window.localStorage.getItem(key);
@ -113,21 +97,18 @@ export class IDBLogger extends BaseLogger {
return []; return [];
} }
_openDB(): Promise<IDBDatabase> { _openDB() {
return openDatabase(this._name, db => db.createObjectStore("logs", {keyPath: "id", autoIncrement: true}), 1); return openDatabase(this._name, db => db.createObjectStore("logs", {keyPath: "id", autoIncrement: true}), 1);
} }
_persistItem(logItem: ILogItem, filter: LogFilter, forced: boolean): void { _persistItem(logItem, filter, forced) {
const serializedItem = logItem.serialize(filter, undefined, forced); const serializedItem = logItem.serialize(filter, forced);
if (serializedItem) { this._queuedItems.push({
const transformedSerializedItem = this._serializedTransformer(serializedItem); json: JSON.stringify(serializedItem)
this._queuedItems.push({ });
json: JSON.stringify(transformedSerializedItem)
});
}
} }
_persistQueuedItems(items: QueuedItem[]): void { _persistQueuedItems(items) {
try { try {
window.localStorage.setItem(`${this._name}_queuedItems`, JSON.stringify(items)); window.localStorage.setItem(`${this._name}_queuedItems`, JSON.stringify(items));
} catch (e) { } catch (e) {
@ -135,12 +116,12 @@ export class IDBLogger extends BaseLogger {
} }
} }
async export(): Promise<ILogExport> { async export() {
const db = await this._openDB(); const db = await this._openDB();
try { try {
const txn = db.transaction(["logs"], "readonly"); const txn = db.transaction(["logs"], "readonly");
const logs = txn.objectStore("logs"); const logs = txn.objectStore("logs");
const storedItems: QueuedItem[] = await fetchResults(logs.openCursor(), () => false); const storedItems = await fetchResults(logs.openCursor(), () => false);
const allItems = storedItems.concat(this._queuedItems); const allItems = storedItems.concat(this._queuedItems);
return new IDBLogExport(allItems, this, this._platform); return new IDBLogExport(allItems, this, this._platform);
} finally { } finally {
@ -150,20 +131,17 @@ export class IDBLogger extends BaseLogger {
} }
} }
async _removeItems(items: QueuedItem[]): Promise<void> { async _removeItems(items) {
const db = await this._openDB(); const db = await this._openDB();
try { try {
const txn = db.transaction(["logs"], "readwrite"); const txn = db.transaction(["logs"], "readwrite");
const logs = txn.objectStore("logs"); const logs = txn.objectStore("logs");
for (const item of items) { for (const item of items) {
if (typeof item.id === "number") { const queuedIdx = this._queuedItems.findIndex(i => i.id === item.id);
if (queuedIdx === -1) {
logs.delete(item.id); logs.delete(item.id);
} else { } else {
// assume the (non-persisted) object in each array will be the same this._queuedItems.splice(queuedIdx, 1);
const queuedIdx = this._queuedItems.indexOf(item);
if (queuedIdx === -1) {
this._queuedItems.splice(queuedIdx, 1);
}
} }
} }
await txnAsPromise(txn); await txnAsPromise(txn);
@ -175,37 +153,33 @@ export class IDBLogger extends BaseLogger {
} }
} }
class IDBLogExport implements ILogExport { class IDBLogExport {
private readonly _items: QueuedItem[]; constructor(items, logger, platform) {
private readonly _logger: IDBLogger;
private readonly _platform: Platform;
constructor(items: QueuedItem[], logger: IDBLogger, platform: Platform) {
this._items = items; this._items = items;
this._logger = logger; this._logger = logger;
this._platform = platform; this._platform = platform;
} }
get count(): number { get count() {
return this._items.length; return this._items.length;
} }
/** /**
* @return {Promise} * @return {Promise}
*/ */
removeFromStore(): Promise<void> { removeFromStore() {
return this._logger._removeItems(this._items); return this._logger._removeItems(this._items);
} }
asBlob(): BlobHandle { asBlob() {
const log = { const log = {
formatVersion: 1, formatVersion: 1,
appVersion: this._platform.updateService?.version, appVersion: this._platform.updateService?.version,
items: this._items.map(i => JSON.parse(i.json)) items: this._items.map(i => JSON.parse(i.json))
}; };
const json = JSON.stringify(log); const json = JSON.stringify(log);
const buffer: Uint8Array = this._platform.encoding.utf8.encode(json); const buffer = this._platform.encoding.utf8.encode(json);
const blob: BlobHandle = this._platform.createBlob(buffer, "application/json"); const blob = this._platform.createBlob(buffer, "application/json");
return blob; return blob;
} }
} }

View file

@ -14,35 +14,31 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import type {ILogItem, ISerializedItem} from "./types"; export const LogLevel = {
All: 1,
export enum LogLevel { Debug: 2,
All = 1, Detail: 3,
Debug, Info: 4,
Detail, Warn: 5,
Info, Error: 6,
Warn, Fatal: 7,
Error, Off: 8,
Fatal,
Off
} }
export class LogFilter { export class LogFilter {
private _min?: LogLevel; constructor(parentFilter) {
private _parentFilter?: LogFilter;
constructor(parentFilter?: LogFilter) {
this._parentFilter = parentFilter; this._parentFilter = parentFilter;
this._min = null;
} }
filter(item: ILogItem, children: ISerializedItem[] | null): boolean { filter(item, children) {
if (this._parentFilter) { if (this._parentFilter) {
if (!this._parentFilter.filter(item, children)) { if (!this._parentFilter.filter(item, children)) {
return false; return false;
} }
} }
// neither our children or us have a loglevel high enough, filter out. // neither our children or us have a loglevel high enough, filter out.
if (this._min !== undefined && !Array.isArray(children) && item.logLevel < this._min) { if (this._min !== null && !Array.isArray(children) && item.logLevel < this._min) {
return false; return false;
} else { } else {
return true; return true;
@ -50,7 +46,7 @@ export class LogFilter {
} }
/* methods to build the filter */ /* methods to build the filter */
minLevel(logLevel: LogLevel): LogFilter { minLevel(logLevel) {
this._min = logLevel; this._min = logLevel;
return this; return this;
} }

View file

@ -15,47 +15,39 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {LogLevel, LogFilter} from "./LogFilter"; import {LogLevel, LogFilter} from "./LogFilter.js";
import type {BaseLogger} from "./BaseLogger";
import type {ISerializedItem, ILogItem, LogItemValues, LabelOrValues, FilterCreator, LogCallback} from "./types";
export class LogItem implements ILogItem { export class LogItem {
public readonly start: number; constructor(labelOrValues, logLevel, filterCreator, logger) {
public logLevel: LogLevel;
public error?: Error;
public end?: number;
private _values: LogItemValues;
private _logger: BaseLogger;
private _filterCreator?: FilterCreator;
private _children?: Array<LogItem>;
constructor(labelOrValues: LabelOrValues, logLevel: LogLevel, logger: BaseLogger, filterCreator?: FilterCreator) {
this._logger = logger; this._logger = logger;
this.start = logger._now(); this._start = logger._now();
this._end = null;
// (l)abel // (l)abel
this._values = typeof labelOrValues === "string" ? {l: labelOrValues} : labelOrValues; this._values = typeof labelOrValues === "string" ? {l: labelOrValues} : labelOrValues;
this.error = null;
this.logLevel = logLevel; this.logLevel = logLevel;
this._children = null;
this._filterCreator = filterCreator; this._filterCreator = filterCreator;
} }
/** start a new root log item and run it detached mode, see BaseLogger.runDetached */ /** start a new root log item and run it detached mode, see BaseLogger.runDetached */
runDetached(labelOrValues: LabelOrValues, callback: LogCallback<unknown>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem { runDetached(labelOrValues, callback, logLevel, filterCreator) {
return this._logger.runDetached(labelOrValues, callback, logLevel, filterCreator); return this._logger.runDetached(labelOrValues, callback, logLevel, filterCreator);
} }
/** start a new detached root log item and log a reference to it from this item */ /** start a new detached root log item and log a reference to it from this item */
wrapDetached(labelOrValues: LabelOrValues, callback: LogCallback<unknown>, logLevel?: LogLevel, filterCreator?: FilterCreator): void { wrapDetached(labelOrValues, callback, logLevel, filterCreator) {
this.refDetached(this.runDetached(labelOrValues, callback, logLevel, filterCreator)); this.refDetached(this.runDetached(labelOrValues, callback, logLevel, filterCreator));
} }
/** logs a reference to a different log item, usually obtained from runDetached. /** logs a reference to a different log item, usually obtained from runDetached.
This is useful if the referenced operation can't be awaited. */ This is useful if the referenced operation can't be awaited. */
refDetached(logItem: ILogItem, logLevel?: LogLevel): void { refDetached(logItem, logLevel = null) {
logItem.ensureRefId(); logItem.ensureRefId();
this.log({ref: logItem.values.refId}, logLevel); return this.log({ref: logItem._values.refId}, logLevel);
} }
ensureRefId(): void { ensureRefId() {
if (!this._values.refId) { if (!this._values.refId) {
this.set("refId", this._logger._createRefId()); this.set("refId", this._logger._createRefId());
} }
@ -64,33 +56,29 @@ export class LogItem implements ILogItem {
/** /**
* Creates a new child item and runs it in `callback`. * Creates a new child item and runs it in `callback`.
*/ */
wrap<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T { wrap(labelOrValues, callback, logLevel = null, filterCreator = null) {
const item = this.child(labelOrValues, logLevel, filterCreator); const item = this.child(labelOrValues, logLevel, filterCreator);
return item.run(callback); return item.run(callback);
} }
get duration(): number | undefined { get duration() {
if (this.end) { if (this._end) {
return this.end - this.start; return this._end - this._start;
} else { } else {
return undefined; return null;
} }
} }
durationWithoutType(type: string): number | undefined { durationWithoutType(type) {
const durationOfType = this.durationOfType(type); return this.duration - this.durationOfType(type);
if (this.duration && durationOfType) {
return this.duration - durationOfType;
}
} }
durationOfType(type: string): number | undefined { durationOfType(type) {
if (this._values.t === type) { if (this._values.t === type) {
return this.duration; return this.duration;
} else if (this._children) { } else if (this._children) {
return this._children.reduce((sum, c) => { return this._children.reduce((sum, c) => {
const duration = c.durationOfType(type); return sum + c.durationOfType(type);
return sum + (duration ?? 0);
}, 0); }, 0);
} else { } else {
return 0; return 0;
@ -99,26 +87,25 @@ export class LogItem implements ILogItem {
/** /**
* Creates a new child item that finishes immediately * Creates a new child item that finishes immediately
* Finished items should not be modified anymore as they can be serialized * and can hence not be modified anymore.
* at any stage, but using `set` on the return value in a synchronous way should still be safe. *
* Hence, the child item is not returned.
*/ */
log(labelOrValues: LabelOrValues, logLevel?: LogLevel): ILogItem { log(labelOrValues, logLevel = null) {
const item = this.child(labelOrValues, logLevel); const item = this.child(labelOrValues, logLevel, null);
item.end = item.start; item._end = item._start;
return item;
} }
set(key: string | object, value?: unknown): ILogItem { set(key, value) {
if(typeof key === "object") { if(typeof key === "object") {
const values = key; const values = key;
Object.assign(this._values, values); Object.assign(this._values, values);
} else { } else {
this._values[key] = value; this._values[key] = value;
} }
return this;
} }
serialize(filter: LogFilter, parentStartTime: number | undefined, forced: boolean): ISerializedItem | undefined { serialize(filter, parentStartTime = null, forced) {
if (this._filterCreator) { if (this._filterCreator) {
try { try {
filter = this._filterCreator(new LogFilter(filter), this); filter = this._filterCreator(new LogFilter(filter), this);
@ -126,10 +113,10 @@ export class LogItem implements ILogItem {
console.error("Error creating log filter", err); console.error("Error creating log filter", err);
} }
} }
let children: Array<ISerializedItem> | null = null; let children;
if (this._children) { if (this._children !== null) {
children = this._children.reduce((array: Array<ISerializedItem>, c) => { children = this._children.reduce((array, c) => {
const s = c.serialize(filter, this.start, false); const s = c.serialize(filter, this._start, false);
if (s) { if (s) {
if (array === null) { if (array === null) {
array = []; array = [];
@ -140,12 +127,12 @@ export class LogItem implements ILogItem {
}, null); }, null);
} }
if (filter && !filter.filter(this, children)) { if (filter && !filter.filter(this, children)) {
return; return null;
} }
// in (v)alues, (l)abel and (t)ype are also reserved. // in (v)alues, (l)abel and (t)ype are also reserved.
const item: ISerializedItem = { const item = {
// (s)tart // (s)tart
s: typeof parentStartTime === "number" ? this.start - parentStartTime : this.start, s: parentStartTime === null ? this._start : this._start - parentStartTime,
// (d)uration // (d)uration
d: this.duration, d: this.duration,
// (v)alues // (v)alues
@ -184,19 +171,20 @@ export class LogItem implements ILogItem {
* @param {Function} callback [description] * @param {Function} callback [description]
* @return {[type]} [description] * @return {[type]} [description]
*/ */
run<T>(callback: LogCallback<T>): T { run(callback) {
if (this.end !== undefined) { if (this._end !== null) {
console.trace("log item is finished, additional logs will likely not be recorded"); console.trace("log item is finished, additional logs will likely not be recorded");
} }
let result;
try { try {
const result = callback(this); result = callback(this);
if (result instanceof Promise) { if (result instanceof Promise) {
return result.then(promiseResult => { return result.then(promiseResult => {
this.finish(); this.finish();
return promiseResult; return promiseResult;
}, err => { }, err => {
throw this.catch(err); throw this.catch(err);
}) as unknown as T; });
} else { } else {
this.finish(); this.finish();
return result; return result;
@ -210,53 +198,45 @@ export class LogItem implements ILogItem {
* finished the item, recording the end time. After finishing, an item can't be modified anymore as it will be persisted. * finished the item, recording the end time. After finishing, an item can't be modified anymore as it will be persisted.
* @internal shouldn't typically be called by hand. allows to force finish if a promise is still running when closing the app * @internal shouldn't typically be called by hand. allows to force finish if a promise is still running when closing the app
*/ */
finish(): void { finish() {
if (this.end === undefined) { if (this._end === null) {
if (this._children) { if (this._children !== null) {
for(const c of this._children) { for(const c of this._children) {
c.finish(); c.finish();
} }
} }
this.end = this._logger._now(); this._end = this._logger._now();
} }
} }
// expose log level without needing import everywhere // expose log level without needing import everywhere
get level(): typeof LogLevel { get level() {
return LogLevel; return LogLevel;
} }
catch(err: Error): Error { catch(err) {
this.error = err; this.error = err;
this.logLevel = LogLevel.Error; this.logLevel = LogLevel.Error;
this.finish(); this.finish();
return err; return err;
} }
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): LogItem { child(labelOrValues, logLevel, filterCreator) {
if (this.end) { if (this._end !== null) {
console.trace("log item is finished, additional logs will likely not be recorded"); console.trace("log item is finished, additional logs will likely not be recorded");
} }
if (!logLevel) { if (!logLevel) {
logLevel = this.logLevel || LogLevel.Info; logLevel = this.logLevel || LogLevel.Info;
} }
const item = new LogItem(labelOrValues, logLevel, this._logger, filterCreator); const item = new LogItem(labelOrValues, logLevel, filterCreator, this._logger);
if (!this._children) { if (this._children === null) {
this._children = []; this._children = [];
} }
this._children.push(item); this._children.push(item);
return item; return item;
} }
get logger(): BaseLogger { get logger() {
return this._logger; return this._logger;
} }
get values(): LogItemValues {
return this._values;
}
get children(): Array<LogItem> | undefined {
return this._children;
}
} }

View file

@ -0,0 +1,99 @@
/*
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.
*/
import {LogLevel} from "./LogFilter.js";
function noop () {}
export class NullLogger {
constructor() {
this.item = new NullLogItem(this);
}
log() {}
run(_, callback) {
return callback(this.item);
}
wrapOrRun(item, _, callback) {
if (item) {
return item.wrap(null, callback);
} else {
return this.run(null, callback);
}
}
runDetached(_, callback) {
new Promise(r => r(callback(this.item))).then(noop, noop);
}
async export() {
return null;
}
get level() {
return LogLevel;
}
}
export class NullLogItem {
constructor(logger) {
this.logger = logger;
}
wrap(_, callback) {
return callback(this);
}
log() {}
set() {}
runDetached(_, callback) {
new Promise(r => r(callback(this))).then(noop, noop);
}
wrapDetached(_, callback) {
return this.refDetached(null, callback);
}
run(callback) {
return callback(this);
}
refDetached() {}
ensureRefId() {}
get level() {
return LogLevel;
}
get duration() {
return 0;
}
catch(err) {
return err;
}
child() {
return this;
}
finish() {}
}
export const Instance = new NullLogger();

View file

@ -0,0 +1,16 @@
// these are helper functions if you can't assume you always have a log item (e.g. some code paths call with one set, others don't)
// if you know you always have a log item, better to use the methods on the log item than these utility functions.
import {Instance as NullLoggerInstance} from "./NullLogger.js";
export function wrapOrRunNullLogger(logItem, labelOrValues, callback, logLevel = null, filterCreator = null) {
if (logItem) {
return logItem.wrap(logItem, labelOrValues, callback, logLevel, filterCreator);
} else {
return NullLoggerInstance.run(null, callback);
}
}
export function ensureLogItem(logItem) {
return logItem || NullLoggerInstance.item;
}

View file

@ -14,9 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {AbortError} from "../utils/error"; import {AbortError} from "../utils/error.js";
import {BaseObservable} from "./BaseObservable"; import {BaseObservable} from "./BaseObservable";
import type {SubscriptionHandle} from "./BaseObservable";
// like an EventEmitter, but doesn't have an event type // like an EventEmitter, but doesn't have an event type
export abstract class BaseObservableValue<T> extends BaseObservable<(value: T) => void> { export abstract class BaseObservableValue<T> extends BaseObservable<(value: T) => void> {
@ -35,10 +34,6 @@ export abstract class BaseObservableValue<T> extends BaseObservable<(value: T) =
return new WaitForHandle(this, predicate); return new WaitForHandle(this, predicate);
} }
} }
flatMap<C>(mapper: (value: T) => (BaseObservableValue<C> | undefined)): BaseObservableValue<C | undefined> {
return new FlatMapObservableValue<T, C>(this, mapper);
}
} }
interface IWaitHandle<T> { interface IWaitHandle<T> {
@ -119,61 +114,6 @@ export class RetainedObservableValue<T> extends ObservableValue<T> {
} }
} }
export class FlatMapObservableValue<P, C> extends BaseObservableValue<C | undefined> {
private sourceSubscription?: SubscriptionHandle;
private targetSubscription?: SubscriptionHandle;
constructor(
private readonly source: BaseObservableValue<P>,
private readonly mapper: (value: P) => (BaseObservableValue<C> | undefined)
) {
super();
}
onUnsubscribeLast() {
super.onUnsubscribeLast();
this.sourceSubscription = this.sourceSubscription!();
if (this.targetSubscription) {
this.targetSubscription = this.targetSubscription();
}
}
onSubscribeFirst() {
super.onSubscribeFirst();
this.sourceSubscription = this.source.subscribe(() => {
this.updateTargetSubscription();
this.emit(this.get());
});
this.updateTargetSubscription();
}
private updateTargetSubscription() {
const sourceValue = this.source.get();
if (sourceValue) {
const target = this.mapper(sourceValue);
if (target) {
if (!this.targetSubscription) {
this.targetSubscription = target.subscribe(() => this.emit(this.get()));
}
return;
}
}
// if no sourceValue or target
if (this.targetSubscription) {
this.targetSubscription = this.targetSubscription();
}
}
get(): C | undefined {
const sourceValue = this.source.get();
if (!sourceValue) {
return undefined;
}
const mapped = this.mapper(sourceValue);
return mapped?.get();
}
}
export function tests() { export function tests() {
return { return {
"set emits an update": assert => { "set emits an update": assert => {
@ -215,34 +155,5 @@ export function tests() {
}); });
await assert.rejects(handle.promise, AbortError); await assert.rejects(handle.promise, AbortError);
}, },
"flatMap.get": assert => {
const a = new ObservableValue<undefined | {count: ObservableValue<number>}>(undefined);
const countProxy = a.flatMap(a => a!.count);
assert.strictEqual(countProxy.get(), undefined);
const count = new ObservableValue<number>(0);
a.set({count});
assert.strictEqual(countProxy.get(), 0);
},
"flatMap update from source": assert => {
const a = new ObservableValue<undefined | {count: ObservableValue<number>}>(undefined);
const updates: (number | undefined)[] = [];
a.flatMap(a => a!.count).subscribe(count => {
updates.push(count);
});
const count = new ObservableValue<number>(0);
a.set({count});
assert.deepEqual(updates, [0]);
},
"flatMap update from target": assert => {
const a = new ObservableValue<undefined | {count: ObservableValue<number>}>(undefined);
const updates: (number | undefined)[] = [];
a.flatMap(a => a!.count).subscribe(count => {
updates.push(count);
});
const count = new ObservableValue<number>(0);
a.set({count});
count.set(5);
assert.deepEqual(updates, [0, 5]);
}
} }
} }

View file

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

View file

@ -15,14 +15,15 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {IListObserver} from "./BaseObservableList"; import {BaseMappedList, runAdd, runUpdate, runRemove, runMove, runReset} from "./BaseMappedList";
import {BaseMappedList, Mapper, Updater, runAdd, runUpdate, runRemove, runMove, runReset} from "./BaseMappedList";
export class AsyncMappedList<F,T> extends BaseMappedList<F,T,Promise<T>> implements IListObserver<F> { export class AsyncMappedList extends BaseMappedList {
private _eventQueue: AsyncEvent<F>[] | null = null; constructor(sourceList, mapper, updater, removeCallback) {
private _flushing: boolean = false; super(sourceList, mapper, updater, removeCallback);
this._eventQueue = null;
}
onSubscribeFirst(): void { onSubscribeFirst() {
this._sourceUnsubscribe = this._sourceList.subscribe(this); this._sourceUnsubscribe = this._sourceList.subscribe(this);
this._eventQueue = []; this._eventQueue = [];
this._mappedValues = []; this._mappedValues = [];
@ -34,112 +35,122 @@ export class AsyncMappedList<F,T> extends BaseMappedList<F,T,Promise<T>> impleme
this._flush(); this._flush();
} }
async _flush(): Promise<void> { async _flush() {
if (this._flushing) { if (this._flushing) {
return; return;
} }
this._flushing = true; this._flushing = true;
try { try {
while (this._eventQueue!.length) { while (this._eventQueue.length) {
const event = this._eventQueue!.shift(); const event = this._eventQueue.shift();
await event!.run(this); await event.run(this);
} }
} finally { } finally {
this._flushing = false; this._flushing = false;
} }
} }
onReset(): void { onReset() {
if (this._eventQueue) { if (this._eventQueue) {
this._eventQueue.push(new ResetEvent()); this._eventQueue.push(new ResetEvent());
this._flush(); this._flush();
} }
} }
onAdd(index: number, value: F): void { onAdd(index, value) {
if (this._eventQueue) { if (this._eventQueue) {
this._eventQueue.push(new AddEvent(index, value)); this._eventQueue.push(new AddEvent(index, value));
this._flush(); this._flush();
} }
} }
onUpdate(index: number, value: F, params: any): void { onUpdate(index, value, params) {
if (this._eventQueue) { if (this._eventQueue) {
this._eventQueue.push(new UpdateEvent(index, value, params)); this._eventQueue.push(new UpdateEvent(index, value, params));
this._flush(); this._flush();
} }
} }
onRemove(index: number): void { onRemove(index) {
if (this._eventQueue) { if (this._eventQueue) {
this._eventQueue.push(new RemoveEvent(index)); this._eventQueue.push(new RemoveEvent(index));
this._flush(); this._flush();
} }
} }
onMove(fromIdx: number, toIdx: number): void { onMove(fromIdx, toIdx) {
if (this._eventQueue) { if (this._eventQueue) {
this._eventQueue.push(new MoveEvent(fromIdx, toIdx)); this._eventQueue.push(new MoveEvent(fromIdx, toIdx));
this._flush(); this._flush();
} }
} }
onUnsubscribeLast(): void { onUnsubscribeLast() {
this._sourceUnsubscribe!(); this._sourceUnsubscribe();
this._eventQueue = null; this._eventQueue = null;
this._mappedValues = null; this._mappedValues = null;
} }
} }
type AsyncEvent<F> = AddEvent<F> | UpdateEvent<F> | RemoveEvent<F> | MoveEvent<F> | ResetEvent<F> class AddEvent {
constructor(index, value) {
this.index = index;
this.value = value;
}
class AddEvent<F> { async run(list) {
constructor(public index: number, public value: F) {}
async run<T>(list: AsyncMappedList<F,T>): Promise<void> {
const mappedValue = await list._mapper(this.value); const mappedValue = await list._mapper(this.value);
runAdd(list, this.index, mappedValue); runAdd(list, this.index, mappedValue);
} }
} }
class UpdateEvent<F> { class UpdateEvent {
constructor(public index: number, public value: F, public params: any) {} constructor(index, value, params) {
this.index = index;
this.value = value;
this.params = params;
}
async run<T>(list: AsyncMappedList<F,T>): Promise<void> { async run(list) {
runUpdate(list, this.index, this.value, this.params); runUpdate(list, this.index, this.value, this.params);
} }
} }
class RemoveEvent<F> { class RemoveEvent {
constructor(public index: number) {} constructor(index) {
this.index = index;
}
async run<T>(list: AsyncMappedList<F,T>): Promise<void> { async run(list) {
runRemove(list, this.index); runRemove(list, this.index);
} }
} }
class MoveEvent<F> { class MoveEvent {
constructor(public fromIdx: number, public toIdx: number) {} constructor(fromIdx, toIdx) {
this.fromIdx = fromIdx;
this.toIdx = toIdx;
}
async run<T>(list: AsyncMappedList<F,T>): Promise<void> { async run(list) {
runMove(list, this.fromIdx, this.toIdx); runMove(list, this.fromIdx, this.toIdx);
} }
} }
class ResetEvent<F> { class ResetEvent {
async run<T>(list: AsyncMappedList<F,T>): Promise<void> { async run(list) {
runReset(list); runReset(list);
} }
} }
import {ObservableArray} from "./ObservableArray"; import {ObservableArray} from "./ObservableArray.js";
import {ListObserver} from "../../mocks/ListObserver.js"; import {ListObserver} from "../../mocks/ListObserver.js";
export function tests() { export function tests() {
return { return {
"events are emitted in order": async assert => { "events are emitted in order": async assert => {
const double = n => n * n; const double = n => n * n;
const source = new ObservableArray<number>(); const source = new ObservableArray();
const mapper = new AsyncMappedList(source, async n => { const mapper = new AsyncMappedList(source, async n => {
await new Promise(r => setTimeout(r, n)); await new Promise(r => setTimeout(r, n));
return {n: double(n)}; return {n: double(n)};

View file

@ -21,15 +21,15 @@ import {findAndUpdateInArray} from "./common";
export type Mapper<F,T> = (value: F) => T export type Mapper<F,T> = (value: F) => T
export type Updater<F,T> = (mappedValue: T, params: any, value: F) => void; export type Updater<F,T> = (mappedValue: T, params: any, value: F) => void;
export class BaseMappedList<F,T,R = T> extends BaseObservableList<T> { export class BaseMappedList<F,T> extends BaseObservableList<T> {
protected _sourceList: BaseObservableList<F>; protected _sourceList: BaseObservableList<F>;
protected _sourceUnsubscribe: (() => void) | null = null; protected _sourceUnsubscribe: (() => void) | null = null;
_mapper: Mapper<F,R>; _mapper: Mapper<F,T>;
_updater?: Updater<F,T>; _updater: Updater<F,T>;
_removeCallback?: (value: T) => void; _removeCallback?: (value: T) => void;
_mappedValues: T[] | null = null; _mappedValues: T[] | null = null;
constructor(sourceList: BaseObservableList<F>, mapper: Mapper<F,R>, updater?: Updater<F,T>, removeCallback?: (value: T) => void) { constructor(sourceList: BaseObservableList<F>, mapper: Mapper<F,T>, updater: Updater<F,T>, removeCallback?: (value: T) => void) {
super(); super();
this._sourceList = sourceList; this._sourceList = sourceList;
this._mapper = mapper; this._mapper = mapper;
@ -50,12 +50,12 @@ export class BaseMappedList<F,T,R = T> extends BaseObservableList<T> {
} }
} }
export function runAdd<F,T,R>(list: BaseMappedList<F,T,R>, index: number, mappedValue: T): void { export function runAdd<F,T>(list: BaseMappedList<F,T>, index: number, mappedValue: T): void {
list._mappedValues!.splice(index, 0, mappedValue); list._mappedValues!.splice(index, 0, mappedValue);
list.emitAdd(index, mappedValue); list.emitAdd(index, mappedValue);
} }
export function runUpdate<F,T,R>(list: BaseMappedList<F,T,R>, index: number, value: F, params: any): void { export function runUpdate<F,T>(list: BaseMappedList<F,T>, index: number, value: F, params: any): void {
const mappedValue = list._mappedValues![index]; const mappedValue = list._mappedValues![index];
if (list._updater) { if (list._updater) {
list._updater(mappedValue, params, value); list._updater(mappedValue, params, value);
@ -63,7 +63,7 @@ export function runUpdate<F,T,R>(list: BaseMappedList<F,T,R>, index: number, val
list.emitUpdate(index, mappedValue, params); list.emitUpdate(index, mappedValue, params);
} }
export function runRemove<F,T,R>(list: BaseMappedList<F,T,R>, index: number): void { export function runRemove<F,T>(list: BaseMappedList<F,T>, index: number): void {
const mappedValue = list._mappedValues![index]; const mappedValue = list._mappedValues![index];
list._mappedValues!.splice(index, 1); list._mappedValues!.splice(index, 1);
if (list._removeCallback) { if (list._removeCallback) {
@ -72,14 +72,14 @@ export function runRemove<F,T,R>(list: BaseMappedList<F,T,R>, index: number): vo
list.emitRemove(index, mappedValue); list.emitRemove(index, mappedValue);
} }
export function runMove<F,T,R>(list: BaseMappedList<F,T,R>, fromIdx: number, toIdx: number): void { export function runMove<F,T>(list: BaseMappedList<F,T>, fromIdx: number, toIdx: number): void {
const mappedValue = list._mappedValues![fromIdx]; const mappedValue = list._mappedValues![fromIdx];
list._mappedValues!.splice(fromIdx, 1); list._mappedValues!.splice(fromIdx, 1);
list._mappedValues!.splice(toIdx, 0, mappedValue); list._mappedValues!.splice(toIdx, 0, mappedValue);
list.emitMove(fromIdx, toIdx, mappedValue); list.emitMove(fromIdx, toIdx, mappedValue);
} }
export function runReset<F,T,R>(list: BaseMappedList<F,T,R>): void { export function runReset<F,T>(list: BaseMappedList<F,T>): void {
list._mappedValues = []; list._mappedValues = [];
list.emitReset(); list.emitReset();
} }

View file

@ -24,18 +24,7 @@ export interface IListObserver<T> {
onMove(from: number, to: number, value: T, list: BaseObservableList<T>): void onMove(from: number, to: number, value: T, list: BaseObservableList<T>): void
} }
export function defaultObserverWith<T>(overrides: { [key in keyof IListObserver<T>]?: IListObserver<T>[key] }): IListObserver<T> { export abstract class BaseObservableList<T> extends BaseObservable<IListObserver<T>> {
const defaults = {
onReset(){},
onAdd(){},
onUpdate(){},
onRemove(){},
onMove(){},
}
return Object.assign(defaults, overrides);
}
export abstract class BaseObservableList<T> extends BaseObservable<IListObserver<T>> implements Iterable<T> {
emitReset() { emitReset() {
for(let h of this._handlers) { for(let h of this._handlers) {
h.onReset(this); h.onReset(this);
@ -49,7 +38,7 @@ export abstract class BaseObservableList<T> extends BaseObservable<IListObserver
} }
} }
emitUpdate(index: number, value: T, params?: any): void { emitUpdate(index: number, value: T, params: any): void {
for(let h of this._handlers) { for(let h of this._handlers) {
h.onUpdate(index, value, params, this); h.onUpdate(index, value, params, this);
} }
@ -69,6 +58,6 @@ export abstract class BaseObservableList<T> extends BaseObservable<IListObserver
} }
} }
abstract [Symbol.iterator](): Iterator<T>; abstract [Symbol.iterator](): IterableIterator<T>;
abstract get length(): number; abstract get length(): number;
} }

View file

@ -14,18 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {BaseObservableList, IListObserver} from "./BaseObservableList"; import {BaseObservableList} from "./BaseObservableList";
export class ConcatList<T> extends BaseObservableList<T> implements IListObserver<T> { export class ConcatList extends BaseObservableList {
protected _sourceLists: BaseObservableList<T>[]; constructor(...sourceLists) {
protected _sourceUnsubscribes: (() => void)[] | null = null;
constructor(...sourceLists: BaseObservableList<T>[]) {
super(); super();
this._sourceLists = sourceLists; this._sourceLists = sourceLists;
this._sourceUnsubscribes = null;
} }
_offsetForSource(sourceList: BaseObservableList<T>): number { _offsetForSource(sourceList) {
const listIdx = this._sourceLists.indexOf(sourceList); const listIdx = this._sourceLists.indexOf(sourceList);
let offset = 0; let offset = 0;
for (let i = 0; i < listIdx; ++i) { for (let i = 0; i < listIdx; ++i) {
@ -34,17 +32,17 @@ export class ConcatList<T> extends BaseObservableList<T> implements IListObserve
return offset; return offset;
} }
onSubscribeFirst(): void { onSubscribeFirst() {
this._sourceUnsubscribes = this._sourceLists.map(sourceList => sourceList.subscribe(this)); this._sourceUnsubscribes = this._sourceLists.map(sourceList => sourceList.subscribe(this));
} }
onUnsubscribeLast(): void { onUnsubscribeLast() {
for (const sourceUnsubscribe of this._sourceUnsubscribes!) { for (const sourceUnsubscribe of this._sourceUnsubscribes) {
sourceUnsubscribe(); sourceUnsubscribe();
} }
} }
onReset(): void { onReset() {
// TODO: not ideal if other source lists are large // TODO: not ideal if other source lists are large
// but working impl for now // but working impl for now
// reset, and // reset, and
@ -56,11 +54,11 @@ export class ConcatList<T> extends BaseObservableList<T> implements IListObserve
} }
} }
onAdd(index: number, value: T, sourceList: BaseObservableList<T>): void { onAdd(index, value, sourceList) {
this.emitAdd(this._offsetForSource(sourceList) + index, value); this.emitAdd(this._offsetForSource(sourceList) + index, value);
} }
onUpdate(index: number, value: T, params: any, sourceList: BaseObservableList<T>): void { onUpdate(index, value, params, sourceList) {
// if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it // if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it
// as we are not supposed to call `length` on any uninitialized list // as we are not supposed to call `length` on any uninitialized list
if (!this._sourceUnsubscribes) { if (!this._sourceUnsubscribes) {
@ -69,16 +67,16 @@ export class ConcatList<T> extends BaseObservableList<T> implements IListObserve
this.emitUpdate(this._offsetForSource(sourceList) + index, value, params); this.emitUpdate(this._offsetForSource(sourceList) + index, value, params);
} }
onRemove(index: number, value: T, sourceList: BaseObservableList<T>): void { onRemove(index, value, sourceList) {
this.emitRemove(this._offsetForSource(sourceList) + index, value); this.emitRemove(this._offsetForSource(sourceList) + index, value);
} }
onMove(fromIdx: number, toIdx: number, value: T, sourceList: BaseObservableList<T>): void { onMove(fromIdx, toIdx, value, sourceList) {
const offset = this._offsetForSource(sourceList); const offset = this._offsetForSource(sourceList);
this.emitMove(offset + fromIdx, offset + toIdx, value); this.emitMove(offset + fromIdx, offset + toIdx, value);
} }
get length(): number { get length() {
let len = 0; let len = 0;
for (let i = 0; i < this._sourceLists.length; ++i) { for (let i = 0; i < this._sourceLists.length; ++i) {
len += this._sourceLists[i].length; len += this._sourceLists[i].length;
@ -106,8 +104,7 @@ export class ConcatList<T> extends BaseObservableList<T> implements IListObserve
} }
} }
import {ObservableArray} from "./ObservableArray"; import {ObservableArray} from "./ObservableArray.js";
import {defaultObserverWith} from "./BaseObservableList";
export async function tests() { export async function tests() {
return { return {
test_length(assert) { test_length(assert) {
@ -136,13 +133,13 @@ export async function tests() {
const list2 = new ObservableArray([11, 12, 13]); const list2 = new ObservableArray([11, 12, 13]);
const all = new ConcatList(list1, list2); const all = new ConcatList(list1, list2);
let fired = false; let fired = false;
all.subscribe(defaultObserverWith({ all.subscribe({
onAdd(index, value) { onAdd(index, value) {
fired = true; fired = true;
assert.equal(index, 4); assert.equal(index, 4);
assert.equal(value, 11.5); assert.equal(value, 11.5);
} }
})); });
list2.insert(1, 11.5); list2.insert(1, 11.5);
assert(fired); assert(fired);
}, },
@ -151,13 +148,13 @@ export async function tests() {
const list2 = new ObservableArray([11, 12, 13]); const list2 = new ObservableArray([11, 12, 13]);
const all = new ConcatList(list1, list2); const all = new ConcatList(list1, list2);
let fired = false; let fired = false;
all.subscribe(defaultObserverWith({ all.subscribe({
onUpdate(index, value) { onUpdate(index, value) {
fired = true; fired = true;
assert.equal(index, 4); assert.equal(index, 4);
assert.equal(value, 10); assert.equal(value, 10);
} }
})); });
list2.emitUpdate(1, 10); list2.emitUpdate(1, 10);
assert(fired); assert(fired);
}, },

View file

@ -15,10 +15,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {IListObserver} from "./BaseObservableList";
import {BaseMappedList, runAdd, runUpdate, runRemove, runMove, runReset} from "./BaseMappedList"; import {BaseMappedList, runAdd, runUpdate, runRemove, runMove, runReset} from "./BaseMappedList";
export class MappedList<F,T> extends BaseMappedList<F,T> implements IListObserver<F> { export class MappedList extends BaseMappedList {
onSubscribeFirst() { onSubscribeFirst() {
this._sourceUnsubscribe = this._sourceList.subscribe(this); this._sourceUnsubscribe = this._sourceList.subscribe(this);
this._mappedValues = []; this._mappedValues = [];
@ -27,16 +26,16 @@ export class MappedList<F,T> extends BaseMappedList<F,T> implements IListObserve
} }
} }
onReset(): void { onReset() {
runReset(this); runReset(this);
} }
onAdd(index: number, value: F): void { onAdd(index, value) {
const mappedValue = this._mapper(value); const mappedValue = this._mapper(value);
runAdd(this, index, mappedValue); runAdd(this, index, mappedValue);
} }
onUpdate(index: number, value: F, params: any): void { onUpdate(index, value, params) {
// if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it // if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it
if (!this._mappedValues) { if (!this._mappedValues) {
return; return;
@ -44,25 +43,24 @@ export class MappedList<F,T> extends BaseMappedList<F,T> implements IListObserve
runUpdate(this, index, value, params); runUpdate(this, index, value, params);
} }
onRemove(index: number): void { onRemove(index) {
runRemove(this, index); runRemove(this, index);
} }
onMove(fromIdx: number, toIdx: number): void { onMove(fromIdx, toIdx) {
runMove(this, fromIdx, toIdx); runMove(this, fromIdx, toIdx);
} }
onUnsubscribeLast(): void { onUnsubscribeLast() {
this._sourceUnsubscribe!(); this._sourceUnsubscribe();
} }
} }
import {ObservableArray} from "./ObservableArray"; import {ObservableArray} from "./ObservableArray.js";
import {BaseObservableList} from "./BaseObservableList"; import {BaseObservableList} from "./BaseObservableList";
import {defaultObserverWith} from "./BaseObservableList";
export async function tests() { export async function tests() {
class MockList extends BaseObservableList<number> { class MockList extends BaseObservableList {
get length() { get length() {
return 0; return 0;
} }
@ -76,26 +74,26 @@ export async function tests() {
const source = new MockList(); const source = new MockList();
const mapped = new MappedList(source, n => {return {n: n*n};}); const mapped = new MappedList(source, n => {return {n: n*n};});
let fired = false; let fired = false;
const unsubscribe = mapped.subscribe(defaultObserverWith({ const unsubscribe = mapped.subscribe({
onAdd(idx, value) { onAdd(idx, value) {
fired = true; fired = true;
assert.equal(idx, 0); assert.equal(idx, 0);
assert.equal(value.n, 36); assert.equal(value.n, 36);
} }
})); });
source.emitAdd(0, 6); source.emitAdd(0, 6);
assert(fired); assert(fired);
unsubscribe(); unsubscribe();
}, },
test_update(assert) { test_update(assert) {
const source = new MockList(); const source = new MockList();
const mapped = new MappedList<number, { n: number, m?: number }>( const mapped = new MappedList(
source, source,
n => {return {n: n*n};}, n => {return {n: n*n};},
(o, p, n) => o.m = n*n (o, p, n) => o.m = n*n
); );
let fired = false; let fired = false;
const unsubscribe = mapped.subscribe(defaultObserverWith({ const unsubscribe = mapped.subscribe({
onAdd() {}, onAdd() {},
onUpdate(idx, value) { onUpdate(idx, value) {
fired = true; fired = true;
@ -103,7 +101,7 @@ export async function tests() {
assert.equal(value.n, 36); assert.equal(value.n, 36);
assert.equal(value.m, 49); assert.equal(value.m, 49);
} }
})); });
source.emitAdd(0, 6); source.emitAdd(0, 6);
source.emitUpdate(0, 7); source.emitUpdate(0, 7);
assert(fired); assert(fired);
@ -115,9 +113,9 @@ export async function tests() {
source, source,
n => {return n*n;} n => {return n*n;}
); );
mapped.subscribe(defaultObserverWith({ mapped.subscribe({
onUpdate() { assert.fail(); } onUpdate() { assert.fail(); }
})); });
assert.equal(mapped.findAndUpdate( assert.equal(mapped.findAndUpdate(
n => n === 100, n => n === 100,
() => assert.fail() () => assert.fail()
@ -129,9 +127,9 @@ export async function tests() {
source, source,
n => {return n*n;} n => {return n*n;}
); );
mapped.subscribe(defaultObserverWith({ mapped.subscribe({
onUpdate() { assert.fail(); } onUpdate() { assert.fail(); }
})); });
let fired = false; let fired = false;
assert.equal(mapped.findAndUpdate( assert.equal(mapped.findAndUpdate(
n => n === 9, n => n === 9,
@ -150,14 +148,14 @@ export async function tests() {
n => {return n*n;} n => {return n*n;}
); );
let fired = false; let fired = false;
mapped.subscribe(defaultObserverWith({ mapped.subscribe({
onUpdate(idx, n, params) { onUpdate(idx, n, params) {
assert.equal(idx, 1); assert.equal(idx, 1);
assert.equal(n, 9); assert.equal(n, 9);
assert.equal(params, "param"); assert.equal(params, "param");
fired = true; fired = true;
} }
})); });
assert.equal(mapped.findAndUpdate(n => n === 9, () => "param"), true); assert.equal(mapped.findAndUpdate(n => n === 9, () => "param"), true);
assert.equal(fired, true); assert.equal(fired, true);
}, },

View file

@ -16,62 +16,52 @@ limitations under the License.
import {BaseObservableList} from "./BaseObservableList"; import {BaseObservableList} from "./BaseObservableList";
export class ObservableArray<T> extends BaseObservableList<T> { export class ObservableArray extends BaseObservableList {
private _items: T[]; constructor(initialValues = []) {
constructor(initialValues: T[] = []) {
super(); super();
this._items = initialValues; this._items = initialValues;
} }
append(item: T): void { append(item) {
this._items.push(item); this._items.push(item);
this.emitAdd(this._items.length - 1, item); this.emitAdd(this._items.length - 1, item);
} }
remove(idx: number): void { remove(idx) {
const [item] = this._items.splice(idx, 1); const [item] = this._items.splice(idx, 1);
this.emitRemove(idx, item); this.emitRemove(idx, item);
} }
insertMany(idx: number, items: T[]): void { insertMany(idx, items) {
for(let item of items) { for(let item of items) {
this.insert(idx, item); this.insert(idx, item);
idx += 1; idx += 1;
} }
} }
insert(idx: number, item: T): void { insert(idx, item) {
this._items.splice(idx, 0, item); this._items.splice(idx, 0, item);
this.emitAdd(idx, item); this.emitAdd(idx, item);
} }
move(fromIdx: number, toIdx: number): void { update(idx, item, params = null) {
if (fromIdx < this._items.length && toIdx < this._items.length) {
const [item] = this._items.splice(fromIdx, 1);
this._items.splice(toIdx, 0, item);
this.emitMove(fromIdx, toIdx, item);
}
}
update(idx: number, item: T, params: any = null): void {
if (idx < this._items.length) { if (idx < this._items.length) {
this._items[idx] = item; this._items[idx] = item;
this.emitUpdate(idx, item, params); this.emitUpdate(idx, item, params);
} }
} }
get array(): Readonly<T[]> { get array() {
return this._items; return this._items;
} }
at(idx: number): T | undefined { at(idx) {
if (this._items && idx >= 0 && idx < this._items.length) { if (this._items && idx >= 0 && idx < this._items.length) {
return this._items[idx]; return this._items[idx];
} }
} }
get length(): number { get length() {
return this._items.length; return this._items.length;
} }

View file

@ -15,23 +15,21 @@ limitations under the License.
*/ */
import {BaseObservableList} from "./BaseObservableList"; import {BaseObservableList} from "./BaseObservableList";
import {sortedIndex} from "../../utils/sortedIndex"; import {sortedIndex} from "../../utils/sortedIndex.js";
import {findAndUpdateInArray} from "./common"; import {findAndUpdateInArray} from "./common";
export class SortedArray<T> extends BaseObservableList<T> { export class SortedArray extends BaseObservableList {
private _comparator: (left: T, right: T) => number; constructor(comparator) {
private _items: T[] = [];
constructor(comparator: (left: T, right: T) => number) {
super(); super();
this._comparator = comparator; this._comparator = comparator;
this._items = [];
} }
setManyUnsorted(items: T[]): void { setManyUnsorted(items) {
this.setManySorted(items); this.setManySorted(items);
} }
setManySorted(items: T[]): void { setManySorted(items) {
// TODO: we can make this way faster by only looking up the first and last key, // TODO: we can make this way faster by only looking up the first and last key,
// and merging whatever is inbetween with items // and merging whatever is inbetween with items
// if items is not sorted, 💩🌀 will follow! // if items is not sorted, 💩🌀 will follow!
@ -44,11 +42,11 @@ export class SortedArray<T> extends BaseObservableList<T> {
} }
} }
findAndUpdate(predicate: (value: T) => boolean, updater: (value: T) => any | false): boolean { findAndUpdate(predicate, updater) {
return findAndUpdateInArray(predicate, this._items, this, updater); return findAndUpdateInArray(predicate, this._items, this, updater);
} }
getAndUpdate(item: T, updater: (existing: T, item: T) => any, updateParams: any = null): void { getAndUpdate(item, updater, updateParams = null) {
const idx = this.indexOf(item); const idx = this.indexOf(item);
if (idx !== -1) { if (idx !== -1) {
const existingItem = this._items[idx]; const existingItem = this._items[idx];
@ -58,7 +56,7 @@ export class SortedArray<T> extends BaseObservableList<T> {
} }
} }
update(item: T, updateParams: any = null): void { update(item, updateParams = null) {
const idx = this.indexOf(item); const idx = this.indexOf(item);
if (idx !== -1) { if (idx !== -1) {
this._items[idx] = item; this._items[idx] = item;
@ -66,7 +64,7 @@ export class SortedArray<T> extends BaseObservableList<T> {
} }
} }
indexOf(item: T): number { indexOf(item) {
const idx = sortedIndex(this._items, item, this._comparator); const idx = sortedIndex(this._items, item, this._comparator);
if (idx < this._items.length && this._comparator(this._items[idx], item) === 0) { if (idx < this._items.length && this._comparator(this._items[idx], item) === 0) {
return idx; return idx;
@ -75,7 +73,7 @@ export class SortedArray<T> extends BaseObservableList<T> {
} }
} }
_getNext(item: T): T | undefined { _getNext(item) {
let idx = sortedIndex(this._items, item, this._comparator); let idx = sortedIndex(this._items, item, this._comparator);
while(idx < this._items.length && this._comparator(this._items[idx], item) <= 0) { while(idx < this._items.length && this._comparator(this._items[idx], item) <= 0) {
idx += 1; idx += 1;
@ -83,7 +81,7 @@ export class SortedArray<T> extends BaseObservableList<T> {
return this.get(idx); return this.get(idx);
} }
set(item: T, updateParams: any = null): void { set(item, updateParams = null) {
const idx = sortedIndex(this._items, item, this._comparator); const idx = sortedIndex(this._items, item, this._comparator);
if (idx >= this._items.length || this._comparator(this._items[idx], item) !== 0) { if (idx >= this._items.length || this._comparator(this._items[idx], item) !== 0) {
this._items.splice(idx, 0, item); this._items.splice(idx, 0, item);
@ -94,21 +92,21 @@ export class SortedArray<T> extends BaseObservableList<T> {
} }
} }
get(idx: number): T | undefined { get(idx) {
return this._items[idx]; return this._items[idx];
} }
remove(idx: number): void { remove(idx) {
const item = this._items[idx]; const item = this._items[idx];
this._items.splice(idx, 1); this._items.splice(idx, 1);
this.emitRemove(idx, item); this.emitRemove(idx, item);
} }
get array(): T[] { get array() {
return this._items; return this._items;
} }
get length(): number { get length() {
return this._items.length; return this._items.length;
} }
@ -118,11 +116,8 @@ export class SortedArray<T> extends BaseObservableList<T> {
} }
// iterator that works even if the current value is removed while iterating // iterator that works even if the current value is removed while iterating
class Iterator<T> { class Iterator {
private _sortedArray: SortedArray<T> | null constructor(sortedArray) {
private _current: T | null | undefined
constructor(sortedArray: SortedArray<T>) {
this._sortedArray = sortedArray; this._sortedArray = sortedArray;
this._current = null; this._current = null;
} }
@ -150,7 +145,7 @@ class Iterator<T> {
export function tests() { export function tests() {
return { return {
"setManyUnsorted": assert => { "setManyUnsorted": assert => {
const sa = new SortedArray<string>((a, b) => a.localeCompare(b)); const sa = new SortedArray((a, b) => a.localeCompare(b));
sa.setManyUnsorted(["b", "a", "c"]); sa.setManyUnsorted(["b", "a", "c"]);
assert.equal(sa.length, 3); assert.equal(sa.length, 3);
assert.equal(sa.get(0), "a"); assert.equal(sa.get(0), "a");
@ -158,7 +153,7 @@ export function tests() {
assert.equal(sa.get(2), "c"); assert.equal(sa.get(2), "c");
}, },
"_getNext": assert => { "_getNext": assert => {
const sa = new SortedArray<string>((a, b) => a.localeCompare(b)); const sa = new SortedArray((a, b) => a.localeCompare(b));
sa.setManyUnsorted(["b", "a", "f"]); sa.setManyUnsorted(["b", "a", "f"]);
assert.equal(sa._getNext("a"), "b"); assert.equal(sa._getNext("a"), "b");
assert.equal(sa._getNext("b"), "f"); assert.equal(sa._getNext("b"), "f");
@ -167,7 +162,7 @@ export function tests() {
assert.equal(sa._getNext("f"), undefined); assert.equal(sa._getNext("f"), undefined);
}, },
"iterator with removals": assert => { "iterator with removals": assert => {
const queue = new SortedArray<{idx: number}>((a, b) => a.idx - b.idx); const queue = new SortedArray((a, b) => a.idx - b.idx);
queue.setManyUnsorted([{idx: 5}, {idx: 3}, {idx: 1}, {idx: 4}, {idx: 2}]); queue.setManyUnsorted([{idx: 5}, {idx: 3}, {idx: 1}, {idx: 4}, {idx: 2}]);
const it = queue[Symbol.iterator](); const it = queue[Symbol.iterator]();
assert.equal(it.next().value.idx, 1); assert.equal(it.next().value.idx, 1);

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import {BaseObservableList} from "./BaseObservableList"; import {BaseObservableList} from "./BaseObservableList";
import {sortedIndex} from "../../utils/sortedIndex"; import {sortedIndex} from "../../utils/sortedIndex.js";
/* /*
@ -133,7 +133,7 @@ export class SortedMapList extends BaseObservableList {
} }
} }
import {ObservableMap} from "../map/ObservableMap"; import {ObservableMap} from "../map/ObservableMap.js";
export function tests() { export function tests() {
return { return {

View file

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

View file

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

View file

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

View file

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

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {BaseObservableMap} from "./BaseObservableMap"; import {BaseObservableMap} from "./BaseObservableMap.js";
/* /*
so a mapped value can emit updates on it's own with this._emitSpontaneousUpdate that is passed in the mapping function so a mapped value can emit updates on it's own with this._emitSpontaneousUpdate that is passed in the mapping function
how should the mapped value be notified of an update though? and can it then decide to not propagate the update? how should the mapped value be notified of an update though? and can it then decide to not propagate the update?

View file

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

View file

@ -14,24 +14,27 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import type {TimelineEvent} from "../../../storage/types"; interface IAbortable {
abort();
}
export class ReplayDetectionEntry { type RunFn<T> = (setAbortable: (a: IAbortable) => typeof a) => T;
public readonly sessionId: string;
public readonly messageIndex: number;
public readonly event: TimelineEvent;
constructor(sessionId: string, messageIndex: number, event: TimelineEvent) { export class AbortableOperation<T> {
this.sessionId = sessionId; public readonly result: T;
this.messageIndex = messageIndex; private _abortable: IAbortable | null;
this.event = event;
constructor(run: RunFn<T>) {
this._abortable = null;
const setAbortable = abortable => {
this._abortable = abortable;
return abortable;
};
this.result = run(setAbortable);
} }
get eventId(): string { abort() {
return this.event.event_id; this._abortable?.abort();
} this._abortable = null;
get timestamp(): number {
return this.event.origin_server_ts;
} }
} }

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud> Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,13 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export interface IDisposable { function disposeValue(value) {
dispose(): void;
}
export type Disposable = IDisposable | (() => void);
function disposeValue(value: Disposable): void {
if (typeof value === "function") { if (typeof value === "function") {
value(); value();
} else { } else {
@ -29,14 +22,16 @@ function disposeValue(value: Disposable): void {
} }
} }
function isDisposable(value: Disposable): boolean { function isDisposable(value) {
return value && (typeof value === "function" || typeof value.dispose === "function"); return value && (typeof value === "function" || typeof value.dispose === "function");
} }
export class Disposables { export class Disposables {
private _disposables?: Disposable[] = []; constructor() {
this._disposables = [];
}
track<D extends Disposable>(disposable: D): D { track(disposable) {
if (!isDisposable(disposable)) { if (!isDisposable(disposable)) {
throw new Error("Not a disposable"); throw new Error("Not a disposable");
} }
@ -45,46 +40,42 @@ export class Disposables {
disposeValue(disposable); disposeValue(disposable);
return disposable; return disposable;
} }
this._disposables!.push(disposable); this._disposables.push(disposable);
return disposable; return disposable;
} }
untrack(disposable: Disposable): undefined { untrack(disposable) {
if (this.isDisposed) { const idx = this._disposables.indexOf(disposable);
console.warn("Disposables already disposed, cannot untrack");
return undefined;
}
const idx = this._disposables!.indexOf(disposable);
if (idx >= 0) { if (idx >= 0) {
this._disposables!.splice(idx, 1); this._disposables.splice(idx, 1);
} }
return undefined; return null;
} }
dispose(): void { dispose() {
if (this._disposables) { if (this._disposables) {
for (const d of this._disposables) { for (const d of this._disposables) {
disposeValue(d); disposeValue(d);
} }
this._disposables = undefined; this._disposables = null;
} }
} }
get isDisposed(): boolean { get isDisposed() {
return this._disposables === undefined; return this._disposables === null;
} }
disposeTracked(value: Disposable | undefined): undefined { disposeTracked(value) {
if (value === undefined || value === null || this.isDisposed) { if (value === undefined || value === null || this.isDisposed) {
return undefined; return null;
} }
const idx = this._disposables!.indexOf(value); const idx = this._disposables.indexOf(value);
if (idx !== -1) { if (idx !== -1) {
const [foundValue] = this._disposables!.splice(idx, 1); const [foundValue] = this._disposables.splice(idx, 1);
disposeValue(foundValue); disposeValue(foundValue);
} else { } else {
console.warn("disposable not found, did it leak?", value); console.warn("disposable not found, did it leak?", value);
} }
return undefined; return null;
} }
} }

View file

@ -14,29 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
type FindCallback<T> = (value: T) => boolean;
/** /**
* Very simple least-recently-used cache implementation * Very simple least-recently-used cache implementation
* that should be fast enough for very small cache sizes * that should be fast enough for very small cache sizes
*/ */
export class BaseLRUCache<T> { export class BaseLRUCache {
constructor(limit) {
public readonly limit: number; this._limit = limit;
protected _entries: T[];
constructor(limit: number) {
this.limit = limit;
this._entries = []; this._entries = [];
} }
get size() { return this._entries.length; } _get(findEntryFn) {
const idx = this._entries.findIndex(findEntryFn);
protected _get(findEntryFn: FindCallback<T>) {
return this._getByIndexAndMoveUp(this._entries.findIndex(findEntryFn));
}
protected _getByIndexAndMoveUp(idx: number) {
if (idx !== -1) { if (idx !== -1) {
const entry = this._entries[idx]; const entry = this._entries[idx];
// move to top // move to top
@ -48,11 +37,11 @@ export class BaseLRUCache<T> {
} }
} }
protected _set(value: T, findEntryFn?: FindCallback<T>) { _set(value, findEntryFn) {
let indexToRemove = findEntryFn ? this._entries.findIndex(findEntryFn) : -1; let indexToRemove = this._entries.findIndex(findEntryFn);
this._entries.unshift(value); this._entries.unshift(value);
if (indexToRemove === -1) { if (indexToRemove === -1) {
if (this._entries.length > this.limit) { if (this._entries.length > this._limit) {
indexToRemove = this._entries.length - 1; indexToRemove = this._entries.length - 1;
} }
} else { } else {
@ -60,82 +49,75 @@ export class BaseLRUCache<T> {
indexToRemove += 1; indexToRemove += 1;
} }
if (indexToRemove !== -1) { if (indexToRemove !== -1) {
this.onEvictEntry(this._entries[indexToRemove]); this._onEvictEntry(this._entries[indexToRemove]);
this._entries.splice(indexToRemove, 1); this._entries.splice(indexToRemove, 1);
} }
} }
protected onEvictEntry(entry: T) {} _onEvictEntry() {}
} }
export class LRUCache<T, K> extends BaseLRUCache<T> { export class LRUCache extends BaseLRUCache {
private _keyFn: (T) => K; constructor(limit, keyFn) {
constructor(limit, keyFn: (T) => K) {
super(limit); super(limit);
this._keyFn = keyFn; this._keyFn = keyFn;
} }
get(key: K): T | undefined { get(key) {
return this._get(e => this._keyFn(e) === key); return this._get(e => this._keyFn(e) === key);
} }
set(value: T) { set(value) {
const key = this._keyFn(value); const key = this._keyFn(value);
this._set(value, e => this._keyFn(e) === key); this._set(value, e => this._keyFn(e) === key);
} }
} }
export function tests() { export function tests() {
interface NameTuple {
id: number;
name: string;
}
return { return {
"can retrieve added entries": assert => { "can retrieve added entries": assert => {
const cache = new LRUCache<NameTuple, number>(2, e => e.id); const cache = new LRUCache(2, e => e.id);
cache.set({id: 1, name: "Alice"}); cache.set({id: 1, name: "Alice"});
cache.set({id: 2, name: "Bob"}); cache.set({id: 2, name: "Bob"});
assert.equal(cache.get(1)!.name, "Alice"); assert.equal(cache.get(1).name, "Alice");
assert.equal(cache.get(2)!.name, "Bob"); assert.equal(cache.get(2).name, "Bob");
}, },
"first entry is evicted first": assert => { "first entry is evicted first": assert => {
const cache = new LRUCache<NameTuple, number>(2, e => e.id); const cache = new LRUCache(2, e => e.id);
cache.set({id: 1, name: "Alice"}); cache.set({id: 1, name: "Alice"});
cache.set({id: 2, name: "Bob"}); cache.set({id: 2, name: "Bob"});
cache.set({id: 3, name: "Charly"}); cache.set({id: 3, name: "Charly"});
assert.equal(cache.get(1), undefined); assert.equal(cache.get(1), undefined);
assert.equal(cache.get(2)!.name, "Bob"); assert.equal(cache.get(2).name, "Bob");
assert.equal(cache.get(3)!.name, "Charly"); assert.equal(cache.get(3).name, "Charly");
assert.equal(cache.size, 2); assert.equal(cache._entries.length, 2);
}, },
"second entry is evicted if first is requested": assert => { "second entry is evicted if first is requested": assert => {
const cache = new LRUCache<NameTuple, number>(2, e => e.id); const cache = new LRUCache(2, e => e.id);
cache.set({id: 1, name: "Alice"}); cache.set({id: 1, name: "Alice"});
cache.set({id: 2, name: "Bob"}); cache.set({id: 2, name: "Bob"});
cache.get(1); cache.get(1);
cache.set({id: 3, name: "Charly"}); cache.set({id: 3, name: "Charly"});
assert.equal(cache.get(1)!.name, "Alice"); assert.equal(cache.get(1).name, "Alice");
assert.equal(cache.get(2), undefined); assert.equal(cache.get(2), undefined);
assert.equal(cache.get(3)!.name, "Charly"); assert.equal(cache.get(3).name, "Charly");
assert.equal(cache.size, 2); assert.equal(cache._entries.length, 2);
}, },
"setting an entry twice removes the first": assert => { "setting an entry twice removes the first": assert => {
const cache = new LRUCache<NameTuple, number>(2, e => e.id); const cache = new LRUCache(2, e => e.id);
cache.set({id: 1, name: "Alice"}); cache.set({id: 1, name: "Alice"});
cache.set({id: 2, name: "Bob"}); cache.set({id: 2, name: "Bob"});
cache.set({id: 1, name: "Al Ice"}); cache.set({id: 1, name: "Al Ice"});
cache.set({id: 3, name: "Charly"}); cache.set({id: 3, name: "Charly"});
assert.equal(cache.get(1)!.name, "Al Ice"); assert.equal(cache.get(1).name, "Al Ice");
assert.equal(cache.get(2), undefined); assert.equal(cache.get(2), undefined);
assert.equal(cache.get(3)!.name, "Charly"); assert.equal(cache.get(3).name, "Charly");
assert.equal(cache.size, 2); assert.equal(cache._entries.length, 2);
}, },
"evict callback is called": assert => { "evict callback is called": assert => {
let evictions = 0; let evictions = 0;
class CustomCache extends LRUCache<NameTuple, number> { class CustomCache extends LRUCache {
onEvictEntry(entry) { _onEvictEntry(entry) {
assert.equal(entry.name, "Alice"); assert.equal(entry.name, "Alice");
evictions += 1; evictions += 1;
} }
@ -148,8 +130,8 @@ export function tests() {
}, },
"evict callback is called when replacing entry with same identity": assert => { "evict callback is called when replacing entry with same identity": assert => {
let evictions = 0; let evictions = 0;
class CustomCache extends LRUCache<NameTuple, number> { class CustomCache extends LRUCache {
onEvictEntry(entry) { _onEvictEntry(entry) {
assert.equal(entry.name, "Alice"); assert.equal(entry.name, "Alice");
evictions += 1; evictions += 1;
} }

View file

@ -14,15 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export interface ILock { export class Lock {
release(): void; constructor() {
} this._promise = null;
this._resolve = null;
}
export class Lock implements ILock { tryTake() {
private _promise?: Promise<void>;
private _resolve?: (() => void);
tryTake(): boolean {
if (!this._promise) { if (!this._promise) {
this._promise = new Promise(resolve => { this._promise = new Promise(resolve => {
this._resolve = resolve; this._resolve = resolve;
@ -32,36 +30,36 @@ export class Lock implements ILock {
return false; return false;
} }
async take(): Promise<void> { async take() {
while(!this.tryTake()) { while(!this.tryTake()) {
await this.released(); await this.released();
} }
} }
get isTaken(): boolean { get isTaken() {
return !!this._promise; return !!this._promise;
} }
release(): void { release() {
if (this._resolve) { if (this._resolve) {
this._promise = undefined; this._promise = null;
const resolve = this._resolve; const resolve = this._resolve;
this._resolve = undefined; this._resolve = null;
resolve(); resolve();
} }
} }
released(): Promise<void> | undefined { released() {
return this._promise; return this._promise;
} }
} }
export class MultiLock implements ILock { export class MultiLock {
constructor(locks) {
constructor(public readonly locks: Lock[]) { this.locks = locks;
} }
release(): void { release() {
for (const lock of this.locks) { for (const lock of this.locks) {
lock.release(); lock.release();
} }
@ -88,9 +86,9 @@ export function tests() {
lock.tryTake(); lock.tryTake();
let first; let first;
lock.released()!.then(() => first = lock.tryTake()); lock.released().then(() => first = lock.tryTake());
let second; let second;
lock.released()!.then(() => second = lock.tryTake()); lock.released().then(() => second = lock.tryTake());
const promise = lock.released(); const promise = lock.released();
lock.release(); lock.release();
await promise; await promise;

View file

@ -14,12 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {Lock} from "./Lock"; import {Lock} from "./Lock.js";
export class LockMap<T> { export class LockMap {
private readonly _map: Map<T, Lock> = new Map(); constructor() {
this._map = new Map();
}
async takeLock(key: T): Promise<Lock> { async takeLock(key) {
let lock = this._map.get(key); let lock = this._map.get(key);
if (lock) { if (lock) {
await lock.take(); await lock.take();
@ -29,10 +31,10 @@ export class LockMap<T> {
this._map.set(key, lock); this._map.set(key, lock);
} }
// don't leave old locks lying around // don't leave old locks lying around
lock.released()!.then(() => { lock.released().then(() => {
// give others a chance to take the lock first // give others a chance to take the lock first
Promise.resolve().then(() => { Promise.resolve().then(() => {
if (!lock!.isTaken) { if (!lock.isTaken) {
this._map.delete(key); this._map.delete(key);
} }
}); });
@ -65,7 +67,6 @@ export function tests() {
ranSecond = true; ranSecond = true;
assert.equal(returnedLock.isTaken, true); assert.equal(returnedLock.isTaken, true);
// peek into internals, naughty // peek into internals, naughty
// @ts-ignore
assert.equal(lockMap._map.get("foo"), returnedLock); assert.equal(lockMap._map.get("foo"), returnedLock);
}); });
lock.release(); lock.release();
@ -83,7 +84,6 @@ export function tests() {
// double delay to make sure cleanup logic ran // double delay to make sure cleanup logic ran
await Promise.resolve(); await Promise.resolve();
await Promise.resolve(); await Promise.resolve();
// @ts-ignore
assert.equal(lockMap._map.has("foo"), false); assert.equal(lockMap._map.has("foo"), false);
}, },

View file

@ -15,18 +15,16 @@ limitations under the License.
*/ */
export class RetainedValue { export class RetainedValue {
private readonly _freeCallback: () => void; constructor(freeCallback) {
private _retentionCount: number = 1;
constructor(freeCallback: () => void) {
this._freeCallback = freeCallback; this._freeCallback = freeCallback;
this._retentionCount = 1;
} }
retain(): void { retain() {
this._retentionCount += 1; this._retentionCount += 1;
} }
release(): void { release() {
this._retentionCount -= 1; this._retentionCount -= 1;
if (this._retentionCount === 0) { if (this._retentionCount === 0) {
this._freeCallback(); this._freeCallback();

View file

@ -6,10 +6,8 @@
* Based on https://github.com/junkurihara/jscu/blob/develop/packages/js-crypto-hkdf/src/hkdf.ts * Based on https://github.com/junkurihara/jscu/blob/develop/packages/js-crypto-hkdf/src/hkdf.ts
*/ */
import type {Crypto} from "../../platform/web/dom/Crypto.js";
// forked this code to make it use the cryptoDriver for HMAC that is more backwards-compatible // forked this code to make it use the cryptoDriver for HMAC that is more backwards-compatible
export async function hkdf(cryptoDriver: Crypto, key: Uint8Array, salt: Uint8Array, info: Uint8Array, hash: "SHA-256" | "SHA-512", length: number): Promise<Uint8Array> { export async function hkdf(cryptoDriver, key, salt, info, hash, length) {
length = length / 8; length = length / 8;
const len = cryptoDriver.digestSize(hash); const len = cryptoDriver.digestSize(hash);

View file

@ -6,19 +6,17 @@
* Based on https://github.com/junkurihara/jscu/blob/develop/packages/js-crypto-pbkdf/src/pbkdf.ts * Based on https://github.com/junkurihara/jscu/blob/develop/packages/js-crypto-pbkdf/src/pbkdf.ts
*/ */
import type {Crypto} from "../../platform/web/dom/Crypto.js";
// not used atm, but might in the future // not used atm, but might in the future
// forked this code to make it use the cryptoDriver for HMAC that is more backwards-compatible // forked this code to make it use the cryptoDriver for HMAC that is more backwards-compatible
const nwbo = (num: number, len: number): Uint8Array => { const nwbo = (num, len) => {
const arr = new Uint8Array(len); const arr = new Uint8Array(len);
for(let i=0; i<len; i++) arr[i] = 0xFF && (num >> ((len - i - 1)*8)); for(let i=0; i<len; i++) arr[i] = 0xFF && (num >> ((len - i - 1)*8));
return arr; return arr;
}; };
export async function pbkdf2(cryptoDriver: Crypto, password: Uint8Array, iterations: number, salt: Uint8Array, hash: "SHA-256" | "SHA-512", length: number): Promise<Uint8Array> { export async function pbkdf2(cryptoDriver, password, iterations, salt, hash, length) {
const dkLen = length / 8; const dkLen = length / 8;
if (iterations <= 0) { if (iterations <= 0) {
throw new Error('InvalidIterationCount'); throw new Error('InvalidIterationCount');
@ -32,7 +30,7 @@ export async function pbkdf2(cryptoDriver: Crypto, password: Uint8Array, iterati
const l = Math.ceil(dkLen/hLen); const l = Math.ceil(dkLen/hLen);
const r = dkLen - (l-1)*hLen; const r = dkLen - (l-1)*hLen;
const funcF = async (i: number) => { const funcF = async (i) => {
const seed = new Uint8Array(salt.length + 4); const seed = new Uint8Array(salt.length + 4);
seed.set(salt); seed.set(salt);
seed.set(nwbo(i+1, 4), salt.length); seed.set(nwbo(i+1, 4), salt.length);
@ -48,7 +46,7 @@ export async function pbkdf2(cryptoDriver: Crypto, password: Uint8Array, iterati
return {index: i, value: outputF}; return {index: i, value: outputF};
}; };
const Tis: Promise<{index: number, value: Uint8Array}>[] = []; const Tis = [];
const DK = new Uint8Array(dkLen); const DK = new Uint8Array(dkLen);
for(let i = 0; i < l; i++) { for(let i = 0; i < l; i++) {
Tis.push(funcF(i)); Tis.push(funcF(i));

View file

@ -14,9 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export function createEnum(...values: string[]): Readonly<{}> { export function createEnum(...values) {
const obj = {}; const obj = {};
for (const value of values) { for (const value of values) {
if (typeof value !== "string") {
throw new Error("Invalid enum value name" + value?.toString());
}
obj[value] = value; obj[value] = value;
} }
return Object.freeze(obj); return Object.freeze(obj);

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
export class AbortError extends Error { export class AbortError extends Error {
get name(): string { get name() {
return "AbortError"; return "AbortError";
} }
} }

View file

@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export function formatSize(size, decimals = 2) {
export function formatSize(size: number, decimals: number = 2): string {
if (Number.isSafeInteger(size)) { if (Number.isSafeInteger(size)) {
const base = Math.min(3, Math.floor(Math.log(size) / Math.log(1024))); const base = Math.min(3, Math.floor(Math.log(size) / Math.log(1024)));
const formattedSize = Math.round(size / Math.pow(1024, base)).toFixed(decimals); const formattedSize = Math.round(size / Math.pow(1024, base)).toFixed(decimals);
@ -26,5 +25,4 @@ export function formatSize(size: number, decimals: number = 2): string {
case 3: return `${formattedSize} GB`; case 3: return `${formattedSize} GB`;
} }
} }
return "";
} }

View file

@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export function groupBy<K, V>(array: V[], groupFn: (V) => K): Map<K, V[]> { export function groupBy(array, groupFn) {
return groupByWithCreator<K, V, V[]>(array, groupFn, return groupByWithCreator(array, groupFn,
() => {return [];}, () => {return [];},
(array, value) => array.push(value) (array, value) => array.push(value)
); );
} }
export function groupByWithCreator<K, V, C>(array: V[], groupFn: (V) => K, createCollectionFn: () => C, addCollectionFn: (C, V) => void): Map<K, C> { export function groupByWithCreator(array, groupFn, createCollectionFn, addCollectionFn) {
return array.reduce((map, value) => { return array.reduce((map, value) => {
const key = groupFn(value); const key = groupFn(value);
let collection = map.get(key); let collection = map.get(key);
@ -31,10 +31,10 @@ export function groupByWithCreator<K, V, C>(array: V[], groupFn: (V) => K, creat
} }
addCollectionFn(collection, value); addCollectionFn(collection, value);
return map; return map;
}, new Map<K, C>()); }, new Map());
} }
export function countBy<V>(events: V[], mapper: (V) => string | number): { [key: string]: number } { export function countBy(events, mapper) {
return events.reduce((counts, event) => { return events.reduce((counts, event) => {
const mappedValue = mapper(event); const mappedValue = mapper(event);
if (!counts[mappedValue]) { if (!counts[mappedValue]) {

View file

@ -0,0 +1,31 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export {AbortableOperation} from "./AbortableOperation";
export {Disposables} from "./Disposables.js";
export {createEnum} from "./enum.js";
export {AbortError} from "./error.js";
export {EventEmitter} from "./EventEmitter.js";
export {formatSize} from "./formatSize.js";
export {groupBy} from "./groupBy.js";
export {Lock} from "./Lock.js";
export {LockMap} from "./LockMap.js";
export {LRUCache} from "./LRUCache.js";
export {mergeMap} from "./mergeMap.js";
export {RetainedValue} from "./RetainedValue.js";
export {sortedIndex} from "./sortedIndex.js";
export {abortOnTimeout} from "./timeout.js";
export {typedJSON} from "./typedJSON.js";

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export function mergeMap<K, V>(src: Map<K, V> | undefined, dst: Map<K, V>): void { export function mergeMap(src, dst) {
if (src) { if (src) {
for (const [key, value] of src.entries()) { for (const [key, value] of src.entries()) {
dst.set(key, value); dst.set(key, value);

View file

@ -22,7 +22,7 @@ limitations under the License.
* Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE> * Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE>
* Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
*/ */
export function sortedIndex<T>(array: T[], value: T, comparator: (x:T, y:T) => number): number { export function sortedIndex(array, value, comparator) {
let low = 0; let low = 0;
let high = array.length; let high = array.length;

View file

@ -16,12 +16,9 @@ limitations under the License.
*/ */
import {ConnectionError} from "../matrix/error.js"; import {ConnectionError} from "../matrix/error.js";
import type {Timeout} from "../platform/web/dom/Clock.js"
import type {IAbortable} from "./AbortableOperation";
type TimeoutCreator = (ms: number) => Timeout;
export function abortOnTimeout(createTimeout: TimeoutCreator, timeoutAmount: number, requestResult: IAbortable, responsePromise: Promise<Response>) { export function abortOnTimeout(createTimeout, timeoutAmount, requestResult, responsePromise) {
const timeout = createTimeout(timeoutAmount); const timeout = createTimeout(timeoutAmount);
// abort request if timeout finishes first // abort request if timeout finishes first
let timedOut = false; let timedOut = false;

View file

@ -14,13 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export function stringify(value: any): string { export const typedJSON = {
return JSON.stringify(encodeValue(value)); stringify(value: any): string {
} return JSON.stringify(encodeValue(value));
},
parse(value: string): any {
return decodeValue(JSON.parse(value));
}
};
export function parse(value: string): any {
return decodeValue(JSON.parse(value));
}
function encodeValue(value: any): any { function encodeValue(value: any): any {
if (typeof value === "object" && value !== null && !Array.isArray(value)) { if (typeof value === "object" && value !== null && !Array.isArray(value)) {

View file

@ -0,0 +1,13 @@
export default {
build: {
lib: {
entry: "src/lib.ts",
formats: ["es", "iife"],
name: "hydrogenCommon",
}
},
public: false,
server: {
hmr: false
}
};

View file

@ -0,0 +1,14 @@
{
"name": "hydrogen-domain",
"version": "0.0.1",
"main": "src/lib.ts",
"homepage": "https://github.com/vector-im/hydrogen-web/#readme",
"devDependencies": {
"vite": "^2.6.2",
"typescript": "^4.3.5"
},
"dependencies": {
"hydrogen-common": "0.0.1",
"hydrogen-matrix": "0.0.1"
}
}

View file

@ -14,24 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {Client} from "../matrix/Client.js";
import {SessionViewModel} from "./session/SessionViewModel.js"; import {SessionViewModel} from "./session/SessionViewModel.js";
import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; import {SessionLoadViewModel} from "./SessionLoadViewModel.js";
import {LoginViewModel} from "./login/LoginViewModel"; import {LoginViewModel} from "./login/LoginViewModel.js";
import {LogoutViewModel} from "./LogoutViewModel";
import {SessionPickerViewModel} from "./SessionPickerViewModel.js"; import {SessionPickerViewModel} from "./SessionPickerViewModel.js";
import {ViewModel} from "./ViewModel"; import {ViewModel} from "./ViewModel.js";
export class RootViewModel extends ViewModel { export class RootViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
this._createSessionContainer = options.createSessionContainer;
this._error = null; this._error = null;
this._sessionPickerViewModel = null; this._sessionPickerViewModel = null;
this._sessionLoadViewModel = null; this._sessionLoadViewModel = null;
this._loginViewModel = null; this._loginViewModel = null;
this._logoutViewModel = null;
this._sessionViewModel = null; this._sessionViewModel = null;
this._pendingClient = null; this._pendingSessionContainer = null;
} }
async load() { async load() {
@ -42,34 +40,29 @@ export class RootViewModel extends ViewModel {
} }
async _applyNavigation(shouldRestoreLastUrl) { async _applyNavigation(shouldRestoreLastUrl) {
const isLogin = this.navigation.path.get("login"); const isLogin = this.navigation.path.get("login")
const logoutSessionId = this.navigation.path.get("logout")?.value;
const sessionId = this.navigation.path.get("session")?.value; const sessionId = this.navigation.path.get("session")?.value;
const loginToken = this.navigation.path.get("sso")?.value; const loginToken = this.navigation.path.get("sso")?.value;
if (isLogin) { if (isLogin) {
if (this.activeSection !== "login") { if (this.activeSection !== "login") {
this._showLogin(); this._showLogin();
} }
} else if (logoutSessionId) {
if (this.activeSection !== "logout") {
this._showLogout(logoutSessionId);
}
} else if (sessionId === true) { } else if (sessionId === true) {
if (this.activeSection !== "picker") { if (this.activeSection !== "picker") {
this._showPicker(); this._showPicker();
} }
} else if (sessionId) { } else if (sessionId) {
if (!this._sessionViewModel || this._sessionViewModel.id !== sessionId) { if (!this._sessionViewModel || this._sessionViewModel.id !== sessionId) {
// see _showLogin for where _pendingClient comes from // see _showLogin for where _pendingSessionContainer comes from
if (this._pendingClient && this._pendingClient.sessionId === sessionId) { if (this._pendingSessionContainer && this._pendingSessionContainer.sessionId === sessionId) {
const client = this._pendingClient; const sessionContainer = this._pendingSessionContainer;
this._pendingClient = null; this._pendingSessionContainer = null;
this._showSession(client); this._showSession(sessionContainer);
} else { } else {
// this should never happen, but we want to be sure not to leak it // this should never happen, but we want to be sure not to leak it
if (this._pendingClient) { if (this._pendingSessionContainer) {
this._pendingClient.dispose(); this._pendingSessionContainer.dispose();
this._pendingClient = null; this._pendingSessionContainer = null;
} }
this._showSessionLoader(sessionId); this._showSessionLoader(sessionId);
} }
@ -113,7 +106,8 @@ export class RootViewModel extends ViewModel {
this._setSection(() => { this._setSection(() => {
this._loginViewModel = new LoginViewModel(this.childOptions({ this._loginViewModel = new LoginViewModel(this.childOptions({
defaultHomeserver: this.platform.config["defaultHomeServer"], defaultHomeserver: this.platform.config["defaultHomeServer"],
ready: client => { createSessionContainer: this._createSessionContainer,
ready: sessionContainer => {
// we don't want to load the session container again, // we don't want to load the session container again,
// 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
@ -122,34 +116,28 @@ export class RootViewModel extends ViewModel {
// 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.
this._pendingClient = client; this._pendingSessionContainer = sessionContainer;
this.navigation.push("session", client.sessionId); this.navigation.push("session", sessionContainer.sessionId);
}, },
loginToken loginToken
})); }));
}); });
} }
_showLogout(sessionId) { _showSession(sessionContainer) {
this._setSection(() => { this._setSection(() => {
this._logoutViewModel = new LogoutViewModel(this.childOptions({sessionId})); this._sessionViewModel = new SessionViewModel(this.childOptions({sessionContainer}));
});
}
_showSession(client) {
this._setSection(() => {
this._sessionViewModel = new SessionViewModel(this.childOptions({client}));
this._sessionViewModel.start(); this._sessionViewModel.start();
}); });
} }
_showSessionLoader(sessionId) { _showSessionLoader(sessionId) {
const client = new Client(this.platform); const sessionContainer = this._createSessionContainer();
client.startWithExistingSession(sessionId); sessionContainer.startWithExistingSession(sessionId);
this._setSection(() => { this._setSection(() => {
this._sessionLoadViewModel = new SessionLoadViewModel(this.childOptions({ this._sessionLoadViewModel = new SessionLoadViewModel(this.childOptions({
client, sessionContainer,
ready: client => this._showSession(client) ready: sessionContainer => this._showSession(sessionContainer)
})); }));
this._sessionLoadViewModel.start(); this._sessionLoadViewModel.start();
}); });
@ -162,8 +150,6 @@ export class RootViewModel extends ViewModel {
return "session"; return "session";
} else if (this._loginViewModel) { } else if (this._loginViewModel) {
return "login"; return "login";
} else if (this._logoutViewModel) {
return "logout";
} else if (this._sessionPickerViewModel) { } else if (this._sessionPickerViewModel) {
return "picker"; return "picker";
} else if (this._sessionLoadViewModel) { } else if (this._sessionLoadViewModel) {
@ -179,14 +165,12 @@ export class RootViewModel extends ViewModel {
this._sessionPickerViewModel = this.disposeTracked(this._sessionPickerViewModel); this._sessionPickerViewModel = this.disposeTracked(this._sessionPickerViewModel);
this._sessionLoadViewModel = this.disposeTracked(this._sessionLoadViewModel); this._sessionLoadViewModel = this.disposeTracked(this._sessionLoadViewModel);
this._loginViewModel = this.disposeTracked(this._loginViewModel); this._loginViewModel = this.disposeTracked(this._loginViewModel);
this._logoutViewModel = this.disposeTracked(this._logoutViewModel);
this._sessionViewModel = this.disposeTracked(this._sessionViewModel); this._sessionViewModel = this.disposeTracked(this._sessionViewModel);
// now set it again // now set it again
setter(); setter();
this._sessionPickerViewModel && this.track(this._sessionPickerViewModel); this._sessionPickerViewModel && this.track(this._sessionPickerViewModel);
this._sessionLoadViewModel && this.track(this._sessionLoadViewModel); this._sessionLoadViewModel && this.track(this._sessionLoadViewModel);
this._loginViewModel && this.track(this._loginViewModel); this._loginViewModel && this.track(this._loginViewModel);
this._logoutViewModel && this.track(this._logoutViewModel);
this._sessionViewModel && this.track(this._sessionViewModel); this._sessionViewModel && this.track(this._sessionViewModel);
this.emitChange("activeSection"); this.emitChange("activeSection");
} }
@ -194,7 +178,6 @@ export class RootViewModel extends ViewModel {
get error() { return this._error; } get error() { return this._error; }
get sessionViewModel() { return this._sessionViewModel; } get sessionViewModel() { return this._sessionViewModel; }
get loginViewModel() { return this._loginViewModel; } get loginViewModel() { return this._loginViewModel; }
get logoutViewModel() { return this._logoutViewModel; }
get sessionPickerViewModel() { return this._sessionPickerViewModel; } get sessionPickerViewModel() { return this._sessionPickerViewModel; }
get sessionLoadViewModel() { return this._sessionLoadViewModel; } get sessionLoadViewModel() { return this._sessionLoadViewModel; }
} }

View file

@ -14,24 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {AccountSetupViewModel} from "./AccountSetupViewModel.js"; import {LoadStatus} from "../matrix/SessionContainer.js";
import {LoadStatus} from "../matrix/Client.js";
import {SyncStatus} from "../matrix/Sync.js"; import {SyncStatus} from "../matrix/Sync.js";
import {ViewModel} from "./ViewModel"; import {ViewModel} from "./ViewModel.js";
export class SessionLoadViewModel extends ViewModel { export class SessionLoadViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
const {client, ready, homeserver, deleteSessionOnCancel} = options; const {sessionContainer, ready, homeserver, deleteSessionOnCancel} = options;
this._client = client; this._sessionContainer = sessionContainer;
this._ready = ready; this._ready = ready;
this._homeserver = homeserver; this._homeserver = homeserver;
this._deleteSessionOnCancel = deleteSessionOnCancel; this._deleteSessionOnCancel = deleteSessionOnCancel;
this._loading = false; this._loading = false;
this._error = null; this._error = null;
this.backUrl = this.urlCreator.urlForSegment("session", true); this.backUrl = this.urlCreator.urlForSegment("session", true);
this._accountSetupViewModel = undefined;
} }
async start() { async start() {
@ -41,16 +38,11 @@ export class SessionLoadViewModel extends ViewModel {
try { try {
this._loading = true; this._loading = true;
this.emitChange("loading"); this.emitChange("loading");
this._waitHandle = this._client.loadStatus.waitFor(s => { this._waitHandle = this._sessionContainer.loadStatus.waitFor(s => {
if (s === LoadStatus.AccountSetup) {
this._accountSetupViewModel = new AccountSetupViewModel(this.childOptions({accountSetup: this._client.accountSetup}));
} else {
this._accountSetupViewModel = undefined;
}
this.emitChange("loadLabel"); this.emitChange("loadLabel");
// wait for initial sync, but not catchup sync // wait for initial sync, but not catchup sync
const isCatchupSync = s === LoadStatus.FirstSync && const isCatchupSync = s === LoadStatus.FirstSync &&
this._client.sync.status.get() === SyncStatus.CatchupSync; this._sessionContainer.sync.status.get() === SyncStatus.CatchupSync;
return isCatchupSync || return isCatchupSync ||
s === LoadStatus.LoginFailed || s === LoadStatus.LoginFailed ||
s === LoadStatus.Error || s === LoadStatus.Error ||
@ -67,15 +59,15 @@ export class SessionLoadViewModel extends ViewModel {
// much like we will once you are in the app. Probably a good idea // much like we will once you are in the app. Probably a good idea
// did it finish or get stuck at LoginFailed or Error? // did it finish or get stuck at LoginFailed or Error?
const loadStatus = this._client.loadStatus.get(); const loadStatus = this._sessionContainer.loadStatus.get();
const loadError = this._client.loadError; const loadError = this._sessionContainer.loadError;
if (loadStatus === LoadStatus.FirstSync || loadStatus === LoadStatus.Ready) { if (loadStatus === LoadStatus.FirstSync || loadStatus === LoadStatus.Ready) {
const client = this._client; const sessionContainer = this._sessionContainer;
// session container is ready, // session container is ready,
// don't dispose it anymore when // don't dispose it anymore when
// we get disposed // we get disposed
this._client = null; this._sessionContainer = null;
this._ready(client); this._ready(sessionContainer);
} }
if (loadError) { if (loadError) {
console.error("session load error", loadError); console.error("session load error", loadError);
@ -85,16 +77,16 @@ export class SessionLoadViewModel extends ViewModel {
console.error("error thrown during session load", err.stack); console.error("error thrown during session load", err.stack);
} finally { } finally {
this._loading = false; this._loading = false;
// loadLabel in case of client.loadError also gets updated through this // loadLabel in case of sc.loadError also gets updated through this
this.emitChange("loading"); this.emitChange("loading");
} }
} }
dispose() { dispose() {
if (this._client) { if (this._sessionContainer) {
this._client.dispose(); this._sessionContainer.dispose();
this._client = null; this._sessionContainer = null;
} }
if (this._waitHandle) { if (this._waitHandle) {
// rejects with AbortError // rejects with AbortError
@ -105,27 +97,20 @@ export class SessionLoadViewModel extends ViewModel {
// to show a spinner or not // to show a spinner or not
get loading() { get loading() {
const client = this._client;
if (client && client.loadStatus.get() === LoadStatus.AccountSetup) {
return false;
}
return this._loading; return this._loading;
} }
get loadLabel() { get loadLabel() {
const client = this._client; const sc = this._sessionContainer;
const error = this._getError(); const error = this._error || (sc && sc.loadError);
if (error || (client && client.loadStatus.get() === LoadStatus.Error)) {
if (error || (sc && sc.loadStatus.get() === LoadStatus.Error)) {
return `Something went wrong: ${error && error.message}.`; return `Something went wrong: ${error && error.message}.`;
} }
// Statuses related to login are handled by respective login view models // Statuses related to login are handled by respective login view models
if (client) { if (sc) {
switch (client.loadStatus.get()) { switch (sc.loadStatus.get()) {
case LoadStatus.QueryAccount:
return `Querying account encryption setup…`;
case LoadStatus.AccountSetup:
return ""; // we'll show a header ing AccountSetupView
case LoadStatus.SessionSetup: case LoadStatus.SessionSetup:
return `Setting up your encryption keys…`; return `Setting up your encryption keys…`;
case LoadStatus.Loading: case LoadStatus.Loading:
@ -133,32 +118,10 @@ export class SessionLoadViewModel extends ViewModel {
case LoadStatus.FirstSync: case LoadStatus.FirstSync:
return `Getting your conversations from the server…`; return `Getting your conversations from the server…`;
default: default:
return this._client.loadStatus.get(); return this._sessionContainer.loadStatus.get();
} }
} }
return `Preparing…`; return `Preparing…`;
} }
_getError() {
return this._error || this._client?.loadError;
}
get hasError() {
return !!this._getError();
}
async exportLogs() {
const logExport = await this.logger.export();
this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`);
}
async logout() {
await this._client.logout();
this.navigation.push("session", true);
}
get accountSetupViewModel() {
return this._accountSetupViewModel;
}
} }

View file

@ -0,0 +1,195 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {SortedArray} from "../observable/index.js";
import {ViewModel} from "./ViewModel.js";
import {avatarInitials, getIdentifierColorNumber} from "./avatar.js";
class SessionItemViewModel extends ViewModel {
constructor(options, pickerVM) {
super(options);
this._pickerVM = pickerVM;
this._sessionInfo = options.sessionInfo;
this._isDeleting = false;
this._isClearing = false;
this._error = null;
this._exportDataUrl = null;
}
get error() {
return this._error && this._error.message;
}
async delete() {
this._isDeleting = true;
this.emitChange("isDeleting");
try {
await this._pickerVM.delete(this.id);
} catch(err) {
this._error = err;
console.error(err);
this.emitChange("error");
} finally {
this._isDeleting = false;
this.emitChange("isDeleting");
}
}
async clear() {
this._isClearing = true;
this.emitChange();
try {
await this._pickerVM.clear(this.id);
} catch(err) {
this._error = err;
console.error(err);
this.emitChange("error");
} finally {
this._isClearing = false;
this.emitChange("isClearing");
}
}
get isDeleting() {
return this._isDeleting;
}
get isClearing() {
return this._isClearing;
}
get id() {
return this._sessionInfo.id;
}
get openUrl() {
return this.urlCreator.urlForSegment("session", this.id);
}
get label() {
const {userId, comment} = this._sessionInfo;
if (comment) {
return `${userId} (${comment})`;
} else {
return userId;
}
}
get sessionInfo() {
return this._sessionInfo;
}
get exportDataUrl() {
return this._exportDataUrl;
}
async export() {
try {
const data = await this._pickerVM._exportData(this._sessionInfo.id);
const json = JSON.stringify(data, undefined, 2);
const blob = new Blob([json], {type: "application/json"});
this._exportDataUrl = URL.createObjectURL(blob);
this.emitChange("exportDataUrl");
} catch (err) {
alert(err.message);
console.error(err);
}
}
clearExport() {
if (this._exportDataUrl) {
URL.revokeObjectURL(this._exportDataUrl);
this._exportDataUrl = null;
this.emitChange("exportDataUrl");
}
}
get avatarColorNumber() {
return getIdentifierColorNumber(this._sessionInfo.userId);
}
get avatarInitials() {
return avatarInitials(this._sessionInfo.userId);
}
}
export class SessionPickerViewModel extends ViewModel {
constructor(options) {
super(options);
this._sessions = new SortedArray((s1, s2) => s1.id.localeCompare(s2.id));
this._loadViewModel = null;
this._error = null;
}
// this loads all the sessions
async load() {
const sessions = await this.platform.sessionInfoStorage.getAll();
this._sessions.setManyUnsorted(sessions.map(s => {
return new SessionItemViewModel(this.childOptions({sessionInfo: s}), this);
}));
}
// for the loading of 1 picked session
get loadViewModel() {
return this._loadViewModel;
}
async _exportData(id) {
const sessionInfo = await this.platform.sessionInfoStorage.get(id);
const stores = await this.logger.run("export", log => {
return this.platform.storageFactory.export(id, log);
});
const data = {sessionInfo, stores};
return data;
}
async import(json) {
try {
const data = JSON.parse(json);
const {sessionInfo} = data;
sessionInfo.comment = `Imported on ${new Date().toLocaleString()} from id ${sessionInfo.id}.`;
sessionInfo.id = this._createSessionContainer().createNewSessionId();
await this.logger.run("import", log => {
return this.platform.storageFactory.import(sessionInfo.id, data.stores, log);
});
await this.platform.sessionInfoStorage.add(sessionInfo);
this._sessions.set(new SessionItemViewModel(sessionInfo, this));
} catch (err) {
alert(err.message);
console.error(err);
}
}
async delete(id) {
const idx = this._sessions.array.findIndex(s => s.id === id);
await this.platform.sessionInfoStorage.delete(id);
await this.platform.storageFactory.delete(id);
this._sessions.remove(idx);
}
async clear(id) {
await this.platform.storageFactory.delete(id);
}
get sessions() {
return this._sessions;
}
get cancelUrl() {
return this.urlCreator.urlForSegment("login");
}
}

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud> Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -19,85 +18,56 @@ limitations under the License.
// as in some cases it would really be more convenient to have multiple events (like telling the timeline to scroll down) // as in some cases it would really be more convenient to have multiple events (like telling the timeline to scroll down)
// we do need to return a disposable from EventEmitter.on, or at least have a method here to easily track a subscription to an EventEmitter // we do need to return a disposable from EventEmitter.on, or at least have a method here to easily track a subscription to an EventEmitter
import {EventEmitter} from "../utils/EventEmitter"; import {Disposables, EventEmitter} from "hydrogen-common";
import {Disposables} from "../utils/Disposables";
import type {Disposable} from "../utils/Disposables"; export class ViewModel extends EventEmitter {
import type {Platform} from "../platform/web/Platform"; constructor(options = {}) {
import type {Clock} from "../platform/web/dom/Clock";
import type {ILogger} from "../logging/types";
import type {Navigation} from "./navigation/Navigation";
import type {SegmentType} from "./navigation/index";
import type {IURLRouter} from "./navigation/URLRouter";
export type Options<T extends object = SegmentType> = {
platform: Platform;
logger: ILogger;
urlCreator: IURLRouter<T>;
navigation: Navigation<T>;
emitChange?: (params: any) => void;
}
export class ViewModel<N extends object = SegmentType, O extends Options<N> = Options<N>> extends EventEmitter<{change: never}> {
private disposables?: Disposables;
private _isDisposed = false;
private _options: Readonly<O>;
constructor(options: Readonly<O>) {
super(); super();
this.disposables = null;
this._isDisposed = false;
this._options = options; this._options = options;
} }
childOptions<T extends Object>(explicitOptions: T): T & Options<N> { childOptions(explicitOptions) {
return Object.assign({}, this._options, explicitOptions); const {navigation, urlCreator, platform} = this._options;
return Object.assign({navigation, urlCreator, platform}, explicitOptions);
} }
get options(): Readonly<O> { return this._options; }
// makes it easier to pass through dependencies of a sub-view model // makes it easier to pass through dependencies of a sub-view model
getOption<N extends keyof O>(name: N): O[N] { getOption(name) {
return this._options[name]; return this._options[name];
} }
observeNavigation<T extends keyof N>(type: T, onChange: (value: N[T], type: T) => void): void { track(disposable) {
const segmentObservable = this.navigation.observe(type);
const unsubscribe = segmentObservable.subscribe((value: N[T]) => {
onChange(value, type);
});
this.track(unsubscribe);
}
track<D extends Disposable>(disposable: D): D {
if (!this.disposables) { if (!this.disposables) {
this.disposables = new Disposables(); this.disposables = new Disposables();
} }
return this.disposables.track(disposable); return this.disposables.track(disposable);
} }
untrack(disposable: Disposable): undefined { untrack(disposable) {
if (this.disposables) { if (this.disposables) {
return this.disposables.untrack(disposable); return this.disposables.untrack(disposable);
} }
return undefined; return null;
} }
dispose(): void { dispose() {
if (this.disposables) { if (this.disposables) {
this.disposables.dispose(); this.disposables.dispose();
} }
this._isDisposed = true; this._isDisposed = true;
} }
get isDisposed(): boolean { get isDisposed() {
return this._isDisposed; return this._isDisposed;
} }
disposeTracked(disposable: Disposable | undefined): undefined { disposeTracked(disposable) {
if (this.disposables) { if (this.disposables) {
return this.disposables.disposeTracked(disposable); return this.disposables.disposeTracked(disposable);
} }
return undefined; return null;
} }
// TODO: this will need to support binding // TODO: this will need to support binding
@ -105,7 +75,7 @@ export class ViewModel<N extends object = SegmentType, O extends Options<N> = Op
// //
// translated string should probably always be bindings, unless we're fine with a refresh when changing the language? // translated string should probably always be bindings, unless we're fine with a refresh when changing the language?
// we probably are, if we're using routing with a url, we could just refresh. // we probably are, if we're using routing with a url, we could just refresh.
i18n(parts: TemplateStringsArray, ...expr: any[]): string { i18n(parts, ...expr) {
// just concat for now // just concat for now
let result = ""; let result = "";
for (let i = 0; i < parts.length; ++i) { for (let i = 0; i < parts.length; ++i) {
@ -117,7 +87,11 @@ export class ViewModel<N extends object = SegmentType, O extends Options<N> = Op
return result; return result;
} }
emitChange(changedProps: any): void { updateOptions(options) {
this._options = Object.assign(this._options, options);
}
emitChange(changedProps) {
if (this._options.emitChange) { if (this._options.emitChange) {
this._options.emitChange(changedProps); this._options.emitChange(changedProps);
} else { } else {
@ -125,24 +99,27 @@ export class ViewModel<N extends object = SegmentType, O extends Options<N> = Op
} }
} }
get platform(): Platform { get platform() {
return this._options.platform; return this._options.platform;
} }
get clock(): Clock { get clock() {
return this._options.platform.clock; return this._options.platform.clock;
} }
get logger(): ILogger { get logger() {
return this.platform.logger; return this.platform.logger;
} }
get urlCreator(): IURLRouter<N> { /**
* The url router, only meant to be used to create urls with from view models.
* @return {URLRouter}
*/
get urlCreator() {
return this._options.urlCreator; return this._options.urlCreator;
} }
get navigation(): Navigation<N> { get navigation() {
// typescript needs a little help here return this._options.navigation;
return this._options.navigation as unknown as Navigation<N>;
} }
} }

View file

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

View file

@ -0,0 +1,22 @@
/*
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 {createNavigation, createRouter} from "./navigation/index.js";
// export main view & view models
export {RootViewModel} from "./RootViewModel.js";
export {SessionViewModel} from "./session/SessionViewModel.js";
export {RoomViewModel} from "./session/room/RoomViewModel.js";
export {TimelineViewModel} from "./session/room/timeline/TimelineViewModel.js";

View file

@ -14,19 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../ViewModel"; import {ViewModel} from "../ViewModel.js";
import {LoginFailure} from "../../matrix/Client.js"; import {LoginFailure} from "../../matrix/SessionContainer.js";
export class CompleteSSOLoginViewModel extends ViewModel { export class CompleteSSOLoginViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
const { const {
loginToken, loginToken,
client, sessionContainer,
attemptLogin, attemptLogin,
} = options; } = options;
this._loginToken = loginToken; this._loginToken = loginToken;
this._client = client; this._sessionContainer = sessionContainer;
this._attemptLogin = attemptLogin; this._attemptLogin = attemptLogin;
this._errorMessage = ""; this._errorMessage = "";
this.performSSOLoginCompletion(); this.performSSOLoginCompletion();
@ -46,7 +46,7 @@ export class CompleteSSOLoginViewModel extends ViewModel {
const homeserver = await this.platform.settingsStorage.getString("sso_ongoing_login_homeserver"); const homeserver = await this.platform.settingsStorage.getString("sso_ongoing_login_homeserver");
let loginOptions; let loginOptions;
try { try {
loginOptions = await this._client.queryLogin(homeserver).result; loginOptions = await this._sessionContainer.queryLogin(homeserver).result;
} }
catch (err) { catch (err) {
this._showError(err.message); this._showError(err.message);

View file

@ -14,176 +14,132 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {Client} from "../../matrix/Client.js"; import {ViewModel} from "../ViewModel.js";
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/SessionContainer.js";
import {SessionLoadViewModel} from "../SessionLoadViewModel.js"; import {SessionLoadViewModel} from "../SessionLoadViewModel.js";
import {SegmentType} from "../navigation/index";
import type {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod} from "../../matrix/login"; export class LoginViewModel extends ViewModel {
constructor(options) {
type Options = {
defaultHomeserver: string;
ready: ReadyFn;
loginToken?: string;
} & BaseOptions;
export class LoginViewModel extends ViewModel<SegmentType, Options> {
private _ready: ReadyFn;
private _loginToken?: string;
private _client: Client;
private _loginOptions?: LoginOptions;
private _passwordLoginViewModel?: PasswordLoginViewModel;
private _startSSOLoginViewModel?: StartSSOLoginViewModel;
private _completeSSOLoginViewModel?: CompleteSSOLoginViewModel;
private _loadViewModel?: SessionLoadViewModel;
private _loadViewModelSubscription?: () => void;
private _homeserver: string;
private _queriedHomeserver?: string;
private _abortHomeserverQueryTimeout?: () => void;
private _abortQueryOperation?: () => void;
private _hideHomeserver: boolean = false;
private _isBusy: boolean = false;
private _errorMessage: string = "";
constructor(options: Readonly<Options>) {
super(options); super(options);
const {ready, defaultHomeserver, loginToken} = options; const {ready, defaultHomeserver, createSessionContainer, loginToken} = options;
this._createSessionContainer = createSessionContainer;
this._ready = ready; this._ready = ready;
this._loginToken = loginToken; this._loginToken = loginToken;
this._client = new Client(this.platform); this._sessionContainer = this._createSessionContainer();
this._loginOptions = null;
this._passwordLoginViewModel = null;
this._startSSOLoginViewModel = null;
this._completeSSOLoginViewModel = null;
this._loadViewModel = null;
this._loadViewModelSubscription = null;
this._homeserver = defaultHomeserver; this._homeserver = defaultHomeserver;
this._queriedHomeserver = null;
this._errorMessage = "";
this._hideHomeserver = false;
this._isBusy = false;
this._abortHomeserverQueryTimeout = null;
this._abortQueryOperation = null;
this._initViewModels(); this._initViewModels();
} }
get passwordLoginViewModel(): PasswordLoginViewModel { get passwordLoginViewModel() { return this._passwordLoginViewModel; }
return this._passwordLoginViewModel; get startSSOLoginViewModel() { return this._startSSOLoginViewModel; }
} get completeSSOLoginViewModel(){ return this._completeSSOLoginViewModel; }
get homeserver() { return this._homeserver; }
get resolvedHomeserver() { return this._loginOptions?.homeserver; }
get errorMessage() { return this._errorMessage; }
get showHomeserver() { return !this._hideHomeserver; }
get loadViewModel() {return this._loadViewModel; }
get isBusy() { return this._isBusy; }
get isFetchingLoginOptions() { return !!this._abortQueryOperation; }
get startSSOLoginViewModel(): StartSSOLoginViewModel { goBack() {
return this._startSSOLoginViewModel;
}
get completeSSOLoginViewModel(): CompleteSSOLoginViewModel {
return this._completeSSOLoginViewModel;
}
get homeserver(): string {
return this._homeserver;
}
get resolvedHomeserver(): string | undefined {
return this._loginOptions?.homeserver;
}
get errorMessage(): string {
return this._errorMessage;
}
get showHomeserver(): boolean {
return !this._hideHomeserver;
}
get loadViewModel(): SessionLoadViewModel {
return this._loadViewModel;
}
get isBusy(): boolean {
return this._isBusy;
}
get isFetchingLoginOptions(): boolean {
return !!this._abortQueryOperation;
}
goBack(): void {
this.navigation.push("session"); this.navigation.push("session");
} }
private _initViewModels(): void { async _initViewModels() {
if (this._loginToken) { if (this._loginToken) {
this._hideHomeserver = true; this._hideHomeserver = true;
this._completeSSOLoginViewModel = this.track(new CompleteSSOLoginViewModel( this._completeSSOLoginViewModel = this.track(new CompleteSSOLoginViewModel(
this.childOptions( this.childOptions(
{ {
client: this._client, sessionContainer: this._sessionContainer,
attemptLogin: (loginMethod: TokenLoginMethod) => this.attemptLogin(loginMethod), attemptLogin: loginMethod => this.attemptLogin(loginMethod),
loginToken: this._loginToken loginToken: this._loginToken
}))); })));
this.emitChange("completeSSOLoginViewModel"); this.emitChange("completeSSOLoginViewModel");
} }
else { else {
void this.queryHomeserver(); await this.queryHomeserver();
} }
} }
private _showPasswordLogin(): void { _showPasswordLogin() {
this._passwordLoginViewModel = this.track(new PasswordLoginViewModel( this._passwordLoginViewModel = this.track(new PasswordLoginViewModel(
this.childOptions({ this.childOptions({
loginOptions: this._loginOptions, loginOptions: this._loginOptions,
attemptLogin: (loginMethod: PasswordLoginMethod) => this.attemptLogin(loginMethod) attemptLogin: loginMethod => this.attemptLogin(loginMethod)
}))); })));
this.emitChange("passwordLoginViewModel"); this.emitChange("passwordLoginViewModel");
} }
private _showSSOLogin(): void { _showSSOLogin() {
this._startSSOLoginViewModel = this.track( this._startSSOLoginViewModel = this.track(
new StartSSOLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) new StartSSOLoginViewModel(this.childOptions({loginOptions: this._loginOptions}))
); );
this.emitChange("startSSOLoginViewModel"); this.emitChange("startSSOLoginViewModel");
} }
private _showError(message: string): void { _showError(message) {
this._errorMessage = message; this._errorMessage = message;
this.emitChange("errorMessage"); this.emitChange("errorMessage");
} }
private _setBusy(status: boolean): void { _setBusy(status) {
this._isBusy = status; this._isBusy = status;
this._passwordLoginViewModel?.setBusy(status); this._passwordLoginViewModel?.setBusy(status);
this._startSSOLoginViewModel?.setBusy(status); this._startSSOLoginViewModel?.setBusy(status);
this.emitChange("isBusy"); this.emitChange("isBusy");
} }
async attemptLogin(loginMethod: ILoginMethod): Promise<null> { async attemptLogin(loginMethod) {
this._setBusy(true); this._setBusy(true);
void this._client.startWithLogin(loginMethod, {inspectAccountSetup: true}); this._sessionContainer.startWithLogin(loginMethod);
const loadStatus = this._client.loadStatus; const loadStatus = this._sessionContainer.loadStatus;
const handle = loadStatus.waitFor((status: LoadStatus) => status !== LoadStatus.Login); const handle = loadStatus.waitFor(status => status !== LoadStatus.Login);
await handle.promise; await handle.promise;
this._setBusy(false); this._setBusy(false);
const status = loadStatus.get(); const status = loadStatus.get();
if (status === LoadStatus.LoginFailed) { if (status === LoadStatus.LoginFailed) {
return this._client.loginFailure; return this._sessionContainer.loginFailure;
} }
this._hideHomeserver = true; this._hideHomeserver = true;
this.emitChange("hideHomeserver"); this.emitChange("hideHomeserver");
this._disposeViewModels(); this._disposeViewModels();
void this._createLoadViewModel(); this._createLoadViewModel();
return null; return null;
} }
private _createLoadViewModel(): void { _createLoadViewModel() {
this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription);
this._loadViewModel = this.disposeTracked(this._loadViewModel); this._loadViewModel = this.disposeTracked(this._loadViewModel);
this._loadViewModel = this.track( this._loadViewModel = this.track(
new SessionLoadViewModel( new SessionLoadViewModel(
this.childOptions({ this.childOptions({
ready: (client) => { ready: (sessionContainer) => {
// make sure we don't delete the session in dispose when navigating away // make sure we don't delete the session in dispose when navigating away
this._client = null; this._sessionContainer = null;
this._ready(client); this._ready(sessionContainer);
}, },
client: this._client, sessionContainer: this._sessionContainer,
homeserver: this._homeserver homeserver: this._homeserver
}) })
) )
); );
void this._loadViewModel.start(); this._loadViewModel.start();
this.emitChange("loadViewModel"); this.emitChange("loadViewModel");
this._loadViewModelSubscription = this.track( this._loadViewModelSubscription = this.track(
this._loadViewModel.disposableOn("change", () => { this._loadViewModel.disposableOn("change", () => {
@ -195,22 +151,22 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
); );
} }
private _disposeViewModels(): void { _disposeViewModels() {
this._startSSOLoginViewModel = this.disposeTracked(this._startSSOLoginViewModel); this._startSSOLoginViewModel = this.disposeTracked(this._ssoLoginViewModel);
this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel); this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel);
this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel); this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel);
this.emitChange("disposeViewModels"); this.emitChange("disposeViewModels");
} }
async setHomeserver(newHomeserver: string): Promise<void> { async setHomeserver(newHomeserver) {
this._homeserver = newHomeserver; this._homeserver = newHomeserver;
// clear everything set by queryHomeserver // clear everything set by queryHomeserver
this._loginOptions = undefined; this._loginOptions = null;
this._queriedHomeserver = undefined; this._queriedHomeserver = null;
this._showError(""); this._showError("");
this._disposeViewModels(); this._disposeViewModels();
this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation);
this.emitChange("loginViewModels"); // multiple fields changing this.emitChange(); // multiple fields changing
// also clear the timeout if it is still running // also clear the timeout if it is still running
this.disposeTracked(this._abortHomeserverQueryTimeout); this.disposeTracked(this._abortHomeserverQueryTimeout);
const timeout = this.clock.createTimeout(1000); const timeout = this.clock.createTimeout(1000);
@ -225,10 +181,10 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
} }
} }
this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout); this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout);
void this.queryHomeserver(); this.queryHomeserver();
} }
async queryHomeserver(): Promise<void> { async queryHomeserver() {
// don't repeat a query we've just done // don't repeat a query we've just done
if (this._homeserver === this._queriedHomeserver || this._homeserver === "") { if (this._homeserver === this._queriedHomeserver || this._homeserver === "") {
return; return;
@ -244,7 +200,7 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
// cancel ongoing query operation, if any // cancel ongoing query operation, if any
this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation);
try { try {
const queryOperation = this._client.queryLogin(this._homeserver); const queryOperation = this._sessionContainer.queryLogin(this._homeserver);
this._abortQueryOperation = this.track(() => queryOperation.abort()); this._abortQueryOperation = this.track(() => queryOperation.abort());
this.emitChange("isFetchingLoginOptions"); this.emitChange("isFetchingLoginOptions");
this._loginOptions = await queryOperation.result; this._loginOptions = await queryOperation.result;
@ -254,7 +210,7 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
if (e.name === "AbortError") { if (e.name === "AbortError") {
return; //aborted, bail out return; //aborted, bail out
} else { } else {
this._loginOptions = undefined; this._loginOptions = null;
} }
} finally { } finally {
this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation);
@ -272,22 +228,12 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
} }
} }
dispose(): void { dispose() {
super.dispose(); super.dispose();
if (this._client) { if (this._sessionContainer) {
// if we move away before we're done with initial sync // if we move away before we're done with initial sync
// delete the session // delete the session
void this._client.deleteSession(); this._sessionContainer.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;
};

View file

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

View file

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

View file

@ -16,49 +16,27 @@ limitations under the License.
import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue"; import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue";
export class Navigation {
type AllowsChild<T> = (parent: Segment<T> | undefined, child: Segment<T>) => boolean; constructor(allowsChild) {
/**
* OptionalValue is basically stating that if SegmentType[type] = true:
* - Allow this type to be optional
* - Give it a default value of undefined
* - Also allow it to be true
* This lets us do:
* const s: Segment<SegmentType> = new Segment("create-room");
* instead of
* const s: Segment<SegmentType> = new Segment("create-room", undefined);
*/
export type OptionalValue<T> = T extends true? [(undefined | true)?]: [T];
export class Navigation<T extends object> {
private readonly _allowsChild: AllowsChild<T>;
private _path: Path<T>;
private readonly _observables: Map<keyof T, SegmentObservable<T>> = new Map();
private readonly _pathObservable: ObservableValue<Path<T>>;
constructor(allowsChild: AllowsChild<T>) {
this._allowsChild = allowsChild; this._allowsChild = allowsChild;
this._path = new Path([], allowsChild); this._path = new Path([], allowsChild);
this._observables = new Map();
this._pathObservable = new ObservableValue(this._path); this._pathObservable = new ObservableValue(this._path);
} }
get pathObservable(): ObservableValue<Path<T>> { get pathObservable() {
return this._pathObservable; return this._pathObservable;
} }
get path(): Path<T> { get path() {
return this._path; return this._path;
} }
push<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): void { push(type, value = undefined) {
const newPath = this.path.with(new Segment(type, ...value)); return this.applyPath(this.path.with(new Segment(type, value)));
if (newPath) {
this.applyPath(newPath);
}
} }
applyPath(path: Path<T>): void { applyPath(path) {
// Path is not exported, so you can only create a Path through Navigation, // Path is not exported, so you can only create a Path through Navigation,
// so we assume it respects the allowsChild rules // so we assume it respects the allowsChild rules
const oldPath = this._path; const oldPath = this._path;
@ -82,7 +60,7 @@ export class Navigation<T extends object> {
this._pathObservable.set(this._path); this._pathObservable.set(this._path);
} }
observe(type: keyof T): SegmentObservable<T> { observe(type) {
let observable = this._observables.get(type); let observable = this._observables.get(type);
if (!observable) { if (!observable) {
observable = new SegmentObservable(this, type); observable = new SegmentObservable(this, type);
@ -91,9 +69,9 @@ export class Navigation<T extends object> {
return observable; return observable;
} }
pathFrom(segments: Segment<any>[]): Path<T> { pathFrom(segments) {
let parent: Segment<any> | undefined; let parent;
let i: number; let i;
for (i = 0; i < segments.length; i += 1) { for (i = 0; i < segments.length; i += 1) {
if (!this._allowsChild(parent, segments[i])) { if (!this._allowsChild(parent, segments[i])) {
return new Path(segments.slice(0, i), this._allowsChild); return new Path(segments.slice(0, i), this._allowsChild);
@ -103,12 +81,12 @@ export class Navigation<T extends object> {
return new Path(segments, this._allowsChild); return new Path(segments, this._allowsChild);
} }
segment<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): Segment<T> { segment(type, value) {
return new Segment(type, ...value); return new Segment(type, value);
} }
} }
function segmentValueEqual<T>(a?: T[keyof T], b?: T[keyof T]): boolean { function segmentValueEqual(a, b) {
if (a === b) { if (a === b) {
return true; return true;
} }
@ -125,29 +103,24 @@ function segmentValueEqual<T>(a?: T[keyof T], b?: T[keyof T]): boolean {
return false; return false;
} }
export class Segment {
export class Segment<T, K extends keyof T = any> { constructor(type, value) {
public value: T[K]; this.type = type;
this.value = value === undefined ? true : value;
constructor(public type: K, ...value: OptionalValue<T[K]>) {
this.value = (value[0] === undefined ? true : value[0]) as unknown as T[K];
} }
} }
class Path<T> { class Path {
private readonly _segments: Segment<T, any>[]; constructor(segments = [], allowsChild) {
private readonly _allowsChild: AllowsChild<T>;
constructor(segments: Segment<T>[] = [], allowsChild: AllowsChild<T>) {
this._segments = segments; this._segments = segments;
this._allowsChild = allowsChild; this._allowsChild = allowsChild;
} }
clone(): Path<T> { clone() {
return new Path(this._segments.slice(), this._allowsChild); return new Path(this._segments.slice(), this._allowsChild);
} }
with(segment: Segment<T>): Path<T> | undefined { with(segment) {
let index = this._segments.length - 1; let index = this._segments.length - 1;
do { do {
if (this._allowsChild(this._segments[index], segment)) { if (this._allowsChild(this._segments[index], segment)) {
@ -159,10 +132,10 @@ class Path<T> {
index -= 1; index -= 1;
} while(index >= -1); } while(index >= -1);
// allow -1 as well so we check if the segment is allowed as root // allow -1 as well so we check if the segment is allowed as root
return undefined; return null;
} }
until(type: keyof T): Path<T> { until(type) {
const index = this._segments.findIndex(s => s.type === type); const index = this._segments.findIndex(s => s.type === type);
if (index !== -1) { if (index !== -1) {
return new Path(this._segments.slice(0, index + 1), this._allowsChild) return new Path(this._segments.slice(0, index + 1), this._allowsChild)
@ -170,11 +143,11 @@ class Path<T> {
return new Path([], this._allowsChild); return new Path([], this._allowsChild);
} }
get(type: keyof T): Segment<T> | undefined { get(type) {
return this._segments.find(s => s.type === type); return this._segments.find(s => s.type === type);
} }
replace(segment: Segment<T>): Path<T> | undefined { replace(segment) {
const index = this._segments.findIndex(s => s.type === segment.type); const index = this._segments.findIndex(s => s.type === segment.type);
if (index !== -1) { if (index !== -1) {
const parent = this._segments[index - 1]; const parent = this._segments[index - 1];
@ -187,10 +160,10 @@ class Path<T> {
} }
} }
} }
return undefined; return null;
} }
get segments(): Segment<T>[] { get segments() {
return this._segments; return this._segments;
} }
} }
@ -199,49 +172,43 @@ class Path<T> {
* custom observable so it always returns what is in navigation.path, even if we haven't emitted the change yet. * custom observable so it always returns what is in navigation.path, even if we haven't emitted the change yet.
* This ensures that observers of a segment can also read the most recent value of other segments. * This ensures that observers of a segment can also read the most recent value of other segments.
*/ */
class SegmentObservable<T extends object> extends BaseObservableValue<T[keyof T] | undefined> { class SegmentObservable extends BaseObservableValue {
private readonly _navigation: Navigation<T>; constructor(navigation, type) {
private _type: keyof T;
private _lastSetValue?: T[keyof T];
constructor(navigation: Navigation<T>, type: keyof T) {
super(); super();
this._navigation = navigation; this._navigation = navigation;
this._type = type; this._type = type;
this._lastSetValue = navigation.path.get(type)?.value; this._lastSetValue = navigation.path.get(type)?.value;
} }
get(): T[keyof T] | undefined { get() {
const path = this._navigation.path; const path = this._navigation.path;
const segment = path.get(this._type); const segment = path.get(this._type);
const value = segment?.value; const value = segment?.value;
return value; return value;
} }
emitIfChanged(): void { emitIfChanged() {
const newValue = this.get(); const newValue = this.get();
if (!segmentValueEqual<T>(newValue, this._lastSetValue)) { if (!segmentValueEqual(newValue, this._lastSetValue)) {
this._lastSetValue = newValue; this._lastSetValue = newValue;
this.emit(newValue); this.emit(newValue);
} }
} }
} }
export type {Path};
export function tests() { export function tests() {
function createMockNavigation() { function createMockNavigation() {
return new Navigation((parent, {type}) => { return new Navigation((parent, {type}) => {
switch (parent?.type) { switch (parent?.type) {
case undefined: case undefined:
return type === "1" || type === "2"; return type === "1" || "2";
case "1": case "1":
return type === "1.1"; return type === "1.1";
case "1.1": case "1.1":
return type === "1.1.1"; return type === "1.1.1";
case "2": case "2":
return type === "2.1" || type === "2.2"; return type === "2.1" || "2.2";
default: default:
return false; return false;
} }
@ -249,7 +216,7 @@ export function tests() {
} }
function observeTypes(nav, types) { function observeTypes(nav, types) {
const changes: {type:string, value:any}[] = []; const changes = [];
for (const type of types) { for (const type of types) {
nav.observe(type).subscribe(value => { nav.observe(type).subscribe(value => {
changes.push({type, value}); changes.push({type, value});
@ -258,12 +225,6 @@ export function tests() {
return changes; return changes;
} }
type SegmentType = {
"foo": number;
"bar": number;
"baz": number;
}
return { return {
"applying a path emits an event on the observable": assert => { "applying a path emits an event on the observable": assert => {
const nav = createMockNavigation(); const nav = createMockNavigation();
@ -281,18 +242,18 @@ export function tests() {
assert.equal(changes[1].value, 8); assert.equal(changes[1].value, 8);
}, },
"path.get": assert => { "path.get": assert => {
const path = new Path<SegmentType>([new Segment("foo", 5), new Segment("bar", 6)], () => true); const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true);
assert.equal(path.get("foo")!.value, 5); assert.equal(path.get("foo").value, 5);
assert.equal(path.get("bar")!.value, 6); assert.equal(path.get("bar").value, 6);
}, },
"path.replace success": assert => { "path.replace success": assert => {
const path = new Path<SegmentType>([new Segment("foo", 5), new Segment("bar", 6)], () => true); const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true);
const newPath = path.replace(new Segment("foo", 1)); const newPath = path.replace(new Segment("foo", 1));
assert.equal(newPath!.get("foo")!.value, 1); assert.equal(newPath.get("foo").value, 1);
assert.equal(newPath!.get("bar")!.value, 6); assert.equal(newPath.get("bar").value, 6);
}, },
"path.replace not found": assert => { "path.replace not found": assert => {
const path = new Path<SegmentType>([new Segment("foo", 5), new Segment("bar", 6)], () => true); const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true);
const newPath = path.replace(new Segment("baz", 1)); const newPath = path.replace(new Segment("baz", 1));
assert.equal(newPath, null); assert.equal(newPath, null);
} }

View file

@ -14,55 +14,28 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import type {History} from "../../platform/web/dom/History.js"; export class URLRouter {
import type {Navigation, Segment, Path, OptionalValue} from "./Navigation"; constructor({history, navigation, parseUrlPath, stringifyPath}) {
import type {SubscriptionHandle} from "../../observable/BaseObservable";
type ParseURLPath<T> = (urlPath: string, currentNavPath: Path<T>, defaultSessionId?: string) => Segment<T>[];
type StringifyPath<T> = (path: Path<T>) => string;
export interface IURLRouter<T> {
attach(): void;
dispose(): void;
pushUrl(url: string): void;
tryRestoreLastUrl(): boolean;
urlForSegments(segments: Segment<T>[]): string | undefined;
urlForSegment<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): string | undefined;
urlUntilSegment(type: keyof T): string;
urlForPath(path: Path<T>): string;
openRoomActionUrl(roomId: string): string;
createSSOCallbackURL(): string;
normalizeUrl(): void;
}
export class URLRouter<T extends {session: string | boolean}> implements IURLRouter<T> {
private readonly _history: History;
private readonly _navigation: Navigation<T>;
private readonly _parseUrlPath: ParseURLPath<T>;
private readonly _stringifyPath: StringifyPath<T>;
private _subscription?: SubscriptionHandle;
private _pathSubscription?: SubscriptionHandle;
private _isApplyingUrl: boolean = false;
private _defaultSessionId?: string;
constructor(history: History, navigation: Navigation<T>, parseUrlPath: ParseURLPath<T>, stringifyPath: StringifyPath<T>) {
this._history = history; this._history = history;
this._navigation = navigation; this._navigation = navigation;
this._parseUrlPath = parseUrlPath; this._parseUrlPath = parseUrlPath;
this._stringifyPath = stringifyPath; this._stringifyPath = stringifyPath;
this._subscription = null;
this._pathSubscription = null;
this._isApplyingUrl = false;
this._defaultSessionId = this._getLastSessionId(); this._defaultSessionId = this._getLastSessionId();
} }
private _getLastSessionId(): string | undefined { _getLastSessionId() {
const navPath = this._urlAsNavPath(this._history.getLastSessionUrl() || ""); const navPath = this._urlAsNavPath(this._history.getLastUrl() || "");
const sessionId = navPath.get("session")?.value; const sessionId = navPath.get("session")?.value;
if (typeof sessionId === "string") { if (typeof sessionId === "string") {
return sessionId; return sessionId;
} }
return undefined; return null;
} }
attach(): void { attach() {
this._subscription = this._history.subscribe(url => this._applyUrl(url)); this._subscription = this._history.subscribe(url => this._applyUrl(url));
// subscribe to path before applying initial url // subscribe to path before applying initial url
// so redirects in _applyNavPathToHistory are reflected in url bar // so redirects in _applyNavPathToHistory are reflected in url bar
@ -70,12 +43,12 @@ export class URLRouter<T extends {session: string | boolean}> implements IURLRou
this._applyUrl(this._history.get()); this._applyUrl(this._history.get());
} }
dispose(): void { dispose() {
if (this._subscription) { this._subscription = this._subscription(); } this._subscription = this._subscription();
if (this._pathSubscription) { this._pathSubscription = this._pathSubscription(); } this._pathSubscription = this._pathSubscription();
} }
private _applyNavPathToHistory(path: Path<T>): void { _applyNavPathToHistory(path) {
const url = this.urlForPath(path); const url = this.urlForPath(path);
if (url !== this._history.get()) { if (url !== this._history.get()) {
if (this._isApplyingUrl) { if (this._isApplyingUrl) {
@ -87,7 +60,7 @@ export class URLRouter<T extends {session: string | boolean}> implements IURLRou
} }
} }
private _applyNavPathToNavigation(navPath: Path<T>): void { _applyNavPathToNavigation(navPath) {
// this will cause _applyNavPathToHistory to be called, // this will cause _applyNavPathToHistory to be called,
// so set a flag whether this request came from ourselves // so set a flag whether this request came from ourselves
// (in which case it is a redirect if the url does not match the current one) // (in which case it is a redirect if the url does not match the current one)
@ -96,22 +69,22 @@ export class URLRouter<T extends {session: string | boolean}> implements IURLRou
this._isApplyingUrl = false; this._isApplyingUrl = false;
} }
private _urlAsNavPath(url: string): Path<T> { _urlAsNavPath(url) {
const urlPath = this._history.urlAsPath(url); const urlPath = this._history.urlAsPath(url);
return this._navigation.pathFrom(this._parseUrlPath(urlPath, this._navigation.path, this._defaultSessionId)); return this._navigation.pathFrom(this._parseUrlPath(urlPath, this._navigation.path, this._defaultSessionId));
} }
private _applyUrl(url: string): void { _applyUrl(url) {
const navPath = this._urlAsNavPath(url); const navPath = this._urlAsNavPath(url);
this._applyNavPathToNavigation(navPath); this._applyNavPathToNavigation(navPath);
} }
pushUrl(url: string): void { pushUrl(url) {
this._history.pushUrl(url); this._history.pushUrl(url);
} }
tryRestoreLastUrl(): boolean { tryRestoreLastUrl() {
const lastNavPath = this._urlAsNavPath(this._history.getLastSessionUrl() || ""); const lastNavPath = this._urlAsNavPath(this._history.getLastUrl() || "");
if (lastNavPath.segments.length !== 0) { if (lastNavPath.segments.length !== 0) {
this._applyNavPathToNavigation(lastNavPath); this._applyNavPathToNavigation(lastNavPath);
return true; return true;
@ -119,8 +92,8 @@ export class URLRouter<T extends {session: string | boolean}> implements IURLRou
return false; return false;
} }
urlForSegments(segments: Segment<T>[]): string | undefined { urlForSegments(segments) {
let path: Path<T> | undefined = this._navigation.path; let path = this._navigation.path;
for (const segment of segments) { for (const segment of segments) {
path = path.with(segment); path = path.with(segment);
if (!path) { if (!path) {
@ -130,29 +103,29 @@ export class URLRouter<T extends {session: string | boolean}> implements IURLRou
return this.urlForPath(path); return this.urlForPath(path);
} }
urlForSegment<K extends keyof T>(type: K, ...value: OptionalValue<T[K]>): string | undefined { urlForSegment(type, value) {
return this.urlForSegments([this._navigation.segment(type, ...value)]); return this.urlForSegments([this._navigation.segment(type, value)]);
} }
urlUntilSegment(type: keyof T): string { urlUntilSegment(type) {
return this.urlForPath(this._navigation.path.until(type)); return this.urlForPath(this._navigation.path.until(type));
} }
urlForPath(path: Path<T>): string { urlForPath(path) {
return this._history.pathAsUrl(this._stringifyPath(path)); return this._history.pathAsUrl(this._stringifyPath(path));
} }
openRoomActionUrl(roomId: string): string { openRoomActionUrl(roomId) {
// not a segment to navigation knowns about, so append it manually // not a segment to navigation knowns about, so append it manually
const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`; const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`;
return this._history.pathAsUrl(urlPath); return this._history.pathAsUrl(urlPath);
} }
createSSOCallbackURL(): string { createSSOCallbackURL() {
return window.location.origin; return window.location.origin;
} }
normalizeUrl(): void { normalizeUrl() {
// Remove any queryParameters from the URL // Remove any queryParameters from the URL
// Gets rid of the loginToken after SSO // Gets rid of the loginToken after SSO
this._history.replaceUrlSilently(`${window.location.origin}/${window.location.hash}`); this._history.replaceUrlSilently(`${window.location.origin}/${window.location.hash}`);

View file

@ -14,43 +14,25 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {Navigation, Segment} from "./Navigation"; import {Navigation, Segment} from "./Navigation.js";
import {URLRouter} from "./URLRouter"; import {URLRouter} from "./URLRouter.js";
import type {Path, OptionalValue} from "./Navigation";
export type SegmentType = { export function createNavigation() {
"login": true;
"session": string | boolean;
"sso": string;
"logout": true;
"room": string;
"rooms": string[];
"settings": true;
"create-room": true;
"empty-grid-tile": number;
"lightbox": string;
"right-panel": true;
"details": true;
"members": true;
"member": string;
};
export function createNavigation(): Navigation<SegmentType> {
return new Navigation(allowsChild); return new Navigation(allowsChild);
} }
export function createRouter({history, navigation}: {history: History, navigation: Navigation<SegmentType>}): URLRouter<SegmentType> { export function createRouter({history, navigation}) {
return new URLRouter(history, navigation, parseUrlPath, stringifyPath); return new URLRouter({history, navigation, stringifyPath, parseUrlPath});
} }
function allowsChild(parent: Segment<SegmentType> | undefined, child: Segment<SegmentType>): boolean { function allowsChild(parent, child) {
const {type} = child; const {type} = child;
switch (parent?.type) { switch (parent?.type) {
case undefined: case undefined:
// allowed root segments // allowed root segments
return type === "login" || type === "session" || type === "sso" || type === "logout"; return type === "login" || type === "session" || type === "sso";
case "session": case "session":
return type === "room" || type === "rooms" || type === "settings" || type === "create-room"; return type === "room" || type === "rooms" || type === "settings";
case "rooms": case "rooms":
// downside of the approach: both of these will control which tile is selected // downside of the approach: both of these will control which tile is selected
return type === "room" || type === "empty-grid-tile"; return type === "room" || type === "empty-grid-tile";
@ -63,9 +45,8 @@ function allowsChild(parent: Segment<SegmentType> | undefined, child: Segment<Se
} }
} }
export function removeRoomFromPath(path: Path<SegmentType>, roomId: string): Path<SegmentType> | undefined { export function removeRoomFromPath(path, roomId) {
let newPath: Path<SegmentType> | undefined = path; const rooms = path.get("rooms");
const rooms = newPath.get("rooms");
let roomIdGridIndex = -1; let roomIdGridIndex = -1;
// first delete from rooms segment // first delete from rooms segment
if (rooms) { if (rooms) {
@ -73,22 +54,22 @@ export function removeRoomFromPath(path: Path<SegmentType>, roomId: string): Pat
if (roomIdGridIndex !== -1) { if (roomIdGridIndex !== -1) {
const idsWithoutRoom = rooms.value.slice(); const idsWithoutRoom = rooms.value.slice();
idsWithoutRoom[roomIdGridIndex] = ""; idsWithoutRoom[roomIdGridIndex] = "";
newPath = newPath.replace(new Segment("rooms", idsWithoutRoom)); path = path.replace(new Segment("rooms", idsWithoutRoom));
} }
} }
const room = newPath!.get("room"); const room = path.get("room");
// then from room (which occurs with or without rooms) // then from room (which occurs with or without rooms)
if (room && room.value === roomId) { if (room && room.value === roomId) {
if (roomIdGridIndex !== -1) { if (roomIdGridIndex !== -1) {
newPath = newPath!.with(new Segment("empty-grid-tile", roomIdGridIndex)); path = path.with(new Segment("empty-grid-tile", roomIdGridIndex));
} else { } else {
newPath = newPath!.until("session"); path = path.until("session");
} }
} }
return newPath; return path;
} }
function roomsSegmentWithRoom(rooms: Segment<SegmentType, "rooms">, roomId: string, path: Path<SegmentType>): Segment<SegmentType, "rooms"> { function roomsSegmentWithRoom(rooms, roomId, path) {
if(!rooms.value.includes(roomId)) { if(!rooms.value.includes(roomId)) {
const emptyGridTile = path.get("empty-grid-tile"); const emptyGridTile = path.get("empty-grid-tile");
const oldRoom = path.get("room"); const oldRoom = path.get("room");
@ -106,28 +87,28 @@ function roomsSegmentWithRoom(rooms: Segment<SegmentType, "rooms">, roomId: stri
} }
} }
function pushRightPanelSegment<T extends keyof SegmentType>(array: Segment<SegmentType>[], segment: T, ...value: OptionalValue<SegmentType[T]>): void { function pushRightPanelSegment(array, segment, value = true) {
array.push(new Segment("right-panel")); array.push(new Segment("right-panel"));
array.push(new Segment(segment, ...value)); array.push(new Segment(segment, value));
} }
export function addPanelIfNeeded<T extends SegmentType>(navigation: Navigation<T>, path: Path<T>): Path<T> { export function addPanelIfNeeded(navigation, path) {
const segments = navigation.path.segments; const segments = navigation.path.segments;
const i = segments.findIndex(segment => segment.type === "right-panel"); const i = segments.findIndex(segment => segment.type === "right-panel");
let _path = path; let _path = path;
if (i !== -1) { if (i !== -1) {
_path = path.until("room"); _path = path.until("room");
_path = _path.with(segments[i])!; _path = _path.with(segments[i]);
_path = _path.with(segments[i + 1])!; _path = _path.with(segments[i + 1]);
} }
return _path; return _path;
} }
export function parseUrlPath(urlPath: string, currentNavPath: Path<SegmentType>, defaultSessionId?: string): Segment<SegmentType>[] { export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) {
// substring(1) to take of initial / // substr(1) to take of initial /
const parts = urlPath.substring(1).split("/"); const parts = urlPath.substr(1).split("/");
const iterator = parts[Symbol.iterator](); const iterator = parts[Symbol.iterator]();
const segments: Segment<SegmentType>[] = []; const segments = [];
let next; let next;
while (!(next = iterator.next()).done) { while (!(next = iterator.next()).done) {
const type = next.value; const type = next.value;
@ -189,9 +170,9 @@ export function parseUrlPath(urlPath: string, currentNavPath: Path<SegmentType>,
return segments; return segments;
} }
export function stringifyPath(path: Path<SegmentType>): string { export function stringifyPath(path) {
let urlPath = ""; let urlPath = "";
let prevSegment: Segment<SegmentType> | undefined; let prevSegment;
for (const segment of path.segments) { for (const segment of path.segments) {
switch (segment.type) { switch (segment.type) {
case "rooms": case "rooms":
@ -224,15 +205,9 @@ export function stringifyPath(path: Path<SegmentType>): string {
} }
export function tests() { export function tests() {
function createEmptyPath() {
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
const path = nav.pathFrom([]);
return path;
}
return { return {
"stringify grid url with focused empty tile": assert => { "stringify grid url with focused empty tile": assert => {
const nav: Navigation<SegmentType> = new Navigation(allowsChild); const nav = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -242,7 +217,7 @@ export function tests() {
assert.equal(urlPath, "/session/1/rooms/a,b,c/3"); assert.equal(urlPath, "/session/1/rooms/a,b,c/3");
}, },
"stringify grid url with focused room": assert => { "stringify grid url with focused room": assert => {
const nav: Navigation<SegmentType> = new Navigation(allowsChild); const nav = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -252,7 +227,7 @@ export function tests() {
assert.equal(urlPath, "/session/1/rooms/a,b,c/1"); assert.equal(urlPath, "/session/1/rooms/a,b,c/1");
}, },
"stringify url with right-panel and details segment": assert => { "stringify url with right-panel and details segment": assert => {
const nav: Navigation<SegmentType> = new Navigation(allowsChild); const nav = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -264,15 +239,13 @@ export function tests() {
assert.equal(urlPath, "/session/1/rooms/a,b,c/1/details"); assert.equal(urlPath, "/session/1/rooms/a,b,c/1/details");
}, },
"Parse loginToken query parameter into SSO segment": assert => { "Parse loginToken query parameter into SSO segment": assert => {
const path = createEmptyPath(); const segments = parseUrlPath("?loginToken=a1232aSD123");
const segments = parseUrlPath("?loginToken=a1232aSD123", path);
assert.equal(segments.length, 1); assert.equal(segments.length, 1);
assert.equal(segments[0].type, "sso"); assert.equal(segments[0].type, "sso");
assert.equal(segments[0].value, "a1232aSD123"); assert.equal(segments[0].value, "a1232aSD123");
}, },
"parse grid url path with focused empty tile": assert => { "parse grid url path with focused empty tile": assert => {
const path = createEmptyPath(); const segments = parseUrlPath("/session/1/rooms/a,b,c/3");
const segments = parseUrlPath("/session/1/rooms/a,b,c/3", path);
assert.equal(segments.length, 3); assert.equal(segments.length, 3);
assert.equal(segments[0].type, "session"); assert.equal(segments[0].type, "session");
assert.equal(segments[0].value, "1"); assert.equal(segments[0].value, "1");
@ -282,8 +255,7 @@ export function tests() {
assert.equal(segments[2].value, 3); assert.equal(segments[2].value, 3);
}, },
"parse grid url path with focused room": assert => { "parse grid url path with focused room": assert => {
const path = createEmptyPath(); const segments = parseUrlPath("/session/1/rooms/a,b,c/1");
const segments = parseUrlPath("/session/1/rooms/a,b,c/1", path);
assert.equal(segments.length, 3); assert.equal(segments.length, 3);
assert.equal(segments[0].type, "session"); assert.equal(segments[0].type, "session");
assert.equal(segments[0].value, "1"); assert.equal(segments[0].value, "1");
@ -293,8 +265,7 @@ export function tests() {
assert.equal(segments[2].value, "b"); assert.equal(segments[2].value, "b");
}, },
"parse empty grid url": assert => { "parse empty grid url": assert => {
const path = createEmptyPath(); const segments = parseUrlPath("/session/1/rooms/");
const segments = parseUrlPath("/session/1/rooms/", path);
assert.equal(segments.length, 3); assert.equal(segments.length, 3);
assert.equal(segments[0].type, "session"); assert.equal(segments[0].type, "session");
assert.equal(segments[0].value, "1"); assert.equal(segments[0].value, "1");
@ -304,8 +275,7 @@ export function tests() {
assert.equal(segments[2].value, 0); assert.equal(segments[2].value, 0);
}, },
"parse empty grid url with focus": assert => { "parse empty grid url with focus": assert => {
const path = createEmptyPath(); const segments = parseUrlPath("/session/1/rooms//1");
const segments = parseUrlPath("/session/1/rooms//1", path);
assert.equal(segments.length, 3); assert.equal(segments.length, 3);
assert.equal(segments[0].type, "session"); assert.equal(segments[0].type, "session");
assert.equal(segments[0].value, "1"); assert.equal(segments[0].value, "1");
@ -315,7 +285,7 @@ export function tests() {
assert.equal(segments[2].value, 1); assert.equal(segments[2].value, 1);
}, },
"parse open-room action replacing the current focused room": assert => { "parse open-room action replacing the current focused room": assert => {
const nav: Navigation<SegmentType> = new Navigation(allowsChild); const nav = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -331,7 +301,7 @@ export function tests() {
assert.equal(segments[2].value, "d"); assert.equal(segments[2].value, "d");
}, },
"parse open-room action changing focus to an existing room": assert => { "parse open-room action changing focus to an existing room": assert => {
const nav: Navigation<SegmentType> = new Navigation(allowsChild); const nav = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -347,7 +317,7 @@ export function tests() {
assert.equal(segments[2].value, "a"); assert.equal(segments[2].value, "a");
}, },
"parse open-room action changing focus to an existing room with details open": assert => { "parse open-room action changing focus to an existing room with details open": assert => {
const nav: Navigation<SegmentType> = new Navigation(allowsChild); const nav = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -369,7 +339,7 @@ export function tests() {
assert.equal(segments[4].value, true); assert.equal(segments[4].value, true);
}, },
"open-room action should only copy over previous segments if there are no parts after open-room": assert => { "open-room action should only copy over previous segments if there are no parts after open-room": assert => {
const nav: Navigation<SegmentType> = new Navigation(allowsChild); const nav = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -391,7 +361,7 @@ export function tests() {
assert.equal(segments[4].value, "foo"); assert.equal(segments[4].value, "foo");
}, },
"parse open-room action setting a room in an empty tile": assert => { "parse open-room action setting a room in an empty tile": assert => {
const nav: Navigation<SegmentType> = new Navigation(allowsChild); const nav = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
@ -407,83 +377,82 @@ export function tests() {
assert.equal(segments[2].value, "d"); assert.equal(segments[2].value, "d");
}, },
"parse session url path without id": assert => { "parse session url path without id": assert => {
const path = createEmptyPath(); const segments = parseUrlPath("/session");
const segments = parseUrlPath("/session", path);
assert.equal(segments.length, 1); assert.equal(segments.length, 1);
assert.equal(segments[0].type, "session"); assert.equal(segments[0].type, "session");
assert.strictEqual(segments[0].value, true); assert.strictEqual(segments[0].value, true);
}, },
"remove active room from grid path turns it into empty tile": assert => { "remove active room from grid path turns it into empty tile": assert => {
const nav: Navigation<SegmentType> = new Navigation(allowsChild); const nav = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
new Segment("room", "b") new Segment("room", "b")
]); ]);
const newPath = removeRoomFromPath(path, "b"); const newPath = removeRoomFromPath(path, "b");
assert.equal(newPath?.segments.length, 3); assert.equal(newPath.segments.length, 3);
assert.equal(newPath?.segments[0].type, "session"); assert.equal(newPath.segments[0].type, "session");
assert.equal(newPath?.segments[0].value, 1); assert.equal(newPath.segments[0].value, 1);
assert.equal(newPath?.segments[1].type, "rooms"); assert.equal(newPath.segments[1].type, "rooms");
assert.deepEqual(newPath?.segments[1].value, ["a", "", "c"]); assert.deepEqual(newPath.segments[1].value, ["a", "", "c"]);
assert.equal(newPath?.segments[2].type, "empty-grid-tile"); assert.equal(newPath.segments[2].type, "empty-grid-tile");
assert.equal(newPath?.segments[2].value, 1); assert.equal(newPath.segments[2].value, 1);
}, },
"remove inactive room from grid path": assert => { "remove inactive room from grid path": assert => {
const nav: Navigation<SegmentType> = new Navigation(allowsChild); const nav = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]), new Segment("rooms", ["a", "b", "c"]),
new Segment("room", "b") new Segment("room", "b")
]); ]);
const newPath = removeRoomFromPath(path, "a"); const newPath = removeRoomFromPath(path, "a");
assert.equal(newPath?.segments.length, 3); assert.equal(newPath.segments.length, 3);
assert.equal(newPath?.segments[0].type, "session"); assert.equal(newPath.segments[0].type, "session");
assert.equal(newPath?.segments[0].value, 1); assert.equal(newPath.segments[0].value, 1);
assert.equal(newPath?.segments[1].type, "rooms"); assert.equal(newPath.segments[1].type, "rooms");
assert.deepEqual(newPath?.segments[1].value, ["", "b", "c"]); assert.deepEqual(newPath.segments[1].value, ["", "b", "c"]);
assert.equal(newPath?.segments[2].type, "room"); assert.equal(newPath.segments[2].type, "room");
assert.equal(newPath?.segments[2].value, "b"); assert.equal(newPath.segments[2].value, "b");
}, },
"remove inactive room from grid path with empty tile": assert => { "remove inactive room from grid path with empty tile": assert => {
const nav: Navigation<SegmentType> = new Navigation(allowsChild); const nav = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("rooms", ["a", "b", ""]), new Segment("rooms", ["a", "b", ""]),
new Segment("empty-grid-tile", 3) new Segment("empty-grid-tile", 3)
]); ]);
const newPath = removeRoomFromPath(path, "b"); const newPath = removeRoomFromPath(path, "b");
assert.equal(newPath?.segments.length, 3); assert.equal(newPath.segments.length, 3);
assert.equal(newPath?.segments[0].type, "session"); assert.equal(newPath.segments[0].type, "session");
assert.equal(newPath?.segments[0].value, 1); assert.equal(newPath.segments[0].value, 1);
assert.equal(newPath?.segments[1].type, "rooms"); assert.equal(newPath.segments[1].type, "rooms");
assert.deepEqual(newPath?.segments[1].value, ["a", "", ""]); assert.deepEqual(newPath.segments[1].value, ["a", "", ""]);
assert.equal(newPath?.segments[2].type, "empty-grid-tile"); assert.equal(newPath.segments[2].type, "empty-grid-tile");
assert.equal(newPath?.segments[2].value, 3); assert.equal(newPath.segments[2].value, 3);
}, },
"remove active room": assert => { "remove active room": assert => {
const nav: Navigation<SegmentType> = new Navigation(allowsChild); const nav = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("room", "b") new Segment("room", "b")
]); ]);
const newPath = removeRoomFromPath(path, "b"); const newPath = removeRoomFromPath(path, "b");
assert.equal(newPath?.segments.length, 1); assert.equal(newPath.segments.length, 1);
assert.equal(newPath?.segments[0].type, "session"); assert.equal(newPath.segments[0].type, "session");
assert.equal(newPath?.segments[0].value, 1); assert.equal(newPath.segments[0].value, 1);
}, },
"remove inactive room doesn't do anything": assert => { "remove inactive room doesn't do anything": assert => {
const nav: Navigation<SegmentType> = new Navigation(allowsChild); const nav = new Navigation(allowsChild);
const path = nav.pathFrom([ const path = nav.pathFrom([
new Segment("session", 1), new Segment("session", 1),
new Segment("room", "b") new Segment("room", "b")
]); ]);
const newPath = removeRoomFromPath(path, "a"); const newPath = removeRoomFromPath(path, "a");
assert.equal(newPath?.segments.length, 2); assert.equal(newPath.segments.length, 2);
assert.equal(newPath?.segments[0].type, "session"); assert.equal(newPath.segments[0].type, "session");
assert.equal(newPath?.segments[0].value, 1); assert.equal(newPath.segments[0].value, 1);
assert.equal(newPath?.segments[1].type, "room"); assert.equal(newPath.segments[1].type, "room");
assert.equal(newPath?.segments[1].value, "b"); assert.equal(newPath.segments[1].value, "b");
}, },
} }

View file

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../ViewModel"; import {ViewModel} from "../ViewModel.js";
import {addPanelIfNeeded} from "../navigation/index"; import {addPanelIfNeeded} from "../navigation/index.js";
function dedupeSparse(roomIds) { function dedupeSparse(roomIds) {
return roomIds.map((id, idx) => { return roomIds.map((id, idx) => {
@ -185,7 +185,7 @@ export class RoomGridViewModel extends ViewModel {
} }
} }
import {createNavigation} from "../navigation/index"; import {createNavigation} from "../navigation/index.js";
import {ObservableValue} from "../../observable/ObservableValue"; import {ObservableValue} from "../../observable/ObservableValue";
export function tests() { export function tests() {

View file

@ -15,7 +15,6 @@ limitations under the License.
*/ */
import {ObservableValue} from "../../observable/ObservableValue"; import {ObservableValue} from "../../observable/ObservableValue";
import {RoomStatus} from "../../matrix/room/common";
/** /**
Depending on the status of a room (invited, joined, archived, or none), Depending on the status of a room (invited, joined, archived, or none),
@ -35,11 +34,11 @@ the now transferred child view model.
This is also why there is an explicit initialize method, see comment there. This is also why there is an explicit initialize method, see comment there.
*/ */
export class RoomViewModelObservable extends ObservableValue { export class RoomViewModelObservable extends ObservableValue {
constructor(sessionViewModel, roomIdOrLocalId) { constructor(sessionViewModel, roomId) {
super(null); super(null);
this._statusSubscription = null; this._statusSubscription = null;
this._sessionViewModel = sessionViewModel; this._sessionViewModel = sessionViewModel;
this.id = roomIdOrLocalId; this.id = roomId;
} }
/** /**
@ -49,7 +48,7 @@ export class RoomViewModelObservable extends ObservableValue {
are called in that case. are called in that case.
*/ */
async initialize() { async initialize() {
const {session} = this._sessionViewModel._client; const {session} = this._sessionViewModel._sessionContainer;
const statusObservable = await session.observeRoomStatus(this.id); const statusObservable = await session.observeRoomStatus(this.id);
this.set(await this._statusToViewModel(statusObservable.get())); this.set(await this._statusToViewModel(statusObservable.get()));
this._statusSubscription = statusObservable.subscribe(async status => { this._statusSubscription = statusObservable.subscribe(async status => {
@ -60,21 +59,11 @@ export class RoomViewModelObservable extends ObservableValue {
} }
async _statusToViewModel(status) { async _statusToViewModel(status) {
if (status & RoomStatus.Replaced) { if (status.invited) {
if (status & RoomStatus.BeingCreated) {
const {session} = this._sessionViewModel._client;
const roomBeingCreated = session.roomsBeingCreated.get(this.id);
this._sessionViewModel.notifyRoomReplaced(roomBeingCreated.id, roomBeingCreated.roomId);
} else {
throw new Error("Don't know how to replace a room with this status: " + (status ^ RoomStatus.Replaced));
}
} else if (status & RoomStatus.BeingCreated) {
return this._sessionViewModel._createRoomBeingCreatedViewModel(this.id);
} else if (status & RoomStatus.Invited) {
return this._sessionViewModel._createInviteViewModel(this.id); return this._sessionViewModel._createInviteViewModel(this.id);
} else if (status & RoomStatus.Joined) { } else if (status.joined) {
return this._sessionViewModel._createRoomViewModelInstance(this.id); return this._sessionViewModel._createRoomViewModel(this.id);
} else if (status & RoomStatus.Archived) { } else if (status.archived) {
return await this._sessionViewModel._createArchivedRoomViewModel(this.id); return await this._sessionViewModel._createArchivedRoomViewModel(this.id);
} else { } else {
return this._sessionViewModel._createUnknownRoomViewModel(this.id); return this._sessionViewModel._createUnknownRoomViewModel(this.id);

View file

@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../ViewModel"; import {ViewModel} from "../ViewModel.js";
import {createEnum} from "../../utils/enum"; import {createEnum} from "../../utils/enum.js";
import {ConnectionStatus} from "../../matrix/net/Reconnector"; import {ConnectionStatus} from "../../matrix/net/Reconnector.js";
import {SyncStatus} from "../../matrix/Sync.js"; import {SyncStatus} from "../../matrix/Sync.js";
const SessionStatus = createEnum( const SessionStatus = createEnum(
@ -36,7 +36,7 @@ export class SessionStatusViewModel extends ViewModel {
this._reconnector = reconnector; this._reconnector = reconnector;
this._status = this._calculateState(reconnector.connectionStatus.get(), sync.status.get()); this._status = this._calculateState(reconnector.connectionStatus.get(), sync.status.get());
this._session = session; this._session = session;
this._setupKeyBackupUrl = this.urlCreator.urlForSegment("settings"); this._setupSessionBackupUrl = this.urlCreator.urlForSegment("settings");
this._dismissSecretStorage = false; this._dismissSecretStorage = false;
} }
@ -44,17 +44,17 @@ export class SessionStatusViewModel extends ViewModel {
const update = () => this._updateStatus(); const update = () => this._updateStatus();
this.track(this._sync.status.subscribe(update)); this.track(this._sync.status.subscribe(update));
this.track(this._reconnector.connectionStatus.subscribe(update)); this.track(this._reconnector.connectionStatus.subscribe(update));
this.track(this._session.needsKeyBackup.subscribe(() => { this.track(this._session.needsSessionBackup.subscribe(() => {
this.emitChange(); this.emitChange();
})); }));
} }
get setupKeyBackupUrl () { get setupSessionBackupUrl () {
return this._setupKeyBackupUrl; return this._setupSessionBackupUrl;
} }
get isShown() { get isShown() {
return (this._session.needsKeyBackup.get() && !this._dismissSecretStorage) || this._status !== SessionStatus.Syncing; return (this._session.needsSessionBackup.get() && !this._dismissSecretStorage) || this._status !== SessionStatus.Syncing;
} }
get statusLabel() { get statusLabel() {
@ -70,7 +70,7 @@ export class SessionStatusViewModel extends ViewModel {
case SessionStatus.SyncError: case SessionStatus.SyncError:
return this.i18n`Sync failed because of ${this._sync.error}`; return this.i18n`Sync failed because of ${this._sync.error}`;
} }
if (this._session.needsKeyBackup.get()) { if (this._session.needsSessionBackup.get()) {
return this.i18n`Set up session backup to decrypt older messages.`; return this.i18n`Set up session backup to decrypt older messages.`;
} }
return ""; return "";
@ -135,7 +135,7 @@ export class SessionStatusViewModel extends ViewModel {
get isSecretStorageShown() { get isSecretStorageShown() {
// TODO: we need a model here where we can have multiple messages queued up and their buttons don't bleed into each other. // TODO: we need a model here where we can have multiple messages queued up and their buttons don't bleed into each other.
return this._status === SessionStatus.Syncing && this._session.needsKeyBackup.get() && !this._dismissSecretStorage; return this._status === SessionStatus.Syncing && this._session.needsSessionBackup.get() && !this._dismissSecretStorage;
} }
get canDismiss() { get canDismiss() {

View file

@ -19,31 +19,31 @@ import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js";
import {RoomViewModel} from "./room/RoomViewModel.js"; import {RoomViewModel} from "./room/RoomViewModel.js";
import {UnknownRoomViewModel} from "./room/UnknownRoomViewModel.js"; import {UnknownRoomViewModel} from "./room/UnknownRoomViewModel.js";
import {InviteViewModel} from "./room/InviteViewModel.js"; import {InviteViewModel} from "./room/InviteViewModel.js";
import {RoomBeingCreatedViewModel} from "./room/RoomBeingCreatedViewModel.js";
import {LightboxViewModel} from "./room/LightboxViewModel.js"; import {LightboxViewModel} from "./room/LightboxViewModel.js";
import {SessionStatusViewModel} from "./SessionStatusViewModel.js"; import {SessionStatusViewModel} from "./SessionStatusViewModel.js";
import {RoomGridViewModel} from "./RoomGridViewModel.js"; import {RoomGridViewModel} from "./RoomGridViewModel.js";
import {SettingsViewModel} from "./settings/SettingsViewModel.js"; import {SettingsViewModel} from "./settings/SettingsViewModel.js";
import {CreateRoomViewModel} from "./CreateRoomViewModel.js"; import {ViewModel} from "../ViewModel.js";
import {ViewModel} from "../ViewModel";
import {RoomViewModelObservable} from "./RoomViewModelObservable.js"; import {RoomViewModelObservable} from "./RoomViewModelObservable.js";
import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js"; import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js";
export class SessionViewModel extends ViewModel { export class SessionViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
const {client} = options; const {sessionContainer} = options;
this._client = this.track(client); this._sessionContainer = this.track(sessionContainer);
this._sessionStatusViewModel = this.track(new SessionStatusViewModel(this.childOptions({ this._sessionStatusViewModel = this.track(new SessionStatusViewModel(this.childOptions({
sync: client.sync, sync: sessionContainer.sync,
reconnector: client.reconnector, reconnector: sessionContainer.reconnector,
session: client.session, session: sessionContainer.session,
})));
this._leftPanelViewModel = this.track(new LeftPanelViewModel(this.childOptions({
invites: this._sessionContainer.session.invites,
rooms: this._sessionContainer.session.rooms
}))); })));
this._leftPanelViewModel = this.track(new LeftPanelViewModel(this.childOptions({session: this._client.session})));
this._settingsViewModel = null; this._settingsViewModel = null;
this._roomViewModelObservable = null; this._roomViewModelObservable = null;
this._gridViewModel = null; this._gridViewModel = null;
this._createRoomViewModel = null;
this._setupNavigation(); this._setupNavigation();
} }
@ -75,12 +75,6 @@ export class SessionViewModel extends ViewModel {
})); }));
this._updateSettings(settings.get()); this._updateSettings(settings.get());
const createRoom = this.navigation.observe("create-room");
this.track(createRoom.subscribe(createRoomOpen => {
this._updateCreateRoom(createRoomOpen);
}));
this._updateCreateRoom(createRoom.get());
const lightbox = this.navigation.observe("lightbox"); const lightbox = this.navigation.observe("lightbox");
this.track(lightbox.subscribe(eventId => { this.track(lightbox.subscribe(eventId => {
this._updateLightbox(eventId); this._updateLightbox(eventId);
@ -94,7 +88,7 @@ export class SessionViewModel extends ViewModel {
} }
get id() { get id() {
return this._client.sessionId; return this._sessionContainer.sessionId;
} }
start() { start() {
@ -102,7 +96,7 @@ export class SessionViewModel extends ViewModel {
} }
get activeMiddleViewModel() { get activeMiddleViewModel() {
return this._roomViewModelObservable?.get() || this._gridViewModel || this._settingsViewModel || this._createRoomViewModel; return this._roomViewModelObservable?.get() || this._gridViewModel || this._settingsViewModel;
} }
get roomGridViewModel() { get roomGridViewModel() {
@ -125,14 +119,11 @@ export class SessionViewModel extends ViewModel {
return this._roomViewModelObservable?.get(); return this._roomViewModelObservable?.get();
} }
get rightPanelViewModel() { get rightPanelViewModel() {
return this._rightPanelViewModel; return this._rightPanelViewModel;
} }
get createRoomViewModel() {
return this._createRoomViewModel;
}
_updateGrid(roomIds) { _updateGrid(roomIds) {
const changed = !(this._gridViewModel && roomIds); const changed = !(this._gridViewModel && roomIds);
const currentRoomId = this.navigation.path.get("room"); const currentRoomId = this.navigation.path.get("room");
@ -171,8 +162,8 @@ export class SessionViewModel extends ViewModel {
} }
} }
_createRoomViewModelInstance(roomId) { _createRoomViewModel(roomId) {
const room = this._client.session.rooms.get(roomId); const room = this._sessionContainer.session.rooms.get(roomId);
if (room) { if (room) {
const roomVM = new RoomViewModel(this.childOptions({room})); const roomVM = new RoomViewModel(this.childOptions({room}));
roomVM.load(); roomVM.load();
@ -184,12 +175,12 @@ export class SessionViewModel extends ViewModel {
_createUnknownRoomViewModel(roomIdOrAlias) { _createUnknownRoomViewModel(roomIdOrAlias) {
return new UnknownRoomViewModel(this.childOptions({ return new UnknownRoomViewModel(this.childOptions({
roomIdOrAlias, roomIdOrAlias,
session: this._client.session, session: this._sessionContainer.session,
})); }));
} }
async _createArchivedRoomViewModel(roomId) { async _createArchivedRoomViewModel(roomId) {
const room = await this._client.session.loadArchivedRoom(roomId); const room = await this._sessionContainer.session.loadArchivedRoom(roomId);
if (room) { if (room) {
const roomVM = new RoomViewModel(this.childOptions({room})); const roomVM = new RoomViewModel(this.childOptions({room}));
roomVM.load(); roomVM.load();
@ -199,22 +190,11 @@ export class SessionViewModel extends ViewModel {
} }
_createInviteViewModel(roomId) { _createInviteViewModel(roomId) {
const invite = this._client.session.invites.get(roomId); const invite = this._sessionContainer.session.invites.get(roomId);
if (invite) { if (invite) {
return new InviteViewModel(this.childOptions({ return new InviteViewModel(this.childOptions({
invite, invite,
mediaRepository: this._client.session.mediaRepository, mediaRepository: this._sessionContainer.session.mediaRepository,
}));
}
return null;
}
_createRoomBeingCreatedViewModel(localId) {
const roomBeingCreated = this._client.session.roomsBeingCreated.get(localId);
if (roomBeingCreated) {
return new RoomBeingCreatedViewModel(this.childOptions({
roomBeingCreated,
mediaRepository: this._client.session.mediaRepository,
})); }));
} }
return null; return null;
@ -250,23 +230,13 @@ export class SessionViewModel extends ViewModel {
} }
if (settingsOpen) { if (settingsOpen) {
this._settingsViewModel = this.track(new SettingsViewModel(this.childOptions({ this._settingsViewModel = this.track(new SettingsViewModel(this.childOptions({
client: this._client, session: this._sessionContainer.session,
}))); })));
this._settingsViewModel.load(); this._settingsViewModel.load();
} }
this.emitChange("activeMiddleViewModel"); this.emitChange("activeMiddleViewModel");
} }
_updateCreateRoom(createRoomOpen) {
if (this._createRoomViewModel) {
this._createRoomViewModel = this.disposeTracked(this._createRoomViewModel);
}
if (createRoomOpen) {
this._createRoomViewModel = this.track(new CreateRoomViewModel(this.childOptions({session: this._client.session})));
}
this.emitChange("activeMiddleViewModel");
}
_updateLightbox(eventId) { _updateLightbox(eventId) {
if (this._lightboxViewModel) { if (this._lightboxViewModel) {
this._lightboxViewModel = this.disposeTracked(this._lightboxViewModel); this._lightboxViewModel = this.disposeTracked(this._lightboxViewModel);
@ -284,7 +254,7 @@ export class SessionViewModel extends ViewModel {
_roomFromNavigation() { _roomFromNavigation() {
const roomId = this.navigation.path.get("room")?.value; const roomId = this.navigation.path.get("room")?.value;
const room = this._client.session.rooms.get(roomId); const room = this._sessionContainer.session.rooms.get(roomId);
return room; return room;
} }
@ -293,12 +263,9 @@ export class SessionViewModel extends ViewModel {
const enable = !!this.navigation.path.get("right-panel")?.value; const enable = !!this.navigation.path.get("right-panel")?.value;
if (enable) { if (enable) {
const room = this._roomFromNavigation(); const room = this._roomFromNavigation();
this._rightPanelViewModel = this.track(new RightPanelViewModel(this.childOptions({room, session: this._client.session}))); this._rightPanelViewModel = this.track(new RightPanelViewModel(this.childOptions({room})));
} }
this.emitChange("rightPanelViewModel"); this.emitChange("rightPanelViewModel");
} }
notifyRoomReplaced(oldId, newId) {
this.navigation.push("room", newId);
}
} }

View file

@ -15,10 +15,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js";
import {ViewModel} from "../../ViewModel"; import {ViewModel} from "../../ViewModel.js";
const KIND_ORDER = ["roomBeingCreated", "invite", "room"]; const KIND_ORDER = ["invite", "room"];
export class BaseTileViewModel extends ViewModel { export class BaseTileViewModel extends ViewModel {
constructor(options) { constructor(options) {

View file

@ -0,0 +1,55 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
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 {BaseTileViewModel} from "./BaseTileViewModel.js";
export class InviteTileViewModel extends BaseTileViewModel {
constructor(options) {
super(options);
const {invite} = options;
this._invite = invite;
this._url = this.urlCreator.openRoomActionUrl(this._invite.id);
}
get busy() {
return this._invite.accepting || this._invite.rejecting;
}
get kind() {
return "invite";
}
get url() {
return this._url;
}
compare(other) {
const parentComparison = super.compare(other);
if (parentComparison !== 0) {
return parentComparison;
}
return other._invite.timestamp - this._invite.timestamp;
}
get name() {
return this._invite.name;
}
get _avatarSource() {
return this._invite;
}
}

View file

@ -15,47 +15,42 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../../ViewModel"; import {ViewModel} from "../../ViewModel.js";
import {RoomTileViewModel} from "./RoomTileViewModel.js"; import {RoomTileViewModel} from "./RoomTileViewModel.js";
import {InviteTileViewModel} from "./InviteTileViewModel.js"; import {InviteTileViewModel} from "./InviteTileViewModel.js";
import {RoomBeingCreatedTileViewModel} from "./RoomBeingCreatedTileViewModel.js";
import {RoomFilter} from "./RoomFilter.js"; import {RoomFilter} from "./RoomFilter.js";
import {ApplyMap} from "../../../observable/map/ApplyMap.js"; import {ApplyMap} from "../../../observable/map/ApplyMap.js";
import {addPanelIfNeeded} from "../../navigation/index"; import {addPanelIfNeeded} from "../../navigation/index.js";
export class LeftPanelViewModel extends ViewModel { export class LeftPanelViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
const {session} = options; const {rooms, invites} = options;
this._tileViewModelsMap = this._mapTileViewModels(session.roomsBeingCreated, session.invites, session.rooms); this._tileViewModelsMap = this._mapTileViewModels(rooms, invites);
this._tileViewModelsFilterMap = new ApplyMap(this._tileViewModelsMap); this._tileViewModelsFilterMap = new ApplyMap(this._tileViewModelsMap);
this._tileViewModels = this._tileViewModelsFilterMap.sortValues((a, b) => a.compare(b)); this._tileViewModels = this._tileViewModelsFilterMap.sortValues((a, b) => a.compare(b));
this._currentTileVM = null; this._currentTileVM = null;
this._setupNavigation(); this._setupNavigation();
this._closeUrl = this.urlCreator.urlForSegment("session"); this._closeUrl = this.urlCreator.urlForSegment("session");
this._settingsUrl = this.urlCreator.urlForSegment("settings"); this._settingsUrl = this.urlCreator.urlForSegment("settings");
this._createRoomUrl = this.urlCreator.urlForSegment("create-room");
} }
_mapTileViewModels(roomsBeingCreated, invites, rooms) { _mapTileViewModels(rooms, invites) {
// join is not commutative, invites will take precedence over rooms // join is not commutative, invites will take precedence over rooms
const allTiles = invites.join(roomsBeingCreated, rooms).mapValues((item, emitChange) => { return invites.join(rooms).mapValues((roomOrInvite, emitChange) => {
let vm; let vm;
if (item.isBeingCreated) { if (roomOrInvite.isInvite) {
vm = new RoomBeingCreatedTileViewModel(this.childOptions({roomBeingCreated: item, emitChange})); vm = new InviteTileViewModel(this.childOptions({invite: roomOrInvite, emitChange}));
} else if (item.isInvite) {
vm = new InviteTileViewModel(this.childOptions({invite: item, emitChange}));
} else { } else {
vm = new RoomTileViewModel(this.childOptions({room: item, emitChange})); vm = new RoomTileViewModel(this.childOptions({room: roomOrInvite, emitChange}));
} }
const isOpen = this.navigation.path.get("room")?.value === item.id; const isOpen = this.navigation.path.get("room")?.value === roomOrInvite.id;
if (isOpen) { if (isOpen) {
vm.open(); vm.open();
this._updateCurrentVM(vm); this._updateCurrentVM(vm);
} }
return vm; return vm;
}); });
return allTiles;
} }
_updateCurrentVM(vm) { _updateCurrentVM(vm) {
@ -74,8 +69,6 @@ export class LeftPanelViewModel extends ViewModel {
return this._settingsUrl; return this._settingsUrl;
} }
get createRoomUrl() { return this._createRoomUrl; }
_setupNavigation() { _setupNavigation() {
const roomObservable = this.navigation.observe("room"); const roomObservable = this.navigation.observe("room");
this.track(roomObservable.subscribe(roomId => this._open(roomId))); this.track(roomObservable.subscribe(roomId => this._open(roomId)));

View file

@ -33,9 +33,6 @@ export class RoomTileViewModel extends BaseTileViewModel {
return this._url; return this._url;
} }
/** very important that sorting order is stable and that comparing
* to itself always returns 0, otherwise SortedMapList will
* remove the wrong children, etc ... */
compare(other) { compare(other) {
const parentComparison = super.compare(other); const parentComparison = super.compare(other);
if (parentComparison !== 0) { if (parentComparison !== 0) {

View file

@ -14,9 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../../ViewModel"; import {ViewModel} from "../../ViewModel.js";
import {RoomType} from "../../../matrix/room/common"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js";
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
export class MemberDetailsViewModel extends ViewModel { export class MemberDetailsViewModel extends ViewModel {
constructor(options) { constructor(options) {
@ -26,7 +25,6 @@ export class MemberDetailsViewModel extends ViewModel {
this._member = this._observableMember.get(); this._member = this._observableMember.get();
this._isEncrypted = options.isEncrypted; this._isEncrypted = options.isEncrypted;
this._powerLevelsObservable = options.powerLevelsObservable; this._powerLevelsObservable = options.powerLevelsObservable;
this._session = options.session;
this.track(this._powerLevelsObservable.subscribe(() => this._onPowerLevelsChange())); this.track(this._powerLevelsObservable.subscribe(() => this._onPowerLevelsChange()));
this.track(this._observableMember.subscribe( () => this._onMemberChange())); this.track(this._observableMember.subscribe( () => this._onMemberChange()));
} }
@ -79,19 +77,6 @@ export class MemberDetailsViewModel extends ViewModel {
} }
get linkToUser() { get linkToUser() {
return `https://matrix.to/#/${encodeURIComponent(this._member.userId)}`; return `https://matrix.to/#/${this._member.userId}`;
}
async openDirectMessage() {
const room = this._session.findDirectMessageForUserId(this.userId);
let roomId = room?.id;
if (!roomId) {
const roomBeingCreated = await this._session.createRoom({
type: RoomType.DirectMessage,
invites: [this.userId]
});
roomId = roomBeingCreated.id;
}
this.navigation.push("room", roomId);
} }
} }

View file

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

View file

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

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../../ViewModel"; import {ViewModel} from "../../ViewModel.js";
import {RoomDetailsViewModel} from "./RoomDetailsViewModel.js"; import {RoomDetailsViewModel} from "./RoomDetailsViewModel.js";
import {MemberListViewModel} from "./MemberListViewModel.js"; import {MemberListViewModel} from "./MemberListViewModel.js";
import {MemberDetailsViewModel} from "./MemberDetailsViewModel.js"; import {MemberDetailsViewModel} from "./MemberDetailsViewModel.js";
@ -23,7 +23,6 @@ export class RightPanelViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
this._room = options.room; this._room = options.room;
this._session = options.session;
this._members = null; this._members = null;
this._setupNavigation(); this._setupNavigation();
} }
@ -49,13 +48,7 @@ export class RightPanelViewModel extends ViewModel {
} }
const isEncrypted = this._room.isEncrypted; const isEncrypted = this._room.isEncrypted;
const powerLevelsObservable = await this._room.observePowerLevels(); const powerLevelsObservable = await this._room.observePowerLevels();
return { return {observableMember, isEncrypted, powerLevelsObservable, mediaRepository: this._room.mediaRepository};
observableMember,
isEncrypted,
powerLevelsObservable,
mediaRepository: this._room.mediaRepository,
session: this._session
};
} }
_setupNavigation() { _setupNavigation() {

View file

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

View file

@ -14,11 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ViewModel} from "../../ViewModel"; import {ViewModel} from "../../ViewModel.js";
export class ComposerViewModel extends ViewModel { export class ComposerViewModel extends ViewModel {
constructor(roomVM) { constructor(roomVM) {
super(roomVM.options); super();
this._roomVM = roomVM; this._roomVM = roomVM;
this._isEmpty = true; this._isEmpty = true;
this._replyVM = null; this._replyVM = null;
@ -30,7 +30,6 @@ export class ComposerViewModel extends ViewModel {
this._replyVM = this.disposeTracked(this._replyVM); this._replyVM = this.disposeTracked(this._replyVM);
if (entry) { if (entry) {
this._replyVM = this.track(this._roomVM._createTile(entry)); this._replyVM = this.track(this._roomVM._createTile(entry));
this._replyVM.notifyVisible();
} }
this.emitChange("replyViewModel"); this.emitChange("replyViewModel");
this.emit("focus"); this.emit("focus");

View file

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

View file

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

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