Compare commits

...

No commits in common. "gh-pages" and "master" have entirely different histories.

603 changed files with 64394 additions and 24988 deletions

2
.dockerignore Normal file
View file

@ -0,0 +1,2 @@
node_modules
target

15
.editorconfig Normal file
View file

@ -0,0 +1,15 @@
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
indent_style = space
indent_size = 4
# Matches multiple files with brace expansion notation
# Set default charset
# [*.{js,py}]

25
.eslintrc.js Normal file
View file

@ -0,0 +1,25 @@
module.exports = {
"env": {
"browser": true,
"es6": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module"
},
"rules": {
"no-console": "off",
"no-empty": "off",
"no-prototype-builtins": "off",
"no-unused-vars": "warn"
},
"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"
}
};

47
.github/workflows/codechecks.js.yml vendored Normal file
View file

@ -0,0 +1,47 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
# yarn cache setup from https://www.karltarvas.com/2020/12/09/github-actions-cache-yarn-install.html
name: Code checks
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x]
steps:
- name: Checkout source
uses: actions/checkout@v2
- name: Install tools
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
# See: https://github.com/actions/cache/blob/main/examples.md#node---yarn
- name: Get Yarn cache directory
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Use Yarn cache
uses: actions/cache@v2
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
- name: Install dependencies
run: yarn install --prefer-offline --frozen-lockfile
- name: Unit tests
run: yarn test
- name: Lint
run: yarn run lint-ci
- name: Typescript
run: yarn run tsc

44
.github/workflows/docker-publish.yml vendored Normal file
View file

@ -0,0 +1,44 @@
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 }}

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
*.sublime-project
*.sublime-workspace
.DS_Store
node_modules
fetchlogs
sessionexports
bundle.js
target
lib
*.tar.gz
.eslintcache
.tmp
tmp/

61
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,61 @@
image: docker.io/alpine
stages:
- test
- build
.yarn-template:
image: docker.io/node
before_script:
- yarn install
cache:
paths:
- node_modules
test:
extends: .yarn-template
stage: test
script:
- yarn test
build:
extends: .yarn-template
stage: build
script:
- yarn build
artifacts:
paths:
- target
.docker-template:
image: docker.io/docker
stage: build
services:
- docker:dind
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
docker-release:
extends: .docker-template
rules:
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
script:
- docker build --pull -t "${CI_REGISTRY_IMAGE}:latest" -t "${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG}" .
- docker push "${CI_REGISTRY_IMAGE}:latest"
- docker push "${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG}"
docker-tags:
extends: .docker-template
rules:
- if: '$CI_COMMIT_TAG && $CI_COMMIT_TAG !~ /^v\d+\.\d+\.\d+$/'
script:
- docker build --pull -t "${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG}" .
- docker push "${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG}"
docker-branches:
extends: .docker-template
rules:
- if: $CI_COMMIT_BRANCH
script:
- docker build --pull -t "${CI_REGISTRY_IMAGE}:${CI_COMMIT_REF_SLUG}" .
- docker push "${CI_REGISTRY_IMAGE}:${CI_COMMIT_REF_SLUG}"

25
.ts-eslintrc.js Normal file
View file

@ -0,0 +1,25 @@
module.exports = {
root: true,
env: {
"browser": true,
"es6": true
},
extends: [
// "plugin:@typescript-eslint/recommended",
// "plugin:@typescript-eslint/recommended-requiring-type-checking",
],
parser: '@typescript-eslint/parser',
parserOptions: {
"ecmaVersion": 2020,
"sourceType": "module",
"project": "./tsconfig.json"
},
plugins: [
'@typescript-eslint',
],
rules: {
"@typescript-eslint/no-floating-promises": 2,
"@typescript-eslint/no-misused-promises": 2,
"semi": ["error", "always"]
}
};

18
.woodpecker.yml Normal file
View file

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

150
CONTRIBUTING.md Normal file
View file

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

9
Dockerfile Normal file
View file

@ -0,0 +1,9 @@
FROM docker.io/node:alpine as builder
RUN apk add --no-cache git python3 build-base
COPY . /app
WORKDIR /app
RUN yarn install \
&& yarn build
FROM docker.io/nginx:alpine
COPY --from=builder /app/target /usr/share/nginx/html

7
Dockerfile-dev Normal file
View file

@ -0,0 +1,7 @@
FROM docker.io/node:alpine
RUN apk add --no-cache git python3 build-base
COPY . /code
WORKDIR /code
RUN yarn install
EXPOSE 3000
ENTRYPOINT ["yarn", "start"]

177
LICENSE Normal file
View file

@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

14
Makefile Normal file
View file

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

46
README.md Normal file
View file

