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/.gitignore b/.gitignore index 78f9f348..ef4f67ca 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ lib *.tar.gz .eslintcache .tmp +playwright/synapselogs diff --git a/.ts-eslintrc.js b/.ts-eslintrc.js index 1974e07b..ae7233ed 100644 --- a/.ts-eslintrc.js +++ b/.ts-eslintrc.js @@ -19,6 +19,11 @@ module.exports = { ], rules: { "@typescript-eslint/no-floating-promises": 2, - "@typescript-eslint/no-misused-promises": 2 + "@typescript-eslint/no-misused-promises": 2, + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": ["warn"], + "no-undef": "off", + "semi": ["error", "always"], + "@typescript-eslint/explicit-function-return-type": ["error"] } }; diff --git a/Dockerfile b/Dockerfile index f9e32313..f35dc559 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,19 @@ FROM docker.io/node:alpine as builder RUN apk add --no-cache git python3 build-base -COPY . /app + WORKDIR /app -RUN yarn install \ - && yarn build + +# 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 FROM docker.io/nginx:alpine + +# Copy the dynamic config script +COPY ./docker/dynamic-config.sh /docker-entrypoint.d/99-dynamic-config.sh + +# 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/SDK.md b/doc/SDK.md index c8f5197f..ba021ad1 100644 --- a/doc/SDK.md +++ b/doc/SDK.md @@ -85,7 +85,7 @@ async function main() { room, ownUserId: session.userId, platform, - urlCreator: urlRouter, + urlRouter: urlRouter, navigation, }); await vm.load(); 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 77% rename from doc/THEMING.md rename to doc/architecture/THEMING.md index b6af27cf..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: @@ -167,3 +168,38 @@ To find the theme-id of some theme, you can look at the built-asset section of t This default theme will render as "Default" option in the theme-chooser dropdown. If the device preference is for dark theme, the dark default is selected and vice versa. **You'll need to reload twice so that Hydrogen picks up the config changes!** + +# Derived Theme(Collection) +This allows users to theme Hydrogen without the need for rebuilding. Derived theme collections can be thought of as extensions (derivations) of some existing build time theme. + +## Creating a derived theme: +Here's how you create a new derived theme: +1. You create a new theme manifest file (eg: theme-awesome.json) and mention which build time theme you're basing your new theme on using the `extends` field. The base css file of the mentioned theme is used for your new theme. +2. You configure the theme manifest as usual by populating the `variants` field with your desired colors. +3. You add your new theme manifest to the list of themes in `config.json`. + +Refresh Hydrogen twice (once to refresh cache, and once to load) and the new theme should show up in the theme chooser. + +## How does it work? + +For every theme collection in hydrogen, the build process emits a runtime css file which like the built theme css file contains variables in the css code. But unlike the theme css file, the runtime css file lacks the definition for these variables: + +CSS for the built theme: +```css +:root { + --background-color-primary: #f2f20f; +} + +body { + background-color: var(--background-color-primary); +} +``` +and the corresponding runtime theme: +```css +/* Notice the lack of definiton for --background-color-primary here! */ +body { + background-color: var(--background-color-primary); +} +``` + +When hydrogen loads a derived theme, it takes the runtime css file of the extended theme and dynamically adds the variable definition based on the values specified in the manifest. Icons are also colored dynamically and injected as variables using Data URIs. 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/architecture/UI/ui.md b/doc/architecture/UI/ui.md new file mode 100644 index 00000000..d3aa3893 --- /dev/null +++ b/doc/architecture/UI/ui.md @@ -0,0 +1,206 @@ +## IView components + +The [interface](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/types.ts) adopted by view components is agnostic of how they are rendered to the DOM. This has several benefits: + - it allows Hydrogen to not ship a [heavy view framework](https://bundlephobia.com/package/react-dom@18.2.0) that may or may not be used by its SDK users, and also keep bundle size of the app down. + - Given the interface is quite simple, is should be easy to integrate this interface into the render lifecycle of other frameworks. + - The main implementations used in Hydrogen are [`ListView`](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/ListView.ts) (rendering [`ObservableList`](https://github.com/vector-im/hydrogen-web/blob/master/src/observable/list/BaseObservableList.ts)s) and [`TemplateView`](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/TemplateView.ts) (templating and one-way databinding), each only a few 100 lines of code and tailored towards their specific use-case. They work straight with the DOM API and have no other dependencies. + - a common inteface allows us to mix and match between these different implementations (and gradually shift if need be in the future) with the code. + +## Templates + +### Template language + +Templates use a mini-DSL language in pure javascript to express declarative templates. This is basically a very thin wrapper around `document.createElement`, `document.createTextNode`, `node.setAttribute` and `node.appendChild` to quickly create DOM trees. The general syntax is as follows: +```js +t.tag_name({attribute1: value, attribute2: value, ...}, [child_elements]); +t.tag_name(child_element); +t.tag_name([child_elements]); +``` +**tag_name** can be [most HTML or SVG tags](https://github.com/vector-im/hydrogen-web/blob/master/src/platform/web/ui/general/html.ts#L102-L110). + +eg: +Here is an example HTML segment followed with the code to create it in Hydrogen. +```html +
+

Demo

+ +
+``` +```js +t.section({className: "main-section"},[ + t.h1("Demo"), + t.button({className:"btn_cool"}, "Click me") +]); +``` + +All these functions return DOM element nodes, e.g. the result of `document.createElement`. + +### TemplateView + +`TemplateView` builds on top of templating by adopting the IView component model and adding event handling attributes, sub views and one-way databinding. +In views based on `TemplateView`, you will see a render method with a `t` argument. +`t` is `TemplateBuilder` object passed to the render function in `TemplateView`. It also takes a data object to render and bind to, often called `vm`, short for view model from the MVVM pattern Hydrogen uses. + +You either subclass `TemplateView` and override the `render` method: +```js +class MyView extends TemplateView { + render(t, vm) { + return t.div(...); + } +} +``` + +Or you pass a render function to `InlineTemplateView`: +```js +new InlineTemplateView(vm, (t, vm) => { + return t.div(...); +}); +``` + +**Note:** the render function is only called once to build the initial DOM tree and setup bindings, etc ... Any subsequent updates to the DOM of a component happens through bindings. + +#### Event handlers + +Any attribute starting with `on` and having a function as a value will be attached as an event listener on the given node. The event handler will be removed during unmounting. + +```js +t.button({onClick: evt => { + vm.doSomething(evt.target.value); +}}, "Click me"); +``` + +#### Subviews + +`t.view(instance)` will mount the sub view (can be any IView) and return its root node so it can be attached in the DOM tree. +All subviews will be unmounted when the parent view gets unmounted. + +```js +t.div({className: "Container"}, t.view(new ChildView(vm.childViewModel))); +``` + +#### One-way data-binding + +A binding couples a part of the DOM to a value on the view model. The view model emits an update when any of its properties change, to which the view can subscribe. When an update is received by the view, it will reevaluate all the bindings, and update the DOM accordingly. + +A binding can appear in many places where a static value can usually be used in the template tree. +To create a binding, you pass a function that maps the view value to a static value. + +##### Text binding + +```js +t.p(["I've got ", vm => vm.counter, " beans"]) +``` + +##### Attribute binding + +```js +t.button({disabled: vm => vm.isBusy}, "Submit"); +``` + +##### Class-name binding +```js +t.div({className: { + button: true, + active: vm => vm.isActive +}}) +``` +##### Subview binding + +So far, all the bindings can only change node values within our tree, but don't change the structure of the DOM. A sub view binding allows you to conditionally add a subview based on the result of a binding function. + +All sub view bindings return a DOM (element or comment) node and can be directly added to the DOM tree by including them in your template. + +###### map + +`t.mapView` allows you to choose a view based on the result of the binding function: + +```js +t.mapView(vm => vm.count, count => { + return count > 5 ? new LargeView(count) : new SmallView(count); +}); +``` + +Every time the first or binding function returns a different value, the second function is run to create a new view to replace the previous view. + +You can also return `null` or `undefined` from the second function to indicate a view should not be rendered. In this case a comment node will be used as a placeholder. + +There is also a `t.map` which will create a new template view (with the same value) and you directly provide a render function for it: + +```js +t.map(vm => vm.shape, (shape, t, vm) => { + switch (shape) { + case "rect": return t.rect(); + case "circle": return t.circle(); + } +}) +``` + +###### if + +`t.ifView` will render the subview if the binding returns a truthy value: + +```js +t.ifView(vm => vm.isActive, vm => new View(vm.someValue)); +``` + +You equally have `t.if`, which creates a `TemplateView` and passes you the `TemplateBuilder`: + +```js +t.if(vm => vm.isActive, (t, vm) => t.div("active!")); +``` + +##### Side-effects + +Sometimes you want to imperatively modify your DOM tree based on the value of a binding. +`mapSideEffect` makes this easy to do: + +```js +let node = t.div(); +t.mapSideEffect(vm => vm.color, (color, oldColor) => node.style.background = color); +return node; +``` + +**Note:** you shouldn't add any bindings, subviews or event handlers from the side-effect callback, +the safest is to not use the `t` argument at all. +If you do, they will be added every time the callback is run and only cleaned up when the view is unmounted. + +#### `tag` vs `t` + +If you don't need a view component with data-binding, sub views and event handler attributes, the template language also is available in `ui/general/html.js` without any of these bells and whistles, exported as `tag`. As opposed to static templates with `tag`, you always use +`TemplateView` as an instance of a class, as there is some extra state to keep track (bindings, event handlers and subviews). + +Although syntactically similar, `TemplateBuilder` and `tag` are not functionally equivalent. +Primarily `t` **supports** bindings and event handlers while `tag` **does not**. This is because to remove event listeners, we need to keep track of them, and thus we need to keep this state somewhere which +we can't do with a simple function call but we can insite the TemplateView class. + +```js + // The onClick here wont work!! + tag.button({className:"awesome-btn", onClick: () => this.foo()}); + +class MyView extends TemplateView { + render(t, vm){ + // The onClick works here. + t.button({className:"awesome-btn", onClick: () => this.foo()}); + } +} +``` + +## ListView + +A view component that renders and updates a list of sub views for every item in a `ObservableList`. + +```js +const list = new ListView({ + list: someObservableList +}, listValue => return new ChildView(listValue)) +``` + +As items are added, removed, moved (change position) and updated, the DOM will be kept in sync. + +There is also a `LazyListView` that only renders items in and around the current viewport, with the restriction that all items in the list must be rendered with the same height. + +### Sub view updates + +Unless the `parentProvidesUpdates` option in the constructor is set to `false`, the ListView will call the `update` method on the child `IView` component when it receives an update event for one of the items in the `ObservableList`. + +This way, not every sub view has to have an individual listener on it's view model (a value from the observable list), and all updates go from the observable list to the list view, who then notifies the correct sub view. 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..db779fe1 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -39,11 +39,11 @@ In this repository, create a Docker image: 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 @@ -56,3 +56,27 @@ docker run \ --publish 80:80 \ hydrogen ``` + +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 80:80 \ + --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/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 100% rename from doc/impl-thoughts/RELATIONS.md rename to doc/implementation planning/RELATIONS.md 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 95% rename from doc/impl-thoughts/SSO.md rename to doc/implementation planning/SSO.md index 2c84cd2c..51b87077 100644 --- a/doc/impl-thoughts/SSO.md +++ b/doc/implementation planning/SSO.md @@ -30,7 +30,7 @@ if (loginOptions.sso) { // store the homeserver for when we get redirected back after the sso flow platform.settingsStorage.setString("sso_homeserver", loginOptions.homeserver); // create the redirect url - const callbackUrl = urlCreator.createSSOCallbackURL(); // will just return the document url without any fragment + const callbackUrl = urlRouter.createSSOCallbackURL(); // will just return the document url without any fragment const redirectUrl = sso.createRedirectUrl(callbackUrl, provider); // and open it platform.openURL(redirectUrl); 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/problem solving/IMPORT-ISSUES.md b/doc/problem solving/IMPORT-ISSUES.md new file mode 100644 index 00000000..d4bb62fe --- /dev/null +++ b/doc/problem solving/IMPORT-ISSUES.md @@ -0,0 +1,11 @@ +## How to import common-js dependency using ES6 syntax +--- +Until [#6632](https://github.com/vitejs/vite/issues/6632) is fixed, such imports should be done as follows: + +```ts +import * as pkg from "off-color"; +// @ts-ignore +const offColor = pkg.offColor ?? pkg.default.offColor; +``` + +This way build, dev server and unit tests should all work. 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..952cb969 --- /dev/null +++ b/docker/dynamic-config.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +set -eux + +# Use config override environment variable if set +if [ -n "${CONFIG_OVERRIDE:-}" ]; then + echo "$CONFIG_OVERRIDE" > /usr/share/nginx/html/config.json +fi diff --git a/package.json b/package.json index f470bd9f..c80b757d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydrogen-web", - "version": "0.2.33", + "version": "0.3.6", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "directories": { "doc": "doc" @@ -18,7 +18,8 @@ "start": "vite --port 3000", "build": "vite build && ./scripts/cleanup.sh", "build:sdk": "./scripts/sdk/build.sh", - "watch:sdk": "./scripts/sdk/build.sh && yarn run vite build -c vite.sdk-lib-config.js --watch" + "watch:sdk": "./scripts/sdk/build.sh && yarn run vite build -c vite.sdk-lib-config.js --watch", + "test:app": "./scripts/test-app.sh" }, "repository": { "type": "git", @@ -32,6 +33,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", "acorn": "^8.6.0", @@ -51,8 +53,9 @@ "postcss-flexbugs-fixes": "^5.0.2", "postcss-value-parser": "^4.2.0", "regenerator-runtime": "^0.13.7", + "svgo": "^2.8.0", "text-encoding": "^0.7.0", - "typescript": "^4.4", + "typescript": "^4.7.0", "vite": "^2.9.8", "xxhashjs": "^0.2.2" }, diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..0a00a88f --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,21 @@ +import type { PlaywrightTestConfig } from "@playwright/test"; + +const BASE_URL = process.env["BASE_URL"] ?? "http://127.0.0.1:3000"; + +const config: PlaywrightTestConfig = { + use: { + headless: false, + viewport: { width: 1280, height: 720 }, + ignoreHTTPSErrors: true, + video: "on-first-retry", + baseURL: BASE_URL, + }, + testDir: "./playwright/tests", + globalSetup: require.resolve("./playwright/global-setup"), + webServer: { + command: "yarn start", + url: `${BASE_URL}/#/login`, + }, + workers: 1 +}; +export default config; diff --git a/playwright/global-setup.ts b/playwright/global-setup.ts new file mode 100644 index 00000000..5944e55c --- /dev/null +++ b/playwright/global-setup.ts @@ -0,0 +1,28 @@ +/* +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. +*/ + +const env = { + SYNAPSE_IP_ADDRESS: "172.18.0.5", + SYNAPSE_PORT: "8008", + DEX_IP_ADDRESS: "172.18.0.4", + DEX_PORT: "5556", +} + +export default function setupEnvironmentVariables() { + for (const [key, value] of Object.entries(env)) { + process.env[key] = value; + } +} diff --git a/playwright/plugins/dex/index.ts b/playwright/plugins/dex/index.ts new file mode 100644 index 00000000..f45257fc --- /dev/null +++ b/playwright/plugins/dex/index.ts @@ -0,0 +1,108 @@ +/* +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 * as path from "path"; +import * as os from "os"; +import * as fse from "fs-extra"; + +import {dockerRun, dockerStop } from "../docker"; + +// A cypress plugins to add command to start & stop dex instances + +interface DexConfig { + configDir: string; + baseUrl: string; + port: number; + host: string; +} + +export interface DexInstance extends DexConfig { + dexId: string; +} + +const dexConfigs = new Map(); + +async function produceConfigWithSynapseURLAdded(): Promise { + const templateDir = path.join(__dirname, "template"); + + const stats = await fse.stat(templateDir); + if (!stats?.isDirectory) { + throw new Error(`Template directory at ${templateDir} not found!`); + } + const tempDir = await fse.mkdtemp(path.join(os.tmpdir(), 'hydrogen-testing-dex-')); + + // copy the contents of the template dir, omitting config.yaml as we'll template that + console.log(`Copy ${templateDir} -> ${tempDir}`); + await fse.copy(templateDir, tempDir, { filter: f => path.basename(f) !== 'config.yaml' }); + + // now copy config.yaml, applying substitutions + console.log(`Gen ${path.join(templateDir, "config.yaml")}`); + let hsYaml = await fse.readFile(path.join(templateDir, "config.yaml"), "utf8"); + const synapseHost = process.env.SYNAPSE_IP_ADDRESS; + const synapsePort = process.env.SYNAPSE_PORT; + const synapseAddress = `${synapseHost}:${synapsePort}`; + hsYaml = hsYaml.replace(/{{SYNAPSE_ADDRESS}}/g, synapseAddress); + const dexHost = process.env.DEX_IP_ADDRESS!; + const dexPort = parseInt(process.env.DEX_PORT!, 10); + const dexAddress = `${dexHost}:${dexPort}`; + hsYaml = hsYaml.replace(/{{DEX_ADDRESS}}/g, dexAddress); + await fse.writeFile(path.join(tempDir, "config.yaml"), hsYaml); + + const baseUrl = `http://${dexHost}:${dexPort}`; + return { + host: dexHost, + port: dexPort, + baseUrl, + configDir: tempDir, + }; +} + +export async function dexStart(): Promise { + const dexCfg = await produceConfigWithSynapseURLAdded(); + console.log(`Starting dex with config dir ${dexCfg.configDir}...`); + const dexId = await dockerRun({ + image: "bitnami/dex:latest", + containerName: "hydrogen-dex", + dockerParams: [ + "--rm", + "-v", `${dexCfg.configDir}:/data`, + `--ip=${dexCfg.host}`, + "-p", `${dexCfg.port}:5556/tcp`, + "--network=hydrogen" + ], + applicationParams: [ + "serve", + "data/config.yaml", + ] + }); + + console.log(`Started dex with id ${dexId} on port ${dexCfg.port}.`); + + const dex: DexInstance = { dexId, ...dexCfg }; + dexConfigs.set(dexId, dex); + return dex; +} + +export async function dexStop(id: string): Promise { + const dexCfg = dexConfigs.get(id); + if (!dexCfg) throw new Error("Unknown dex ID"); + await dockerStop({ containerId: id, }); + await fse.remove(dexCfg.configDir); + dexConfigs.delete(id); + console.log(`Stopped dex id ${id}.`); +} diff --git a/playwright/plugins/dex/template/config.yaml b/playwright/plugins/dex/template/config.yaml new file mode 100755 index 00000000..f773eb76 --- /dev/null +++ b/playwright/plugins/dex/template/config.yaml @@ -0,0 +1,56 @@ +issuer: http://{{DEX_ADDRESS}}/dex + +storage: + type: sqlite3 + config: + file: data/dev.db + + +# Configuration for the HTTP endpoints. +web: + http: 0.0.0.0:5556 + # Uncomment for HTTPS options. + # https: 127.0.0.1:5554 + # tlsCert: /etc/dex/tls.crt + # tlsKey: /etc/dex/tls.key + +# Configuration for telemetry +telemetry: + http: 0.0.0.0:5558 + # enableProfiling: true + +staticClients: +- id: synapse + secret: secret + redirectURIs: + - 'http://{{SYNAPSE_ADDRESS}}/_synapse/client/oidc/callback' + name: 'Synapse' +connectors: +- type: mockCallback + id: mock + name: Example +# - type: google +# id: google +# name: Google +# config: +# issuer: https://accounts.google.com +# # Connector config values starting with a "$" will read from the environment. +# clientID: $GOOGLE_CLIENT_ID +# clientSecret: $GOOGLE_CLIENT_SECRET +# redirectURI: http://127.0.0.1:5556/dex/callback +# hostedDomains: +# - $GOOGLE_HOSTED_DOMAIN + +# Let dex keep a list of passwords which can be used to login to dex. +enablePasswordDB: true + +# A static list of passwords to login the end user. By identifying here, dex +# won't look in its underlying storage for passwords. +# +# If this option isn't chosen users may be added through the gRPC API. +staticPasswords: +- email: "admin@example.com" + # bcrypt hash of the string "password": $(echo password | htpasswd -BinC 10 admin | cut -d: -f2) + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + username: "admin" + userID: "08a8684b-db88-4b73-90a9-3cd1661f5466" diff --git a/playwright/plugins/dex/template/dev.db b/playwright/plugins/dex/template/dev.db new file mode 100755 index 00000000..95a42aed Binary files /dev/null and b/playwright/plugins/dex/template/dev.db differ diff --git a/playwright/plugins/docker/index.ts b/playwright/plugins/docker/index.ts new file mode 100644 index 00000000..b0080d6c --- /dev/null +++ b/playwright/plugins/docker/index.ts @@ -0,0 +1,151 @@ +/* +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 * as os from "os"; +import * as childProcess from "child_process"; +import * as fse from "fs-extra"; + +export function dockerRun(args: { + image: string; + containerName: string; + dockerParams?: string[]; + applicationParams?: string[]; +}): Promise { + const userInfo = os.userInfo(); + const params = args.dockerParams ?? []; + const appParams = args.applicationParams ?? []; + + if (userInfo.uid >= 0) { + // On *nix we run the docker container as our uid:gid otherwise cleaning it up its media_store can be difficult + params.push("-u", `${userInfo.uid}:${userInfo.gid}`); + } + + return new Promise((resolve, reject) => { + childProcess.execFile('docker', [ + "run", + "--name", args.containerName, + "-d", + ...params, + args.image, + ... appParams + ], (err, stdout) => { + if (err) { + reject(err); + } + resolve(stdout.trim()); + }); + }); +} + +export function dockerExec(args: { + containerId: string; + params: string[]; +}): Promise { + return new Promise((resolve, reject) => { + childProcess.execFile("docker", [ + "exec", args.containerId, + ...args.params, + ], { encoding: 'utf8' }, (err, stdout, stderr) => { + if (err) { + console.log(stdout); + console.log(stderr); + reject(err); + return; + } + resolve(); + }); + }); +} + +/** + * Create a docker network; does not fail if network already exists + */ +export function dockerCreateNetwork(args: { + networkName: string; +}): Promise { + return new Promise((resolve, reject) => { + childProcess.execFile("docker", [ + "network", + "create", + args.networkName + ], { encoding: 'utf8' }, (err, stdout, stderr) => { + if(err) { + if (stderr.includes(`network with name ${args.networkName} already exists`)) { + // Don't consider this as error + resolve(); + } + reject(err); + return; + } + resolve(); + }) + }); +} + +export async function dockerLogs(args: { + containerId: string; + stdoutFile?: string; + stderrFile?: string; +}): Promise { + const stdoutFile = args.stdoutFile ? await fse.open(args.stdoutFile, "w") : "ignore"; + const stderrFile = args.stderrFile ? await fse.open(args.stderrFile, "w") : "ignore"; + + await new Promise((resolve) => { + childProcess.spawn("docker", [ + "logs", + args.containerId, + ], { + stdio: ["ignore", stdoutFile, stderrFile], + }).once('close', resolve); + }); + + if (args.stdoutFile) await fse.close(stdoutFile); + if (args.stderrFile) await fse.close(stderrFile); +} + +export function dockerStop(args: { + containerId: string; +}): Promise { + return new Promise((resolve, reject) => { + childProcess.execFile('docker', [ + "stop", + args.containerId, + ], err => { + if (err) { + reject(err); + } + resolve(); + }); + }); +} + +export function dockerRm(args: { + containerId: string; +}): Promise { + return new Promise((resolve, reject) => { + childProcess.execFile('docker', [ + "rm", + args.containerId, + ], err => { + if (err) { + reject(err); + } + resolve(); + }); + }); +} diff --git a/playwright/plugins/synapsedocker/index.ts b/playwright/plugins/synapsedocker/index.ts new file mode 100644 index 00000000..390888cd --- /dev/null +++ b/playwright/plugins/synapsedocker/index.ts @@ -0,0 +1,203 @@ +/* +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 * as path from "path"; +import * as os from "os"; +import * as crypto from "crypto"; +import * as fse from "fs-extra"; + +import {dockerCreateNetwork, dockerExec, dockerLogs, dockerRun, dockerStop} from "../docker"; +import {request} from "@playwright/test"; + + +// A cypress plugins to add command to start & stop synapses in +// docker with preset templates. + +interface SynapseConfig { + configDir: string; + registrationSecret: string; + // Synapse must be configured with its public_baseurl so we have to allocate a port & url at this stage + baseUrl: string; + port: number; + host: string; +} + +export interface SynapseInstance extends SynapseConfig { + synapseId: string; +} + +const synapses = new Map(); + +function randB64Bytes(numBytes: number): string { + return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, ""); +} + +async function cfgDirFromTemplate(template: string): Promise { + const templateDir = path.join(__dirname, "templates", template); + + const stats = await fse.stat(templateDir); + if (!stats?.isDirectory) { + throw new Error(`No such template: ${template}`); + } + const tempDir = await fse.mkdtemp(path.join(os.tmpdir(), 'synapsedocker-')); + + // copy the contents of the template dir, omitting homeserver.yaml as we'll template that + console.log(`Copy ${templateDir} -> ${tempDir}`); + await fse.copy(templateDir, tempDir, { filter: f => path.basename(f) !== 'homeserver.yaml' }); + + const registrationSecret = randB64Bytes(16); + const macaroonSecret = randB64Bytes(16); + const formSecret = randB64Bytes(16); + + const synapseHost = process.env["SYNAPSE_IP_ADDRESS"]!!; + const synapsePort = parseInt(process.env["SYNAPSE_PORT"]!, 10); + const baseUrl = `http://${synapseHost}:${synapsePort}`; + + + // now copy homeserver.yaml, applying substitutions + console.log(`Gen ${path.join(templateDir, "homeserver.yaml")}`); + let hsYaml = await fse.readFile(path.join(templateDir, "homeserver.yaml"), "utf8"); + hsYaml = hsYaml.replace(/{{REGISTRATION_SECRET}}/g, registrationSecret); + hsYaml = hsYaml.replace(/{{MACAROON_SECRET_KEY}}/g, macaroonSecret); + hsYaml = hsYaml.replace(/{{FORM_SECRET}}/g, formSecret); + hsYaml = hsYaml.replace(/{{PUBLIC_BASEURL}}/g, baseUrl); + + const dexHost = process.env["DEX_IP_ADDRESS"]; + const dexPort = process.env["DEX_PORT"]; + const dexUrl = `http://${dexHost}:${dexPort}/dex`; + hsYaml = hsYaml.replace(/{{OIDC_ISSUER}}/g, dexUrl); + await fse.writeFile(path.join(tempDir, "homeserver.yaml"), hsYaml); + + // now generate a signing key (we could use synapse's config generation for + // this, or we could just do this...) + // NB. This assumes the homeserver.yaml specifies the key in this location + const signingKey = randB64Bytes(32); + console.log(`Gen ${path.join(templateDir, "localhost.signing.key")}`); + await fse.writeFile(path.join(tempDir, "localhost.signing.key"), `ed25519 x ${signingKey}`); + + return { + port: synapsePort, + host: synapseHost, + baseUrl, + configDir: tempDir, + registrationSecret, + }; +} + +// Start a synapse instance: the template must be the name of +// one of the templates in the cypress/plugins/synapsedocker/templates +// directory +export async function synapseStart(template: string): Promise { + const synCfg = await cfgDirFromTemplate(template); + console.log(`Starting synapse with config dir ${synCfg.configDir}...`); + await dockerCreateNetwork({ networkName: "hydrogen" }); + const synapseId = await dockerRun({ + image: "matrixdotorg/synapse:develop", + containerName: `hydrogen-synapse`, + dockerParams: [ + "--rm", + "-v", `${synCfg.configDir}:/data`, + `--ip=${synCfg.host}`, + /** + * When using -p flag with --ip, the docker internal port must be used to access from the host + */ + "-p", `${synCfg.port}:8008/tcp`, + "--network=hydrogen", + ], + applicationParams: [ + "run" + ] + }); + + console.log(`Started synapse with id ${synapseId} on port ${synCfg.port}.`); + + // Await Synapse healthcheck + await dockerExec({ + containerId: synapseId, + params: [ + "curl", + "--connect-timeout", "30", + "--retry", "30", + "--retry-delay", "1", + "--retry-all-errors", + "--silent", + "http://localhost:8008/health", + ], + }); + + const synapse: SynapseInstance = { synapseId, ...synCfg }; + synapses.set(synapseId, synapse); + return synapse; +} + +export async function synapseStop(id: string): Promise { + const synCfg = synapses.get(id); + + if (!synCfg) throw new Error("Unknown synapse ID"); + + const synapseLogsPath = path.join("playwright", "synapselogs", id); + await fse.ensureDir(synapseLogsPath); + + await dockerLogs({ + containerId: id, + stdoutFile: path.join(synapseLogsPath, "stdout.log"), + stderrFile: path.join(synapseLogsPath, "stderr.log"), + }); + + await dockerStop({ + containerId: id, + }); + + await fse.remove(synCfg.configDir); + synapses.delete(id); + console.log(`Stopped synapse id ${id}.`); +} + + + +interface Credentials { + accessToken: string; + userId: string; + deviceId: string; + homeServer: string; +} + +export async function registerUser(synapse: SynapseInstance, username: string, password: string, displayName?: string,): Promise { + const url = `${synapse.baseUrl}/_synapse/admin/v1/register`; + const context = await request.newContext({ baseURL: url }); + const { nonce } = await (await context.get(url)).json(); + const mac = crypto.createHmac('sha1', synapse.registrationSecret).update( + `${nonce}\0${username}\0${password}\0notadmin`, + ).digest('hex'); + const response = await (await context.post(url, { + data: { + nonce, + username, + password, + mac, + admin: false, + displayname: displayName, + } + })).json(); + return { + homeServer: response.home_server, + accessToken: response.access_token, + userId: response.user_id, + deviceId: response.device_id, + }; +} diff --git a/playwright/plugins/synapsedocker/templates/COPYME/README.md b/playwright/plugins/synapsedocker/templates/COPYME/README.md new file mode 100644 index 00000000..df1ed89e --- /dev/null +++ b/playwright/plugins/synapsedocker/templates/COPYME/README.md @@ -0,0 +1,3 @@ +# Meta-template for synapse templates + +To make another template, you can copy this directory diff --git a/playwright/plugins/synapsedocker/templates/COPYME/homeserver.yaml b/playwright/plugins/synapsedocker/templates/COPYME/homeserver.yaml new file mode 100644 index 00000000..fab1bc1c --- /dev/null +++ b/playwright/plugins/synapsedocker/templates/COPYME/homeserver.yaml @@ -0,0 +1,72 @@ +server_name: "localhost" +pid_file: /data/homeserver.pid +# XXX: This won't actually be right: it lets docker allocate an ephemeral port, +# so we have a chicken-and-egg problem +public_baseurl: http://localhost:8008/ +# Listener is always port 8008 (configured in the container) +listeners: + - port: 8008 + tls: false + bind_addresses: ['::'] + type: http + x_forwarded: true + + resources: + - names: [client, federation, consent] + compress: false + +# An sqlite in-memory database is fast & automatically wipes each time +database: + name: "sqlite3" + args: + database: ":memory:" + +# Needs to be configured to log to the console like a good docker process +log_config: "/data/log.config" + +rc_messages_per_second: 10000 +rc_message_burst_count: 10000 +rc_registration: + per_second: 10000 + burst_count: 10000 + +rc_login: + address: + per_second: 10000 + burst_count: 10000 + account: + per_second: 10000 + burst_count: 10000 + failed_attempts: + per_second: 10000 + burst_count: 10000 + +media_store_path: "/data/media_store" +uploads_path: "/data/uploads" +enable_registration: true +enable_registration_without_verification: true +disable_msisdn_registration: false +# These placeholders will be be replaced with values generated at start +registration_shared_secret: "{{REGISTRATION_SECRET}}" +report_stats: false +macaroon_secret_key: "{{MACAROON_SECRET_KEY}}" +form_secret: "{{FORM_SECRET}}" +# Signing key must be here: it will be generated to this file +signing_key_path: "/data/localhost.signing.key" +email: + enable_notifs: false + smtp_host: "localhost" + smtp_port: 25 + smtp_user: "exampleusername" + smtp_pass: "examplepassword" + require_transport_security: False + notif_from: "Your Friendly %(app)s homeserver " + app_name: Matrix + notif_template_html: notif_mail.html + notif_template_text: notif_mail.txt + notif_for_new_users: True + client_base_url: "http://localhost/element" + +trusted_key_servers: + - server_name: "matrix.org" +suppress_key_server_warning: true diff --git a/playwright/plugins/synapsedocker/templates/COPYME/log.config b/playwright/plugins/synapsedocker/templates/COPYME/log.config new file mode 100644 index 00000000..ac232762 --- /dev/null +++ b/playwright/plugins/synapsedocker/templates/COPYME/log.config @@ -0,0 +1,50 @@ +# Log configuration for Synapse. +# +# This is a YAML file containing a standard Python logging configuration +# dictionary. See [1] for details on the valid settings. +# +# Synapse also supports structured logging for machine readable logs which can +# be ingested by ELK stacks. See [2] for details. +# +# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema +# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html + +version: 1 + +formatters: + precise: + format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' + +handlers: + # A handler that writes logs to stderr. Unused by default, but can be used + # instead of "buffer" and "file" in the logger handlers. + console: + class: logging.StreamHandler + formatter: precise + +loggers: + synapse.storage.SQL: + # beware: increasing this to DEBUG will make synapse log sensitive + # information such as access tokens. + level: INFO + + twisted: + # We send the twisted logging directly to the file handler, + # to work around https://github.com/matrix-org/synapse/issues/3471 + # when using "buffer" logger. Use "console" to log to stderr instead. + handlers: [console] + propagate: false + +root: + level: INFO + + # Write logs to the `buffer` handler, which will buffer them together in memory, + # then write them to a file. + # + # Replace "buffer" with "console" to log to stderr instead. (Note that you'll + # also need to update the configuration for the `twisted` logger above, in + # this case.) + # + handlers: [console] + +disable_existing_loggers: false diff --git a/playwright/plugins/synapsedocker/templates/consent/README.md b/playwright/plugins/synapsedocker/templates/consent/README.md new file mode 100644 index 00000000..713e55f9 --- /dev/null +++ b/playwright/plugins/synapsedocker/templates/consent/README.md @@ -0,0 +1 @@ +A synapse configured with user privacy consent enabled diff --git a/playwright/plugins/synapsedocker/templates/consent/homeserver.yaml b/playwright/plugins/synapsedocker/templates/consent/homeserver.yaml new file mode 100644 index 00000000..6decaeb5 --- /dev/null +++ b/playwright/plugins/synapsedocker/templates/consent/homeserver.yaml @@ -0,0 +1,84 @@ +server_name: "localhost" +pid_file: /data/homeserver.pid +public_baseurl: "{{PUBLIC_BASEURL}}" +listeners: + - port: 8008 + tls: false + bind_addresses: ['::'] + type: http + x_forwarded: true + + resources: + - names: [client, federation, consent] + compress: false + +database: + name: "sqlite3" + args: + database: ":memory:" + +log_config: "/data/log.config" + +rc_messages_per_second: 10000 +rc_message_burst_count: 10000 +rc_registration: + per_second: 10000 + burst_count: 10000 + +rc_login: + address: + per_second: 10000 + burst_count: 10000 + account: + per_second: 10000 + burst_count: 10000 + failed_attempts: + per_second: 10000 + burst_count: 10000 + +media_store_path: "/data/media_store" +uploads_path: "/data/uploads" +enable_registration: true +enable_registration_without_verification: true +disable_msisdn_registration: false +registration_shared_secret: "{{REGISTRATION_SECRET}}" +report_stats: false +macaroon_secret_key: "{{MACAROON_SECRET_KEY}}" +form_secret: "{{FORM_SECRET}}" +signing_key_path: "/data/localhost.signing.key" +email: + enable_notifs: false + smtp_host: "localhost" + smtp_port: 25 + smtp_user: "exampleusername" + smtp_pass: "examplepassword" + require_transport_security: False + notif_from: "Your Friendly %(app)s homeserver " + app_name: Matrix + notif_template_html: notif_mail.html + notif_template_text: notif_mail.txt + notif_for_new_users: True + client_base_url: "http://localhost/element" + +user_consent: + template_dir: /data/res/templates/privacy + version: 1.0 + server_notice_content: + msgtype: m.text + body: >- + To continue using this homeserver you must review and agree to the + terms and conditions at %(consent_uri)s + send_server_notice_to_guests: True + block_events_error: >- + To continue using this homeserver you must review and agree to the + terms and conditions at %(consent_uri)s + require_at_registration: true + +server_notices: + system_mxid_localpart: notices + system_mxid_display_name: "Server Notices" + system_mxid_avatar_url: "mxc://localhost:5005/oumMVlgDnLYFaPVkExemNVVZ" + room_name: "Server Notices" +trusted_key_servers: + - server_name: "matrix.org" +suppress_key_server_warning: true diff --git a/playwright/plugins/synapsedocker/templates/consent/log.config b/playwright/plugins/synapsedocker/templates/consent/log.config new file mode 100644 index 00000000..b9123d0f --- /dev/null +++ b/playwright/plugins/synapsedocker/templates/consent/log.config @@ -0,0 +1,50 @@ +# Log configuration for Synapse. +# +# This is a YAML file containing a standard Python logging configuration +# dictionary. See [1] for details on the valid settings. +# +# Synapse also supports structured logging for machine readable logs which can +# be ingested by ELK stacks. See [2] for details. +# +# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema +# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html + +version: 1 + +formatters: + precise: + format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' + +handlers: + # A handler that writes logs to stderr. Unused by default, but can be used + # instead of "buffer" and "file" in the logger handlers. + console: + class: logging.StreamHandler + formatter: precise + +loggers: + synapse.storage.SQL: + # beware: increasing this to DEBUG will make synapse log sensitive + # information such as access tokens. + level: DEBUG + + twisted: + # We send the twisted logging directly to the file handler, + # to work around https://github.com/matrix-org/synapse/issues/3471 + # when using "buffer" logger. Use "console" to log to stderr instead. + handlers: [console] + propagate: false + +root: + level: DEBUG + + # Write logs to the `buffer` handler, which will buffer them together in memory, + # then write them to a file. + # + # Replace "buffer" with "console" to log to stderr instead. (Note that you'll + # also need to update the configuration for the `twisted` logger above, in + # this case.) + # + handlers: [console] + +disable_existing_loggers: false diff --git a/playwright/plugins/synapsedocker/templates/consent/res/templates/privacy/en/1.0.html b/playwright/plugins/synapsedocker/templates/consent/res/templates/privacy/en/1.0.html new file mode 100644 index 00000000..d4959b4b --- /dev/null +++ b/playwright/plugins/synapsedocker/templates/consent/res/templates/privacy/en/1.0.html @@ -0,0 +1,23 @@ + + + + Test Privacy policy + + + {% if has_consented %} +

+ Thank you, you've already accepted the license. +

+ {% else %} +

+ Please accept the license! +

+
+ + + + +
+ {% endif %} + + \ No newline at end of file diff --git a/playwright/plugins/synapsedocker/templates/consent/res/templates/privacy/en/success.html b/playwright/plugins/synapsedocker/templates/consent/res/templates/privacy/en/success.html new file mode 100644 index 00000000..abe27d87 --- /dev/null +++ b/playwright/plugins/synapsedocker/templates/consent/res/templates/privacy/en/success.html @@ -0,0 +1,9 @@ + + + + Test Privacy policy + + +

Danke schon

+ + \ No newline at end of file diff --git a/playwright/plugins/synapsedocker/templates/default/README.md b/playwright/plugins/synapsedocker/templates/default/README.md new file mode 100644 index 00000000..8f6b11f9 --- /dev/null +++ b/playwright/plugins/synapsedocker/templates/default/README.md @@ -0,0 +1 @@ +A synapse configured with user privacy consent disabled diff --git a/playwright/plugins/synapsedocker/templates/default/homeserver.yaml b/playwright/plugins/synapsedocker/templates/default/homeserver.yaml new file mode 100644 index 00000000..347dadc8 --- /dev/null +++ b/playwright/plugins/synapsedocker/templates/default/homeserver.yaml @@ -0,0 +1,76 @@ +server_name: "localhost" +pid_file: /data/homeserver.pid +public_baseurl: "{{PUBLIC_BASEURL}}" +listeners: + - port: 8008 + tls: false + bind_addresses: ['::'] + type: http + x_forwarded: true + + resources: + - names: [client] + compress: false + +database: + name: "sqlite3" + args: + database: ":memory:" + +log_config: "/data/log.config" + +rc_messages_per_second: 10000 +rc_message_burst_count: 10000 +rc_registration: + per_second: 10000 + burst_count: 10000 +rc_joins: + local: + per_second: 9999 + burst_count: 9999 + remote: + per_second: 9999 + burst_count: 9999 +rc_joins_per_room: + per_second: 9999 + burst_count: 9999 +rc_3pid_validation: + per_second: 1000 + burst_count: 1000 + +rc_invites: + per_room: + per_second: 1000 + burst_count: 1000 + per_user: + per_second: 1000 + burst_count: 1000 + +rc_login: + address: + per_second: 10000 + burst_count: 10000 + account: + per_second: 10000 + burst_count: 10000 + failed_attempts: + per_second: 10000 + burst_count: 10000 + +media_store_path: "/data/media_store" +uploads_path: "/data/uploads" +enable_registration: true +enable_registration_without_verification: true +disable_msisdn_registration: false +registration_shared_secret: "{{REGISTRATION_SECRET}}" +report_stats: false +macaroon_secret_key: "{{MACAROON_SECRET_KEY}}" +form_secret: "{{FORM_SECRET}}" +signing_key_path: "/data/localhost.signing.key" + +trusted_key_servers: + - server_name: "matrix.org" +suppress_key_server_warning: true + +ui_auth: + session_timeout: "300s" diff --git a/playwright/plugins/synapsedocker/templates/default/log.config b/playwright/plugins/synapsedocker/templates/default/log.config new file mode 100644 index 00000000..b9123d0f --- /dev/null +++ b/playwright/plugins/synapsedocker/templates/default/log.config @@ -0,0 +1,50 @@ +# Log configuration for Synapse. +# +# This is a YAML file containing a standard Python logging configuration +# dictionary. See [1] for details on the valid settings. +# +# Synapse also supports structured logging for machine readable logs which can +# be ingested by ELK stacks. See [2] for details. +# +# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema +# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html + +version: 1 + +formatters: + precise: + format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' + +handlers: + # A handler that writes logs to stderr. Unused by default, but can be used + # instead of "buffer" and "file" in the logger handlers. + console: + class: logging.StreamHandler + formatter: precise + +loggers: + synapse.storage.SQL: + # beware: increasing this to DEBUG will make synapse log sensitive + # information such as access tokens. + level: DEBUG + + twisted: + # We send the twisted logging directly to the file handler, + # to work around https://github.com/matrix-org/synapse/issues/3471 + # when using "buffer" logger. Use "console" to log to stderr instead. + handlers: [console] + propagate: false + +root: + level: DEBUG + + # Write logs to the `buffer` handler, which will buffer them together in memory, + # then write them to a file. + # + # Replace "buffer" with "console" to log to stderr instead. (Note that you'll + # also need to update the configuration for the `twisted` logger above, in + # this case.) + # + handlers: [console] + +disable_existing_loggers: false diff --git a/playwright/plugins/synapsedocker/templates/sso/homeserver.yaml b/playwright/plugins/synapsedocker/templates/sso/homeserver.yaml new file mode 100644 index 00000000..10246f5c --- /dev/null +++ b/playwright/plugins/synapsedocker/templates/sso/homeserver.yaml @@ -0,0 +1,89 @@ +server_name: "localhost" +pid_file: /data/homeserver.pid +public_baseurl: "{{PUBLIC_BASEURL}}" +listeners: + - port: 8008 + tls: false + bind_addresses: ['::'] + type: http + x_forwarded: true + + resources: + - names: [client] + compress: false + +database: + name: "sqlite3" + args: + database: ":memory:" + +log_config: "/data/log.config" + +rc_messages_per_second: 10000 +rc_message_burst_count: 10000 +rc_registration: + per_second: 10000 + burst_count: 10000 +rc_joins: + local: + per_second: 9999 + burst_count: 9999 + remote: + per_second: 9999 + burst_count: 9999 +rc_joins_per_room: + per_second: 9999 + burst_count: 9999 +rc_3pid_validation: + per_second: 1000 + burst_count: 1000 + +rc_invites: + per_room: + per_second: 1000 + burst_count: 1000 + per_user: + per_second: 1000 + burst_count: 1000 + +rc_login: + address: + per_second: 10000 + burst_count: 10000 + account: + per_second: 10000 + burst_count: 10000 + failed_attempts: + per_second: 10000 + burst_count: 10000 + +media_store_path: "/data/media_store" +uploads_path: "/data/uploads" +enable_registration: true +enable_registration_without_verification: true +disable_msisdn_registration: false +registration_shared_secret: "{{REGISTRATION_SECRET}}" +report_stats: false +macaroon_secret_key: "{{MACAROON_SECRET_KEY}}" +form_secret: "{{FORM_SECRET}}" +signing_key_path: "/data/localhost.signing.key" + +trusted_key_servers: + - server_name: "matrix.org" +suppress_key_server_warning: true + +ui_auth: + session_timeout: "300s" + +oidc_providers: + - idp_id: dex + idp_name: "My Dex server" + skip_verification: true # This is needed as Dex is served on an insecure endpoint + issuer: "{{OIDC_ISSUER}}" + client_id: "synapse" + client_secret: "secret" + scopes: ["openid", "profile"] + user_mapping_provider: + config: + localpart_template: "{{ user.name }}" + display_name_template: "{{ user.name|capitalize }}" diff --git a/playwright/plugins/synapsedocker/templates/sso/log.config b/playwright/plugins/synapsedocker/templates/sso/log.config new file mode 100644 index 00000000..b9123d0f --- /dev/null +++ b/playwright/plugins/synapsedocker/templates/sso/log.config @@ -0,0 +1,50 @@ +# Log configuration for Synapse. +# +# This is a YAML file containing a standard Python logging configuration +# dictionary. See [1] for details on the valid settings. +# +# Synapse also supports structured logging for machine readable logs which can +# be ingested by ELK stacks. See [2] for details. +# +# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema +# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html + +version: 1 + +formatters: + precise: + format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' + +handlers: + # A handler that writes logs to stderr. Unused by default, but can be used + # instead of "buffer" and "file" in the logger handlers. + console: + class: logging.StreamHandler + formatter: precise + +loggers: + synapse.storage.SQL: + # beware: increasing this to DEBUG will make synapse log sensitive + # information such as access tokens. + level: DEBUG + + twisted: + # We send the twisted logging directly to the file handler, + # to work around https://github.com/matrix-org/synapse/issues/3471 + # when using "buffer" logger. Use "console" to log to stderr instead. + handlers: [console] + propagate: false + +root: + level: DEBUG + + # Write logs to the `buffer` handler, which will buffer them together in memory, + # then write them to a file. + # + # Replace "buffer" with "console" to log to stderr instead. (Note that you'll + # also need to update the configuration for the `twisted` logger above, in + # this case.) + # + handlers: [console] + +disable_existing_loggers: false diff --git a/playwright/tests/login.spec.ts b/playwright/tests/login.spec.ts new file mode 100644 index 00000000..a5f30476 --- /dev/null +++ b/playwright/tests/login.spec.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 {test} from '@playwright/test'; +import {synapseStart, synapseStop, registerUser} from "../plugins/synapsedocker"; +import {dexStart, dexStop} from "../plugins/dex"; +import type {DexInstance} from "../plugins/dex"; +import type {SynapseInstance} from "../plugins/synapsedocker"; + +test.describe("Login", () => { + let synapse: SynapseInstance; + let dex: DexInstance; + + test.beforeEach(async () => { + dex = await dexStart(); + synapse = await synapseStart("sso"); + }); + + test.afterEach(async () => { + await synapseStop(synapse.synapseId); + await dexStop(dex.dexId); + }); + + test("Login using username/password", async ({ page }) => { + const username = "foobaraccount"; + const password = "password123"; + await registerUser(synapse, username, password); + await page.goto("/"); + await page.locator("#homeserver").fill(""); + await page.locator("#homeserver").type(synapse.baseUrl); + await page.locator("#username").type(username); + await page.locator("#password").type(password); + await page.getByText('Log In', { exact: true }).click(); + await page.locator(".SessionView").waitFor(); + }); + + test("Login using SSO", async ({ page }) => { + await page.goto("/"); + await page.locator("#homeserver").fill(""); + await page.locator("#homeserver").type(synapse.baseUrl); + await page.locator(".StartSSOLoginView_button").click(); + await page.getByText("Log in with Example").click(); + await page.locator(".dex-btn-text", {hasText: "Grant Access"}).click(); + await page.locator(".primary-button", {hasText: "Continue"}).click(); + await page.locator(".SessionView").waitFor(); + }); +}); diff --git a/playwright/tests/startup.spec.ts b/playwright/tests/startup.spec.ts new file mode 100644 index 00000000..562859d7 --- /dev/null +++ b/playwright/tests/startup.spec.ts @@ -0,0 +1,21 @@ +/* +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 {test} from '@playwright/test'; + +test("App has no startup errors that prevent UI render", async ({ page }) => { + await page.goto("/"); + await page.getByText("Log In", { exact: true }).waitFor(); +}); diff --git a/scripts/build-plugins/rollup-plugin-build-themes.js b/scripts/build-plugins/rollup-plugin-build-themes.js index 43a21623..d159f1db 100644 --- a/scripts/build-plugins/rollup-plugin-build-themes.js +++ b/scripts/build-plugins/rollup-plugin-build-themes.js @@ -13,11 +13,17 @@ 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. */ -const path = require('path').posix; +// Use the path implementation native to the platform so paths from disk play +// well with resolving against the relative location (think Windows `C:\` and +// backslashes). +const path = require('path'); +// Use the posix (forward slash) implementation when working with `import` paths +// to reference resources +const posixPath = require('path').posix; +const {optimize} = require('svgo'); async function readCSSSource(location) { const fs = require("fs").promises; - const path = require("path"); const resolvedLocation = path.resolve(__dirname, "../../", `${location}/theme.css`); const data = await fs.readFile(resolvedLocation); return data; @@ -43,29 +49,54 @@ function addThemesToConfig(bundle, manifestLocations, defaultThemes) { } } -function parseBundle(bundle) { +/** + * Returns an object where keys are the svg file names and the values + * are the svg code (optimized) + * @param {*} icons Object where keys are css variable names and values are locations of the svg + * @param {*} manifestLocation Location of manifest used for resolving path + */ +async function generateIconSourceMap(icons, manifestLocation) { + const sources = {}; + const fileNames = []; + const promises = []; + const fs = require("fs").promises; + for (const icon of Object.values(icons)) { + const [location] = icon.split("?"); + // resolve location against manifestLocation + const resolvedLocation = path.resolve(manifestLocation, location); + const iconData = fs.readFile(resolvedLocation); + promises.push(iconData); + const fileName = path.basename(resolvedLocation); + fileNames.push(fileName); + } + const results = await Promise.all(promises); + for (let i = 0; i < results.length; ++i) { + const svgString = results[i].toString(); + const result = optimize(svgString, { + plugins: [ + { + name: "preset-default", + params: { + overrides: { convertColors: false, }, + }, + }, + ], + }); + const optimizedSvgString = result.data; + sources[fileNames[i]] = optimizedSvgString; + } + return sources; +} + +/** + * Returns a mapping from location (of manifest file) to an array containing all the chunks (of css files) generated from that location. + * To understand what chunk means in this context, see https://rollupjs.org/guide/en/#generatebundle. + * @param {*} bundle Mapping from fileName to AssetInfo | ChunkInfo + */ +function getMappingFromLocationToChunkArray(bundle) { const chunkMap = new Map(); - const assetMap = new Map(); - let runtimeThemeChunk; for (const [fileName, info] of Object.entries(bundle)) { - if (!fileName.endsWith(".css")) { - continue; - } - if (info.type === "asset") { - /** - * So this is the css assetInfo that contains the asset hashed file name. - * We'll store it in a separate map indexed via fileName (unhashed) to avoid - * searching through the bundle array later. - */ - assetMap.set(info.name, info); - continue; - } - if (info.facadeModuleId?.includes("type=runtime")) { - /** - * We have a separate field in manifest.source just for the runtime theme, - * so store this separately. - */ - runtimeThemeChunk = info; + if (!fileName.endsWith(".css") || info.type === "asset" || info.facadeModuleId?.includes("type=runtime")) { continue; } const location = info.facadeModuleId?.match(/(.+)\/.+\.css/)?.[1]; @@ -80,7 +111,56 @@ function parseBundle(bundle) { array.push(info); } } - return { chunkMap, assetMap, runtimeThemeChunk }; + return chunkMap; +} + +/** + * Returns a mapping from unhashed file name (of css files) to AssetInfo. + * To understand what AssetInfo means in this context, see https://rollupjs.org/guide/en/#generatebundle. + * @param {*} bundle Mapping from fileName to AssetInfo | ChunkInfo + */ +function getMappingFromFileNameToAssetInfo(bundle) { + const assetMap = new Map(); + for (const [fileName, info] of Object.entries(bundle)) { + if (!fileName.endsWith(".css")) { + continue; + } + if (info.type === "asset") { + /** + * So this is the css assetInfo that contains the asset hashed file name. + * We'll store it in a separate map indexed via fileName (unhashed) to avoid + * searching through the bundle array later. + */ + assetMap.set(info.name, info); + } + } + return assetMap; +} + +/** + * Returns a mapping from location (of manifest file) to ChunkInfo of the runtime css asset + * To understand what ChunkInfo means in this context, see https://rollupjs.org/guide/en/#generatebundle. + * @param {*} bundle Mapping from fileName to AssetInfo | ChunkInfo + */ +function getMappingFromLocationToRuntimeChunk(bundle) { + let runtimeThemeChunkMap = new Map(); + for (const [fileName, info] of Object.entries(bundle)) { + if (!fileName.endsWith(".css") || info.type === "asset") { + continue; + } + const location = info.facadeModuleId?.match(/(.+)\/.+\.css/)?.[1]; + if (!location) { + throw new Error("Cannot find location of css chunk!"); + } + if (info.facadeModuleId?.includes("type=runtime")) { + /** + * We have a separate field in manifest.source just for the runtime theme, + * so store this separately. + */ + runtimeThemeChunkMap.set(location, info); + } + } + return runtimeThemeChunkMap; } module.exports = function buildThemes(options) { @@ -88,6 +168,7 @@ module.exports = function buildThemes(options) { let isDevelopment = false; const virtualModuleId = '@theme/' const resolvedVirtualModuleId = '\0' + virtualModuleId; + const themeToManifestLocation = new Map(); return { name: "build-themes", @@ -100,37 +181,34 @@ module.exports = function buildThemes(options) { }, async buildStart() { - if (isDevelopment) { return; } const { themeConfig } = options; - for (const [name, location] of Object.entries(themeConfig.themes)) { + for (const location of themeConfig.themes) { manifest = require(`${location}/manifest.json`); + const themeCollectionId = manifest.id; + themeToManifestLocation.set(themeCollectionId, location); variants = manifest.values.variants; for (const [variant, details] of Object.entries(variants)) { - const fileName = `theme-${name}-${variant}.css`; - if (name === themeConfig.default && details.default) { + const fileName = `theme-${themeCollectionId}-${variant}.css`; + if (themeCollectionId === themeConfig.default && details.default) { // This is the default theme, stash the file name for later if (details.dark) { defaultDark = fileName; - defaultThemes["dark"] = `${name}-${variant}`; + defaultThemes["dark"] = `${themeCollectionId}-${variant}`; } else { defaultLight = fileName; - defaultThemes["light"] = `${name}-${variant}`; + defaultThemes["light"] = `${themeCollectionId}-${variant}`; } } // emit the css as built theme bundle - this.emitFile({ - type: "chunk", - id: `${location}/theme.css?variant=${variant}${details.dark? "&dark=true": ""}`, - fileName, - }); + if (!isDevelopment) { + this.emitFile({ type: "chunk", id: `${location}/theme.css?variant=${variant}${details.dark ? "&dark=true" : ""}`, fileName, }); + } } // emit the css as runtime theme bundle - this.emitFile({ - type: "chunk", - id: `${location}/theme.css?type=runtime`, - fileName: `theme-${name}-runtime.css`, - }); + if (!isDevelopment) { + this.emitFile({ type: "chunk", id: `${location}/theme.css?type=runtime`, fileName: `theme-${themeCollectionId}-runtime.css`, }); + } } }, @@ -152,7 +230,7 @@ module.exports = function buildThemes(options) { if (theme === "default") { theme = options.themeConfig.default; } - const location = options.themeConfig.themes[theme]; + const location = themeToManifestLocation.get(theme); const manifest = require(`${location}/manifest.json`); const variants = manifest.values.variants; if (!variant || variant === "default") { @@ -166,7 +244,7 @@ module.exports = function buildThemes(options) { switch (file) { case "index.js": { const isDark = variants[variant].dark; - return `import "${path.resolve(`${location}/theme.css`)}${isDark? "?dark=true": ""}";` + + return `import "${posixPath.resolve(`${location}/theme.css`)}${isDark? "?dark=true": ""}";` + `import "@theme/${theme}/${variant}/variables.css"`; } case "variables.css": { @@ -245,30 +323,53 @@ module.exports = function buildThemes(options) { ]; }, - generateBundle(_, bundle) { - // assetMap: Mapping from asset-name (eg: element-dark.css) to AssetInfo - // chunkMap: Mapping from theme-location (eg: hydrogen-web/src/.../css/themes/element) to a list of ChunkInfo - // types of AssetInfo and ChunkInfo can be found at https://rollupjs.org/guide/en/#generatebundle - const { assetMap, chunkMap, runtimeThemeChunk } = parseBundle(bundle); + async generateBundle(_, bundle) { + const assetMap = getMappingFromFileNameToAssetInfo(bundle); + const chunkMap = getMappingFromLocationToChunkArray(bundle); + const runtimeThemeChunkMap = getMappingFromLocationToRuntimeChunk(bundle); const manifestLocations = []; + // Location of the directory containing manifest relative to the root of the build output + const manifestLocation = "assets"; for (const [location, chunkArray] of chunkMap) { const manifest = require(`${location}/manifest.json`); const compiledVariables = options.compiledVariables.get(location); const derivedVariables = compiledVariables["derived-variables"]; const icon = compiledVariables["icon"]; const builtAssets = {}; + let themeKey; for (const chunk of chunkArray) { const [, name, variant] = chunk.fileName.match(/theme-(.+)-(.+)\.css/); - builtAssets[`${name}-${variant}`] = assetMap.get(chunk.fileName).fileName; + themeKey = name; + const locationRelativeToBuildRoot = assetMap.get(chunk.fileName).fileName; + const locationRelativeToManifest = path.relative(manifestLocation, locationRelativeToBuildRoot); + builtAssets[`${name}-${variant}`] = locationRelativeToManifest; } + // Emit the base svg icons as asset + const nameToAssetHashedLocation = []; + const nameToSource = await generateIconSourceMap(icon, location); + for (const [name, source] of Object.entries(nameToSource)) { + const ref = this.emitFile({ type: "asset", name, source }); + const assetHashedName = this.getFileName(ref); + nameToAssetHashedLocation[name] = assetHashedName; + } + // Update icon section in output manifest with paths to the icon in build output + for (const [variable, location] of Object.entries(icon)) { + const [locationWithoutQueryParameters, queryParameters] = location.split("?"); + const name = path.basename(locationWithoutQueryParameters); + const locationRelativeToBuildRoot = nameToAssetHashedLocation[name]; + const locationRelativeToManifest = path.relative(manifestLocation, locationRelativeToBuildRoot); + icon[variable] = `${locationRelativeToManifest}?${queryParameters}`; + } + const runtimeThemeChunk = runtimeThemeChunkMap.get(location); + const runtimeAssetLocation = path.relative(manifestLocation, assetMap.get(runtimeThemeChunk.fileName).fileName); manifest.source = { "built-assets": builtAssets, - "runtime-asset": assetMap.get(runtimeThemeChunk.fileName).fileName, + "runtime-asset": runtimeAssetLocation, "derived-variables": derivedVariables, - "icon": icon + "icon": icon, }; - const name = `theme-${manifest.name}.json`; - manifestLocations.push(`assets/${name}`); + const name = `theme-${themeKey}.json`; + manifestLocations.push(`${manifestLocation}/${name}`); this.emitFile({ type: "asset", name, diff --git a/scripts/postcss/css-compile-variables.js b/scripts/postcss/css-compile-variables.js index 63aef97f..80aedf60 100644 --- a/scripts/postcss/css-compile-variables.js +++ b/scripts/postcss/css-compile-variables.js @@ -112,7 +112,14 @@ function populateMapWithDerivedVariables(map, cssFileLocation, {resolvedMap, ali ...([...resolvedMap.keys()].filter(v => !aliasMap.has(v))), ...([...aliasMap.entries()].map(([alias, variable]) => `${alias}=${variable}`)) ]; - map.set(location, { "derived-variables": derivedVariables }); + const sharedObject = map.get(location); + const output = { "derived-variables": derivedVariables }; + if (sharedObject) { + Object.assign(sharedObject, output); + } + else { + map.set(location, output); + } } /** diff --git a/scripts/postcss/css-url-to-variables.js b/scripts/postcss/css-url-to-variables.js index 9334032b..e4c4e1d1 100644 --- a/scripts/postcss/css-url-to-variables.js +++ b/scripts/postcss/css-url-to-variables.js @@ -61,7 +61,13 @@ function addResolvedVariablesToRootSelector(root, { Rule, Declaration }, urlVari function populateMapWithIcons(map, cssFileLocation, urlVariables) { const location = cssFileLocation.match(/(.+)\/.+\.css/)?.[1]; const sharedObject = map.get(location); - sharedObject["icon"] = Object.fromEntries(urlVariables); + const output = {"icon": Object.fromEntries(urlVariables)}; + if (sharedObject) { + Object.assign(sharedObject, output); + } + else { + map.set(location, output); + } } function *createCounter() { @@ -81,7 +87,8 @@ module.exports = (opts = {}) => { const urlVariables = new Map(); const counter = createCounter(); root.walkDecls(decl => findAndReplaceUrl(decl, urlVariables, counter)); - if (urlVariables.size) { + const cssFileLocation = root.source.input.from; + if (urlVariables.size && !cssFileLocation.includes("type=runtime")) { addResolvedVariablesToRootSelector(root, { Rule, Declaration }, urlVariables); } if (opts.compiledVariables){ diff --git a/scripts/postcss/svg-colorizer.js b/scripts/postcss/svg-builder.mjs similarity index 61% rename from scripts/postcss/svg-colorizer.js rename to scripts/postcss/svg-builder.mjs index 06b7b14b..cbfd3637 100644 --- a/scripts/postcss/svg-colorizer.js +++ b/scripts/postcss/svg-builder.mjs @@ -14,12 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -const fs = require("fs"); -const path = require("path"); -const xxhash = require('xxhashjs'); +import {readFileSync, mkdirSync, writeFileSync} from "fs"; +import {resolve} from "path"; +import {h32} from "xxhashjs"; +import {getColoredSvgString} from "../../src/platform/web/theming/shared/svg-colorizer.mjs"; function createHash(content) { - const hasher = new xxhash.h32(0); + const hasher = new h32(0); hasher.update(content); return hasher.digest(); } @@ -30,18 +31,14 @@ function createHash(content) { * @param {string} primaryColor Primary color for the new svg * @param {string} secondaryColor Secondary color for the new svg */ -module.exports.buildColorizedSVG = function (svgLocation, primaryColor, secondaryColor) { - const svgCode = fs.readFileSync(svgLocation, { encoding: "utf8"}); - let coloredSVGCode = svgCode.replaceAll("#ff00ff", primaryColor); - coloredSVGCode = coloredSVGCode.replaceAll("#00ffff", secondaryColor); - if (svgCode === coloredSVGCode) { - throw new Error("svg-colorizer made no color replacements! The input svg should only contain colors #ff00ff (primary, case-sensitive) and #00ffff (secondary, case-sensitive)."); - } +export function buildColorizedSVG(svgLocation, primaryColor, secondaryColor) { + const svgCode = readFileSync(svgLocation, { encoding: "utf8"}); + const coloredSVGCode = getColoredSvgString(svgCode, primaryColor, secondaryColor); const fileName = svgLocation.match(/.+[/\\](.+\.svg)/)[1]; const outputName = `${fileName.substring(0, fileName.length - 4)}-${createHash(coloredSVGCode)}.svg`; - const outputPath = path.resolve(__dirname, "../../.tmp"); + const outputPath = resolve(__dirname, "./.tmp"); try { - fs.mkdirSync(outputPath); + mkdirSync(outputPath); } catch (e) { if (e.code !== "EEXIST") { @@ -49,6 +46,6 @@ module.exports.buildColorizedSVG = function (svgLocation, primaryColor, secondar } } const outputFile = `${outputPath}/${outputName}`; - fs.writeFileSync(outputFile, coloredSVGCode); + writeFileSync(outputFile, coloredSVGCode); return outputFile; } diff --git a/scripts/sdk/base-manifest.json b/scripts/sdk/base-manifest.json index a62888a7..92df93c5 100644 --- a/scripts/sdk/base-manifest.json +++ b/scripts/sdk/base-manifest.json @@ -1,7 +1,7 @@ { "name": "hydrogen-view-sdk", "description": "Embeddable matrix client library, including view components", - "version": "0.0.13", + "version": "0.1.1", "main": "./lib-build/hydrogen.cjs.js", "exports": { ".": { diff --git a/scripts/test-app.sh b/scripts/test-app.sh new file mode 100755 index 00000000..cfbd37a3 --- /dev/null +++ b/scripts/test-app.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Make sure docker is available +if ! docker info > /dev/null 2>&1; then + echo "You need to intall docker before you can run the tests!" + exit 1 +fi + +# Stop running containers +if docker stop hydrogen-synapse > /dev/null 2>&1; then + echo "Existing 'hydrogen-synapse' container stopped ✔" +fi + +if docker stop hydrogen-dex > /dev/null 2>&1; then + echo "Existing 'hydrogen-dex' container stopped ✔" +fi + +# Run playwright +yarn playwright test diff --git a/scripts/test-derived-theme/test-theme.sh b/scripts/test-derived-theme/test-theme.sh new file mode 100755 index 00000000..6ac9b128 --- /dev/null +++ b/scripts/test-derived-theme/test-theme.sh @@ -0,0 +1,5 @@ +#!/bin/sh +cp scripts/test-derived-theme/theme.json target/assets/theme-customer.json +cat target/config.json | jq '.themeManifests += ["assets/theme-customer.json"]' | cat > target/config.temp.json +rm target/config.json +mv target/config.temp.json target/config.json diff --git a/scripts/test-derived-theme/theme.json b/scripts/test-derived-theme/theme.json new file mode 100644 index 00000000..ea7492d4 --- /dev/null +++ b/scripts/test-derived-theme/theme.json @@ -0,0 +1,51 @@ +{ + "name": "Customer", + "extends": "element", + "id": "customer", + "values": { + "variants": { + "dark": { + "dark": true, + "default": true, + "name": "Dark", + "variables": { + "background-color-primary": "#21262b", + "background-color-secondary": "#2D3239", + "text-color": "#fff", + "accent-color": "#F03F5B", + "error-color": "#FF4B55", + "fixed-white": "#fff", + "room-badge": "#61708b", + "link-color": "#238cf5" + } + }, + "light": { + "default": true, + "name": "Dark", + "variables": { + "background-color-primary": "#21262b", + "background-color-secondary": "#2D3239", + "text-color": "#fff", + "accent-color": "#F03F5B", + "error-color": "#FF4B55", + "fixed-white": "#fff", + "room-badge": "#61708b", + "link-color": "#238cf5" + } + }, + "red": { + "name": "Gruvbox", + "variables": { + "background-color-primary": "#282828", + "background-color-secondary": "#3c3836", + "text-color": "#fbf1c7", + "accent-color": "#8ec07c", + "error-color": "#fb4934", + "fixed-white": "#fff", + "room-badge": "#cc241d", + "link-color": "#fe8019" + } + } + } + } +} diff --git a/src/domain/ErrorReportViewModel.ts b/src/domain/ErrorReportViewModel.ts index da46b2df..37ddd1c1 100644 --- a/src/domain/ErrorReportViewModel.ts +++ b/src/domain/ErrorReportViewModel.ts @@ -20,15 +20,15 @@ import type { Session } from "../matrix/Session"; import { ErrorViewModel } from "./ErrorViewModel"; import type { LogCallback, LabelOrValues } from "../logging/types"; -export type Options = BaseOptions & { +export type Options = BaseOptions & { session: Session }; /** Base class for view models that need to report errors to the UI. */ -export class ErrorReportViewModel extends ViewModel { - private _errorViewModel?: ErrorViewModel; +export class ErrorReportViewModel = Options> extends ViewModel { + private _errorViewModel?: ErrorViewModel; - get errorViewModel(): ErrorViewModel | undefined { + get errorViewModel(): ErrorViewModel | undefined { return this._errorViewModel; } diff --git a/src/domain/ErrorViewModel.ts b/src/domain/ErrorViewModel.ts index 51a34565..b73254ba 100644 --- a/src/domain/ErrorViewModel.ts +++ b/src/domain/ErrorViewModel.ts @@ -17,16 +17,17 @@ 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 = { +type Options = { error: Error session: Session, onClose: () => void -} & BaseOptions; +} & BaseOptions; -export class ErrorViewModel extends ViewModel { +export class ErrorViewModel = Options> extends ViewModel { get message(): string { - return this.getOption("error")?.message; + return this.error.message; } get error(): Error { @@ -45,4 +46,4 @@ export class ErrorViewModel extends ViewModel { return false; } } -} \ No newline at end of file +} diff --git a/src/domain/ForcedLogoutViewModel.ts b/src/domain/ForcedLogoutViewModel.ts new file mode 100644 index 00000000..19b581ed --- /dev/null +++ b/src/domain/ForcedLogoutViewModel.ts @@ -0,0 +1,81 @@ +/* +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 {Options as BaseOptions, ViewModel} from "./ViewModel"; +import {Client} from "../matrix/Client.js"; +import {SegmentType} from "./navigation/index"; + +type Options = { sessionId: string; } & BaseOptions; + +export class ForcedLogoutViewModel extends ViewModel { + private _sessionId: string; + private _error?: Error; + private _logoutPromise: Promise; + private _showStatus: boolean = false; + private _showSpinner: boolean = false; + + constructor(options: Options) { + super(options); + this._sessionId = options.sessionId; + // Start the logout process immediately without any user interaction + this._logoutPromise = this.forceLogout(); + } + + async forceLogout(): Promise { + try { + const client = new Client(this.platform); + await client.startForcedLogout(this._sessionId); + } + catch (err) { + this._error = err; + // Show the error in the UI + this._showSpinner = false; + this._showStatus = true; + this.emitChange("error"); + } + } + + async proceed(): Promise { + /** + * The logout should already be completed because we started it from the ctor. + * In case the logout is still proceeding, we will show a message with a spinner. + */ + this._showSpinner = true; + this._showStatus = true; + this.emitChange("showStatus"); + await this._logoutPromise; + // At this point, the logout is completed for sure. + if (!this._error) { + this.navigation.push("login", true); + } + } + + get status(): string { + if (this._error) { + return this.i18n`Could not log out of device: ${this._error.message}`; + } else { + return this.i18n`Logging out… Please don't close the app.`; + } + } + + get showStatus(): boolean { + return this._showStatus; + } + + get showSpinner(): boolean { + return this._showSpinner; + } +} diff --git a/src/domain/LogoutViewModel.ts b/src/domain/LogoutViewModel.ts index 3edfcad5..49933f21 100644 --- a/src/domain/LogoutViewModel.ts +++ b/src/domain/LogoutViewModel.ts @@ -14,18 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Options, ViewModel} from "./ViewModel"; +import {Options as BaseOptions, ViewModel} from "./ViewModel"; import {Client} from "../matrix/Client.js"; +import {SegmentType} from "./navigation/index"; -type LogoutOptions = { sessionId: string; } & Options; +type Options = { sessionId: string; } & BaseOptions; -export class LogoutViewModel extends ViewModel { +export class LogoutViewModel extends ViewModel { private _sessionId: string; private _busy: boolean; private _showConfirm: boolean; private _error?: Error; - constructor(options: LogoutOptions) { + constructor(options: Options) { super(options); this._sessionId = options.sessionId; this._busy = false; @@ -41,8 +42,8 @@ export class LogoutViewModel extends ViewModel { return this._busy; } - get cancelUrl(): string { - return this.urlCreator.urlForSegment("session", true); + get cancelUrl(): string | undefined { + return this.urlRouter.urlForSegment("session", true); } async logout(): Promise { diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 2711cd2f..524dfe13 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -17,8 +17,9 @@ limitations under the License. import {Client} from "../matrix/Client.js"; import {SessionViewModel} from "./session/SessionViewModel.js"; import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; -import {LoginViewModel} from "./login/LoginViewModel.js"; +import {LoginViewModel} from "./login/LoginViewModel"; import {LogoutViewModel} from "./LogoutViewModel"; +import {ForcedLogoutViewModel} from "./ForcedLogoutViewModel"; import {SessionPickerViewModel} from "./SessionPickerViewModel.js"; import {ViewModel} from "./ViewModel"; @@ -30,6 +31,7 @@ export class RootViewModel extends ViewModel { this._sessionLoadViewModel = null; this._loginViewModel = null; this._logoutViewModel = null; + this._forcedLogoutViewModel = null; this._sessionViewModel = null; this._pendingClient = null; } @@ -38,18 +40,24 @@ export class RootViewModel extends ViewModel { this.track(this.navigation.observe("login").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("sso").subscribe(() => this._applyNavigation())); + this.track(this.navigation.observe("logout").subscribe(() => this._applyNavigation())); this._applyNavigation(true); } async _applyNavigation(shouldRestoreLastUrl) { const isLogin = this.navigation.path.get("login"); const logoutSessionId = this.navigation.path.get("logout")?.value; + const isForcedLogout = this.navigation.path.get("forced")?.value; const sessionId = this.navigation.path.get("session")?.value; const loginToken = this.navigation.path.get("sso")?.value; if (isLogin) { if (this.activeSection !== "login") { this._showLogin(); } + } else if (logoutSessionId && isForcedLogout) { + if (this.activeSection !== "forced-logout") { + this._showForcedLogout(logoutSessionId); + } } else if (logoutSessionId) { if (this.activeSection !== "logout") { this._showLogout(logoutSessionId); @@ -75,14 +83,14 @@ export class RootViewModel extends ViewModel { } } } else if (loginToken) { - this.urlCreator.normalizeUrl(); + this.urlRouter.normalizeUrl(); if (this.activeSection !== "login") { this._showLogin(loginToken); } } else { try { - if (!(shouldRestoreLastUrl && this.urlCreator.tryRestoreLastUrl())) { + if (!(shouldRestoreLastUrl && this.urlRouter.tryRestoreLastUrl())) { const sessionInfos = await this.platform.sessionInfoStorage.getAll(); if (sessionInfos.length === 0) { this.navigation.push("login"); @@ -118,7 +126,7 @@ export class RootViewModel extends ViewModel { // but we also want the change of screen to go through the navigation // so we store the session container in a temporary variable that will be // consumed by _applyNavigation, triggered by the navigation change - // + // // Also, we should not call _setSection before the navigation is in the correct state, // as url creation (e.g. in RoomTileViewModel) // won't be using the correct navigation base path. @@ -136,6 +144,12 @@ export class RootViewModel extends ViewModel { }); } + _showForcedLogout(sessionId) { + this._setSection(() => { + this._forcedLogoutViewModel = new ForcedLogoutViewModel(this.childOptions({sessionId})); + }); + } + _showSession(client) { this._setSection(() => { this._sessionViewModel = new SessionViewModel(this.childOptions({client})); @@ -164,6 +178,8 @@ export class RootViewModel extends ViewModel { return "login"; } else if (this._logoutViewModel) { return "logout"; + } else if (this._forcedLogoutViewModel) { + return "forced-logout"; } else if (this._sessionPickerViewModel) { return "picker"; } else if (this._sessionLoadViewModel) { @@ -180,6 +196,7 @@ export class RootViewModel extends ViewModel { this._sessionLoadViewModel = this.disposeTracked(this._sessionLoadViewModel); this._loginViewModel = this.disposeTracked(this._loginViewModel); this._logoutViewModel = this.disposeTracked(this._logoutViewModel); + this._forcedLogoutViewModel = this.disposeTracked(this._forcedLogoutViewModel); this._sessionViewModel = this.disposeTracked(this._sessionViewModel); // now set it again setter(); @@ -187,6 +204,7 @@ export class RootViewModel extends ViewModel { this._sessionLoadViewModel && this.track(this._sessionLoadViewModel); this._loginViewModel && this.track(this._loginViewModel); this._logoutViewModel && this.track(this._logoutViewModel); + this._forcedLogoutViewModel && this.track(this._forcedLogoutViewModel); this._sessionViewModel && this.track(this._sessionViewModel); this.emitChange("activeSection"); } @@ -195,6 +213,7 @@ export class RootViewModel extends ViewModel { get sessionViewModel() { return this._sessionViewModel; } get loginViewModel() { return this._loginViewModel; } get logoutViewModel() { return this._logoutViewModel; } + get forcedLogoutViewModel() { return this._forcedLogoutViewModel; } get sessionPickerViewModel() { return this._sessionPickerViewModel; } get sessionLoadViewModel() { return this._sessionLoadViewModel; } } diff --git a/src/domain/SessionLoadViewModel.js b/src/domain/SessionLoadViewModel.js index abc16299..6a63145f 100644 --- a/src/domain/SessionLoadViewModel.js +++ b/src/domain/SessionLoadViewModel.js @@ -29,7 +29,7 @@ export class SessionLoadViewModel extends ViewModel { this._deleteSessionOnCancel = deleteSessionOnCancel; this._loading = false; this._error = null; - this.backUrl = this.urlCreator.urlForSegment("session", true); + this.backUrl = this.urlRouter.urlForSegment("session", true); this._accountSetupViewModel = undefined; } @@ -154,7 +154,7 @@ export class SessionLoadViewModel extends ViewModel { } async logout() { - await this._client.logout(); + await this._client.startLogout(this.navigation.path.get("session").value); this.navigation.push("session", true); } diff --git a/src/domain/SessionPickerViewModel.js b/src/domain/SessionPickerViewModel.js index f65fd2e6..97b29f39 100644 --- a/src/domain/SessionPickerViewModel.js +++ b/src/domain/SessionPickerViewModel.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {SortedArray} from "../observable/index"; +import {SortedArray} from "../observable"; import {ViewModel} from "./ViewModel"; import {avatarInitials, getIdentifierColorNumber} from "./avatar"; @@ -38,7 +38,7 @@ class SessionItemViewModel extends ViewModel { } get openUrl() { - return this.urlCreator.urlForSegment("session", this.id); + return this.urlRouter.urlForSegment("session", this.id); } get label() { @@ -94,6 +94,6 @@ export class SessionPickerViewModel extends ViewModel { } get cancelUrl() { - return this.urlCreator.urlForSegment("login"); + return this.urlRouter.urlForSegment("login"); } } diff --git a/src/domain/ViewModel.ts b/src/domain/ViewModel.ts index 257624ea..34a05855 100644 --- a/src/domain/ViewModel.ts +++ b/src/domain/ViewModel.ts @@ -27,17 +27,20 @@ import type {Platform} from "../platform/web/Platform"; import type {Clock} from "../platform/web/dom/Clock"; import type {ILogger} from "../logging/types"; import type {Navigation} from "./navigation/Navigation"; -import type {URLRouter} from "./navigation/URLRouter"; +import type {SegmentType} from "./navigation/index"; +import type {IURLRouter} from "./navigation/URLRouter"; +import type { ITimeFormatter } from "../platform/types/types"; -export type Options = { - platform: Platform - logger: ILogger - urlCreator: URLRouter - navigation: Navigation - emitChange?: (params: any) => void +export type Options = { + platform: Platform; + logger: ILogger; + urlRouter: IURLRouter; + navigation: Navigation; + emitChange?: (params: any) => void; } -export class ViewModel extends EventEmitter<{change: never}> { + +export class ViewModel = Options> extends EventEmitter<{change: never}> { private disposables?: Disposables; private _isDisposed = false; private _options: Readonly; @@ -58,11 +61,11 @@ export class ViewModel extends EventEmitter<{change return this._options[name]; } - observeNavigation(type: string, onChange: (value: string | true | undefined, type: string) => void) { + observeNavigation(type: T, onChange: (value: N[T], type: T) => void): void { const segmentObservable = this.navigation.observe(type); - const unsubscribe = segmentObservable.subscribe((value: string | true | undefined) => { + const unsubscribe = segmentObservable.subscribe((value: N[T]) => { onChange(value, type); - }) + }); this.track(unsubscribe); } @@ -100,10 +103,10 @@ export class ViewModel extends EventEmitter<{change // TODO: this will need to support binding // if any of the expr is a function, assume the function is a binding, and return a binding function ourselves - // + // // translated string should probably always be bindings, unless we're fine with a refresh when changing the language? // we probably are, if we're using routing with a url, we could just refresh. - i18n(parts: TemplateStringsArray, ...expr: any[]) { + i18n(parts: TemplateStringsArray, ...expr: any[]): string { // just concat for now let result = ""; for (let i = 0; i < parts.length; ++i) { @@ -135,11 +138,16 @@ export class ViewModel extends EventEmitter<{change return this.platform.logger; } - get urlCreator(): URLRouter { - return this._options.urlCreator; + get urlRouter(): IURLRouter { + return this._options.urlRouter; } - get navigation(): Navigation { - return this._options.navigation; + 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/login/CompleteSSOLoginViewModel.js b/src/domain/login/CompleteSSOLoginViewModel.ts similarity index 72% rename from src/domain/login/CompleteSSOLoginViewModel.js rename to src/domain/login/CompleteSSOLoginViewModel.ts index d41d53ec..b7be4642 100644 --- a/src/domain/login/CompleteSSOLoginViewModel.js +++ b/src/domain/login/CompleteSSOLoginViewModel.ts @@ -14,11 +14,24 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../ViewModel"; +import {Options as BaseOptions, ViewModel} from "../ViewModel"; import {LoginFailure} from "../../matrix/Client.js"; +import type {TokenLoginMethod} from "../../matrix/login"; +import { Client } from "../../matrix/Client.js"; + +type Options = { + client: Client; + attemptLogin: (loginMethod: TokenLoginMethod) => Promise; + loginToken: string; +} & BaseOptions export class CompleteSSOLoginViewModel extends ViewModel { - constructor(options) { + private _loginToken: string; + private _client: Client; + private _attemptLogin: (loginMethod: TokenLoginMethod) => Promise; + private _errorMessage = ""; + + constructor(options: Options) { super(options); const { loginToken, @@ -29,22 +42,22 @@ export class CompleteSSOLoginViewModel extends ViewModel { this._client = client; this._attemptLogin = attemptLogin; this._errorMessage = ""; - this.performSSOLoginCompletion(); + void this.performSSOLoginCompletion(); } - get errorMessage() { return this._errorMessage; } + get errorMessage(): string { return this._errorMessage; } - _showError(message) { + _showError(message: string): void { this._errorMessage = message; this.emitChange("errorMessage"); } - async performSSOLoginCompletion() { + async performSSOLoginCompletion(): Promise { if (!this._loginToken) { return; } const homeserver = await this.platform.settingsStorage.getString("sso_ongoing_login_homeserver"); - let loginOptions; + let loginOptions: { token?: (loginToken: string) => TokenLoginMethod; }; try { loginOptions = await this._client.queryLogin(homeserver).result; } diff --git a/src/domain/login/LoginViewModel.js b/src/domain/login/LoginViewModel.ts similarity index 64% rename from src/domain/login/LoginViewModel.js rename to src/domain/login/LoginViewModel.ts index bf77e624..75d57880 100644 --- a/src/domain/login/LoginViewModel.js +++ b/src/domain/login/LoginViewModel.ts @@ -15,101 +15,145 @@ limitations under the License. */ import {Client} from "../../matrix/Client.js"; -import {ViewModel} from "../ViewModel"; -import {PasswordLoginViewModel} from "./PasswordLoginViewModel.js"; -import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js"; -import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js"; +import {Options as BaseOptions, ViewModel} from "../ViewModel"; +import {PasswordLoginViewModel} from "./PasswordLoginViewModel"; +import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel"; +import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel"; import {LoadStatus} from "../../matrix/Client.js"; import {SessionLoadViewModel} from "../SessionLoadViewModel.js"; +import {SegmentType} from "../navigation/index"; -export class LoginViewModel extends ViewModel { - constructor(options) { +import type {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod} from "../../matrix/login"; + +type Options = { + defaultHomeserver: string; + ready: ReadyFn; + loginToken?: string; +} & BaseOptions; + +export class LoginViewModel extends ViewModel { + private _ready: ReadyFn; + private _loginToken?: string; + private _client: Client; + private _loginOptions?: LoginOptions; + private _passwordLoginViewModel?: PasswordLoginViewModel; + private _startSSOLoginViewModel?: StartSSOLoginViewModel; + private _completeSSOLoginViewModel?: CompleteSSOLoginViewModel; + private _loadViewModel?: SessionLoadViewModel; + private _loadViewModelSubscription?: () => void; + private _homeserver: string; + private _queriedHomeserver?: string; + private _abortHomeserverQueryTimeout?: () => void; + private _abortQueryOperation?: () => void; + + private _hideHomeserver: boolean = false; + private _isBusy: boolean = false; + private _errorMessage: string = ""; + + constructor(options: Readonly) { super(options); const {ready, defaultHomeserver, loginToken} = options; this._ready = ready; this._loginToken = loginToken; this._client = new Client(this.platform); - this._loginOptions = null; - this._passwordLoginViewModel = null; - this._startSSOLoginViewModel = null; - this._completeSSOLoginViewModel = null; - this._loadViewModel = null; - this._loadViewModelSubscription = null; this._homeserver = defaultHomeserver; - this._queriedHomeserver = null; - this._errorMessage = ""; - this._hideHomeserver = false; - this._isBusy = false; - this._abortHomeserverQueryTimeout = null; - this._abortQueryOperation = null; this._initViewModels(); } - get passwordLoginViewModel() { return this._passwordLoginViewModel; } - get startSSOLoginViewModel() { return this._startSSOLoginViewModel; } - get completeSSOLoginViewModel(){ return this._completeSSOLoginViewModel; } - get homeserver() { return this._homeserver; } - get resolvedHomeserver() { return this._loginOptions?.homeserver; } - get errorMessage() { return this._errorMessage; } - get showHomeserver() { return !this._hideHomeserver; } - get loadViewModel() {return this._loadViewModel; } - get isBusy() { return this._isBusy; } - get isFetchingLoginOptions() { return !!this._abortQueryOperation; } + get passwordLoginViewModel(): PasswordLoginViewModel | undefined { + return this._passwordLoginViewModel; + } - goBack() { + get startSSOLoginViewModel(): StartSSOLoginViewModel | undefined { + return this._startSSOLoginViewModel; + } + + get completeSSOLoginViewModel(): CompleteSSOLoginViewModel | undefined { + return this._completeSSOLoginViewModel; + } + + get homeserver(): string { + return this._homeserver; + } + + get resolvedHomeserver(): string | undefined { + return this._loginOptions?.homeserver; + } + + get errorMessage(): string { + return this._errorMessage; + } + + get showHomeserver(): boolean { + return !this._hideHomeserver; + } + + get loadViewModel(): SessionLoadViewModel { + return this._loadViewModel; + } + + get isBusy(): boolean { + return this._isBusy; + } + + get isFetchingLoginOptions(): boolean { + return !!this._abortQueryOperation; + } + + goBack(): void { this.navigation.push("session"); } - async _initViewModels() { + private _initViewModels(): void { if (this._loginToken) { this._hideHomeserver = true; this._completeSSOLoginViewModel = this.track(new CompleteSSOLoginViewModel( this.childOptions( { client: this._client, - attemptLogin: loginMethod => this.attemptLogin(loginMethod), + attemptLogin: (loginMethod: TokenLoginMethod) => this.attemptLogin(loginMethod), loginToken: this._loginToken }))); this.emitChange("completeSSOLoginViewModel"); } else { - await this.queryHomeserver(); + void this.queryHomeserver(); } } - _showPasswordLogin() { + private _showPasswordLogin(): void { this._passwordLoginViewModel = this.track(new PasswordLoginViewModel( this.childOptions({ loginOptions: this._loginOptions, - attemptLogin: loginMethod => this.attemptLogin(loginMethod) + attemptLogin: (loginMethod: PasswordLoginMethod) => this.attemptLogin(loginMethod) }))); this.emitChange("passwordLoginViewModel"); } - _showSSOLogin() { + private _showSSOLogin(): void { this._startSSOLoginViewModel = this.track( new StartSSOLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) ); this.emitChange("startSSOLoginViewModel"); } - _showError(message) { + private _showError(message: string): void { this._errorMessage = message; this.emitChange("errorMessage"); } - _setBusy(status) { + private _setBusy(status: boolean): void { this._isBusy = status; this._passwordLoginViewModel?.setBusy(status); this._startSSOLoginViewModel?.setBusy(status); this.emitChange("isBusy"); } - async attemptLogin(loginMethod) { + async attemptLogin(loginMethod: ILoginMethod): Promise { this._setBusy(true); - this._client.startWithLogin(loginMethod, {inspectAccountSetup: true}); + void this._client.startWithLogin(loginMethod, {inspectAccountSetup: true}); const loadStatus = this._client.loadStatus; - const handle = loadStatus.waitFor(status => status !== LoadStatus.Login); + const handle = loadStatus.waitFor((status: LoadStatus) => status !== LoadStatus.Login); await handle.promise; this._setBusy(false); const status = loadStatus.get(); @@ -119,11 +163,11 @@ export class LoginViewModel extends ViewModel { this._hideHomeserver = true; this.emitChange("hideHomeserver"); this._disposeViewModels(); - this._createLoadViewModel(); + void this._createLoadViewModel(); return null; } - _createLoadViewModel() { + private _createLoadViewModel(): void { this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); this._loadViewModel = this.disposeTracked(this._loadViewModel); this._loadViewModel = this.track( @@ -139,7 +183,7 @@ export class LoginViewModel extends ViewModel { }) ) ); - this._loadViewModel.start(); + void this._loadViewModel.start(); this.emitChange("loadViewModel"); this._loadViewModelSubscription = this.track( this._loadViewModel.disposableOn("change", () => { @@ -151,22 +195,22 @@ export class LoginViewModel extends ViewModel { ); } - _disposeViewModels() { - this._startSSOLoginViewModel = this.disposeTracked(this._ssoLoginViewModel); + private _disposeViewModels(): void { + this._startSSOLoginViewModel = this.disposeTracked(this._startSSOLoginViewModel); this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel); this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel); this.emitChange("disposeViewModels"); } - async setHomeserver(newHomeserver) { + async setHomeserver(newHomeserver: string): Promise { this._homeserver = newHomeserver; // clear everything set by queryHomeserver - this._loginOptions = null; - this._queriedHomeserver = null; + this._loginOptions = undefined; + this._queriedHomeserver = undefined; this._showError(""); this._disposeViewModels(); this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); - this.emitChange(); // multiple fields changing + this.emitChange("loginViewModels"); // multiple fields changing // also clear the timeout if it is still running this.disposeTracked(this._abortHomeserverQueryTimeout); const timeout = this.clock.createTimeout(1000); @@ -181,10 +225,10 @@ export class LoginViewModel extends ViewModel { } } this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout); - this.queryHomeserver(); + void this.queryHomeserver(); } - - async queryHomeserver() { + + async queryHomeserver(): Promise { // don't repeat a query we've just done if (this._homeserver === this._queriedHomeserver || this._homeserver === "") { return; @@ -210,7 +254,7 @@ export class LoginViewModel extends ViewModel { if (e.name === "AbortError") { return; //aborted, bail out } else { - this._loginOptions = null; + this._loginOptions = undefined; } } finally { this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); @@ -221,19 +265,29 @@ export class LoginViewModel extends ViewModel { if (this._loginOptions.password) { this._showPasswordLogin(); } if (!this._loginOptions.sso && !this._loginOptions.password) { this._showError("This homeserver supports neither SSO nor password based login flows"); - } + } } else { this._showError(`Could not query login methods supported by ${this.homeserver}`); } } - dispose() { + dispose(): void { super.dispose(); if (this._client) { // if we move away before we're done with initial sync // delete the session - this._client.deleteSession(); + void this._client.deleteSession(); } } } + +type ReadyFn = (client: Client) => void; + +// TODO: move to Client.js when its converted to typescript. +export type LoginOptions = { + homeserver: string; + password?: (username: string, password: string) => PasswordLoginMethod; + sso?: SSOLoginHelper; + token?: (loginToken: string) => TokenLoginMethod; +}; diff --git a/src/domain/login/PasswordLoginViewModel.js b/src/domain/login/PasswordLoginViewModel.ts similarity index 66% rename from src/domain/login/PasswordLoginViewModel.js rename to src/domain/login/PasswordLoginViewModel.ts index 7c4ff78a..6b4d9978 100644 --- a/src/domain/login/PasswordLoginViewModel.js +++ b/src/domain/login/PasswordLoginViewModel.ts @@ -14,43 +14,53 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../ViewModel"; import {LoginFailure} from "../../matrix/Client.js"; +import type {PasswordLoginMethod} from "../../matrix/login"; +import {Options as BaseOptions, ViewModel} from "../ViewModel"; +import type {LoginOptions} from "./LoginViewModel"; + +type Options = { + loginOptions: LoginOptions | undefined; + attemptLogin: (loginMethod: PasswordLoginMethod) => Promise; +} & BaseOptions export class PasswordLoginViewModel extends ViewModel { - constructor(options) { + private _loginOptions?: LoginOptions; + private _attemptLogin: (loginMethod: PasswordLoginMethod) => Promise; + private _isBusy = false; + private _errorMessage = ""; + + constructor(options: Options) { super(options); const {loginOptions, attemptLogin} = options; this._loginOptions = loginOptions; this._attemptLogin = attemptLogin; - this._isBusy = false; - this._errorMessage = ""; } - get isBusy() { return this._isBusy; } - get errorMessage() { return this._errorMessage; } + get isBusy(): boolean { return this._isBusy; } + get errorMessage(): string { return this._errorMessage; } - setBusy(status) { + setBusy(status: boolean): void { this._isBusy = status; this.emitChange("isBusy"); } - _showError(message) { + _showError(message: string): void { this._errorMessage = message; this.emitChange("errorMessage"); } - async login(username, password) { + async login(username: string, password: string): Promise{ this._errorMessage = ""; this.emitChange("errorMessage"); - const status = await this._attemptLogin(this._loginOptions.password(username, password)); + const status = await this._attemptLogin(this._loginOptions!.password!(username, password)); let error = ""; switch (status) { case LoginFailure.Credentials: error = this.i18n`Your username and/or password don't seem to be correct.`; break; case LoginFailure.Connection: - error = this.i18n`Can't connect to ${this._loginOptions.homeserver}.`; + error = this.i18n`Can't connect to ${this._loginOptions!.homeserver}.`; break; case LoginFailure.Unknown: error = this.i18n`Something went wrong while checking your login and password.`; diff --git a/src/domain/login/StartSSOLoginViewModel.js b/src/domain/login/StartSSOLoginViewModel.ts similarity index 56% rename from src/domain/login/StartSSOLoginViewModel.js rename to src/domain/login/StartSSOLoginViewModel.ts index dba0bcb5..0a20249b 100644 --- a/src/domain/login/StartSSOLoginViewModel.js +++ b/src/domain/login/StartSSOLoginViewModel.ts @@ -14,25 +14,35 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ViewModel} from "../ViewModel"; +import type {SSOLoginHelper} from "../../matrix/login"; +import {Options as BaseOptions, ViewModel} from "../ViewModel"; +import type {LoginOptions} from "./LoginViewModel"; + + +type Options = { + loginOptions: LoginOptions | undefined; +} & BaseOptions; export class StartSSOLoginViewModel extends ViewModel{ - constructor(options) { + private _sso?: SSOLoginHelper; + private _isBusy = false; + + constructor(options: Options) { super(options); - this._sso = options.loginOptions.sso; + this._sso = options.loginOptions!.sso; this._isBusy = false; } - - get isBusy() { return this._isBusy; } - - setBusy(status) { + + get isBusy(): boolean { return this._isBusy; } + + setBusy(status: boolean): void { this._isBusy = status; this.emitChange("isBusy"); } - async startSSOLogin() { - await this.platform.settingsStorage.setString("sso_ongoing_login_homeserver", this._sso.homeserver); - const link = this._sso.createSSORedirectURL(this.urlCreator.createSSOCallbackURL()); + async startSSOLogin(): Promise { + await this.platform.settingsStorage.setString("sso_ongoing_login_homeserver", this._sso!.homeserver); + const link = this._sso!.createSSORedirectURL(this.urlRouter.createSSOCallbackURL()); this.platform.openUrl(link); } } diff --git a/src/domain/navigation/Navigation.js b/src/domain/navigation/Navigation.ts similarity index 65% rename from src/domain/navigation/Navigation.js rename to src/domain/navigation/Navigation.ts index c2b2b54c..ee27cb4c 100644 --- a/src/domain/navigation/Navigation.js +++ b/src/domain/navigation/Navigation.ts @@ -14,30 +14,50 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ObservableValue} from "../../observable/value/ObservableValue"; -import {BaseObservableValue} from "../../observable/value/BaseObservableValue"; +import {BaseObservableValue, ObservableValue} from "../../observable/value"; -export class Navigation { - constructor(allowsChild) { +type AllowsChild = (parent: Segment | undefined, child: Segment) => boolean; + +/** + * OptionalValue is basically stating that if SegmentType[type] = true: + * - Allow this type to be optional + * - Give it a default value of undefined + * - Also allow it to be true + * This lets us do: + * const s: Segment = new Segment("create-room"); + * instead of + * const s: Segment = new Segment("create-room", undefined); + */ +export type OptionalValue = T extends true? [(undefined | true)?]: [T]; + +export class Navigation { + private readonly _allowsChild: AllowsChild; + private _path: Path; + private readonly _observables: Map> = new Map(); + private readonly _pathObservable: ObservableValue>; + + constructor(allowsChild: AllowsChild) { this._allowsChild = allowsChild; this._path = new Path([], allowsChild); - this._observables = new Map(); this._pathObservable = new ObservableValue(this._path); } - get pathObservable() { + get pathObservable(): ObservableValue> { return this._pathObservable; } - get path() { + get path(): Path { return this._path; } - push(type, value = undefined) { - return this.applyPath(this.path.with(new Segment(type, value))); + push(type: K, ...value: OptionalValue): void { + const newPath = this.path.with(new Segment(type, ...value)); + if (newPath) { + this.applyPath(newPath); + } } - applyPath(path) { + applyPath(path: Path): void { // Path is not exported, so you can only create a Path through Navigation, // so we assume it respects the allowsChild rules const oldPath = this._path; @@ -61,7 +81,7 @@ export class Navigation { this._pathObservable.set(this._path); } - observe(type) { + observe(type: keyof T): SegmentObservable { let observable = this._observables.get(type); if (!observable) { observable = new SegmentObservable(this, type); @@ -70,9 +90,9 @@ export class Navigation { return observable; } - pathFrom(segments) { - let parent; - let i; + pathFrom(segments: Segment[]): Path { + let parent: Segment | undefined; + let i: number; for (i = 0; i < segments.length; i += 1) { if (!this._allowsChild(parent, segments[i])) { return new Path(segments.slice(0, i), this._allowsChild); @@ -82,12 +102,12 @@ export class Navigation { return new Path(segments, this._allowsChild); } - segment(type, value) { - return new Segment(type, value); + segment(type: K, ...value: OptionalValue): Segment { + return new Segment(type, ...value); } } -function segmentValueEqual(a, b) { +function segmentValueEqual(a?: T[keyof T], b?: T[keyof T]): boolean { if (a === b) { return true; } @@ -104,24 +124,29 @@ function segmentValueEqual(a, b) { return false; } -export class Segment { - constructor(type, value) { - this.type = type; - this.value = value === undefined ? true : value; + +export class Segment { + public value: T[K]; + + constructor(public type: K, ...value: OptionalValue) { + this.value = (value[0] === undefined ? true : value[0]) as unknown as T[K]; } } -class Path { - constructor(segments = [], allowsChild) { +class Path { + private readonly _segments: Segment[]; + private readonly _allowsChild: AllowsChild; + + constructor(segments: Segment[] = [], allowsChild: AllowsChild) { this._segments = segments; this._allowsChild = allowsChild; } - clone() { + clone(): Path { return new Path(this._segments.slice(), this._allowsChild); } - with(segment) { + with(segment: Segment): Path | undefined { let index = this._segments.length - 1; do { if (this._allowsChild(this._segments[index], segment)) { @@ -133,10 +158,10 @@ class Path { index -= 1; } while(index >= -1); // allow -1 as well so we check if the segment is allowed as root - return null; + return undefined; } - until(type) { + until(type: keyof T): Path { const index = this._segments.findIndex(s => s.type === type); if (index !== -1) { return new Path(this._segments.slice(0, index + 1), this._allowsChild) @@ -144,11 +169,11 @@ class Path { return new Path([], this._allowsChild); } - get(type) { + get(type: keyof T): Segment | undefined { return this._segments.find(s => s.type === type); } - replace(segment) { + replace(segment: Segment): Path | undefined { const index = this._segments.findIndex(s => s.type === segment.type); if (index !== -1) { const parent = this._segments[index - 1]; @@ -161,10 +186,10 @@ class Path { } } } - return null; + return undefined; } - get segments() { + get segments(): Segment[] { return this._segments; } } @@ -173,43 +198,49 @@ class Path { * custom observable so it always returns what is in navigation.path, even if we haven't emitted the change yet. * This ensures that observers of a segment can also read the most recent value of other segments. */ -class SegmentObservable extends BaseObservableValue { - constructor(navigation, type) { +class SegmentObservable extends BaseObservableValue { + private readonly _navigation: Navigation; + private _type: keyof T; + private _lastSetValue?: T[keyof T]; + + constructor(navigation: Navigation, type: keyof T) { super(); this._navigation = navigation; this._type = type; this._lastSetValue = navigation.path.get(type)?.value; } - get() { + get(): T[keyof T] | undefined { const path = this._navigation.path; const segment = path.get(this._type); const value = segment?.value; return value; } - emitIfChanged() { + emitIfChanged(): void { const newValue = this.get(); - if (!segmentValueEqual(newValue, this._lastSetValue)) { + if (!segmentValueEqual(newValue, this._lastSetValue)) { this._lastSetValue = newValue; this.emit(newValue); } } } +export type {Path}; + export function tests() { function createMockNavigation() { return new Navigation((parent, {type}) => { switch (parent?.type) { case undefined: - return type === "1" || "2"; + return type === "1" || type === "2"; case "1": return type === "1.1"; case "1.1": return type === "1.1.1"; case "2": - return type === "2.1" || "2.2"; + return type === "2.1" || type === "2.2"; default: return false; } @@ -217,7 +248,7 @@ export function tests() { } function observeTypes(nav, types) { - const changes = []; + const changes: {type:string, value:any}[] = []; for (const type of types) { nav.observe(type).subscribe(value => { changes.push({type, value}); @@ -226,6 +257,12 @@ export function tests() { return changes; } + type SegmentType = { + "foo": number; + "bar": number; + "baz": number; + } + return { "applying a path emits an event on the observable": assert => { const nav = createMockNavigation(); @@ -243,18 +280,18 @@ export function tests() { assert.equal(changes[1].value, 8); }, "path.get": assert => { - const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); - assert.equal(path.get("foo").value, 5); - assert.equal(path.get("bar").value, 6); + const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); + assert.equal(path.get("foo")!.value, 5); + assert.equal(path.get("bar")!.value, 6); }, "path.replace success": assert => { - const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); + const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); const newPath = path.replace(new Segment("foo", 1)); - assert.equal(newPath.get("foo").value, 1); - assert.equal(newPath.get("bar").value, 6); + assert.equal(newPath!.get("foo")!.value, 1); + assert.equal(newPath!.get("bar")!.value, 6); }, "path.replace not found": assert => { - const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); + const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); const newPath = path.replace(new Segment("baz", 1)); assert.equal(newPath, null); } diff --git a/src/domain/navigation/URLRouter.js b/src/domain/navigation/URLRouter.ts similarity index 57% rename from src/domain/navigation/URLRouter.js rename to src/domain/navigation/URLRouter.ts index 586eec8a..23503530 100644 --- a/src/domain/navigation/URLRouter.js +++ b/src/domain/navigation/URLRouter.ts @@ -14,28 +14,55 @@ See the License for the specific language governing permissions and limitations under the License. */ -export class URLRouter { - constructor({history, navigation, parseUrlPath, stringifyPath}) { +import type {History} from "../../platform/web/dom/History.js"; +import type {Navigation, Segment, Path, OptionalValue} from "./Navigation"; +import type {SubscriptionHandle} from "../../observable/BaseObservable"; + +type ParseURLPath = (urlPath: string, currentNavPath: Path, defaultSessionId?: string) => Segment[]; +type StringifyPath = (path: Path) => string; + +export interface IURLRouter { + attach(): void; + dispose(): void; + pushUrl(url: string): void; + tryRestoreLastUrl(): boolean; + urlForSegments(segments: Segment[]): string | undefined; + urlForSegment(type: K, ...value: OptionalValue): string | undefined; + urlUntilSegment(type: keyof T): string; + urlForPath(path: Path): string; + openRoomActionUrl(roomId: string): string; + createSSOCallbackURL(): string; + normalizeUrl(): void; +} + +export class URLRouter implements IURLRouter { + private readonly _history: History; + private readonly _navigation: Navigation; + private readonly _parseUrlPath: ParseURLPath; + private readonly _stringifyPath: StringifyPath; + private _subscription?: SubscriptionHandle; + private _pathSubscription?: SubscriptionHandle; + private _isApplyingUrl: boolean = false; + private _defaultSessionId?: string; + + constructor(history: History, navigation: Navigation, parseUrlPath: ParseURLPath, stringifyPath: StringifyPath) { this._history = history; this._navigation = navigation; this._parseUrlPath = parseUrlPath; this._stringifyPath = stringifyPath; - this._subscription = null; - this._pathSubscription = null; - this._isApplyingUrl = false; this._defaultSessionId = this._getLastSessionId(); } - _getLastSessionId() { - const navPath = this._urlAsNavPath(this._history.getLastUrl() || ""); + private _getLastSessionId(): string | undefined { + const navPath = this._urlAsNavPath(this._history.getLastSessionUrl() || ""); const sessionId = navPath.get("session")?.value; if (typeof sessionId === "string") { return sessionId; } - return null; + return undefined; } - attach() { + attach(): void { this._subscription = this._history.subscribe(url => this._applyUrl(url)); // subscribe to path before applying initial url // so redirects in _applyNavPathToHistory are reflected in url bar @@ -43,12 +70,12 @@ export class URLRouter { this._applyUrl(this._history.get()); } - dispose() { - this._subscription = this._subscription(); - this._pathSubscription = this._pathSubscription(); + dispose(): void { + if (this._subscription) { this._subscription = this._subscription(); } + if (this._pathSubscription) { this._pathSubscription = this._pathSubscription(); } } - _applyNavPathToHistory(path) { + private _applyNavPathToHistory(path: Path): void { const url = this.urlForPath(path); if (url !== this._history.get()) { if (this._isApplyingUrl) { @@ -60,7 +87,7 @@ export class URLRouter { } } - _applyNavPathToNavigation(navPath) { + private _applyNavPathToNavigation(navPath: Path): void { // this will cause _applyNavPathToHistory to be called, // so set a flag whether this request came from ourselves // (in which case it is a redirect if the url does not match the current one) @@ -69,22 +96,22 @@ export class URLRouter { this._isApplyingUrl = false; } - _urlAsNavPath(url) { + private _urlAsNavPath(url: string): Path { const urlPath = this._history.urlAsPath(url); return this._navigation.pathFrom(this._parseUrlPath(urlPath, this._navigation.path, this._defaultSessionId)); } - _applyUrl(url) { + private _applyUrl(url: string): void { const navPath = this._urlAsNavPath(url); this._applyNavPathToNavigation(navPath); } - pushUrl(url) { + pushUrl(url: string): void { this._history.pushUrl(url); } - tryRestoreLastUrl() { - const lastNavPath = this._urlAsNavPath(this._history.getLastUrl() || ""); + tryRestoreLastUrl(): boolean { + const lastNavPath = this._urlAsNavPath(this._history.getLastSessionUrl() || ""); if (lastNavPath.segments.length !== 0) { this._applyNavPathToNavigation(lastNavPath); return true; @@ -92,8 +119,8 @@ export class URLRouter { return false; } - urlForSegments(segments) { - let path = this._navigation.path; + urlForSegments(segments: Segment[]): string | undefined { + let path: Path | undefined = this._navigation.path; for (const segment of segments) { path = path.with(segment); if (!path) { @@ -103,29 +130,29 @@ export class URLRouter { return this.urlForPath(path); } - urlForSegment(type, value) { - return this.urlForSegments([this._navigation.segment(type, value)]); + urlForSegment(type: K, ...value: OptionalValue): string | undefined { + return this.urlForSegments([this._navigation.segment(type, ...value)]); } - urlUntilSegment(type) { + urlUntilSegment(type: keyof T): string { return this.urlForPath(this._navigation.path.until(type)); } - urlForPath(path) { + urlForPath(path: Path): string { return this._history.pathAsUrl(this._stringifyPath(path)); } - openRoomActionUrl(roomId) { + 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); } - createSSOCallbackURL() { + createSSOCallbackURL(): string { return window.location.origin; } - normalizeUrl() { + normalizeUrl(): void { // Remove any queryParameters from the URL // Gets rid of the loginToken after SSO this._history.replaceUrlSilently(`${window.location.origin}/${window.location.hash}`); diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.ts similarity index 68% rename from src/domain/navigation/index.js rename to src/domain/navigation/index.ts index 086367ce..a2705944 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.ts @@ -14,25 +14,44 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Navigation, Segment} from "./Navigation.js"; -import {URLRouter} from "./URLRouter.js"; +import {Navigation, Segment} from "./Navigation"; +import {URLRouter} from "./URLRouter"; +import type {Path, OptionalValue} from "./Navigation"; -export function createNavigation() { +export type SegmentType = { + "login": true; + "session": string | boolean; + "sso": string; + "logout": true; + "forced": true; + "room": string; + "rooms": string[]; + "settings": true; + "create-room": true; + "empty-grid-tile": number; + "lightbox": string; + "right-panel": true; + "details": true; + "members": true; + "member": string; +}; + +export function createNavigation(): Navigation { return new Navigation(allowsChild); } -export function createRouter({history, navigation}) { - return new URLRouter({history, navigation, stringifyPath, parseUrlPath}); +export function createRouter({history, navigation}: {history: History, navigation: Navigation}): URLRouter { + return new URLRouter(history, navigation, parseUrlPath, stringifyPath); } -function allowsChild(parent, child) { +function allowsChild(parent: Segment | undefined, child: Segment): boolean { const {type} = child; switch (parent?.type) { case undefined: // allowed root segments - return type === "login" || type === "session" || type === "sso" || type === "logout"; + return type === "login" || type === "session" || type === "sso" || type === "logout"; case "session": - return type === "room" || type === "rooms" || type === "settings" || type === "create-room"; + return type === "room" || type === "rooms" || type === "settings" || type === "create-room" || type === "join-room"; case "rooms": // downside of the approach: both of these will control which tile is selected return type === "room" || type === "empty-grid-tile"; @@ -40,13 +59,16 @@ function allowsChild(parent, child) { return type === "lightbox" || type === "right-panel"; case "right-panel": return type === "details"|| type === "members" || type === "member"; + case "logout": + return type === "forced"; default: return false; } } -export function removeRoomFromPath(path, roomId) { - const rooms = path.get("rooms"); +export function removeRoomFromPath(path: Path, roomId: string): Path | undefined { + let newPath: Path | undefined = path; + const rooms = newPath.get("rooms"); let roomIdGridIndex = -1; // first delete from rooms segment if (rooms) { @@ -54,22 +76,22 @@ export function removeRoomFromPath(path, roomId) { if (roomIdGridIndex !== -1) { const idsWithoutRoom = rooms.value.slice(); idsWithoutRoom[roomIdGridIndex] = ""; - path = path.replace(new Segment("rooms", idsWithoutRoom)); + newPath = newPath.replace(new Segment("rooms", idsWithoutRoom)); } } - const room = path.get("room"); + const room = newPath!.get("room"); // then from room (which occurs with or without rooms) if (room && room.value === roomId) { if (roomIdGridIndex !== -1) { - path = path.with(new Segment("empty-grid-tile", roomIdGridIndex)); + newPath = newPath!.with(new Segment("empty-grid-tile", roomIdGridIndex)); } else { - path = path.until("session"); + newPath = newPath!.until("session"); } } - return path; + return newPath; } -function roomsSegmentWithRoom(rooms, roomId, path) { +function roomsSegmentWithRoom(rooms: Segment, roomId: string, path: Path): Segment { if(!rooms.value.includes(roomId)) { const emptyGridTile = path.get("empty-grid-tile"); const oldRoom = path.get("room"); @@ -87,35 +109,35 @@ function roomsSegmentWithRoom(rooms, roomId, path) { } } -function pushRightPanelSegment(array, segment, value = true) { +function pushRightPanelSegment(array: Segment[], segment: T, ...value: OptionalValue): void { array.push(new Segment("right-panel")); - array.push(new Segment(segment, value)); + array.push(new Segment(segment, ...value)); } -export function addPanelIfNeeded(navigation, path) { +export function addPanelIfNeeded(navigation: Navigation, path: Path): Path { const segments = navigation.path.segments; const i = segments.findIndex(segment => segment.type === "right-panel"); let _path = path; if (i !== -1) { _path = path.until("room"); - _path = _path.with(segments[i]); - _path = _path.with(segments[i + 1]); + _path = _path.with(segments[i])!; + _path = _path.with(segments[i + 1])!; } return _path; } -export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) { - // substr(1) to take of initial / - const parts = urlPath.substr(1).split("/"); +export function parseUrlPath(urlPath: string, currentNavPath: Path, defaultSessionId?: string): Segment[] { + // substring(1) to take of initial / + const parts = urlPath.substring(1).split("/"); const iterator = parts[Symbol.iterator](); - const segments = []; + const segments: Segment[] = []; let next; while (!(next = iterator.next()).done) { const type = next.value; 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]; @@ -125,8 +147,9 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) { 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)); @@ -154,8 +177,9 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) { } 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= @@ -163,30 +187,35 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) { 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)); } } return segments; } -export function stringifyPath(path) { +export function stringifyPath(path: Path): string { let urlPath = ""; - let prevSegment; + 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": @@ -195,8 +224,8 @@ export function stringifyPath(path) { continue; default: urlPath += `/${segment.type}`; - if (segment.value && segment.value !== true) { - urlPath += `/${segment.value}`; + if (encodedSegmentValue) { + urlPath += `/${encodedSegmentValue}`; } } prevSegment = segment; @@ -204,10 +233,29 @@ export function stringifyPath(path) { 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); + const path = nav.pathFrom([]); + return path; + } + return { "stringify grid url with focused empty tile": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("rooms", ["a", "b", "c"]), @@ -217,7 +265,7 @@ export function tests() { assert.equal(urlPath, "/session/1/rooms/a,b,c/3"); }, "stringify grid url with focused room": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("rooms", ["a", "b", "c"]), @@ -227,7 +275,7 @@ export function tests() { assert.equal(urlPath, "/session/1/rooms/a,b,c/1"); }, "stringify url with right-panel and details segment": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("rooms", ["a", "b", "c"]), @@ -239,13 +287,15 @@ export function tests() { assert.equal(urlPath, "/session/1/rooms/a,b,c/1/details"); }, "Parse loginToken query parameter into SSO segment": assert => { - const segments = parseUrlPath("?loginToken=a1232aSD123"); + const path = createEmptyPath(); + const segments = parseUrlPath("?loginToken=a1232aSD123", path); assert.equal(segments.length, 1); assert.equal(segments[0].type, "sso"); assert.equal(segments[0].value, "a1232aSD123"); }, "parse grid url path with focused empty tile": assert => { - const segments = parseUrlPath("/session/1/rooms/a,b,c/3"); + const path = createEmptyPath(); + const segments = parseUrlPath("/session/1/rooms/a,b,c/3", path); assert.equal(segments.length, 3); assert.equal(segments[0].type, "session"); assert.equal(segments[0].value, "1"); @@ -255,7 +305,8 @@ export function tests() { assert.equal(segments[2].value, 3); }, "parse grid url path with focused room": assert => { - const segments = parseUrlPath("/session/1/rooms/a,b,c/1"); + const path = createEmptyPath(); + const segments = parseUrlPath("/session/1/rooms/a,b,c/1", path); assert.equal(segments.length, 3); assert.equal(segments[0].type, "session"); assert.equal(segments[0].value, "1"); @@ -265,7 +316,8 @@ export function tests() { assert.equal(segments[2].value, "b"); }, "parse empty grid url": assert => { - const segments = parseUrlPath("/session/1/rooms/"); + const path = createEmptyPath(); + const segments = parseUrlPath("/session/1/rooms/", path); assert.equal(segments.length, 3); assert.equal(segments[0].type, "session"); assert.equal(segments[0].value, "1"); @@ -275,7 +327,8 @@ export function tests() { assert.equal(segments[2].value, 0); }, "parse empty grid url with focus": assert => { - const segments = parseUrlPath("/session/1/rooms//1"); + const path = createEmptyPath(); + const segments = parseUrlPath("/session/1/rooms//1", path); assert.equal(segments.length, 3); assert.equal(segments[0].type, "session"); assert.equal(segments[0].value, "1"); @@ -285,7 +338,7 @@ export function tests() { assert.equal(segments[2].value, 1); }, "parse open-room action replacing the current focused room": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("rooms", ["a", "b", "c"]), @@ -301,7 +354,7 @@ export function tests() { assert.equal(segments[2].value, "d"); }, "parse open-room action changing focus to an existing room": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("rooms", ["a", "b", "c"]), @@ -317,7 +370,7 @@ export function tests() { assert.equal(segments[2].value, "a"); }, "parse open-room action changing focus to an existing room with details open": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("rooms", ["a", "b", "c"]), @@ -339,7 +392,7 @@ export function tests() { assert.equal(segments[4].value, true); }, "open-room action should only copy over previous segments if there are no parts after open-room": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("rooms", ["a", "b", "c"]), @@ -361,7 +414,7 @@ export function tests() { assert.equal(segments[4].value, "foo"); }, "parse open-room action setting a room in an empty tile": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("rooms", ["a", "b", "c"]), @@ -377,82 +430,83 @@ export function tests() { assert.equal(segments[2].value, "d"); }, "parse session url path without id": assert => { - const segments = parseUrlPath("/session"); + const path = createEmptyPath(); + const segments = parseUrlPath("/session", path); assert.equal(segments.length, 1); assert.equal(segments[0].type, "session"); assert.strictEqual(segments[0].value, true); }, "remove active room from grid path turns it into empty tile": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("rooms", ["a", "b", "c"]), new Segment("room", "b") ]); const newPath = removeRoomFromPath(path, "b"); - assert.equal(newPath.segments.length, 3); - assert.equal(newPath.segments[0].type, "session"); - assert.equal(newPath.segments[0].value, 1); - assert.equal(newPath.segments[1].type, "rooms"); - assert.deepEqual(newPath.segments[1].value, ["a", "", "c"]); - assert.equal(newPath.segments[2].type, "empty-grid-tile"); - assert.equal(newPath.segments[2].value, 1); + assert.equal(newPath?.segments.length, 3); + assert.equal(newPath?.segments[0].type, "session"); + assert.equal(newPath?.segments[0].value, 1); + assert.equal(newPath?.segments[1].type, "rooms"); + assert.deepEqual(newPath?.segments[1].value, ["a", "", "c"]); + assert.equal(newPath?.segments[2].type, "empty-grid-tile"); + assert.equal(newPath?.segments[2].value, 1); }, "remove inactive room from grid path": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("rooms", ["a", "b", "c"]), new Segment("room", "b") ]); const newPath = removeRoomFromPath(path, "a"); - assert.equal(newPath.segments.length, 3); - assert.equal(newPath.segments[0].type, "session"); - assert.equal(newPath.segments[0].value, 1); - assert.equal(newPath.segments[1].type, "rooms"); - assert.deepEqual(newPath.segments[1].value, ["", "b", "c"]); - assert.equal(newPath.segments[2].type, "room"); - assert.equal(newPath.segments[2].value, "b"); + assert.equal(newPath?.segments.length, 3); + assert.equal(newPath?.segments[0].type, "session"); + assert.equal(newPath?.segments[0].value, 1); + assert.equal(newPath?.segments[1].type, "rooms"); + assert.deepEqual(newPath?.segments[1].value, ["", "b", "c"]); + assert.equal(newPath?.segments[2].type, "room"); + assert.equal(newPath?.segments[2].value, "b"); }, "remove inactive room from grid path with empty tile": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("rooms", ["a", "b", ""]), new Segment("empty-grid-tile", 3) ]); const newPath = removeRoomFromPath(path, "b"); - assert.equal(newPath.segments.length, 3); - assert.equal(newPath.segments[0].type, "session"); - assert.equal(newPath.segments[0].value, 1); - assert.equal(newPath.segments[1].type, "rooms"); - assert.deepEqual(newPath.segments[1].value, ["a", "", ""]); - assert.equal(newPath.segments[2].type, "empty-grid-tile"); - assert.equal(newPath.segments[2].value, 3); + assert.equal(newPath?.segments.length, 3); + assert.equal(newPath?.segments[0].type, "session"); + assert.equal(newPath?.segments[0].value, 1); + assert.equal(newPath?.segments[1].type, "rooms"); + assert.deepEqual(newPath?.segments[1].value, ["a", "", ""]); + assert.equal(newPath?.segments[2].type, "empty-grid-tile"); + assert.equal(newPath?.segments[2].value, 3); }, "remove active room": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("room", "b") ]); const newPath = removeRoomFromPath(path, "b"); - assert.equal(newPath.segments.length, 1); - assert.equal(newPath.segments[0].type, "session"); - assert.equal(newPath.segments[0].value, 1); + assert.equal(newPath?.segments.length, 1); + assert.equal(newPath?.segments[0].type, "session"); + assert.equal(newPath?.segments[0].value, 1); }, "remove inactive room doesn't do anything": assert => { - const nav = new Navigation(allowsChild); + const nav: Navigation = new Navigation(allowsChild); const path = nav.pathFrom([ new Segment("session", 1), new Segment("room", "b") ]); const newPath = removeRoomFromPath(path, "a"); - assert.equal(newPath.segments.length, 2); - assert.equal(newPath.segments[0].type, "session"); - assert.equal(newPath.segments[0].value, 1); - assert.equal(newPath.segments[1].type, "room"); - assert.equal(newPath.segments[1].value, "b"); + assert.equal(newPath?.segments.length, 2); + assert.equal(newPath?.segments[0].type, "session"); + assert.equal(newPath?.segments[0].value, 1); + assert.equal(newPath?.segments[1].type, "room"); + assert.equal(newPath?.segments[1].value, "b"); }, } diff --git a/src/domain/session/JoinRoomViewModel.ts b/src/domain/session/JoinRoomViewModel.ts new file mode 100644 index 00000000..1b7148f9 --- /dev/null +++ b/src/domain/session/JoinRoomViewModel.ts @@ -0,0 +1,63 @@ +/* +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 {ViewModel, Options as BaseOptions} from "../ViewModel"; +import {SegmentType} from "../navigation/index"; +import type {Session} from "../../matrix/Session.js"; +import {joinRoom} from "../../matrix/room/joinRoom"; + +type Options = BaseOptions & { + session: Session; +}; + +export class JoinRoomViewModel extends ViewModel { + private _session: Session; + private _joinInProgress: boolean = false; + private _error: Error | undefined; + + constructor(options: Readonly) { + super(options); + this._session = options.session; + } + + async join(roomId: string): Promise { + this._error = undefined; + this._joinInProgress = true; + this.emitChange("joinInProgress"); + try { + const id = await joinRoom(roomId, this._session); + this.navigation.push("room", id); + } + catch (e) { + this._error = e; + this._joinInProgress = false; + this.emitChange("error"); + } + } + + get joinInProgress(): boolean { + return this._joinInProgress; + } + + get status(): string | undefined { + if (this._error) { + return this._error.message; + } + else if(this._joinInProgress){ + return "Joining room"; + } + } +} diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index 5d42f0f6..478972d4 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -15,7 +15,7 @@ limitations under the License. */ import {ViewModel} from "../ViewModel"; -import {addPanelIfNeeded} from "../navigation/index.js"; +import {addPanelIfNeeded} from "../navigation/index"; function dedupeSparse(roomIds) { return roomIds.map((id, idx) => { @@ -185,8 +185,8 @@ export class RoomGridViewModel extends ViewModel { } } -import {createNavigation} from "../navigation/index.js"; -import {ObservableValue} from "../../observable/value/ObservableValue"; +import {createNavigation} from "../navigation/index"; +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 86e2bb5d..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/value/ObservableValue"; +import {ObservableValue} from "../../observable/value"; import {RoomStatus} from "../../matrix/room/common"; /** diff --git a/src/domain/session/SessionStatusViewModel.js b/src/domain/session/SessionStatusViewModel.js index 8f1d0748..16332477 100644 --- a/src/domain/session/SessionStatusViewModel.js +++ b/src/domain/session/SessionStatusViewModel.js @@ -36,7 +36,7 @@ export class SessionStatusViewModel extends ViewModel { this._reconnector = reconnector; this._status = this._calculateState(reconnector.connectionStatus.get(), sync.status.get()); this._session = session; - this._setupKeyBackupUrl = this.urlCreator.urlForSegment("settings"); + this._setupKeyBackupUrl = this.urlRouter.urlForSegment("settings"); this._dismissSecretStorage = false; } diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 715e51c5..eb371ca8 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -25,9 +25,11 @@ import {SessionStatusViewModel} from "./SessionStatusViewModel.js"; import {RoomGridViewModel} from "./RoomGridViewModel.js"; import {SettingsViewModel} from "./settings/SettingsViewModel.js"; import {CreateRoomViewModel} from "./CreateRoomViewModel.js"; +import {JoinRoomViewModel} from "./JoinRoomViewModel"; import {ViewModel} from "../ViewModel"; import {RoomViewModelObservable} from "./RoomViewModelObservable.js"; import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js"; +import {SyncStatus} from "../../matrix/Sync.js"; export class SessionViewModel extends ViewModel { constructor(options) { @@ -44,7 +46,9 @@ export class SessionViewModel extends ViewModel { this._roomViewModelObservable = null; this._gridViewModel = null; this._createRoomViewModel = null; + this._joinRoomViewModel = null; this._setupNavigation(); + this._setupForcedLogoutOnAccessTokenInvalidation(); } _setupNavigation() { @@ -81,6 +85,12 @@ export class SessionViewModel extends ViewModel { })); this._updateCreateRoom(createRoom.get()); + const joinRoom = this.navigation.observe("join-room"); + this.track(joinRoom.subscribe((joinRoomOpen) => { + this._updateJoinRoom(joinRoomOpen); + })); + this._updateJoinRoom(joinRoom.get()); + const lightbox = this.navigation.observe("lightbox"); this.track(lightbox.subscribe(eventId => { this._updateLightbox(eventId); @@ -93,6 +103,23 @@ export class SessionViewModel extends ViewModel { this._updateRightPanel(); } + _setupForcedLogoutOnAccessTokenInvalidation() { + this.track(this._client.sync.status.subscribe(status => { + if (status === SyncStatus.Stopped) { + const error = this._client.sync.error; + if (error?.errcode === "M_UNKNOWN_TOKEN") { + // Access token is no longer valid, so force the user to log out + const segments = [ + this.navigation.segment("logout", this.id), + this.navigation.segment("forced", true), + ]; + const path = this.navigation.pathFrom(segments); + this.navigation.applyPath(path); + } + } + })); + } + get id() { return this._client.sessionId; } @@ -105,7 +132,13 @@ export class SessionViewModel extends ViewModel { } get activeMiddleViewModel() { - return this._roomViewModelObservable?.get() || this._gridViewModel || this._settingsViewModel || this._createRoomViewModel; + return ( + this._roomViewModelObservable?.get() || + this._gridViewModel || + this._settingsViewModel || + this._createRoomViewModel || + this._joinRoomViewModel + ); } get roomGridViewModel() { @@ -136,6 +169,10 @@ export class SessionViewModel extends ViewModel { return this._createRoomViewModel; } + get joinRoomViewModel() { + return this._joinRoomViewModel; + } + _updateGrid(roomIds) { const changed = !(this._gridViewModel && roomIds); const currentRoomId = this.navigation.path.get("room"); @@ -270,6 +307,16 @@ export class SessionViewModel extends ViewModel { this.emitChange("activeMiddleViewModel"); } + _updateJoinRoom(joinRoomOpen) { + if (this._joinRoomViewModel) { + this._joinRoomViewModel = this.disposeTracked(this._joinRoomViewModel); + } + if (joinRoomOpen) { + this._joinRoomViewModel = this.track(new JoinRoomViewModel(this.childOptions({session: this._client.session}))); + } + this.emitChange("activeMiddleViewModel"); + } + _updateLightbox(eventId) { if (this._lightboxViewModel) { this._lightboxViewModel = this.disposeTracked(this._lightboxViewModel); diff --git a/src/domain/session/leftpanel/InviteTileViewModel.js b/src/domain/session/leftpanel/InviteTileViewModel.js index cdd955b1..26c8ec76 100644 --- a/src/domain/session/leftpanel/InviteTileViewModel.js +++ b/src/domain/session/leftpanel/InviteTileViewModel.js @@ -22,7 +22,7 @@ export class InviteTileViewModel extends BaseTileViewModel { super(options); const {invite} = options; this._invite = invite; - this._url = this.urlCreator.openRoomActionUrl(this._invite.id); + this._url = this.urlRouter.openRoomActionUrl(this._invite.id); } get busy() { return this._invite.accepting || this._invite.rejecting; } @@ -53,9 +53,9 @@ export class InviteTileViewModel extends BaseTileViewModel { export function tests() { return { "test compare with timestamp": assert => { - const urlCreator = {openRoomActionUrl() { return "";}} - const vm1 = new InviteTileViewModel({invite: {timestamp: 500, id: "1"}, urlCreator}); - const vm2 = new InviteTileViewModel({invite: {timestamp: 250, id: "2"}, urlCreator}); + const urlRouter = {openRoomActionUrl() { return "";}} + const vm1 = new InviteTileViewModel({invite: {timestamp: 500, id: "1"}, urlRouter}); + const vm2 = new InviteTileViewModel({invite: {timestamp: 250, id: "2"}, urlRouter}); assert(vm1.compare(vm2) < 0); assert(vm2.compare(vm1) > 0); assert.equal(vm1.compare(vm1), 0); diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index 2fd3ca7e..23dca0b2 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -20,8 +20,8 @@ import {RoomTileViewModel} from "./RoomTileViewModel.js"; import {InviteTileViewModel} from "./InviteTileViewModel.js"; import {RoomBeingCreatedTileViewModel} from "./RoomBeingCreatedTileViewModel.js"; import {RoomFilter} from "./RoomFilter.js"; -import {ApplyMap} from "../../../observable/map/ApplyMap.js"; -import {addPanelIfNeeded} from "../../navigation/index.js"; +import {ApplyMap} from "../../../observable"; +import {addPanelIfNeeded} from "../../navigation"; export class LeftPanelViewModel extends ViewModel { constructor(options) { @@ -32,9 +32,8 @@ export class LeftPanelViewModel extends ViewModel { this._tileViewModels = this._tileViewModelsFilterMap.sortValues((a, b) => a.compare(b)); this._currentTileVM = null; this._setupNavigation(); - this._closeUrl = this.urlCreator.urlForSegment("session"); - this._settingsUrl = this.urlCreator.urlForSegment("settings"); - this._createRoomUrl = this.urlCreator.urlForSegment("create-room"); + this._closeUrl = this.urlRouter.urlForSegment("session"); + this._settingsUrl = this.urlRouter.urlForSegment("settings"); } _mapTileViewModels(roomsBeingCreated, invites, rooms) { @@ -74,8 +73,14 @@ export class LeftPanelViewModel extends ViewModel { return this._settingsUrl; } - get createRoomUrl() { return this._createRoomUrl; } + showCreateRoomView() { + this.navigation.push("create-room"); + } + showJoinRoomView() { + this.navigation.push("join-room"); + } + _setupNavigation() { const roomObservable = this.navigation.observe("room"); this.track(roomObservable.subscribe(roomId => this._open(roomId))); diff --git a/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js b/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js index 81785bd9..b6c8c976 100644 --- a/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js +++ b/src/domain/session/leftpanel/RoomBeingCreatedTileViewModel.js @@ -23,7 +23,7 @@ export class RoomBeingCreatedTileViewModel extends BaseTileViewModel { super(options); const {roomBeingCreated} = options; this._roomBeingCreated = roomBeingCreated; - this._url = this.urlCreator.openRoomActionUrl(this._roomBeingCreated.id); + this._url = this.urlRouter.openRoomActionUrl(this._roomBeingCreated.id); } get busy() { return !this._roomBeingCreated.error; } @@ -59,9 +59,9 @@ export class RoomBeingCreatedTileViewModel extends BaseTileViewModel { export function tests() { return { "test compare with names": assert => { - const urlCreator = {openRoomActionUrl() { return "";}} - const vm1 = new RoomBeingCreatedTileViewModel({roomBeingCreated: {name: "A", id: "1"}, urlCreator}); - const vm2 = new RoomBeingCreatedTileViewModel({roomBeingCreated: {name: "B", id: "2"}, urlCreator}); + const urlRouter = {openRoomActionUrl() { return "";}} + const vm1 = new RoomBeingCreatedTileViewModel({roomBeingCreated: {name: "A", id: "1"}, urlRouter}); + const vm2 = new RoomBeingCreatedTileViewModel({roomBeingCreated: {name: "B", id: "2"}, urlRouter}); assert(vm1.compare(vm2) < 0); assert(vm2.compare(vm1) > 0); assert.equal(vm1.compare(vm1), 0); diff --git a/src/domain/session/leftpanel/RoomTileViewModel.js b/src/domain/session/leftpanel/RoomTileViewModel.js index 8c38bb3e..ece57eaf 100644 --- a/src/domain/session/leftpanel/RoomTileViewModel.js +++ b/src/domain/session/leftpanel/RoomTileViewModel.js @@ -22,7 +22,7 @@ export class RoomTileViewModel extends BaseTileViewModel { super(options); const {room} = options; this._room = room; - this._url = this.urlCreator.openRoomActionUrl(this._room.id); + this._url = this.urlRouter.openRoomActionUrl(this._room.id); } get kind() { diff --git a/src/domain/session/rightpanel/MemberListViewModel.js b/src/domain/session/rightpanel/MemberListViewModel.js index b75a3d1c..1c878c85 100644 --- a/src/domain/session/rightpanel/MemberListViewModel.js +++ b/src/domain/session/rightpanel/MemberListViewModel.js @@ -46,8 +46,8 @@ export class MemberListViewModel extends ViewModel { const vm = new MemberTileViewModel(this.childOptions({member, emitChange, mediaRepository})); this.nameDisambiguator.disambiguate(vm); return vm; - } - const updater = (vm, params, newMember) => { + }; + const updater = (params, vm, newMember) => { vm.updateFrom(newMember); this.nameDisambiguator.disambiguate(vm); }; diff --git a/src/domain/session/rightpanel/MemberTileViewModel.js b/src/domain/session/rightpanel/MemberTileViewModel.js index 153c70c8..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.urlCreator.openRoomActionUrl(roomId)}/member/${this._member.userId}`; + return `${this.urlRouter.openRoomActionUrl(roomId)}/member/${encodeURIComponent(this._member.userId)}`; } _updatePreviousName(newName) { diff --git a/src/domain/session/rightpanel/RightPanelViewModel.js b/src/domain/session/rightpanel/RightPanelViewModel.js index b4b6b4eb..7a13c7b1 100644 --- a/src/domain/session/rightpanel/RightPanelViewModel.js +++ b/src/domain/session/rightpanel/RightPanelViewModel.js @@ -64,8 +64,8 @@ export class RightPanelViewModel extends ViewModel { this._hookUpdaterToSegment("member", MemberDetailsViewModel, () => this._getMemberDetailsArguments(), () => { // If we fail to create the member details panel, fallback to memberlist - const url = `${this.urlCreator.urlUntilSegment("room")}/members`; - this.urlCreator.pushUrl(url); + const url = `${this.urlRouter.urlUntilSegment("room")}/members`; + this.urlRouter.pushUrl(url); } ); } diff --git a/src/domain/session/room/CallViewModel.ts b/src/domain/session/room/CallViewModel.ts index dd942878..0ba774a2 100644 --- a/src/domain/session/room/CallViewModel.ts +++ b/src/domain/session/room/CallViewModel.ts @@ -19,34 +19,35 @@ 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/EventObservableValue"; -import {ObservableValueMap} from "../../../observable/map/ObservableValueMap"; +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/BaseObservableValue"; +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 & { +type Options = BaseOptions & { call: GroupCall, room: Room, }; -export class CallViewModel extends ErrorReportViewModel { +export class CallViewModel extends ErrorReportViewModel> { public readonly memberViewModels: BaseObservableList; - constructor(options: Options) { + 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})), () => {}); - this.memberViewModels = this.call.members + const otherMemberViewModels = this.call.members .filterValues(member => member.isConnected) .mapValues( (member, emitChange) => new CallMemberViewModel(this.childOptions({ @@ -55,7 +56,8 @@ export class CallViewModel extends ErrorReportViewModel { mediaRepository: this.getOption("room").mediaRepository })), (vm: CallMemberViewModel) => vm.onUpdate(), - ) + ) as BaseObservableMap; + this.memberViewModels = otherMemberViewModels .join(ownMemberViewModelMap) .sortValues((a, b) => a.compare(b)); this.track(this.memberViewModels.subscribe({ @@ -142,10 +144,10 @@ export class CallViewModel extends ErrorReportViewModel { } } -class OwnMemberViewModel extends ErrorReportViewModel implements IStreamViewModel { +class OwnMemberViewModel extends ErrorReportViewModel> implements IStreamViewModel { private memberObservable: undefined | BaseObservableValue; - constructor(options: Options) { + constructor(options: Options) { super(options); this.init(); } @@ -207,17 +209,18 @@ class OwnMemberViewModel extends ErrorReportViewModel implements IStrea } } - compare(other: OwnMemberViewModel | CallMemberViewModel): number { + compare(other: IStreamViewModel): number { + // I always come first. return -1; } } -type MemberOptions = BaseOptions & { +type MemberOptions = BaseOptions & { member: Member, mediaRepository: MediaRepository, }; -export class CallMemberViewModel extends ErrorReportViewModel implements IStreamViewModel { +export class CallMemberViewModel extends ErrorReportViewModel> implements IStreamViewModel { get stream(): Stream | undefined { return this.member.remoteMedia?.userMedia; } @@ -262,16 +265,17 @@ export class CallMemberViewModel extends ErrorReportViewModel imp } } - compare(other: OwnMemberViewModel | CallMemberViewModel): number { - if (other instanceof OwnMemberViewModel) { + 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); } - const myUserId = this.member.member.userId; - const otherUserId = other.member.member.userId; - if(myUserId === otherUserId) { - return 0; - } - return myUserId < otherUserId ? -1 : 1; } } @@ -280,4 +284,5 @@ export interface IStreamViewModel extends AvatarSource, ViewModel { get isCameraMuted(): boolean; get isMicrophoneMuted(): boolean; get errorViewModel(): ErrorViewModel | undefined; + compare(other: IStreamViewModel): number; } diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js index 00697642..7a6c2a6a 100644 --- a/src/domain/session/room/InviteViewModel.js +++ b/src/domain/session/room/InviteViewModel.js @@ -26,7 +26,7 @@ export class InviteViewModel extends ViewModel { this._mediaRepository = mediaRepository; this._onInviteChange = this._onInviteChange.bind(this); this._error = null; - this._closeUrl = this.urlCreator.urlUntilSegment("session"); + this._closeUrl = this.urlRouter.urlUntilSegment("session"); this._invite.on("change", this._onInviteChange); this._inviter = null; if (this._invite.inviter) { diff --git a/src/domain/session/room/LightboxViewModel.js b/src/domain/session/room/LightboxViewModel.js index 8ce8757a..a14eef89 100644 --- a/src/domain/session/room/LightboxViewModel.js +++ b/src/domain/session/room/LightboxViewModel.js @@ -22,8 +22,7 @@ export class LightboxViewModel extends ViewModel { this._eventId = options.eventId; this._unencryptedImageUrl = null; this._decryptedImage = null; - this._closeUrl = this.urlCreator.urlUntilSegment("room"); - this._eventEntry = null; + this._closeUrl = this.urlRouter.urlUntilSegment("room"); this._date = null; this._subscribeToEvent(options.room, options.eventId); } diff --git a/src/domain/session/room/RoomBeingCreatedViewModel.js b/src/domain/session/room/RoomBeingCreatedViewModel.js index b503af73..9e230308 100644 --- a/src/domain/session/room/RoomBeingCreatedViewModel.js +++ b/src/domain/session/room/RoomBeingCreatedViewModel.js @@ -25,7 +25,7 @@ export class RoomBeingCreatedViewModel extends ViewModel { this._roomBeingCreated = roomBeingCreated; this._mediaRepository = mediaRepository; this._onRoomChange = this._onRoomChange.bind(this); - this._closeUrl = this.urlCreator.urlUntilSegment("session"); + this._closeUrl = this.urlRouter.urlUntilSegment("session"); this._roomBeingCreated.on("change", this._onRoomChange); } diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 8c551172..a741050d 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -18,7 +18,7 @@ limitations under the License. import {TimelineViewModel} from "./timeline/TimelineViewModel.js"; import {ComposerViewModel} from "./ComposerViewModel.js" import {CallViewModel} from "./CallViewModel" -import {PickMapObservableValue} from "../../../observable/value/PickMapObservableValue"; +import {PickMapObservableValue} from "../../../observable/value"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar"; import {ErrorReportViewModel} from "../../ErrorReportViewModel"; import {ViewModel} from "../../ViewModel"; @@ -27,6 +27,7 @@ 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 ErrorReportViewModel { constructor(options) { @@ -39,12 +40,12 @@ export class RoomViewModel extends ErrorReportViewModel { this._onRoomChange = this._onRoomChange.bind(this); this._composerVM = null; if (room.isArchived) { - this._composerVM = new ArchivedViewModel(this.childOptions({archivedRoom: room})); + this._composerVM = this.track(new ArchivedViewModel(this.childOptions({archivedRoom: room}))); } else { - this._composerVM = new ComposerViewModel(this); + this._recreateComposerOnPowerLevelChange(); } this._clearUnreadTimout = null; - this._closeUrl = this.urlCreator.urlUntilSegment("session"); + this._closeUrl = this.urlRouter.urlUntilSegment("session"); this._setupCallViewModel(); } @@ -91,6 +92,30 @@ export class RoomViewModel extends ErrorReportViewModel { }); } + async _recreateComposerOnPowerLevelChange() { + const powerLevelObservable = await this._room.observePowerLevels(); + const canSendMessage = () => powerLevelObservable.get().canSendType("m.room.message"); + let oldCanSendMessage = canSendMessage(); + const recreateComposer = newCanSendMessage => { + this._composerVM = this.disposeTracked(this._composerVM); + if (newCanSendMessage) { + this._composerVM = this.track(new ComposerViewModel(this)); + } + else { + this._composerVM = this.track(new LowerPowerLevelViewModel(this.childOptions())); + } + this.emitChange("powerLevelObservable") + }; + this.track(powerLevelObservable.subscribe(() => { + const newCanSendMessage = canSendMessage(); + if (oldCanSendMessage !== newCanSendMessage) { + recreateComposer(newCanSendMessage); + oldCanSendMessage = newCanSendMessage; + } + })); + recreateComposer(oldCanSendMessage); + } + async _clearUnreadAfterDelay(log) { if (this._room.isArchived || this._clearUnreadTimout) { return; @@ -131,7 +156,7 @@ export class RoomViewModel extends ErrorReportViewModel { // so emit all fields originating from summary _onRoomChange() { // propagate the update to the child view models so it's bindings can update based on room changes - this._composerVM.emitChange(); + this._composerVM?.emitChange(); this.emitChange(); } @@ -196,9 +221,12 @@ export class RoomViewModel extends ErrorReportViewModel { let success = false; if (!this._room.isArchived && message) { let msgtype = "m.text"; - if (message.startsWith("/me ")) { - message = message.substr(4).trim(); - msgtype = "m.emote"; + 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) { @@ -215,6 +243,55 @@ export class RoomViewModel extends ErrorReportViewModel { }, 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.reportError(err); + } + } + + async _processCommand(message) { + let msgtype; + const [commandName, ...args] = message.substring(1).split(" "); + switch (commandName) { + case "me": + message = args.join(" "); + msgtype = "m.emote"; + break; + case "join": + if (args.length === 1) { + const roomName = args[0]; + await this._processCommandJoin(roomName); + } else { + this.reportError(new Error("join syntax: /join ")); + } + break; + case "shrug": + message = "¯\\_(ツ)_/¯ " + args.join(" "); + msgtype = "m.text"; + break; + case "tableflip": + message = "(╯°□°)╯︵ ┻━┻ " + args.join(" "); + msgtype = "m.text"; + break; + case "unflip": + message = "┬──┬ ノ( ゜-゜ノ) " + args.join(" "); + msgtype = "m.text"; + break; + case "lenny": + message = "( ͡° ͜ʖ ͡°) " + args.join(" "); + msgtype = "m.text"; + break; + default: + this.reportError(new Error(`no command name "${commandName}". To send the message instead of executing, please type "/${message}"`)); + message = undefined; + } + return {type: msgtype, message: message}; + } + _pickAndSendFile() { return this.logAndCatch("RoomViewModel.sendFile", async log => { const file = await this.platform.openFile(); @@ -406,6 +483,16 @@ class ArchivedViewModel extends ViewModel { } get kind() { - return "archived"; + return "disabled"; + } +} + +class LowerPowerLevelViewModel extends ViewModel { + get description() { + return this.i18n`You do not have the powerlevel necessary to send messages`; + } + + get kind() { + return "disabled"; } } diff --git a/src/domain/session/room/timeline/MessageBody.js b/src/domain/session/room/timeline/MessageBody.js index 65b487a9..f2df1166 100644 --- a/src/domain/session/room/timeline/MessageBody.js +++ b/src/domain/session/room/timeline/MessageBody.js @@ -1,4 +1,4 @@ -import { linkify } from "./linkify/linkify.js"; +import { linkify } from "./linkify/linkify"; import { getIdentifierColorNumber, avatarInitials } from "../../../avatar"; /** diff --git a/src/domain/session/room/timeline/ReactionsViewModel.js b/src/domain/session/room/timeline/ReactionsViewModel.js index c6055d20..0355889c 100644 --- a/src/domain/session/room/timeline/ReactionsViewModel.js +++ b/src/domain/session/room/timeline/ReactionsViewModel.js @@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import {ObservableMap} from "../../../../observable/map/ObservableMap"; +import {ObservableMap} from "../../../../observable"; export class ReactionsViewModel { constructor(parentTile) { @@ -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/value/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..458697ca 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. @@ -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/deserialize.js b/src/domain/session/room/timeline/deserialize.js index b59c2e59..2e2a30f2 100644 --- a/src/domain/session/room/timeline/deserialize.js +++ b/src/domain/session/room/timeline/deserialize.js @@ -15,7 +15,7 @@ limitations under the License. */ import { MessageBody, HeaderBlock, TableBlock, ListBlock, CodeBlock, PillPart, FormatPart, NewLinePart, RulePart, TextPart, LinkPart, ImagePart } from "./MessageBody.js" -import { linkify } from "./linkify/linkify.js"; +import {linkify} from "./linkify/linkify"; /* At the time of writing (Jul 1 2021), Matrix Spec recommends * allowing the following HTML tags: diff --git a/src/domain/session/room/timeline/linkify/linkify.js b/src/domain/session/room/timeline/linkify/linkify.ts similarity index 77% rename from src/domain/session/room/timeline/linkify/linkify.js rename to src/domain/session/room/timeline/linkify/linkify.ts index 6c285d79..6e5f90d1 100644 --- a/src/domain/session/room/timeline/linkify/linkify.js +++ b/src/domain/session/room/timeline/linkify/linkify.ts @@ -21,10 +21,8 @@ import { regex } from "./regex.js"; * For each such separated token, callback is called * with the token and a boolean passed as argument. * The boolean indicates whether the token is a link or not. - * @param {string} text Text to split - * @param {function(string, boolean)} callback A function to call with split tokens */ -export function linkify(text, callback) { +export function linkify(text: string, callback: (token: string, isLink: boolean) => void): void { const matches = text.matchAll(regex); let curr = 0; for (let match of matches) { @@ -32,16 +30,18 @@ export function linkify(text, callback) { callback(precedingText, false); callback(match[0], true); const len = match[0].length; - curr = match.index + len; + curr = match.index! + len; } const remainingText = text.slice(curr); callback(remainingText, false); } -export function tests() { +export function tests(): any { class MockCallback { - mockCallback(text, isLink) { + result: { type: "link" | "text", text: string }[]; + + mockCallback(text: string, isLink: boolean): void { if (!text.length) { return; } @@ -53,13 +53,13 @@ export function tests() { } } - function test(assert, input, output) { + function test(assert, input, output): void { const m = new MockCallback; linkify(input, m.mockCallback.bind(m)); assert.deepEqual(output, m.result); } - function testLink(assert, link, expectFail = false) { + function testLink(assert, link, expectFail = false): void { const input = link; const output = expectFail ? [{ type: "text", text: input }] : [{ type: "link", text: input }]; @@ -67,23 +67,23 @@ export function tests() { } return { - "Link with host": assert => { + "Link with host": (assert): void => { testLink(assert, "https://matrix.org"); }, - "Link with host & path": assert => { + "Link with host & path": (assert): void => { testLink(assert, "https://matrix.org/docs/develop"); }, - "Link with host & fragment": assert => { + "Link with host & fragment": (assert): void => { testLink(assert, "https://matrix.org#test"); }, - "Link with host & query": assert => { + "Link with host & query": (assert): void => { testLink(assert, "https://matrix.org/?foo=bar"); }, - "Complex link": assert => { + "Complex link": (assert): void => { const link = "https://www.foobar.com/url?sa=t&rct=j&q=&esrc=s&source" + "=web&cd=&cad=rja&uact=8&ved=2ahUKEwjyu7DJ-LHwAhUQyzgGHc" + "OKA70QFjAAegQIBBAD&url=https%3A%2F%2Fmatrix.org%2Fdocs%" + @@ -92,26 +92,26 @@ export function tests() { testLink(assert, link); }, - "Localhost link": assert => { + "Localhost link": (assert): void => { testLink(assert, "http://localhost"); testLink(assert, "http://localhost:3000"); }, - "IPV4 link": assert => { + "IPV4 link": (assert): void => { testLink(assert, "https://192.0.0.1"); testLink(assert, "https://250.123.67.23:5924"); }, - "IPV6 link": assert => { + "IPV6 link": (assert): void => { testLink(assert, "http://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]"); testLink(assert, "http://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:7000"); }, - "Missing scheme must not linkify": assert => { + "Missing scheme must not linkify": (assert): void => { testLink(assert, "matrix.org/foo/bar", true); }, - "Punctuation at end of link must not linkify": assert => { + "Punctuation at end of link must not linkify": (assert): void => { const link = "https://foo.bar/?nenjil=lal810"; const end = ".,? "; for (const char of end) { @@ -120,28 +120,28 @@ export function tests() { } }, - "Link doesn't adopt closing parenthesis": assert => { + "Link doesn't adopt closing parenthesis": (assert): void => { const link = "(https://matrix.org)"; const out = [{ type: "text", text: "(" }, { type: "link", text: "https://matrix.org" }, { type: "text", text: ")" }]; test(assert, link, out); }, - "Unicode in hostname must not linkify": assert => { + "Unicode in hostname must not linkify": (assert): void => { const link = "https://foo.bar\uD83D\uDE03.com"; const out = [{ type: "link", text: "https://foo.bar" }, { type: "text", text: "\uD83D\uDE03.com" }]; test(assert, link, out); }, - "Link with unicode only after / must linkify": assert => { + "Link with unicode only after / must linkify": (assert): void => { testLink(assert, "https://foo.bar.com/\uD83D\uDE03"); }, - "Link with unicode after fragment without path must linkify": assert => { + "Link with unicode after fragment without path must linkify": (assert): void => { testLink(assert, "https://foo.bar.com#\uD83D\uDE03"); }, - "Link ends with <": assert => { + "Link ends with <": (assert): void => { const link = "https://matrix.org<"; const out = [{ type: "link", text: "https://matrix.org" }, { type: "text", text: "<" }]; test(assert, link, out); diff --git a/src/domain/session/room/timeline/linkify/regex.js b/src/domain/session/room/timeline/linkify/regex.ts similarity index 100% rename from src/domain/session/room/timeline/linkify/regex.js rename to src/domain/session/room/timeline/linkify/regex.ts diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 44e0c5b0..62c9632e 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -21,7 +21,6 @@ import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../ 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; @@ -50,7 +49,7 @@ export class BaseMessageTile extends SimpleTile { } get memberPanelLink() { - return `${this.urlCreator.urlUntilSegment("room")}/member/${this.sender}`; + return `${this.urlRouter.urlUntilSegment("room")}/member/${this.sender}`; } // Avatar view model contract @@ -67,15 +66,11 @@ export class BaseMessageTile extends SimpleTile { } get avatarTitle() { - return this.displayName; - } - - get date() { - return this._date && this._date.toLocaleDateString({}, {month: "numeric", day: "numeric"}); + return this.sender; } get time() { - return this._date && this._date.toLocaleTimeString({}, {hour: "numeric", minute: "2-digit"}); + return this._date && this.timeFormatter.formatTime(this._date); } get isOwn() { 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 6caa4b9b..1e6bdd08 100644 --- a/src/domain/session/room/timeline/tiles/GapTile.js +++ b/src/domain/session/room/timeline/tiles/GapTile.js @@ -16,7 +16,10 @@ limitations under the License. import {SimpleTile} from "./SimpleTile.js"; 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); @@ -24,23 +27,41 @@ export class GapTile extends SimpleTile { this._error = null; this._isAtTop = true; this._siblingChanged = false; + this._showSpinner = false; + } + + get needsDateSeparator() { + return false; } async fill() { 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; - this.emitChange("error"); + 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(); + } // 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; @@ -55,7 +76,19 @@ export class GapTile extends SimpleTile { let canFillMore; this._siblingChanged = false; do { - canFillMore = await this.fill(); + 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; + } + } depth = depth + 1; } while (depth < 10 && !this._siblingChanged && canFillMore && !this.isDisposed); } @@ -90,6 +123,10 @@ export class GapTile extends SimpleTile { } } + async _waitForReconnection() { + await this.options.client.reconnector.connectionStatus.waitFor(status => status === ConnectionStatus.Online).promise; + } + get shape() { return "gap"; } @@ -98,13 +135,32 @@ export class GapTile extends SimpleTile { return this._loading; } + get showSpinner() { + return this._showSpinner; + } + 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"; + } + } } import {FragmentBoundaryEntry} from "../../../../../matrix/room/timeline/entries/FragmentBoundaryEntry.js"; 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/ImageTile.js b/src/domain/session/room/timeline/tiles/ImageTile.js index dd959b28..8ae55ca1 100644 --- a/src/domain/session/room/timeline/tiles/ImageTile.js +++ b/src/domain/session/room/timeline/tiles/ImageTile.js @@ -20,7 +20,7 @@ import {BaseMediaTile} from "./BaseMediaTile.js"; export class ImageTile extends BaseMediaTile { constructor(entry, options) { super(entry, options); - this._lightboxUrl = this.urlCreator.urlForSegments([ + this._lightboxUrl = this.urlRouter.urlForSegments([ // ensure the right room is active if in grid view this.navigation.segment("room", this._room.id), this.navigation.segment("lightbox", this._entry.id) diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index aa26da81..59ddf15b 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -16,12 +16,16 @@ limitations under the License. import {UpdateAction} from "../UpdateAction.js"; import {ErrorReportViewModel} from "../../../../ErrorReportViewModel"; +import {TileShape} from "./ITile"; import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js"; +import {DateTile} from "./DateTile"; 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 ErrorReportViewModel { 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 ErrorReportViewModel { 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 ErrorReportViewModel { 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 @@ -168,3 +196,65 @@ export class SimpleTile extends ErrorReportViewModel { 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 c41f90a6..b4805635 100644 --- a/src/domain/session/room/timeline/tiles/index.ts +++ b/src/domain/session/room/timeline/tiles/index.ts @@ -28,7 +28,7 @@ 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"; @@ -45,7 +45,7 @@ export type Options = ViewModelOptions & { 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 { if (entry.isGap) { diff --git a/src/domain/session/settings/KeyBackupViewModel.js b/src/domain/session/settings/KeyBackupViewModel.js index b4ee9a0e..40dbfd73 100644 --- a/src/domain/session/settings/KeyBackupViewModel.js +++ b/src/domain/session/settings/KeyBackupViewModel.js @@ -17,7 +17,7 @@ limitations under the License. import {ViewModel} from "../../ViewModel"; import {KeyType} from "../../../matrix/ssss/index"; import {createEnum} from "../../../utils/enum"; -import {FlatMapObservableValue} from "../../../observable/value/FlatMapObservableValue"; +import {FlatMapObservableValue} from "../../../observable/value"; export const Status = createEnum("Enabled", "SetupKey", "SetupPhrase", "Pending", "NewVersionAvailable"); export const BackupWriteStatus = createEnum("Writing", "Stopped", "Done", "Pending"); diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index d60a6327..952c910b 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -45,7 +45,7 @@ export class SettingsViewModel extends ViewModel { const {client} = options; this._client = client; this._keyBackupViewModel = this.track(new KeyBackupViewModel(this.childOptions({session: this._session}))); - this._closeUrl = this.urlCreator.urlUntilSegment("session"); + this._closeUrl = this.urlRouter.urlUntilSegment("session"); this._estimate = null; this.sentImageSizeLimit = null; this.minSentImageSizeLimit = 400; diff --git a/src/lib.ts b/src/lib.ts index 839fbb15..a6f609f4 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -27,9 +27,9 @@ 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.js"; +export {createNavigation, createRouter} from "./domain/navigation/index"; export {RootViewModel} from "./domain/RootViewModel.js"; export {RootView} from "./platform/web/ui/RootView.js"; export {SessionViewModel} from "./domain/session/SessionViewModel.js"; @@ -92,6 +92,8 @@ export { ConcatList, ObservableMap } from "./observable/index"; -export {BaseObservableValue} from "./observable/value/BaseObservableValue"; -export {ObservableValue} from "./observable/value/ObservableValue"; -export {RetainedObservableValue} from "./observable/value/RetainedObservableValue"; +export { + BaseObservableValue, + ObservableValue, + RetainedObservableValue +} from "./observable/value"; diff --git a/src/logging/IDBLogPersister.ts b/src/logging/IDBLogPersister.ts index 87996519..b2792506 100644 --- a/src/logging/IDBLogPersister.ts +++ b/src/logging/IDBLogPersister.ts @@ -247,6 +247,7 @@ export class IDBLogExport { 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); diff --git a/src/matrix/Client.js b/src/matrix/Client.js index c3352a36..5b53958b 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/value/ObservableValue"; +import {ObservableValue} from "../observable/value"; import {HomeServerApi} from "./net/HomeServerApi"; import {Reconnector, ConnectionStatus} from "./net/Reconnector"; import {ExponentialRetryDelay} from "./net/ExponentialRetryDelay"; @@ -100,6 +100,8 @@ export class Client { }); } + // TODO: When converted to typescript this should return the same type + // as this._loginOptions is in LoginViewModel.ts (LoginOptions). _parseLoginOptions(options, homeserver) { /* Take server response and return new object which has two props password and sso which @@ -135,8 +137,8 @@ export class Client { async startRegistration(homeserver, username, password, initialDeviceDisplayName, flowSelector) { const request = this._platform.request; const hsApi = new HomeServerApi({homeserver, request}); - const registration = new Registration(hsApi, { - username, + const registration = new Registration(homeserver, hsApi, { + username, password, initialDeviceDisplayName, }, @@ -144,6 +146,16 @@ export class Client { return registration; } + /** Method to start client after registration or with given access token. + * To start the client after registering, use `startWithAuthData(registration.authData)`. + * `homeserver` won't be resolved or normalized using this method, + * use `lookupHomeserver` first if needed (not needed after registration) */ + async startWithAuthData({accessToken, deviceId, userId, homeserver}) { + await this._platform.logger.run("startWithAuthData", async (log) => { + await this._createSessionAfterAuth({accessToken, deviceId, userId, homeserver}, true, log); + }); + } + async startWithLogin(loginMethod, {inspectAccountSetup} = {}) { const currentStatus = this._status.get(); if (currentStatus !== LoadStatus.LoginFailed && @@ -154,23 +166,17 @@ export class Client { this._resetStatus(); await this._platform.logger.run("login", async log => { this._status.set(LoadStatus.Login); - const clock = this._platform.clock; let sessionInfo; try { const request = this._platform.request; const hsApi = new HomeServerApi({homeserver: loginMethod.homeserver, request}); const loginData = await loginMethod.login(hsApi, "Hydrogen", log); - const sessionId = this.createNewSessionId(); sessionInfo = { - id: sessionId, deviceId: loginData.device_id, userId: loginData.user_id, - homeServer: loginMethod.homeserver, // deprecate this over time homeserver: loginMethod.homeserver, accessToken: loginData.access_token, - lastUsed: clock.now() }; - log.set("id", sessionId); } catch (err) { this._error = err; if (err.name === "HomeServerError") { @@ -189,30 +195,45 @@ export class Client { } return; } - let dehydratedDevice; - if (inspectAccountSetup) { - dehydratedDevice = await this._inspectAccountAfterLogin(sessionInfo, log); - if (dehydratedDevice) { - sessionInfo.deviceId = dehydratedDevice.deviceId; - } - } - await this._platform.sessionInfoStorage.add(sessionInfo); - // loading the session can only lead to - // LoadStatus.Error in case of an error, - // so separate try/catch - try { - await this._loadSessionInfo(sessionInfo, dehydratedDevice, log); - log.set("status", this._status.get()); - } catch (err) { - log.catch(err); - // free olm Account that might be contained - dehydratedDevice?.dispose(); - this._error = err; - this._status.set(LoadStatus.Error); - } + await this._createSessionAfterAuth(sessionInfo, inspectAccountSetup, log); }); } + async _createSessionAfterAuth({deviceId, userId, accessToken, homeserver}, inspectAccountSetup, log) { + const id = this.createNewSessionId(); + const lastUsed = this._platform.clock.now(); + const sessionInfo = { + id, + deviceId, + userId, + homeServer: homeserver, // deprecate this over time + homeserver, + accessToken, + lastUsed, + }; + let dehydratedDevice; + if (inspectAccountSetup) { + dehydratedDevice = await this._inspectAccountAfterLogin(sessionInfo, log); + if (dehydratedDevice) { + sessionInfo.deviceId = dehydratedDevice.deviceId; + } + } + await this._platform.sessionInfoStorage.add(sessionInfo); + // loading the session can only lead to + // LoadStatus.Error in case of an error, + // so separate try/catch + try { + await this._loadSessionInfo(sessionInfo, dehydratedDevice, log); + log.set("status", this._status.get()); + } catch (err) { + log.catch(err); + // free olm Account that might be contained + dehydratedDevice?.dispose(); + this._error = err; + this._status.set(LoadStatus.Error); + } + } + async _loadSessionInfo(sessionInfo, dehydratedDevice, log) { log.set("appVersion", this._platform.version); const clock = this._platform.clock; @@ -266,7 +287,7 @@ export class Client { this._status.set(LoadStatus.SessionSetup); await log.wrap("createIdentity", log => this._session.createIdentity(log)); } - + this._sync = new Sync({hsApi: this._requestScheduler.hsApi, storage: this._storage, session: this._session, logger: this._platform.logger}); // notify sync and session when back online this._reconnectSubscription = this._reconnector.connectionStatus.subscribe(state => { @@ -311,7 +332,7 @@ export class Client { this._waitForFirstSyncHandle = this._sync.status.waitFor(s => { if (s === SyncStatus.Stopped) { // keep waiting if there is a ConnectionError - // as the reconnector above will call + // as the reconnector above will call // sync.start again to retry in this case return this._sync.error?.name !== "ConnectionError"; } @@ -419,6 +440,14 @@ export class Client { }); } + startForcedLogout(sessionId) { + return this._platform.logger.run("forced-logout", async log => { + this._sessionId = sessionId; + log.set("id", this._sessionId); + await this.deleteSession(log); + }); + } + dispose() { if (this._reconnectSubscription) { this._reconnectSubscription(); diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 291002c5..d640e2df 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -21,7 +21,7 @@ import {RoomStatus} from "./room/common"; import {RoomBeingCreated} from "./room/RoomBeingCreated"; import {Invite} from "./room/Invite.js"; import {Pusher} from "./push/Pusher"; -import { ObservableMap } from "../observable/index"; +import {ObservableMap} from "../observable"; import {User} from "./User.js"; import {DeviceMessageHandler} from "./DeviceMessageHandler.js"; import {Account as E2EEAccount} from "./e2ee/Account.js"; @@ -45,8 +45,7 @@ import { keyFromDehydratedDeviceKey as createSSSSKeyFromDehydratedDeviceKey } from "./ssss/index"; import {SecretStorage} from "./ssss/SecretStorage"; -import {ObservableValue} from "../observable/value/ObservableValue"; -import {RetainedObservableValue} from "../observable/value/RetainedObservableValue"; +import {ObservableValue, RetainedObservableValue} from "../observable/value"; import {CallHandler} from "./calls/CallHandler"; import {RoomStateHandlerSet} from "./room/state/RoomStateHandlerSet"; @@ -229,7 +228,7 @@ export class Session { /** * Enable secret storage by providing the secret storage credential. * This will also see if there is a megolm key backup and try to enable that if so. - * + * * @param {string} type either "passphrase" or "recoverykey" * @param {string} credential either the passphrase or the recovery key, depending on the type * @return {Promise} resolves or rejects after having tried to enable secret storage @@ -705,7 +704,7 @@ export class Session { if (this._e2eeAccount && deviceOneTimeKeysCount) { changes.e2eeAccountChanges = this._e2eeAccount.writeSync(deviceOneTimeKeysCount, txn, log); } - + const deviceLists = syncResponse.device_lists; if (this._deviceTracker && Array.isArray(deviceLists?.changed) && deviceLists.changed.length) { await log.wrap("deviceLists", log => this._deviceTracker.writeDeviceChanges(deviceLists.changed, txn, log)); @@ -959,7 +958,7 @@ export class Session { 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 write a full summary adopting it from the joined room when leaving - + @internal */ createOrGetArchivedRoomForSync(roomId) { diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index f77fa79a..d335336d 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/value/ObservableValue"; +import {ObservableValue} from "../observable/value"; import {createEnum} from "../utils/enum"; const INCREMENTAL_TIMEOUT = 30000; @@ -47,10 +47,8 @@ function timelineIsEmpty(roomResponse) { * const changes = await room.writeSync(roomResponse, isInitialSync, preparation, syncTxn); * // applies and emits changes once syncTxn is committed * room.afterSync(changes); - * if (room.needsAfterSyncCompleted(changes)) { - * // can do network requests - * await room.afterSyncCompleted(changes); - * } + * // can do network requests + * await room.afterSyncCompleted(changes); * ``` */ export class Sync { @@ -163,13 +161,9 @@ export class Sync { await log.wrap("session", log => this._session.afterSyncCompleted(sessionChanges, isCatchupSync, log)); } catch (err) {} // error is logged, but don't fail sessionPromise })(); - - const roomsNeedingAfterSyncCompleted = roomStates.filter(rs => { - return rs.room.needsAfterSyncCompleted(rs.changes); - }); - const roomsPromises = roomsNeedingAfterSyncCompleted.map(async rs => { + const roomsPromises = roomStates.map(async rs => { try { - await log.wrap("room", log => rs.room.afterSyncCompleted(rs.changes, log), log.level.Detail); + await rs.room.afterSyncCompleted(rs.changes, log); } catch (err) {} // error is logged, but don't fail roomsPromises }); // run everything in parallel, diff --git a/src/matrix/calls/CallHandler.ts b/src/matrix/calls/CallHandler.ts index 51afcda9..6be6b193 100644 --- a/src/matrix/calls/CallHandler.ts +++ b/src/matrix/calls/CallHandler.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ObservableMap} from "../../observable/map/ObservableMap"; +import {ObservableMap} from "../../observable/map"; import {WebRTC, PeerConnection} from "../../platform/types/WebRTC"; import {MediaDevices, Track} from "../../platform/types/MediaDevices"; import {handlesEventType} from "./PeerCall"; @@ -31,7 +31,7 @@ import type {MemberChange} from "../room/members/RoomMember"; import type {StateEvent} from "../storage/types"; import type {ILogItem, ILogger} from "../../logging/types"; import type {Platform} from "../../platform/web/Platform"; -import type {BaseObservableMap} from "../../observable/map/BaseObservableMap"; +import type {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"; diff --git a/src/matrix/calls/PeerCall.ts b/src/matrix/calls/PeerCall.ts index ae200ea2..36136b97 100644 --- a/src/matrix/calls/PeerCall.ts +++ b/src/matrix/calls/PeerCall.ts @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ObservableMap} from "../../observable/map/ObservableMap"; -import {BaseObservableValue} from "../../observable/value/BaseObservableValue"; +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"; diff --git a/src/matrix/calls/TurnServerSource.ts b/src/matrix/calls/TurnServerSource.ts index 1066f7a6..a9163349 100644 --- a/src/matrix/calls/TurnServerSource.ts +++ b/src/matrix/calls/TurnServerSource.ts @@ -14,12 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {RetainedObservableValue} from "../../observable/value/RetainedObservableValue"; +import {RetainedObservableValue} from "../../observable/value"; import type {HomeServerApi} from "../net/HomeServerApi"; import type {IHomeServerRequest} from "../net/HomeServerRequest"; -import type {BaseObservableValue} from "../../observable/value/BaseObservableValue"; -import type {ObservableValue} from "../../observable/value/ObservableValue"; +import type {BaseObservableValue, ObservableValue} from "../../observable/value"; import type {Clock, Timeout} from "../../platform/web/dom/Clock"; import type {ILogItem} from "../../logging/types"; diff --git a/src/matrix/calls/group/GroupCall.ts b/src/matrix/calls/group/GroupCall.ts index f6436eeb..efb321b2 100644 --- a/src/matrix/calls/group/GroupCall.ts +++ b/src/matrix/calls/group/GroupCall.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ObservableMap} from "../../../observable/map/ObservableMap"; +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"; @@ -25,7 +25,7 @@ import { ErrorBoundary } from "../../../utils/ErrorBoundary"; import type {Options as MemberOptions} from "./Member"; import type {TurnServerSource} from "../TurnServerSource"; -import type {BaseObservableMap} from "../../../observable/map/BaseObservableMap"; +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"; @@ -34,7 +34,7 @@ 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/BaseObservableValue"; +import type {BaseObservableValue} from "../../../observable/value"; import type {Clock, Timeout} from "../../../platform/web/dom/Clock"; export enum GroupCallState { diff --git a/src/matrix/calls/group/Member.ts b/src/matrix/calls/group/Member.ts index 257628f5..71c8b7f9 100644 --- a/src/matrix/calls/group/Member.ts +++ b/src/matrix/calls/group/Member.ts @@ -30,7 +30,7 @@ import type {GroupCall} from "./GroupCall"; import type {RoomMember} from "../../room/members/RoomMember"; import type {EncryptedMessage} from "../../e2ee/olm/Encryption"; import type {ILogItem} from "../../../logging/types"; -import type {BaseObservableValue} from "../../../observable/value/BaseObservableValue"; +import type {BaseObservableValue} from "../../../observable/value"; import type {Clock, Timeout} from "../../../platform/web/dom/Clock"; export type Options = Omit & { diff --git a/src/matrix/e2ee/DecryptionResult.ts b/src/matrix/e2ee/DecryptionResult.ts index 8fe1a6ed..83ad7a1e 100644 --- a/src/matrix/e2ee/DecryptionResult.ts +++ b/src/matrix/e2ee/DecryptionResult.ts @@ -27,6 +27,7 @@ limitations under the License. */ import type {DeviceIdentity} from "../storage/idb/stores/DeviceIdentityStore"; +import type {TimelineEvent} from "../storage/types"; type DecryptedEvent = { type?: string, @@ -35,22 +36,18 @@ type DecryptedEvent = { export class DecryptionResult { private device?: DeviceIdentity; - private roomTracked: boolean = true; constructor( public readonly event: DecryptedEvent, public readonly senderCurve25519Key: string, - public readonly claimedEd25519Key: string + public readonly claimedEd25519Key: string, + public readonly encryptedEvent?: TimelineEvent ) {} setDevice(device: DeviceIdentity): void { this.device = device; } - setRoomNotTrackedYet(): void { - this.roomTracked = false; - } - get isVerified(): boolean { if (this.device) { const comesFromDevice = this.device.ed25519Key === this.claimedEd25519Key; @@ -62,8 +59,6 @@ export class DecryptionResult { get isUnverified(): boolean { if (this.device) { return !this.isVerified; - } else if (this.isVerificationUnknown) { - return false; } else { return true; } @@ -78,7 +73,6 @@ export class DecryptionResult { } get isVerificationUnknown(): boolean { - // verification is unknown if we haven't yet fetched the devices for the room - return !this.device && !this.roomTracked; + return !this.device; } } diff --git a/src/matrix/e2ee/DeviceTracker.js b/src/matrix/e2ee/DeviceTracker.js index e0eea4fc..b256065a 100644 --- a/src/matrix/e2ee/DeviceTracker.js +++ b/src/matrix/e2ee/DeviceTracker.js @@ -15,17 +15,23 @@ limitations under the License. */ import {verifyEd25519Signature, SIGNATURE_ALGORITHM} from "./common.js"; +import {HistoryVisibility, shouldShareKey} from "./common.js"; +import {RoomMember} from "../room/members/RoomMember.js"; const TRACKING_STATUS_OUTDATED = 0; const TRACKING_STATUS_UPTODATE = 1; -export function addRoomToIdentity(identity, userId, roomId) { +function createUserIdentity(userId, initialRoomId = undefined) { + return { + userId: userId, + roomIds: initialRoomId ? [initialRoomId] : [], + deviceTrackingStatus: TRACKING_STATUS_OUTDATED, + }; +} + +function addRoomToIdentity(identity, userId, roomId) { if (!identity) { - identity = { - userId: userId, - roomIds: [roomId], - deviceTrackingStatus: TRACKING_STATUS_OUTDATED, - }; + identity = createUserIdentity(userId, roomId); return identity; } else { if (!identity.roomIds.includes(roomId)) { @@ -79,28 +85,62 @@ export class DeviceTracker { })); } - writeMemberChanges(room, memberChanges, txn) { - return Promise.all(Array.from(memberChanges.values()).map(async memberChange => { - return this._applyMemberChange(memberChange, txn); + /** @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 = []; + await Promise.all(Array.from(memberChanges.values()).map(async memberChange => { + // keys should now be shared with this member? + // add the room to the userIdentity if so + if (shouldShareKey(memberChange.membership, historyVisibility)) { + if (await this._addRoomToUserIdentity(memberChange.roomId, memberChange.userId, txn)) { + added.push(memberChange.userId); + } + } else if (shouldShareKey(memberChange.previousMembership, historyVisibility)) { + // try to remove room we were previously sharing the key with the member but not anymore + const {roomId} = memberChange; + // if we left the room, remove room from all user identities in the room + if (memberChange.userId === this._ownUserId) { + const userIds = await txn.roomMembers.getAllUserIds(roomId); + await Promise.all(userIds.map(userId => { + return this._removeRoomFromUserIdentity(roomId, userId, txn); + })); + } else { + await this._removeRoomFromUserIdentity(roomId, memberChange.userId, txn); + } + removed.push(memberChange.userId); + } })); + return {added, removed}; } - async trackRoom(room, log) { + async trackRoom(room, historyVisibility, log) { if (room.isTrackingMembers || !room.isEncrypted) { return; } - const memberList = await room.loadMemberList(log); + const memberList = await room.loadMemberList(undefined, log); + const txn = await this._storage.readWriteTxn([ + this._storage.storeNames.roomSummary, + this._storage.storeNames.userIdentities, + this._storage.storeNames.deviceIdentities, // to remove all devices in _removeRoomFromUserIdentity + ]); try { - const txn = await this._storage.readWriteTxn([ - this._storage.storeNames.roomSummary, - this._storage.storeNames.userIdentities, - ]); let isTrackingChanges; try { isTrackingChanges = room.writeIsTrackingMembers(true, txn); const members = Array.from(memberList.members.values()); log.set("members", members.length); - await this._writeJoinedMembers(members, txn); + // 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? + await Promise.all(members.map(async member => { + if (shouldShareKey(member.membership, historyVisibility)) { + await this._addRoomToUserIdentity(member.roomId, member.userId, txn); + } else { + await this._removeRoomFromUserIdentity(member.roomId, member.userId, txn); + } + })); } catch (err) { txn.abort(); throw err; @@ -112,21 +152,43 @@ export class DeviceTracker { } } - async _writeJoinedMembers(members, txn) { - await Promise.all(members.map(async member => { - if (member.membership === "join") { - await this._writeMember(member, txn); - } - })); + async writeHistoryVisibility(room, historyVisibility, syncTxn, log) { + const added = []; + const removed = []; + if (room.isTrackingMembers && room.isEncrypted) { + await log.wrap("rewriting userIdentities", async log => { + const memberList = await room.loadMemberList(syncTxn, log); + try { + const members = Array.from(memberList.members.values()); + log.set("members", members.length); + await Promise.all(members.map(async member => { + if (shouldShareKey(member.membership, historyVisibility)) { + if (await this._addRoomToUserIdentity(member.roomId, member.userId, syncTxn)) { + added.push(member.userId); + } + } else { + if (await this._removeRoomFromUserIdentity(member.roomId, member.userId, syncTxn)) { + removed.push(member.userId); + } + } + })); + } finally { + memberList.release(); + } + }); + } + return {added, removed}; } - async _writeMember(member, txn) { + async _addRoomToUserIdentity(roomId, userId, txn) { const {userIdentities} = txn; - const identity = await userIdentities.get(member.userId); - const updatedIdentity = addRoomToIdentity(identity, member.userId, member.roomId); + const identity = await userIdentities.get(userId); + const updatedIdentity = addRoomToIdentity(identity, userId, roomId); if (updatedIdentity) { userIdentities.set(updatedIdentity); + return true; } + return false; } async _removeRoomFromUserIdentity(roomId, userId, txn) { @@ -141,33 +203,17 @@ export class DeviceTracker { } else { userIdentities.set(identity); } + return true; } - } - - async _applyMemberChange(memberChange, txn) { - // TODO: depends whether we encrypt for invited users?? - // add room - if (memberChange.hasJoined) { - await this._writeMember(memberChange.member, txn); - } - // remove room - else if (memberChange.hasLeft) { - const {roomId} = memberChange; - // if we left the room, remove room from all user identities in the room - if (memberChange.userId === this._ownUserId) { - const userIds = await txn.roomMembers.getAllUserIds(roomId); - await Promise.all(userIds.map(userId => { - return this._removeRoomFromUserIdentity(roomId, userId, txn); - })); - } else { - await this._removeRoomFromUserIdentity(roomId, memberChange.userId, txn); - } - } + return false; } async _queryKeys(userIds, hsApi, log) { // 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 + // when /keys/query returns, we should check that list and requery if we queried for a given user. + // and then remove the list. const deviceKeyResponse = await hsApi.queryKeys({ "timeout": 10000, @@ -230,7 +276,15 @@ export class DeviceTracker { txn.deviceIdentities.set(deviceIdentity); } // mark user identities as up to date - const identity = await txn.userIdentities.get(userId); + let identity = await txn.userIdentities.get(userId); + if (!identity) { + // create the identity if it doesn't exist, which can happen if + // we request devices before tracking the room. + // IMPORTANT here that the identity gets created without any roomId! + // if we claim that we share and e2ee room with the user without having + // checked, we could share keys with that user without them being in the room + identity = createUserIdentity(userId); + } identity.deviceTrackingStatus = TRACKING_STATUS_UPTODATE; txn.userIdentities.set(identity); @@ -289,6 +343,7 @@ export class DeviceTracker { /** * Gives all the device identities for a room that is already tracked. + * Can be used to decide which users to share keys with. * Assumes room is already tracked. Call `trackRoom` first if unsure. * @param {String} roomId [description] * @return {[type]} [description] @@ -305,16 +360,43 @@ export class DeviceTracker { // So, this will also contain non-joined memberships const userIds = await txn.roomMembers.getAllUserIds(roomId); - - return await this._devicesForUserIds(roomId, userIds, txn, hsApi, log); + // TODO: check here if userIds is safe? yes it is + return await this._devicesForUserIdsInTrackedRoom(roomId, userIds, txn, hsApi, log); } - /** gets devices for the given user ids that are in the given room */ + /** + * Can be used to decide which users to share keys with. + * Assumes room is already tracked. Call `trackRoom` first if unsure. + */ async devicesForRoomMembers(roomId, userIds, hsApi, log) { const txn = await this._storage.readTxn([ this._storage.storeNames.userIdentities, ]); - return await this._devicesForUserIds(roomId, userIds, txn, hsApi, log); + return await this._devicesForUserIdsInTrackedRoom(roomId, userIds, txn, hsApi, log); + } + + /** + * Cannot be used to decide which users to share keys with. + * Does not assume membership to any room or whether any room is tracked. + */ + async devicesForUsers(userIds, hsApi, log) { + const txn = await this._storage.readTxn([ + this._storage.storeNames.userIdentities, + ]); + + const upToDateIdentities = []; + const outdatedUserIds = []; + await Promise.all(userIds.map(async userId => { + const i = await txn.userIdentities.get(userId); + if (i && i.deviceTrackingStatus === TRACKING_STATUS_UPTODATE) { + upToDateIdentities.push(i); + } else if (!i || i.deviceTrackingStatus === TRACKING_STATUS_OUTDATED) { + // allow fetching for userIdentities we don't know about yet, + // as we don't assume the room is tracked here. + outdatedUserIds.push(userId); + } + })); + return this._devicesForUserIdentities(upToDateIdentities, outdatedUserIds, hsApi, log); } /** gets a single device */ @@ -372,29 +454,50 @@ export class DeviceTracker { } /** - * @param {string} roomId [description] - * @param {Array} userIds a set of user ids to try and find the identity for. Will be check to belong to roomId. + * 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. + * @param {string} roomId the id of the tracked room to filter users by. + * @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} + * @return {Array} all devices identities for the given users we should share keys with. */ - async _devicesForUserIds(roomId, userIds, userIdentityTxn, hsApi, log) { + async _devicesForUserIdsInTrackedRoom(roomId, userIds, userIdentityTxn, hsApi, log) { const allMemberIdentities = await Promise.all(userIds.map(userId => userIdentityTxn.userIdentities.get(userId))); const identities = allMemberIdentities.filter(identity => { - // identity will be missing for any userIds that don't have - // membership join in any of your encrypted rooms + // we use roomIds to decide with whom we should share keys for a given room, + // taking into account the membership and room history visibility. + // so filter out anyone who we shouldn't share keys with. + // 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); - const outdatedIdentities = identities.filter(i => i.deviceTrackingStatus === TRACKING_STATUS_OUTDATED); + const outdatedUserIds = identities + .filter(i => i.deviceTrackingStatus === TRACKING_STATUS_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; + return !isOwnDevice; + }); + return devices; + } + + /** Gets the device identites for a set of user identities that + * 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) { log.set("uptodate", upToDateIdentities.length); - log.set("outdated", outdatedIdentities.length); + log.set("outdated", outdatedUserIds.length); let queriedDevices; - if (outdatedIdentities.length) { + 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(outdatedIdentities.map(i => i.userId), hsApi, log); + queriedDevices = await this._queryKeys(outdatedUserIds, hsApi, log); } const deviceTxn = await this._storage.readTxn([ @@ -407,12 +510,7 @@ export class DeviceTracker { if (queriedDevices && queriedDevices.length) { flattenedDevices = flattenedDevices.concat(queriedDevices); } - // filter out our own device - const devices = flattenedDevices.filter(device => { - const isOwnDevice = device.userId === this._ownUserId && device.deviceId === this._ownDeviceId; - return !isOwnDevice; - }); - return devices; + return flattenedDevices; } async getDeviceByCurve25519Key(curve25519Key, txn) { @@ -422,16 +520,18 @@ export class DeviceTracker { import {createMockStorage} from "../../mocks/Storage"; import {Instance as NullLoggerInstance} from "../../logging/NullLogger"; +import {MemberChange} from "../room/members/RoomMember"; export function tests() { function createUntrackedRoomMock(roomId, joinedUserIds, invitedUserIds = []) { return { + id: roomId, isTrackingMembers: false, isEncrypted: true, loadMemberList: () => { - const joinedMembers = joinedUserIds.map(userId => {return {membership: "join", roomId, userId};}); - const invitedMembers = invitedUserIds.map(userId => {return {membership: "invite", roomId, userId};}); + const joinedMembers = joinedUserIds.map(userId => {return RoomMember.fromUserId(roomId, userId, "join");}); + const invitedMembers = invitedUserIds.map(userId => {return RoomMember.fromUserId(roomId, userId, "invite");}); const members = joinedMembers.concat(invitedMembers); const memberMap = members.reduce((map, member) => { map.set(member.userId, member); @@ -495,10 +595,29 @@ export function tests() { } }; } + + async function writeMemberListToStorage(room, storage) { + const txn = await storage.readWriteTxn([ + storage.storeNames.roomMembers, + ]); + const memberList = await room.loadMemberList(txn); + try { + for (const member of memberList.members.values()) { + txn.roomMembers.set(member.serialize()); + } + } catch (err) { + txn.abort(); + throw err; + } finally { + memberList.release(); + } + await txn.complete(); + } + const roomId = "!abc:hs.tld"; return { - "trackRoom only writes joined members": async assert => { + "trackRoom only writes joined members with history visibility of joined": async assert => { const storage = await createMockStorage(); const tracker = new DeviceTracker({ storage, @@ -508,7 +627,7 @@ export function tests() { ownDeviceId: "ABCD", }); const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"], ["@charly:hs.tld"]); - await tracker.trackRoom(room, NullLoggerInstance.item); + await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item); const txn = await storage.readTxn([storage.storeNames.userIdentities]); assert.deepEqual(await txn.userIdentities.get("@alice:hs.tld"), { userId: "@alice:hs.tld", @@ -532,7 +651,7 @@ export function tests() { ownDeviceId: "ABCD", }); const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"]); - await tracker.trackRoom(room, NullLoggerInstance.item); + await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item); const hsApi = createQueryKeysHSApiMock(); const devices = await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApi, NullLoggerInstance.item); assert.equal(devices.length, 2); @@ -549,7 +668,7 @@ export function tests() { ownDeviceId: "ABCD", }); const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld", "@bob:hs.tld"]); - await tracker.trackRoom(room, NullLoggerInstance.item); + await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item); const hsApi = createQueryKeysHSApiMock(); // query devices first time await tracker.devicesForRoomMembers(roomId, ["@alice:hs.tld", "@bob:hs.tld"], hsApi, NullLoggerInstance.item); @@ -567,6 +686,199 @@ export function tests() { const txn2 = await storage.readTxn([storage.storeNames.deviceIdentities]); // also check the modified key was not stored assert.equal((await txn2.deviceIdentities.get("@alice:hs.tld", "device1")).ed25519Key, "ed25519:@alice:hs.tld:device1:key"); + }, + "change history visibility from joined to invited adds invitees": async assert => { + const storage = await createMockStorage(); + const tracker = new DeviceTracker({ + storage, + getSyncToken: () => "token", + olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + ownUserId: "@alice:hs.tld", + ownDeviceId: "ABCD", + }); + // alice is joined, bob is invited + const room = await createUntrackedRoomMock(roomId, + ["@alice:hs.tld"], ["@bob:hs.tld"]); + await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item); + const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined); + const {added, removed} = await tracker.writeHistoryVisibility(room, HistoryVisibility.Invited, txn, NullLoggerInstance.item); + assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld"); + assert.deepEqual(added, ["@bob:hs.tld"]); + assert.deepEqual(removed, []); + }, + "change history visibility from invited to joined removes invitees": async assert => { + const storage = await createMockStorage(); + const tracker = new DeviceTracker({ + storage, + getSyncToken: () => "token", + olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + ownUserId: "@alice:hs.tld", + ownDeviceId: "ABCD", + }); + // alice is joined, bob is invited + const room = await createUntrackedRoomMock(roomId, + ["@alice:hs.tld"], ["@bob:hs.tld"]); + await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item); + const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld"); + const {added, removed} = await tracker.writeHistoryVisibility(room, HistoryVisibility.Joined, txn, NullLoggerInstance.item); + assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined); + assert.deepEqual(added, []); + assert.deepEqual(removed, ["@bob:hs.tld"]); + }, + "adding invitee with history visibility of invited adds room to userIdentities": async assert => { + const storage = await createMockStorage(); + const tracker = new DeviceTracker({ + storage, + getSyncToken: () => "token", + olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + ownUserId: "@alice:hs.tld", + ownDeviceId: "ABCD", + }); + const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"]); + await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item); + const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + // inviting a new member + const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "invite")); + const {added, removed} = await tracker.writeMemberChanges(room, [inviteChange], HistoryVisibility.Invited, txn); + assert.deepEqual(added, ["@bob:hs.tld"]); + assert.deepEqual(removed, []); + assert.equal((await txn.userIdentities.get("@bob:hs.tld")).userId, "@bob:hs.tld"); + }, + "adding invitee with history visibility of joined doesn't add room": async assert => { + const storage = await createMockStorage(); + const tracker = new DeviceTracker({ + storage, + getSyncToken: () => "token", + olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + ownUserId: "@alice:hs.tld", + ownDeviceId: "ABCD", + }); + const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"]); + await tracker.trackRoom(room, HistoryVisibility.Joined, NullLoggerInstance.item); + const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + // inviting a new member + const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "invite")); + const memberChanges = new Map([[inviteChange.userId, inviteChange]]); + const {added, removed} = await tracker.writeMemberChanges(room, memberChanges, HistoryVisibility.Joined, txn); + assert.deepEqual(added, []); + assert.deepEqual(removed, []); + assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined); + }, + "getting all devices after changing history visibility now includes invitees": async assert => { + const storage = await createMockStorage(); + const tracker = new DeviceTracker({ + storage, + getSyncToken: () => "token", + olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + ownUserId: "@alice:hs.tld", + ownDeviceId: "ABCD", + }); + const room = createUntrackedRoomMock(roomId, ["@alice:hs.tld"], ["@bob:hs.tld"]); + await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item); + const hsApi = createQueryKeysHSApiMock(); + // write memberlist from room mock to mock storage, + // as devicesForTrackedRoom reads directly from roomMembers store. + await writeMemberListToStorage(room, storage); + const devices = await tracker.devicesForTrackedRoom(roomId, hsApi, NullLoggerInstance.item); + assert.equal(devices.length, 2); + assert.equal(devices.find(d => d.userId === "@alice:hs.tld").ed25519Key, "ed25519:@alice:hs.tld:device1:key"); + assert.equal(devices.find(d => d.userId === "@bob:hs.tld").ed25519Key, "ed25519:@bob:hs.tld:device1:key"); + }, + "rejecting invite with history visibility of invited removes room from user identity": async assert => { + const storage = await createMockStorage(); + const tracker = new DeviceTracker({ + storage, + getSyncToken: () => "token", + olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + ownUserId: "@alice:hs.tld", + ownDeviceId: "ABCD", + }); + // alice is joined, bob is invited + const room = await createUntrackedRoomMock(roomId, ["@alice:hs.tld"], ["@bob:hs.tld"]); + await tracker.trackRoom(room, HistoryVisibility.Invited, NullLoggerInstance.item); + const txn = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + // reject invite + const inviteChange = new MemberChange(RoomMember.fromUserId(roomId, "@bob:hs.tld", "leave"), "invite"); + const memberChanges = new Map([[inviteChange.userId, inviteChange]]); + const {added, removed} = await tracker.writeMemberChanges(room, memberChanges, HistoryVisibility.Invited, txn); + assert.deepEqual(added, []); + assert.deepEqual(removed, ["@bob:hs.tld"]); + assert.equal(await txn.userIdentities.get("@bob:hs.tld"), undefined); + }, + "remove room from user identity sharing multiple rooms with us preserves other room": async assert => { + const storage = await createMockStorage(); + const tracker = new DeviceTracker({ + storage, + getSyncToken: () => "token", + olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + ownUserId: "@alice:hs.tld", + ownDeviceId: "ABCD", + }); + // alice is joined, bob is invited + const room1 = await createUntrackedRoomMock("!abc:hs.tld", ["@alice:hs.tld", "@bob:hs.tld"]); + const room2 = await createUntrackedRoomMock("!def:hs.tld", ["@alice:hs.tld", "@bob:hs.tld"]); + await tracker.trackRoom(room1, HistoryVisibility.Joined, NullLoggerInstance.item); + await tracker.trackRoom(room2, HistoryVisibility.Joined, NullLoggerInstance.item); + const txn1 = await storage.readTxn([storage.storeNames.userIdentities]); + assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld", "!def:hs.tld"]); + const leaveChange = new MemberChange(RoomMember.fromUserId(room2.id, "@bob:hs.tld", "leave"), "join"); + const memberChanges = new Map([[leaveChange.userId, leaveChange]]); + const txn2 = await storage.readWriteTxn([storage.storeNames.userIdentities, storage.storeNames.deviceIdentities]); + await tracker.writeMemberChanges(room2, memberChanges, HistoryVisibility.Joined, txn2); + await txn2.complete(); + const txn3 = await storage.readTxn([storage.storeNames.userIdentities]); + assert.deepEqual((await txn3.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld"]); + }, + "add room to user identity sharing multiple rooms with us preserves other room": async assert => { + const storage = await createMockStorage(); + const tracker = new DeviceTracker({ + storage, + getSyncToken: () => "token", + olmUtil: {ed25519_verify: () => {}}, // valid if it does not throw + ownUserId: "@alice:hs.tld", + ownDeviceId: "ABCD", + }); + // alice is joined, bob is invited + const room1 = await createUntrackedRoomMock("!abc:hs.tld", ["@alice:hs.tld", "@bob:hs.tld"]); + const room2 = await createUntrackedRoomMock("!def:hs.tld", ["@alice:hs.tld", "@bob:hs.tld"]); + await tracker.trackRoom(room1, HistoryVisibility.Joined, NullLoggerInstance.item); + const txn1 = await storage.readTxn([storage.storeNames.userIdentities]); + assert.deepEqual((await txn1.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld"]); + await tracker.trackRoom(room2, HistoryVisibility.Joined, NullLoggerInstance.item); + const txn2 = await storage.readTxn([storage.storeNames.userIdentities]); + assert.deepEqual((await txn2.userIdentities.get("@bob:hs.tld")).roomIds, ["!abc:hs.tld", "!def:hs.tld"]); + }, + "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 + 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"); + const txn1 = await storage.readTxn([storage.storeNames.userIdentities]); + 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 + 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, []); } } } diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index cb0dd333..b74dc710 100644 --- a/src/matrix/e2ee/RoomEncryption.js +++ b/src/matrix/e2ee/RoomEncryption.js @@ -19,8 +19,10 @@ import {groupEventsBySession} from "./megolm/decryption/utils"; import {mergeMap} from "../../utils/mergeMap"; import {groupBy} from "../../utils/groupBy"; import {makeTxnId, formatToDeviceMessagesPayload} from "../common.js"; +import {iterateResponseStateEvents} from "../room/common"; const ENCRYPTED_TYPE = "m.room.encrypted"; +const ROOM_HISTORY_VISIBILITY_TYPE = "m.room.history_visibility"; // how often ensureMessageKeyIsShared can check if it needs to // create a new outbound session // note that encrypt could still create a new session @@ -45,6 +47,7 @@ export class RoomEncryption { this._isFlushingRoomKeyShares = false; this._lastKeyPreShareTime = null; this._keySharePromise = null; + this._historyVisibility = undefined; this._disposed = false; } @@ -77,22 +80,68 @@ export class RoomEncryption { this._senderDeviceCache = new Map(); // purge the sender device cache } - async writeMemberChanges(memberChanges, txn, log) { - let shouldFlush = false; - const memberChangesArray = Array.from(memberChanges.values()); - // this also clears our session if we leave the room ourselves - if (memberChangesArray.some(m => m.hasLeft)) { + async writeSync(roomResponse, memberChanges, txn, log) { + let historyVisibility = await this._loadHistoryVisibilityIfNeeded(this._historyVisibility, txn); + const addedMembers = []; + const removedMembers = []; + // update the historyVisibility if needed + await iterateResponseStateEvents(roomResponse, event => { + // TODO: can the same state event appear twice? Hence we would be rewriting the useridentities twice... + // we'll see in the logs + if(event.state_key === "" && event.type === ROOM_HISTORY_VISIBILITY_TYPE) { + const newHistoryVisibility = event?.content?.history_visibility; + if (newHistoryVisibility !== historyVisibility) { + return log.wrap({ + l: "history_visibility changed", + from: historyVisibility, + to: newHistoryVisibility + }, async log => { + historyVisibility = newHistoryVisibility; + const result = await this._deviceTracker.writeHistoryVisibility(this._room, historyVisibility, txn, log); + addedMembers.push(...result.added); + removedMembers.push(...result.removed); + }); + } + } + }); + // process member changes + if (memberChanges.size) { + const result = await this._deviceTracker.writeMemberChanges( + this._room, memberChanges, historyVisibility, txn); + addedMembers.push(...result.added); + removedMembers.push(...result.removed); + } + // discard key if somebody (including ourselves) left + if (removedMembers.length) { log.log({ l: "discardOutboundSession", - leftUsers: memberChangesArray.filter(m => m.hasLeft).map(m => m.userId), + leftUsers: removedMembers, }); this._megolmEncryption.discardOutboundSession(this._room.id, txn); } - if (memberChangesArray.some(m => m.hasJoined)) { - shouldFlush = await this._addShareRoomKeyOperationForNewMembers(memberChangesArray, txn, log); + let shouldFlush = false; + // add room to userIdentities if needed, and share the current key with them + if (addedMembers.length) { + shouldFlush = await this._addShareRoomKeyOperationForMembers(addedMembers, txn, log); } - await this._deviceTracker.writeMemberChanges(this._room, memberChanges, txn); - return shouldFlush; + return {shouldFlush, historyVisibility}; + } + + afterSync({historyVisibility}) { + this._historyVisibility = historyVisibility; + } + + async _loadHistoryVisibilityIfNeeded(historyVisibility, txn = undefined) { + if (!historyVisibility) { + if (!txn) { + txn = await this._storage.readTxn([this._storage.storeNames.roomState]); + } + const visibilityEntry = await txn.roomState.get(this._room.id, ROOM_HISTORY_VISIBILITY_TYPE, ""); + if (visibilityEntry) { + return visibilityEntry.event?.content?.history_visibility; + } + } + return historyVisibility; } async prepareDecryptAll(events, newKeys, source, txn) { @@ -159,17 +208,44 @@ export class RoomEncryption { }); } - async _verifyDecryptionResult(result, txn) { - let device = this._senderDeviceCache.get(result.senderCurve25519Key); - if (!device) { - device = await this._deviceTracker.getDeviceByCurve25519Key(result.senderCurve25519Key, txn); - this._senderDeviceCache.set(result.senderCurve25519Key, device); - } - if (device) { - result.setDevice(device); - } else if (!this._room.isTrackingMembers) { - result.setRoomNotTrackedYet(); + async _verifyDecryptionResults(results, txn) { + await Promise.all(results.map(async result => { + let device = this._senderDeviceCache.get(result.senderCurve25519Key); + if (!device) { + device = await this._deviceTracker.getDeviceByCurve25519Key(result.senderCurve25519Key, txn); + this._senderDeviceCache.set(result.senderCurve25519Key, device); + } + if (device) { + result.setDevice(device); + } + })); + } + + /** fetches the devices that are not yet known locally from the homeserver to verify the sender of this message. */ + async _fetchKeyAndVerifyDecryptionResults(results, hsApi, log) { + const resultsWithoutDevice = results.filter(r => r.isVerificationUnknown); + if (resultsWithoutDevice.length) { + return log.wrap("fetch unverified senders", async log => { + const sendersWithoutDevice = Array.from(resultsWithoutDevice.reduce((senders, r) => { + return senders.add(r.encryptedEvent.sender); + }, new Set())); + log.set("senders", sendersWithoutDevice); + // Fetch the devices, ignore return value, and just reuse + // _verifyDecryptionResults method so we only have one impl how to verify. + // 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]); + await this._verifyDecryptionResults(resultsWithoutDevice, txn); + const resultsWithFoundDevice = resultsWithoutDevice.filter(r => !r.isVerificationUnknown); + const resultsToEventIdMap = resultsWithFoundDevice.reduce((map, r) => { + map.set(r.encryptedEvent.event_id, r); + return map; + }, new Map()); + return new BatchDecryptionResult(resultsToEventIdMap, new Map(), this); + }); } + return new BatchDecryptionResult(new Map(), new Map(), this); } async _requestMissingSessionFromBackup(senderKey, sessionId, log) { @@ -274,10 +350,15 @@ export class RoomEncryption { } async _shareNewRoomKey(roomKeyMessage, hsApi, log) { + this._historyVisibility = await this._loadHistoryVisibilityIfNeeded(this._historyVisibility); + await this._deviceTracker.trackRoom(this._room, this._historyVisibility, log); + const devices = await this._deviceTracker.devicesForTrackedRoom(this._room.id, hsApi, log); + const userIds = Array.from(devices.reduce((set, device) => set.add(device.userId), new Set())); + let writeOpTxn = await this._storage.readWriteTxn([this._storage.storeNames.operations]); let operation; try { - operation = this._writeRoomKeyShareOperation(roomKeyMessage, null, writeOpTxn); + operation = this._writeRoomKeyShareOperation(roomKeyMessage, userIds, writeOpTxn); } catch (err) { writeOpTxn.abort(); throw err; @@ -288,8 +369,7 @@ export class RoomEncryption { await this._processShareRoomKeyOperation(operation, hsApi, log); } - async _addShareRoomKeyOperationForNewMembers(memberChangesArray, txn, log) { - const userIds = memberChangesArray.filter(m => m.hasJoined).map(m => m.userId); + async _addShareRoomKeyOperationForMembers(userIds, txn, log) { const roomKeyMessage = await this._megolmEncryption.createRoomKeyMessage( this._room.id, txn); if (roomKeyMessage) { @@ -342,18 +422,9 @@ export class RoomEncryption { async _processShareRoomKeyOperation(operation, hsApi, log) { log.set("id", operation.id); - - await this._deviceTracker.trackRoom(this._room, log); - let devices; - if (operation.userIds === null) { - devices = await this._deviceTracker.devicesForTrackedRoom(this._room.id, hsApi, log); - const userIds = Array.from(devices.reduce((set, device) => set.add(device.userId), new Set())); - operation.userIds = userIds; - await this._updateOperationsStore(operations => operations.update(operation)); - } else { - devices = await this._deviceTracker.devicesForRoomMembers(this._room.id, operation.userIds, hsApi, log); - } - + this._historyVisibility = await this._loadHistoryVisibilityIfNeeded(this._historyVisibility); + await this._deviceTracker.trackRoom(this._room, this._historyVisibility, log); + const devices = await this._deviceTracker.devicesForRoomMembers(this._room.id, operation.userIds, hsApi, log); const messages = await log.wrap("olm encrypt", log => this._olmEncryption.encrypt( "m.room_key", operation.roomKeyMessage, devices, hsApi, log)); const missingDevices = devices.filter(d => !messages.some(m => m.device === d)); @@ -479,23 +550,180 @@ class BatchDecryptionResult { this._roomEncryption = roomEncryption; } - applyToEntries(entries) { + applyToEntries(entries, callback = undefined) { for (const entry of entries) { const result = this.results.get(entry.id); if (result) { entry.setDecryptionResult(result); + callback?.(entry); } else { const error = this.errors.get(entry.id); if (error) { entry.setDecryptionError(error); + callback?.(entry); } } } } - verifySenders(txn) { - return Promise.all(Array.from(this.results.values()).map(result => { - return this._roomEncryption._verifyDecryptionResult(result, txn); - })); + /** Verify the decryption results by looking for the corresponding device in local persistance + * @returns {BatchDecryptionResult} a new batch result with the results for which we now found a device */ + verifyKnownSenders(txn) { + return this._roomEncryption._verifyDecryptionResults(Array.from(this.results.values()), txn); + } + + get hasUnverifiedSenders() { + for (const r of this.results.values()) { + if (r.isVerificationUnknown) { + return true; + } + } + return false; + } + + /** Verify any decryption results for which we could not find a device when + * calling `verifyKnownSenders` prior, by fetching them from the homeserver. + * @returns {Promise} the results for which we found a device */ + fetchAndVerifyRemainingSenders(hsApi, log) { + return this._roomEncryption._fetchKeyAndVerifyDecryptionResults(Array.from(this.results.values()), hsApi, log); + } +} + +import {createMockStorage} from "../../mocks/Storage"; +import {Clock as MockClock} from "../../mocks/Clock"; +import {poll} from "../../mocks/poll"; +import {Instance as NullLoggerInstance} from "../../logging/NullLogger"; +import {HomeServer as MockHomeServer} from "../../mocks/HomeServer.js"; + +export function tests() { + const roomId = "!abc:hs.tld"; + return { + "ensureMessageKeyIsShared tracks room and passes correct history visibility to deviceTracker": async assert => { + const storage = await createMockStorage(); + const megolmMock = { + async ensureOutboundSession() { return { }; } + }; + const olmMock = { + async encrypt() { return []; } + } + let isRoomTracked = false; + let isDevicesRequested = false; + const deviceTracker = { + async trackRoom(room, historyVisibility) { + // only assert on first call + if (isRoomTracked) { return; } + assert(!isDevicesRequested); + assert.equal(room.id, roomId); + assert.equal(historyVisibility, "invited"); + isRoomTracked = true; + }, + async devicesForTrackedRoom() { + assert(isRoomTracked); + isDevicesRequested = true; + return []; + }, + async devicesForRoomMembers() { + return []; + } + } + const writeTxn = await storage.readWriteTxn([storage.storeNames.roomState]); + writeTxn.roomState.set(roomId, {state_key: "", type: ROOM_HISTORY_VISIBILITY_TYPE, content: { + history_visibility: "invited" + }}); + await writeTxn.complete(); + const roomEncryption = new RoomEncryption({ + room: {id: roomId}, + megolmEncryption: megolmMock, + olmEncryption: olmMock, + storage, + deviceTracker, + clock: new MockClock() + }); + const homeServer = new MockHomeServer(); + const promise = roomEncryption.ensureMessageKeyIsShared(homeServer.api, NullLoggerInstance.item); + // need to poll because sendToDevice isn't first async step + const request = await poll(() => homeServer.requests.sendToDevice?.[0]); + request.respond({}); + await promise; + assert(isRoomTracked); + assert(isDevicesRequested); + }, + "encrypt tracks room and passes correct history visibility to deviceTracker": async assert => { + const storage = await createMockStorage(); + const megolmMock = { + async encrypt() { return { roomKeyMessage: {} }; } + }; + const olmMock = { + async encrypt() { return []; } + } + let isRoomTracked = false; + let isDevicesRequested = false; + const deviceTracker = { + async trackRoom(room, historyVisibility) { + // only assert on first call + if (isRoomTracked) { return; } + assert(!isDevicesRequested); + assert.equal(room.id, roomId); + assert.equal(historyVisibility, "invited"); + isRoomTracked = true; + }, + async devicesForTrackedRoom() { + assert(isRoomTracked); + isDevicesRequested = true; + return []; + }, + async devicesForRoomMembers() { + return []; + } + } + const writeTxn = await storage.readWriteTxn([storage.storeNames.roomState]); + writeTxn.roomState.set(roomId, {state_key: "", type: ROOM_HISTORY_VISIBILITY_TYPE, content: { + history_visibility: "invited" + }}); + await writeTxn.complete(); + const roomEncryption = new RoomEncryption({ + room: {id: roomId}, + megolmEncryption: megolmMock, + olmEncryption: olmMock, + storage, + deviceTracker + }); + const homeServer = new MockHomeServer(); + const promise = roomEncryption.encrypt("m.room.message", {body: "hello"}, homeServer.api, NullLoggerInstance.item); + // need to poll because sendToDevice isn't first async step + const request = await poll(() => homeServer.requests.sendToDevice?.[0]); + request.respond({}); + await promise; + assert(isRoomTracked); + assert(isDevicesRequested); + }, + "writeSync passes correct history visibility to deviceTracker": async assert => { + const storage = await createMockStorage(); + let isMemberChangesCalled = false; + const deviceTracker = { + async writeMemberChanges(room, memberChanges, historyVisibility) { + assert.equal(historyVisibility, "invited"); + isMemberChangesCalled = true; + return {removed: [], added: []}; + }, + async devicesForRoomMembers() { + return []; + } + } + const writeTxn = await storage.readWriteTxn([storage.storeNames.roomState]); + writeTxn.roomState.set(roomId, {state_key: "", type: ROOM_HISTORY_VISIBILITY_TYPE, content: { + history_visibility: "invited" + }}); + const memberChanges = new Map([["@alice:hs.tld", {}]]); + const roomEncryption = new RoomEncryption({ + room: {id: roomId}, + storage, + deviceTracker + }); + const roomResponse = {}; + const txn = await storage.readWriteTxn([storage.storeNames.roomState]); + await roomEncryption.writeSync(roomResponse, memberChanges, txn, NullLoggerInstance.item); + assert(isMemberChangesCalled); + }, } } diff --git a/src/matrix/e2ee/common.js b/src/matrix/e2ee/common.js index 2b9d46b9..cc3bfff5 100644 --- a/src/matrix/e2ee/common.js +++ b/src/matrix/e2ee/common.js @@ -69,3 +69,28 @@ export function createRoomEncryptionEvent() { } } } + + +// Use enum when converting to TS +export const HistoryVisibility = Object.freeze({ + Joined: "joined", + Invited: "invited", + WorldReadable: "world_readable", + Shared: "shared", +}); + +export function shouldShareKey(membership, historyVisibility) { + switch (historyVisibility) { + case HistoryVisibility.WorldReadable: + return true; + case HistoryVisibility.Shared: + // was part of room at some time + return membership !== undefined; + case HistoryVisibility.Joined: + return membership === "join"; + case HistoryVisibility.Invited: + return membership === "invite" || membership === "join"; + default: + return false; + } +} diff --git a/src/matrix/e2ee/megolm/decryption/SessionDecryption.ts b/src/matrix/e2ee/megolm/decryption/SessionDecryption.ts index 57ef9a96..ca294460 100644 --- a/src/matrix/e2ee/megolm/decryption/SessionDecryption.ts +++ b/src/matrix/e2ee/megolm/decryption/SessionDecryption.ts @@ -31,17 +31,14 @@ interface DecryptAllResult { * Does the actual decryption of all events for a given megolm session in a batch */ export class SessionDecryption { - private key: RoomKey; - private events: TimelineEvent[]; - private keyLoader: KeyLoader; - private olmWorker?: OlmWorker; private decryptionRequests?: any[]; - constructor(key: RoomKey, events: TimelineEvent[], olmWorker: OlmWorker | undefined, keyLoader: KeyLoader) { - this.key = key; - this.events = events; - this.olmWorker = olmWorker; - this.keyLoader = keyLoader; + constructor( + private readonly key: RoomKey, + private readonly events: TimelineEvent[], + private readonly olmWorker: OlmWorker | undefined, + private readonly keyLoader: KeyLoader + ) { this.decryptionRequests = olmWorker ? [] : undefined; } @@ -75,7 +72,7 @@ export class SessionDecryption { {encryptedRoomId: payload.room_id, eventRoomId: this.key.roomId}); } replayEntries.push(new ReplayDetectionEntry(this.key.sessionId, decryptionResult!.message_index, event)); - const result = new DecryptionResult(payload, this.key.senderKey, this.key.claimedEd25519Key); + const result = new DecryptionResult(payload, this.key.senderKey, this.key.claimedEd25519Key, event); results.set(event.event_id, result); } catch (err) { // ignore AbortError from cancelling decryption requests in dispose method diff --git a/src/matrix/e2ee/megolm/keybackup/KeyBackup.ts b/src/matrix/e2ee/megolm/keybackup/KeyBackup.ts index 3da1c704..bcfbf85a 100644 --- a/src/matrix/e2ee/megolm/keybackup/KeyBackup.ts +++ b/src/matrix/e2ee/megolm/keybackup/KeyBackup.ts @@ -19,7 +19,7 @@ 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/value/ObservableValue"; +import {ObservableValue} from "../../../../observable/value"; import {SetAbortableFn} from "../../../../utils/AbortableOperation"; import type {BackupInfo, SessionData, SessionKeyInfo, SessionInfo, KeyBackupPayload} from "./types"; diff --git a/src/matrix/login/SSOLoginHelper.ts b/src/matrix/login/SSOLoginHelper.ts index 0fe3d6b8..1d2dc7d0 100644 --- a/src/matrix/login/SSOLoginHelper.ts +++ b/src/matrix/login/SSOLoginHelper.ts @@ -24,6 +24,6 @@ export class SSOLoginHelper{ get homeserver(): string { return this._homeserver; } createSSORedirectURL(returnURL: string): string { - return `${this._homeserver}/_matrix/client/r0/login/sso/redirect?redirectUrl=${returnURL}`; + return `${this._homeserver}/_matrix/client/r0/login/sso/redirect?redirectUrl=${encodeURIComponent(returnURL)}`; } } diff --git a/src/matrix/login/index.ts b/src/matrix/login/index.ts new file mode 100644 index 00000000..ba133a26 --- /dev/null +++ b/src/matrix/login/index.ts @@ -0,0 +1,7 @@ +import {ILoginMethod} from "./LoginMethod"; +import {PasswordLoginMethod} from "./PasswordLoginMethod"; +import {SSOLoginHelper} from "./SSOLoginHelper"; +import {TokenLoginMethod} from "./TokenLoginMethod"; + + +export {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod}; \ No newline at end of file diff --git a/src/matrix/net/HomeServerApi.ts b/src/matrix/net/HomeServerApi.ts index 828a1c82..923f23e3 100644 --- a/src/matrix/net/HomeServerApi.ts +++ b/src/matrix/net/HomeServerApi.ts @@ -168,7 +168,7 @@ export class HomeServerApi { return this._unauthedRequest("GET", this._url("/login")); } - register(username: string | null, password: string, initialDeviceDisplayName: string, auth?: Record, inhibitLogin: boolean = true , options: BaseRequestOptions = {}): IHomeServerRequest { + register(username: string | null, password: string, initialDeviceDisplayName: string, auth?: Record, inhibitLogin: boolean = false , options: BaseRequestOptions = {}): IHomeServerRequest { options.allowedStatusCodes = [401]; const body: any = { auth, diff --git a/src/matrix/net/Reconnector.ts b/src/matrix/net/Reconnector.ts index a3739425..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/value/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/registration/Registration.ts b/src/matrix/registration/Registration.ts index ded66719..5440d66d 100644 --- a/src/matrix/registration/Registration.ts +++ b/src/matrix/registration/Registration.ts @@ -25,6 +25,7 @@ import type { RegistrationResponseMoreDataNeeded, RegistrationResponse, RegistrationResponseSuccess, + AuthData, RegistrationParams, } from "./types"; @@ -34,9 +35,11 @@ export class Registration { private readonly _hsApi: HomeServerApi; private readonly _accountDetails: AccountDetails; private readonly _flowSelector: FlowSelector; - private _sessionInfo?: RegistrationResponseSuccess + private _registerResponse?: RegistrationResponseSuccess; + public readonly homeserver: string; - constructor(hsApi: HomeServerApi, accountDetails: AccountDetails, flowSelector?: FlowSelector) { + constructor(homeserver: string, hsApi: HomeServerApi, accountDetails: AccountDetails, flowSelector?: FlowSelector) { + this.homeserver = homeserver; this._hsApi = hsApi; this._accountDetails = accountDetails; this._flowSelector = flowSelector ?? (flows => flows[0]); @@ -91,7 +94,7 @@ export class Registration { private async parseRegistrationResponse(response: RegistrationResponse, currentStage: BaseRegistrationStage) { switch (response.status) { case 200: - this._sessionInfo = response; + this._registerResponse = response; return undefined; case 401: if (response.completed?.includes(currentStage.type)) { @@ -117,7 +120,14 @@ export class Registration { } } - get sessionInfo(): RegistrationResponseSuccess | undefined { - return this._sessionInfo; + get authData(): AuthData | undefined { + if (this._registerResponse) { + return { + accessToken: this._registerResponse.access_token, + homeserver: this.homeserver, + userId: this._registerResponse.user_id, + deviceId: this._registerResponse.device_id, + }; + } } } diff --git a/src/matrix/registration/types.ts b/src/matrix/registration/types.ts index f1ddbe98..7e0d01ff 100644 --- a/src/matrix/registration/types.ts +++ b/src/matrix/registration/types.ts @@ -38,6 +38,13 @@ export type RegistrationResponseSuccess = { status: 200; } +export type AuthData = { + userId: string; + deviceId: string; + homeserver: string; + accessToken?: string; +} + export type RegistrationFlow = { stages: string[]; } diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index 9ca75a24..7eb73faf 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -29,7 +29,7 @@ import {ObservedEventMap} from "./ObservedEventMap.js"; import {DecryptionSource} from "../e2ee/common.js"; import {ensureLogItem} from "../../logging/utils"; import {PowerLevels} from "./PowerLevels.js"; -import {RetainedObservableValue} from "../../observable/value/RetainedObservableValue"; +import {RetainedObservableValue} from "../../observable/value"; import {TimelineReader} from "./timeline/persistence/TimelineReader"; import {ObservedStateTypeMap} from "./state/ObservedStateTypeMap"; import {ObservedStateKeyValue} from "./state/ObservedStateKeyValue"; @@ -180,7 +180,7 @@ export class BaseRoom extends EventEmitter { try { decryption = await changes.write(writeTxn, log); if (isTimelineOpen) { - await decryption.verifySenders(writeTxn); + await decryption.verifyKnownSenders(writeTxn); } } catch (err) { writeTxn.abort(); @@ -192,6 +192,16 @@ export class BaseRoom extends EventEmitter { if (this._observedEvents) { this._observedEvents.updateEvents(entries); } + if (isTimelineOpen && decryption.hasUnverifiedSenders) { + // verify missing senders async and update timeline once done so we don't delay rendering with network requests + log.wrapDetached("fetch unknown senders keys", async log => { + const newlyVerifiedDecryption = await decryption.fetchAndVerifyRemainingSenders(this._hsApi, log); + const verifiedEntries = []; + newlyVerifiedDecryption.applyToEntries(entries, entry => verifiedEntries.push(entry)); + this._timeline?.replaceEntries(verifiedEntries); + this._observedEvents?.updateEvents(verifiedEntries); + }); + } }, ensureLogItem(log)); return request; } @@ -269,7 +279,7 @@ export class BaseRoom extends EventEmitter { /** @public */ - async loadMemberList(log = null) { + async loadMemberList(txn = undefined, log = null) { if (this._memberList) { // TODO: also await fetchOrLoadMembers promise here this._memberList.retain(); @@ -280,6 +290,9 @@ export class BaseRoom extends EventEmitter { roomId: this._roomId, hsApi: this._hsApi, storage: this._storage, + // pass in a transaction if we know we won't need to fetch (which would abort the transaction) + // and we want to make this operation part of the larger transaction + txn, syncToken: this._getSyncToken(), // to handle race between /members and /sync setChangedMembersMap: map => this._changedMembersDuringSync = map, diff --git a/src/matrix/room/ObservedEventMap.js b/src/matrix/room/ObservedEventMap.js index 8ee1bca8..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/value/BaseObservableValue"; +import {BaseObservableValue} from "../../observable/value"; export class ObservedEventMap { constructor(notifyEmpty) { diff --git a/src/matrix/room/PowerLevels.js b/src/matrix/room/PowerLevels.js index bb723c34..76e062ef 100644 --- a/src/matrix/room/PowerLevels.js +++ b/src/matrix/room/PowerLevels.js @@ -75,11 +75,11 @@ export class PowerLevels { } _getEventTypeLevel(eventType) { - const level = this._plEvent?.content.events?.[eventType]; + const level = this._plEvent?.content?.events?.[eventType]; if (typeof level === "number") { return level; } else { - const level = this._plEvent?.content.events_default; + const level = this._plEvent?.content?.events_default; if (typeof level === "number") { return level; } else { diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 425401d8..b87d7a88 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -126,12 +126,13 @@ export class Room extends BaseRoom { 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; if (decryptChanges) { - const decryption = await log.wrap("decryptChanges", log => decryptChanges.write(txn, log)); + decryption = await log.wrap("decryptChanges", log => decryptChanges.write(txn, log)); log.set("decryptionResults", decryption.results.size); log.set("decryptionErrors", decryption.errors.size); if (this._isTimelineOpen) { - await decryption.verifySenders(txn); + await decryption.verifyKnownSenders(txn); } decryption.applyToEntries(newEntries); if (retryEntries?.length) { @@ -141,11 +142,11 @@ export class Room extends BaseRoom { } log.set("newEntries", newEntries.length); log.set("updatedEntries", updatedEntries.length); - let shouldFlushKeyShares = false; + let encryptionChanges; // pass member changes to device tracker - if (roomEncryption && this.isTrackingMembers && memberChanges?.size) { - shouldFlushKeyShares = await roomEncryption.writeMemberChanges(memberChanges, txn, log); - log.set("shouldFlushKeyShares", shouldFlushKeyShares); + if (roomEncryption) { + encryptionChanges = await roomEncryption.writeSync(roomResponse, memberChanges, txn, log); + log.set("shouldFlushKeyShares", encryptionChanges.shouldFlush); } const allEntries = newEntries.concat(updatedEntries); // also apply (decrypted) timeline entries to the summary changes @@ -192,7 +193,8 @@ export class Room extends BaseRoom { memberChanges, heroChanges, powerLevelsEvent, - shouldFlushKeyShares, + encryptionChanges, + decryption }; } @@ -205,11 +207,14 @@ export class Room extends BaseRoom { const { summaryChanges, newEntries, updatedEntries, newLiveKey, removedPendingEvents, memberChanges, powerLevelsEvent, - heroChanges, roomEncryption, roomResponse + heroChanges, roomEncryption, roomResponse, encryptionChanges } = changes; log.set("id", this.id); this._syncWriter.afterSync(newLiveKey); this._setEncryption(roomEncryption); + if (this._roomEncryption) { + this._roomEncryption.afterSync(encryptionChanges); + } if (memberChanges.size) { if (this._changedMembersDuringSync) { for (const [userId, memberChange] of memberChanges.entries()) { @@ -299,19 +304,36 @@ export class Room extends BaseRoom { } } - needsAfterSyncCompleted({shouldFlushKeyShares}) { - return shouldFlushKeyShares; - } - /** * Only called if the result of writeSync had `needsAfterSyncCompleted` set. * Can be used to do longer running operations that resulted from the last sync, * like network operations. */ - async afterSyncCompleted(changes, log) { - log.set("id", this.id); - if (this._roomEncryption) { - await this._roomEncryption.flushPendingRoomKeyShares(this._hsApi, null, log); + async afterSyncCompleted({encryptionChanges, decryption, newEntries, updatedEntries}, log) { + const shouldFlushKeys = encryptionChanges?.shouldFlush; + const shouldFetchUnverifiedSenders = this._isTimelineOpen && decryption?.hasUnverifiedSenders; + // only log rooms where we actually do something + if (shouldFlushKeys || shouldFetchUnverifiedSenders) { + await log.wrap({l: "room", id: this.id}, async log => { + const promises = []; + if (shouldFlushKeys) { + promises.push(this._roomEncryption.flushPendingRoomKeyShares(this._hsApi, null, log)); + } + if (shouldFetchUnverifiedSenders) { + const promise = log.wrap("verify senders", (async log => { + const newlyVerifiedDecryption = await decryption.fetchAndVerifyRemainingSenders(this._hsApi, log); + const verifiedEntries = []; + const updateCallback = entry => verifiedEntries.push(entry); + newlyVerifiedDecryption.applyToEntries(newEntries, updateCallback); + newlyVerifiedDecryption.applyToEntries(updatedEntries, updateCallback); + log.set("verifiedEntries", verifiedEntries.length); + this._timeline?.replaceEntries(verifiedEntries); + this._observedEvents?.updateEvents(verifiedEntries); + })); + promises.push(promise); + } + await Promise.all(promises); + }); } } diff --git a/src/matrix/room/RoomBeingCreated.ts b/src/matrix/room/RoomBeingCreated.ts index 78202203..b2c9dafb 100644 --- a/src/matrix/room/RoomBeingCreated.ts +++ b/src/matrix/room/RoomBeingCreated.ts @@ -37,7 +37,8 @@ type CreateRoomPayload = { invite?: string[]; room_alias_name?: string; creation_content?: {"m.federate": boolean}; - initial_state: {type: string; state_key: string; content: Record}[] + initial_state: { type: string; state_key: string; content: Record }[]; + power_level_content_override?: Record; } type ImageInfo = { @@ -62,6 +63,7 @@ type Options = { invites?: string[]; avatar?: Avatar; alias?: string; + powerLevelContentOverride?: Record; } function defaultE2EEStatusForType(type: RoomType): boolean { @@ -151,6 +153,9 @@ export class RoomBeingCreated extends EventEmitter<{change: never}> { "m.federate": false }; } + if (this.options.powerLevelContentOverride) { + createOptions.power_level_content_override = this.options.powerLevelContentOverride; + } if (this.isEncrypted) { createOptions.initial_state.push(createRoomEncryptionEvent()); } diff --git a/src/matrix/room/common.ts b/src/matrix/room/common.ts index 7556cfb0..2ce8b5dd 100644 --- a/src/matrix/room/common.ts +++ b/src/matrix/room/common.ts @@ -14,11 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import type {Room} from "./Room"; -import type {StateEvent, TimelineEvent} from "../storage/types"; -import type {Transaction} from "../storage/idb/Transaction"; -import type {ILogItem} from "../../logging/types"; -import type {MemberChange} from "./members/RoomMember"; +import type {StateEvent} from "../storage/types"; export function getPrevContentFromStateEvent(event) { // where to look for prev_content is a bit of a mess, @@ -57,12 +53,21 @@ 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) => void) { + +export function iterateResponseStateEvents(roomResponse: RoomResponse, callback: (StateEvent) => Promise | void): Promise | void { + let promises: Promise[] | undefined = undefined; + const callCallback = stateEvent => { + const result = callback(stateEvent); + if (result instanceof Promise) { + promises = promises ?? []; + promises.push(result); + } + }; // first iterate over state events, they precede the timeline const stateEvents = roomResponse.state?.events; if (stateEvents) { for (let i = 0; i < stateEvents.length; i++) { - callback(stateEvents[i]); + callCallback(stateEvents[i]); } } // now see if there are any state events within the timeline @@ -71,10 +76,13 @@ export function iterateResponseStateEvents(roomResponse: RoomResponse, callback: for (let i = 0; i < timelineEvents.length; i++) { const event = timelineEvents[i]; if (typeof event.state_key === "string") { - callback(event); + callCallback(event); } } } + if (promises) { + return Promise.all(promises).then(() => undefined); + } } export function tests() { diff --git a/src/matrix/room/joinRoom.ts b/src/matrix/room/joinRoom.ts new file mode 100644 index 00000000..7f2ed19e --- /dev/null +++ b/src/matrix/room/joinRoom.ts @@ -0,0 +1,42 @@ +/* +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 {Session} from "../Session.js"; +import {RoomStatus} from "./common"; + +/** + * Join a room and wait for it to arrive in the next sync + * @param roomId The id of the room to join + * @param session A session instance + */ +export async function joinRoom(roomId: string, session: Session): Promise { + try { + const internalRoomId = await session.joinRoom(roomId); + const roomStatusObservable = await session.observeRoomStatus(internalRoomId); + await roomStatusObservable.waitFor((status: RoomStatus) => status === RoomStatus.Joined); + return internalRoomId; + } + catch (e) { + if ((e.statusCode ?? e.status) === 400) { + throw new Error(`'${roomId}' is not a legal room ID or alias`); + } else if ((e.statusCode ?? e.status) === 404 || (e.statusCode ?? e.status) === 502 || e.message == "Internal Server eor") { + throw new Error(`Room '${roomId}' could not be found`); + } else if ((e.statusCode ?? e.status) === 403) { + throw new Error(`You are not invited to join '${roomId}'`); + } else { + throw e; + } + } +} diff --git a/src/matrix/room/members/Heroes.js b/src/matrix/room/members/Heroes.js index 1d2ab39e..97630edd 100644 --- a/src/matrix/room/members/Heroes.js +++ b/src/matrix/room/members/Heroes.js @@ -85,7 +85,9 @@ export class Heroes { this._members.delete(userId); } for (const member of updatedHeroMembers) { - this._members.set(member.userId, member); + if (!removedUserIds.includes(member.userId)) { + this._members.set(member.userId, member); + } } const sortedMembers = Array.from(this._members.values()).sort((a, b) => a.name.localeCompare(b.name)); this._roomName = calculateRoomName(sortedMembers, summaryData, log); diff --git a/src/matrix/room/members/MemberList.js b/src/matrix/room/members/MemberList.js index f32a63d3..74b3fe7d 100644 --- a/src/matrix/room/members/MemberList.js +++ b/src/matrix/room/members/MemberList.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ObservableMap} from "../../../observable/map/ObservableMap"; +import {ObservableMap} from "../../../observable"; import {RetainedValue} from "../../../utils/RetainedValue"; export class MemberList extends RetainedValue { diff --git a/src/matrix/room/members/RoomMember.js b/src/matrix/room/members/RoomMember.js index dabff972..8e00f5de 100644 --- a/src/matrix/room/members/RoomMember.js +++ b/src/matrix/room/members/RoomMember.js @@ -137,6 +137,10 @@ export class MemberChange { return this.member.membership; } + get wasInvited() { + return this.previousMembership === "invite" && this.membership !== "invite"; + } + get hasLeft() { return this.previousMembership === "join" && this.membership !== "join"; } diff --git a/src/matrix/room/members/load.js b/src/matrix/room/members/load.js index 5077d793..3d0556fc 100644 --- a/src/matrix/room/members/load.js +++ b/src/matrix/room/members/load.js @@ -17,10 +17,12 @@ limitations under the License. import {RoomMember} from "./RoomMember.js"; -async function loadMembers({roomId, storage}) { - const txn = await storage.readTxn([ - storage.storeNames.roomMembers, - ]); +async function loadMembers({roomId, storage, txn}) { + if (!txn) { + txn = await storage.readTxn([ + storage.storeNames.roomMembers, + ]); + } const memberDatas = await txn.roomMembers.getAll(roomId); return memberDatas.map(d => new RoomMember(d)); } diff --git a/src/matrix/room/state/ObservedStateKeyValue.ts b/src/matrix/room/state/ObservedStateKeyValue.ts index ce380458..942d293e 100644 --- a/src/matrix/room/state/ObservedStateKeyValue.ts +++ b/src/matrix/room/state/ObservedStateKeyValue.ts @@ -17,7 +17,7 @@ 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/BaseObservableValue"; +import {BaseObservableValue} from "../../../observable/value"; /** * Observable value for a state event with a given type and state key. diff --git a/src/matrix/room/state/ObservedStateTypeMap.ts b/src/matrix/room/state/ObservedStateTypeMap.ts index e8fa6f7b..97ab8268 100644 --- a/src/matrix/room/state/ObservedStateTypeMap.ts +++ b/src/matrix/room/state/ObservedStateTypeMap.ts @@ -17,7 +17,7 @@ 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/ObservableMap"; +import {ObservableMap} from "../../../observable/map"; /** * Observable map for a given type with state keys as map keys. diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index de17fce2..a721092e 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {SortedArray, AsyncMappedList, ConcatList, ObservableArray} from "../../../observable/index"; +import {SortedArray, AsyncMappedList, ConcatList, ObservableArray} from "../../../observable"; import {Disposables} from "../../../utils/Disposables"; import {Direction} from "./Direction"; import {TimelineReader} from "./persistence/TimelineReader.js"; @@ -45,7 +45,7 @@ export class Timeline { }); this._readerRequest = null; this._allEntries = null; - /** Stores event entries that we had to fetch from hs/storage for reply previews (because they were not in timeline) */ + /** Stores event entries that we had to fetch from hs/storage for reply previews (because they were not in timeline) */ this._contextEntriesNotInTimeline = new Map(); /** Only used to decrypt non-persisted context entries fetched from the homeserver */ this._decryptEntries = null; @@ -189,7 +189,7 @@ export class Timeline { // before it has any subscriptions, we bail out if this isn't // the case yet. This can happen when sync adds or replaces entries // before load has finished and the view has subscribed to the timeline. - // + // // Once the subscription is setup, MappedList will set up the local // relations as needed with _applyAndEmitLocalRelationChange, // so we're not missing anything by bailing out. @@ -239,7 +239,7 @@ export class Timeline { if (err.name === "CompareError") { // see FragmentIdComparer, if the replacing entry is on a fragment // that is currently not loaded into the FragmentIdComparer, it will - // throw a CompareError, and it means that the event is not loaded + // throw a CompareError, and it means that the event is not loaded // in the timeline (like when receiving a relation for an event // that is not loaded in memory) so we can just drop this error as // replacing an event that is not already loaded is a no-op. @@ -311,7 +311,7 @@ export class Timeline { * - timeline * - storage * - homeserver - * @param {EventEntry[]} entries + * @param {EventEntry[]} entries */ async _loadContextEntriesWhereNeeded(entries) { for (const entry of entries) { @@ -392,7 +392,7 @@ export class Timeline { * [loadAtTop description] * @param {[type]} amount [description] * @return {boolean} true if the top of the timeline has been reached - * + * */ async loadAtTop(amount) { if (this._disposables.isDisposed) { @@ -547,7 +547,7 @@ export function tests() { content: {}, relatedEventId: event2.event_id }})); - // 4. subscribe (it's now safe to iterate timeline.entries) + // 4. subscribe (it's now safe to iterate timeline.entries) timeline.entries.subscribe(new ListObserver()); // 5. check the local relation got correctly aggregated const locallyRedacted = await poll(() => Array.from(timeline.entries)[0].isRedacting); diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index d218a598..cf56cbf9 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -33,10 +33,11 @@ export class EventEntry extends BaseEventEntry { } updateFrom(other) { - if (other._decryptionResult && !this._decryptionResult) { + // only update these when we attempted decryption, as some updates (like reactions) don't. + if (other._decryptionResult) { this._decryptionResult = other._decryptionResult; } - if (other._decryptionError && !this._decryptionError) { + if (other._decryptionError) { this._decryptionError = other._decryptionError; } this._contextForEntries = other.contextForEntries; diff --git a/src/matrix/storage/idb/StorageFactory.ts b/src/matrix/storage/idb/StorageFactory.ts index 264d8670..bc447095 100644 --- a/src/matrix/storage/idb/StorageFactory.ts +++ b/src/matrix/storage/idb/StorageFactory.ts @@ -16,11 +16,12 @@ limitations under the License. import {IDOMStorage} from "./types"; import {Storage} from "./Storage"; -import { openDatabase, reqAsPromise } from "./utils"; -import { exportSession, importSession, Export } from "./export"; -import { schema } from "./schema"; -import { detectWebkitEarlyCloseTxnBug } from "./quirks"; -import { ILogItem } from "../../../logging/types"; +import {openDatabase, reqAsPromise} from "./utils"; +import {exportSession, importSession, Export} from "./export"; +import {schema} from "./schema"; +import {detectWebkitEarlyCloseTxnBug} from "./quirks"; +import {ILogItem} from "../../../logging/types"; +import {clearKeysFromLocalStorage} from "./stores/SessionStore"; const sessionName = (sessionId: string) => `hydrogen_session_${sessionId}`; const openDatabaseWithSessionId = function(sessionId: string, idbFactory: IDBFactory, localStorage: IDOMStorage, log: ILogItem) { @@ -42,6 +43,7 @@ async function requestPersistedStorage(): Promise { await glob.document.requestStorageAccess(); return true; } catch (err) { + console.warn("requestStorageAccess threw an error:", err); return false; } } else { @@ -76,10 +78,15 @@ export class StorageFactory { return new Storage(db, this._idbFactory, this._IDBKeyRange, hasWebkitEarlyCloseTxnBug, this._localStorage, log.logger); } - delete(sessionId: string): Promise { + async delete(sessionId: string): Promise { const databaseName = sessionName(sessionId); - const req = this._idbFactory.deleteDatabase(databaseName); - return reqAsPromise(req); + try { + clearKeysFromLocalStorage(this._localStorage, databaseName); + } catch (e) {} + try { + const req = this._idbFactory.deleteDatabase(databaseName); + await reqAsPromise(req); + } catch (e) {} } async export(sessionId: string, log: ILogItem): Promise { diff --git a/src/matrix/storage/idb/schema.ts b/src/matrix/storage/idb/schema.ts index 4461ae15..d88f535e 100644 --- a/src/matrix/storage/idb/schema.ts +++ b/src/matrix/storage/idb/schema.ts @@ -2,7 +2,6 @@ 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 {addRoomToIdentity} from "../../e2ee/DeviceTracker.js"; import {SESSION_E2EE_KEY_PREFIX} from "../../e2ee/common.js"; import {SummaryData} from "../../room/RoomSummary"; import {RoomMemberStore, MemberData} from "./stores/RoomMemberStore"; @@ -184,51 +183,12 @@ function createTimelineRelationsStore(db: IDBDatabase) : void { db.createObjectStore("timelineRelations", {keyPath: "key"}); } -//v11 doesn't change the schema, but ensures all userIdentities have all the roomIds they should (see #470) -async function fixMissingRoomsInUserIdentities(db: IDBDatabase, txn: IDBTransaction, localStorage: IDOMStorage, log: ILogItem) { - const roomSummaryStore = txn.objectStore("roomSummary"); - const trackedRoomIds: string[] = []; - await iterateCursor(roomSummaryStore.openCursor(), roomSummary => { - if (roomSummary.isTrackingMembers) { - trackedRoomIds.push(roomSummary.roomId); - } - return NOT_DONE; - }); - const outboundGroupSessionsStore = txn.objectStore("outboundGroupSessions"); - const userIdentitiesStore: IDBObjectStore = txn.objectStore("userIdentities"); - const roomMemberStore = txn.objectStore("roomMembers"); - for (const roomId of trackedRoomIds) { - let foundMissing = false; - const joinedUserIds: string[] = []; - const memberRange = IDBKeyRange.bound(roomId, `${roomId}|${MAX_UNICODE}`, true, true); - await log.wrap({l: "room", id: roomId}, async log => { - await iterateCursor(roomMemberStore.openCursor(memberRange), member => { - if (member.membership === "join") { - joinedUserIds.push(member.userId); - } - return NOT_DONE; - }); - log.set("joinedUserIds", joinedUserIds.length); - for (const userId of joinedUserIds) { - const identity = await reqAsPromise(userIdentitiesStore.get(userId)); - const originalRoomCount = identity?.roomIds?.length; - const updatedIdentity = addRoomToIdentity(identity, userId, roomId); - if (updatedIdentity) { - log.log({l: `fixing up`, id: userId, - roomsBefore: originalRoomCount, roomsAfter: updatedIdentity.roomIds.length}); - userIdentitiesStore.put(updatedIdentity); - foundMissing = true; - } - } - log.set("foundMissing", foundMissing); - if (foundMissing) { - // clear outbound megolm session, - // so we'll create a new one on the next message that will be properly shared - outboundGroupSessionsStore.delete(roomId); - } - }); - } -} +//v11 doesn't change the schema, +// but ensured all userIdentities have all the roomIds they should (see #470) + +// 2022-07-20: The fix dated from August 2021, and have removed it now because of a +// refactoring needed in the device tracker, which made it inconvenient to expose addRoomToIdentity +function fixMissingRoomsInUserIdentities() {} // v12 move ssssKey to e2ee:ssssKey so it will get backed up in the next step async function changeSSSSKeyPrefix(db: IDBDatabase, txn: IDBTransaction) { diff --git a/src/matrix/storage/idb/stores/SessionStore.ts b/src/matrix/storage/idb/stores/SessionStore.ts index 7faedc41..9ae9bb7e 100644 --- a/src/matrix/storage/idb/stores/SessionStore.ts +++ b/src/matrix/storage/idb/stores/SessionStore.ts @@ -24,6 +24,23 @@ export interface SessionEntry { value: any; } +function getLocalStorageKeyPrefix(databaseName: string): string { + return `${databaseName}.session.`; +} + +export function clearKeysFromLocalStorage(localStorage: IDOMStorage, databaseName: string): void { + const keys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith(getLocalStorageKeyPrefix(databaseName))) { + keys.push(key); + } + } + for (const key of keys) { + localStorage.removeItem(key); + } +} + export class SessionStore { private _sessionStore: Store private _localStorage: IDOMStorage; @@ -34,7 +51,7 @@ export class SessionStore { } private get _localStorageKeyPrefix(): string { - return `${this._sessionStore.databaseName}.session.`; + return getLocalStorageKeyPrefix(this._sessionStore.databaseName); } async get(key: string): Promise { diff --git a/src/matrix/storage/idb/utils.ts b/src/matrix/storage/idb/utils.ts index 4ac373d2..44149e12 100644 --- a/src/matrix/storage/idb/utils.ts +++ b/src/matrix/storage/idb/utils.ts @@ -83,7 +83,11 @@ export function openDatabase(name: string, createObjectStore: CreateObjectStore, // try aborting on error, if that hasn't been done already try { txn.abort(); - } catch (err) {} + } catch (err) { + // No-op: `InvalidStateError` is only thrown if the transaction has + // already been committed or aborted. Since we wanted the txn to + // be aborted anyway, it doesn't matter if this fails. + } } }; return reqAsPromise(req); diff --git a/src/mocks/Clock.js b/src/mocks/Clock.js index b9d5457d..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/value/ObservableValue"; +import {ObservableValue} from "../observable/value"; class Timeout { constructor(elapsed, ms) { diff --git a/src/observable/BaseObservable.ts b/src/observable/BaseObservable.ts index 44d716ac..edbdd8bc 100644 --- a/src/observable/BaseObservable.ts +++ b/src/observable/BaseObservable.ts @@ -34,7 +34,7 @@ export abstract class BaseObservable { if (this._handlers.size === 1) { this.onSubscribeFirst(); } - return () => { + return (): undefined => { return this.unsubscribe(handler); }; } @@ -63,22 +63,23 @@ export abstract class BaseObservable { // Add iterator over handlers here } +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function tests() { class Collection extends BaseObservable<{}> { firstSubscribeCalls: number = 0; firstUnsubscribeCalls: number = 0; - onSubscribeFirst() { this.firstSubscribeCalls += 1; } - onUnsubscribeLast() { this.firstUnsubscribeCalls += 1; } + onSubscribeFirst(): void { this.firstSubscribeCalls += 1; } + onUnsubscribeLast(): void { this.firstUnsubscribeCalls += 1; } } return { - test_unsubscribe(assert) { + test_unsubscribe(assert): void { const c = new Collection(); const unsubscribe = c.subscribe({}); unsubscribe(); assert.equal(c.firstSubscribeCalls, 1); assert.equal(c.firstUnsubscribeCalls, 1); } - } + }; } diff --git a/src/observable/index.ts b/src/observable/index.ts index 040ec761..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. @@ -14,44 +12,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {SortedMapList} from "./list/SortedMapList.js"; -import {FilteredMap} from "./map/FilteredMap.js"; -import {MappedMap} from "./map/MappedMap.js"; -import {JoinedMap} from "./map/JoinedMap.js"; -import {BaseObservableMap} from "./map/BaseObservableMap"; -// re-export "root" (of chain) collections + +// re-export "root" (of chain) collection +export { ObservableMap, ApplyMap, FilteredMap, JoinedMap, LogMap, MappedMap } from "./map"; 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"; -export { ObservableMap } from "./map/ObservableMap"; - -// avoid circular dependency between these classes -// and BaseObservableMap (as they extend it) -Object.assign(BaseObservableMap.prototype, { - sortValues(comparator) { - return new SortedMapList(this, comparator); - }, - - mapValues(mapper, updater) { - return new MappedMap(this, mapper, updater); - }, - - filterValues(filter) { - return new FilteredMap(this, filter); - }, - - join(...otherMaps) { - return new JoinedMap([this].concat(otherMaps)); - } -}); - -declare module "./map/BaseObservableMap" { - interface BaseObservableMap { - sortValues(comparator: (a: V, b: V) => number): SortedMapList; - mapValues(mapper: (V, emitSpontaneousUpdate: (params: any) => void) => M, updater: (mappedValue: M, params: any, value: V) => void): MappedMap; - filterValues(filter: (V, K) => boolean): FilteredMap; - join(...otherMaps: BaseObservableMap[]): JoinedMap; - } -} diff --git a/src/observable/list/AsyncMappedList.ts b/src/observable/list/AsyncMappedList.ts index 0a919cdc..2c5ef63f 100644 --- a/src/observable/list/AsyncMappedList.ts +++ b/src/observable/list/AsyncMappedList.ts @@ -16,7 +16,7 @@ limitations under the License. */ import {IListObserver} from "./BaseObservableList"; -import {BaseMappedList, Mapper, Updater, runAdd, runUpdate, runRemove, runMove, runReset} from "./BaseMappedList"; +import {BaseMappedList, runAdd, runUpdate, runRemove, runMove, runReset} from "./BaseMappedList"; export class AsyncMappedList extends BaseMappedList> implements IListObserver { private _eventQueue: AsyncEvent[] | null = null; @@ -31,7 +31,7 @@ export class AsyncMappedList extends BaseMappedList> impleme this._eventQueue.push(new AddEvent(idx, item)); idx += 1; } - this._flush(); + void this._flush(); } async _flush(): Promise { @@ -52,35 +52,35 @@ export class AsyncMappedList extends BaseMappedList> impleme onReset(): void { if (this._eventQueue) { this._eventQueue.push(new ResetEvent()); - this._flush(); + void this._flush(); } } onAdd(index: number, value: F): void { if (this._eventQueue) { this._eventQueue.push(new AddEvent(index, value)); - this._flush(); + void this._flush(); } } onUpdate(index: number, value: F, params: any): void { if (this._eventQueue) { this._eventQueue.push(new UpdateEvent(index, value, params)); - this._flush(); + void this._flush(); } } onRemove(index: number): void { if (this._eventQueue) { this._eventQueue.push(new RemoveEvent(index)); - this._flush(); + void this._flush(); } } onMove(fromIdx: number, toIdx: number): void { if (this._eventQueue) { this._eventQueue.push(new MoveEvent(fromIdx, toIdx)); - this._flush(); + void this._flush(); } } @@ -135,10 +135,12 @@ class ResetEvent { import {ObservableArray} from "./ObservableArray"; import {ListObserver} from "../../mocks/ListObserver.js"; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function tests() { return { - "events are emitted in order": async assert => { - const double = n => n * n; + "events are emitted in order": async (assert): Promise => { + const double = (n: number): number => n * n; const source = new ObservableArray(); const mapper = new AsyncMappedList(source, async n => { await new Promise(r => setTimeout(r, n)); @@ -150,7 +152,7 @@ export function tests() { mapper.subscribe(observer); source.append(2); // will sleep this amount, so second append would take less time source.append(1); - source.update(0, 7, "lucky seven") + source.update(0, 7, "lucky seven"); source.remove(0); { const {type, index, value} = await observer.next(); @@ -182,5 +184,5 @@ export function tests() { assert.equal(value.n, 49); } } - } + }; } diff --git a/src/observable/list/BaseMappedList.ts b/src/observable/list/BaseMappedList.ts index 4e3d05e0..8646153e 100644 --- a/src/observable/list/BaseMappedList.ts +++ b/src/observable/list/BaseMappedList.ts @@ -37,15 +37,15 @@ export class BaseMappedList extends BaseObservableList { this._removeCallback = removeCallback; } - findAndUpdate(predicate: (value: T) => boolean, updater: (value: T) => any | false) { + findAndUpdate(predicate: (value: T) => boolean, updater: (value: T) => any | false): boolean { return findAndUpdateInArray(predicate, this._mappedValues!, this, updater); } - get length() { + get length(): number { return this._mappedValues!.length; } - [Symbol.iterator]() { + [Symbol.iterator](): IterableIterator { return this._mappedValues!.values(); } } diff --git a/src/observable/list/BaseObservableList.ts b/src/observable/list/BaseObservableList.ts index d103eb64..1fd82c25 100644 --- a/src/observable/list/BaseObservableList.ts +++ b/src/observable/list/BaseObservableList.ts @@ -26,17 +26,17 @@ export interface IListObserver { export function defaultObserverWith(overrides: { [key in keyof IListObserver]?: IListObserver[key] }): IListObserver { const defaults = { - onReset(){}, - onAdd(){}, - onUpdate(){}, - onRemove(){}, - onMove(){}, - } + onReset(): void {}, + onAdd(): void {}, + onUpdate(): void {}, + onRemove(): void {}, + onMove(): void {}, + }; return Object.assign(defaults, overrides); } export abstract class BaseObservableList extends BaseObservable> implements Iterable { - emitReset() { + emitReset(): void { for(let h of this._handlers) { h.onReset(this); } diff --git a/src/observable/list/ConcatList.ts b/src/observable/list/ConcatList.ts index 5822468a..80accb81 100644 --- a/src/observable/list/ConcatList.ts +++ b/src/observable/list/ConcatList.ts @@ -47,7 +47,7 @@ export class ConcatList extends BaseObservableList implements IListObserve onReset(): void { // TODO: not ideal if other source lists are large // but working impl for now - // reset, and + // reset, and this.emitReset(); let idx = 0; for(const item of this) { @@ -86,11 +86,11 @@ export class ConcatList extends BaseObservableList implements IListObserve return len; } - [Symbol.iterator]() { + [Symbol.iterator](): Iterator { let sourceListIdx = 0; let it = this._sourceLists[0][Symbol.iterator](); return { - next: () => { + next: (): IteratorResult => { let result = it.next(); while (result.done) { sourceListIdx += 1; @@ -102,22 +102,25 @@ export class ConcatList extends BaseObservableList implements IListObserve } return result; } - } + }; } } import {ObservableArray} from "./ObservableArray"; import {defaultObserverWith} from "./BaseObservableList"; + + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type export async function tests() { return { - test_length(assert) { + test_length(assert): void { const all = new ConcatList( new ObservableArray([1, 2, 3]), new ObservableArray([11, 12, 13]) ); assert.equal(all.length, 6); }, - test_iterator(assert) { + test_iterator(assert): void { const all = new ConcatList( new ObservableArray([1, 2, 3]), new ObservableArray([11, 12, 13]) @@ -131,7 +134,7 @@ export async function tests() { assert.equal(it.next().value, 13); assert(it.next().done); }, - test_add(assert) { + test_add(assert): void { const list1 = new ObservableArray([1, 2, 3]); const list2 = new ObservableArray([11, 12, 13]); const all = new ConcatList(list1, list2); @@ -146,7 +149,7 @@ export async function tests() { list2.insert(1, 11.5); assert(fired); }, - test_update(assert) { + test_update(assert): void { const list1 = new ObservableArray([1, 2, 3]); const list2 = new ObservableArray([11, 12, 13]); const all = new ConcatList(list1, list2); diff --git a/src/observable/list/MappedList.ts b/src/observable/list/MappedList.ts index ebb418d3..2ddae698 100644 --- a/src/observable/list/MappedList.ts +++ b/src/observable/list/MappedList.ts @@ -19,7 +19,7 @@ import {IListObserver} from "./BaseObservableList"; import {BaseMappedList, runAdd, runUpdate, runRemove, runMove, runReset} from "./BaseMappedList"; export class MappedList extends BaseMappedList implements IListObserver { - onSubscribeFirst() { + onSubscribeFirst(): void { this._sourceUnsubscribe = this._sourceList.subscribe(this); this._mappedValues = []; for (const item of this._sourceList) { @@ -61,18 +61,21 @@ import {ObservableArray} from "./ObservableArray"; import {BaseObservableList} from "./BaseObservableList"; import {defaultObserverWith} from "./BaseObservableList"; +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type export async function tests() { class MockList extends BaseObservableList { - get length() { + get length(): 0 { return 0; } + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type [Symbol.iterator]() { return [].values(); } } return { - test_add(assert) { + test_add(assert): void { const source = new MockList(); const mapped = new MappedList(source, n => {return {n: n*n};}); let fired = false; @@ -87,7 +90,7 @@ export async function tests() { assert(fired); unsubscribe(); }, - test_update(assert) { + test_update(assert): void { const source = new MockList(); const mapped = new MappedList( source, @@ -109,7 +112,7 @@ export async function tests() { assert(fired); unsubscribe(); }, - "test findAndUpdate not found": assert => { + "test findAndUpdate not found": (assert): void => { const source = new ObservableArray([1, 3, 4]); const mapped = new MappedList( source, @@ -123,7 +126,7 @@ export async function tests() { () => assert.fail() ), false); }, - "test findAndUpdate found but updater bails out of update": assert => { + "test findAndUpdate found but updater bails out of update": (assert): void => { const source = new ObservableArray([1, 3, 4]); const mapped = new MappedList( source, @@ -143,7 +146,7 @@ export async function tests() { ), true); assert.equal(fired, true); }, - "test findAndUpdate emits update": assert => { + "test findAndUpdate emits update": (assert): void => { const source = new ObservableArray([1, 3, 4]); const mapped = new MappedList( source, @@ -161,6 +164,6 @@ export async function tests() { assert.equal(mapped.findAndUpdate(n => n === 9, () => "param"), true); assert.equal(fired, true); }, - + }; } diff --git a/src/observable/list/ObservableArray.ts b/src/observable/list/ObservableArray.ts index 0771d0f6..1b962e81 100644 --- a/src/observable/list/ObservableArray.ts +++ b/src/observable/list/ObservableArray.ts @@ -75,7 +75,7 @@ export class ObservableArray extends BaseObservableList { return this._items.length; } - [Symbol.iterator]() { + [Symbol.iterator](): IterableIterator { return this._items.values(); } } diff --git a/src/observable/list/SortedArray.ts b/src/observable/list/SortedArray.ts index c85cca27..e4723db1 100644 --- a/src/observable/list/SortedArray.ts +++ b/src/observable/list/SortedArray.ts @@ -87,7 +87,7 @@ export class SortedArray extends BaseObservableList { const idx = sortedIndex(this._items, item, this._comparator); if (idx >= this._items.length || this._comparator(this._items[idx], item) !== 0) { this._items.splice(idx, 0, item); - this.emitAdd(idx, item) + this.emitAdd(idx, item); } else { this._items[idx] = item; this.emitUpdate(idx, item, updateParams); @@ -112,52 +112,46 @@ export class SortedArray extends BaseObservableList { return this._items.length; } - [Symbol.iterator]() { + [Symbol.iterator](): Iterator { return new Iterator(this); } } // iterator that works even if the current value is removed while iterating class Iterator { - private _sortedArray: SortedArray | null - private _current: T | null | undefined + private _sortedArray: SortedArray; + private _current: T | null | undefined; + private _consumed: boolean = false; constructor(sortedArray: SortedArray) { this._sortedArray = sortedArray; this._current = null; } - next() { - if (this._sortedArray) { - if (this._current) { - this._current = this._sortedArray._getNext(this._current); - } else { - this._current = this._sortedArray.get(0); - } - if (this._current) { - return {value: this._current}; - } else { - // cause done below - this._sortedArray = null; - } + next(): IteratorResult { + if (this._consumed) { + return {value: undefined, done: true}; } - if (!this._sortedArray) { - return {done: true}; + this._current = this._current? this._sortedArray._getNext(this._current): this._sortedArray.get(0); + if (!this._current) { + this._consumed = true; } + return { value: this._current, done: this._consumed } as IteratorResult; } } +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function tests() { return { - "setManyUnsorted": assert => { + "setManyUnsorted": (assert): void => { const sa = new SortedArray((a, b) => a.localeCompare(b)); sa.setManyUnsorted(["b", "a", "c"]); assert.equal(sa.length, 3); assert.equal(sa.get(0), "a"); assert.equal(sa.get(1), "b"); assert.equal(sa.get(2), "c"); - }, - "_getNext": assert => { + }, + "_getNext": (assert): void => { const sa = new SortedArray((a, b) => a.localeCompare(b)); sa.setManyUnsorted(["b", "a", "f"]); assert.equal(sa._getNext("a"), "b"); @@ -166,7 +160,7 @@ export function tests() { assert.equal(sa._getNext("c"), "f"); assert.equal(sa._getNext("f"), undefined); }, - "iterator with removals": assert => { + "iterator with removals": (assert): void => { const queue = new SortedArray<{idx: number}>((a, b) => a.idx - b.idx); queue.setManyUnsorted([{idx: 5}, {idx: 3}, {idx: 1}, {idx: 4}, {idx: 2}]); const it = queue[Symbol.iterator](); @@ -183,5 +177,5 @@ export function tests() { // check done persists assert.equal(it.next().done, true); } - } + }; } diff --git a/src/observable/list/SortedMapList.js b/src/observable/list/SortedMapList.js index d74dbade..6f4be123 100644 --- a/src/observable/list/SortedMapList.js +++ b/src/observable/list/SortedMapList.js @@ -53,7 +53,7 @@ export class SortedMapList extends BaseObservableList { this._sortedPairs = null; this._mapSubscription = null; } - + onAdd(key, value) { const pair = {key, value}; const idx = sortedIndex(this._sortedPairs, pair, this._comparator); @@ -129,11 +129,11 @@ export class SortedMapList extends BaseObservableList { } return v; } - } + }; } } -import {ObservableMap} from "../map/ObservableMap"; +import {ObservableMap} from "../"; export function tests() { return { @@ -267,5 +267,5 @@ export function tests() { assert.equal(updateFired, 1); assert.deepEqual(Array.from(list).map(v => v.number), [1, 3, 11]); }, - } + }; } diff --git a/src/observable/list/common.ts b/src/observable/list/common.ts index c67a841b..20f3a8bf 100644 --- a/src/observable/list/common.ts +++ b/src/observable/list/common.ts @@ -17,7 +17,12 @@ limitations under the License. import {BaseObservableList} from "./BaseObservableList"; /* inline update of item in collection backed by array, without replacing the preexising item */ -export function findAndUpdateInArray(predicate: (value: T) => boolean, array: T[], observable: BaseObservableList, updater: (value: T) => any | false) { +export function findAndUpdateInArray( + predicate: (value: T) => boolean, + array: T[], + observable: BaseObservableList, + updater: (value: T) => any | false +): boolean { const index = array.findIndex(predicate); if (index !== -1) { const value = array[index]; diff --git a/src/observable/map/ApplyMap.js b/src/observable/map/ApplyMap.ts similarity index 59% rename from src/observable/map/ApplyMap.js rename to src/observable/map/ApplyMap.ts index 6be7278a..0c4962c8 100644 --- a/src/observable/map/ApplyMap.js +++ b/src/observable/map/ApplyMap.ts @@ -14,52 +14,62 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableMap} from "./BaseObservableMap"; +import {BaseObservableMap} from "./index"; +import {SubscriptionHandle} from "../BaseObservable"; -export class ApplyMap extends BaseObservableMap { - constructor(source, apply) { + +/* +This class MUST never be imported directly from here. +Instead, it MUST be imported from index.ts. See the +top level comment in index.ts for details. +*/ +export class ApplyMap extends BaseObservableMap { + private _source: BaseObservableMap; + private _subscription?: SubscriptionHandle; + private _apply?: Apply; + + constructor(source: BaseObservableMap, apply?: Apply) { super(); this._source = source; this._apply = apply; - this._subscription = null; } - hasApply() { + hasApply(): boolean { return !!this._apply; } - setApply(apply) { + setApply(apply?: Apply): void { this._apply = apply; - if (apply) { + if (this._apply) { this.applyOnce(this._apply); } } - applyOnce(apply) { + applyOnce(apply: Apply): void { for (const [key, value] of this._source) { apply(key, value); } } - onAdd(key, value) { + onAdd(key: K, value: V): void { if (this._apply) { this._apply(key, value); } this.emitAdd(key, value); } - onRemove(key, value) { + onRemove(key: K, value: V): void { this.emitRemove(key, value); } - onUpdate(key, value, params) { + onUpdate(key: K, value: V, params: any): void { if (this._apply) { this._apply(key, value, params); } this.emitUpdate(key, value, params); } - onSubscribeFirst() { + onSubscribeFirst(): void { this._subscription = this._source.subscribe(this); if (this._apply) { this.applyOnce(this._apply); @@ -67,27 +77,31 @@ export class ApplyMap extends BaseObservableMap { super.onSubscribeFirst(); } - onUnsubscribeLast() { + onUnsubscribeLast(): void { super.onUnsubscribeLast(); - this._subscription = this._subscription(); + if (this._subscription) { + this._subscription = this._subscription(); + } } - onReset() { + onReset(): void { if (this._apply) { this.applyOnce(this._apply); } this.emitReset(); } - [Symbol.iterator]() { + [Symbol.iterator](): Iterator<[K, V]> { return this._source[Symbol.iterator](); } - get size() { + get size(): number { return this._source.size; } - get(key) { + get(key: K): V | undefined { return this._source.get(key); } } + +type Apply = (key: K, value: V, params?: any) => void; \ No newline at end of file diff --git a/src/observable/map/BaseObservableMap.ts b/src/observable/map/BaseObservableMap.ts index 694c017e..f81a94f8 100644 --- a/src/observable/map/BaseObservableMap.ts +++ b/src/observable/map/BaseObservableMap.ts @@ -15,6 +15,11 @@ limitations under the License. */ import {BaseObservable} from "../BaseObservable"; +import {JoinedMap} from "./index"; +import {MappedMap} from "./index"; +import {FilteredMap} from "./index"; +import {SortedMapList} from "../list/SortedMapList.js"; + export interface IMapObserver { onReset(): void; @@ -23,33 +28,70 @@ export interface IMapObserver { onRemove(key: K, value: V): void } +/* +This class MUST never be imported directly from here. +Instead, it MUST be imported from index.ts. See the +top level comment in index.ts for details. +*/ export abstract class BaseObservableMap extends BaseObservable> { - emitReset() { + + constructor() { + super(); + } + + emitReset(): void { for(let h of this._handlers) { h.onReset(); } } // we need batch events, mostly on index based collection though? // maybe we should get started without? - emitAdd(key: K, value: V) { + emitAdd(key: K, value: V): void { for(let h of this._handlers) { h.onAdd(key, value); } } - emitUpdate(key, value, params) { + emitUpdate(key: K, value: V, params: any): void { for(let h of this._handlers) { h.onUpdate(key, value, params); } } - emitRemove(key, value) { + emitRemove(key: K, value: V): void { for(let h of this._handlers) { h.onRemove(key, value); } } + join>(...otherMaps: Array): JoinedMap { + return new JoinedMap([this as BaseObservableMap].concat(otherMaps)); + } + + mapValues(mapper: Mapper, updater?: Updater): MappedMap { + return new MappedMap(this, mapper, updater); + } + + sortValues(comparator: Comparator): SortedMapList { + return new SortedMapList(this, comparator); + } + + filterValues(filter: Filter): FilteredMap { + return new FilteredMap(this, filter); + } + abstract [Symbol.iterator](): Iterator<[K, V]>; abstract get size(): number; abstract get(key: K): V | undefined; } + +export type Mapper = ( + value: V, + emitSpontaneousUpdate: any, +) => MappedV; + +export type Updater = (params: any, mappedValue?: MappedV, value?: V) => void; + +export type Comparator = (a: V, b: V) => number; + +export type Filter = (v: V, k: K) => boolean; \ No newline at end of file diff --git a/src/observable/map/FilteredMap.js b/src/observable/map/FilteredMap.ts similarity index 59% rename from src/observable/map/FilteredMap.js rename to src/observable/map/FilteredMap.ts index d7e11fbe..c97bc48a 100644 --- a/src/observable/map/FilteredMap.js +++ b/src/observable/map/FilteredMap.ts @@ -14,19 +14,28 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableMap} from "./BaseObservableMap"; +import {BaseObservableMap, Filter} from "./index"; +import {SubscriptionHandle} from "../BaseObservable"; -export class FilteredMap extends BaseObservableMap { - constructor(source, filter) { + +/* +This class MUST never be imported directly from here. +Instead, it MUST be imported from index.ts. See the +top level comment in index.ts for details. +*/ +export class FilteredMap extends BaseObservableMap { + private _source: BaseObservableMap; + private _filter: Filter; + private _included?: Map; + private _subscription?: SubscriptionHandle; + + constructor(source: BaseObservableMap, filter: Filter) { super(); this._source = source; this._filter = filter; - /** @type {Map} */ - this._included = null; - this._subscription = null; } - setFilter(filter) { + setFilter(filter: Filter): void { this._filter = filter; if (this._subscription) { this._reapplyFilter(); @@ -36,7 +45,7 @@ export class FilteredMap extends BaseObservableMap { /** * reapply the filter */ - _reapplyFilter(silent = false) { + _reapplyFilter(silent = false): void { if (this._filter) { const oldIncluded = this._included; this._included = this._included || new Map(); @@ -58,30 +67,38 @@ export class FilteredMap extends BaseObservableMap { } } } - this._included = null; + this._included = undefined; } } - onAdd(key, value) { + onAdd(key: K, value: V): void { if (this._filter) { - const included = this._filter(value, key); - this._included.set(key, included); - if (!included) { - return; + if (this._included) { + const included = this._filter(value, key); + this._included.set(key, included); + if (!included) { + return; + } + } else { + throw new Error("Internal logic error: FilteredMap._included used before initialized"); } } this.emitAdd(key, value); } - onRemove(key, value) { - const wasIncluded = !this._filter || this._included.get(key); - this._included.delete(key); - if (wasIncluded) { - this.emitRemove(key, value); + onRemove(key: K, value: V): void { + const wasIncluded = !this._filter || this._included?.get(key); + if (this._included) { + this._included.delete(key); + if (wasIncluded) { + this.emitRemove(key, value); + } + } else { + throw new Error("Internal logic error: FilteredMap._included used before initialized"); } } - onUpdate(key, value, params) { + onUpdate(key: K, value: V, params: any): void { // if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it if (!this._included) { return; @@ -96,7 +113,7 @@ export class FilteredMap extends BaseObservableMap { } } - _emitForUpdate(wasIncluded, isIncluded, key, value, params = null) { + _emitForUpdate(wasIncluded: boolean | undefined, isIncluded: boolean, key: K, value: V, params: any = null): void { if (wasIncluded && !isIncluded) { this.emitRemove(key, value); } else if (!wasIncluded && isIncluded) { @@ -106,30 +123,32 @@ export class FilteredMap extends BaseObservableMap { } } - onSubscribeFirst() { + onSubscribeFirst(): void { this._subscription = this._source.subscribe(this); this._reapplyFilter(true); super.onSubscribeFirst(); } - onUnsubscribeLast() { + onUnsubscribeLast(): void { super.onUnsubscribeLast(); - this._included = null; - this._subscription = this._subscription(); + this._included = undefined; + if (this._subscription) { + this._subscription = this._subscription(); + } } - onReset() { + onReset(): void { this._reapplyFilter(); this.emitReset(); } - [Symbol.iterator]() { - return new FilterIterator(this._source, this._included); + [Symbol.iterator](): FilterIterator { + return new FilterIterator(this._source, this._included); } - get size() { + get size(): number { let count = 0; - this._included.forEach(included => { + this._included?.forEach(included => { if (included) { count += 1; } @@ -137,7 +156,7 @@ export class FilteredMap extends BaseObservableMap { return count; } - get(key) { + get(key: K): V | undefined { const value = this._source.get(key); if (value && this._filter(value, key)) { return value; @@ -145,13 +164,15 @@ export class FilteredMap extends BaseObservableMap { } } -class FilterIterator { - constructor(map, _included) { - this._included = _included; +class FilterIterator { + private _included?: Map + private _sourceIterator: Iterator<[K, V], any, undefined> + constructor(map: BaseObservableMap, included?: Map) { + this._included = included; this._sourceIterator = map[Symbol.iterator](); } - next() { + next(): IteratorResult<[K, V]> { // eslint-disable-next-line no-constant-condition while (true) { const sourceResult = this._sourceIterator.next(); @@ -159,47 +180,62 @@ class FilterIterator { return sourceResult; } const key = sourceResult.value[0]; - if (this._included.get(key)) { + if (this._included?.get(key)) { return sourceResult; } } } } -import {ObservableMap} from "./ObservableMap"; +import {ObservableMap} from ".."; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function tests() { return { - "filter preloaded list": assert => { + "filter preloaded list": (assert): void => { const source = new ObservableMap(); source.add("one", 1); source.add("two", 2); source.add("three", 3); - const oddNumbers = new FilteredMap(source, x => x % 2 !== 0); + const oddNumbers = new FilteredMap(source, (x: number) => x % 2 !== 0); // can only iterate after subscribing - oddNumbers.subscribe({}); + oddNumbers.subscribe({ + onAdd() { + return; + }, + onRemove() { + return; + }, + onUpdate() { + return; + }, + onReset() { + return; + }, + }); assert.equal(oddNumbers.size, 2); const it = oddNumbers[Symbol.iterator](); assert.deepEqual(it.next().value, ["one", 1]); assert.deepEqual(it.next().value, ["three", 3]); assert.equal(it.next().done, true); }, - // "filter added values": assert => { + // "filter added values": (assert): void => { // }, - // "filter removed values": assert => { + // "filter removed values": (assert): void => { // }, - // "filter changed values": assert => { + // "filter changed values": (assert): void => { // }, - "emits must trigger once": assert => { + "emits must trigger once": (assert): void => { const source = new ObservableMap(); let count_add = 0, count_update = 0, count_remove = 0; source.add("num1", 1); source.add("num2", 2); source.add("num3", 3); - const oddMap = new FilteredMap(source, x => x % 2 !== 0); + const oddMap = new FilteredMap(source, (x: number) => x % 2 !== 0); oddMap.subscribe({ onAdd() { count_add += 1; @@ -209,6 +245,9 @@ export function tests() { }, onUpdate() { count_update += 1; + }, + onReset() { + return; } }); source.set("num3", 4); @@ -218,5 +257,5 @@ export function tests() { assert.strictEqual(count_update, 1); assert.strictEqual(count_remove, 1); } - } + }; } diff --git a/src/observable/map/JoinedMap.js b/src/observable/map/JoinedMap.ts similarity index 65% rename from src/observable/map/JoinedMap.js rename to src/observable/map/JoinedMap.ts index d97c5677..c125f4da 100644 --- a/src/observable/map/JoinedMap.js +++ b/src/observable/map/JoinedMap.ts @@ -14,16 +14,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableMap} from "./BaseObservableMap"; +import {BaseObservableMap} from "."; +import {SubscriptionHandle} from "../BaseObservable"; -export class JoinedMap extends BaseObservableMap { - constructor(sources) { + +/* +This class MUST never be imported directly from here. +Instead, it MUST be imported from index.ts. See the +top level comment in index.ts for details. +*/ +export class JoinedMap extends BaseObservableMap { + protected _sources: BaseObservableMap[]; + private _subscriptions?: SourceSubscriptionHandler[]; + + constructor(sources: BaseObservableMap[]) { super(); this._sources = sources; - this._subscriptions = null; } - onAdd(source, key, value) { + onAdd(source: BaseObservableMap, key: K, value: V): void { if (!this._isKeyAtSourceOccluded(source, key)) { const occludingValue = this._getValueFromOccludedSources(source, key); if (occludingValue !== undefined) { @@ -35,7 +44,7 @@ export class JoinedMap extends BaseObservableMap { } } - onRemove(source, key, value) { + onRemove(source: BaseObservableMap, key: K, value: V): void { if (!this._isKeyAtSourceOccluded(source, key)) { this.emitRemove(key, value); const occludedValue = this._getValueFromOccludedSources(source, key); @@ -47,7 +56,7 @@ export class JoinedMap extends BaseObservableMap { } } - onUpdate(source, key, value, params) { + onUpdate(source: BaseObservableMap, key: K, value: V, params: any): void { // if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it if (!this._subscriptions) { return; @@ -57,16 +66,16 @@ export class JoinedMap extends BaseObservableMap { } } - onReset() { + onReset(): void { this.emitReset(); } - onSubscribeFirst() { + onSubscribeFirst(): void { this._subscriptions = this._sources.map(source => new SourceSubscriptionHandler(source, this).subscribe()); super.onSubscribeFirst(); } - _isKeyAtSourceOccluded(source, key) { + _isKeyAtSourceOccluded(source: BaseObservableMap, key: K): boolean { // sources that come first in the sources array can // hide the keys in later sources, to prevent events // being emitted for the same key and different values, @@ -81,7 +90,7 @@ export class JoinedMap extends BaseObservableMap { } // get the value that the given source and key occlude, if any - _getValueFromOccludedSources(source, key) { + _getValueFromOccludedSources(source: BaseObservableMap, key: K): V | undefined{ // sources that come first in the sources array can // hide the keys in later sources, to prevent events // being emitted for the same key and different values, @@ -97,53 +106,58 @@ export class JoinedMap extends BaseObservableMap { return undefined; } - onUnsubscribeLast() { + onUnsubscribeLast(): void { super.onUnsubscribeLast(); - for (const s of this._subscriptions) { - s.dispose(); + if (this._subscriptions) { + for (const s of this._subscriptions) { + s.dispose(); + } } } + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type [Symbol.iterator]() { - return new JoinedIterator(this._sources); + return new JoinedIterator(this._sources); } - get size() { + get size(): number { return this._sources.reduce((sum, s) => sum + s.size, 0); } - get(key) { + get(key: K): V | undefined { for (const s of this._sources) { const value = s.get(key); if (value) { return value; } } - return null; + return undefined; } } -class JoinedIterator { - constructor(sources) { +class JoinedIterator implements Iterator<[K, V]> { + private _sources: {[Symbol.iterator](): Iterator<[K, V]>}[]; + private _sourceIndex = -1; + private _encounteredKeys = new Set(); + private _currentIterator?: Iterator<[K, V]> + + constructor(sources: {[Symbol.iterator](): Iterator<[K, V]>}[]) { this._sources = sources; - this._sourceIndex = -1; - this._currentIterator = null; - this._encounteredKeys = new Set(); } - next() { - let result; + next(): IteratorYieldResult<[K, V]> | IteratorReturnResult { + let result: IteratorYieldResult<[K, V]> | undefined = undefined; while (!result) { if (!this._currentIterator) { this._sourceIndex += 1; if (this._sources.length <= this._sourceIndex) { - return {done: true}; + return {done: true, value: null}; } this._currentIterator = this._sources[this._sourceIndex][Symbol.iterator](); } - const sourceResult = this._currentIterator.next(); - if (sourceResult.done) { - this._currentIterator = null; + const sourceResult = this._currentIterator?.next(); + if (!sourceResult || sourceResult.done) { + this._currentIterator = undefined; continue; } else { const key = sourceResult.value[0]; @@ -157,66 +171,73 @@ class JoinedIterator { } } -class SourceSubscriptionHandler { - constructor(source, joinedMap) { +class SourceSubscriptionHandler { + private _source: BaseObservableMap; + private _joinedMap: JoinedMap; + private _subscription?: SubscriptionHandle; + + constructor(source: BaseObservableMap, joinedMap: JoinedMap) { this._source = source; this._joinedMap = joinedMap; - this._subscription = null; + this._subscription = undefined; } - subscribe() { + subscribe(): this { this._subscription = this._source.subscribe(this); return this; } - dispose() { - this._subscription = this._subscription(); + dispose(): void { + if (this._subscription) this._subscription = this._subscription(); } - onAdd(key, value) { + onAdd(key: K, value: V): void { this._joinedMap.onAdd(this._source, key, value); } - onRemove(key, value) { + onRemove(key: K, value: V): void { this._joinedMap.onRemove(this._source, key, value); } - onUpdate(key, value, params) { + onUpdate(key: K, value: V, params: any): void { this._joinedMap.onUpdate(this._source, key, value, params); } - onReset() { - this._joinedMap.onReset(this._source); + onReset(): void { + this._joinedMap.onReset(); } } -import { ObservableMap } from "./ObservableMap"; +import {ObservableMap} from ".."; +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function tests() { - - function observeMap(map) { - const events = []; + function observeMap(map: JoinedMap): { type: string; key: any; value: any; params?: any; }[] { + const events: { type: string, key: any, value: any, params?: any }[] = []; map.subscribe({ - onAdd(key, value) { events.push({type: "add", key, value}); }, - onRemove(key, value) { events.push({type: "remove", key, value}); }, - onUpdate(key, value, params) { events.push({type: "update", key, value, params}); } + onAdd(key, value) { events.push({ type: "add", key, value }); }, + onRemove(key, value) { events.push({ type: "remove", key, value }); }, + onUpdate(key, value, params) { events.push({ type: "update", key, value, params }); }, + onReset: function (): void { + return; + } }); return events; } return { - "joined iterator": assert => { - const firstKV = ["a", 1]; - const secondKV = ["b", 2]; - const thirdKV = ["c", 3]; - const it = new JoinedIterator([[firstKV, secondKV], [thirdKV]]); + "joined iterator": (assert): void => { + const firstKV: [string, number] = ["a", 1]; + const secondKV: [string, number] = ["b", 2]; + const thirdKV: [string, number] = ["c", 3]; + const it = new JoinedIterator([[firstKV, secondKV], [thirdKV]]); assert.equal(it.next().value, firstKV); assert.equal(it.next().value, secondKV); assert.equal(it.next().value, thirdKV); assert.equal(it.next().done, true); }, - "prevent key collision during iteration": assert => { + "prevent key collision during iteration": (assert): void => { const first = new ObservableMap(); const second = new ObservableMap(); const join = new JoinedMap([first, second]); @@ -228,7 +249,7 @@ export function tests() { assert.deepEqual(it.next().value, ["b", 3]); assert.equal(it.next().done, true); }, - "adding occluded key doesn't emit add": assert => { + "adding occluded key doesn't emit add": (assert): void => { const first = new ObservableMap(); const second = new ObservableMap(); const join = new JoinedMap([first, second]); @@ -240,7 +261,7 @@ export function tests() { assert.equal(events[0].key, "a"); assert.equal(events[0].value, 1); }, - "updating occluded key doesn't emit update": assert => { + "updating occluded key doesn't emit update": (assert): void => { const first = new ObservableMap(); const second = new ObservableMap(); const join = new JoinedMap([first, second]); @@ -250,7 +271,7 @@ export function tests() { second.update("a", 3); assert.equal(events.length, 0); }, - "removal of occluding key emits add after remove": assert => { + "removal of occluding key emits add after remove": (assert): void => { const first = new ObservableMap(); const second = new ObservableMap(); const join = new JoinedMap([first, second]); @@ -266,7 +287,7 @@ export function tests() { assert.equal(events[1].key, "a"); assert.equal(events[1].value, 2); }, - "adding occluding key emits remove first": assert => { + "adding occluding key emits remove first": (assert): void => { const first = new ObservableMap(); const second = new ObservableMap(); const join = new JoinedMap([first, second]); diff --git a/src/observable/map/LogMap.js b/src/observable/map/LogMap.js deleted file mode 100644 index 1beb4846..00000000 --- a/src/observable/map/LogMap.js +++ /dev/null @@ -1,70 +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 {BaseObservableMap} from "./BaseObservableMap"; - -export class LogMap extends BaseObservableMap { - constructor(source, log) { - super(); - this._source = source; - this.log = log; - this._subscription = null; - } - - onAdd(key, value) { - this.log("add", key, value); - this.emitAdd(key, value); - } - - onRemove(key, value) { - this.log("remove", key, value); - this.emitRemove(key, value); - } - - onUpdate(key, value, params) { - this.log("update", key, value, params); - this.emitUpdate(key, value, params); - } - - onSubscribeFirst() { - this.log("subscribeFirst"); - this._subscription = this._source.subscribe(this); - super.onSubscribeFirst(); - } - - onUnsubscribeLast() { - super.onUnsubscribeLast(); - this._subscription = this._subscription(); - this.log("unsubscribeLast"); - } - - onReset() { - this.log("reset"); - this.emitReset(); - } - - [Symbol.iterator]() { - return this._source[Symbol.iterator](); - } - - get size() { - return this._source.size; - } - - get(key) { - return this._source.get(key); - } -} diff --git a/src/observable/map/LogMap.ts b/src/observable/map/LogMap.ts new file mode 100644 index 00000000..ce9d343e --- /dev/null +++ b/src/observable/map/LogMap.ts @@ -0,0 +1,86 @@ +/* +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 {BaseObservableMap} from "./index"; +import {SubscriptionHandle} from "../BaseObservable"; +import {ILogItem, LabelOrValues} from "../../logging/types"; +import {LogLevel} from "../../logging/LogFilter"; + + +/* +This class MUST never be imported directly from here. +Instead, it MUST be imported from index.ts. See the +top level comment in index.ts for details. +*/ +export class LogMap extends BaseObservableMap { + private _source: BaseObservableMap; + private _subscription?: SubscriptionHandle; + private _log: ILogItem; + + constructor(source: BaseObservableMap, log: ILogItem) { + super(); + this._source = source; + this._log = log; + } + + private log(labelOrValues: LabelOrValues, logLevel?: LogLevel): ILogItem { + return this._log.log(labelOrValues, logLevel); + } + + onAdd(key: K, value: V): void { + this.log("add " + JSON.stringify({key, value})); + this.emitAdd(key, value); + } + + onRemove(key: K, value: V): void { + this.log("remove " + JSON.stringify({key, value})); + this.emitRemove(key, value); + } + + onUpdate(key: K, value: V, params: any): void { + this.log("update" + JSON.stringify({key, value, params})); + this.emitUpdate(key, value, params); + } + + onSubscribeFirst(): void { + this.log("subscribeFirst"); + this._subscription = this._source.subscribe(this); + super.onSubscribeFirst(); + } + + onUnsubscribeLast(): void { + super.onUnsubscribeLast(); + if (this._subscription) this._subscription = this._subscription(); + this.log("unsubscribeLast"); + } + + onReset(): void { + this.log("reset"); + this.emitReset(); + } + + [Symbol.iterator](): Iterator<[K, V]> { + return this._source[Symbol.iterator](); + } + + get size(): number { + return this._source.size; + } + + get(key: K): V | undefined{ + return this._source.get(key); + } +} diff --git a/src/observable/map/MappedMap.js b/src/observable/map/MappedMap.ts similarity index 64% rename from src/observable/map/MappedMap.js rename to src/observable/map/MappedMap.ts index a6b65c41..6ada079f 100644 --- a/src/observable/map/MappedMap.js +++ b/src/observable/map/MappedMap.ts @@ -14,55 +14,76 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableMap} from "./BaseObservableMap"; +import {BaseObservableMap, Mapper, Updater} from "./index"; +import {SubscriptionHandle} from "../BaseObservable"; + + /* so a mapped value can emit updates on it's own with this._emitSpontaneousUpdate that is passed in the mapping function how should the mapped value be notified of an update though? and can it then decide to not propagate the update? */ -export class MappedMap extends BaseObservableMap { - constructor(source, mapper, updater) { +/* +This class MUST never be imported directly from here. +Instead, it MUST be imported from index.ts. See the +top level comment in index.ts for details. +*/ +export class MappedMap extends BaseObservableMap { + private _source: BaseObservableMap; + private _mapper: Mapper; + private _updater?: Updater; + private _mappedValues: Map; + private _subscription?: SubscriptionHandle; + + + constructor( + source: BaseObservableMap, + mapper: Mapper, + updater?: Updater + ) { super(); this._source = source; this._mapper = mapper; this._updater = updater; - this._mappedValues = new Map(); + this._mappedValues = new Map(); } - _emitSpontaneousUpdate(key, params) { + _emitSpontaneousUpdate(key: K, params: any): void { const value = this._mappedValues.get(key); if (value) { this.emitUpdate(key, value, params); } } - onAdd(key, value) { + onAdd(key: K, value: V): void { const emitSpontaneousUpdate = this._emitSpontaneousUpdate.bind(this, key); const mappedValue = this._mapper(value, emitSpontaneousUpdate); this._mappedValues.set(key, mappedValue); this.emitAdd(key, mappedValue); } - onRemove(key/*, _value*/) { + onRemove(key: K/*, _value*/): void { const mappedValue = this._mappedValues.get(key); if (this._mappedValues.delete(key)) { - this.emitRemove(key, mappedValue); + if (mappedValue) { + this.emitRemove(key, mappedValue); + } } } - onUpdate(key, value, params) { + onUpdate(key: K, value: V, params: any): void { // if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it if (!this._mappedValues) { return; } const mappedValue = this._mappedValues.get(key); if (mappedValue !== undefined) { - this._updater?.(mappedValue, params, value); + this._updater?.(params, mappedValue, value); // TODO: map params somehow if needed? this.emitUpdate(key, mappedValue, params); } } - onSubscribeFirst() { + onSubscribeFirst(): void { this._subscription = this._source.subscribe(this); for (let [key, value] of this._source) { const emitSpontaneousUpdate = this._emitSpontaneousUpdate.bind(this, key); @@ -72,26 +93,28 @@ export class MappedMap extends BaseObservableMap { super.onSubscribeFirst(); } - onUnsubscribeLast() { + onUnsubscribeLast(): void { super.onUnsubscribeLast(); - this._subscription = this._subscription(); + if (this._subscription) { + this._subscription = this._subscription(); + } this._mappedValues.clear(); } - onReset() { + onReset(): void { this._mappedValues.clear(); this.emitReset(); } - [Symbol.iterator]() { + [Symbol.iterator](): IterableIterator<[K, MappedV]> { return this._mappedValues.entries(); } - get size() { + get size(): number { return this._mappedValues.size; } - get(key) { + get(key: K): MappedV | undefined { return this._mappedValues.get(key); } -} +} \ No newline at end of file diff --git a/src/observable/map/ObservableMap.ts b/src/observable/map/ObservableMap.ts index 79662e29..45a6aa4c 100644 --- a/src/observable/map/ObservableMap.ts +++ b/src/observable/map/ObservableMap.ts @@ -14,8 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableMap} from "./BaseObservableMap"; +import {BaseObservableMap} from "./index"; + +/* +This class MUST never be imported directly from here. +Instead, it MUST be imported from index.ts. See the +top level comment in index.ts for details. +*/ export class ObservableMap extends BaseObservableMap { private readonly _values: Map; @@ -61,7 +67,7 @@ export class ObservableMap extends BaseObservableMap { // We set the value here because update only supports inline updates this._values.set(key, value); return this.update(key, undefined); - } + } else { return this.add(key, value); } @@ -93,9 +99,10 @@ export class ObservableMap extends BaseObservableMap { } } +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function tests() { return { - test_initial_values(assert) { + test_initial_values(assert): void { const map = new ObservableMap([ ["a", 5], ["b", 10] @@ -105,14 +112,14 @@ export function tests() { assert.equal(map.get("b"), 10); }, - test_add(assert) { + test_add(assert): void { let fired = 0; const map = new ObservableMap(); map.subscribe({ onAdd(key, value) { fired += 1; assert.equal(key, 1); - assert.deepEqual(value, {value: 5}); + assert.deepEqual(value, {value: 5}); }, onUpdate() {}, onRemove() {}, @@ -123,7 +130,7 @@ export function tests() { assert.equal(fired, 1); }, - test_update(assert) { + test_update(assert): void { let fired = 0; const map = new ObservableMap(); const value = {number: 5}; @@ -132,7 +139,7 @@ export function tests() { onUpdate(key, value, params) { fired += 1; assert.equal(key, 1); - assert.deepEqual(value, {number: 6}); + assert.deepEqual(value, {number: 6}); assert.equal(params, "test"); }, onAdd() {}, @@ -144,7 +151,7 @@ export function tests() { assert.equal(fired, 1); }, - test_update_unknown(assert) { + test_update_unknown(assert): void { let fired = 0; const map = new ObservableMap(); map.subscribe({ @@ -158,19 +165,19 @@ export function tests() { assert.equal(result, false); }, - test_set(assert) { + test_set(assert): void { let add_fired = 0, update_fired = 0; const map = new ObservableMap(); map.subscribe({ onAdd(key, value) { add_fired += 1; assert.equal(key, 1); - assert.deepEqual(value, {value: 5}); + assert.deepEqual(value, {value: 5}); }, onUpdate(key, value/*, params*/) { update_fired += 1; assert.equal(key, 1); - assert.deepEqual(value, {value: 7}); + assert.deepEqual(value, {value: 7}); }, onRemove() {}, onReset() {} @@ -185,7 +192,7 @@ export function tests() { assert.equal(update_fired, 1); }, - test_remove(assert) { + test_remove(assert): void { let fired = 0; const map = new ObservableMap(); const value = {value: 5}; @@ -194,7 +201,7 @@ export function tests() { onRemove(key, value) { fired += 1; assert.equal(key, 1); - assert.deepEqual(value, {value: 5}); + assert.deepEqual(value, {value: 5}); }, onAdd() {}, onUpdate() {}, @@ -205,7 +212,7 @@ export function tests() { assert.equal(fired, 1); }, - test_iterate(assert) { + test_iterate(assert): void { const results: any[] = []; const map = new ObservableMap(); map.add(1, {number: 5}); @@ -219,11 +226,11 @@ export function tests() { assert.equal(results.find(([key]) => key === 2)[1].number, 6); assert.equal(results.find(([key]) => key === 3)[1].number, 7); }, - test_size(assert) { + test_size(assert): void { const map = new ObservableMap(); map.add(1, {number: 5}); map.add(2, {number: 6}); assert.equal(map.size, 2); }, - } + }; } diff --git a/src/observable/map/index.ts b/src/observable/map/index.ts new file mode 100644 index 00000000..f4dd0f18 --- /dev/null +++ b/src/observable/map/index.ts @@ -0,0 +1,18 @@ +// In order to avoid a circular dependency problem at runtime between BaseObservableMap +// 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 {BaseObservableMap} from './BaseObservableMap'; +export type {Mapper, Updater, Comparator, Filter} from './BaseObservableMap'; +export {ApplyMap} from './ApplyMap'; +export {FilteredMap} from './FilteredMap'; +export {JoinedMap} from './JoinedMap'; +export {LogMap} from './LogMap'; +export {MappedMap} from './MappedMap'; +export {ObservableMap} from './ObservableMap'; +export {ObservableValueMap} from './ObservableValueMap'; diff --git a/src/observable/value/BaseObservableValue.ts b/src/observable/value/BaseObservableValue.ts index 85437262..62766331 100644 --- a/src/observable/value/BaseObservableValue.ts +++ b/src/observable/value/BaseObservableValue.ts @@ -17,11 +17,11 @@ limitations under the License. import {AbortError} from "../../utils/error"; import {BaseObservable} from "../BaseObservable"; import type {SubscriptionHandle} from "../BaseObservable"; -import {FlatMapObservableValue} from "./FlatMapObservableValue"; +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) { + emit(argument: T): void { for (const h of this._handlers) { h(argument); } @@ -36,6 +36,10 @@ export abstract class BaseObservableValue extends BaseObservable<(value: T) = return new WaitForHandle(this, predicate); } } + + flatMap(mapper: (value: T) => (BaseObservableValue | undefined)): BaseObservableValue { + return new FlatMapObservableValue(this, mapper); + } } interface IWaitHandle { @@ -65,7 +69,7 @@ class WaitForHandle implements IWaitHandle { return this._promise; } - dispose() { + dispose(): void { if (this._subscription) { this._subscription(); this._subscription = null; @@ -79,5 +83,5 @@ class WaitForHandle implements IWaitHandle { class ResolvedWaitForHandle implements IWaitHandle { constructor(public promise: Promise) {} - dispose() {} + dispose(): void {} } diff --git a/src/observable/value/EventObservableValue.ts b/src/observable/value/EventObservableValue.ts index 0f5fd524..7158ec0e 100644 --- a/src/observable/value/EventObservableValue.ts +++ b/src/observable/value/EventObservableValue.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableValue} from "./BaseObservableValue"; +import {BaseObservableValue} from "./index"; import {EventEmitter} from "../../utils/EventEmitter"; export class EventObservableValue> extends BaseObservableValue { diff --git a/src/observable/value/FlatMapObservableValue.ts b/src/observable/value/FlatMapObservableValue.ts index 9dff07a6..7e903372 100644 --- a/src/observable/value/FlatMapObservableValue.ts +++ b/src/observable/value/FlatMapObservableValue.ts @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableValue} from "./BaseObservableValue"; -import {SubscriptionHandle} from "../BaseObservable"; +import {BaseObservableValue} from "./index"; +import type {SubscriptionHandle} from "../BaseObservable"; export class FlatMapObservableValue extends BaseObservableValue { private sourceSubscription?: SubscriptionHandle; @@ -28,7 +28,7 @@ export class FlatMapObservableValue extends BaseObservableValue extends BaseObservableValue { this.updateTargetSubscription(); @@ -45,7 +45,7 @@ export class FlatMapObservableValue extends BaseObservableValue { + "flatMap.get": (assert): void => { const a = new ObservableValue}>(undefined); - const countProxy = new FlatMapObservableValue(a, a => a!.count); + 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 => { + "flatMap update from source": (assert): void => { const a = new ObservableValue}>(undefined); const updates: (number | undefined)[] = []; - new FlatMapObservableValue(a, a => a!.count).subscribe(count => { + 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 => { + "flatMap update from target": (assert): void => { const a = new ObservableValue}>(undefined); const updates: (number | undefined)[] = []; - new FlatMapObservableValue(a, a => a!.count).subscribe(count => { + a.flatMap(a => a!.count).subscribe(count => { updates.push(count); }); const count = new ObservableValue(0); @@ -105,5 +105,5 @@ export function tests() { count.set(5); assert.deepEqual(updates, [0, 5]); } - } + }; } diff --git a/src/observable/value/ObservableValue.ts b/src/observable/value/ObservableValue.ts index d75a0d76..d6566613 100644 --- a/src/observable/value/ObservableValue.ts +++ b/src/observable/value/ObservableValue.ts @@ -15,7 +15,7 @@ limitations under the License. */ import {AbortError} from "../../utils/error"; -import {BaseObservableValue} from "./BaseObservableValue"; +import {BaseObservableValue} from "./index"; export class ObservableValue extends BaseObservableValue { private _value: T; @@ -39,7 +39,7 @@ export class ObservableValue extends BaseObservableValue { export function tests() { return { - "set emits an update": assert => { + "set emits an update": (assert): void => { const a = new ObservableValue(0); let fired = false; const subscription = a.subscribe(v => { @@ -50,7 +50,7 @@ export function tests() { assert(fired); subscription(); }, - "set doesn't emit if value hasn't changed": assert => { + "set doesn't emit if value hasn't changed": (assert): void => { const a = new ObservableValue(5); let fired = false; const subscription = a.subscribe(() => { @@ -61,22 +61,22 @@ export function tests() { assert(!fired); subscription(); }, - "waitFor promise resolves on matching update": async assert => { + "waitFor promise resolves on matching update": async (assert): Promise => { const a = new ObservableValue(5); const handle = a.waitFor(v => v === 6); - Promise.resolve().then(() => { + await Promise.resolve().then(() => { a.set(6); }); await handle.promise; assert.strictEqual(a.get(), 6); }, - "waitFor promise rejects when disposed": async assert => { + "waitFor promise rejects when disposed": async (assert): Promise => { const a = new ObservableValue(0); const handle = a.waitFor(() => false); - Promise.resolve().then(() => { + 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 index 0b30fcfd..69891df5 100644 --- a/src/observable/value/PickMapObservableValue.ts +++ b/src/observable/value/PickMapObservableValue.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableValue} from "./BaseObservableValue"; +import {BaseObservableValue} from "./index"; import {BaseObservableMap, IMapObserver} from "../map/BaseObservableMap"; import {SubscriptionHandle} from "../BaseObservable"; diff --git a/src/observable/value/RetainedObservableValue.ts b/src/observable/value/RetainedObservableValue.ts index 16058f8e..1943035e 100644 --- a/src/observable/value/RetainedObservableValue.ts +++ b/src/observable/value/RetainedObservableValue.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ObservableValue} from "./ObservableValue"; +import {ObservableValue} from "./index"; export class RetainedObservableValue extends ObservableValue { @@ -22,11 +22,11 @@ export class RetainedObservableValue extends ObservableValue { super(initialValue); } - onSubscribeFirst() { + onSubscribeFirst(): void { this.startCallback(); } - onUnsubscribeLast() { + 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..ceb53628 --- /dev/null +++ b/src/observable/value/index.ts @@ -0,0 +1,15 @@ +// 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 {ObservableValue} from './ObservableValue'; diff --git a/src/platform/types/config.ts b/src/platform/types/config.ts new file mode 100644 index 00000000..8a5eabf2 --- /dev/null +++ b/src/platform/types/config.ts @@ -0,0 +1,64 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export type Config = { + /** + * The default homeserver used by Hydrogen; auto filled in the login UI. + * eg: https://matrix.org + * REQUIRED + */ + defaultHomeServer: string; + /** + * The submit endpoint for your preferred rageshake server. + * eg: https://element.io/bugreports/submit + * Read more about rageshake at https://github.com/matrix-org/rageshake + * OPTIONAL + */ + bugReportEndpointUrl?: string; + /** + * Paths to theme-manifests + * eg: ["assets/theme-element.json", "assets/theme-awesome.json"] + * REQUIRED + */ + themeManifests: string[]; + /** + * This configures the default theme(s) used by Hydrogen. + * These themes appear as "Default" option in the theme chooser UI and are also + * used as a fallback when other themes fail to load. + * Whether the dark or light variant is used depends on the system preference. + * OPTIONAL + */ + defaultTheme?: { + // id of light theme + light: string; + // id of dark theme + dark: string; + }; + /** + * Configuration for push notifications. + * See https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3pushersset + * and https://github.com/matrix-org/sygnal/blob/main/docs/applications.md#webpush + * OPTIONAL + */ + push?: { + // See app_id in the request body in above link + appId: string; + // The host used for pushing notification + gatewayUrl: string; + // See pushkey in above link + applicationServerKey: string; + }; +}; diff --git a/src/platform/types/theme.ts b/src/platform/types/theme.ts index 9a984277..9dd969de 100644 --- a/src/platform/types/theme.ts +++ b/src/platform/types/theme.ts @@ -22,6 +22,13 @@ export type ThemeManifest = Partial<{ version: number; // A user-facing string that is the name for this theme-collection. name: string; + // An identifier for this theme + id: string; + /** + * Id of the theme that this theme derives from. + * Only present for derived/runtime themes. + */ + extends: string; /** * This is added to the manifest during the build process and includes data * that is needed to load themes at runtime. @@ -42,6 +49,12 @@ export type ThemeManifest = Partial<{ "runtime-asset": string; // Array of derived-variables "derived-variables": Array; + /** + * Mapping from icon variable to location of icon in build output with query parameters + * indicating how it should be colored for this particular theme. + * eg: "icon-url-1": "element-logo.86bc8565.svg?primary=accent-color" + */ + icon: Record; }; values: { /** @@ -60,6 +73,8 @@ type Variant = Partial<{ default: boolean; // A user-facing string that is the name for this variant. name: string; + // A boolean indicating whether this is a dark theme or not + dark: boolean; /** * Mapping from css variable to its value. * eg: {"background-color-primary": "#21262b", ...} diff --git a/src/platform/types/types.ts b/src/platform/types/types.ts index c5f96f8b..77953c4a 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; @@ -51,3 +50,9 @@ export interface Timeout { }; export type TimeoutCreator = (timeout: number) => Timeout; + +export interface ITimeFormatter { + formatTime(date: Date): string; + formatRelativeDate(date: Date): string; + formatMachineReadableDate(date: Date): string; +} diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index ed912ee5..bfb585a3 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -41,7 +41,8 @@ import {parseHTML} from "./parsehtml.js"; import {handleAvatarError} from "./ui/avatar"; import {MediaDevicesWrapper} from "./dom/MediaDevices"; import {DOMWebRTC} from "./dom/WebRTC"; -import {ThemeLoader} from "./ThemeLoader"; +import {ThemeLoader} from "./theming/ThemeLoader"; +import {TimeFormatter} from "./dom/TimeFormatter"; function addScript(src) { return new Promise(function (resolve, reject) { @@ -142,6 +143,7 @@ export class Platform { 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(); @@ -197,7 +199,7 @@ export class Platform { await this._themeLoader?.init(manifests, log); const { themeName, themeVariant } = await this._themeLoader.getActiveTheme(); log.log({ l: "Active theme", name: themeName, variant: themeVariant }); - this._themeLoader.setTheme(themeName, themeVariant, log); + await this._themeLoader.setTheme(themeName, themeVariant, log); } }); } catch (err) { @@ -339,21 +341,39 @@ export class Platform { return this._themeLoader; } - replaceStylesheet(newPath) { - const head = document.querySelector("head"); - // remove default theme - document.querySelectorAll(".theme").forEach(e => e.remove()); - // add new theme - const styleTag = document.createElement("link"); - styleTag.href = `./${newPath}`; - styleTag.rel = "stylesheet"; - styleTag.type = "text/css"; - styleTag.className = "theme"; - head.appendChild(styleTag); + async replaceStylesheet(newPath, log) { + const error = await this.logger.wrapOrRun(log, { l: "replaceStylesheet", location: newPath, }, async (l) => { + let error; + const head = document.querySelector("head"); + // remove default theme + document.querySelectorAll(".theme").forEach(e => e.remove()); + // add new theme + const styleTag = document.createElement("link"); + styleTag.href = newPath; + styleTag.rel = "stylesheet"; + styleTag.type = "text/css"; + styleTag.className = "theme"; + const promise = new Promise(resolve => { + styleTag.onerror = () => { + error = new Error(`Failed to load stylesheet from ${newPath}`); + l.catch(error); + resolve(); + }; + styleTag.onload = () => { + resolve(); + }; + }); + head.appendChild(styleTag); + await promise; + return error; + }); + if (error) { + throw error; + } } get description() { - return navigator.userAgent ?? ""; + return "web-" + (navigator.userAgent ?? ""); } dispose() { diff --git a/src/platform/web/ThemeLoader.ts b/src/platform/web/ThemeLoader.ts deleted file mode 100644 index 8c9364bc..00000000 --- a/src/platform/web/ThemeLoader.ts +++ /dev/null @@ -1,207 +0,0 @@ -/* -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import type {ILogItem} from "../../logging/types.js"; -import type {Platform} from "./Platform.js"; - -type NormalVariant = { - id: string; - cssLocation: string; -}; - -type DefaultVariant = { - dark: { - id: string; - cssLocation: string; - variantName: string; - }; - light: { - id: string; - cssLocation: string; - variantName: string; - }; - default: { - id: string; - cssLocation: string; - variantName: string; - }; -} - -type ThemeInformation = NormalVariant | DefaultVariant; - -export enum ColorSchemePreference { - Dark, - Light -}; - -export class ThemeLoader { - private _platform: Platform; - private _themeMapping: Record; - - constructor(platform: Platform) { - this._platform = platform; - } - - async init(manifestLocations: string[], log?: ILogItem): Promise { - await this._platform.logger.wrapOrRun(log, "ThemeLoader.init", async (log) => { - this._themeMapping = {}; - const results = await Promise.all( - manifestLocations.map( location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response()) - ); - results.forEach(({ body }) => this._populateThemeMap(body, log)); - }); - } - - private _populateThemeMap(manifest, log: ILogItem) { - log.wrap("populateThemeMap", (l) => { - /* - After build has finished, the source section of each theme manifest - contains `built-assets` which is a mapping from the theme-id to - cssLocation of theme - */ - const builtAssets: Record = manifest.source?.["built-assets"]; - const themeName = manifest.name; - let defaultDarkVariant: any = {}, defaultLightVariant: any = {}; - for (const [themeId, cssLocation] of Object.entries(builtAssets)) { - const variant = themeId.match(/.+-(.+)/)?.[1]; - const { name: variantName, default: isDefault, dark } = manifest.values.variants[variant!]; - const themeDisplayName = `${themeName} ${variantName}`; - if (isDefault) { - /** - * This is a default variant! - * We'll add these to the themeMapping (separately) keyed with just the - * theme-name (i.e "Element" instead of "Element Dark"). - * We need to be able to distinguish them from other variants! - * - * This allows us to render radio-buttons with "dark" and - * "light" options. - */ - const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant; - defaultVariant.variantName = variantName; - defaultVariant.id = themeId - defaultVariant.cssLocation = cssLocation; - continue; - } - // Non-default variants are keyed in themeMapping with "theme_name variant_name" - // eg: "Element Dark" - this._themeMapping[themeDisplayName] = { - cssLocation, - id: themeId - }; - } - if (defaultDarkVariant.id && defaultLightVariant.id) { - /** - * As mentioned above, if there's both a default dark and a default light variant, - * add them to themeMapping separately. - */ - const defaultVariant = this.preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant; - this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant }; - } - else { - /** - * If only one default variant is found (i.e only dark default or light default but not both), - * treat it like any other variant. - */ - const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant; - this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation }; - } - //Add the default-theme as an additional option to the mapping - const defaultThemeId = this.getDefaultTheme(); - if (defaultThemeId) { - const themeDetails = this._findThemeDetailsFromId(defaultThemeId); - if (themeDetails) { - this._themeMapping["Default"] = { id: "default", cssLocation: themeDetails.cssLocation }; - } - } - l.log({ l: "Default Theme", theme: defaultThemeId}); - l.log({ l: "Preferred colorscheme", scheme: this.preferredColorScheme === ColorSchemePreference.Dark ? "dark" : "light" }); - l.log({ l: "Result", themeMapping: this._themeMapping }); - }); - } - - setTheme(themeName: string, themeVariant?: "light" | "dark" | "default", log?: ILogItem) { - this._platform.logger.wrapOrRun(log, { l: "change theme", name: themeName, variant: themeVariant }, () => { - let cssLocation: string; - let themeDetails = this._themeMapping[themeName]; - if ("id" in themeDetails) { - cssLocation = themeDetails.cssLocation; - } - else { - if (!themeVariant) { - throw new Error("themeVariant is undefined!"); - } - cssLocation = themeDetails[themeVariant].cssLocation; - } - this._platform.replaceStylesheet(cssLocation); - this._platform.settingsStorage.setString("theme-name", themeName); - if (themeVariant) { - this._platform.settingsStorage.setString("theme-variant", themeVariant); - } - else { - this._platform.settingsStorage.remove("theme-variant"); - } - }); - } - - /** Maps theme display name to theme information */ - get themeMapping(): Record { - return this._themeMapping; - } - - async getActiveTheme(): Promise<{themeName: string, themeVariant?: string}> { - let themeName = await this._platform.settingsStorage.getString("theme-name"); - let themeVariant = await this._platform.settingsStorage.getString("theme-variant"); - if (!themeName || !this._themeMapping[themeName]) { - themeName = "Default" in this._themeMapping ? "Default" : Object.keys(this._themeMapping)[0]; - if (!this._themeMapping[themeName][themeVariant]) { - themeVariant = "default" in this._themeMapping[themeName] ? "default" : undefined; - } - } - return { themeName, themeVariant }; - } - - getDefaultTheme(): string | undefined { - switch (this.preferredColorScheme) { - case ColorSchemePreference.Dark: - return this._platform.config["defaultTheme"]?.dark; - case ColorSchemePreference.Light: - return this._platform.config["defaultTheme"]?.light; - } - } - - private _findThemeDetailsFromId(themeId: string): {themeName: string, cssLocation: string, variant?: string} | undefined { - for (const [themeName, themeData] of Object.entries(this._themeMapping)) { - if ("id" in themeData && themeData.id === themeId) { - return { themeName, cssLocation: themeData.cssLocation }; - } - else if ("light" in themeData && themeData.light?.id === themeId) { - return { themeName, cssLocation: themeData.light.cssLocation, variant: "light" }; - } - else if ("dark" in themeData && themeData.dark?.id === themeId) { - return { themeName, cssLocation: themeData.dark.cssLocation, variant: "dark" }; - } - } - } - - get preferredColorScheme(): ColorSchemePreference | undefined { - if (window.matchMedia("(prefers-color-scheme: dark)").matches) { - return ColorSchemePreference.Dark; - } - else if (window.matchMedia("(prefers-color-scheme: light)").matches) { - return ColorSchemePreference.Light; - } - } -} diff --git a/src/platform/web/dom/History.js b/src/platform/web/dom/History.js index 96576626..81ddc739 100644 --- a/src/platform/web/dom/History.js +++ b/src/platform/web/dom/History.js @@ -14,9 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableValue} from "../../../observable/value/BaseObservableValue"; +import {BaseObservableValue} from "../../../observable/value"; export class History extends BaseObservableValue { + + constructor() { + super(); + this._lastSessionHash = undefined; + } + handleEvent(event) { if (event.type === "hashchange") { this.emit(this.get()); @@ -65,6 +71,7 @@ export class History extends BaseObservableValue { } onSubscribeFirst() { + this._lastSessionHash = window.localStorage?.getItem("hydrogen_last_url_hash"); window.addEventListener('hashchange', this); } @@ -76,7 +83,7 @@ export class History extends BaseObservableValue { window.localStorage?.setItem("hydrogen_last_url_hash", hash); } - getLastUrl() { - return window.localStorage?.getItem("hydrogen_last_url_hash"); + getLastSessionUrl() { + return this._lastSessionHash; } } diff --git a/src/platform/web/dom/OnlineStatus.js b/src/platform/web/dom/OnlineStatus.js index fd114603..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/value/BaseObservableValue"; +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..2a98a716 --- /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"; + +enum TimeScope { + Minute = 60 * 1000, + Day = 24 * 60 * 60 * 1000, +} + +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); + } + } +} + +function capitalizeFirstLetter(str: string) { + return str.slice(0, 1).toLocaleUpperCase() + str.slice(1); +} diff --git a/src/platform/web/dom/request/fetch.js b/src/platform/web/dom/request/fetch.js index 497ad553..c2e2d4b7 100644 --- a/src/platform/web/dom/request/fetch.js +++ b/src/platform/web/dom/request/fetch.js @@ -115,8 +115,12 @@ export function createFetchRequest(createTimeout, serviceWorkerHandler) { } else if (format === "buffer") { body = await response.arrayBuffer(); } + else if (format === "text") { + body = await response.text(); + } } catch (err) { // some error pages return html instead of json, ignore error + // detect these ignored errors from the response status if (!(err.name === "SyntaxError" && status >= 400)) { throw err; } diff --git a/src/platform/web/main.js b/src/platform/web/main.js index edc2cf14..2b28187e 100644 --- a/src/platform/web/main.js +++ b/src/platform/web/main.js @@ -17,7 +17,7 @@ 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.js"; +import {createNavigation, createRouter} from "../../domain/navigation/index"; // 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 @@ -41,7 +41,7 @@ export async function main(platform) { platform, // the only public interface of the router is to create urls, // so we call it that in the view models - urlCreator: urlRouter, + urlRouter: urlRouter, navigation, }); await vm.load(); diff --git a/src/platform/web/theming/DerivedVariables.ts b/src/platform/web/theming/DerivedVariables.ts new file mode 100644 index 00000000..ca46a8fd --- /dev/null +++ b/src/platform/web/theming/DerivedVariables.ts @@ -0,0 +1,131 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import {derive} from "./shared/color.mjs"; + +export class DerivedVariables { + private _baseVariables: Record; + private _variablesToDerive: string[] + private _isDark: boolean + private _aliases: Record = {}; + private _derivedAliases: string[] = []; + + constructor(baseVariables: Record, variablesToDerive: string[], isDark: boolean) { + this._baseVariables = baseVariables; + this._variablesToDerive = variablesToDerive; + this._isDark = isDark; + } + + toVariables(): Record { + const resolvedVariables: any = {}; + this._detectAliases(); + for (const variable of this._variablesToDerive) { + const resolvedValue = this._derive(variable); + if (resolvedValue) { + resolvedVariables[variable] = resolvedValue; + } + } + for (const [alias, variable] of Object.entries(this._aliases) as any) { + resolvedVariables[alias] = this._baseVariables[variable] ?? resolvedVariables[variable]; + } + for (const variable of this._derivedAliases) { + const resolvedValue = this._deriveAlias(variable, resolvedVariables); + if (resolvedValue) { + resolvedVariables[variable] = resolvedValue; + } + } + return resolvedVariables; + } + + private _detectAliases(): void { + const newVariablesToDerive: string[] = []; + for (const variable of this._variablesToDerive) { + const [alias, value] = variable.split("="); + if (value) { + this._aliases[alias] = value; + } + else { + newVariablesToDerive.push(variable); + } + } + this._variablesToDerive = newVariablesToDerive; + } + + private _derive(variable: string): string | undefined { + const RE_VARIABLE_VALUE = /(.+)--(.+)-(.+)/; + const matches = variable.match(RE_VARIABLE_VALUE); + if (matches) { + const [, baseVariable, operation, argument] = matches; + const value = this._baseVariables[baseVariable]; + if (!value ) { + if (this._aliases[baseVariable]) { + this._derivedAliases.push(variable); + return; + } + else { + throw new Error(`Cannot find value for base variable "${baseVariable}"!`); + } + } + const resolvedValue = derive(value, operation, argument, this._isDark); + return resolvedValue; + } + } + + private _deriveAlias(variable: string, resolvedVariables: Record): string | undefined { + const RE_VARIABLE_VALUE = /(.+)--(.+)-(.+)/; + const matches = variable.match(RE_VARIABLE_VALUE); + if (matches) { + const [, baseVariable, operation, argument] = matches; + const value = resolvedVariables[baseVariable]; + if (!value ) { + throw new Error(`Cannot find value for alias "${baseVariable}" when trying to derive ${variable}!`); + } + const resolvedValue = derive(value, operation, argument, this._isDark); + return resolvedValue; + } + } +} + +import * as pkg from "off-color"; +// @ts-ignore +const offColor = pkg.offColor ?? pkg.default.offColor; + +export function tests() { + return { + "Simple variable derivation": assert => { + const deriver = new DerivedVariables({ "background-color": "#ff00ff" }, ["background-color--darker-5"], false); + const result = deriver.toVariables(); + const resultColor = offColor("#ff00ff").darken(5/100).hex(); + assert.deepEqual(result, {"background-color--darker-5": resultColor}); + }, + + "For dark themes, lighten and darken are inverted": assert => { + const deriver = new DerivedVariables({ "background-color": "#ff00ff" }, ["background-color--darker-5"], true); + const result = deriver.toVariables(); + const resultColor = offColor("#ff00ff").lighten(5/100).hex(); + assert.deepEqual(result, {"background-color--darker-5": resultColor}); + }, + + "Aliases can be derived": assert => { + const deriver = new DerivedVariables({ "background-color": "#ff00ff" }, ["my-awesome-alias=background-color","my-awesome-alias--darker-5"], false); + const result = deriver.toVariables(); + const resultColor = offColor("#ff00ff").darken(5/100).hex(); + assert.deepEqual(result, { + "my-awesome-alias": "#ff00ff", + "my-awesome-alias--darker-5": resultColor, + }); + }, + } +} diff --git a/src/platform/web/theming/IconColorizer.ts b/src/platform/web/theming/IconColorizer.ts new file mode 100644 index 00000000..e02c0971 --- /dev/null +++ b/src/platform/web/theming/IconColorizer.ts @@ -0,0 +1,79 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import type {Platform} from "../Platform.js"; +import {getColoredSvgString} from "./shared/svg-colorizer.mjs"; + +type ParsedStructure = { + [variableName: string]: { + svg: Promise<{ status: number; body: string }>; + primary: string | null; + secondary: string | null; + }; +}; + +export class IconColorizer { + private _iconVariables: Record; + private _resolvedVariables: Record; + private _manifestLocation: string; + private _platform: Platform; + + constructor(platform: Platform, iconVariables: Record, resolvedVariables: Record, manifestLocation: string) { + this._platform = platform; + this._iconVariables = iconVariables; + this._resolvedVariables = resolvedVariables; + this._manifestLocation = manifestLocation; + } + + async toVariables(): Promise> { + const { parsedStructure, promises } = await this._fetchAndParseIcons(); + await Promise.all(promises); + return this._produceColoredIconVariables(parsedStructure); + } + + private async _fetchAndParseIcons(): Promise<{ parsedStructure: ParsedStructure, promises: any[] }> { + const promises: any[] = []; + const parsedStructure: ParsedStructure = {}; + for (const [variable, url] of Object.entries(this._iconVariables)) { + const urlObject = new URL(`https://${url}`); + const pathWithoutQueryParams = urlObject.hostname; + const relativePath = new URL(pathWithoutQueryParams, new URL(this._manifestLocation, window.location.origin)); + const responsePromise = this._platform.request(relativePath, { method: "GET", format: "text", cache: true, }).response() + promises.push(responsePromise); + const searchParams = urlObject.searchParams; + parsedStructure[variable] = { + svg: responsePromise, + primary: searchParams.get("primary"), + secondary: searchParams.get("secondary") + }; + } + return { parsedStructure, promises }; + } + + private async _produceColoredIconVariables(parsedStructure: ParsedStructure): Promise> { + let coloredVariables: Record = {}; + for (const [variable, { svg, primary, secondary }] of Object.entries(parsedStructure)) { + const { body: svgCode } = await svg; + if (!primary) { + throw new Error(`Primary color variable ${primary} not in list of variables!`); + } + const primaryColor = this._resolvedVariables[primary], secondaryColor = this._resolvedVariables[secondary!]; + const coloredSvgCode = getColoredSvgString(svgCode, primaryColor, secondaryColor); + const dataURI = `url('data:image/svg+xml;utf8,${encodeURIComponent(coloredSvgCode)}')`; + coloredVariables[variable] = dataURI; + } + return coloredVariables; + } +} diff --git a/src/platform/web/theming/ThemeLoader.ts b/src/platform/web/theming/ThemeLoader.ts new file mode 100644 index 00000000..665c3a17 --- /dev/null +++ b/src/platform/web/theming/ThemeLoader.ts @@ -0,0 +1,209 @@ +/* +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 {RuntimeThemeParser} from "./parsers/RuntimeThemeParser"; +import {ColorSchemePreference} from "./parsers/types"; +import {BuiltThemeParser} from "./parsers/BuiltThemeParser"; +import type {Variant, ThemeInformation} from "./parsers/types"; +import type {ThemeManifest} from "../../types/theme"; +import type {ILogItem} from "../../../logging/types"; +import type {Platform} from "../Platform.js"; +import {LogLevel} from "../../../logging/LogFilter"; + +export class ThemeLoader { + private _platform: Platform; + private _themeMapping: Record; + private _injectedVariables?: Record; + + constructor(platform: Platform) { + this._platform = platform; + } + + async init(manifestLocations: string[], log?: ILogItem): Promise { + await this._platform.logger.wrapOrRun(log, "ThemeLoader.init", async (log) => { + let noManifestsAvailable = true; + const failedManifestLoads: string[] = []; + const parseErrors: string[] = []; + const results = await Promise.all( + manifestLocations.map(location => this._platform.request(location, { method: "GET", format: "json", cache: true, }).response()) + ); + const runtimeThemeParser = new RuntimeThemeParser(this._platform, this.preferredColorScheme); + const builtThemeParser = new BuiltThemeParser(this.preferredColorScheme); + const runtimeThemePromises: Promise[] = []; + for (let i = 0; i < results.length; ++i) { + const result = results[i]; + const { status, body } = result; + if (!(status >= 200 && status <= 299)) { + console.error(`Failed to load manifest at ${manifestLocations[i]}, status: ${status}`); + log.log({ l: "Manifest fetch failed", location: manifestLocations[i], status }, LogLevel.Error); + failedManifestLoads.push(manifestLocations[i]) + continue; + } + noManifestsAvailable = false; + try { + if (body.extends) { + const indexOfBaseManifest = results.findIndex(result => "value" in result && result.value.body.id === body.extends); + if (indexOfBaseManifest === -1) { + throw new Error(`Base manifest for derived theme at ${manifestLocations[i]} not found!`); + } + const { body: baseManifest } = (results[indexOfBaseManifest] as PromiseFulfilledResult<{ body: ThemeManifest }>).value; + const baseManifestLocation = manifestLocations[indexOfBaseManifest]; + const promise = runtimeThemeParser.parse(body, baseManifest, baseManifestLocation, log); + runtimeThemePromises.push(promise); + } + else { + builtThemeParser.parse(body, manifestLocations[i], log); + } + } + catch(e) { + console.error(e); + parseErrors.push(e.message); + } + } + await Promise.all(runtimeThemePromises); + this._themeMapping = { ...builtThemeParser.themeMapping, ...runtimeThemeParser.themeMapping }; + if (noManifestsAvailable) { + // We need at least one working theme manifest! + throw new Error(`All configured theme manifests failed to load, the following were tried: ${failedManifestLoads.join(", ")}`); + } + else if (Object.keys(this._themeMapping).length === 0 && parseErrors.length) { + // Something is wrong..., themeMapping is empty! + throw new Error(`Failed to parse theme manifests, the following errors were encountered: ${parseErrors.join(", ")}`); + } + this._addDefaultThemeToMapping(log); + log.log({ l: "Preferred colorscheme", scheme: this.preferredColorScheme === ColorSchemePreference.Dark ? "dark" : "light" }); + log.log({ l: "Result", themeMapping: this._themeMapping }); + }); + } + + async setTheme(themeName: string, themeVariant?: "light" | "dark" | "default", log?: ILogItem) { + await this._platform.logger.wrapOrRun(log, { l: "change theme", name: themeName, variant: themeVariant }, async (l) => { + let cssLocation: string, variables: Record; + let themeDetails = this._themeMapping[themeName]; + if ("id" in themeDetails) { + cssLocation = themeDetails.cssLocation; + variables = themeDetails.variables; + } + else { + if (!themeVariant) { + throw new Error("themeVariant is undefined!"); + } + cssLocation = themeDetails[themeVariant].cssLocation; + variables = themeDetails[themeVariant].variables; + } + await this._platform.replaceStylesheet(cssLocation, l); + if (variables) { + log?.log({l: "Derived Theme", variables}); + this._injectCSSVariables(variables); + } + else { + this._removePreviousCSSVariables(); + } + this._platform.settingsStorage.setString("theme-name", themeName); + if (themeVariant) { + this._platform.settingsStorage.setString("theme-variant", themeVariant); + } + else { + this._platform.settingsStorage.remove("theme-variant"); + } + }); + } + + private _injectCSSVariables(variables: Record): void { + const root = document.documentElement; + for (const [variable, value] of Object.entries(variables)) { + root.style.setProperty(`--${variable}`, value); + } + this._injectedVariables = variables; + } + + private _removePreviousCSSVariables(): void { + if (!this._injectedVariables) { + return; + } + const root = document.documentElement; + for (const variable of Object.keys(this._injectedVariables)) { + root.style.removeProperty(`--${variable}`); + } + this._injectedVariables = undefined; + } + + /** Maps theme display name to theme information */ + get themeMapping(): Record { + return this._themeMapping; + } + + async getActiveTheme(): Promise<{themeName: string, themeVariant?: string}> { + let themeName = await this._platform.settingsStorage.getString("theme-name"); + let themeVariant = await this._platform.settingsStorage.getString("theme-variant"); + if (!themeName || !this._themeMapping[themeName]) { + themeName = "Default" in this._themeMapping ? "Default" : Object.keys(this._themeMapping)[0]; + if (!this._themeMapping[themeName][themeVariant]) { + themeVariant = "default" in this._themeMapping[themeName] ? "default" : undefined; + } + } + return { themeName, themeVariant }; + } + + getDefaultTheme(): string | undefined { + switch (this.preferredColorScheme) { + case ColorSchemePreference.Dark: + return this._platform.config["defaultTheme"]?.dark; + case ColorSchemePreference.Light: + return this._platform.config["defaultTheme"]?.light; + } + } + + private _findThemeDetailsFromId(themeId: string): {themeName: string, themeData: Partial} | undefined { + for (const [themeName, themeData] of Object.entries(this._themeMapping)) { + if ("id" in themeData && themeData.id === themeId) { + return { themeName, themeData }; + } + else if ("light" in themeData && themeData.light?.id === themeId) { + return { themeName, themeData: themeData.light }; + } + else if ("dark" in themeData && themeData.dark?.id === themeId) { + return { themeName, themeData: themeData.dark }; + } + } + } + + private _addDefaultThemeToMapping(log: ILogItem) { + log.wrap("addDefaultThemeToMapping", l => { + const defaultThemeId = this.getDefaultTheme(); + if (defaultThemeId) { + const themeDetails = this._findThemeDetailsFromId(defaultThemeId); + if (themeDetails) { + this._themeMapping["Default"] = { id: "default", cssLocation: themeDetails.themeData.cssLocation! }; + const variables = themeDetails.themeData.variables; + if (variables) { + this._themeMapping["Default"].variables = variables; + } + } + } + l.log({ l: "Default Theme", theme: defaultThemeId}); + }); + } + + get preferredColorScheme(): ColorSchemePreference | undefined { + if (window.matchMedia("(prefers-color-scheme: dark)").matches) { + return ColorSchemePreference.Dark; + } + else if (window.matchMedia("(prefers-color-scheme: light)").matches) { + return ColorSchemePreference.Light; + } + } +} diff --git a/src/platform/web/theming/parsers/BuiltThemeParser.ts b/src/platform/web/theming/parsers/BuiltThemeParser.ts new file mode 100644 index 00000000..fbafadb8 --- /dev/null +++ b/src/platform/web/theming/parsers/BuiltThemeParser.ts @@ -0,0 +1,106 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type {ThemeInformation} from "./types"; +import type {ThemeManifest} from "../../../types/theme"; +import type {ILogItem} from "../../../../logging/types"; +import {ColorSchemePreference} from "./types"; + +export class BuiltThemeParser { + private _themeMapping: Record = {}; + private _preferredColorScheme?: ColorSchemePreference; + + constructor(preferredColorScheme?: ColorSchemePreference) { + this._preferredColorScheme = preferredColorScheme; + } + + parse(manifest: ThemeManifest, manifestLocation: string, log: ILogItem) { + log.wrap("BuiltThemeParser.parse", () => { + /* + After build has finished, the source section of each theme manifest + contains `built-assets` which is a mapping from the theme-id to + cssLocation of theme + */ + const builtAssets: Record = manifest.source?.["built-assets"]; + const themeName = manifest.name; + if (!themeName) { + throw new Error(`Theme name not found in manifest at ${manifestLocation}`); + } + let defaultDarkVariant: any = {}, defaultLightVariant: any = {}; + for (let [themeId, cssLocation] of Object.entries(builtAssets)) { + try { + /** + * This cssLocation is relative to the location of the manifest file. + * So we first need to resolve it relative to the root of this hydrogen instance. + */ + cssLocation = new URL(cssLocation, new URL(manifestLocation, window.location.origin)).href; + } + catch { + continue; + } + const variant = themeId.match(/.+-(.+)/)?.[1]; + const variantDetails = manifest.values?.variants[variant!]; + if (!variantDetails) { + throw new Error(`Variant ${variant} is missing in manifest at ${manifestLocation}`); + } + const { name: variantName, default: isDefault, dark } = variantDetails; + const themeDisplayName = `${themeName} ${variantName}`; + if (isDefault) { + /** + * This is a default variant! + * We'll add these to the themeMapping (separately) keyed with just the + * theme-name (i.e "Element" instead of "Element Dark"). + * We need to be able to distinguish them from other variants! + * + * This allows us to render radio-buttons with "dark" and + * "light" options. + */ + const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant; + defaultVariant.variantName = variantName; + defaultVariant.id = themeId + defaultVariant.cssLocation = cssLocation; + continue; + } + // Non-default variants are keyed in themeMapping with "theme_name variant_name" + // eg: "Element Dark" + this._themeMapping[themeDisplayName] = { + cssLocation, + id: themeId + }; + } + if (defaultDarkVariant.id && defaultLightVariant.id) { + /** + * As mentioned above, if there's both a default dark and a default light variant, + * add them to themeMapping separately. + */ + const defaultVariant = this._preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant; + this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant }; + } + else { + /** + * If only one default variant is found (i.e only dark default or light default but not both), + * treat it like any other variant. + */ + const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant; + this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation }; + } + }); + } + + get themeMapping(): Record { + return this._themeMapping; + } +} diff --git a/src/platform/web/theming/parsers/RuntimeThemeParser.ts b/src/platform/web/theming/parsers/RuntimeThemeParser.ts new file mode 100644 index 00000000..9471740a --- /dev/null +++ b/src/platform/web/theming/parsers/RuntimeThemeParser.ts @@ -0,0 +1,98 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import type {ThemeInformation} from "./types"; +import type {Platform} from "../../Platform.js"; +import type {ThemeManifest} from "../../../types/theme"; +import {ColorSchemePreference} from "./types"; +import {IconColorizer} from "../IconColorizer"; +import {DerivedVariables} from "../DerivedVariables"; +import {ILogItem} from "../../../../logging/types"; + +export class RuntimeThemeParser { + private _themeMapping: Record = {}; + private _preferredColorScheme?: ColorSchemePreference; + private _platform: Platform; + + constructor(platform: Platform, preferredColorScheme?: ColorSchemePreference) { + this._preferredColorScheme = preferredColorScheme; + this._platform = platform; + } + + async parse(manifest: ThemeManifest, baseManifest: ThemeManifest, baseManifestLocation: string, log: ILogItem): Promise { + await log.wrap("RuntimeThemeParser.parse", async () => { + const {cssLocation, derivedVariables, icons} = this._getSourceData(baseManifest, baseManifestLocation, log); + const themeName = manifest.name; + if (!themeName) { + throw new Error(`Theme name not found in manifest!`); + } + let defaultDarkVariant: any = {}, defaultLightVariant: any = {}; + for (const [variant, variantDetails] of Object.entries(manifest.values?.variants!) as [string, any][]) { + try { + const themeId = `${manifest.id}-${variant}`; + const { name: variantName, default: isDefault, dark, variables } = variantDetails; + const resolvedVariables = new DerivedVariables(variables, derivedVariables, dark).toVariables(); + Object.assign(variables, resolvedVariables); + const iconVariables = await new IconColorizer(this._platform, icons, variables, baseManifestLocation).toVariables(); + Object.assign(variables, resolvedVariables, iconVariables); + const themeDisplayName = `${themeName} ${variantName}`; + if (isDefault) { + const defaultVariant = dark ? defaultDarkVariant : defaultLightVariant; + Object.assign(defaultVariant, { variantName, id: themeId, cssLocation, variables }); + continue; + } + this._themeMapping[themeDisplayName] = { cssLocation, id: themeId, variables: variables, }; + } + catch (e) { + console.error(e); + continue; + } + } + if (defaultDarkVariant.id && defaultLightVariant.id) { + const defaultVariant = this._preferredColorScheme === ColorSchemePreference.Dark ? defaultDarkVariant : defaultLightVariant; + this._themeMapping[themeName] = { dark: defaultDarkVariant, light: defaultLightVariant, default: defaultVariant }; + } + else { + const variant = defaultDarkVariant.id ? defaultDarkVariant : defaultLightVariant; + this._themeMapping[`${themeName} ${variant.variantName}`] = { id: variant.id, cssLocation: variant.cssLocation }; + } + }); + } + + private _getSourceData(manifest: ThemeManifest, location: string, log: ILogItem) + : { cssLocation: string, derivedVariables: string[], icons: Record} { + return log.wrap("getSourceData", () => { + const runtimeCSSLocation = manifest.source?.["runtime-asset"]; + if (!runtimeCSSLocation) { + throw new Error(`Run-time asset not found in source section for theme at ${location}`); + } + const cssLocation = new URL(runtimeCSSLocation, new URL(location, window.location.origin)).href; + const derivedVariables = manifest.source?.["derived-variables"]; + if (!derivedVariables) { + throw new Error(`Derived variables not found in source section for theme at ${location}`); + } + const icons = manifest.source?.["icon"]; + if (!icons) { + throw new Error(`Icon mapping not found in source section for theme at ${location}`); + } + return { cssLocation, derivedVariables, icons }; + }); + } + + get themeMapping(): Record { + return this._themeMapping; + } + +} diff --git a/src/platform/web/theming/parsers/types.ts b/src/platform/web/theming/parsers/types.ts new file mode 100644 index 00000000..b357cf2c --- /dev/null +++ b/src/platform/web/theming/parsers/types.ts @@ -0,0 +1,38 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export type NormalVariant = { + id: string; + cssLocation: string; + variables?: any; +}; + +export type Variant = NormalVariant & { + variantName: string; +}; + +export type DefaultVariant = { + dark: Variant; + light: Variant; + default: Variant; +} + +export type ThemeInformation = NormalVariant | DefaultVariant; + +export enum ColorSchemePreference { + Dark, + Light +}; diff --git a/scripts/postcss/color.js b/src/platform/web/theming/shared/color.mjs similarity index 83% rename from scripts/postcss/color.js rename to src/platform/web/theming/shared/color.mjs index b1ef7073..31d40185 100644 --- a/scripts/postcss/color.js +++ b/src/platform/web/theming/shared/color.mjs @@ -13,10 +13,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +import * as pkg from 'off-color'; +const offColor = pkg.offColor ?? pkg.default.offColor; -const offColor = require("off-color").offColor; - -module.exports.derive = function (value, operation, argument, isDark) { +export function derive(value, operation, argument, isDark) { const argumentAsNumber = parseInt(argument); if (isDark) { // For dark themes, invert the operation @@ -36,5 +36,8 @@ module.exports.derive = function (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/theming/shared/svg-colorizer.mjs b/src/platform/web/theming/shared/svg-colorizer.mjs new file mode 100644 index 00000000..cb291726 --- /dev/null +++ b/src/platform/web/theming/shared/svg-colorizer.mjs @@ -0,0 +1,24 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export function getColoredSvgString(svgString, primaryColor, secondaryColor) { + let coloredSVGCode = svgString.replaceAll("#ff00ff", primaryColor); + coloredSVGCode = coloredSVGCode.replaceAll("#00ffff", secondaryColor); + if (svgString === coloredSVGCode) { + throw new Error("svg-colorizer made no color replacements! The input svg should only contain colors #ff00ff (primary, case-sensitive) and #00ffff (secondary, case-sensitive)."); + } + return coloredSVGCode; +} diff --git a/src/platform/web/ui/ForcedLogoutView.js b/src/platform/web/ui/ForcedLogoutView.js new file mode 100644 index 00000000..2c2aab97 --- /dev/null +++ b/src/platform/web/ui/ForcedLogoutView.js @@ -0,0 +1,47 @@ +/* +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 {TemplateView} from "./general/TemplateView"; +import {spinner} from "./common.js"; + +export class ForcedLogoutView extends TemplateView { + render(t) { + return t.div({ className: "LogoutScreen" }, [ + t.div({ className: "content" }, + t.map(vm => vm.showStatus, (showStatus, t, vm) => { + if (showStatus) { + return t.p({ className: "status" }, [ + spinner(t, { hidden: vm => !vm.showSpinner }), + t.span(vm => vm.status) + ]); + } + else { + return t.div([ + t.p("Your access token is no longer valid! You can reauthenticate in the next screen."), + t.div({ className: "button-row" }, [ + t.button({ + className: "button-action primary", + type: "submit", + onClick: () => vm.proceed(), + }, vm.i18n`Proceed`) + ]), + ]); + } + }) + ), + ]); + } +} diff --git a/src/platform/web/ui/RootView.js b/src/platform/web/ui/RootView.js index 69b327a5..1db5c334 100644 --- a/src/platform/web/ui/RootView.js +++ b/src/platform/web/ui/RootView.js @@ -17,6 +17,7 @@ limitations under the License. import {SessionView} from "./session/SessionView.js"; import {LoginView} from "./login/LoginView"; import {LogoutView} from "./LogoutView.js"; +import {ForcedLogoutView} from "./ForcedLogoutView.js"; import {SessionLoadView} from "./login/SessionLoadView.js"; import {SessionPickerView} from "./login/SessionPickerView.js"; import {TemplateView} from "./general/TemplateView"; @@ -39,6 +40,8 @@ export class RootView extends TemplateView { return new LoginView(vm.loginViewModel); case "logout": return new LogoutView(vm.logoutViewModel); + case "forced-logout": + return new ForcedLogoutView(vm.forcedLogoutViewModel); case "picker": return new SessionPickerView(vm.sessionPickerViewModel); case "redirecting": diff --git a/src/platform/web/ui/avatar.js b/src/platform/web/ui/avatar.js index 547d4b51..ce2dfdab 100644 --- a/src/platform/web/ui/avatar.js +++ b/src/platform/web/ui/avatar.js @@ -31,7 +31,11 @@ export function renderStaticAvatar(vm, size, extraClasses = undefined) { avatarClasses += ` ${extraClasses}`; } const avatarContent = hasAvatar ? renderImg(vm, size) : text(vm.avatarLetter); - const avatar = tag.div({className: avatarClasses, "data-testid": "avatar"}, [avatarContent]); + const avatar = tag.div({ + className: avatarClasses, + title: vm.avatarTitle, + "data-testid": "avatar", + }, [avatarContent]); if (hasAvatar) { setAttribute(avatar, "data-avatar-letter", vm.avatarLetter); setAttribute(avatar, "data-avatar-color", vm.avatarColorNumber); diff --git a/src/platform/web/ui/css/room.css b/src/platform/web/ui/css/room.css index 746349c8..7dd4fd81 100644 --- a/src/platform/web/ui/css/room.css +++ b/src/platform/web/ui/css/room.css @@ -23,10 +23,6 @@ limitations under the License. flex: 1; } -.middle-header button { - display: block; -} - .middle-header .room-description { flex: 1; min-width: 0; 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/manifest.json b/src/platform/web/ui/css/themes/element/manifest.json index e183317c..cb21eaad 100644 --- a/src/platform/web/ui/css/themes/element/manifest.json +++ b/src/platform/web/ui/css/themes/element/manifest.json @@ -1,6 +1,7 @@ { "version": 1, "name": "Element", + "id": "element", "values": { "variants": { "light": { diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 84a27f13..1c9a233b 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -523,6 +523,62 @@ a { .RoomView_error { color: var(--error-color); + background : #efefef; + height : 0px; + font-weight : bold; + transition : 0.25s all ease-out; + padding-right : 20px; + padding-left : 20px; +} + +.RoomView_error div{ + overflow : hidden; + height: 100%; + width: 100%; + position : relative; + display : flex; + align-items : center; +} + +.RoomView_error:not(:empty) { + height : auto; + padding-top : 20px; + padding-bottom : 20px; +} + +.RoomView_error p { + position : relative; + display : block; + width : 100%; + height : auto; + margin : 0; +} + +.RoomView_error button { + width : 40px; + padding-top : 20px; + padding-bottom : 20px; + background : none; + border : none; + position : relative; + border-radius : 5px; + transition: 0.1s all ease-out; + cursor: pointer; +} + +.RoomView_error button:hover { + background : #cfcfcf; +} + +.RoomView_error button:before { + content:"\274c"; + position : absolute; + top : 15px; + left: 9px; + width : 20px; + height : 10px; + font-size : 10px; + align-self : middle; } .MessageComposer_replyPreview .Timeline_message { @@ -896,12 +952,12 @@ button.link { width: 100%; } -.RoomArchivedView { +.DisabledComposerView { padding: 12px; background-color: var(--background-color-secondary); } -.RoomArchivedView h3 { +.DisabledComposerView h3 { margin: 0; } @@ -1126,7 +1182,7 @@ button.RoomDetailsView_row::after { gap: 12px; } -.CreateRoomView, .RoomBeingCreated_error { +.CreateRoomView, .JoinRoomView, .RoomBeingCreated_error { max-width: 400px; } @@ -1157,3 +1213,14 @@ button.RoomDetailsView_row::after { background-position: center; background-size: 36px; } + +.JoinRoomView_status { + display: flex; + align-items: center; + justify-content: center; + margin-top: 10px; +} + +.JoinRoomView_status .spinner { + margin-right: 5px; +} diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index 43c57d19..7ff35eb1 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); } @@ -422,3 +422,22 @@ 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; + } diff --git a/src/platform/web/ui/general/html.ts b/src/platform/web/ui/general/html.ts index 44f7476a..93512897 100644 --- a/src/platform/web/ui/general/html.ts +++ b/src/platform/web/ui/general/html.ts @@ -102,10 +102,11 @@ 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", "main", "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", - "progress", "output", "video"], + "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/general/utils.ts b/src/platform/web/ui/general/utils.ts index b310571f..3f470048 100644 --- a/src/platform/web/ui/general/utils.ts +++ b/src/platform/web/ui/general/utils.ts @@ -22,6 +22,9 @@ export function mountView(view: IView, mountArgs?: IMountArgs): ViewNode { try { node = view.mount(mountArgs); } catch (err) { + // Log it to the console so it's easy to reference + console.error(err); + // Then render our error boundary to the DOM node = errorToDOM(err); } return node; diff --git a/src/platform/web/ui/session/JoinRoomView.ts b/src/platform/web/ui/session/JoinRoomView.ts new file mode 100644 index 00000000..90194317 --- /dev/null +++ b/src/platform/web/ui/session/JoinRoomView.ts @@ -0,0 +1,63 @@ +/* +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} from "../general/TemplateView"; +import type {JoinRoomViewModel} from "../../../../domain/session/JoinRoomViewModel"; +import {spinner} from "../common.js"; + +export class JoinRoomView extends TemplateView { + render(t, vm) { + const input = t.input({ + type: "text", + name: "id", + id: "id", + placeholder: vm.i18n`Enter a room id or alias`, + disabled: vm => vm.joinInProgress, + }); + return t.main({className: "middle"}, + t.div({className: "JoinRoomView centered-column"}, [ + t.h2("Join room"), + t.form({className: "JoinRoomView_detailsForm form", onSubmit: evt => this.onSubmit(evt, input.value)}, [ + t.div({className: "vertical-layout"}, [ + t.div({className: "stretch form-row text"}, [ + t.label({for: "id"}, vm.i18n`Room id`), + input, + ]), + ]), + t.div({className: "button-row"}, [ + t.button({ + className: "button-action primary", + type: "submit", + disabled: vm => vm.joinInProgress + }, vm.i18n`Join`), + ]), + t.map(vm => vm.status, (status, t) => { + return t.div({ className: "JoinRoomView_status" }, [ + spinner(t, { hidden: vm => !vm.joinInProgress }), + t.span(status), + ]); + }) + ]) + ]) + ); + } + + onSubmit(evt, id) { + evt.preventDefault(); + this.value.join(id); + } +} + diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index ef63b29b..7bcd8c0f 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -29,6 +29,7 @@ import {SettingsView} from "./settings/SettingsView.js"; import {CreateRoomView} from "./CreateRoomView.js"; import {RightPanelView} from "./rightpanel/RightPanelView.js"; import {viewClassForTile} from "./room/common"; +import {JoinRoomView} from "./JoinRoomView"; export class SessionView extends TemplateView { render(t, vm) { @@ -48,6 +49,8 @@ export class SessionView extends TemplateView { return new SettingsView(vm.settingsViewModel); } else if (vm.createRoomViewModel) { return new CreateRoomView(vm.createRoomViewModel); + } else if (vm.joinRoomViewModel) { + return new JoinRoomView(vm.joinRoomViewModel); } else if (vm.currentRoomViewModel) { if (vm.currentRoomViewModel.kind === "invite") { return new InviteView(vm.currentRoomViewModel); diff --git a/src/platform/web/ui/session/leftpanel/LeftPanelView.js b/src/platform/web/ui/session/leftpanel/LeftPanelView.js index c79192be..fb2feb57 100644 --- a/src/platform/web/ui/session/leftpanel/LeftPanelView.js +++ b/src/platform/web/ui/session/leftpanel/LeftPanelView.js @@ -17,6 +17,8 @@ limitations under the License. import {ListView} from "../../general/ListView"; import {TemplateView} from "../../general/TemplateView"; import {RoomTileView} from "./RoomTileView.js"; +import {Menu} from "../../general/Menu.js"; +import {Popup} from "../../general/Popup.js"; class FilterField extends TemplateView { render(t, options) { @@ -51,6 +53,11 @@ class FilterField extends TemplateView { } export class LeftPanelView extends TemplateView { + constructor(vm) { + super(vm); + this._createMenuPopup = null; + } + render(t, vm) { const gridButtonLabel = vm => { return vm.gridEnabled ? @@ -90,7 +97,11 @@ export class LeftPanelView extends TemplateView { "aria-label": gridButtonLabel }), t.a({className: "button-utility settings", href: vm.settingsUrl, "aria-label": vm.i18n`Settings`, title: vm.i18n`Settings`}), - t.a({className: "button-utility create", href: vm.createRoomUrl, "aria-label": vm.i18n`Create room`, title: vm.i18n`Create room`}), + t.button({ + className: "button-utility create", + "aria-label": vm.i18n`Create room`, + onClick: evt => this._toggleCreateMenu(evt) + }), ]); return t.div({className: "LeftPanel"}, [ @@ -98,4 +109,18 @@ export class LeftPanelView extends TemplateView { roomList ]); } + + _toggleCreateMenu(evt) { + if (this._createMenuPopup && this._createMenuPopup.isOpen) { + this._createMenuPopup.close(); + } else { + const vm = this.value; + const options = []; + options.push(Menu.option(vm.i18n`Create Room`, () => vm.showCreateRoomView())); + options.push(Menu.option(vm.i18n`Join Room`, () => vm.showJoinRoomView())); + this._createMenuPopup = new Popup(new Menu(options)); + this._createMenuPopup.trackInTemplateView(this); + this._createMenuPopup.showRelativeTo(evt.target, 10); + } + } } diff --git a/src/platform/web/ui/session/room/RoomArchivedView.js b/src/platform/web/ui/session/room/DisabledComposerView.js similarity index 82% rename from src/platform/web/ui/session/room/RoomArchivedView.js rename to src/platform/web/ui/session/room/DisabledComposerView.js index 1db1c2d2..caa8eeb9 100644 --- a/src/platform/web/ui/session/room/RoomArchivedView.js +++ b/src/platform/web/ui/session/room/DisabledComposerView.js @@ -16,8 +16,8 @@ limitations under the License. import {TemplateView} from "../../general/TemplateView"; -export class RoomArchivedView extends TemplateView { +export class DisabledComposerView extends TemplateView { render(t) { - return t.div({className: "RoomArchivedView"}, t.h3(vm => vm.description)); + return t.div({className: "DisabledComposerView"}, t.h3(vm => vm.description)); } } diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 9478f5e6..892cb25e 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -21,7 +21,7 @@ import {Menu} from "../../general/Menu.js"; import {TimelineView} from "./TimelineView"; import {TimelineLoadingView} from "./TimelineLoadingView.js"; import {MessageComposer} from "./MessageComposer.js"; -import {RoomArchivedView} from "./RoomArchivedView.js"; +import {DisabledComposerView} from "./DisabledComposerView.js"; import {AvatarView} from "../../AvatarView.js"; import {CallView} from "./CallView"; import { ErrorView } from "../../general/ErrorView"; @@ -34,12 +34,6 @@ export class RoomView extends TemplateView { } render(t, vm) { - let bottomView; - if (vm.composerViewModel.kind === "composer") { - bottomView = new MessageComposer(vm.composerViewModel, this._viewClassForTile); - } else if (vm.composerViewModel.kind === "archived") { - bottomView = new RoomArchivedView(vm.composerViewModel); - } return t.main({className: "RoomView middle"}, [ t.div({className: "RoomHeader middle-header"}, [ t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close room`}), @@ -61,11 +55,18 @@ export class RoomView extends TemplateView { new TimelineView(timelineViewModel, this._viewClassForTile) : new TimelineLoadingView(vm); // vm is just needed for i18n }), - t.view(bottomView), + t.mapView(vm => vm.composerViewModel, composerViewModel => { + switch (composerViewModel?.kind) { + case "composer": + return new MessageComposer(vm.composerViewModel, this._viewClassForTile); + case "disabled": + return new DisabledComposerView(vm.composerViewModel); + } + }), ]) ]); } - + _toggleOptionsMenu(evt) { if (this._optionsPopup && this._optionsPopup.isOpen) { this._optionsPopup.close(); diff --git a/src/platform/web/ui/session/room/common.ts b/src/platform/web/ui/session/room/common.ts index 74007cee..d435266e 100644 --- a/src/platform/web/ui/session/room/common.ts +++ b/src/platform/web/ui/session/room/common.ts @@ -22,34 +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 "call": + 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/AnnouncementView.js b/src/platform/web/ui/session/room/timeline/AnnouncementView.js index 5ae92daa..8b68d33b 100644 --- a/src/platform/web/ui/session/room/timeline/AnnouncementView.js +++ b/src/platform/web/ui/session/room/timeline/AnnouncementView.js @@ -22,8 +22,11 @@ export class AnnouncementView extends TemplateView { super(vm); } - render(t) { - return t.li({className: "AnnouncementView"}, t.div(vm => vm.announcement)); + render(t, vm) { + return t.li({ + className: "AnnouncementView", + 'data-event-id': vm.eventId + }, t.div(vm => vm.announcement)); } /* This is called by the parent ListView, which just has 1 listener for the whole list */ 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 74b96ecf..d998e826 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -46,7 +46,7 @@ export class BaseMessageView extends TemplateView { "Timeline_message": true, own: vm.isOwn, unsent: vm.isUnsent, - unverified: vm.isUnverified, + unverified: vm => vm.isUnverified, disabled: !this._interactive, continuation: vm => vm.isContinuation, }, @@ -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/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 db6cda59..4fc0e3d6 100644 --- a/src/platform/web/ui/session/room/timeline/GapView.js +++ b/src/platform/web/ui/session/room/timeline/GapView.js @@ -29,10 +29,9 @@ export class GapView extends TemplateView { isLoading: vm => vm.isLoading, isAtTop: vm => vm.isAtTop, }; - return t.li({className}, [ - spinner(t), - t.div(vm => vm.isLoading ? vm.i18n`Loading more messages …` : vm.i18n`Not loading!`), - t.if(vm => vm.error, t => t.strong(vm => vm.error)) + return t.li({ className }, [ + t.if(vm => vm.showSpinner, (t) => spinner(t)), + t.span(vm => vm.currentAction) ]); } diff --git a/src/platform/web/ui/session/room/timeline/ImageView.js b/src/platform/web/ui/session/room/timeline/ImageView.js index 1668b09c..19591606 100644 --- a/src/platform/web/ui/session/room/timeline/ImageView.js +++ b/src/platform/web/ui/session/room/timeline/ImageView.js @@ -24,6 +24,6 @@ export class ImageView extends BaseMediaView { title: vm => vm.label, style: `max-width: ${vm.width}px; max-height: ${vm.height}px;` }); - return vm.isPending ? img : t.a({href: vm.lightboxUrl}, img); + return vm.isPending || !vm.lightboxUrl ? img : t.a({href: vm.lightboxUrl}, img); } } 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/utils/AbortableOperation.ts b/src/utils/AbortableOperation.ts index b3f663bd..e0afecd3 100644 --- a/src/utils/AbortableOperation.ts +++ b/src/utils/AbortableOperation.ts @@ -14,8 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {BaseObservableValue} from "../observable/value/BaseObservableValue"; -import {ObservableValue} from "../observable/value/ObservableValue"; +import {BaseObservableValue, ObservableValue} from "../observable/value"; export interface IAbortable { abort(); diff --git a/vite.common-config.js b/vite.common-config.js index 5d65f8e2..2fa09d46 100644 --- a/vite.common-config.js +++ b/vite.common-config.js @@ -8,8 +8,8 @@ const path = require("path"); const manifest = require("./package.json"); const version = manifest.version; const compiledVariables = new Map(); -const derive = require("./scripts/postcss/color").derive; -const replacer = require("./scripts/postcss/svg-colorizer").buildColorizedSVG; +import {buildColorizedSVG as replacer} from "./scripts/postcss/svg-builder.mjs"; +import {derive} from "./src/platform/web/theming/shared/color.mjs"; const commonOptions = { logLevel: "warn", diff --git a/vite.config.js b/vite.config.js index e784a4ad..0bbeb4d4 100644 --- a/vite.config.js +++ b/vite.config.js @@ -33,9 +33,7 @@ export default defineConfig(({mode}) => { plugins: [ themeBuilder({ themeConfig: { - themes: { - element: "./src/platform/web/ui/css/themes/element", - }, + themes: ["./src/platform/web/ui/css/themes/element"], default: "element", }, compiledVariables, diff --git a/vite.sdk-assets-config.js b/vite.sdk-assets-config.js index d7d4d064..5c1f3196 100644 --- a/vite.sdk-assets-config.js +++ b/vite.sdk-assets-config.js @@ -36,7 +36,7 @@ export default mergeOptions(commonOptions, { plugins: [ themeBuilder({ themeConfig: { - themes: { element: "./src/platform/web/ui/css/themes/element" }, + themes: ["./src/platform/web/ui/css/themes/element"], default: "element", }, compiledVariables, diff --git a/yarn.lock b/yarn.lock index 9b9e5453..4b7e83f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23,6 +23,43 @@ 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" @@ -82,11 +119,51 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@playwright/test@^1.27.1": + version "1.27.1" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.27.1.tgz#9364d1e02021261211c8ff586d903faa79ce95c4" + integrity sha512-mrL2q0an/7tVqniQQF6RBL2saskjljXzqNcCOVMUjRIgE6Y38nCNaP+Dc2FBW06bcpD3tqIws/HT9qiMHbNU0A== + dependencies: + "@types/node" "*" + playwright-core "1.27.1" + +"@trysound/sax@0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" + integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== + "@types/json-schema@^7.0.7": version "7.0.9" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d" integrity sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ== +"@types/node@*": + version "18.7.13" + 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" @@ -181,6 +258,14 @@ 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" @@ -211,11 +296,23 @@ 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" @@ -230,6 +327,11 @@ 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" @@ -242,11 +344,48 @@ 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" @@ -269,6 +408,28 @@ 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" @@ -296,11 +457,34 @@ 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" @@ -318,6 +502,53 @@ 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" @@ -342,16 +573,43 @@ 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" integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== +commander@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" + integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== + +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" @@ -367,7 +625,12 @@ core-js@^3.6.5: resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.19.3.tgz#6df8142a996337503019ff3235a7022d7cdf4559" integrity sha512-LeLBMgEGSsG7giquSzvgBrTS7V5UL6ks3eQlUSbN8dJStlLFiRzUm5iqsRyzUB8carhfKjkJ2vzKqE6z1Vga9g== -cross-spawn@^7.0.2: +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: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== @@ -387,16 +650,98 @@ css-select@^4.1.3: domutils "^2.6.0" nth-check "^2.0.0" +css-tree@^1.1.2, css-tree@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d" + integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q== + dependencies: + mdn-data "2.0.14" + source-map "^0.6.1" + css-what@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/css-what/-/css-what-5.0.1.tgz#3efa820131f4669a8ac2408f9c32e7c7de9f4cad" integrity sha512-FYDTSHb/7KXsWICVsxdmiExPjCfRC4qRFBdVwv7Ax9hMnvMmEjP9RfxTEZ3qPZGmADDn2vAKSo9UcN1jKVYscg== +csso@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/csso/-/csso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529" + integrity sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA== + dependencies: + css-tree "^1.1.2" + cuint@^0.2.2: version "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" @@ -411,6 +756,13 @@ 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" @@ -421,6 +773,11 @@ 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" @@ -482,12 +839,27 @@ 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== -enquirer@^2.3.5: +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: version "2.3.6" resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d" integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg== @@ -887,11 +1259,59 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -extend@^3.0.1: +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: 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" @@ -933,6 +1353,20 @@ 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" @@ -960,6 +1394,30 @@ 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" @@ -980,6 +1438,27 @@ 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" @@ -999,6 +1478,13 @@ 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" @@ -1025,6 +1511,11 @@ 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" @@ -1047,6 +1538,25 @@ 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" @@ -1079,6 +1589,11 @@ 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" @@ -1092,6 +1607,18 @@ 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" @@ -1116,21 +1643,54 @@ 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" @@ -1144,6 +1704,11 @@ 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" @@ -1154,11 +1719,45 @@ 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" @@ -1175,6 +1774,20 @@ 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" @@ -1185,16 +1798,39 @@ 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.7.0: +lodash@^4.17.21, 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" @@ -1202,6 +1838,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +mdn-data@2.0.14: + version "2.0.14" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" + integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== + mdn-polyfills@^5.20.0: version "5.20.0" resolved "https://registry.yarnpkg.com/mdn-polyfills/-/mdn-polyfills-5.20.0.tgz#ca8247edf20a4f60dec6804372229812b348260b" @@ -1214,6 +1855,11 @@ 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" @@ -1227,6 +1873,23 @@ 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" @@ -1234,11 +1897,21 @@ 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" @@ -1257,6 +1930,13 @@ 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" @@ -1271,13 +1951,20 @@ off-color@^2.0.0: dependencies: core-js "^3.6.5" -once@^1.3.0: +once@^1.3.0, once@^1.3.1, once@^1.4.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" @@ -1302,6 +1989,18 @@ 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" @@ -1314,7 +2013,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.1.0: +path-key@^3.0.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== @@ -1329,6 +2028,16 @@ 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" @@ -1339,6 +2048,16 @@ 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" + integrity sha512-9EmeXDncC2Pmp/z+teoVYlvmPWUC6ejSSYZUln7YaP89Z6lpAaiaAnqroUt/BoLo8tn7WYShcfaCh+xofZa44Q== + postcss-css-variables@^0.18.0: version "0.18.0" resolved "https://registry.yarnpkg.com/postcss-css-variables/-/postcss-css-variables-0.18.0.tgz#d97b6da19e86245eb817006e11117382f997bb93" @@ -1377,16 +2096,44 @@ 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" @@ -1412,6 +2159,13 @@ 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" @@ -1431,12 +2185,25 @@ 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== -rimraf@^3.0.2: +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: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== @@ -1457,11 +2224,23 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -safe-buffer@^5.0.1: +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: 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" @@ -1469,6 +2248,13 @@ 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" @@ -1486,11 +2272,25 @@ 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" @@ -1505,7 +2305,7 @@ source-map-js@^1.0.2: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== -source-map@~0.6.1: +source-map@^0.6.1, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== @@ -1515,6 +2315,35 @@ 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" @@ -1531,6 +2360,18 @@ 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" @@ -1550,11 +2391,31 @@ 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" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +svgo@^2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/svgo/-/svgo-2.8.0.tgz#4ff80cce6710dc2795f0c7c74101e6764cfccd24" + integrity sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg== + dependencies: + "@trysound/sax" "0.2.0" + commander "^7.2.0" + css-select "^4.1.3" + css-tree "^1.1.3" + csso "^4.2.0" + picocolors "^1.0.0" + stable "^0.1.8" + table@^6.0.9: version "6.7.1" resolved "https://registry.yarnpkg.com/table/-/table-6.7.1.tgz#ee05592b7143831a8c94f3cee6aae4c1ccef33e2" @@ -1577,6 +2438,23 @@ 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" @@ -1584,6 +2462,14 @@ 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" @@ -1596,6 +2482,11 @@ 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" @@ -1603,6 +2494,18 @@ 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" @@ -1622,10 +2525,15 @@ 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== -typescript@^4.4: - version "4.6.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.3.tgz#eefeafa6afdd31d725584c67a0eaba80f6fc6c6c" - integrity sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw== +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" + integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ== typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.39" @@ -1646,6 +2554,16 @@ 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" @@ -1653,11 +2571,25 @@ 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" @@ -1701,6 +2633,24 @@ 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" @@ -1717,3 +2667,11 @@ 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"