diff --git a/.eslintrc.js b/.eslintrc.js index cb28f4c8..eb23d387 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,6 +17,7 @@ module.exports = { "globals": { "DEFINE_VERSION": "readonly", "DEFINE_GLOBAL_HASH": "readonly", + "DEFINE_PROJECT_DIR": "readonly", // only available in sw.js "DEFINE_UNHASHED_PRECACHED_ASSETS": "readonly", "DEFINE_HASHED_PRECACHED_ASSETS": "readonly", diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..3dd1a6ab --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,7 @@ +# Docker related files are not maintained by the core Hydrogen team +/.dockerignore @hughns @sandhose +/Dockerfile @hughns @sandhose +/Dockerfile-dev @hughns @sandhose +/.github/workflows/docker-publish.yml @hughns @sandhose +/docker/ @hughns @sandhose +/doc/docker.md @hughns @sandhose diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 0a322a3d..a70eea6c 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -21,10 +21,13 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 - name: Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -32,13 +35,14 @@ jobs: - name: Extract Docker metadata id: meta - uses: docker/metadata-action@v3 + uses: docker/metadata-action@v4 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - name: Build and push Docker image - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v3 with: push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64,linux/arm64,linux/arm/v7 diff --git a/Dockerfile b/Dockerfile index f9e32313..3a430d6c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,26 @@ -FROM docker.io/node:alpine as builder +FROM --platform=${BUILDPLATFORM} 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 +WORKDIR /app + +# Copy package.json and yarn.lock and install dependencies first to speed up subsequent builds +COPY package.json yarn.lock /app/ +RUN yarn install + +COPY . /app +RUN yarn build + +# Because we will be running as an unprivileged user, we need to make sure that the config file is writable +# So, we will copy the default config to the /tmp folder that will be writable at runtime +RUN mv -f target/config.json /config.json.bundled \ + && ln -sf /tmp/config.json target/config.json + +FROM --platform=${TARGETPLATFORM} docker.io/nginxinc/nginx-unprivileged:alpine + +# Copy the dynamic config script +COPY ./docker/dynamic-config.sh /docker-entrypoint.d/99-dynamic-config.sh +# And the bundled config file +COPY --from=builder /config.json.bundled /config.json.bundled + +# Copy the built app from the first build stage COPY --from=builder /app/target /usr/share/nginx/html diff --git a/Dockerfile-dev b/Dockerfile-dev index 08dd9abd..7212a4ae 100644 --- a/Dockerfile-dev +++ b/Dockerfile-dev @@ -1,7 +1,12 @@ FROM docker.io/node:alpine RUN apk add --no-cache git python3 build-base -COPY . /code + WORKDIR /code + +# Copy package.json and yarn.lock and install dependencies first to speed up subsequent builds +COPY package.json yarn.lock /code/ RUN yarn install + +COPY . /code EXPOSE 3000 ENTRYPOINT ["yarn", "start"] diff --git a/doc/FAQ.md b/FAQ.md similarity index 83% rename from doc/FAQ.md rename to FAQ.md index ce372dc3..43053c2f 100644 --- a/doc/FAQ.md +++ b/FAQ.md @@ -10,6 +10,10 @@ TorBrowser ships a crippled IndexedDB implementation and will not work. At some 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. +The following browser extensions are known to break Hydrogen + - uBlock Origin (Some custom filters seem to block the service worker script) + - Try locating the filter that is blocking the service worker script in the uBlock Origin logger, and disabling that filter. Otherwise, the easiest solution is to disable uBlock Origin for the Hydrogen site (by opening the uBlock Origin popup and clicking the large power button symbol). It is possible to re-enable it after logging in, but it may possibly break again when there is an update. + ## 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 ;) @@ -32,4 +36,4 @@ Published builds can be found at https://github.com/vector-im/hydrogen-web/relea ## 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). +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](doc/SDK.md). diff --git a/README.md b/README.md index 6c447024..7ad012a5 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ You can run Hydrogen locally by the following commands in the terminal: Now point your browser to `http://localhost:3000`. If you prefer, you can also [use docker](doc/docker.md). +PS: You need nodejs, running yarn on top of any other js platform is not supported. + # FAQ -Some frequently asked questions are answered [here](doc/FAQ.md). +Some frequently asked questions are answered [here](FAQ.md). diff --git a/doc/GOAL.md b/doc/GOAL.md deleted file mode 100644 index 3883cf27..00000000 --- a/doc/GOAL.md +++ /dev/null @@ -1,8 +0,0 @@ -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 diff --git a/doc/SKINNING.md b/doc/SKINNING.md deleted file mode 100644 index 5f1c735d..00000000 --- a/doc/SKINNING.md +++ /dev/null @@ -1,22 +0,0 @@ -# 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 ` 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 */ -``` diff --git a/doc/TODO.md b/doc/TODO.md deleted file mode 100644 index 7d16400d..00000000 --- a/doc/TODO.md +++ /dev/null @@ -1,77 +0,0 @@ -# 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 diff --git a/doc/api.md b/doc/api.md deleted file mode 100644 index 89e03639..00000000 --- a/doc/api.md +++ /dev/null @@ -1,90 +0,0 @@ -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 - 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) \ No newline at end of file diff --git a/doc/THEMING.md b/doc/architecture/THEMING.md similarity index 99% rename from doc/THEMING.md rename to doc/architecture/THEMING.md index 599434bd..c00ab155 100644 --- a/doc/THEMING.md +++ b/doc/architecture/THEMING.md @@ -80,6 +80,7 @@ Currently supported operations are: | -------- | -------- | -------- | | darker | percentage | color | | lighter | percentage | color | +| alpha | alpha percentage | color | ## Aliases It is possible give aliases to variables in the `theme.css` file: diff --git a/doc/UI/index.md b/doc/architecture/UI/index.md similarity index 100% rename from doc/UI/index.md rename to doc/architecture/UI/index.md diff --git a/doc/UI/render-dom-elements.md b/doc/architecture/UI/render-dom-elements.md similarity index 100% rename from doc/UI/render-dom-elements.md rename to doc/architecture/UI/render-dom-elements.md diff --git a/doc/UI/ui.md b/doc/architecture/UI/ui.md similarity index 100% rename from doc/UI/ui.md rename to doc/architecture/UI/ui.md diff --git a/doc/architecture.md b/doc/architecture/architecture.md similarity index 100% rename from doc/architecture.md rename to doc/architecture/architecture.md diff --git a/doc/images/coloring-process.png b/doc/architecture/images/coloring-process.png similarity index 100% rename from doc/images/coloring-process.png rename to doc/architecture/images/coloring-process.png diff --git a/doc/images/svg-icon-example.png b/doc/architecture/images/svg-icon-example.png similarity index 100% rename from doc/images/svg-icon-example.png rename to doc/architecture/images/svg-icon-example.png diff --git a/doc/images/theming-architecture.png b/doc/architecture/images/theming-architecture.png similarity index 100% rename from doc/images/theming-architecture.png rename to doc/architecture/images/theming-architecture.png diff --git a/doc/persisted-network-calls.md b/doc/architecture/persisted-network-calls.md similarity index 100% rename from doc/persisted-network-calls.md rename to doc/architecture/persisted-network-calls.md diff --git a/doc/sync-updates.md b/doc/architecture/sync-updates.md similarity index 100% rename from doc/sync-updates.md rename to doc/architecture/sync-updates.md diff --git a/doc/architecture/updates.md b/doc/architecture/updates.md new file mode 100644 index 00000000..6522fd10 --- /dev/null +++ b/doc/architecture/updates.md @@ -0,0 +1,58 @@ +# Updates + +How updates flow from the model to the view model to the UI. + +## EventEmitter, single values + +When interested in updates from a single object, chances are it inherits from `EventEmitter` and it supports a `change` event. + +`ViewModel` by default follows this pattern, but it can be overwritten, see Collections below. + +### Parameters + +Often a `parameters` or `params` argument is passed with the name of the field who's value has now changed. This parameter is currently only sometimes used, e.g. when it is too complicated or costly to check every possible field. An example of this is `TilesListView.onUpdate` to see if the `shape` property of a tile changed and hence the view needs to be recreated. Other than that, bindings in the web UI just reevaluate all bindings when receiving an update. This is a soft convention that could probably be more standardized, and it's not always clear what to pass (e.g. when multiple fields are being updated). + +Another reason to keep this convention around is that if one day we decide to add support for a different platform with a different UI, it may not be feasible to reevaluate all data-bindings in the UI for a given view model when receiving an update. + +## Collections + +As an optimization, Hydrogen uses a pattern to let updates flow over an observable collection where this makes sense. There is an `update` event for this in both `ObservableMap` and `ObservableList`. This prevents having to listen for updates on each individual item in large collections. The `update` event uses the same `params` argument as explained above. + +Some values like `BaseRoom` emit both with a `change` event on the event emitter and also over the collection. This way consumers can use what fits best for their case: the left panel can listen for updates on the room over the collection to power the room list, and the room view model can listen to the event emitter to get updates from the current room only. + +### MappedMap and mapping models to `ViewModel`s + +This can get a little complicated when using `MappedMap`, e.g. when mapping a model from `matrix/` +to a view model in `domain/`. Often, view models will want to emit updates _spontanously_, +e.g. without a prior update being sent from the lower-lying model. An example would be to change the value of a field after the view has called a method on the view model. +To support this pattern while having updates still flow over the collection requires some extra work; +`ViewModel` has a `emitChange` option which you can pass in to override +what `ViewModel.emitChange` does (by default it emits the `change` event on the view model). +`MappedMap` passes a callback to emit an update over the collection to the mapper function. +You can pass this callback as the `emitChange` option and updates will now flow over the collection. + +`MappedMap` also accepts an updater function, which you can use to make the view model respond to updates +from the lower-lying model. + +Here is an example: + +```ts +const viewModels = someCollection.mapValues( + (model, emitChange) => new SomeViewModel(this.childOptions({ + model, + // will make ViewModel.emitChange go over + // the collection rather than emit a "change" event + emitChange, + })), + // an update came in from the model, let the vm know + (vm: SomeViewModel) => vm.onUpdate(), + ); +``` + +### `ListView` & the `parentProvidesUpdates` flag. + +`ObservableList` is always rendered in the UI using `ListView`. When receiving an update over the collection, it will find the child view for the given index and call `update(params)` on it. Views will typically need to be told whether they should listen to the `change` event in their view model or rather wait for their `update()` method to be called by their parent view, `ListView`. That's why the `mount(args)` method on a view supports a `parentProvidesUpdates` flag. If `true`, the view should not subscribe to its view model, but rather updates the DOM when its `update()` method is called. Also see `BaseUpdateView` and `TemplateView` for how this is implemented in the child view. + +## `ObservableValue` + +When some method wants to return an object that can be updated, often an `ObservableValue` is used rather than an `EventEmitter`. It's not 100% clear cut when to use the former or the latter, but `ObservableValue` is often used when the returned value in it's entirety will change rather than just a property on it. `ObservableValue` also has some nice facilities like lazy evaluation when subscribed to and the `waitFor` method to work with promises. \ No newline at end of file diff --git a/doc/docker.md b/doc/docker.md index 910938f0..41632909 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -35,15 +35,17 @@ To stop the container, simply hit `ctrl+c`. In this repository, create a Docker image: -``` +```sh +# Enable BuildKit https://docs.docker.com/develop/develop-images/build_enhancements/ +export DOCKER_BUILDKIT=1 docker build -t hydrogen . ``` -Or, pull the docker image from GitLab: +Or, pull the docker image from GitHub Container Registry: ``` -docker pull registry.gitlab.com/jcgruenhage/hydrogen-web -docker tag registry.gitlab.com/jcgruenhage/hydrogen-web hydrogen +docker pull ghcr.io/vector-im/hydrogen-web +docker tag ghcr.io/vector-im/hydrogen-web hydrogen ``` ### Start container image @@ -53,6 +55,32 @@ Then, start up a container from that image: ``` docker run \ --name hydrogen \ - --publish 80:80 \ + --publish 8080:8080 \ + hydrogen +``` + +n.b. the image is now based on the unprivileged nginx base, so the port is now `8080` instead of `80` and you need a writable `/tmp` volume. + +You can override the default `config.json` using the `CONFIG_OVERRIDE` environment variable. For example to specify a different Homeserver and : + +``` +docker run \ + --name hydrogen \ + --publish 8080:8080 \ + --env CONFIG_OVERRIDE='{ + "push": { + "appId": "io.element.hydrogen.web", + "gatewayUrl": "https://matrix.org", + "applicationServerKey": "BC-gpSdVHEXhvHSHS0AzzWrQoukv2BE7KzpoPO_FfPacqOo3l1pdqz7rSgmB04pZCWaHPz7XRe6fjLaC-WPDopM" + }, + "defaultHomeServer": "https://fosdem.org", + "themeManifests": [ + "assets/theme-element.json" + ], + "defaultTheme": { + "light": "element-light", + "dark": "element-dark" + } +}' \ hydrogen ``` diff --git a/doc/error-handling.md b/doc/error-handling.md new file mode 100644 index 00000000..ac2c4e59 --- /dev/null +++ b/doc/error-handling.md @@ -0,0 +1,15 @@ +# Error handling + +Ideally, every error that is unexpected and can't be automatically recovered from without degrading the experience is shown in the UI. This is the task of the view model, and you can use `ErrorReportViewModel` for this purpose, a dedicated base view model class. It exposes a child view model, `ErrorViewModel`, when `reportError` is called which can be paired with `ErrorView` in the view to present an error message from which debug logs can also be sent. + +Methods on classes from the `matrix` layer can often throw errors and those errors should be caught in the view model and reported to the UI. When inheriting from `ErrorReportViewModel`, there is the low-level `reportError` method, but typically you'd use the convenience method `logAndCatch`. The latter makes it easy to get both error handlikng and logging right. You would typically use `logAndCatch` for every public method in the view model (e.g methods called from the view or from the parent view model). It calls a callback within a log item and also a try catch that reports the error. + +## Sync errors & ErrorBoundary + +There are some errors that are thrown during background processes though, most notably the sync loop. These processes are not triggered by the view model directly, and hence there is not always a method call they can wrap in a try/catch. For this, there is the `ErrorBoundary` utility class. Since almost all aspects of the client can be updated through the sync loop, it is also not too helpful if there is only one try/catch around the whole sync and we stop sync if something goes wrong. + +Instead, it's more helpful to split up the error handling into different scopes, where errors are stored and not rethrown when leaving the scope. One example is to have a scope per room. In this way, we can isolate an error occuring during sync to a specific room, and report it in the UI of that room. This is typically where you would use `reportError` from `ErrorReportViewModel` rather than `logAndCatch`. You observe changes from your model in the view model (see docs on updates), and if the `error` property is set (by the `ErrorBoundary`), you call reportError with it. You can do this repeatedly without problems, if the same error is already reported, it's a No-Op. + +### `writeSync` and preventing data loss when dealing with errors. + +There is an extra complication though. The `writeSync` sync lifecycle step should not swallow any errors, or data loss can occur. This is because the whole `writeSync` lifecycle step writes all changes (for all rooms, the session, ...) for a sync response in one transaction (including the sync token), and aborts the transaction and stops sync if there is an error thrown during this step. So if there is an error in `writeSync` of a given room, it's fair to assume not all changes it was planning to write were passed to the transaction, as it got interrupted by the exception. Therefore, if we would swallow the error, data loss can occur as we'd not get another chance to write these changes to disk as we would have advanced the sync token. Therefore, code in the `writeSync` lifecycle step should be written defensively but always throw. diff --git a/doc/impl-thoughts/CATCHUP-BACKFILL.md b/doc/implementation planning/CATCHUP-BACKFILL.md similarity index 100% rename from doc/impl-thoughts/CATCHUP-BACKFILL.md rename to doc/implementation planning/CATCHUP-BACKFILL.md diff --git a/doc/CSS.md b/doc/implementation planning/CSS.md similarity index 90% rename from doc/CSS.md rename to doc/implementation planning/CSS.md index 7365ec5b..36e02a3f 100644 --- a/doc/CSS.md +++ b/doc/implementation planning/CSS.md @@ -6,6 +6,10 @@ We could do top to bottom gradients in default avatars to make them look a bit c Can take ideas/adopt from OOCSS and SMACSS. +## Documentation + +Whether we use OOCSS, SMACSS or BEM, we should write a tool that uses a JS parser (acorn?) to find all css classes used in the view code by looking for a `{className: "..."}` pattern. E.g. if using BEM, use all the found classes to construct a doc with a section for every block, with therein all elements and modifiers. + ### 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 diff --git a/doc/impl-thoughts/DESIGN.md b/doc/implementation planning/DESIGN.md similarity index 100% rename from doc/impl-thoughts/DESIGN.md rename to doc/implementation planning/DESIGN.md diff --git a/doc/impl-thoughts/E2EE.md b/doc/implementation planning/E2EE.md similarity index 100% rename from doc/impl-thoughts/E2EE.md rename to doc/implementation planning/E2EE.md diff --git a/doc/impl-thoughts/FRAGMENTS.md b/doc/implementation planning/FRAGMENTS.md similarity index 100% rename from doc/impl-thoughts/FRAGMENTS.md rename to doc/implementation planning/FRAGMENTS.md diff --git a/doc/impl-thoughts/LOCAL-ECHO-STATE.md b/doc/implementation planning/LOCAL-ECHO-STATE.md similarity index 100% rename from doc/impl-thoughts/LOCAL-ECHO-STATE.md rename to doc/implementation planning/LOCAL-ECHO-STATE.md diff --git a/doc/impl-thoughts/LOGIN.md b/doc/implementation planning/LOGIN.md similarity index 100% rename from doc/impl-thoughts/LOGIN.md rename to doc/implementation planning/LOGIN.md diff --git a/doc/impl-thoughts/MEMBERS.md b/doc/implementation planning/MEMBERS.md similarity index 100% rename from doc/impl-thoughts/MEMBERS.md rename to doc/implementation planning/MEMBERS.md diff --git a/doc/impl-thoughts/PENDING_REPLIES.md b/doc/implementation planning/PENDING_REPLIES.md similarity index 100% rename from doc/impl-thoughts/PENDING_REPLIES.md rename to doc/implementation planning/PENDING_REPLIES.md diff --git a/doc/impl-thoughts/PUSH.md b/doc/implementation planning/PUSH.md similarity index 100% rename from doc/impl-thoughts/PUSH.md rename to doc/implementation planning/PUSH.md diff --git a/doc/QUESTIONS.md b/doc/implementation planning/QUESTIONS.md similarity index 100% rename from doc/QUESTIONS.md rename to doc/implementation planning/QUESTIONS.md diff --git a/doc/impl-thoughts/READ-RECEIPTS.md b/doc/implementation planning/READ-RECEIPTS.md similarity index 100% rename from doc/impl-thoughts/READ-RECEIPTS.md rename to doc/implementation planning/READ-RECEIPTS.md diff --git a/doc/impl-thoughts/RECONNECTING.md b/doc/implementation planning/RECONNECTING.md similarity index 100% rename from doc/impl-thoughts/RECONNECTING.md rename to doc/implementation planning/RECONNECTING.md diff --git a/doc/impl-thoughts/RELATIONS.md b/doc/implementation planning/RELATIONS.md similarity index 99% rename from doc/impl-thoughts/RELATIONS.md rename to doc/implementation planning/RELATIONS.md index 5d91c28e..ac4e43ce 100644 --- a/doc/impl-thoughts/RELATIONS.md +++ b/doc/implementation planning/RELATIONS.md @@ -237,7 +237,7 @@ room.sendEvent(eventEntry.eventType, replacement); ## Replies ```js -const reply = eventEntry.reply({}); +const reply = eventEntry.createReplyContent({}); room.sendEvent("m.room.message", reply); ``` diff --git a/doc/RELEASE.md b/doc/implementation planning/RELEASE.md similarity index 100% rename from doc/RELEASE.md rename to doc/implementation planning/RELEASE.md diff --git a/doc/impl-thoughts/REPLIES.md b/doc/implementation planning/REPLIES.md similarity index 100% rename from doc/impl-thoughts/REPLIES.md rename to doc/implementation planning/REPLIES.md diff --git a/doc/impl-thoughts/ROOM-VERSIONS.md b/doc/implementation planning/ROOM-VERSIONS.md similarity index 100% rename from doc/impl-thoughts/ROOM-VERSIONS.md rename to doc/implementation planning/ROOM-VERSIONS.md diff --git a/doc/impl-thoughts/SDK.md b/doc/implementation planning/SDK.md similarity index 100% rename from doc/impl-thoughts/SDK.md rename to doc/implementation planning/SDK.md diff --git a/doc/SENDING.md b/doc/implementation planning/SENDING.md similarity index 100% rename from doc/SENDING.md rename to doc/implementation planning/SENDING.md diff --git a/doc/impl-thoughts/SSO.md b/doc/implementation planning/SSO.md similarity index 100% rename from doc/impl-thoughts/SSO.md rename to doc/implementation planning/SSO.md diff --git a/doc/impl-thoughts/VIEW-UPDATES.md b/doc/implementation planning/VIEW-UPDATES.md similarity index 100% rename from doc/impl-thoughts/VIEW-UPDATES.md rename to doc/implementation planning/VIEW-UPDATES.md diff --git a/doc/impl-thoughts/background-tasks.md b/doc/implementation planning/background-tasks.md similarity index 100% rename from doc/impl-thoughts/background-tasks.md rename to doc/implementation planning/background-tasks.md diff --git a/doc/impl-thoughts/html-messages.md b/doc/implementation planning/html-messages.md similarity index 100% rename from doc/impl-thoughts/html-messages.md rename to doc/implementation planning/html-messages.md diff --git a/doc/invites.md b/doc/implementation planning/invites.md similarity index 100% rename from doc/invites.md rename to doc/implementation planning/invites.md diff --git a/doc/implementation planning/room-types.ts b/doc/implementation planning/room-types.ts new file mode 100644 index 00000000..f683e927 --- /dev/null +++ b/doc/implementation planning/room-types.ts @@ -0,0 +1,55 @@ +/* +different room types create different kind of "sync listeners", who implement the sync lifecycle handlers + +they would each have a factory, +*/ + +interface IRoomSyncHandler { + prepareSync() + afterPrepareSync() + writeSync() + afterSync() + afterSyncCompleted() +} + +interface IRoom extends IRoomSyncHandler { + start(): void; + load(): void; + get id(): string; +} + +interface IRoomFactory { + createRoom(type, roomId, syncResponse): T + createSchema(db, txn, oldVersion, version, log) + get storesForSync(): string[]; + get rooms(): ObservableMap +} + +class InstantMessageRoom implements IRoom { +} + +class InstantMessageRoomFactory implements IRoomFactory{ + loadLastMessages(): Promise + /* + get all room ids and sort them according to idb sorting order + open cursor 'f' on `timelineFragments` + open a cursor 'e' on `timelineEvents` + for each room: + with cursor 'f', go to last fragment id and go up from there to find live fragment + with cursor 'e', go to last event index for fragment id and room id and go up until we have acceptable event type + for encrypted rooms: + decrypt message if needed (m.room.encrypted is likely something we want to display) + */ +} + +class SpaceRoom implements IRoom {} + +class SpaceRoomFactory implements IRoomFactory { + createRoom(type, roomId, syncResponse): IRoomSyncHandler +} + +class Session { + constructor(roomFactoriesByType: Map) { + + } +} diff --git a/doc/impl-thoughts/session-container.md b/doc/implementation planning/session-container.md similarity index 100% rename from doc/impl-thoughts/session-container.md rename to doc/implementation planning/session-container.md diff --git a/doc/impl-thoughts/timeline-member.md b/doc/implementation planning/timeline-member.md similarity index 100% rename from doc/impl-thoughts/timeline-member.md rename to doc/implementation planning/timeline-member.md diff --git a/doc/IMPORT-ISSUES.md b/doc/problem solving/IMPORT-ISSUES.md similarity index 100% rename from doc/IMPORT-ISSUES.md rename to doc/problem solving/IMPORT-ISSUES.md diff --git a/doc/INDEXEDDB.md b/doc/problem solving/INDEXEDDB.md similarity index 100% rename from doc/INDEXEDDB.md rename to doc/problem solving/INDEXEDDB.md diff --git a/doc/domexception_mapping.md b/doc/problem solving/domexception_mapping.md similarity index 100% rename from doc/domexception_mapping.md rename to doc/problem solving/domexception_mapping.md diff --git a/doc/TS-MIGRATION.md b/doc/style guide/typescript.md similarity index 100% rename from doc/TS-MIGRATION.md rename to doc/style guide/typescript.md diff --git a/doc/viewhierarchy.md b/doc/viewhierarchy.md deleted file mode 100644 index c4e6355a..00000000 --- a/doc/viewhierarchy.md +++ /dev/null @@ -1,21 +0,0 @@ -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 -``` diff --git a/docker/dynamic-config.sh b/docker/dynamic-config.sh new file mode 100755 index 00000000..99858a27 --- /dev/null +++ b/docker/dynamic-config.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +set -eux + +if [ -n "${CONFIG_OVERRIDE:-}" ]; then + # Use config override environment variable if set + echo "$CONFIG_OVERRIDE" > /tmp/config.json +else + # Otherwise, use the default config that was bundled in the image + cp /config.json.bundled /tmp/config.json +fi diff --git a/package.json b/package.json index 5429e0cf..e367bc09 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydrogen-web", - "version": "0.3.4", + "version": "0.3.8", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "directories": { "doc": "doc" @@ -32,6 +32,7 @@ }, "homepage": "https://github.com/vector-im/hydrogen-web/#readme", "devDependencies": { + "@matrixdotorg/structured-logviewer": "^0.0.3", "@playwright/test": "^1.27.1", "@typescript-eslint/eslint-plugin": "^4.29.2", "@typescript-eslint/parser": "^4.29.2", diff --git a/scripts/logviewer/file.js b/scripts/logviewer/file.js deleted file mode 100644 index 64a8422b..00000000 --- a/scripts/logviewer/file.js +++ /dev/null @@ -1,51 +0,0 @@ -/* -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -export function openFile(mimeType = null) { - const input = document.createElement("input"); - input.setAttribute("type", "file"); - input.className = "hidden"; - if (mimeType) { - input.setAttribute("accept", mimeType); - } - const promise = new Promise((resolve, reject) => { - const checkFile = () => { - input.removeEventListener("change", checkFile, true); - const file = input.files[0]; - document.body.removeChild(input); - if (file) { - resolve(file); - } else { - reject(new Error("no file picked")); - } - } - input.addEventListener("change", checkFile, true); - }); - // IE11 needs the input to be attached to the document - document.body.appendChild(input); - input.click(); - return promise; -} - -export function readFileAsText(file) { - const reader = new FileReader(); - const promise = new Promise((resolve, reject) => { - reader.addEventListener("load", evt => resolve(evt.target.result)); - reader.addEventListener("error", evt => reject(evt.target.error)); - }); - reader.readAsText(file); - return promise; -} diff --git a/scripts/logviewer/html.js b/scripts/logviewer/html.js deleted file mode 100644 index a965a6ee..00000000 --- a/scripts/logviewer/html.js +++ /dev/null @@ -1,110 +0,0 @@ -/* -Copyright 2020 Bruno Windels - -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. -*/ - -// DOM helper functions - -export function isChildren(children) { - // children should be an not-object (that's the attributes), or a domnode, or an array - return typeof children !== "object" || !!children.nodeType || Array.isArray(children); -} - -export function classNames(obj, value) { - return Object.entries(obj).reduce((cn, [name, enabled]) => { - if (typeof enabled === "function") { - enabled = enabled(value); - } - if (enabled) { - return cn + (cn.length ? " " : "") + name; - } else { - return cn; - } - }, ""); -} - -export function setAttribute(el, name, value) { - if (name === "className") { - name = "class"; - } - if (value === false) { - el.removeAttribute(name); - } else { - if (value === true) { - value = name; - } - el.setAttribute(name, value); - } -} - -export function el(elementName, attributes, children) { - return elNS(HTML_NS, elementName, attributes, children); -} - -export function elNS(ns, elementName, attributes, children) { - if (attributes && isChildren(attributes)) { - children = attributes; - attributes = null; - } - - const e = document.createElementNS(ns, elementName); - - if (attributes) { - for (let [name, value] of Object.entries(attributes)) { - if (name === "className" && typeof value === "object" && value !== null) { - value = classNames(value); - } - setAttribute(e, name, value); - } - } - - if (children) { - if (!Array.isArray(children)) { - children = [children]; - } - for (let c of children) { - if (!c.nodeType) { - c = text(c); - } - e.appendChild(c); - } - } - return e; -} - -export function text(str) { - return document.createTextNode(str); -} - -export const HTML_NS = "http://www.w3.org/1999/xhtml"; -export const SVG_NS = "http://www.w3.org/2000/svg"; - -export const TAG_NAMES = { - [HTML_NS]: [ - "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"], - [SVG_NS]: ["svg", "circle"] -}; - -export const tag = {}; - - -for (const [ns, tags] of Object.entries(TAG_NAMES)) { - for (const tagName of tags) { - tag[tagName] = function(attributes, children) { - return elNS(ns, tagName, attributes, children); - } - } -} diff --git a/scripts/logviewer/index.html b/scripts/logviewer/index.html deleted file mode 100644 index 790c4508..00000000 --- a/scripts/logviewer/index.html +++ /dev/null @@ -1,209 +0,0 @@ - - - - - - - - -
- - - - diff --git a/scripts/logviewer/main.js b/scripts/logviewer/main.js deleted file mode 100644 index b6883667..00000000 --- a/scripts/logviewer/main.js +++ /dev/null @@ -1,398 +0,0 @@ -/* -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import {tag as t} from "./html.js"; -import {openFile, readFileAsText} from "./file.js"; - -const main = document.querySelector("main"); - -let selectedItemNode; -let rootItem; -let itemByRef; - -const logLevels = [undefined, "All", "Debug", "Detail", "Info", "Warn", "Error", "Fatal", "Off"]; - -main.addEventListener("click", event => { - if (event.target.classList.contains("toggleExpanded")) { - const li = event.target.parentElement.parentElement; - li.classList.toggle("expanded"); - } else { - // allow clicking any links other than .item in the timeline, like refs - if (event.target.tagName === "A" && !event.target.classList.contains("item")) { - return; - } - const itemNode = event.target.closest(".item"); - if (itemNode) { - // we don't want scroll to jump when clicking - // so prevent default behaviour, and select and push to history manually - event.preventDefault(); - selectNode(itemNode); - history.pushState(null, null, `#${itemNode.id}`); - } - } -}); - -window.addEventListener("hashchange", () => { - const id = window.location.hash.substr(1); - const itemNode = document.getElementById(id); - if (itemNode && itemNode.closest("main")) { - selectNode(itemNode); - itemNode.scrollIntoView({behavior: "smooth", block: "nearest"}); - } -}); - -function selectNode(itemNode) { - if (selectedItemNode) { - selectedItemNode.classList.remove("selected"); - } - selectedItemNode = itemNode; - selectedItemNode.classList.add("selected"); - let item = rootItem; - let parent; - const indices = selectedItemNode.id.split("/").map(i => parseInt(i, 10)); - for(const i of indices) { - parent = item; - item = itemChildren(item)[i]; - } - showItemDetails(item, parent, selectedItemNode); -} - -function stringifyItemValue(value) { - if (typeof value === "object" && value !== null) { - return JSON.stringify(value, undefined, 2); - } else { - return value + ""; - } -} - -function showItemDetails(item, parent, itemNode) { - const parentOffset = itemStart(parent) ? `${itemStart(item) - itemStart(parent)}ms` : "none"; - const expandButton = t.button("Expand recursively"); - expandButton.addEventListener("click", () => expandResursively(itemNode.parentElement.parentElement)); - const start = itemStart(item); - const aside = t.aside([ - t.h3(itemCaption(item)), - t.p([t.strong("Log level: "), logLevels[itemLevel(item)]]), - t.p([t.strong("Error: "), itemError(item) ? `${itemError(item).name} ${itemError(item).stack}` : "none"]), - t.p([t.strong("Parent offset: "), parentOffset]), - t.p([t.strong("Start: "), new Date(start).toString(), ` (${start})`]), - t.p([t.strong("Duration: "), `${itemDuration(item)}ms`]), - t.p([t.strong("Child count: "), itemChildren(item) ? `${itemChildren(item).length}` : "none"]), - t.p([t.strong("Forced finish: "), (itemForcedFinish(item) || false) + ""]), - t.p(t.strong("Values:")), - t.ul({class: "values"}, Object.entries(itemValues(item)).map(([key, value]) => { - let valueNode; - if (key === "ref") { - const refItem = itemByRef.get(value); - if (refItem) { - valueNode = t.a({href: `#${refItem.id}`}, itemCaption(refItem)); - } else { - valueNode = `unknown ref ${value}`; - } - } else { - valueNode = stringifyItemValue(value); - } - return t.li([ - t.span({className: "key"}, normalizeValueKey(key)), - t.span({className: "value"}, valueNode) - ]); - })), - t.p(expandButton) - ]); - document.querySelector("aside").replaceWith(aside); -} - -function expandResursively(li) { - li.classList.add("expanded"); - const ol = li.querySelector("ol"); - if (ol) { - const len = ol.children.length; - for (let i = 0; i < len; i += 1) { - expandResursively(ol.children[i]); - } - } -} - -document.getElementById("openFile").addEventListener("click", loadFile); - -function getRootItemHeader(prevItem, item) { - if (prevItem) { - const diff = itemStart(item) - itemEnd(prevItem); - if (diff >= 0) { - return `+ ${formatTime(diff)}`; - } else { - const overlap = -diff; - if (overlap >= itemDuration(item)) { - return `ran entirely in parallel with`; - } else { - return `ran ${formatTime(-diff)} in parallel with`; - } - } - } else { - return new Date(itemStart(item)).toString(); - } -} - -async function loadFile() { - const file = await openFile(); - const json = await readFileAsText(file); - const logs = JSON.parse(json); - logs.items.sort((a, b) => itemStart(a) - itemStart(b)); - rootItem = {c: logs.items}; - itemByRef = new Map(); - preprocessRecursively(rootItem, null, itemByRef, []); - - const fragment = logs.items.reduce((fragment, item, i, items) => { - const prevItem = i === 0 ? null : items[i - 1]; - fragment.appendChild(t.section([ - t.h2(getRootItemHeader(prevItem, item)), - t.div({className: "timeline"}, t.ol(itemToNode(item, [i]))) - ])); - return fragment; - }, document.createDocumentFragment()); - main.replaceChildren(fragment); -} - -// TODO: make this use processRecursively -function preprocessRecursively(item, parentElement, refsMap, path) { - item.s = (parentElement?.s || 0) + item.s; - if (itemRefSource(item)) { - refsMap.set(itemRefSource(item), item); - } - if (itemChildren(item)) { - for (let i = 0; i < itemChildren(item).length; i += 1) { - // do it in advance for a child as we don't want to do it for the rootItem - const child = itemChildren(item)[i]; - const childPath = path.concat(i); - child.id = childPath.join("/"); - preprocessRecursively(child, item, refsMap, childPath); - } - } -} - -const MS_IN_SEC = 1000; -const MS_IN_MIN = MS_IN_SEC * 60; -const MS_IN_HOUR = MS_IN_MIN * 60; -const MS_IN_DAY = MS_IN_HOUR * 24; -function formatTime(ms) { - let str = ""; - if (ms > MS_IN_DAY) { - const days = Math.floor(ms / MS_IN_DAY); - ms -= days * MS_IN_DAY; - str += `${days}d`; - } - if (ms > MS_IN_HOUR) { - const hours = Math.floor(ms / MS_IN_HOUR); - ms -= hours * MS_IN_HOUR; - str += `${hours}h`; - } - if (ms > MS_IN_MIN) { - const mins = Math.floor(ms / MS_IN_MIN); - ms -= mins * MS_IN_MIN; - str += `${mins}m`; - } - if (ms > MS_IN_SEC) { - const secs = ms / MS_IN_SEC; - str += `${secs.toFixed(2)}s`; - } else if (ms > 0 || !str.length) { - str += `${ms}ms`; - } - return str; -} - -function itemChildren(item) { return item.c; } -function itemStart(item) { return item.s; } -function itemEnd(item) { return item.s + item.d; } -function itemDuration(item) { return item.d; } -function itemValues(item) { return item.v; } -function itemLevel(item) { return item.l; } -function itemLabel(item) { return item.v?.l; } -function itemType(item) { return item.v?.t; } -function itemError(item) { return item.e; } -function itemForcedFinish(item) { return item.f; } -function itemRef(item) { return item.v?.ref; } -function itemRefSource(item) { return item.v?.refId; } -function itemShortErrorMessage(item) { - if (itemError(item)) { - const e = itemError(item); - return e.name || e.stack.substr(0, e.stack.indexOf("\n")); - } -} - -function itemCaption(item) { - if (itemType(item) === "network") { - return `${itemValues(item)?.method} ${itemValues(item)?.url}`; - } else if (itemLabel(item) && itemValues(item)?.id) { - return `${itemLabel(item)} ${itemValues(item).id}`; - } else if (itemLabel(item) && itemValues(item)?.status) { - return `${itemLabel(item)} (${itemValues(item).status})`; - } else if (itemLabel(item) && itemError(item)) { - return `${itemLabel(item)} (${itemShortErrorMessage(item)})`; - } else if (itemRef(item)) { - const refItem = itemByRef.get(itemRef(item)); - if (refItem) { - return `ref "${itemCaption(refItem)}"` - } else { - return `unknown ref ${itemRef(item)}` - } - } else { - return itemLabel(item) || itemType(item); - } -} -function normalizeValueKey(key) { - switch (key) { - case "t": return "type"; - case "l": return "label"; - default: return key; - } -} - -// returns the node and the total range (recursively) occupied by the node -function itemToNode(item) { - const hasChildren = !!itemChildren(item)?.length; - const className = { - item: true, - "has-children": hasChildren, - error: itemError(item), - [`type-${itemType(item)}`]: !!itemType(item), - [`level-${itemLevel(item)}`]: true, - }; - - const id = item.id; - let captionNode; - if (itemRef(item)) { - const refItem = itemByRef.get(itemRef(item)); - if (refItem) { - captionNode = ["ref ", t.a({href: `#${refItem.id}`}, itemCaption(refItem))]; - } - } - if (!captionNode) { - captionNode = itemCaption(item); - } - const li = t.li([ - t.div([ - hasChildren ? t.button({className: "toggleExpanded"}) : "", - t.a({className, id, href: `#${id}`}, [ - t.span({class: "caption"}, captionNode), - t.span({class: "duration"}, `(${formatTime(itemDuration(item))})`), - ]) - ]) - ]); - if (itemChildren(item) && itemChildren(item).length) { - li.appendChild(t.ol(itemChildren(item).map(item => { - return itemToNode(item); - }))); - } - return li; -} - -const highlightForm = document.getElementById("highlightForm"); - -highlightForm.addEventListener("submit", evt => { - evt.preventDefault(); - const matchesOutput = document.getElementById("highlightMatches"); - const query = document.getElementById("highlight").value; - if (query) { - matchesOutput.innerText = "Searching…"; - let matches = 0; - processRecursively(rootItem, item => { - let domNode = document.getElementById(item.id); - if (itemMatchesFilter(item, query)) { - matches += 1; - domNode.classList.add("highlighted"); - domNode = domNode.parentElement; - while (domNode.nodeName !== "SECTION") { - if (domNode.nodeName === "LI") { - domNode.classList.add("expanded"); - } - domNode = domNode.parentElement; - } - } else { - domNode.classList.remove("highlighted"); - } - }); - matchesOutput.innerText = `${matches} matches`; - } else { - for (const node of document.querySelectorAll(".highlighted")) { - node.classList.remove("highlighted"); - } - matchesOutput.innerText = ""; - } -}); - -function itemMatchesFilter(item, query) { - if (itemError(item)) { - if (valueMatchesQuery(itemError(item), query)) { - return true; - } - } - return valueMatchesQuery(itemValues(item), query); -} - -function valueMatchesQuery(value, query) { - if (typeof value === "string") { - return value.includes(query); - } else if (typeof value === "object" && value !== null) { - for (const key in value) { - if (value.hasOwnProperty(key) && valueMatchesQuery(value[key], query)) { - return true; - } - } - } else if (typeof value === "number") { - return value.toString().includes(query); - } - return false; -} - -function processRecursively(item, callback, parentItem) { - if (item.id) { - callback(item, parentItem); - } - if (itemChildren(item)) { - for (let i = 0; i < itemChildren(item).length; i += 1) { - // do it in advance for a child as we don't want to do it for the rootItem - const child = itemChildren(item)[i]; - processRecursively(child, callback, item); - } - } -} - -document.getElementById("collapseAll").addEventListener("click", () => { - for (const node of document.querySelectorAll(".expanded")) { - node.classList.remove("expanded"); - } -}); -document.getElementById("hideCollapsed").addEventListener("click", () => { - for (const node of document.querySelectorAll("section > div.timeline > ol > li:not(.expanded)")) { - node.closest("section").classList.add("hidden"); - } -}); -document.getElementById("hideHighlightedSiblings").addEventListener("click", () => { - for (const node of document.querySelectorAll(".highlighted")) { - const list = node.closest("ol"); - const siblings = Array.from(list.querySelectorAll("li > div > a:not(.highlighted)")).map(n => n.closest("li")); - for (const sibling of siblings) { - if (!sibling.classList.contains("expanded")) { - sibling.classList.add("hidden"); - } - } - } -}); -document.getElementById("showAll").addEventListener("click", () => { - for (const node of document.querySelectorAll(".hidden")) { - node.classList.remove("hidden"); - } -}); \ No newline at end of file diff --git a/scripts/postcss/css-url-to-variables.js b/scripts/postcss/css-url-to-variables.js index 9988a10e..e4c4e1d1 100644 --- a/scripts/postcss/css-url-to-variables.js +++ b/scripts/postcss/css-url-to-variables.js @@ -24,7 +24,13 @@ const idToPrepend = "icon-url"; function findAndReplaceUrl(decl, urlVariables, counter) { const value = decl.value; - const parsed = valueParser(value); + let parsed; + try { + parsed = valueParser(value); + } catch (err) { + console.log(`Error trying to parse ${decl}`); + throw err; + } parsed.walk(node => { if (node.type !== "function" || node.value !== "url") { return; diff --git a/src/domain/AccountSetupViewModel.js b/src/domain/AccountSetupViewModel.js index e7c1301f..3e643582 100644 --- a/src/domain/AccountSetupViewModel.js +++ b/src/domain/AccountSetupViewModel.js @@ -16,7 +16,7 @@ limitations under the License. import {ViewModel} from "./ViewModel"; import {KeyType} from "../matrix/ssss/index"; -import {Status} from "./session/settings/KeyBackupViewModel.js"; +import {Status} from "./session/settings/KeyBackupViewModel"; export class AccountSetupViewModel extends ViewModel { constructor(options) { diff --git a/src/domain/AvatarSource.ts b/src/domain/AvatarSource.ts new file mode 100644 index 00000000..8673b6e6 --- /dev/null +++ b/src/domain/AvatarSource.ts @@ -0,0 +1,23 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export interface AvatarSource { + get avatarLetter(): string; + get avatarColorNumber(): number; + avatarUrl(size: number): string | undefined; + get avatarTitle(): string; +} diff --git a/src/domain/ErrorReportViewModel.ts b/src/domain/ErrorReportViewModel.ts new file mode 100644 index 00000000..37ddd1c1 --- /dev/null +++ b/src/domain/ErrorReportViewModel.ts @@ -0,0 +1,72 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ViewModel } from "./ViewModel"; +import type { Options as BaseOptions } from "./ViewModel"; +import type { Session } from "../matrix/Session"; +import { ErrorViewModel } from "./ErrorViewModel"; +import type { LogCallback, LabelOrValues } from "../logging/types"; + +export type Options = BaseOptions & { + session: Session +}; + +/** Base class for view models that need to report errors to the UI. */ +export class ErrorReportViewModel = Options> extends ViewModel { + private _errorViewModel?: ErrorViewModel; + + get errorViewModel(): ErrorViewModel | undefined { + return this._errorViewModel; + } + + /** Typically you'd want to use `logAndCatch` when implementing a view model method. + * Use `reportError` when showing errors on your model that were set by + * background processes using `ErrorBoundary` or you have some other + * special low-level need to write your try/catch yourself. */ + protected reportError(error: Error) { + if (this._errorViewModel?.error === error) { + return; + } + this.disposeTracked(this._errorViewModel); + this._errorViewModel = this.track(new ErrorViewModel(this.childOptions({ + error, + onClose: () => { + this._errorViewModel = this.disposeTracked(this._errorViewModel); + this.emitChange("errorViewModel"); + } + }))); + this.emitChange("errorViewModel"); + } + + /** Combines logging and error reporting in one method. + * Wrap the implementation of public view model methods + * with this to ensure errors are logged and reported.*/ + protected logAndCatch(labelOrValues: LabelOrValues, callback: LogCallback, errorValue: T = undefined as unknown as T): T { + try { + let result = this.logger.run(labelOrValues, callback); + if (result instanceof Promise) { + result = result.catch(err => { + this.reportError(err); + return errorValue; + }) as unknown as T; + } + return result; + } catch (err) { + this.reportError(err); + return errorValue; + } + } +} diff --git a/src/domain/ErrorViewModel.ts b/src/domain/ErrorViewModel.ts new file mode 100644 index 00000000..b73254ba --- /dev/null +++ b/src/domain/ErrorViewModel.ts @@ -0,0 +1,49 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ViewModel, Options as BaseOptions } from "./ViewModel"; +import {submitLogsFromSessionToDefaultServer} from "./rageshake"; +import type { Session } from "../matrix/Session"; +import type {SegmentType} from "./navigation/index"; + +type Options = { + error: Error + session: Session, + onClose: () => void +} & BaseOptions; + +export class ErrorViewModel = Options> extends ViewModel { + get message(): string { + return this.error.message; + } + + get error(): Error { + return this.getOption("error"); + } + + close() { + this.getOption("onClose")(); + } + + async submitLogs(): Promise { + try { + await submitLogsFromSessionToDefaultServer(this.getOption("session"), this.platform); + return true; + } catch (err) { + return false; + } + } +} diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 524dfe13..2896fba6 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -158,7 +158,7 @@ export class RootViewModel extends ViewModel { } _showSessionLoader(sessionId) { - const client = new Client(this.platform); + const client = new Client(this.platform, this.features); client.startWithExistingSession(sessionId); this._setSection(() => { this._sessionLoadViewModel = new SessionLoadViewModel(this.childOptions({ diff --git a/src/domain/SessionLoadViewModel.js b/src/domain/SessionLoadViewModel.js index 6a63145f..75ecfec1 100644 --- a/src/domain/SessionLoadViewModel.js +++ b/src/domain/SessionLoadViewModel.js @@ -78,7 +78,7 @@ export class SessionLoadViewModel extends ViewModel { this._ready(client); } if (loadError) { - console.error("session load error", loadError); + console.error("session load error", loadError.stack); } } catch (err) { this._error = err; diff --git a/src/domain/ViewModel.ts b/src/domain/ViewModel.ts index 8dbc37ea..409e61c9 100644 --- a/src/domain/ViewModel.ts +++ b/src/domain/ViewModel.ts @@ -29,6 +29,8 @@ import type {ILogger} from "../logging/types"; import type {Navigation} from "./navigation/Navigation"; import type {SegmentType} from "./navigation/index"; import type {IURLRouter} from "./navigation/URLRouter"; +import type { ITimeFormatter } from "../platform/types/types"; +import type { FeatureSet } from "../features"; export type Options = { platform: Platform; @@ -36,6 +38,7 @@ export type Options = { urlRouter: IURLRouter; navigation: Navigation; emitChange?: (params: any) => void; + features: FeatureSet } @@ -49,7 +52,7 @@ export class ViewModel = Op this._options = options; } - childOptions(explicitOptions: T): T & Options { + childOptions(explicitOptions: T): T & O { return Object.assign({}, this._options, explicitOptions); } @@ -117,7 +120,7 @@ export class ViewModel = Op return result; } - emitChange(changedProps: any): void { + emitChange(changedProps?: any): void { if (this._options.emitChange) { this._options.emitChange(changedProps); } else { @@ -141,8 +144,16 @@ export class ViewModel = Op return this._options.urlRouter; } + get features(): FeatureSet { + return this._options.features; + } + get navigation(): Navigation { // typescript needs a little help here return this._options.navigation as unknown as Navigation; } + + get timeFormatter(): ITimeFormatter { + return this._options.platform.timeFormatter; + } } diff --git a/src/domain/avatar.ts b/src/domain/avatar.ts index 6f1ef8b0..aaf700ef 100644 --- a/src/domain/avatar.ts +++ b/src/domain/avatar.ts @@ -51,10 +51,18 @@ export function getIdentifierColorNumber(id: string): number { return (hashCode(id) % 8) + 1; } -export function getAvatarHttpUrl(avatarUrl: string, cssSize: number, platform: Platform, mediaRepository: MediaRepository): string | null { +export function getAvatarHttpUrl(avatarUrl: string | undefined, cssSize: number, platform: Platform, mediaRepository: MediaRepository): string | undefined { if (avatarUrl) { const imageSize = cssSize * platform.devicePixelRatio; return mediaRepository.mxcUrlThumbnail(avatarUrl, imageSize, imageSize, "crop"); } - return null; + return undefined; +} + +// move to AvatarView.js when converting to typescript +export interface IAvatarContract { + avatarLetter: string; + avatarColorNumber: number; + avatarUrl: (size: number) => string | undefined; + avatarTitle: string; } diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index 75d57880..f43361d0 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -55,7 +55,7 @@ export class LoginViewModel extends ViewModel { const {ready, defaultHomeserver, loginToken} = options; this._ready = ready; this._loginToken = loginToken; - this._client = new Client(this.platform); + this._client = new Client(this.platform, this.features); this._homeserver = defaultHomeserver; this._initViewModels(); } diff --git a/src/domain/navigation/Navigation.ts b/src/domain/navigation/Navigation.ts index f5039732..ee27cb4c 100644 --- a/src/domain/navigation/Navigation.ts +++ b/src/domain/navigation/Navigation.ts @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue"; - +import {BaseObservableValue, ObservableValue} from "../../observable/value"; type AllowsChild = (parent: Segment | undefined, child: Segment) => boolean; diff --git a/src/domain/navigation/URLRouter.ts b/src/domain/navigation/URLRouter.ts index bf1c218d..23503530 100644 --- a/src/domain/navigation/URLRouter.ts +++ b/src/domain/navigation/URLRouter.ts @@ -144,7 +144,7 @@ export class URLRouter implements IURLRou openRoomActionUrl(roomId: string): string { // not a segment to navigation knowns about, so append it manually - const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`; + const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${encodeURIComponent(roomId)}`; return this._history.pathAsUrl(urlPath); } diff --git a/src/domain/navigation/index.ts b/src/domain/navigation/index.ts index af3c35bd..a2705944 100644 --- a/src/domain/navigation/index.ts +++ b/src/domain/navigation/index.ts @@ -137,7 +137,7 @@ export function parseUrlPath(urlPath: string, currentNavPath: Path, if (type === "rooms") { const roomsValue = iterator.next().value; if (roomsValue === undefined) { break; } - const roomIds = roomsValue.split(","); + const roomIds = roomsValue.split(",").map(id => decodeURIComponent(id)); segments.push(new Segment(type, roomIds)); const selectedIndex = parseInt(iterator.next().value || "0", 10); const roomId = roomIds[selectedIndex]; @@ -147,8 +147,9 @@ export function parseUrlPath(urlPath: string, currentNavPath: Path, segments.push(new Segment("empty-grid-tile", selectedIndex)); } } else if (type === "open-room") { - const roomId = iterator.next().value; + let roomId = iterator.next().value; if (!roomId) { break; } + roomId = decodeURIComponent(roomId); const rooms = currentNavPath.get("rooms"); if (rooms) { segments.push(roomsSegmentWithRoom(rooms, roomId, currentNavPath)); @@ -176,8 +177,9 @@ export function parseUrlPath(urlPath: string, currentNavPath: Path, } else if (type === "details" || type === "members") { pushRightPanelSegment(segments, type); } else if (type === "member") { - const userId = iterator.next().value; + let userId = iterator.next().value; if (!userId) { break; } + userId = decodeURIComponent(userId); pushRightPanelSegment(segments, type, userId); } else if (type.includes("loginToken")) { // Special case for SSO-login with query parameter loginToken= @@ -185,7 +187,11 @@ export function parseUrlPath(urlPath: string, currentNavPath: Path, segments.push(new Segment("sso", loginToken)); } else { // might be undefined, which will be turned into true by Segment - const value = iterator.next().value; + let value = iterator.next().value; + if (value) { + // decode only if value isn't undefined! + value = decodeURIComponent(value) + } segments.push(new Segment(type, value)); } } @@ -196,19 +202,20 @@ export function stringifyPath(path: Path): string { let urlPath = ""; let prevSegment: Segment | undefined; for (const segment of path.segments) { + const encodedSegmentValue = encodeSegmentValue(segment.value); switch (segment.type) { case "rooms": - urlPath += `/rooms/${segment.value.join(",")}`; + urlPath += `/rooms/${encodedSegmentValue}`; break; case "empty-grid-tile": - urlPath += `/${segment.value}`; + urlPath += `/${encodedSegmentValue}`; break; case "room": if (prevSegment?.type === "rooms") { const index = prevSegment.value.indexOf(segment.value); urlPath += `/${index}`; } else { - urlPath += `/${segment.type}/${segment.value}`; + urlPath += `/${segment.type}/${encodedSegmentValue}`; } break; case "right-panel": @@ -217,8 +224,8 @@ export function stringifyPath(path: Path): string { continue; default: urlPath += `/${segment.type}`; - if (segment.value && segment.value !== true) { - urlPath += `/${segment.value}`; + if (encodedSegmentValue) { + urlPath += `/${encodedSegmentValue}`; } } prevSegment = segment; @@ -226,6 +233,19 @@ export function stringifyPath(path: Path): string { return urlPath; } +function encodeSegmentValue(value: SegmentType[keyof SegmentType]): string { + if (value === true) { + // Nothing to encode for boolean + return ""; + } + else if (Array.isArray(value)) { + return value.map(v => encodeURIComponent(v)).join(","); + } + else { + return encodeURIComponent(value); + } +} + export function tests() { function createEmptyPath() { const nav: Navigation = new Navigation(allowsChild); diff --git a/src/domain/rageshake.ts b/src/domain/rageshake.ts index cb06e638..cc8aec07 100644 --- a/src/domain/rageshake.ts +++ b/src/domain/rageshake.ts @@ -16,11 +16,15 @@ limitations under the License. import type {BlobHandle} from "../platform/web/dom/BlobHandle"; import type {RequestFunction} from "../platform/types/types"; +import type {Platform} from "../platform/web/Platform"; +import type {ILogger} from "../logging/types"; +import type { IDBLogPersister } from "../logging/IDBLogPersister"; +import type { Session } from "../matrix/Session"; // see https://github.com/matrix-org/rageshake#readme type RageshakeData = { // A textual description of the problem. Included in the details.log.gz file. - text: string | undefined; + text?: string; // Application user-agent. Included in the details.log.gz file. userAgent: string; // Identifier for the application (eg 'riot-web'). Should correspond to a mapping configured in the configuration file for github issue reporting to work. @@ -28,7 +32,7 @@ type RageshakeData = { // Application version. Included in the details.log.gz file. version: string; // Label to attach to the github issue, and include in the details file. - label: string | undefined; + label?: string; }; export async function submitLogsToRageshakeServer(data: RageshakeData, logsBlob: BlobHandle, submitUrl: string, request: RequestFunction): Promise { @@ -63,3 +67,28 @@ export async function submitLogsToRageshakeServer(data: RageshakeData, logsBlob: // we don't bother with reading report_url from the body as the rageshake server doesn't always return it // and would have to have CORS setup properly for us to be able to read it. } + +/** @throws {Error} */ +export async function submitLogsFromSessionToDefaultServer(session: Session, platform: Platform): Promise { + const {bugReportEndpointUrl} = platform.config; + if (!bugReportEndpointUrl) { + throw new Error("no server configured to submit logs"); + } + const logReporters = (platform.logger as ILogger).reporters; + const exportReporter = logReporters.find(r => !!r["export"]) as IDBLogPersister | undefined; + if (!exportReporter) { + throw new Error("No logger that can export configured"); + } + const logExport = await exportReporter.export(); + await submitLogsToRageshakeServer( + { + app: "hydrogen", + userAgent: platform.description, + version: platform.version, + text: `Submit logs from settings for user ${session.userId} on device ${session.deviceId}`, + }, + logExport.asBlob(), + bugReportEndpointUrl, + platform.request + ); +} diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index 8e443e2d..478972d4 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -186,7 +186,7 @@ export class RoomGridViewModel extends ViewModel { } import {createNavigation} from "../navigation/index"; -import {ObservableValue} from "../../observable/ObservableValue"; +import {ObservableValue} from "../../observable/value"; export function tests() { class RoomVMMock { diff --git a/src/domain/session/RoomViewModelObservable.js b/src/domain/session/RoomViewModelObservable.js index 52833332..fb2fc502 100644 --- a/src/domain/session/RoomViewModelObservable.js +++ b/src/domain/session/RoomViewModelObservable.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ObservableValue} from "../../observable/ObservableValue"; +import {ObservableValue} from "../../observable/value"; import {RoomStatus} from "../../matrix/room/common"; /** diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index ecb2b759..7d1dac3c 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -30,6 +30,7 @@ import {ViewModel} from "../ViewModel"; import {RoomViewModelObservable} from "./RoomViewModelObservable.js"; import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js"; import {SyncStatus} from "../../matrix/Sync.js"; +import {ToastCollectionViewModel} from "./toast/ToastCollectionViewModel"; export class SessionViewModel extends ViewModel { constructor(options) { @@ -47,6 +48,9 @@ export class SessionViewModel extends ViewModel { this._gridViewModel = null; this._createRoomViewModel = null; this._joinRoomViewModel = null; + this._toastCollectionViewModel = this.track(new ToastCollectionViewModel(this.childOptions({ + session: this._client.session, + }))); this._setupNavigation(); this._setupForcedLogoutOnAccessTokenInvalidation(); } @@ -126,6 +130,11 @@ export class SessionViewModel extends ViewModel { start() { this._sessionStatusViewModel.start(); + if (this.features.calls) { + this._client.session.callHandler.loadCalls("m.ring"); + // TODO: only do this when opening the room + this._client.session.callHandler.loadCalls("m.prompt"); + } } get activeMiddleViewModel() { @@ -170,6 +179,10 @@ export class SessionViewModel extends ViewModel { return this._joinRoomViewModel; } + get toastCollectionViewModel() { + return this._toastCollectionViewModel; + } + _updateGrid(roomIds) { const changed = !(this._gridViewModel && roomIds); const currentRoomId = this.navigation.path.get("room"); @@ -211,7 +224,7 @@ export class SessionViewModel extends ViewModel { _createRoomViewModelInstance(roomId) { const room = this._client.session.rooms.get(roomId); if (room) { - const roomVM = new RoomViewModel(this.childOptions({room})); + const roomVM = new RoomViewModel(this.childOptions({room, session: this._client.session})); roomVM.load(); return roomVM; } @@ -228,7 +241,7 @@ export class SessionViewModel extends ViewModel { async _createArchivedRoomViewModel(roomId) { const room = await this._client.session.loadArchivedRoom(roomId); if (room) { - const roomVM = new RoomViewModel(this.childOptions({room})); + const roomVM = new RoomViewModel(this.childOptions({room, session: this._client.session})); roomVM.load(); return roomVM; } diff --git a/src/domain/session/rightpanel/MemberDetailsViewModel.js b/src/domain/session/rightpanel/MemberDetailsViewModel.js index b3c8278c..b73bf4bb 100644 --- a/src/domain/session/rightpanel/MemberDetailsViewModel.js +++ b/src/domain/session/rightpanel/MemberDetailsViewModel.js @@ -17,6 +17,7 @@ limitations under the License. import {ViewModel} from "../../ViewModel"; import {RoomType} from "../../../matrix/room/common"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar"; +import {UserTrust} from "../../../matrix/verification/CrossSigning"; export class MemberDetailsViewModel extends ViewModel { constructor(options) { @@ -29,13 +30,60 @@ export class MemberDetailsViewModel extends ViewModel { this._session = options.session; this.track(this._powerLevelsObservable.subscribe(() => this._onPowerLevelsChange())); this.track(this._observableMember.subscribe( () => this._onMemberChange())); + this.track(this._session.crossSigning.subscribe(() => { + this.emitChange("trustShieldColor"); + })); + this._userTrust = undefined; + this.init(); // TODO: call this from parent view model and do something smart with error view model if it fails async? + } + + async init() { + if (this.features.crossSigning) { + this._userTrust = await this.logger.run({l: "MemberDetailsViewModel.get user trust", id: this._member.userId}, log => { + return this._session.crossSigning.get()?.getUserTrust(this._member.userId, log); + }); + this.emitChange("trustShieldColor"); + } } get name() { return this._member.name; } + get userId() { return this._member.userId; } + + get trustDescription() { + switch (this._userTrust) { + case UserTrust.Trusted: return this.i18n`You have verified this user. This user has verified all of their sessions.`; + case UserTrust.UserNotSigned: return this.i18n`You have not verified this user.`; + case UserTrust.UserSignatureMismatch: return this.i18n`You appear to have signed this user, but the signature is invalid.`; + case UserTrust.UserDeviceNotSigned: return this.i18n`You have verified this user, but they have one or more unverified sessions.`; + case UserTrust.UserDeviceSignatureMismatch: return this.i18n`This user has a session signature that is invalid.`; + case UserTrust.UserSetupError: return this.i18n`This user hasn't set up cross-signing correctly`; + case UserTrust.OwnSetupError: return this.i18n`Cross-signing wasn't set up correctly on your side.`; + default: return this.i18n`Pending…`; + } + } + + get trustShieldColor() { + if (!this._isEncrypted) { + return undefined; + } + switch (this._userTrust) { + case undefined: + case UserTrust.OwnSetupError: + return undefined; + case UserTrust.Trusted: + return "green"; + case UserTrust.UserNotSigned: + return "black"; + default: + return "red"; + } + } get type() { return "member-details"; } + get shouldShowBackButton() { return true; } + get previousSegmentName() { return "members"; } get role() { @@ -54,6 +102,14 @@ export class MemberDetailsViewModel extends ViewModel { this.emitChange("role"); } + async signUser() { + if (this._session.crossSigning) { + await this.logger.run("MemberDetailsViewModel.signUser", async log => { + await this._session.crossSigning.signUser(this.userId, log); + }); + } + } + get avatarLetter() { return avatarInitials(this.name); } diff --git a/src/domain/session/rightpanel/MemberTileViewModel.js b/src/domain/session/rightpanel/MemberTileViewModel.js index c8dcf63a..9f9a5483 100644 --- a/src/domain/session/rightpanel/MemberTileViewModel.js +++ b/src/domain/session/rightpanel/MemberTileViewModel.js @@ -48,7 +48,7 @@ export class MemberTileViewModel extends ViewModel { get detailsUrl() { const roomId = this.navigation.path.get("room").value; - return `${this.urlRouter.openRoomActionUrl(roomId)}/member/${this._member.userId}`; + return `${this.urlRouter.openRoomActionUrl(roomId)}/member/${encodeURIComponent(this._member.userId)}`; } _updatePreviousName(newName) { diff --git a/src/domain/session/room/CallViewModel.ts b/src/domain/session/room/CallViewModel.ts new file mode 100644 index 00000000..484543b7 --- /dev/null +++ b/src/domain/session/room/CallViewModel.ts @@ -0,0 +1,288 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {AvatarSource} from "../../AvatarSource"; +import type {ViewModel} from "../../ViewModel"; +import {ErrorReportViewModel, Options as BaseOptions} from "../../ErrorReportViewModel"; +import {getStreamVideoTrack, getStreamAudioTrack} from "../../../matrix/calls/common"; +import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar"; +import {EventObservableValue} from "../../../observable/value"; +import {ObservableValueMap, BaseObservableMap} from "../../../observable/map"; +import {ErrorViewModel} from "../../ErrorViewModel"; +import type {Room} from "../../../matrix/room/Room"; +import type {GroupCall} from "../../../matrix/calls/group/GroupCall"; +import type {Member} from "../../../matrix/calls/group/Member"; +import type {RoomMember} from "../../../matrix/room/members/RoomMember"; +import type {BaseObservableList} from "../../../observable/list/BaseObservableList"; +import type {BaseObservableValue} from "../../../observable/value"; +import type {Stream} from "../../../platform/types/MediaDevices"; +import type {MediaRepository} from "../../../matrix/net/MediaRepository"; +import type {Session} from "../../../matrix/Session"; +import type {SegmentType} from "../../navigation"; + +type Options = BaseOptions & { + call: GroupCall, + room: Room, +}; + +export class CallViewModel extends ErrorReportViewModel> { + public readonly memberViewModels: BaseObservableList; + + constructor(options: Options) { + super(options); + const callObservable = new EventObservableValue(this.call, "change"); + this.track(callObservable.subscribe(() => this.onUpdate())); + const ownMemberViewModelMap = new ObservableValueMap("self", callObservable) + .mapValues((call, emitChange) => new OwnMemberViewModel(this.childOptions({call, emitChange})), () => {}); + const otherMemberViewModels = this.call.members + .filterValues(member => member.isConnected) + .mapValues( + (member, emitChange) => new CallMemberViewModel(this.childOptions({ + member, + emitChange, + mediaRepository: this.getOption("room").mediaRepository + })), + (param, vm) => vm?.onUpdate(), + ) as BaseObservableMap; + this.memberViewModels = otherMemberViewModels + .join(ownMemberViewModelMap) + .sortValues((a, b) => a.compare(b)); + this.track(this.memberViewModels.subscribe({ + onRemove: () => { + this.emitChange(); // update memberCount + }, + onAdd: () => { + this.emitChange(); // update memberCount + }, + onUpdate: () => {}, + onReset: () => {}, + onMove: () => {} + })) + } + + get isCameraMuted(): boolean { + return this.call.muteSettings?.camera ?? true; + } + + get isMicrophoneMuted(): boolean { + return this.call.muteSettings?.microphone ?? true; + } + + get memberCount(): number { + return this.memberViewModels.length; + } + + get name(): string { + return this.call.name; + } + + get id(): string { + return this.call.id; + } + + private get call(): GroupCall { + return this.getOption("call"); + } + + private onUpdate() { + if (this.call.error) { + this.reportError(this.call.error); + } + } + + async hangup() { + this.logAndCatch("CallViewModel.hangup", async log => { + if (this.call.hasJoined) { + await this.call.leave(log); + } + }); + } + + async toggleCamera() { + this.logAndCatch("Call.toggleCamera", async log => { + const {localMedia, muteSettings} = this.call; + if (muteSettings && localMedia) { + // unmute but no track? + if (muteSettings.camera && !getStreamVideoTrack(localMedia.userMedia)) { + const stream = await this.platform.mediaDevices.getMediaTracks(!muteSettings.microphone, true); + await this.call.setMedia(localMedia.withUserMedia(stream)); + } else { + await this.call.setMuted(muteSettings.toggleCamera()); + } + this.emitChange(); + } + }); + } + + async toggleMicrophone() { + this.logAndCatch("Call.toggleMicrophone", async log => { + const {localMedia, muteSettings} = this.call; + if (muteSettings && localMedia) { + // unmute but no track? + if (muteSettings.microphone && !getStreamAudioTrack(localMedia.userMedia)) { + const stream = await this.platform.mediaDevices.getMediaTracks(true, !muteSettings.camera); + await this.call.setMedia(localMedia.withUserMedia(stream)); + } else { + await this.call.setMuted(muteSettings.toggleMicrophone()); + } + this.emitChange(); + } + }); + } +} + +class OwnMemberViewModel extends ErrorReportViewModel> implements IStreamViewModel { + private memberObservable: undefined | BaseObservableValue; + + constructor(options: Options) { + super(options); + this.init(); + } + + async init() { + const room = this.getOption("room"); + this.memberObservable = await room.observeMember(room.user.id); + this.track(this.memberObservable!.subscribe(() => { + this.emitChange(undefined); + })); + } + + get errorViewModel(): ErrorViewModel | undefined { + return undefined; + } + + get stream(): Stream | undefined { + return this.call.localPreviewMedia?.userMedia; + } + + private get call(): GroupCall { + return this.getOption("call"); + } + + get isCameraMuted(): boolean { + return this.call.muteSettings?.camera ?? true; + } + + get isMicrophoneMuted(): boolean { + return this.call.muteSettings?.microphone ?? true; + } + + get avatarLetter(): string { + const member = this.memberObservable?.get(); + if (member) { + return avatarInitials(member.name); + } else { + return this.getOption("room").user.id; + } + } + + get avatarColorNumber(): number { + return getIdentifierColorNumber(this.getOption("room").user.id); + } + + avatarUrl(size: number): string | undefined { + const member = this.memberObservable?.get(); + if (member) { + return getAvatarHttpUrl(member.avatarUrl, size, this.platform, this.getOption("room").mediaRepository); + } + } + + get avatarTitle(): string { + const member = this.memberObservable?.get(); + if (member) { + return member.name; + } else { + return this.getOption("room").user.id; + } + } + + compare(other: IStreamViewModel): number { + // I always come first. + return -1; + } +} + +type MemberOptions = BaseOptions & { + member: Member, + mediaRepository: MediaRepository, +}; + +export class CallMemberViewModel extends ErrorReportViewModel> implements IStreamViewModel { + get stream(): Stream | undefined { + return this.member.remoteMedia?.userMedia; + } + + private get member(): Member { + return this.getOption("member"); + } + + get isCameraMuted(): boolean { + return this.member.remoteMuteSettings?.camera ?? true; + } + + get isMicrophoneMuted(): boolean { + return this.member.remoteMuteSettings?.microphone ?? true; + } + + get avatarLetter(): string { + return avatarInitials(this.member.member.name); + } + + get avatarColorNumber(): number { + return getIdentifierColorNumber(this.member.userId); + } + + avatarUrl(size: number): string | undefined { + const {avatarUrl} = this.member.member; + const mediaRepository = this.getOption("mediaRepository"); + return getAvatarHttpUrl(avatarUrl, size, this.platform, mediaRepository); + } + + get avatarTitle(): string { + return this.member.member.name; + } + + onUpdate() { + this.mapMemberSyncErrorIfNeeded(); + } + + private mapMemberSyncErrorIfNeeded() { + if (this.member.error) { + this.reportError(this.member.error); + } + } + + compare(other: IStreamViewModel): number { + if (other instanceof CallMemberViewModel) { + const myUserId = this.member.member.userId; + const otherUserId = other.member.member.userId; + if(myUserId === otherUserId) { + return 0; + } + return myUserId < otherUserId ? -1 : 1; + } else { + return -other.compare(this); + } + } +} + +export interface IStreamViewModel extends AvatarSource, ViewModel { + get stream(): Stream | undefined; + get isCameraMuted(): boolean; + get isMicrophoneMuted(): boolean; + get errorViewModel(): ErrorViewModel | undefined; + compare(other: IStreamViewModel): number; +} diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index ac169795..31608a62 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -17,15 +17,19 @@ limitations under the License. import {TimelineViewModel} from "./timeline/TimelineViewModel.js"; import {ComposerViewModel} from "./ComposerViewModel.js" +import {CallViewModel} from "./CallViewModel" +import {PickMapObservableValue} from "../../../observable/value"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar"; +import {ErrorReportViewModel} from "../../ErrorReportViewModel"; import {ViewModel} from "../../ViewModel"; import {imageToInfo} from "../common.js"; +import {LocalMedia} from "../../../matrix/calls/LocalMedia"; // TODO: remove fallback so default isn't included in bundle for SDK users that have their custom tileClassForEntry // this is a breaking SDK change though to make this option mandatory import {tileClassForEntry as defaultTileClassForEntry} from "./timeline/tiles/index"; import {joinRoom} from "../../../matrix/room/joinRoom"; -export class RoomViewModel extends ViewModel { +export class RoomViewModel extends ErrorReportViewModel { constructor(options) { super(options); const {room, tileClassForEntry} = options; @@ -34,8 +38,6 @@ export class RoomViewModel extends ViewModel { this._tileClassForEntry = tileClassForEntry ?? defaultTileClassForEntry; this._tileOptions = undefined; this._onRoomChange = this._onRoomChange.bind(this); - this._timelineError = null; - this._sendError = null; this._composerVM = null; if (room.isArchived) { this._composerVM = this.track(new ArchivedViewModel(this.childOptions({archivedRoom: room}))); @@ -44,13 +46,42 @@ export class RoomViewModel extends ViewModel { } this._clearUnreadTimout = null; this._closeUrl = this.urlRouter.urlUntilSegment("session"); + this._setupCallViewModel(); + } + + _setupCallViewModel() { + if (!this.features.calls) { + return; + } + // pick call for this room with lowest key + const calls = this.getOption("session").callHandler.calls; + this._callObservable = new PickMapObservableValue(calls.filterValues(c => { + return c.roomId === this._room.id && c.hasJoined; + })); + this._callViewModel = undefined; + this.track(this._callObservable.subscribe(call => { + if (call && this._callViewModel && call.id === this._callViewModel.id) { + return; + } + this._callViewModel = this.disposeTracked(this._callViewModel); + if (call) { + this._callViewModel = this.track(new CallViewModel(this.childOptions({call, room: this._room}))); + } + this.emitChange("callViewModel"); + })); + const call = this._callObservable.get(); + // TODO: cleanup this duplication to create CallViewModel + if (call) { + this._callViewModel = this.track(new CallViewModel(this.childOptions({call, room: this._room}))); + } } async load() { - this._room.on("change", this._onRoomChange); - try { - const timeline = await this._room.openTimeline(); + this.logAndCatch("RoomViewModel.load", async log => { + this._room.on("change", this._onRoomChange); + const timeline = await this._room.openTimeline(log); this._tileOptions = this.childOptions({ + session: this.getOption("session"), roomVM: this, timeline, tileClassForEntry: this._tileClassForEntry, @@ -60,12 +91,8 @@ export class RoomViewModel extends ViewModel { timeline, }))); this.emitChange("timelineViewModel"); - } catch (err) { - console.error(`room.openTimeline(): ${err.message}:\n${err.stack}`); - this._timelineError = err; - this.emitChange("error"); - } - this._clearUnreadAfterDelay(); + await this._clearUnreadAfterDelay(log); + }); } async _recreateComposerOnPowerLevelChange() { @@ -92,24 +119,28 @@ export class RoomViewModel extends ViewModel { recreateComposer(oldCanSendMessage); } - async _clearUnreadAfterDelay() { + async _clearUnreadAfterDelay(log) { if (this._room.isArchived || this._clearUnreadTimout) { return; } this._clearUnreadTimout = this.clock.createTimeout(2000); try { await this._clearUnreadTimout.elapsed(); - await this._room.clearUnread(); + await this._room.clearUnread(log); this._clearUnreadTimout = null; } catch (err) { - if (err.name !== "AbortError") { + if (err.name === "AbortError") { + log.set("clearUnreadCancelled", true); + } else { throw err; } } } focus() { - this._clearUnreadAfterDelay(); + this.logAndCatch("RoomViewModel.focus", async log => { + this._clearUnreadAfterDelay(log); + }); } dispose() { @@ -139,16 +170,6 @@ export class RoomViewModel extends ViewModel { get timelineViewModel() { return this._timelineVM; } get isEncrypted() { return this._room.isEncrypted; } - get error() { - if (this._timelineError) { - return `Something went wrong loading the timeline: ${this._timelineError.message}`; - } - if (this._sendError) { - return `Something went wrong sending your message: ${this._sendError.message}`; - } - return ""; - } - get avatarLetter() { return avatarInitials(this.name); } @@ -191,26 +212,51 @@ export class RoomViewModel extends ViewModel { _createTile(entry) { if (this._tileOptions) { - const Tile = this._tileOptions.tileClassForEntry(entry); + const Tile = this._tileOptions.tileClassForEntry(entry, this._tileOptions); if (Tile) { return new Tile(entry, this._tileOptions); } } } + _sendMessage(message, replyingTo) { + return this.logAndCatch("RoomViewModel.sendMessage", async log => { + let success = false; + if (!this._room.isArchived && message) { + let msgtype = "m.text"; + if (message.startsWith("//")) { + message = message.substring(1).trim(); + } else if (message.startsWith("/")) { + const result = await this._processCommand(message); + msgtype = result.msgtype; + message = result.message; + } + let content; + if (replyingTo) { + log.set("replyingTo", replyingTo.eventId); + content = await replyingTo.createReplyContent(msgtype, message); + } else { + content = {msgtype, body: message}; + } + await this._room.sendEvent("m.room.message", content, undefined, log); + success = true; + } + log.set("success", success); + return success; + }, false); + } + async _processCommandJoin(roomName) { try { const session = this._options.client.session; const roomId = await joinRoom(roomName, session); this.navigation.push("room", roomId); } catch (err) { - this._sendError = err; - this._timelineError = null; - this.emitChange("error"); + this.reportError(err); } } - async _processCommand (message) { + async _processCommand(message) { let msgtype; const [commandName, ...args] = message.substring(1).split(" "); switch (commandName) { @@ -223,9 +269,7 @@ export class RoomViewModel extends ViewModel { const roomName = args[0]; await this._processCommandJoin(roomName); } else { - this._sendError = new Error("join syntax: /join "); - this._timelineError = null; - this.emitChange("error"); + this.reportError(new Error("join syntax: /join ")); } break; case "shrug": @@ -245,78 +289,44 @@ export class RoomViewModel extends ViewModel { msgtype = "m.text"; break; default: - this._sendError = new Error(`no command name "${commandName}". To send the message instead of executing, please type "/${message}"`); - this._timelineError = null; - this.emitChange("error"); + this.reportError(new Error(`no command name "${commandName}". To send the message instead of executing, please type "/${message}"`)); message = undefined; - } - return {type: msgtype, message: message}; - } - - async _sendMessage(message, replyingTo) { - if (!this._room.isArchived && message) { - let messinfo = {type : "m.text", message : message}; - if (message.startsWith("//")) { - messinfo.message = message.substring(1).trim(); - } else if (message.startsWith("/")) { - messinfo = await this._processCommand(message); - } - try { - const msgtype = messinfo.type; - const message = messinfo.message; - if (msgtype && message) { - if (replyingTo) { - await replyingTo.reply(msgtype, message); - } else { - await this._room.sendEvent("m.room.message", {msgtype, body: message}); - } - } - } catch (err) { - console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`); - this._sendError = err; - this._timelineError = null; - this.emitChange("error"); - return false; - } - return true; } - return false; + return {msgtype, message: message}; } - async _pickAndSendFile() { - try { + _pickAndSendFile() { + return this.logAndCatch("RoomViewModel.sendFile", async log => { const file = await this.platform.openFile(); if (!file) { + log.set("cancelled", true); return; } - return this._sendFile(file); - } catch (err) { - console.error(err); - } + return this._sendFile(file, log); + }); } - async _sendFile(file) { + async _sendFile(file, log) { const content = { body: file.name, msgtype: "m.file" }; await this._room.sendEvent("m.room.message", content, { "url": this._room.createAttachment(file.blob, file.name) - }); + }, log); } - async _pickAndSendVideo() { - try { + _pickAndSendVideo() { + return this.logAndCatch("RoomViewModel.sendVideo", async log => { if (!this.platform.hasReadPixelPermission()) { - alert("Please allow canvas image data access, so we can scale your images down."); - return; + throw new Error("Please allow canvas image data access, so we can scale your images down."); } const file = await this.platform.openFile("video/*"); if (!file) { return; } if (!file.blob.mimeType.startsWith("video/")) { - return this._sendFile(file); + return this._sendFile(file, log); } let video; try { @@ -344,26 +354,23 @@ export class RoomViewModel extends ViewModel { content.info.thumbnail_info = imageToInfo(thumbnail); attachments["info.thumbnail_url"] = this._room.createAttachment(thumbnail.blob, file.name); - await this._room.sendEvent("m.room.message", content, attachments); - } catch (err) { - this._sendError = err; - this.emitChange("error"); - console.error(err.stack); - } + await this._room.sendEvent("m.room.message", content, attachments, log); + }); } async _pickAndSendPicture() { - try { + this.logAndCatch("RoomViewModel.sendPicture", async log => { if (!this.platform.hasReadPixelPermission()) { alert("Please allow canvas image data access, so we can scale your images down."); return; } const file = await this.platform.openFile("image/*"); if (!file) { + log.set("cancelled", true); return; } if (!file.blob.mimeType.startsWith("image/")) { - return this._sendFile(file); + return this._sendFile(file, log); } let image = await this.platform.loadImage(file.blob); const limit = await this.platform.settingsStorage.getInt("sentImageSizeLimit"); @@ -386,12 +393,8 @@ export class RoomViewModel extends ViewModel { attachments["info.thumbnail_url"] = this._room.createAttachment(thumbnail.blob, file.name); } - await this._room.sendEvent("m.room.message", content, attachments); - } catch (err) { - this._sendError = err; - this.emitChange("error"); - console.error(err.stack); - } + await this._room.sendEvent("m.room.message", content, attachments, log); + }); } get room() { @@ -402,6 +405,10 @@ export class RoomViewModel extends ViewModel { return this._composerVM; } + get callViewModel() { + return this._callViewModel; + } + openDetailsPanel() { let path = this.navigation.path.until("room"); path = path.with(this.navigation.segment("right-panel", true)); @@ -414,10 +421,41 @@ export class RoomViewModel extends ViewModel { this._composerVM.setReplyingTo(entry); } } - - dismissError() { - this._sendError = null; - this.emitChange("error"); + + startCall() { + return this.logAndCatch("RoomViewModel.startCall", async log => { + if (!this.features.calls) { + log.set("feature_disbled", true); + return; + } + log.set("roomId", this._room.id); + let localMedia; + try { + const stream = await this.platform.mediaDevices.getMediaTracks(false, true); + localMedia = new LocalMedia().withUserMedia(stream); + } catch (err) { + throw new Error(`Could not get local audio and/or video stream: ${err.message}`); + } + const session = this.getOption("session"); + let call; + try { + // this will set the callViewModel above as a call will be added to callHandler.calls + call = await session.callHandler.createCall( + this._room.id, + "m.video", + "A call " + Math.round(this.platform.random() * 100), + undefined, + log + ); + } catch (err) { + throw new Error(`Could not create call: ${err.message}`); + } + try { + await call.join(localMedia, log); + } catch (err) { + throw new Error(`Could not join call: ${err.message}`); + } + }); } } diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index c5ea1224..0355889c 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -189,7 +189,7 @@ import {HomeServer as MockHomeServer} from "../../../../mocks/HomeServer.js"; // other imports import {BaseMessageTile} from "./tiles/BaseMessageTile.js"; import {MappedList} from "../../../../observable/list/MappedList"; -import {ObservableValue} from "../../../../observable/ObservableValue"; +import {ObservableValue} from "../../../../observable/value"; import {PowerLevels} from "../../../../matrix/room/PowerLevels.js"; export function tests() { diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index 173b0cf6..c5bddc2c 100644 --- a/src/domain/session/room/timeline/TilesCollection.js +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -16,6 +16,7 @@ limitations under the License. import {BaseObservableList} from "../../../../observable/list/BaseObservableList"; import {sortedIndex} from "../../../../utils/sortedIndex"; +import {TileShape} from "./tiles/ITile"; // maps 1..n entries to 0..1 tile. Entries are what is stored in the timeline, either an event or fragmentboundary // for now, tileClassForEntry should be stable in whether it returns a tile or not. @@ -33,7 +34,7 @@ export class TilesCollection extends BaseObservableList { } _createTile(entry) { - const Tile = this._tileOptions.tileClassForEntry(entry); + const Tile = this._tileOptions.tileClassForEntry(entry, this._tileOptions); if (Tile) { return new Tile(entry, this._tileOptions); } @@ -51,6 +52,7 @@ export class TilesCollection extends BaseObservableList { } _populateTiles() { + this._silent = true; this._tiles = []; let currentTile = null; for (let entry of this._entries) { @@ -72,11 +74,20 @@ export class TilesCollection extends BaseObservableList { if (prevTile) { prevTile.updateNextSibling(null); } + // add date headers here + for (let idx = 0; idx < this._tiles.length; idx += 1) { + const tile = this._tiles[idx]; + if (tile.needsDateSeparator) { + this._addTileAt(idx, tile.createDateSeparator(), true); + idx += 1; // tile's index moved one up, don't process it again + } + } // now everything is wired up, // allow tiles to emit updates for (const tile of this._tiles) { tile.setUpdateEmit(this._emitSpontanousUpdate); } + this._silent = false; } _findTileIdx(entry) { @@ -130,25 +141,57 @@ export class TilesCollection extends BaseObservableList { const newTile = this._createTile(entry); if (newTile) { - if (prevTile) { - prevTile.updateNextSibling(newTile); - // this emits an update while the add hasn't been emitted yet - newTile.updatePreviousSibling(prevTile); - } - if (nextTile) { - newTile.updateNextSibling(nextTile); - nextTile.updatePreviousSibling(newTile); - } - this._tiles.splice(tileIdx, 0, newTile); - this.emitAdd(tileIdx, newTile); - // add event is emitted, now the tile - // can emit updates - newTile.setUpdateEmit(this._emitSpontanousUpdate); + this._addTileAt(tileIdx, newTile); + this._evaluateDateHeaderAtIdx(tileIdx); } // find position by sort key // ask siblings to be included? both? yes, twice: a (insert c here) b, ask a(c), if yes ask b(a), else ask b(c)? if yes then b(a)? } + _evaluateDateHeaderAtIdx(tileIdx) { + // consider two tiles after the inserted tile, because + // the first of the two tiles may be a DateTile in which case, + // we remove it after looking at the needsDateSeparator prop of the + // next next tile + for (let i = 0; i < 3; i += 1) { + const idx = tileIdx + i; + if (idx >= this._tiles.length) { + break; + } + const tile = this._tiles[idx]; + const prevTile = idx > 0 ? this._tiles[idx - 1] : undefined; + const hasDateSeparator = prevTile?.shape === TileShape.DateHeader; + if (tile.needsDateSeparator && !hasDateSeparator) { + // adding a tile shift all the indices we need to consider + // especially given we consider removals for the tile that + // comes after a datetile + tileIdx += 1; + this._addTileAt(idx, tile.createDateSeparator()); + } else if (!tile.needsDateSeparator && hasDateSeparator) { + // this is never triggered because needsDateSeparator is not cleared + // when loading more items because we don't do anything once the + // direct sibling is a DateTile + this._removeTile(idx - 1, prevTile); + } + } + } + + _addTileAt(idx, newTile, silent = false) { + const prevTile = idx > 0 ? this._tiles[idx - 1] : undefined; + const nextTile = this._tiles[idx]; + prevTile?.updateNextSibling(newTile); + newTile.updatePreviousSibling(prevTile); + newTile.updateNextSibling(nextTile); + nextTile?.updatePreviousSibling(newTile); + this._tiles.splice(idx, 0, newTile); + if (!silent) { + this.emitAdd(idx, newTile); + } + // add event is emitted, now the tile + // can emit updates + newTile.setUpdateEmit(this._emitSpontanousUpdate); + } + onUpdate(index, entry, params) { // if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it if (!this._tiles) { @@ -210,11 +253,16 @@ export class TilesCollection extends BaseObservableList { this.emitRemove(tileIdx, tile); prevTile?.updateNextSibling(nextTile); nextTile?.updatePreviousSibling(prevTile); + + if (prevTile && prevTile.shape === TileShape.DateHeader && (!nextTile || !nextTile.needsDateSeparator)) { + this._removeTile(tileIdx - 1, prevTile); + } } // would also be called when unloading a part of the timeline onRemove(index, entry) { const tileIdx = this._findTileIdx(entry); + const tile = this._findTileAtIdx(entry, tileIdx); if (tile) { const removeTile = tile.removeEntry(entry); @@ -268,6 +316,7 @@ export function tests() { constructor(entry) { this.entry = entry; this.update = null; + this.needsDateSeparator = false; } setUpdateEmit(update) { this.update = update; @@ -297,6 +346,34 @@ export function tests() { dispose() {} } + class DateHeaderTile extends TestTile { + get shape() { return TileShape.DateHeader; } + updateNextSibling(next) { + this.next = next; + } + updatePreviousSibling(prev) { + this.next?.updatePreviousSibling(prev); + } + compareEntry(b) { + // important that date tiles as sorted before their next item, but after their previous sibling + return this.next.compareEntry(b) - 0.5; + } + } + + class MessageNeedingDateHeaderTile extends TestTile { + get shape() { return TileShape.Message; } + + createDateSeparator() { + return new DateHeaderTile(this.entry); + } + updatePreviousSibling(prev) { + if (prev?.shape !== TileShape.DateHeader) { + // 1 day is 10 + this.needsDateSeparator = !prev || Math.floor(prev.entry.n / 10) !== Math.floor(this.entry.n / 10); + } + } + } + return { "don't emit update before add": assert => { class UpdateOnSiblingTile extends TestTile { @@ -355,6 +432,73 @@ export function tests() { }); entries.remove(1); assert.deepEqual(events, ["remove", "update"]); + }, + "date tile is added when needed when populating": assert => { + const entries = new ObservableArray([{n: 15}]); + const tileOptions = { + tileClassForEntry: () => MessageNeedingDateHeaderTile, + }; + const tiles = new TilesCollection(entries, tileOptions); + tiles.subscribe({}); + const tilesArray = Array.from(tiles); + assert.equal(tilesArray.length, 2); + assert.equal(tilesArray[0].shape, TileShape.DateHeader); + assert.equal(tilesArray[1].shape, TileShape.Message); + }, + "date header is added when receiving addition": assert => { + const entries = new ObservableArray([{n: 15}]); + const tileOptions = { + tileClassForEntry: () => MessageNeedingDateHeaderTile, + }; + const tiles = new TilesCollection(entries, tileOptions); + tiles.subscribe({ + onAdd() {}, + onRemove() {} + }); + entries.insert(0, {n: 5}); + const tilesArray = Array.from(tiles); + assert.equal(tilesArray[0].shape, TileShape.DateHeader); + assert.equal(tilesArray[1].shape, TileShape.Message); + assert.equal(tilesArray[2].shape, TileShape.DateHeader); + assert.equal(tilesArray[3].shape, TileShape.Message); + assert.equal(tilesArray.length, 4); + }, + "date header is removed and added when loading more messages for the same day": assert => { + const entries = new ObservableArray([{n: 15}]); + const tileOptions = { + tileClassForEntry: () => MessageNeedingDateHeaderTile, + }; + const tiles = new TilesCollection(entries, tileOptions); + tiles.subscribe({ + onAdd() {}, + onRemove() {} + }); + entries.insert(0, {n: 12}); + const tilesArray = Array.from(tiles); + assert.equal(tilesArray[0].shape, TileShape.DateHeader); + assert.equal(tilesArray[1].shape, TileShape.Message); + assert.equal(tilesArray[2].shape, TileShape.Message); + assert.equal(tilesArray.length, 3); + }, + "date header is removed at the end of the timeline": assert => { + const entries = new ObservableArray([{n: 5}, {n: 15}]); + const tileOptions = { + tileClassForEntry: () => MessageNeedingDateHeaderTile, + }; + const tiles = new TilesCollection(entries, tileOptions); + let removals = 0; + tiles.subscribe({ + onAdd() {}, + onRemove() { + removals += 1; + } + }); + entries.remove(1); + const tilesArray = Array.from(tiles); + assert.equal(tilesArray[0].shape, TileShape.DateHeader); + assert.equal(tilesArray[1].shape, TileShape.Message); + assert.equal(tilesArray.length, 2); + assert.equal(removals, 2); } } } diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 2cc3e572..3412b3f9 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -23,7 +23,6 @@ import {copyPlaintext} from "../../../../../platform/web/dom/utils"; export class BaseMessageTile extends SimpleTile { constructor(entry, options) { super(entry, options); - this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null; this._isContinuation = false; this._reactions = null; this._replyTile = null; @@ -55,14 +54,6 @@ export class BaseMessageTile extends SimpleTile { return `https://matrix.to/#/${encodeURIComponent(this.sender)}`; } - get displayName() { - return this._entry.displayName || this.sender; - } - - get sender() { - return this._entry.sender; - } - get memberPanelLink() { return `${this.urlRouter.urlUntilSegment("room")}/member/${this.sender}`; } @@ -84,12 +75,8 @@ export class BaseMessageTile extends SimpleTile { return this.sender; } - get date() { - return this._date && this._date.toLocaleDateString({}, {month: "numeric", day: "numeric"}); - } - get time() { - return this._date && this._date.toLocaleTimeString({}, {hour: "numeric", minute: "2-digit"}); + return this._date && this.timeFormatter.formatTime(this._date); } get isOwn() { @@ -145,7 +132,7 @@ export class BaseMessageTile extends SimpleTile { if (action?.shouldReplace || !this._replyTile) { this.disposeTracked(this._replyTile); const tileClassForEntry = this._options.tileClassForEntry; - const ReplyTile = tileClassForEntry(replyEntry); + const ReplyTile = tileClassForEntry(replyEntry, this._options); if (ReplyTile) { this._replyTile = new ReplyTile(replyEntry, this._options); } @@ -160,8 +147,8 @@ export class BaseMessageTile extends SimpleTile { this._roomVM.startReply(this._entry); } - reply(msgtype, body, log = null) { - return this._room.sendEvent("m.room.message", this._entry.reply(msgtype, body), null, log); + createReplyContent(msgtype, body) { + return this._entry.createReplyContent(msgtype, body); } redact(reason, log) { diff --git a/src/domain/session/room/timeline/tiles/CallTile.js b/src/domain/session/room/timeline/tiles/CallTile.js new file mode 100644 index 00000000..05762bc0 --- /dev/null +++ b/src/domain/session/room/timeline/tiles/CallTile.js @@ -0,0 +1,180 @@ +/* +Copyright 2020 Bruno Windels + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {SimpleTile} from "./SimpleTile.js"; +import {ViewModel} from "../../../../ViewModel"; +import {LocalMedia} from "../../../../../matrix/calls/LocalMedia"; +import {CallType} from "../../../../../matrix/calls/callEventTypes"; +import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../../../avatar"; + +// TODO: timeline entries for state events with the same state key and type +// should also update previous entries in the timeline, so we can update the name of the call, whether it is terminated, etc ... + +// alternatively, we could just subscribe to the GroupCall and spontanously emit an update when it updates + +export class CallTile extends SimpleTile { + constructor(entry, options) { + super(entry, options); + const calls = this.getOption("session").callHandler.calls; + this._callSubscription = undefined; + this._memberSizeSubscription = undefined; + const call = calls.get(this._entry.stateKey); + if (call && !call.isTerminated) { + this._call = call; + this.memberViewModels = this._setupMembersList(this._call); + this._callSubscription = this.track(this._call.disposableOn("change", () => { + this._onCallUpdate(); + })); + this._memberSizeSubscription = this.track(this._call.members.observeSize().subscribe(() => { + this.emitChange("memberCount"); + })); + this._onCallUpdate(); + } + } + + _onCallUpdate() { + // unsubscribe when terminated + if (this._call.isTerminated) { + this._durationInterval = this.disposeTracked(this._durationInterval); + this._callSubscription = this.disposeTracked(this._callSubscription); + this._call = undefined; + } else if (!this._durationInterval) { + this._durationInterval = this.track(this.platform.clock.createInterval(() => { + this.emitChange("duration"); + }, 1000)); + } + this.emitChange(); + } + + _setupMembersList(call) { + return call.members.mapValues( + (member, emitChange) => new MemberAvatarViewModel(this.childOptions({ + member, + emitChange, + mediaRepository: this.getOption("room").mediaRepository + })), + ).sortValues((a, b) => a.userId.localeCompare(b.userId)); + } + + get memberCount() { + // TODO: emit updates for this property + if (this._call) { + return this._call.members.size; + } + return 0; + } + + get confId() { + return this._entry.stateKey; + } + + get duration() { + if (this._call && this._call.duration) { + return this.timeFormatter.formatDuration(this._call.duration); + } else { + return ""; + } + } + + get shape() { + return "call"; + } + + get canJoin() { + return this._call && !this._call.hasJoined && !this._call.usesFoci; + } + + get canLeave() { + return this._call && this._call.hasJoined; + } + + get title() { + if (this._call) { + if (this.type === CallType.Video) { + return `${this.displayName} started a video call`; + } else { + return `${this.displayName} started a voice call`; + } + } else { + if (this.type === CallType.Video) { + return `Video call ended`; + } else { + return `Voice call ended`; + } + } + } + + get typeLabel() { + if (this._call && this._call.usesFoci) { + return `This call uses a stream-forwarding unit, which isn't supported yet, so you can't join this call.`; + } + if (this.type === CallType.Video) { + return `Video call`; + } else { + return `Voice call`; + } + } + + get type() { + return this._entry.event.content["m.type"]; + } + + async join() { + await this.logAndCatch("CallTile.join", async log => { + if (this.canJoin) { + const stream = await this.platform.mediaDevices.getMediaTracks(false, true); + const localMedia = new LocalMedia().withUserMedia(stream); + await this._call.join(localMedia, log); + } + }); + } + + async leave() { + await this.logAndCatch("CallTile.leave", async log => { + if (this.canLeave) { + await this._call.leave(log); + } + }); + } +} + +class MemberAvatarViewModel extends ViewModel { + get _member() { + return this.getOption("member"); + } + + get userId() { + return this._member.userId; + } + + get avatarLetter() { + return avatarInitials(this._member.member.name); + } + + get avatarColorNumber() { + return getIdentifierColorNumber(this._member.userId); + } + + avatarUrl(size) { + const {avatarUrl} = this._member.member; + const mediaRepository = this.getOption("mediaRepository"); + return getAvatarHttpUrl(avatarUrl, size, this.platform, mediaRepository); + } + + get avatarTitle() { + return this._member.member.name; + } +} diff --git a/src/domain/session/room/timeline/tiles/DateTile.ts b/src/domain/session/room/timeline/tiles/DateTile.ts new file mode 100644 index 00000000..2174b9c5 --- /dev/null +++ b/src/domain/session/room/timeline/tiles/DateTile.ts @@ -0,0 +1,189 @@ +import {ITile, TileShape, EmitUpdateFn} from "./ITile"; +import {UpdateAction} from "../UpdateAction"; +import {BaseEntry} from "../../../../../matrix/room/timeline/entries/BaseEntry"; +import {BaseEventEntry} from "../../../../../matrix/room/timeline/entries/BaseEventEntry"; +import {ViewModel} from "../../../../ViewModel"; +import type {Options} from "../../../../ViewModel"; + +/** + * edge cases: + * - be able to remove the tile in response to the sibling changing, + * probably by letting updateNextSibling/updatePreviousSibling + * return an UpdateAction and change TilesCollection accordingly. + * this is relevant when next becomes undefined there when + * a pending event is removed on remote echo. + * */ + +export class DateTile extends ViewModel implements ITile { + private _emitUpdate?: EmitUpdateFn; + private _dateString?: string; + private _machineReadableString?: string; + + constructor(private _firstTileInDay: ITile, options: Options) { + super(options); + } + + setUpdateEmit(emitUpdate: EmitUpdateFn): void { + this._emitUpdate = emitUpdate; + } + + get upperEntry(): BaseEventEntry { + return this.refEntry; + } + + get lowerEntry(): BaseEventEntry { + return this.refEntry; + } + + /** the entry reference by this datetile, e.g. the entry of the first tile for this day */ + private get refEntry(): BaseEventEntry { + // lowerEntry is the first entry... i think? + // so given the date header always comes before, + // this is our closest entry. + return this._firstTileInDay.lowerEntry; + } + + compare(tile: ITile): number { + return this.compareEntry(tile.upperEntry); + } + + get relativeDate(): string { + if (!this._dateString) { + this._dateString = this.timeFormatter.formatRelativeDate(new Date(this.refEntry.timestamp)); + } + return this._dateString; + } + + get machineReadableDate(): string { + if (!this._machineReadableString) { + this._machineReadableString = this.timeFormatter.formatMachineReadableDate(new Date(this.refEntry.timestamp)); + } + return this._machineReadableString; + } + + get shape(): TileShape { + return TileShape.DateHeader; + } + + get needsDateSeparator(): boolean { + return false; + } + + createDateSeparator(): undefined { + return undefined; + } + +/** + * _findTileIdx in TilesCollection should never return + * the index of a DateTile as that is mainly used + * for mapping incoming event indices coming from the Timeline + * to the tile index to propage the event. + * This is not a path that is relevant to date headers as they + * are added as a side-effect of adding other tiles and are generally + * not updated (only removed in some cases). _findTileIdx is also + * used for emitting spontanous updates, but that should also not be + * needed for a DateTile. + * The problem is basically that _findTileIdx maps an entry to + * a tile, and DateTile adopts the entry of it's sibling tile (_firstTileInDay) + * so now we have the entry pointing to two tiles. So we should avoid + * returning the DateTile itself from the compare method. + * We will always return -1 or 1 from here to signal an entry comes before or after us, + * never 0 + * */ + compareEntry(entry: BaseEntry): number { + const result = this.refEntry.compare(entry); + if (result === 0) { + // if it's a match for the reference entry (e.g. _firstTileInDay), + // say it comes after us as the date tile always comes at the top + // of the day. + return -1; + } + // otherwise, assume the given entry is never for ourselves + // as we don't have our own entry, we only borrow one from _firstTileInDay + return result; + } + + // update received for already included (falls within sort keys) entry + updateEntry(entry, param): UpdateAction { + return UpdateAction.Nothing(); + } + + // return whether the tile should be removed + // as SimpleTile only has one entry, the tile should be removed + removeEntry(entry: BaseEntry): boolean { + return false; + } + + // SimpleTile can only contain 1 entry + tryIncludeEntry(): boolean { + return false; + } + + /** + * This tile needs to do the comparison between tiles, as it uses the entry + * from another tile to determine its sorting order. + * */ + get comparisonIsNotCommutative(): boolean { + return true; + } + + // let item know it has a new sibling + updatePreviousSibling(prev: ITile | undefined): void { + // forward the sibling update to our next tile, so it is informed + // about it's previous sibling beyond the date header (which is it's direct previous sibling) + // so it can recalculate whether it still needs a date header + this._firstTileInDay.updatePreviousSibling(prev); + } + + // let item know it has a new sibling + updateNextSibling(next: ITile | undefined): UpdateAction { + if(!next) { + // If we are the DateTile for the last tile in the timeline, + // and that tile gets removed, next would be undefined + // and this DateTile would be removed as well, + // so do nothing + return; + } + this._firstTileInDay = next; + const prevDateString = this._dateString; + this._dateString = undefined; + this._machineReadableString = undefined; + if (prevDateString && prevDateString !== this.relativeDate) { + this._emitUpdate?.(this, "relativeDate"); + } + } + + notifyVisible(): void { + // trigger sticky logic here? + } + + dispose(): void { + + } +} + +import { EventEntry } from "../../../../../matrix/room/timeline/entries/EventEntry.js"; +import { SimpleTile } from "./SimpleTile"; + +export function tests() { + return { + "date tile sorts before reference tile": assert => { + const a = new SimpleTile(new EventEntry({ + event: {}, + eventIndex: 2, + fragmentId: 1 + }, undefined), {}); + const b = new SimpleTile(new EventEntry({ + event: {}, + eventIndex: 3, + fragmentId: 1 + }, undefined), {}); + const d = new DateTile(b, {} as any); + const tiles = [d, b, a]; + tiles.sort((a, b) => a.compare(b)); + assert.equal(tiles[0], a); + assert.equal(tiles[1], d); + assert.equal(tiles[2], b); + } + } +} diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js index bb7d8086..66e83147 100644 --- a/src/domain/session/room/timeline/tiles/GapTile.js +++ b/src/domain/session/room/timeline/tiles/GapTile.js @@ -19,71 +19,64 @@ import {UpdateAction} from "../UpdateAction.js"; import {ConnectionError} from "../../../../../matrix/error.js"; import {ConnectionStatus} from "../../../../../matrix/net/Reconnector"; +// TODO: should this become an ITile and SimpleTile become EventTile? export class GapTile extends SimpleTile { constructor(entry, options) { super(entry, options); this._loading = false; - this._error = null; + this._waitingForConnection = false; this._isAtTop = true; this._siblingChanged = false; - this._showSpinner = false; } - async fill() { + get needsDateSeparator() { + return false; + } + + async fill(isRetrying = false) { if (!this._loading && !this._entry.edgeReached) { this._loading = true; - this._error = null; - this._showSpinner = true; this.emitChange("isLoading"); try { await this._room.fillGap(this._entry, 10); } catch (err) { - console.error(`room.fillGap(): ${err.message}:\n${err.stack}`); - this._error = err; if (err instanceof ConnectionError) { - this.emitChange("error"); - /* - We need to wait for reconnection here rather than in - notifyVisible() because when we return/throw here - this._loading is set to false and other queued invocations of - this method will succeed and attempt further room.fillGap() calls - - resulting in multiple error entries in logs and elsewhere! - */ await this._waitForReconnection(); + if (!isRetrying) { + // retry after the connection comes back + // if this wasn't already a retry after coming back online + return await this.fill(true); + } else { + return false; + } + } else { + this.reportError(err); + return false; } - // rethrow so caller of this method - // knows not to keep calling this for now - throw err; } finally { this._loading = false; - this._showSpinner = false; this.emitChange("isLoading"); } - return true; + return true; } return false; } async notifyVisible() { + // if any error happened before (apart from being offline), + // let the user dismiss the error before trying to backfill + // again so we don't try to do backfill the don't succeed + // in quick succession + if (this.errorViewModel) { + return; + } // we do (up to 10) backfills while no new tiles have been added to the timeline // because notifyVisible won't be called again until something gets added to the timeline let depth = 0; let canFillMore; this._siblingChanged = false; do { - try { - canFillMore = await this.fill(); - } - catch (e) { - if (e instanceof ConnectionError) { - canFillMore = true; - // Don't increase depth because this gap fill was a noop - continue; - } - else { - canFillMore = false; - } - } + canFillMore = await this.fill(); depth = depth + 1; } while (depth < 10 && !this._siblingChanged && canFillMore && !this.isDisposed); } @@ -119,7 +112,11 @@ export class GapTile extends SimpleTile { } async _waitForReconnection() { + this._waitingForConnection = true; + this.emitUpdate("status"); await this.options.client.reconnector.connectionStatus.waitFor(status => status === ConnectionStatus.Online).promise; + this._waitingForConnection = false; + this.emitUpdate("status"); } get shape() { @@ -131,29 +128,19 @@ export class GapTile extends SimpleTile { } get showSpinner() { - return this._showSpinner; + return this.isLoading || this._waitingForConnection; } - get error() { - if (this._error) { - if (this._error instanceof ConnectionError) { - return "Waiting for reconnection"; - } - const dir = this._entry.prev_batch ? "previous" : "next"; - return `Could not load ${dir} messages: ${this._error.message}`; - } - return null; - } - - get currentAction() { - if (this.error) { - return this.error; - } - else if (this.isLoading) { - return "Loading"; - } - else { - return "Not Loading"; + get status() { + const dir = this._entry.prev_batch ? "previous" : "next"; + if (this._waitingForConnection) { + return "Waiting for connection…"; + } else if (this.errorViewModel) { + return `Could not load ${dir} messages`; + } else if (this.isLoading) { + return "Loading more messages…"; + } else { + return "Gave up loading more messages"; } } } diff --git a/src/domain/session/room/timeline/tiles/ITile.ts b/src/domain/session/room/timeline/tiles/ITile.ts new file mode 100644 index 00000000..e47ebcd0 --- /dev/null +++ b/src/domain/session/room/timeline/tiles/ITile.ts @@ -0,0 +1,58 @@ +import {UpdateAction} from "../UpdateAction.js"; +import {BaseEntry} from "../../../../../matrix/room/timeline/entries/BaseEntry"; +import {BaseEventEntry} from "../../../../../matrix/room/timeline/entries/BaseEventEntry"; +import {IDisposable} from "../../../../../utils/Disposables"; + +export type EmitUpdateFn = (tile: ITile, props: any) => void + +export enum TileShape { + Message = "message", + MessageStatus = "message-status", + Announcement = "announcement", + File = "file", + Gap = "gap", + Image = "image", + Location = "location", + MissingAttachment = "missing-attachment", + Redacted = "redacted", + Video = "video", + DateHeader = "date-header", + Call = "call", +} + +// TODO: should we imply inheriting from view model here? +export interface ITile extends IDisposable { + setUpdateEmit(emitUpdate: EmitUpdateFn): void; + get upperEntry(): E; + get lowerEntry(): E; + /** compare two tiles, returning: + * - 0 if both tiles are considered equal + * - a negative value if this tiles is sorted before the given tile + * - a positive value if this tiles is sorted after the given tile + **/ + compare(tile: ITile): number; + /** Some tiles might need comparison mechanisms that are not commutative, + * (e.g. `tileA.compare(tileB)` not being the same as `tileB.compare(tileA)`), + * a property needed for reliably sorting the tiles in TilesCollection. + * To counteract this, tiles can indicate this is not the case for them and + * when any other tile is being compared to a tile where this flag is true, + * it should delegate the comparison to the given tile. + * E.g. one example where this flag is used is DateTile. */ + get comparisonIsNotCommutative(): boolean; + compareEntry(entry: BaseEntry): number; + // update received for already included (falls within sort keys) entry + updateEntry(entry: BaseEntry, param: any): UpdateAction; + // return whether the tile should be removed + // as SimpleTile only has one entry, the tile should be removed + removeEntry(entry: BaseEntry): boolean + // SimpleTile can only contain 1 entry + tryIncludeEntry(entry: BaseEntry): boolean; + // let item know it has a new sibling + updatePreviousSibling(prev: ITile | undefined): void; + // let item know it has a new sibling + updateNextSibling(next: ITile | undefined): void; + notifyVisible(): void; + get needsDateSeparator(): boolean; + createDateSeparator(): ITile | undefined; + get shape(): TileShape; +} diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index 04141576..59ddf15b 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -15,13 +15,17 @@ limitations under the License. */ import {UpdateAction} from "../UpdateAction.js"; -import {ViewModel} from "../../../../ViewModel"; +import {ErrorReportViewModel} from "../../../../ErrorReportViewModel"; +import {TileShape} from "./ITile"; import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js"; +import {DateTile} from "./DateTile"; -export class SimpleTile extends ViewModel { +export class SimpleTile extends ErrorReportViewModel { constructor(entry, options) { super(options); this._entry = entry; + this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : undefined; + this._needsDateSeparator = false; this._emitUpdate = undefined; } // view model props for all subclasses @@ -37,8 +41,22 @@ export class SimpleTile extends ViewModel { return false; } - get hasDateSeparator() { - return false; + get needsDateSeparator() { + return this._needsDateSeparator; + } + + createDateSeparator() { + return new DateTile(this, this.childOptions({})); + } + + _updateDateSeparator(prev) { + if (prev && prev._date && this._date) { + this._needsDateSeparator = prev._date.getFullYear() !== this._date.getFullYear() || + prev._date.getMonth() !== this._date.getMonth() || + prev._date.getDate() !== this._date.getDate(); + } else { + this._needsDateSeparator = !!this._date; + } } get id() { @@ -92,8 +110,16 @@ export class SimpleTile extends ViewModel { return this._entry; } + get comparisonIsNotCommutative() { + return false; + } + compare(tile) { - return this.upperEntry.compare(tile.upperEntry); + if (tile.comparisonIsNotCommutative) { + return -tile.compare(this); + } else { + return this.upperEntry.compare(tile.upperEntry); + } } compareEntry(entry) { @@ -123,8 +149,10 @@ export class SimpleTile extends ViewModel { return false; } // let item know it has a new sibling - updatePreviousSibling(/*prev*/) { - + updatePreviousSibling(prev) { + if (prev?.shape !== TileShape.DateHeader) { + this._updateDateSeparator(prev); + } } // let item know it has a new sibling @@ -159,4 +187,74 @@ export class SimpleTile extends ViewModel { get _ownMember() { return this._options.timeline.me; } + + get displayName() { + return this._entry.displayName || this.sender; + } + + get sender() { + return this._entry.sender; + } +} + +import { EventEntry } from "../../../../../matrix/room/timeline/entries/EventEntry.js"; + +export function tests() { + return { + "needsDateSeparator is false when previous sibling is for same date": assert => { + const fridayEntry = new EventEntry({ + event: { + origin_server_ts: 1669376446222, + type: "m.room.message", + content: {} + } + }, undefined); + const thursdayEntry = new EventEntry({ + event: { + origin_server_ts: fridayEntry.timestamp - (60 * 60 * 8 * 1000), + type: "m.room.message", + content: {} + } + }, undefined); + const fridayTile = new SimpleTile(fridayEntry, {}); + const thursdayTile = new SimpleTile(thursdayEntry, {}); + assert.equal(fridayTile.needsDateSeparator, false); + fridayTile.updatePreviousSibling(thursdayTile); + assert.equal(fridayTile.needsDateSeparator, false); + }, + "needsDateSeparator is true when previous sibling is for different date": assert => { + const fridayEntry = new EventEntry({ + event: { + origin_server_ts: 1669376446222, + type: "m.room.message", + content: {} + } + }, undefined); + const thursdayEntry = new EventEntry({ + event: { + origin_server_ts: fridayEntry.timestamp - (60 * 60 * 24 * 1000), + type: "m.room.message", + content: {} + } + }, undefined); + const fridayTile = new SimpleTile(fridayEntry, {}); + const thursdayTile = new SimpleTile(thursdayEntry, {}); + assert.equal(fridayTile.needsDateSeparator, false); + fridayTile.updatePreviousSibling(thursdayTile); + assert.equal(fridayTile.needsDateSeparator, true); + }, + "needsDateSeparator is true when previous sibling is undefined": assert => { + const fridayEntry = new EventEntry({ + event: { + origin_server_ts: 1669376446222, + type: "m.room.message", + content: {} + } + }, undefined); + const fridayTile = new SimpleTile(fridayEntry, {}); + assert.equal(fridayTile.needsDateSeparator, false); + fridayTile.updatePreviousSibling(undefined); + assert.equal(fridayTile.needsDateSeparator, true); + }, + } } diff --git a/src/domain/session/room/timeline/tiles/index.ts b/src/domain/session/room/timeline/tiles/index.ts index 242bea2f..e86d61cb 100644 --- a/src/domain/session/room/timeline/tiles/index.ts +++ b/src/domain/session/room/timeline/tiles/index.ts @@ -26,9 +26,11 @@ import {RoomMemberTile} from "./RoomMemberTile.js"; import {EncryptedEventTile} from "./EncryptedEventTile.js"; import {EncryptionEnabledTile} from "./EncryptionEnabledTile.js"; import {MissingAttachmentTile} from "./MissingAttachmentTile.js"; +import {CallTile} from "./CallTile.js"; -import type {SimpleTile} from "./SimpleTile.js"; +import type {ITile, TileShape} from "./ITile"; import type {Room} from "../../../../../matrix/room/Room"; +import type {Session} from "../../../../../matrix/Session"; import type {Timeline} from "../../../../../matrix/room/timeline/Timeline"; import type {FragmentBoundaryEntry} from "../../../../../matrix/room/timeline/entries/FragmentBoundaryEntry"; import type {EventEntry} from "../../../../../matrix/room/timeline/entries/EventEntry"; @@ -38,13 +40,14 @@ import type {Options as ViewModelOptions} from "../../../../ViewModel"; export type TimelineEntry = FragmentBoundaryEntry | EventEntry | PendingEventEntry; export type TileClassForEntryFn = (entry: TimelineEntry) => TileConstructor | undefined; export type Options = ViewModelOptions & { + session: Session, room: Room, timeline: Timeline tileClassForEntry: TileClassForEntryFn; }; -export type TileConstructor = new (entry: TimelineEntry, options: Options) => SimpleTile; +export type TileConstructor = new (entry: TimelineEntry, options: Options) => ITile; -export function tileClassForEntry(entry: TimelineEntry): TileConstructor | undefined { +export function tileClassForEntry(entry: TimelineEntry, options: Options): TileConstructor | undefined { if (entry.isGap) { return GapTile; } else if (entry.isPending && entry.pendingEvent.isMissingAttachments) { @@ -86,6 +89,14 @@ export function tileClassForEntry(entry: TimelineEntry): TileConstructor | undef return EncryptedEventTile; case "m.room.encryption": return EncryptionEnabledTile; + case "org.matrix.msc3401.call": { + // if prevContent is present, it's an update to a call event, which we don't render + // as the original event is updated through the call object which receive state event updates + if (options.features.calls && entry.stateKey && !entry.prevContent) { + return CallTile; + } + return undefined; + } default: // unknown type not rendered return undefined; diff --git a/src/domain/session/settings/FeaturesViewModel.ts b/src/domain/session/settings/FeaturesViewModel.ts new file mode 100644 index 00000000..b0ee3598 --- /dev/null +++ b/src/domain/session/settings/FeaturesViewModel.ts @@ -0,0 +1,75 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {ViewModel} from "../../ViewModel"; +import type {Options as BaseOptions} from "../../ViewModel"; +import {FeatureFlag, FeatureSet} from "../../../features"; +import type {SegmentType} from "../../navigation/index"; + +export class FeaturesViewModel extends ViewModel { + public readonly featureViewModels: ReadonlyArray; + + constructor(options) { + super(options); + this.featureViewModels = [ + new FeatureViewModel(this.childOptions({ + name: this.i18n`Audio/video calls`, + description: this.i18n`Allows starting and participating in A/V calls compatible with Element Call (MSC3401). Look for the start call option in the room menu ((...) in the right corner) to start a call.`, + feature: FeatureFlag.Calls + })), + new FeatureViewModel(this.childOptions({ + name: this.i18n`Cross-Signing`, + description: this.i18n`Allows verifying the identity of people you chat with. This feature is still evolving constantly, expect things to break.`, + feature: FeatureFlag.CrossSigning + })), + ]; + } +} + +type FeatureOptions = BaseOptions & { + feature: FeatureFlag, + description: string, + name: string +}; + +export class FeatureViewModel extends ViewModel { + get enabled(): boolean { + return this.features.isFeatureEnabled(this.getOption("feature")); + } + + async enableFeature(enabled: boolean): Promise { + let newFeatures; + if (enabled) { + newFeatures = this.features.withFeature(this.getOption("feature")); + } else { + newFeatures = this.features.withoutFeature(this.getOption("feature")); + } + await newFeatures.store(this.platform.settingsStorage); + this.platform.restart(); + } + + get id(): string { + return `${this.getOption("feature")}`; + } + + get name(): string { + return this.getOption("name"); + } + + get description(): string { + return this.getOption("description"); + } +} diff --git a/src/domain/session/settings/KeyBackupViewModel.js b/src/domain/session/settings/KeyBackupViewModel.js deleted file mode 100644 index 243b0d7c..00000000 --- a/src/domain/session/settings/KeyBackupViewModel.js +++ /dev/null @@ -1,206 +0,0 @@ -/* -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import {ViewModel} from "../../ViewModel"; -import {KeyType} from "../../../matrix/ssss/index"; -import {createEnum} from "../../../utils/enum"; - -export const Status = createEnum("Enabled", "SetupKey", "SetupPhrase", "Pending", "NewVersionAvailable"); -export const BackupWriteStatus = createEnum("Writing", "Stopped", "Done", "Pending"); - -export class KeyBackupViewModel extends ViewModel { - constructor(options) { - super(options); - this._session = options.session; - this._error = null; - this._isBusy = false; - this._dehydratedDeviceId = undefined; - this._status = undefined; - this._backupOperation = this._session.keyBackup.flatMap(keyBackup => keyBackup.operationInProgress); - this._progress = this._backupOperation.flatMap(op => op.progress); - this.track(this._backupOperation.subscribe(() => { - // see if needsNewKey might be set - this._reevaluateStatus(); - this.emitChange("isBackingUp"); - })); - this.track(this._progress.subscribe(() => this.emitChange("backupPercentage"))); - this._reevaluateStatus(); - this.track(this._session.keyBackup.subscribe(() => { - if (this._reevaluateStatus()) { - this.emitChange("status"); - } - })); - } - - _reevaluateStatus() { - if (this._isBusy) { - return false; - } - let status; - const keyBackup = this._session.keyBackup.get(); - if (keyBackup) { - status = keyBackup.needsNewKey ? Status.NewVersionAvailable : Status.Enabled; - } else if (keyBackup === null) { - status = this.showPhraseSetup() ? Status.SetupPhrase : Status.SetupKey; - } else { - status = Status.Pending; - } - const changed = status !== this._status; - this._status = status; - return changed; - } - - get decryptAction() { - return this.i18n`Set up`; - } - - get purpose() { - return this.i18n`set up key backup`; - } - - offerDehydratedDeviceSetup() { - return true; - } - - get dehydratedDeviceId() { - return this._dehydratedDeviceId; - } - - get isBusy() { - return this._isBusy; - } - - get backupVersion() { - return this._session.keyBackup.get()?.version; - } - - get backupWriteStatus() { - const keyBackup = this._session.keyBackup.get(); - if (!keyBackup) { - return BackupWriteStatus.Pending; - } else if (keyBackup.hasStopped) { - return BackupWriteStatus.Stopped; - } - const operation = keyBackup.operationInProgress.get(); - if (operation) { - return BackupWriteStatus.Writing; - } else if (keyBackup.hasBackedUpAllKeys) { - return BackupWriteStatus.Done; - } else { - return BackupWriteStatus.Pending; - } - } - - get backupError() { - return this._session.keyBackup.get()?.error?.message; - } - - get status() { - return this._status; - } - - get error() { - return this._error?.message; - } - - showPhraseSetup() { - if (this._status === Status.SetupKey) { - this._status = Status.SetupPhrase; - this.emitChange("status"); - } - } - - showKeySetup() { - if (this._status === Status.SetupPhrase) { - this._status = Status.SetupKey; - this.emitChange("status"); - } - } - - async _enterCredentials(keyType, credential, setupDehydratedDevice) { - if (credential) { - try { - this._isBusy = true; - this.emitChange("isBusy"); - const key = await this._session.enableSecretStorage(keyType, credential); - if (setupDehydratedDevice) { - this._dehydratedDeviceId = await this._session.setupDehydratedDevice(key); - } - } catch (err) { - console.error(err); - this._error = err; - this.emitChange("error"); - } finally { - this._isBusy = false; - this._reevaluateStatus(); - this.emitChange(""); - } - } - } - - enterSecurityPhrase(passphrase, setupDehydratedDevice) { - this._enterCredentials(KeyType.Passphrase, passphrase, setupDehydratedDevice); - } - - enterSecurityKey(securityKey, setupDehydratedDevice) { - this._enterCredentials(KeyType.RecoveryKey, securityKey, setupDehydratedDevice); - } - - async disable() { - try { - this._isBusy = true; - this.emitChange("isBusy"); - await this._session.disableSecretStorage(); - } catch (err) { - console.error(err); - this._error = err; - this.emitChange("error"); - } finally { - this._isBusy = false; - this._reevaluateStatus(); - this.emitChange(""); - } - } - - get isBackingUp() { - return !!this._backupOperation.get(); - } - - get backupPercentage() { - const progress = this._progress.get(); - if (progress) { - return Math.round((progress.finished / progress.total) * 100); - } - return 0; - } - - get backupInProgressLabel() { - const progress = this._progress.get(); - if (progress) { - return this.i18n`${progress.finished} of ${progress.total}`; - } - return this.i18n`…`; - } - - cancelBackup() { - this._backupOperation.get()?.abort(); - } - - startBackup() { - this._session.keyBackup.get()?.flush(); - } -} - diff --git a/src/domain/session/settings/KeyBackupViewModel.ts b/src/domain/session/settings/KeyBackupViewModel.ts new file mode 100644 index 00000000..3426191b --- /dev/null +++ b/src/domain/session/settings/KeyBackupViewModel.ts @@ -0,0 +1,270 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {ViewModel} from "../../ViewModel"; +import {SegmentType} from "../../navigation/index"; +import {KeyType} from "../../../matrix/ssss/index"; + +import type {Options as BaseOptions} from "../../ViewModel"; +import type {Session} from "../../../matrix/Session"; +import type {Disposable} from "../../../utils/Disposables"; +import type {KeyBackup, Progress} from "../../../matrix/e2ee/megolm/keybackup/KeyBackup"; +import type {CrossSigning} from "../../../matrix/verification/CrossSigning"; + +export enum Status { + Enabled, + Setup, + Pending, + NewVersionAvailable +}; + +export enum BackupWriteStatus { + Writing, + Stopped, + Done, + Pending +}; + +type Options = { + session: Session, +} & BaseOptions; + +export class KeyBackupViewModel extends ViewModel { + private _error?: Error = undefined; + private _isBusy = false; + private _dehydratedDeviceId?: string = undefined; + private _status = Status.Pending; + private _backupOperationSubscription?: Disposable = undefined; + private _keyBackupSubscription?: Disposable = undefined; + private _progress?: Progress = undefined; + private _setupKeyType = KeyType.RecoveryKey; + + constructor(options) { + super(options); + const onKeyBackupSet = (keyBackup: KeyBackup | undefined) => { + if (keyBackup && !this._keyBackupSubscription) { + this._keyBackupSubscription = this.track(this._session.keyBackup.get().disposableOn("change", () => { + this._onKeyBackupChange(); + })); + } else if (!keyBackup && this._keyBackupSubscription) { + this._keyBackupSubscription = this.disposeTracked(this._keyBackupSubscription); + } + this._onKeyBackupChange(); // update status + }; + this.track(this._session.keyBackup.subscribe(onKeyBackupSet)); + onKeyBackupSet(this._keyBackup); + } + + private get _session(): Session { + return this.getOption("session"); + } + + private get _keyBackup(): KeyBackup | undefined { + return this._session.keyBackup.get(); + } + + private get _crossSigning(): CrossSigning | undefined { + return this._session.crossSigning.get(); + } + + private _onKeyBackupChange() { + const keyBackup = this._keyBackup; + if (keyBackup) { + const {operationInProgress} = keyBackup; + if (operationInProgress && !this._backupOperationSubscription) { + this._backupOperationSubscription = this.track(operationInProgress.disposableOn("change", () => { + this._progress = operationInProgress.progress; + this.emitChange("backupPercentage"); + })); + } else if (this._backupOperationSubscription && !operationInProgress) { + this._backupOperationSubscription = this.disposeTracked(this._backupOperationSubscription); + this._progress = undefined; + } + } + this.emitChange("status"); + } + + get status(): Status { + const keyBackup = this._keyBackup; + if (keyBackup) { + if (keyBackup.needsNewKey) { + return Status.NewVersionAvailable; + } else if (keyBackup.version === undefined) { + return Status.Pending; + } else { + return keyBackup.needsNewKey ? Status.NewVersionAvailable : Status.Enabled; + } + } else { + return Status.Setup; + } + } + + get decryptAction(): string { + return this.i18n`Set up`; + } + + get purpose(): string { + return this.i18n`set up key backup`; + } + + offerDehydratedDeviceSetup(): boolean { + return true; + } + + get dehydratedDeviceId(): string | undefined { + return this._dehydratedDeviceId; + } + + get isBusy(): boolean { + return this._isBusy; + } + + get backupVersion(): string { + return this._keyBackup?.version ?? ""; + } + + get isMasterKeyTrusted(): boolean { + return this._crossSigning?.isMasterKeyTrusted ?? false; + } + + get canSignOwnDevice(): boolean { + return !!this._crossSigning; + } + + async signOwnDevice(): Promise { + const crossSigning = this._crossSigning; + if (crossSigning) { + await this.logger.run("KeyBackupViewModel.signOwnDevice", async log => { + await crossSigning.signOwnDevice(log); + }); + } + } + + get backupWriteStatus(): BackupWriteStatus { + const keyBackup = this._keyBackup; + if (!keyBackup || keyBackup.version === undefined) { + return BackupWriteStatus.Pending; + } else if (keyBackup.hasStopped) { + return BackupWriteStatus.Stopped; + } + const operation = keyBackup.operationInProgress; + if (operation) { + return BackupWriteStatus.Writing; + } else if (keyBackup.hasBackedUpAllKeys) { + return BackupWriteStatus.Done; + } else { + return BackupWriteStatus.Pending; + } + } + + get backupError(): string | undefined { + return this._keyBackup?.error?.message; + } + + get error(): string | undefined { + return this._error?.message; + } + + showPhraseSetup(): void { + if (this._status === Status.Setup) { + this._setupKeyType = KeyType.Passphrase; + this.emitChange("setupKeyType"); + } + } + + showKeySetup(): void { + if (this._status === Status.Setup) { + this._setupKeyType = KeyType.Passphrase; + this.emitChange("setupKeyType"); + } + } + + get setupKeyType(): KeyType { + return this._setupKeyType; + } + + private async _enterCredentials(keyType, credential, setupDehydratedDevice): Promise { + if (credential) { + try { + this._isBusy = true; + this.emitChange("isBusy"); + const key = await this._session.enableSecretStorage(keyType, credential); + if (setupDehydratedDevice) { + this._dehydratedDeviceId = await this._session.setupDehydratedDevice(key); + } + } catch (err) { + console.error(err); + this._error = err; + this.emitChange("error"); + } finally { + this._isBusy = false; + this.emitChange(); + } + } + } + + enterSecurityPhrase(passphrase, setupDehydratedDevice): Promise { + return this._enterCredentials(KeyType.Passphrase, passphrase, setupDehydratedDevice); + } + + enterSecurityKey(securityKey, setupDehydratedDevice): Promise { + return this._enterCredentials(KeyType.RecoveryKey, securityKey, setupDehydratedDevice); + } + + async disable(): Promise { + try { + this._isBusy = true; + this.emitChange("isBusy"); + await this._session.disableSecretStorage(); + } catch (err) { + console.error(err); + this._error = err; + this.emitChange("error"); + } finally { + this._isBusy = false; + this.emitChange(); + } + } + + get isBackingUp(): boolean { + return this._keyBackup?.operationInProgress !== undefined; + } + + get backupPercentage(): number { + if (this._progress) { + return Math.round((this._progress.finished / this._progress.total) * 100); + } + return 0; + } + + get backupInProgressLabel(): string { + if (this._progress) { + return this.i18n`${this._progress.finished} of ${this._progress.total}`; + } + return this.i18n`…`; + } + + cancelBackup(): void { + this._keyBackup?.operationInProgress?.abort(); + } + + startBackup(): void { + this.logger.run("KeyBackupViewModel.startBackup", log => { + this._keyBackup?.flush(log); + }); + } +} + diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index ad01c64e..7f4cab59 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -15,8 +15,9 @@ limitations under the License. */ import {ViewModel} from "../../ViewModel"; -import {KeyBackupViewModel} from "./KeyBackupViewModel.js"; -import {submitLogsToRageshakeServer} from "../../../domain/rageshake"; +import {KeyBackupViewModel} from "./KeyBackupViewModel"; +import {FeaturesViewModel} from "./FeaturesViewModel"; +import {submitLogsFromSessionToDefaultServer} from "../../../domain/rageshake"; class PushNotificationStatus { constructor() { @@ -53,6 +54,7 @@ export class SettingsViewModel extends ViewModel { this.pushNotifications = new PushNotificationStatus(); this._activeTheme = undefined; this._logsFeedbackMessage = undefined; + this._featuresViewModel = new FeaturesViewModel(this.childOptions()); } get _session() { @@ -125,6 +127,10 @@ export class SettingsViewModel extends ViewModel { return this._keyBackupViewModel; } + get featuresViewModel() { + return this._featuresViewModel; + } + get storageQuota() { return this._formatBytes(this._estimate?.quota); } @@ -150,8 +156,14 @@ export class SettingsViewModel extends ViewModel { } async exportLogs() { - const logExport = await this.logger.export(); - this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`); + const logs = await this.exportLogsBlob(); + this.platform.saveFileAs(logs, `hydrogen-logs-${this.platform.clock.now()}.json`); + } + + async exportLogsBlob() { + const persister = this.logger.reporters.find(r => typeof r.export === "function"); + const logExport = await persister.export(); + return logExport.asBlob(); } get canSendLogsToServer() { @@ -169,29 +181,13 @@ export class SettingsViewModel extends ViewModel { } async sendLogsToServer() { - const {bugReportEndpointUrl} = this.platform.config; - if (bugReportEndpointUrl) { - this._logsFeedbackMessage = this.i18n`Sending logs…`; + this._logsFeedbackMessage = this.i18n`Sending logs…`; + try { + await submitLogsFromSessionToDefaultServer(this._session, this.platform); + this._logsFeedbackMessage = this.i18n`Logs sent succesfully!`; + } catch (err) { + this._logsFeedbackMessage = err.message; this.emitChange(); - try { - const logExport = await this.logger.export(); - await submitLogsToRageshakeServer( - { - app: "hydrogen", - userAgent: this.platform.description, - version: DEFINE_VERSION, - text: `Submit logs from settings for user ${this._session.userId} on device ${this._session.deviceId}`, - }, - logExport.asBlob(), - bugReportEndpointUrl, - this.platform.request - ); - this._logsFeedbackMessage = this.i18n`Logs sent succesfully!`; - this.emitChange(); - } catch (err) { - this._logsFeedbackMessage = err.message; - this.emitChange(); - } } } diff --git a/src/domain/session/toast/BaseToastNotificationViewModel.ts b/src/domain/session/toast/BaseToastNotificationViewModel.ts new file mode 100644 index 00000000..41e20e42 --- /dev/null +++ b/src/domain/session/toast/BaseToastNotificationViewModel.ts @@ -0,0 +1,35 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {ErrorReportViewModel} from "../../ErrorReportViewModel"; +import {Options as BaseOptions} from "../../ViewModel"; +import type {Session} from "../../../matrix/Session.js"; +import {SegmentType} from "../../navigation"; + +export type BaseClassOptions = { + dismiss: () => void; + session: Session; +} & BaseOptions; + +export abstract class BaseToastNotificationViewModel = BaseClassOptions> extends ErrorReportViewModel { + constructor(options: O) { + super(options); + } + + dismiss(): void { + this.getOption("dismiss")(); + } +} diff --git a/src/domain/session/toast/CallToastNotificationViewModel.ts b/src/domain/session/toast/CallToastNotificationViewModel.ts new file mode 100644 index 00000000..5c788334 --- /dev/null +++ b/src/domain/session/toast/CallToastNotificationViewModel.ts @@ -0,0 +1,92 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import type {GroupCall} from "../../../matrix/calls/group/GroupCall"; +import type {Room} from "../../../matrix/room/Room.js"; +import {IAvatarContract, avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar"; +import {LocalMedia} from "../../../matrix/calls/LocalMedia"; +import {BaseClassOptions, BaseToastNotificationViewModel} from "./BaseToastNotificationViewModel"; +import {SegmentType} from "../../navigation"; + +type Options = { + call: GroupCall; + room: Room; +} & BaseClassOptions; + +// Since we access the room segment below, the segment type +// needs to at least contain the room segment! +type MinimumNeededSegmentType = { + "room": string; +}; + +export class CallToastNotificationViewModel = Options> extends BaseToastNotificationViewModel implements IAvatarContract { + constructor(options: O) { + super(options); + this.track(this.call.members.observeSize().subscribe(() => { + this.emitChange("memberCount"); + })); + // Dismiss the toast if the room is opened manually + this.track( + this.navigation.observe("room").subscribe((roomId) => { + if ((roomId as unknown as string) === this.call.roomId) { + this.dismiss(); + } + })); + } + + async join(): Promise { + await this.logAndCatch("CallToastNotificationViewModel.join", async (log) => { + const stream = await this.platform.mediaDevices.getMediaTracks(false, true); + const localMedia = new LocalMedia().withUserMedia(stream); + await this.call.join(localMedia, log); + const url = this.urlRouter.openRoomActionUrl(this.call.roomId); + this.urlRouter.pushUrl(url); + }); + } + + get call(): GroupCall { + return this.getOption("call"); + } + + private get room(): Room { + return this.getOption("room"); + } + + get roomName(): string { + return this.room.name; + } + + get memberCount(): number { + return this.call.members.size; + } + + get avatarLetter(): string { + return avatarInitials(this.roomName); + } + + get avatarColorNumber(): number { + return getIdentifierColorNumber(this.room.avatarColorId); + } + + avatarUrl(size: number): string | undefined { + return getAvatarHttpUrl(this.room.avatarUrl, size, this.platform, this.room.mediaRepository); + } + + get avatarTitle(): string { + return this.roomName; + } +} + + diff --git a/src/domain/session/toast/ToastCollectionViewModel.ts b/src/domain/session/toast/ToastCollectionViewModel.ts new file mode 100644 index 00000000..df4da88f --- /dev/null +++ b/src/domain/session/toast/ToastCollectionViewModel.ts @@ -0,0 +1,96 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {CallToastNotificationViewModel} from "./CallToastNotificationViewModel"; +import {ObservableArray} from "../../../observable"; +import {ViewModel, Options as BaseOptions} from "../../ViewModel"; +import type {GroupCall} from "../../../matrix/calls/group/GroupCall"; +import type {Room} from "../../../matrix/room/Room.js"; +import type {Session} from "../../../matrix/Session.js"; +import type {SegmentType} from "../../navigation"; +import { RoomStatus } from "../../../lib"; + +type Options = { + session: Session; +} & BaseOptions; + +export class ToastCollectionViewModel extends ViewModel { + public readonly toastViewModels: ObservableArray = new ObservableArray(); + + constructor(options: Options) { + super(options); + const session = this.getOption("session"); + if (this.features.calls) { + const callsObservableMap = session.callHandler.calls; + this.track(callsObservableMap.subscribe(this)); + } + } + + async onAdd(_, call: GroupCall) { + if (this._shouldShowNotification(call)) { + const room = await this._findRoomForCall(call); + const dismiss = () => { + const idx = this.toastViewModels.array.findIndex(vm => vm.call === call); + if (idx !== -1) { + this.toastViewModels.remove(idx); + } + }; + this.toastViewModels.append( + new CallToastNotificationViewModel(this.childOptions({ call, room, dismiss })) + ); + } + } + + onRemove(_, call: GroupCall) { + const idx = this.toastViewModels.array.findIndex(vm => vm.call === call); + if (idx !== -1) { + this.toastViewModels.remove(idx); + } + } + + onUpdate(_, call: GroupCall) { + const idx = this.toastViewModels.array.findIndex(vm => vm.call === call); + if (idx !== -1) { + this.toastViewModels.update(idx, this.toastViewModels.at(idx)!); + } + } + + onReset() { + for (let i = 0; i < this.toastViewModels.length; ++i) { + this.toastViewModels.remove(i); + } + } + + private async _findRoomForCall(call: GroupCall): Promise { + const id = call.roomId; + const session = this.getOption("session"); + const rooms = session.rooms; + // Make sure that we know of this room, + // otherwise wait for it to come through sync + const observable = await session.observeRoomStatus(id); + await observable.waitFor(s => s === RoomStatus.Joined).promise; + const room = rooms.get(id); + return room; + } + + private _shouldShowNotification(call: GroupCall): boolean { + const currentlyOpenedRoomId = this.navigation.path.get("room")?.value; + if (!call.isLoadedFromStorage && call.roomId !== currentlyOpenedRoomId && !call.usesFoci) { + return true; + } + return false; + } +} diff --git a/src/features.ts b/src/features.ts new file mode 100644 index 00000000..6fa5dd43 --- /dev/null +++ b/src/features.ts @@ -0,0 +1,55 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type {SettingsStorage} from "./platform/web/dom/SettingsStorage"; + +export enum FeatureFlag { + Calls = 1 << 0, + CrossSigning = 1 << 1 +} + +export class FeatureSet { + constructor(public readonly flags: number = 0) {} + + withFeature(flag: FeatureFlag): FeatureSet { + return new FeatureSet(this.flags | flag); + } + + withoutFeature(flag: FeatureFlag): FeatureSet { + return new FeatureSet(this.flags ^ flag); + } + + isFeatureEnabled(flag: FeatureFlag): boolean { + return (this.flags & flag) !== 0; + } + + get calls(): boolean { + return this.isFeatureEnabled(FeatureFlag.Calls); + } + + get crossSigning(): boolean { + return this.isFeatureEnabled(FeatureFlag.CrossSigning); + } + + static async load(settingsStorage: SettingsStorage): Promise { + const flags = await settingsStorage.getInt("enabled_features") || 0; + return new FeatureSet(flags); + } + + async store(settingsStorage: SettingsStorage): Promise { + await settingsStorage.setInt("enabled_features", this.flags); + } +} diff --git a/src/lib.ts b/src/lib.ts index cbb943e0..a6f609f4 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -14,9 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ +export {Logger} from "./logging/Logger"; +export type {ILogItem} from "./logging/types"; +export {IDBLogPersister} from "./logging/IDBLogPersister"; +export {ConsoleReporter} from "./logging/ConsoleReporter"; export {Platform} from "./platform/web/Platform.js"; export {Client, LoadStatus} from "./matrix/Client.js"; export {RoomStatus} from "./matrix/room/common"; +// export everything needed to observe state events on all rooms using session.observeRoomState +export type {RoomStateHandler} from "./matrix/room/state/types"; +export type {MemberChange} from "./matrix/room/members/RoomMember"; +export type {Transaction} from "./matrix/storage/idb/Transaction"; +export type {Room} from "./matrix/room/Room"; +export type {StateEvent} from "./matrix/storage/types"; export {PowerLevels} from "./matrix/room/PowerLevels.js"; // export main view & view models export {createNavigation, createRouter} from "./domain/navigation/index"; @@ -72,6 +82,7 @@ export {AvatarView} from "./platform/web/ui/AvatarView.js"; export {RoomType} from "./matrix/room/common"; export {EventEmitter} from "./utils/EventEmitter"; export {Disposables} from "./utils/Disposables"; +export {LocalMedia} from "./matrix/calls/LocalMedia"; // these should eventually be moved to another library export { ObservableArray, @@ -79,9 +90,10 @@ export { MappedList, AsyncMappedList, ConcatList, -} from "./observable"; + ObservableMap +} from "./observable/index"; export { BaseObservableValue, ObservableValue, RetainedObservableValue -} from "./observable/ObservableValue"; +} from "./observable/value"; diff --git a/src/logging/ConsoleLogger.ts b/src/logging/ConsoleReporter.ts similarity index 75% rename from src/logging/ConsoleLogger.ts rename to src/logging/ConsoleReporter.ts index f48c72b2..328b4c23 100644 --- a/src/logging/ConsoleLogger.ts +++ b/src/logging/ConsoleReporter.ts @@ -13,17 +13,28 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import {BaseLogger} from "./BaseLogger"; -import {LogItem} from "./LogItem"; -import type {ILogItem, LogItemValues, ILogExport} from "./types"; -export class ConsoleLogger extends BaseLogger { - _persistItem(item: LogItem): void { - printToConsole(item); +import type {ILogger, ILogItem, LogItemValues, ILogReporter} from "./types"; +import type {LogItem} from "./LogItem"; + +export class ConsoleReporter implements ILogReporter { + private logger?: ILogger; + + reportItem(item: ILogItem): void { + printToConsole(item as LogItem); } - async export(): Promise { - return undefined; + setLogger(logger: ILogger) { + this.logger = logger; + } + + printOpenItems(): void { + if (!this.logger) { + return; + } + for (const item of this.logger.getOpenRootItems()) { + this.reportItem(item); + } } } @@ -39,7 +50,7 @@ function filterValues(values: LogItemValues): LogItemValues | null { } function printToConsole(item: LogItem): void { - const label = `${itemCaption(item)} (${item.duration}ms)`; + const label = `${itemCaption(item)} (@${item.start}ms, duration: ${item.duration}ms)`; const filteredValues = filterValues(item.values); const shouldGroup = item.children || filteredValues; if (shouldGroup) { @@ -78,6 +89,8 @@ function itemCaption(item: ILogItem): string { return `${item.values.l} ${item.values.id}`; } else if (item.values.l && typeof item.values.status !== "undefined") { return `${item.values.l} (${item.values.status})`; + } else if (item.values.l && typeof item.values.type !== "undefined") { + return `${item.values.l} (${item.values.type})`; } else if (item.values.l && item.error) { return `${item.values.l} failed`; } else if (typeof item.values.ref !== "undefined") { diff --git a/src/logging/IDBLogger.ts b/src/logging/IDBLogPersister.ts similarity index 60% rename from src/logging/IDBLogger.ts rename to src/logging/IDBLogPersister.ts index ab9474b0..b2792506 100644 --- a/src/logging/IDBLogger.ts +++ b/src/logging/IDBLogPersister.ts @@ -22,36 +22,69 @@ import { iterateCursor, fetchResults, } from "../matrix/storage/idb/utils"; -import {BaseLogger} from "./BaseLogger"; import type {Interval} from "../platform/web/dom/Clock"; import type {Platform} from "../platform/web/Platform.js"; import type {BlobHandle} from "../platform/web/dom/BlobHandle.js"; -import type {ILogItem, ILogExport, ISerializedItem} from "./types"; -import type {LogFilter} from "./LogFilter"; +import type {ILogItem, ILogger, ILogReporter, ISerializedItem} from "./types"; +import {LogFilter} from "./LogFilter"; type QueuedItem = { json: string; id?: number; } -export class IDBLogger extends BaseLogger { - private readonly _name: string; - private readonly _limit: number; +type Options = { + name: string, + flushInterval?: number, + limit?: number, + platform: Platform, + serializedTransformer?: (item: ISerializedItem) => ISerializedItem +} + +export class IDBLogPersister implements ILogReporter { private readonly _flushInterval: Interval; private _queuedItems: QueuedItem[]; + private readonly options: Options; + private logger?: ILogger; - constructor(options: {name: string, flushInterval?: number, limit?: number, platform: Platform, serializedTransformer?: (item: ISerializedItem) => ISerializedItem}) { - super(options); - const {name, flushInterval = 60 * 1000, limit = 3000} = options; - this._name = name; - this._limit = limit; + constructor(options: Options) { + this.options = options; this._queuedItems = this._loadQueuedItems(); // TODO: also listen for unload just in case sync keeps on running after pagehide is fired? window.addEventListener("pagehide", this, false); - this._flushInterval = this._platform.clock.createInterval(() => this._tryFlush(), flushInterval); + this._flushInterval = this.options.platform.clock.createInterval( + () => this._tryFlush(), + this.options.flushInterval ?? 60 * 1000 + ); } - // TODO: move dispose to ILogger, listen to pagehide elsewhere and call dispose from there, which calls _finishAllAndFlush + setLogger(logger: ILogger): void { + this.logger = logger; + } + + reportItem(logItem: ILogItem, filter: LogFilter, forced: boolean): void { + const queuedItem = this.prepareItemForQueue(logItem, filter, forced); + if (queuedItem) { + this._queuedItems.push(queuedItem); + } + } + + async export(): Promise { + const db = await this._openDB(); + try { + const txn = db.transaction(["logs"], "readonly"); + const logs = txn.objectStore("logs"); + const storedItems: QueuedItem[] = await fetchResults(logs.openCursor(), () => false); + const openItems = this.getSerializedOpenItems(); + const allItems = storedItems.concat(this._queuedItems).concat(openItems); + return new IDBLogExport(allItems, this, this.options.platform); + } finally { + try { + db.close(); + } catch (e) {} + } + } + dispose(): void { window.removeEventListener("pagehide", this, false); this._flushInterval.dispose(); @@ -63,7 +96,7 @@ export class IDBLogger extends BaseLogger { } } - async _tryFlush(): Promise { + private async _tryFlush(): Promise { const db = await this._openDB(); try { const txn = db.transaction(["logs"], "readwrite"); @@ -73,9 +106,10 @@ export class IDBLogger extends BaseLogger { logs.add(i); } const itemCount = await reqAsPromise(logs.count()); - if (itemCount > this._limit) { + const limit = this.options.limit ?? 3000; + if (itemCount > limit) { // delete an extra 10% so we don't need to delete every time we flush - let deleteAmount = (itemCount - this._limit) + Math.round(0.1 * this._limit); + let deleteAmount = (itemCount - limit) + Math.round(0.1 * limit); await iterateCursor(logs.openCursor(), (_, __, cursor) => { cursor.delete(); deleteAmount -= 1; @@ -93,14 +127,16 @@ export class IDBLogger extends BaseLogger { } } - _finishAllAndFlush(): void { - this._finishOpenItems(); - this.log({l: "pagehide, closing logs", t: "navigation"}); + private _finishAllAndFlush(): void { + if (this.logger) { + this.logger.log({l: "pagehide, closing logs", t: "navigation"}); + this.logger.forceFinish(); + } this._persistQueuedItems(this._queuedItems); } - _loadQueuedItems(): QueuedItem[] { - const key = `${this._name}_queuedItems`; + private _loadQueuedItems(): QueuedItem[] { + const key = `${this.options.name}_queuedItems`; try { const json = window.localStorage.getItem(key); if (json) { @@ -113,44 +149,32 @@ export class IDBLogger extends BaseLogger { return []; } - _openDB(): Promise { - return openDatabase(this._name, db => db.createObjectStore("logs", {keyPath: "id", autoIncrement: true}), 1); + private _openDB(): Promise { + return openDatabase(this.options.name, db => db.createObjectStore("logs", {keyPath: "id", autoIncrement: true}), 1); } - - _persistItem(logItem: ILogItem, filter: LogFilter, forced: boolean): void { - const serializedItem = logItem.serialize(filter, undefined, forced); + + private prepareItemForQueue(logItem: ILogItem, filter: LogFilter, forced: boolean): QueuedItem | undefined { + let serializedItem = logItem.serialize(filter, undefined, forced); if (serializedItem) { - const transformedSerializedItem = this._serializedTransformer(serializedItem); - this._queuedItems.push({ - json: JSON.stringify(transformedSerializedItem) - }); + if (this.options.serializedTransformer) { + serializedItem = this.options.serializedTransformer(serializedItem); + } + return { + json: JSON.stringify(serializedItem) + }; } } - _persistQueuedItems(items: QueuedItem[]): void { + private _persistQueuedItems(items: QueuedItem[]): void { try { - window.localStorage.setItem(`${this._name}_queuedItems`, JSON.stringify(items)); + window.localStorage.setItem(`${this.options.name}_queuedItems`, JSON.stringify(items)); } catch (e) { console.error("Could not persist queued log items in localStorage, they will likely be lost", e); } } - async export(): Promise { - const db = await this._openDB(); - try { - const txn = db.transaction(["logs"], "readonly"); - const logs = txn.objectStore("logs"); - const storedItems: QueuedItem[] = await fetchResults(logs.openCursor(), () => false); - const allItems = storedItems.concat(this._queuedItems); - return new IDBLogExport(allItems, this, this._platform); - } finally { - try { - db.close(); - } catch (e) {} - } - } - - async _removeItems(items: QueuedItem[]): Promise { + /** @internal called by ILogExport.removeFromStore */ + async removeItems(items: QueuedItem[]): Promise { const db = await this._openDB(); try { const txn = db.transaction(["logs"], "readwrite"); @@ -173,14 +197,29 @@ export class IDBLogger extends BaseLogger { } catch (e) {} } } + + private getSerializedOpenItems(): QueuedItem[] { + const openItems: QueuedItem[] = []; + if (!this.logger) { + return openItems; + } + const filter = new LogFilter(); + for(const item of this.logger!.getOpenRootItems()) { + const openItem = this.prepareItemForQueue(item, filter, false); + if (openItem) { + openItems.push(openItem); + } + } + return openItems; + } } -class IDBLogExport implements ILogExport { +export class IDBLogExport { private readonly _items: QueuedItem[]; - private readonly _logger: IDBLogger; + private readonly _logger: IDBLogPersister; private readonly _platform: Platform; - constructor(items: QueuedItem[], logger: IDBLogger, platform: Platform) { + constructor(items: QueuedItem[], logger: IDBLogPersister, platform: Platform) { this._items = items; this._logger = logger; this._platform = platform; @@ -194,18 +233,24 @@ class IDBLogExport implements ILogExport { * @return {Promise} */ removeFromStore(): Promise { - return this._logger._removeItems(this._items); + return this._logger.removeItems(this._items); } asBlob(): BlobHandle { - const log = { - formatVersion: 1, - appVersion: this._platform.updateService?.version, - items: this._items.map(i => JSON.parse(i.json)) - }; - const json = JSON.stringify(log); + const json = this.toJSON(); const buffer: Uint8Array = this._platform.encoding.utf8.encode(json); const blob: BlobHandle = this._platform.createBlob(buffer, "application/json"); return blob; } + + toJSON(): string { + const log = { + formatVersion: 1, + appVersion: this._platform.updateService?.version, + platform: this._platform.description, + items: this._items.map(i => JSON.parse(i.json)) + }; + const json = JSON.stringify(log); + return json; + } } diff --git a/src/logging/LogItem.ts b/src/logging/LogItem.ts index b47b69c1..5aaabcc4 100644 --- a/src/logging/LogItem.ts +++ b/src/logging/LogItem.ts @@ -16,7 +16,7 @@ limitations under the License. */ import {LogLevel, LogFilter} from "./LogFilter"; -import type {BaseLogger} from "./BaseLogger"; +import type {Logger} from "./Logger"; import type {ISerializedItem, ILogItem, LogItemValues, LabelOrValues, FilterCreator, LogCallback} from "./types"; export class LogItem implements ILogItem { @@ -25,11 +25,11 @@ export class LogItem implements ILogItem { public error?: Error; public end?: number; private _values: LogItemValues; - private _logger: BaseLogger; + protected _logger: Logger; private _filterCreator?: FilterCreator; private _children?: Array; - constructor(labelOrValues: LabelOrValues, logLevel: LogLevel, logger: BaseLogger, filterCreator?: FilterCreator) { + constructor(labelOrValues: LabelOrValues, logLevel: LogLevel, logger: Logger, filterCreator?: FilterCreator) { this._logger = logger; this.start = logger._now(); // (l)abel @@ -38,7 +38,7 @@ export class LogItem implements ILogItem { this._filterCreator = filterCreator; } - /** start a new root log item and run it detached mode, see BaseLogger.runDetached */ + /** start a new root log item and run it detached mode, see Logger.runDetached */ runDetached(labelOrValues: LabelOrValues, callback: LogCallback, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem { return this._logger.runDetached(labelOrValues, callback, logLevel, filterCreator); } @@ -221,6 +221,11 @@ export class LogItem implements ILogItem { } } + /** @internal */ + forceFinish(): void { + this.finish(); + } + // expose log level without needing import everywhere get level(): typeof LogLevel { return LogLevel; @@ -235,7 +240,7 @@ export class LogItem implements ILogItem { child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): LogItem { if (this.end) { - console.trace("log item is finished, additional logs will likely not be recorded"); + console.trace(`log item ${this.values.l} finished, additional log ${JSON.stringify(labelOrValues)} will likely not be recorded`); } if (!logLevel) { logLevel = this.logLevel || LogLevel.Info; @@ -248,7 +253,7 @@ export class LogItem implements ILogItem { return item; } - get logger(): BaseLogger { + get logger(): Logger { return this._logger; } diff --git a/src/logging/BaseLogger.ts b/src/logging/Logger.ts similarity index 70% rename from src/logging/BaseLogger.ts rename to src/logging/Logger.ts index e32b9f0f..d3aa1d6d 100644 --- a/src/logging/BaseLogger.ts +++ b/src/logging/Logger.ts @@ -17,23 +17,33 @@ limitations under the License. import {LogItem} from "./LogItem"; import {LogLevel, LogFilter} from "./LogFilter"; -import type {ILogger, ILogExport, FilterCreator, LabelOrValues, LogCallback, ILogItem, ISerializedItem} from "./types"; +import type {ILogger, ILogReporter, FilterCreator, LabelOrValues, LogCallback, ILogItem, ISerializedItem} from "./types"; import type {Platform} from "../platform/web/Platform.js"; -export abstract class BaseLogger implements ILogger { +export class Logger implements ILogger { protected _openItems: Set = new Set(); protected _platform: Platform; protected _serializedTransformer: (item: ISerializedItem) => ISerializedItem; + public readonly reporters: ILogReporter[] = []; - constructor({platform, serializedTransformer = (item: ISerializedItem) => item}) { + constructor({platform}) { this._platform = platform; - this._serializedTransformer = serializedTransformer; } - log(labelOrValues: LabelOrValues, logLevel: LogLevel = LogLevel.Info): void { + log(labelOrValues: LabelOrValues, logLevel: LogLevel = LogLevel.Info): ILogItem { const item = new LogItem(labelOrValues, logLevel, this); item.end = item.start; this._persistItem(item, undefined, false); + return item; + } + + /** Prefer `run()` or `log()` above this method; only use it if you have a long-running operation + * *without* a single call stack that should be logged into one sub-tree. + * You need to call `finish()` on the returned item or it will stay open until the app unloads. */ + child(labelOrValues: LabelOrValues, logLevel: LogLevel = LogLevel.Info, filterCreator?: FilterCreator): ILogItem { + const item = new DeferredPersistRootLogItem(labelOrValues, logLevel, this, filterCreator); + this._openItems.add(item); + return item; } /** if item is a log item, wrap the callback in a child of it, otherwise start a new root log item. */ @@ -70,10 +80,10 @@ export abstract class BaseLogger implements ILogger { return this._run(item, callback, logLevel, true, filterCreator); } - _run(item: LogItem, callback: LogCallback, logLevel: LogLevel, wantResult: true, filterCreator?: FilterCreator): T; + private _run(item: LogItem, callback: LogCallback, logLevel: LogLevel, wantResult: true, filterCreator?: FilterCreator): T; // we don't return if we don't throw, as we don't have anything to return when an error is caught but swallowed for the fire-and-forget case. - _run(item: LogItem, callback: LogCallback, logLevel: LogLevel, wantResult: false, filterCreator?: FilterCreator): void; - _run(item: LogItem, callback: LogCallback, logLevel: LogLevel, wantResult: boolean, filterCreator?: FilterCreator): T | void { + private _run(item: LogItem, callback: LogCallback, logLevel: LogLevel, wantResult: false, filterCreator?: FilterCreator): void; + private _run(item: LogItem, callback: LogCallback, logLevel: LogLevel, wantResult: boolean, filterCreator?: FilterCreator): T | void { this._openItems.add(item); const finishItem = () => { @@ -125,9 +135,18 @@ export abstract class BaseLogger implements ILogger { } } - _finishOpenItems() { + addReporter(reporter: ILogReporter): void { + reporter.setLogger(this); + this.reporters.push(reporter); + } + + getOpenRootItems(): Iterable { + return this._openItems; + } + + forceFinish() { for (const openItem of this._openItems) { - openItem.finish(); + openItem.forceFinish(); try { // for now, serialize with an all-permitting filter // as the createFilter function would get a distorted image anyway @@ -141,20 +160,43 @@ export abstract class BaseLogger implements ILogger { this._openItems.clear(); } - abstract _persistItem(item: LogItem, filter?: LogFilter, forced?: boolean): void; + /** @internal */ + _removeItemFromOpenList(item: LogItem): void { + this._openItems.delete(item); + } - abstract export(): Promise; + /** @internal */ + _persistItem(item: LogItem, filter?: LogFilter, forced?: boolean): void { + for (var i = 0; i < this.reporters.length; i += 1) { + this.reporters[i].reportItem(item, filter, forced); + } + } // expose log level without needing get level(): typeof LogLevel { return LogLevel; } + /** @internal */ _now(): number { return this._platform.clock.now(); } + /** @internal */ _createRefId(): number { return Math.round(this._platform.random() * Number.MAX_SAFE_INTEGER); } } + +class DeferredPersistRootLogItem extends LogItem { + finish() { + super.finish(); + (this._logger as Logger)._persistItem(this, undefined, false); + (this._logger as Logger)._removeItemFromOpenList(this); + } + + forceFinish() { + super.finish(); + /// no need to persist when force-finishing as _finishOpenItems above will do it + } +} diff --git a/src/logging/NullLogger.ts b/src/logging/NullLogger.ts index 21c3d349..83e5fc00 100644 --- a/src/logging/NullLogger.ts +++ b/src/logging/NullLogger.ts @@ -14,14 +14,32 @@ See the License for the specific language governing permissions and limitations under the License. */ import {LogLevel} from "./LogFilter"; -import type {ILogger, ILogExport, ILogItem, LabelOrValues, LogCallback, LogItemValues} from "./types"; +import type {ILogger, ILogItem, ILogReporter, LabelOrValues, LogCallback, LogItemValues} from "./types"; function noop (): void {} export class NullLogger implements ILogger { public readonly item: ILogItem = new NullLogItem(this); - log(): void {} + log(labelOrValues: LabelOrValues): ILogItem { + return this.item; + } + + addReporter() {} + + get reporters(): ReadonlyArray { + return []; + } + + getOpenRootItems(): Iterable { + return []; + } + + forceFinish(): void {} + + child(labelOrValues: LabelOrValues): ILogItem { + return this.item; + } run(_, callback: LogCallback): T { return callback(this.item); @@ -39,11 +57,7 @@ export class NullLogger implements ILogger { new Promise(r => r(callback(this.item))).then(noop, noop); return this.item; } - - async export(): Promise { - return undefined; - } - + get level(): typeof LogLevel { return LogLevel; } @@ -61,13 +75,19 @@ export class NullLogItem implements ILogItem { } wrap(_: LabelOrValues, callback: LogCallback): T { + return this.run(callback); + } + + run(callback: LogCallback): T { return callback(this); } - log(): ILogItem { + + log(labelOrValues: LabelOrValues): ILogItem { return this; } - set(): ILogItem { return this; } + + set(labelOrValues: LabelOrValues): ILogItem { return this; } runDetached(_: LabelOrValues, callback: LogCallback): ILogItem { new Promise(r => r(callback(this))).then(noop, noop); @@ -99,6 +119,7 @@ export class NullLogItem implements ILogItem { } finish(): void {} + forceFinish(): void {} serialize(): undefined { return undefined; diff --git a/src/logging/types.ts b/src/logging/types.ts index bf9861a5..54436423 100644 --- a/src/logging/types.ts +++ b/src/logging/types.ts @@ -16,7 +16,6 @@ limitations under the License. */ import {LogLevel, LogFilter} from "./LogFilter"; -import type {BaseLogger} from "./BaseLogger"; import type {BlobHandle} from "../platform/web/dom/BlobHandle.js"; export interface ISerializedItem { @@ -40,10 +39,13 @@ export interface ILogItem { readonly level: typeof LogLevel; readonly end?: number; readonly start?: number; - readonly values: LogItemValues; + readonly values: Readonly; wrap(labelOrValues: LabelOrValues, callback: LogCallback, logLevel?: LogLevel, filterCreator?: FilterCreator): T; + /*** This is sort of low-level, you probably want to use wrap. If you do use it, it should only be called once. */ + run(callback: LogCallback): T; log(labelOrValues: LabelOrValues, logLevel?: LogLevel): ILogItem; - set(key: string | object, value: unknown): ILogItem; + set(key: string, value: unknown): ILogItem; + set(key: object): ILogItem; runDetached(labelOrValues: LabelOrValues, callback: LogCallback, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem; wrapDetached(labelOrValues: LabelOrValues, callback: LogCallback, logLevel?: LogLevel, filterCreator?: FilterCreator): void; refDetached(logItem: ILogItem, logLevel?: LogLevel): void; @@ -51,22 +53,41 @@ export interface ILogItem { catch(err: Error): Error; serialize(filter: LogFilter, parentStartTime: number | undefined, forced: boolean): ISerializedItem | undefined; finish(): void; + forceFinish(): void; child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem; } +/* +extend both ILogger and ILogItem from this interface, but need to rename ILogger.run => wrap then. Or both to `span`? + +export interface ILogItemCreator { + child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem; + refDetached(logItem: ILogItem, logLevel?: LogLevel): void; + log(labelOrValues: LabelOrValues, logLevel?: LogLevel): ILogItem; + wrap(labelOrValues: LabelOrValues, callback: LogCallback, logLevel?: LogLevel, filterCreator?: FilterCreator): T; + get level(): typeof LogLevel; +} +*/ export interface ILogger { - log(labelOrValues: LabelOrValues, logLevel?: LogLevel): void; + log(labelOrValues: LabelOrValues, logLevel?: LogLevel): ILogItem; + child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem; wrapOrRun(item: ILogItem | undefined, labelOrValues: LabelOrValues, callback: LogCallback, logLevel?: LogLevel, filterCreator?: FilterCreator): T; runDetached(labelOrValues: LabelOrValues, callback: LogCallback, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem; run(labelOrValues: LabelOrValues, callback: LogCallback, logLevel?: LogLevel, filterCreator?: FilterCreator): T; - export(): Promise; get level(): typeof LogLevel; + getOpenRootItems(): Iterable; + addReporter(reporter: ILogReporter): void; + get reporters(): ReadonlyArray; + /** + * force-finishes any open items and passes them to the reporter, with the forced flag set. + * Good think to do when the page is being closed to not lose any logs. + **/ + forceFinish(): void; } -export interface ILogExport { - get count(): number; - removeFromStore(): Promise; - asBlob(): BlobHandle; +export interface ILogReporter { + setLogger(logger: ILogger): void; + reportItem(item: ILogItem, filter?: LogFilter, forced?: boolean): void; } export type LogItemValues = { diff --git a/src/matrix/Client.js b/src/matrix/Client.js index ce0c44d7..fabb489b 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -18,7 +18,7 @@ limitations under the License. import {createEnum} from "../utils/enum"; import {lookupHomeserver} from "./well-known.js"; import {AbortableOperation} from "../utils/AbortableOperation"; -import {ObservableValue} from "../observable/ObservableValue"; +import {ObservableValue} from "../observable/value"; import {HomeServerApi} from "./net/HomeServerApi"; import {Reconnector, ConnectionStatus} from "./net/Reconnector"; import {ExponentialRetryDelay} from "./net/ExponentialRetryDelay"; @@ -31,6 +31,7 @@ import {TokenLoginMethod} from "./login/TokenLoginMethod"; import {SSOLoginHelper} from "./login/SSOLoginHelper"; import {getDehydratedDevice} from "./e2ee/Dehydration.js"; import {Registration} from "./registration/Registration"; +import {FeatureSet} from "../features"; export const LoadStatus = createEnum( "NotLoading", @@ -53,7 +54,7 @@ export const LoginFailure = createEnum( ); export class Client { - constructor(platform) { + constructor(platform, features = new FeatureSet(0)) { this._platform = platform; this._sessionStartedByReconnector = false; this._status = new ObservableValue(LoadStatus.NotLoading); @@ -68,6 +69,7 @@ export class Client { this._olmPromise = platform.loadOlm(); this._workerPromise = platform.loadOlmWorker(); this._accountSetup = undefined; + this._features = features; } createNewSessionId() { @@ -278,6 +280,7 @@ export class Client { olmWorker, mediaRepository, platform: this._platform, + features: this._features }); await this._session.load(log); if (dehydratedDevice) { diff --git a/src/matrix/DeviceMessageHandler.js b/src/matrix/DeviceMessageHandler.js index 6ac5ac07..78b384ab 100644 --- a/src/matrix/DeviceMessageHandler.js +++ b/src/matrix/DeviceMessageHandler.js @@ -14,14 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {OLM_ALGORITHM} from "./e2ee/common.js"; +import {OLM_ALGORITHM} from "./e2ee/common"; import {countBy, groupBy} from "../utils/groupBy"; +import {LRUCache} from "../utils/LRUCache"; export class DeviceMessageHandler { - constructor({storage}) { + constructor({storage, callHandler}) { this._storage = storage; this._olmDecryption = null; this._megolmDecryption = null; + this._callHandler = callHandler; + this._senderDeviceCache = new LRUCache(10, di => di.curve25519Key); } enableEncryption({olmDecryption, megolmDecryption}) { @@ -49,6 +52,11 @@ export class DeviceMessageHandler { log.child("decrypt_error").catch(err); } const newRoomKeys = this._megolmDecryption.roomKeysFromDeviceMessages(olmDecryptChanges.results, log); + + // TODO: somehow include rooms that received a call to_device message in the sync state? + // or have updates flow through event emitter? + // well, we don't really need to update the room other then when a call starts or stops + // any changes within the call will be emitted on the call object? return new SyncPreparation(olmDecryptChanges, newRoomKeys); } } @@ -58,7 +66,40 @@ export class DeviceMessageHandler { // write olm changes prep.olmDecryptChanges.write(txn); const didWriteValues = await Promise.all(prep.newRoomKeys.map(key => this._megolmDecryption.writeRoomKey(key, txn))); - return didWriteValues.some(didWrite => !!didWrite); + const hasNewRoomKeys = didWriteValues.some(didWrite => !!didWrite); + return { + hasNewRoomKeys, + decryptionResults: prep.olmDecryptChanges.results + }; + } + + async afterSyncCompleted(decryptionResults, deviceTracker, hsApi, log) { + if (this._callHandler) { + // if we don't have a device, we need to fetch the device keys the message claims + // and check the keys, and we should only do network requests during + // sync processing in the afterSyncCompleted step. + const callMessages = decryptionResults.filter(dr => this._callHandler.handlesDeviceMessageEventType(dr.event?.type)); + if (callMessages.length) { + await log.wrap("process call signalling messages", async log => { + for (const dr of callMessages) { + // serialize device loading, so subsequent messages for the same device take advantage of the cache + const device = await deviceTracker.deviceForId(dr.event.sender, dr.event.content.device_id, hsApi, log); + dr.setDevice(device); + if (dr.isVerified) { + this._callHandler.handleDeviceMessage(dr.event, dr.userId, dr.deviceId, log); + } else { + log.log({ + l: "could not verify olm fingerprint key matches, ignoring", + ed25519Key: dr.device.ed25519Key, + claimedEd25519Key: dr.claimedEd25519Key, + deviceId: device.deviceId, + userId: device.userId, + }); + } + } + }); + } + } } } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 048ddbc8..4acdd56a 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -31,10 +31,11 @@ import {Encryption as OlmEncryption} from "./e2ee/olm/Encryption"; import {Decryption as MegOlmDecryption} from "./e2ee/megolm/Decryption"; import {KeyLoader as MegOlmKeyLoader} from "./e2ee/megolm/decryption/KeyLoader"; import {KeyBackup} from "./e2ee/megolm/keybackup/KeyBackup"; +import {CrossSigning} from "./verification/CrossSigning"; import {Encryption as MegOlmEncryption} from "./e2ee/megolm/Encryption.js"; -import {MEGOLM_ALGORITHM} from "./e2ee/common.js"; +import {MEGOLM_ALGORITHM} from "./e2ee/common"; import {RoomEncryption} from "./e2ee/RoomEncryption.js"; -import {DeviceTracker} from "./e2ee/DeviceTracker.js"; +import {DeviceTracker} from "./e2ee/DeviceTracker"; import {LockMap} from "../utils/LockMap"; import {groupBy} from "../utils/groupBy"; import { @@ -45,18 +46,21 @@ import { keyFromDehydratedDeviceKey as createSSSSKeyFromDehydratedDeviceKey } from "./ssss/index"; import {SecretStorage} from "./ssss/SecretStorage"; -import {ObservableValue, RetainedObservableValue} from "../observable/ObservableValue"; +import {ObservableValue, RetainedObservableValue} from "../observable/value"; +import {CallHandler} from "./calls/CallHandler"; +import {RoomStateHandlerSet} from "./room/state/RoomStateHandlerSet"; const PICKLE_KEY = "DEFAULT_KEY"; const PUSHER_KEY = "pusher"; export class Session { // sessionInfo contains deviceId, userId and homeserver - constructor({storage, hsApi, sessionInfo, olm, olmWorker, platform, mediaRepository}) { + constructor({storage, hsApi, sessionInfo, olm, olmWorker, platform, mediaRepository, features}) { this._platform = platform; this._storage = storage; this._hsApi = hsApi; this._mediaRepository = mediaRepository; + this._features = features; this._syncInfo = null; this._sessionInfo = sessionInfo; this._rooms = new ObservableMap(); @@ -73,7 +77,8 @@ export class Session { }; this._roomsBeingCreated = new ObservableMap(); this._user = new User(sessionInfo.userId); - this._deviceMessageHandler = new DeviceMessageHandler({storage}); + this._roomStateHandler = new RoomStateHandlerSet(); + this._deviceMessageHandler = new DeviceMessageHandler({storage, callHandler: this._callHandler}); this._olm = olm; this._olmUtil = null; this._e2eeAccount = null; @@ -85,6 +90,7 @@ export class Session { this._getSyncToken = () => this.syncToken; this._olmWorker = olmWorker; this._keyBackup = new ObservableValue(undefined); + this._crossSigning = new ObservableValue(undefined); this._observedRoomStatus = new Map(); if (olm) { @@ -100,6 +106,10 @@ export class Session { this._createRoomEncryption = this._createRoomEncryption.bind(this); this._forgetArchivedRoom = this._forgetArchivedRoom.bind(this); this.needsKeyBackup = new ObservableValue(false); + + if (features.calls) { + this._setupCallHandler(); + } } get fingerprintKey() { @@ -118,6 +128,42 @@ export class Session { return this._sessionInfo.userId; } + get callHandler() { + return this._callHandler; + } + + _setupCallHandler() { + this._callHandler = new CallHandler({ + clock: this._platform.clock, + random: this._platform.random, + hsApi: this._hsApi, + encryptDeviceMessage: async (roomId, userId, deviceId, message, log) => { + if (!this._deviceTracker || !this._olmEncryption) { + log.set("encryption_disabled", true); + return; + } + const device = await log.wrap("get device key", async log => { + const device = this._deviceTracker.deviceForId(userId, deviceId, this._hsApi, log); + if (!device) { + log.set("not_found", true); + } + return device; + }); + if (device) { + const encryptedMessages = await this._olmEncryption.encrypt(message.type, message.content, [device], this._hsApi, log); + return encryptedMessages; + } + }, + storage: this._storage, + webRTC: this._platform.webRTC, + ownDeviceId: this._sessionInfo.deviceId, + ownUserId: this._sessionInfo.userId, + logger: this._platform.logger, + forceTURN: false, + }); + this.observeRoomState(this._callHandler); + } + // called once this._e2eeAccount is assigned _setupEncryption() { // TODO: this should all go in a wrapper in e2ee/ that is bootstrapped by passing in the account @@ -204,18 +250,18 @@ export class Session { } if (this._keyBackup.get()) { this._keyBackup.get().dispose(); - this._keyBackup.set(null); + this._keyBackup.set(undefined); + } + if (this._crossSigning.get()) { + this._crossSigning.set(undefined); } const key = await ssssKeyFromCredential(type, credential, this._storage, this._platform, this._olm); - // and create key backup, which needs to read from accountData - const readTxn = await this._storage.readTxn([ - this._storage.storeNames.accountData, - ]); - if (await this._createKeyBackup(key, readTxn, log)) { + if (await this._tryLoadSecretStorage(key, log)) { // only after having read a secret, write the key // as we only find out if it was good if the MAC verification succeeds await this._writeSSSSKey(key, log); - this._keyBackup.get().flush(log); + await this._keyBackup.get()?.start(log); + await this._crossSigning.get()?.start(log); return key; } else { throw new Error("Could not read key backup with the given key"); @@ -269,24 +315,36 @@ export class Session { } } this._keyBackup.get().dispose(); - this._keyBackup.set(null); + this._keyBackup.set(undefined); + } + if (this._crossSigning.get()) { + this._crossSigning.set(undefined); } } - _createKeyBackup(ssssKey, txn, log) { - return log.wrap("enable key backup", async log => { - try { - const secretStorage = new SecretStorage({key: ssssKey, platform: this._platform}); - const keyBackup = await KeyBackup.fromSecretStorage( - this._platform, - this._olm, - secretStorage, + _tryLoadSecretStorage(ssssKey, log) { + return log.wrap("enable secret storage", async log => { + const secretStorage = new SecretStorage({key: ssssKey, platform: this._platform, storage: this._storage}); + const isValid = await secretStorage.hasValidKeyForAnyAccountData(); + log.set("isValid", isValid); + if (isValid) { + await this._loadSecretStorageServices(secretStorage, log); + } + return isValid; + }); + } + + async _loadSecretStorageServices(secretStorage, log) { + try { + await log.wrap("enable key backup", async log => { + const keyBackup = new KeyBackup( this._hsApi, + this._olm, this._keyLoader, this._storage, - txn + this._platform, ); - if (keyBackup) { + if (await keyBackup.load(secretStorage, log)) { for (const room of this._rooms.values()) { if (room.isEncrypted) { room.enableKeyBackup(keyBackup); @@ -294,12 +352,31 @@ export class Session { } this._keyBackup.set(keyBackup); return true; + } else { + log.set("no_backup", true); } - } catch (err) { - log.catch(err); + }); + if (this._features.crossSigning) { + await log.wrap("enable cross-signing", async log => { + const crossSigning = new CrossSigning({ + storage: this._storage, + secretStorage, + platform: this._platform, + olm: this._olm, + olmUtil: this._olmUtil, + deviceTracker: this._deviceTracker, + hsApi: this._hsApi, + ownUserId: this.userId, + e2eeAccount: this._e2eeAccount + }); + if (await crossSigning.load(log)) { + this._crossSigning.set(crossSigning); + } + }); } - return false; - }); + } catch (err) { + log.catch(err); + } } /** @@ -311,6 +388,10 @@ export class Session { return this._keyBackup; } + get crossSigning() { + return this._crossSigning; + } + get hasIdentity() { return !!this._e2eeAccount; } @@ -399,6 +480,8 @@ export class Session { this._storage.storeNames.timelineEvents, this._storage.storeNames.timelineFragments, this._storage.storeNames.pendingEvents, + this._storage.storeNames.accountData, + this._storage.storeNames.crossSigningKeys, ]); // restore session object this._syncInfo = await txn.session.get("sync"); @@ -413,10 +496,8 @@ export class Session { olmWorker: this._olmWorker, txn }); - if (this._e2eeAccount) { - log.set("keys", this._e2eeAccount.identityKeys); - this._setupEncryption(); - } + log.set("keys", this._e2eeAccount.identityKeys); + this._setupEncryption(); } const pendingEventsByRoomId = await this._getPendingEventsByRoom(txn); // load invites @@ -441,6 +522,14 @@ export class Session { room.setInvite(invite); } } + if (this._olm && this._e2eeAccount) { + // try set up session backup and cross-signing if we stored the ssss key + const ssssKey = await ssssReadKey(txn); + if (ssssKey) { + // this will close the txn above, so we do it last + await this._tryLoadSecretStorage(ssssKey, log); + } + } } dispose() { @@ -452,6 +541,8 @@ export class Session { this._megolmDecryption = undefined; this._e2eeAccount?.dispose(); this._e2eeAccount = undefined; + this._callHandler?.dispose(); + this._callHandler = undefined; for (const room of this._rooms.values()) { room.dispose(); } @@ -474,35 +565,21 @@ export class Session { // TODO: what can we do if this throws? await txn.complete(); } - // enable session backup, this requests the latest backup version - if (!this._keyBackup.get()) { - if (dehydratedDevice) { - await log.wrap("SSSSKeyFromDehydratedDeviceKey", async log => { - const ssssKey = await createSSSSKeyFromDehydratedDeviceKey(dehydratedDevice.key, this._storage, this._platform); - if (ssssKey) { + // try if the key used to decrypt the dehydrated device also fits for secret storage + if (dehydratedDevice) { + await log.wrap("SSSSKeyFromDehydratedDeviceKey", async log => { + const ssssKey = await createSSSSKeyFromDehydratedDeviceKey(dehydratedDevice.key, this._storage, this._platform); + if (ssssKey) { + if (await this._tryLoadSecretStorage(ssssKey, log)) { log.set("success", true); await this._writeSSSSKey(ssssKey); } - }); - } - const txn = await this._storage.readTxn([ - this._storage.storeNames.session, - this._storage.storeNames.accountData, - ]); - // try set up session backup if we stored the ssss key - const ssssKey = await ssssReadKey(txn); - if (ssssKey) { - // txn will end here as this does a network request - if (await this._createKeyBackup(ssssKey, txn, log)) { - this._keyBackup.get()?.flush(log); } - } - if (!this._keyBackup.get()) { - // null means key backup isn't configured yet - // as opposed to undefined, which means we're still checking - this._keyBackup.set(null); - } + }); } + await this._keyBackup.get()?.start(log); + await this._crossSigning.get()?.start(log); + // restore unfinished operations, like sending out room keys const opsTxn = await this._storage.readWriteTxn([ this._storage.storeNames.operations @@ -562,7 +639,8 @@ export class Session { pendingEvents, user: this._user, createRoomEncryption: this._createRoomEncryption, - platform: this._platform + platform: this._platform, + roomStateHandler: this._roomStateHandler }); } @@ -649,7 +727,9 @@ export class Session { async writeSync(syncResponse, syncFilterId, preparation, txn, log) { const changes = { syncInfo: null, - e2eeAccountChanges: null + e2eeAccountChanges: null, + hasNewRoomKeys: false, + deviceMessageDecryptionResults: null, }; const syncToken = syncResponse.next_batch; if (syncToken !== this.syncToken) { @@ -670,7 +750,9 @@ export class Session { } if (preparation) { - changes.hasNewRoomKeys = await log.wrap("deviceMsgs", log => this._deviceMessageHandler.writeSync(preparation, txn, log)); + const {hasNewRoomKeys, decryptionResults} = await log.wrap("deviceMsgs", log => this._deviceMessageHandler.writeSync(preparation, txn, log)); + changes.hasNewRoomKeys = hasNewRoomKeys; + changes.deviceMessageDecryptionResults = decryptionResults; } // store account data @@ -711,6 +793,9 @@ export class Session { if (changes.hasNewRoomKeys) { this._keyBackup.get()?.flush(log); } + if (changes.deviceMessageDecryptionResults) { + await this._deviceMessageHandler.afterSyncCompleted(changes.deviceMessageDecryptionResults, this._deviceTracker, this._hsApi, log); + } } _tryReplaceRoomBeingCreated(roomId, log) { @@ -730,7 +815,7 @@ export class Session { } } - applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates, log) { + async applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates, log) { // update the collections after sync for (const rs of roomStates) { if (rs.shouldAdd) { @@ -894,16 +979,28 @@ export class Session { async observeRoomStatus(roomId) { let observable = this._observedRoomStatus.get(roomId); if (!observable) { - const status = await this.getRoomStatus(roomId); + let status = undefined; + // Create and set the observable with value = undefined, so that + // we don't loose any sync changes that come in while we are busy + // calculating the current room status. observable = new RetainedObservableValue(status, () => { this._observedRoomStatus.delete(roomId); }); - this._observedRoomStatus.set(roomId, observable); + status = await this.getRoomStatus(roomId); + // If observable.value is not undefined anymore, then some + // change has come through the sync. + if (observable.get() === undefined) { + observable.set(status); + } } return observable; } + observeRoomState(roomStateHandler) { + return this._roomStateHandler.subscribe(roomStateHandler); + } + /** Creates an empty (summary isn't loaded) the archived room if it isn't loaded already, assuming sync will either remove it (when rejoining) or @@ -950,6 +1047,7 @@ export class Session { } } +import {FeatureSet} from "../features"; export function tests() { function createStorageMock(session, pendingEvents = []) { return { @@ -983,9 +1081,19 @@ export function tests() { return { "session data is not modified until after sync": async (assert) => { - const session = new Session({storage: createStorageMock({ + const storage = createStorageMock({ sync: {token: "a", filterId: 5} - }), sessionInfo: {userId: ""}}); + }); + const session = new Session({ + storage, + sessionInfo: {userId: ""}, + platform: { + clock: { + createTimeout: () => undefined + } + }, + features: new FeatureSet(0) + }); await session.load(); let syncSet = false; const syncTxn = { diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 4c414149..4fb48713 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ObservableValue} from "../observable/ObservableValue"; +import {ObservableValue} from "../observable/value"; import {createEnum} from "../utils/enum"; const INCREMENTAL_TIMEOUT = 30000; @@ -218,6 +218,7 @@ export class Sync { _openPrepareSyncTxn() { const storeNames = this._storage.storeNames; return this._storage.readTxn([ + storeNames.deviceKeys, // to read device from olm messages storeNames.olmSessions, storeNames.inboundGroupSessions, // to read fragments when loading sync writer when rejoining archived room @@ -328,7 +329,7 @@ export class Sync { storeNames.pendingEvents, storeNames.userIdentities, storeNames.groupSessionDecryptions, - storeNames.deviceIdentities, + storeNames.deviceKeys, // to discard outbound session when somebody leaves a room // and to create room key messages when somebody joins storeNames.outboundGroupSessions, @@ -337,6 +338,7 @@ export class Sync { // to decrypt and store new room keys storeNames.olmSessions, storeNames.inboundGroupSessions, + storeNames.calls, ]); } diff --git a/src/matrix/calls/CallHandler.ts b/src/matrix/calls/CallHandler.ts new file mode 100644 index 00000000..b4a1c5cf --- /dev/null +++ b/src/matrix/calls/CallHandler.ts @@ -0,0 +1,289 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {ObservableMap} from "../../observable/map"; +import {WebRTC, PeerConnection} from "../../platform/types/WebRTC"; +import {MediaDevices, Track} from "../../platform/types/MediaDevices"; +import {handlesEventType} from "./PeerCall"; +import {EventType, CallIntent, CallType} from "./callEventTypes"; +import {GroupCall} from "./group/GroupCall"; +import {makeId} from "../common"; +import {CALL_LOG_TYPE} from "./common"; +import {EVENT_TYPE as MEMBER_EVENT_TYPE, RoomMember} from "../room/members/RoomMember"; +import {TurnServerSource} from "./TurnServerSource"; + +import type {LocalMedia} from "./LocalMedia"; +import type {Room} from "../room/Room"; +import type {MemberChange} from "../room/members/RoomMember"; +import type {StateEvent} from "../storage/types"; +import type {ILogItem, ILogger} from "../../logging/types"; +import type {Platform} from "../../platform/web/Platform"; +import type {BaseObservableMap} from "../../observable/map"; +import type {SignallingMessage, MGroupCallBase} from "./callEventTypes"; +import type {Options as GroupCallOptions} from "./group/GroupCall"; +import type {Transaction} from "../storage/idb/Transaction"; +import type {CallEntry} from "../storage/idb/stores/CallStore"; +import type {Clock} from "../../platform/web/dom/Clock"; +import type {RoomStateHandler} from "../room/state/types"; +import type {MemberSync} from "../room/timeline/persistence/MemberWriter"; + +export type Options = Omit & { + clock: Clock +}; + +function getRoomMemberKey(roomId: string, userId: string): string { + return JSON.stringify(roomId)+`,`+JSON.stringify(userId); +} + +export class CallHandler implements RoomStateHandler { + // group calls by call id + private readonly _calls: ObservableMap = new ObservableMap(); + // map of `"roomId","userId"` to set of conf_id's they are in + private roomMemberToCallIds: Map> = new Map(); + private groupCallOptions: GroupCallOptions; + private sessionId = makeId("s"); + + constructor(private readonly options: Options) { + this.groupCallOptions = Object.assign({}, this.options, { + turnServerSource: new TurnServerSource(this.options.hsApi, this.options.clock), + emitUpdate: (groupCall, params) => this._calls.update(groupCall.id, params), + createTimeout: this.options.clock.createTimeout, + sessionId: this.sessionId + }); + } + + loadCalls(intent?: CallIntent, log?: ILogItem) { + return this.options.logger.wrapOrRun(log, "CallHandler.loadCalls", async log => { + if (!intent) { + intent = CallIntent.Ring; + } + log.set("intent", intent); + const txn = await this._getLoadTxn(); + const callEntries = await txn.calls.getByIntent(intent); + await this._loadCallEntries(callEntries, txn, log); + }); + } + + loadCallsForRoom(intent: CallIntent, roomId: string, log?: ILogItem) { + return this.options.logger.wrapOrRun(log, "CallHandler.loadCallsForRoom", async log => { + log.set("intent", intent); + log.set("roomId", roomId); + const txn = await this._getLoadTxn(); + const callEntries = await txn.calls.getByIntentAndRoom(intent, roomId); + await this._loadCallEntries(callEntries, txn, log); + }); + } + + private async _getLoadTxn(): Promise { + const names = this.options.storage.storeNames; + const txn = await this.options.storage.readTxn([ + names.calls, + names.roomState, + ]); + return txn; + } + + private async _loadCallEntries(callEntries: CallEntry[], txn: Transaction, log: ILogItem): Promise { + log.set("entries", callEntries.length); + await Promise.all(callEntries.map(async callEntry => { + if (this._calls.get(callEntry.callId)) { + return; + } + const event = await txn.roomState.get(callEntry.roomId, EventType.GroupCall, callEntry.callId); + if (event) { + const call = new GroupCall( + event.event.state_key, // id + true, // isLoadedFromStorage + false, // newCall + callEntry.timestamp, // startTime + event.event.content, // callContent + event.roomId, // roomId + this.groupCallOptions // options + ); + this._calls.set(call.id, call); + } + })); + const roomIds = Array.from(new Set(callEntries.map(e => e.roomId))); + await Promise.all(roomIds.map(async roomId => { + // TODO: don't load all members until we need them + const callsMemberEvents = await txn.roomState.getAllForType(roomId, EventType.GroupCallMember); + await Promise.all(callsMemberEvents.map(async entry => { + const userId = entry.event.sender; + const roomMemberState = await txn.roomState.get(roomId, MEMBER_EVENT_TYPE, userId); + let roomMember; + if (roomMemberState) { + roomMember = RoomMember.fromMemberEvent(roomMemberState.event); + } + if (!roomMember) { + // we'll be missing the member here if we received a call and it's members + // as pre-gap state and the members weren't active in the timeline we got. + roomMember = RoomMember.fromUserId(roomId, userId, "join"); + } + this.handleCallMemberEvent(entry.event, roomMember, roomId, log); + })); + })); + log.set("newSize", this._calls.size); + } + + createCall(roomId: string, type: CallType, name: string, intent?: CallIntent, log?: ILogItem): Promise { + return this.options.logger.wrapOrRun(log, "CallHandler.createCall", async log => { + if (!intent) { + intent = CallIntent.Ring; + } + const call = new GroupCall( + makeId("conf-"), // id + false, // isLoadedFromStorage + true, // newCall + undefined, // startTime + {"m.name": name, "m.intent": intent}, // callContent + roomId, // roomId + this.groupCallOptions // options + ); + this._calls.set(call.id, call); + + try { + await call.create(type, log); + // store call info so it will ring again when reopening the app + const txn = await this.options.storage.readWriteTxn([this.options.storage.storeNames.calls]); + txn.calls.add({ + intent: call.intent, + callId: call.id, + timestamp: this.options.clock.now(), + roomId: roomId + }); + await txn.complete(); + } catch (err) { + //if (err.name === "ConnectionError") { + // if we're offline, give up and remove the call again + this._calls.remove(call.id); + //} + throw err; + } + return call; + }); + } + + get calls(): BaseObservableMap { return this._calls; } + + // TODO: check and poll turn server credentials here + + /** @internal */ + async handleRoomState(room: Room, event: StateEvent, memberSync: MemberSync, txn: Transaction, log: ILogItem) { + if (event.type === EventType.GroupCall) { + this.handleCallEvent(event, room.id, txn, log); + } + if (event.type === EventType.GroupCallMember) { + let member = await memberSync.lookupMemberAtEvent(event.sender, event, txn); + if (!member) { + // we'll be missing the member here if we received a call and it's members + // as pre-gap state and the members weren't active in the timeline we got. + member = RoomMember.fromUserId(room.id, event.sender, "join"); + } + this.handleCallMemberEvent(event, member, room.id, log); + } + } + + /** @internal */ + updateRoomMembers(room: Room, memberChanges: Map) { + // TODO: also have map for roomId to calls, so we can easily update members + // we will also need this to get the call for a room + for (const call of this._calls.values()) { + if (call.roomId === room.id) { + call.updateRoomMembers(memberChanges); + } + } + } + + /** @internal */ + handlesDeviceMessageEventType(eventType: string): boolean { + return handlesEventType(eventType); + } + + /** @internal */ + handleDeviceMessage(message: SignallingMessage, userId: string, deviceId: string, log: ILogItem) { + // TODO: buffer messages for calls we haven't received the state event for yet? + const call = this._calls.get(message.content.conf_id); + call?.handleDeviceMessage(message, userId, deviceId, log); + } + + private handleCallEvent(event: StateEvent, roomId: string, txn: Transaction, log: ILogItem) { + const callId = event.state_key; + let call = this._calls.get(callId); + if (call) { + call.updateCallEvent(event, log); + if (call.isTerminated) { + call.disconnect(log); + this._calls.remove(call.id); + txn.calls.remove(call.intent, roomId, call.id); + } + } else if(!event.content["m.terminated"]) { + // We don't have this call already and it isn't terminated, so create the call: + call = new GroupCall( + event.state_key, // id + false, // isLoadedFromStorage + false, // newCall + event.origin_server_ts, // startTime + event.content, // callContent + roomId, // roomId + this.groupCallOptions // options + ); + this._calls.set(call.id, call); + txn.calls.add({ + intent: call.intent, + callId: call.id, + timestamp: event.origin_server_ts, + roomId: roomId + }); + } + } + + private handleCallMemberEvent(event: StateEvent, member: RoomMember, roomId: string, log: ILogItem) { + const userId = event.state_key; + const roomMemberKey = getRoomMemberKey(roomId, userId) + const calls = event.content["m.calls"] ?? []; + for (const call of calls) { + const callId = call["m.call_id"]; + const groupCall = this._calls.get(callId); + // TODO: also check the member when receiving the m.call event + groupCall?.updateMembership(userId, member, call, log); + }; + const newCallIdsMemberOf = new Set(calls.map(call => call["m.call_id"])); + let previousCallIdsMemberOf = this.roomMemberToCallIds.get(roomMemberKey); + + // remove user as member of any calls not present anymore + if (previousCallIdsMemberOf) { + for (const previousCallId of previousCallIdsMemberOf) { + if (!newCallIdsMemberOf.has(previousCallId)) { + const groupCall = this._calls.get(previousCallId); + groupCall?.removeMembership(userId, log); + } + } + } + if (newCallIdsMemberOf.size === 0) { + this.roomMemberToCallIds.delete(roomMemberKey); + } else { + this.roomMemberToCallIds.set(roomMemberKey, newCallIdsMemberOf); + } + } + + dispose() { + this.groupCallOptions.turnServerSource.dispose(); + for(const call of this._calls.values()) { + call.dispose(); + } + } +} + diff --git a/src/matrix/calls/LocalMedia.ts b/src/matrix/calls/LocalMedia.ts new file mode 100644 index 00000000..b7a14a9a --- /dev/null +++ b/src/matrix/calls/LocalMedia.ts @@ -0,0 +1,85 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {SDPStreamMetadataPurpose} from "./callEventTypes"; +import {Stream} from "../../platform/types/MediaDevices"; +import {SDPStreamMetadata} from "./callEventTypes"; +import {getStreamVideoTrack, getStreamAudioTrack} from "./common"; + +export class LocalMedia { + + constructor( + public readonly userMedia?: Stream, + public readonly screenShare?: Stream, + public readonly dataChannelOptions?: RTCDataChannelInit, + ) {} + + withUserMedia(stream: Stream) { + return new LocalMedia(stream, this.screenShare?.clone(), this.dataChannelOptions); + } + + withScreenShare(stream: Stream) { + return new LocalMedia(this.userMedia?.clone(), stream, this.dataChannelOptions); + } + + withDataChannel(options: RTCDataChannelInit): LocalMedia { + return new LocalMedia(this.userMedia?.clone(), this.screenShare?.clone(), options); + } + + /** + * Create an instance of LocalMedia without audio track (for user preview) + */ + asPreview(): LocalMedia { + const media = this.clone(); + const userMedia = media.userMedia; + if (userMedia && userMedia.getVideoTracks().length > 0) { + const audioTrack = getStreamAudioTrack(userMedia); + if (audioTrack) { + audioTrack.stop(); + userMedia.removeTrack(audioTrack); + } + } + return media; + } + + /** @internal */ + replaceClone(oldClone: LocalMedia | undefined, oldOriginal: LocalMedia | undefined): LocalMedia { + const cloneOrAdoptStream = (oldOriginalStream: Stream | undefined, oldCloneStream: Stream | undefined, newStream: Stream | undefined): Stream | undefined => { + let stream; + if (oldOriginalStream?.id === newStream?.id) { + return oldCloneStream; + } else { + return newStream?.clone(); + } + } + return new LocalMedia( + cloneOrAdoptStream(oldOriginal?.userMedia, oldClone?.userMedia, this.userMedia), + cloneOrAdoptStream(oldOriginal?.screenShare, oldClone?.screenShare, this.screenShare), + this.dataChannelOptions + ); + } + + /** @internal */ + clone(): LocalMedia { + return new LocalMedia(this.userMedia?.clone(),this.screenShare?.clone(), this.dataChannelOptions); + } + + dispose() { + getStreamAudioTrack(this.userMedia)?.stop(); + getStreamVideoTrack(this.userMedia)?.stop(); + getStreamVideoTrack(this.screenShare)?.stop(); + } +} diff --git a/src/matrix/calls/PeerCall.ts b/src/matrix/calls/PeerCall.ts new file mode 100644 index 00000000..d94c9c35 --- /dev/null +++ b/src/matrix/calls/PeerCall.ts @@ -0,0 +1,1218 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {ObservableMap} from "../../observable/map"; +import {BaseObservableValue} from "../../observable/value"; +import {recursivelyAssign} from "../../utils/recursivelyAssign"; +import {Disposables, Disposable, IDisposable} from "../../utils/Disposables"; +import {WebRTC, PeerConnection, Transceiver, TransceiverDirection, Sender, Receiver, PeerConnectionEventMap} from "../../platform/types/WebRTC"; +import {MediaDevices, Track, TrackKind, Stream, StreamTrackEvent} from "../../platform/types/MediaDevices"; +import {getStreamVideoTrack, getStreamAudioTrack, MuteSettings, mute} from "./common"; +import { + SDPStreamMetadataKey, + SDPStreamMetadataPurpose, + EventType, + CallErrorCode, +} from "./callEventTypes"; + +import type {Room} from "../room/Room"; +import type {StateEvent} from "../storage/types"; +import type {ILogItem} from "../../logging/types"; +import type {TimeoutCreator, Timeout} from "../../platform/types/types"; +import type {LocalMedia} from "./LocalMedia"; +import type { + MCallBase, + MCallInvite, + MCallNegotiate, + MCallAnswer, + MCallSDPStreamMetadataChanged, + MCallCandidates, + MCallHangupReject, + SDPStreamMetadata, + SignallingMessage +} from "./callEventTypes"; +import type { ErrorBoundary } from "../../utils/ErrorBoundary"; +import { AbortError } from "../../utils/error"; + +export type Options = { + webRTC: WebRTC, + forceTURN: boolean, + turnServer: BaseObservableValue, + createTimeout: TimeoutCreator, + emitUpdate: (peerCall: PeerCall, params: any, log: ILogItem) => void; + errorBoundary: ErrorBoundary; + sendSignallingMessage: (message: SignallingMessage, log: ILogItem) => Promise; +}; + +export enum IncomingMessageAction { + InviteGlare, + Handle, + Ignore +}; + +export class RemoteMedia { + constructor(public userMedia?: Stream | undefined, public screenShare?: Stream | undefined) {} +} + +// when sending, we need to encrypt message with olm. I think the flow of room => roomEncryption => olmEncryption as we already +// do for sharing keys will be best as that already deals with room tracking. +/** + * Does WebRTC signalling for a single PeerConnection, and deals with WebRTC wrappers from platform + * */ +/** Implements a call between two peers with the signalling state keeping, while still delegating the signalling message sending. Used by GroupCall.*/ +export class PeerCall implements IDisposable { + private readonly peerConnection: PeerConnection; + private _state = CallState.Fledgling; + private direction: CallDirection; + // we don't own localMedia and should hence not call dispose on it from here + private localMedia?: LocalMedia; + private localMuteSettings?: MuteSettings; + // TODO: this should go in member + // A queue for candidates waiting to go out. + // We try to amalgamate candidates into a single candidate message where + // possible + private candidateSendQueue: Array = []; + // If candidates arrive before we've picked an opponent (which, in particular, + // will happen if the opponent sends candidates eagerly before the user answers + // the call) we buffer them up here so we can then add the ones from the party we pick + private remoteCandidateBuffer? = new Map(); + + private remoteSDPStreamMetadata?: SDPStreamMetadata; + private responsePromiseChain?: Promise; + private opponentPartyId?: PartyId; + private hangupParty: CallParty; + private disposables = new Disposables(); + private statePromiseMap = new Map void, promise: Promise}>(); + private _remoteTrackToStreamId = new Map(); + private _remoteStreams = new Map(); + // perfect negotiation flags + private makingOffer: boolean = false; + private ignoreOffer: boolean = false; + + private sentEndOfCandidates: boolean = false; + private iceDisconnectedTimeout?: Timeout; + + private _dataChannel?: any; + private _hangupReason?: CallErrorCode; + private _remoteMedia: RemoteMedia; + private _remoteMuteSettings = new MuteSettings(); + private flushCandidatesLog?: ILogItem; + + constructor( + private callId: string, + private readonly options: Options, + private readonly logItem: ILogItem, + ) { + logItem.log({l: "create PeerCall", id: callId}); + this._remoteMedia = new RemoteMedia(); + this.peerConnection = options.webRTC.createPeerConnection( + this.options.forceTURN, + [this.options.turnServer.get()], + 0 + ); + // update turn servers when they change (see TurnServerSource) + this.disposables.track(this.options.turnServer.subscribe(turnServer => { + this.logItem.log({l: "updating turn server", turnServer}) + this.peerConnection.setConfiguration({iceServers: [turnServer]}); + })); + const listen = (type: K, listener: (ev: PeerConnectionEventMap[K]) => any, options?: boolean | EventListenerOptions): void => { + const newListener = (e) => { + this.options.errorBoundary.try(() => listener(e)); + }; + this.peerConnection.addEventListener(type, newListener); + const dispose = () => { + this.peerConnection.removeEventListener(type, newListener); + }; + this.disposables.track(dispose); + }; + + listen("iceconnectionstatechange", async () => { + const state = this.peerConnection.iceConnectionState; + await logItem.wrap({l: "onIceConnectionStateChange", status: state}, async log => { + await this.onIceConnectionStateChange(state, log); + }); + }); + listen("icecandidate", async (event) => { + await logItem.wrap("onLocalIceCandidate", async log => { + if (event.candidate) { + await this.handleLocalIceCandidate(event.candidate, log); + } + }); + }); + listen("icegatheringstatechange", async () => { + const state = this.peerConnection.iceGatheringState; + await logItem.wrap({l: "onIceGatheringStateChange", status: state}, async log => { + await this.handleIceGatheringState(state, log); + }); + }); + listen("track", event => { + logItem.wrap("onRemoteTrack", log => { + this.onRemoteTrack(event.track, event.streams, log); + }); + }); + listen("datachannel", event => { + logItem.wrap("onRemoteDataChannel", log => { + this._dataChannel = event.channel; + this.options.emitUpdate(this, undefined, log); + }); + }); + listen("negotiationneeded", () => { + const signalingState = this.peerConnection.signalingState; + const promiseCreator = () => { + return logItem.wrap({l: "onNegotiationNeeded", signalingState}, log => { + return this.handleNegotiation(log); + }); + }; + this.responsePromiseChain = this.responsePromiseChain?.then(promiseCreator) ?? promiseCreator(); + this.responsePromiseChain.catch((e) => + this.options.errorBoundary.reportError(e) + ); + }); + } + + get dataChannel(): any | undefined { return this._dataChannel; } + + get state(): CallState { return this._state; } + + get hangupReason(): CallErrorCode | undefined { return this._hangupReason; } + + get remoteMedia(): Readonly { + return this._remoteMedia; + } + + get remoteMuteSettings(): MuteSettings { + return this._remoteMuteSettings; + } + + call(localMedia: LocalMedia, localMuteSettings: MuteSettings, log: ILogItem): Promise { + return log.wrap("call", async log => { + if (this._state !== CallState.Fledgling) { + return; + } + log.set("signalingState", this.peerConnection.signalingState); + this.direction = CallDirection.Outbound; + this.setState(CallState.CreateOffer, log); + this.localMuteSettings = localMuteSettings; + await this.updateLocalMedia(localMedia, log); + if (this.localMedia?.dataChannelOptions) { + this._dataChannel = this.peerConnection.createDataChannel("channel", this.localMedia.dataChannelOptions); + } + // after adding the local tracks, and wait for handleNegotiation to be called, + // or invite glare where we give up our invite and answer instead + // TODO: we don't actually use this + await this.waitForState([CallState.InviteSent, CallState.CreateAnswer]); + }); + } + + answer(localMedia: LocalMedia, localMuteSettings: MuteSettings, log: ILogItem): Promise { + return log.wrap("answer", async log => { + if (this._state !== CallState.Ringing) { + return; + } + this.setState(CallState.CreateAnswer, log); + this.localMuteSettings = localMuteSettings; + await this.updateLocalMedia(localMedia, log); + let myAnswer: RTCSessionDescriptionInit; + try { + myAnswer = await this.peerConnection.createAnswer(); + } catch (err) { + await log.wrap(`Failed to create answer`, log => { + log.catch(err); + this.terminate(CallParty.Local, CallErrorCode.CreateAnswer, log); + }); + return; + } + + try { + await this.peerConnection.setLocalDescription(myAnswer); + this.setState(CallState.Connecting, log); + } catch (err) { + await log.wrap(`Error setting local description!`, log => { + log.catch(err); + this.terminate(CallParty.Local, CallErrorCode.SetLocalDescription, log); + }); + return; + } + // Allow a short time for initial candidates to be gathered + try { await this.delay(200); } + catch (err) { return; } + await this.sendAnswer(log); + }); + } + + setMedia(localMedia: LocalMedia, log: ILogItem): Promise { + return log.wrap("setMedia", async log => { + log.set("userMedia_audio", !!getStreamAudioTrack(localMedia.userMedia)); + log.set("userMedia_video", !!getStreamVideoTrack(localMedia.userMedia)); + log.set("screenShare_video", !!getStreamVideoTrack(localMedia.screenShare)); + log.set("datachannel", !!localMedia.dataChannelOptions); + await this.updateLocalMedia(localMedia, log); + const content: MCallSDPStreamMetadataChanged = { + call_id: this.callId, + version: 1, + [SDPStreamMetadataKey]: this.getSDPMetadata() + }; + await this.sendSignallingMessage({type: EventType.SDPStreamMetadataChangedPrefix, content}, log); + }); + } + + setMuted(localMuteSettings: MuteSettings, log: ILogItem): Promise { + return log.wrap("setMuted", async log => { + this.localMuteSettings = localMuteSettings; + log.set("cameraMuted", localMuteSettings.camera); + log.set("microphoneMuted", localMuteSettings.microphone); + + if (this.localMedia) { + mute(this.localMedia, localMuteSettings, log); + const content: MCallSDPStreamMetadataChanged = { + call_id: this.callId, + version: 1, + [SDPStreamMetadataKey]: this.getSDPMetadata() + }; + await this.sendSignallingMessage({type: EventType.SDPStreamMetadataChangedPrefix, content}, log); + } + }); + } + + hangup(errorCode: CallErrorCode, log: ILogItem): Promise { + return log.wrap("hangup", log => { + return this._hangup(errorCode, log); + }); + } + + private async _hangup(errorCode: CallErrorCode, log: ILogItem): Promise { + if (this._state === CallState.Ended || this._state === CallState.Ending) { + return; + } + this.setState(CallState.Ending, log); + await this.sendHangupWithCallId(this.callId, errorCode, log); + this.terminate(CallParty.Local, errorCode, log); + } + + getMessageAction(message: SignallingMessage): IncomingMessageAction { + const callIdMatches = this.callId === message.content.call_id; + if (message.type === EventType.Invite && !callIdMatches) { + return IncomingMessageAction.InviteGlare; + } if (callIdMatches) { + return IncomingMessageAction.Handle; + } else { + return IncomingMessageAction.Ignore; + } + } + + handleIncomingSignallingMessage(message: SignallingMessage, partyId: PartyId, log: ILogItem): ILogItem { + // return logItem item immediately so it can be referenced by the sync log + let logItem; + log.wrap({ + l: "receive signalling message", + type: message.type, + callId: message.content.call_id, + payload: message.content + }, async log => { + logItem = log; + if (this.getMessageAction(message) !== IncomingMessageAction.Handle) { + log.set("wrongCallId", true); + return; + } + switch (message.type) { + case EventType.Invite: + await this.handleFirstInvite(message.content, partyId, log); + break; + case EventType.Answer: + await this.handleAnswer(message.content, partyId, log); + break; + case EventType.Negotiate: + await this.onNegotiateReceived(message.content, log); + break; + case EventType.Candidates: + await this.handleRemoteIceCandidates(message.content, partyId, log); + break; + case EventType.SDPStreamMetadataChanged: + case EventType.SDPStreamMetadataChangedPrefix: + this.updateRemoteSDPStreamMetadata(message.content[SDPStreamMetadataKey], log); + break; + case EventType.Hangup: + // TODO: this is a bit hacky, double check its what we need + log.set("reason", message.content.reason); + this.terminate(CallParty.Remote, message.content.reason ?? CallErrorCode.UserHangup, log); + break; + default: + log.log(`Unknown event type for call: ${message.type}`); + break; + } + }); + return logItem; + } + + private sendHangupWithCallId(callId: string, reason: CallErrorCode | undefined, log: ILogItem): Promise { + const content = { + call_id: callId, + version: 1, + }; + // TODO: Don't send UserHangup reason to older clients + if (reason) { + content["reason"] = reason; + } + return this.sendSignallingMessage({ + type: EventType.Hangup, + content + }, log); + } + + // calls are serialized and deduplicated by responsePromiseChain + private async handleNegotiation(log: ILogItem): Promise { + this.makingOffer = true; + try { + try { + await this.peerConnection.setLocalDescription(); + } catch (err) { + log.log(`Error setting local description!`).catch(err); + this.terminate(CallParty.Local, CallErrorCode.SetLocalDescription, log); + return; + } + + if (this.peerConnection.iceGatheringState === 'gathering') { + // Allow a short time for initial candidates to be gathered + try { await this.delay(200); } + catch (err) { return; } + } + + if (this._state === CallState.Ended) { + return; + } + + const offer = this.peerConnection.localDescription!; + // Get rid of any candidates waiting to be sent: they'll be included in the local + // description we just got and will send in the offer. + log.set("includedCandidates", this.candidateSendQueue.length); + this.candidateSendQueue = []; + + // need to queue this + if (this._state === CallState.CreateOffer) { + const content = { + call_id: this.callId, + offer, + [SDPStreamMetadataKey]: this.getSDPMetadata(), + version: 1, + lifetime: CALL_TIMEOUT_MS + }; + await this.sendSignallingMessage({type: EventType.Invite, content}, log); + this.setState(CallState.InviteSent, log); + } else if (this._state === CallState.Connected || this._state === CallState.Connecting) { + const content = { + call_id: this.callId, + description: offer, + [SDPStreamMetadataKey]: this.getSDPMetadata(), + version: 1, + lifetime: CALL_TIMEOUT_MS + }; + await this.sendSignallingMessage({type: EventType.Negotiate, content}, log); + } + } finally { + this.makingOffer = false; + } + + this.sendCandidateQueue(log); + + if (this._state === CallState.InviteSent) { + const timeoutLog = this.logItem.child("invite timeout"); + log.refDetached(timeoutLog); + // don't await this, as it would block other negotationneeded events from being processed + // as they are processed serially + await timeoutLog.run(async log => { + try { await this.delay(CALL_TIMEOUT_MS); } + catch (err) { return; } // return when delay is cancelled by throwing an AbortError + // @ts-ignore TS doesn't take the await above into account to know that the state could have changed in between + if (this._state === CallState.InviteSent) { + await this._hangup(CallErrorCode.InviteTimeout, log); + } + }); + } + }; + + /** + * @returns {boolean} whether or not this call should be replaced + * */ + handleInviteGlare(message: SignallingMessage, partyId: PartyId, log: ILogItem): {shouldReplace: boolean, log?: ILogItem} { + if (message.type !== EventType.Invite) { + return {shouldReplace: false}; + } + + const {content} = message; + const newCallId = content.call_id; + const shouldReplace = this.callId! > newCallId; + + let logItem; + log.wrap("handling call glare", async log => { + logItem = log; + if (shouldReplace) { + log.log( + "Glare detected: answering incoming call " + newCallId + + " and canceling outgoing call " + ); + // TODO: How do we interrupt `call()`? well, perhaps we need to not just await InviteSent but also CreateAnswer? + if (this._state !== CallState.Fledgling && this._state !== CallState.CreateOffer) { + await this.sendHangupWithCallId(this.callId, CallErrorCode.Replaced, log); + } + // since this method isn't awaited, we dispose ourselves once we hung up + this.close(CallErrorCode.Replaced, log); + this.dispose(); + } else { + log.log( + "Glare detected: rejecting incoming call " + newCallId + + " and keeping outgoing call " + ); + await this.sendHangupWithCallId(newCallId, CallErrorCode.Replaced, log); + } + }); + + return {shouldReplace, log: logItem}; + } + + private handleHangupReceived(content: MCallHangupReject, log: ILogItem) { + // party ID must match (our chosen partner hanging up the call) or be undefined (we haven't chosen + // a partner yet but we're treating the hangup as a reject as per VoIP v0) + // if (this.state === CallState.Ringing) { + // default reason is user_hangup + this.terminate(CallParty.Remote, content.reason || CallErrorCode.UserHangup, log); + // } else { + // log.set("ignored", true); + // } + }; + + private async handleFirstInvite(content: MCallInvite, partyId: PartyId, log: ILogItem): Promise { + if (this._state !== CallState.Fledgling || this.opponentPartyId !== undefined) { + // TODO: hangup or ignore? + return; + } + await this.handleInvite(content, partyId, log); + } + + private async handleInvite(content: MCallInvite, partyId: PartyId, log: ILogItem): Promise { + + // we must set the party ID before await-ing on anything: the call event + // handler will start giving us more call events (eg. candidates) so if + // we haven't set the party ID, we'll ignore them. + this.opponentPartyId = partyId; + this.direction = CallDirection.Inbound; + + const sdpStreamMetadata = content[SDPStreamMetadataKey]; + if (sdpStreamMetadata) { + this.updateRemoteSDPStreamMetadata(sdpStreamMetadata, log); + } else { + log.log(`Call did not get any SDPStreamMetadata! Can not send/receive multiple streams`); + } + + try { + // Q: Why do we set the remote description before accepting the call? To start creating ICE candidates? + await this.peerConnection.setRemoteDescription(content.offer); + await this.addBufferedIceCandidates(log); + } catch (e) { + await log.wrap(`Call failed to set remote description`, async log => { + log.catch(e); + return this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, log); + }); + return; + } + + // According to previous comments in this file, firefox at some point did not + // add streams until media started arriving on them. Testing latest firefox + // (81 at time of writing), this is no longer a problem, so let's do it the correct way. + if (this.peerConnection.getReceivers().length === 0) { + await log.wrap(`Call no remote stream or no tracks after setting remote description!`, async log => { + return this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, log); + }); + return; + } + + this.setState(CallState.Ringing, log); + + try { await this.delay(content.lifetime ?? CALL_TIMEOUT_MS); } + catch (err) { return; } + // @ts-ignore TS doesn't take the await above into account to know that the state could have changed in between + if (this._state === CallState.Ringing) { + log.log(`Invite has expired. Hanging up.`); + this.hangupParty = CallParty.Remote; // effectively + this.setState(CallState.Ended, log); + //this.localMedia?.dispose(); + //this.localMedia = undefined; + if (this.peerConnection.signalingState != 'closed') { + this.peerConnection.close(); + } + } + } + + private async handleAnswer(content: MCallAnswer, partyId: PartyId, log: ILogItem): Promise { + if (this._state === CallState.Ended) { + log.log(`Ignoring answer because call has ended`); + return; + } + + if (this.opponentPartyId !== undefined) { + log.log(`Ignoring answer: we already have an answer/reject from ${this.opponentPartyId}`); + return; + } + + this.opponentPartyId = partyId; + await this.addBufferedIceCandidates(log); + + this.setState(CallState.Connecting, log); + + const sdpStreamMetadata = content[SDPStreamMetadataKey]; + if (sdpStreamMetadata) { + this.updateRemoteSDPStreamMetadata(sdpStreamMetadata, log); + } else { + log.log(`Did not get any SDPStreamMetadata! Can not send/receive multiple streams`); + } + + try { + await this.peerConnection.setRemoteDescription(content.answer); + } catch (e) { + await log.wrap(`Failed to set remote description`, log => { + log.catch(e); + this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, log); + }); + return; + } + } + + private async handleIceGatheringState(state: RTCIceGatheringState, log: ILogItem) { + if (state === 'complete' && !this.sentEndOfCandidates) { + // If we didn't get an empty-string candidate to signal the end of candidates, + // create one ourselves now gathering has finished. + // We cast because the interface lists all the properties as required but we + // only want to send 'candidate' + // XXX: We probably want to send either sdpMid or sdpMLineIndex, as it's not strictly + // correct to have a candidate that lacks both of these. We'd have to figure out what + // previous candidates had been sent with and copy them. + const c = { + candidate: '', + } as RTCIceCandidate; + await this.queueCandidate(c, log); + this.sentEndOfCandidates = true; + } + } + + private async handleLocalIceCandidate(candidate: RTCIceCandidate, log: ILogItem) { + log.set("sdpMid", candidate.sdpMid); + log.set("candidate", candidate.candidate); + + if (this._state === CallState.Ended) { + return; + } + // As with the offer, note we need to make a copy of this object, not + // pass the original: that broke in Chrome ~m43. + if (candidate.candidate !== '' || !this.sentEndOfCandidates) { + await this.queueCandidate(candidate, log); + if (candidate.candidate === '') { + this.sentEndOfCandidates = true; + } + } + } + + private async handleRemoteIceCandidates(content: MCallCandidates, partyId: PartyId, log: ILogItem) { + if (this.state === CallState.Ended) { + log.log("Ignoring remote ICE candidate because call has ended"); + return; + } + + const candidates = content.candidates; + if (!candidates) { + log.log(`Ignoring candidates event with no candidates!`); + return; + } + + const fromPartyId = content.version === 0 ? null : partyId || null; + + if (this.opponentPartyId === undefined) { + // we haven't picked an opponent yet so save the candidates + log.log(`Buffering ${candidates.length} candidates until we pick an opponent`); + const bufferedCandidates = this.remoteCandidateBuffer!.get(fromPartyId) || []; + bufferedCandidates.push(...candidates); + this.remoteCandidateBuffer!.set(fromPartyId, bufferedCandidates); + return; + } + + if (this.opponentPartyId !== partyId) { + log.log( + `Ignoring candidates from party ID ${partyId}: ` + + `we have chosen party ID ${this.opponentPartyId}` + ); + + return; + } + + await this.addIceCandidates(candidates, log); + } + + private async onNegotiateReceived(content: MCallNegotiate, log: ILogItem): Promise { + const description = content.description; + if (!description || !description.sdp || !description.type) { + log.log(`Ignoring invalid m.call.negotiate event`); + return; + } + // Politeness always follows the direction of the call: in a glare situation, + // we pick either the inbound or outbound call, so one side will always be + // inbound and one outbound + const polite = this.direction === CallDirection.Inbound; + + // Here we follow the perfect negotiation logic from + // https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Perfect_negotiation + const offerCollision = ( + (description.type === 'offer') && + (this.makingOffer || this.peerConnection.signalingState !== 'stable') + ); + + this.ignoreOffer = !polite && offerCollision; + if (this.ignoreOffer) { + log.log(`Ignoring colliding negotiate event because we're impolite`); + return; + } + + const sdpStreamMetadata = content[SDPStreamMetadataKey]; + if (sdpStreamMetadata) { + this.updateRemoteSDPStreamMetadata(sdpStreamMetadata, log); + } else { + log.log(`Received negotiation event without SDPStreamMetadata!`); + } + + try { + await this.peerConnection.setRemoteDescription(description); + if (description.type === 'offer') { + await this.peerConnection.setLocalDescription(); + const content = { + call_id: this.callId, + description: this.peerConnection.localDescription!, + [SDPStreamMetadataKey]: this.getSDPMetadata(), + version: 1, + lifetime: CALL_TIMEOUT_MS + }; + await this.sendSignallingMessage({type: EventType.Negotiate, content}, log); + } + } catch (err) { + log.log(`Failed to complete negotiation`).catch(err); + } + } + + private async sendAnswer(log: ILogItem): Promise { + const localDescription = this.peerConnection.localDescription!; + const answerContent: MCallAnswer = { + call_id: this.callId, + version: 1, + answer: { + sdp: localDescription.sdp, + type: localDescription.type, + }, + [SDPStreamMetadataKey]: this.getSDPMetadata(), + }; + + // We have just taken the local description from the peerConn which will + // contain all the local candidates added so far, so we can discard any candidates + // we had queued up because they'll be in the answer. + log.log(`Discarding ${ + this.candidateSendQueue.length} candidates that will be sent in answer`); + this.candidateSendQueue = []; + + try { + await this.sendSignallingMessage({type: EventType.Answer, content: answerContent}, log); + } catch (error) { + this.terminate(CallParty.Local, CallErrorCode.SendAnswer, log); + throw error; + } + + // error handler re-throws so this won't happen on error, but + // we don't want the same error handling on the candidate queue + this.sendCandidateQueue(log); + } + + private async queueCandidate(content: RTCIceCandidate, log: ILogItem): Promise { + // We partially de-trickle candidates by waiting for `delay` before sending them + // amalgamated, in order to avoid sending too many m.call.candidates events and hitting + // rate limits in Matrix. + // In practice, it'd be better to remove rate limits for m.call.* + + // N.B. this deliberately lets you queue and send blank candidates, which MSC2746 + // currently proposes as the way to indicate that candidate gathering is complete. + // This will hopefully be changed to an explicit rather than implicit notification + // shortly. + this.candidateSendQueue.push(content); + + // Don't send the ICE candidates yet if the call is in the ringing state + if (this._state === CallState.Ringing) return; + + + this.flushCandidatesLog = this.flushCandidatesLog ?? this.logItem.child("flush candidate queue"); + log.refDetached(this.flushCandidatesLog); + const {flushCandidatesLog} = this; + // MSC2746 recommends these values (can be quite long when calling because the + // callee will need a while to answer the call) + try { await this.delay(this.direction === CallDirection.Inbound ? 500 : 2000); } + catch (err) { return; } + this.sendCandidateQueue(flushCandidatesLog); + this.flushCandidatesLog = undefined; + } + + private async sendCandidateQueue(log: ILogItem): Promise { + if (this.candidateSendQueue.length === 0 || this._state === CallState.Ended) { + return; + } + const candidates = this.candidateSendQueue; + this.candidateSendQueue = []; + return log.wrap({l: "send candidates", size: candidates.length}, async log => { + try { + await this.sendSignallingMessage({ + type: EventType.Candidates, + content: { + call_id: this.callId, + version: 1, + candidates + }, + }, log); + // Try to send candidates again just in case we received more candidates while sending. + await this.sendCandidateQueue(log); + } catch (error) { + log.catch(error); + // don't retry this event: we'll send another one later as we might + // have more candidates by then. + // put all the candidates we failed to send back in the queue + + // TODO: terminate doesn't seem to vibe with the comment above? + this.terminate(CallParty.Local, CallErrorCode.SignallingFailed, log); + } + }); + } + + private updateRemoteSDPStreamMetadata(metadata: SDPStreamMetadata, log: ILogItem): void { + // this will accumulate all updates into one object, so we still have the old stream info when we change stream id + this.remoteSDPStreamMetadata = recursivelyAssign(this.remoteSDPStreamMetadata || {}, metadata, true); + this.updateRemoteMedia(log); + + } + + private async addBufferedIceCandidates(log: ILogItem): Promise { + if (this.remoteCandidateBuffer && this.opponentPartyId) { + const bufferedCandidates = this.remoteCandidateBuffer.get(this.opponentPartyId); + if (bufferedCandidates) { + log.log(`Adding ${ + bufferedCandidates.length} buffered candidates for opponent ${this.opponentPartyId}`); + await this.addIceCandidates(bufferedCandidates, log); + } + this.remoteCandidateBuffer = undefined; + } + } + + private async addIceCandidates(candidates: RTCIceCandidate[], log: ILogItem): Promise { + for (const candidate of candidates) { + let logItem; + if ( + (candidate.sdpMid === null || candidate.sdpMid === undefined) && + (candidate.sdpMLineIndex === null || candidate.sdpMLineIndex === undefined) + ) { + logItem = log.log(`Got remote end-of-ICE candidates`); + } + else { + logItem = log.log(`Adding remote ICE ${candidate.sdpMid} candidate: ${candidate.candidate}`); + } + try { + await this.peerConnection.addIceCandidate(candidate); + } catch (err) { + if (!this.ignoreOffer) { + logItem.catch(err); + } + } + } + } + + private onIceConnectionStateChange = async (state: RTCIceConnectionState, log: ILogItem): Promise => { + if (this._state === CallState.Ended) { + return; // because ICE can still complete as we're ending the call + } + let logStats = false; + // ideally we'd consider the call to be connected when we get media but + // chrome doesn't implement any of the 'onstarted' events yet + if (state == 'connected') { + this.iceDisconnectedTimeout?.abort(); + this.iceDisconnectedTimeout = undefined; + this.setState(CallState.Connected, log); + } else if (state == 'failed') { + logStats = true; + this.iceDisconnectedTimeout?.abort(); + this.iceDisconnectedTimeout = undefined; + await this._hangup(CallErrorCode.IceFailed, log); + } else if (state == 'disconnected') { + logStats = true; + this.iceDisconnectedTimeout = this.options.createTimeout(30 * 1000); + try { + await this.iceDisconnectedTimeout.elapsed() + await this._hangup(CallErrorCode.IceFailed, log); + } + catch (e){ + if (!(e instanceof AbortError)) { + throw e; + } + } + } + if (logStats) { + const stats = await this.peerConnection.getStats(); + const statsObj = {}; + stats.forEach((value, key) => { + statsObj[key] = value; + }); + log.set("peerConnectionStats", statsObj); + } + }; + + private setState(state: CallState, log: ILogItem): void { + if (state !== this._state) { + log.log({l: "change state", status: state, oldState: this._state}); + const oldState = this._state; + this._state = state; + let deferred = this.statePromiseMap.get(state); + if (deferred) { + deferred.resolve(); + this.statePromiseMap.delete(state); + } + this.options.emitUpdate(this, undefined, log); + } + } + + private waitForState(states: CallState[]): Promise { + // TODO: rework this, do we need to clean up the promises? + return Promise.race(states.map(state => { + let deferred = this.statePromiseMap.get(state); + if (!deferred) { + let resolve; + const promise = new Promise(r => { + resolve = r; + }); + deferred = {resolve, promise}; + this.statePromiseMap.set(state, deferred); + } + return deferred.promise; + })); + } + + private terminate(hangupParty: CallParty, hangupReason: CallErrorCode, log: ILogItem): void { + if (this._state === CallState.Ended) { + return; + } + + this.hangupParty = hangupParty; + this._hangupReason = hangupReason; + this.setState(CallState.Ended, log); + this.localMedia = undefined; + + // TODO: change signalingState to connectionState? + if (this.peerConnection && this.peerConnection.signalingState !== 'closed') { + this.peerConnection.close(); + } + } + + private getSDPMetadata(): SDPStreamMetadata { + const metadata = {}; + if (this.localMedia?.userMedia) { + const streamId = this.localMedia.userMedia.id; + metadata[streamId] = { + purpose: SDPStreamMetadataPurpose.Usermedia, + audio_muted: this.localMuteSettings?.microphone ?? false, + video_muted: this.localMuteSettings?.camera ?? false, + }; + } + if (this.localMedia?.screenShare) { + const streamId = this.localMedia.screenShare.id; + metadata[streamId] = { + purpose: SDPStreamMetadataPurpose.Screenshare + }; + } + return metadata; + } + + private findReceiverForStream(kind: TrackKind, streamId: string): Receiver | undefined { + return this.peerConnection.getReceivers().find(r => { + return r.track.kind === kind && this._remoteTrackToStreamId.get(r.track.id) === streamId; + }); + } + + private findTransceiverForTrack(track: Track): Transceiver | undefined { + return this.peerConnection.getTransceivers().find(t => { + return t.sender.track?.id === track.id; + }); + } + + + private onRemoteTrack(track: Track, streams: ReadonlyArray, log: ILogItem) { + log.set("kind", track.kind); + log.set("id", track.id); + log.set("streams", streams.map(s => s.id)); + if (streams.length === 0) { + log.log({l: `ignoring ${track.kind} streamless track`, id: track.id}); + return; + } + const stream = streams[0]; + this._remoteTrackToStreamId.set(track.id, stream.id); + if (!this._remoteStreams.has(stream.id)) { + const listener = (event: StreamTrackEvent): void => { + this.logItem.wrap({l: "removetrack", id: event.track.id}, log => { + const streamId = this._remoteTrackToStreamId.get(event.track.id); + if (streamId) { + this._remoteTrackToStreamId.delete(event.track.id); + const streamDetails = this._remoteStreams.get(streamId); + if (streamDetails && streamDetails.stream.getTracks().length === 0) { + this.disposables.disposeTracked(disposeListener); + this._remoteStreams.delete(stream.id); + this.updateRemoteMedia(log); + } + } + }); + }; + stream.addEventListener("removetrack", listener); + const disposeListener = () => { + stream.removeEventListener("removetrack", listener); + }; + this.disposables.track(disposeListener); + this._remoteStreams.set(stream.id, { + disposeListener, + stream + }); + } + this.updateRemoteMedia(log); + } + + private updateRemoteMedia(log: ILogItem): void { + log.wrap("reevaluating remote media", log => { + this._remoteMedia.userMedia = undefined; + this._remoteMedia.screenShare = undefined; + if (this.remoteSDPStreamMetadata) { + for (const streamDetails of this._remoteStreams.values()) { + const {stream} = streamDetails; + const metaData = this.remoteSDPStreamMetadata[stream.id]; + if (metaData) { + if (metaData.purpose === SDPStreamMetadataPurpose.Usermedia) { + this._remoteMedia.userMedia = stream; + const audioReceiver = this.findReceiverForStream(TrackKind.Audio, stream.id); + if (audioReceiver) { + audioReceiver.track.enabled = !metaData.audio_muted; + } + const videoReceiver = this.findReceiverForStream(TrackKind.Video, stream.id); + if (videoReceiver) { + videoReceiver.track.enabled = !metaData.video_muted; + } + this._remoteMuteSettings = new MuteSettings( + metaData.audio_muted ?? false, + metaData.video_muted ?? false, + !!audioReceiver?.track ?? false, + !!videoReceiver?.track ?? false + ); + log.log({ + l: "setting userMedia", + micMuted: this._remoteMuteSettings.microphone, + cameraMuted: this._remoteMuteSettings.camera + }); + } else if (metaData.purpose === SDPStreamMetadataPurpose.Screenshare) { + this._remoteMedia.screenShare = stream; + log.log("setting screenShare"); + } + } else { + log.log({l: "no metadata yet for stream, ignoring for now", id: stream.id}); + } + } + } + this.options.emitUpdate(this, undefined, log); + }); + } + + private updateLocalMedia(localMedia: LocalMedia, logItem: ILogItem): Promise { + return logItem.wrap("updateLocalMedia", async log => { + const senders = this.peerConnection.getSenders(); + const applyStream = async (oldStream: Stream | undefined, stream: Stream | undefined, streamPurpose: SDPStreamMetadataPurpose) => { + const applyTrack = async (oldTrack: Track | undefined, newTrack: Track | undefined) => { + const oldSender = senders.find(s => s.track === oldTrack); + const streamToKeep = (oldStream ?? stream)!; + if (streamToKeep !== stream) { + if (oldTrack) { + streamToKeep.removeTrack(oldTrack); + oldTrack.stop(); + } + if (newTrack) { + streamToKeep.addTrack(newTrack); + } + } + if (newTrack && oldSender) { + try { + await log.wrap(`attempting to replace ${streamPurpose} ${newTrack.kind} track`, log => { + return oldSender.replaceTrack(newTrack); + }); + // replaceTrack succeeded, nothing left to do + return; + } catch (err) {} + } + if(oldSender) { + log.wrap(`removing ${streamPurpose} ${oldSender.track!.kind} track`, log => { + this.peerConnection.removeTrack(oldSender); + }); + } + if (newTrack) { + log.wrap(`adding ${streamPurpose} ${newTrack.kind} track`, log => { + const newSender = this.peerConnection.addTrack(newTrack, streamToKeep); + this.options.webRTC.prepareSenderForPurpose(this.peerConnection, newSender, streamPurpose); + }); + } + } + if (!oldStream && !stream) { + return; + } + await applyTrack(getStreamAudioTrack(oldStream), getStreamAudioTrack(stream)); + await applyTrack(getStreamVideoTrack(oldStream), getStreamVideoTrack(stream)); + }; + + await applyStream(this.localMedia?.userMedia, localMedia?.userMedia, SDPStreamMetadataPurpose.Usermedia); + await applyStream(this.localMedia?.screenShare, localMedia?.screenShare, SDPStreamMetadataPurpose.Screenshare); + // we explicitly don't replace this.localMedia if already set + // as we need to keep the old stream so the stream id doesn't change + // instead we add and remove tracks in the stream in applyTrack + if (!this.localMedia) { + this.localMedia = localMedia; + } + // TODO: datachannel, but don't do it here as we don't want to do it from answer, rather in different method + }); + } + + private async delay(timeoutMs: number): Promise { + // Allow a short time for initial candidates to be gathered + const timeout = this.disposables.track(this.options.createTimeout(timeoutMs)); + try { + await timeout.elapsed(); + } finally { + this.disposables.untrack(timeout); + } + } + + private sendSignallingMessage(message: SignallingMessage, log: ILogItem) { + return log.wrap({l: "send", id: message.type}, async log => { + return this.options.sendSignallingMessage(message, log); + }); + } + + public dispose(): void { + this.disposables.dispose(); + this.iceDisconnectedTimeout?.abort(); + this.peerConnection.close(); + // replace emitUpdate in case any eventhandler in here is still attached + // we really don't want to trigger any code in Member after this + this.options.emitUpdate = (_, __, log) => { + log.log("emitting update from PeerCall after disposal", this.logItem.level.Error); + console.trace("emitting update from PeerCall after disposal"); + }; + } + + public close(reason: CallErrorCode | undefined, log: ILogItem): void { + if (reason === undefined) { + reason = CallErrorCode.UserHangup; + } + this.terminate(CallParty.Local, reason, log); + } +} + + + +//import { randomString } from '../randomstring'; + +// null is used as a special value meaning that the we're in a legacy 1:1 call +// without MSC2746 that doesn't provide an id which device sent the message. +type PartyId = string | null; + +export enum CallParty { + Local = 'local', + Remote = 'remote', +} + +export enum CallState { + Fledgling = 'fledgling', + CreateOffer = 'create_offer', + InviteSent = 'invite_sent', + CreateAnswer = 'create_answer', + Connecting = 'connecting', + Connected = 'connected', + Ringing = 'ringing', + Ending = 'ending', + Ended = 'ended', +} + +export enum CallDirection { + Inbound = 'inbound', + Outbound = 'outbound', +} + +/** + * The version field that we set in m.call.* events + */ +const VOIP_PROTO_VERSION = 1; + +/** The length of time a call can be ringing for. */ +const CALL_TIMEOUT_MS = 60000; + +//const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + +export class CallError extends Error { + code: string; + + constructor(code: CallErrorCode, msg: string, err: Error) { + // Still don't think there's any way to have proper nested errors + super(msg + ": " + err); + + this.code = code; + } +} + +export function handlesEventType(eventType: string): boolean { + return eventType === EventType.Invite || + eventType === EventType.Candidates || + eventType === EventType.Answer || + eventType === EventType.Hangup || + eventType === EventType.SDPStreamMetadataChanged || + eventType === EventType.SDPStreamMetadataChangedPrefix || + eventType === EventType.Negotiate; +} + +function enableSenderOnTransceiver(transceiver: Transceiver, enabled: boolean) { + return enableTransceiver(transceiver, enabled, "sendonly", "recvonly"); +} + +function enableTransceiver(transceiver: Transceiver, enabled: boolean, exclusiveValue: TransceiverDirection, excludedValue: TransceiverDirection) { + if (enabled) { + if (transceiver.direction === "inactive") { + transceiver.direction = exclusiveValue; + } else { + transceiver.direction = "sendrecv"; + } + } else { + if (transceiver.direction === "sendrecv") { + transceiver.direction = excludedValue; + } else { + transceiver.direction = "inactive"; + } + } +} + +/** + * tests to write: + * + * upgradeCall: adding a track with setMedia calls the correct methods on the peerConnection + * upgradeCall: removing a track with setMedia calls the correct methods on the peerConnection + * upgradeCall: replacing compatible track with setMedia calls the correct methods on the peerConnection + * upgradeCall: replacing incompatible track (sender.replaceTrack throws) with setMedia calls the correct methods on the peerConnection + * + * */ diff --git a/src/matrix/calls/TODO.md b/src/matrix/calls/TODO.md new file mode 100644 index 00000000..eb9f1f25 --- /dev/null +++ b/src/matrix/calls/TODO.md @@ -0,0 +1,225 @@ + - relevant MSCs next to spec: + - https://github.com/matrix-org/matrix-doc/pull/2746 Improved Signalling for 1:1 VoIP + - https://github.com/matrix-org/matrix-doc/pull/2747 Transferring VoIP Calls + - https://github.com/matrix-org/matrix-doc/pull/3077 Support for multi-stream VoIP + - https://github.com/matrix-org/matrix-doc/pull/3086 Asserted identity on VoIP calls + - https://github.com/matrix-org/matrix-doc/pull/3291 Muting in VoIP calls + - https://github.com/matrix-org/matrix-doc/pull/3401 Native Group VoIP Signalling + + +## TODO + - DONE: implement receiving hangup + - DONE: implement cloning the localMedia so it works in safari? + - DONE: implement 3 retries per peer + - DONE: implement muting tracks with m.call.sdp_stream_metadata_changed + - DONE: implement renegotiation + - DONE: finish session id support + - call peers are essentially identified by (userid, deviceid, sessionid). If see a new session id, we first disconnect from the current member so we're ready to connect with a clean slate again (in a member event, also in to_device? no harm I suppose, given olm encryption ensures you can't spoof the deviceid). + - DONE: making logging better + - figure out why sometimes leave button does not work + - get correct members and avatars in call + - improve UI while in a call + - allow toggling audio + - support active speaker, sort speakers by last active + - close muted media stream after a while + - support highlight mode where we show active speaker and thumbnails for other participants + - better grid mode: + - we report the call view size to the view model with ResizeObserver, we calculate the A/R + - we calculate the grid based on view A/R, taking into account minimal stream size + - show name on stream view + - when you start a call, or join one, first you go to a SelectCallMedia screen where you can pick whether you want to use camera, audio or both: + - if you are joining a call, we'll default to the call intent + - if you are creating a call, we'll default to video + - when creating a call, adjust the navigation path to room/room_id/call + - when selecting a call, adjust the navigation path to room/room_id/call/call_id + - implement to_device messages arriving before m.call(.member) state event + - DONE for m.call.member, not for m.call and not for to_device other than m.call.invite arriving before invite + - reeable crypto & implement fetching olm keys before sending encrypted signalling message + - local echo for join/leave buttons? + - batch outgoing to_device messages in one request to homeserver for operations that will send out an event to all participants (e.g. mute) + - implement call ringing and rejecting a ringing call + - support screen sharing + - add button to enable, disable + - support showing stream view with large screen share video element and small camera video element (if present) + - don't load all members when loading calls to know whether they are ringing and joined by ourself + - only load our own member once, then have a way to load additional members on a call. + - see if we remove partyId entirely, it is only used for detecting remote echo which is not an issue for group calls? see https://github.com/matrix-org/matrix-spec-proposals/blob/dbkr/msc2746/proposals/2746-reliable-voip.md#add-party_id-to-all-voip-events + - remove PeerCall.waitForState ? + - invite glare is completely untested, does it work? + - how to remove call from m.call.member when just closing client? + - when closing client and still in call, tell service worker to send event on our behalf? + ```js + // dispose when leaving call + this.track(platform.registerExitHandler(unloadActions => { + // batch requests will resolve immediately, + // so we can reuse the same send code that does awaits without awaiting? + const batch = new RequestBatch(); + const hsApi = this.hsApi.withBatch(batch); + // _leaveCallMemberContent will need to become sync, + // so we'll need to keep track of own member event rather than rely on storage + hsApi.sendStateEvent("m.call.member", this._leaveCallMemberContent()); + // does this internally: serviceWorkerHandler.trySend("sendRequestBatch", batch.toJSON()); + unloadActions.sendRequestBatch(batch); + })); + ``` +## TODO (old) + - DONE: PeerCall + - send invite + - implement terminate + - implement waitForState + + - find out if we need to do something different when renegotation is triggered (a subsequent onnegotiationneeded event) whether + we sent the invite/offer or answer. e.g. do we always do createOffer/setLocalDescription and then send it over a matrix negotiation event? even if we before called createAnswer. + - handle receiving offer and send anwser + - handle sending ice candidates + - handle ice candidates finished (iceGatheringState === 'complete') + - handle receiving ice candidates + - handle sending renegotiation + - handle receiving renegotiation + - reject call + - hangup call + - handle muting tracks + - handle remote track being muted + - handle adding/removing tracks to an ongoing call + - handle sdp metadata + - DONE: Participant + - handle glare + - encrypt to_device message with olm + - batch outgoing to_device messages in one request to homeserver for operations that will send out an event to all participants (e.g. mute) + - find out if we should start muted or not? + +## Store ongoing calls + +DONE: Add store with all ongoing calls so when we quit and start again, we don't have to go through all the past calls to know which ones might still be ongoing. + + +## Notes + +we send m.call as state event in room + +we add m.call.participant for our own device + +we wait for other participants to add their user and device (in the sources) + +for each (userid, deviceid) + - if userId < ourUserId + - get local media + - we setup a peer connection + - add local tracks + - we wait for negotation event to get sdp + - peerConn.createOffer + - peerConn.setLocalDescription + - we send an m.call.invite + - else + - wait for invite from other side + +on local ice candidate: + - if we haven't ... sent invite yet? or received answer? buffer candidate + - otherwise send candidate (without buffering?) + +on incoming call: + - ring, offer to answer + +answering incoming call + - get local media + - peerConn.setRemoteDescription + - add local tracks to peerConn + - peerConn.createAnswer() + - peerConn.setLocalDescription + +in some cases, we will actually send the invite to all devices (e.g. SFU), so +we probably still need to handle multiple anwsers? + +so we would send an invite to multiple devices and pick the one for which we +received the anwser first. between invite and anwser, we could already receive +ice candidates that we need to buffer. + + + +updating the metadata: + +if we're renegotiating: use m.call.negotatie +if just muting: use m.call.sdp_stream_metadata_changed + + +party identification + - for 1:1 calls, we identify with a party_id + - for group calls, we identify with a device_id + + + + +## TODO + +Build basic version of PeerCall + - add candidates code +DONE: Build basic version of GroupCall + - DONE: add state, block invalid actions +DONE: Make it possible to olm encrypt the messages +Do work needed for state events + - DONEish: receiving (almost done?) + - DONEish: sending +logging +DONE: Expose call objects + expose volume events from audiotrack to group call +DONE: Write view model +DONE: write view + - handle glare edge-cases (not yet sent): https://spec.matrix.org/latest/client-server-api/#glare + +## Calls questions + - how do we handle glare between group calls (e.g. different state events with different call ids?) + - Split up DOM part into platform code? What abstractions to choose? + Does it make sense to come up with our own API very similar to DOM api? + - what code do we copy over vs what do we implement ourselves? + - MatrixCall: perhaps we can copy it over and modify it to our needs? Seems to have a lot of edge cases implemented. + - what is partyId about? + - CallFeed: I need better understand where it is used. It's basically a wrapper around a MediaStream with volume detection. Could it make sense to put this in platform for example? + + - which parts of MSC2746 are still relevant for group calls? + - which parts of MSC2747 are still relevant for group calls? it seems mostly orthogonal? + - SOLVED: how does switching channels work? This was only enabled by MSC 2746 + - you do getUserMedia()/getDisplayMedia() to get the stream(s) + - you call removeTrack/addTrack on the peerConnection + - you receive a negotiationneeded event + - you call createOffer + - you send m.call.negotiate + - SOLVED: wrt to MSC2746, is the screen share track and the audio track (and video track) part of the same stream? or do screen share tracks need to go in a different stream? it sounds incompatible with the MSC2746 requirement. + - SOLVED: how does muting work? MediaStreamTrack.enabled + - SOLVED: so, what's the difference between the call_id and the conf_id in group call events? + - call_id is the specific 1:1 call, conf_id is the thing in the m.call state event key + - so a group call has a conf_id with MxN peer calls, each having their call_id. + +I think we need to synchronize the negotiation needed because we don't use a CallState to guard it... + +## Thursday 3-3 notes + +we probably best keep the perfect negotiation flags, as they are needed for both starting the call AND renegotiation? if only for the former, it would make sense as it is a step in setting up the call, but if the call is ongoing, does it make sense to have a MakingOffer state? it actually looks like they are only needed for renegotiation! for call setup we compare the call_ids. What does that mean for these flags? + + +## Peer call state transitions + +FROM CALLER FROM CALLEE + +Fledgling Fledgling + V `call()` V `handleInvite()`: setRemoteDescription(event.offer), add buffered candidates + V Ringing + V V `answer()` +CreateOffer V + V add local tracks V + V wait for negotionneeded events V add local tracks + V setLocalDescription() CreateAnswer + V send invite event V setLocalDescription(createAnswer()) +InviteSent | + V receive anwser, setRemoteDescription() | + \___________________________________________________/ + V + Connecting + V receive ice candidates and iceConnectionState becomes 'connected' + Connected + V `hangup()` or some terminate condition + Ended + +so if we don't want to bother with having two call objects, we can make the existing call hangup his old call_id? That way we keep the old peerConnection. + + +when glare, won't we drop both calls? No: https://github.com/matrix-org/matrix-spec-proposals/pull/2746#discussion_r819388754 diff --git a/src/matrix/calls/TurnServerSource.ts b/src/matrix/calls/TurnServerSource.ts new file mode 100644 index 00000000..a9163349 --- /dev/null +++ b/src/matrix/calls/TurnServerSource.ts @@ -0,0 +1,222 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {RetainedObservableValue} from "../../observable/value"; + +import type {HomeServerApi} from "../net/HomeServerApi"; +import type {IHomeServerRequest} from "../net/HomeServerRequest"; +import type {BaseObservableValue, ObservableValue} from "../../observable/value"; +import type {Clock, Timeout} from "../../platform/web/dom/Clock"; +import type {ILogItem} from "../../logging/types"; + +type TurnServerSettings = { + uris: string[], + username: string, + password: string, + ttl: number +}; + +const DEFAULT_TTL = 5 * 60; // 5min +const DEFAULT_SETTINGS: RTCIceServer = { + urls: ["stun:turn.matrix.org"], + username: "", + credential: "", +}; + +export class TurnServerSource { + private currentObservable?: ObservableValue; + private pollTimeout?: Timeout; + private pollRequest?: IHomeServerRequest; + private isPolling = false; + + constructor( + private hsApi: HomeServerApi, + private clock: Clock, + private defaultSettings: RTCIceServer = DEFAULT_SETTINGS + ) {} + + getSettings(log: ILogItem): Promise> { + return log.wrap("get turn server", async log => { + if (!this.isPolling) { + const settings = await this.doRequest(log); + const iceServer = settings ? toIceServer(settings) : this.defaultSettings; + log.set("iceServer", iceServer); + if (this.currentObservable) { + this.currentObservable.set(iceServer); + } else { + this.currentObservable = new RetainedObservableValue(iceServer, + () => { + this.stopPollLoop(); + }, + () => { + // start loop on first subscribe + this.runLoop(settings?.ttl ?? DEFAULT_TTL); + }); + } + } + return this.currentObservable!; + }); + } + + private async runLoop(initialTtl: number): Promise { + let ttl = initialTtl; + this.isPolling = true; + while(this.isPolling) { + try { + this.pollTimeout = this.clock.createTimeout(ttl * 1000); + await this.pollTimeout.elapsed(); + this.pollTimeout = undefined; + const settings = await this.doRequest(undefined); + if (settings) { + const iceServer = toIceServer(settings); + if (shouldUpdate(this.currentObservable!, iceServer)) { + this.currentObservable!.set(iceServer); + } + if (settings.ttl > 0) { + ttl = settings.ttl; + } else { + // stop polling is settings are good indefinitely + this.stopPollLoop(); + } + } else { + ttl = DEFAULT_TTL; + } + } catch (err) { + if (err.name === "AbortError") { + /* ignore, the loop will exit because isPolling is false */ + } else { + // TODO: log error + } + } + } + } + + private async doRequest(log: ILogItem | undefined): Promise { + try { + this.pollRequest = this.hsApi.getTurnServer({log}); + const settings = await this.pollRequest.response(); + return settings; + } catch (err) { + if (err.name === "HomeServerError") { + return undefined; + } + throw err; + } finally { + this.pollRequest = undefined; + } + } + + private stopPollLoop() { + this.isPolling = false; + this.currentObservable = undefined; + this.pollTimeout?.dispose(); + this.pollTimeout = undefined; + this.pollRequest?.abort(); + this.pollRequest = undefined; + } + + dispose() { + this.stopPollLoop(); + } +} + +function shouldUpdate(observable: BaseObservableValue, settings: RTCIceServer): boolean { + const currentSettings = observable.get(); + if (!currentSettings) { + return true; + } + // same length and new settings doesn't contain any uri the old settings don't contain + const currentUrls = Array.isArray(currentSettings.urls) ? currentSettings.urls : [currentSettings.urls]; + const newUrls = Array.isArray(settings.urls) ? settings.urls : [settings.urls]; + const arraysEqual = currentUrls.length === newUrls.length && + !newUrls.some(uri => !currentUrls.includes(uri)); + return !arraysEqual || settings.username !== currentSettings.username || + settings.credential !== currentSettings.credential; +} + +function toIceServer(settings: TurnServerSettings): RTCIceServer { + return { + urls: settings.uris, + username: settings.username, + credential: settings.password, + credentialType: "password" + } +} + +export function tests() { + return { + "shouldUpdate returns false for same object": assert => { + const observable = {get() { + return { + urls: ["a", "b"], + username: "alice", + credential: "f00", + }; + }}; + const same = { + urls: ["a", "b"], + username: "alice", + credential: "f00", + }; + assert.equal(false, shouldUpdate(observable as any as BaseObservableValue, same)); + }, + "shouldUpdate returns true for 1 different uri": assert => { + const observable = {get() { + return { + urls: ["a", "c"], + username: "alice", + credential: "f00", + }; + }}; + const same = { + urls: ["a", "b"], + username: "alice", + credential: "f00", + }; + assert.equal(true, shouldUpdate(observable as any as BaseObservableValue, same)); + }, + "shouldUpdate returns true for different user": assert => { + const observable = {get() { + return { + urls: ["a", "b"], + username: "alice", + credential: "f00", + }; + }}; + const same = { + urls: ["a", "b"], + username: "bob", + credential: "f00", + }; + assert.equal(true, shouldUpdate(observable as any as BaseObservableValue, same)); + }, + "shouldUpdate returns true for different password": assert => { + const observable = {get() { + return { + urls: ["a", "b"], + username: "alice", + credential: "f00", + }; + }}; + const same = { + urls: ["a", "b"], + username: "alice", + credential: "b4r", + }; + assert.equal(true, shouldUpdate(observable as any as BaseObservableValue, same)); + } + } +} diff --git a/src/matrix/calls/callEventTypes.ts b/src/matrix/calls/callEventTypes.ts new file mode 100644 index 00000000..879d0c11 --- /dev/null +++ b/src/matrix/calls/callEventTypes.ts @@ -0,0 +1,240 @@ +// allow non-camelcase as these are events type that go onto the wire +/* eslint-disable camelcase */ +import type {StateEvent} from "../storage/types"; +import type {SessionDescription} from "../../platform/types/WebRTC"; +export enum EventType { + GroupCall = "org.matrix.msc3401.call", + GroupCallMember = "org.matrix.msc3401.call.member", + Invite = "m.call.invite", + Candidates = "m.call.candidates", + Answer = "m.call.answer", + Hangup = "m.call.hangup", + Reject = "m.call.reject", + SelectAnswer = "m.call.select_answer", + Negotiate = "m.call.negotiate", + SDPStreamMetadataChanged = "m.call.sdp_stream_metadata_changed", + SDPStreamMetadataChangedPrefix = "org.matrix.call.sdp_stream_metadata_changed", + Replaces = "m.call.replaces", + AssertedIdentity = "m.call.asserted_identity", + AssertedIdentityPrefix = "org.matrix.call.asserted_identity", +} + +// TODO: Change to "sdp_stream_metadata" when MSC3077 is merged +export const SDPStreamMetadataKey = "org.matrix.msc3077.sdp_stream_metadata"; + +export interface FocusConfig { + user_id: string, + device_id: string +} + +export interface CallDeviceMembership { + device_id: string, + session_id: string, + ["expires_ts"]?: number, + feeds?: Array<{purpose: string}> + ["m.foci.active"]?: Array +} + +export interface CallMembership { + ["m.call_id"]: string, + ["m.devices"]: CallDeviceMembership[] +} + +export interface CallMemberContent { + ["m.calls"]: CallMembership[]; +} + +export enum SDPStreamMetadataPurpose { + Usermedia = "m.usermedia", + Screenshare = "m.screenshare", +} + +export interface SDPStreamMetadataObject { + purpose: SDPStreamMetadataPurpose; + audio_muted: boolean; + video_muted: boolean; +} + +export interface SDPStreamMetadata { + [key: string]: SDPStreamMetadataObject; +} + +export interface CallCapabilities { + 'm.call.transferee': boolean; + 'm.call.dtmf': boolean; +} + +export interface CallReplacesTarget { + id: string; + display_name: string; + avatar_url: string; +} + +export type MCallBase = { + call_id: string; + version: string | number; +} + +export type MGroupCallBase = MCallBase & { + conf_id: string; + device_id: string; + sender_session_id: string; + dest_session_id: string; + party_id: string; // Should not need this? + seq: number; +} + +export type MCallAnswer = Base & { + answer: SessionDescription; + capabilities?: CallCapabilities; + [SDPStreamMetadataKey]: SDPStreamMetadata; +} + +export type MCallSelectAnswer = Base & { + selected_party_id: string; +} + +export type MCallInvite = Base & { + offer: SessionDescription; + lifetime: number; + [SDPStreamMetadataKey]: SDPStreamMetadata; +} + +export type MCallNegotiate = Base & { + description: SessionDescription; + lifetime: number; + [SDPStreamMetadataKey]: SDPStreamMetadata; +} + +export type MCallSDPStreamMetadataChanged = Base & { + [SDPStreamMetadataKey]: SDPStreamMetadata; +} + +export type MCallReplacesEvent = Base & { + replacement_id: string; + target_user: CallReplacesTarget; + create_call: string; + await_call: string; + target_room: string; +} + +export type MCAllAssertedIdentity = Base & { + asserted_identity: { + id: string; + display_name: string; + avatar_url: string; + }; +} + +export type MCallCandidates = Base & { + candidates: RTCIceCandidate[]; +} + +export type MCallHangupReject = Base & { + reason?: CallErrorCode; +} + +export enum CallErrorCode { + /** The user chose to end the call */ + UserHangup = 'user_hangup', + + /** An error code when the local client failed to create an offer. */ + LocalOfferFailed = 'local_offer_failed', + /** + * An error code when there is no local mic/camera to use. This may be because + * the hardware isn't plugged in, or the user has explicitly denied access. + */ + NoUserMedia = 'no_user_media', + + /** + * Error code used when a call event failed to send + * because unknown devices were present in the room + */ + UnknownDevices = 'unknown_devices', + + /** + * Error code used when we fail to send the invite + * for some reason other than there being unknown devices + */ + SendInvite = 'send_invite', + + /** + * An answer could not be created + */ + CreateAnswer = 'create_answer', + + /** + * Error code used when we fail to send the answer + * for some reason other than there being unknown devices + */ + SendAnswer = 'send_answer', + + /** + * The session description from the other side could not be set + */ + SetRemoteDescription = 'set_remote_description', + + /** + * The session description from this side could not be set + */ + SetLocalDescription = 'set_local_description', + + /** + * A different device answered the call + */ + AnsweredElsewhere = 'answered_elsewhere', + + /** + * No media connection could be established to the other party + */ + IceFailed = 'ice_failed', + + /** + * The invite timed out whilst waiting for an answer + */ + InviteTimeout = 'invite_timeout', + + /** + * The call was replaced by another call + */ + Replaced = 'replaced', + + /** + * Signalling for the call could not be sent (other than the initial invite) + */ + SignallingFailed = 'signalling_timeout', + + /** + * The remote party is busy + */ + UserBusy = 'user_busy', + + /** + * We transferred the call off to somewhere else + */ + Transfered = 'transferred', + + /** + * A call from the same user was found with a new session id + */ + NewSession = 'new_session', +} + +export type SignallingMessage = + {type: EventType.Invite, content: MCallInvite} | + {type: EventType.Negotiate, content: MCallNegotiate} | + {type: EventType.Answer, content: MCallAnswer} | + {type: EventType.SDPStreamMetadataChanged | EventType.SDPStreamMetadataChangedPrefix, content: MCallSDPStreamMetadataChanged} | + {type: EventType.Candidates, content: MCallCandidates} | + {type: EventType.Hangup | EventType.Reject, content: MCallHangupReject}; + +export enum CallIntent { + Ring = "m.ring", + Prompt = "m.prompt", + Room = "m.room", +}; + +export enum CallType { + Video = "m.video", + Voice = "m.voice", +} diff --git a/src/matrix/calls/common.ts b/src/matrix/calls/common.ts new file mode 100644 index 00000000..5036eae4 --- /dev/null +++ b/src/matrix/calls/common.ts @@ -0,0 +1,87 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {ILogItem} from "../../logging/types"; +import type {Track, Stream} from "../../platform/types/MediaDevices"; +import {LocalMedia} from "./LocalMedia"; + +export function getStreamAudioTrack(stream: Stream | undefined): Track | undefined { + return stream?.getAudioTracks()[0]; +} + +export function getStreamVideoTrack(stream: Stream | undefined): Track | undefined { + return stream?.getVideoTracks()[0]; +} + +export function mute(localMedia: LocalMedia, localMuteSettings: MuteSettings, log: ILogItem) { + return log.wrap("mute", log => { + log.set("cameraMuted", localMuteSettings.camera); + log.set("microphoneMuted", localMuteSettings.microphone); + + // Mute audio + const userMediaAudio = getStreamAudioTrack(localMedia.userMedia); + if (userMediaAudio) { + const enabled = !localMuteSettings.microphone; + log.set("microphone enabled", enabled); + userMediaAudio.enabled = enabled; + } + + // Mute video + const userMediaVideo = getStreamVideoTrack(localMedia.userMedia); + if (userMediaVideo) { + const enabled = !localMuteSettings.camera; + log.set("camera enabled", enabled); + userMediaVideo.enabled = enabled; + } + }); +} + +export class MuteSettings { + constructor ( + private readonly isMicrophoneMuted: boolean = false, + private readonly isCameraMuted: boolean = false, + private hasMicrophoneTrack: boolean = false, + private hasCameraTrack: boolean = false, + ) {} + + updateTrackInfo(userMedia: Stream | undefined) { + this.hasMicrophoneTrack = !!getStreamAudioTrack(userMedia); + this.hasCameraTrack = !!getStreamVideoTrack(userMedia); + } + + get microphone(): boolean { + return !this.hasMicrophoneTrack || this.isMicrophoneMuted; + } + + get camera(): boolean { + return !this.hasCameraTrack || this.isCameraMuted; + } + + toggleCamera(): MuteSettings { + return new MuteSettings(this.microphone, !this.camera, this.hasMicrophoneTrack, this.hasCameraTrack); + } + + toggleMicrophone(): MuteSettings { + return new MuteSettings(!this.microphone, this.camera, this.hasMicrophoneTrack, this.hasCameraTrack); + } + + equals(other: MuteSettings) { + return this.microphone === other.microphone && this.camera === other.camera; + } +} + +export const CALL_LOG_TYPE = "call"; +export const CALL_MEMBER_VALIDITY_PERIOD_MS = 3600 * 1000; // 1h diff --git a/src/matrix/calls/group/GroupCall.ts b/src/matrix/calls/group/GroupCall.ts new file mode 100644 index 00000000..a47a3ff2 --- /dev/null +++ b/src/matrix/calls/group/GroupCall.ts @@ -0,0 +1,702 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {ObservableMap} from "../../../observable/map"; +import {Member, isMemberExpired, memberExpiresAt} from "./Member"; +import {LocalMedia} from "../LocalMedia"; +import {MuteSettings, CALL_LOG_TYPE, CALL_MEMBER_VALIDITY_PERIOD_MS, mute} from "../common"; +import {MemberChange, RoomMember} from "../../room/members/RoomMember"; +import {EventEmitter} from "../../../utils/EventEmitter"; +import {EventType, CallIntent, CallType} from "../callEventTypes"; +import { ErrorBoundary } from "../../../utils/ErrorBoundary"; + +import type {Options as MemberOptions} from "./Member"; +import type {TurnServerSource} from "../TurnServerSource"; +import type {BaseObservableMap} from "../../../observable/map"; +import type {Track} from "../../../platform/types/MediaDevices"; +import type {SignallingMessage, MGroupCallBase, CallMembership, CallMemberContent, CallDeviceMembership} from "../callEventTypes"; +import type {Room} from "../../room/Room"; +import type {StateEvent} from "../../storage/types"; +import type {Platform} from "../../../platform/web/Platform"; +import type {EncryptedMessage} from "../../e2ee/olm/Encryption"; +import type {ILogItem, ILogger} from "../../../logging/types"; +import type {Storage} from "../../storage/idb/Storage"; +import type {BaseObservableValue} from "../../../observable/value"; +import type {Clock, Timeout} from "../../../platform/web/dom/Clock"; + +export enum GroupCallState { + Fledgling = "fledgling", + Creating = "creating", + Created = "created", + Joining = "joining", + Joined = "joined", +} + +function getMemberKey(userId: string, deviceId: string) { + return JSON.stringify(userId)+`,`+JSON.stringify(deviceId); +} + +function memberKeyIsForUser(key: string, userId: string) { + return key.startsWith(JSON.stringify(userId)+`,`); +} + +function getDeviceFromMemberKey(key: string): string { + return JSON.parse(`[${key}]`)[1]; +} + +export type Options = Omit & { + emitUpdate: (call: GroupCall, params?: any) => void; + encryptDeviceMessage: (roomId: string, userId: string, deviceId: string, message: SignallingMessage, log: ILogItem) => Promise, + storage: Storage, + random: () => number, + logger: ILogger, + turnServerSource: TurnServerSource +}; + +class JoinedData { + public renewMembershipTimeout?: Timeout; + + constructor( + public readonly logItem: ILogItem, + public readonly membersLogItem: ILogItem, + public localMedia: LocalMedia, + public localPreviewMedia: LocalMedia, + public localMuteSettings: MuteSettings, + public readonly turnServer: BaseObservableValue + ) {} + + dispose() { + this.localMedia.dispose(); + this.localPreviewMedia.dispose(); + this.logItem.finish(); + this.renewMembershipTimeout?.dispose(); + } +} + +export class GroupCall extends EventEmitter<{change: never}> { + private readonly _members: ObservableMap = new ObservableMap(); + private _memberOptions: MemberOptions; + private _state: GroupCallState; + private bufferedDeviceMessages = new Map>>(); + /** Set between calling join and leave. */ + private joinedData?: JoinedData; + private errorBoundary = new ErrorBoundary(err => { + this.emitChange(); + if (this.joinedData) { + // in case the error happens in code that does not log, + // log it here to make sure it isn't swallowed + this.joinedData.logItem.log("error at boundary").catch(err); + console.error(err); + } + }); + + constructor( + public readonly id: string, + public readonly isLoadedFromStorage: boolean, + newCall: boolean, + private startTime: number | undefined, + private callContent: Record, + public readonly roomId: string, + private readonly options: Options, + ) { + super(); + this._state = newCall ? GroupCallState.Fledgling : GroupCallState.Created; + this._memberOptions = Object.assign({}, options, { + confId: this.id, + emitUpdate: member => { + const memberKey = getMemberKey(member.userId, member.deviceId); + // only remove expired members to whom we're not already connected + if (member.isExpired && !member.isConnected) { + const logItem = this.options.logger.log({ + l: "removing expired member from call", + memberKey, + callId: this.id + }) + member.logItem?.refDetached(logItem); + member.dispose(); + this._members.remove(memberKey); + } else { + this._members.update(memberKey); + } + }, + encryptDeviceMessage: (userId: string, deviceId: string, message: SignallingMessage, log) => { + return this.options.encryptDeviceMessage(this.roomId, userId, deviceId, message, log); + } + }); + } + + get localMedia(): LocalMedia | undefined { return this.joinedData?.localMedia; } + get localPreviewMedia(): LocalMedia | undefined { return this.joinedData?.localPreviewMedia; } + get members(): BaseObservableMap { return this._members; } + + get isTerminated(): boolean { + return !!this.callContent?.["m.terminated"]; + } + + get usesFoci(): boolean { + for (const member of this._members.values()) { + if (member.usesFoci) { + return true; + } + } + return false; + } + + get duration(): number | undefined { + if (typeof this.startTime === "number") { + return (this.options.clock.now() - this.startTime); + } + } + + get isRinging(): boolean { + return this._state === GroupCallState.Created && this.intent === "m.ring" && !this.isMember(this.options.ownUserId); + } + + get name(): string { + return this.callContent?.["m.name"]; + } + + get intent(): CallIntent { + return this.callContent?.["m.intent"]; + } + + get type(): CallType { + return this.callContent?.["m.type"]; + } + + /** + * Gives access the log item for this call while joined. + * Can be used for call diagnostics while in the call. + **/ + get logItem(): ILogItem | undefined { + return this.joinedData?.logItem; + } + + get error(): Error | undefined { + return this.errorBoundary.error; + } + + join(localMedia: LocalMedia, log?: ILogItem): Promise { + return this.options.logger.wrapOrRun(log, "Call.join", async joinLog => { + if (this._state !== GroupCallState.Created || this.joinedData || this.usesFoci) { + localMedia.dispose(); + return; + } + const logItem = this.options.logger.child({ + l: "Call.connection", + t: CALL_LOG_TYPE, + id: this.id, + ownSessionId: this.options.sessionId + }); + const turnServer = await this.options.turnServerSource.getSettings(logItem); + const membersLogItem = logItem.child("member connections"); + const localMuteSettings = new MuteSettings(); + localMuteSettings.updateTrackInfo(localMedia.userMedia); + const localPreviewMedia = localMedia.asPreview(); + const joinedData = new JoinedData( + logItem, + membersLogItem, + localMedia, + localPreviewMedia, + localMuteSettings, + turnServer + ); + this.joinedData = joinedData; + await joinedData.logItem.wrap("join", async log => { + joinLog.refDetached(log); + this._state = GroupCallState.Joining; + this.emitChange(); + await log.wrap("update member state", async log => { + const memberContent = await this._createMemberPayload(true); + log.set("payload", memberContent); + // send m.call.member state event + const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCallMember, this.options.ownUserId, memberContent, {log}); + await request.response(); + this.emitChange(); + }); + // send invite to all members that are < my userId + for (const member of this._members.values()) { + this.connectToMember(member, joinedData, log); + } + }); + }); + } + + async setMedia(localMedia: LocalMedia): Promise { + if ((this._state === GroupCallState.Joining || this._state === GroupCallState.Joined) && this.joinedData) { + const oldMedia = this.joinedData.localMedia; + this.joinedData.localMedia = localMedia; + this.joinedData.localPreviewMedia?.dispose(); + this.joinedData.localPreviewMedia = localMedia.asPreview(); + // reflect the fact we gained or lost local tracks in the local mute settings + // and update the track info so PeerCall can use it to send up to date metadata, + this.joinedData.localMuteSettings.updateTrackInfo(localMedia.userMedia); + this.emitChange(); //allow listeners to see new media/mute settings + // TODO: if setMedia fails on one of the members, we should revert to the old media + // on the members processed so far, and show an error that we could not set the new media + // for this, we will need to remove the usage of the errorBoundary in member.setMedia. + await Promise.all(Array.from(this._members.values()).map(m => { + return m.setMedia(localMedia, oldMedia); + })); + oldMedia?.dispose(); + } + } + + async setMuted(muteSettings: MuteSettings): Promise { + const {joinedData} = this; + if (!joinedData) { + return; + } + const prevMuteSettings = joinedData.localMuteSettings; + // we still update the mute settings if nothing changed because + // you might be muted because you don't have a track or because + // you actively chosen to mute + // (which we want to respect in the future when you add a track) + muteSettings.updateTrackInfo(joinedData.localMedia.userMedia); + joinedData.localMuteSettings = muteSettings; + if (!prevMuteSettings.equals(muteSettings)) { + // Mute our copies of LocalMedias; + // otherwise the camera lights will still be on. + if (this.localPreviewMedia) { + mute(this.localPreviewMedia, muteSettings, this.joinedData!.logItem); + } + if (this.localMedia) { + mute(this.localMedia, muteSettings, this.joinedData!.logItem); + } + // TODO: if setMuted fails on one of the members, we should revert to the old media + // on the members processed so far, and show an error that we could not set the new media + // for this, we will need to remove the usage of the errorBoundary in member.setMuted. + await Promise.all(Array.from(this._members.values()).map(m => { + return m.setMuted(joinedData.localMuteSettings); + })); + this.emitChange(); + } + } + + get muteSettings(): MuteSettings | undefined { + return this.joinedData?.localMuteSettings; + } + + get hasJoined() { + return this._state === GroupCallState.Joining || this._state === GroupCallState.Joined; + } + + async leave(log?: ILogItem): Promise { + await this.options.logger.wrapOrRun(log, "Call.leave", async log => { + const {joinedData} = this; + if (!joinedData) { + return; + } + try { + joinedData.renewMembershipTimeout?.dispose(); + joinedData.renewMembershipTimeout = undefined; + const memberContent = await this._createMemberPayload(false); + // send m.call.member state event + if (memberContent) { + const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCallMember, this.options.ownUserId, memberContent, {log}); + await request.response(); + // our own user isn't included in members, so not in the count + if ((this.intent === CallIntent.Ring || this.intent === CallIntent.Prompt) && this._members.size === 0) { + await this.terminate(log); + } + } else { + log.set("already_left", true); + } + } finally { + // disconnect is called both from the sync loop and from methods like this one that + // are called from the view model. We want errors during the sync loop being caught + // by the errorboundary, but since leave is called from the view model, we want + // the error to be thrown. So here we check if disconnect succeeded, and if not + // we rethrow the error put into the errorBoundary. + if(!this.disconnect(log)) { + throw this.errorBoundary.error; + } + } + }); + } + + private terminate(log?: ILogItem): Promise { + return this.options.logger.wrapOrRun(log, {l: "terminate call", t: CALL_LOG_TYPE}, async log => { + if (this._state === GroupCallState.Fledgling) { + return; + } + const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCall, this.id, Object.assign({}, this.callContent, { + "m.terminated": true + }), {log}); + await request.response(); + }); + } + + /** @internal */ + create(type: CallType, log: ILogItem): Promise { + return log.wrap({l: "create call", t: CALL_LOG_TYPE}, async log => { + if (this._state !== GroupCallState.Fledgling) { + return; + } + this._state = GroupCallState.Creating; + this.emitChange(); + this.callContent = Object.assign({ + "m.type": type, + }, this.callContent); + const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCall, this.id, this.callContent!, {log}); + await request.response(); + this._state = GroupCallState.Created; + this.emitChange(); + }); + } + + /** @internal */ + updateCallEvent(event: StateEvent, syncLog: ILogItem) { + this.errorBoundary.try(() => { + syncLog.wrap({l: "update call", t: CALL_LOG_TYPE, id: this.id}, log => { + + if (typeof this.startTime !== "number") { + this.startTime = event.origin_server_ts; + } + this.callContent = event.content; + if (this._state === GroupCallState.Creating) { + this._state = GroupCallState.Created; + } + log.set("status", this._state); + this.emitChange(); + }); + }); + } + + /** @internal */ + updateRoomMembers(memberChanges: Map) { + this.errorBoundary.try(() => { + for (const change of memberChanges.values()) { + const {member} = change; + for (const callMember of this._members.values()) { + // find all call members for a room member (can be multiple, for every device) + if (callMember.userId === member.userId) { + callMember.updateRoomMember(member); + } + } + } + }); + } + + /** @internal */ + updateMembership(userId: string, roomMember: RoomMember, callMembership: CallMembership, syncLog: ILogItem) { + this.errorBoundary.try(async () => { + await syncLog.wrap({l: "update call membership", t: CALL_LOG_TYPE, id: this.id, userId}, async log => { + const now = this.options.clock.now(); + const devices = callMembership["m.devices"]; + const previousDeviceIds = this.getDeviceIdsForUserId(userId); + for (const device of devices) { + const deviceId = device.device_id; + const memberKey = getMemberKey(userId, deviceId); + if (userId === this.options.ownUserId && deviceId === this.options.ownDeviceId) { + log.wrap("update own membership", log => { + if (this.hasJoined) { + if (this.joinedData) { + this.joinedData.logItem.refDetached(log); + } + this._setupRenewMembershipTimeout(device, log); + } + if (this._state === GroupCallState.Joining) { + log.set("joined", true); + this._state = GroupCallState.Joined; + this.emitChange(); + } + }); + } else { + await log.wrap({l: "update device membership", id: memberKey, sessionId: device.session_id}, async log => { + if (isMemberExpired(device, now)) { + log.set("expired", true); + const member = this._members.get(memberKey); + if (member) { + member.dispose(); + this._members.remove(memberKey); + log.set("removed", true); + } + return; + } + let member = this._members.get(memberKey); + const sessionIdChanged = member && member.sessionId !== device.session_id; + if (member && !sessionIdChanged) { + log.set("update", true); + member.updateCallInfo(device, log); + } else { + if (member && sessionIdChanged) { + log.set("removedSessionId", member.sessionId); + const disconnectLogItem = await member.disconnect(false); + if (disconnectLogItem) { + log.refDetached(disconnectLogItem); + } + member.dispose(); + this._members.remove(memberKey); + member = undefined; + } + log.set("add", true); + member = new Member( + roomMember, + device, this._memberOptions, + log + ); + this._members.add(memberKey, member); + if (this.joinedData) { + this.connectToMember(member, this.joinedData, log); + } + } + // flush pending messages, either after having created the member, + // or updated the session id with updateCallInfo + this.flushPendingIncomingDeviceMessages(member, log); + }); + } + } + + const newDeviceIds = new Set(devices.map(call => call.device_id)); + // remove user as member of any calls not present anymore + for (const previousDeviceId of previousDeviceIds) { + if (!newDeviceIds.has(previousDeviceId)) { + this.removeMemberDevice(userId, previousDeviceId, log); + } + } + if (userId === this.options.ownUserId && !newDeviceIds.has(this.options.ownDeviceId)) { + this.removeOwnDevice(log); + } + }); + }); + } + + /** @internal */ + removeMembership(userId: string, syncLog: ILogItem) { + this.errorBoundary.try(() => { + const deviceIds = this.getDeviceIdsForUserId(userId); + syncLog.wrap({ + l: "remove call member", + t: CALL_LOG_TYPE, + id: this.id, + userId + }, log => { + for (const deviceId of deviceIds) { + this.removeMemberDevice(userId, deviceId, log); + } + if (userId === this.options.ownUserId) { + this.removeOwnDevice(log); + } + }); + }); + } + + private flushPendingIncomingDeviceMessages(member: Member, log: ILogItem) { + const memberKey = getMemberKey(member.userId, member.deviceId); + const bufferedMessages = this.bufferedDeviceMessages.get(memberKey); + // check if we have any pending message for the member with (userid, deviceid, sessionid) + if (bufferedMessages) { + for (const message of bufferedMessages) { + if (message.content.sender_session_id === member.sessionId) { + member.handleDeviceMessage(message, log); + bufferedMessages.delete(message); + } + } + if (bufferedMessages.size === 0) { + this.bufferedDeviceMessages.delete(memberKey); + } + } + } + + private getDeviceIdsForUserId(userId: string): string[] { + return Array.from(this._members.keys()) + .filter(key => memberKeyIsForUser(key, userId)) + .map(key => getDeviceFromMemberKey(key)); + } + + private isMember(userId: string): boolean { + return Array.from(this._members.keys()).some(key => memberKeyIsForUser(key, userId)); + } + + private removeOwnDevice(log: ILogItem) { + log.wrap("remove own membership", log => { + this.disconnect(log); + }); + } + + /** @internal */ + disconnect(log: ILogItem): Promise | true { + return this.errorBoundary.try(async () => { + if (this.hasJoined) { + for (const member of this._members.values()) { + const disconnectLogItem = await member.disconnect(true); + if (disconnectLogItem) { + log.refDetached(disconnectLogItem); + } + } + this._state = GroupCallState.Created; + } + this.joinedData?.dispose(); + this.joinedData = undefined; + this.emitChange(); + }, false) || true; + } + + /** @internal */ + private async removeMemberDevice(userId: string, deviceId: string, log: ILogItem) { + const memberKey = getMemberKey(userId, deviceId); + await log.wrap({l: "remove device member", id: memberKey}, async log => { + const member = this._members.get(memberKey); + if (member) { + log.set("leave", true); + const disconnectLogItem = await member.disconnect(false); + if (disconnectLogItem) { + log.refDetached(disconnectLogItem); + } + member.dispose(); + this._members.remove(memberKey); + } + }); + } + + /** @internal */ + handleDeviceMessage(message: SignallingMessage, userId: string, deviceId: string, syncLog: ILogItem) { + this.errorBoundary.try(() => { + // TODO: return if we are not membering to the call + const key = getMemberKey(userId, deviceId); + let member = this._members.get(key); + if (member && message.content.sender_session_id === member.sessionId) { + member.handleDeviceMessage(message, syncLog); + } else { + const item = syncLog.log({ + l: "call: buffering to_device message, member not found", + t: CALL_LOG_TYPE, + id: this.id, + userId, + deviceId, + sessionId: message.content.sender_session_id, + type: message.type + }); + // we haven't received the m.call.member yet for this caller (or with this session id). + // buffer the device messages or create the member/call as it should arrive in a moment + let messages = this.bufferedDeviceMessages.get(key); + if (!messages) { + messages = new Set(); + this.bufferedDeviceMessages.set(key, messages); + } + messages.add(message); + } + }); + } + + private async _createMemberPayload(includeOwn: boolean): Promise { + const {storage} = this.options; + const txn = await storage.readTxn([storage.storeNames.roomState]); + const stateEvent = await txn.roomState.get(this.roomId, EventType.GroupCallMember, this.options.ownUserId); + const stateContent: CallMemberContent = stateEvent?.event?.content as CallMemberContent ?? { + ["m.calls"]: [] + }; + let callsInfo = stateContent["m.calls"]; + let callInfo = callsInfo.find(c => c["m.call_id"] === this.id); + if (!callInfo) { + callInfo = { + ["m.call_id"]: this.id, + ["m.devices"]: [] + }; + callsInfo.push(callInfo); + } + const now = this.options.clock.now(); + callInfo["m.devices"] = callInfo["m.devices"].filter(d => { + // remove our own device (to add it again below) + if (d["device_id"] === this.options.ownDeviceId) { + return false; + } + // also remove any expired devices (+ the validity period added again) + if (memberExpiresAt(d) === undefined || isMemberExpired(d, now, CALL_MEMBER_VALIDITY_PERIOD_MS)) { + return false; + } + return true; + }); + if (includeOwn) { + callInfo["m.devices"].push({ + ["device_id"]: this.options.ownDeviceId, + ["session_id"]: this.options.sessionId, + ["expires_ts"]: now + CALL_MEMBER_VALIDITY_PERIOD_MS, + feeds: [{purpose: "m.usermedia"}] + }); + } + // filter out empty call membership + stateContent["m.calls"] = callsInfo.filter(c => c["m.devices"].length !== 0); + return stateContent; + } + + private async connectToMember(member: Member, joinedData: JoinedData, log: ILogItem) { + const memberKey = getMemberKey(member.userId, member.deviceId); + const logItem = joinedData.membersLogItem.child({ + l: "member", + id: memberKey, + sessionId: member.sessionId + }); + await log.wrap({l: "connect", id: memberKey}, async log => { + const connectItem = await member.connect( + joinedData.localMedia, + joinedData.localMuteSettings, + joinedData.turnServer, + logItem + ); + if (connectItem) { + log.refDetached(connectItem); + } + }); + } + + protected emitChange() { + this.emit("change"); + this.options.emitUpdate(this); + } + + private _setupRenewMembershipTimeout(callDeviceMembership: CallDeviceMembership, log: ILogItem) { + const {joinedData} = this; + if (!joinedData) { + return; + } + joinedData.renewMembershipTimeout?.dispose(); + joinedData.renewMembershipTimeout = undefined; + const expiresAt = memberExpiresAt(callDeviceMembership); + if (typeof expiresAt !== "number") { + return; + } + const expiresFromNow = expiresAt - this.options.clock.now(); + // renew 1 to 5 minutes (8.3% of 1h, but min 10s) before expiring + // do it a bit beforehand and somewhat random to not collide with + // other clients trying to renew as well + const timeToRenewBeforeExpiration = Math.max(10000, Math.ceil((0.2 +(this.options.random() * 0.8)) * (0.08333 * CALL_MEMBER_VALIDITY_PERIOD_MS))); + const renewFromNow = Math.max(0, expiresFromNow - timeToRenewBeforeExpiration); + log.set("expiresIn", expiresFromNow); + log.set("renewIn", renewFromNow); + joinedData.renewMembershipTimeout = this.options.clock.createTimeout(renewFromNow); + joinedData.renewMembershipTimeout.elapsed().then( + () => { + joinedData.logItem.wrap("renew membership", async log => { + const memberContent = await this._createMemberPayload(true); + log.set("payload", memberContent); + // send m.call.member state event + const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCallMember, this.options.ownUserId, memberContent, {log}); + await request.response(); + }); + }, + () => { /* assume we're swallowing AbortError from dispose above */ } + ); + } + + dispose() { + this.joinedData?.dispose(); + for (const member of this._members.values()) { + member.dispose(); + } + } +} diff --git a/src/matrix/calls/group/Member.ts b/src/matrix/calls/group/Member.ts new file mode 100644 index 00000000..5d67bafe --- /dev/null +++ b/src/matrix/calls/group/Member.ts @@ -0,0 +1,587 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {PeerCall, CallState, IncomingMessageAction} from "../PeerCall"; +import {makeTxnId, makeId} from "../../common"; +import {EventType, CallErrorCode} from "../callEventTypes"; +import {formatToDeviceMessagesPayload} from "../../common"; +import {sortedIndex} from "../../../utils/sortedIndex"; +import { ErrorBoundary } from "../../../utils/ErrorBoundary"; + +import {MuteSettings} from "../common"; +import type {Options as PeerCallOptions, RemoteMedia} from "../PeerCall"; +import type {LocalMedia} from "../LocalMedia"; +import type {HomeServerApi} from "../../net/HomeServerApi"; +import type {MCallBase, MGroupCallBase, SignallingMessage, CallDeviceMembership} from "../callEventTypes"; +import type {GroupCall} from "./GroupCall"; +import {RoomMember} from "../../room/members/RoomMember"; +import type {EncryptedMessage} from "../../e2ee/olm/Encryption"; +import type {ILogItem} from "../../../logging/types"; +import type {BaseObservableValue} from "../../../observable/value"; +import type {Clock, Timeout} from "../../../platform/web/dom/Clock"; + +export type Options = Omit & { + confId: string, + ownUserId: string, + ownDeviceId: string, + // local session id of our client + sessionId: string, + hsApi: HomeServerApi, + encryptDeviceMessage: (userId: string, deviceId: string, message: SignallingMessage, log: ILogItem) => Promise, + emitUpdate: (participant: Member, params?: any) => void, + clock: Clock +} + +const errorCodesWithoutRetry = [ + CallErrorCode.UserHangup, + CallErrorCode.AnsweredElsewhere, + CallErrorCode.Replaced, + CallErrorCode.UserBusy, + CallErrorCode.Transfered, + CallErrorCode.NewSession +]; + +/** @internal */ +class MemberConnection { + public retryCount: number = 0; + public peerCall?: PeerCall; + public lastProcessedSeqNr: number | undefined; + // XXX: Not needed anymore when seq is scoped to call_id + // see https://github.com/matrix-org/matrix-spec-proposals/pull/3401#discussion_r1097482166 + public lastIgnoredSeqNr: number | undefined; + public queuedSignallingMessages: SignallingMessage[] = []; + public outboundSeqCounter: number = 0; + + constructor( + public localMedia: LocalMedia, + public localMuteSettings: MuteSettings, + public turnServer: BaseObservableValue, + public readonly logItem: ILogItem + ) {} + + get canDequeueNextSignallingMessage() { + if (this.queuedSignallingMessages.length === 0) { + return false; + } + const first = this.queuedSignallingMessages[0]; + const firstSeq = first.content.seq; + // prevent not being able to jump over seq values of ignored messages for other call ids + // as they don't increase lastProcessedSeqNr. + if (this.lastIgnoredSeqNr !== undefined && firstSeq === this.lastIgnoredSeqNr + 1) { + return true; + } + if (this.lastProcessedSeqNr === undefined) { + return firstSeq === 0; + } + // allow messages with both a seq we've just seen and + // the next one to be dequeued as it can happen + // that messages for other callIds (which could repeat seq) + // are present in the queue + // XXX: Not needed anymore when seq is scoped to call_id + // see https://github.com/matrix-org/matrix-spec-proposals/pull/3401#discussion_r1097482166 + return firstSeq <= (this.lastProcessedSeqNr + 1); + } + + dispose() { + this.peerCall?.dispose(); + this.localMedia.dispose(); + this.logItem.finish(); + } +} + +export class Member { + private connection?: MemberConnection; + private expireTimeout?: Timeout; + private errorBoundary = new ErrorBoundary(err => { + this.options.emitUpdate(this, "error"); + if (this.connection) { + // in case the error happens in code that does not log, + // log it here to make sure it isn't swallowed + this.connection.logItem.log("error at boundary").catch(err); + } + }); + + constructor( + public member: RoomMember, + private callDeviceMembership: CallDeviceMembership, + private options: Options, + updateMemberLog: ILogItem + ) { + this._renewExpireTimeout(updateMemberLog); + } + + get error(): Error | undefined { + return this.errorBoundary.error; + } + + get usesFoci(): boolean { + const activeFoci = this.callDeviceMembership["m.foci.active"]; + return Array.isArray(activeFoci) && activeFoci.length > 0; + } + + private _renewExpireTimeout(log: ILogItem) { + this.expireTimeout?.dispose(); + this.expireTimeout = undefined; + const expiresAt = memberExpiresAt(this.callDeviceMembership); + if (typeof expiresAt !== "number") { + return; + } + const expiresFromNow = Math.max(0, expiresAt - this.options.clock.now()); + log?.set("expiresIn", expiresFromNow); + // add 10ms to make sure isExpired returns true + this.expireTimeout = this.options.clock.createTimeout(expiresFromNow + 10); + this.expireTimeout.elapsed().then( + () => { this.options.emitUpdate(this, "isExpired"); }, + (err) => { /* ignore abort error */ }, + ); + } + + /** + * Gives access the log item for this item once joined to the group call. + * The signalling for this member will be log in this item. + * Can be used for call diagnostics while in the call. + **/ + get logItem(): ILogItem | undefined { + return this.connection?.logItem; + } + + get remoteMedia(): RemoteMedia | undefined { + return this.connection?.peerCall?.remoteMedia; + } + + get isExpired(): boolean { + // never consider a peer we're connected to, to be expired + return !this.isConnected && isMemberExpired(this.callDeviceMembership, this.options.clock.now()); + } + + get remoteMuteSettings(): MuteSettings | undefined { + return this.connection?.peerCall?.remoteMuteSettings; + } + + get isConnected(): boolean { + return this.connection?.peerCall?.state === CallState.Connected; + } + + get userId(): string { + return this.member.userId; + } + + get deviceId(): string { + return this.callDeviceMembership.device_id; + } + + /** session id of the member */ + get sessionId(): string { + return this.callDeviceMembership.session_id; + } + + get dataChannel(): any | undefined { + return this.connection?.peerCall?.dataChannel; + } + + /** @internal */ + connect(localMedia: LocalMedia, localMuteSettings: MuteSettings, turnServer: BaseObservableValue, memberLogItem: ILogItem): Promise | undefined { + return this.errorBoundary.try(async () => { + if (this.connection) { + return; + } + // Safari can't send a MediaStream to multiple sources, so clone it + const connection = new MemberConnection( + localMedia.clone(), + localMuteSettings, + turnServer, + memberLogItem + ); + this.connection = connection; + let connectLogItem: ILogItem | undefined; + await connection.logItem.wrap("connect", async log => { + connectLogItem = log; + await this.callIfNeeded(log); + }); + return connectLogItem; + }); + } + + private callIfNeeded(log: ILogItem): Promise { + return log.wrap("callIfNeeded", async log => { + // otherwise wait for it to connect + let shouldInitiateCall; + // the lexicographically lower side initiates the call + if (this.member.userId === this.options.ownUserId) { + shouldInitiateCall = this.deviceId > this.options.ownDeviceId; + } else { + shouldInitiateCall = this.member.userId > this.options.ownUserId; + } + if (shouldInitiateCall) { + const connection = this.connection!; + connection.peerCall = this._createPeerCall(makeId("c")); + await connection.peerCall.call( + connection.localMedia, + connection.localMuteSettings, + log + ); + } else { + log.set("wait_for_invite", true); + } + }); + } + + /** @internal */ + disconnect(hangup: boolean): Promise | undefined { + return this.errorBoundary.try(async () => { + const {connection} = this; + if (!connection) { + return; + } + let disconnectLogItem: ILogItem | undefined; + // if if not sending the hangup, still log disconnect + await connection.logItem.wrap("disconnect", async log => { + disconnectLogItem = log; + if (hangup && connection.peerCall) { + await connection.peerCall.hangup(CallErrorCode.UserHangup, log); + } + }); + connection.dispose(); + this.connection = undefined; + return disconnectLogItem; + }); + } + + /** @internal */ + updateCallInfo(callDeviceMembership: CallDeviceMembership, causeItem: ILogItem) { + this.errorBoundary.try(() => { + this.callDeviceMembership = callDeviceMembership; + this._renewExpireTimeout(causeItem); + if (this.connection) { + this.connection.logItem.refDetached(causeItem); + } + }); + } + + /** @internal */ + updateRoomMember(roomMember: RoomMember) { + this.member = roomMember; + // TODO: this emits an update during the writeSync phase, which we usually try to avoid + this.options.emitUpdate(this); + } + + /** @internal */ + emitUpdateFromPeerCall = async (peerCall: PeerCall, params: any, log: ILogItem): Promise => { + const connection = this.connection!; + if (peerCall.state === CallState.Ringing) { + connection.logItem.wrap("ringing, answer peercall", answerLog => { + log.refDetached(answerLog); + return peerCall.answer(connection.localMedia, connection.localMuteSettings, answerLog); + }); + } + else if (peerCall.state === CallState.Ended) { + const hangupReason = peerCall.hangupReason; + peerCall.dispose(); + connection.peerCall = undefined; + if (hangupReason && !errorCodesWithoutRetry.includes(hangupReason)) { + connection.retryCount += 1; + const {retryCount} = connection; + await connection.logItem.wrap({l: "retry connection", retryCount}, async retryLog => { + log.refDetached(retryLog); + if (retryCount <= 3) { + await this.callIfNeeded(retryLog); + } else { + const disconnectLogItem = await this.disconnect(false); + if (disconnectLogItem) { + retryLog.refDetached(disconnectLogItem); + } + } + }); + } + } + this.options.emitUpdate(this, params); + } + + /** @internal */ + sendSignallingMessage = async (message: SignallingMessage, log: ILogItem): Promise => { + const groupMessage = message as SignallingMessage; + groupMessage.content.seq = this.connection!.outboundSeqCounter++; + groupMessage.content.conf_id = this.options.confId; + groupMessage.content.device_id = this.options.ownDeviceId; + groupMessage.content.party_id = this.options.ownDeviceId; + groupMessage.content.sender_session_id = this.options.sessionId; + groupMessage.content.dest_session_id = this.sessionId; + let payload; + let type: string = message.type; + const encryptedMessages = await this.options.encryptDeviceMessage(this.member.userId, this.deviceId, groupMessage, log); + if (encryptedMessages) { + payload = formatToDeviceMessagesPayload(encryptedMessages); + type = "m.room.encrypted"; + } else { + // device needs deviceId and userId + payload = formatToDeviceMessagesPayload([{content: groupMessage.content, device: this}]); + } + // TODO: remove this for release + log.set("payload", groupMessage.content); + const request = this.options.hsApi.sendToDevice( + type, + payload, + makeTxnId(), + {log} + ); + await request.response(); + } + + /** @internal */ + handleDeviceMessage(message: SignallingMessage, syncLog: ILogItem): void { + this.errorBoundary.try(() => { + syncLog.wrap({l: "Member.handleDeviceMessage", type: message.type, seq: message.content?.seq}, log => { + const {connection} = this; + if (connection) { + const destSessionId = message.content.dest_session_id; + if (destSessionId !== this.options.sessionId) { + const logItem = connection.logItem.log({l: "ignoring to_device event with wrong session_id", destSessionId, type: message.type}); + log.refDetached(logItem); + return; + } + // if there is no peerCall, we either create it with an invite and Handle is implied or we'll ignore it + if (connection.peerCall) { + const action = connection.peerCall.getMessageAction(message); + // deal with glare and replacing the call before creating new calls + if (action === IncomingMessageAction.InviteGlare) { + const {shouldReplace, log} = connection.peerCall.handleInviteGlare(message, this.deviceId, connection.logItem); + if (log) { + log.refDetached(log); + } + if (shouldReplace) { + connection.peerCall.dispose(); + connection.peerCall = undefined; + } + } + } + // create call on invite + if (message.type === EventType.Invite && !connection.peerCall) { + connection.peerCall = this._createPeerCall(message.content.call_id); + } + // enqueue + const idx = sortedIndex(connection.queuedSignallingMessages, message, (a, b) => a.content.seq - b.content.seq); + connection.queuedSignallingMessages.splice(idx, 0, message); + // dequeue as much as we can + let hasNewMessageBeenDequeued = false; + if (connection.peerCall) { + hasNewMessageBeenDequeued = this.dequeueSignallingMessages(connection, connection.peerCall, message, log); + } + if (!hasNewMessageBeenDequeued) { + log.refDetached(connection.logItem.log({l: "queued message", type: message.type, seq: message.content.seq, idx})); + } + } else { + // TODO: the right thing to do here would be to at least enqueue the message rather than drop it, + // and if it's up to the other end to send the invite and the type is an invite to actually + // call connect and assume the m.call.member state update is on its way? + syncLog.log({l: "member not connected", userId: this.userId, deviceId: this.deviceId}); + } + }); + }); + } + + private dequeueSignallingMessages(connection: MemberConnection, peerCall: PeerCall, newMessage: SignallingMessage, syncLog: ILogItem): boolean { + let hasNewMessageBeenDequeued = false; + while (connection.canDequeueNextSignallingMessage) { + const message = connection.queuedSignallingMessages.shift()!; + const isNewMsg = message === newMessage; + hasNewMessageBeenDequeued = hasNewMessageBeenDequeued || isNewMsg; + syncLog.wrap(isNewMsg ? "process message" : "dequeue message", log => { + const seq = message.content?.seq; + log.set("seq", seq); + log.set("type", message.type); + // ignore items in the queue that should not be handled and prevent + // the lastProcessedSeqNr being corrupted with the `seq` for other call ids + // XXX: Not needed anymore when seq is scoped to call_id + // see https://github.com/matrix-org/matrix-spec-proposals/pull/3401#discussion_r1097482166 + const action = peerCall.getMessageAction(message); + if (action === IncomingMessageAction.Handle) { + const item = peerCall.handleIncomingSignallingMessage(message, this.deviceId, connection.logItem); + log.refDetached(item); + connection.lastProcessedSeqNr = seq; + } else { + log.set("ignored", true); + connection.lastIgnoredSeqNr = seq; + } + }); + } + return hasNewMessageBeenDequeued; + } + + /** @internal */ + async setMedia(localMedia: LocalMedia, previousMedia: LocalMedia): Promise { + return this.errorBoundary.try(async () => { + const {connection} = this; + if (connection) { + connection.localMedia = localMedia.replaceClone(connection.localMedia, previousMedia); + await connection.peerCall?.setMedia(connection.localMedia, connection.logItem); + } + }); + } + + async setMuted(muteSettings: MuteSettings): Promise { + return this.errorBoundary.try(async () => { + const {connection} = this; + if (connection) { + connection.localMuteSettings = muteSettings; + await connection.peerCall?.setMuted(muteSettings, connection.logItem); + } + }); + } + + private _createPeerCall(callId: string): PeerCall { + const connection = this.connection!; + return new PeerCall(callId, Object.assign({}, this.options, { + errorBoundary: this.errorBoundary, + emitUpdate: this.emitUpdateFromPeerCall, + sendSignallingMessage: this.sendSignallingMessage, + turnServer: connection.turnServer + }), connection.logItem); + } + + dispose() { + this.connection?.dispose(); + this.connection = undefined; + this.expireTimeout?.dispose(); + this.expireTimeout = undefined; + // ensure the emitUpdate callback can't be called anymore + this.options.emitUpdate = () => {}; + } +} + +export function memberExpiresAt(callDeviceMembership: CallDeviceMembership): number | undefined { + const expiresAt = callDeviceMembership["expires_ts"]; + if (Number.isSafeInteger(expiresAt)) { + return expiresAt; + } +} + +export function isMemberExpired(callDeviceMembership: CallDeviceMembership, now: number, margin: number = 0) { + const expiresAt = memberExpiresAt(callDeviceMembership); + return typeof expiresAt === "number" ? ((expiresAt + margin) <= now) : true; +} + +import {ObservableValue} from "../../../observable/value"; +import {Clock as MockClock} from "../../../mocks/Clock"; +import {Instance as NullLoggerInstance} from "../../../logging/NullLogger"; + +export function tests() { + + class MockMedia { + clone(): MockMedia { return this; } + } + + class MockPeerConn { + addEventListener() {} + removeEventListener() {} + setConfiguration() {} + setRemoteDescription() {} + getReceivers() { return [{}]; } // non-empty array + getSenders() { return []; } + addTrack() { return {}; } + removeTrack() {} + close() {} + } + return { + "test queue doesn't get blocked by enqueued, then ignored device message": assert => { + // XXX we might want to refactor the queue code a bit so it's easier to test + // without having to provide so many mocks + const clock = new MockClock(); + // setup logging + const logger = NullLoggerInstance; + // const logger = new Logger({platform: {clock, random: Math.random}}); + // logger.addReporter(new ConsoleReporter()); + + // create member + const callDeviceMembership = { + ["device_id"]: "BVPIHSKXFC", + ["session_id"]: "s1d5863f41ec5a5", + ["expires_ts"]: 123, + feeds: [{purpose: "m.usermedia"}] + }; + const roomMember = RoomMember.fromUserId("!abc", "@bruno4:matrix.org", "join"); + const turnServer = new ObservableValue({}) as ObservableValue; + // @ts-ignore + const options = { + confId: "conf", + ownUserId: "@foobaraccount2:matrix.org", + ownDeviceId: "CMLEZSARRT", + sessionId: "s1cece7088b9d35", + clock, + emitUpdate: () => {}, + webRTC: { + prepareSenderForPurpose: () => {}, + createPeerConnection() { + return new MockPeerConn(); + } + } + } as Options; + const member = new Member(roomMember, callDeviceMembership, options, logger.child("member")); + member.connect(new MockMedia() as LocalMedia, new MuteSettings(), turnServer, logger.child("connect")); + // pretend we've already received 3 messages + // @ts-ignore + member.connection!.lastProcessedSeqNr = 2; + // send hangup with seq=3, this will enqueue the message because there is no peerCall + // as it's up to @bruno4:matrix.org to send the invite + const hangup = { + type: EventType.Hangup, + content: { + "call_id": "c0ac1b0e37afe73", + "version": 1, + "reason": "invite_timeout", + "seq": 3, + "conf_id": "conf-16a120796440a6", + "device_id": "BVPIHSKXFC", + "party_id": "BVPIHSKXFC", + "sender_session_id": "s1d5863f41ec5a5", + "dest_session_id": "s1cece7088b9d35" + } + } as SignallingMessage; + member.handleDeviceMessage(hangup, logger.child("handle hangup")); + // Send an invite with seq=4, this will create a new peer call with a the call id + // when dequeueing the hangup from before, it'll get ignored because it is + // for the previous call id. + const invite = { + type: EventType.Invite, + content: { + "call_id": "c1175b12d559fb1", + "offer": { + "type": "offer", + "sdp": "..." + }, + "org.matrix.msc3077.sdp_stream_metadata": { + "60087b60-487e-4fa0-8229-b232c18e1332": { + "purpose": "m.usermedia", + "audio_muted": false, + "video_muted": false + } + }, + "version": 1, + "lifetime": 60000, + "seq": 4, + "conf_id": "conf-16a120796440a6", + "device_id": "BVPIHSKXFC", + "party_id": "BVPIHSKXFC", + "sender_session_id": "s1d5863f41ec5a5", + "dest_session_id": "s1cece7088b9d35" + } + } as SignallingMessage; + member.handleDeviceMessage(invite, logger.child("handle invite")); + // @ts-ignore + assert.equal(member.connection!.queuedSignallingMessages.length, 0); + // logger.reporters[0].printOpenItems(); + } + }; +} diff --git a/src/matrix/common.js b/src/matrix/common.js index ba7876ed..7cd72ae1 100644 --- a/src/matrix/common.js +++ b/src/matrix/common.js @@ -15,16 +15,37 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {groupBy} from "../utils/groupBy"; + + export function makeTxnId() { + return makeId("t"); +} + +export function makeId(prefix) { const n = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); const str = n.toString(16); - return "t" + "0".repeat(14 - str.length) + str; + return prefix + "0".repeat(14 - str.length) + str; } export function isTxnId(txnId) { return txnId.startsWith("t") && txnId.length === 15; } +export function formatToDeviceMessagesPayload(messages) { + const messagesByUser = groupBy(messages, message => message.device.userId); + const payload = { + messages: Array.from(messagesByUser.entries()).reduce((userMap, [userId, messages]) => { + userMap[userId] = messages.reduce((deviceMap, message) => { + deviceMap[message.device.deviceId] = message.content; + return deviceMap; + }, {}); + return userMap; + }, {}) + }; + return payload; +} + export function tests() { return { "isTxnId succeeds on result of makeTxnId": assert => { diff --git a/src/matrix/e2ee/Account.js b/src/matrix/e2ee/Account.js index b704d7f5..8fa2db02 100644 --- a/src/matrix/e2ee/Account.js +++ b/src/matrix/e2ee/Account.js @@ -15,7 +15,7 @@ limitations under the License. */ import anotherjson from "another-json"; -import {SESSION_E2EE_KEY_PREFIX, OLM_ALGORITHM, MEGOLM_ALGORITHM} from "./common.js"; +import {SESSION_E2EE_KEY_PREFIX, OLM_ALGORITHM, MEGOLM_ALGORITHM} from "./common"; // use common prefix so it's easy to clear properties that are not e2ee related during session clear const ACCOUNT_SESSION_KEY = SESSION_E2EE_KEY_PREFIX + "olmAccount"; @@ -246,7 +246,7 @@ export class Account { } } - _deviceKeysPayload(identityKeys) { + _keysAsSignableObject(identityKeys) { const obj = { user_id: this._userId, device_id: this._deviceId, @@ -256,6 +256,16 @@ export class Account { for (const [algorithm, pubKey] of Object.entries(identityKeys)) { obj.keys[`${algorithm}:${this._deviceId}`] = pubKey; } + return obj; + } + + getUnsignedDeviceKey() { + const identityKeys = JSON.parse(this._account.identity_keys()); + return this._keysAsSignableObject(identityKeys); + } + + _deviceKeysPayload(identityKeys) { + const obj = this._keysAsSignableObject(identityKeys); this.signObject(obj); return obj; } diff --git a/src/matrix/e2ee/DecryptionResult.ts b/src/matrix/e2ee/DecryptionResult.ts index 8846616c..146a1ad3 100644 --- a/src/matrix/e2ee/DecryptionResult.ts +++ b/src/matrix/e2ee/DecryptionResult.ts @@ -26,7 +26,8 @@ limitations under the License. * see DeviceTracker */ -import type {DeviceIdentity} from "../storage/idb/stores/DeviceIdentityStore"; +import {getDeviceEd25519Key} from "./common"; +import type {DeviceKey} from "./common"; import type {TimelineEvent} from "../storage/types"; type DecryptedEvent = { @@ -35,7 +36,7 @@ type DecryptedEvent = { } export class DecryptionResult { - private device?: DeviceIdentity; + private device?: DeviceKey; constructor( public readonly event: DecryptedEvent, @@ -44,13 +45,13 @@ export class DecryptionResult { public readonly encryptedEvent?: TimelineEvent ) {} - setDevice(device: DeviceIdentity): void { + setDevice(device: DeviceKey): void { this.device = device; } get isVerified(): boolean { if (this.device) { - const comesFromDevice = this.device.ed25519Key === this.claimedEd25519Key; + const comesFromDevice = getDeviceEd25519Key(this.device) === this.claimedEd25519Key; return comesFromDevice; } return false; @@ -64,6 +65,14 @@ export class DecryptionResult { } } + get userId(): string | undefined { + return this.device?.user_id; + } + + get deviceId(): string | undefined { + return this.device?.device_id; + } + get isVerificationUnknown(): boolean { return !this.device; } diff --git a/src/matrix/e2ee/DeviceTracker.js b/src/matrix/e2ee/DeviceTracker.ts similarity index 60% rename from src/matrix/e2ee/DeviceTracker.js rename to src/matrix/e2ee/DeviceTracker.ts index 592ad24e..3a50a890 100644 --- a/src/matrix/e2ee/DeviceTracker.js +++ b/src/matrix/e2ee/DeviceTracker.ts @@ -14,22 +14,42 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {verifyEd25519Signature, SIGNATURE_ALGORITHM} from "./common.js"; -import {HistoryVisibility, shouldShareKey} from "./common.js"; +import {verifyEd25519Signature, getEd25519Signature, SIGNATURE_ALGORITHM, SignatureVerification} from "./common"; +import {HistoryVisibility, shouldShareKey, DeviceKey, getDeviceEd25519Key, getDeviceCurve25519Key} from "./common"; import {RoomMember} from "../room/members/RoomMember.js"; +import {getKeyUsage, getKeyEd25519Key, getKeyUserId, KeyUsage} from "../verification/CrossSigning"; +import {MemberChange} from "../room/members/RoomMember"; +import type {CrossSigningKey} from "../verification/CrossSigning"; +import type {HomeServerApi} from "../net/HomeServerApi"; +import type {ObservableMap} from "../../observable/map"; +import type {Room} from "../room/Room"; +import type {ILogItem} from "../../logging/types"; +import type {Storage} from "../storage/idb/Storage"; +import type {Transaction} from "../storage/idb/Transaction"; +import type * as OlmNamespace from "@matrix-org/olm"; +type Olm = typeof OlmNamespace; -const TRACKING_STATUS_OUTDATED = 0; -const TRACKING_STATUS_UPTODATE = 1; +// tracking status for cross-signing and device keys +export enum KeysTrackingStatus { + Outdated = 0, + UpToDate = 1 +} -function createUserIdentity(userId, initialRoomId = undefined) { +export type UserIdentity = { + userId: string, + roomIds: string[], + keysTrackingStatus: KeysTrackingStatus, +} + +function createUserIdentity(userId: string, initialRoomId?: string): UserIdentity { return { userId: userId, roomIds: initialRoomId ? [initialRoomId] : [], - deviceTrackingStatus: TRACKING_STATUS_OUTDATED, + keysTrackingStatus: KeysTrackingStatus.Outdated, }; } -function addRoomToIdentity(identity, userId, roomId) { +function addRoomToIdentity(identity: UserIdentity | undefined, userId: string, roomId: string): UserIdentity | undefined { if (!identity) { identity = createUserIdentity(userId, roomId); return identity; @@ -41,31 +61,22 @@ function addRoomToIdentity(identity, userId, roomId) { } } -// map 1 device from /keys/query response to DeviceIdentity -function deviceKeysAsDeviceIdentity(deviceSection) { - const deviceId = deviceSection["device_id"]; - const userId = deviceSection["user_id"]; - return { - userId, - deviceId, - ed25519Key: deviceSection.keys[`ed25519:${deviceId}`], - curve25519Key: deviceSection.keys[`curve25519:${deviceId}`], - algorithms: deviceSection.algorithms, - displayName: deviceSection.unsigned?.device_display_name, - }; -} - export class DeviceTracker { - constructor({storage, getSyncToken, olmUtil, ownUserId, ownDeviceId}) { - this._storage = storage; - this._getSyncToken = getSyncToken; - this._identityChangedForRoom = null; - this._olmUtil = olmUtil; - this._ownUserId = ownUserId; - this._ownDeviceId = ownDeviceId; + private readonly _storage: Storage; + private readonly _getSyncToken: () => string; + private readonly _olmUtil: Olm.Utility; + private readonly _ownUserId: string; + private readonly _ownDeviceId: string; + + constructor(options: {storage: Storage, getSyncToken: () => string, olmUtil: Olm.Utility, ownUserId: string, ownDeviceId: string}) { + this._storage = options.storage; + this._getSyncToken = options.getSyncToken; + this._olmUtil = options.olmUtil; + this._ownUserId = options.ownUserId; + this._ownDeviceId = options.ownDeviceId; } - async writeDeviceChanges(changed, txn, log) { + async writeDeviceChanges(changedUserIds: ReadonlyArray, txn: Transaction, log: ILogItem): Promise { const {userIdentities} = txn; // TODO: should we also look at left here to handle this?: // the usual problem here is that you share a room with a user, @@ -74,12 +85,12 @@ export class DeviceTracker { // At which point you come online, all of this happens in the gap, // and you don't notice that they ever left, // and so the client doesn't invalidate their device cache for the user - log.set("changed", changed.length); - await Promise.all(changed.map(async userId => { + log.set("changed", changedUserIds.length); + await Promise.all(changedUserIds.map(async userId => { const user = await userIdentities.get(userId); if (user) { log.log({l: "outdated", id: userId}); - user.deviceTrackingStatus = TRACKING_STATUS_OUTDATED; + user.keysTrackingStatus = KeysTrackingStatus.Outdated; userIdentities.set(user); } })); @@ -88,9 +99,9 @@ export class DeviceTracker { /** @return Promise<{added: string[], removed: string[]}> the user ids for who the room was added or removed to the userIdentity, * and with who a key should be now be shared **/ - async writeMemberChanges(room, memberChanges, historyVisibility, txn) { - const added = []; - const removed = []; + async writeMemberChanges(room: Room, memberChanges: Map, historyVisibility: HistoryVisibility, txn: Transaction): Promise<{added: string[], removed: string[]}> { + const added: string[] = []; + const removed: string[] = []; await Promise.all(Array.from(memberChanges.values()).map(async memberChange => { // keys should now be shared with this member? // add the room to the userIdentity if so @@ -116,7 +127,7 @@ export class DeviceTracker { return {added, removed}; } - async trackRoom(room, historyVisibility, log) { + async trackRoom(room: Room, historyVisibility: HistoryVisibility, log: ILogItem): Promise { if (room.isTrackingMembers || !room.isEncrypted) { return; } @@ -124,13 +135,13 @@ export class DeviceTracker { const txn = await this._storage.readWriteTxn([ this._storage.storeNames.roomSummary, this._storage.storeNames.userIdentities, - this._storage.storeNames.deviceIdentities, // to remove all devices in _removeRoomFromUserIdentity + this._storage.storeNames.deviceKeys, // to remove all devices in _removeRoomFromUserIdentity ]); try { let isTrackingChanges; try { isTrackingChanges = room.writeIsTrackingMembers(true, txn); - const members = Array.from(memberList.members.values()); + const members = Array.from((memberList.members as ObservableMap).values()); log.set("members", members.length); // TODO: should we remove any userIdentities we should not share the key with?? // e.g. as an extra security measure if we had a mistake in other code? @@ -152,14 +163,42 @@ export class DeviceTracker { } } - async writeHistoryVisibility(room, historyVisibility, syncTxn, log) { - const added = []; - const removed = []; + async getCrossSigningKeyForUser(userId: string, usage: KeyUsage, hsApi: HomeServerApi | undefined, log: ILogItem): Promise { + return await log.wrap({l: "DeviceTracker.getCrossSigningKeyForUser", id: userId, usage}, async log => { + const txn = await this._storage.readTxn([ + this._storage.storeNames.userIdentities, + this._storage.storeNames.crossSigningKeys, + ]); + const userIdentity = await txn.userIdentities.get(userId); + if (userIdentity && userIdentity.keysTrackingStatus !== KeysTrackingStatus.Outdated) { + return await txn.crossSigningKeys.get(userId, usage); + } + // not allowed to access the network, bail out + if (!hsApi) { + return undefined; + } + // fetch from hs + const keys = await this._queryKeys([userId], hsApi, log); + switch (usage) { + case KeyUsage.Master: + return keys.masterKeys.get(userId); + case KeyUsage.SelfSigning: + return keys.selfSigningKeys.get(userId); + case KeyUsage.UserSigning: + return keys.userSigningKeys.get(userId); + } + }); + } + + async writeHistoryVisibility(room: Room, historyVisibility: HistoryVisibility, syncTxn: Transaction, log: ILogItem): Promise<{added: string[], removed: string[]}> { + const added: string[] = []; + const removed: string[] = []; if (room.isTrackingMembers && room.isEncrypted) { await log.wrap("rewriting userIdentities", async log => { + // TODO: how do we know that we won't fetch the members from the server here and hence close the syncTxn? const memberList = await room.loadMemberList(syncTxn, log); try { - const members = Array.from(memberList.members.values()); + const members = Array.from((memberList.members as ObservableMap).values()); log.set("members", members.length); await Promise.all(members.map(async member => { if (shouldShareKey(member.membership, historyVisibility)) { @@ -180,7 +219,7 @@ export class DeviceTracker { return {added, removed}; } - async _addRoomToUserIdentity(roomId, userId, txn) { + async _addRoomToUserIdentity(roomId: string, userId: string, txn: Transaction): Promise { const {userIdentities} = txn; const identity = await userIdentities.get(userId); const updatedIdentity = addRoomToIdentity(identity, userId, roomId); @@ -191,15 +230,15 @@ export class DeviceTracker { return false; } - async _removeRoomFromUserIdentity(roomId, userId, txn) { - const {userIdentities, deviceIdentities} = txn; + async _removeRoomFromUserIdentity(roomId: string, userId: string, txn: Transaction): Promise { + const {userIdentities, deviceKeys} = txn; const identity = await userIdentities.get(userId); if (identity) { identity.roomIds = identity.roomIds.filter(id => id !== roomId); // no more encrypted rooms with this user, remove if (identity.roomIds.length === 0) { userIdentities.remove(userId); - deviceIdentities.removeAllForUser(userId); + deviceKeys.removeAllForUser(userId); } else { userIdentities.set(identity); } @@ -208,7 +247,12 @@ export class DeviceTracker { return false; } - async _queryKeys(userIds, hsApi, log) { + async _queryKeys(userIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise<{ + deviceKeys: Map, + masterKeys: Map, + selfSigningKeys: Map, + userSigningKeys: Map + }> { // TODO: we need to handle the race here between /sync and /keys/query just like we need to do for the member list ... // there are multiple requests going out for /keys/query though and only one for /members // So, while doing /keys/query, writeDeviceChanges should add userIds marked as outdated to a list @@ -224,56 +268,79 @@ export class DeviceTracker { "token": this._getSyncToken() }, {log}).response(); - const verifiedKeysPerUser = log.wrap("verify", log => this._filterVerifiedDeviceKeys(deviceKeyResponse["device_keys"], log)); + const masterKeys = log.wrap("master keys", log => this._filterVerifiedCrossSigningKeys(deviceKeyResponse["master_keys"], KeyUsage.Master, log)); + const selfSigningKeys = log.wrap("self-signing keys", log => this._filterVerifiedCrossSigningKeys(deviceKeyResponse["self_signing_keys"], KeyUsage.SelfSigning, log)); + const userSigningKeys = log.wrap("user-signing keys", log => this._filterVerifiedCrossSigningKeys(deviceKeyResponse["user_signing_keys"], KeyUsage.UserSigning, log)); + const deviceKeys = log.wrap("device keys", log => this._filterVerifiedDeviceKeys(deviceKeyResponse["device_keys"], log)); const txn = await this._storage.readWriteTxn([ this._storage.storeNames.userIdentities, - this._storage.storeNames.deviceIdentities, + this._storage.storeNames.deviceKeys, + this._storage.storeNames.crossSigningKeys, ]); let deviceIdentities; try { - const devicesIdentitiesPerUser = await Promise.all(verifiedKeysPerUser.map(async ({userId, verifiedKeys}) => { - const deviceIdentities = verifiedKeys.map(deviceKeysAsDeviceIdentity); - return await this._storeQueriedDevicesForUserId(userId, deviceIdentities, txn); + for (const key of masterKeys.values()) { + txn.crossSigningKeys.set(key); + } + for (const key of selfSigningKeys.values()) { + txn.crossSigningKeys.set(key); + } + for (const key of userSigningKeys.values()) { + txn.crossSigningKeys.set(key); + } + let totalCount = 0; + await Promise.all(Array.from(deviceKeys.keys()).map(async (userId) => { + let deviceKeysForUser = deviceKeys.get(userId)!; + totalCount += deviceKeysForUser.length; + // check for devices that changed their keys and keep the old key + deviceKeysForUser = await this._storeQueriedDevicesForUserId(userId, deviceKeysForUser, txn); + deviceKeys.set(userId, deviceKeysForUser); })); - deviceIdentities = devicesIdentitiesPerUser.reduce((all, devices) => all.concat(devices), []); - log.set("devices", deviceIdentities.length); + log.set("devices", totalCount); } catch (err) { txn.abort(); throw err; } await txn.complete(); - return deviceIdentities; + return { + deviceKeys, + masterKeys, + selfSigningKeys, + userSigningKeys + }; } - async _storeQueriedDevicesForUserId(userId, deviceIdentities, txn) { - const knownDeviceIds = await txn.deviceIdentities.getAllDeviceIds(userId); + async _storeQueriedDevicesForUserId(userId: string, deviceKeys: DeviceKey[], txn: Transaction): Promise { + // TODO: we should obsolete (flag) the device keys that have been removed, + // but keep them to verify messages encrypted with it? + const knownDeviceIds = await txn.deviceKeys.getAllDeviceIds(userId); // delete any devices that we know off but are not in the response anymore. // important this happens before checking if the ed25519 key changed, // otherwise we would end up deleting existing devices with changed keys. for (const deviceId of knownDeviceIds) { - if (deviceIdentities.every(di => di.deviceId !== deviceId)) { - txn.deviceIdentities.remove(userId, deviceId); + if (deviceKeys.every(di => di.device_id !== deviceId)) { + txn.deviceKeys.remove(userId, deviceId); } } // all the device identities as we will have them in storage - const allDeviceIdentities = []; - const deviceIdentitiesToStore = []; + const allDeviceKeys: DeviceKey[] = []; + const deviceKeysToStore: DeviceKey[] = []; // filter out devices that have changed their ed25519 key since last time we queried them - await Promise.all(deviceIdentities.map(async deviceIdentity => { - if (knownDeviceIds.includes(deviceIdentity.deviceId)) { - const existingDevice = await txn.deviceIdentities.get(deviceIdentity.userId, deviceIdentity.deviceId); - if (existingDevice.ed25519Key !== deviceIdentity.ed25519Key) { - allDeviceIdentities.push(existingDevice); + await Promise.all(deviceKeys.map(async deviceKey => { + if (knownDeviceIds.includes(deviceKey.device_id)) { + const existingDevice = await txn.deviceKeys.get(deviceKey.user_id, deviceKey.device_id); + if (existingDevice && getDeviceEd25519Key(existingDevice) !== getDeviceEd25519Key(deviceKey)) { + allDeviceKeys.push(existingDevice); return; } } - allDeviceIdentities.push(deviceIdentity); - deviceIdentitiesToStore.push(deviceIdentity); + allDeviceKeys.push(deviceKey); + deviceKeysToStore.push(deviceKey); })); // store devices - for (const deviceIdentity of deviceIdentitiesToStore) { - txn.deviceIdentities.set(deviceIdentity); + for (const deviceKey of deviceKeysToStore) { + txn.deviceKeys.set(deviceKey); } // mark user identities as up to date let identity = await txn.userIdentities.get(userId); @@ -285,60 +352,108 @@ export class DeviceTracker { // checked, we could share keys with that user without them being in the room identity = createUserIdentity(userId); } - identity.deviceTrackingStatus = TRACKING_STATUS_UPTODATE; + identity.keysTrackingStatus = KeysTrackingStatus.UpToDate; txn.userIdentities.set(identity); - return allDeviceIdentities; + return allDeviceKeys; + } + + _filterVerifiedCrossSigningKeys(crossSigningKeysResponse: {[userId: string]: CrossSigningKey}, usage: KeyUsage, log: ILogItem): Map { + const keys: Map = new Map(); + if (!crossSigningKeysResponse) { + return keys; + } + for (const [userId, keyInfo] of Object.entries(crossSigningKeysResponse)) { + log.wrap({l: userId}, log => { + if (this._validateCrossSigningKey(userId, keyInfo, usage, log)) { + keys.set(getKeyUserId(keyInfo)!, keyInfo); + } + }); + } + return keys; + } + + _validateCrossSigningKey(userId: string, keyInfo: CrossSigningKey, usage: KeyUsage, log: ILogItem): boolean { + if (getKeyUserId(keyInfo) !== userId) { + log.log({l: "user_id mismatch", userId: keyInfo["user_id"]}); + return false; + } + if (getKeyUsage(keyInfo) !== usage) { + log.log({l: "usage mismatch", usage: keyInfo.usage}); + return false; + } + const publicKey = getKeyEd25519Key(keyInfo); + if (!publicKey) { + log.log({l: "no ed25519 key", keys: keyInfo.keys}); + return false; + } + return true; } /** * @return {Array<{userId, verifiedKeys: Array>} */ - _filterVerifiedDeviceKeys(keyQueryDeviceKeysResponse, parentLog) { - const curve25519Keys = new Set(); - const verifiedKeys = Object.entries(keyQueryDeviceKeysResponse).map(([userId, keysByDevice]) => { - const verifiedEntries = Object.entries(keysByDevice).filter(([deviceId, deviceKeys]) => { - const deviceIdOnKeys = deviceKeys["device_id"]; - const userIdOnKeys = deviceKeys["user_id"]; - if (userIdOnKeys !== userId) { - return false; - } - if (deviceIdOnKeys !== deviceId) { - return false; - } - const ed25519Key = deviceKeys.keys?.[`ed25519:${deviceId}`]; - const curve25519Key = deviceKeys.keys?.[`curve25519:${deviceId}`]; - if (typeof ed25519Key !== "string" || typeof curve25519Key !== "string") { - return false; - } - if (curve25519Keys.has(curve25519Key)) { - parentLog.log({ - l: "ignore device with duplicate curve25519 key", - keys: deviceKeys - }, parentLog.level.Warn); - return false; - } - curve25519Keys.add(curve25519Key); - const isValid = this._hasValidSignature(deviceKeys, parentLog); - if (!isValid) { - parentLog.log({ - l: "ignore device with invalid signature", - keys: deviceKeys - }, parentLog.level.Warn); - } - return isValid; + _filterVerifiedDeviceKeys( + keyQueryDeviceKeysResponse: {[userId: string]: {[deviceId: string]: DeviceKey}}, + parentLog: ILogItem + ): Map { + const curve25519Keys: Set = new Set(); + const keys: Map = new Map(); + if (!keyQueryDeviceKeysResponse) { + return keys; + } + for (const [userId, keysByDevice] of Object.entries(keyQueryDeviceKeysResponse)) { + parentLog.wrap(userId, log => { + const verifiedEntries = Object.entries(keysByDevice).filter(([deviceId, deviceKey]) => { + return log.wrap(deviceId, log => { + if (this._validateDeviceKey(userId, deviceId, deviceKey, log)) { + const curve25519Key = getDeviceCurve25519Key(deviceKey); + if (curve25519Keys.has(curve25519Key)) { + parentLog.log({ + l: "ignore device with duplicate curve25519 key", + keys: deviceKey + }, parentLog.level.Warn); + return false; + } + curve25519Keys.add(curve25519Key); + return true; + } else { + return false; + } + }); + }); + const verifiedKeys = verifiedEntries.map(([, deviceKeys]) => deviceKeys); + keys.set(userId, verifiedKeys); }); - const verifiedKeys = verifiedEntries.map(([, deviceKeys]) => deviceKeys); - return {userId, verifiedKeys}; - }); - return verifiedKeys; + } + return keys; } - _hasValidSignature(deviceSection, parentLog) { - const deviceId = deviceSection["device_id"]; - const userId = deviceSection["user_id"]; - const ed25519Key = deviceSection?.keys?.[`${SIGNATURE_ALGORITHM}:${deviceId}`]; - return verifyEd25519Signature(this._olmUtil, userId, deviceId, ed25519Key, deviceSection, parentLog); + _validateDeviceKey(userIdFromServer: string, deviceIdFromServer: string, deviceKey: DeviceKey, log: ILogItem): boolean { + const deviceId = deviceKey["device_id"]; + const userId = deviceKey["user_id"]; + if (userId !== userIdFromServer) { + log.log("user_id mismatch"); + return false; + } + if (deviceId !== deviceIdFromServer) { + log.log("device_id mismatch"); + return false; + } + const ed25519Key = getDeviceEd25519Key(deviceKey); + const curve25519Key = getDeviceCurve25519Key(deviceKey); + if (typeof ed25519Key !== "string" || typeof curve25519Key !== "string") { + log.log("ed25519 and/or curve25519 key invalid").set({deviceKey}); + return false; + } + const isValid = verifyEd25519Signature(this._olmUtil, userId, deviceId, ed25519Key, deviceKey, log) === SignatureVerification.Valid; + if (!isValid) { + log.log({ + l: "ignore device with invalid signature", + keys: deviceKey + }, log.level.Warn); + } + return isValid; } /** @@ -348,7 +463,7 @@ export class DeviceTracker { * @param {String} roomId [description] * @return {[type]} [description] */ - async devicesForTrackedRoom(roomId, hsApi, log) { + async devicesForTrackedRoom(roomId: string, hsApi: HomeServerApi, log: ILogItem): Promise { const txn = await this._storage.readTxn([ this._storage.storeNames.roomMembers, this._storage.storeNames.userIdentities, @@ -367,8 +482,9 @@ export class DeviceTracker { /** * Can be used to decide which users to share keys with. * Assumes room is already tracked. Call `trackRoom` first if unsure. + * This will not return the device key for our own user, as we don't need to share keys with ourselves. */ - async devicesForRoomMembers(roomId, userIds, hsApi, log) { + async devicesForRoomMembers(roomId: string, userIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise { const txn = await this._storage.readTxn([ this._storage.storeNames.userIdentities, ]); @@ -378,19 +494,20 @@ export class DeviceTracker { /** * Cannot be used to decide which users to share keys with. * Does not assume membership to any room or whether any room is tracked. + * This will return device keys for our own user, including our own device. */ - async devicesForUsers(userIds, hsApi, log) { + async devicesForUsers(userIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise { const txn = await this._storage.readTxn([ this._storage.storeNames.userIdentities, ]); - const upToDateIdentities = []; - const outdatedUserIds = []; + const upToDateIdentities: UserIdentity[] = []; + const outdatedUserIds: string[] = []; await Promise.all(userIds.map(async userId => { const i = await txn.userIdentities.get(userId); - if (i && i.deviceTrackingStatus === TRACKING_STATUS_UPTODATE) { + if (i && i.keysTrackingStatus === KeysTrackingStatus.UpToDate) { upToDateIdentities.push(i); - } else if (!i || i.deviceTrackingStatus === TRACKING_STATUS_OUTDATED) { + } else if (!i || i.keysTrackingStatus === KeysTrackingStatus.Outdated) { // allow fetching for userIdentities we don't know about yet, // as we don't assume the room is tracked here. outdatedUserIds.push(userId); @@ -399,6 +516,57 @@ export class DeviceTracker { return this._devicesForUserIdentities(upToDateIdentities, outdatedUserIds, hsApi, log); } + /** Gets a single device */ + async deviceForId(userId: string, deviceId: string, hsApi: HomeServerApi, log: ILogItem) { + const txn = await this._storage.readTxn([ + this._storage.storeNames.deviceKeys, + ]); + let deviceKey = await txn.deviceKeys.get(userId, deviceId); + if (deviceKey) { + log.set("existingDevice", true); + } else { + //// BEGIN EXTRACT (deviceKeysMap) + const deviceKeyResponse = await hsApi.queryKeys({ + "timeout": 10000, + "device_keys": { + [userId]: [deviceId] + }, + "token": this._getSyncToken() + }, {log}).response(); + // verify signature + const verifiedKeysPerUser = log.wrap("verify", log => this._filterVerifiedDeviceKeys(deviceKeyResponse["device_keys"], log)); + //// END EXTRACT + const verifiedKey = verifiedKeysPerUser.get(userId)?.find(d => d.device_id === deviceId); + // user hasn't uploaded keys for device? + if (!verifiedKey) { + return undefined; + } + const txn = await this._storage.readWriteTxn([ + this._storage.storeNames.deviceKeys, + ]); + // check again we don't have the device already. + // when updating all keys for a user we allow updating the + // device when the key hasn't changed so the device display name + // can be updated, but here we don't. + const existingDevice = await txn.deviceKeys.get(userId, deviceId); + if (existingDevice) { + deviceKey = existingDevice; + log.set("existingDeviceAfterFetch", true); + } else { + try { + txn.deviceKeys.set(verifiedKey); + deviceKey = verifiedKey; + log.set("newDevice", true); + } catch (err) { + txn.abort(); + throw err; + } + await txn.complete(); + } + } + return deviceKey; + } + /** * Gets all the device identities with which keys should be shared for a set of users in a tracked room. * If any userIdentities are outdated, it will fetch them from the homeserver. @@ -406,9 +574,9 @@ export class DeviceTracker { * @param {Array} userIds a set of user ids to try and find the identity for. * @param {Transaction} userIdentityTxn to read the user identities * @param {HomeServerApi} hsApi - * @return {Array} all devices identities for the given users we should share keys with. + * @return {Array} all devices identities for the given users we should share keys with. */ - async _devicesForUserIdsInTrackedRoom(roomId, userIds, userIdentityTxn, hsApi, log) { + async _devicesForUserIdsInTrackedRoom(roomId: string, userIds: string[], userIdentityTxn: Transaction, hsApi: HomeServerApi, log: ILogItem): Promise { const allMemberIdentities = await Promise.all(userIds.map(userId => userIdentityTxn.userIdentities.get(userId))); const identities = allMemberIdentities.filter(identity => { // we use roomIds to decide with whom we should share keys for a given room, @@ -417,15 +585,15 @@ export class DeviceTracker { // Given we assume the room is tracked, // also exclude any userId which doesn't have a userIdentity yet. return identity && identity.roomIds.includes(roomId); - }); - const upToDateIdentities = identities.filter(i => i.deviceTrackingStatus === TRACKING_STATUS_UPTODATE); + }) as UserIdentity[]; // undefined has been filter out + const upToDateIdentities = identities.filter(i => i.keysTrackingStatus === KeysTrackingStatus.UpToDate); const outdatedUserIds = identities - .filter(i => i.deviceTrackingStatus === TRACKING_STATUS_OUTDATED) + .filter(i => i.keysTrackingStatus === KeysTrackingStatus.Outdated) .map(i => i.userId); let devices = await this._devicesForUserIdentities(upToDateIdentities, outdatedUserIds, hsApi, log); // filter out our own device as we should never share keys with it. devices = devices.filter(device => { - const isOwnDevice = device.userId === this._ownUserId && device.deviceId === this._ownDeviceId; + const isOwnDevice = device.user_id === this._ownUserId && device.device_id === this._ownDeviceId; return !isOwnDevice; }); return devices; @@ -435,42 +603,44 @@ export class DeviceTracker { * are known to be up to date, and a set of userIds that are known * to be absent from our store our outdated. The outdated user ids * will have their keys fetched from the homeserver. */ - async _devicesForUserIdentities(upToDateIdentities, outdatedUserIds, hsApi, log) { + async _devicesForUserIdentities(upToDateIdentities: UserIdentity[], outdatedUserIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise { log.set("uptodate", upToDateIdentities.length); log.set("outdated", outdatedUserIds.length); - let queriedDevices; + let queriedDeviceKeys: Map | undefined; if (outdatedUserIds.length) { // TODO: ignore the race between /sync and /keys/query for now, // where users could get marked as outdated or added/removed from the room while // querying keys - queriedDevices = await this._queryKeys(outdatedUserIds, hsApi, log); + const {deviceKeys} = await this._queryKeys(outdatedUserIds, hsApi, log); + queriedDeviceKeys = deviceKeys; } const deviceTxn = await this._storage.readTxn([ - this._storage.storeNames.deviceIdentities, + this._storage.storeNames.deviceKeys, ]); const devicesPerUser = await Promise.all(upToDateIdentities.map(identity => { - return deviceTxn.deviceIdentities.getAllForUserId(identity.userId); + return deviceTxn.deviceKeys.getAllForUserId(identity.userId); })); let flattenedDevices = devicesPerUser.reduce((all, devicesForUser) => all.concat(devicesForUser), []); - if (queriedDevices && queriedDevices.length) { - flattenedDevices = flattenedDevices.concat(queriedDevices); + if (queriedDeviceKeys && queriedDeviceKeys.size) { + for (const deviceKeysForUser of queriedDeviceKeys.values()) { + flattenedDevices = flattenedDevices.concat(deviceKeysForUser); + } } return flattenedDevices; } - async getDeviceByCurve25519Key(curve25519Key, txn) { - return await txn.deviceIdentities.getByCurve25519Key(curve25519Key); + async getDeviceByCurve25519Key(curve25519Key, txn: Transaction): Promise { + return await txn.deviceKeys.getByCurve25519Key(curve25519Key); } } import {createMockStorage} from "../../mocks/Storage"; import {Instance as NullLoggerInstance} from "../../logging/NullLogger"; -import {MemberChange} from "../room/members/RoomMember"; export function tests() { - function createUntrackedRoomMock(roomId, joinedUserIds, invitedUserIds = []) { + function createUntrackedRoomMock(roomId: string, joinedUserIds: string[], invitedUserIds: string[] = []) { return { id: roomId, isTrackingMembers: false, @@ -499,11 +669,11 @@ export function tests() { } } - function createQueryKeysHSApiMock(createKey = (algorithm, userId, deviceId) => `${algorithm}:${userId}:${deviceId}:key`) { + function createQueryKeysHSApiMock(createKey = (algorithm, userId, deviceId) => `${algorithm}:${userId}:${deviceId}:key`): HomeServerApi { return { queryKeys(payload) { const {device_keys: deviceKeys} = payload; - const userKeys = Object.entries(deviceKeys).reduce((userKeys, [userId, deviceIds]) => { + const userKeys = Object.entries(deviceKeys as {[userId: string]: string[]}).reduce((userKeys, [userId, deviceIds]) => { if (deviceIds.length === 0) { deviceIds = ["device1"]; } @@ -539,7 +709,7 @@ export function tests() { } }; } - }; + } as unknown as HomeServerApi; } async function writeMemberListToStorage(room, storage) { @@ -568,7 +738,7 @@ export function tests() { const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); @@ -578,12 +748,12 @@ export function tests() { assert.deepEqual(await txn.userIdentities.get("@alice:hs.tld"), { userId: "@alice:hs.tld", roomIds: [roomId], - deviceTrackingStatus: TRACKING_STATUS_OUTDATED + keysTrackingStatus: KeysTrackingStatus.Outdated }); assert.deepEqual(await txn.userIdentities.get("@bob:hs.tld"), { userId: "@bob:hs.tld", roomIds: [roomId], - deviceTrackingStatus: TRACKING_STATUS_OUTDATED + keysTrackingStatus: KeysTrackingStatus.Outdated }); assert.equal(await txn.userIdentities.get("@charly:hs.tld"), undefined); }, @@ -592,7 +762,7 @@ export function tests() { const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); @@ -601,15 +771,15 @@ export function tests() { const hsApi = createQueryKeysHSApiMock(); const devices = await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApi, NullLoggerInstance.item); assert.equal(devices.length, 2); - assert.equal(devices.find(d => d.userId === "@alice:hs.tld").ed25519Key, "ed25519:@alice:hs.tld:device1:key"); - assert.equal(devices.find(d => d.userId === "@bob:hs.tld").ed25519Key, "ed25519:@bob:hs.tld:device1:key"); + assert.equal(getDeviceEd25519Key(devices.find(d => d.user_id === "@alice:hs.tld")!), "ed25519:@alice:hs.tld:device1:key"); + assert.equal(getDeviceEd25519Key(devices.find(d => d.user_id === "@bob:hs.tld")!), "ed25519:@bob:hs.tld:device1:key"); }, "device with changed key is ignored": async assert => { const storage = await createMockStorage(); const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); @@ -627,18 +797,18 @@ export function tests() { }); const devices = await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApiWithChangedAliceKey, NullLoggerInstance.item); assert.equal(devices.length, 2); - assert.equal(devices.find(d => d.userId === "@alice:hs.tld").ed25519Key, "ed25519:@alice:hs.tld:device1:key"); - assert.equal(devices.find(d => d.userId === "@bob:hs.tld").ed25519Key, "ed25519:@bob:hs.tld:device1:key"); - const txn2 = await storage.readTxn([storage.storeNames.deviceIdentities]); + assert.equal(getDeviceEd25519Key(devices.find(d => d.user_id === "@alice:hs.tld")!), "ed25519:@alice:hs.tld:device1:key"); + assert.equal(getDeviceEd25519Key(devices.find(d => d.user_id === "@bob:hs.tld")!), "ed25519:@bob:hs.tld:device1:key"); + const txn2 = await storage.readTxn([storage.storeNames.deviceKeys]); // also check the modified key was not stored - assert.equal((await txn2.deviceIdentities.get("@alice:hs.tld", "device1")).ed25519Key, "ed25519:@alice:hs.tld:device1:key"); + assert.equal(getDeviceEd25519Key((await txn2.deviceKeys.get("@alice:hs.tld", "device1"))!), "ed25519:@alice:hs.tld:device1:key"); }, "change history visibility from joined to invited adds invitees": async assert => { const storage = await createMockStorage(); const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); @@ -646,10 +816,10 @@ export function tests() { const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"], ["@bob:hs.tld"]); await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item); - const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceKeys]); assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined); const {added, removed} = await tracker.writeHistoryVisibility(room, HistoryVisibility.Invited, txn, NullLoggerInstance.item); - assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld"); + assert.equal((await txn.userIdentities.get("@bob:hs.tld"))!.userId, "@bob:hs.tld"); assert.deepEqual(added, ["@bob:hs.tld"]); assert.deepEqual(removed, []); }, @@ -658,7 +828,7 @@ export function tests() { const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); @@ -666,8 +836,8 @@ export function tests() { const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"], ["@bob:hs.tld"]); await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item); - const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); - assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld"); + const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceKeys]); + assert.equal((await txn.userIdentities.get("@bob:hs.tld"))!.userId, "@bob:hs.tld"); const {added, removed} = await tracker.writeHistoryVisibility(room, HistoryVisibility.Joined, txn, NullLoggerInstance.item); assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined); assert.deepEqual(added, []); @@ -678,32 +848,32 @@ export function tests() { const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"]); await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item); - const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceKeys]); // inviting a new member const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "invite")); - const {added, removed} = await tracker.writeMemberChanges(room, [inviteChange], HistoryVisibility.Invited, txn); + const {added, removed} = await tracker.writeMemberChanges(room, new Map([[inviteChange.userId, inviteChange]]), HistoryVisibility.Invited, txn); assert.deepEqual(added, ["@bob:hs.tld"]); assert.deepEqual(removed, []); - assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld"); + assert.equal((await txn.userIdentities.get("@bob:hs.tld"))!.userId, "@bob:hs.tld"); }, "adding invitee with history visibility of joined doesn't add room": async assert => { const storage = await createMockStorage(); const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"]); await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item); - const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceKeys]); // inviting a new member const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "invite")); const memberChanges = new Map([[inviteChange.userId, inviteChange]]); @@ -717,7 +887,7 @@ export function tests() { const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); @@ -729,22 +899,22 @@ export function tests() { await writeMemberListToStorage(room, storage); const devices = await tracker.devicesForTrackedRoom(roomId, hsApi, NullLoggerInstance.item); assert.equal(devices.length, 2); - assert.equal(devices.find(d => d.userId === "@alice:hs.tld").ed25519Key, "ed25519:@alice:hs.tld:device1:key"); - assert.equal(devices.find(d => d.userId === "@bob:hs.tld").ed25519Key, "ed25519:@bob:hs.tld:device1:key"); + assert.equal(getDeviceEd25519Key(devices.find(d => d.user_id === "@alice:hs.tld")!), "ed25519:@alice:hs.tld:device1:key"); + assert.equal(getDeviceEd25519Key(devices.find(d => d.user_id === "@bob:hs.tld")!), "ed25519:@bob:hs.tld:device1:key"); }, "rejecting invite with history visibility of invited removes room from user identity": async assert => { const storage = await createMockStorage(); const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); // alice is joined, bob is invited const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"], ["@bob:hs.tld"]); await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item); - const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceKeys]); // reject invite const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "leave"), "invite"); const memberChanges = new Map([[inviteChange.userId, inviteChange]]); @@ -758,7 +928,7 @@ export function tests() { const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); @@ -768,21 +938,21 @@ export function tests() { await tracker.trackRoom(room1, HistoryVisibility.Joined, NullLoggerInstance.item); await tracker.trackRoom(room2, HistoryVisibility.Joined, NullLoggerInstance.item); const txn1 = await storage.readTxn([storage.storeNames.userIdentities]); - assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld", "!def:hs.tld"]); + assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld"))!.roomIds, ["!abc:hs.tld", "!def:hs.tld"]); const leaveChange = new MemberChange(RoomMember.fromUserId(room2.id, "@bob:hs.tld", "leave"), "join"); const memberChanges = new Map([[leaveChange.userId, leaveChange]]); - const txn2 = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + const txn2 = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceKeys]); await tracker.writeMemberChanges(room2, memberChanges, HistoryVisibility.Joined, txn2); await txn2.complete(); const txn3 = await storage.readTxn([storage.storeNames.userIdentities]); - assert.deepEqual((await txn3.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld"]); + assert.deepEqual((await txn3.userIdentities.get("@bob:hs.tld"))!.roomIds, ["!abc:hs.tld"]); }, "add room to user identity sharing multiple rooms with us preserves other room": async assert => { const storage = await createMockStorage(); const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); @@ -791,40 +961,40 @@ export function tests() { const room2 = await createUntrackedRoomMock("!def:hs.tld", ["@alice:hs.tld", "@bob:hs.tld"]); await tracker.trackRoom(room1, HistoryVisibility.Joined, NullLoggerInstance.item); const txn1 = await storage.readTxn([storage.storeNames.userIdentities]); - assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld"]); + assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld"))!.roomIds, ["!abc:hs.tld"]); await tracker.trackRoom(room2, HistoryVisibility.Joined, NullLoggerInstance.item); const txn2 = await storage.readTxn([storage.storeNames.userIdentities]); - assert.deepEqual((await txn2.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld", "!def:hs.tld"]); + assert.deepEqual((await txn2.userIdentities.get("@bob:hs.tld"))!.roomIds, ["!abc:hs.tld", "!def:hs.tld"]); }, "devicesForUsers fetches users even though they aren't in any tracked room": async assert => { const storage = await createMockStorage(); const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); const hsApi = createQueryKeysHSApiMock(); const devices = await tracker.devicesForUsers(["@bob:hs.tld"], hsApi, NullLoggerInstance.item); assert.equal(devices.length, 1); - assert.equal(devices[0].curve25519Key, "curve25519:@bob:hs.tld:device1:key"); + assert.equal(getDeviceCurve25519Key(devices[0]), "curve25519:@bob:hs.tld:device1:key"); const txn1 = await storage.readTxn([storage.storeNames.userIdentities]); - assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld")).roomIds, []); + assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld"))!.roomIds, []); }, "devicesForUsers doesn't add any roomId when creating userIdentity": async assert => { const storage = await createMockStorage(); const tracker = new DeviceTracker({ storage, getSyncToken: () => "token", - olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + olmUtil: {ed25519_verify: () => {}} as unknown as Olm.Utility, // valid if it does not throw ownUserId: "@alice:hs.tld", ownDeviceId: "ABCD", }); const hsApi = createQueryKeysHSApiMock(); await tracker.devicesForUsers(["@bob:hs.tld"], hsApi, NullLoggerInstance.item); const txn1 = await storage.readTxn([storage.storeNames.userIdentities]); - assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld")).roomIds, []); + assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld"))!.roomIds, []); } } } diff --git a/src/matrix/e2ee/README.md b/src/matrix/e2ee/README.md index fab53880..fdb4866c 100644 --- a/src/matrix/e2ee/README.md +++ b/src/matrix/e2ee/README.md @@ -41,5 +41,8 @@ Runs before any room.prepareSync, so the new room keys can be passed to each roo - e2ee account - generate more otks if needed - upload new otks if needed or device keys if not uploaded before + - device message handler: + - fetch keys we don't know about yet for (call) to_device messages identity + - pass signalling messages to call handler - rooms - share new room keys if needed diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index 27388e1c..bd0defac 100644 --- a/src/matrix/e2ee/RoomEncryption.js +++ b/src/matrix/e2ee/RoomEncryption.js @@ -14,11 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MEGOLM_ALGORITHM, DecryptionSource} from "./common.js"; +import {MEGOLM_ALGORITHM, DecryptionSource} from "./common"; import {groupEventsBySession} from "./megolm/decryption/utils"; import {mergeMap} from "../../utils/mergeMap"; import {groupBy} from "../../utils/groupBy"; -import {makeTxnId} from "../common.js"; +import {makeTxnId, formatToDeviceMessagesPayload} from "../common.js"; import {iterateResponseStateEvents} from "../room/common"; const ENCRYPTED_TYPE = "m.room.encrypted"; @@ -235,7 +235,7 @@ export class RoomEncryption { // Use devicesForUsers rather than devicesForRoomMembers as the room might not be tracked yet await this._deviceTracker.devicesForUsers(sendersWithoutDevice, hsApi, log); // now that we've fetched the missing devices, try verifying the results again - const txn = await this._storage.readTxn([this._storage.storeNames.deviceIdentities]); + const txn = await this._storage.readTxn([this._storage.storeNames.deviceKeys]); await this._verifyDecryptionResults(resultsWithoutDevice, txn); const resultsWithFoundDevice = resultsWithoutDevice.filter(r => !r.isVerificationUnknown); const resultsToEventIdMap = resultsWithFoundDevice.reduce((map, r) => { @@ -457,6 +457,7 @@ export class RoomEncryption { await writeTxn.complete(); } + // TODO: make this use _sendMessagesToDevices async _sendSharedMessageToDevices(type, message, devices, hsApi, log) { const devicesByUser = groupBy(devices, device => device.userId); const payload = { @@ -474,16 +475,7 @@ export class RoomEncryption { async _sendMessagesToDevices(type, messages, hsApi, log) { log.set("messages", messages.length); - const messagesByUser = groupBy(messages, message => message.device.userId); - const payload = { - messages: Array.from(messagesByUser.entries()).reduce((userMap, [userId, messages]) => { - userMap[userId] = messages.reduce((deviceMap, message) => { - deviceMap[message.device.deviceId] = message.content; - return deviceMap; - }, {}); - return userMap; - }, {}) - }; + const payload = formatToDeviceMessagesPayload(messages); const txnId = makeTxnId(); await hsApi.sendToDevice(type, payload, txnId, {log}).response(); } diff --git a/src/matrix/e2ee/common.js b/src/matrix/e2ee/common.js deleted file mode 100644 index cc3bfff5..00000000 --- a/src/matrix/e2ee/common.js +++ /dev/null @@ -1,96 +0,0 @@ -/* -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import anotherjson from "another-json"; -import {createEnum} from "../../utils/enum"; - -export const DecryptionSource = createEnum("Sync", "Timeline", "Retry"); - -// use common prefix so it's easy to clear properties that are not e2ee related during session clear -export const SESSION_E2EE_KEY_PREFIX = "e2ee:"; -export const OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2"; -export const MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2"; - -export class DecryptionError extends Error { - constructor(code, event, detailsObj = null) { - super(`Decryption error ${code}${detailsObj ? ": "+JSON.stringify(detailsObj) : ""}`); - this.code = code; - this.event = event; - this.details = detailsObj; - } -} - -export const SIGNATURE_ALGORITHM = "ed25519"; - -export function verifyEd25519Signature(olmUtil, userId, deviceOrKeyId, ed25519Key, value, log = undefined) { - const clone = Object.assign({}, value); - delete clone.unsigned; - delete clone.signatures; - const canonicalJson = anotherjson.stringify(clone); - const signature = value?.signatures?.[userId]?.[`${SIGNATURE_ALGORITHM}:${deviceOrKeyId}`]; - try { - if (!signature) { - throw new Error("no signature"); - } - // throws when signature is invalid - olmUtil.ed25519_verify(ed25519Key, canonicalJson, signature); - return true; - } catch (err) { - if (log) { - const logItem = log.log({l: "Invalid signature, ignoring.", ed25519Key, canonicalJson, signature}); - logItem.error = err; - logItem.logLevel = log.level.Warn; - } - return false; - } -} - -export function createRoomEncryptionEvent() { - return { - "type": "m.room.encryption", - "state_key": "", - "content": { - "algorithm": MEGOLM_ALGORITHM, - "rotation_period_ms": 604800000, - "rotation_period_msgs": 100 - } - } -} - - -// Use enum when converting to TS -export const HistoryVisibility = Object.freeze({ - Joined: "joined", - Invited: "invited", - WorldReadable: "world_readable", - Shared: "shared", -}); - -export function shouldShareKey(membership, historyVisibility) { - switch (historyVisibility) { - case HistoryVisibility.WorldReadable: - return true; - case HistoryVisibility.Shared: - // was part of room at some time - return membership !== undefined; - case HistoryVisibility.Joined: - return membership === "join"; - case HistoryVisibility.Invited: - return membership === "invite" || membership === "join"; - default: - return false; - } -} diff --git a/src/matrix/e2ee/common.ts b/src/matrix/e2ee/common.ts new file mode 100644 index 00000000..c8a5ec0f --- /dev/null +++ b/src/matrix/e2ee/common.ts @@ -0,0 +1,134 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import anotherjson from "another-json"; + +import type {UnsentStateEvent} from "../room/common"; +import type {ILogItem} from "../../logging/types"; +import type * as OlmNamespace from "@matrix-org/olm"; +type Olm = typeof OlmNamespace; + +export enum DecryptionSource { + Sync, Timeline, Retry +}; + +// use common prefix so it's easy to clear properties that are not e2ee related during session clear +export const SESSION_E2EE_KEY_PREFIX = "e2ee:"; +export const OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2"; +export const MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2"; + +export class DecryptionError extends Error { + constructor(private readonly code: string, private readonly event: object, private readonly detailsObj?: object) { + super(`Decryption error ${code}${detailsObj ? ": "+JSON.stringify(detailsObj) : ""}`); + } +} + +export const SIGNATURE_ALGORITHM = "ed25519"; + +export type SignedValue = { + signatures?: {[userId: string]: {[keyId: string]: string}} + unsigned?: object +} + +// we store device keys (and cross-signing) in the format we get them from the server +// as that is what the signature is calculated on, so to verify and sign, we need +// it in this format anyway. +export type DeviceKey = SignedValue & { + readonly user_id: string; + readonly device_id: string; + readonly algorithms: ReadonlyArray; + readonly keys: {[keyId: string]: string}; + readonly unsigned: { + device_display_name?: string + } +} + +export function getDeviceEd25519Key(deviceKey: DeviceKey): string { + return deviceKey.keys[`ed25519:${deviceKey.device_id}`]; +} + +export function getDeviceCurve25519Key(deviceKey: DeviceKey): string { + return deviceKey.keys[`curve25519:${deviceKey.device_id}`]; +} + +export function getEd25519Signature(signedValue: SignedValue, userId: string, deviceOrKeyId: string): string | undefined { + return signedValue?.signatures?.[userId]?.[`${SIGNATURE_ALGORITHM}:${deviceOrKeyId}`]; +} + +export enum SignatureVerification { + Valid, + Invalid, + NotSigned, +} + +export function verifyEd25519Signature(olmUtil: Olm.Utility, userId: string, deviceOrKeyId: string, ed25519Key: string, value: SignedValue, log?: ILogItem): SignatureVerification { + const signature = getEd25519Signature(value, userId, deviceOrKeyId); + if (!signature) { + log?.set("no_signature", true); + return SignatureVerification.NotSigned; + } + const clone = Object.assign({}, value) as object; + delete clone["unsigned"]; + delete clone["signatures"]; + const canonicalJson = anotherjson.stringify(clone); + try { + // throws when signature is invalid + olmUtil.ed25519_verify(ed25519Key, canonicalJson, signature); + return SignatureVerification.Valid; + } catch (err) { + if (log) { + const logItem = log.log({l: "Invalid signature, ignoring.", ed25519Key, canonicalJson, signature}); + logItem.error = err; + logItem.logLevel = log.level.Warn; + } + return SignatureVerification.Invalid; + } +} + +export function createRoomEncryptionEvent(): UnsentStateEvent { + return { + "type": "m.room.encryption", + "state_key": "", + "content": { + "algorithm": MEGOLM_ALGORITHM, + "rotation_period_ms": 604800000, + "rotation_period_msgs": 100 + } + } +} + +export enum HistoryVisibility { + Joined = "joined", + Invited = "invited", + WorldReadable = "world_readable", + Shared = "shared", +}; + +export function shouldShareKey(membership: string, historyVisibility: HistoryVisibility) { + switch (historyVisibility) { + case HistoryVisibility.WorldReadable: + return true; + case HistoryVisibility.Shared: + // was part of room at some time + return membership !== undefined; + case HistoryVisibility.Joined: + return membership === "join"; + case HistoryVisibility.Invited: + return membership === "invite" || membership === "join"; + default: + return false; + } +} diff --git a/src/matrix/e2ee/megolm/Decryption.ts b/src/matrix/e2ee/megolm/Decryption.ts index e139e8c9..c2d56207 100644 --- a/src/matrix/e2ee/megolm/Decryption.ts +++ b/src/matrix/e2ee/megolm/Decryption.ts @@ -14,10 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {DecryptionError} from "../common.js"; import {DecryptionPreparation} from "./decryption/DecryptionPreparation.js"; import {SessionDecryption} from "./decryption/SessionDecryption"; -import {MEGOLM_ALGORITHM} from "../common.js"; +import {DecryptionError, MEGOLM_ALGORITHM} from "../common"; import {validateEvent, groupEventsBySession} from "./decryption/utils"; import {keyFromStorage, keyFromDeviceMessage, keyFromBackup} from "./decryption/RoomKey"; import type {RoomKey, IncomingRoomKey} from "./decryption/RoomKey"; diff --git a/src/matrix/e2ee/megolm/Encryption.js b/src/matrix/e2ee/megolm/Encryption.js index eb5f68d3..681344fe 100644 --- a/src/matrix/e2ee/megolm/Encryption.js +++ b/src/matrix/e2ee/megolm/Encryption.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MEGOLM_ALGORITHM} from "../common.js"; +import {MEGOLM_ALGORITHM} from "../common"; import {OutboundRoomKey} from "./decryption/RoomKey"; export class Encryption { diff --git a/src/matrix/e2ee/megolm/decryption/DecryptionChanges.js b/src/matrix/e2ee/megolm/decryption/DecryptionChanges.js index b45ab6dd..24226e25 100644 --- a/src/matrix/e2ee/megolm/decryption/DecryptionChanges.js +++ b/src/matrix/e2ee/megolm/decryption/DecryptionChanges.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {DecryptionError} from "../../common.js"; +import {DecryptionError} from "../../common"; export class DecryptionChanges { constructor(roomId, results, errors, replayEntries) { diff --git a/src/matrix/e2ee/megolm/decryption/SessionDecryption.ts b/src/matrix/e2ee/megolm/decryption/SessionDecryption.ts index ca294460..72af718c 100644 --- a/src/matrix/e2ee/megolm/decryption/SessionDecryption.ts +++ b/src/matrix/e2ee/megolm/decryption/SessionDecryption.ts @@ -15,7 +15,7 @@ limitations under the License. */ import {DecryptionResult} from "../../DecryptionResult"; -import {DecryptionError} from "../../common.js"; +import {DecryptionError} from "../../common"; import {ReplayDetectionEntry} from "./ReplayDetectionEntry"; import type {RoomKey} from "./RoomKey"; import type {KeyLoader, OlmDecryptionResult} from "./KeyLoader"; diff --git a/src/matrix/e2ee/megolm/keybackup/Curve25519.ts b/src/matrix/e2ee/megolm/keybackup/Curve25519.ts index 7d2ebac7..45cacb3f 100644 --- a/src/matrix/e2ee/megolm/keybackup/Curve25519.ts +++ b/src/matrix/e2ee/megolm/keybackup/Curve25519.ts @@ -57,6 +57,7 @@ export class BackupEncryption { encryption.set_recipient_key(pubKey); } catch(err) { decryption.free(); + encryption.free(); throw err; } return new BackupEncryption(encryption, decryption); diff --git a/src/matrix/e2ee/megolm/keybackup/KeyBackup.ts b/src/matrix/e2ee/megolm/keybackup/KeyBackup.ts index 43631552..da107502 100644 --- a/src/matrix/e2ee/megolm/keybackup/KeyBackup.ts +++ b/src/matrix/e2ee/megolm/keybackup/KeyBackup.ts @@ -19,7 +19,9 @@ import {StoredRoomKey, keyFromBackup} from "../decryption/RoomKey"; import {MEGOLM_ALGORITHM} from "../../common"; import * as Curve25519 from "./Curve25519"; import {AbortableOperation} from "../../../../utils/AbortableOperation"; -import {ObservableValue} from "../../../../observable/ObservableValue"; +import {ObservableValue} from "../../../../observable/value"; +import {Deferred} from "../../../../utils/Deferred"; +import {EventEmitter} from "../../../../utils/EventEmitter"; import {SetAbortableFn} from "../../../../utils/AbortableOperation"; import type {BackupInfo, SessionData, SessionKeyInfo, SessionInfo, KeyBackupPayload} from "./types"; @@ -31,41 +33,69 @@ import type {Storage} from "../../../storage/idb/Storage"; import type {ILogItem} from "../../../../logging/types"; import type {Platform} from "../../../../platform/web/Platform"; import type {Transaction} from "../../../storage/idb/Transaction"; +import type {IHomeServerRequest} from "../../../net/HomeServerRequest"; import type * as OlmNamespace from "@matrix-org/olm"; type Olm = typeof OlmNamespace; const KEYS_PER_REQUEST = 200; -export class KeyBackup { - public readonly operationInProgress = new ObservableValue, Progress> | undefined>(undefined); +// a set of fields we need to store once we've fetched +// the backup info from the homeserver, which happens in start() +class BackupConfig { + constructor( + public readonly info: BackupInfo, + public readonly crypto: Curve25519.BackupEncryption + ) {} +} +export class KeyBackup extends EventEmitter<{change: never}> { + private _operationInProgress?: AbortableOperation, Progress>; private _stopped = false; private _needsNewKey = false; private _hasBackedUpAllKeys = false; private _error?: Error; + private crypto?: Curve25519.BackupEncryption; + private backupInfo?: BackupInfo; + private privateKey?: Uint8Array; + private backupConfigDeferred: Deferred = new Deferred(); + private backupInfoRequest?: IHomeServerRequest; constructor( - private readonly backupInfo: BackupInfo, - private readonly crypto: Curve25519.BackupEncryption, private readonly hsApi: HomeServerApi, + private readonly olm: Olm, private readonly keyLoader: KeyLoader, private readonly storage: Storage, private readonly platform: Platform, private readonly maxDelay: number = 10000 - ) {} + ) { + super(); + // doing the network request for getting the backup info + // and hence creating the crypto instance depending on the chose algorithm + // is delayed until start() is called, but we want to already take requests + // for fetching the room keys, so put the crypto and backupInfo in a deferred. + this.backupConfigDeferred = new Deferred(); + } get hasStopped(): boolean { return this._stopped; } get error(): Error | undefined { return this._error; } - get version(): string { return this.backupInfo.version; } + get version(): string | undefined { return this.backupConfigDeferred.value?.info?.version; } get needsNewKey(): boolean { return this._needsNewKey; } get hasBackedUpAllKeys(): boolean { return this._hasBackedUpAllKeys; } + get operationInProgress(): AbortableOperation, Progress> | undefined { return this._operationInProgress; } async getRoomKey(roomId: string, sessionId: string, log: ILogItem): Promise { - const sessionResponse = await this.hsApi.roomKeyForRoomAndSession(this.backupInfo.version, roomId, sessionId, {log}).response(); + if (this.needsNewKey) { + return; + } + const backupConfig = await this.backupConfigDeferred.promise; + if (!backupConfig) { + return; + } + const sessionResponse = await this.hsApi.roomKeyForRoomAndSession(backupConfig.info.version, roomId, sessionId, {log}).response(); if (!sessionResponse.session_data) { return; } - const sessionKeyInfo = this.crypto.decryptRoomKey(sessionResponse.session_data as SessionData); + const sessionKeyInfo = backupConfig.crypto.decryptRoomKey(sessionResponse.session_data as SessionData); if (sessionKeyInfo?.algorithm === MEGOLM_ALGORITHM) { return keyFromBackup(roomId, sessionId, sessionKeyInfo); } else if (sessionKeyInfo?.algorithm) { @@ -77,8 +107,52 @@ export class KeyBackup { return txn.inboundGroupSessions.markAllAsNotBackedUp(); } + async load(secretStorage: SecretStorage, log: ILogItem) { + const base64PrivateKey = await secretStorage.readSecret("m.megolm_backup.v1"); + if (base64PrivateKey) { + this.privateKey = new Uint8Array(this.platform.encoding.base64.decode(base64PrivateKey)); + return true; + } else { + this.backupConfigDeferred.resolve(undefined); + return false; + } + } + + async start(log: ILogItem) { + await log.wrap("KeyBackup.start", async log => { + if (this.privateKey && !this.backupInfoRequest) { + let backupInfo: BackupInfo; + try { + this.backupInfoRequest = this.hsApi.roomKeysVersion(undefined, {log}); + backupInfo = await this.backupInfoRequest.response() as BackupInfo; + } catch (err) { + if (err.name === "AbortError") { + log.set("aborted", true); + return; + } else { + throw err; + } + } finally { + this.backupInfoRequest = undefined; + } + // TODO: what if backupInfo is undefined or we get 404 or something? + if (backupInfo.algorithm === Curve25519.Algorithm) { + const crypto = Curve25519.BackupEncryption.fromAuthData(backupInfo.auth_data, this.privateKey, this.olm); + this.backupConfigDeferred.resolve(new BackupConfig(backupInfo, crypto)); + this.emit("change"); + } else { + this.backupConfigDeferred.resolve(undefined); + log.log({l: `Unknown backup algorithm`, algorithm: backupInfo.algorithm}); + } + this.privateKey = undefined; + } + // fetch latest version + this.flush(log); + }); + } + flush(log: ILogItem): void { - if (!this.operationInProgress.get()) { + if (!this._operationInProgress) { log.wrapDetached("flush key backup", async log => { if (this._needsNewKey) { log.set("needsNewKey", this._needsNewKey); @@ -88,7 +162,8 @@ export class KeyBackup { this._error = undefined; this._hasBackedUpAllKeys = false; const operation = this._runFlushOperation(log); - this.operationInProgress.set(operation); + this._operationInProgress = operation; + this.emit("change"); try { await operation.result; this._hasBackedUpAllKeys = true; @@ -105,13 +180,18 @@ export class KeyBackup { } log.catch(err); } - this.operationInProgress.set(undefined); + this._operationInProgress = undefined; + this.emit("change"); }); } } private _runFlushOperation(log: ILogItem): AbortableOperation, Progress> { return new AbortableOperation(async (setAbortable, setProgress) => { + const backupConfig = await this.backupConfigDeferred.promise; + if (!backupConfig) { + return; + } let total = 0; let amountFinished = 0; while (true) { @@ -130,8 +210,8 @@ export class KeyBackup { log.set("total", total); return; } - const payload = await this.encodeKeysForBackup(keysNeedingBackup); - const uploadRequest = this.hsApi.uploadRoomKeysToBackup(this.backupInfo.version, payload, {log}); + const payload = await this.encodeKeysForBackup(keysNeedingBackup, backupConfig.crypto); + const uploadRequest = this.hsApi.uploadRoomKeysToBackup(backupConfig.info.version, payload, {log}); setAbortable(uploadRequest); await uploadRequest.response(); await this.markKeysAsBackedUp(keysNeedingBackup, setAbortable); @@ -141,7 +221,7 @@ export class KeyBackup { }); } - private async encodeKeysForBackup(roomKeys: RoomKey[]): Promise { + private async encodeKeysForBackup(roomKeys: RoomKey[], crypto: Curve25519.BackupEncryption): Promise { const payload: KeyBackupPayload = { rooms: {} }; const payloadRooms = payload.rooms; for (const key of roomKeys) { @@ -149,7 +229,7 @@ export class KeyBackup { if (!roomPayload) { roomPayload = payloadRooms[key.roomId] = { sessions: {} }; } - roomPayload.sessions[key.sessionId] = await this.encodeRoomKey(key); + roomPayload.sessions[key.sessionId] = await this.encodeRoomKey(key, crypto); } return payload; } @@ -170,7 +250,7 @@ export class KeyBackup { await txn.complete(); } - private async encodeRoomKey(roomKey: RoomKey): Promise { + private async encodeRoomKey(roomKey: RoomKey, crypto: Curve25519.BackupEncryption): Promise { return await this.keyLoader.useKey(roomKey, session => { const firstMessageIndex = session.first_known_index(); const sessionKey = session.export_session(firstMessageIndex); @@ -178,27 +258,14 @@ export class KeyBackup { first_message_index: firstMessageIndex, forwarded_count: 0, is_verified: false, - session_data: this.crypto.encryptRoomKey(roomKey, sessionKey) + session_data: crypto.encryptRoomKey(roomKey, sessionKey) }; }); } dispose() { - this.crypto.dispose(); - } - - static async fromSecretStorage(platform: Platform, olm: Olm, secretStorage: SecretStorage, hsApi: HomeServerApi, keyLoader: KeyLoader, storage: Storage, txn: Transaction): Promise { - const base64PrivateKey = await secretStorage.readSecret("m.megolm_backup.v1", txn); - if (base64PrivateKey) { - const privateKey = new Uint8Array(platform.encoding.base64.decode(base64PrivateKey)); - const backupInfo = await hsApi.roomKeysVersion().response() as BackupInfo; - if (backupInfo.algorithm === Curve25519.Algorithm) { - const crypto = Curve25519.BackupEncryption.fromAuthData(backupInfo.auth_data, privateKey, olm); - return new KeyBackup(backupInfo, crypto, hsApi, keyLoader, storage, platform); - } else { - throw new Error(`Unknown backup algorithm: ${backupInfo.algorithm}`); - } - } + this.backupInfoRequest?.abort(); + this.backupConfigDeferred.value?.crypto?.dispose(); } } diff --git a/src/matrix/e2ee/megolm/keybackup/types.ts b/src/matrix/e2ee/megolm/keybackup/types.ts index ce56cca7..f433a7d1 100644 --- a/src/matrix/e2ee/megolm/keybackup/types.ts +++ b/src/matrix/e2ee/megolm/keybackup/types.ts @@ -42,7 +42,7 @@ export type SessionInfo = { } export type MegOlmSessionKeyInfo = { - algorithm: MEGOLM_ALGORITHM, + algorithm: typeof MEGOLM_ALGORITHM, sender_key: string, sender_claimed_keys: {[algorithm: string]: string}, forwarding_curve25519_key_chain: string[], diff --git a/src/matrix/e2ee/olm/Decryption.ts b/src/matrix/e2ee/olm/Decryption.ts index 0f96f2fc..e1546b0b 100644 --- a/src/matrix/e2ee/olm/Decryption.ts +++ b/src/matrix/e2ee/olm/Decryption.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {DecryptionError} from "../common.js"; +import {DecryptionError} from "../common"; import {groupBy} from "../../../utils/groupBy"; import {MultiLock, ILock} from "../../../utils/Lock"; import {Session} from "./Session"; diff --git a/src/matrix/e2ee/olm/Encryption.ts b/src/matrix/e2ee/olm/Encryption.ts index 9b754272..ef16ba45 100644 --- a/src/matrix/e2ee/olm/Encryption.ts +++ b/src/matrix/e2ee/olm/Encryption.ts @@ -15,15 +15,16 @@ limitations under the License. */ import {groupByWithCreator} from "../../../utils/groupBy"; -import {verifyEd25519Signature, OLM_ALGORITHM} from "../common.js"; +import {verifyEd25519Signature, OLM_ALGORITHM, getDeviceCurve25519Key, getDeviceEd25519Key, SignatureVerification} from "../common"; import {createSessionEntry} from "./Session"; import type {OlmMessage, OlmPayload, OlmEncryptedMessageContent} from "./types"; import type {Account} from "../Account"; import type {LockMap} from "../../../utils/LockMap"; +import {Lock, MultiLock, ILock} from "../../../utils/Lock"; import type {Storage} from "../../storage/idb/Storage"; import type {Transaction} from "../../storage/idb/Transaction"; -import type {DeviceIdentity} from "../../storage/idb/stores/DeviceIdentityStore"; +import type {DeviceKey} from "../common"; import type {HomeServerApi} from "../../net/HomeServerApi"; import type {ILogItem} from "../../../logging/types"; import type * as OlmNamespace from "@matrix-org/olm"; @@ -62,6 +63,9 @@ const OTK_ALGORITHM = "signed_curve25519"; const MAX_BATCH_SIZE = 20; export class Encryption { + + private _batchLocks: Array; + constructor( private readonly account: Account, private readonly pickleKey: string, @@ -71,24 +75,52 @@ export class Encryption { private readonly ownUserId: string, private readonly olmUtil: Olm.Utility, private readonly senderKeyLock: LockMap - ) {} + ) { + this._batchLocks = new Array(MAX_BATCH_SIZE); + for (let i = 0; i < MAX_BATCH_SIZE; i += 1) { + this._batchLocks[i] = new Lock(); + } + } - async encrypt(type: string, content: Record, devices: DeviceIdentity[], hsApi: HomeServerApi, log: ILogItem): Promise { + /** A hack to prevent olm OOMing when `encrypt` is called several times concurrently, + * which is the case when encrypting voip signalling message to send over to_device. + * A better fix will be to extract the common bits from megolm/KeyLoader in a super class + * and have some sort of olm/SessionLoader that is shared between encryption and decryption + * and only keeps the olm session in wasm memory for a brief moment, like we already do for RoomKeys, + * and get the benefit of an optimal cache at the same time. + * */ + private async _takeBatchLock(amount: number): Promise { + const locks = this._batchLocks.filter(l => !l.isTaken).slice(0, amount); + if (locks.length < amount) { + const takenLocks = this._batchLocks.filter(l => l.isTaken).slice(0, amount - locks.length); + locks.push(...takenLocks); + } + await Promise.all(locks.map(l => l.take())); + return new MultiLock(locks); + } + + async encrypt(type: string, content: Record, devices: DeviceKey[], hsApi: HomeServerApi, log: ILogItem): Promise { let messages: EncryptedMessage[] = []; for (let i = 0; i < devices.length ; i += MAX_BATCH_SIZE) { const batchDevices = devices.slice(i, i + MAX_BATCH_SIZE); - const batchMessages = await this._encryptForMaxDevices(type, content, batchDevices, hsApi, log); - messages = messages.concat(batchMessages); + const batchLock = await this._takeBatchLock(batchDevices.length); + try { + const batchMessages = await this._encryptForMaxDevices(type, content, batchDevices, hsApi, log); + messages = messages.concat(batchMessages); + } + finally { + batchLock.release(); + } } return messages; } - async _encryptForMaxDevices(type: string, content: Record, devices: DeviceIdentity[], hsApi: HomeServerApi, log: ILogItem): Promise { + async _encryptForMaxDevices(type: string, content: Record, devices: DeviceKey[], hsApi: HomeServerApi, log: ILogItem): Promise { // TODO: see if we can only hold some of the locks until after the /keys/claim call (if needed) // take a lock on all senderKeys so decryption and other calls to encrypt (should not happen) // don't modify the sessions at the same time const locks = await Promise.all(devices.map(device => { - return this.senderKeyLock.takeLock(device.curve25519Key); + return this.senderKeyLock.takeLock(getDeviceCurve25519Key(device)); })); try { const { @@ -126,10 +158,10 @@ export class Encryption { } } - async _findExistingSessions(devices: DeviceIdentity[]): Promise<{devicesWithoutSession: DeviceIdentity[], existingEncryptionTargets: EncryptionTarget[]}> { + async _findExistingSessions(devices: DeviceKey[]): Promise<{devicesWithoutSession: DeviceKey[], existingEncryptionTargets: EncryptionTarget[]}> { const txn = await this.storage.readTxn([this.storage.storeNames.olmSessions]); const sessionIdsForDevice = await Promise.all(devices.map(async device => { - return await txn.olmSessions.getSessionIds(device.curve25519Key); + return await txn.olmSessions.getSessionIds(getDeviceCurve25519Key(device)); })); const devicesWithoutSession = devices.filter((_, i) => { const sessionIds = sessionIdsForDevice[i]; @@ -152,36 +184,36 @@ export class Encryption { const plaintext = JSON.stringify(this._buildPlainTextMessageForDevice(type, content, device)); const message = session!.encrypt(plaintext); const encryptedContent = { - algorithm: OLM_ALGORITHM, + algorithm: OLM_ALGORITHM as typeof OLM_ALGORITHM, sender_key: this.account.identityKeys.curve25519, ciphertext: { - [device.curve25519Key]: message + [getDeviceCurve25519Key(device)]: message } }; return encryptedContent; } - _buildPlainTextMessageForDevice(type: string, content: Record, device: DeviceIdentity): OlmPayload { + _buildPlainTextMessageForDevice(type: string, content: Record, device: DeviceKey): OlmPayload { return { keys: { "ed25519": this.account.identityKeys.ed25519 }, recipient_keys: { - "ed25519": device.ed25519Key + "ed25519": getDeviceEd25519Key(device) }, - recipient: device.userId, + recipient: device.user_id, sender: this.ownUserId, content, type } } - async _createNewSessions(devicesWithoutSession: DeviceIdentity[], hsApi: HomeServerApi, timestamp: number, log: ILogItem): Promise { + async _createNewSessions(devicesWithoutSession: DeviceKey[], hsApi: HomeServerApi, timestamp: number, log: ILogItem): Promise { const newEncryptionTargets = await log.wrap("claim", log => this._claimOneTimeKeys(hsApi, devicesWithoutSession, log)); try { for (const target of newEncryptionTargets) { const {device, oneTimeKey} = target; - target.session = await this.account.createOutboundOlmSession(device.curve25519Key, oneTimeKey); + target.session = await this.account.createOutboundOlmSession(getDeviceCurve25519Key(device), oneTimeKey); } await this._storeSessions(newEncryptionTargets, timestamp); } catch (err) { @@ -193,16 +225,16 @@ export class Encryption { return newEncryptionTargets; } - async _claimOneTimeKeys(hsApi: HomeServerApi, deviceIdentities: DeviceIdentity[], log: ILogItem): Promise { + async _claimOneTimeKeys(hsApi: HomeServerApi, deviceIdentities: DeviceKey[], log: ILogItem): Promise { // create a Map> const devicesByUser = groupByWithCreator(deviceIdentities, - (device: DeviceIdentity) => device.userId, - (): Map => new Map(), - (deviceMap: Map, device: DeviceIdentity) => deviceMap.set(device.deviceId, device) + (device: DeviceKey) => device.user_id, + (): Map => new Map(), + (deviceMap: Map, device: DeviceKey) => deviceMap.set(device.device_id, device) ); const oneTimeKeys = Array.from(devicesByUser.entries()).reduce((usersObj, [userId, deviceMap]) => { usersObj[userId] = Array.from(deviceMap.values()).reduce((devicesObj, device) => { - devicesObj[device.deviceId] = OTK_ALGORITHM; + devicesObj[device.device_id] = OTK_ALGORITHM; return devicesObj; }, {}); return usersObj; @@ -218,7 +250,7 @@ export class Encryption { return this._verifyAndCreateOTKTargets(userKeyMap, devicesByUser, log); } - _verifyAndCreateOTKTargets(userKeyMap: ClaimedOTKResponse, devicesByUser: Map>, log: ILogItem): EncryptionTarget[] { + _verifyAndCreateOTKTargets(userKeyMap: ClaimedOTKResponse, devicesByUser: Map>, log: ILogItem): EncryptionTarget[] { const verifiedEncryptionTargets: EncryptionTarget[] = []; for (const [userId, userSection] of Object.entries(userKeyMap)) { for (const [deviceId, deviceSection] of Object.entries(userSection)) { @@ -228,7 +260,7 @@ export class Encryption { const device = devicesByUser.get(userId)?.get(deviceId); if (device) { const isValidSignature = verifyEd25519Signature( - this.olmUtil, userId, deviceId, device.ed25519Key, keySection, log); + this.olmUtil, userId, deviceId, getDeviceEd25519Key(device), keySection, log) === SignatureVerification.Valid; if (isValidSignature) { const target = EncryptionTarget.fromOTK(device, keySection.key); verifiedEncryptionTargets.push(target); @@ -249,7 +281,7 @@ export class Encryption { try { await Promise.all(encryptionTargets.map(async encryptionTarget => { const sessionEntry = await txn.olmSessions.get( - encryptionTarget.device.curve25519Key, encryptionTarget.sessionId!); + getDeviceCurve25519Key(encryptionTarget.device), encryptionTarget.sessionId!); if (sessionEntry && !failed) { const olmSession = new this.olm.Session(); olmSession.unpickle(this.pickleKey, sessionEntry.session); @@ -271,7 +303,7 @@ export class Encryption { try { for (const target of encryptionTargets) { const sessionEntry = createSessionEntry( - target.session!, target.device.curve25519Key, timestamp, this.pickleKey); + target.session!, getDeviceCurve25519Key(target.device), timestamp, this.pickleKey); txn.olmSessions.set(sessionEntry); } } catch (err) { @@ -291,16 +323,16 @@ class EncryptionTarget { public session: Olm.Session | null = null; constructor( - public readonly device: DeviceIdentity, + public readonly device: DeviceKey, public readonly oneTimeKey: string | null, public readonly sessionId: string | null ) {} - static fromOTK(device: DeviceIdentity, oneTimeKey: string): EncryptionTarget { + static fromOTK(device: DeviceKey, oneTimeKey: string): EncryptionTarget { return new EncryptionTarget(device, oneTimeKey, null); } - static fromSessionId(device: DeviceIdentity, sessionId: string): EncryptionTarget { + static fromSessionId(device: DeviceKey, sessionId: string): EncryptionTarget { return new EncryptionTarget(device, null, sessionId); } @@ -311,9 +343,9 @@ class EncryptionTarget { } } -class EncryptedMessage { +export class EncryptedMessage { constructor( public readonly content: OlmEncryptedMessageContent, - public readonly device: DeviceIdentity + public readonly device: DeviceKey ) {} } diff --git a/src/matrix/e2ee/olm/types.ts b/src/matrix/e2ee/olm/types.ts index 5302dad8..164854ad 100644 --- a/src/matrix/e2ee/olm/types.ts +++ b/src/matrix/e2ee/olm/types.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type {OLM_ALGORITHM} from "../common"; + export const enum OlmPayloadType { PreKey = 0, Normal = 1 @@ -25,7 +27,7 @@ export type OlmMessage = { } export type OlmEncryptedMessageContent = { - algorithm?: "m.olm.v1.curve25519-aes-sha2" + algorithm?: typeof OLM_ALGORITHM sender_key?: string, ciphertext?: { [deviceCurve25519Key: string]: OlmMessage diff --git a/src/matrix/net/HomeServerApi.ts b/src/matrix/net/HomeServerApi.ts index bb791577..c5f90555 100644 --- a/src/matrix/net/HomeServerApi.ts +++ b/src/matrix/net/HomeServerApi.ts @@ -159,6 +159,10 @@ export class HomeServerApi { state(roomId: string, eventType: string, stateKey: string, options?: BaseRequestOptions): IHomeServerRequest { return this._get(`/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/${encodeURIComponent(stateKey)}`, {}, undefined, options); } + + sendState(roomId: string, eventType: string, stateKey: string, content: Record, options?: BaseRequestOptions): IHomeServerRequest { + return this._put(`/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/${encodeURIComponent(stateKey)}`, {}, content, options); + } getLoginFlows(): IHomeServerRequest { return this._unauthedRequest("GET", this._url("/login")); @@ -219,6 +223,10 @@ export class HomeServerApi { return this._post(path, {}, payload, options); } + uploadSignatures(payload: Record, options?: BaseRequestOptions): IHomeServerRequest { + return this._post("/keys/signatures/upload", {}, payload, options); + } + queryKeys(queryRequest: Record, options?: BaseRequestOptions): IHomeServerRequest { return this._post("/keys/query", {}, queryRequest, options); } @@ -301,10 +309,14 @@ export class HomeServerApi { createRoom(payload: Record, options?: BaseRequestOptions): IHomeServerRequest { return this._post(`/createRoom`, {}, payload, options); } - + setAccountData(ownUserId: string, type: string, content: Record, options?: BaseRequestOptions): IHomeServerRequest { return this._put(`/user/${encodeURIComponent(ownUserId)}/account_data/${encodeURIComponent(type)}`, {}, content, options); } + + getTurnServer(options?: BaseRequestOptions): IHomeServerRequest { + return this._get(`/voip/turnServer`, undefined, undefined, options); + } } import {Request as MockRequest} from "../../mocks/Request.js"; diff --git a/src/matrix/net/MediaRepository.ts b/src/matrix/net/MediaRepository.ts index 357b17c6..e95ed60c 100644 --- a/src/matrix/net/MediaRepository.ts +++ b/src/matrix/net/MediaRepository.ts @@ -29,32 +29,31 @@ export class MediaRepository { this._platform = platform; } - mxcUrlThumbnail(url: string, width: number, height: number, method: "crop" | "scale"): string | null { + mxcUrlThumbnail(url: string, width: number, height: number, method: "crop" | "scale"): string | undefined { const parts = this._parseMxcUrl(url); if (parts) { const [serverName, mediaId] = parts; const httpUrl = `${this._homeserver}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`; return httpUrl + "?" + encodeQueryParams({width: Math.round(width), height: Math.round(height), method}); } - return null; + return undefined; } - mxcUrl(url: string): string | null { + mxcUrl(url: string): string | undefined { const parts = this._parseMxcUrl(url); if (parts) { const [serverName, mediaId] = parts; return `${this._homeserver}/_matrix/media/r0/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`; - } else { - return null; } + return undefined; } - private _parseMxcUrl(url: string): string[] | null { + private _parseMxcUrl(url: string): string[] | undefined { const prefix = "mxc://"; if (url.startsWith(prefix)) { return url.substr(prefix.length).split("/", 2); } else { - return null; + return undefined; } } diff --git a/src/matrix/net/Reconnector.ts b/src/matrix/net/Reconnector.ts index bc54ab73..f5c24b33 100644 --- a/src/matrix/net/Reconnector.ts +++ b/src/matrix/net/Reconnector.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ObservableValue} from "../../observable/ObservableValue"; +import {ObservableValue} from "../../observable/value"; import type {ExponentialRetryDelay} from "./ExponentialRetryDelay"; import type {TimeMeasure} from "../../platform/web/dom/Clock.js"; import type {OnlineStatus} from "../../platform/web/dom/OnlineStatus.js"; diff --git a/src/matrix/room/ArchivedRoom.js b/src/matrix/room/ArchivedRoom.js index 1a23d25b..86595163 100644 --- a/src/matrix/room/ArchivedRoom.js +++ b/src/matrix/room/ArchivedRoom.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {reduceStateEvents} from "./RoomSummary.js"; +import {iterateResponseStateEvents} from "./common"; import {BaseRoom} from "./BaseRoom.js"; import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.js"; @@ -173,15 +173,15 @@ export class ArchivedRoom extends BaseRoom { } function findKickDetails(roomResponse, ownUserId) { - const kickEvent = reduceStateEvents(roomResponse, (kickEvent, event) => { + let kickEvent; + iterateResponseStateEvents(roomResponse, event => { if (event.type === MEMBER_EVENT_TYPE) { // did we get kicked? if (event.state_key === ownUserId && event.sender !== event.state_key) { kickEvent = event; } } - return kickEvent; - }, null); + }); if (kickEvent) { return { // this is different from the room membership in the sync section, which can only be leave diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index 9e12f257..7ab8a209 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -26,11 +26,13 @@ import {MemberList} from "./members/MemberList.js"; import {Heroes} from "./members/Heroes.js"; import {EventEntry} from "./timeline/entries/EventEntry.js"; import {ObservedEventMap} from "./ObservedEventMap.js"; -import {DecryptionSource} from "../e2ee/common.js"; +import {DecryptionSource} from "../e2ee/common"; import {ensureLogItem} from "../../logging/utils"; import {PowerLevels} from "./PowerLevels.js"; -import {RetainedObservableValue} from "../../observable/ObservableValue"; +import {RetainedObservableValue} from "../../observable/value"; import {TimelineReader} from "./timeline/persistence/TimelineReader"; +import {ObservedStateTypeMap} from "./state/ObservedStateTypeMap"; +import {ObservedStateKeyValue} from "./state/ObservedStateKeyValue"; const EVENT_ENCRYPTED_TYPE = "m.room.encrypted"; @@ -53,11 +55,35 @@ export class BaseRoom extends EventEmitter { this._getSyncToken = getSyncToken; this._platform = platform; this._observedEvents = null; + this._roomStateObservers = new Set(); this._powerLevels = null; this._powerLevelLoading = null; this._observedMembers = null; } + async observeStateType(type, txn = undefined) { + const map = new ObservedStateTypeMap(type); + await this._addStateObserver(map, txn); + return map; + } + + async observeStateTypeAndKey(type, stateKey, txn = undefined) { + const value = new ObservedStateKeyValue(type, stateKey); + await this._addStateObserver(value, txn); + return value; + } + + async _addStateObserver(stateObserver, txn) { + if (!txn) { + txn = await this._storage.readTxn([this._storage.storeNames.roomState]); + } + await stateObserver.load(this.id, txn); + this._roomStateObservers.add(stateObserver); + stateObserver.setRemoveCallback(() => { + this._roomStateObservers.delete(stateObserver); + }); + } + async _eventIdsToEntries(eventIds, txn) { const retryEntries = []; await Promise.all(eventIds.map(async eventId => { @@ -147,7 +173,7 @@ export class BaseRoom extends EventEmitter { const isTimelineOpen = this._isTimelineOpen; if (isTimelineOpen) { // read to fetch devices if timeline is open - stores.push(this._storage.storeNames.deviceIdentities); + stores.push(this._storage.storeNames.deviceKeys); } const writeTxn = await this._storage.readWriteTxn(stores); let decryption; @@ -324,7 +350,7 @@ export class BaseRoom extends EventEmitter { fragmentIdComparer: this._fragmentIdComparer, relationWriter }); - gapResult = await gapWriter.writeFragmentFill(fragmentEntry, response, txn, log); + gapResult = await gapWriter.writeFragmentFill(fragmentEntry, response, fragmentEntry.token, txn, log); } catch (err) { txn.abort(); throw err; @@ -433,6 +459,10 @@ export class BaseRoom extends EventEmitter { return this._summary.data.membership; } + get user() { + return this._user; + } + isDirectMessageForUserId(userId) { if (this._summary.data.dmUserId === userId) { return true; diff --git a/src/matrix/room/ObservedEventMap.js b/src/matrix/room/ObservedEventMap.js index 6b20f85e..7b1906a0 100644 --- a/src/matrix/room/ObservedEventMap.js +++ b/src/matrix/room/ObservedEventMap.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableValue} from "../../observable/ObservableValue"; +import {BaseObservableValue} from "../../observable/value"; export class ObservedEventMap { constructor(notifyEmpty) { diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index d3d1a3b0..47da3c03 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -22,7 +22,8 @@ import {SendQueue} from "./sending/SendQueue.js"; import {WrappedError} from "../error.js" import {Heroes} from "./members/Heroes.js"; import {AttachmentUpload} from "./AttachmentUpload.js"; -import {DecryptionSource} from "../e2ee/common.js"; +import {DecryptionSource} from "../e2ee/common"; +import {iterateResponseStateEvents} from "./common"; import {PowerLevels, EVENT_TYPE as POWERLEVELS_EVENT_TYPE } from "./PowerLevels.js"; const EVENT_ENCRYPTED_TYPE = "m.room.encrypted"; @@ -30,6 +31,7 @@ const EVENT_ENCRYPTED_TYPE = "m.room.encrypted"; export class Room extends BaseRoom { constructor(options) { super(options); + this._roomStateHandler = options.roomStateHandler; // TODO: pass pendingEvents to start like pendingOperations? const {pendingEvents} = options; const relationWriter = new RelationWriter({ @@ -121,7 +123,7 @@ export class Room extends BaseRoom { txn.roomState.removeAllForRoom(this.id); txn.roomMembers.removeAllForRoom(this.id); } - const {entries: newEntries, updatedEntries, newLiveKey, memberChanges} = + const {entries: newEntries, updatedEntries, newLiveKey, memberChanges, memberSync} = await log.wrap("syncWriter", log => this._syncWriter.writeSync( roomResponse, isRejoin, summaryChanges.hasFetchedMembers, txn, log), log.level.Detail); let decryption; @@ -179,7 +181,9 @@ export class Room extends BaseRoom { removedPendingEvents = await this._sendQueue.removeRemoteEchos(roomResponse.timeline.events, txn, log); } const powerLevelsEvent = this._getPowerLevelsEvent(roomResponse); + await this._runRoomStateHandlers(roomResponse, memberSync, txn, log); return { + roomResponse, summaryChanges, roomEncryption, newEntries, @@ -203,7 +207,7 @@ export class Room extends BaseRoom { const { summaryChanges, newEntries, updatedEntries, newLiveKey, removedPendingEvents, memberChanges, powerLevelsEvent, - heroChanges, roomEncryption, encryptionChanges + heroChanges, roomEncryption, roomResponse, encryptionChanges } = changes; log.set("id", this.id); this._syncWriter.afterSync(newLiveKey); @@ -220,6 +224,7 @@ export class Room extends BaseRoom { if (this._memberList) { this._memberList.afterSync(memberChanges); } + this._roomStateHandler.updateRoomMembers(this, memberChanges); if (this._observedMembers) { this._updateObservedMembers(memberChanges); } @@ -265,6 +270,7 @@ export class Room extends BaseRoom { if (removedPendingEvents) { this._sendQueue.emitRemovals(removedPendingEvents); } + this._emitSyncRoomState(roomResponse); } _updateObservedMembers(memberChanges) { @@ -277,8 +283,13 @@ export class Room extends BaseRoom { } _getPowerLevelsEvent(roomResponse) { - const isPowerlevelEvent = event => event.state_key === "" && event.type === POWERLEVELS_EVENT_TYPE; - const powerLevelEvent = roomResponse.timeline?.events.find(isPowerlevelEvent) ?? roomResponse.state?.events.find(isPowerlevelEvent); + let powerLevelEvent; + iterateResponseStateEvents(roomResponse, event => { + if(event.state_key === "" && event.type === POWERLEVELS_EVENT_TYPE) { + powerLevelEvent = event; + } + + }); return powerLevelEvent; } @@ -464,6 +475,24 @@ export class Room extends BaseRoom { return this._sendQueue.pendingEvents; } + /** global room state handlers, run during writeSync step */ + _runRoomStateHandlers(roomResponse, memberSync, txn, log) { + const promises = []; + iterateResponseStateEvents(roomResponse, event => { + promises.push(this._roomStateHandler.handleRoomState(this, event, memberSync, txn, log)); + }); + return Promise.all(promises); + } + + /** local room state observers, run during afterSync step */ + _emitSyncRoomState(roomResponse) { + iterateResponseStateEvents(roomResponse, event => { + for (const handler of this._roomStateObservers) { + handler.handleStateEvent(event); + } + }); + } + /** @package */ writeIsTrackingMembers(value, txn) { return this._summary.writeIsTrackingMembers(value, txn); diff --git a/src/matrix/room/RoomBeingCreated.ts b/src/matrix/room/RoomBeingCreated.ts index b2c9dafb..4e908aa2 100644 --- a/src/matrix/room/RoomBeingCreated.ts +++ b/src/matrix/room/RoomBeingCreated.ts @@ -20,7 +20,7 @@ import {MediaRepository} from "../net/MediaRepository"; import {EventEmitter} from "../../utils/EventEmitter"; import {AttachmentUpload} from "./AttachmentUpload"; import {loadProfiles, Profile, UserIdProfile} from "../profile"; -import {RoomType} from "./common"; +import {RoomType, UnsentStateEvent} from "./common"; import type {HomeServerApi} from "../net/HomeServerApi"; import type {ILogItem} from "../../logging/types"; @@ -37,7 +37,7 @@ type CreateRoomPayload = { invite?: string[]; room_alias_name?: string; creation_content?: {"m.federate": boolean}; - initial_state: { type: string; state_key: string; content: Record }[]; + initial_state: UnsentStateEvent[]; power_level_content_override?: Record; } diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index a3dec467..8e1619ca 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MEGOLM_ALGORITHM} from "../e2ee/common.js"; - +import {MEGOLM_ALGORITHM} from "../e2ee/common"; +import {iterateResponseStateEvents} from "./common"; function applyTimelineEntries(data, timelineEntries, isInitialSync, canMarkUnread, ownUserId) { if (timelineEntries.length) { @@ -27,25 +27,6 @@ function applyTimelineEntries(data, timelineEntries, isInitialSync, canMarkUnrea return data; } -export function reduceStateEvents(roomResponse, callback, value) { - const stateEvents = roomResponse?.state?.events; - // state comes before timeline - if (Array.isArray(stateEvents)) { - value = stateEvents.reduce(callback, value); - } - const timelineEvents = roomResponse?.timeline?.events; - // and after that state events in the timeline - if (Array.isArray(timelineEvents)) { - value = timelineEvents.reduce((data, event) => { - if (typeof event.state_key === "string") { - value = callback(value, event); - } - return value; - }, value); - } - return value; -} - function applySyncResponse(data, roomResponse, membership, ownUserId) { if (roomResponse.summary) { data = updateSummary(data, roomResponse.summary); @@ -60,7 +41,9 @@ function applySyncResponse(data, roomResponse, membership, ownUserId) { // process state events in state and in timeline. // non-state events are handled by applyTimelineEntries // so decryption is handled properly - data = reduceStateEvents(roomResponse, (data, event) => processStateEvent(data, event, ownUserId), data); + iterateResponseStateEvents(roomResponse, event => { + data = processStateEvent(data, event, ownUserId); + }); const unreadNotifications = roomResponse.unread_notifications; if (unreadNotifications) { data = processNotificationCounts(data, unreadNotifications); diff --git a/src/matrix/room/common.ts b/src/matrix/room/common.ts index 4556302f..1174d09d 100644 --- a/src/matrix/room/common.ts +++ b/src/matrix/room/common.ts @@ -28,6 +28,8 @@ export function isRedacted(event) { return !!event?.unsigned?.redacted_because; } +export type UnsentStateEvent = { type: string; state_key: string; content: Record }; + export enum RoomStatus { None = 1 << 0, BeingCreated = 1 << 1, @@ -53,6 +55,7 @@ type RoomResponse = { } /** iterates over any state events in a sync room response, in the order that they should be applied (from older to younger events) */ + export function iterateResponseStateEvents(roomResponse: RoomResponse, callback: (StateEvent) => Promise | void): Promise | void { let promises: Promise[] | undefined = undefined; const callCallback = stateEvent => { diff --git a/src/matrix/room/state/ObservedStateKeyValue.ts b/src/matrix/room/state/ObservedStateKeyValue.ts new file mode 100644 index 00000000..942d293e --- /dev/null +++ b/src/matrix/room/state/ObservedStateKeyValue.ts @@ -0,0 +1,104 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type {StateObserver} from "./types"; +import type {StateEvent} from "../../storage/types"; +import type {Transaction} from "../../storage/idb/Transaction"; +import {BaseObservableValue} from "../../../observable/value"; + +/** + * Observable value for a state event with a given type and state key. + * Unsubscribes when last subscription is removed */ +export class ObservedStateKeyValue extends BaseObservableValue implements StateObserver { + private event?: StateEvent; + private removeCallback?: () => void; + + constructor(private readonly type: string, private readonly stateKey: string) { + super(); + } + /** @internal */ + async load(roomId: string, txn: Transaction): Promise { + this.event = (await txn.roomState.get(roomId, this.type, this.stateKey))?.event; + } + /** @internal */ + handleStateEvent(event: StateEvent) { + if (event.type === this.type && event.state_key === this.stateKey) { + this.event = event; + this.emit(this.get()); + } + } + + get(): StateEvent | undefined { + return this.event; + } + + setRemoveCallback(callback: () => void) { + this.removeCallback = callback; + } + + onUnsubscribeLast() { + this.removeCallback?.(); + } +} + +import {createMockStorage} from "../../../mocks/Storage"; + +export async function tests() { + return { + "test load and update": async assert => { + const storage = await createMockStorage(); + const writeTxn = await storage.readWriteTxn([storage.storeNames.roomState]); + writeTxn.roomState.set("!abc", { + event_id: "$abc", + type: "m.room.member", + state_key: "@alice", + sender: "@alice", + origin_server_ts: 5, + content: {} + }); + await writeTxn.complete(); + const txn = await storage.readTxn([storage.storeNames.roomState]); + const value = new ObservedStateKeyValue("m.room.member", "@alice"); + await value.load("!abc", txn); + const updates: Array = []; + assert.strictEqual(value.get()?.origin_server_ts, 5); + const unsubscribe = value.subscribe(value => updates.push(value)); + value.handleStateEvent({ + event_id: "$abc", + type: "m.room.member", + state_key: "@bob", + sender: "@alice", + origin_server_ts: 10, + content: {} + }); + assert.strictEqual(updates.length, 0); + value.handleStateEvent({ + event_id: "$abc", + type: "m.room.member", + state_key: "@alice", + sender: "@alice", + origin_server_ts: 10, + content: {} + }); + assert.strictEqual(updates.length, 1); + assert.strictEqual(updates[0]?.origin_server_ts, 10); + let removed = false; + value.setRemoveCallback(() => removed = true); + unsubscribe(); + assert(removed); + } + } +} diff --git a/src/matrix/room/state/ObservedStateTypeMap.ts b/src/matrix/room/state/ObservedStateTypeMap.ts new file mode 100644 index 00000000..97ab8268 --- /dev/null +++ b/src/matrix/room/state/ObservedStateTypeMap.ts @@ -0,0 +1,53 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type {StateObserver} from "./types"; +import type {StateEvent} from "../../storage/types"; +import type {Transaction} from "../../storage/idb/Transaction"; +import {ObservableMap} from "../../../observable/map"; + +/** + * Observable map for a given type with state keys as map keys. + * Unsubscribes when last subscription is removed */ +export class ObservedStateTypeMap extends ObservableMap implements StateObserver { + private removeCallback?: () => void; + + constructor(private readonly type: string) { + super(); + } + /** @internal */ + async load(roomId: string, txn: Transaction): Promise { + const events = await txn.roomState.getAllForType(roomId, this.type); + for (let i = 0; i < events.length; ++i) { + const {event} = events[i]; + this.add(event.state_key, event); + } + } + /** @internal */ + handleStateEvent(event: StateEvent) { + if (event.type === this.type) { + this.set(event.state_key, event); + } + } + + setRemoveCallback(callback: () => void) { + this.removeCallback = callback; + } + + onUnsubscribeLast() { + this.removeCallback?.(); + } +} diff --git a/src/matrix/room/state/RoomStateHandlerSet.ts b/src/matrix/room/state/RoomStateHandlerSet.ts new file mode 100644 index 00000000..491b02ce --- /dev/null +++ b/src/matrix/room/state/RoomStateHandlerSet.ts @@ -0,0 +1,40 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type {ILogItem} from "../../../logging/types"; +import type {StateEvent} from "../../storage/types"; +import type {Transaction} from "../../storage/idb/Transaction"; +import type {Room} from "../Room"; +import type {MemberChange} from "../members/RoomMember"; +import type {RoomStateHandler} from "./types"; +import type {MemberSync} from "../timeline/persistence/MemberWriter.js"; +import {BaseObservable} from "../../../observable/BaseObservable"; + +/** keeps track of all handlers registered with Session.observeRoomState */ +export class RoomStateHandlerSet extends BaseObservable implements RoomStateHandler { + async handleRoomState(room: Room, stateEvent: StateEvent, memberSync: MemberSync, txn: Transaction, log: ILogItem): Promise { + const promises: Promise[] = []; + for(let h of this._handlers) { + promises.push(h.handleRoomState(room, stateEvent, memberSync, txn, log)); + } + await Promise.all(promises); + } + updateRoomMembers(room: Room, memberChanges: Map) { + for(let h of this._handlers) { + h.updateRoomMembers(room, memberChanges); + } + } +} diff --git a/src/matrix/room/state/types.ts b/src/matrix/room/state/types.ts new file mode 100644 index 00000000..2e7167d2 --- /dev/null +++ b/src/matrix/room/state/types.ts @@ -0,0 +1,39 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type {Room} from "../Room"; +import type {StateEvent} from "../../storage/types"; +import type {Transaction} from "../../storage/idb/Transaction"; +import type {ILogItem} from "../../../logging/types"; +import type {MemberChange} from "../members/RoomMember"; +import type {MemberSync} from "../timeline/persistence/MemberWriter"; + +/** used for Session.observeRoomState, which observes in all room, but without loading from storage + * It receives the sync write transaction, so other stores can be updated as part of the same transaction. */ +export interface RoomStateHandler { + handleRoomState(room: Room, stateEvent: StateEvent, memberSync: MemberSync, syncWriteTxn: Transaction, log: ILogItem): Promise; + updateRoomMembers(room: Room, memberChanges: Map): void; +} + +/** + * used for Room.observeStateType and Room.observeStateTypeAndKey + * @internal + * */ +export interface StateObserver { + handleStateEvent(event: StateEvent); + load(roomId: string, txn: Transaction): Promise; + setRemoveCallback(callback: () => void); +} diff --git a/src/matrix/room/timeline/entries/BaseEventEntry.js b/src/matrix/room/timeline/entries/BaseEventEntry.js index 44fdcaec..39698557 100644 --- a/src/matrix/room/timeline/entries/BaseEventEntry.js +++ b/src/matrix/room/timeline/entries/BaseEventEntry.js @@ -181,7 +181,7 @@ export class BaseEventEntry extends BaseEntry { return createAnnotation(this.id, key); } - reply(msgtype, body) { + createReplyContent(msgtype, body) { return createReplyContent(this, msgtype, body); } diff --git a/src/matrix/room/timeline/persistence/GapWriter.js b/src/matrix/room/timeline/persistence/GapWriter.js index 4458e1c5..d9ad5476 100644 --- a/src/matrix/room/timeline/persistence/GapWriter.js +++ b/src/matrix/room/timeline/persistence/GapWriter.js @@ -154,10 +154,13 @@ export class GapWriter { return changedFragments; } - async writeFragmentFill(fragmentEntry, response, txn, log) { + /** + * @param {string} fromToken the token used to call /messages, to ensure it hasn't changed in storage + */ + async writeFragmentFill(fragmentEntry, response, fromToken, txn, log) { const {fragmentId, direction} = fragmentEntry; // chunk is in reverse-chronological order when backwards - const {chunk, start, state} = response; + const {chunk, state} = response; let {end} = response; if (!Array.isArray(chunk)) { @@ -174,8 +177,8 @@ export class GapWriter { } fragmentEntry = fragmentEntry.withUpdatedFragment(fragment); // check that the request was done with the token we are aware of (extra care to avoid timeline corruption) - if (fragmentEntry.token !== start) { - throw new Error("start is not equal to prev_batch or next_batch"); + if (fragmentEntry.token !== fromToken) { + throw new Error("The pagination token has changed locally while fetching messages."); } // begin (or end) of timeline reached @@ -263,7 +266,7 @@ export function tests() { async function backfillAndWrite(mocks, fragmentEntry, limit) { const {txn, timelineMock, gapWriter} = mocks; const messageResponse = timelineMock.messages(fragmentEntry.token, undefined, fragmentEntry.direction.asApiString(), limit); - await gapWriter.writeFragmentFill(fragmentEntry, messageResponse, txn, logger); + await gapWriter.writeFragmentFill(fragmentEntry, messageResponse, fragmentEntry.token, txn, logger); } async function allFragmentEvents(mocks, fragmentId) { diff --git a/src/matrix/room/timeline/persistence/MemberWriter.js b/src/matrix/room/timeline/persistence/MemberWriter.js index 1cdcb7d5..9345324e 100644 --- a/src/matrix/room/timeline/persistence/MemberWriter.js +++ b/src/matrix/room/timeline/persistence/MemberWriter.js @@ -56,7 +56,11 @@ export class MemberWriter { } } -class MemberSync { +/** Represents the member changes in a given sync. + * Used to write the changes to storage and historical member + * information for events in the same sync. + **/ +export class MemberSync { constructor(memberWriter, stateEvents, timelineEvents, hasFetchedMembers) { this._memberWriter = memberWriter; this._timelineEvents = timelineEvents; diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index dc5ae3a8..76c7bec7 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -244,7 +244,7 @@ export class SyncWriter { const {currentKey, entries, updatedEntries} = await this._writeTimeline(timelineEvents, timeline, memberSync, this._lastLiveKey, txn, log); const memberChanges = await memberSync.write(txn); - return {entries, updatedEntries, newLiveKey: currentKey, memberChanges}; + return {entries, updatedEntries, newLiveKey: currentKey, memberChanges, memberSync}; } afterSync(newLiveKey) { diff --git a/src/matrix/ssss/SecretStorage.ts b/src/matrix/ssss/SecretStorage.ts index c026b453..4c767bbb 100644 --- a/src/matrix/ssss/SecretStorage.ts +++ b/src/matrix/ssss/SecretStorage.ts @@ -16,6 +16,8 @@ limitations under the License. import type {Key} from "./common"; import type {Platform} from "../../platform/web/Platform.js"; import type {Transaction} from "../storage/idb/Transaction"; +import type {Storage} from "../storage/idb/Storage"; +import type {AccountDataEntry} from "../storage/idb/stores/AccountDataStore"; type EncryptedData = { iv: string; @@ -23,29 +25,72 @@ type EncryptedData = { mac: string; } +export enum DecryptionFailure { + NotEncryptedWithKey, + BadMAC, + UnsupportedAlgorithm, +} + +class DecryptionError extends Error { + constructor(msg: string, public readonly reason: DecryptionFailure) { + super(msg); + } +} + export class SecretStorage { private readonly _key: Key; private readonly _platform: Platform; + private readonly _storage: Storage; - constructor({key, platform}: {key: Key, platform: Platform}) { + constructor({key, platform, storage}: {key: Key, platform: Platform, storage: Storage}) { this._key = key; this._platform = platform; + this._storage = storage; } - async readSecret(name: string, txn: Transaction): Promise { + /** this method will auto-commit any indexeddb transaction because of its use of the webcrypto api */ + async hasValidKeyForAnyAccountData() { + const txn = await this._storage.readTxn([ + this._storage.storeNames.accountData, + ]); + const allAccountData = await txn.accountData.getAll(); + for (const accountData of allAccountData) { + try { + const secret = await this._decryptAccountData(accountData); + return true; // decryption succeeded + } catch (err) { + if (err instanceof DecryptionError && err.reason !== DecryptionFailure.NotEncryptedWithKey) { + throw err; + } else { + continue; + } + } + } + return false; + } + + /** this method will auto-commit any indexeddb transaction because of its use of the webcrypto api */ + async readSecret(name: string): Promise { + const txn = await this._storage.readTxn([ + this._storage.storeNames.accountData, + ]); const accountData = await txn.accountData.get(name); if (!accountData) { return; } + return await this._decryptAccountData(accountData); + } + + async _decryptAccountData(accountData: AccountDataEntry): Promise { const encryptedData = accountData?.content?.encrypted?.[this._key.id] as EncryptedData; if (!encryptedData) { - throw new Error(`Secret ${accountData.type} is not encrypted for key ${this._key.id}`); + throw new DecryptionError(`Secret ${accountData.type} is not encrypted for key ${this._key.id}`, DecryptionFailure.NotEncryptedWithKey); } if (this._key.algorithm === "m.secret_storage.v1.aes-hmac-sha2") { return await this._decryptAESSecret(accountData.type, encryptedData); } else { - throw new Error(`Unsupported algorithm for key ${this._key.id}: ${this._key.algorithm}`); + throw new DecryptionError(`Unsupported algorithm for key ${this._key.id}: ${this._key.algorithm}`, DecryptionFailure.UnsupportedAlgorithm); } } @@ -68,7 +113,7 @@ export class SecretStorage { ciphertextBytes, "SHA-256"); if (!isVerified) { - throw new Error("Bad MAC"); + throw new DecryptionError("Bad MAC", DecryptionFailure.BadMAC); } const plaintextBytes = await this._platform.crypto.aes.decryptCTR({ diff --git a/src/matrix/ssss/index.ts b/src/matrix/ssss/index.ts index fd4c2245..02f3290e 100644 --- a/src/matrix/ssss/index.ts +++ b/src/matrix/ssss/index.ts @@ -17,7 +17,7 @@ limitations under the License. import {KeyDescription, Key} from "./common"; import {keyFromPassphrase} from "./passphrase"; import {keyFromRecoveryKey} from "./recoveryKey"; -import {SESSION_E2EE_KEY_PREFIX} from "../e2ee/common.js"; +import {SESSION_E2EE_KEY_PREFIX} from "../e2ee/common"; import type {Storage} from "../storage/idb/Storage"; import type {Transaction} from "../storage/idb/Transaction"; import type {KeyDescriptionData} from "./common"; diff --git a/src/matrix/storage/common.ts b/src/matrix/storage/common.ts index 23bb0d31..bf9ce39b 100644 --- a/src/matrix/storage/common.ts +++ b/src/matrix/storage/common.ts @@ -26,13 +26,15 @@ export enum StoreNames { timelineFragments = "timelineFragments", pendingEvents = "pendingEvents", userIdentities = "userIdentities", - deviceIdentities = "deviceIdentities", + deviceKeys = "deviceKeys", olmSessions = "olmSessions", inboundGroupSessions = "inboundGroupSessions", outboundGroupSessions = "outboundGroupSessions", groupSessionDecryptions = "groupSessionDecryptions", operations = "operations", accountData = "accountData", + calls = "calls", + crossSigningKeys = "crossSigningKeys" } export const STORE_NAMES: Readonly = Object.values(StoreNames); diff --git a/src/matrix/storage/idb/StorageFactory.ts b/src/matrix/storage/idb/StorageFactory.ts index 722cf0a6..bc447095 100644 --- a/src/matrix/storage/idb/StorageFactory.ts +++ b/src/matrix/storage/idb/StorageFactory.ts @@ -69,7 +69,7 @@ export class StorageFactory { requestPersistedStorage().then(persisted => { // Firefox lies here though, and returns true even if the user denied the request if (!persisted) { - console.warn("no persisted storage, database can be evicted by browser"); + log.log("no persisted storage, database can be evicted by browser", log.level.Warn); } }); diff --git a/src/matrix/storage/idb/Transaction.ts b/src/matrix/storage/idb/Transaction.ts index 80894105..4c76608c 100644 --- a/src/matrix/storage/idb/Transaction.ts +++ b/src/matrix/storage/idb/Transaction.ts @@ -29,13 +29,15 @@ import {RoomMemberStore} from "./stores/RoomMemberStore"; import {TimelineFragmentStore} from "./stores/TimelineFragmentStore"; import {PendingEventStore} from "./stores/PendingEventStore"; import {UserIdentityStore} from "./stores/UserIdentityStore"; -import {DeviceIdentityStore} from "./stores/DeviceIdentityStore"; +import {DeviceKeyStore} from "./stores/DeviceKeyStore"; +import {CrossSigningKeyStore} from "./stores/CrossSigningKeyStore"; import {OlmSessionStore} from "./stores/OlmSessionStore"; import {InboundGroupSessionStore} from "./stores/InboundGroupSessionStore"; import {OutboundGroupSessionStore} from "./stores/OutboundGroupSessionStore"; import {GroupSessionDecryptionStore} from "./stores/GroupSessionDecryptionStore"; import {OperationStore} from "./stores/OperationStore"; import {AccountDataStore} from "./stores/AccountDataStore"; +import {CallStore} from "./stores/CallStore"; import type {ILogger, ILogItem} from "../../../logging/types"; export type IDBKey = IDBValidKey | IDBKeyRange; @@ -140,8 +142,12 @@ export class Transaction { return this._store(StoreNames.userIdentities, idbStore => new UserIdentityStore(idbStore)); } - get deviceIdentities(): DeviceIdentityStore { - return this._store(StoreNames.deviceIdentities, idbStore => new DeviceIdentityStore(idbStore)); + get deviceKeys(): DeviceKeyStore { + return this._store(StoreNames.deviceKeys, idbStore => new DeviceKeyStore(idbStore)); + } + + get crossSigningKeys(): CrossSigningKeyStore { + return this._store(StoreNames.crossSigningKeys, idbStore => new CrossSigningKeyStore(idbStore)); } get olmSessions(): OlmSessionStore { @@ -167,6 +173,10 @@ export class Transaction { get accountData(): AccountDataStore { return this._store(StoreNames.accountData, idbStore => new AccountDataStore(idbStore)); } + + get calls(): CallStore { + return this._store(StoreNames.calls, idbStore => new CallStore(idbStore)); + } async complete(log?: ILogItem): Promise { try { diff --git a/src/matrix/storage/idb/schema.ts b/src/matrix/storage/idb/schema.ts index 9b875f9c..9b4d5547 100644 --- a/src/matrix/storage/idb/schema.ts +++ b/src/matrix/storage/idb/schema.ts @@ -2,7 +2,7 @@ import {IDOMStorage} from "./types"; import {ITransaction} from "./QueryTarget"; import {iterateCursor, NOT_DONE, reqAsPromise} from "./utils"; import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/members/RoomMember.js"; -import {SESSION_E2EE_KEY_PREFIX} from "../../e2ee/common.js"; +import {SESSION_E2EE_KEY_PREFIX} from "../../e2ee/common"; import {SummaryData} from "../../room/RoomSummary"; import {RoomMemberStore, MemberData} from "./stores/RoomMemberStore"; import {InboundGroupSessionStore, InboundGroupSessionEntry, BackupStatus, KeySource} from "./stores/InboundGroupSessionStore"; @@ -13,6 +13,8 @@ import {encodeScopeTypeKey} from "./stores/OperationStore"; import {MAX_UNICODE} from "./stores/common"; import {ILogItem} from "../../../logging/types"; +import type {UserIdentity} from "../../e2ee/DeviceTracker"; +import {KeysTrackingStatus} from "../../e2ee/DeviceTracker"; export type MigrationFunc = (db: IDBDatabase, txn: IDBTransaction, localStorage: IDOMStorage, log: ILogItem) => Promise | void; // FUNCTIONS SHOULD ONLY BE APPENDED!! @@ -33,7 +35,9 @@ export const schema: MigrationFunc[] = [ backupAndRestoreE2EEAccountToLocalStorage, clearAllStores, addInboundSessionBackupIndex, - migrateBackupStatus + migrateBackupStatus, + createCallStore, + applyCrossSigningChanges ]; // TODO: how to deal with git merge conflicts of this array? @@ -269,3 +273,29 @@ async function migrateBackupStatus(db: IDBDatabase, txn: IDBTransaction, localSt log.set("countWithoutSession", countWithoutSession); log.set("countWithSession", countWithSession); } + +//v17 create calls store +function createCallStore(db: IDBDatabase) : void { + db.createObjectStore("calls", {keyPath: "key"}); +} + +//v18 add crossSigningKeys store, rename deviceIdentities to deviceKeys and empties userIdentities +async function applyCrossSigningChanges(db: IDBDatabase, txn: IDBTransaction, localStorage: IDOMStorage, log: ILogItem) : Promise { + db.createObjectStore("crossSigningKeys", {keyPath: "key"}); + db.deleteObjectStore("deviceIdentities"); + const deviceKeys = db.createObjectStore("deviceKeys", {keyPath: "key"}); + deviceKeys.createIndex("byCurve25519Key", "curve25519Key", {unique: true}); + // mark all userIdentities as outdated as cross-signing keys won't be stored + // also rename the deviceTrackingStatus field to keysTrackingStatus + const userIdentities = txn.objectStore("userIdentities"); + let counter = 0; + await iterateCursor(userIdentities.openCursor(), (value, key, cursor) => { + delete value["deviceTrackingStatus"]; + delete value["crossSigningKeys"]; + value.keysTrackingStatus = KeysTrackingStatus.Outdated; + cursor.update(value); + counter += 1; + return NOT_DONE; + }); + log.set("marked_outdated", counter); +} diff --git a/src/matrix/storage/idb/stores/AccountDataStore.ts b/src/matrix/storage/idb/stores/AccountDataStore.ts index 2081ad8f..33c8a162 100644 --- a/src/matrix/storage/idb/stores/AccountDataStore.ts +++ b/src/matrix/storage/idb/stores/AccountDataStore.ts @@ -16,7 +16,7 @@ limitations under the License. import {Store} from "../Store"; import {Content} from "../../types"; -interface AccountDataEntry { +export interface AccountDataEntry { type: string; content: Content; } @@ -35,4 +35,8 @@ export class AccountDataStore { set(event: AccountDataEntry): void { this._store.put(event); } + + async getAll(): Promise> { + return await this._store.selectAll(); + } } diff --git a/src/matrix/storage/idb/stores/CallStore.ts b/src/matrix/storage/idb/stores/CallStore.ts new file mode 100644 index 00000000..566bcc40 --- /dev/null +++ b/src/matrix/storage/idb/stores/CallStore.ts @@ -0,0 +1,83 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {Store} from "../Store"; +import {StateEvent} from "../../types"; +import {MIN_UNICODE, MAX_UNICODE} from "./common"; + +function encodeKey(intent: string, roomId: string, callId: string) { + return `${intent}|${roomId}|${callId}`; +} + +function decodeStorageEntry(storageEntry: CallStorageEntry): CallEntry { + const [intent, roomId, callId] = storageEntry.key.split("|"); + return {intent, roomId, callId, timestamp: storageEntry.timestamp}; +} + +export interface CallEntry { + intent: string; + roomId: string; + callId: string; + timestamp: number; +} + +type CallStorageEntry = { + key: string; + timestamp: number; +} + +export class CallStore { + private _callStore: Store; + + constructor(idbStore: Store) { + this._callStore = idbStore; + } + + async getByIntent(intent: string): Promise { + const range = this._callStore.IDBKeyRange.bound( + encodeKey(intent, MIN_UNICODE, MIN_UNICODE), + encodeKey(intent, MAX_UNICODE, MAX_UNICODE), + true, + true + ); + const storageEntries = await this._callStore.selectAll(range); + return storageEntries.map(e => decodeStorageEntry(e)); + } + + async getByIntentAndRoom(intent: string, roomId: string): Promise { + const range = this._callStore.IDBKeyRange.bound( + encodeKey(intent, roomId, MIN_UNICODE), + encodeKey(intent, roomId, MAX_UNICODE), + true, + true + ); + const storageEntries = await this._callStore.selectAll(range); + return storageEntries.map(e => decodeStorageEntry(e)); + } + + add(entry: CallEntry) { + const storageEntry: CallStorageEntry = { + key: encodeKey(entry.intent, entry.roomId, entry.callId), + timestamp: entry.timestamp + }; + this._callStore.add(storageEntry); + } + + remove(intent: string, roomId: string, callId: string): void { + this._callStore.delete(encodeKey(intent, roomId, callId)); + } +} diff --git a/src/matrix/storage/idb/stores/CrossSigningKeyStore.ts b/src/matrix/storage/idb/stores/CrossSigningKeyStore.ts new file mode 100644 index 00000000..bbda15c0 --- /dev/null +++ b/src/matrix/storage/idb/stores/CrossSigningKeyStore.ts @@ -0,0 +1,63 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {MAX_UNICODE, MIN_UNICODE} from "./common"; +import {Store} from "../Store"; +import type {CrossSigningKey} from "../../../verification/CrossSigning"; + +type CrossSigningKeyEntry = { + crossSigningKey: CrossSigningKey + key: string; // key in storage, not a crypto key +} + +function encodeKey(userId: string, usage: string): string { + return `${userId}|${usage}`; +} + +function decodeKey(key: string): { userId: string, usage: string } { + const [userId, usage] = key.split("|"); + return {userId, usage}; +} + +export class CrossSigningKeyStore { + private _store: Store; + + constructor(store: Store) { + this._store = store; + } + + async get(userId: string, deviceId: string): Promise { + return (await this._store.get(encodeKey(userId, deviceId)))?.crossSigningKey; + } + + set(crossSigningKey: CrossSigningKey): void { + this._store.put({ + key:encodeKey(crossSigningKey["user_id"], crossSigningKey.usage[0]), + crossSigningKey + }); + } + + remove(userId: string, usage: string): void { + this._store.delete(encodeKey(userId, usage)); + } + + removeAllForUser(userId: string): void { + // exclude both keys as they are theoretical min and max, + // but we should't have a match for just the room id, or room id with max + const range = this._store.IDBKeyRange.bound(encodeKey(userId, MIN_UNICODE), encodeKey(userId, MAX_UNICODE), true, true); + this._store.delete(range); + } +} diff --git a/src/matrix/storage/idb/stores/DeviceIdentityStore.ts b/src/matrix/storage/idb/stores/DeviceKeyStore.ts similarity index 63% rename from src/matrix/storage/idb/stores/DeviceIdentityStore.ts rename to src/matrix/storage/idb/stores/DeviceKeyStore.ts index 2936f079..897d6453 100644 --- a/src/matrix/storage/idb/stores/DeviceIdentityStore.ts +++ b/src/matrix/storage/idb/stores/DeviceKeyStore.ts @@ -16,15 +16,13 @@ limitations under the License. import {MAX_UNICODE, MIN_UNICODE} from "./common"; import {Store} from "../Store"; +import {getDeviceCurve25519Key} from "../../../e2ee/common"; +import type {DeviceKey} from "../../../e2ee/common"; -export interface DeviceIdentity { - userId: string; - deviceId: string; - ed25519Key: string; +type DeviceKeyEntry = { + key: string; // key in storage, not a crypto key curve25519Key: string; - algorithms: string[]; - displayName: string; - key: string; + deviceKey: DeviceKey } function encodeKey(userId: string, deviceId: string): string { @@ -36,23 +34,24 @@ function decodeKey(key: string): { userId: string, deviceId: string } { return {userId, deviceId}; } -export class DeviceIdentityStore { - private _store: Store; +export class DeviceKeyStore { + private _store: Store; - constructor(store: Store) { + constructor(store: Store) { this._store = store; } - getAllForUserId(userId: string): Promise { - const range = this._store.IDBKeyRange.lowerBound(encodeKey(userId, "")); - return this._store.selectWhile(range, device => { - return device.userId === userId; + async getAllForUserId(userId: string): Promise { + const range = this._store.IDBKeyRange.lowerBound(encodeKey(userId, MIN_UNICODE)); + const entries = await this._store.selectWhile(range, device => { + return device.deviceKey.user_id === userId; }); + return entries.map(e => e.deviceKey); } async getAllDeviceIds(userId: string): Promise { const deviceIds: string[] = []; - const range = this._store.IDBKeyRange.lowerBound(encodeKey(userId, "")); + const range = this._store.IDBKeyRange.lowerBound(encodeKey(userId, MIN_UNICODE)); await this._store.iterateKeys(range, key => { const decodedKey = decodeKey(key as string); // prevent running into the next room @@ -65,17 +64,21 @@ export class DeviceIdentityStore { return deviceIds; } - get(userId: string, deviceId: string): Promise { - return this._store.get(encodeKey(userId, deviceId)); + async get(userId: string, deviceId: string): Promise { + return (await this._store.get(encodeKey(userId, deviceId)))?.deviceKey; } - set(deviceIdentity: DeviceIdentity): void { - deviceIdentity.key = encodeKey(deviceIdentity.userId, deviceIdentity.deviceId); - this._store.put(deviceIdentity); + set(deviceKey: DeviceKey): void { + this._store.put({ + key: encodeKey(deviceKey.user_id, deviceKey.device_id), + curve25519Key: getDeviceCurve25519Key(deviceKey)!, + deviceKey + }); } - getByCurve25519Key(curve25519Key: string): Promise { - return this._store.index("byCurve25519Key").get(curve25519Key); + async getByCurve25519Key(curve25519Key: string): Promise { + const entry = await this._store.index("byCurve25519Key").get(curve25519Key); + return entry?.deviceKey; } remove(userId: string, deviceId: string): void { diff --git a/src/matrix/storage/idb/stores/RoomStateStore.ts b/src/matrix/storage/idb/stores/RoomStateStore.ts index d2bf811d..e7661378 100644 --- a/src/matrix/storage/idb/stores/RoomStateStore.ts +++ b/src/matrix/storage/idb/stores/RoomStateStore.ts @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {MAX_UNICODE} from "./common"; +import {MIN_UNICODE, MAX_UNICODE} from "./common"; import {Store} from "../Store"; import {StateEvent} from "../../types"; @@ -41,6 +41,16 @@ export class RoomStateStore { return this._roomStateStore.get(key); } + getAllForType(roomId: string, type: string): Promise { + const range = this._roomStateStore.IDBKeyRange.bound( + encodeKey(roomId, type, ""), + encodeKey(roomId, type, MAX_UNICODE), + false, + true + ); + return this._roomStateStore.selectAll(range); + } + set(roomId: string, event: StateEvent): void { const key = encodeKey(roomId, event.type, event.state_key); const entry = {roomId, event, key}; diff --git a/src/matrix/storage/idb/stores/SessionStore.ts b/src/matrix/storage/idb/stores/SessionStore.ts index 9ae9bb7e..24b7099a 100644 --- a/src/matrix/storage/idb/stores/SessionStore.ts +++ b/src/matrix/storage/idb/stores/SessionStore.ts @@ -15,7 +15,7 @@ limitations under the License. */ import {Store} from "../Store"; import {IDOMStorage} from "../types"; -import {SESSION_E2EE_KEY_PREFIX} from "../../../e2ee/common.js"; +import {SESSION_E2EE_KEY_PREFIX} from "../../../e2ee/common"; import {parse, stringify} from "../../../../utils/typedJSON"; import type {ILogItem} from "../../../../logging/types"; diff --git a/src/matrix/storage/idb/stores/UserIdentityStore.ts b/src/matrix/storage/idb/stores/UserIdentityStore.ts index 1c55baf0..76bb2080 100644 --- a/src/matrix/storage/idb/stores/UserIdentityStore.ts +++ b/src/matrix/storage/idb/stores/UserIdentityStore.ts @@ -14,12 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ import {Store} from "../Store"; - -interface UserIdentity { - userId: string; - roomIds: string[]; - deviceTrackingStatus: number; -} +import type {UserIdentity} from "../../../e2ee/DeviceTracker"; export class UserIdentityStore { private _store: Store; diff --git a/src/matrix/verification/CrossSigning.ts b/src/matrix/verification/CrossSigning.ts new file mode 100644 index 00000000..fc4589ef --- /dev/null +++ b/src/matrix/verification/CrossSigning.ts @@ -0,0 +1,339 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {ILogItem} from "../../logging/types"; +import {pkSign} from "./common"; +import {verifyEd25519Signature, SignatureVerification} from "../e2ee/common"; + +import type {SecretStorage} from "../ssss/SecretStorage"; +import type {Storage} from "../storage/idb/Storage"; +import type {Transaction} from "../storage/idb/Transaction"; +import type {Platform} from "../../platform/web/Platform"; +import type {DeviceTracker} from "../e2ee/DeviceTracker"; +import type {HomeServerApi} from "../net/HomeServerApi"; +import type {Account} from "../e2ee/Account"; +import type {SignedValue, DeviceKey} from "../e2ee/common"; +import type * as OlmNamespace from "@matrix-org/olm"; +type Olm = typeof OlmNamespace; + +// we store cross-signing (and device) keys in the format we get them from the server +// as that is what the signature is calculated on, so to verify and sign, we need +// it in this format anyway. +export type CrossSigningKey = SignedValue & { + readonly user_id: string; + readonly usage: ReadonlyArray; + readonly keys: {[keyId: string]: string}; +} + +export enum KeyUsage { + Master = "master", + SelfSigning = "self_signing", + UserSigning = "user_signing" +}; + +export enum UserTrust { + /** We trust the user, the whole signature chain checks out from our MSK to all of their device keys. */ + Trusted = 1, + /** We haven't signed this user's identity yet. Verify this user first to sign it. */ + UserNotSigned, + /** We have signed the user already, but the signature isn't valid. + One possible cause could be that an attacker is uploading signatures in our name. */ + UserSignatureMismatch, + /** We trust the user, but they don't trust one of their devices. */ + UserDeviceNotSigned, + /** We trust the user, but the signatures of one of their devices is invalid. + * One possible cause could be that an attacker is uploading signatures in their name. */ + UserDeviceSignatureMismatch, + /** The user doesn't have a valid signature for the SSK with their MSK, or the SSK is missing. + * This likely means bootstrapping cross-signing on their end didn't finish correctly. */ + UserSetupError, + /** We don't have a valid signature for our SSK with our MSK, the SSK is missing, or we don't trust our own MSK. + * This likely means bootstrapping cross-signing on our end didn't finish correctly. */ + OwnSetupError +} + +export class CrossSigning { + private readonly storage: Storage; + private readonly secretStorage: SecretStorage; + private readonly platform: Platform; + private readonly deviceTracker: DeviceTracker; + private readonly olm: Olm; + private readonly olmUtil: Olm.Utility; + private readonly hsApi: HomeServerApi; + private readonly ownUserId: string; + private readonly e2eeAccount: Account; + private _isMasterKeyTrusted: boolean = false; + + constructor(options: { + storage: Storage, + secretStorage: SecretStorage, + deviceTracker: DeviceTracker, + platform: Platform, + olm: Olm, + olmUtil: Olm.Utility, + ownUserId: string, + hsApi: HomeServerApi, + e2eeAccount: Account + }) { + this.storage = options.storage; + this.secretStorage = options.secretStorage; + this.platform = options.platform; + this.deviceTracker = options.deviceTracker; + this.olm = options.olm; + this.olmUtil = options.olmUtil; + this.hsApi = options.hsApi; + this.ownUserId = options.ownUserId; + this.e2eeAccount = options.e2eeAccount + } + + async load(log: ILogItem) { + // try to verify the msk without accessing the network + return await this.verifyMSKFrom4S(false, log); + } + + async start(log: ILogItem) { + if (!this.isMasterKeyTrusted) { + // try to verify the msk _with_ access to the network + return await this.verifyMSKFrom4S(true, log); + } + } + + private async verifyMSKFrom4S(allowNetwork: boolean, log: ILogItem): Promise { + return await log.wrap("CrossSigning.verifyMSKFrom4S", async log => { + // TODO: use errorboundary here + const privateMasterKey = await this.getSigningKey(KeyUsage.Master); + if (!privateMasterKey) { + return false; + } + const signing = new this.olm.PkSigning(); + let derivedPublicKey; + try { + derivedPublicKey = signing.init_with_seed(privateMasterKey); + } finally { + signing.free(); + } + const publishedMasterKey = await this.deviceTracker.getCrossSigningKeyForUser(this.ownUserId, KeyUsage.Master, allowNetwork ? this.hsApi : undefined, log); + if (!publishedMasterKey) { + return false; + } + const publisedEd25519Key = publishedMasterKey && getKeyEd25519Key(publishedMasterKey); + log.set({publishedMasterKey: publisedEd25519Key, derivedPublicKey}); + this._isMasterKeyTrusted = !!publisedEd25519Key && publisedEd25519Key === derivedPublicKey; + log.set("isMasterKeyTrusted", this.isMasterKeyTrusted); + return this.isMasterKeyTrusted; + }); + } + + get isMasterKeyTrusted(): boolean { + return this._isMasterKeyTrusted; + } + + /** returns our own device key signed by our self-signing key. Other signatures will be missing. */ + async signOwnDevice(log: ILogItem): Promise { + return log.wrap("CrossSigning.signOwnDevice", async log => { + if (!this._isMasterKeyTrusted) { + log.set("mskNotTrusted", true); + return; + } + const ownDeviceKey = this.e2eeAccount.getUnsignedDeviceKey() as DeviceKey; + return this.signDeviceKey(ownDeviceKey, log); + }); + } + + /** @return the signed device key for the given device id */ + async signDevice(deviceId: string, log: ILogItem): Promise { + return log.wrap("CrossSigning.signDevice", async log => { + log.set("id", deviceId); + if (!this._isMasterKeyTrusted) { + log.set("mskNotTrusted", true); + return; + } + const keyToSign = await this.deviceTracker.deviceForId(this.ownUserId, deviceId, this.hsApi, log); + if (!keyToSign) { + return undefined; + } + delete keyToSign.signatures; + return this.signDeviceKey(keyToSign, log); + }); + } + + /** @return the signed MSK for the given user id */ + async signUser(userId: string, log: ILogItem): Promise { + return log.wrap("CrossSigning.signUser", async log => { + log.set("id", userId); + if (!this._isMasterKeyTrusted) { + log.set("mskNotTrusted", true); + return; + } + // can't sign own user + if (userId === this.ownUserId) { + return; + } + const keyToSign = await this.deviceTracker.getCrossSigningKeyForUser(userId, KeyUsage.Master, this.hsApi, log); + if (!keyToSign) { + return; + } + const signingKey = await this.getSigningKey(KeyUsage.UserSigning); + if (!signingKey) { + return; + } + delete keyToSign.signatures; + // add signature to keyToSign + this.signKey(keyToSign, signingKey); + const payload = { + [keyToSign.user_id]: { + [getKeyEd25519Key(keyToSign)!]: keyToSign + } + }; + const request = this.hsApi.uploadSignatures(payload, {log}); + await request.response(); + return keyToSign; + }); + } + + async getUserTrust(userId: string, log: ILogItem): Promise { + return log.wrap("getUserTrust", async log => { + log.set("id", userId); + if (!this.isMasterKeyTrusted) { + return UserTrust.OwnSetupError; + } + const ourMSK = await log.wrap("get our msk", log => this.deviceTracker.getCrossSigningKeyForUser(this.ownUserId, KeyUsage.Master, this.hsApi, log)); + if (!ourMSK) { + return UserTrust.OwnSetupError; + } + const ourUSK = await log.wrap("get our usk", log => this.deviceTracker.getCrossSigningKeyForUser(this.ownUserId, KeyUsage.UserSigning, this.hsApi, log)); + if (!ourUSK) { + return UserTrust.OwnSetupError; + } + const ourUSKVerification = log.wrap("verify our usk", log => this.hasValidSignatureFrom(ourUSK, ourMSK, log)); + if (ourUSKVerification !== SignatureVerification.Valid) { + return UserTrust.OwnSetupError; + } + const theirMSK = await log.wrap("get their msk", log => this.deviceTracker.getCrossSigningKeyForUser(userId, KeyUsage.Master, this.hsApi, log)); + if (!theirMSK) { + /* assume that when they don't have an MSK, they've never enabled cross-signing on their client + (or it's not supported) rather than assuming a setup error on their side. + Later on, for their SSK, we _do_ assume it's a setup error as it doesn't make sense to have an MSK without a SSK */ + return UserTrust.UserNotSigned; + } + const theirMSKVerification = log.wrap("verify their msk", log => this.hasValidSignatureFrom(theirMSK, ourUSK, log)); + if (theirMSKVerification !== SignatureVerification.Valid) { + if (theirMSKVerification === SignatureVerification.NotSigned) { + return UserTrust.UserNotSigned; + } else { /* SignatureVerification.Invalid */ + return UserTrust.UserSignatureMismatch; + } + } + const theirSSK = await log.wrap("get their ssk", log => this.deviceTracker.getCrossSigningKeyForUser(userId, KeyUsage.SelfSigning, this.hsApi, log)); + if (!theirSSK) { + return UserTrust.UserSetupError; + } + const theirSSKVerification = log.wrap("verify their ssk", log => this.hasValidSignatureFrom(theirSSK, theirMSK, log)); + if (theirSSKVerification !== SignatureVerification.Valid) { + return UserTrust.UserSetupError; + } + const theirDeviceKeys = await log.wrap("get their devices", log => this.deviceTracker.devicesForUsers([userId], this.hsApi, log)); + const lowestDeviceVerification = theirDeviceKeys.reduce((lowest, dk) => log.wrap({l: "verify device", id: dk.device_id}, log => { + const verification = this.hasValidSignatureFrom(dk, theirSSK, log); + // first Invalid, then NotSigned, then Valid + if (lowest === SignatureVerification.Invalid || verification === SignatureVerification.Invalid) { + return SignatureVerification.Invalid; + } else if (lowest === SignatureVerification.NotSigned || verification === SignatureVerification.NotSigned) { + return SignatureVerification.NotSigned; + } else if (lowest === SignatureVerification.Valid || verification === SignatureVerification.Valid) { + return SignatureVerification.Valid; + } + // should never happen as we went over all the enum options + return SignatureVerification.Invalid; + }), SignatureVerification.Valid); + if (lowestDeviceVerification !== SignatureVerification.Valid) { + if (lowestDeviceVerification === SignatureVerification.NotSigned) { + return UserTrust.UserDeviceNotSigned; + } else { /* SignatureVerification.Invalid */ + return UserTrust.UserDeviceSignatureMismatch; + } + } + return UserTrust.Trusted; + }); + } + + private async signDeviceKey(keyToSign: DeviceKey, log: ILogItem): Promise { + const signingKey = await this.getSigningKey(KeyUsage.SelfSigning); + if (!signingKey) { + return undefined; + } + // add signature to keyToSign + this.signKey(keyToSign, signingKey); + // so the payload format of a signature is a map from userid to key id of the signed key + // (without the algoritm prefix though according to example, e.g. just device id or base 64 public key) + // to the complete signed key with the signature of the signing key in the signatures section. + const payload = { + [keyToSign.user_id]: { + [keyToSign.device_id]: keyToSign + } + }; + const request = this.hsApi.uploadSignatures(payload, {log}); + await request.response(); + return keyToSign; + } + + private async getSigningKey(usage: KeyUsage): Promise { + const seedStr = await this.secretStorage.readSecret(`m.cross_signing.${usage}`); + if (seedStr) { + return new Uint8Array(this.platform.encoding.base64.decode(seedStr)); + } + } + + private signKey(keyToSign: DeviceKey | CrossSigningKey, signingKey: Uint8Array) { + pkSign(this.olm, keyToSign, signingKey, this.ownUserId, ""); + } + + private hasValidSignatureFrom(key: DeviceKey | CrossSigningKey, signingKey: CrossSigningKey, log: ILogItem): SignatureVerification { + const pubKey = getKeyEd25519Key(signingKey); + if (!pubKey) { + return SignatureVerification.NotSigned; + } + return verifyEd25519Signature(this.olmUtil, signingKey.user_id, pubKey, pubKey, key, log); + } +} + +export function getKeyUsage(keyInfo: CrossSigningKey): KeyUsage | undefined { + if (!Array.isArray(keyInfo.usage) || keyInfo.usage.length !== 1) { + return undefined; + } + const usage = keyInfo.usage[0]; + if (usage !== KeyUsage.Master && usage !== KeyUsage.SelfSigning && usage !== KeyUsage.UserSigning) { + return undefined; + } + return usage; +} + +const algorithm = "ed25519"; +const prefix = `${algorithm}:`; + +export function getKeyEd25519Key(keyInfo: CrossSigningKey): string | undefined { + const ed25519KeyIds = Object.keys(keyInfo.keys).filter(keyId => keyId.startsWith(prefix)); + if (ed25519KeyIds.length !== 1) { + return undefined; + } + const keyId = ed25519KeyIds[0]; + const publicKey = keyInfo.keys[keyId]; + return publicKey; +} + +export function getKeyUserId(keyInfo: CrossSigningKey): string | undefined { + return keyInfo["user_id"]; +} diff --git a/src/matrix/verification/common.ts b/src/matrix/verification/common.ts new file mode 100644 index 00000000..de9b1b1b --- /dev/null +++ b/src/matrix/verification/common.ts @@ -0,0 +1,58 @@ +/* +Copyright 2016-2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { PkSigning } from "@matrix-org/olm"; +import anotherjson from "another-json"; +import type {SignedValue} from "../e2ee/common"; +import type * as OlmNamespace from "@matrix-org/olm"; +type Olm = typeof OlmNamespace; + +// from matrix-js-sdk +/** + * Sign a JSON object using public key cryptography + * @param obj - Object to sign. The object will be modified to include + * the new signature + * @param key - the signing object or the private key + * seed + * @param userId - The user ID who owns the signing key + * @param pubKey - The public key (ignored if key is a seed) + * @returns the signature for the object + */ + export function pkSign(olmUtil: Olm, obj: SignedValue, key: Uint8Array | PkSigning, userId: string, pubKey: string): string { + let createdKey = false; + if (key instanceof Uint8Array) { + const keyObj = new olmUtil.PkSigning(); + pubKey = keyObj.init_with_seed(key); + key = keyObj; + createdKey = true; + } + const sigs = obj.signatures || {}; + delete obj.signatures; + const unsigned = obj.unsigned; + if (obj.unsigned) delete obj.unsigned; + try { + const mysigs = sigs[userId] || {}; + sigs[userId] = mysigs; + + return (mysigs["ed25519:" + pubKey] = key.sign(anotherjson.stringify(obj))); + } finally { + obj.signatures = sigs; + if (unsigned) obj.unsigned = unsigned; + if (createdKey) { + key.free(); + } + } +} diff --git a/src/mocks/Clock.js b/src/mocks/Clock.js index 440c4cb4..e060c24e 100644 --- a/src/mocks/Clock.js +++ b/src/mocks/Clock.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ObservableValue} from "../observable/ObservableValue"; +import {ObservableValue} from "../observable/value"; class Timeout { constructor(elapsed, ms) { diff --git a/src/observable/ObservableValue.ts b/src/observable/ObservableValue.ts deleted file mode 100644 index 96791f91..00000000 --- a/src/observable/ObservableValue.ts +++ /dev/null @@ -1,249 +0,0 @@ -/* -Copyright 2020 Bruno Windels - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import {AbortError} from "../utils/error"; -import {BaseObservable} from "./BaseObservable"; -import type {SubscriptionHandle} from "./BaseObservable"; - -// like an EventEmitter, but doesn't have an event type -export abstract class BaseObservableValue extends BaseObservable<(value: T) => void> { - emit(argument: T): void { - for (const h of this._handlers) { - h(argument); - } - } - - abstract get(): T; - - waitFor(predicate: (value: T) => boolean): IWaitHandle { - if (predicate(this.get())) { - return new ResolvedWaitForHandle(Promise.resolve(this.get())); - } else { - return new WaitForHandle(this, predicate); - } - } - - flatMap(mapper: (value: T) => (BaseObservableValue | undefined)): BaseObservableValue { - return new FlatMapObservableValue(this, mapper); - } -} - -interface IWaitHandle { - promise: Promise; - dispose(): void; -} - -class WaitForHandle implements IWaitHandle { - private _promise: Promise - private _reject: ((reason?: any) => void) | null; - private _subscription: (() => void) | null; - - constructor(observable: BaseObservableValue, predicate: (value: T) => boolean) { - this._promise = new Promise((resolve, reject) => { - this._reject = reject; - this._subscription = observable.subscribe(v => { - if (predicate(v)) { - this._reject = null; - resolve(v); - this.dispose(); - } - }); - }); - } - - get promise(): Promise { - return this._promise; - } - - dispose(): void { - if (this._subscription) { - this._subscription(); - this._subscription = null; - } - if (this._reject) { - this._reject(new AbortError()); - this._reject = null; - } - } -} - -class ResolvedWaitForHandle implements IWaitHandle { - constructor(public promise: Promise) {} - dispose(): void {} -} - -export class ObservableValue extends BaseObservableValue { - private _value: T; - - constructor(initialValue: T) { - super(); - this._value = initialValue; - } - - get(): T { - return this._value; - } - - set(value: T): void { - if (value !== this._value) { - this._value = value; - this.emit(this._value); - } - } -} - -export class RetainedObservableValue extends ObservableValue { - private _freeCallback: () => void; - - constructor(initialValue: T, freeCallback: () => void) { - super(initialValue); - this._freeCallback = freeCallback; - } - - onUnsubscribeLast(): void { - super.onUnsubscribeLast(); - this._freeCallback(); - } -} - -export class FlatMapObservableValue extends BaseObservableValue { - private sourceSubscription?: SubscriptionHandle; - private targetSubscription?: SubscriptionHandle; - - constructor( - private readonly source: BaseObservableValue

, - private readonly mapper: (value: P) => (BaseObservableValue | undefined) - ) { - super(); - } - - onUnsubscribeLast(): void { - super.onUnsubscribeLast(); - this.sourceSubscription = this.sourceSubscription!(); - if (this.targetSubscription) { - this.targetSubscription = this.targetSubscription(); - } - } - - onSubscribeFirst(): void { - super.onSubscribeFirst(); - this.sourceSubscription = this.source.subscribe(() => { - this.updateTargetSubscription(); - this.emit(this.get()); - }); - this.updateTargetSubscription(); - } - - private updateTargetSubscription(): void { - const sourceValue = this.source.get(); - if (sourceValue) { - const target = this.mapper(sourceValue); - if (target) { - if (!this.targetSubscription) { - this.targetSubscription = target.subscribe(() => this.emit(this.get())); - } - return; - } - } - // if no sourceValue or target - if (this.targetSubscription) { - this.targetSubscription = this.targetSubscription(); - } - } - - get(): C | undefined { - const sourceValue = this.source.get(); - if (!sourceValue) { - return undefined; - } - const mapped = this.mapper(sourceValue); - return mapped?.get(); - } -} - -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function tests() { - return { - "set emits an update": (assert): void => { - const a = new ObservableValue(0); - let fired = false; - const subscription = a.subscribe(v => { - fired = true; - assert.strictEqual(v, 5); - }); - a.set(5); - assert(fired); - subscription(); - }, - "set doesn't emit if value hasn't changed": (assert): void => { - const a = new ObservableValue(5); - let fired = false; - const subscription = a.subscribe(() => { - fired = true; - }); - a.set(5); - a.set(5); - assert(!fired); - subscription(); - }, - "waitFor promise resolves on matching update": async (assert): Promise => { - const a = new ObservableValue(5); - const handle = a.waitFor(v => v === 6); - await Promise.resolve().then(() => { - a.set(6); - }); - await handle.promise; - assert.strictEqual(a.get(), 6); - }, - "waitFor promise rejects when disposed": async (assert): Promise => { - const a = new ObservableValue(0); - const handle = a.waitFor(() => false); - await Promise.resolve().then(() => { - handle.dispose(); - }); - await assert.rejects(handle.promise, AbortError); - }, - "flatMap.get": (assert): void => { - const a = new ObservableValue}>(undefined); - const countProxy = a.flatMap(a => a!.count); - assert.strictEqual(countProxy.get(), undefined); - const count = new ObservableValue(0); - a.set({count}); - assert.strictEqual(countProxy.get(), 0); - }, - "flatMap update from source": (assert): void => { - const a = new ObservableValue}>(undefined); - const updates: (number | undefined)[] = []; - a.flatMap(a => a!.count).subscribe(count => { - updates.push(count); - }); - const count = new ObservableValue(0); - a.set({count}); - assert.deepEqual(updates, [0]); - }, - "flatMap update from target": (assert): void => { - const a = new ObservableValue}>(undefined); - const updates: (number | undefined)[] = []; - a.flatMap(a => a!.count).subscribe(count => { - updates.push(count); - }); - const count = new ObservableValue(0); - a.set({count}); - count.set(5); - assert.deepEqual(updates, [0, 5]); - } - }; -} diff --git a/src/observable/index.ts b/src/observable/index.ts index 25af50b8..477f5ee0 100644 --- a/src/observable/index.ts +++ b/src/observable/index.ts @@ -1,12 +1,10 @@ + /* Copyright 2020 Bruno Windels - 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. @@ -21,4 +19,4 @@ export { ObservableArray } from "./list/ObservableArray"; export { SortedArray } from "./list/SortedArray"; export { MappedList } from "./list/MappedList"; export { AsyncMappedList } from "./list/AsyncMappedList"; -export { ConcatList } from "./list/ConcatList"; \ No newline at end of file +export { ConcatList } from "./list/ConcatList"; diff --git a/src/observable/map/BaseObservableMap.ts b/src/observable/map/BaseObservableMap.ts index f81a94f8..c82e531c 100644 --- a/src/observable/map/BaseObservableMap.ts +++ b/src/observable/map/BaseObservableMap.ts @@ -18,6 +18,7 @@ import {BaseObservable} from "../BaseObservable"; import {JoinedMap} from "./index"; import {MappedMap} from "./index"; import {FilteredMap} from "./index"; +import {BaseObservableValue, MapSizeObservableValue} from "../value/index"; import {SortedMapList} from "../list/SortedMapList.js"; @@ -66,19 +67,23 @@ export abstract class BaseObservableMap extends BaseObservable>(...otherMaps: Array): JoinedMap { return new JoinedMap([this as BaseObservableMap].concat(otherMaps)); - } + } - mapValues(mapper: Mapper, updater?: Updater): MappedMap { - return new MappedMap(this, mapper, updater); - } + mapValues(mapper: Mapper, updater?: Updater): MappedMap { + return new MappedMap(this, mapper, updater); + } - sortValues(comparator: Comparator): SortedMapList { - return new SortedMapList(this, comparator); - } + sortValues(comparator: Comparator): SortedMapList { + return new SortedMapList(this, comparator); + } - filterValues(filter: Filter): FilteredMap { - return new FilteredMap(this, filter); - } + filterValues(filter: Filter): FilteredMap { + return new FilteredMap(this, filter); + } + + observeSize(): BaseObservableValue { + return new MapSizeObservableValue(this); + } abstract [Symbol.iterator](): Iterator<[K, V]>; abstract get size(): number; @@ -94,4 +99,4 @@ export type Updater = (params: any, mappedValue?: MappedV, value?: V export type Comparator = (a: V, b: V) => number; -export type Filter = (v: V, k: K) => boolean; \ No newline at end of file +export type Filter = (v: V, k: K) => boolean; diff --git a/src/observable/map/ObservableMap.ts b/src/observable/map/ObservableMap.ts index f0d4c77a..45a6aa4c 100644 --- a/src/observable/map/ObservableMap.ts +++ b/src/observable/map/ObservableMap.ts @@ -86,15 +86,15 @@ export class ObservableMap extends BaseObservableMap { return this._values.size; } - [Symbol.iterator](): Iterator<[K, V]> { + [Symbol.iterator](): IterableIterator<[K, V]> { return this._values.entries(); } - values(): Iterator { + values(): IterableIterator { return this._values.values(); } - keys(): Iterator { + keys(): IterableIterator { return this._values.keys(); } } diff --git a/src/observable/map/ObservableValueMap.ts b/src/observable/map/ObservableValueMap.ts new file mode 100644 index 00000000..2108d8ce --- /dev/null +++ b/src/observable/map/ObservableValueMap.ts @@ -0,0 +1,53 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {BaseObservableMap} from "./BaseObservableMap"; +import {BaseObservableValue} from "../value/BaseObservableValue"; +import {SubscriptionHandle} from "../BaseObservable"; + +export class ObservableValueMap extends BaseObservableMap { + private subscription?: SubscriptionHandle; + + constructor(private readonly key: K, private readonly observableValue: BaseObservableValue) { + super(); + } + + onSubscribeFirst() { + this.subscription = this.observableValue.subscribe(value => { + this.emitUpdate(this.key, value, undefined); + }); + super.onSubscribeFirst(); + } + + onUnsubscribeLast() { + this.subscription!(); + super.onUnsubscribeLast(); + } + + *[Symbol.iterator](): Iterator<[K, V]> { + yield [this.key, this.observableValue.get()]; + } + + get size(): number { + return 1; + } + + get(key: K): V | undefined { + if (key == this.key) { + return this.observableValue.get(); + } + } +} diff --git a/src/observable/map/index.ts b/src/observable/map/index.ts index a78446c4..f4dd0f18 100644 --- a/src/observable/map/index.ts +++ b/src/observable/map/index.ts @@ -14,4 +14,5 @@ export {FilteredMap} from './FilteredMap'; export {JoinedMap} from './JoinedMap'; export {LogMap} from './LogMap'; export {MappedMap} from './MappedMap'; -export {ObservableMap} from './ObservableMap'; \ No newline at end of file +export {ObservableMap} from './ObservableMap'; +export {ObservableValueMap} from './ObservableValueMap'; diff --git a/src/observable/value/BaseObservableValue.ts b/src/observable/value/BaseObservableValue.ts new file mode 100644 index 00000000..62766331 --- /dev/null +++ b/src/observable/value/BaseObservableValue.ts @@ -0,0 +1,87 @@ +/* +Copyright 2020 Bruno Windels + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {AbortError} from "../../utils/error"; +import {BaseObservable} from "../BaseObservable"; +import type {SubscriptionHandle} from "../BaseObservable"; +import {FlatMapObservableValue} from "./index"; + +// like an EventEmitter, but doesn't have an event type +export abstract class BaseObservableValue extends BaseObservable<(value: T) => void> { + emit(argument: T): void { + for (const h of this._handlers) { + h(argument); + } + } + + abstract get(): T; + + waitFor(predicate: (value: T) => boolean): IWaitHandle { + if (predicate(this.get())) { + return new ResolvedWaitForHandle(Promise.resolve(this.get())); + } else { + return new WaitForHandle(this, predicate); + } + } + + flatMap(mapper: (value: T) => (BaseObservableValue | undefined)): BaseObservableValue { + return new FlatMapObservableValue(this, mapper); + } +} + +interface IWaitHandle { + promise: Promise; + dispose(): void; +} + +class WaitForHandle implements IWaitHandle { + private _promise: Promise + private _reject: ((reason?: any) => void) | null; + private _subscription: (() => void) | null; + + constructor(observable: BaseObservableValue, predicate: (value: T) => boolean) { + this._promise = new Promise((resolve, reject) => { + this._reject = reject; + this._subscription = observable.subscribe(v => { + if (predicate(v)) { + this._reject = null; + resolve(v); + this.dispose(); + } + }); + }); + } + + get promise(): Promise { + return this._promise; + } + + dispose(): void { + if (this._subscription) { + this._subscription(); + this._subscription = null; + } + if (this._reject) { + this._reject(new AbortError()); + this._reject = null; + } + } +} + +class ResolvedWaitForHandle implements IWaitHandle { + constructor(public promise: Promise) {} + dispose(): void {} +} diff --git a/src/observable/value/EventObservableValue.ts b/src/observable/value/EventObservableValue.ts new file mode 100644 index 00000000..7158ec0e --- /dev/null +++ b/src/observable/value/EventObservableValue.ts @@ -0,0 +1,45 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {BaseObservableValue} from "./index"; +import {EventEmitter} from "../../utils/EventEmitter"; + +export class EventObservableValue> extends BaseObservableValue { + private eventSubscription: () => void; + + constructor( + private readonly value: V, + private readonly eventName: keyof T + ) { + super(); + } + + onSubscribeFirst(): void { + this.eventSubscription = this.value.disposableOn(this.eventName, () => { + this.emit(this.value); + }); + super.onSubscribeFirst(); + } + + onUnsubscribeLast(): void { + this.eventSubscription!(); + super.onUnsubscribeLast(); + } + + get(): V { + return this.value; + } +} diff --git a/src/observable/value/FlatMapObservableValue.ts b/src/observable/value/FlatMapObservableValue.ts new file mode 100644 index 00000000..7e903372 --- /dev/null +++ b/src/observable/value/FlatMapObservableValue.ts @@ -0,0 +1,109 @@ +/* +Copyright 2020 Bruno Windels + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {BaseObservableValue} from "./index"; +import type {SubscriptionHandle} from "../BaseObservable"; + +export class FlatMapObservableValue extends BaseObservableValue { + private sourceSubscription?: SubscriptionHandle; + private targetSubscription?: SubscriptionHandle; + + constructor( + private readonly source: BaseObservableValue

, + private readonly mapper: (value: P) => (BaseObservableValue | undefined) + ) { + super(); + } + + onUnsubscribeLast(): void { + super.onUnsubscribeLast(); + this.sourceSubscription = this.sourceSubscription!(); + if (this.targetSubscription) { + this.targetSubscription = this.targetSubscription(); + } + } + + onSubscribeFirst(): void { + super.onSubscribeFirst(); + this.sourceSubscription = this.source.subscribe(() => { + this.updateTargetSubscription(); + this.emit(this.get()); + }); + this.updateTargetSubscription(); + } + + private updateTargetSubscription(): void { + const sourceValue = this.source.get(); + if (sourceValue) { + const target = this.mapper(sourceValue); + if (target) { + if (!this.targetSubscription) { + this.targetSubscription = target.subscribe(() => this.emit(this.get())); + } + return; + } + } + // if no sourceValue or target + if (this.targetSubscription) { + this.targetSubscription = this.targetSubscription(); + } + } + + get(): C | undefined { + const sourceValue = this.source.get(); + if (!sourceValue) { + return undefined; + } + const mapped = this.mapper(sourceValue); + return mapped?.get(); + } +} + +import {ObservableValue} from "./ObservableValue"; + +export function tests() { + return { + "flatMap.get": (assert): void => { + const a = new ObservableValue}>(undefined); + const countProxy = a.flatMap(a => a!.count); + assert.strictEqual(countProxy.get(), undefined); + const count = new ObservableValue(0); + a.set({count}); + assert.strictEqual(countProxy.get(), 0); + }, + "flatMap update from source": (assert): void => { + const a = new ObservableValue}>(undefined); + const updates: (number | undefined)[] = []; + a.flatMap(a => a!.count).subscribe(count => { + updates.push(count); + }); + const count = new ObservableValue(0); + a.set({count}); + assert.deepEqual(updates, [0]); + }, + "flatMap update from target": (assert): void => { + const a = new ObservableValue}>(undefined); + const updates: (number | undefined)[] = []; + a.flatMap(a => a!.count).subscribe(count => { + updates.push(count); + }); + const count = new ObservableValue(0); + a.set({count}); + count.set(5); + assert.deepEqual(updates, [0, 5]); + } + }; +} diff --git a/src/observable/value/MapSizeObservableValue.ts b/src/observable/value/MapSizeObservableValue.ts new file mode 100644 index 00000000..13bac604 --- /dev/null +++ b/src/observable/value/MapSizeObservableValue.ts @@ -0,0 +1,71 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {BaseObservableValue} from "./index"; +import {BaseObservableMap} from "../map/index"; +import type {SubscriptionHandle} from "../BaseObservable"; + +export class MapSizeObservableValue extends BaseObservableValue { + private subscription?: SubscriptionHandle; + + constructor(private readonly map: BaseObservableMap) + { + super(); + } + + onSubscribeFirst(): void { + this.subscription = this.map.subscribe({ + onAdd: (key: K, value: V) => { + this.emit(this.get()); + }, + onRemove: (key: K, value: V) => { + this.emit(this.get()); + }, + onUpdate: (key: K, value: V) => {}, + onReset: () => { + this.emit(this.get()); + }, + }); + } + + onUnsubscribeLast(): void { + this.subscription = this.subscription?.(); + } + + get(): number { + return this.map.size; + } +} + +import {ObservableMap} from "../map/index"; + +export function tests() { + return { + "emits update on add and remove": assert => { + const map = new ObservableMap(); + const size = new MapSizeObservableValue(map); + const updates: number[] = []; + size.subscribe(size => { + updates.push(size); + }); + map.add("hello", 1); + map.add("world", 2); + map.remove("world"); + map.remove("hello"); + assert.deepEqual(updates, [1, 2, 1, 0]); + } + }; +} diff --git a/src/observable/value/ObservableValue.ts b/src/observable/value/ObservableValue.ts new file mode 100644 index 00000000..d6566613 --- /dev/null +++ b/src/observable/value/ObservableValue.ts @@ -0,0 +1,82 @@ +/* +Copyright 2020 Bruno Windels + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {AbortError} from "../../utils/error"; +import {BaseObservableValue} from "./index"; + +export class ObservableValue extends BaseObservableValue { + private _value: T; + + constructor(initialValue: T) { + super(); + this._value = initialValue; + } + + get(): T { + return this._value; + } + + set(value: T): void { + if (value !== this._value) { + this._value = value; + this.emit(this._value); + } + } +} + +export function tests() { + return { + "set emits an update": (assert): void => { + const a = new ObservableValue(0); + let fired = false; + const subscription = a.subscribe(v => { + fired = true; + assert.strictEqual(v, 5); + }); + a.set(5); + assert(fired); + subscription(); + }, + "set doesn't emit if value hasn't changed": (assert): void => { + const a = new ObservableValue(5); + let fired = false; + const subscription = a.subscribe(() => { + fired = true; + }); + a.set(5); + a.set(5); + assert(!fired); + subscription(); + }, + "waitFor promise resolves on matching update": async (assert): Promise => { + const a = new ObservableValue(5); + const handle = a.waitFor(v => v === 6); + await Promise.resolve().then(() => { + a.set(6); + }); + await handle.promise; + assert.strictEqual(a.get(), 6); + }, + "waitFor promise rejects when disposed": async (assert): Promise => { + const a = new ObservableValue(0); + const handle = a.waitFor(() => false); + await Promise.resolve().then(() => { + handle.dispose(); + }); + await assert.rejects(handle.promise, AbortError); + }, + }; +} diff --git a/src/observable/value/PickMapObservableValue.ts b/src/observable/value/PickMapObservableValue.ts new file mode 100644 index 00000000..69891df5 --- /dev/null +++ b/src/observable/value/PickMapObservableValue.ts @@ -0,0 +1,89 @@ +/* +Copyright 2020 Bruno Windels + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {BaseObservableValue} from "./index"; +import {BaseObservableMap, IMapObserver} from "../map/BaseObservableMap"; +import {SubscriptionHandle} from "../BaseObservable"; + +function pickLowestKey(currentKey: K, newKey: K): boolean { + return newKey < currentKey; +} + +export class PickMapObservableValue extends BaseObservableValue implements IMapObserver{ + + private key?: K; + private mapSubscription?: SubscriptionHandle; + + constructor( + private readonly map: BaseObservableMap, + private readonly pickKey: (currentKey: K, newKey: K) => boolean = pickLowestKey + ) { + super(); + } + + private updateKey(newKey: K): boolean { + if (this.key === undefined || this.pickKey(this.key, newKey)) { + this.key = newKey; + return true; + } + return false; + } + + onReset(): void { + this.key = undefined; + this.emit(this.get()); + } + + onAdd(key: K, value:V): void { + if (this.updateKey(key)) { + this.emit(this.get()); + } + } + + onUpdate(key: K, value: V, params: any): void { + this.emit(this.get()); + } + + onRemove(key: K, value: V): void { + if (key === this.key) { + this.key = undefined; + // try to see if there is another key that fullfills pickKey + for (const [key] of this.map) { + this.updateKey(key); + } + this.emit(this.get()); + } + } + + onSubscribeFirst(): void { + this.mapSubscription = this.map.subscribe(this); + for (const [key] of this.map) { + this.updateKey(key); + } + } + + onUnsubscribeLast(): void { + this.mapSubscription!(); + this.key = undefined; + } + + get(): V | undefined { + if (this.key !== undefined) { + return this.map.get(this.key); + } + return undefined; + } +} diff --git a/src/observable/value/RetainedObservableValue.ts b/src/observable/value/RetainedObservableValue.ts new file mode 100644 index 00000000..1943035e --- /dev/null +++ b/src/observable/value/RetainedObservableValue.ts @@ -0,0 +1,33 @@ +/* +Copyright 2020 Bruno Windels + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {ObservableValue} from "./index"; + +export class RetainedObservableValue extends ObservableValue { + + constructor(initialValue: T, private freeCallback: () => void, private startCallback: () => void = () => {}) { + super(initialValue); + } + + onSubscribeFirst(): void { + this.startCallback(); + } + + onUnsubscribeLast(): void { + super.onUnsubscribeLast(); + this.freeCallback(); + } +} diff --git a/src/observable/value/index.ts b/src/observable/value/index.ts new file mode 100644 index 00000000..1b0f1347 --- /dev/null +++ b/src/observable/value/index.ts @@ -0,0 +1,16 @@ +// In order to avoid a circular dependency problem at runtime between BaseObservableValue +// and the classes that extend it, it's important that: +// +// 1) It always remain the first module exported below. +// 2) Anything that imports any of the classes in this module +// ONLY import them from this index.ts file. +// +// See https://medium.com/visual-development/how-to-fix-nasty-circular-dependency-issues-once-and-for-all-in-javascript-typescript-a04c987cf0de +// for more on why this discipline is necessary. +export {BaseObservableValue} from './BaseObservableValue'; +export {EventObservableValue} from './EventObservableValue'; +export {FlatMapObservableValue} from './FlatMapObservableValue'; +export {PickMapObservableValue} from './PickMapObservableValue'; +export {RetainedObservableValue} from './RetainedObservableValue'; +export {MapSizeObservableValue} from './MapSizeObservableValue'; +export {ObservableValue} from './ObservableValue'; diff --git a/src/platform/types/MediaDevices.ts b/src/platform/types/MediaDevices.ts new file mode 100644 index 00000000..c5439ac4 --- /dev/null +++ b/src/platform/types/MediaDevices.ts @@ -0,0 +1,81 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export interface Event {} + +export interface MediaDevices { + // filter out audiooutput + enumerate(): Promise; + // to assign to a video element, we downcast to WrappedTrack and use the stream property. + getMediaTracks(audio: true | MediaDeviceInfo, video: boolean | MediaDeviceInfo): Promise; + getScreenShareTrack(): Promise; + createVolumeMeasurer(stream: Stream, callback: () => void): VolumeMeasurer; +} + +// Typescript definitions derived from https://github.com/microsoft/TypeScript/blob/main/lib/lib.dom.d.ts +/*! ***************************************************************************** +Copyright (c) Microsoft Corporation. All rights reserved. +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 +THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED +WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, +MERCHANTABLITY OR NON-INFRINGEMENT. +See the Apache Version 2.0 License for specific language governing permissions +and limitations under the License. +***************************************************************************** */ + +export interface StreamTrackEvent extends Event { + readonly track: Track; +} + +export interface StreamEventMap { + "addtrack": StreamTrackEvent; + "removetrack": StreamTrackEvent; +} + +export interface Stream { + getTracks(): ReadonlyArray; + getAudioTracks(): ReadonlyArray; + getVideoTracks(): ReadonlyArray; + readonly id: string; + clone(): Stream; + addEventListener(type: K, listener: (this: Stream, ev: StreamEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: Stream, ev: StreamEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + addTrack(track: Track); + removeTrack(track: Track); +} + +export enum TrackKind { + Video = "video", + Audio = "audio" +} + +export interface Track { + readonly kind: TrackKind; + readonly label: string; + readonly id: string; + enabled: boolean; + // getSettings(): MediaTrackSettings; + stop(): void; +} + +export interface VolumeMeasurer { + get isSpeaking(): boolean; + setSpeakingThreshold(threshold: number): void; + stop(); +} diff --git a/src/platform/types/WebRTC.ts b/src/platform/types/WebRTC.ts new file mode 100644 index 00000000..236e8354 --- /dev/null +++ b/src/platform/types/WebRTC.ts @@ -0,0 +1,176 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {Track, Stream, Event} from "./MediaDevices"; +import {SDPStreamMetadataPurpose} from "../../matrix/calls/callEventTypes"; + +export interface WebRTC { + createPeerConnection(forceTURN: boolean, turnServers: RTCIceServer[], iceCandidatePoolSize: number): PeerConnection; + prepareSenderForPurpose(peerConnection: PeerConnection, sender: Sender, purpose: SDPStreamMetadataPurpose): void; +} + +// Typescript definitions derived from https://github.com/microsoft/TypeScript/blob/main/lib/lib.dom.d.ts +/*! ***************************************************************************** +Copyright (c) Microsoft Corporation. All rights reserved. +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 +THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED +WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, +MERCHANTABLITY OR NON-INFRINGEMENT. +See the Apache Version 2.0 License for specific language governing permissions +and limitations under the License. +***************************************************************************** */ + +export interface DataChannelEventMap { + "bufferedamountlow": Event; + "close": Event; + "error": Event; + "message": MessageEvent; + "open": Event; +} + +export interface DataChannel { + binaryType: BinaryType; + readonly id: number | null; + readonly label: string; + readonly negotiated: boolean; + readonly readyState: DataChannelState; + close(): void; + send(data: string): void; + send(data: Blob): void; + send(data: ArrayBuffer): void; + send(data: ArrayBufferView): void; + addEventListener(type: K, listener: (this: DataChannel, ev: DataChannelEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: DataChannel, ev: DataChannelEventMap[K]) => any, options?: boolean | EventListenerOptions): void; +} + +export interface DataChannelInit { + id?: number; + maxPacketLifeTime?: number; + maxRetransmits?: number; + negotiated?: boolean; + ordered?: boolean; + protocol?: string; +} + +export interface DataChannelEvent extends Event { + readonly channel: DataChannel; +} + +export interface PeerConnectionIceEvent extends Event { + readonly candidate: RTCIceCandidate | null; +} + +export interface TrackEvent extends Event { + readonly receiver: Receiver; + readonly streams: ReadonlyArray; + readonly track: Track; + readonly transceiver: Transceiver; +} + +export interface PeerConnectionEventMap { + "connectionstatechange": Event; + "datachannel": DataChannelEvent; + "icecandidate": PeerConnectionIceEvent; + "iceconnectionstatechange": Event; + "icegatheringstatechange": Event; + "negotiationneeded": Event; + "signalingstatechange": Event; + "track": TrackEvent; +} + +export type DataChannelState = "closed" | "closing" | "connecting" | "open"; +export type IceConnectionState = "checking" | "closed" | "completed" | "connected" | "disconnected" | "failed" | "new"; +export type PeerConnectionState = "closed" | "connected" | "connecting" | "disconnected" | "failed" | "new"; +export type SignalingState = "closed" | "have-local-offer" | "have-local-pranswer" | "have-remote-offer" | "have-remote-pranswer" | "stable"; +export type IceGatheringState = "complete" | "gathering" | "new"; +export type SdpType = "answer" | "offer" | "pranswer" | "rollback"; +export type TransceiverDirection = "inactive" | "recvonly" | "sendonly" | "sendrecv" | "stopped"; +export interface SessionDescription { + readonly sdp: string; + readonly type: SdpType; +} + +export interface AnswerOptions {} + +export interface OfferOptions { + iceRestart?: boolean; + offerToReceiveAudio?: boolean; + offerToReceiveVideo?: boolean; +} + +export interface SessionDescriptionInit { + sdp?: string; + type: SdpType; +} + +export interface LocalSessionDescriptionInit { + sdp?: string; + type?: SdpType; +} + +/** A WebRTC connection between the local computer and a remote peer. It provides methods to connect to a remote peer, maintain and monitor the connection, and close the connection once it's no longer needed. */ +export interface PeerConnection { + readonly connectionState: PeerConnectionState; + readonly iceConnectionState: IceConnectionState; + readonly iceGatheringState: IceGatheringState; + readonly localDescription: SessionDescription | null; + readonly remoteDescription: SessionDescription | null; + readonly signalingState: SignalingState; + addIceCandidate(candidate?: RTCIceCandidateInit): Promise; + addTrack(track: Track, ...streams: Stream[]): Sender; + close(): void; + createAnswer(options?: AnswerOptions): Promise; + createDataChannel(label: string, dataChannelDict?: DataChannelInit): DataChannel; + createOffer(options?: OfferOptions): Promise; + getReceivers(): Receiver[]; + getSenders(): Sender[]; + getTransceivers(): Transceiver[]; + removeTrack(sender: Sender): void; + restartIce(): void; + setLocalDescription(description?: LocalSessionDescriptionInit): Promise; + setRemoteDescription(description: SessionDescriptionInit): Promise; + addEventListener(type: K, listener: (this: PeerConnection, ev: PeerConnectionEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: PeerConnection, ev: PeerConnectionEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + getStats(selector?: Track | null): Promise; + setConfiguration(configuration?: RTCConfiguration): void; +} + + + +interface StatsReport { + forEach(callbackfn: (value: any, key: string, parent: StatsReport) => void, thisArg?: any): void; +} + +export interface Receiver { + readonly track: Track; +} + +export interface Sender { + readonly track: Track | null; + replaceTrack(withTrack: Track | null): Promise; +} + +export interface Transceiver { + readonly currentDirection: TransceiverDirection | null; + direction: TransceiverDirection; + readonly mid: string | null; + readonly receiver: Receiver; + readonly sender: Sender; + stop(): void; +} diff --git a/src/platform/types/types.ts b/src/platform/types/types.ts index 1d359a09..0e2f536e 100644 --- a/src/platform/types/types.ts +++ b/src/platform/types/types.ts @@ -16,7 +16,6 @@ limitations under the License. import type {RequestResult} from "../web/dom/request/fetch.js"; import type {RequestBody} from "../../matrix/net/common"; -import type {ILogItem} from "../../logging/types"; export interface IRequestOptions { uploadProgress?: (loadedBytes: number) => void; @@ -43,3 +42,18 @@ export type File = { readonly name: string; readonly blob: IBlobHandle; } + +export interface Timeout { + elapsed(): Promise; + abort(): void; + dispose(): void; +}; + +export type TimeoutCreator = (timeout: number) => Timeout; + +export interface ITimeFormatter { + formatTime(date: Date): string; + formatRelativeDate(date: Date): string; + formatMachineReadableDate(date: Date): string; + formatDuration(milliseconds: number): string; +} diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 29a83e1f..be8c9970 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -21,8 +21,9 @@ import {SessionInfoStorage} from "../../matrix/sessioninfo/localstorage/SessionI import {SettingsStorage} from "./dom/SettingsStorage.js"; import {Encoding} from "./utils/Encoding.js"; import {OlmWorker} from "../../matrix/e2ee/OlmWorker.js"; -import {IDBLogger} from "../../logging/IDBLogger"; -import {ConsoleLogger} from "../../logging/ConsoleLogger"; +import {IDBLogPersister} from "../../logging/IDBLogPersister"; +import {ConsoleReporter} from "../../logging/ConsoleReporter"; +import {Logger} from "../../logging/Logger"; import {RootView} from "./ui/RootView.js"; import {Clock} from "./dom/Clock.js"; import {ServiceWorkerHandler} from "./dom/ServiceWorkerHandler.js"; @@ -38,7 +39,10 @@ import {downloadInIframe} from "./dom/download.js"; import {Disposables} from "../../utils/Disposables"; import {parseHTML} from "./parsehtml.js"; import {handleAvatarError} from "./ui/avatar"; +import {MediaDevicesWrapper} from "./dom/MediaDevices"; +import {DOMWebRTC} from "./dom/WebRTC"; import {ThemeLoader} from "./theming/ThemeLoader"; +import {TimeFormatter} from "./dom/TimeFormatter"; function addScript(src) { return new Promise(function (resolve, reject) { @@ -127,7 +131,7 @@ function adaptUIOnVisualViewportResize(container) { } export class Platform { - constructor({ container, assetPaths, config, configURL, options = null, cryptoExtras = null }) { + constructor({ container, assetPaths, config, configURL, logger, options = null, cryptoExtras = null }) { this._container = container; this._assetPaths = assetPaths; this._config = config; @@ -136,9 +140,10 @@ export class Platform { this.clock = new Clock(); this.encoding = new Encoding(); this.random = Math.random; - this._createLogger(options?.development); + this.logger = logger ?? this._createLogger(options?.development); this.history = new History(); this.onlineStatus = new OnlineStatus(); + this.timeFormatter = new TimeFormatter(); this._serviceWorkerHandler = null; if (assetPaths.serviceWorker && "serviceWorker" in navigator) { this._serviceWorkerHandler = new ServiceWorkerHandler(); @@ -165,6 +170,8 @@ export class Platform { this._disposables = new Disposables(); this._olmPromise = undefined; this._workerPromise = undefined; + this.mediaDevices = new MediaDevicesWrapper(navigator.mediaDevices); + this.webRTC = new DOMWebRTC(); this._themeLoader = import.meta.env.DEV? null: new ThemeLoader(this); } @@ -202,6 +209,7 @@ export class Platform { } _createLogger(isDevelopment) { + const logger = new Logger({platform: this}); // Make sure that loginToken does not end up in the logs const transformer = (item) => { if (item.e?.stack) { @@ -209,11 +217,12 @@ export class Platform { } return item; }; + const logPersister = new IDBLogPersister({name: "hydrogen_logs", platform: this, serializedTransformer: transformer}); + logger.addReporter(logPersister); if (isDevelopment) { - this.logger = new ConsoleLogger({platform: this}); - } else { - this.logger = new IDBLogger({name: "hydrogen_logs", platform: this, serializedTransformer: transformer}); + logger.addReporter(new ConsoleReporter()); } + return logger; } get updateService() { @@ -274,6 +283,10 @@ export class Platform { } } + restart() { + document.location.reload(); + } + openFile(mimeType = null) { const input = document.createElement("input"); input.setAttribute("type", "file"); @@ -364,7 +377,7 @@ export class Platform { } get description() { - return navigator.userAgent ?? ""; + return "web-" + (navigator.userAgent ?? ""); } dispose() { @@ -376,24 +389,22 @@ import {LogItem} from "../../logging/LogItem"; export function tests() { return { "loginToken should not be in logs": (assert) => { - const transformer = (item) => { - if (item.e?.stack) { - item.e.stack = item.e.stack.replace(/(?<=\/\?loginToken=).+/, ""); + const logPersister = Object.create(IDBLogPersister.prototype); + logPersister._queuedItems = []; + logPersister.options = { + serializedTransformer: (item) => { + if (item.e?.stack) { + item.e.stack = item.e.stack.replace(/(?<=\/\?loginToken=).+/, ""); + } + return item; } - return item; }; - const logger = { - _queuedItems: [], - _serializedTransformer: transformer, - _now: () => {} - }; - logger.persist = IDBLogger.prototype._persistItem.bind(logger); + const logger = { _now() {return 5;} }; const logItem = new LogItem("test", 1, logger); logItem.error = new Error(); logItem.error.stack = "main http://localhost:3000/src/main.js:55\n http://localhost:3000/?loginToken=secret:26" - logger.persist(logItem, null, false); - const item = logger._queuedItems.pop(); - console.log(item); + logPersister.reportItem(logItem, null, false); + const item = logPersister._queuedItems.pop(); assert.strictEqual(item.json.search("secret"), -1); } }; diff --git a/src/platform/web/dom/History.js b/src/platform/web/dom/History.js index d40f501b..81ddc739 100644 --- a/src/platform/web/dom/History.js +++ b/src/platform/web/dom/History.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableValue} from "../../../observable/ObservableValue"; +import {BaseObservableValue} from "../../../observable/value"; export class History extends BaseObservableValue { diff --git a/src/platform/web/dom/MediaDevices.ts b/src/platform/web/dom/MediaDevices.ts new file mode 100644 index 00000000..d6439faa --- /dev/null +++ b/src/platform/web/dom/MediaDevices.ts @@ -0,0 +1,184 @@ +/* +Copyright 2021 Šimon Brandner +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {MediaDevices as IMediaDevices, Stream, Track, TrackKind, VolumeMeasurer} from "../../types/MediaDevices"; + +const POLLING_INTERVAL = 200; // ms +export const SPEAKING_THRESHOLD = -60; // dB +const SPEAKING_SAMPLE_COUNT = 8; // samples + +export class MediaDevicesWrapper implements IMediaDevices { + constructor(private readonly mediaDevices: MediaDevices) {} + + enumerate(): Promise { + return this.mediaDevices.enumerateDevices(); + } + + async getMediaTracks(audio: true | MediaDeviceInfo, video: boolean | MediaDeviceInfo): Promise { + const stream = await this.mediaDevices.getUserMedia(this.getUserMediaContraints(audio, video)); + stream.addEventListener("removetrack", evt => { + console.log(`removing track ${evt.track.id} (${evt.track.kind}) from stream ${stream.id}`); + }); + return stream as Stream; + } + + async getScreenShareTrack(): Promise { + const stream = await this.mediaDevices.getDisplayMedia(this.getScreenshareContraints()); + return stream as Stream; + } + + private getUserMediaContraints(audio: boolean | MediaDeviceInfo, video: boolean | MediaDeviceInfo): MediaStreamConstraints { + const isWebkit = !!navigator["webkitGetUserMedia"]; + + return { + audio: audio + ? { + deviceId: typeof audio !== "boolean" ? { ideal: audio.deviceId } : undefined, + } + : false, + video: video + ? { + deviceId: typeof video !== "boolean" ? { ideal: video.deviceId } : undefined, + /* We want 640x360. Chrome will give it only if we ask exactly, + FF refuses entirely if we ask exactly, so have to ask for ideal + instead + XXX: Is this still true? + */ + width: isWebkit ? { exact: 640 } : { ideal: 640 }, + height: isWebkit ? { exact: 360 } : { ideal: 360 }, + } + : false, + }; + } + + private getScreenshareContraints(): DisplayMediaStreamConstraints { + return { + audio: false, + video: true, + }; + } + + createVolumeMeasurer(stream: Stream, callback: () => void): VolumeMeasurer { + return new WebAudioVolumeMeasurer(stream as MediaStream, callback); + } +} + +export class WebAudioVolumeMeasurer implements VolumeMeasurer { + private measuringVolumeActivity = false; + private audioContext?: AudioContext; + private analyser: AnalyserNode; + private frequencyBinCount: Float32Array; + private speakingThreshold = SPEAKING_THRESHOLD; + private speaking = false; + private volumeLooperTimeout: number; + private speakingVolumeSamples: number[]; + private callback: () => void; + private stream: MediaStream; + + constructor(stream: MediaStream, callback: () => void) { + this.stream = stream; + this.callback = callback; + this.speakingVolumeSamples = new Array(SPEAKING_SAMPLE_COUNT).fill(-Infinity); + this.initVolumeMeasuring(); + this.measureVolumeActivity(true); + } + + get isSpeaking(): boolean { return this.speaking; } + /** + * Starts emitting volume_changed events where the emitter value is in decibels + * @param enabled emit volume changes + */ + private measureVolumeActivity(enabled: boolean): void { + if (enabled) { + if (!this.audioContext || !this.analyser || !this.frequencyBinCount) return; + + this.measuringVolumeActivity = true; + this.volumeLooper(); + } else { + this.measuringVolumeActivity = false; + this.speakingVolumeSamples.fill(-Infinity); + this.callback(); + // this.emit(CallFeedEvent.VolumeChanged, -Infinity); + } + } + + private initVolumeMeasuring(): void { + const AudioContext = window.AudioContext || window["webkitAudioContext"] as undefined | typeof window.AudioContext; + if (!AudioContext) return; + + this.audioContext = new AudioContext(); + + this.analyser = this.audioContext.createAnalyser(); + this.analyser.fftSize = 512; + this.analyser.smoothingTimeConstant = 0.1; + + const mediaStreamAudioSourceNode = this.audioContext.createMediaStreamSource(this.stream); + mediaStreamAudioSourceNode.connect(this.analyser); + + this.frequencyBinCount = new Float32Array(this.analyser.frequencyBinCount); + } + + public setSpeakingThreshold(threshold: number) { + this.speakingThreshold = threshold; + } + + private volumeLooper = () => { + if (!this.analyser) return; + + if (!this.measuringVolumeActivity) return; + + this.analyser.getFloatFrequencyData(this.frequencyBinCount); + + let maxVolume = -Infinity; + for (let i = 0; i < this.frequencyBinCount.length; i++) { + if (this.frequencyBinCount[i] > maxVolume) { + maxVolume = this.frequencyBinCount[i]; + } + } + + this.speakingVolumeSamples.shift(); + this.speakingVolumeSamples.push(maxVolume); + + this.callback(); + // this.emit(CallFeedEvent.VolumeChanged, maxVolume); + + let newSpeaking = false; + + for (let i = 0; i < this.speakingVolumeSamples.length; i++) { + const volume = this.speakingVolumeSamples[i]; + + if (volume > this.speakingThreshold) { + newSpeaking = true; + break; + } + } + + if (this.speaking !== newSpeaking) { + this.speaking = newSpeaking; + this.callback(); + // this.emit(CallFeedEvent.Speaking, this.speaking); + } + + this.volumeLooperTimeout = setTimeout(this.volumeLooper, POLLING_INTERVAL) as unknown as number; + }; + + public stop(): void { + clearTimeout(this.volumeLooperTimeout); + this.analyser.disconnect(); + this.audioContext?.close(); + } +} diff --git a/src/platform/web/dom/OnlineStatus.js b/src/platform/web/dom/OnlineStatus.js index 48e4e912..4c316de6 100644 --- a/src/platform/web/dom/OnlineStatus.js +++ b/src/platform/web/dom/OnlineStatus.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableValue} from "../../../observable/ObservableValue"; +import {BaseObservableValue} from "../../../observable/value"; export class OnlineStatus extends BaseObservableValue { constructor() { diff --git a/src/platform/web/dom/TimeFormatter.ts b/src/platform/web/dom/TimeFormatter.ts new file mode 100644 index 00000000..c25e902b --- /dev/null +++ b/src/platform/web/dom/TimeFormatter.ts @@ -0,0 +1,82 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { ITimeFormatter } from "../../types/types"; +import {Clock} from "./Clock"; +import {formatDuration, TimeScope} from "../../../utils/timeFormatting"; + +export class TimeFormatter implements ITimeFormatter { + + private todayMidnight: Date; + private relativeDayFormatter: Intl.RelativeTimeFormat; + private weekdayFormatter: Intl.DateTimeFormat; + private currentYearFormatter: Intl.DateTimeFormat; + private otherYearFormatter: Intl.DateTimeFormat; + private timeFormatter: Intl.DateTimeFormat; + + constructor(private clock: Clock) { + // don't use the clock time here as the DOM relative formatters don't support setting the reference date anyway + this.todayMidnight = new Date(); + this.todayMidnight.setHours(0, 0, 0, 0); + this.relativeDayFormatter = new Intl.RelativeTimeFormat(undefined, {numeric: "auto"}); + this.weekdayFormatter = new Intl.DateTimeFormat(undefined, {weekday: 'long'}); + this.currentYearFormatter = new Intl.DateTimeFormat(undefined, { + weekday: 'long', + month: 'long', + day: 'numeric' + }); + this.otherYearFormatter = new Intl.DateTimeFormat(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }); + this.timeFormatter = new Intl.DateTimeFormat(undefined, {hour: "numeric", minute: "2-digit"}); + } + + formatTime(date: Date): string { + return this.timeFormatter.format(date); + } + + formatMachineReadableDate(date: Date): string { + return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; + } + + formatRelativeDate(date: Date): string { + let daysDiff = Math.floor((date.getTime() - this.todayMidnight.getTime()) / TimeScope.Day); + if (daysDiff >= -1 && daysDiff <= 1) { + // Tomorrow, Today, Yesterday + return capitalizeFirstLetter(this.relativeDayFormatter.format(daysDiff, "day")); + } else if (daysDiff > -7 && daysDiff < 0) { + // Wednesday + return this.weekdayFormatter.format(date); + } else if (this.todayMidnight.getFullYear() === date.getFullYear()) { + // Friday, November 6 + return this.currentYearFormatter.format(date); + } else { + // Friday, November 5, 2021 + return this.otherYearFormatter.format(date); + } + } + + formatDuration(milliseconds: number): string { + return formatDuration(milliseconds); + } +} + +function capitalizeFirstLetter(str: string) { + return str.slice(0, 1).toLocaleUpperCase() + str.slice(1); +} diff --git a/src/platform/web/dom/WebRTC.ts b/src/platform/web/dom/WebRTC.ts new file mode 100644 index 00000000..05e032ca --- /dev/null +++ b/src/platform/web/dom/WebRTC.ts @@ -0,0 +1,77 @@ +/* +Copyright 2021 Šimon Brandner + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {Stream, Track, TrackKind} from "../../types/MediaDevices"; +import {WebRTC, Sender, PeerConnection} from "../../types/WebRTC"; +import {SDPStreamMetadataPurpose} from "../../../matrix/calls/callEventTypes"; + +const POLLING_INTERVAL = 200; // ms +export const SPEAKING_THRESHOLD = -60; // dB +const SPEAKING_SAMPLE_COUNT = 8; // samples + +export class DOMWebRTC implements WebRTC { + createPeerConnection(forceTURN: boolean, turnServers: RTCIceServer[], iceCandidatePoolSize): PeerConnection { + const peerConn = new RTCPeerConnection({ + iceTransportPolicy: forceTURN ? 'relay' : undefined, + iceServers: turnServers, + iceCandidatePoolSize: iceCandidatePoolSize, + }) as PeerConnection; + return new Proxy(peerConn, { + get(target, prop, receiver) { + if (prop === "close") { + console.trace("calling peerConnection.close"); + } + const value = target[prop]; + if (typeof value === "function") { + return value.bind(target); + } else { + return value; + } + } + }); + } + + prepareSenderForPurpose(peerConnection: PeerConnection, sender: Sender, purpose: SDPStreamMetadataPurpose): void { + if (purpose === SDPStreamMetadataPurpose.Screenshare) { + this.getRidOfRTXCodecs(peerConnection as RTCPeerConnection, sender as RTCRtpSender); + } + } + + private getRidOfRTXCodecs(peerConnection: RTCPeerConnection, sender: RTCRtpSender): void { + // RTCRtpReceiver.getCapabilities and RTCRtpSender.getCapabilities don't seem to be supported on FF + if (!RTCRtpReceiver.getCapabilities || !RTCRtpSender.getCapabilities) return; + + const recvCodecs = RTCRtpReceiver.getCapabilities("video")?.codecs ?? []; + const sendCodecs = RTCRtpSender.getCapabilities("video")?.codecs ?? []; + const codecs = [...sendCodecs, ...recvCodecs]; + + for (const codec of codecs) { + if (codec.mimeType === "video/rtx") { + const rtxCodecIndex = codecs.indexOf(codec); + codecs.splice(rtxCodecIndex, 1); + } + } + + const transceiver = peerConnection.getTransceivers().find(t => t.sender === sender); + if (transceiver && ( + transceiver.sender.track?.kind === "video" || + transceiver.receiver.track?.kind === "video" + ) + ) { + transceiver.setCodecPreferences(codecs); + } + } +} diff --git a/src/platform/web/main.js b/src/platform/web/main.js index 2b28187e..d9c6fe8a 100644 --- a/src/platform/web/main.js +++ b/src/platform/web/main.js @@ -18,6 +18,8 @@ limitations under the License. // import {RecordRequester, ReplayRequester} from "./matrix/net/request/replay"; import {RootViewModel} from "../../domain/RootViewModel.js"; import {createNavigation, createRouter} from "../../domain/navigation/index"; +import {FeatureSet} from "../../features"; + // Don't use a default export here, as we use multiple entries during legacy build, // which does not support default exports, // see https://github.com/rollup/plugins/tree/master/packages/multi-entry @@ -33,6 +35,7 @@ export async function main(platform) { // const request = recorder.request; // window.getBrawlFetchLog = () => recorder.log(); await platform.init(); + const features = await FeatureSet.load(platform.settingsStorage); const navigation = createNavigation(); platform.setNavigation(navigation); const urlRouter = createRouter({navigation, history: platform.history}); @@ -43,6 +46,7 @@ export async function main(platform) { // so we call it that in the view models urlRouter: urlRouter, navigation, + features }); await vm.load(); platform.createAndMountRootView(vm); diff --git a/src/platform/web/theming/shared/color.mjs b/src/platform/web/theming/shared/color.mjs index 8af76b6b..31d40185 100644 --- a/src/platform/web/theming/shared/color.mjs +++ b/src/platform/web/theming/shared/color.mjs @@ -36,5 +36,8 @@ export function derive(value, operation, argument, isDark) { const newColorString = offColor(value).lighten(argumentAsNumber / 100).hex(); return newColorString; } + case "alpha": { + return offColor(value).rgba(argumentAsNumber / 100); + } } } diff --git a/src/platform/web/ui/css/avatar.css b/src/platform/web/ui/css/avatar.css index 2ee9ca0c..a8f8080d 100644 --- a/src/platform/web/ui/css/avatar.css +++ b/src/platform/web/ui/css/avatar.css @@ -46,6 +46,14 @@ limitations under the License. font-size: calc(var(--avatar-size) * 0.6); } +.hydrogen .avatar.size-96 { + --avatar-size: 96px; + width: var(--avatar-size); + height: var(--avatar-size); + line-height: var(--avatar-size); + font-size: calc(var(--avatar-size) * 0.6); +} + .hydrogen .avatar.size-64 { --avatar-size: 64px; width: var(--avatar-size); diff --git a/src/platform/web/ui/css/right-panel.css b/src/platform/web/ui/css/right-panel.css index 92a89c0a..5af0e6a0 100644 --- a/src/platform/web/ui/css/right-panel.css +++ b/src/platform/web/ui/css/right-panel.css @@ -19,6 +19,37 @@ text-align: center; } +.MemberDetailsView_shield_container { + display: flex; + gap: 4px; +} + +.MemberDetailsView_shield_red, .MemberDetailsView_shield_green, .MemberDetailsView_shield_black { + background-size: contain; + background-repeat: no-repeat; + width: 24px; + height: 24px; + display: block; + flex-shrink: 0; +} + +.MemberDetailsView_shield_description { + flex-grow: 1; + margin: 0; +} + +.MemberDetailsView_shield_red { + background-image: url("./icons/verification-error.svg?primary=error-color"); +} + +.MemberDetailsView_shield_green { + background-image: url("./icons/verified.svg?primary=accent-color"); +} + +.MemberDetailsView_shield_black { + background-image: url("./icons/encryption-status.svg?primary=text-color"); +} + .RoomDetailsView_label, .RoomDetailsView_row, .RoomDetailsView, .MemberDetailsView, .EncryptionIconView { display: flex; align-items: center; diff --git a/src/platform/web/ui/css/themes/element/call.css b/src/platform/web/ui/css/themes/element/call.css new file mode 100644 index 00000000..9b525dc2 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/call.css @@ -0,0 +1,238 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.CallView { + height: 40vh; + display: grid; +} + +.CallView > * { + grid-column: 1; + grid-row: 1; +} + +.CallView_error { + align-self: start; + justify-self: center; + margin: 16px; + /** Chrome (v100) requires this to make the buttons clickable + * where they overlap with the video element, even though + * the buttons come later in the DOM. */ + z-index: 1; +} + +.CallView_members { + display: grid; + gap: 12px; + background: var(--background-color-secondary--darker-5); + padding: 12px; + margin: 0; + min-height: 0; + list-style: none; + align-self: stretch; +} + +.StreamView { + display: grid; + border-radius: 8px; + overflow: hidden; + background-color: black; +} + +.StreamView > * { + grid-column: 1; + grid-row: 1; +} + +.StreamView video { + width: 100%; + height: 100%; + min-height: 0; + object-fit: contain; +} + +.StreamView_avatar { + align-self: center; + justify-self: center; +} + +.StreamView_error { + align-self: start; + justify-self: center; + /** Chrome (v100) requires this to make the buttons clickable + * where they overlap with the video element, even though + * the buttons come later in the DOM. */ + z-index: 1; +} + +.StreamView_muteStatus { + align-self: start; + justify-self: end; + width: 24px; + height: 24px; + background-position: center; + background-repeat: no-repeat; + background-size: 14px; + display: block; + background-color: var(--text-color); + border-radius: 4px; + margin: 4px; +} + +.StreamView_muteStatus.microphoneMuted { + background-image: url("./icons/mic-muted.svg?primary=text-color--lighter-80"); +} + +.StreamView_muteStatus.cameraMuted { + background-image: url("./icons/cam-muted.svg?primary=text-color--lighter-80"); +} + +.CallView_buttons { + align-self: end; + justify-self: center; + display: flex; + gap: 12px; + margin-bottom: 16px; + /** Chrome (v100) requires this to make the buttons clickable + * where they overlap with the video element, even though + * the buttons come later in the DOM. */ + z-index: 1; +} + +.CallView_buttons button { + border-radius: 100%; + width: 48px; + height: 48px; + border: none; + background-color: var(--accent-color); + background-position: center; + background-repeat: no-repeat; +} + +.CallView_buttons button:disabled { + background-color: var(--accent-color--lighter-10); +} + +.CallView_buttons .CallView_hangup { + background-color: var(--error-color); + background-image: url("./icons/hangup.svg?primary=background-color-primary"); +} + +.CallView_buttons .CallView_hangup:disabled { + background-color: var(--error-color--lighter-10); +} + +.CallView_buttons .CallView_mutedMicrophone { + background-color: var(--background-color-primary); + background-image: url("./icons/mic-muted.svg?primary=text-color"); +} + +.CallView_buttons .CallView_unmutedMicrophone { + background-image: url("./icons/mic-unmuted.svg?primary=background-color-primary"); +} + +.CallView_buttons .CallView_mutedCamera { + background-color: var(--background-color-primary); + background-image: url("./icons/cam-muted.svg?primary=text-color"); +} + +.CallView_buttons .CallView_unmutedCamera { + background-image: url("./icons/cam-unmuted.svg?primary=background-color-primary"); +} + +.CallView_members.size1 { + grid-template-columns: 1fr; + grid-template-rows: 1fr; +} + +.CallView_members.size2 { + grid-template-columns: 1fr; + grid-template-rows: repeat(2, 1fr); +} + +/* square */ +.CallView_members.square.size3, +.CallView_members.square.size4 { + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(2, 1fr); +} +.CallView_members.square.size5, +.CallView_members.square.size6 { + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(2, 1fr); +} +.CallView_members.square.size7, +.CallView_members.square.size8, +.CallView_members.square.size9 { + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(3, 1fr); +} +.CallView_members.square.size10 { + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(4, 1fr); +} +/** tall */ +.CallView_members.tall.size3 { + grid-template-columns: 1fr; + grid-template-rows: repeat(3, 1fr); +} +.CallView_members.tall.size4 { + grid-template-columns: 1fr; + grid-template-rows: repeat(4, 1fr); +} +.CallView_members.tall.size5, +.CallView_members.tall.size6 { + grid-template-rows: repeat(3, 1fr); + grid-template-columns: repeat(2, 1fr); +} +.CallView_members.tall.size7, +.CallView_members.tall.size8 { + grid-template-rows: repeat(4, 1fr); + grid-template-columns: repeat(2, 1fr); +} +.CallView_members.tall.size9, +.CallView_members.tall.size10 { + grid-template-rows: repeat(5, 1fr); + grid-template-columns: repeat(2, 1fr); +} +/** wide */ +.CallView_members.wide.size2 { + grid-template-columns: repeat(2, 1fr); + grid-template-rows: 1fr; +} +.CallView_members.wide.size3 { + grid-template-rows: 1fr; + grid-template-columns: repeat(3, 1fr); +} +.CallView_members.wide.size4 { + grid-template-rows: 1fr; + grid-template-columns: repeat(4, 1fr); +} +.CallView_members.wide.size5, +.CallView_members.wide.size6 { + grid-template-rows: repeat(2, 1fr); + grid-template-columns: repeat(3, 1fr); +} +.CallView_members.wide.size7, +.CallView_members.wide.size8 { + grid-template-rows: repeat(2, 1fr); + grid-template-columns: repeat(4, 1fr); +} +.CallView_members.wide.size9, +.CallView_members.wide.size10 { + grid-template-rows: repeat(3, 1fr); + grid-template-columns: repeat(4, 1fr); +} diff --git a/src/platform/web/ui/css/themes/element/error.css b/src/platform/web/ui/css/themes/element/error.css new file mode 100644 index 00000000..e0b945ca --- /dev/null +++ b/src/platform/web/ui/css/themes/element/error.css @@ -0,0 +1,56 @@ +.ErrorView_block { + background: var(--error-color); + color: var(--fixed-white); + margin: 16px; +} + +.ErrorView.ErrorView_inline { + color: var(--error-color); + margin: 4px 0; + padding: 4px 0; +} + +.ErrorView.ErrorView_inline > p { + margin: 0; +} + +.ErrorView { + font-weight: bold; + margin: 16px; + border-radius: 8px; + padding: 12px; + display: flex; + gap: 8px; +} + +.ErrorView_message { + flex-basis: 0; + flex-grow: 1; + margin: 0px; + word-break: break-all; + word-break: break-word; + align-self: center; +} + +.ErrorView_submit { + align-self: end; +} + +.ErrorView_close { + align-self: start; + width: 16px; + height: 16px; + border: none; + background: none; + background-repeat: no-repeat; + background-size: contain; + cursor: pointer; +} + +.ErrorView_block .ErrorView_close { + background-image: url('icons/clear.svg?primary=fixed-white'); +} + +.ErrorView_inline .ErrorView_close { + background-image: url('icons/clear.svg?primary=text-color'); +} diff --git a/src/platform/web/ui/css/themes/element/icons/cam-muted.svg b/src/platform/web/ui/css/themes/element/icons/cam-muted.svg new file mode 100644 index 00000000..6a739ae2 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/cam-muted.svg @@ -0,0 +1 @@ + diff --git a/src/platform/web/ui/css/themes/element/icons/cam-unmuted.svg b/src/platform/web/ui/css/themes/element/icons/cam-unmuted.svg new file mode 100644 index 00000000..9497e075 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/cam-unmuted.svg @@ -0,0 +1 @@ + diff --git a/src/platform/web/ui/css/themes/element/icons/hangup.svg b/src/platform/web/ui/css/themes/element/icons/hangup.svg new file mode 100644 index 00000000..c56fe7a4 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/hangup.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/platform/web/ui/css/themes/element/icons/mic-muted.svg b/src/platform/web/ui/css/themes/element/icons/mic-muted.svg new file mode 100644 index 00000000..35669ee0 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/mic-muted.svg @@ -0,0 +1,19 @@ + + + + + + diff --git a/src/platform/web/ui/css/themes/element/icons/mic-unmuted.svg b/src/platform/web/ui/css/themes/element/icons/mic-unmuted.svg new file mode 100644 index 00000000..94b81510 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/mic-unmuted.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/src/platform/web/ui/css/themes/element/icons/verification-error.svg b/src/platform/web/ui/css/themes/element/icons/verification-error.svg new file mode 100644 index 00000000..9733f563 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/verification-error.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/platform/web/ui/css/themes/element/icons/verified.svg b/src/platform/web/ui/css/themes/element/icons/verified.svg new file mode 100644 index 00000000..340891f1 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/verified.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/platform/web/ui/css/themes/element/icons/video-call.svg b/src/platform/web/ui/css/themes/element/icons/video-call.svg new file mode 100644 index 00000000..bc3688b5 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/video-call.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/platform/web/ui/css/themes/element/icons/voice-call.svg b/src/platform/web/ui/css/themes/element/icons/voice-call.svg new file mode 100644 index 00000000..02a79969 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/voice-call.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/platform/web/ui/css/themes/element/inter.css b/src/platform/web/ui/css/themes/element/inter.css index 5c69d7e6..d4cd1e8a 100644 --- a/src/platform/web/ui/css/themes/element/inter.css +++ b/src/platform/web/ui/css/themes/element/inter.css @@ -3,16 +3,16 @@ font-style: normal; font-weight: 100; font-display: swap; - src: url("inter/Inter-Thin.woff2?v=3.13") format("woff2"), - url("inter/Inter-Thin.woff?v=3.13") format("woff"); + src: url("inter/Inter-Thin.woff2") format("woff2"), + url("inter/Inter-Thin.woff") format("woff"); } @font-face { font-family: 'Inter'; font-style: italic; font-weight: 100; font-display: swap; - src: url("inter/Inter-ThinItalic.woff2?v=3.13") format("woff2"), - url("inter/Inter-ThinItalic.woff?v=3.13") format("woff"); + src: url("inter/Inter-ThinItalic.woff2") format("woff2"), + url("inter/Inter-ThinItalic.woff") format("woff"); } @font-face { @@ -20,16 +20,16 @@ font-style: normal; font-weight: 200; font-display: swap; - src: url("inter/Inter-ExtraLight.woff2?v=3.13") format("woff2"), - url("inter/Inter-ExtraLight.woff?v=3.13") format("woff"); + src: url("inter/Inter-ExtraLight.woff2") format("woff2"), + url("inter/Inter-ExtraLight.woff") format("woff"); } @font-face { font-family: 'Inter'; font-style: italic; font-weight: 200; font-display: swap; - src: url("inter/Inter-ExtraLightItalic.woff2?v=3.13") format("woff2"), - url("inter/Inter-ExtraLightItalic.woff?v=3.13") format("woff"); + src: url("inter/Inter-ExtraLightItalic.woff2") format("woff2"), + url("inter/Inter-ExtraLightItalic.woff") format("woff"); } @font-face { @@ -37,16 +37,16 @@ font-style: normal; font-weight: 300; font-display: swap; - src: url("inter/Inter-Light.woff2?v=3.13") format("woff2"), - url("inter/Inter-Light.woff?v=3.13") format("woff"); + src: url("inter/Inter-Light.woff2") format("woff2"), + url("inter/Inter-Light.woff") format("woff"); } @font-face { font-family: 'Inter'; font-style: italic; font-weight: 300; font-display: swap; - src: url("inter/Inter-LightItalic.woff2?v=3.13") format("woff2"), - url("inter/Inter-LightItalic.woff?v=3.13") format("woff"); + src: url("inter/Inter-LightItalic.woff2") format("woff2"), + url("inter/Inter-LightItalic.woff") format("woff"); } @font-face { @@ -54,16 +54,16 @@ font-style: normal; font-weight: 400; font-display: swap; - src: url("inter/Inter-Regular.woff2?v=3.13") format("woff2"), - url("inter/Inter-Regular.woff?v=3.13") format("woff"); + src: url("inter/Inter-Regular.woff2") format("woff2"), + url("inter/Inter-Regular.woff") format("woff"); } @font-face { font-family: 'Inter'; font-style: italic; font-weight: 400; font-display: swap; - src: url("inter/Inter-Italic.woff2?v=3.13") format("woff2"), - url("inter/Inter-Italic.woff?v=3.13") format("woff"); + src: url("inter/Inter-Italic.woff2") format("woff2"), + url("inter/Inter-Italic.woff") format("woff"); } @font-face { @@ -71,16 +71,16 @@ font-style: normal; font-weight: 500; font-display: swap; - src: url("inter/Inter-Medium.woff2?v=3.13") format("woff2"), - url("inter/Inter-Medium.woff?v=3.13") format("woff"); + src: url("inter/Inter-Medium.woff2") format("woff2"), + url("inter/Inter-Medium.woff") format("woff"); } @font-face { font-family: 'Inter'; font-style: italic; font-weight: 500; font-display: swap; - src: url("inter/Inter-MediumItalic.woff2?v=3.13") format("woff2"), - url("inter/Inter-MediumItalic.woff?v=3.13") format("woff"); + src: url("inter/Inter-MediumItalic.woff2") format("woff2"), + url("inter/Inter-MediumItalic.woff") format("woff"); } @font-face { @@ -88,16 +88,16 @@ font-style: normal; font-weight: 600; font-display: swap; - src: url("inter/Inter-SemiBold.woff2?v=3.13") format("woff2"), - url("inter/Inter-SemiBold.woff?v=3.13") format("woff"); + src: url("inter/Inter-SemiBold.woff2") format("woff2"), + url("inter/Inter-SemiBold.woff") format("woff"); } @font-face { font-family: 'Inter'; font-style: italic; font-weight: 600; font-display: swap; - src: url("inter/Inter-SemiBoldItalic.woff2?v=3.13") format("woff2"), - url("inter/Inter-SemiBoldItalic.woff?v=3.13") format("woff"); + src: url("inter/Inter-SemiBoldItalic.woff2") format("woff2"), + url("inter/Inter-SemiBoldItalic.woff") format("woff"); } @font-face { @@ -105,16 +105,16 @@ font-style: normal; font-weight: 700; font-display: swap; - src: url("inter/Inter-Bold.woff2?v=3.13") format("woff2"), - url("inter/Inter-Bold.woff?v=3.13") format("woff"); + src: url("inter/Inter-Bold.woff2") format("woff2"), + url("inter/Inter-Bold.woff") format("woff"); } @font-face { font-family: 'Inter'; font-style: italic; font-weight: 700; font-display: swap; - src: url("inter/Inter-BoldItalic.woff2?v=3.13") format("woff2"), - url("inter/Inter-BoldItalic.woff?v=3.13") format("woff"); + src: url("inter/Inter-BoldItalic.woff2") format("woff2"), + url("inter/Inter-BoldItalic.woff") format("woff"); } @font-face { @@ -122,16 +122,16 @@ font-style: normal; font-weight: 800; font-display: swap; - src: url("inter/Inter-ExtraBold.woff2?v=3.13") format("woff2"), - url("inter/Inter-ExtraBold.woff?v=3.13") format("woff"); + src: url("inter/Inter-ExtraBold.woff2") format("woff2"), + url("inter/Inter-ExtraBold.woff") format("woff"); } @font-face { font-family: 'Inter'; font-style: italic; font-weight: 800; font-display: swap; - src: url("inter/Inter-ExtraBoldItalic.woff2?v=3.13") format("woff2"), - url("inter/Inter-ExtraBoldItalic.woff?v=3.13") format("woff"); + src: url("inter/Inter-ExtraBoldItalic.woff2") format("woff2"), + url("inter/Inter-ExtraBoldItalic.woff") format("woff"); } @font-face { @@ -139,14 +139,14 @@ font-style: normal; font-weight: 900; font-display: swap; - src: url("inter/Inter-Black.woff2?v=3.13") format("woff2"), - url("inter/Inter-Black.woff?v=3.13") format("woff"); + src: url("inter/Inter-Black.woff2") format("woff2"), + url("inter/Inter-Black.woff") format("woff"); } @font-face { font-family: 'Inter'; font-style: italic; font-weight: 900; font-display: swap; - src: url("inter/Inter-BlackItalic.woff2?v=3.13") format("woff2"), - url("inter/Inter-BlackItalic.woff?v=3.13") format("woff"); + src: url("inter/Inter-BlackItalic.woff2") format("woff2"), + url("inter/Inter-BlackItalic.woff") format("woff"); } diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index e6f5b7a1..ca64e15a 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -18,6 +18,8 @@ limitations under the License. @import url('../../main.css'); @import url('inter.css'); @import url('timeline.css'); +@import url('call.css'); +@import url('error.css'); :root { font-size: 10px; @@ -749,6 +751,29 @@ a { margin-bottom: 0; } +.FeatureView { + display: flex; + gap: 8px; +} + +.FeaturesView ul { + list-style: none; + padding: 8px 16px; +} + +.FeaturesView input[type="checkbox"] { + align-self: start; +} + +.FeatureView h4 { + margin: 0; +} + + +.FeatureView p { + margin: 8px 0; +} + .error { color: var(--error-color); font-weight: 600; @@ -1157,6 +1182,7 @@ button.RoomDetailsView_row::after { border: none; background: none; cursor: pointer; + text-align: left; } .LazyListParent { @@ -1222,3 +1248,109 @@ button.RoomDetailsView_row::after { .JoinRoomView_status .spinner { margin-right: 5px; } + +/* Toast */ +.ToastCollectionView { + display: flex; + position: fixed; + flex-direction: column; + z-index: 1000; + left: 44px; + top: 52px; +} + +.ToastCollectionView ul { + margin: 0; + padding: 0; +} + +.CallToastNotificationView:not(:first-child) { + margin-top: 12px; +} + +.CallToastNotificationView { + display: grid; + grid-template-rows: 40px 1fr 1fr 48px; + row-gap: 4px; + width: 260px; + background-color: var(--background-color-secondary); + border-radius: 8px; + color: var(--text-color); + box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.5); +} + +.CallToastNotificationView__top { + display: grid; + grid-template-columns: auto 176px auto; + align-items: center; + justify-items: center; +} +.CallToastNotificationView__dismiss-btn { + background: center var(--background-color-secondary--darker-5) url("./icons/dismiss.svg?primary=text-color") no-repeat; + border-radius: 100%; + height: 15px; + width: 15px; +} + +.CallToastNotificationView__name { + font-weight: 600; + width: 100%; +} + +.CallToastNotificationView__description { + margin-left: 42px; +} + +.CallToastNotificationView__call-type::before { + content: ""; + background-image: url("./icons/video-call.svg?primary=light-text-color"); + background-repeat: no-repeat; + background-size: 20px 20px; + display: flex; + width: 20px; + height: 20px; + padding-right: 5px; +} + +.CallToastNotificationView__call-type::after { + content: ""; + width: 4px; + height: 4px; + background-color: var(--text-color); + border-radius: 100%; + align-self: center; + margin: 5px; +} + +.CallToastNotificationView__member-count::before { + content: ""; + background-image: url("./icons/room-members.svg?primary=light-text-color"); + background-repeat: no-repeat; + background-size: 20px 20px; + display: flex; + width: 20px; + height: 20px; + padding-right: 5px; +} + +.CallToastNotificationView__member-count, +.CallToastNotificationView__call-type { + display: flex; + align-items: center; +} + +.CallToastNotificationView__info { + display: flex; + margin-left: 42px; +} + +.CallToastNotificationView__action { + display: flex; + justify-content: end; + margin-right: 10px; +} + +.CallToastNotificationView__action .button-action { + width: 100px; + height: 40px; +} diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index 43c57d19..4a822605 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -364,7 +364,7 @@ only loads when the top comes into view*/ } .Timeline_messageReactions button.active { - background-color: var(--background-color-secondary); + background-color: var(--accent-color--alpha-11); border-color: var(--accent-color); } @@ -411,7 +411,7 @@ only loads when the top comes into view*/ border-radius: 10px; } -.GapView > :not(:first-child) { +.GapView_container > :not(:first-child) { margin-left: 12px; } @@ -422,3 +422,56 @@ only loads when the top comes into view*/ .GapView.isAtTop { padding: 52px 20px 12px 20px; } + +.DateHeader { + font-weight: bold; + font-size: 1.5rem; + position: sticky; + top: 5px; +} + +.DateHeader time { + margin: 0 auto; + padding: 12px 4px; + width: 250px; + padding: 12px; + display: block; + color: var(--light-text-color); + background-color: var(--background-color-primary); + border-radius: 8px; + text-align: center; +} + +.CallTileView > div > div { + display: flex; + flex-direction: column; + gap: 4px; +} + +.CallTileView_members > * { + margin-right: -16px; +} + +.CallTileView_members { + display: flex; +} + +.CallTileView_title { + font-weight: bold; +} + +.CallTileView_subtitle { + font-size: 12px; +} + +.CallTileView_memberCount::before { + content: ""; + background-image: url('./icons/room-members.svg?primary=text-color'); + background-repeat: no-repeat; + background-size: 16px 16px; + width: 16px; + height: 16px; + display: inline-flex; + vertical-align: bottom; + margin-right: 4px; +} diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index b6d62c98..0932739c 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -52,11 +52,11 @@ limitations under the License. align-items: center; } -.GapView { +.GapView_container { display: flex; } -.GapView > :nth-child(2) { +.GapView_container > span { flex: 1; } diff --git a/src/platform/web/ui/general/ErrorView.ts b/src/platform/web/ui/general/ErrorView.ts new file mode 100644 index 00000000..eb1e7838 --- /dev/null +++ b/src/platform/web/ui/general/ErrorView.ts @@ -0,0 +1,60 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {TemplateView, Builder} from "./TemplateView"; +import { disableTargetCallback } from "./utils"; + +import type { ViewNode } from "./types"; +import type {ErrorViewModel} from "../../../../domain/ErrorViewModel"; + + +export class ErrorView extends TemplateView { + constructor(vm: ErrorViewModel, private readonly options: {inline: boolean} = {inline: false}) { + super(vm); + } + override render(t: Builder, vm: ErrorViewModel): ViewNode { + const submitLogsButton = t.button({ + className: "ErrorView_submit", + onClick: disableTargetCallback(async evt => { + evt.stopPropagation(); + if (await vm.submitLogs()) { + alert("Logs submitted!"); + } else { + alert("Could not submit logs"); + } + }) + }, "Submit logs"); + const closeButton = t.button({ + className: "ErrorView_close", + onClick: evt => { + evt.stopPropagation(); + vm.close(); + }, + title: "Dismiss error" + }); + return t.div({ + className: { + "ErrorView": true, + "ErrorView_inline": this.options.inline, + "ErrorView_block": !this.options.inline + }}, [ + t.p({className: "ErrorView_message"}, vm.message), + submitLogsButton, + closeButton + ]); + } +} + diff --git a/src/platform/web/ui/general/TemplateView.ts b/src/platform/web/ui/general/TemplateView.ts index d6e3dd3f..3b65ed9c 100644 --- a/src/platform/web/ui/general/TemplateView.ts +++ b/src/platform/web/ui/general/TemplateView.ts @@ -181,7 +181,7 @@ export class TemplateBuilder { this._templateView._addEventListener(node, name, fn, useCapture); } - _addAttributeBinding(node: Element, name: string, fn: (value: T) => boolean | string): void { + _addAttributeBinding(node: Element, name: string, fn: AttributeBinding): void { let prevValue: string | boolean | undefined = undefined; const binding = () => { const newValue = fn(this._value); @@ -337,7 +337,7 @@ export class TemplateBuilder { // Special case of mapView for a TemplateView. // Always creates a TemplateView, if this is optional depending // on mappedValue, use `if` or `mapView` - map(mapFn: (value: T) => R, renderFn: (mapped: R, t: Builder, vm: T) => ViewNode): ViewNode { + map(mapFn: (value: T) => R, renderFn: (mapped: R, t: Builder, vm: T) => ViewNode | undefined): ViewNode { return this.mapView(mapFn, mappedValue => { return new InlineTemplateView(this._value, (t, vm) => { const rootNode = renderFn(mappedValue, t, vm); @@ -371,17 +371,17 @@ export class TemplateBuilder { event handlers, ... You should not call the TemplateBuilder (e.g. `t.xxx()`) at all from the side effect, instead use tags from html.ts to help you construct any DOM you need. */ - mapSideEffect(mapFn: (value: T) => R, sideEffect: (newV: R, oldV: R | undefined) => void) { + mapSideEffect(mapFn: (value: T) => R, sideEffect: (newV: R, oldV: R | undefined, value: T) => void) { let prevValue = mapFn(this._value); const binding = () => { const newValue = mapFn(this._value); if (prevValue !== newValue) { - sideEffect(newValue, prevValue); + sideEffect(newValue, prevValue, this._value); prevValue = newValue; } }; this._addBinding(binding); - sideEffect(prevValue, undefined); + sideEffect(prevValue, undefined, this._value); } } diff --git a/src/platform/web/ui/general/html.ts b/src/platform/web/ui/general/html.ts index 4e1fe78d..93512897 100644 --- a/src/platform/web/ui/general/html.ts +++ b/src/platform/web/ui/general/html.ts @@ -102,10 +102,10 @@ export const SVG_NS: string = "http://www.w3.org/2000/svg"; export const TAG_NAMES = { [HTML_NS]: [ "br", "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6", - "p", "strong", "em", "span", "img", "section", "header", "main", "footer", - "article", "aside", "del", "blockquote", + "p", "strong", "em", "span", "img", "section", "header", "main", "footer", "dialog", + "article", "aside", "del", "blockquote", "details", "summary", "table", "thead", "tbody", "tr", "th", "td", "hr", - "pre", "code", "button", "time", "input", "textarea", "select", "option", "label", "form", + "pre", "code", "button", "time", "input", "textarea", "select", "option", "optgroup", "label", "form", "progress", "output", "video", "style"], [SVG_NS]: ["svg", "g", "path", "circle", "ellipse", "rect", "use"] } as const; diff --git a/src/platform/web/ui/login/AccountSetupView.js b/src/platform/web/ui/login/AccountSetupView.js index e0d41693..cf2b544f 100644 --- a/src/platform/web/ui/login/AccountSetupView.js +++ b/src/platform/web/ui/login/AccountSetupView.js @@ -15,7 +15,7 @@ limitations under the License. */ import {TemplateView} from "../general/TemplateView"; -import {KeyBackupSettingsView} from "../session/settings/KeyBackupSettingsView.js"; +import {KeyBackupSettingsView} from "../session/settings/KeyBackupSettingsView"; export class AccountSetupView extends TemplateView { render(t, vm) { diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index 7bcd8c0f..9f84e872 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -30,6 +30,7 @@ import {CreateRoomView} from "./CreateRoomView.js"; import {RightPanelView} from "./rightpanel/RightPanelView.js"; import {viewClassForTile} from "./room/common"; import {JoinRoomView} from "./JoinRoomView"; +import {ToastCollectionView} from "./toast/ToastCollectionView"; export class SessionView extends TemplateView { render(t, vm) { @@ -40,6 +41,7 @@ export class SessionView extends TemplateView { "right-shown": vm => !!vm.rightPanelViewModel }, }, [ + t.view(new ToastCollectionView(vm.toastCollectionViewModel)), t.view(new SessionStatusView(vm.sessionStatusViewModel)), t.view(new LeftPanelView(vm.leftPanelViewModel)), t.mapView(vm => vm.activeMiddleViewModel, () => { diff --git a/src/platform/web/ui/session/rightpanel/MemberDetailsView.js b/src/platform/web/ui/session/rightpanel/MemberDetailsView.js index 5d2f9387..c02d8d73 100644 --- a/src/platform/web/ui/session/rightpanel/MemberDetailsView.js +++ b/src/platform/web/ui/session/rightpanel/MemberDetailsView.js @@ -19,15 +19,25 @@ import {TemplateView} from "../../general/TemplateView"; export class MemberDetailsView extends TemplateView { render(t, vm) { + const securityNodes = [ + t.p(vm.isEncrypted ? + vm.i18n`Messages in this room are end-to-end encrypted.` : + vm.i18n`Messages in this room are not end-to-end encrypted.`), + ] + + if (vm.features.crossSigning) { + securityNodes.push(t.div({className: "MemberDetailsView_shield_container"}, [ + t.span({className: vm => `MemberDetailsView_shield_${vm.trustShieldColor}`}), + t.p({className: "MemberDetailsView_shield_description"}, vm => vm.trustDescription) + ])); + } + return t.div({className: "MemberDetailsView"}, [ t.view(new AvatarView(vm, 128)), t.div({className: "MemberDetailsView_name"}, t.h2(vm => vm.name)), t.div({className: "MemberDetailsView_id"}, vm.userId), this._createSection(t, vm.i18n`Role`, vm => vm.role), - this._createSection(t, vm.i18n`Security`, vm.isEncrypted ? - vm.i18n`Messages in this room are end-to-end encrypted.` : - vm.i18n`Messages in this room are not end-to-end encrypted.` - ), + this._createSection(t, vm.i18n`Security`, securityNodes), this._createOptions(t, vm) ]); } @@ -41,14 +51,22 @@ export class MemberDetailsView extends TemplateView { } _createOptions(t, vm) { + const options = [ + t.a({href: vm.linkToUser, target: "_blank", rel: "noopener"}, vm.i18n`Open Link to User`), + t.button({className: "text", onClick: () => vm.openDirectMessage()}, vm.i18n`Open direct message`) + ]; + if (vm.features.crossSigning) { + const onClick = () => { + if (confirm("You don't want to do this with any account but a test account. This will cross-sign this user without verifying their keys first. You won't be able to undo this apart from resetting your cross-signing keys.")) { + vm.signUser(); + } + }; + options.push(t.button({className: "text", onClick}, vm.i18n`Cross-sign user (DO NOT USE, TESTING ONLY)`)) + } return t.div({ className: "MemberDetailsView_section" }, [ t.div({className: "MemberDetailsView_label"}, vm.i18n`Options`), - t.div({className: "MemberDetailsView_options"}, - [ - t.a({href: vm.linkToUser, target: "_blank", rel: "noopener"}, vm.i18n`Open Link to User`), - t.button({className: "text", onClick: () => vm.openDirectMessage()}, vm.i18n`Open direct message`) - ]) + t.div({className: "MemberDetailsView_options"}, options) ]); } } diff --git a/src/platform/web/ui/session/room/CallView.ts b/src/platform/web/ui/session/room/CallView.ts new file mode 100644 index 00000000..eacb3144 --- /dev/null +++ b/src/platform/web/ui/session/room/CallView.ts @@ -0,0 +1,139 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {TemplateView, Builder} from "../../general/TemplateView"; +import {AvatarView} from "../../AvatarView"; +import {ListView} from "../../general/ListView"; +import {classNames} from "../../general/html"; +import {Stream} from "../../../../types/MediaDevices"; +import type {CallViewModel, CallMemberViewModel, IStreamViewModel} from "../../../../../domain/session/room/CallViewModel"; +import { ErrorView } from "../../general/ErrorView"; + +export class CallView extends TemplateView { + private resizeObserver?: ResizeObserver; + + render(t: Builder, vm: CallViewModel): Element { + const members = t.view(new ListView({ + className: "CallView_members", + list: vm.memberViewModels + }, vm => new StreamView(vm))) as HTMLElement; + this.bindMembersCssClasses(t, members); + return t.div({class: "CallView"}, [ + members, + //t.p(vm => `Call ${vm.name}`), + t.div({class: "CallView_buttons"}, [ + t.button({className: { + "CallView_mutedMicrophone": vm => vm.isMicrophoneMuted, + "CallView_unmutedMicrophone": vm => !vm.isMicrophoneMuted, + }, onClick: disableTargetCallback(() => vm.toggleMicrophone())}), + t.button({className: { + "CallView_mutedCamera": vm => vm.isCameraMuted, + "CallView_unmutedCamera": vm => !vm.isCameraMuted, + }, onClick: disableTargetCallback(() => vm.toggleCamera())}), + t.button({className: "CallView_hangup", onClick: disableTargetCallback(() => vm.hangup())}), + ]), + t.if(vm => !!vm.errorViewModel, t => { + return t.div({className: "CallView_error"}, t.view(new ErrorView(vm.errorViewModel!))); + }) + ]); + } + + private bindMembersCssClasses(t, members) { + t.mapSideEffect(vm => vm.memberCount, count => { + members.classList.forEach((c, _, list) => { + if (c.startsWith("size")) { + list.remove(c); + } + }); + members.classList.add(`size${count}`); + }); + // update classes describing aspect ratio categories + if (typeof ResizeObserver === "function") { + const set = (c, flag) => { + if (flag) { + members.classList.add(c); + } else { + members.classList.remove(c); + } + }; + this.resizeObserver = new ResizeObserver(() => { + const ar = members.clientWidth / members.clientHeight; + const isTall = ar < 0.5; + const isSquare = !isTall && ar < 1.8 + const isWide = !isTall && !isSquare; + set("tall", isTall); + set("square", isSquare); + set("wide", isWide); + }); + this.resizeObserver!.observe(members); + } + } + + public unmount() { + if (this.resizeObserver) { + this.resizeObserver.unobserve((this.root()! as Element).querySelector(".CallView_members")!); + this.resizeObserver = undefined; + } + super.unmount(); + } +} + +class StreamView extends TemplateView { + render(t: Builder, vm: IStreamViewModel): Element { + const video = t.video({ + autoplay: true, + disablePictureInPicture: true, + className: { + hidden: vm => vm.isCameraMuted + } + }) as HTMLVideoElement; + t.mapSideEffect(vm => vm.stream, stream => { + video.srcObject = stream as MediaStream; + }); + return t.div({className: "StreamView"}, [ + video, + t.div({className: { + StreamView_avatar: true, + hidden: vm => !vm.isCameraMuted + }}, t.view(new AvatarView(vm, 96), {parentProvidesUpdates: true})), + t.div({ + className: { + StreamView_muteStatus: true, + hidden: vm => !vm.isCameraMuted && !vm.isMicrophoneMuted, + microphoneMuted: vm => vm.isMicrophoneMuted && !vm.isCameraMuted, + cameraMuted: vm => vm.isCameraMuted, + } + }), + t.if(vm => !!vm.errorViewModel, t => { + return t.div({className: "StreamView_error"}, t.view(new ErrorView(vm.errorViewModel!))); + }) + ]); + } + + update(value, props) { + super.update(value); + // update the AvatarView as we told it to not subscribe itself with parentProvidesUpdates + this.updateSubViews(value, props); + } +} + +function disableTargetCallback(callback: (evt: Event) => Promise): (evt: Event) => Promise { + return async (evt: Event) => { + (evt.target as HTMLElement)?.setAttribute("disabled", "disabled"); + await callback(evt); + (evt.target as HTMLElement)?.removeAttribute("disabled"); + } +} diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index e3eb0587..727fb44d 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView"; +import {TemplateView} from "../../general/TemplateView"; import {Popup} from "../../general/Popup.js"; import {Menu} from "../../general/Menu.js"; import {TimelineView} from "./TimelineView"; @@ -23,6 +23,8 @@ import {TimelineLoadingView} from "./TimelineLoadingView.js"; import {MessageComposer} from "./MessageComposer.js"; import {DisabledComposerView} from "./DisabledComposerView.js"; import {AvatarView} from "../../AvatarView.js"; +import {CallView} from "./CallView"; +import { ErrorView } from "../../general/ErrorView"; export class RoomView extends TemplateView { constructor(vm, viewClassForTile) { @@ -46,27 +48,21 @@ export class RoomView extends TemplateView { }) ]), t.div({className: "RoomView_body"}, [ - t.div({className: "RoomView_error"}, [ - t.if(vm => vm.error, t => t.div( - [ - t.p({}, vm => vm.error), - t.button({ className: "RoomView_error_closerButton", onClick: evt => vm.dismissError(evt) }) - ]) - )]), + t.if(vm => vm.errorViewModel, t => t.div({className: "RoomView_error"}, t.view(new ErrorView(vm.errorViewModel)))), + t.mapView(vm => vm.callViewModel, callViewModel => callViewModel ? new CallView(callViewModel) : null), t.mapView(vm => vm.timelineViewModel, timelineViewModel => { return timelineViewModel ? new TimelineView(timelineViewModel, this._viewClassForTile) : new TimelineLoadingView(vm); // vm is just needed for i18n }), - t.mapView(vm => vm.composerViewModel, - composerViewModel => { - switch (composerViewModel?.kind) { - case "composer": - return new MessageComposer(vm.composerViewModel, this._viewClassForTile); - case "disabled": - return new DisabledComposerView(vm.composerViewModel); - } - }), + t.mapView(vm => vm.composerViewModel, composerViewModel => { + switch (composerViewModel?.kind) { + case "composer": + return new MessageComposer(vm.composerViewModel, this._viewClassForTile); + case "disabled": + return new DisabledComposerView(vm.composerViewModel); + } + }), ]) ]); } @@ -77,7 +73,10 @@ export class RoomView extends TemplateView { } else { const vm = this.value; const options = []; - options.push(Menu.option(vm.i18n`Room details`, () => vm.openDetailsPanel())) + options.push(Menu.option(vm.i18n`Room details`, () => vm.openDetailsPanel())); + if (vm.features.calls) { + options.push(Menu.option(vm.i18n`Start call`, () => vm.startCall())); + } if (vm.canLeave) { options.push(Menu.option(vm.i18n`Leave room`, () => this._confirmToLeaveRoom()).setDestructive()); } diff --git a/src/platform/web/ui/session/room/common.ts b/src/platform/web/ui/session/room/common.ts index 7b62630f..d435266e 100644 --- a/src/platform/web/ui/session/room/common.ts +++ b/src/platform/web/ui/session/room/common.ts @@ -22,31 +22,37 @@ import {LocationView} from "./timeline/LocationView.js"; import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js"; import {AnnouncementView} from "./timeline/AnnouncementView.js"; import {RedactedView} from "./timeline/RedactedView.js"; -import {SimpleTile} from "../../../../../domain/session/room/timeline/tiles/SimpleTile.js"; +import {ITile, TileShape} from "../../../../../domain/session/room/timeline/tiles/ITile.js"; import {GapView} from "./timeline/GapView.js"; +import {CallTileView} from "./timeline/CallTileView"; +import {DateHeaderView} from "./timeline/DateHeaderView"; import type {TileViewConstructor, ViewClassForEntryFn} from "./TimelineView"; -export function viewClassForTile(vm: SimpleTile): TileViewConstructor { +export function viewClassForTile(vm: ITile): TileViewConstructor { switch (vm.shape) { - case "gap": + case TileShape.Gap: return GapView; - case "announcement": + case TileShape.Announcement: return AnnouncementView; - case "message": - case "message-status": + case TileShape.Message: + case TileShape.MessageStatus: return TextMessageView; - case "image": + case TileShape.Image: return ImageView; - case "video": + case TileShape.Video: return VideoView; - case "file": + case TileShape.File: return FileView; - case "location": + case TileShape.Location: return LocationView; - case "missing-attachment": + case TileShape.MissingAttachment: return MissingAttachmentView; - case "redacted": + case TileShape.Redacted: return RedactedView; + case TileShape.Call: + return CallTileView; + case TileShape.DateHeader: + return DateHeaderView; default: throw new Error(`Tiles of shape "${vm.shape}" are not supported, check the tileClassForEntry function in the view model`); } diff --git a/src/platform/web/ui/session/room/timeline/BaseMediaView.js b/src/platform/web/ui/session/room/timeline/BaseMediaView.js index 9d534fd1..bc49b3f6 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMediaView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMediaView.js @@ -34,7 +34,7 @@ export class BaseMediaView extends BaseMessageView { const children = [ t.div({className: "spacer", style: spacerStyle}), this.renderMedia(t, vm), - t.time(vm.date + " " + vm.time), + t.time(vm.time), ]; const status = t.div({ className: { diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index d35e8c5a..84a4a1ad 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -63,7 +63,13 @@ export class BaseMessageView extends TemplateView { li.removeChild(li.querySelector(".Timeline_messageSender")); } else if (!isContinuation && !this._isReplyPreview) { const avatar = tag.a({href: vm.memberPanelLink, className: "Timeline_messageAvatar"}, [renderStaticAvatar(vm, 30)]); - const sender = tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName); + const sender = tag.div( + { + className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`, + title: vm.sender, + }, + vm.displayName, + ); li.insertBefore(avatar, li.firstChild); li.insertBefore(sender, li.firstChild); } diff --git a/src/platform/web/ui/session/room/timeline/CallTileView.ts b/src/platform/web/ui/session/room/timeline/CallTileView.ts new file mode 100644 index 00000000..a9a872b3 --- /dev/null +++ b/src/platform/web/ui/session/room/timeline/CallTileView.ts @@ -0,0 +1,59 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {Builder, TemplateView} from "../../../general/TemplateView"; +import type {CallTile} from "../../../../../../domain/session/room/timeline/tiles/CallTile"; +import {ErrorView} from "../../../general/ErrorView"; +import {ListView} from "../../../general/ListView"; +import {AvatarView} from "../../../AvatarView"; + +export class CallTileView extends TemplateView { + render(t: Builder, vm: CallTile) { + return t.li( + {className: "CallTileView AnnouncementView"}, + t.div( + [ + t.if(vm => vm.errorViewModel, t => { + return t.div({className: "CallTileView_error"}, t.view(new ErrorView(vm.errorViewModel, {inline: true}))); + }), + t.div([ + t.div({className: "CallTileView_title"}, vm => vm.title), + t.div({className: "CallTileView_subtitle"}, [ + vm.typeLabel, " • ", + t.span({className: "CallTileView_memberCount"}, vm => vm.memberCount) + ]), + t.view(new ListView({className: "CallTileView_members", tagName: "div", list: vm.memberViewModels}, vm => { + return new AvatarView(vm, 24); + })), + t.div(vm => vm.duration), + t.div([ + t.button({className: "CallTileView_join button-action primary", hidden: vm => !vm.canJoin}, "Join"), + t.button({className: "CallTileView_leave button-action primary destructive", hidden: vm => !vm.canLeave}, "Leave") + ]) + ]) + ]) + ); + } + + /* This is called by the parent ListView, which just has 1 listener for the whole list */ + onClick(evt) { + if (evt.target.classList.contains("CallTileView_join")) { + this.value.join(); + } else if (evt.target.classList.contains("CallTileView_leave")) { + this.value.leave(); + } + } +} diff --git a/src/platform/web/ui/session/room/timeline/DateHeaderView.ts b/src/platform/web/ui/session/room/timeline/DateHeaderView.ts new file mode 100644 index 00000000..3d640568 --- /dev/null +++ b/src/platform/web/ui/session/room/timeline/DateHeaderView.ts @@ -0,0 +1,33 @@ +/* +Copyright 2020 Bruno Windels + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {TemplateView} from "../../../general/TemplateView"; +import {spinner} from "../../../common.js"; +import type {DateTile} from "../../../../../../domain/session/room/timeline/tiles/DateTile"; + +export class DateHeaderView extends TemplateView { + // ignore other argument + constructor(vm) { + super(vm); + } + + render(t, vm) { + return t.h2({className: "DateHeader"}, t.time({dateTime: vm.machineReadableDate}, vm.relativeDate)); + } + + /* This is called by the parent ListView, which just has 1 listener for the whole list */ + onClick() {} +} diff --git a/src/platform/web/ui/session/room/timeline/FileView.js b/src/platform/web/ui/session/room/timeline/FileView.js index 6a2d418e..ca0eb10e 100644 --- a/src/platform/web/ui/session/room/timeline/FileView.js +++ b/src/platform/web/ui/session/room/timeline/FileView.js @@ -24,7 +24,7 @@ export class FileView extends BaseMessageView { } else { children.push( t.button({className: "link", onClick: () => vm.download()}, vm => vm.label), - t.time(vm.date + " " + vm.time) + t.time(vm.time) ); } return t.p({className: "Timeline_messageBody statusMessage"}, children); diff --git a/src/platform/web/ui/session/room/timeline/GapView.js b/src/platform/web/ui/session/room/timeline/GapView.js index 4fc0e3d6..91181c09 100644 --- a/src/platform/web/ui/session/room/timeline/GapView.js +++ b/src/platform/web/ui/session/room/timeline/GapView.js @@ -16,6 +16,7 @@ limitations under the License. import {TemplateView} from "../../../general/TemplateView"; import {spinner} from "../../../common.js"; +import {ErrorView} from "../../../general/ErrorView"; export class GapView extends TemplateView { // ignore other argument @@ -23,15 +24,20 @@ export class GapView extends TemplateView { super(vm); } - render(t) { + render(t, vm) { const className = { GapView: true, isLoading: vm => vm.isLoading, isAtTop: vm => vm.isAtTop, }; return t.li({ className }, [ - t.if(vm => vm.showSpinner, (t) => spinner(t)), - t.span(vm => vm.currentAction) + t.div({class: "GapView_container"}, [ + t.if(vm => vm.showSpinner, (t) => spinner(t)), + t.span(vm => vm.status), + ]), + t.if(vm => !!vm.errorViewModel, t => { + return t.view(new ErrorView(vm.errorViewModel, {inline: true})); + }) ]); } diff --git a/src/platform/web/ui/session/room/timeline/LocationView.js b/src/platform/web/ui/session/room/timeline/LocationView.js index de605c6a..e0d2656c 100644 --- a/src/platform/web/ui/session/room/timeline/LocationView.js +++ b/src/platform/web/ui/session/room/timeline/LocationView.js @@ -21,7 +21,7 @@ export class LocationView extends BaseMessageView { return t.p({className: "Timeline_messageBody statusMessage"}, [ t.span(vm.label), t.a({className: "Timeline_locationLink", href: vm.mapsLink, target: "_blank", rel: "noopener"}, vm.i18n`Open in maps`), - t.time(vm.date + " " + vm.time) + t.time(vm.time) ]); } } diff --git a/src/platform/web/ui/session/room/timeline/TextMessageView.js b/src/platform/web/ui/session/room/timeline/TextMessageView.js index 8d6cb4dc..a6741de7 100644 --- a/src/platform/web/ui/session/room/timeline/TextMessageView.js +++ b/src/platform/web/ui/session/room/timeline/TextMessageView.js @@ -20,7 +20,7 @@ import {ReplyPreviewError, ReplyPreviewView} from "./ReplyPreviewView.js"; export class TextMessageView extends BaseMessageView { renderMessageBody(t, vm) { - const time = t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time); + const time = t.time({className: {hidden: !vm.time}}, vm.time); const container = t.div({ className: { "Timeline_messageBody": true, diff --git a/src/platform/web/ui/session/settings/FeaturesView.ts b/src/platform/web/ui/session/settings/FeaturesView.ts new file mode 100644 index 00000000..625fc362 --- /dev/null +++ b/src/platform/web/ui/session/settings/FeaturesView.ts @@ -0,0 +1,52 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {TemplateView, TemplateBuilder} from "../../general/TemplateView"; +import {ViewNode} from "../../general/types"; +import {disableTargetCallback} from "../../general/utils"; +import type {FeaturesViewModel, FeatureViewModel} from "../../../../../domain/session/settings/FeaturesViewModel"; + +export class FeaturesView extends TemplateView { + render(t, vm: FeaturesViewModel): ViewNode { + return t.div({ + className: "FeaturesView", + }, [ + t.p("Enable experimental features here that are still in development. These are not yet ready for primetime, so expect bugs."), + // we don't use a binding/ListView because this is a static list + t.ul(vm.featureViewModels.map(vm => { + return t.li(t.view(new FeatureView(vm))); + })) + ]); + } +} + +class FeatureView extends TemplateView { + render(t, vm): ViewNode { + let id = `feature_${vm.id}`; + return t.div({className: "FeatureView"}, [ + t.input({ + type: "checkbox", + id, + checked: vm => vm.enabled, + onChange: evt => vm.enableFeature(evt.target.checked) + }), + t.div({class: "FeatureView_container"}, [ + t.h4(t.label({for: id}, vm.name)), + t.p(vm.description) + ]) + ]); + } +} diff --git a/src/platform/web/ui/session/settings/KeyBackupSettingsView.js b/src/platform/web/ui/session/settings/KeyBackupSettingsView.ts similarity index 66% rename from src/platform/web/ui/session/settings/KeyBackupSettingsView.js rename to src/platform/web/ui/session/settings/KeyBackupSettingsView.ts index 3f8812c9..28c4febf 100644 --- a/src/platform/web/ui/session/settings/KeyBackupSettingsView.js +++ b/src/platform/web/ui/session/settings/KeyBackupSettingsView.ts @@ -14,31 +14,41 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {TemplateView} from "../../general/TemplateView"; +import {TemplateView, Builder} from "../../general/TemplateView"; +import {disableTargetCallback} from "../../general/utils"; +import {ViewNode} from "../../general/types"; +import {KeyBackupViewModel, Status, BackupWriteStatus} from "../../../../../domain/session/settings/KeyBackupViewModel"; +import {KeyType} from "../../../../../matrix/ssss/index"; -export class KeyBackupSettingsView extends TemplateView { - render(t) { +export class KeyBackupSettingsView extends TemplateView { + render(t: Builder, vm: KeyBackupViewModel): ViewNode { return t.div([ t.map(vm => vm.status, (status, t, vm) => { switch (status) { - case "Enabled": return renderEnabled(t, vm); - case "NewVersionAvailable": return renderNewVersionAvailable(t, vm); - case "SetupKey": return renderEnableFromKey(t, vm); - case "SetupPhrase": return renderEnableFromPhrase(t, vm); - case "Pending": return t.p(vm.i18n`Waiting to go online…`); + case Status.Enabled: return renderEnabled(t, vm); + case Status.NewVersionAvailable: return renderNewVersionAvailable(t, vm); + case Status.Setup: { + if (vm.setupKeyType === KeyType.Passphrase) { + return renderEnableFromPhrase(t, vm); + } else { + return renderEnableFromKey(t, vm); + } + break; + } + case Status.Pending: return t.p(vm.i18n`Waiting to go online…`); } }), t.map(vm => vm.backupWriteStatus, (status, t, vm) => { switch (status) { - case "Writing": { + case BackupWriteStatus.Writing: { const progress = t.progress({ - min: 0, - max: 100, + min: 0+"", + max: 100+"", value: vm => vm.backupPercentage, }); return t.div([`Backup in progress `, progress, " ", vm => vm.backupInProgressLabel]); } - case "Stopped": { + case BackupWriteStatus.Stopped: { let label; const error = vm.backupError; if (error) { @@ -46,19 +56,30 @@ export class KeyBackupSettingsView extends TemplateView { } else { label = `Backup has stopped`; } - return t.p(label, " ", t.button({onClick: () => vm.startBackup()}, `Backup now`)); + return t.p([label, " ", t.button({onClick: () => vm.startBackup()}, `Backup now`)]); } - case "Done": + case BackupWriteStatus.Done: return t.p(`All keys are backed up.`); default: - return null; + return undefined; } - }) + }), + t.if(vm => vm.isMasterKeyTrusted, t => { + return t.p("Cross-signing master key found and trusted.") + }), + t.if(vm => vm.canSignOwnDevice, t => { + return t.button({ + onClick: disableTargetCallback(async () => { + await vm.signOwnDevice(); + }) + }, "Sign own device"); + }), + ]); } } -function renderEnabled(t, vm) { +function renderEnabled(t: Builder, vm: KeyBackupViewModel): ViewNode { const items = [ t.p([vm.i18n`Key backup is enabled, using backup version ${vm.backupVersion}. `, t.button({onClick: () => vm.disable()}, vm.i18n`Disable`)]) ]; @@ -68,14 +89,14 @@ function renderEnabled(t, vm) { return t.div(items); } -function renderNewVersionAvailable(t, vm) { +function renderNewVersionAvailable(t: Builder, vm: KeyBackupViewModel): ViewNode { const items = [ t.p([vm.i18n`A new backup version has been created from another device. Disable key backup and enable it again with the new key.`, t.button({onClick: () => vm.disable()}, vm.i18n`Disable`)]) ]; return t.div(items); } -function renderEnableFromKey(t, vm) { +function renderEnableFromKey(t: Builder, vm: KeyBackupViewModel): ViewNode { const useASecurityPhrase = t.button({className: "link", onClick: () => vm.showPhraseSetup()}, vm.i18n`use a security phrase`); return t.div([ t.p(vm.i18n`Enter your secret storage security key below to ${vm.purpose}, which will enable you to decrypt messages received before you logged into this session. The security key is a code of 12 groups of 4 characters separated by a space that Element created for you when setting up security.`), @@ -85,7 +106,7 @@ function renderEnableFromKey(t, vm) { ]); } -function renderEnableFromPhrase(t, vm) { +function renderEnableFromPhrase(t: Builder, vm: KeyBackupViewModel): ViewNode { const useASecurityKey = t.button({className: "link", onClick: () => vm.showKeySetup()}, vm.i18n`use your security key`); return t.div([ t.p(vm.i18n`Enter your secret storage security phrase below to ${vm.purpose}, which will enable you to decrypt messages received before you logged into this session. The security phrase is a freeform secret phrase you optionally chose when setting up security in Element. It is different from your password to login, unless you chose to set them to the same value.`), @@ -95,7 +116,7 @@ function renderEnableFromPhrase(t, vm) { ]); } -function renderEnableFieldRow(t, vm, label, callback) { +function renderEnableFieldRow(t, vm, label, callback): ViewNode { let setupDehydrationCheck; const eventHandler = () => callback(input.value, setupDehydrationCheck?.checked || false); const input = t.input({type: "password", disabled: vm => vm.isBusy, placeholder: label}); @@ -119,8 +140,8 @@ function renderEnableFieldRow(t, vm, label, callback) { ]); } -function renderError(t) { - return t.if(vm => vm.error, (t, vm) => { +function renderError(t: Builder): ViewNode { + return t.if(vm => vm.error !== undefined, (t, vm) => { return t.div([ t.p({className: "error"}, vm => vm.i18n`Could not enable key backup: ${vm.error}.`), t.p(vm.i18n`Try double checking that you did not mix up your security key, security phrase and login password as explained above.`) diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index c4405e82..4035281f 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -16,7 +16,8 @@ limitations under the License. import {TemplateView} from "../../general/TemplateView"; import {disableTargetCallback} from "../../general/utils"; -import {KeyBackupSettingsView} from "./KeyBackupSettingsView.js" +import {KeyBackupSettingsView} from "./KeyBackupSettingsView" +import {FeaturesView} from "./FeaturesView" export class SettingsView extends TemplateView { render(t, vm) { @@ -48,7 +49,7 @@ export class SettingsView extends TemplateView { }, vm.i18n`Log out`)), ); settingNodes.push( - t.h3("Key backup"), + t.h3("Key backup & security"), t.view(new KeyBackupSettingsView(vm.keyBackupViewModel)) ); @@ -103,10 +104,19 @@ export class SettingsView extends TemplateView { }), ); const logButtons = []; + if (import.meta.env.DEV) { + logButtons.push(t.button({onClick: () => openLogs(vm)}, "Open logs")); + } if (vm.canSendLogsToServer) { logButtons.push(t.button({onClick: disableTargetCallback(() => vm.sendLogsToServer())}, `Submit logs to ${vm.logsServer}`)); } logButtons.push(t.button({onClick: () => vm.exportLogs()}, "Download logs")); + + settingNodes.push( + t.h3("Experimental features"), + t.view(new FeaturesView(vm.featuresViewModel)) + ); + settingNodes.push( t.h3("Application"), row(t, vm.i18n`Version`, version), @@ -115,6 +125,7 @@ export class SettingsView extends TemplateView { t.p({className: {hidden: vm => !vm.logsFeedbackMessage}}, vm => vm.logsFeedbackMessage), t.p(["Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited, the usernames of other users and the names of files you send. They do not contain messages. For more information, review our ", t.a({href: "https://element.io/privacy", target: "_blank", rel: "noopener"}, "privacy policy"), "."]), + t.p([]) ); return t.main({className: "Settings middle"}, [ @@ -196,3 +207,37 @@ export class SettingsView extends TemplateView { return t.div({ className: "theme-chooser" }, [select, radioButtons]); } } + + +async function openLogs(vm) { + // Use vite-specific url so this asset doesn't get picked up by vite and included in the production build, + // as opening the logs is only available during dev time, and @matrixdotorg/structured-logviewer is a dev dependency + // This url is what import "@matrixdotorg/structured-logviewer/index.html?url" resolves to with vite. + const win = window.open(`/@fs/${DEFINE_PROJECT_DIR}/node_modules/@matrixdotorg/structured-logviewer/index.html`); + await new Promise((resolve, reject) => { + let shouldSendPings = true; + const cleanup = () => { + shouldSendPings = false; + window.removeEventListener("message", waitForPong); + }; + const waitForPong = event => { + if (event.data.type === "pong") { + cleanup(); + resolve(); + } + }; + const sendPings = async () => { + while (shouldSendPings) { + win.postMessage({type: "ping"}); + await new Promise(rr => setTimeout(rr, 50)); + if (win.closed) { + cleanup(); + } + } + }; + window.addEventListener("message", waitForPong); + sendPings().catch(reject); + }); + const logs = await vm.exportLogsBlob(); + win.postMessage({type: "open", logs: logs.nativeBlob}); +} diff --git a/src/platform/web/ui/session/toast/CallToastNotificationView.ts b/src/platform/web/ui/session/toast/CallToastNotificationView.ts new file mode 100644 index 00000000..50adcc7b --- /dev/null +++ b/src/platform/web/ui/session/toast/CallToastNotificationView.ts @@ -0,0 +1,51 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {AvatarView} from "../../AvatarView.js"; +import {ErrorView} from "../../general/ErrorView"; +import {TemplateView, Builder} from "../../general/TemplateView"; +import type {CallToastNotificationViewModel} from "../../../../../domain/session/toast/CallToastNotificationViewModel"; + +export class CallToastNotificationView extends TemplateView { + render(t: Builder, vm: CallToastNotificationViewModel) { + return t.div({ className: "CallToastNotificationView" }, [ + t.div({ className: "CallToastNotificationView__top" }, [ + t.view(new AvatarView(vm, 24)), + t.span({ className: "CallToastNotificationView__name" }, (vm) => vm.roomName), + t.button({ + className: "button-action CallToastNotificationView__dismiss-btn", + onClick: () => vm.dismiss(), + }), + ]), + t.div({ className: "CallToastNotificationView__description" }, [ + t.span(vm.i18n`Video call started`) + ]), + t.div({ className: "CallToastNotificationView__info" }, [ + t.span({className: "CallToastNotificationView__call-type"}, vm.i18n`Video`), + t.span({className: "CallToastNotificationView__member-count"}, (vm) => vm.memberCount), + ]), + t.div({ className: "CallToastNotificationView__action" }, [ + t.button({ + className: "button-action primary", + onClick: () => vm.join(), + }, vm.i18n`Join`), + ]), + t.if(vm => !!vm.errorViewModel, t => { + return t.div({className: "CallView_error"}, t.view(new ErrorView(vm.errorViewModel!))); + }), + ]); + } +} diff --git a/src/platform/web/ui/session/toast/ToastCollectionView.ts b/src/platform/web/ui/session/toast/ToastCollectionView.ts new file mode 100644 index 00000000..3dc99c77 --- /dev/null +++ b/src/platform/web/ui/session/toast/ToastCollectionView.ts @@ -0,0 +1,33 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {CallToastNotificationView} from "./CallToastNotificationView"; +import {ListView} from "../../general/ListView"; +import {TemplateView, Builder} from "../../general/TemplateView"; +import type {CallToastNotificationViewModel} from "../../../../../domain/session/toast/CallToastNotificationViewModel"; +import type {ToastCollectionViewModel} from "../../../../../domain/session/toast/ToastCollectionViewModel"; + +export class ToastCollectionView extends TemplateView { + render(t: Builder, vm: ToastCollectionViewModel) { + const view = new ListView({ + list: vm.toastViewModels, + parentProvidesUpdates: false, + }, (vm: CallToastNotificationViewModel) => new CallToastNotificationView(vm)); + return t.div({ className: "ToastCollectionView" }, [ + t.view(view), + ]); + } +} diff --git a/src/utils/AbortableOperation.ts b/src/utils/AbortableOperation.ts index fba71a8c..3592c951 100644 --- a/src/utils/AbortableOperation.ts +++ b/src/utils/AbortableOperation.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableValue, ObservableValue} from "../observable/ObservableValue"; +import {EventEmitter} from "../utils/EventEmitter"; export interface IAbortable { abort(); @@ -24,25 +24,27 @@ export type SetAbortableFn = (a: IAbortable) => typeof a; export type SetProgressFn

= (progress: P) => void; type RunFn = (setAbortable: SetAbortableFn, setProgress: SetProgressFn

) => T; -export class AbortableOperation implements IAbortable { +export class AbortableOperation extends EventEmitter<{change: keyof AbortableOperation}> implements IAbortable { public readonly result: T; private _abortable?: IAbortable; - private _progress: ObservableValue

; + private _progress?: P; constructor(run: RunFn) { + super(); this._abortable = undefined; const setAbortable: SetAbortableFn = abortable => { this._abortable = abortable; return abortable; }; - this._progress = new ObservableValue

(undefined); + this._progress = undefined; const setProgress: SetProgressFn

= (progress: P) => { - this._progress.set(progress); + this._progress = progress; + this.emit("change", "progress"); }; this.result = run(setAbortable, setProgress); } - get progress(): BaseObservableValue

{ + get progress(): P | undefined { return this._progress; } diff --git a/src/utils/Deferred.ts b/src/utils/Deferred.ts new file mode 100644 index 00000000..430fe996 --- /dev/null +++ b/src/utils/Deferred.ts @@ -0,0 +1,41 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + + +export class Deferred { + public readonly promise: Promise; + public readonly resolve: (value: T) => void; + public readonly reject: (err: Error) => void; + private _value?: T; + + constructor() { + let resolve; + let reject; + this.promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }) + this.resolve = (value: T) => { + this._value = value; + resolve(value); + }; + this.reject = reject; + } + + get value(): T | undefined { + return this._value; + } +} diff --git a/src/utils/Disposables.ts b/src/utils/Disposables.ts index f7c7eb53..f27846be 100644 --- a/src/utils/Disposables.ts +++ b/src/utils/Disposables.ts @@ -50,8 +50,8 @@ export class Disposables { } untrack(disposable: Disposable): undefined { - if (this.isDisposed) { - console.warn("Disposables already disposed, cannot untrack"); + // already disposed + if (!this._disposables) { return undefined; } const idx = this._disposables!.indexOf(disposable); diff --git a/src/utils/ErrorBoundary.ts b/src/utils/ErrorBoundary.ts new file mode 100644 index 00000000..750385c7 --- /dev/null +++ b/src/utils/ErrorBoundary.ts @@ -0,0 +1,97 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export class ErrorBoundary { + private _error?: Error; + + constructor(private readonly errorCallback: (err: Error) => void) {} + + /** + * Executes callback() and then runs errorCallback() on error. + * This will never throw but instead return `errorValue` if an error occurred. + */ + try(callback: () => T, errorValue?: E): T | typeof errorValue; + try(callback: () => Promise, errorValue?: E): Promise | typeof errorValue { + try { + let result: T | Promise = callback(); + if (result instanceof Promise) { + result = result.catch(err => { + this._error = err; + this.reportError(err); + return errorValue; + }); + } + return result; + } catch (err) { + this._error = err; + this.reportError(err); + return errorValue; + } + } + + reportError(err: Error) { + try { + this.errorCallback(err); + } catch (err) { + console.error("error in ErrorBoundary callback", err); + } + } + + get error(): Error | undefined { + return this._error; + } +} + +export function tests() { + return { + "catches sync error": assert => { + let emitted = false; + const boundary = new ErrorBoundary(() => emitted = true); + const result = boundary.try(() => { + throw new Error("fail!"); + }, 0); + assert(emitted); + assert.strictEqual(result, 0); + }, + "return value of callback is forwarded": assert => { + let emitted = false; + const boundary = new ErrorBoundary(() => emitted = true); + const result = boundary.try(() => { + return "hello"; + }); + assert(!emitted); + assert.strictEqual(result, "hello"); + }, + "catches async error": async assert => { + let emitted = false; + const boundary = new ErrorBoundary(() => emitted = true); + const result = await boundary.try(async () => { + throw new Error("fail!"); + }, 0); + assert(emitted); + assert.strictEqual(result, 0); + }, + "exception in error callback is swallowed": async assert => { + let emitted = false; + const boundary = new ErrorBoundary(() => { throw new Error("bug in errorCallback"); }); + assert.doesNotThrow(() => { + boundary.try(() => { + throw new Error("fail!"); + }); + }); + } + } +} diff --git a/src/utils/LRUCache.ts b/src/utils/LRUCache.ts index c5a7cd06..bab9bf51 100644 --- a/src/utils/LRUCache.ts +++ b/src/utils/LRUCache.ts @@ -71,7 +71,7 @@ export class BaseLRUCache { export class LRUCache extends BaseLRUCache { private _keyFn: (T) => K; - constructor(limit, keyFn: (T) => K) { + constructor(limit: number, keyFn: (T) => K) { super(limit); this._keyFn = keyFn; } diff --git a/src/utils/recursivelyAssign.ts b/src/utils/recursivelyAssign.ts new file mode 100644 index 00000000..adf5f2ef --- /dev/null +++ b/src/utils/recursivelyAssign.ts @@ -0,0 +1,39 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2019 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. +*/ + + +/** + * This function is similar to Object.assign() but it assigns recursively and + * allows you to ignore nullish values from the source + * + * @param {Object} target + * @param {Object} source + * @returns the target object + */ +export function recursivelyAssign(target: Object, source: Object, ignoreNullish = false): any { + for (const [sourceKey, sourceValue] of Object.entries(source)) { + if (target[sourceKey] instanceof Object && sourceValue) { + recursivelyAssign(target[sourceKey], sourceValue); + continue; + } + if ((sourceValue !== null && sourceValue !== undefined) || !ignoreNullish) { + target[sourceKey] = sourceValue; + continue; + } + } + return target; +} \ No newline at end of file diff --git a/src/utils/timeFormatting.ts b/src/utils/timeFormatting.ts new file mode 100644 index 00000000..2f2ae8c0 --- /dev/null +++ b/src/utils/timeFormatting.ts @@ -0,0 +1,52 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export enum TimeScope { + Minute = 60 * 1000, + Hours = 60 * TimeScope.Minute, + Day = 24 * TimeScope.Hours, +} + +export function formatDuration(milliseconds: number): string { + let days = 0; + let hours = 0; + let minutes = 0; + if (milliseconds >= TimeScope.Day) { + days = Math.floor(milliseconds / TimeScope.Day); + milliseconds -= days * TimeScope.Day; + } + if (milliseconds >= TimeScope.Hours) { + hours = Math.floor(milliseconds / TimeScope.Hours); + milliseconds -= hours * TimeScope.Hours; + } + if (milliseconds >= TimeScope.Minute) { + minutes = Math.floor(milliseconds / TimeScope.Minute); + milliseconds -= minutes * TimeScope.Minute; + } + const seconds = Math.floor(milliseconds / 1000); + let result = ""; + if (days) { + result = `${days}d `; + } + if (hours || days) { + result += `${hours}h `; + } + if (minutes || hours || days) { + result += `${minutes}m `; + } + result += `${seconds}s`; + return result; +} diff --git a/vite.config.js b/vite.config.js index 10348218..0bbeb4d4 100644 --- a/vite.config.js +++ b/vite.config.js @@ -49,7 +49,9 @@ export default defineConfig(({mode}) => { sw: definePlaceholders, }), ], - define: definePlaceholders, + define: Object.assign({ + DEFINE_PROJECT_DIR: JSON.stringify(__dirname) + }, definePlaceholders), }); }); diff --git a/yarn.lock b/yarn.lock index 188a5fe1..876917a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23,43 +23,6 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@colors/colors@1.5.0": - version "1.5.0" - resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" - integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== - -"@cypress/request@^2.88.10": - version "2.88.10" - resolved "https://registry.yarnpkg.com/@cypress/request/-/request-2.88.10.tgz#b66d76b07f860d3a4b8d7a0604d020c662752cce" - integrity sha512-Zp7F+R93N0yZyG34GutyTNr+okam7s/Fzc1+i3kcqOP8vk6OuajuE9qZJ6Rs+10/1JFtXFYMdyarnU1rZuJesg== - dependencies: - aws-sign2 "~0.7.0" - aws4 "^1.8.0" - caseless "~0.12.0" - combined-stream "~1.0.6" - extend "~3.0.2" - forever-agent "~0.6.1" - form-data "~2.3.2" - http-signature "~1.3.6" - is-typedarray "~1.0.0" - isstream "~0.1.2" - json-stringify-safe "~5.0.1" - mime-types "~2.1.19" - performance-now "^2.1.0" - qs "~6.5.2" - safe-buffer "^5.1.2" - tough-cookie "~2.5.0" - tunnel-agent "^0.6.0" - uuid "^8.3.2" - -"@cypress/xvfb@^1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@cypress/xvfb/-/xvfb-1.2.4.tgz#2daf42e8275b39f4aa53c14214e557bd14e7748a" - integrity sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q== - dependencies: - debug "^3.1.0" - lodash.once "^4.1.1" - "@eslint/eslintrc@^0.4.3": version "0.4.3" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.3.tgz#9e42981ef035beb3dd49add17acb96e8ff6f394c" @@ -93,6 +56,11 @@ version "3.2.8" resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz#8d53636d045e1776e2a2ec6613e57330dd9ce856" +"@matrixdotorg/structured-logviewer@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@matrixdotorg/structured-logviewer/-/structured-logviewer-0.0.3.tgz#1555111159d83cde0cfd5ba1a571e1faa1a90871" + integrity sha512-QqFglx0M8ix0IoRsJXDg1If26ltbYfuLjJ0MQrJYze3yz4ayEESRpQEA0YxJRVVtbco5M94tmrDpikokTFnn3A== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -137,28 +105,6 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.13.tgz#23e6c5168333480d454243378b69e861ab5c011a" integrity sha512-46yIhxSe5xEaJZXWdIBP7GU4HDTG8/eo0qd9atdiL+lFpA03y8KS+lkTN834TWJj5767GbWv4n/P6efyTFt1Dw== -"@types/node@^14.14.31": - version "14.18.26" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.26.tgz#239e19f8b4ea1a9eb710528061c1d733dc561996" - integrity sha512-0b+utRBSYj8L7XAp0d+DX7lI4cSmowNaaTkk6/1SKzbKkG+doLuPusB9EOvzLJ8ahJSk03bTLIL6cWaEd4dBKA== - -"@types/sinonjs__fake-timers@8.1.1": - version "8.1.1" - resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz#b49c2c70150141a15e0fa7e79cf1f92a72934ce3" - integrity sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g== - -"@types/sizzle@^2.3.2": - version "2.3.3" - resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.3.tgz#ff5e2f1902969d305225a047c8a0fd5c915cebef" - integrity sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ== - -"@types/yauzl@^2.9.1": - version "2.10.0" - resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.0.tgz#b3248295276cf8c6f153ebe6a9aba0c988cb2599" - integrity sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw== - dependencies: - "@types/node" "*" - "@typescript-eslint/eslint-plugin@^4.29.2": version "4.29.2" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.29.2.tgz#f54dc0a32b8f61c6024ab8755da05363b733838d" @@ -253,14 +199,6 @@ aes-js@^3.1.2: resolved "https://registry.yarnpkg.com/aes-js/-/aes-js-3.1.2.tgz#db9aabde85d5caabbfc0d4f2a4446960f627146a" integrity sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ== -aggregate-error@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" - integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== - dependencies: - clean-stack "^2.0.0" - indent-string "^4.0.0" - ajv@^6.10.0, ajv@^6.12.4: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" @@ -291,23 +229,11 @@ ansi-colors@^4.1.1: resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== -ansi-escapes@^4.3.0: - version "4.3.2" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" - integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== - dependencies: - type-fest "^0.21.3" - ansi-regex@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== -ansi-regex@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" - integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== - ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" @@ -322,11 +248,6 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" -arch@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11" - integrity sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ== - argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -339,48 +260,11 @@ array-union@^2.1.0: resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== -asn1@~0.2.3: - version "0.2.6" - resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" - integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== - dependencies: - safer-buffer "~2.1.0" - -assert-plus@1.0.0, assert-plus@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" - integrity sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw== - astral-regex@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== -async@^3.2.0: - version "3.2.4" - resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c" - integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ== - -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== - -at-least-node@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" - integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== - -aws-sign2@~0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" - integrity sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA== - -aws4@^1.8.0: - version "1.11.0" - resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" - integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== - balanced-match@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" @@ -403,28 +287,6 @@ base64-arraybuffer@^0.2.0: resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.2.0.tgz#4b944fac0191aa5907afe2d8c999ccc57ce80f45" integrity sha512-7emyCsu1/xiBXgQZrscw/8KPRT44I4Yq9Pe6EGs3aPRTsWuggML1/1DTuZUuIaJPIm1FTDUVXl4x/yW8s0kQDQ== -base64-js@^1.3.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" - integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== - -bcrypt-pbkdf@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" - integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== - dependencies: - tweetnacl "^0.14.3" - -blob-util@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/blob-util/-/blob-util-2.0.2.tgz#3b4e3c281111bb7f11128518006cdc60b403a1eb" - integrity sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ== - -bluebird@^3.7.2: - version "3.7.2" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" - integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== - boolbase@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" @@ -452,34 +314,11 @@ bs58@^4.0.1: dependencies: base-x "^3.0.2" -buffer-crc32@~0.2.3: - version "0.2.13" - resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" - integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== - -buffer@^5.6.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" - integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== - dependencies: - base64-js "^1.3.1" - ieee754 "^1.1.13" - -cachedir@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-2.3.0.tgz#0c75892a052198f0b21c7c1804d8331edfcae0e8" - integrity sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw== - callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== -caseless@~0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" - integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw== - chalk@^2.0.0: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -497,53 +336,6 @@ chalk@^4.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -chalk@^4.1.0: - version "4.1.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" - integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - -check-more-types@^2.24.0: - version "2.24.0" - resolved "https://registry.yarnpkg.com/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600" - integrity sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA== - -ci-info@^3.2.0: - version "3.3.2" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.3.2.tgz#6d2967ffa407466481c6c90b6e16b3098f080128" - integrity sha512-xmDt/QIAdeZ9+nfdPsaBCpMvHNLFiLdjj59qjqn+6iPe6YmHGQ35sBnQ8uslRBXFmXkiZQOJRjvQeoGppoTjjg== - -clean-stack@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" - integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== - -cli-cursor@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" - integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== - dependencies: - restore-cursor "^3.1.0" - -cli-table3@~0.6.1: - version "0.6.2" - resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.2.tgz#aaf5df9d8b5bf12634dc8b3040806a0c07120d2a" - integrity sha512-QyavHCaIC80cMivimWu4aWHilIpiDpfm3hGmqAmXVL1UsnbLuBSMd21hTX6VY4ZSDSM73ESLeF8TOYId3rBTbw== - dependencies: - string-width "^4.2.0" - optionalDependencies: - "@colors/colors" "1.5.0" - -cli-truncate@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-2.1.0.tgz#c39e28bf05edcde5be3b98992a22deed5a2b93c7" - integrity sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg== - dependencies: - slice-ansi "^3.0.0" - string-width "^4.2.0" - color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -568,28 +360,11 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -colorette@^2.0.16: - version "2.0.19" - resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" - integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== - colors@^1.3.3: version "1.4.0" resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== -combined-stream@^1.0.6, combined-stream@~1.0.6: - version "1.0.8" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" - integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== - dependencies: - delayed-stream "~1.0.0" - -commander@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" - integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== - commander@^6.1.0: version "6.2.1" resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" @@ -600,11 +375,6 @@ commander@^7.2.0: resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== -common-tags@^1.8.0: - version "1.8.2" - resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.2.tgz#94ebb3c076d26032745fd54face7f688ef5ac9c6" - integrity sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA== - concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -620,12 +390,7 @@ core-js@^3.6.5: resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.19.3.tgz#6df8142a996337503019ff3235a7022d7cdf4559" integrity sha512-LeLBMgEGSsG7giquSzvgBrTS7V5UL6ks3eQlUSbN8dJStlLFiRzUm5iqsRyzUB8carhfKjkJ2vzKqE6z1Vga9g== -core-util-is@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" - integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== - -cross-spawn@^7.0.0, cross-spawn@^7.0.2: +cross-spawn@^7.0.2: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== @@ -670,73 +435,6 @@ cuint@^0.2.2: resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b" integrity sha1-QICG1AlVDCYxFVYZ6fp7ytw7mRs= -cypress@^10.6.0: - version "10.6.0" - resolved "https://registry.yarnpkg.com/cypress/-/cypress-10.6.0.tgz#13f46867febf2c3715874ed5dce9c2e946b175fe" - integrity sha512-6sOpHjostp8gcLO34p6r/Ci342lBs8S5z9/eb3ZCQ22w2cIhMWGUoGKkosabPBfKcvRS9BE4UxybBtlIs8gTQA== - dependencies: - "@cypress/request" "^2.88.10" - "@cypress/xvfb" "^1.2.4" - "@types/node" "^14.14.31" - "@types/sinonjs__fake-timers" "8.1.1" - "@types/sizzle" "^2.3.2" - arch "^2.2.0" - blob-util "^2.0.2" - bluebird "^3.7.2" - buffer "^5.6.0" - cachedir "^2.3.0" - chalk "^4.1.0" - check-more-types "^2.24.0" - cli-cursor "^3.1.0" - cli-table3 "~0.6.1" - commander "^5.1.0" - common-tags "^1.8.0" - dayjs "^1.10.4" - debug "^4.3.2" - enquirer "^2.3.6" - eventemitter2 "^6.4.3" - execa "4.1.0" - executable "^4.1.1" - extract-zip "2.0.1" - figures "^3.2.0" - fs-extra "^9.1.0" - getos "^3.2.1" - is-ci "^3.0.0" - is-installed-globally "~0.4.0" - lazy-ass "^1.6.0" - listr2 "^3.8.3" - lodash "^4.17.21" - log-symbols "^4.0.0" - minimist "^1.2.6" - ospath "^1.2.2" - pretty-bytes "^5.6.0" - proxy-from-env "1.0.0" - request-progress "^3.0.0" - semver "^7.3.2" - supports-color "^8.1.1" - tmp "~0.2.1" - untildify "^4.0.0" - yauzl "^2.10.0" - -dashdash@^1.12.0: - version "1.14.1" - resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" - integrity sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g== - dependencies: - assert-plus "^1.0.0" - -dayjs@^1.10.4: - version "1.11.5" - resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.5.tgz#00e8cc627f231f9499c19b38af49f56dc0ac5e93" - integrity sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA== - -debug@^3.1.0: - version "3.2.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== - dependencies: - ms "^2.1.1" - debug@^4.0.1, debug@^4.1.1: version "4.3.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" @@ -751,13 +449,6 @@ debug@^4.3.1: dependencies: ms "2.1.2" -debug@^4.3.2: - version "4.3.4" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== - dependencies: - ms "2.1.2" - deep-is@^0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" @@ -768,11 +459,6 @@ deep-is@~0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== - dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -834,27 +520,12 @@ domutils@^2.6.0: domelementtype "^2.2.0" domhandler "^4.2.0" -ecc-jsbn@~0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" - integrity sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw== - dependencies: - jsbn "~0.1.0" - safer-buffer "^2.1.0" - emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== -end-of-stream@^1.1.0: - version "1.4.4" - resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" - integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== - dependencies: - once "^1.4.0" - -enquirer@^2.3.5, enquirer@^2.3.6: +enquirer@^2.3.5: version "2.3.6" resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d" integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg== @@ -1254,59 +925,11 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -eventemitter2@^6.4.3: - version "6.4.7" - resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.7.tgz#a7f6c4d7abf28a14c1ef3442f21cb306a054271d" - integrity sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg== - -execa@4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a" - integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA== - dependencies: - cross-spawn "^7.0.0" - get-stream "^5.0.0" - human-signals "^1.1.1" - is-stream "^2.0.0" - merge-stream "^2.0.0" - npm-run-path "^4.0.0" - onetime "^5.1.0" - signal-exit "^3.0.2" - strip-final-newline "^2.0.0" - -executable@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/executable/-/executable-4.1.1.tgz#41532bff361d3e57af4d763b70582db18f5d133c" - integrity sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg== - dependencies: - pify "^2.2.0" - -extend@^3.0.1, extend@~3.0.2: +extend@^3.0.1: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== -extract-zip@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a" - integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg== - dependencies: - debug "^4.1.1" - get-stream "^5.1.0" - yauzl "^2.10.0" - optionalDependencies: - "@types/yauzl" "^2.9.1" - -extsprintf@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" - integrity sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g== - -extsprintf@^1.2.0: - version "1.4.1" - resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.1.tgz#8d172c064867f235c0c84a596806d279bf4bcc07" - integrity sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA== - fake-indexeddb@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-3.1.2.tgz#8073a12ed3b254f7afc064f3cc2629f0110a5303" @@ -1348,20 +971,6 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" -fd-slicer@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" - integrity sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g== - dependencies: - pend "~1.2.0" - -figures@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" - integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== - dependencies: - escape-string-regexp "^1.0.5" - file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" @@ -1389,30 +998,6 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.1.1.tgz#c4b489e80096d9df1dfc97c79871aea7c617c469" integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA== -forever-agent@~0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" - integrity sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw== - -form-data@~2.3.2: - version "2.3.3" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" - integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.6" - mime-types "^2.1.12" - -fs-extra@^9.1.0: - version "9.1.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" - integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== - dependencies: - at-least-node "^1.0.0" - graceful-fs "^4.2.0" - jsonfile "^6.0.1" - universalify "^2.0.0" - fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -1433,27 +1018,6 @@ functional-red-black-tree@^1.0.1: resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= -get-stream@^5.0.0, get-stream@^5.1.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" - integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== - dependencies: - pump "^3.0.0" - -getos@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/getos/-/getos-3.2.1.tgz#0134d1f4e00eb46144c5a9c0ac4dc087cbb27dc5" - integrity sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q== - dependencies: - async "^3.2.0" - -getpass@^0.1.1: - version "0.1.7" - resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" - integrity sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng== - dependencies: - assert-plus "^1.0.0" - glob-parent@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" @@ -1473,13 +1037,6 @@ glob@^7.1.3: once "^1.3.0" path-is-absolute "^1.0.0" -global-dirs@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-3.0.0.tgz#70a76fe84ea315ab37b1f5576cbde7d48ef72686" - integrity sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA== - dependencies: - ini "2.0.0" - globals@^13.6.0: version "13.8.0" resolved "https://registry.yarnpkg.com/globals/-/globals-13.8.0.tgz#3e20f504810ce87a8d72e55aecf8435b50f4c1b3" @@ -1506,11 +1063,6 @@ globby@^11.0.3: merge2 "^1.3.0" slash "^3.0.0" -graceful-fs@^4.1.6, graceful-fs@^4.2.0: - version "4.2.10" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" - integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== - has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -1533,25 +1085,6 @@ he@1.2.0: resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== -http-signature@~1.3.6: - version "1.3.6" - resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.3.6.tgz#cb6fbfdf86d1c974f343be94e87f7fc128662cf9" - integrity sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw== - dependencies: - assert-plus "^1.0.0" - jsprim "^2.0.2" - sshpk "^1.14.1" - -human-signals@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" - integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== - -ieee754@^1.1.13: - version "1.2.1" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" - integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== - ignore@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" @@ -1584,11 +1117,6 @@ imurmurhash@^0.1.4: resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= -indent-string@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" - integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== - inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" @@ -1602,18 +1130,6 @@ inherits@2: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== -ini@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" - integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== - -is-ci@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-3.0.1.tgz#db6ecbed1bd659c43dac0f45661e7674103d1867" - integrity sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ== - dependencies: - ci-info "^3.2.0" - is-core-module@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.1.tgz#f59fdfca701d5879d0a6b100a40aa1560ce27211" @@ -1638,54 +1154,21 @@ is-glob@^4.0.0, is-glob@^4.0.1: dependencies: is-extglob "^2.1.1" -is-installed-globally@~0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.4.0.tgz#9a0fd407949c30f86eb6959ef1b7994ed0b7b520" - integrity sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ== - dependencies: - global-dirs "^3.0.0" - is-path-inside "^3.0.2" - is-number@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== -is-path-inside@^3.0.2: - version "3.0.3" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" - integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== - is-plain-obj@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== -is-stream@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" - integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== - -is-typedarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" - integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== - -is-unicode-supported@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" - integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== - isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= -isstream@~0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" - integrity sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g== - js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -1699,11 +1182,6 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" -jsbn@~0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" - integrity sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg== - json-schema-traverse@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" @@ -1714,45 +1192,11 @@ json-schema-traverse@^1.0.0: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== -json-schema@0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" - integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== - json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= -json-stringify-safe@~5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" - integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== - -jsonfile@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" - integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== - dependencies: - universalify "^2.0.0" - optionalDependencies: - graceful-fs "^4.1.6" - -jsprim@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-2.0.2.tgz#77ca23dbcd4135cd364800d22ff82c2185803d4d" - integrity sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ== - dependencies: - assert-plus "1.0.0" - extsprintf "1.3.0" - json-schema "0.4.0" - verror "1.10.0" - -lazy-ass@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/lazy-ass/-/lazy-ass-1.6.0.tgz#7999655e8646c17f089fdd187d150d3324d54513" - integrity sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw== - levn@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" @@ -1769,20 +1213,6 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" -listr2@^3.8.3: - version "3.14.0" - resolved "https://registry.yarnpkg.com/listr2/-/listr2-3.14.0.tgz#23101cc62e1375fd5836b248276d1d2b51fdbe9e" - integrity sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g== - dependencies: - cli-truncate "^2.1.0" - colorette "^2.0.16" - log-update "^4.0.0" - p-map "^4.0.0" - rfdc "^1.3.0" - rxjs "^7.5.1" - through "^2.3.8" - wrap-ansi "^7.0.0" - lodash.clonedeep@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" @@ -1793,39 +1223,16 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash.once@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" - integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== - lodash.truncate@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" integrity sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM= -lodash@^4.17.21, lodash@^4.7.0: +lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== -log-symbols@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" - integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== - dependencies: - chalk "^4.1.0" - is-unicode-supported "^0.1.0" - -log-update@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/log-update/-/log-update-4.0.0.tgz#589ecd352471f2a1c0c570287543a64dfd20e0a1" - integrity sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg== - dependencies: - ansi-escapes "^4.3.0" - cli-cursor "^3.1.0" - slice-ansi "^4.0.0" - wrap-ansi "^6.2.0" - lru-cache@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" @@ -1850,11 +1257,6 @@ merge-options@^3.0.4: dependencies: is-plain-obj "^2.1.0" -merge-stream@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" - integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== - merge2@^1.3.0: version "1.4.1" resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" @@ -1868,23 +1270,6 @@ micromatch@^4.0.4: braces "^3.0.1" picomatch "^2.2.3" -mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-types@^2.1.12, mime-types@~2.1.19: - version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - -mimic-fn@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" - integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== - minimatch@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" @@ -1892,21 +1277,11 @@ minimatch@^3.0.4: dependencies: brace-expansion "^1.1.7" -minimist@^1.2.6: - version "1.2.6" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" - integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== - ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@^2.1.1: - version "2.1.3" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - nanoid@^3.3.3: version "3.3.4" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" @@ -1925,13 +1300,6 @@ node-html-parser@^4.0.0: css-select "^4.1.3" he "1.2.0" -npm-run-path@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" - integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== - dependencies: - path-key "^3.0.0" - nth-check@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.0.0.tgz#1bb4f6dac70072fc313e8c9cd1417b5074c0a125" @@ -1946,20 +1314,13 @@ off-color@^2.0.0: dependencies: core-js "^3.6.5" -once@^1.3.0, once@^1.3.1, once@^1.4.0: +once@^1.3.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= dependencies: wrappy "1" -onetime@^5.1.0: - version "5.1.2" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" - integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== - dependencies: - mimic-fn "^2.1.0" - optionator@^0.8.1: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" @@ -1984,18 +1345,6 @@ optionator@^0.9.1: type-check "^0.4.0" word-wrap "^1.2.3" -ospath@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/ospath/-/ospath-1.2.2.tgz#1276639774a3f8ef2572f7fe4280e0ea4550c07b" - integrity sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA== - -p-map@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b" - integrity sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== - dependencies: - aggregate-error "^3.0.0" - parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" @@ -2008,7 +1357,7 @@ path-is-absolute@^1.0.0: resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= -path-key@^3.0.0, path-key@^3.1.0: +path-key@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== @@ -2023,16 +1372,6 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== -pend@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" - integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== - -performance-now@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" - integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== - picocolors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" @@ -2043,11 +1382,6 @@ picomatch@^2.2.3: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972" integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw== -pify@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" - integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== - playwright-core@1.27.1: version "1.27.1" resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.27.1.tgz#840ef662e55a3ed759d8b5d3d00a5f885a7184f4" @@ -2091,44 +1425,16 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= -pretty-bytes@^5.6.0: - version "5.6.0" - resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" - integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== - progress@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== -proxy-from-env@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee" - integrity sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A== - -psl@^1.1.28: - version "1.9.0" - resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" - integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== - -pump@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" - integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== - dependencies: - end-of-stream "^1.1.0" - once "^1.3.1" - punycode@^2.1.0, punycode@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -qs@~6.5.2: - version "6.5.3" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad" - integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA== - queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" @@ -2154,13 +1460,6 @@ regexpp@^3.1.0: resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.1.0.tgz#206d0ad0a5648cffbdb8ae46438f3dc51c9f78e2" integrity sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q== -request-progress@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/request-progress/-/request-progress-3.0.0.tgz#4ca754081c7fec63f505e4faa825aa06cd669dbe" - integrity sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg== - dependencies: - throttleit "^1.0.0" - require-from-string@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" @@ -2180,25 +1479,12 @@ resolve@^1.22.0: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -restore-cursor@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" - integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== - dependencies: - onetime "^5.1.0" - signal-exit "^3.0.2" - reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== -rfdc@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b" - integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA== - -rimraf@^3.0.0, rimraf@^3.0.2: +rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== @@ -2219,23 +1505,11 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -rxjs@^7.5.1: - version "7.5.6" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.6.tgz#0446577557862afd6903517ce7cae79ecb9662bc" - integrity sha512-dnyv2/YsXhnm461G+R/Pe5bWP41Nm6LBXEYWI6eiFP4fiwx6WRI/CD0zbdVAudd9xwLEF2IDcKXLHit0FYjUzw== - dependencies: - tslib "^2.1.0" - -safe-buffer@^5.0.1, safe-buffer@^5.1.2: +safe-buffer@^5.0.1: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== -safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - semver@^7.2.1, semver@^7.3.5: version "7.3.5" resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" @@ -2243,13 +1517,6 @@ semver@^7.2.1, semver@^7.3.5: dependencies: lru-cache "^6.0.0" -semver@^7.3.2: - version "7.3.7" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f" - integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g== - dependencies: - lru-cache "^6.0.0" - setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" @@ -2267,25 +1534,11 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -signal-exit@^3.0.2: - version "3.0.7" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" - integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== - slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== -slice-ansi@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-3.0.0.tgz#31ddc10930a1b7e0b67b08c96c2f49b77a789787" - integrity sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ== - dependencies: - ansi-styles "^4.0.0" - astral-regex "^2.0.0" - is-fullwidth-code-point "^3.0.0" - slice-ansi@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b" @@ -2310,35 +1563,11 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= -sshpk@^1.14.1: - version "1.17.0" - resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.17.0.tgz#578082d92d4fe612b13007496e543fa0fbcbe4c5" - integrity sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ== - dependencies: - asn1 "~0.2.3" - assert-plus "^1.0.0" - bcrypt-pbkdf "^1.0.0" - dashdash "^1.12.0" - ecc-jsbn "~0.1.1" - getpass "^0.1.1" - jsbn "~0.1.0" - safer-buffer "^2.0.2" - tweetnacl "~0.14.0" - stable@^0.1.8: version "0.1.8" resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== -string-width@^4.1.0: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^4.2.0: version "4.2.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5" @@ -2355,18 +1584,6 @@ strip-ansi@^6.0.0: dependencies: ansi-regex "^5.0.0" -strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-final-newline@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" - integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== - strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" @@ -2386,13 +1603,6 @@ supports-color@^7.1.0: dependencies: has-flag "^4.0.0" -supports-color@^8.1.1: - version "8.1.1" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - supports-preserve-symlinks-flag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" @@ -2433,23 +1643,6 @@ text-table@^0.2.0: resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= -throttleit@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c" - integrity sha512-rkTVqu6IjfQ/6+uNuuc3sZek4CEYxTJom3IktzgdSxcZqdARuebbA/f4QmAxMQIxqq9ZLEUkSYqvuk1I6VKq4g== - -through@^2.3.8: - version "2.3.8" - resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" - integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== - -tmp@~0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" - integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ== - dependencies: - rimraf "^3.0.0" - to-regex-range@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" @@ -2457,14 +1650,6 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" -tough-cookie@~2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" - integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== - dependencies: - psl "^1.1.28" - punycode "^2.1.1" - tr46@^2.0.2: version "2.1.0" resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240" @@ -2477,11 +1662,6 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.1.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" - integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== - tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -2489,18 +1669,6 @@ tsutils@^3.21.0: dependencies: tslib "^1.8.1" -tunnel-agent@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" - integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w== - dependencies: - safe-buffer "^5.0.1" - -tweetnacl@^0.14.3, tweetnacl@~0.14.0: - version "0.14.5" - resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" - integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== - type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -2520,11 +1688,6 @@ type-fest@^0.20.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== -type-fest@^0.21.3: - version "0.21.3" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" - integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== - typescript@^4.7.0: version "4.7.4" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235" @@ -2549,16 +1712,6 @@ typeson@^6.0.0: resolved "https://registry.yarnpkg.com/typeson/-/typeson-6.1.0.tgz#5b2a53705a5f58ff4d6f82f965917cabd0d7448b" integrity sha512-6FTtyGr8ldU0pfbvW/eOZrEtEkczHRUtduBnA90Jh9kMPCiFNnXIon3vF41N0S4tV1HHQt4Hk1j4srpESziCaA== -universalify@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" - integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== - -untildify@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/untildify/-/untildify-4.0.0.tgz#2bc947b953652487e4600949fb091e3ae8cd919b" - integrity sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw== - uri-js@^4.2.2: version "4.4.1" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" @@ -2566,25 +1719,11 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -uuid@^8.3.2: - version "8.3.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" - integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== - v8-compile-cache@^2.0.3: version "2.3.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== -verror@1.10.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" - integrity sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw== - dependencies: - assert-plus "^1.0.0" - core-util-is "1.0.2" - extsprintf "^1.2.0" - vite@^2.9.8: version "2.9.8" resolved "https://registry.yarnpkg.com/vite/-/vite-2.9.8.tgz#2c2cb0790beb0fbe4b8c0995b80fe691a91c2545" @@ -2628,24 +1767,6 @@ word-wrap@^1.2.3, word-wrap@~1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== -wrap-ansi@^6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" - integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -2662,11 +1783,3 @@ yallist@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== - -yauzl@^2.10.0: - version "2.10.0" - resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" - integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g== - dependencies: - buffer-crc32 "~0.2.3" - fd-slicer "~1.1.0"