@ -0,0 +1,46 @@
[![status-badge](https://ci.batsense.net/api/badges/mystiq/hydrogen-web/status.svg)](https://ci.batsense.net/mystiq/hydrogen-web)
# 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.
## Goals
Hydrogen's goals are:
- Work well on desktop as well as mobile browsers
- UI components can be easily used in isolation
- 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
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).
# How to use
Hydrogen is deployed to [hydrogen.element.io](https://hydrogen.element.io). You can also deploy Hydrogen on your own web server:
1. Download the [latest release package](https://github.com/vector-im/hydrogen-web/releases).
1. Extract the package to the public directory of your web server.
1. If this is your first deploy:
1. copy `config.sample.json` to `config.json` and if needed, make any modifications (unless you've set up your own [sygnal](https://github.com/matrix-org/sygnal) instance, you don't need to change anything in the `push` section).
1. Disable caching entirely on the server for:
- `index.html`
- `sw.js`
- `config.json`
- All theme manifests referenced in the `themeManifests` of `config.json`, these files are typically called `theme-{name}.json`.
These resources will still be cached client-side by the service worker. Because of this; you'll still need to refresh the app twice before config.json changes are applied.
## Set up a dev environment
You can run Hydrogen locally by the following commands in the terminal:
- `yarn install` (only the first time)
- `yarn start` in the terminal
Now point your browser to `http://localhost:3000`. If you prefer, you can also [use docker](doc/docker.md).
# FAQ
Some frequently asked questions are answered [here](doc/FAQ.md).

13
TODO.md Normal file
View file

@ -0,0 +1,13 @@
- make it a copy, not a fork of brawl, so we can have issues
- add compilation step for ie11 compatible bundle
- compile to es5
- use bluebird for promises
- make xhr request impl
- once app is loading, go over errors
- project goals
- works on mobile
- works well offline
- components can be used in isolation
- lazyload components?

12
codestyle.md Normal file
View file

@ -0,0 +1,12 @@
# Code-style
- methods that return a promise should always use async/await
otherwise synchronous errors can get swallowed
you can return a promise without awaiting it though.
- only named exports, no default exports
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?
- 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)

83
doc/CSS.md Normal file
View file

@ -0,0 +1,83 @@
https://nio.chat/ looks nice.
We could do top to bottom gradients in default avatars to make them look a bit cooler. Automatically generate them from a single color, e.g. from slightly lighter to slightly darker.
## How to organize the CSS?
Can take ideas/adopt from OOCSS and SMACSS.
### Root
- maybe we should not assume `body` is the root, but rather a `.brawl` class. The root is where we'd set root level css variables, fonts?, etc. Should we scope all css to this root class? That could get painful with just vanilla css. We could use something like https://github.com/domwashburn/postcss-parent-selector to only do this at build time. Other useful plugin for postcss: https://github.com/postcss/postcss-selector-parser
We would still you `rem` for size units though.
### Class names
#### View
- view name?
#### Not quite a View
Some things might not be a view, as they don't have their own view model.
- a spinner, has .spinner for now
- avatar
#### modifier classes
are these modifiers?
- contrast-hi, contrast-mid, contrast-low
- font-large, font-medium, font-small
- large, medium, small (for spinner and avatar)
- hidden: hides the element, can be useful when not wanting to use an if-binding to databind on a css class
- inline: can be applied to any item if it needs to look good in an inline layout
- flex: can be applied to any item if it is placed in a flex container. You'd combine this with some other class to set a `flex` that makes sense, e.g.:
```css
.spinner.flex,
.avatar.flex,
.icon.flex,
button.flex {
flex: 0;
}
```
you could end up with a lot of these though?
well... for flex we don't really need a class, as `flex` doesn't do anything if the parent is not a flex container.
Modifier classes can be useful though. Should we prefix them?
### Theming
do we want as system with HSL or RGBA to define shades and contrasts?
we could define colors as HS and have a separate value for L:
```
/* for dark theme */
--lightness-mod: -1;
--accent-shade: 310, 70%;
/* then at every level */
--lightness: 60%;
/* add/remove (based on dark theme) 20% lightness */
--lightness: calc(var(--lightness) + calc(var(--lightness-mod) * 20%));
--bg-color: hsl(var(-accent-shade), var(--lightness));
```
this makes it easy to derive colors, but if there is no override with rga values, could be limiting.
I guess --fg-color and --bg-color can be those overrides?
what theme color variables do we want?
- accent color
- avatar/name colors
- background color (panels are shades of this?)
Themes are specified as JSON and need javascript to be set. The JSON contains colors in rgb, the theme code will generate css variables containing shades as specified? Well, that could be custom theming, but built-in themes should have full css flexibility.
what hierarchical variables do we want?
- `--fg-color` (we use this instead of color so icons and borders can also take the color, we could use the `currentcolor` constant for this though!)
- `--bg-color` (we use this instead of background so icons and borders can also take the color)
- `--lightness`
- `--size` for things like spinner, avatar

35
doc/FAQ.md Normal file
View file

@ -0,0 +1,35 @@
# FAQ
## What browsers are supported?
Internet Explorer 11, Chrome [1], Firefox [1] (not in a private window), Edge [1], Safari [1] and any mobile versions of these. It will probably also work on any derivatives of these.
1: Because of https://github.com/vector-im/hydrogen-web/issues/230, only [more recent versions](https://caniuse.com/mdn-javascript_operators_optional_chaining) are supported.
TorBrowser ships a crippled IndexedDB implementation and will not work. At some point we should support a memory store as a fallback, but that will still give a sub-par experience with end-to-end encryption.
It used work in pre-webkit Edge, to have it work on Windows Phone, but that support has probably bit-rotted as it isn't tested anymore.
## Is there a way to run the app as a desktop app?
You can install Hydrogen as a PWA using Chrome/Chromium on any platform or Edge on Windows. Gnome Web/Ephiphany also allows to "Install site as web application". There is no Electron build of Hydrogen, and there will likely be none in the near future, as Electron complicates the release process considerably. Once Hydrogen is more mature and feature complete, we might reconsider and use [Tauri](https://tauri.studio) if there are compelling use cases not possible with PWAs. For now though, we want to keep development and releasing fast and nimble ;)
## Is feature X supported?
If you can't find an easy way to locate the feature you are looking for, then the anwser is usually "no, not yet" :) But here are some things people have asked about in the past:
### How does newline work? Shift+Enter has no effect.
That's not yet a feature, as hydrogen just uses a single line text box for message input for now.
## How can I verify my session from Element?
You can only verify by comparing keys manually currently. In Element, go to your own profile in the right panel, click on the Hydrogen device and select Manually Verify by Text. The session key displayed should be the same as in the Hydrogen settings. You can't yet mark your Element session as trusted from Hydrogen.
## 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.
## I want to embed Hydrogen in my website, how should I do that?
Hydrogen aims to be usable as an SDK, and while it is still early days, you can find some documentation how to do that in [SDK.md](SDK.md).

8
doc/GOAL.md Normal file
View file

@ -0,0 +1,8 @@
goal:
write client that works on lumia 950 phone, so I can use matrix on my phone.
try approach offline to indexeddb. go low-memory, and test the performance of storing every event individually in indexeddb.
try to use little bandwidth, mainly by being an offline application and storing all requested data in indexeddb.
be as functional as possible while offline

11
doc/IMPORT-ISSUES.md Normal file
View file

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

75
doc/INDEXEDDB.md Normal file
View file

@ -0,0 +1,75 @@
## Promises, async/await and indexedDB
Doesn't indexedDB close your transaction if you don't queue more requests from an idb event handler?
So wouldn't that mean that you can't use promises and async/await when using idb?
It used to be like this, and for IE11 on Win7 (not on Windows 10 strangely enough), it still is like this.
Here we manually flush the promise queue synchronously at the end of an idb event handler.
In modern browsers, indexedDB transactions should only be closed after flushing the microtask queue of the event loop,
which is where promises run.
Keep in mind that indexedDB events, just like any other DOM event, are fired as macro tasks.
Promises queue micro tasks, of which the queue is drained before proceeding to the next macro task.
This also means that if a transaction is completed, you will only receive the event once you are ready to process the next macro tasks.
That doesn't prevent any placed request from throwing TransactionInactiveError though.
## TransactionInactiveError in Safari
Safari doesn't fully follow the rules above, in that if you open a transaction,
you need to "use" (not sure if this means getting a store or actually placing a request) it straight away,
without waiting for any *micro*tasks. See comments about Safari at https://github.com/dfahlander/Dexie.js/issues/317#issue-178349994.
Another failure mode perceived in Hydrogen on Safari is that when the (readonly) prepareTxn in sync wasn't awaited to be completed before opening and using the syncTxn.
I haven't found any documentation online about this at all. Awaiting prepareTxn.complete() fixed the issue below. It's strange though the put does not fail.
## Diagnose of problem
What is happening below is:
- in the sync loop:
- we first open a readonly txn on inboundGroupSessions, which we don't use in the example below
- we then open a readwrite txn on session, ... (does not overlap with first txn)
- first the first incremental sync on a room (!YxKeAxtNcDZDrGgaMF:matrix.org) it seems to work well
- on a second incremental sync for that same room, the first get throws TransactionInactiveError for some reason.
- the put in the second incremental sync somehow did not throw.
So it looks like safari doesn't like (some) transactions still being active while a second one is being openened, even with non-overlapping stores.
For now I haven't awaited every read txn in the app, as this was the only place it fails, but if this pops up again in safari, we might have to do that.
Keep in mind that the `txn ... inactive` logs are only logged when the "complete" or "abort" events are processed,
which happens in a macro task, as opposed to all of our promises, which run in a micro task.
So the transaction is likely to have closed before it appears in the logs.
```
[Log] txn 4504181722375185 active on inboundGroupSessions
[Log] txn 861052256474256 active on session, roomSummary, roomState, roomMembers, timelineEvents, timelineFragments, pendingEvents, userIdentities, groupSessionDecryptions, deviceIdentities, outboundGroupSessions, operations, accountData
[Info] hydrogen_session_5286139994689036.session.put({"key":"sync","value":{"token":"s1572540047_757284957_7660701_602588550_435736037_1567300_101589125_347651623_132704","filterId":"2"}})
[Info] hydrogen_session_5286139994689036.userIdentities.get("@bwindels:matrix.org")
[Log] txn 4504181722375185 inactive
[Log] * applying sync response to room !YxKeAxtNcDZDrGgaMF:matrix.org ...
[Info] hydrogen_session_5286139994689036.roomMembers.put({"roomId":"!YxKeAxtNcDZDrGgaMF:matrix.org","userId":"@bwindels:matrix.org","membership":"join","avatarUrl":"mxc://matrix.org/aerWVfICBMcyFcEyREcivLuI","displayName":"Bruno","key":"!YxKeAxtNcDZDrGgaMF:matrix.org|@bwindels:matrix.org"})
[Info] hydrogen_session_5286139994689036.roomMembers.get("!YxKeAxtNcDZDrGgaMF:matrix.org|@bwindels:matrix.org")
[Info] hydrogen_session_5286139994689036.timelineEvents.add({"fragmentId":0,"eventIndex":2147483658,"roomId":"!YxKeAxtNcDZDrGgaMF:matrix.org","event":{"content":{"body":"haha","msgtype":"m.text"},"origin_server_ts":1601457573756,"sender":"@bwindels:matrix.org","type":"m.room.message","unsigned":{"age":8360},"event_id":"$eD9z73-lCpXBVby5_fKqzRZzMVHiPzKbE_RSZzqRKx0"},"displayName":"Bruno","avatarUrl":"mxc://matrix.org/aerWVfICBMcyFcEyREcivLuI","key":"!YxKeAxtNcDZDrGgaMF:matrix.org|00000000|8000000a","eventIdKey":"!YxKeAxtNcDZDrGgaMF:matrix.org|$eD9z73-lCpXBVby5_fKqzRZzMVHiPzKbE_RSZzqRKx0"})
[Info] hydrogen_session_5286139994689036.roomSummary.put({"roomId":"!YxKeAxtNcDZDrGgaMF:matrix.org","name":"!!!test8!!!!!!","lastMessageBody":"haha","lastMessageTimestamp":1601457573756,"isUnread":true,"encryption":null,"lastDecryptedEventKey":null,"isDirectMessage":false,"membership":"join","inviteCount":0,"joinCount":2,"heroes":null,"hasFetchedMembers":false,"isTrackingMembers":false,"avatarUrl":null,"notificationCount":5,"highlightCount":0,"tags":{"m.lowpriority":{}}})
[Log] txn 861052256474256 inactive
[Info] syncTxn committed!!
... two more unrelated sync responses ...
[Log] starting sync request with since s1572540191_757284957_7660742_602588567_435736063_1567300_101589126_347651632_132704 ...
[Log] txn 8104296957004707 active on inboundGroupSessions
[Log] txn 2233038992157489 active on session, roomSummary, roomState, roomMembers, timelineEvents, timelineFragments, pendingEvents, userIdentities, groupSessionDecryptions, deviceIdentities, outboundGroupSessions, operations, accountData
[Info] hydrogen_session_5286139994689036.session.put({"key":"sync","value":{"token":"s1572540223_757284957_7660782_602588579_435736078_1567300_101589130_347651633_132704","filterId":"2"}})
[Log] * applying sync response to room !YxKeAxtNcDZDrGgaMF:matrix.org ...
[Info] hydrogen_session_5286139994689036.roomMembers.get("!YxKeAxtNcDZDrGgaMF:matrix.org|@bwindels:matrix.org")
[Warning] stopping sync because of error
[Error] StorageError: get("!YxKeAxtNcDZDrGgaMF:matrix.org|@bwindels:matrix.org") failed on txn with stores accountData, deviceIdentities, groupSessionDecryptions, operations, outboundGroupSessions, pendingEvents, roomMembers, roomState, roomSummary, session, timelineEvents, timelineFragments, userIdentities on hydrogen_session_5286139994689036.roomMembers: (name: TransactionInactiveError) (code: 0) Failed to execute 'get' on 'IDBObjectStore': The transaction is inactive or finished.
(anonymous function)
asyncFunctionResume
(anonymous function)
promiseReactionJobWithoutPromise
promiseReactionJob
[Log] newStatus "SyncError"
[Log] txn 8104296957004707 inactive
[Log] txn 2233038992157489 inactive
```

19
doc/QUESTIONS.md Normal file
View file

@ -0,0 +1,19 @@
remaining problems to resolve:
how to store timelime fragments that we don't yet know how they should be sorted wrt the other events and gaps. the case with event permalinks and showing the replied to event when rendering a reply (anything from /context).
either we could put timeline pieces that were the result of /context in something that is not the timeline. Gaps also don't really make sense there ... You can just paginate backwards and forwards. Or maybe still in the timeline but in a different scope not part of the sortKey, scope: live, or scope: piece-1204. While paginating, we could keep the start and end event_id of all the scopes in memory, and set a marker on them to stitch them together?
Hmmm, I can see the usefullness of the concept of timeline set with multiple timelines in it for this. for the live timeline it's less convenient as you're not bothered so much by the stitching up, but for /context pieces that run into the live timeline while paginating it seems more useful... we could have a marker entry that refers to the next or previous scope ... this way we could also use gap entries for /context timelines, just one on either end.
the start and end event_id of a scope, keeping that in memory, how do we make sure this is safe taking transactions into account? our preferred strategy so far has been to read everything from store inside a txn to make sure we don't have any stale caches or races. Would be nice to keep this.
so while paginating, you'd check the event_id of the event against the start/end event_id of every scope to see if stitching is in order, and add marker entries if so. Perhaps marker entries could also be used to stitch up rooms that have changed versioning?
What does all of this mean for using sortKey as an identifier? Will we need to take scope into account as well everywhere?
we'll need to at least contemplate how room state will be handled with all of the above.
how do we deal with the fact that an event can be rendered (and updated) multiple times in the timeline as part of replies.
room state...

14
doc/RELEASE.md Normal file
View file

@ -0,0 +1,14 @@
release:
- bundling css files
- bundling javascript
- run index.html template for release as opposed to develop version?
- make list of all resources needed (images, html page)
- create appcache manifest + service worker
- create tarball + sign
- make gh release with tarball + signature
publish:
- extract tarball
- upload to static website
- overwrite index.html
- overwrite service worker & appcache manifest
- put new version files under /x.x.x

116
doc/SDK.md Normal file
View file

@ -0,0 +1,116 @@
# Hydrogen View SDK
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.
## 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
# you can pick "vanilla-ts" here for project type if you're not using react or vue
yarn create vite
cd <your-project-name>
yarn
yarn add hydrogen-view-sdk
```
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 (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
import {
Platform,
Client,
LoadStatus,
createNavigation,
createRouter,
RoomViewModel,
TimelineView,
viewClassForTile
} from "hydrogen-view-sdk";
import downloadSandboxPath from 'hydrogen-view-sdk/download-sandbox.html?url';
import workerPath from 'hydrogen-view-sdk/main.js?url';
import olmWasmPath from '@matrix-org/olm/olm.wasm?url';
import olmJsPath from '@matrix-org/olm/olm.js?url';
import olmLegacyJsPath from '@matrix-org/olm/olm_legacy.js?url';
const assetPaths = {
downloadSandbox: downloadSandboxPath,
worker: workerPath,
olm: {
wasm: olmWasmPath,
legacyBundle: olmLegacyJsPath,
wasmBundle: olmJsPath
}
};
import "hydrogen-view-sdk/assets/theme-element-light.css";
// OR import "hydrogen-view-sdk/assets/theme-element-dark.css";
async function main() {
const app = document.querySelector<HTMLDivElement>('#app')!
const config = {};
const platform = new Platform({container: app, assetPaths, config, options: { development: import.meta.env.DEV }});
const navigation = createNavigation();
platform.setNavigation(navigation);
const urlRouter = createRouter({
navigation: navigation,
history: platform.history
});
urlRouter.attach();
const client = new Client(platform);
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)).

238
doc/SENDING.md Normal file
View file

@ -0,0 +1,238 @@
# Remaining stuffs
- don't swallow send errors, they should probably appear in the room error?
- not sure it makes sense to show them where the composer is,
because they might get sent a long time after you enter them in brawl,
so you don't neccessarily have the context of the composer anymore
- local echo
takes care of rate limiting,
and sending events from different rooms in parallel,
NO: txnIds are created inside room. ~~making txnIds? ... it's rooms though that will receive the event in their sync response~~
EventSender:
// used for all kinds of events (state, redaction, ...)
sendEvent(roomId, pendingEvent...Entry?)
how will we do local echo?
a special kind of entry? will they be added to the same list?
how do we store pending events?
OBSOLETE, see PendingEvent below:
separate store with:
roomId
txnId
priority
queueOrder
partialEvent
remoteEventId ? // once we've received a response, but haven't received the event through sync yet. would be nice if you refresh then, that the message doesn't disappear. Actually, we don't need the event id for that, just to only delete it when we receive something down the sync with the same transaction id?
// all the fields that might need to be sent to the server when posting a particular kind of event
PendingEvent
queueOrder //is this high enough to
priority //high priority means it also takes precedence over events sent in other rooms ... but how will that scheduling work?
txnId
type
stateKey
redacts
content
localRelatedId //what's the id? queueOrder? e.g. this would be a local id that this event relates to. We might need an index on it to update the PendingEvent once the related PendingEvent is sent.
blob: a blob that needs to be uploaded and turned into a mxc to put into the content.url field before sending the event
there is also info.thumbnail_url
blobMimeType? Or stored as part of blob?
//blobUploadByteOffset: to support resumable uploads?
so when sending an event, we don't post a whole object, just the content, or a state key and content, or a redacts id.
however, it's somewhat interesting to pretend an event has the same structure before it is sent, then when it came down from the server, so all the logic can reuse the same structure...
we could potentially have a PendingEventEntry, that shares most of its API with EventEntry ... but is there a good reason to do so?
PendingEvent would be a bit less hackish this way
we could have a base class shared between PendingEventEntry and EventEntry to do most of the work, and only have things like getStateKey, getContent, ... in the subclasses?
wrt to pending events in the timeline, their entry would have a special fragmentId, always placing them behind the last "real" event, and the queueOrder could be the entryIndex.
how will local echo work for:
relations
this would depend a lot on how relations work ... I guess the only relevant part is that aggregation can be undo, and redone
redactions
again, depends a lot on how redactions would be implemented. somehow
images (that are uploading)
we could have a createUrl/destroyUrl method? returns local blob url for pending event
and also an optional progress method?
how will the EventSender tell the rooms to start submitting events again when coming online again after being offline for a while?
we'll need to support some states for the UI:
- offline
- queued
- uploading attachment ?
- sending
- sent
offline is an external factor ... we probably need to deal with it throughout the app / matrix level in some way ...
- we could have callback on room for online/offline that is invoked by session, where they can start sending again?
perhaps with a transaction already open on the pending_events store
How could the SendQueue update the timeline? By having an ObservableMap for it's entries in the queue
Room
SendQueue
Timeline
steps of sending
```javascript
//at some point:
// sender is the thing that is shared across rooms to handle rate limiting.
const sendQueue = new SendQueue({roomId, hsApi, sender, storage});
await sendQueue.load(); //loads the queue?
//might need to load members for e2e rooms ...
//events should be encrypted before storing them though ...
// terminology ...?
// task: to let us wait for it to be our turn
// given rate limiting
class Sender {
acquireSlot() {
return new SendSlot();
}
}
// terminology ...?
// task: after waiting for it to be our turn given rate-limiting,
// send the actual thing we want to send.
// this should be used for all rate-limited apis... ?
class SendSlot {
sendContent(content) {
}
sendRedaction() {
}
uploadMedia() {
}
}
class SendQueue {
// when trying to send
enqueueEvent(pendingEvent) {
// store event
// if online and not running send loop
// start sending loop
}
// send loop
// findNextPendingEvent comes from memory or store?
// if different object then in timeline, how to update timeline thingy?
// by entryKey? update it?
_sendLoop() {
while (let pendingEvent = await findNextPendingEvent()) {
pendingEvent.status = QUEUED;
try {
const mediaSlot = await this.sender.acquireSlot();
const mxcUrl = await mediaSlot.uploadMedia(pendingEvent.blob);
pendingEvent.content.url = mxcUrl;
const contentSlot = await this.sender.acquireSlot();
contentSlot.sendContent(pendingEvent.content);
pendingEvent.status = SENDING;
await slot.sendContent(...);
} catch (err) {
//offline
}
pendingEvent.status = SENT;
}
}
resumeSending(online) {
// start loop again when back online
}
// on sync, when received an event with transaction_id
// the first is the transaction_id,
// the second is the storage transaction to modify the pendingevent store if needed
receiveRemoteEcho(txnId, txn) {
}
// returns entries? to be appended to timeline?
// return an ObservableList here? Rather ObservableMap? what ID? queueOrder? that won't be unique over time?
// wrt to relations and redactions, we will also need the list of current
// or we could just do a lookup of the local id to remote once
// it's time to send an event ... perhaps we already have the txn open anyways.
// so we will need to store the event_id returned from /send...
// but by the time it's time to send an event, the one it relates to might already have been
// remove from pendingevents?
// maybe we should have an index on relatedId or something stored in pendingevents and that way
// we can update it once the relatedto event is sent
// ok, so we need an index on relatedId, not the full list for anything apart from timeline display? think so ...
get entriesMap() {
}
}
class Room {
resumeSending(online) {
if (online) {
this.sendQueue.setOnline(online);
}
}
}
```
we were thinking before of having a more lightweight structure to export from timeline, where we only keep a sorted list/set of keys in the collection, and we emit ranges of sorted keys that are either added, updated or removed. we could easily join this with the timeline and values are only stored by the TilesCollection. We do however need to peek into the queue to update local relatedTo ids.
probably best to keep send queue in memory.
so, persistence steps in sending:
- get largest queueOrder + 1 as id/new queueOrder
- the downside of this that when the last event is sent at the same time as adding a new event it would become an update? but the code paths being separate (receiveRemoteEcho and enqueueEvent) probably prevent this.
- persist incoming pending event
- update with remote id if relatedId for pending event
- update once attachment(s) are sent
- send in-memory updates of upload progress through pending event entry
- if the media store supports resumable uploads, we *could* also periodically store how much was uploaded already. But the current REST API can't support this.
- update once sent (we don't remove here until we've receive remote echo)
- store the remote event id so events that will relate to this pending event can get the remote id through getRelateToId()
- remove once remote echo is received
(Pending)EventEntry will need a method getRelateToId() that can return an instance of LocalId or something for unsent events
if we're not rate limited, we'll want to upload attachments in parallel with sending messages before attachee event.
so as long as not rate limited, we'd want several queues to send per room
```
sender (room 1)
---------------------
^ ^
event1 attachment1
^ |
event2-------
```
later on we can make this possible, for now we just upload the attachments right before event.
so, we need to write:
RateLimitedSender
all rate-limited rest api calls go through here so it can coordinate which ones should be prioritized and not
do more requests than needed while rate limited. It will have a list of current requests and initially just go from first to last but later could implement prioritizing the current room, events before attachments, ...
SendQueue (talks to store, had queue logic) for now will live under timeline as you can't send events for rooms you are not watching? could also live under Room so always available if needed
PendingEvent (what the store returns) perhaps doesn't even need a class? can all go in the entry
PendingEventEntry (conforms to Entry API)
can have static helper functions to create given kind of events
PendingEventEntry.stateEvent(type, stateKey, content)
PendingEventEntry.event(type, content, {url: file, "info.thumbnail_url": thumb_file})
PendingEventEntry.redaction(redacts)
PendingEventStore
add()
maxQueueOrder
getAll()
get()
update()
remove()
make sure to handle race between /sync and /send (e.g. /sync with sent event may come in before /send returns)

22
doc/SKINNING.md Normal file
View file

@ -0,0 +1,22 @@
# Replacing javascript files
Any source file can be replaced at build time by mapping the path in a JSON file passed in to the build command, e.g. `yarn build --override-imports customizations.json`. The file should be written like so:
```json
{
"src/platform/web/ui/session/room/timeline/TextMessageView.js": "src/platform/web/ui/session/room/timeline/MyTextMessageView.js"
}
```
The paths are relative to the location of the mapping file, but the mapping file should be in a parent directory of the files you want to replace.
You should see a "replacing x with y" line (twice actually, for the normal and legacy build).
# Injecting CSS
You can override the location of the main css file with the `--override-css <file>` option to the build script. The default is `src/platform/web/ui/css/main.css`, which you probably want to import from your custom css file like so:
```css
@import url('src/platform/web/ui/css/main.css');
/* additions */
```

204
doc/THEMING.md Normal file
View file

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

77
doc/TODO.md Normal file
View file

@ -0,0 +1,77 @@
# Minimal thing to get working
- DONE: finish summary store
- DONE: move "sdk" bits over to "matrix" directory
- DONE: add eventemitter
- DONE: make sync work
- DONE: store summaries
- DONE: setup editorconfig
- DONE: setup linting (also in editor)
- DONE: store timeline
- DONE: store state
- DONE: make summary work better (name and joined/inviteCount doesn't seem to work well)
- DONE: timeline doesn't seem to recover it's key well upon loading, the query in load seems to never yield an event in the persister
- DONE: map DOMException to something better
- it's pretty opaque now when something idb related fails. DOMException has these fields:
code: 0
message: "Key already exists in the object store."
name: "ConstraintError"
- DONE: emit events so we can start showing something on the screen maybe?
- DONE: move session._rooms over to Map, so we can iterate over it, ...
- DONE: build a very basic interface with
- DONE: a start/stop sync button
- DONE: a room list sorted alphabetically
- DONE: do some preprocessing on sync response which can then be used by persister, summary, timeline
- DONE: support timeline
- DONE: clicking on a room list, you see messages (userId -> body)
- DONE: style minimal UI
- DONE: implement gap filling and fragments (see FRAGMENTS.md)
- DONE: allow collection items (especially tiles) to self-update
- improve fragmentidcomparer::add
- DONE: better UI
- fix MappedMap update mechanism
- see if in BaseObservableMap we need to change ...params
- DONE: put sync button and status label inside SessionView
- fix some errors:
- find out if `(this._emitCollectionUpdate)(this)` is different than `this._emitCollectionUpdate(this)`
- got "database tried to mutate when not allowed" or something error as well
- find out why when RoomPersister.(\_createGapEntry/\_createEventEntry) we remove .buffer the transaction fails (good), but upon fixing and refreshing is missing a message! syncToken should not be saved, so why isn't this again in the sync response and now the txn does succeed?
- DONE: take access token out of IDB? this way it can be stored in a more secure thing for non-web clients, together wit encryption key for olm sessions ... ? like macos keychain, gnome keyring, ... maybe using https://www.npmjs.com/package/keytar
- DONE: experiment with using just a normal array with 2 numbers for sortkeys, to work in Edge as well.
- DONE: send messages
- DONE: fill gaps with call to /messages
- DONE: build script
- DONE: take dev index.html, run some dom modifications to change script tag with `parse5`.
- DONE: create js bundle, rollup
- DONE: create css bundle, postcss, probably just need postcss-import for now, but good to have more options
- DONE: put all in /target
- have option to run it locally to test
- deploy script
- upload /target to github pages
- DONE: offline available
- both offline mechanisms have (filelist, version) as input for their template:
- create appcache manifest with (index.html, brawl.js, brawl.css) and print version number in it
- create service worker wit file list to cache (at top const files = "%%FILES_ARRAY%%", version = "%%VERSION%%")
- write web manifest
- DONE: delete and clear sessions from picker
- option to close current session and go back to picker
- accept invite
- member list
- e2e encryption
- sync retry strategy
- instead of stopping sync on fetch error, show spinner and status and have auto retry strategy
- create room
- join room
- leave room
- unread rooms, badge count, sort rooms by activity
- DONE: create sync filter
- DONE: lazy loading members
- decide denormalized data in summary vs reading from multiple stores PER room on load
- allow Room/Summary class to be subclassed and store additional data?
- store account data, support read markers

38
doc/TS-MIGRATION.md Normal file
View file

@ -0,0 +1,38 @@
# Typescript style guide
## Use `type` rather than `interface` for named parameters and POJO return values.
`type` and `interface` can be used somewhat interchangeably, but let's use `type` to describe data and `interface` to describe (polymorphic) behaviour.
Good examples of data are option objects to have named parameters, and POJO (plain old javascript objects) without any methods, just fields.
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.

3
doc/UI/index.md Normal file
View file

@ -0,0 +1,3 @@
# Index for UI code
1. [Rendering DOM elements](./render-dom-elements.md)

View file

@ -0,0 +1,47 @@
tldr; Use `tag` from `ui/general/html.js` to quickly create DOM elements.
## Syntax
---
The general syntax is as follows:
```js
tag.tag_name({attribute1: value, attribute2: value, ...}, [child_elements]);
```
**tag_name** can be any one of the following:
```
br, a, ol, ul, li, div, h1, h2, h3, h4, h5, h6,
p, strong, em, span, img, section, main, article, aside,
pre, button, time, input, textarea, label, form, progress, output, video
```
<br />
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
tag.section({className: "main-section"},[
tag.h1("Demo"),
tag.button({className:"btn_cool"}, "Click me")
]);
```
<br />
**Note:** In views based on `TemplateView`, you will see `t` used instead of `tag`.
`t` is is `TemplateBuilder` object passed to the render function in `TemplateView`.
Although syntactically similar, they are not functionally equivalent.
Primarily `t` **supports** bindings and event handlers while `tag` **does not**.
```js
// The onClick here wont work!!
tag.button({className:"awesome-btn", onClick: () => this.foo()});
render(t, vm){
// The onClick works here.
t.button({className:"awesome-btn", onClick: () => this.foo()});
}
```

206
doc/UI/ui.md Normal file
View file

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

90
doc/api.md Normal file
View file

@ -0,0 +1,90 @@
Session
properties:
rooms -> Rooms
# storage
Storage
key...() -> KeyRange
start...Txn() -> Transaction
Transaction
store(name) -> ObjectStore
finish()
rollback()
ObjectStore : QueryTarget
index(name)
Index : QueryTarget
Rooms: EventEmitter, Iterator<RoomSummary>
get(id) -> RoomSummary ?
InternalRoom: EventEmitter
applySync(roomResponse, membership, txn)
- this method updates the room summary
- persists the room summary
- persists room state & timeline with RoomPersister
- updates the OpenRoom if present
applyAndPersistSync(roomResponse, membership, txn) {
this._summary.applySync(roomResponse, membership);
this._summary.persist(txn);
this._roomPersister.persist(roomResponse, membership, txn);
if (this._openRoom) {
this._openRoom.applySync(roomResponse);
}
}
RoomPersister
RoomPersister (persists timeline and room state)
RoomSummary (persists room summary)
RoomSummary : EventEmitter
methods:
async open()
id
name
lastMessage
unreadCount
mentionCount
isEncrypted
isDirectMessage
membership
should this have a custom reducer for custom fields?
events
propChange(fieldName)
OpenRoom : EventEmitter
properties:
timeline
events:
RoomState: EventEmitter
[room_id, event_type, state_key] -> [sort_key, event]
Timeline: EventEmitter
// should have a cache of recently lookup sender members?
// can we disambiguate members like this?
methods:
lastEvents(amount)
firstEvents(amount)
eventsAfter(sortKey, amount)
eventsBefore(sortKey, amount)
events:
eventsApppended
RoomMembers : EventEmitter, Iterator
// no order, but need to be able to get all members somehow, needs to map to a ReactiveMap or something
events:
added(ids, values)
removed(ids, values)
changed(id, fieldName)
RoomMember: EventEmitter
properties:
id
name
powerLevel
membership
avatar
events:
propChange(fieldName)

47
doc/architecture.md Normal file
View file

@ -0,0 +1,47 @@
The matrix layer consists of a `Session`, which represents a logged in user session. It's the root object you can get rooms off. It can persist and load itself from storage, at which point it's ready to be displayed. It doesn't sync it's own though, and you need to create and start a Sync object for updates to be pushed and persisted to the session. `Sync` is the thing (although not the only thing) that mutates the `Session`, with `Session` being unaware of `Sync`.
The matrix layer assumes a transaction-based storage layer, modelled much to how IndexedDB works. The idea is that any logical operation like process sync response, send a message, ... runs completely in a transaction that gets aborted if anything goes wrong. This helps the storage to always be in a consistent state. For this reason you'll often see transactions (txn) being passed in the code. Also, the idea is to not emit any events until readwrite transactions have been committed.
- Reduce the chance that errors (in the event handlers) abort the transaction. You *could* catch & rethrow but it can get messy.
- Try to keep transactions as short-lived as possible, to not block other transactions.
For this reason a `Room` processes a sync response in two phases: `persistSync` & `emitSync`, with the return value of the former being passed into the latter to avoid double processing.
## Timeline, fragments & event indices.
A room in matrix is a DAG (directed, acyclic graph) of events, also known as the timeline. brawl is only aware of fragments of this graph, and can be unaware how these fragments relate to each other until a common event is found while paginating a fragment. After doing an initial sync, you start with one fragment. When looking up an event with the `/context` endpoint (for fetching a replied to message, or navigating to a given event id, e.g. through a permalink), a new, unconnected, fragment is created. Also, when receiving a limited sync response during incremental sync, a new fragment is created. Here, the relationship is clear, so they are immediately linked up at creation. Events in brawl are identified within a room by `[fragment_id, event_index]`. The `event_index` is an unique number within a fragment to sort events in chronological order in the timeline. `fragment_id` cannot be directly compared for sorting (as the relationship may be unknown), but with help of the `FragmentIndex`, one can attempt to sort events by their `FragmentIndex([fragment_id, event_index])`.
A fragment is the following data structure:
```
let fragment := {
roomId: string
id: number
previousId: number?
nextId: number?
previousToken: string?
nextToken: string?
}
```
## Observing the session
`Room`s on the `Session` are exposed as an `ObservableMap` collection, which is like an ordinary `Map` but emits events when it is modified (here when a room is added, removed, or the properties of a room change). `ObservableMap` can have different operators applied to it like `mapValues()`, `filterValues()` each returning a new `ObservableMap`-like object, and also `sortValues()` returning an `ObservableList` (emitting events when a room at an index is added, removed, moved or changes properties).
So for example, the room list, `Room` objects from `Session.rooms` are mapped to a `RoomTileViewModel` and then sorted. This gives us fine-grained events at the end of the collection chain that can be easily and efficiently rendered by the `ListView` component.
On that note, view components are just a simple convention, having these methods:
- `mount()` - prepare to become part of the document and interactive, ensure `root()` returns a valid DOM node.
- `root()` - the room DOM node for the component. Only valid to be called between `mount()` and `unmount()`.
- `update(attributes)` (to be renamed to `setAttributes(attributes)`) - update the attributes for this component. Not all components support all attributes to be updated. For example most components expect a viewModel, but if you want a component with a different view model, you'd just create a new one.
- `unmount()` - tear down after having been removed from the document.
The initial attributes are usually received by the constructor in the first argument. Other arguments are usually freeform, `ListView` accepting a closure to create a child component from a collection value.
Templating and one-way databinding are neccesary improvements, but not assumed by the component contract.
Updates from view models can come in two ways. View models emit a change event, that can be listened to from a view. This usually includes the name of the property that changed. This is the mechanism used to update the room name in the room header of the currently active room for example.
For view models part of an observable collection (and to be rendered by a ListView), updates can also propagate through the collection and delivered by the ListView to the view in question. This avoids every child component in a ListView having to attach a listener to it's viewModel. This is the mechanism to update the room name in a RoomTile in the room list for example.
TODO: specify how the collection based updates work. (not specified yet, we'd need a way to derive a key from a value to emit an update from within a collection, but haven't found a nice way of specifying that in an api)

58
doc/docker.md Normal file
View file

@ -0,0 +1,58 @@
## Warning
Usage of docker is a third-party contribution and not actively tested, used or supported by the main developer(s).
Having said that, you can also use Docker to create a local dev environment or a production deployment.
## Dev environment
In this repository, create a Docker image:
```
docker build -t hydrogen-dev -f Dockerfile-dev .
```
Then start up a container from that image:
```
docker run \
--name hydrogen-dev \
--publish 3000:3000 \
--volume "$PWD":/code \
--interactive \
--tty \
--rm \
hydrogen-dev
```
Then point your browser to `http://localhost:3000`. You can see the server logs in the terminal where you started the container.
To stop the container, simply hit `ctrl+c`.
## Production deployment
### Build or pull image
In this repository, create a Docker image:
```
docker build -t hydrogen .
```
Or, pull the docker image from GitLab:
```
docker pull registry.gitlab.com/jcgruenhage/hydrogen-web
docker tag registry.gitlab.com/jcgruenhage/hydrogen-web hydrogen
```
### Start container image
Then, start up a container from that image:
```
docker run \
--name hydrogen \
--publish 80:80 \
hydrogen
```

View file

@ -0,0 +1,2 @@
err.name: explanation
DataError: parameters to idb request where invalid

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -0,0 +1,12 @@
we should automatically fill gaps (capped at a certain (large) amount of events, 5000?) after a limited sync for a room
## E2EE rooms
during these fills (once supported), we should calculate push actions and trigger notifications, as we would otherwise have received this through sync.
we could also trigger notifications when just backfilling on initial sync up to a certain amount of time in the past?
we also need to backfill if we didn't receive any m.room.message in a limited sync for an encrypted room, as it's possible the room summary hasn't seen the last message in the room and is now out of date. this is also true for a non-encrypted room actually, although wrt to the above, here notifications would work well though.
a room should request backfills in needsAfterSyncCompleted and do them in afterSyncCompleted.

View file

@ -0,0 +1,3 @@
use mock view models or even a mock session to render different states of the app in a static html document, where we can somehow easily tweak the css (just browser tools, or do something in the page?) how to persist css after changes?
Also dialogs, forms, ... could be shown on this page.

264
doc/impl-thoughts/E2EE.md Normal file
View file

@ -0,0 +1,264 @@
# Implementing e2e encryption:
## Olm
- implement MemberList as ObservableMap
- make sure we have all members (as we're using lazy loading members), and store these somehow
- keep in mind that the server might not support lazy loading? E.g. we should store in a memberlist all the membership events passed by sync, perhaps with a flag if we already attempted to fetch all. We could also check if the server announces lazy loading support in the version response (I think r0.6.0).
- do we need to update /members on every limited sync response or did we find a way around this?
- I don't think we need to ... we get all state events that were sent during the gap in `room.state`
- I tested this with riot and synapse, and indeed, we get membership events from the gap on a limited sync. This could be clearer in the spec though.
- fields:
- user id
- room id
- membership (invite, join, leave, ban)
- display name
- avatar url
- needs disambiguation in member list? (e.g. display name is not unique)
- device tracking status
- key [room id, user id] so we can easily get who is in a room by looking at [room id, min] -> [room id, max]
should the display name also be part of the key so the list is sorted by name? or have a sorting field of some sort
- index on:
- [user_id, room_id] to see which rooms a user is in, e.g. to recalculate trust on key changes
- [room id, display name] to determine disambiguation?
- for just e2ee without showing the list in the UI, we can do with only some of these things.
- implement creating/loading an olm account
- add libolm as dependency
- store pickled account
- load pickled account
- update pickled account
- add initial one-time keys
- publish keys with /keys/upload
- implement creating/loading an olm session for (userid, deviceid)
- get olm session for [(userid, deviceid), ...] (array so they can all go out in one /keys/claim call?)
- create if not exists
- claim one-time key with /keys/claim
- verify signature on key
- ??? what about inbound/outbound sessions? do they require multiple OlmSession objects?
- doesn't look like it, more like a way to start the session but once started (type=1), they are equivalent?
- for outbound, see https://matrix.org/docs/guides/end-to-end-encryption-implementation-guide#starting-an-olm-session
- for inbound, see: https://matrix.org/docs/guides/end-to-end-encryption-implementation-guide#handling-an-mroomencrypted-event
- so in this case, it would the session would be created as an outbound session.
- store pickled, index by curve25519 identity key?
- get from storage if exists and unpickle
- implement device tracking
- store users?
- needs_update (riot has status with 4 states: not tracked, pending download, downloading, up to date)
- set during sync for users that appear in device_lists.changed
- store devices
- id
- userid
- signing PK
- identity PK
- algorithms
- device name
- verified
- known? riot has this ... what does it mean exactly?
- handle device_lists.changed in /sync response
- call /keys/changed to get updated devices and store them
- handle device_lists.left in /sync response
- we can keep verified devices we don't share a room with anymore around perhaps, but
shouldn't update them ... which we won't do anyway as they won't appear in changed anymore
- when e2e is enabled, start tracking:
- call /keys/query for all members in MemberList
- verify signature on device keys
- store devices
- track which e2ee rooms a user is in? This so we don't need to load the member list when figuring out for which rooms a device changes has an effect. Maybe not yet needed here but we will need it to recalculate room trust. Perhaps we can also reuse the membership store if we have an index on (only) userid so we can ask with one query which rooms a user is in.
- implement maintaining one-time keys on server
- update account with new new keys when /sync responded with device_one_time_keys_count < MAX/2
- upload new one-time keys to /keys/upload
- mark them as published in account
- update picked session in storage
- implement encrypting olm messages
- roughly https://matrix.org/docs/guides/end-to-end-encryption-implementation-guide#encrypting-an-event-with-olm
- packaging as m.room.encrypted event
- implement decrypting olm messages
- roughly https://matrix.org/docs/guides/end-to-end-encryption-implementation-guide#handling-an-mroomencrypted-event
- decrypt with libolm
- verify signature
- check message index, etc to detect replay attacks
- handling wedged olm sessions
- ???
## Megolm
- ??? does every sender in a room have their own megolm session (to send)? I suppose so, yes
- we need to pickle inbound and outbound sessions separately ... are they different entities?
- they are: OutboundGroupSession and InboundGroupSession
- should they be in different stores?
- e.g. we have a store for outbound sessions (to send ourselves) and one for inbound
- NO! the e2e implementation guide says specifically:
"It should store these details as an inbound session, just as it would when receiving them via an m.room_key event."
- wait, we probably have to store the session as BOTH an inbound and outbound session?
- the outbound one so we can keep using it to encrypt
- the inbound one to be able to decrypt our own messages? as we won't send a m.room_key to our own device
- so yes, we'll store our own outbound sessions. Riot doesn't do this and just starts new ones when starting the client,
but keeping this would probably give us better offline support/less network usage as we wouldn't have to create new megolm session most of the time
- and we store the inbound sessions (including the ones derived from our own outbound sessions) to be able to decrypt all messages
- create new megolm session
- create new outbound group session
- get megolm session id and key, put in m.room_key event
- store megolm session
- encrypt using olm and send as m.room.encrypted device message
- receiving new megolm session
- listen for m.room_key device message
- decrypt using olm
- create inbound group session
- store megolm session
- encrypt megolm message
- decrypt megolm message
- rotate megolm session
- ??? does this happen automatically?
- deactive sessions when members leave the room
## SendQueue
we'll need to pass an implementation of EventSender or something to SendQueue that does the actual requests to send a message, one implementation for non-e2ee rooms (upload attachment, send event OR redact, ...) and one for e2ee rooms that send the olm keys, etc ... encrypts the message before sending, reusing as much logic as possible. this will entail multiple sendScheduler.request slots, as we should only do one request per slot, making sure if we'd restart that steps completed in sending are stored so we don't run them again (advancing olm key, ...) or they are safe to rerun. The `E2eeEventSender` or so would then also be the thing that has a dependency on the memberlist for device tracking, which keeps the dependency tree clean (e.g. no setMembers on a class that does both e2ee and non-e2ee). We would also need to be able to encrypt non-megolm events with Olm, like 4S gossiping, etc ...
## Verifying devices
- validate fingerprint
- have a look at SAS?
## Encrypted attachments
- use AES-CTR from https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto
## Notes
- libolm api docs (also for js api) would be great. Found something that could work:
https://gitlab.matrix.org/matrix-org/olm/-/blob/master/javascript/index.d.ts
## OO Design
e2ee/MemberList
// changes user tracking and returns changed members
// this probably needs to be run after updates to the rooms have been written
// to the txn so that if encryption was enabled in the same sync,
// or maybe not as we probably don't get device updates for a room we just joined/enabled encryption in.
async writeSync(txn)
emitSync(changes)
async addRoom(roomId, userIds, txn)
async addMember(roomId, userId, txn)
async removeMember(roomId, userId, txn)
async getMember(userId, txn)
// where would we use this? to encrypt?
// - does that need to be observable? well, at least updatable
// - to derive room trust from ... but how will this work with central emit point for room updates?
// check observablevalue trust before and after sync and detect change ourselves?
// set flag on room when observablevalue trust value emitted update and then reemit in emitSync?
// ALSO, we need to show trust for all rooms but don't want to have to load all EncryptionUsers and their devices for all e2ee rooms.
// can we build trust incrementally?
// trusted + new unverified device = untrusted
// trusted + some device got verified = ?? //needs a full recheck, but could be ok to do this after verification / cross signing by other party
// trusted + some device/user got unverified = untrusted (not supported yet, but should be possible)
// so sounds possible, but depends on how/if we can build decryption without needing all members
async openMembersForRoom(roomId) : ObservableMap<userId, EncryptionUser>`
// can we easily prevent redundancy between e2ee rooms that share the same member?
e2ee/EncryptionUser
get trackingStatus()
get roomIds()
// how to represent we only keep these in memory for e2ee rooms?
// for non-e2ee we would need to load them from storage, so needing an async method,
// but for e2ee we probably don't want to await a Promise.resolve for every member when encrypting, decrypting, ... ? or would it be that bad?
// should we index by sender key here and assume Device is only used for e2ee? Sounds reasonable ...
`get devices() : ObservableMap<senderKey, Device>`
would be nice if we could expose the devices of a member as an observable list on the member
at the same time, we need to know if any member needs updating devices before sending a message... but this state would actually be kept on the member, so that works.
we do need to aggregate all the trust in a room though for shields... how would trust be added to this?
```js
// do we need the map here?
const roomTrust = memberList.members.map(m => m.trust).reduce((minTrust, trust) => {
if (!minTrust || minTrust.compare(trust) < 0) {
return trust;
}
return minTrust;
});
```
e2ee/Device
// the session we should use to encrypt with, or null if none exists
get outboundSession()
// should this be on device or something more specific to crypto? Although Device is specific to crypto ...
createOutboundSession()
// gets the matching session, or creates one if needed/allowed
async getInboundSessionForMessage()
e2ee/olm/OutboundSession
encrypt(type, content, txn) (same txn should be used that will add the message to pendingEvents, here used to advance ratchet)
e2ee/olm/InboundSession
decrypt(payload, txn)
e2ee/olm/Account
// for everything in crypto, we should have a method to persist the intent
createOTKs(txn)
// ... an another one to upload it, persisting that we have in fact uploaded it
uploadOTKs(txn)
DeviceList
writeSync(txn)
emitSync()
queryPending()
actually, we need two member stores:
- (Member) one for members per room with userid, avatar url, display name, power level, ... (most recent message timestamp)?
- (EncryptionUser) one per unique member id for e2ee, with device tracking status, and e2ee rooms the member is in? If we duplicate this over every room the user is in, we complicate device tracking.
the e2ee rooms an EncryptionUser is in needs to be notified of device (tracking) changes to update its trust shield. The fact that the device list is outdated or that we will need to create a new olm session when sending a message should not emit an event.
requirements:
- Members need to be able to exists without EncryptionUser
- Members need to be able to map to an EncryptionUser (by userId)
- Member needs to have trust property derived from their EncryptionUser, with updates triggered somehow in central point, e.g. by Room.emitSync
- also, how far do we want to take this central update point thing? I guess any update that will cascade in a room (summary) update ... so here adding a device would cascade into the room trust changing, which we want to emit from Room.emitSync.
- hmm, I wonder if it makes sense to do this over member, or rather expose a second ObservableMap on the room for EncryptionUser where we can monitor trust
- PROs separate observablemap:
- don't need to load member list to display shields of user in timeline ... this might be fine though as e2ee rooms tend to be smaller rooms, and this is only for the room that is open.
- CONs separate observablemap:
- more clunky api, would need a join operator on ObservableMap to join the trust and Member into one ObservableMap to be able to display member list.
- See if it is doable to sync e2ee rooms without having all their encryptionUsers and devices in memory:
- Be able to decrypt *without* having all EncryptionUsers of a room and their devices in memory, but rather use indices on storage to load just what we need. Finding a matching inbound olm session is something we need to think how to do best. We'll need to see this one.
- Be able to encrypt may require having all EncryptionUsers of a room and their devices in memory, as we typically only send in the room we are looking at (but not always, so forwarding an event, etc... might then require to "load" some of the machinery for that room, but that should be fine)
- Be able to send EncryptionUser updates *without* having all EncryptionUsers and their devices in memory
other:
- when populating EncryptionUsers for an e2ee room, we'll also populate Members as we need to fetch them from the server anyway.
- Members can also be populated for other reasons (showing member list in non-e2ee room)
we should adjust the session store to become a key value store rather than just a single value, we can split up in:
- syncData (filterId, syncToken, syncCount)
- serverConfig (/versions response)
- serialized olm Account
so we don't have write all of that on every sync to update the sync token
new stores:
room-members
e2ee-users
e2ee-devices
inbound-olm-sessions
outbound-olm-sessions
//tdb:
inbound-megolm-sessions
outbound-megolm-sessions
we should create constants with sets of store names that are needed for certain use cases, like write timeline will require [timeline, fragments, inbound-megolm-sessions] which we can reuse when filling the gap, writing timeline sync, ...
room summary should gain a field tracking if members have been loaded or not?
main things to figure out:
- how to decrypt? what indices do we need? is it reasonable to do this without having all EncryptionUser/devices in memory?
- big part of this is how we can find the matching olm session for an incoming event/create a new olm session
- can we mark some olm sessions as "spent" once they are too old/have used max messages? or do we need to look into all past olm sessions for a senderKey/device?

View file

@ -0,0 +1,88 @@
- DONE: write FragmentIndex
- DONE: adapt SortKey ... naming! :
- FragmentIdIndex (index as in db index)
- compare(idA, idB)
- SortKey
- FragmentId
- EventIndex
- DONE: write fragmentStore
- load all fragments
- add a fragment (live on limited sync, or /context)
- connect two fragments
- update token on fragment (when filling gap or connecting two fragments)
fragments can need connecting when filling a gap or creating a new /context fragment
- DONE: adapt timelineStore
how will fragments be exposed in timeline store?
- all read operations are passed a fragment id
- adapt persister
- DONE: persist fragments in /sync
- DONE: fill gaps / fragment filling
- DONE: load n items before and after key,
- DONE: need to add fragments as we come across boundaries
- DONE: also cache fragments? not for now ...
- DONE: not doing any of the above, just reloading and rebuilding for now
- DONE: adapt Timeline
- DONE: turn ObservableArray into ObservableSortedArray
- upsert already sorted sections
- DONE: upsert single entry
- DONE: adapt TilesCollection & Tile to entry changes
- add live fragment id optimization if we haven't done so already
- lets try to not have to have the fragmentindex in memory if the timeline isn't loaded
- could do this by only loading all fragments into index when filling gaps, backpaginating, ... and on persister load only load the last fragment. This wouldn't even need a FragmentIndex?
# Leftover items
implement SortedArray::setManySorted in a performant manner
implement FragmentIdComparator::add in a performant manner
there is some duplication (also in memory) between SortedArray and TilesCollection. Both keep a sorted list based on fragmentId/eventIndex... TilesCollection doesn't use the index in the event handlers at all. we could allow timeline to export a structure that just emits "these entries are a thing (now)" and not have to go through sorting twice. Timeline would have to keep track of the earliest key so it can use it in loadAtTop, but that should be easy. Hmmm. also, Timeline might want to be in charge of unloading parts of the loaded timeline, and for that it would need to know the order of entries. So maybe not ... we'll see.
check: do /sync events not have a room_id and /messages do???
so a gap is two connected fragments where either the first fragment has a nextToken and/or the second fragment has a previousToken. It can be both, so we can have a gap where you can fill in from the top, from the bottom (like when limited sync) or both.
also, filling gaps and storing /context, how do we find the fragment we could potentially merge with to look for overlapping events?
with /sync this is all fine and dandy, but with /context is there a way where we don't need to look up every event_id in the store to see if it's already there?
we can do a anyOf(event_id) on timelineStore.index("by_index") by sorting the event ids according to IndexedDb.cmp and passing the next value to cursor.continue(nextId).
so we'll need to remove previous/nextEvent on the timeline store and come up with a method to find the first matched event in a list of eventIds.
so we'll need to map all event ids to an event and return the first one that is not null. If we haven't read all events but we know that all the previous ones are null, then we can already return the result.
we can call this findFirstEventIn(roomId, [event ids])
thoughts:
- ranges in timeline store with fragmentId might not make sense anymore as doing queries over multiple fragment ids doesn't make sense anymore ... still makes sense to have them part of SortKey though ...
- we need a test for querytarget::lookup, or make sure it works well ...
# Reading the timeline with fragments
- what format does the persister return newEntries after persisting sync or a gap fill?
- a new fragment can be created during a limited sync
- when doing a /context or /messages call, we could have joined with another fragment
- don't think we need to describe a result spanning multiple fragments here
so:
in case of limited sync, we just say there was a limited sync, this is the fragment that was created for it so we can show a gap in the timeline
in case of a gap fill, we need to return what was changed to the fragment (was it joined with another fragment, what's the new token), and which events were actually added.
we return entries! fragmentboundaryentry(start or end) or evententry. so looks much like the gaps we had before, but now they are not stored in the timeline store, but based on fragments.
- where do we translate from fragments to gap entries? and back? in the timeline object?
that would make sense, that seems to be the only place we need that translation
# SortKey
so, it feels simpler to store fragmentId and eventIndex as fields on the entry instead of an array/arraybuffer in the field sortKey. Currently, the tiles code somewhat relies on having sortKeys but nothing too hard to change.
so, what we could do:
- we create EventKey(fragmentId, eventIndex) that has the nextKey methods.
- we create a class EventEntry that wraps what is stored in the timeline store. This has a reference to the fragmentindex and has an opaque compare method. Tiles delegate to this method. EventEntry could later on also contain methods like MatrixEvent has in the riot js-sdk, e.g. something to safely dig into the event object.

View file

@ -0,0 +1,16 @@
# Local echo
## Remote vs local state for account_data, etc ...
For things like account data, and other requests that might fail, we could persist what we are sending next to the last remote version we have (with a flag for which one is remote and local, part of the key). E.g. for account data the key would be: [type, localOrRemoteFlag]
localOrRemoteFlag would be 1 of 3:
- Remote
- (Local)Unsent
- (Local)Sent
although we only want 1 remote and 1 local value for a given key, perhaps a second field where localOrRemoteFlag is a boolean, and a sent=boolean field as well? We need this to know if we need to retry.
This will allow resending of these requests if needed. Once the request goes through, we remove the local version.
then we can also see what the current value is with or without the pending local changes, and we don't have to wait for remote echo...

View file

@ -0,0 +1,11 @@
LoginView
LoginViewModel
SessionPickerView
SessionPickerViewModel
matrix:
SessionStorage (could be in keychain, ... for now we go with localstorage)
getAll()
Login

View file

@ -0,0 +1,136 @@
# TODO
## Member list
- support migrations in StorageFactory
- migrate all stores from key to key_path
- how to deal with members coming from backfill? do we even need to store them?
# How to store members?
All of this is assuming we'll use lazy loading of members.
Things we need to persist per member
- (user id)
- (room id)
- avatar url
- display name
## Historical members
store name: historical_members
### Without include_redundant_members
To show the correct (historical) display name and avatar next to a message, we need to manage historical members. If we don't set include_redundant_members as a filter, we'll need to track members per fragment, backwards and forwards looking, so we can find out which member to use for redundant members not included in the /messages response. E.g., if we have this timeline:
```
] gap #1 ]
[ messages ]
[ gap #2 ]
[ live messages ]
```
When expanding gap #1, we store the members. After that, we expand gap #2.
How do we know if the members already present are from gap #1 (and thus we need to replace them as gap #2 is later)
or live member changes at the bottom of the timeline (not to be overwritten as gap #2 comes before it).
We can store the fragmentId with a member? This is not enough, as somebody can change their display name in the middle of a fragment. We'd need a forward and backward pointing member per fragment.
```
room_id
fragment_id
forward
avatar_url
display_name
```
It would be good not to duplicate events too much.
We just need these for the extremities though...
I'm assuming `/context` will contain all members, as chronological relation with other chunks can't be assumed by clients? Looking at the riot code, this indeed seems to be the case.
#### Avoiding duplication of members
Ideally, historical_members would fall back on members to not duplicate all the member events. So the forward members for the sync fragment would be taking from the `members` store. Anytime we have a limited sync, this would be put in the backwards look members for the new sync fragment. If we then backpaginate, we can get our members from there. But we also don't want to duplicate the members between forward and backward looking members per fragment, so if they are the same, we only store it as forward. If the fragment is live, it comes from the `members` store.
This would mean that for fragments in the sync island:
- for members that change within a fragment
- we store the old member as backwards member,
- we store the new member as forward if not live fragment or if live in `members`
- when the live fragment changes, the old fragment forward looking members still need to point to the `members`? but those will change over time ... so need to be duplicated at the time of limited sync response? hmmmm
### With include_redundant_members
More data in response
we just set `include_redundant_members` and `/messages` and `/context` contain all their own members, which can be written to the event, and we track a partial member list from /sync, that can later be completed with /joined_members. This is *a lot* simpler.
If we go for this, we might want to think of a migration step to remove include_redundant_members? Well, maybe not before 1.0
IMPORTANT: I'm not sure that with `include_redundant_members` all the member state events will be included in the sync response, we need to test this.
## Member list
store name: members
We need to be able to get a list of all most recent members, and are not interested in historical members. We need it for:
- tracking devices for sending e2ee messages
- showing the member list
- member auto-completion from the composer
Once we decide to start tracking all members (when any of the above cases is triggered for the first time), we load all members with `/members?at=`, and keep updating it with the state and timeline state events of incoming /sync responses. Any member already stored should be replaced. We should have an index on roomId, and on [roomId, userId].
We need historical members (only) for the timeline, so either:
- we store the avatar url and display name on each event
- we need to store all versions of a member (and keep an in-memory cache to not have to read from yet another store while loading the timeline)
## General room state
We won't store `m.room.members` as room state. Any other state events should be stored in a separate store indexed by [roomId, eventType, stateKey].
----
Note that with lazy loading, we don't need all members to show the timeline, as the relevant state is passed in /sync and /messages (not true without include_redundant_members?). This state can be persisted in the members table, and we'll need a flag in room summary whether *all* members have been loaded. We'd insert in two ways:
- appending timeline, replace any members already there
- prepending timeline, don't touch members already there...
this won't work with multiple gaps though, if we have this timeline:
] gap #1 ]
[ messages ]
[ gap #2 ]
[ live messages ]
when expanding gap #1, we store the members. After that, we expand gap #2.
How do we know if the members already present are from gap #1 (and thus we need to replace them as gap #2 is later)
or live member changes at the bottom of the timeline (not to be overwritten as gap #2 comes before it).
We can store the fragmentId with a member? This is not enough, as somebody can change their display name in the middle of a fragment. We'd need a forward and backward pointing member per fragment.
We still have a problem with /context/{eventId} (permalinks) then, but could not store members in this case? As we would store the avatar and display name on the event anyway, we would only have less members in the store when filling permalinks, but if we need all members, we
Should we just bite the bullet and store historical members
# How to track members to add to incoming events
## for /sync
- have most-recently-used cache of *n* members per room
- cache takes members from ... ? persisted members? how do we get most recent members?
## for /messages
- everything will be in the response itself (is that also true without include_redundant_members?)
without include_redundant_members it does look like some members for which events are being returned
will not be included. So when back-paginating, we can take any member we know of with the same fragmentId, or one
that comes after (so we would need to load all membership events for a given userid, and filter them in memory using the fragmentidcomparer)
## for /context/{eventId}
- everything will be in the response itself (is that also true without include_redundant_members?)
I'm guessing include_redundant_members doesn't apply to /context because the client doesn't know
whether it comes before or after some part of the timeline it previously fetched.

View file

@ -0,0 +1,25 @@
# Replying to pending messages
The matrix spec requires clients capable of rich replies (that would be us once replies work) to include fallback (textual in `body` and structured in `formatted_body`) that can be rendered
by clients that do not natively support rich replies (that would be us at the time of writing). The schema for the fallback is as follows:
```
<mx-reply>
<blockquote>
<a href="https://matrix.to/#/!somewhere:example.org/$event:example.org">In reply to</a>
<a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a>
<br />
<!-- This is where the related event's HTML would be. -->
</blockquote>
</mx-reply>
```
There's a single complication here for pending events: we have `$event:example.org` in the schema (the `In reply to` link), and it must
be present _within the content_, inside `formatted_body`. The issue is that, if we are queuing a reply to a pending event,
we don't know its remote ID. All we know is its transaction ID on our end. If we were to use that while formatting the message,
we'd be sending messages that contain our internal transaction IDs instead of proper matrix event identifiers.
To solve this, we'd need `SendQueue`, whenever it receives a remote echo, to update pending events that are replies with their
`relatedEventId`. This already happens, and the `event_id` field in `m.relates_to` is updated. But we'd need to extend this
to adjust the messages' `formatted_body` with the resolved remote ID, too.
How do we safely do this, without accidentally substituting event IDs into places in the body where they were not intended?

22
doc/impl-thoughts/PUSH.md Normal file
View file

@ -0,0 +1,22 @@
# Push Notifications
- we setup the app on the sygnal server, with an app_id (io.element.hydrogen.web), generating a key pair
- we create a web push subscription, passing the server pub key, and get `endpoint`, `p256dh` and `auth` back. We put `webpush_endpoint` and `auth` in the push data, and use `p256dh` as the push key?
- we call `POST /_matrix/client/r0/pushers/set` on the homeserver with the sygnal instance url. We pass the web push subscription as pusher data.
- the homeserver wants to send out a notification, calling sygnal on `POST /_matrix/push/v1/notify` with for each device the pusher data.
- we encrypt and send with the data in the data for each device in the notification
- this wakes up the service worker
- now we need to find which local session id this notification is for
## Testing/development
- set up local synapse
- set up local sygnal
- write pushkin
- configure "hydrogen" app in sygnal config with a webpush pushkin
- start writing service worker code in hydrogen (we'll need to enable it for local dev)
- try to get a notification through
## Questions
- do we use the `event_id_only` format?
- for e2ee rooms, are we fine with just showing "Bob sent you a message (in room if not DM)", or do we want to sync and show the actual message? perhaps former can be MVP.

View file

@ -0,0 +1,5 @@
# Read receipts
## UI
For the expanding avatars, trimmed at 5 or so, we could use css grid and switch from the right most cell to a cell that covers the whole width when clicking.

View file

@ -0,0 +1,83 @@
# Reconnecting
`HomeServerApi` notifies `Reconnector` of network call failure
`Reconnector` listens for online/offline event
`Reconnector` polls `/versions` with a `RetryDelay` (implemented as ExponentialRetryDelay, also used by SendScheduler if no retry_after_ms is given)
`Reconnector` emits an event when sync and message sending should retry
`Sync` listen to `Reconnector`
`Sync` notifies when the catchup sync has happened
`Reconnector` has state:
- disconnected (and retrying at x seconds from timestamp)
- reconnecting (call /versions, and if successful /sync)
- connected
`Reconnector` has a method to try to connect now
`SessionStatus` can be:
- disconnected (and retrying at x seconds from timestamp)
- reconnecting
- connected (and syncing)
- doing catchup sync
- sending x / y messages
rooms should report how many messages they have queued up, and each time they sent one?
`SendReporter` (passed from `Session` to `Room`, passed down to `SendQueue`), with:
- setPendingEventCount(roomId, count). This should probably use the generic Room updating mechanism, e.g. a pendingMessageCount on Room that is updated. Then session listens for this in `_roomUpdateCallback`.
`Session` listens to `Reconnector` to update it's status, but perhaps we wait to send messages until catchup sync is done
# TODO
- DONE: finish (Base)ObservableValue
- put in own file
- add waitFor (won't this leak if the promise never resolves?)
- decide whether we want to inherit (no?)
- DONE: cleanup Reconnector with recent changes, move generic code, make imports work
- DONE: add SyncStatus as ObservableValue of enum in Sync
- DONE: cleanup SessionContainer
- DONE: move all imports to non-default
- DONE: remove #ifdef
- DONE: move EventEmitter to utils
- DONE: move all lower-cased files
- DONE: change main.js to pass in a creation function of a SessionContainer instead of everything it is replacing
- DONE: adjust BrawlViewModel, SessionPickViewModel and LoginViewModel to use a SessionContainer
- DONE: show load progress in LoginView/SessionPickView and do away with loading screen
- DONE: rename SessionsStore to SessionInfoStorage
- make sure we've renamed all \*State enums and fields to \*Status
- add pendingMessageCount prop to SendQueue and Room, aggregate this in Session
- DONE: add completedFirstSync to Sync, so we can check if the catchup or initial sync is still in progress
- DONE: update SyncStatusViewModel to use reconnector.connectionStatus, sync.completedFirstSync, session.syncToken (is initial sync?) and session.pendingMessageCount to show these messages:
- DONE: disconnected, retrying in x seconds. [try now].
- DONE: reconnecting...
- DONE: doing catchup sync
- syncing, sending x messages
- DONE: syncing
perhaps we will want to put this as an ObservableValue on the SessionContainer ?
NO: When connected, syncing and not sending anything, just hide the thing for now? although when you send messages it will just pop in and out all the time.
- see if it makes sense for SendScheduler to use the same RetryDelay as Reconnector
- DONE: finally adjust all file names to their class names? e.g. camel case
- see if we want more dependency injection
- for classes from outside sdk
- for internal sdk classes? probably not yet
thought: do we want to retry a request a couple of times when we can't reach the server before handing it over to the reconnector? Not that some requests may succeed while others may fail, like when matrix.org is really slow, some requests may timeout and others may not. Although starting a service like sync while it is still succeeding should be mostly fine. Perhaps we can pass a canRetry flag to the HomeServerApi that if we get a ConnectionError, we will retry. Only when the flag is not set, we'd call the Reconnector. The downside of this is that if 2 parts are doing requests, 1 retries and 1 does not, and the both requests fail, the other part of the code would still be retrying when the reconnector already kicked in. The HomeServerApi should perhaps tell the retryer if it should give up if a non-retrying request already caused the reconnector to kick in?
CatchupSync should also use timeout 0, in case there is nothing to report we spend 30s with a catchup spinner. Riot-web sync also says something about using a 0 timeout until there are no more to_device messages as they are queued up by the server and not all returned at once if there are a lot? This is needed for crypto to be aware of all to_device messages.
We should have a persisted observable value on Sync `syncCount` that just increments with every sync. This way would have other parts of the app, like account data, observe this and take action if something hasn't synced down within a number of syncs. E.g. account data could assume local changes that got sent to the server got subsequently overwritten by another client if the remote echo didn't arrive within 5 syncs, and we could attempt conflict resolution or give up. We could also show a warning that there is a problem with the server if our own messages don't come down the server in x syncs. We'd need to store the current syncCount with pieces of pending data like account data and pendingEvents.
Are overflows of this number a problem to take into account? Don't think so, because Number.MAX_SAFE_INTEGER is 9007199254740991, so if you sync on average once a second (which you won't, as you're offline often) it would take Number.MAX_SAFE_INTEGER/(3600*24*365) = 285616414.72415626 years to overflow.

View file

@ -0,0 +1,269 @@
Relations and redactions
events that refer to another event will need support in the SyncWriter, Timeline and SendQueue I think.
SyncWriter will need to resolve the related remote id to a [fragmentId, eventIndex] and persist that on the event that relates to some other. Same for SendQueue? If unknown remote id, not much to do. However, once the remote id comes in, how do we handle it correctly? We might need a index on m.relates_to/event_id? I'd rather avoid that if possible, as that becomes useless once we have the target event of the relationship (we store the relations on the target event (see "One fetch" below) and have the target event id on the relation so can go both ways). I'm not sure this index will be completely useless actually. For edits, we'll want to be able to list all edits. For reactions, we'll want to fetch the authors and timestamps. For replies, we want to render the origin event and not use the fallback text? It is true though that only a minority of the events will have a related_to event id, so I wonder if it is faster to put it in a different store? Perhaps a prototype can clarify ...
`event_relations` store could be this:
{
sourceEventId:
targetEventId:
rel_type:
roomId:
}
`{"key": "!bEWtlqtDwCLFIAKAcv:matrix.org|$apmyieZOI5vm4DzjEFzjbRiZW9oeQQR21adM6A6eRwM|m.annotation|m.reaction|$jSisozR3is5XUuDZXD5cyaVMOQ5_BtFS3jKfcP89MOM"}`
or actually stored like `roomId|targetEventId|rel_type|sourceEventId`. How can we get the last edit? They are sorted by origin_server_ts IIRC? Should this be part of the key? Solved: we store the event id of a replacement on the target event
We should look into what part of the relationships will be present on the event once it is received from the server (e.g. m.replace might be evident, but not all the reaction events?). If not, we could add a object store with missing relation targets.
The timeline can take incoming events from both the SendQueue and SyncWriter, and see if their related to fragmentId/eventIndex is in view, and then update it?
alternatively, SyncWriter/SendQueue could have a section with updatedEntries apart from newEntries?
SendQueue will need to pass the non-sent state (redactions & relations) about an event that has it's remote echo received to the SyncWriter so it doesn't flash while redactions and relations for it still have to be synced.
Also, related ids should be processed recursively. If event 3 is a redaction of event 2, a reaction to event 1, all 3 entries should be considered as updated.
As a UI for reactions, we could show (👍 14 + 1) where the + 1 is our own local echo (perhaps style it pulsating and/or in grey?). Clicking it again would just show 14 and when the remote echo comes in it would turn into 15.
## One fetch for timeline reading
wrt to how to store relations in indexeddb, we could store all local ids of related events (per type?) on the related-to event, so we can fetch them in one query for *all* events that have related events that were fetched in a range, without needing another index that would slow down writes. So that would only add 1 query which we only need to do when there are relations in the TimelineReader. what do we do though if we receive the relating event before the related-to event? An index would fix this mostly ... or we need a temp store where we store unresolved relations...
Replies should definitely use this relation mechanism, so we can easily show the most up to date version of the replied-to event.
Redactions can de done separately
For replies (or references in general?), we do need to load the referred-to event in a second read. For reactions and edits, they will already be stored on the target event.
## Example events from the wild
### Reaction
```json
{
"content": {
"m.relates_to": {
"event_id": "$apmyieZOI5vm4DzjEFzjbRiZW9oeQQR21adM6A6eRwM",
"key": "👍️",
"rel_type": "m.annotation"
}
},
"origin_server_ts": 1621284357314,
"sender": "@charly:matrix.org",
"type": "m.reaction",
"unsigned": {
"age": 64140856
},
"event_id": "$jSisozR3is5XUuDZXD5cyaVMOQ5_BtFS3jKfcP89MOM",
"room_id": "!bEWtlqtDwCLFIAKAcv:matrix.org"
}
```
### Edit
```json
{
"content": {
"body": " * ...",
"m.new_content": {
"body": "...",
"msgtype": "m.text"
},
"m.relates_to": {
"event_id": "$OXL0yk18y-VG3DuTybVh9j9cvdjjnnzWbBKY-QPXJ-0",
"rel_type": "m.replace"
},
"msgtype": "m.text"
},
"origin_server_ts": 1621264902371,
"room_id": "!bEWtlqtDwCLFIAKAcv:matrix.org",
"sender": "@alice:matrix.org",
"type": "m.room.message",
"unsigned": {
"age": 83636544
},
"event_id": "$Z7sFSKWtLTFoMMabkPFe0PSKWpkakjWUkYQeBU8IHVc",
"user_id": "@alice:matrix.org",
"age": 83636544
}
```
### Reply
```json
{
"content": {
"body": "...",
"format": "org.matrix.custom.html",
"formatted_body": "...",
"m.relates_to": {
"m.in_reply_to": {
"event_id": "$rGD9iQ93UmopkkagJ0tW_FHATa8IrvABg9cM_tNUvu4"
}
},
"msgtype": "m.text"
},
"origin_server_ts": 1621242338597,
"room_id": "!bEWtlqtDwCLFIAKAcv:matrix.org",
"sender": "@bob:matrix.org",
"type": "m.room.message",
"unsigned": {
"age": 106408661,
"m.relations": {
"m.annotation": {
"chunk": [
{
"type": "m.reaction",
"key": "👍️",
"count": 1
}
]
}
}
},
"event_id": "$yS_n5n3cIO2aTtek0_2ZSlv-7g4YYR2zKrk2mFCW_q4",
"user_id": "@bob:matrix.org",
"age": 106408661
}
```
### Remaining spec issues
- m.in_reply_to vs rel_type
- reactions in unsigned can't be deduplicated
- how to sort edits? for now we went with origin_server_ts
- do we say anything about events of a different type replacing an event?
- do we specify that replies should be to the original event, not the edit?
## What to store denormalized on the event itself?
```json
{
"reactions": {
"👍": {"count": 3, "me": true, "firstTimestamp": 2323989},
"👋": {"count": 1, "me": false, "firstTimestamp": 2323989}
},
"replacingEvent": {
"event_id": "$abc",
"origin_server_ts": ?,
"content": {}
}
}
```
we only need the m.new_content and event id of the replacing event, even timestamp we can load the event for on hover?
store the replacing event along the original event because we need to keep the original event along somewhere, but for displaying purposes, we'd use the content of the replacingEvent. Should we just store the content of the replacing event? Or even just the `m.new_content`? Could make sense, but perhaps also store the new timestamp along. How about whem somebody than the sender edits?
# Aggregation
what do we do with the aggregated timestamps? do we store them? if so, where?
when we hover reactions, we want to show the authors, rather than the timestamp, so we'll need to call /relations for that anyway. so no need to store the timestamp?
`/relations` is in fact a bit the server-side version of our `event_relations` store
## Dealing with gappy syncs
Doesn't look like synapse currently tells us which target events have outdates relations after a gappy sync. MSC 2675 proposes `stale_events`, but inspecting network traffic, that doesn't seem to be implemented right now.
So, if we locally would need to determine if relations are outdated, we could look if any of the fragments between an event and the last synced event have pagination tokens. Although we have no way to clear this "flag" if we were to fetch the relations after this.
As an initial cut it is probably fine if reactions and edits are outdated unless you scroll up all the way to an event (and hence back-fill), as this is what we'll always do (apart from permalinks).
### Permalinks
So once we do support permalinks, how do we solve this? Element solves this by not storing `/context` and the `/messages` requests around, hence it is always fresh.
We could store the live fragment id in events when we refresh their `/relations`, and if it is not the current live fragment id, you're outdated.
To accurately display anything not in the live fragment, we either need to:
- backfill until there are no more gaps between the event fragment and the live fragment.
- -- there is no way to know how many events this would load.
- ++ that we know which gaps we've already filled
- ++ we need to do this for e2ee rooms anyway
- ++ we need to implement this anyway for non-gappy sync
- ++ we can only do this as an initial cut, especially as we don't support permalinks yet
- Refetch the `/context` and `/messages` for what is on the screen and reconcile.
- ++ we know how much we'll fetch
- -- we need to fetch everything again if we have one small gap
- we store the current live fragment when doing this, so can know:
- if we need to refetch / if there is a gap
- how many gaps we need to fill
- could we fall back to this strategy if the first one takes too long/many events?
- we could pick a heuristic to pick either strategy (like time between syncs or try for x events and if the gap is not closed, give up)?
- Refetch /aggregations for every event
- ++ we don't get the events (we dont need? edits?)
- --- need to do it for every event
- use `stale_events` if we actually implement it one day
- this can work well with the first strategy, we'd store a "relationsStale" flag on the event, and refetch /relations immediately or if scrolled into view.
# API
## Reactions
```js
const reaction = eventEntry.react("👍");
room.sendEvent("m.reaction", reaction);
```
```js
// this is an ObservableMap mapping the key to the count (or rather SortedArray?)
// probably fine to just use a SortedArray to sorts by count, then key
// actually, maybe better to do ObservableMap and store first timestamp so we can support https://github.com/vector-im/element-web/issues/9698 outside of SDK.
const reactions = eventEntry.reactions.sortValues((r1, r2) => r1.count - r2.count);
new ListView({list: reactions}, reaction => new ReactionView(reaction, room));
// reaction has:
reaction.key
reaction.hasMyReaction // how do we get this from the bundled events?
reaction.count
reaction.firstTimestamp
room.sendEvent("m.reaction", reaction.react());
// this won't work as we don't have the event id:
// room.sendRedaction(reaction.redact());
```
## Edits
```js
const replacement = eventEntry.replace({});
room.sendEvent(eventEntry.eventType, replacement);
```
## Replies
```js
const reply = eventEntry.reply({});
room.sendEvent("m.room.message", reply);
```
## Redactions
```js
const redaction = eventEntry.redact();
room.sendRedaction(redaction);
```
All off these reaction and edit entries should probably not be live, and at some point in the future if we need them to be live for some use case, we can add an additional api to make them live with an explicit release mechanism?
```js
// there is no api to get the reactions by sender though, so perhaps we need to load them all and then find our own?
const reactions = await eventEntry.getReactionEntries("👍");
const reaction = reactions.find(r => r.sender = ownUserId);
room.sendRedaction(reaction.redact());
```
```js
const edits = await eventEntry.getEdits();
room.sendRedaction(edits[1].redact());
```
```js
const lastEdit = await eventEntry.getLastEdit();
room.sendRedaction(lastEdit.redact());
```

View file

@ -0,0 +1,17 @@
If we were to render replies in a smart way (instead of relying on the fallback), we would
need to manually find entries that are pointed to be `in_reply_to`. Consulting the timeline
code, it seems appropriate to add a `_replyingTo` field to a `BaseEventEntry` (much like we
have `_pendingAnnotations` and `pendingRedactions`). We can then:
* use `TilesCollection`'s `_findTileIdx` to find the tile of the message being replied to,
and put a reference to its tile into the new tile being created (?).
* It doesn't seem appropriate to add an additional argument to TileCreator, but we may
want to re-use tiles instead of creating duplicate ones. Otherwise, of course, `tileCreator`
can create more than one tile from an entry's `_replyingTo` field.
* Resolve `_replyingTo` much like we resolve `redactingEntry` in timeline: search by `relatedTxnId`
and `relatedEventId` if our entry is a reply (we can add an `isReply` flag there).
* This works fine for local entries, which are loaded via an `AsyncMappedList`, but what
about remote entries? They are not loaded asynchronously, and the fact that they are
not a derived collection is used throughout `Timeline`.
* Entries that don't have replies that are loadeded (but that are replies) probably need
to be tracked somehow?
* Then, on timeline add, check new IDs and update corresponding entries

View file

@ -0,0 +1,11 @@
- add internal room ids (to support room versioning later, and make internal event ids smaller and not needing escaping, and not needing a migration later on) ... hm this might need some more though. how to address a logical room? last room id? also we might not need it for room versioning ... it would basically be to make the ids smaller, but as idb is compressing, not sure that's a good reason? Although as we keep all room summaries in memory, it would be easy to map between these... you'd get event ids like 0000E78A00000020000A0B3C with room id, fragment id and event index. The room summary would store:
```
rooms: {
"!eKhOsgLidcrWMWnxOr:vector.modular.im": 0x0000E78A,
...
}
mostRecentRoom: 0x0000E78A
```
if this is not on an indexed field, how can we do a query to find the last room id and +1 to assign a new one?
how do we identify a logical room (consisting on a recent room and perhaps multiple outdated ones)?

109
doc/impl-thoughts/SDK.md Normal file
View file

@ -0,0 +1,109 @@
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

54
doc/impl-thoughts/SSO.md Normal file
View file

@ -0,0 +1,54 @@
Pseudo code of how SSO should work:
```js
// 1. Starting SSO
const loginOptions = await sessionContainer.queryLogin("matrix.org");
// every login option (the return type of loginOptions.password and loginOptions.sso.createLogin)
// that can be passed in to startWithLogin will implement a common LoginMethod interface that has:
// - a `homeserver` property (so the hsApi can be created for it before passing it into `login`)
// - a method `async login(hsApi, deviceName)` that returns loginData (device_id, user_id, access_token)
// loginOptions goes to the LoginViewModel
// if password login, mapped to PasswordLoginViewModel
if (loginOptions.password) {
sessionContainer.startWithLogin(loginOptions.password(username, password));
}
// if sso login, mapped to SSOLoginViewModel
if (loginOptions.sso) {
const {sso} = loginOptions;
// params contains everything needed to create a callback url:
// the homeserver, and optionally the provider
let provider = null;
if (sso.providers) {
// show button for each provider
// pick the first one as an example
provider = providers[0];
}
// when sso button is clicked:
// store the homeserver for when we get redirected back after the sso flow
platform.settingsStorage.setString("sso_homeserver", loginOptions.homeserver);
// create the redirect url
const callbackUrl = urlCreator.createSSOCallbackURL(); // will just return the document url without any fragment
const redirectUrl = sso.createRedirectUrl(callbackUrl, provider);
// and open it
platform.openURL(redirectUrl);
}
// 2. URLRouter, History & parseUrlPath will need to also take the query params into account, so hydrogen.element.io/?loginToken=abc can be converted into a navigation path of [{type: "sso", value: "abc"}]
// 3. when "sso" is on the navigation path, a CompleteSSOLoginView is shown.
// It will use the same SessionLoadView(Model) as for password login once login is called.
//
// Also see RootViewModel._applyNavigation.
//
// Its view model will do something like:
// need to retrieve ssoHomeserver url in localStorage
const ssoHomeserver = platform.settingsStorage.getString("sso_homeserver");
// need to retrieve loginToken from query parameters
const loginToken = "..."; // passed in to view model constructor
const loginOptions = await sessionContainer.queryLogin(ssoHomeserver);
sessionContainer.startWithLogin(loginOptions.sso.createLogin(loginToken));
```

View file

@ -0,0 +1,23 @@
# View updates
## Current situation
- arguments of View.update are not standardized, it's either:
- name of property that was updated on viewmodel
- names of property that was updated on viewmodel
- map of updated values
- we have 2 update mechanisms:
- listening on viewmodel change event
- through ObservableCollection which parent view listens on and calls `update(newValue)` on the child view. This is an optimization to prevent every view in a collection to need to subscribe and unsubscribe to a viewmodel.
- should updates on a template value propagate to subviews?
- either a view listens on the view model, ...
- or waits for updates from parent view:
- item view in a list view
- subtemplate (not needed, we could just have 2 subscriptions!!)
ok, we always subscribe in a (sub)template. But for example RoomTile and it's viewmodel; RoomTileViewModel doesn't extend EventEmitter or ObservableValue today because it (would) emit(s) updates through the parent collection. So today it's view would not subscribe to it. But if it wants to extend ViewModel to have all the other infrastructure, you'd receive double updates.
I think we might need to make it explicit whether or not the parent will provide updates for the children or not. Maybe as a mount() parameter? Yeah, I like that. ListView would pass in `true`. Most other things would pass in `false`/`undefined`. `Template` can then choose to bind or not based on that param.
Should we make a base/subclass of Template that does not do event binding to save a few bytes in memory for the event subscription fields that are not needed? Not now, this is less ergonimic, and a small optimization. We can always do that later, and we'd just have to replace the base class of the few views that appear in a `ListView`.

View file

@ -0,0 +1,10 @@
we make the current session status bar float and display generally short messages for all background tasks like:
"Waiting Xs to reconnect... [try now]"
"Reconnecting..."
"Sending message 1 of 10..."
As it is floating, it doesn't pop they layout and mess up the scroll offset of the timeline.
Need to find a good place to float it though. Preferably on top for visibility, but it could occlude the room header. Perhaps bottom left?
If more than 1 background thing is going on at the same time we display (1/x).
If you click the button status bar anywhere, it takes you to a page adjacent to the room view (and e.g. in the future the settings) and you get an overview of all running background tasks.

View file

@ -0,0 +1,4 @@
message model:
- paragraphs (p, h1, code block, quote, ...)
- lines
- parts (inline markup), which can be recursive

View file

@ -0,0 +1,18 @@
what should this new container be called?
- Client
- SessionContainer
it is what is returned from bootstrapping a ... thing
it allows you to replace classes within the client through IoC?
it wires up the different components
it unwires the components when you're done with the thing
it could hold all the dependencies for setting up a client, even before login
- online detection api
- clock
- homeserver
- requestFn
we'll be explicitly making its parts public though, like session, sync, reconnector
merge the connectionstate and

View file

@ -0,0 +1,7 @@
## Get member for timeline event
so when writing sync, we persist the display name and avatar
the server might or might not support lazy loading
if it is a room we just joined

120
doc/invites.md Normal file
View file

@ -0,0 +1,120 @@
# Invites
- invite_state doesn't update over /sync
- can we reuse room summary? need to clear when joining
- rely on filter operator to split membership=join from membership=invite?
- invite_state comes once, and then not again
- only state (no heroes for example, but we do get the members)
- wants:
- different class to represent invited room, with accept or reject method?
- make it somewhat easy to render just joined rooms (rely on filter and still put them all in the same observable map)
- make the transition from invite to joined smooth
- reuse room summary logic?
InvitedRoom
isDM
isEncrypted
name
timestamp
accept()
reject()
JoiningRoom
to store intent of room you joined through directory, invite, or just /join roomid
also joining is retried when coming back online
forget()
Room
so, also taking into account that other types of room we might not want to expose through session.rooms will have invites,
perhaps it is best to expose invites through a different observable collection. You can always join/concat them to show in
the same list.
How do we handle a smooth UI transition when accepting an invite though?
For looking at the room itself:
- we would attach to the Invite event emitter, and we can have a property "joined" that we would update. Then you know you can go look for the room (or even allow to access the room through a property?)
- so this way the view model can know when to switch and signal the view
For the room list:
- the new Room will be added at exactly the same moment the Invite is removed,
so it should already be fairly smooth whether they are rendered in the same list or not.
How will we locate the Invite/Room during sync when we go from invite => join?
- have both adhere to sync target api (e.g. prepareSync, ...) and look in invite map
if room id is not found in room map in session.getroom.
- how do we remove the invite when join?
- we ca
Where to store?
- room summaries?
- do we have an interest in keeping the raw events?
- room versions will add another layer of indirection to the room summaries (or will it? once you've upgraded the room, we don't care too much anymore about the details of the old room? hmmm, we do care about whether it is encrypted or not... we need everything to be able to show the timeline in any case)
Invite => accept() => Room (ends up in session.rooms)
(.type) => Space (ends up in session.spaces)
Invite:
- isEncrypted
- isDM
- type
- id
- name
- avatarUrl
- timestamp
- joinRule (to say wheter you cannot join this room again if you reject)
new "memberships":
joining (when we want to join/are joining but haven't received remote echo yet)
leaving (needed?)
maybe it's slightly overkill to persist the intent of joining or leaving a room,
but I do want a way to local echo joining a room,
so that it immediately appears in the room list when clicking join in the room directory / from a url ... how would we sort these rooms though? we can always add another collection, but I'm not sure invites should be treated the same, they can already local echo on the invite object itself.
since invites don't update, we could, in sync when processing a new join just set a flag on the roomsyncstate if a room is newly created and in writeSync/afterSync check if there is a `session.invites.get(id)` and call `writeSync/afterSync` on it as well. We need to handle leave => invite as well. So don't check for invites only if it is a new room, but also if membership is leave
transitions are:
invite => join
invite => leave
invite => ban
join => left
join => ban
leave => invite
leave => join
leave => ban
ban => leave
none => invite
none => join
none => ban
kick should keep the room & timeline visible (even in room list, until you archive?)
leave should close the room. So explicit archive() step on room ?
Room => leave() => ArchivedRoom (just a Room loaded from archived_room_summaries) => .forget()
=> .forget()
Room receives leave membership
- if sender === state_key, we left, and we archive the room (remove it from the room list, but keep it in storage)
- if sender !== state_key, we got kicked, and we write the membership but don't archive so it stays in the room list until you call archive/forget on the room
when calling room.leave(), do you have to call archive() or forget() after as well? or rather param of leave and stored intent? sounds like non-atomical operation to me ...
we should be able to archive or forget before leave remote echo arrives
if two stores, this could mean we could have both an invite and a room with kicked state for a given room id?
we should avoid key collisions between `session.invites` and `session.rooms` (also `session.archivedRooms` once supported?) in any case,
because if we join them to display in one list, things get complicated.
avoiding key collisions can happen both with 1 or multiple stores for different room states and is just a matter
of carefully removing one state representation before adding another one.
so a kicked or left room would disappear from session.rooms when an invite is synced?
this would prevent you from seeing the old timeline for example, and if you reject, the old state would come back?
# Decisions
- we expose session.invites separate from session.rooms because they are of a different type.
This way, you only have methods on the object that make sense (accept on Room does not make sense, like Invite.openTimeline doesn't make sense)
- we store invites (and likely also archived rooms) in a different store, so that we don't have to clear/add properties where they both differ when transitioning. Also, this gives us the possibility to show the timeline on a room that you have previously joined, as the room summary and invite can exist at the same time. (need to resolve key collision question though for this)
- we want to keep kicked rooms in the room list until explicitly archived
- room id collisions between invites and rooms, can we implement a strategy to prefer invites in the join operator?

View file

@ -0,0 +1,8 @@
# General Pattern of implementing a persisted network call
1. do network request
1. start transaction
1. write result of network request into transaction store, keeping differences from previous store state in local variables
1. close transaction
1. apply differences applied to store to in-memory data
1. emit events for changes

18
doc/sync-updates.md Normal file
View file

@ -0,0 +1,18 @@
# persistance vs model update of a room
## persist first, return update object, update model with update object
- we went with this
## update model first, return update object, persist with update object
- not all models exist at all times (timeline only when room is "open"),
so model to create timeline update object might not exist for persistence need
## persist, update, each only based on sync data (independent of each other)
- possible inconsistency between syncing and loading from storage as they are different code paths
+ storage code remains very simple and focussed
## updating model and persisting in one go
- if updating model needs to do anything async, it needs to postpone it or the txn will be closed
## persist first, read from storage to update model
+ guaranteed consistency between what is on screen and in storage
- slower as we need to reread what was just synced every time (big accounts with frequent updates)

21
doc/viewhierarchy.md Normal file
View file

@ -0,0 +1,21 @@
view hierarchy:
```
BrawlView
SwitchView
SessionView
SyncStatusBar
ListView(left-panel)
RoomTile
SwitchView
RoomPlaceholderView
RoomView
MiddlePanel
ListView(timeline)
event tiles (see ui/session/room/timeline/)
ComposerView
RightPanel
SessionPickView
ListView
SessionPickerItemView
LoginView
```

View file

@ -1,469 +0,0 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/** from https://gist.github.com/mfornos/9991865 */
@font-face {
font-family: 'emoji';
src: local('Apple Color Emoji'),
local('Segoe UI Emoji'),
local('Segoe UI Symbol'),
local('Noto Color Emoji'),
local('Android Emoji'),
local('EmojiSymbols'),
local('Symbola');
/* Emoji unicode blocks */
unicode-range: U+1F300-1F5FF, U+1F600-1F64F, U+1F680-1F6FF, U+2600-26FF;
}
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
html {
height: 100%;
}
@media screen and (min-width: 600px) {
.PreSessionScreen {
width: 600px;
box-sizing: border-box;
margin: 0 auto;
margin-top: 50px;
}
}
.SessionView {
display: flex;
flex-direction: column;
height: 100vh;
}
.SessionView > .main {
flex: 1 1;
display: flex;
min-height: 0;
min-width: 0;
width: 100vw;
}
/* mobile layout */
@media screen and (max-width: 800px) {
.RoomHeader button.back { display: block; }
div.RoomView, div.RoomPlaceholderView { display: none; }
div.LeftPanel {flex-grow: 1;}
div.room-shown div.RoomView { display: flex; }
div.room-shown div.LeftPanel { display: none; }
div.right-shown div.TimelinePanel { display: none; }
}
.LeftPanel {
flex: 0 0 300px;
min-width: 0;
}
.RoomPlaceholderView, .RoomView {
flex: 1 0;
min-width: 0;
}
.RoomView {
min-width: 0;
display: flex;
}
.TimelinePanel {
flex: 3 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.TimelinePanel ul {
flex: 1 0;
}
.RoomHeader {
display: flex;
}
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/** contains styles for everything before the session view, like the session picker, login, load view, ... */
.SessionPickerView {
padding: 0.4em;
}
.SessionPickerView ul {
list-style: none;
padding: 0;
}
.SessionPickerView li {
margin: 0.4em 0;
}
.SessionPickerView .session-info {
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
}
.SessionPickerView li .user-id {
flex: 1 1;
}
.SessionPickerView li .error {
margin: 0 20px;
}
.LoginView {
padding: 0.4em;
}
.SessionLoadView {
display: flex;
}
.SessionLoadView p {
flex: 1 1;
margin: 0 0 0 10px;
}
/*
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.
*/
.LeftPanel {
overflow-y: auto;
overscroll-behavior: contain;
}
.LeftPanel ul {
list-style: none;
padding: 0;
margin: 0;
}
.LeftPanel li {
display: flex;
align-items: center;
}
.LeftPanel div.description {
margin: 0;
flex: 1 1;
min-width: 0;
}
.LeftPanel .description > * {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.RoomPlaceholderView {
display: flex;
flex-direction: row;
}
.RoomHeader {
align-items: center;
}
.RoomHeader > *:last-child {
margin-right: 0;
}
.RoomHeader > * {
margin-right: 10px;
flex: 0 0 auto;
}
.RoomHeader button {
display: block;
}
.RoomHeader .back {
display: none;
}
.RoomHeader .room-description {
flex: 1 1;
min-width: 0;
}
.RoomHeader .topic {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.RoomHeader .description {
flex: 1 1 auto;
min-width: 0;
}
.RoomHeader h2 {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin: 0;
}
.MessageComposer {
display: flex;
}
.MessageComposer > input {
display: block;
flex: 1 1;
box-sizing: border-box;
}
/*
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.
*/
.TimelinePanel ul {
overflow-y: auto;
overscroll-behavior: contain;
list-style: none;
padding: 0;
margin: 0;
}
.TimelinePanel li {
}
.message-container {
flex: 0 1 auto;
/* first try break-all, then break-word, which isn't supported everywhere */
word-break: break-all;
word-break: break-word;
}
.message-container .sender {
margin: 5px 0;
font-size: 0.9em;
font-weight: bold;
}
.message-container a {
display: block;
position: relative;
max-width: 100%;
/* width and padding-top set inline to maintain aspect ratio,
replace with css aspect-ratio once supported */
}
.message-container img {
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
width: 100%;
height: 100%;
}
.TextMessageView {
display: flex;
min-width: 0;
}
.AnnouncementView {
display: flex;
align-items: center;
}
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.avatar {
width: 32px;
height: 32px;
overflow: hidden;
flex-shrink: 0;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
line-height: 32px;
font-size: calc(32px * 0.6);
text-align: center;
letter-spacing: calc(32px * -0.05);
speak: none;
}
.avatar img {
width: 100%;
height: 100%;
}
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
@keyframes spinner {
0% {
transform: rotate(0);
stroke-dasharray: 0 0 10 90;
}
45% {
stroke-dasharray: 0 0 90 10;
}
75% {
stroke-dasharray: 0 50 50 0;
}
100% {
transform: rotate(360deg);
stroke-dasharray: 10 90 0 0;
}
}
.spinner circle {
transform-origin: 50% 50%;
animation-name: spinner;
animation-duration: 2s;
animation-iteration-count: infinite;
animation-timing-function: linear;
/**
* TODO
* see if with IE11 we can just set a static stroke state and make it rotate?
*/
stroke-dasharray: 0 0 85 85;
fill: none;
stroke: currentcolor;
stroke-width: 12;
stroke-linecap: butt;
}
.spinner {
width: 20px;
height: 20px;
}
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.form input {
display: block;
width: 100%;
box-sizing: border-box;
}
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.SessionStatusView {
display: flex;
}
.SessionStatusView p {
margin: 0 10px;
word-break: break-all;
word-break: break-word;
}
.SessionStatusView button {
border: none;
background: none;
color: currentcolor;
text-decoration: underline;
}
/* only if the body contains the whole app (e.g. we're not embedded in a page), make some changes */
body.hydrogen {
/* make sure to disable rubber-banding and pull to refresh in a PWA if we'd end up having a scrollbar */
overscroll-behavior: none;
/* disable rubberband scrolling on document in IE11 */
overflow: hidden;
}
.hydrogen {
margin: 0;
}
.hiddenWithLayout {
visibility: hidden;
}
.hidden {
display: none !important;
}

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

View file

@ -1,26 +0,0 @@
<!DOCTYPE html><html manifest="manifest.appcache"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no">
<meta name="application-name" content="Hydrogen Chat">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Hydrogen Chat">
<meta name="description" content="A matrix chat application">
<link rel="stylesheet" type="text/css" href="hydrogen-465980817.css">
<link rel="stylesheet" type="text/css" href="themes/element/bundle-1467704481.css" title="Element Theme">
<link rel="alternate stylesheet" type="text/css" href="themes/bubbles/bundle-2682099160.css" title="Bubbles Theme">
<link rel="manifest" href="manifest-2714077836.json"></head>
<body class="hydrogen">
<script id="version" type="text/javascript">
window.HYDROGEN_VERSION = "0.0.27";
</script>
<script type="text/javascript" src="hydrogen-legacy-907456704.js"></script><script type="text/javascript">hydrogenBundle.main(document.body);</script>
<script id="service-worker" type="text/javascript">
if('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js')
.then(function() { console.log("Service Worker registered"); });
}
</script>
</body></html>

View file

@ -1 +0,0 @@
{"name":"Hydrogen Chat","short_name":"Hydrogen","display":"fullscreen","start_url":"index.html","icons":[{"src":"icon-192.png","sizes":"192x192","type":"image/png"}]}

View file

@ -1,11 +0,0 @@
CACHE MANIFEST
# v0.0.27
NETWORK
"*"
CACHE
hydrogen-legacy-907456704.js
hydrogen-465980817.css
index.html
icon-192.png
themes/element/bundle-1467704481.css
themes/bubbles/bundle-2682099160.css

66
package.json Normal file
View file

@ -0,0 +1,66 @@
{
"name": "hydrogen-web",
"version": "0.3.1",
"description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB",
"directories": {
"doc": "doc"
},
"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"
}
}

30
prototypes/base256.html Normal file
View file

@ -0,0 +1,30 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<script type="text/javascript">
function encodeNumber(n) {
const a = (n & 0xFFFF);
const b = (n & 0xFFFF0000) >> 16;
const c = (n & 0xFFFF00000000) >> 32;
const d = (n & 0xFFFF000000000000) >> 48;
return String.fromCharCode(a, b, c, d);
}
function printN(n) {
//document.write(`<p>fn(${n}) = ${encodeNumber(n)}</p>`);
console.log(n, encodeNumber(n));
}
printN(0);
printN(9);
printN(1000);
printN(Number.MAX_SAFE_INTEGER);
</script>
</body>
</html>

View file

@ -0,0 +1,74 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script type="module">
function reqAsPromise(req) {
return new Promise((resolve, reject) => {
req.addEventListener("success", event => resolve(event.target.result));
req.addEventListener("error", event => reject(event.target.error));
});
}
function txnAsPromise(txn) {
return new Promise((resolve, reject) => {
txn.addEventListener("complete", resolve);
txn.addEventListener("abort", reject);
});
}
export function readAll(cursor) {
return new Promise((resolve, reject) => {
const results = [];
cursor.onerror = (event) => {
reject(new Error("Query failed: " + event.target.errorCode));
};
cursor.onsuccess = (event) => {
const cursor = event.target.result;
if (!cursor) {
resolve(results);
} else {
results.push(cursor.value);
cursor.continue();
}
};
});
}
async function main() {
let isNew = false;
const openReq = window.indexedDB.open("composed_binary_keys_test", 1);
openReq.onupgradeneeded = (ev) => {
const db = ev.target.result;
db.createObjectStore("test", {keyPath: ["strKey", "binKey"]});
isNew = true;
};
const db = await reqAsPromise(openReq);
// fill test store with some initial data
if (isNew) {
const txn = db.transaction("test", "readwrite");
const store = txn.objectStore("test");
store.add({strKey: "abc", binKey: new Uint8Array([0x0F, 0x00, 0x10, 0x00]).buffer});
store.add({strKey: "def", binKey: new Uint8Array([0x00, 0x10, 0x00, 0x00]).buffer});
store.add({strKey: "def", binKey: new Uint8Array([0xFF, 0x10, 0x00, 0x00]).buffer});
store.add({strKey: "ghi", binKey: new Uint8Array([0x00, 0x00, 0x40, 0x00]).buffer});
await txnAsPromise(txn);
}
const txn = db.transaction("test", "readonly");
const store = txn.objectStore("test");
const defRange = IDBKeyRange.bound(
["def", new Uint8Array([0x00, 0x00, 0x00, 0x00]).buffer],
["def", new Uint8Array([0xFF, 0xFF, 0xFF, 0xFF]).buffer],
true, true,
);
const defValues = await readAll(store.openCursor(defRange, "prev"));
console.log(defValues);
}
main();
</script>
</body>
</html>

View file

@ -0,0 +1,102 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script type="module">
function reqAsPromise(req) {
return new Promise((resolve, reject) => {
req.addEventListener("success", event => resolve(event.target.result));
req.addEventListener("error", event => reject(event.target.error));
});
}
function txnAsPromise(txn) {
return new Promise((resolve, reject) => {
txn.addEventListener("complete", resolve);
txn.addEventListener("abort", reject);
});
}
function readAll(cursor) {
return new Promise((resolve, reject) => {
const results = [];
cursor.onerror = (event) => {
reject(new Error("Query failed: " + event.target.errorCode));
};
cursor.onsuccess = (event) => {
const cursor = event.target.result;
if (!cursor) {
resolve(results);
} else {
results.push(cursor.value);
cursor.continue();
}
};
});
}
let seed = 13423;
function prn() {
let x = Math.sin(seed++) * 10000;
return x - Math.floor(x);
}
function pad(str, len) {
return str + " ".repeat(len - str.length);
}
function formatByteArray(a) {
return `[${Array.from(a).join(",")}]`;
}
async function main() {
let isNew = false;
const openReq = window.indexedDB.open("bin_key_sorting_test", 1);
openReq.onupgradeneeded = (ev) => {
const db = ev.target.result;
db.createObjectStore("test", {keyPath: ["binKey"]});
isNew = true;
};
const db = await reqAsPromise(openReq);
// fill test store with some data
if (isNew) {
const txn = db.transaction("test", "readwrite");
const store = txn.objectStore("test");
const rndByte = () => Math.ceil(prn() * 255);
for(let i = 0; i < 10; ++i) {
const b1 = rndByte(), b2 = rndByte(), b3 = rndByte(), b4 = rndByte();
console.log(`adding key (${b1},${b2},${b3},${b4})`);
store.add({binKey: new Uint8Array([b1, b2, b3, b4])});
}
store.add({binKey: new Uint8Array([0x80, 0x00, 0x00, 0x00])});
store.add({binKey: new Uint8Array([0x00, 0x00, 0x00, 0x00])});
store.add({binKey: new Uint8Array([0x7F, 0xFF, 0xFF, 0xFF])});
store.add({binKey: new Uint8Array([0xFF, 0xFF, 0xFF, 0xFF])});
await txnAsPromise(txn);
}
const txn = db.transaction("test", "readonly");
const store = txn.objectStore("test");
const range = IDBKeyRange.bound(
Uint8Array.from([0x00, 0x00, 0x00, 0x00]).buffer,
Uint8Array.from([0xFF, 0xFF, 0xFF, 0xFF]).buffer,
// new Uint8Array([0x80, 0x00, 0x00, 0x00]).buffer, new
// Uint8Array([0x7F, 0xFF, 0xFF, 0xFF]).buffer,
);
// const values = await readAll(store.openCursor(range, "next"));
const values = await readAll(store.openCursor());
console.log(pad(`[uint8;4]`, 20) + " [int8;4]");
for (const v of values) {
const uintStr = formatByteArray(new Uint8Array(v.binKey));
const intStr = formatByteArray(new Int8Array(v.binKey));
console.log(pad(uintStr, 20) + " " + intStr);
}
}
main();
</script>
</body>
</html>

View file

@ -0,0 +1,103 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script type="module">
function reqAsPromise(req) {
return new Promise((resolve, reject) => {
req.addEventListener("success", event => resolve(event.target.result));
req.addEventListener("error", event => reject(event.target.error));
});
}
function txnAsPromise(txn) {
return new Promise((resolve, reject) => {
txn.addEventListener("complete", resolve);
txn.addEventListener("abort", reject);
});
}
function readAll(cursor) {
return new Promise((resolve, reject) => {
const results = [];
cursor.onerror = (event) => {
reject(new Error("Query failed: " + event.target.errorCode));
};
cursor.onsuccess = (event) => {
const cursor = event.target.result;
if (!cursor) {
resolve(results);
} else {
results.push(cursor.value);
cursor.continue();
}
};
});
}
let seed = 13423;
function prn() {
let x = Math.sin(seed++) * 10000;
return x - Math.floor(x);
}
function pad(str, len) {
return str + " ".repeat(len - str.length);
}
function formatByteArray(a) {
return `[${Array.from(a).join(",")}]`;
}
async function main() {
let isNew = false;
const openReq = window.indexedDB.open("bin_key_sorting_test", 1);
openReq.onupgradeneeded = (ev) => {
const db = ev.target.result;
db.createObjectStore("test", {keyPath: ["binKey"]});
isNew = true;
};
const db = await reqAsPromise(openReq);
// fill test store with some data
if (isNew) {
const txn = db.transaction("test", "readwrite");
const store = txn.objectStore("test");
const rndByte = () => Math.ceil(prn() * 255);
for(let i = 0; i < 10; ++i) {
const b1 = rndByte(), b2 = rndByte(), b3 = rndByte(), b4 = rndByte();
console.log(`adding key (${b1},${b2},${b3},${b4})`);
store.add({binKey: new Uint8Array([b1, b2, b3, b4])});
}
store.add({binKey: new Uint8Array([0x80, 0x00, 0x00, 0x00])});
store.add({binKey: new Uint8Array([0x00, 0x00, 0x00, 0x00])});
store.add({binKey: new Uint8Array([0x7F, 0xFF, 0xFF, 0xFF])});
store.add({binKey: new Uint8Array([0xFF, 0xFF, 0xFF, 0xFF])});
await txnAsPromise(txn);
}
const txn = db.transaction("test", "readonly");
const store = txn.objectStore("test");
const range = IDBKeyRange.upperBound(
// Uint8Array.from([0x00, 0x00, 0x00, 0x00]).buffer,
Uint8Array.from([0xFF, 0xFF, 0xFF, 0xFF]).buffer,
// new Uint8Array([0x80, 0x00, 0x00, 0x00]).buffer, new
// Uint8Array([0x7F, 0xFF, 0xFF, 0xFF]).buffer,
false
);
// const values = await readAll(store.openCursor(range, "next"));
const values = await readAll(store.openCursor(range, "prev"));
console.log(pad(`[uint8;4]`, 20) + " [int8;4]");
for (const v of values) {
const uintStr = formatByteArray(new Uint8Array(v.binKey));
const intStr = formatByteArray(new Int8Array(v.binKey));
console.log(pad(uintStr, 20) + " " + intStr);
}
}
main();
</script>
</body>
</html>

View file

@ -0,0 +1,165 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script src="https://unpkg.com/text-encoding@0.6.4/lib/encoding-indexes.js"></script>
<script src="https://unpkg.com/text-encoding@0.6.4/lib/encoding.js"></script>
<script src="https://cdn.jsdelivr.net/npm/promise-polyfill@8/dist/polyfill.min.js"></script>
<script src="deps/jsSHA/dist/sha512.js"></script>
<script type="text/javascript" src="https://cdn.rawgit.com/ricmoo/aes-js/e27b99df/index.js"></script>
<script type="text/javascript" src="derive-keys-bundle.js"></script>
<script type="text/javascript">
if (!Math.imul) Math.imul = function(a, b) {
var aHi = (a >>> 16) & 0xffff;
var aLo = a & 0xffff;
var bHi = (b >>> 16) & 0xffff;
var bLo = b & 0xffff;
// the shift by 0 fixes the sign on the high part
// the final |0 converts the unsigned value into a signed value
return ((aLo * bLo) + (((aHi * bLo + aLo * bHi) << 16) >>> 0) | 0);
};
if (!Math.clz32) Math.clz32 = (function(log, LN2){
return function(x) {
// Let n be ToUint32(x).
// Let p be the number of leading zero bits in
// the 32-bit binary representation of n.
// Return p.
var asUint = x >>> 0;
if (asUint === 0) {
return 32;
}
return 31 - (log(asUint) / LN2 | 0) |0; // the "| 0" acts like math.floor
};
})(Math.log, Math.LN2);
</script>
<script type="text/javascript" src="../lib/olm/olm_legacy.js"></script>
<script type="text/javascript">
// sample data from account with recovery key
const ssssKeyAccountData = {
"type": "m.secret_storage.key.le4jDjlxrIMZDSKu1EudJL5Tc4U5qI0d",
"content": {
"algorithm": "m.secret_storage.v1.aes-hmac-sha2",
"iv": "YPhwwArIUTwasbROMFd1PQ==",
"mac": "khWXeBzKtZi8SX6I7m/9yPoLB1yv1u9l+NNi6WF4+ek="
}
};
const megolmBackupKeyAccountData = {
"type": "m.megolm_backup.v1",
"content": {
"encrypted": {
"le4jDjlxrIMZDSKu1EudJL5Tc4U5qI0d": {
"iv": "PiqYdySj9s4RsaLc1oDF1w==",
"ciphertext": "62fjUs1xkF3BvqVEvAEoDH9jcYiotkcJHG/VNtzSrPBlrmOYQyPA93L2rKo=",
"mac": "vtq+kEg5XaRdw08aPiQi7+w9qUiDCQKo/jKNTvrN4ho="
}
}
}
};
const backupInfo = {
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
"auth_data": {
"public_key": "tY/jSdfy2q1pS8Ux+LP8xr/RMn9NDElwofH+E5sFG38",
"signatures": {
"@bruno-test4s2:matrix.org": {
"ed25519:KTLGZUJCYZ": "YPuzpLo4OZL5+HQTkbDnUKpIuCmL50Q7RnMs9cRfKqyS+CMPm0RBU1ttPO6XOZ+TjZ4VThXU50LUkmpJiKM+Aw",
"ed25519:l17fdsfeS7qUKIYzgx3LxIcHnjPM00+Ge5dTk7Msy04": "epDo+d9foXXcnXChZaEOCKNYzofOMBXQF3FCMDJ52hxvxh9K1w+2zOOAwWEKOts88gubgIsdRQedkuhuIm2LCg"
}
}
},
"count": 1,
"etag": "1",
"version": "1"
};
const sessionResponse = {
"first_message_index": 0,
"forwarded_count": 0,
"is_verified": true,
"session_data": {
"ciphertext": "+a8OCF0v5U5GYTNAMwgNEqSItxy4hea073zlWCp+ocr4mUQDuUZyOo+DGHDPPvSOnhJA2waSV05wna/Jmig7NAzuJJy8eEd0dHmGiA16eUMFiUz0HYFseDXs0dDGF38shz1C6CXYRjTOS3S7JWLVzeeYy632BMGvGjWMvAuOpm4NgV9fLB5J6nYVb/wvU3Mf8mw/eT5k8AUJA/CAD6zM7T9skEJhuFoi5kdPfBoozUbScA5xcPVmE6aY08zZ6QpiZ7lsyWoIRDbRxaBxL82T2CnpcngE/SAHF+eJ9ZWK3txolYLT/KAfKlAVLV7yWXkYL7oxrW8DI/5ZQFXUqzqqqfAB7Qz2AIvCdUVqhDGwuDr5noCMlKYEwyYR0VC2i4ZyXdtLdOjKBS2eTqDcwdv2gcaOnbJJcIEuGMKVg89/rKqpWncY/+NOBTQhuts05+Wi+9wU+OlGlNFvhkOgp1BaP0Q7T4pkxgj4OSbf3t1UfthltJSX8TS9ZGd3DVDI8swQuMBvF9H+7kAeO2IWTMSe57MYvlk0aw/gPFdI06lcOvH2nAr9C2HNsuYhyO4XGZOAg8HHzkjLlzNU+zJk1MfRIXRoVgbIh1hApcK9HhyTBzg",
"ephemeral": "z0JE6swJZbrmRYOWGvEI6zhIzoJ57lhzp1uujVS2jUs",
"mac": "+AAASqA+4U8"
}
};
const keyId = "le4jDjlxrIMZDSKu1EudJL5Tc4U5qI0d";
// sample data with account with recovery passphrase
// const ssssKeyAccountData =
// {
// "type": "m.secret_storage.key.HB6AKfUD4avkZfPfyjcJ6iJPWDp4f9WM",
// "content": {
// "algorithm": "m.secret_storage.v1.aes-hmac-sha2",
// "passphrase": {
// "algorithm": "m.pbkdf2",
// "iterations": 500000,
// "salt": "tfY5mgvQBr3Gd5Dy1IBiKf7fLquL4Y9O"
// },
// "iv": "xitm4hxsqagkbyEmXj0tUw==",
// "mac": "nagOYz7FKrdlFEKM9ij78th0O2p7YVGgl+p0LHr4EBE="
// }
// };
// const megolmBackupKeyAccountData = {
// "type": "m.megolm_backup.v1",
// "content": {
// "encrypted": {
// "HB6AKfUD4avkZfPfyjcJ6iJPWDp4f9WM": {
// "iv": "HpzOY5DxYFJCxw5Vi6BBOQ==",
// "ciphertext": "u1TJjaaGKVDGExg9hu2fIUZ0gjToMcMReyhn4nsXgnhm7Dvz6E/4p+nSF3w=",
// "mac": "08ckDbQK9wB2jiE4n4sfp2sw83q/0C2/gEz2LuHMEPg="
// }
// }
// }
// };
// const backupInfo = {
// "algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
// "auth_data": {
// "public_key": "Vw2cwhbxFg/GQ2rr4VRIQ+Oh74lP7IxY6oN4R9q992k",
// "signatures": {
// "@bruno-test4s:matrix.org": {
// "ed25519:XAIKJXBCNZ": "AFBp1T2x8hyPSi2hCHg6IzNy67RxULj3/7LYZgVT3Ruz49v5h1+jAScTxZj5jrItxo2LCzSORH+yABHjPIqOBQ",
// "ed25519:lukepZkTmPcJS6wCl12B0tIURIO8YbMd5QJLf8UOugI": "a1ZJa+1+p9Gm5Po1B619ZDy4xidHmLt82vXVPH7vWTjny1r3JI2iM4fB2qh8vEiASNlFyVrFx//gQrz9Y1IJBA"
// }
// }
// },
// "count": 1,
// "etag": "1",
// "version": "1"
// };
// const sessionResponse = {
// "first_message_index": 0,
// "forwarded_count": 0,
// "is_verified": true,
// "session_data": {
// "ciphertext": "1NoC8/GZWeGjneuoFDcqpbMYOJ8bjDFiw2O4/YOKC59x9RqSejLyM8qLL5FzlV+uW7anPVED8t9m+p2t1kKa15LxlcdzXjLPCv1QGYlhotbUhN8eRUobQuLqsD5Dl/QqNxv+Xl65tEaQhUeF30NmSesw6GHvP93vB3mTN8Yz9QyaQtvgoI/Q6c4d+yGmFVE2dlhXdOs7Hrylrg8UyM1QI+qpNJ3L9ETcqiXCG/FJIdM87LmNnHPX65TWK5xsu1JKWCI2BY1KFVDyxm40FyHHypUPYoT9RqPnygHtYoTiZzyaVxqUu2vg08Bv0t1VH2SNDGs5aZYQN5S1JNAHrXE+cWSg0rfVb160Z4FJC/89wO8fw/uXqJehqMVuC9BSU/zsKcZ797U92qDnIb6QQuMYKRgh9JrEugqJN9ocL7F8W9fW2oFfUYRyvOZRSf387hGrapEGBKx7Owb7UoXvWyb4C5hc5SFNvej+yg98+Fi4hzlGH26DqzJdLcxU5P/MWfZc222QqPFuFspe6f0Ts5jnJhjCQhXWoM4G6mtvGbOm2ESSJULj8U4JSDz8GsxrmojR/pBpywBvuy/mx//htnacnTRqYJz+PZVtV63rfaZlEtU",
// "ephemeral": "wXBeLoazggBmFS0eiVY9H/qq5o1yt2/NIKWcq384EHc",
// "mac": "w3IfO5vL9Bc"
// }
// };
//const keyId = "HB6AKfUD4avkZfPfyjcJ6iJPWDp4f9WM";
const cryptoDriver = new bundle.CryptoDriver((window.crypto || window.msCrypto).subtle);
window.Olm.init().then(function() {
bundle.deserializeSSSSKey("EsUH dBfj L7XF Kdej TNmK 2CdP R7NQ KnQH zA1o 8kDg piuJ QEZh", ssssKeyAccountData).then(function(ssssKey) {
//bundle.deriveSSSSKey(cryptoDriver, prompt("passphrase"), ssssKeyAccountData).then(function(ssssKey) {
// const ssssKey = new Uint8Array(32);
// const bytes = [123, 47, 138, 15, 190, 69, 224, 204, 88, 246, 203, 65, 243, 234, 91, 17, 250, 107, 104, 51, 211, 252, 81, 67, 80, 191, 105, 208, 127, 87, 107, 231];
// for (var i = bytes.length - 1; i >= 0; i--) {
// ssssKey[i] = bytes[i];
// }
console.log("ssssKey", ssssKey);
bundle.decryptSecret(cryptoDriver, keyId, ssssKey, megolmBackupKeyAccountData).then(function(backupKeyBase64) {
console.log("backupKeyBase64", backupKeyBase64);
bundle.decryptSession(backupKeyBase64, backupInfo, sessionResponse).then(function(session) {
console.log("session", session);
alert(session.session_key);
});
});
});
});
</script>
</body>
</html>

458
prototypes/derive-keys.js vendored Normal file
View file

@ -0,0 +1,458 @@
import {base58} from "../src/utils/base-encoding.js";
function subtleCryptoResult(promiseOrOp, method) {
if (promiseOrOp instanceof Promise) {
return promiseOrOp;
} else {
return new Promise((resolve, reject) => {
promiseOrOp.oncomplete = e => resolve(e.target.result);
promiseOrOp.onerror = e => reject(new Error("Crypto error on " + method));
});
}
}
class CryptoHMACDriver {
constructor(subtleCrypto) {
this._subtleCrypto = subtleCrypto;
}
/**
* [hmac description]
* @param {BufferSource} key
* @param {BufferSource} mac
* @param {BufferSource} data
* @param {HashName} hash
* @return {boolean}
*/
async verify(key, mac, data, hash) {
const opts = {
name: 'HMAC',
hash: {name: hashName(hash)},
};
const hmacKey = await subtleCryptoResult(this._subtleCrypto.importKey(
'raw',
key,
opts,
false,
['verify'],
), "importKey");
const isVerified = await subtleCryptoResult(this._subtleCrypto.verify(
opts,
hmacKey,
mac,
data,
), "verify");
return isVerified;
}
async compute(key, data, hash) {
const opts = {
name: 'HMAC',
hash: {name: hashName(hash)},
};
const hmacKey = await subtleCryptoResult(this._subtleCrypto.importKey(
'raw',
key,
opts,
false,
['sign'],
), "importKey");
const buffer = await subtleCryptoResult(this._subtleCrypto.sign(
opts,
hmacKey,
data,
), "sign");
return new Uint8Array(buffer);
}
}
const nwbo = (num, len) => {
const arr = new Uint8Array(len);
for(let i=0; i<len; i++) arr[i] = 0xFF && (num >> ((len - i - 1)*8));
return arr;
};
class CryptoLegacyHMACDriver {
constructor(hmacDriver) {
this._hmacDriver = hmacDriver;
}
async verify(key, mac, data, hash) {
if (hash === "SHA-512") {
throw new Error("SHA-512 HMAC verification is not implemented yet");
} else {
return this._hmacDriver.verify(key, mac, data, hash)
}
}
async compute(key, data, hash) {
if (hash === "SHA-256") {
return await this._hmacDriver.compute(key, data, hash);
} else {
const shaObj = new window.jsSHA(hash, "UINT8ARRAY", {
"hmacKey": {
"value": key,
"format": "UINT8ARRAY"
}
});
shaObj.update(data);
return shaObj.getHash("UINT8ARRAY");
}
}
}
class CryptoLegacyDeriveDriver {
constructor(cryptoDriver) {
this._cryptoDriver = cryptoDriver;
}
// adapted from https://github.com/junkurihara/jscu/blob/develop/packages/js-crypto-pbkdf/src/pbkdf.ts#L21
// could also consider https://github.com/brix/crypto-js/blob/develop/src/pbkdf2.js although not async
async pbkdf2(password, iterations, salt, hash, length) {
const dkLen = length / 8;
if (iterations <= 0) {
throw new Error('InvalidIterationCount');
}
if (dkLen <= 0) {
throw new Error('InvalidDerivedKeyLength');
}
const hLen = this._cryptoDriver.digestSize(hash);
if(dkLen > (Math.pow(2, 32) - 1) * hLen) throw new Error('DerivedKeyTooLong');
const l = Math.ceil(dkLen/hLen);
const r = dkLen - (l-1)*hLen;
const funcF = async (i) => {
const seed = new Uint8Array(salt.length + 4);
seed.set(salt);
seed.set(nwbo(i+1, 4), salt.length);
let u = await this._cryptoDriver.hmac.compute(password, seed, hash);
let outputF = new Uint8Array(u);
for(let j = 1; j < iterations; j++){
if ((j % 1000) === 0) {
console.log(j, j/iterations);
}
u = await this._cryptoDriver.hmac.compute(password, u, hash);
outputF = u.map( (elem, idx) => elem ^ outputF[idx]);
}
return {index: i, value: outputF};
};
const Tis = [];
const DK = new Uint8Array(dkLen);
for(let i = 0; i < l; i++) {
Tis.push(funcF(i));
}
const TisResolved = await Promise.all(Tis);
TisResolved.forEach(elem => {
if (elem.index !== l - 1) {
DK.set(elem.value, elem.index*hLen);
}
else {
DK.set(elem.value.slice(0, r), elem.index*hLen);
}
});
return DK;
}
// based on https://github.com/junkurihara/jscu/blob/develop/packages/js-crypto-hkdf/src/hkdf.ts
async hkdf(key, salt, info, hash, length) {
length = length / 8;
const len = this._cryptoDriver.digestSize(hash);
// RFC5869 Step 1 (Extract)
const prk = await this._cryptoDriver.hmac.compute(salt, key, hash);
// RFC5869 Step 2 (Expand)
let t = new Uint8Array([]);
const okm = new Uint8Array(Math.ceil(length / len) * len);
for(let i = 0; i < Math.ceil(length / len); i++){
const concat = new Uint8Array(t.length + info.length + 1);
concat.set(t);
concat.set(info, t.length);
concat.set(new Uint8Array([i+1]), t.length + info.length);
t = await this._cryptoDriver.hmac.compute(prk, concat, hash);
okm.set(t, len * i);
}
return okm.slice(0, length);
}
}
class CryptoDeriveDriver {
constructor(subtleCrypto) {
this._subtleCrypto = subtleCrypto;
}
/**
* [pbkdf2 description]
* @param {BufferSource} password
* @param {Number} iterations
* @param {BufferSource} salt
* @param {HashName} hash
* @param {Number} length the desired length of the generated key, in bits (not bytes!)
* @return {BufferSource}
*/
async pbkdf2(password, iterations, salt, hash, length) {
// check for existance of deriveBits, which IE11 does not have
const key = await subtleCryptoResult(this._subtleCrypto.importKey(
'raw',
password,
{name: 'PBKDF2'},
false,
['deriveBits'],
), "importKey");
const keybits = await subtleCryptoResult(this._subtleCrypto.deriveBits(
{
name: 'PBKDF2',
salt,
iterations,
hash: hashName(hash),
},
key,
length,
), "deriveBits");
return new Uint8Array(keybits);
}
/**
* [hkdf description]
* @param {BufferSource} key [description]
* @param {BufferSource} salt [description]
* @param {BufferSource} info [description]
* @param {HashName} hash the hash to use
* @param {Number} length desired length of the generated key in bits (not bytes!)
* @return {[type]} [description]
*/
async hkdf(key, salt, info, hash, length) {
const hkdfkey = await subtleCryptoResult(this._subtleCrypto.importKey(
'raw',
key,
{name: "HKDF"},
false,
["deriveBits"],
), "importKey");
const keybits = await subtleCryptoResult(this._subtleCrypto.deriveBits({
name: "HKDF",
salt,
info,
hash: hashName(hash),
},
hkdfkey,
length,
), "deriveBits");
return new Uint8Array(keybits);
}
}
class CryptoAESDriver {
constructor(subtleCrypto) {
this._subtleCrypto = subtleCrypto;
}
/**
* [decrypt description]
* @param {BufferSource} key [description]
* @param {BufferSource} iv [description]
* @param {BufferSource} ciphertext [description]
* @return {BufferSource} [description]
*/
async decrypt(key, iv, ciphertext) {
const opts = {
name: "AES-CTR",
counter: iv,
length: 64,
};
let aesKey;
try {
aesKey = await subtleCryptoResult(this._subtleCrypto.importKey(
'raw',
key,
opts,
false,
['decrypt'],
), "importKey");
} catch (err) {
throw new Error(`Could not import key for AES-CTR decryption: ${err.message}`);
}
try {
const plaintext = await subtleCryptoResult(this._subtleCrypto.decrypt(
// see https://developer.mozilla.org/en-US/docs/Web/API/AesCtrParams
opts,
aesKey,
ciphertext,
), "decrypt");
return new Uint8Array(plaintext);
} catch (err) {
throw new Error(`Could not decrypt with AES-CTR: ${err.message}`);
}
}
}
class CryptoLegacyAESDriver {
/**
* [decrypt description]
* @param {BufferSource} key [description]
* @param {BufferSource} iv [description]
* @param {BufferSource} ciphertext [description]
* @return {BufferSource} [description]
*/
async decrypt(key, iv, ciphertext) {
const aesjs = window.aesjs;
var aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(iv));
return aesCtr.decrypt(ciphertext);
}
}
function hashName(name) {
if (name !== "SHA-256" && name !== "SHA-512") {
throw new Error(`Invalid hash name: ${name}`);
}
return name;
}
export class CryptoDriver {
constructor(subtleCrypto) {
this.aes = new CryptoLegacyAESDriver();
// this.aes = new CryptoAESDriver(subtleCrypto);
//this.derive = new CryptoDeriveDriver(subtleCrypto);
this.derive = new CryptoLegacyDeriveDriver(this);
// subtleCrypto.deriveBits ?
// new CryptoDeriveDriver(subtleCrypto) :
// new CryptoLegacyDeriveDriver(this);
this.hmac = new CryptoLegacyHMACDriver(new CryptoHMACDriver(subtleCrypto));
this._subtleCrypto = subtleCrypto;
}
/**
* [digest description]
* @param {HashName} hash
* @param {BufferSource} data
* @return {BufferSource}
*/
async digest(hash, data) {
return await subtleCryptoResult(this._subtleCrypto.digest(hashName(hash), data));
}
digestSize(hash) {
switch (hashName(hash)) {
case "SHA-512": return 64;
case "SHA-256": return 32;
default: throw new Error(`Not implemented for ${hashName(hash)}`);
}
}
}
export function decodeBase64(base64) {
const binStr = window.atob(base64);
const len = binStr.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binStr.charCodeAt(i);
}
return bytes;
}
const DEFAULT_ITERATIONS = 500000;
const DEFAULT_BITSIZE = 256;
export async function deriveSSSSKey(cryptoDriver, passphrase, ssssKey) {
const textEncoder = new TextEncoder();
return await cryptoDriver.derive.pbkdf2(
textEncoder.encode(passphrase),
ssssKey.content.passphrase.iterations || DEFAULT_ITERATIONS,
textEncoder.encode(ssssKey.content.passphrase.salt),
"SHA-512",
ssssKey.content.passphrase.bits || DEFAULT_BITSIZE);
}
export async function decryptSecret(cryptoDriver, keyId, ssssKey, event) {
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
// now derive the aes and mac key from the 4s key
const hkdfKey = await cryptoDriver.derive.hkdf(
ssssKey,
new Uint8Array(8).buffer, //salt
textEncoder.encode(event.type), // info
"SHA-256",
512 // 512 bits or 64 bytes
);
const aesKey = hkdfKey.slice(0, 32);
const hmacKey = hkdfKey.slice(32);
const data = event.content.encrypted[keyId];
const ciphertextBytes = decodeBase64(data.ciphertext);
const isVerified = await cryptoDriver.hmac.verify(
hmacKey, decodeBase64(data.mac),
ciphertextBytes, "SHA-256");
if (!isVerified) {
throw new Error("Bad MAC");
}
const plaintext = await cryptoDriver.aes.decrypt(aesKey, decodeBase64(data.iv), ciphertextBytes);
return textDecoder.decode(new Uint8Array(plaintext));
}
export async function decryptSession(backupKeyBase64, backupInfo, sessionResponse) {
const privKey = decodeBase64(backupKeyBase64);
console.log("privKey", privKey);
const decryption = new window.Olm.PkDecryption();
let backupPubKey;
try {
backupPubKey = decryption.init_with_private_key(privKey);
} catch (e) {
decryption.free();
throw e;
}
// If the pubkey computed from the private data we've been given
// doesn't match the one in the auth_data, the user has enetered
// a different recovery key / the wrong passphrase.
if (backupPubKey !== backupInfo.auth_data.public_key) {
console.log("backupPubKey", backupPubKey.length, backupPubKey);
throw new Error("bad backup key");
}
const sessionInfo = decryption.decrypt(
sessionResponse.session_data.ephemeral,
sessionResponse.session_data.mac,
sessionResponse.session_data.ciphertext,
);
return JSON.parse(sessionInfo);
}
const OLM_RECOVERY_KEY_PREFIX = [0x8B, 0x01];
export async function deserializeSSSSKey(recoverykey) {
const result = base58.decode(recoverykey.replace(/ /g, ''));
let parity = 0;
for (const b of result) {
parity ^= b;
}
if (parity !== 0) {
throw new Error("Incorrect parity");
}
for (let i = 0; i < OLM_RECOVERY_KEY_PREFIX.length; ++i) {
if (result[i] !== OLM_RECOVERY_KEY_PREFIX[i]) {
throw new Error("Incorrect prefix");
}
}
if (
result.length !==
OLM_RECOVERY_KEY_PREFIX.length + window.Olm.PRIVATE_KEY_LENGTH + 1
) {
throw new Error("Incorrect length");
}
return Uint8Array.from(result.slice(
OLM_RECOVERY_KEY_PREFIX.length,
OLM_RECOVERY_KEY_PREFIX.length + window.Olm.PRIVATE_KEY_LENGTH,
));
}

View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" href="non-ie11.css" media="not all and (-ms-high-contrast: none), (-ms-high-contrast: active)">
<link rel="stylesheet" type="text/css" href="ie11.css" media="all and (-ms-high-contrast: none), (-ms-high-contrast: active)">
<style type="text/css">
/*
can't make this work in non-IE browser for now...
*/
@import url('non-ie11.css') screen @supports(--foo: green);
</style>
</head>
<body>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Velit dignissim sodales ut eu sem integer vitae justo eget. Libero justo laoreet sit amet cursus sit amet dictum. Egestas fringilla phasellus faucibus scelerisque eleifend donec pretium vulputate. Quis eleifend quam adipiscing vitae proin sagittis nisl. Egestas maecenas pharetra convallis posuere morbi leo. Metus dictum at tempor commodo ullamcorper a lacus. Odio pellentesque diam volutpat commodo sed egestas egestas. Elementum eu facilisis sed odio morbi quis commodo odio aenean. Velit euismod in pellentesque massa placerat duis ultricies lacus sed. Feugiat sed lectus vestibulum mattis ullamcorper velit sed ullamcorper morbi. Pulvinar etiam non quam lacus suspendisse. Dictumst vestibulum rhoncus est pellentesque elit ullamcorper dignissim. Proin gravida hendrerit lectus a. Nibh sed pulvinar proin gravida. Massa placerat duis ultricies lacus. Enim sed faucibus turpis in eu mi bibendum neque egestas. Turpis egestas sed tempus urna et pharetra pharetra.</p>
</body>
</html>

View file

@ -0,0 +1,110 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
pre {
font-family: monospace;
display: block;
white-space: pre;
font-size: 2em;
}
</style>
</head>
<body>
<script type="text/javascript">
const MIN = 0;
const MAX = 0xFFFFFFFF;
function zeropad(a, n) {
return "0".repeat(n - a.length) + a;
}
// const encodeNumber = n => zeropad((n >>> 0).toString(16), 8);
function encodeNumber(n) {
const a = (n & 0xFFFF0000) >>> 16;
const b = (n & 0xFFFF) >>> 0;
//return zeropad(a.toString(16), 4) + zeropad(b.toString(16), 4);
return String.fromCharCode(a, b);
}
function decodeNumber(s) {
const a = s.charCodeAt(0);
const b = s.charCodeAt(1);
//return `${a.toString(16)} ${b}`;
return ((a << 16) | b) >>> 0;
}
function formatArg(a) {
if (typeof a === "string") {
return `"${a}"`;
}
if (Array.isArray(a)) {
return `[${a.map(formatArg)}]`;
}
return a+"";
}
function cmp(a, b) {
let value;
try {
const result = indexedDB.cmp(encodeNumber(a), encodeNumber(b));
if (result < 0) {
value = "a < b";
} else if (result === 0) {
value = "a = b";
} else if (result > 0) {
value = "a > b";
}
} catch(err) {
value = err.message;
}
return `cmp(${formatArg(a)} as ${formatArg(encodeNumber(a))},\n ${formatArg(b)} as ${formatArg(encodeNumber(b))}): ${value}`;
}
try {
const tests = [
// see https://stackoverflow.com/questions/28413947/space-efficient-way-to-encode-numbers-as-sortable-strings
// need to encode numbers with base 256 and zero padded at start
// should still fit in 8 bytes then?
(cmp) => cmp(9, 100000),
(cmp) => cmp(1, 2),
(cmp) => cmp(MIN, 1),
(cmp) => cmp(MIN, MIN),
(cmp) => cmp(MIN, MAX),
(cmp) => cmp(MAX >>> 1, MAX),
(cmp) => cmp(0x7fffffff, 0xffff7fff),
(cmp) => cmp(MAX, MAX),
(cmp) => cmp(MAX - 1, MAX),
];
for (const fn of tests) {
const txt = document.createTextNode(fn(cmp));
const p = document.createElement("pre");
p.appendChild(txt);
document.body.appendChild(p);
}
} catch(err) {
alert(err.message);
}
let prev;
for (let i = MIN; i <= MAX; i += 100) {
if (decodeNumber(encodeNumber(i)) !== i) {
document.write(`<p>${i} decodes back to ${decodeNumber(encodeNumber(i))}</p>`);
break;
}
if (typeof prev === "number") {
if (indexedDB.cmp(encodeNumber(prev), encodeNumber(i)) >= 0) {
document.write(`<p>${i} <= ${prev}</p>`);
break;
}
}
prev = i;
}
document.write(`<p>check from ${MIN} to ${prev}</p>`);
</script>
</body>
</html>

76
prototypes/idb-cmp.html Normal file
View file

@ -0,0 +1,76 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
pre {
font-family: "courier";
display: block;
white-space: pre;
}
</style>
</head>
<body>
<script type="text/javascript">
function encodeNumber(n) {
const a = (n & 0xFFFF);
const b = (n & 0xFFFF0000) >> 16;
const c = (n & 0xFFFF00000000) >> 32;
const d = (n & 0xFFFF000000000000) >> 48;
return String.fromCharCode(a, b, c, d);
}
function formatArg(a) {
if (typeof a === "string") {
return `"${a}"`;
}
if (Array.isArray(a)) {
return `[${a.map(formatArg)}]`;
}
return a+"";
}
function cmp(a, b) {
let value;
try {
const result = indexedDB.cmp(encodeNumber(a), encodeNumber(b));
if (result < 0) {
value = "a < b";
} else if (result === 0) {
value = "a = b";
} else if (result > 0) {
value = "a > b";
}
} catch(err) {
value = err.message;
}
return `cmp(${formatArg(a)},\n ${formatArg(b)}): ${value}`;
}
try {
const tests = [
(cmp) => cmp(Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER),
(cmp) => cmp([Number.MIN_SAFE_INTEGER], [Number.MAX_SAFE_INTEGER]),
// see https://stackoverflow.com/questions/28413947/space-efficient-way-to-encode-numbers-as-sortable-strings
// need to encode numbers with base 256 and zero padded at start
// should still fit in 8 bytes then?
(cmp) => cmp("foo-9", "foo-10000"),
(cmp) => cmp("foo-\u0000", "foo-\uFFFF"),
(cmp) => cmp("foo-\u0000", "foo-0"),
(cmp) => cmp("foo-" + Number.MAX_SAFE_INTEGER, "foo-\uFFFF"),
(cmp) => cmp("!abc:host.tld,"+Number.MIN_SAFE_INTEGER, "!abc:host.tld,"+(Number.MIN_SAFE_INTEGER + 1)),
(cmp) => cmp("!abc:host.tld,"+0, "!abc:host.tld,"+(Number.MAX_SAFE_INTEGER)),
(cmp) => cmp("!abc:host.tld,"+Math.floor(Number.MAX_SAFE_INTEGER / 2), "!abc:host.tld,"+(Number.MAX_SAFE_INTEGER)),
];
for (const fn of tests) {
const txt = document.createTextNode(fn(cmp));
const p = document.createElement("pre");
p.appendChild(txt);
document.body.appendChild(p);
}
} catch(err) {
alert(err.message);
}
</script>
</body>
</html>

View file

@ -0,0 +1,170 @@
<html>
<head><meta charset="utf-8"></head>
<body>
<script type="text/javascript">
console.log = (...params) => {
document.write(params.join(" ")+"<br>");
};
function reqAsPromise(req) {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req);
req.onerror = (err) => reject(err);
});
}
function txnAsPromise(txn) {
return new Promise((resolve, reject) => {
txn.addEventListener("complete", resolve);
txn.addEventListener("abort", reject);
});
}
function iterateCursor(cursor, processValue) {
// TODO: does cursor already have a value here??
return new Promise((resolve, reject) => {
cursor.onerror = (event) => {
reject(new Error("Query failed: " + event.target.errorCode));
};
// collect results
cursor.onsuccess = (event) => {
const cursor = event.target.result;
if (!cursor) {
resolve(false);
return; // end of results
}
const {done, jumpTo} = processValue(cursor.value, cursor.key);
if (done) {
resolve(true);
} else {
cursor.continue(jumpTo);
}
};
});
}
/**
* Checks if a given set of keys exist.
* Calls `callback(key, found)` for each key in `keys`, in an unspecified order.
* If the callback returns true, the search is halted and callback won't be called again.
*/
async function findKeys(target, keys, backwards, callback) {
const direction = backwards ? "prev" : "next";
const compareKeys = (a, b) => backwards ? -indexedDB.cmp(a, b) : indexedDB.cmp(a, b);
const sortedKeys = keys.slice().sort(compareKeys);
console.log(sortedKeys);
const firstKey = backwards ? sortedKeys[sortedKeys.length - 1] : sortedKeys[0];
const lastKey = backwards ? sortedKeys[0] : sortedKeys[sortedKeys.length - 1];
const cursor = target.openKeyCursor(IDBKeyRange.bound(firstKey, lastKey), direction);
let i = 0;
let consumerDone = false;
await iterateCursor(cursor, (value, key) => {
// while key is larger than next key, advance and report false
while(i < sortedKeys.length && compareKeys(sortedKeys[i], key) < 0 && !consumerDone) {
console.log("before match", sortedKeys[i]);
consumerDone = callback(sortedKeys[i], false);
++i;
}
if (i < sortedKeys.length && compareKeys(sortedKeys[i], key) === 0 && !consumerDone) {
console.log("match", sortedKeys[i]);
consumerDone = callback(sortedKeys[i], true);
++i;
}
const done = consumerDone || i >= sortedKeys.length;
const jumpTo = !done && sortedKeys[i];
return {done, jumpTo};
});
// report null for keys we didn't to at the end
while (!consumerDone && i < sortedKeys.length) {
console.log("afterwards", sortedKeys[i]);
consumerDone = callback(sortedKeys[i], false);
++i;
}
}
async function findFirstOrLastOccurringEventId(target, roomId, eventIds, findLast = false) {
const keys = eventIds.map(eventId => [roomId, eventId]);
const results = new Array(keys.length);
let firstFoundEventId;
// find first result that is found and has no undefined results before it
function firstFoundAndPrecedingResolved() {
let inc = findLast ? -1 : 1;
let start = findLast ? results.length - 1 : 0;
for(let i = start; i >= 0 && i < results.length; i += inc) {
if (results[i] === undefined) {
return;
} else if(results[i] === true) {
return keys[i];
}
}
}
await findKeys(target, keys, findLast, (key, found) => {
const index = keys.indexOf(key);
results[index] = found;
firstFoundEventId = firstFoundAndPrecedingResolved();
return !!firstFoundEventId;
});
return firstFoundEventId;
}
(async () => {
let db;
let isNew = false;
// open db
{
const req = window.indexedDB.open("prototype-idb-continue-key");
req.onupgradeneeded = (ev) => {
const db = ev.target.result;
db.createObjectStore("timeline", {keyPath: ["roomId", "eventId"]});
isNew = true;
};
db = (await reqAsPromise(req)).result;
}
const roomId = "!abcdef:localhost";
if (isNew) {
const txn = db.transaction(["timeline"], "readwrite");
const store = txn.objectStore("timeline");
for (var i = 1; i <= 100; ++i) {
store.add({roomId, eventId: `$${i * 10}`});
}
await txnAsPromise(txn);
}
console.log("show all in order we get them");
{
const txn = db.transaction(["timeline"], "readonly");
const store = txn.objectStore("timeline");
const cursor = store.openKeyCursor();
await iterateCursor(cursor, (value, key) => {
console.log(key);
return {done: false};
});
}
console.log("run findKeys");
{
const txn = db.transaction(["timeline"], "readonly");
const store = txn.objectStore("timeline");
const eventIds = ["$992", "$1000", "$1010", "$991", "$500", "$990"];
// const eventIds = ["$992", "$1010"];
const keys = eventIds.map(eventId => [roomId, eventId]);
await findKeys(store, keys, false, (key, found) => {
console.log(key, found);
});
}
console.log("run findFirstOrLastOccurringEventId");
{
const txn = db.transaction(["timeline"], "readonly");
const store = txn.objectStore("timeline");
const eventIds = ["$992", "$1000", "$1010", "$991", "$500", "$990", "$123"];
const firstMatch = await findFirstOrLastOccurringEventId(store, roomId, eventIds, false);
console.log("firstMatch", firstMatch);
const lastMatch = await findFirstOrLastOccurringEventId(store, roomId, eventIds, true);
console.log("lastMatch", lastMatch);
}
})();
</script>
</body>
</html>

View file

@ -0,0 +1,100 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<script type="text/javascript">
class IDBError extends Error {
constructor(errorEvent) {
const request = errorEvent.target;
const {error} = request;
super(error.message);
this.name = error.name;
this.errorEvent = errorEvent;
}
preventAbort() {
this.errorEvent.preventDefault();
}
}
class AbortError extends Error {
get name() { return "AbortError"; }
}
function reqAsPromise(req) {
return new Promise(function (resolve, reject) {
req.onsuccess = function(e) {
resolve(e.target.result);
};
req.onerror = function(e) {
reject(new IDBError(e));
};
});
}
function txnAsPromise(txn) {
return new Promise((resolve, reject) => {
txn.addEventListener("complete", () => resolve());
txn.addEventListener("abort", event => {
reject(new AbortError());
});
});
}
function Storage(databaseName) {
this._databaseName = databaseName;
this._database = null;
}
Storage.prototype = {
open: function() {
const req = window.indexedDB.open(this._databaseName);
const self = this;
req.onupgradeneeded = function(ev) {
const db = ev.target.result;
const oldVersion = ev.oldVersion;
self._createStores(db, oldVersion);
};
return reqAsPromise(req).then(function() {
self._database = req.result;
});
},
readWriteTxn: function(storeName) {
return this._database.transaction([storeName], "readwrite");
},
readTxn: function(storeName) {
return this._database.transaction([storeName], "readonly");
},
_createStores: function(db) {
db.createObjectStore("foos", {keyPath: "id"});
}
};
async function main() {
const storage = new Storage("idb-continue-on-constrainterror");
await storage.open();
const txn1 = storage.readWriteTxn("foos");
const store = txn1.objectStore("foos");
await reqAsPromise(store.clear());
console.log("first foo read back", await reqAsPromise(store.get(5)));
await reqAsPromise(store.add({id: 5, name: "Mr Foo"}));
try {
await reqAsPromise(store.add({id: 5, name: "bar"}));
} catch (err) {
console.log("we did get an error", err.name);
err.preventAbort();
}
await txnAsPromise(txn1);
const txn2 = storage.readTxn("foos");
const store2 = txn2.objectStore("foos");
console.log("got name from second txn", await reqAsPromise(store2.get(5)));
}
main().catch(err => console.error(err));
</script>
</body>
</html>

View file

@ -0,0 +1,75 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<script type="text/javascript">
function reqAsPromise(req) {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req);
req.onerror = (err) => reject(err);
});
}
class Storage {
constructor(databaseName) {
this._databaseName = databaseName;
this._database = null;
}
async open() {
const req = window.indexedDB.open(this._databaseName);
req.onupgradeneeded = (ev) => {
const db = ev.target.result;
const oldVersion = ev.oldVersion;
this._createStores(db, oldVersion);
};
await reqAsPromise(req);
this._database = req.result;
}
_createStores(db) {
db.createObjectStore("files", {keyPath: ["idName"]});
}
async storeFoo(id, name) {
const tx = this._database.transaction(["files"], "readwrite");
const store = tx.objectStore("files");
await reqAsPromise(store.add(value(id, name)));
}
}
function value(id, name) {
return {idName: key(id, name)};
}
function key(id, name) {
return id+","+name;
}
async function main() {
let storage = new Storage("idb-multi-key2");
try {
await storage.open();
await storage.storeFoo(5, "foo");
await storage.storeFoo(6, "bar");
alert("all good");
} catch(err) {
alert(err.message);
}
try {
const result = indexedDB.cmp(key(5, "foo"), key(6, "bar"));
//IDBKeyRange.bound(["aaa", "111"],["zzz", "999"], false, false);
alert("all good: " + result);
} catch (err) {
alert(`IDBKeyRange.bound: ${err.message}`);
}
}
main();
</script>
</body>
</html>

View file

@ -0,0 +1,112 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<script type="text/javascript" src="promifill.js"></script>
<!-- <script src="https://cdn.jsdelivr.net/npm/promise-polyfill@8/dist/polyfill.min.js"></script> -->
<script type="text/javascript">
//window.Promise = Promifill;
function reqAsPromise(req) {
return new Promise(function (resolve, reject) {
req.onsuccess = function() {
resolve(req);
Promise.flushQueue && Promise.flushQueue();
};
req.onerror = function(e) {
reject(new Error("IDB request failed: " + e.target.error.message));
Promise.flushQueue && Promise.flushQueue();
};
});
}
function Storage(databaseName) {
this._databaseName = databaseName;
this._database = null;
}
Storage.prototype = {
open: function() {
const req = window.indexedDB.open(this._databaseName);
const self = this;
req.onupgradeneeded = function(ev) {
const db = ev.target.result;
const oldVersion = ev.oldVersion;
self._createStores(db, oldVersion);
};
return reqAsPromise(req).then(function() {
self._database = req.result;
});
},
openTxn: function(mode, storeName) {
const txn = this._database.transaction([storeName], mode);
const store = txn.objectStore(storeName);
return Promise.resolve(store);
},
_createStores: function(db) {
db.createObjectStore("foos", {keyPath: ["id"]});
}
};
function getAll(store) {
const request = store.openCursor();
const results = [];
return new Promise(function(resolve, reject) {
request.onsuccess = function(event) {
const cursor = event.target.result;
if(cursor) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
Promise.flushQueue && Promise.flushQueue();
}
};
request.onerror = function(e) {
reject(new Error("IDB request failed: " + e.target.error.message));
Promise.flushQueue && Promise.flushQueue();
};
});
}
async function main() {
try {
let storage = new Storage("idb-promises");
await storage.open();
const store = await storage.openTxn("readwrite", "foos");
store.clear();
store.add({id: 5, name: "foo"});
store.add({id: 6, name: "bar"});
console.log("all1", await getAll(store));
store.add({id: 7, name: "bazzz"});
console.log("all2", await getAll(store));
} catch(err) {
console.error(err.message + ": " + err.stack);
};
}
main();
/*
we basically want something like this for IE11/Win7:
return new Promise(function (resolve, reject) {
req.onsuccess = function() {
resolve(req);
Promise?.flushQueue();
};
req.onerror = function(e) {
reject(new Error("IDB request failed: " + e.target.error.message));
Promise?.flushQueue();
};
});
we don't have this problem on platforms with a native promise implementation, so we can just have our own (forked) promise polyfill?
*/
</script>
</body>
</html>

View file

@ -0,0 +1,169 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<script type="text/javascript">
function reqAsPromise(req) {
return new Promise(function (resolve, reject) {
req.onsuccess = function() {
resolve(req.result);
};
req.onerror = function(e) {
reject(new Error("IDB request failed: " + req.error));
};
});
}
function txnAsPromise(txn) {
return new Promise(function (resolve, reject) {
txn.oncomplete = function() {
resolve(txn);
};
txn.onabort = function(e) {
reject(new Error("Transaction got aborted: " + txn.error));
};
});
}
const BrowserMutationObserver = window.MutationObserver || window.WebKitMutationObserver;
function useMutationObserver(flush) {
let iterations = 0;
const observer = new BrowserMutationObserver(flush);
const node = document.createTextNode('');
observer.observe(node, { characterData: true });
return () => {
node.data = (iterations = ++iterations % 2);
};
}
const wait = (function() {
let resolve = null;
const trigger = useMutationObserver(() => {
resolve();
});
return () => {
return new Promise(r => {
resolve = r;
trigger();
});
};
})();
var _resolve = Promise.resolve.bind(Promise);
var _then = Promise.prototype.then;
async function delay() {
return Promise.resolve();
// two consecutive macro tasks
//await new Promise(r => setImmediate(r));
// the next macro task will now be the complete event of the txn,
// so schedule another macro task to execute after that
//await new Promise(r => setImmediate(r));
//return;
// for (let i = 0; i < 1000; i+=1) {
// console.log("await...");
// await wait();
// }
let p = _resolve(0);
for (let i=0;i<10;++i) {
p = _then.call(p, x => x + 1);
}
let result = await p;
console.log("Result: "+ result + " (should be 10)");
}
class Storage {
constructor(databaseName) {
this._databaseName = databaseName;
this._database = null;
}
open() {
const req = window.indexedDB.open(this._databaseName);
const self = this;
req.onupgradeneeded = function(ev) {
const db = ev.target.result;
const oldVersion = ev.oldVersion;
self._createStores(db, oldVersion);
};
return reqAsPromise(req).then(function() {
self._database = req.result;
});
}
openTxn(mode, storeName) {
const txn = this._database.transaction([storeName], mode);
txn.addEventListener("complete", () => {
console.info(`transaction ${mode} for ${storeName} completed`);
});
txn.addEventListener("abort", e => {
console.warn(`transaction ${mode} for ${storeName} aborted`, e.target.error);
});
return txn;
}
_createStores(db) {
db.createObjectStore("foos", {keyPath: "id"});
}
}
async function getAll(store, depth = 0) {
if (depth < 15) {
return await getAll(store, depth + 1);
}
const request = store.openCursor();
const results = [];
return await new Promise(function(resolve, reject) {
request.onsuccess = function(event) {
const cursor = event.target.result;
if(cursor) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
Promise.flushQueue && Promise.flushQueue();
}
};
request.onerror = function(e) {
reject(new Error("IDB request failed: " + e.target.error.message));
Promise.flushQueue && Promise.flushQueue();
};
});
}
async function main() {
try {
let storage = new Storage("idb-promises");
await storage.open();
//await reqAsPromise(storage.openTxn("readwrite", "foos").objectStore("foos").clear());
for (let i = 0; i < 10; i += 1) {
storage.openTxn("readonly", "foos").objectStore("foos").get(5);
//console.log("from readtxn", await reqAsPromise(storage.openTxn("readonly", "foos").objectStore("foos").get(5)));
const txn = storage.openTxn("readwrite", "foos");
const store = txn.objectStore("foos");
console.log("writing the foos");
store.put({id: 5, name: "foo"});
store.put({id: 6, name: "bar"});
store.put({id: 7, name: "bazzz"});
await delay();
console.log("reading the foos");
console.log("5", await reqAsPromise(store.get(5)));
console.log("6", await reqAsPromise(store.get(6)));
console.log("7", await reqAsPromise(store.get(7)));
// await txnAsPromise(txn);
}
} catch(err) {
console.error(err);
};
}
main();
</script>
</body>
</html>

View file

@ -0,0 +1,118 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<script type="text/javascript" src="promifill.js"></script>
<!-- <script src="https://cdn.jsdelivr.net/npm/promise-polyfill@8/dist/polyfill.min.js"></script> -->
<script type="text/javascript">
//window.Promise = Promifill;
function reqAsPromise(req) {
return new Promise(function (resolve, reject) {
req.onsuccess = function() {
resolve(req);
Promise.flushQueue && Promise.flushQueue();
};
req.onerror = function(e) {
reject(new Error("IDB request failed: " + e.target.error.message));
Promise.flushQueue && Promise.flushQueue();
};
});
}
function Storage(databaseName) {
this._databaseName = databaseName;
this._database = null;
}
Storage.prototype = {
open: function() {
const req = window.indexedDB.open(this._databaseName);
const self = this;
req.onupgradeneeded = function(ev) {
const db = ev.target.result;
const oldVersion = ev.oldVersion;
self._createStores(db, oldVersion);
};
return reqAsPromise(req).then(function() {
self._database = req.result;
});
},
openTxn: function(mode, storeName) {
const txn = this._database.transaction([storeName], mode);
const store = txn.objectStore(storeName);
return Promise.resolve(store);
},
_createStores: function(db) {
db.createObjectStore("foos", {keyPath: ["id"]});
}
};
function getAll(store) {
const request = store.openCursor();
const results = [];
return new Promise(function(resolve, reject) {
request.onsuccess = function(event) {
const cursor = event.target.result;
if(cursor) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
Promise.flushQueue && Promise.flushQueue();
}
};
request.onerror = function(e) {
reject(new Error("IDB request failed: " + e.target.error.message));
Promise.flushQueue && Promise.flushQueue();
};
});
}
function main() {
let storage = new Storage("idb-promises");
let store;
storage.open().then(function() {
return storage.openTxn("readwrite", "foos");
}).then(function(s) {
store = s;
store.clear();
store.add({id: 5, name: "foo"});
store.add({id: 6, name: "bar"});
return getAll(store);
}).then(function(all) {
console.log("all1", all);
store.add({id: 7, name: "bazzz"});
return getAll(store);
}).then(function(all) {
console.log("all2", all);
}).catch(function(err) {
console.error(err.message + ": " + err.stack);
});
}
main();
/*
we basically want something like this for IE11/Win7:
return new Promise(function (resolve, reject) {
req.onsuccess = function() {
resolve(req);
Promise?.flushQueue();
};
req.onerror = function(e) {
reject(new Error("IDB request failed: " + e.target.error.message));
Promise?.flushQueue();
};
});
we don't have this problem on platforms with a native promise implementation, so we can just have our own (forked) promise polyfill?
*/
</script>
</body>
</html>

View file

@ -0,0 +1,161 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<ul id="files"></ul>
<p>
<input type="file" id="file" multiple capture="user" accept="image/*">
<button id="addFile">Add</button>
<button id="drop">Delete all</button>
</p>
<script type="text/javascript">
function reqAsPromise(req) {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req);
req.onerror = (err) => reject(err);
});
}
function fetchResults(cursor, isDone, resultMapper) {
return new Promise((resolve, reject) => {
const results = [];
cursor.onerror = (event) => {
reject(new Error("Query failed: " + event.target.errorCode));
};
// collect results
cursor.onsuccess = (event) => {
const cursor = event.target.result;
if (!cursor) {
resolve(results);
return; // end of results
}
results.push(resultMapper(cursor));
if (!isDone(results)) {
cursor.continue();
} else {
resolve(results);
}
};
});
}
class Storage {
constructor(databaseName) {
this._databaseName = databaseName;
this._database = null;
}
async open() {
const req = window.indexedDB.open(this._databaseName);
req.onupgradeneeded = (ev) => {
const db = ev.target.result;
const oldVersion = ev.oldVersion;
this._createStores(db, oldVersion);
};
await reqAsPromise(req);
this._database = req.result;
}
async drop() {
if (this._database) {
this._database.close();
this._database = null;
}
await reqAsPromise(window.indexedDB.deleteDatabase(this._databaseName));
}
_createStores(db) {
db.createObjectStore("files", {keyPath: "id"});
}
async storeFile(file) {
const id = Math.floor(Math.random() * 10000000000);
console.log(`adding a file as id ${id}`);
const tx = this._database.transaction(["files"], "readwrite");
const store = tx.objectStore("files");
await reqAsPromise(store.add({id, file}));
}
getFiles() {
const tx = this._database.transaction(["files"], "readonly");
const store = tx.objectStore("files");
const cursor = store.openCursor();
return fetchResults(cursor,
() => false,
(cursor) => cursor.value);
}
}
async function reloadFiles(storage, fileList) {
const files = await storage.getFiles();
const fileNodes = files.map(f => {
const {type, size, name} = f.file;
const txt = document.createTextNode(`${f.id} - ${name} of type ${type} - size: ${Math.round(size / 1024, 2)}kb`);
const li = document.createElement("li");
li.addEventListener("click", async () => {
const reader = new FileReader();
const promise = new Promise((resolve, reject) => {
reader.onload = e => resolve(e.target.result);
reader.onerror = e => reject(e.target.error);
});
reader.readAsArrayBuffer(f.file);
try {
const buf = await promise;
alert(`read blob, len is ${buf.byteLength}`);
} catch(e) {
alert(e.message);
}
});
li.appendChild(txt);
return li;
});
fileList.innerHTML = "";
for(const li of fileNodes) {
fileList.appendChild(li);
}
}
async function main() {
let storage = new Storage("idb-store-files-test");
await storage.open();
const fileList = document.getElementById("files");
const dropButton = document.getElementById("drop");
const addButton = document.getElementById("addFile");
const filePicker = document.getElementById("file");
addButton.addEventListener("click", async () => {
const files = Array.from(filePicker.files);
try {
for(const file of files) {
await storage.storeFile(file);
}
alert(`stored ${files.length} files!`);
reloadFiles(storage, fileList);
} catch(e) {
alert(e.message);
}
});
dropButton.addEventListener("click", async () => {
try {
if (storage) {
await storage.drop();
storage = null;
alert("dropped db");
}
} catch(e) {
alert(e.message);
}
});
reloadFiles(storage, fileList);
}
main();
</script>
</body>
</html>

View file

@ -0,0 +1,71 @@
<html>
<head><meta charset="utf-8"></head>
<body>
<script type="text/javascript">
const log = (...params) => {
document.write(params.join(" ")+"<br>");
};
function reqAsPromise(req) {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = (err) => reject(err);
});
}
function txnAsPromise(txn) {
return new Promise((resolve, reject) => {
txn.addEventListener("complete", resolve);
txn.addEventListener("abort", reject);
});
}
function openDatabase(name, createObjectStore, version) {
const req = indexedDB.open(name, version);
req.onupgradeneeded = (ev) => {
const db = ev.target.result;
const txn = ev.target.transaction;
const oldVersion = ev.oldVersion;
createObjectStore(db, txn, oldVersion, version);
};
return reqAsPromise(req);
}
async function detectWebkitEarlyCloseTxnBug() {
const dbName = "webkit_test_inactive_txn_" + Math.random() * Number.MAX_SAFE_INTEGER;
try {
const db = await openDatabase(dbName, db => {
db.createObjectStore("test", {keyPath: "key"});
}, 1);
const readTxn = db.transaction(["test"], "readonly");
await reqAsPromise(readTxn.objectStore("test").get("somekey"));
// schedule a macro task in between the two txns
await new Promise(r => setTimeout(r, 0));
const writeTxn = db.transaction(["test"], "readwrite");
await Promise.resolve();
writeTxn.objectStore("test").add({key: "somekey", value: "foo"});
await txnAsPromise(writeTxn);
} catch (err) {
if (err.name === "TransactionInactiveError") {
return true;
}
} finally {
try {
indexedDB.deleteDatabase(dbName);
} catch (err) {}
}
return false;
}
(async () => {
if (await detectWebkitEarlyCloseTxnBug()) {
log("the test failed, your browser seems to have the bug");
} else {
log("the test succeeded, your browser seems fine");
}
})();
</script>
</body>
</html>

80
prototypes/idb2rwtxn.html Normal file
View file

@ -0,0 +1,80 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<button id="doit">Do It 3!</button>
<script type="text/javascript">
function reqAsPromise(req) {
return new Promise((resolve, reject) => {
req.addEventListener("success", event => resolve(event.target.result));
req.addEventListener("error", event => reject(event.target.error));
});
}
function txnAsPromise(txn) {
return new Promise((resolve, reject) => {
txn.addEventListener("complete", resolve);
txn.addEventListener("abort", reject);
});
}
async function main() {
try {
const openReq = window.indexedDB.open("two-read-write-txn", 1);
openReq.onupgradeneeded = (ev) => {
const db = ev.target.result;
const store = db.createObjectStore("test", {keyPath: "id"});
store.add({id: 6});
};
const db = await reqAsPromise(openReq);
console.log("open txn1");
const txn1 = db.transaction("test", "readwrite");
const test1 = txn1.objectStore("test");
console.log("request value1");
const value1 = await reqAsPromise(test1.get(6));
console.log("value1", value1);
value1.previous = 5;
value1.next = null;
let txn2, test2, prom2, value2;
// prevent deadlock
(async function() {
console.log("open txn2");
txn2 = db.transaction("test", "readwrite");
test2 = txn2.objectStore("test");
console.log("request value2");
prom2 = reqAsPromise(test2.get(6));
value2 = await prom2;
console.log("read value2");
})();
console.log("write value1");
test1.put(value1);
await txnAsPromise(txn1);
await prom2;
console.log("value2", value2);
value2.next = 7;
console.log("write value2");
test2.put(value2);
await txnAsPromise(txn2);
const txn3 = db.transaction("test", "readonly");
const value3 = await reqAsPromise(txn3.objectStore("test").get(6));
alert("done " + JSON.stringify(value3));
} catch (err) {
alert(`error ${err.message} ${err.name} ${err.stack}`);
}
}
document.getElementById("doit").addEventListener("click", main);
</script>
</body>
</html>

View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<ul id="changes"></ul>
<script type="text/javascript">
const ul = document.getElementById("changes");
window.onhashchange = function() {
const hash = document.location.hash.substr(1);
const li = document.createElement("li");
li.appendChild(document.createTextNode(hash));
ul.appendChild(li);
window.history.replaceState(null, null, "#" + hash + hash);
}
</script>
<p>
<a href="#foo">foo</a>
<a href="#bar">bar</a>
<a href="#baz">baz</a>
</p>
</body>
</html>

91
prototypes/ie11-hmac.html Normal file
View file

@ -0,0 +1,91 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script src="https://dl.dropboxusercontent.com/s/r55397ld512etib/EncoderDecoderTogether.min.js?dl=0" nomodule="" type="text/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/promise-polyfill@8/dist/polyfill.min.js"></script>
<script src="deps/jsSHA/dist/sha512.js"></script>
<script type="text/javascript">
function decodeBase64(base64) {
const binStr = window.atob(base64);
const len = binStr.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binStr.charCodeAt(i);
}
return bytes;
}
function encodeBase64(bytes) {
let binStr = "";
for (let i = 0; i < bytes.length; i++) {
binStr += String.fromCharCode(bytes[i]);
}
return window.btoa(binStr);
}
function subtleCryptoResult(promiseOrOp, method) {
if (promiseOrOp instanceof Promise) {
return promiseOrOp;
} else {
return new Promise(function(resolve, reject) {
promiseOrOp.oncomplete = function(e) {resolve(e.target.result);}
promiseOrOp.onerror = function(e) {
reject(new Error("Crypto error on " + method));
}
});
}
}
const subtleCrypto = (window.crypto || window.msCrypto).subtle;
function computeFallback(key, data, hash) {
const shaObj = new jsSHA(hash, "UINT8ARRAY", {
"hmacKey": {
"value": key,
"format": "UINT8ARRAY"
}
});
shaObj.update(data);
return Promise.resolve(shaObj.getHash("UINT8ARRAY"));
}
function compute(key, data, hash) {
const opts = {
name: 'HMAC',
hash: {name: hash},
};
return subtleCryptoResult(subtleCrypto.importKey(
'raw',
key,
opts,
false,
['sign']
), "importKey").then(function (hmacKey) {
console.log("hmacKey", hmacKey);
return subtleCryptoResult(subtleCrypto.sign(
opts,
hmacKey,
data
), "sign");
}).then(function(buffer) {
return new Uint8Array(buffer);
});
}
const te = new TextEncoder();
computeFallback(
new Uint8Array(te.encode("I am a key!!")),
new Uint8Array(te.encode("I am some data!!")),
"SHA-512"
).then(function(mac) {
// should be 9bpJS7myNR/ttCfts+woXJSapVb19qqFRntGh17rHydOBB8+pplZFG8Cc4Qkxxznri4nWyzhFWcWnenY9vd5rA==
alert(encodeBase64(mac));
})
</script>
</body>
</html>

View file

@ -0,0 +1,23 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<script type="text/javascript">
const bytes = [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100];
const buffer = new Uint8Array(bytes.length);
for (let i = 0; i < buffer.length; i += 1) {
buffer[i] = bytes[i];
}
const blob = new Blob([buffer], {type: "text/plain"});
const reader = new FileReader();
reader.addEventListener("load", function(evt) {
const result = evt.target.result;
console.log("result", result);
});
reader.addEventListener("error", function(evt) {reject(evt.target.error);});
reader.readAsText(blob, "utf-8");
</script>
</body>
</html>

3
prototypes/ie11.css Normal file
View file

@ -0,0 +1,3 @@
p {
color: red;
}

View file

@ -0,0 +1,4 @@
CACHE MANIFEST
# v1
/responsive-layout-flex.html
/me.jpg

110
prototypes/matrix.mjs Normal file
View file

@ -0,0 +1,110 @@
/*
idb stores:
all in one database per stored session:
- session (store all sessions in localStorage object?)
- device id
- last sync token
- access token
- home server
- user id
- user name
- avatar
- filter(s)?
- room_summaries
- room_id
- heroes
- room_name
- room_avatar (just the url)
- tags (account_data?)
- is_direct
- unread_message_count ?
- unread_message_with_mention ?
- roomstate_{room_id}
we only really need historical roomstate for historical display names?
so we can get away without doing this to begin with ...
how about every state event gets a revision number
for each state event, we store the min and max revision number where they form part of the room state
then we "just" do a where revision_range includes revision, and every state event event/gap in the timeline we store the revision number, and we have an index on it? so we can easily look for the nearest one
it's like every state event we know about has a range where it is relevant
we want the intersection of a revision with all ranges
1 2 3 * 4 5 6
| topic | oth*er topic |
| power levels * |
| member a'1 | membe*r a'2 |
*-------- get intersection for all or some type & state_keys for revision 3 (forward) or 4 (backwards)
tricky to do a > && < in indexeddb
we'll need to do either > or < for min or max revision and iterate through the cursor and apply the rest of the conditions in code ...
all current state for last event would have max revision of some special value to indicate it hasn't been replaced yet.
the idea is that we can easily load just the state for a given event in the timeline,
can be the last synced event, or a permalink event
- members_{room_id}
historical?
- timeline_{room_id}
how to store timeline events in order they should be shown?
what's the key they should be sorted by?
start with origin_server_ts of first event as 0 and add / subtract from there
in case of gaps, take the max(last_ts + 1000, origin_server_ts) again to get an idea of how many
numbers are in between, and go down/up one again for events filling the gap
when closing the gap, we notice there are not enough numbers between the PK
of both sides of the gap (because more than 1 event / millisecond was sent, or server clocks were off),
what do we do? floating point?
- search?
where to store avatars?
we could cache the requested ones in a table ...
or service worker, but won't work on my phone
*/
class Credentials {
accessToken,
deviceId
}
class LoginFlow {
constructor(network) {
}
//differentiate between next stage and Credentials?
async next(stage) {}
static async attemptPasswordLogin(username, password) {
}
}
class LoginStage {
get type() {}
serialize() {} //called by LoginFlow::next
}
class PasswordStage extends LoginStage {
set password() {
}
set username() {
}
serialize() {
return {
identifier: {
type: "m.id.user",
user: this._username
},
password: this._password
};
}
}

BIN
prototypes/me.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -0,0 +1,378 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style type="text/css">
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
.container {
display: grid;
grid-template: "left middle" 1fr /
200px 1fr;
height: 100vh;
}
.container .left {
display: grid;
grid-template:
"welcome" auto
"rooms" 1fr /
1fr;
min-height: 0;
}
.container .middle {
display: grid;
grid-template:
"header" auto
"timeline" 1fr
"composer" auto /
1fr;
min-height: 0;
position: relative;
}
.left { grid-area: left;}
.left p {
grid-area welcome;
display: flex;
}
.left ul {
grid-area: rooms;
min-height: 0;
overflow-y: auto;
}
.middle { grid-area: middle;}
.middle .header { grid-area: header;}
.middle .timeline {
grid-area: timeline;
min-height: 0;
overflow-y: auto;
}
.middle .composer {
grid-area: composer;
}
.header {
display: flex;
}
.header h2 {
flex: 1;
}
.composer {
display: flex;
}
.composer input {
display: block;
flex: 1;
}
.menu {
position: absolute;
border-radius: 8px;
box-shadow: 2px 2px 10px rgba(0,0,0,0.5);
padding: 16px;
background-color: white;
z-index: 1;
list-style: none;
margin: 0;
}
</style>
</head>
<body>
<div class="container">
<div class="left">
<p>Welcome!<button></button></p>
<ul>
<li>Room xyz <button></button></li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz <button></button></li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz</li>
<li>Room xyz <button></button></li>
</ul>
</div>
<div class="middle">
<div class="header">
<h2>Room xyz</h2>
<button></button>
</div>
<ul class="timeline">
<li>Message abc</li>
<li>Message abc <button></button></li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc <button></button></li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc</li>
<li>Message abc <button></button></li>
</ul>
<div class="composer">
<input type="text" name="">
<button></button>
</div>
</div>
</div>
<script type="text/javascript">
let menu;
function createMenu(options) {
const menu = document.createElement("ul");
menu.className = "menu";
for (const o of options) {
const li = document.createElement("li");
li.innerText = o;
menu.appendChild(li);
}
return menu;
}
function showMenu(evt) {
if (menu) {
menu = menu.close();
} else if (evt.target.tagName.toLowerCase() === "button") {
menu = showPopup(evt.target, createMenu(["Send file", "Save contact", "Send picture", "Foo the bar"]), {
horizontal: {
relativeTo: "end",
align: "start",
after: 0,
},
vertical: {
relativeTo: "end",
align: "end",
after: 10,
}
});
}
}
function showMenuInScroller(evt) {
if (!menu && evt.target.tagName.toLowerCase() === "button") {
evt.stopPropagation();
menu = showPopup(evt.target, createMenu(["Show reactions", "Share"]), {
horizontal: {
relativeTo: "start",
align: "end",
after: 10,
},
vertical: {
relativeTo: "start",
align: "center",
}
});
}
}
document.body.addEventListener("click", showMenu, false);
document.querySelector(".middle ul").addEventListener("click", showMenuInScroller, false);
document.querySelector(".left ul").addEventListener("click", showMenuInScroller, false);
function showPopup(target, popup, arrangement) {
targetAxes = elementToAxes(target);
if (!arrangement) {
arrangement = getAutoArrangement(targetAxes);
}
target.offsetParent.appendChild(popup);
const popupAxes = elementToAxes(popup);
const scrollerAxes = elementToAxes(findScrollParent(target));
const offsetParentAxes = elementToAxes(target.offsetParent);
function reposition() {
if (scrollerAxes && !isVisibleInScrollParent(targetAxes.vertical, scrollerAxes.vertical)) {
popupObj.close();
}
applyArrangement(
popupAxes.vertical,
targetAxes.vertical,
offsetParentAxes.vertical,
scrollerAxes?.vertical,
arrangement.vertical
);
applyArrangement(
popupAxes.horizontal,
targetAxes.horizontal,
offsetParentAxes.horizontal,
scrollerAxes?.horizontal,
arrangement.horizontal
);
}
reposition();
document.body.addEventListener("scroll", reposition, true);
const popupObj = {
close() {
document.body.removeEventListener("scroll", reposition, true);
popup.remove();
}
};
return popupObj;
}
function elementToAxes(element) {
if (element) {
return {
horizontal: new HorizontalAxis(element),
vertical: new VerticalAxis(element),
element
};
}
}
function findScrollParent(el) {
let parent = el;
do {
parent = parent.parentElement;
if (parent.scrollHeight > parent.clientHeight) {
return parent;
}
} while (parent !== el.offsetParent);
}
function isVisibleInScrollParent(targetAxis, scrollerAxis) {
// clipped at start?
if ((targetAxis.offsetStart + targetAxis.clientSize) < (
scrollerAxis.offsetStart +
scrollerAxis.scrollOffset
)) {
return false;
}
// clipped at end?
if (targetAxis.offsetStart > (
scrollerAxis.offsetStart +
scrollerAxis.clientSize +
scrollerAxis.scrollOffset
)) {
return false;
}
return true;
}
function applyArrangement(elAxis, targetAxis, offsetParentAxis, scrollerAxis, {relativeTo, align, before, after}) {
if (relativeTo === "end") {
let end = offsetParentAxis.clientSize - targetAxis.offsetStart;
if (align === "end") {
end -= elAxis.offsetSize;
} else if (align === "center") {
end -= ((elAxis.offsetSize / 2) - (targetAxis.offsetSize / 2));
}
if (typeof before === "number") {
end += before;
} else if (typeof after === "number") {
end -= (targetAxis.offsetSize + after);
}
elAxis.end = end;
} else if (relativeTo === "start") {
let scrollOffset = scrollerAxis?.scrollOffset || 0;
let start = targetAxis.offsetStart - scrollOffset;
if (align === "start") {
start -= elAxis.offsetSize;
} else if (align === "center") {
start -= ((elAxis.offsetSize / 2) - (targetAxis.offsetSize / 2));
}
if (typeof before === "number") {
start -= before;
} else if (typeof after === "number") {
start += (targetAxis.offsetSize + after);
}
elAxis.start = start;
} else {
throw new Error("unknown relativeTo: " + relativeTo);
}
}
class HorizontalAxis {
constructor(el) {
this.element = el;
}
get scrollOffset() {return this.element.scrollLeft;}
get clientSize() {return this.element.clientWidth;}
get offsetSize() {return this.element.offsetWidth;}
get offsetStart() {return this.element.offsetLeft;}
set start(value) {this.element.style.left = `${value}px`;}
set end(value) {this.element.style.right = `${value}px`;}
}
class VerticalAxis {
constructor(el) {
this.element = el;
}
get scrollOffset() {return this.element.scrollTop;}
get clientSize() {return this.element.clientHeight;}
get offsetSize() {return this.element.offsetHeight;}
get offsetStart() {return this.element.offsetTop;}
set start(value) {this.element.style.top = `${value}px`;}
set end(value) {this.element.style.bottom = `${value}px`;}
}
</script>
</body>
</html>

3
prototypes/non-ie11.css Normal file
View file

@ -0,0 +1,3 @@
p {
color: green;
}

View file

@ -0,0 +1,128 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
pre {
font-family: monospace;
display: block;
white-space: pre;
font-size: 2em;
}
</style>
</head>
<body>
<script type="text/javascript">
if (!Math.imul) Math.imul = function(a,b) {return (a*b)|0;}/* function(a, b) {
var aHi = (a >>> 16) & 0xffff;
var aLo = a & 0xffff;
var bHi = (b >>> 16) & 0xffff;
var bLo = b & 0xffff;
// the shift by 0 fixes the sign on the high part
// the final |0 converts the unsigned value into a signed value
return ((aLo * bLo) + (((aHi * bLo + aLo * bHi) << 16) >>> 0) | 0);
};*/
if (!Math.clz32) Math.clz32 = (function(log, LN2){
return function(x) {
// Let n be ToUint32(x).
// Let p be the number of leading zero bits in
// the 32-bit binary representation of n.
// Return p.
var asUint = x >>> 0;
if (asUint === 0) {
return 32;
}
return 31 - (log(asUint) / LN2 | 0) |0; // the "| 0" acts like math.floor
};
})(Math.log, Math.LN2);
</script>
<script src="https://cdn.jsdelivr.net/npm/promise-polyfill@8/dist/polyfill.min.js"></script>
<script type="text/javascript" src="../lib/olm/olm_legacy.js"></script>
<script type="text/javascript">
function doit(log) {
var alice = new Olm.Account();
alice.create();
log("alice", alice.identity_keys());
var bob = new Olm.Account();
bob.unpickle("secret", "EWfA87or4GgQ+wqVkyuFiW9gUk3FI6QSXgp8E2dS5RFLvXgy4oFvxwQ1gVnbMkdJz2Hy9ex9UmJ/ZyuRU0aRt0IwXpw/SUNq4IQeVJ7J/miXW7rV4Ep+4RSEf945KbDrokDCS2CoL5PIfv/NYyey32gA0hMi8wWIfIlOxFBV4SBJYSC+Qd54VjprwCg0Sn9vjQouKVrM/+5jzsv9+JK5OpWW0Vrb3qrXwyAOEAQ4WlOQcqZHAyPQIw");
log("bob", bob.identity_keys());
// generate OTK on receiver side
bob.generate_one_time_keys(1);
var bobOneTimeKeys = JSON.parse(bob.one_time_keys());
var otkName = Object.getOwnPropertyNames(bobOneTimeKeys.curve25519)[0];
var bobOneTimeKey = bobOneTimeKeys.curve25519[otkName];
// encrypt
var aliceSession = new Olm.Session();
aliceSession.create_outbound(
alice,
JSON.parse(bob.identity_keys()).curve25519,
bobOneTimeKey
);
log("alice outbound session created");
var aliceSessionPickled = aliceSession.pickle("secret");
log("aliceSession pickled", aliceSessionPickled);
try {
var tmp = new Olm.Session();
tmp.unpickle("secret", aliceSessionPickled);
log("aliceSession unpickled");
} finally {
tmp.free();
}
var message = aliceSession.encrypt("hello secret world");
log("message", message);
// decrypt
var bobSession = new Olm.Session();
bobSession.create_inbound(bob, message.body);
var plaintext = bobSession.decrypt(message.type, message.body);
log("plaintext", plaintext);
// remove Bob's OTK as it was used to start an olm session
log("bob OTK before removing", bob.one_time_keys());
bob.remove_one_time_keys(bobSession);
log("bob OTK after removing", bob.one_time_keys());
}
if (window.msCrypto && !window.crypto) {
window.crypto = window.msCrypto;
}
function doRun(e) {
e.target.setAttribute("disabled", "disabled");
var logEl = document.getElementById("log");
logEl.innerText = "";
var startTime = performance.now();
function log() {
var timeDiff = Math.round(performance.now() - startTime).toString();
while (timeDiff.length < 5) {
timeDiff = "0" + timeDiff;
}
logEl.appendChild(document.createTextNode(timeDiff + " "));
for (var i = 0; i < arguments.length; i += 1) {
var value = arguments[i];
if (typeof value !== "string") {
value = JSON.stringify(value);
}
logEl.appendChild(document.createTextNode(value + " "));
}
logEl.appendChild(document.createTextNode("\n"));
}
doit(log);
e.target.removeAttribute("disabled");
}
function main() {
Olm.init( ).then(function() {
var startButton = document.getElementById("start");
startButton.innerText = "Start";
startButton.addEventListener("click", doRun);
});
}
document.addEventListener("DOMContentLoaded", main);
</script>
<pre id="log"></pre>
<button id="start">Loading...</button>
</body>
</html>

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