mirror of
https://github.com/vector-im/hydrogen-web.git
synced 2024-12-22 19:14:52 +01:00
Merge branch 'master' into madlittlemods/copy-permalink
This commit is contained in:
commit
371916e68a
@ -17,6 +17,7 @@ module.exports = {
|
||||
"globals": {
|
||||
"DEFINE_VERSION": "readonly",
|
||||
"DEFINE_GLOBAL_HASH": "readonly",
|
||||
"DEFINE_PROJECT_DIR": "readonly",
|
||||
// only available in sw.js
|
||||
"DEFINE_UNHASHED_PRECACHED_ASSETS": "readonly",
|
||||
"DEFINE_HASHED_PRECACHED_ASSETS": "readonly",
|
||||
|
7
.github/CODEOWNERS
vendored
Normal file
7
.github/CODEOWNERS
vendored
Normal file
@ -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
|
12
.github/workflows/docker-publish.yml
vendored
12
.github/workflows/docker-publish.yml
vendored
@ -21,10 +21,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@ -32,13 +35,14 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
|
29
Dockerfile
29
Dockerfile
@ -1,9 +1,26 @@
|
||||
FROM docker.io/node:alpine as builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/node:alpine as builder
|
||||
RUN apk add --no-cache git python3 build-base
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
RUN yarn install \
|
||||
&& yarn build
|
||||
|
||||
FROM docker.io/nginx:alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and yarn.lock and install dependencies first to speed up subsequent builds
|
||||
COPY package.json yarn.lock /app/
|
||||
RUN yarn install
|
||||
|
||||
COPY . /app
|
||||
RUN yarn build
|
||||
|
||||
# Because we will be running as an unprivileged user, we need to make sure that the config file is writable
|
||||
# So, we will copy the default config to the /tmp folder that will be writable at runtime
|
||||
RUN mv -f target/config.json /config.json.bundled \
|
||||
&& ln -sf /tmp/config.json target/config.json
|
||||
|
||||
FROM --platform=${TARGETPLATFORM} docker.io/nginxinc/nginx-unprivileged:alpine
|
||||
|
||||
# Copy the dynamic config script
|
||||
COPY ./docker/dynamic-config.sh /docker-entrypoint.d/99-dynamic-config.sh
|
||||
# And the bundled config file
|
||||
COPY --from=builder /config.json.bundled /config.json.bundled
|
||||
|
||||
# Copy the built app from the first build stage
|
||||
COPY --from=builder /app/target /usr/share/nginx/html
|
||||
|
@ -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"]
|
||||
|
@ -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).
|
@ -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).
|
||||
|
@ -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
|
@ -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 <file>` option to the build script. The default is `src/platform/web/ui/css/main.css`, which you probably want to import from your custom css file like so:
|
||||
|
||||
```css
|
||||
@import url('src/platform/web/ui/css/main.css');
|
||||
|
||||
/* additions */
|
||||
```
|
77
doc/TODO.md
77
doc/TODO.md
@ -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
|
90
doc/api.md
90
doc/api.md
@ -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<RoomSummary>
|
||||
get(id) -> RoomSummary ?
|
||||
InternalRoom: EventEmitter
|
||||
applySync(roomResponse, membership, txn)
|
||||
- this method updates the room summary
|
||||
- persists the room summary
|
||||
- persists room state & timeline with RoomPersister
|
||||
- updates the OpenRoom if present
|
||||
|
||||
|
||||
applyAndPersistSync(roomResponse, membership, txn) {
|
||||
this._summary.applySync(roomResponse, membership);
|
||||
this._summary.persist(txn);
|
||||
this._roomPersister.persist(roomResponse, membership, txn);
|
||||
if (this._openRoom) {
|
||||
this._openRoom.applySync(roomResponse);
|
||||
}
|
||||
}
|
||||
|
||||
RoomPersister
|
||||
RoomPersister (persists timeline and room state)
|
||||
RoomSummary (persists room summary)
|
||||
RoomSummary : EventEmitter
|
||||
methods:
|
||||
async open()
|
||||
id
|
||||
name
|
||||
lastMessage
|
||||
unreadCount
|
||||
mentionCount
|
||||
isEncrypted
|
||||
isDirectMessage
|
||||
membership
|
||||
|
||||
should this have a custom reducer for custom fields?
|
||||
|
||||
events
|
||||
propChange(fieldName)
|
||||
|
||||
OpenRoom : EventEmitter
|
||||
properties:
|
||||
timeline
|
||||
events:
|
||||
|
||||
|
||||
RoomState: EventEmitter
|
||||
[room_id, event_type, state_key] -> [sort_key, event]
|
||||
Timeline: EventEmitter
|
||||
// should have a cache of recently lookup sender members?
|
||||
// can we disambiguate members like this?
|
||||
methods:
|
||||
lastEvents(amount)
|
||||
firstEvents(amount)
|
||||
eventsAfter(sortKey, amount)
|
||||
eventsBefore(sortKey, amount)
|
||||
events:
|
||||
eventsApppended
|
||||
|
||||
RoomMembers : EventEmitter, Iterator
|
||||
// no order, but need to be able to get all members somehow, needs to map to a ReactiveMap or something
|
||||
events:
|
||||
added(ids, values)
|
||||
removed(ids, values)
|
||||
changed(id, fieldName)
|
||||
RoomMember: EventEmitter
|
||||
properties:
|
||||
id
|
||||
name
|
||||
powerLevel
|
||||
membership
|
||||
avatar
|
||||
events:
|
||||
propChange(fieldName)
|
@ -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:
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
58
doc/architecture/updates.md
Normal file
58
doc/architecture/updates.md
Normal file
@ -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.
|
@ -35,15 +35,17 @@ To stop the container, simply hit `ctrl+c`.
|
||||
|
||||
In this repository, create a Docker image:
|
||||
|
||||
```
|
||||
```sh
|
||||
# Enable BuildKit https://docs.docker.com/develop/develop-images/build_enhancements/
|
||||
export DOCKER_BUILDKIT=1
|
||||
docker build -t hydrogen .
|
||||
```
|
||||
|
||||
Or, pull the docker image from GitLab:
|
||||
Or, pull the docker image from GitHub Container Registry:
|
||||
|
||||
```
|
||||
docker pull registry.gitlab.com/jcgruenhage/hydrogen-web
|
||||
docker tag registry.gitlab.com/jcgruenhage/hydrogen-web hydrogen
|
||||
docker pull ghcr.io/vector-im/hydrogen-web
|
||||
docker tag ghcr.io/vector-im/hydrogen-web hydrogen
|
||||
```
|
||||
|
||||
### Start container image
|
||||
@ -53,6 +55,32 @@ Then, start up a container from that image:
|
||||
```
|
||||
docker run \
|
||||
--name hydrogen \
|
||||
--publish 80:80 \
|
||||
--publish 8080:8080 \
|
||||
hydrogen
|
||||
```
|
||||
|
||||
n.b. the image is now based on the unprivileged nginx base, so the port is now `8080` instead of `80` and you need a writable `/tmp` volume.
|
||||
|
||||
You can override the default `config.json` using the `CONFIG_OVERRIDE` environment variable. For example to specify a different Homeserver and :
|
||||
|
||||
```
|
||||
docker run \
|
||||
--name hydrogen \
|
||||
--publish 8080:8080 \
|
||||
--env CONFIG_OVERRIDE='{
|
||||
"push": {
|
||||
"appId": "io.element.hydrogen.web",
|
||||
"gatewayUrl": "https://matrix.org",
|
||||
"applicationServerKey": "BC-gpSdVHEXhvHSHS0AzzWrQoukv2BE7KzpoPO_FfPacqOo3l1pdqz7rSgmB04pZCWaHPz7XRe6fjLaC-WPDopM"
|
||||
},
|
||||
"defaultHomeServer": "https://fosdem.org",
|
||||
"themeManifests": [
|
||||
"assets/theme-element.json"
|
||||
],
|
||||
"defaultTheme": {
|
||||
"light": "element-light",
|
||||
"dark": "element-dark"
|
||||
}
|
||||
}' \
|
||||
hydrogen
|
||||
```
|
||||
|
15
doc/error-handling.md
Normal file
15
doc/error-handling.md
Normal file
@ -0,0 +1,15 @@
|
||||
# Error handling
|
||||
|
||||
Ideally, every error that is unexpected and can't be automatically recovered from without degrading the experience is shown in the UI. This is the task of the view model, and you can use `ErrorReportViewModel` for this purpose, a dedicated base view model class. It exposes a child view model, `ErrorViewModel`, when `reportError` is called which can be paired with `ErrorView` in the view to present an error message from which debug logs can also be sent.
|
||||
|
||||
Methods on classes from the `matrix` layer can often throw errors and those errors should be caught in the view model and reported to the UI. When inheriting from `ErrorReportViewModel`, there is the low-level `reportError` method, but typically you'd use the convenience method `logAndCatch`. The latter makes it easy to get both error handlikng and logging right. You would typically use `logAndCatch` for every public method in the view model (e.g methods called from the view or from the parent view model). It calls a callback within a log item and also a try catch that reports the error.
|
||||
|
||||
## Sync errors & ErrorBoundary
|
||||
|
||||
There are some errors that are thrown during background processes though, most notably the sync loop. These processes are not triggered by the view model directly, and hence there is not always a method call they can wrap in a try/catch. For this, there is the `ErrorBoundary` utility class. Since almost all aspects of the client can be updated through the sync loop, it is also not too helpful if there is only one try/catch around the whole sync and we stop sync if something goes wrong.
|
||||
|
||||
Instead, it's more helpful to split up the error handling into different scopes, where errors are stored and not rethrown when leaving the scope. One example is to have a scope per room. In this way, we can isolate an error occuring during sync to a specific room, and report it in the UI of that room. This is typically where you would use `reportError` from `ErrorReportViewModel` rather than `logAndCatch`. You observe changes from your model in the view model (see docs on updates), and if the `error` property is set (by the `ErrorBoundary`), you call reportError with it. You can do this repeatedly without problems, if the same error is already reported, it's a No-Op.
|
||||
|
||||
### `writeSync` and preventing data loss when dealing with errors.
|
||||
|
||||
There is an extra complication though. The `writeSync` sync lifecycle step should not swallow any errors, or data loss can occur. This is because the whole `writeSync` lifecycle step writes all changes (for all rooms, the session, ...) for a sync response in one transaction (including the sync token), and aborts the transaction and stops sync if there is an error thrown during this step. So if there is an error in `writeSync` of a given room, it's fair to assume not all changes it was planning to write were passed to the transaction, as it got interrupted by the exception. Therefore, if we would swallow the error, data loss can occur as we'd not get another chance to write these changes to disk as we would have advanced the sync token. Therefore, code in the `writeSync` lifecycle step should be written defensively but always throw.
|
@ -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
|
||||
|
@ -237,7 +237,7 @@ room.sendEvent(eventEntry.eventType, replacement);
|
||||
## Replies
|
||||
|
||||
```js
|
||||
const reply = eventEntry.reply({});
|
||||
const reply = eventEntry.createReplyContent({});
|
||||
room.sendEvent("m.room.message", reply);
|
||||
```
|
||||
|
55
doc/implementation planning/room-types.ts
Normal file
55
doc/implementation planning/room-types.ts
Normal file
@ -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<T extends IRoom> {
|
||||
createRoom(type, roomId, syncResponse): T
|
||||
createSchema(db, txn, oldVersion, version, log)
|
||||
get storesForSync(): string[];
|
||||
get rooms(): ObservableMap<string, T>
|
||||
}
|
||||
|
||||
class InstantMessageRoom implements IRoom {
|
||||
}
|
||||
|
||||
class InstantMessageRoomFactory implements IRoomFactory<InstantMessageRoom>{
|
||||
loadLastMessages(): Promise<void>
|
||||
/*
|
||||
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<SpaceRoom> {
|
||||
createRoom(type, roomId, syncResponse): IRoomSyncHandler
|
||||
}
|
||||
|
||||
class Session {
|
||||
constructor(roomFactoriesByType: Map<string, IRoomFactory>) {
|
||||
|
||||
}
|
||||
}
|
@ -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
|
||||
```
|
11
docker/dynamic-config.sh
Executable file
11
docker/dynamic-config.sh
Executable file
@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eux
|
||||
|
||||
if [ -n "${CONFIG_OVERRIDE:-}" ]; then
|
||||
# Use config override environment variable if set
|
||||
echo "$CONFIG_OVERRIDE" > /tmp/config.json
|
||||
else
|
||||
# Otherwise, use the default config that was bundled in the image
|
||||
cp /config.json.bundled /tmp/config.json
|
||||
fi
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hydrogen-web",
|
||||
"version": "0.3.4",
|
||||
"version": "0.3.8",
|
||||
"description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB",
|
||||
"directories": {
|
||||
"doc": "doc"
|
||||
@ -32,6 +32,7 @@
|
||||
},
|
||||
"homepage": "https://github.com/vector-im/hydrogen-web/#readme",
|
||||
"devDependencies": {
|
||||
"@matrixdotorg/structured-logviewer": "^0.0.3",
|
||||
"@playwright/test": "^1.27.1",
|
||||
"@typescript-eslint/eslint-plugin": "^4.29.2",
|
||||
"@typescript-eslint/parser": "^4.29.2",
|
||||
|
@ -1,51 +0,0 @@
|
||||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
export function openFile(mimeType = null) {
|
||||
const input = document.createElement("input");
|
||||
input.setAttribute("type", "file");
|
||||
input.className = "hidden";
|
||||
if (mimeType) {
|
||||
input.setAttribute("accept", mimeType);
|
||||
}
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const checkFile = () => {
|
||||
input.removeEventListener("change", checkFile, true);
|
||||
const file = input.files[0];
|
||||
document.body.removeChild(input);
|
||||
if (file) {
|
||||
resolve(file);
|
||||
} else {
|
||||
reject(new Error("no file picked"));
|
||||
}
|
||||
}
|
||||
input.addEventListener("change", checkFile, true);
|
||||
});
|
||||
// IE11 needs the input to be attached to the document
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function readFileAsText(file) {
|
||||
const reader = new FileReader();
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
reader.addEventListener("load", evt => resolve(evt.target.result));
|
||||
reader.addEventListener("error", evt => reject(evt.target.error));
|
||||
});
|
||||
reader.readAsText(file);
|
||||
return promise;
|
||||
}
|
@ -1,110 +0,0 @@
|
||||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// DOM helper functions
|
||||
|
||||
export function isChildren(children) {
|
||||
// children should be an not-object (that's the attributes), or a domnode, or an array
|
||||
return typeof children !== "object" || !!children.nodeType || Array.isArray(children);
|
||||
}
|
||||
|
||||
export function classNames(obj, value) {
|
||||
return Object.entries(obj).reduce((cn, [name, enabled]) => {
|
||||
if (typeof enabled === "function") {
|
||||
enabled = enabled(value);
|
||||
}
|
||||
if (enabled) {
|
||||
return cn + (cn.length ? " " : "") + name;
|
||||
} else {
|
||||
return cn;
|
||||
}
|
||||
}, "");
|
||||
}
|
||||
|
||||
export function setAttribute(el, name, value) {
|
||||
if (name === "className") {
|
||||
name = "class";
|
||||
}
|
||||
if (value === false) {
|
||||
el.removeAttribute(name);
|
||||
} else {
|
||||
if (value === true) {
|
||||
value = name;
|
||||
}
|
||||
el.setAttribute(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
export function el(elementName, attributes, children) {
|
||||
return elNS(HTML_NS, elementName, attributes, children);
|
||||
}
|
||||
|
||||
export function elNS(ns, elementName, attributes, children) {
|
||||
if (attributes && isChildren(attributes)) {
|
||||
children = attributes;
|
||||
attributes = null;
|
||||
}
|
||||
|
||||
const e = document.createElementNS(ns, elementName);
|
||||
|
||||
if (attributes) {
|
||||
for (let [name, value] of Object.entries(attributes)) {
|
||||
if (name === "className" && typeof value === "object" && value !== null) {
|
||||
value = classNames(value);
|
||||
}
|
||||
setAttribute(e, name, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (children) {
|
||||
if (!Array.isArray(children)) {
|
||||
children = [children];
|
||||
}
|
||||
for (let c of children) {
|
||||
if (!c.nodeType) {
|
||||
c = text(c);
|
||||
}
|
||||
e.appendChild(c);
|
||||
}
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
export function text(str) {
|
||||
return document.createTextNode(str);
|
||||
}
|
||||
|
||||
export const HTML_NS = "http://www.w3.org/1999/xhtml";
|
||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
export const TAG_NAMES = {
|
||||
[HTML_NS]: [
|
||||
"br", "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"p", "strong", "em", "span", "img", "section", "main", "article", "aside",
|
||||
"pre", "button", "time", "input", "textarea", "label", "form", "progress", "output"],
|
||||
[SVG_NS]: ["svg", "circle"]
|
||||
};
|
||||
|
||||
export const tag = {};
|
||||
|
||||
|
||||
for (const [ns, tags] of Object.entries(TAG_NAMES)) {
|
||||
for (const tagName of tags) {
|
||||
tag[tagName] = function(attributes, children) {
|
||||
return elNS(ns, tagName, attributes, children);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,209 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style type="text/css">
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-areas: "nav nav" "items details";
|
||||
grid-template-columns: 1fr 400px;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
main {
|
||||
grid-area: items;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
main section h2 {
|
||||
margin: 2px 14px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
aside {
|
||||
grid-area: details;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
aside h3 {
|
||||
word-wrap: anywhere;
|
||||
}
|
||||
|
||||
aside p {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
aside .values li span {
|
||||
word-wrap: ;
|
||||
word-wrap: anywhere;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
aside .values {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
border: 1px solid lightgray;
|
||||
}
|
||||
|
||||
aside .values span.key {
|
||||
width: 30%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
aside .values span.value {
|
||||
width: 70%;
|
||||
display: block;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
aside .values li {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
aside .values li:not(:first-child) {
|
||||
border-top: 1px solid lightgray;
|
||||
}
|
||||
|
||||
nav {
|
||||
grid-area: nav;
|
||||
}
|
||||
|
||||
.timeline li:not(.expanded) > ol {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline li > div {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.timeline .toggleExpanded {
|
||||
border: none;
|
||||
background: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline .toggleExpanded:before {
|
||||
content: "▶";
|
||||
}
|
||||
|
||||
.timeline li.expanded > div > .toggleExpanded:before {
|
||||
content: "▼";
|
||||
}
|
||||
|
||||
.timeline ol {
|
||||
list-style: none;
|
||||
padding: 0 0 0 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.timeline .item {
|
||||
--hue: 100deg;
|
||||
--brightness: 80%;
|
||||
background-color: hsl(var(--hue), 60%, var(--brightness));
|
||||
border: 1px solid hsl(var(--hue), 60%, calc(var(--brightness) - 40%));
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
margin: 1px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
.timeline .item:not(.has-children) {
|
||||
margin-left: calc(24px + 4px + 1px);
|
||||
}
|
||||
|
||||
.timeline .item .caption {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.timeline .item.level-3 {
|
||||
--brightness: 90%;
|
||||
}
|
||||
|
||||
.timeline .item.level-2 {
|
||||
--brightness: 95%;
|
||||
}
|
||||
|
||||
.timeline .item.level-5 {
|
||||
--brightness: 80%;
|
||||
}
|
||||
|
||||
.timeline .item.level-6, .timeline .item.level-7 {
|
||||
--hue: 0deg !important;
|
||||
}
|
||||
|
||||
.timeline .item.level-7 {
|
||||
--brightness: 50%;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.timeline .item.type-network {
|
||||
--hue: 30deg;
|
||||
}
|
||||
|
||||
.timeline .item.type-navigation {
|
||||
--hue: 200deg;
|
||||
}
|
||||
|
||||
.timeline .item.selected {
|
||||
background-color: Highlight;
|
||||
border-color: Highlight;
|
||||
color: HighlightText;
|
||||
}
|
||||
|
||||
.timeline .item.highlighted {
|
||||
background-color: fuchsia;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#highlight {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
nav form {
|
||||
display: inline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<button id="openFile">Open log file</button>
|
||||
<button id="collapseAll">Collapse all</button>
|
||||
<button id="hideCollapsed">Hide collapsed root items</button>
|
||||
<button id="hideHighlightedSiblings" title="Hide collapsed siblings of highlighted">Hide non-highlighted</button>
|
||||
<button id="showAll">Show all</button>
|
||||
<form id="highlightForm">
|
||||
<input type="text" id="highlight" name="highlight" placeholder="Highlight a search term" autocomplete="on">
|
||||
<output id="highlightMatches"></output>
|
||||
</form>
|
||||
</nav>
|
||||
<main></main>
|
||||
<aside></aside>
|
||||
<script type="module" src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
@ -1,398 +0,0 @@
|
||||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {tag as t} from "./html.js";
|
||||
import {openFile, readFileAsText} from "./file.js";
|
||||
|
||||
const main = document.querySelector("main");
|
||||
|
||||
let selectedItemNode;
|
||||
let rootItem;
|
||||
let itemByRef;
|
||||
|
||||
const logLevels = [undefined, "All", "Debug", "Detail", "Info", "Warn", "Error", "Fatal", "Off"];
|
||||
|
||||
main.addEventListener("click", event => {
|
||||
if (event.target.classList.contains("toggleExpanded")) {
|
||||
const li = event.target.parentElement.parentElement;
|
||||
li.classList.toggle("expanded");
|
||||
} else {
|
||||
// allow clicking any links other than .item in the timeline, like refs
|
||||
if (event.target.tagName === "A" && !event.target.classList.contains("item")) {
|
||||
return;
|
||||
}
|
||||
const itemNode = event.target.closest(".item");
|
||||
if (itemNode) {
|
||||
// we don't want scroll to jump when clicking
|
||||
// so prevent default behaviour, and select and push to history manually
|
||||
event.preventDefault();
|
||||
selectNode(itemNode);
|
||||
history.pushState(null, null, `#${itemNode.id}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("hashchange", () => {
|
||||
const id = window.location.hash.substr(1);
|
||||
const itemNode = document.getElementById(id);
|
||||
if (itemNode && itemNode.closest("main")) {
|
||||
selectNode(itemNode);
|
||||
itemNode.scrollIntoView({behavior: "smooth", block: "nearest"});
|
||||
}
|
||||
});
|
||||
|
||||
function selectNode(itemNode) {
|
||||
if (selectedItemNode) {
|
||||
selectedItemNode.classList.remove("selected");
|
||||
}
|
||||
selectedItemNode = itemNode;
|
||||
selectedItemNode.classList.add("selected");
|
||||
let item = rootItem;
|
||||
let parent;
|
||||
const indices = selectedItemNode.id.split("/").map(i => parseInt(i, 10));
|
||||
for(const i of indices) {
|
||||
parent = item;
|
||||
item = itemChildren(item)[i];
|
||||
}
|
||||
showItemDetails(item, parent, selectedItemNode);
|
||||
}
|
||||
|
||||
function stringifyItemValue(value) {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return JSON.stringify(value, undefined, 2);
|
||||
} else {
|
||||
return value + "";
|
||||
}
|
||||
}
|
||||
|
||||
function showItemDetails(item, parent, itemNode) {
|
||||
const parentOffset = itemStart(parent) ? `${itemStart(item) - itemStart(parent)}ms` : "none";
|
||||
const expandButton = t.button("Expand recursively");
|
||||
expandButton.addEventListener("click", () => expandResursively(itemNode.parentElement.parentElement));
|
||||
const start = itemStart(item);
|
||||
const aside = t.aside([
|
||||
t.h3(itemCaption(item)),
|
||||
t.p([t.strong("Log level: "), logLevels[itemLevel(item)]]),
|
||||
t.p([t.strong("Error: "), itemError(item) ? `${itemError(item).name} ${itemError(item).stack}` : "none"]),
|
||||
t.p([t.strong("Parent offset: "), parentOffset]),
|
||||
t.p([t.strong("Start: "), new Date(start).toString(), ` (${start})`]),
|
||||
t.p([t.strong("Duration: "), `${itemDuration(item)}ms`]),
|
||||
t.p([t.strong("Child count: "), itemChildren(item) ? `${itemChildren(item).length}` : "none"]),
|
||||
t.p([t.strong("Forced finish: "), (itemForcedFinish(item) || false) + ""]),
|
||||
t.p(t.strong("Values:")),
|
||||
t.ul({class: "values"}, Object.entries(itemValues(item)).map(([key, value]) => {
|
||||
let valueNode;
|
||||
if (key === "ref") {
|
||||
const refItem = itemByRef.get(value);
|
||||
if (refItem) {
|
||||
valueNode = t.a({href: `#${refItem.id}`}, itemCaption(refItem));
|
||||
} else {
|
||||
valueNode = `unknown ref ${value}`;
|
||||
}
|
||||
} else {
|
||||
valueNode = stringifyItemValue(value);
|
||||
}
|
||||
return t.li([
|
||||
t.span({className: "key"}, normalizeValueKey(key)),
|
||||
t.span({className: "value"}, valueNode)
|
||||
]);
|
||||
})),
|
||||
t.p(expandButton)
|
||||
]);
|
||||
document.querySelector("aside").replaceWith(aside);
|
||||
}
|
||||
|
||||
function expandResursively(li) {
|
||||
li.classList.add("expanded");
|
||||
const ol = li.querySelector("ol");
|
||||
if (ol) {
|
||||
const len = ol.children.length;
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
expandResursively(ol.children[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("openFile").addEventListener("click", loadFile);
|
||||
|
||||
function getRootItemHeader(prevItem, item) {
|
||||
if (prevItem) {
|
||||
const diff = itemStart(item) - itemEnd(prevItem);
|
||||
if (diff >= 0) {
|
||||
return `+ ${formatTime(diff)}`;
|
||||
} else {
|
||||
const overlap = -diff;
|
||||
if (overlap >= itemDuration(item)) {
|
||||
return `ran entirely in parallel with`;
|
||||
} else {
|
||||
return `ran ${formatTime(-diff)} in parallel with`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return new Date(itemStart(item)).toString();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFile() {
|
||||
const file = await openFile();
|
||||
const json = await readFileAsText(file);
|
||||
const logs = JSON.parse(json);
|
||||
logs.items.sort((a, b) => itemStart(a) - itemStart(b));
|
||||
rootItem = {c: logs.items};
|
||||
itemByRef = new Map();
|
||||
preprocessRecursively(rootItem, null, itemByRef, []);
|
||||
|
||||
const fragment = logs.items.reduce((fragment, item, i, items) => {
|
||||
const prevItem = i === 0 ? null : items[i - 1];
|
||||
fragment.appendChild(t.section([
|
||||
t.h2(getRootItemHeader(prevItem, item)),
|
||||
t.div({className: "timeline"}, t.ol(itemToNode(item, [i])))
|
||||
]));
|
||||
return fragment;
|
||||
}, document.createDocumentFragment());
|
||||
main.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
// TODO: make this use processRecursively
|
||||
function preprocessRecursively(item, parentElement, refsMap, path) {
|
||||
item.s = (parentElement?.s || 0) + item.s;
|
||||
if (itemRefSource(item)) {
|
||||
refsMap.set(itemRefSource(item), item);
|
||||
}
|
||||
if (itemChildren(item)) {
|
||||
for (let i = 0; i < itemChildren(item).length; i += 1) {
|
||||
// do it in advance for a child as we don't want to do it for the rootItem
|
||||
const child = itemChildren(item)[i];
|
||||
const childPath = path.concat(i);
|
||||
child.id = childPath.join("/");
|
||||
preprocessRecursively(child, item, refsMap, childPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MS_IN_SEC = 1000;
|
||||
const MS_IN_MIN = MS_IN_SEC * 60;
|
||||
const MS_IN_HOUR = MS_IN_MIN * 60;
|
||||
const MS_IN_DAY = MS_IN_HOUR * 24;
|
||||
function formatTime(ms) {
|
||||
let str = "";
|
||||
if (ms > MS_IN_DAY) {
|
||||
const days = Math.floor(ms / MS_IN_DAY);
|
||||
ms -= days * MS_IN_DAY;
|
||||
str += `${days}d`;
|
||||
}
|
||||
if (ms > MS_IN_HOUR) {
|
||||
const hours = Math.floor(ms / MS_IN_HOUR);
|
||||
ms -= hours * MS_IN_HOUR;
|
||||
str += `${hours}h`;
|
||||
}
|
||||
if (ms > MS_IN_MIN) {
|
||||
const mins = Math.floor(ms / MS_IN_MIN);
|
||||
ms -= mins * MS_IN_MIN;
|
||||
str += `${mins}m`;
|
||||
}
|
||||
if (ms > MS_IN_SEC) {
|
||||
const secs = ms / MS_IN_SEC;
|
||||
str += `${secs.toFixed(2)}s`;
|
||||
} else if (ms > 0 || !str.length) {
|
||||
str += `${ms}ms`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function itemChildren(item) { return item.c; }
|
||||
function itemStart(item) { return item.s; }
|
||||
function itemEnd(item) { return item.s + item.d; }
|
||||
function itemDuration(item) { return item.d; }
|
||||
function itemValues(item) { return item.v; }
|
||||
function itemLevel(item) { return item.l; }
|
||||
function itemLabel(item) { return item.v?.l; }
|
||||
function itemType(item) { return item.v?.t; }
|
||||
function itemError(item) { return item.e; }
|
||||
function itemForcedFinish(item) { return item.f; }
|
||||
function itemRef(item) { return item.v?.ref; }
|
||||
function itemRefSource(item) { return item.v?.refId; }
|
||||
function itemShortErrorMessage(item) {
|
||||
if (itemError(item)) {
|
||||
const e = itemError(item);
|
||||
return e.name || e.stack.substr(0, e.stack.indexOf("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
function itemCaption(item) {
|
||||
if (itemType(item) === "network") {
|
||||
return `${itemValues(item)?.method} ${itemValues(item)?.url}`;
|
||||
} else if (itemLabel(item) && itemValues(item)?.id) {
|
||||
return `${itemLabel(item)} ${itemValues(item).id}`;
|
||||
} else if (itemLabel(item) && itemValues(item)?.status) {
|
||||
return `${itemLabel(item)} (${itemValues(item).status})`;
|
||||
} else if (itemLabel(item) && itemError(item)) {
|
||||
return `${itemLabel(item)} (${itemShortErrorMessage(item)})`;
|
||||
} else if (itemRef(item)) {
|
||||
const refItem = itemByRef.get(itemRef(item));
|
||||
if (refItem) {
|
||||
return `ref "${itemCaption(refItem)}"`
|
||||
} else {
|
||||
return `unknown ref ${itemRef(item)}`
|
||||
}
|
||||
} else {
|
||||
return itemLabel(item) || itemType(item);
|
||||
}
|
||||
}
|
||||
function normalizeValueKey(key) {
|
||||
switch (key) {
|
||||
case "t": return "type";
|
||||
case "l": return "label";
|
||||
default: return key;
|
||||
}
|
||||
}
|
||||
|
||||
// returns the node and the total range (recursively) occupied by the node
|
||||
function itemToNode(item) {
|
||||
const hasChildren = !!itemChildren(item)?.length;
|
||||
const className = {
|
||||
item: true,
|
||||
"has-children": hasChildren,
|
||||
error: itemError(item),
|
||||
[`type-${itemType(item)}`]: !!itemType(item),
|
||||
[`level-${itemLevel(item)}`]: true,
|
||||
};
|
||||
|
||||
const id = item.id;
|
||||
let captionNode;
|
||||
if (itemRef(item)) {
|
||||
const refItem = itemByRef.get(itemRef(item));
|
||||
if (refItem) {
|
||||
captionNode = ["ref ", t.a({href: `#${refItem.id}`}, itemCaption(refItem))];
|
||||
}
|
||||
}
|
||||
if (!captionNode) {
|
||||
captionNode = itemCaption(item);
|
||||
}
|
||||
const li = t.li([
|
||||
t.div([
|
||||
hasChildren ? t.button({className: "toggleExpanded"}) : "",
|
||||
t.a({className, id, href: `#${id}`}, [
|
||||
t.span({class: "caption"}, captionNode),
|
||||
t.span({class: "duration"}, `(${formatTime(itemDuration(item))})`),
|
||||
])
|
||||
])
|
||||
]);
|
||||
if (itemChildren(item) && itemChildren(item).length) {
|
||||
li.appendChild(t.ol(itemChildren(item).map(item => {
|
||||
return itemToNode(item);
|
||||
})));
|
||||
}
|
||||
return li;
|
||||
}
|
||||
|
||||
const highlightForm = document.getElementById("highlightForm");
|
||||
|
||||
highlightForm.addEventListener("submit", evt => {
|
||||
evt.preventDefault();
|
||||
const matchesOutput = document.getElementById("highlightMatches");
|
||||
const query = document.getElementById("highlight").value;
|
||||
if (query) {
|
||||
matchesOutput.innerText = "Searching…";
|
||||
let matches = 0;
|
||||
processRecursively(rootItem, item => {
|
||||
let domNode = document.getElementById(item.id);
|
||||
if (itemMatchesFilter(item, query)) {
|
||||
matches += 1;
|
||||
domNode.classList.add("highlighted");
|
||||
domNode = domNode.parentElement;
|
||||
while (domNode.nodeName !== "SECTION") {
|
||||
if (domNode.nodeName === "LI") {
|
||||
domNode.classList.add("expanded");
|
||||
}
|
||||
domNode = domNode.parentElement;
|
||||
}
|
||||
} else {
|
||||
domNode.classList.remove("highlighted");
|
||||
}
|
||||
});
|
||||
matchesOutput.innerText = `${matches} matches`;
|
||||
} else {
|
||||
for (const node of document.querySelectorAll(".highlighted")) {
|
||||
node.classList.remove("highlighted");
|
||||
}
|
||||
matchesOutput.innerText = "";
|
||||
}
|
||||
});
|
||||
|
||||
function itemMatchesFilter(item, query) {
|
||||
if (itemError(item)) {
|
||||
if (valueMatchesQuery(itemError(item), query)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return valueMatchesQuery(itemValues(item), query);
|
||||
}
|
||||
|
||||
function valueMatchesQuery(value, query) {
|
||||
if (typeof value === "string") {
|
||||
return value.includes(query);
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
for (const key in value) {
|
||||
if (value.hasOwnProperty(key) && valueMatchesQuery(value[key], query)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else if (typeof value === "number") {
|
||||
return value.toString().includes(query);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function processRecursively(item, callback, parentItem) {
|
||||
if (item.id) {
|
||||
callback(item, parentItem);
|
||||
}
|
||||
if (itemChildren(item)) {
|
||||
for (let i = 0; i < itemChildren(item).length; i += 1) {
|
||||
// do it in advance for a child as we don't want to do it for the rootItem
|
||||
const child = itemChildren(item)[i];
|
||||
processRecursively(child, callback, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("collapseAll").addEventListener("click", () => {
|
||||
for (const node of document.querySelectorAll(".expanded")) {
|
||||
node.classList.remove("expanded");
|
||||
}
|
||||
});
|
||||
document.getElementById("hideCollapsed").addEventListener("click", () => {
|
||||
for (const node of document.querySelectorAll("section > div.timeline > ol > li:not(.expanded)")) {
|
||||
node.closest("section").classList.add("hidden");
|
||||
}
|
||||
});
|
||||
document.getElementById("hideHighlightedSiblings").addEventListener("click", () => {
|
||||
for (const node of document.querySelectorAll(".highlighted")) {
|
||||
const list = node.closest("ol");
|
||||
const siblings = Array.from(list.querySelectorAll("li > div > a:not(.highlighted)")).map(n => n.closest("li"));
|
||||
for (const sibling of siblings) {
|
||||
if (!sibling.classList.contains("expanded")) {
|
||||
sibling.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
document.getElementById("showAll").addEventListener("click", () => {
|
||||
for (const node of document.querySelectorAll(".hidden")) {
|
||||
node.classList.remove("hidden");
|
||||
}
|
||||
});
|
@ -24,7 +24,13 @@ const idToPrepend = "icon-url";
|
||||
|
||||
function findAndReplaceUrl(decl, urlVariables, counter) {
|
||||
const value = decl.value;
|
||||
const parsed = valueParser(value);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = valueParser(value);
|
||||
} catch (err) {
|
||||
console.log(`Error trying to parse ${decl}`);
|
||||
throw err;
|
||||
}
|
||||
parsed.walk(node => {
|
||||
if (node.type !== "function" || node.value !== "url") {
|
||||
return;
|
||||
|
@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import {ViewModel} from "./ViewModel";
|
||||
import {KeyType} from "../matrix/ssss/index";
|
||||
import {Status} from "./session/settings/KeyBackupViewModel.js";
|
||||
import {Status} from "./session/settings/KeyBackupViewModel";
|
||||
|
||||
export class AccountSetupViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
|
23
src/domain/AvatarSource.ts
Normal file
23
src/domain/AvatarSource.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
export interface AvatarSource {
|
||||
get avatarLetter(): string;
|
||||
get avatarColorNumber(): number;
|
||||
avatarUrl(size: number): string | undefined;
|
||||
get avatarTitle(): string;
|
||||
}
|
72
src/domain/ErrorReportViewModel.ts
Normal file
72
src/domain/ErrorReportViewModel.ts
Normal file
@ -0,0 +1,72 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ViewModel } from "./ViewModel";
|
||||
import type { Options as BaseOptions } from "./ViewModel";
|
||||
import type { Session } from "../matrix/Session";
|
||||
import { ErrorViewModel } from "./ErrorViewModel";
|
||||
import type { LogCallback, LabelOrValues } from "../logging/types";
|
||||
|
||||
export type Options<N extends object> = BaseOptions<N> & {
|
||||
session: Session
|
||||
};
|
||||
|
||||
/** Base class for view models that need to report errors to the UI. */
|
||||
export class ErrorReportViewModel<N extends object, O extends Options<N> = Options<N>> extends ViewModel<N, O> {
|
||||
private _errorViewModel?: ErrorViewModel<N>;
|
||||
|
||||
get errorViewModel(): ErrorViewModel<N> | undefined {
|
||||
return this._errorViewModel;
|
||||
}
|
||||
|
||||
/** Typically you'd want to use `logAndCatch` when implementing a view model method.
|
||||
* Use `reportError` when showing errors on your model that were set by
|
||||
* background processes using `ErrorBoundary` or you have some other
|
||||
* special low-level need to write your try/catch yourself. */
|
||||
protected reportError(error: Error) {
|
||||
if (this._errorViewModel?.error === error) {
|
||||
return;
|
||||
}
|
||||
this.disposeTracked(this._errorViewModel);
|
||||
this._errorViewModel = this.track(new ErrorViewModel(this.childOptions({
|
||||
error,
|
||||
onClose: () => {
|
||||
this._errorViewModel = this.disposeTracked(this._errorViewModel);
|
||||
this.emitChange("errorViewModel");
|
||||
}
|
||||
})));
|
||||
this.emitChange("errorViewModel");
|
||||
}
|
||||
|
||||
/** Combines logging and error reporting in one method.
|
||||
* Wrap the implementation of public view model methods
|
||||
* with this to ensure errors are logged and reported.*/
|
||||
protected logAndCatch<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, errorValue: T = undefined as unknown as T): T {
|
||||
try {
|
||||
let result = this.logger.run(labelOrValues, callback);
|
||||
if (result instanceof Promise) {
|
||||
result = result.catch(err => {
|
||||
this.reportError(err);
|
||||
return errorValue;
|
||||
}) as unknown as T;
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.reportError(err);
|
||||
return errorValue;
|
||||
}
|
||||
}
|
||||
}
|
49
src/domain/ErrorViewModel.ts
Normal file
49
src/domain/ErrorViewModel.ts
Normal file
@ -0,0 +1,49 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ViewModel, Options as BaseOptions } from "./ViewModel";
|
||||
import {submitLogsFromSessionToDefaultServer} from "./rageshake";
|
||||
import type { Session } from "../matrix/Session";
|
||||
import type {SegmentType} from "./navigation/index";
|
||||
|
||||
type Options<N extends object> = {
|
||||
error: Error
|
||||
session: Session,
|
||||
onClose: () => void
|
||||
} & BaseOptions<N>;
|
||||
|
||||
export class ErrorViewModel<N extends object = SegmentType, O extends Options<N> = Options<N>> extends ViewModel<N, O> {
|
||||
get message(): string {
|
||||
return this.error.message;
|
||||
}
|
||||
|
||||
get error(): Error {
|
||||
return this.getOption("error");
|
||||
}
|
||||
|
||||
close() {
|
||||
this.getOption("onClose")();
|
||||
}
|
||||
|
||||
async submitLogs(): Promise<boolean> {
|
||||
try {
|
||||
await submitLogsFromSessionToDefaultServer(this.getOption("session"), this.platform);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
@ -158,7 +158,7 @@ export class RootViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
_showSessionLoader(sessionId) {
|
||||
const client = new Client(this.platform);
|
||||
const client = new Client(this.platform, this.features);
|
||||
client.startWithExistingSession(sessionId);
|
||||
this._setSection(() => {
|
||||
this._sessionLoadViewModel = new SessionLoadViewModel(this.childOptions({
|
||||
|
@ -78,7 +78,7 @@ export class SessionLoadViewModel extends ViewModel {
|
||||
this._ready(client);
|
||||
}
|
||||
if (loadError) {
|
||||
console.error("session load error", loadError);
|
||||
console.error("session load error", loadError.stack);
|
||||
}
|
||||
} catch (err) {
|
||||
this._error = err;
|
||||
|
@ -29,6 +29,8 @@ import type {ILogger} from "../logging/types";
|
||||
import type {Navigation} from "./navigation/Navigation";
|
||||
import type {SegmentType} from "./navigation/index";
|
||||
import type {IURLRouter} from "./navigation/URLRouter";
|
||||
import type { ITimeFormatter } from "../platform/types/types";
|
||||
import type { FeatureSet } from "../features";
|
||||
|
||||
export type Options<T extends object = SegmentType> = {
|
||||
platform: Platform;
|
||||
@ -36,6 +38,7 @@ export type Options<T extends object = SegmentType> = {
|
||||
urlRouter: IURLRouter<T>;
|
||||
navigation: Navigation<T>;
|
||||
emitChange?: (params: any) => void;
|
||||
features: FeatureSet
|
||||
}
|
||||
|
||||
|
||||
@ -49,7 +52,7 @@ export class ViewModel<N extends object = SegmentType, O extends Options<N> = Op
|
||||
this._options = options;
|
||||
}
|
||||
|
||||
childOptions<T extends Object>(explicitOptions: T): T & Options<N> {
|
||||
childOptions<T extends Object>(explicitOptions: T): T & O {
|
||||
return Object.assign({}, this._options, explicitOptions);
|
||||
}
|
||||
|
||||
@ -117,7 +120,7 @@ export class ViewModel<N extends object = SegmentType, O extends Options<N> = Op
|
||||
return result;
|
||||
}
|
||||
|
||||
emitChange(changedProps: any): void {
|
||||
emitChange(changedProps?: any): void {
|
||||
if (this._options.emitChange) {
|
||||
this._options.emitChange(changedProps);
|
||||
} else {
|
||||
@ -141,8 +144,16 @@ export class ViewModel<N extends object = SegmentType, O extends Options<N> = Op
|
||||
return this._options.urlRouter;
|
||||
}
|
||||
|
||||
get features(): FeatureSet {
|
||||
return this._options.features;
|
||||
}
|
||||
|
||||
get navigation(): Navigation<N> {
|
||||
// typescript needs a little help here
|
||||
return this._options.navigation as unknown as Navigation<N>;
|
||||
}
|
||||
|
||||
get timeFormatter(): ITimeFormatter {
|
||||
return this._options.platform.timeFormatter;
|
||||
}
|
||||
}
|
||||
|
@ -51,10 +51,18 @@ export function getIdentifierColorNumber(id: string): number {
|
||||
return (hashCode(id) % 8) + 1;
|
||||
}
|
||||
|
||||
export function getAvatarHttpUrl(avatarUrl: string, cssSize: number, platform: Platform, mediaRepository: MediaRepository): string | null {
|
||||
export function getAvatarHttpUrl(avatarUrl: string | undefined, cssSize: number, platform: Platform, mediaRepository: MediaRepository): string | undefined {
|
||||
if (avatarUrl) {
|
||||
const imageSize = cssSize * platform.devicePixelRatio;
|
||||
return mediaRepository.mxcUrlThumbnail(avatarUrl, imageSize, imageSize, "crop");
|
||||
}
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// move to AvatarView.js when converting to typescript
|
||||
export interface IAvatarContract {
|
||||
avatarLetter: string;
|
||||
avatarColorNumber: number;
|
||||
avatarUrl: (size: number) => string | undefined;
|
||||
avatarTitle: string;
|
||||
}
|
||||
|
@ -55,7 +55,7 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
|
||||
const {ready, defaultHomeserver, loginToken} = options;
|
||||
this._ready = ready;
|
||||
this._loginToken = loginToken;
|
||||
this._client = new Client(this.platform);
|
||||
this._client = new Client(this.platform, this.features);
|
||||
this._homeserver = defaultHomeserver;
|
||||
this._initViewModels();
|
||||
}
|
||||
|
@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue";
|
||||
|
||||
import {BaseObservableValue, ObservableValue} from "../../observable/value";
|
||||
|
||||
type AllowsChild<T> = (parent: Segment<T> | undefined, child: Segment<T>) => boolean;
|
||||
|
||||
|
@ -144,7 +144,7 @@ export class URLRouter<T extends {session: string | boolean}> implements IURLRou
|
||||
|
||||
openRoomActionUrl(roomId: string): string {
|
||||
// not a segment to navigation knowns about, so append it manually
|
||||
const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`;
|
||||
const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${encodeURIComponent(roomId)}`;
|
||||
return this._history.pathAsUrl(urlPath);
|
||||
}
|
||||
|
||||
|
@ -137,7 +137,7 @@ export function parseUrlPath(urlPath: string, currentNavPath: Path<SegmentType>,
|
||||
if (type === "rooms") {
|
||||
const roomsValue = iterator.next().value;
|
||||
if (roomsValue === undefined) { break; }
|
||||
const roomIds = roomsValue.split(",");
|
||||
const roomIds = roomsValue.split(",").map(id => decodeURIComponent(id));
|
||||
segments.push(new Segment(type, roomIds));
|
||||
const selectedIndex = parseInt(iterator.next().value || "0", 10);
|
||||
const roomId = roomIds[selectedIndex];
|
||||
@ -147,8 +147,9 @@ export function parseUrlPath(urlPath: string, currentNavPath: Path<SegmentType>,
|
||||
segments.push(new Segment("empty-grid-tile", selectedIndex));
|
||||
}
|
||||
} else if (type === "open-room") {
|
||||
const roomId = iterator.next().value;
|
||||
let roomId = iterator.next().value;
|
||||
if (!roomId) { break; }
|
||||
roomId = decodeURIComponent(roomId);
|
||||
const rooms = currentNavPath.get("rooms");
|
||||
if (rooms) {
|
||||
segments.push(roomsSegmentWithRoom(rooms, roomId, currentNavPath));
|
||||
@ -176,8 +177,9 @@ export function parseUrlPath(urlPath: string, currentNavPath: Path<SegmentType>,
|
||||
} 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=<token>
|
||||
@ -185,7 +187,11 @@ export function parseUrlPath(urlPath: string, currentNavPath: Path<SegmentType>,
|
||||
segments.push(new Segment("sso", loginToken));
|
||||
} else {
|
||||
// might be undefined, which will be turned into true by Segment
|
||||
const value = iterator.next().value;
|
||||
let value = iterator.next().value;
|
||||
if (value) {
|
||||
// decode only if value isn't undefined!
|
||||
value = decodeURIComponent(value)
|
||||
}
|
||||
segments.push(new Segment(type, value));
|
||||
}
|
||||
}
|
||||
@ -196,19 +202,20 @@ export function stringifyPath(path: Path<SegmentType>): string {
|
||||
let urlPath = "";
|
||||
let prevSegment: Segment<SegmentType> | undefined;
|
||||
for (const segment of path.segments) {
|
||||
const encodedSegmentValue = encodeSegmentValue(segment.value);
|
||||
switch (segment.type) {
|
||||
case "rooms":
|
||||
urlPath += `/rooms/${segment.value.join(",")}`;
|
||||
urlPath += `/rooms/${encodedSegmentValue}`;
|
||||
break;
|
||||
case "empty-grid-tile":
|
||||
urlPath += `/${segment.value}`;
|
||||
urlPath += `/${encodedSegmentValue}`;
|
||||
break;
|
||||
case "room":
|
||||
if (prevSegment?.type === "rooms") {
|
||||
const index = prevSegment.value.indexOf(segment.value);
|
||||
urlPath += `/${index}`;
|
||||
} else {
|
||||
urlPath += `/${segment.type}/${segment.value}`;
|
||||
urlPath += `/${segment.type}/${encodedSegmentValue}`;
|
||||
}
|
||||
break;
|
||||
case "right-panel":
|
||||
@ -217,8 +224,8 @@ export function stringifyPath(path: Path<SegmentType>): string {
|
||||
continue;
|
||||
default:
|
||||
urlPath += `/${segment.type}`;
|
||||
if (segment.value && segment.value !== true) {
|
||||
urlPath += `/${segment.value}`;
|
||||
if (encodedSegmentValue) {
|
||||
urlPath += `/${encodedSegmentValue}`;
|
||||
}
|
||||
}
|
||||
prevSegment = segment;
|
||||
@ -226,6 +233,19 @@ export function stringifyPath(path: Path<SegmentType>): string {
|
||||
return urlPath;
|
||||
}
|
||||
|
||||
function encodeSegmentValue(value: SegmentType[keyof SegmentType]): string {
|
||||
if (value === true) {
|
||||
// Nothing to encode for boolean
|
||||
return "";
|
||||
}
|
||||
else if (Array.isArray(value)) {
|
||||
return value.map(v => encodeURIComponent(v)).join(",");
|
||||
}
|
||||
else {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function tests() {
|
||||
function createEmptyPath() {
|
||||
const nav: Navigation<SegmentType> = new Navigation(allowsChild);
|
||||
|
@ -16,11 +16,15 @@ limitations under the License.
|
||||
|
||||
import type {BlobHandle} from "../platform/web/dom/BlobHandle";
|
||||
import type {RequestFunction} from "../platform/types/types";
|
||||
import type {Platform} from "../platform/web/Platform";
|
||||
import type {ILogger} from "../logging/types";
|
||||
import type { IDBLogPersister } from "../logging/IDBLogPersister";
|
||||
import type { Session } from "../matrix/Session";
|
||||
|
||||
// see https://github.com/matrix-org/rageshake#readme
|
||||
type RageshakeData = {
|
||||
// A textual description of the problem. Included in the details.log.gz file.
|
||||
text: string | undefined;
|
||||
text?: string;
|
||||
// Application user-agent. Included in the details.log.gz file.
|
||||
userAgent: string;
|
||||
// Identifier for the application (eg 'riot-web'). Should correspond to a mapping configured in the configuration file for github issue reporting to work.
|
||||
@ -28,7 +32,7 @@ type RageshakeData = {
|
||||
// Application version. Included in the details.log.gz file.
|
||||
version: string;
|
||||
// Label to attach to the github issue, and include in the details file.
|
||||
label: string | undefined;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export async function submitLogsToRageshakeServer(data: RageshakeData, logsBlob: BlobHandle, submitUrl: string, request: RequestFunction): Promise<void> {
|
||||
@ -63,3 +67,28 @@ export async function submitLogsToRageshakeServer(data: RageshakeData, logsBlob:
|
||||
// we don't bother with reading report_url from the body as the rageshake server doesn't always return it
|
||||
// and would have to have CORS setup properly for us to be able to read it.
|
||||
}
|
||||
|
||||
/** @throws {Error} */
|
||||
export async function submitLogsFromSessionToDefaultServer(session: Session, platform: Platform): Promise<void> {
|
||||
const {bugReportEndpointUrl} = platform.config;
|
||||
if (!bugReportEndpointUrl) {
|
||||
throw new Error("no server configured to submit logs");
|
||||
}
|
||||
const logReporters = (platform.logger as ILogger).reporters;
|
||||
const exportReporter = logReporters.find(r => !!r["export"]) as IDBLogPersister | undefined;
|
||||
if (!exportReporter) {
|
||||
throw new Error("No logger that can export configured");
|
||||
}
|
||||
const logExport = await exportReporter.export();
|
||||
await submitLogsToRageshakeServer(
|
||||
{
|
||||
app: "hydrogen",
|
||||
userAgent: platform.description,
|
||||
version: platform.version,
|
||||
text: `Submit logs from settings for user ${session.userId} on device ${session.deviceId}`,
|
||||
},
|
||||
logExport.asBlob(),
|
||||
bugReportEndpointUrl,
|
||||
platform.request
|
||||
);
|
||||
}
|
||||
|
@ -186,7 +186,7 @@ export class RoomGridViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
import {createNavigation} from "../navigation/index";
|
||||
import {ObservableValue} from "../../observable/ObservableValue";
|
||||
import {ObservableValue} from "../../observable/value";
|
||||
|
||||
export function tests() {
|
||||
class RoomVMMock {
|
||||
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ObservableValue} from "../../observable/ObservableValue";
|
||||
import {ObservableValue} from "../../observable/value";
|
||||
import {RoomStatus} from "../../matrix/room/common";
|
||||
|
||||
/**
|
||||
|
@ -30,6 +30,7 @@ import {ViewModel} from "../ViewModel";
|
||||
import {RoomViewModelObservable} from "./RoomViewModelObservable.js";
|
||||
import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js";
|
||||
import {SyncStatus} from "../../matrix/Sync.js";
|
||||
import {ToastCollectionViewModel} from "./toast/ToastCollectionViewModel";
|
||||
|
||||
export class SessionViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
@ -47,6 +48,9 @@ export class SessionViewModel extends ViewModel {
|
||||
this._gridViewModel = null;
|
||||
this._createRoomViewModel = null;
|
||||
this._joinRoomViewModel = null;
|
||||
this._toastCollectionViewModel = this.track(new ToastCollectionViewModel(this.childOptions({
|
||||
session: this._client.session,
|
||||
})));
|
||||
this._setupNavigation();
|
||||
this._setupForcedLogoutOnAccessTokenInvalidation();
|
||||
}
|
||||
@ -126,6 +130,11 @@ export class SessionViewModel extends ViewModel {
|
||||
|
||||
start() {
|
||||
this._sessionStatusViewModel.start();
|
||||
if (this.features.calls) {
|
||||
this._client.session.callHandler.loadCalls("m.ring");
|
||||
// TODO: only do this when opening the room
|
||||
this._client.session.callHandler.loadCalls("m.prompt");
|
||||
}
|
||||
}
|
||||
|
||||
get activeMiddleViewModel() {
|
||||
@ -170,6 +179,10 @@ export class SessionViewModel extends ViewModel {
|
||||
return this._joinRoomViewModel;
|
||||
}
|
||||
|
||||
get toastCollectionViewModel() {
|
||||
return this._toastCollectionViewModel;
|
||||
}
|
||||
|
||||
_updateGrid(roomIds) {
|
||||
const changed = !(this._gridViewModel && roomIds);
|
||||
const currentRoomId = this.navigation.path.get("room");
|
||||
@ -211,7 +224,7 @@ export class SessionViewModel extends ViewModel {
|
||||
_createRoomViewModelInstance(roomId) {
|
||||
const room = this._client.session.rooms.get(roomId);
|
||||
if (room) {
|
||||
const roomVM = new RoomViewModel(this.childOptions({room}));
|
||||
const roomVM = new RoomViewModel(this.childOptions({room, session: this._client.session}));
|
||||
roomVM.load();
|
||||
return roomVM;
|
||||
}
|
||||
@ -228,7 +241,7 @@ export class SessionViewModel extends ViewModel {
|
||||
async _createArchivedRoomViewModel(roomId) {
|
||||
const room = await this._client.session.loadArchivedRoom(roomId);
|
||||
if (room) {
|
||||
const roomVM = new RoomViewModel(this.childOptions({room}));
|
||||
const roomVM = new RoomViewModel(this.childOptions({room, session: this._client.session}));
|
||||
roomVM.load();
|
||||
return roomVM;
|
||||
}
|
||||
|
@ -17,6 +17,7 @@ limitations under the License.
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
import {RoomType} from "../../../matrix/room/common";
|
||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
|
||||
import {UserTrust} from "../../../matrix/verification/CrossSigning";
|
||||
|
||||
export class MemberDetailsViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
@ -29,13 +30,60 @@ export class MemberDetailsViewModel extends ViewModel {
|
||||
this._session = options.session;
|
||||
this.track(this._powerLevelsObservable.subscribe(() => this._onPowerLevelsChange()));
|
||||
this.track(this._observableMember.subscribe( () => this._onMemberChange()));
|
||||
this.track(this._session.crossSigning.subscribe(() => {
|
||||
this.emitChange("trustShieldColor");
|
||||
}));
|
||||
this._userTrust = undefined;
|
||||
this.init(); // TODO: call this from parent view model and do something smart with error view model if it fails async?
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.features.crossSigning) {
|
||||
this._userTrust = await this.logger.run({l: "MemberDetailsViewModel.get user trust", id: this._member.userId}, log => {
|
||||
return this._session.crossSigning.get()?.getUserTrust(this._member.userId, log);
|
||||
});
|
||||
this.emitChange("trustShieldColor");
|
||||
}
|
||||
}
|
||||
|
||||
get name() { return this._member.name; }
|
||||
|
||||
get userId() { return this._member.userId; }
|
||||
|
||||
get trustDescription() {
|
||||
switch (this._userTrust) {
|
||||
case UserTrust.Trusted: return this.i18n`You have verified this user. This user has verified all of their sessions.`;
|
||||
case UserTrust.UserNotSigned: return this.i18n`You have not verified this user.`;
|
||||
case UserTrust.UserSignatureMismatch: return this.i18n`You appear to have signed this user, but the signature is invalid.`;
|
||||
case UserTrust.UserDeviceNotSigned: return this.i18n`You have verified this user, but they have one or more unverified sessions.`;
|
||||
case UserTrust.UserDeviceSignatureMismatch: return this.i18n`This user has a session signature that is invalid.`;
|
||||
case UserTrust.UserSetupError: return this.i18n`This user hasn't set up cross-signing correctly`;
|
||||
case UserTrust.OwnSetupError: return this.i18n`Cross-signing wasn't set up correctly on your side.`;
|
||||
default: return this.i18n`Pending…`;
|
||||
}
|
||||
}
|
||||
|
||||
get trustShieldColor() {
|
||||
if (!this._isEncrypted) {
|
||||
return undefined;
|
||||
}
|
||||
switch (this._userTrust) {
|
||||
case undefined:
|
||||
case UserTrust.OwnSetupError:
|
||||
return undefined;
|
||||
case UserTrust.Trusted:
|
||||
return "green";
|
||||
case UserTrust.UserNotSigned:
|
||||
return "black";
|
||||
default:
|
||||
return "red";
|
||||
}
|
||||
}
|
||||
|
||||
get type() { return "member-details"; }
|
||||
|
||||
get shouldShowBackButton() { return true; }
|
||||
|
||||
get previousSegmentName() { return "members"; }
|
||||
|
||||
get role() {
|
||||
@ -54,6 +102,14 @@ export class MemberDetailsViewModel extends ViewModel {
|
||||
this.emitChange("role");
|
||||
}
|
||||
|
||||
async signUser() {
|
||||
if (this._session.crossSigning) {
|
||||
await this.logger.run("MemberDetailsViewModel.signUser", async log => {
|
||||
await this._session.crossSigning.signUser(this.userId, log);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get avatarLetter() {
|
||||
return avatarInitials(this.name);
|
||||
}
|
||||
|
@ -48,7 +48,7 @@ export class MemberTileViewModel extends ViewModel {
|
||||
|
||||
get detailsUrl() {
|
||||
const roomId = this.navigation.path.get("room").value;
|
||||
return `${this.urlRouter.openRoomActionUrl(roomId)}/member/${this._member.userId}`;
|
||||
return `${this.urlRouter.openRoomActionUrl(roomId)}/member/${encodeURIComponent(this._member.userId)}`;
|
||||
}
|
||||
|
||||
_updatePreviousName(newName) {
|
||||
|
288
src/domain/session/room/CallViewModel.ts
Normal file
288
src/domain/session/room/CallViewModel.ts
Normal file
@ -0,0 +1,288 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {AvatarSource} from "../../AvatarSource";
|
||||
import type {ViewModel} from "../../ViewModel";
|
||||
import {ErrorReportViewModel, Options as BaseOptions} from "../../ErrorReportViewModel";
|
||||
import {getStreamVideoTrack, getStreamAudioTrack} from "../../../matrix/calls/common";
|
||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
|
||||
import {EventObservableValue} from "../../../observable/value";
|
||||
import {ObservableValueMap, BaseObservableMap} from "../../../observable/map";
|
||||
import {ErrorViewModel} from "../../ErrorViewModel";
|
||||
import type {Room} from "../../../matrix/room/Room";
|
||||
import type {GroupCall} from "../../../matrix/calls/group/GroupCall";
|
||||
import type {Member} from "../../../matrix/calls/group/Member";
|
||||
import type {RoomMember} from "../../../matrix/room/members/RoomMember";
|
||||
import type {BaseObservableList} from "../../../observable/list/BaseObservableList";
|
||||
import type {BaseObservableValue} from "../../../observable/value";
|
||||
import type {Stream} from "../../../platform/types/MediaDevices";
|
||||
import type {MediaRepository} from "../../../matrix/net/MediaRepository";
|
||||
import type {Session} from "../../../matrix/Session";
|
||||
import type {SegmentType} from "../../navigation";
|
||||
|
||||
type Options<N extends object> = BaseOptions<N> & {
|
||||
call: GroupCall,
|
||||
room: Room,
|
||||
};
|
||||
|
||||
export class CallViewModel extends ErrorReportViewModel<SegmentType, Options<SegmentType>> {
|
||||
public readonly memberViewModels: BaseObservableList<IStreamViewModel>;
|
||||
|
||||
constructor(options: Options<SegmentType>) {
|
||||
super(options);
|
||||
const callObservable = new EventObservableValue(this.call, "change");
|
||||
this.track(callObservable.subscribe(() => this.onUpdate()));
|
||||
const ownMemberViewModelMap = new ObservableValueMap("self", callObservable)
|
||||
.mapValues((call, emitChange) => new OwnMemberViewModel(this.childOptions({call, emitChange})), () => {});
|
||||
const otherMemberViewModels = this.call.members
|
||||
.filterValues(member => member.isConnected)
|
||||
.mapValues(
|
||||
(member, emitChange) => new CallMemberViewModel(this.childOptions({
|
||||
member,
|
||||
emitChange,
|
||||
mediaRepository: this.getOption("room").mediaRepository
|
||||
})),
|
||||
(param, vm) => vm?.onUpdate(),
|
||||
) as BaseObservableMap<string, IStreamViewModel>;
|
||||
this.memberViewModels = otherMemberViewModels
|
||||
.join(ownMemberViewModelMap)
|
||||
.sortValues((a, b) => a.compare(b));
|
||||
this.track(this.memberViewModels.subscribe({
|
||||
onRemove: () => {
|
||||
this.emitChange(); // update memberCount
|
||||
},
|
||||
onAdd: () => {
|
||||
this.emitChange(); // update memberCount
|
||||
},
|
||||
onUpdate: () => {},
|
||||
onReset: () => {},
|
||||
onMove: () => {}
|
||||
}))
|
||||
}
|
||||
|
||||
get isCameraMuted(): boolean {
|
||||
return this.call.muteSettings?.camera ?? true;
|
||||
}
|
||||
|
||||
get isMicrophoneMuted(): boolean {
|
||||
return this.call.muteSettings?.microphone ?? true;
|
||||
}
|
||||
|
||||
get memberCount(): number {
|
||||
return this.memberViewModels.length;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.call.name;
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this.call.id;
|
||||
}
|
||||
|
||||
private get call(): GroupCall {
|
||||
return this.getOption("call");
|
||||
}
|
||||
|
||||
private onUpdate() {
|
||||
if (this.call.error) {
|
||||
this.reportError(this.call.error);
|
||||
}
|
||||
}
|
||||
|
||||
async hangup() {
|
||||
this.logAndCatch("CallViewModel.hangup", async log => {
|
||||
if (this.call.hasJoined) {
|
||||
await this.call.leave(log);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async toggleCamera() {
|
||||
this.logAndCatch("Call.toggleCamera", async log => {
|
||||
const {localMedia, muteSettings} = this.call;
|
||||
if (muteSettings && localMedia) {
|
||||
// unmute but no track?
|
||||
if (muteSettings.camera && !getStreamVideoTrack(localMedia.userMedia)) {
|
||||
const stream = await this.platform.mediaDevices.getMediaTracks(!muteSettings.microphone, true);
|
||||
await this.call.setMedia(localMedia.withUserMedia(stream));
|
||||
} else {
|
||||
await this.call.setMuted(muteSettings.toggleCamera());
|
||||
}
|
||||
this.emitChange();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async toggleMicrophone() {
|
||||
this.logAndCatch("Call.toggleMicrophone", async log => {
|
||||
const {localMedia, muteSettings} = this.call;
|
||||
if (muteSettings && localMedia) {
|
||||
// unmute but no track?
|
||||
if (muteSettings.microphone && !getStreamAudioTrack(localMedia.userMedia)) {
|
||||
const stream = await this.platform.mediaDevices.getMediaTracks(true, !muteSettings.camera);
|
||||
await this.call.setMedia(localMedia.withUserMedia(stream));
|
||||
} else {
|
||||
await this.call.setMuted(muteSettings.toggleMicrophone());
|
||||
}
|
||||
this.emitChange();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class OwnMemberViewModel extends ErrorReportViewModel<SegmentType, Options<SegmentType>> implements IStreamViewModel {
|
||||
private memberObservable: undefined | BaseObservableValue<RoomMember>;
|
||||
|
||||
constructor(options: Options<SegmentType>) {
|
||||
super(options);
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
const room = this.getOption("room");
|
||||
this.memberObservable = await room.observeMember(room.user.id);
|
||||
this.track(this.memberObservable!.subscribe(() => {
|
||||
this.emitChange(undefined);
|
||||
}));
|
||||
}
|
||||
|
||||
get errorViewModel(): ErrorViewModel | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get stream(): Stream | undefined {
|
||||
return this.call.localPreviewMedia?.userMedia;
|
||||
}
|
||||
|
||||
private get call(): GroupCall {
|
||||
return this.getOption("call");
|
||||
}
|
||||
|
||||
get isCameraMuted(): boolean {
|
||||
return this.call.muteSettings?.camera ?? true;
|
||||
}
|
||||
|
||||
get isMicrophoneMuted(): boolean {
|
||||
return this.call.muteSettings?.microphone ?? true;
|
||||
}
|
||||
|
||||
get avatarLetter(): string {
|
||||
const member = this.memberObservable?.get();
|
||||
if (member) {
|
||||
return avatarInitials(member.name);
|
||||
} else {
|
||||
return this.getOption("room").user.id;
|
||||
}
|
||||
}
|
||||
|
||||
get avatarColorNumber(): number {
|
||||
return getIdentifierColorNumber(this.getOption("room").user.id);
|
||||
}
|
||||
|
||||
avatarUrl(size: number): string | undefined {
|
||||
const member = this.memberObservable?.get();
|
||||
if (member) {
|
||||
return getAvatarHttpUrl(member.avatarUrl, size, this.platform, this.getOption("room").mediaRepository);
|
||||
}
|
||||
}
|
||||
|
||||
get avatarTitle(): string {
|
||||
const member = this.memberObservable?.get();
|
||||
if (member) {
|
||||
return member.name;
|
||||
} else {
|
||||
return this.getOption("room").user.id;
|
||||
}
|
||||
}
|
||||
|
||||
compare(other: IStreamViewModel): number {
|
||||
// I always come first.
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
type MemberOptions<N extends object> = BaseOptions<N> & {
|
||||
member: Member,
|
||||
mediaRepository: MediaRepository,
|
||||
};
|
||||
|
||||
export class CallMemberViewModel extends ErrorReportViewModel<SegmentType, MemberOptions<SegmentType>> implements IStreamViewModel {
|
||||
get stream(): Stream | undefined {
|
||||
return this.member.remoteMedia?.userMedia;
|
||||
}
|
||||
|
||||
private get member(): Member {
|
||||
return this.getOption("member");
|
||||
}
|
||||
|
||||
get isCameraMuted(): boolean {
|
||||
return this.member.remoteMuteSettings?.camera ?? true;
|
||||
}
|
||||
|
||||
get isMicrophoneMuted(): boolean {
|
||||
return this.member.remoteMuteSettings?.microphone ?? true;
|
||||
}
|
||||
|
||||
get avatarLetter(): string {
|
||||
return avatarInitials(this.member.member.name);
|
||||
}
|
||||
|
||||
get avatarColorNumber(): number {
|
||||
return getIdentifierColorNumber(this.member.userId);
|
||||
}
|
||||
|
||||
avatarUrl(size: number): string | undefined {
|
||||
const {avatarUrl} = this.member.member;
|
||||
const mediaRepository = this.getOption("mediaRepository");
|
||||
return getAvatarHttpUrl(avatarUrl, size, this.platform, mediaRepository);
|
||||
}
|
||||
|
||||
get avatarTitle(): string {
|
||||
return this.member.member.name;
|
||||
}
|
||||
|
||||
onUpdate() {
|
||||
this.mapMemberSyncErrorIfNeeded();
|
||||
}
|
||||
|
||||
private mapMemberSyncErrorIfNeeded() {
|
||||
if (this.member.error) {
|
||||
this.reportError(this.member.error);
|
||||
}
|
||||
}
|
||||
|
||||
compare(other: IStreamViewModel): number {
|
||||
if (other instanceof CallMemberViewModel) {
|
||||
const myUserId = this.member.member.userId;
|
||||
const otherUserId = other.member.member.userId;
|
||||
if(myUserId === otherUserId) {
|
||||
return 0;
|
||||
}
|
||||
return myUserId < otherUserId ? -1 : 1;
|
||||
} else {
|
||||
return -other.compare(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface IStreamViewModel extends AvatarSource, ViewModel {
|
||||
get stream(): Stream | undefined;
|
||||
get isCameraMuted(): boolean;
|
||||
get isMicrophoneMuted(): boolean;
|
||||
get errorViewModel(): ErrorViewModel | undefined;
|
||||
compare(other: IStreamViewModel): number;
|
||||
}
|
@ -17,15 +17,19 @@ limitations under the License.
|
||||
|
||||
import {TimelineViewModel} from "./timeline/TimelineViewModel.js";
|
||||
import {ComposerViewModel} from "./ComposerViewModel.js"
|
||||
import {CallViewModel} from "./CallViewModel"
|
||||
import {PickMapObservableValue} from "../../../observable/value";
|
||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
|
||||
import {ErrorReportViewModel} from "../../ErrorReportViewModel";
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
import {imageToInfo} from "../common.js";
|
||||
import {LocalMedia} from "../../../matrix/calls/LocalMedia";
|
||||
// TODO: remove fallback so default isn't included in bundle for SDK users that have their custom tileClassForEntry
|
||||
// this is a breaking SDK change though to make this option mandatory
|
||||
import {tileClassForEntry as defaultTileClassForEntry} from "./timeline/tiles/index";
|
||||
import {joinRoom} from "../../../matrix/room/joinRoom";
|
||||
|
||||
export class RoomViewModel extends ViewModel {
|
||||
export class RoomViewModel extends ErrorReportViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
const {room, tileClassForEntry} = options;
|
||||
@ -34,8 +38,6 @@ export class RoomViewModel extends ViewModel {
|
||||
this._tileClassForEntry = tileClassForEntry ?? defaultTileClassForEntry;
|
||||
this._tileOptions = undefined;
|
||||
this._onRoomChange = this._onRoomChange.bind(this);
|
||||
this._timelineError = null;
|
||||
this._sendError = null;
|
||||
this._composerVM = null;
|
||||
if (room.isArchived) {
|
||||
this._composerVM = this.track(new ArchivedViewModel(this.childOptions({archivedRoom: room})));
|
||||
@ -44,13 +46,42 @@ export class RoomViewModel extends ViewModel {
|
||||
}
|
||||
this._clearUnreadTimout = null;
|
||||
this._closeUrl = this.urlRouter.urlUntilSegment("session");
|
||||
this._setupCallViewModel();
|
||||
}
|
||||
|
||||
_setupCallViewModel() {
|
||||
if (!this.features.calls) {
|
||||
return;
|
||||
}
|
||||
// pick call for this room with lowest key
|
||||
const calls = this.getOption("session").callHandler.calls;
|
||||
this._callObservable = new PickMapObservableValue(calls.filterValues(c => {
|
||||
return c.roomId === this._room.id && c.hasJoined;
|
||||
}));
|
||||
this._callViewModel = undefined;
|
||||
this.track(this._callObservable.subscribe(call => {
|
||||
if (call && this._callViewModel && call.id === this._callViewModel.id) {
|
||||
return;
|
||||
}
|
||||
this._callViewModel = this.disposeTracked(this._callViewModel);
|
||||
if (call) {
|
||||
this._callViewModel = this.track(new CallViewModel(this.childOptions({call, room: this._room})));
|
||||
}
|
||||
this.emitChange("callViewModel");
|
||||
}));
|
||||
const call = this._callObservable.get();
|
||||
// TODO: cleanup this duplication to create CallViewModel
|
||||
if (call) {
|
||||
this._callViewModel = this.track(new CallViewModel(this.childOptions({call, room: this._room})));
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
this._room.on("change", this._onRoomChange);
|
||||
try {
|
||||
const timeline = await this._room.openTimeline();
|
||||
this.logAndCatch("RoomViewModel.load", async log => {
|
||||
this._room.on("change", this._onRoomChange);
|
||||
const timeline = await this._room.openTimeline(log);
|
||||
this._tileOptions = this.childOptions({
|
||||
session: this.getOption("session"),
|
||||
roomVM: this,
|
||||
timeline,
|
||||
tileClassForEntry: this._tileClassForEntry,
|
||||
@ -60,12 +91,8 @@ export class RoomViewModel extends ViewModel {
|
||||
timeline,
|
||||
})));
|
||||
this.emitChange("timelineViewModel");
|
||||
} catch (err) {
|
||||
console.error(`room.openTimeline(): ${err.message}:\n${err.stack}`);
|
||||
this._timelineError = err;
|
||||
this.emitChange("error");
|
||||
}
|
||||
this._clearUnreadAfterDelay();
|
||||
await this._clearUnreadAfterDelay(log);
|
||||
});
|
||||
}
|
||||
|
||||
async _recreateComposerOnPowerLevelChange() {
|
||||
@ -92,24 +119,28 @@ export class RoomViewModel extends ViewModel {
|
||||
recreateComposer(oldCanSendMessage);
|
||||
}
|
||||
|
||||
async _clearUnreadAfterDelay() {
|
||||
async _clearUnreadAfterDelay(log) {
|
||||
if (this._room.isArchived || this._clearUnreadTimout) {
|
||||
return;
|
||||
}
|
||||
this._clearUnreadTimout = this.clock.createTimeout(2000);
|
||||
try {
|
||||
await this._clearUnreadTimout.elapsed();
|
||||
await this._room.clearUnread();
|
||||
await this._room.clearUnread(log);
|
||||
this._clearUnreadTimout = null;
|
||||
} catch (err) {
|
||||
if (err.name !== "AbortError") {
|
||||
if (err.name === "AbortError") {
|
||||
log.set("clearUnreadCancelled", true);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
focus() {
|
||||
this._clearUnreadAfterDelay();
|
||||
this.logAndCatch("RoomViewModel.focus", async log => {
|
||||
this._clearUnreadAfterDelay(log);
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
@ -139,16 +170,6 @@ export class RoomViewModel extends ViewModel {
|
||||
get timelineViewModel() { return this._timelineVM; }
|
||||
get isEncrypted() { return this._room.isEncrypted; }
|
||||
|
||||
get error() {
|
||||
if (this._timelineError) {
|
||||
return `Something went wrong loading the timeline: ${this._timelineError.message}`;
|
||||
}
|
||||
if (this._sendError) {
|
||||
return `Something went wrong sending your message: ${this._sendError.message}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
get avatarLetter() {
|
||||
return avatarInitials(this.name);
|
||||
}
|
||||
@ -191,26 +212,51 @@ export class RoomViewModel extends ViewModel {
|
||||
|
||||
_createTile(entry) {
|
||||
if (this._tileOptions) {
|
||||
const Tile = this._tileOptions.tileClassForEntry(entry);
|
||||
const Tile = this._tileOptions.tileClassForEntry(entry, this._tileOptions);
|
||||
if (Tile) {
|
||||
return new Tile(entry, this._tileOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_sendMessage(message, replyingTo) {
|
||||
return this.logAndCatch("RoomViewModel.sendMessage", async log => {
|
||||
let success = false;
|
||||
if (!this._room.isArchived && message) {
|
||||
let msgtype = "m.text";
|
||||
if (message.startsWith("//")) {
|
||||
message = message.substring(1).trim();
|
||||
} else if (message.startsWith("/")) {
|
||||
const result = await this._processCommand(message);
|
||||
msgtype = result.msgtype;
|
||||
message = result.message;
|
||||
}
|
||||
let content;
|
||||
if (replyingTo) {
|
||||
log.set("replyingTo", replyingTo.eventId);
|
||||
content = await replyingTo.createReplyContent(msgtype, message);
|
||||
} else {
|
||||
content = {msgtype, body: message};
|
||||
}
|
||||
await this._room.sendEvent("m.room.message", content, undefined, log);
|
||||
success = true;
|
||||
}
|
||||
log.set("success", success);
|
||||
return success;
|
||||
}, false);
|
||||
}
|
||||
|
||||
async _processCommandJoin(roomName) {
|
||||
try {
|
||||
const session = this._options.client.session;
|
||||
const roomId = await joinRoom(roomName, session);
|
||||
this.navigation.push("room", roomId);
|
||||
} catch (err) {
|
||||
this._sendError = err;
|
||||
this._timelineError = null;
|
||||
this.emitChange("error");
|
||||
this.reportError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async _processCommand (message) {
|
||||
async _processCommand(message) {
|
||||
let msgtype;
|
||||
const [commandName, ...args] = message.substring(1).split(" ");
|
||||
switch (commandName) {
|
||||
@ -223,9 +269,7 @@ export class RoomViewModel extends ViewModel {
|
||||
const roomName = args[0];
|
||||
await this._processCommandJoin(roomName);
|
||||
} else {
|
||||
this._sendError = new Error("join syntax: /join <room-id>");
|
||||
this._timelineError = null;
|
||||
this.emitChange("error");
|
||||
this.reportError(new Error("join syntax: /join <room-id>"));
|
||||
}
|
||||
break;
|
||||
case "shrug":
|
||||
@ -245,78 +289,44 @@ export class RoomViewModel extends ViewModel {
|
||||
msgtype = "m.text";
|
||||
break;
|
||||
default:
|
||||
this._sendError = new Error(`no command name "${commandName}". To send the message instead of executing, please type "/${message}"`);
|
||||
this._timelineError = null;
|
||||
this.emitChange("error");
|
||||
this.reportError(new Error(`no command name "${commandName}". To send the message instead of executing, please type "/${message}"`));
|
||||
message = undefined;
|
||||
}
|
||||
return {type: msgtype, message: message};
|
||||
}
|
||||
|
||||
async _sendMessage(message, replyingTo) {
|
||||
if (!this._room.isArchived && message) {
|
||||
let messinfo = {type : "m.text", message : message};
|
||||
if (message.startsWith("//")) {
|
||||
messinfo.message = message.substring(1).trim();
|
||||
} else if (message.startsWith("/")) {
|
||||
messinfo = await this._processCommand(message);
|
||||
}
|
||||
try {
|
||||
const msgtype = messinfo.type;
|
||||
const message = messinfo.message;
|
||||
if (msgtype && message) {
|
||||
if (replyingTo) {
|
||||
await replyingTo.reply(msgtype, message);
|
||||
} else {
|
||||
await this._room.sendEvent("m.room.message", {msgtype, body: message});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`room.sendMessage(): ${err.message}:\n${err.stack}`);
|
||||
this._sendError = err;
|
||||
this._timelineError = null;
|
||||
this.emitChange("error");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return {msgtype, message: message};
|
||||
}
|
||||
|
||||
async _pickAndSendFile() {
|
||||
try {
|
||||
_pickAndSendFile() {
|
||||
return this.logAndCatch("RoomViewModel.sendFile", async log => {
|
||||
const file = await this.platform.openFile();
|
||||
if (!file) {
|
||||
log.set("cancelled", true);
|
||||
return;
|
||||
}
|
||||
return this._sendFile(file);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
return this._sendFile(file, log);
|
||||
});
|
||||
}
|
||||
|
||||
async _sendFile(file) {
|
||||
async _sendFile(file, log) {
|
||||
const content = {
|
||||
body: file.name,
|
||||
msgtype: "m.file"
|
||||
};
|
||||
await this._room.sendEvent("m.room.message", content, {
|
||||
"url": this._room.createAttachment(file.blob, file.name)
|
||||
});
|
||||
}, log);
|
||||
}
|
||||
|
||||
async _pickAndSendVideo() {
|
||||
try {
|
||||
_pickAndSendVideo() {
|
||||
return this.logAndCatch("RoomViewModel.sendVideo", async log => {
|
||||
if (!this.platform.hasReadPixelPermission()) {
|
||||
alert("Please allow canvas image data access, so we can scale your images down.");
|
||||
return;
|
||||
throw new Error("Please allow canvas image data access, so we can scale your images down.");
|
||||
}
|
||||
const file = await this.platform.openFile("video/*");
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
if (!file.blob.mimeType.startsWith("video/")) {
|
||||
return this._sendFile(file);
|
||||
return this._sendFile(file, log);
|
||||
}
|
||||
let video;
|
||||
try {
|
||||
@ -344,26 +354,23 @@ export class RoomViewModel extends ViewModel {
|
||||
content.info.thumbnail_info = imageToInfo(thumbnail);
|
||||
attachments["info.thumbnail_url"] =
|
||||
this._room.createAttachment(thumbnail.blob, file.name);
|
||||
await this._room.sendEvent("m.room.message", content, attachments);
|
||||
} catch (err) {
|
||||
this._sendError = err;
|
||||
this.emitChange("error");
|
||||
console.error(err.stack);
|
||||
}
|
||||
await this._room.sendEvent("m.room.message", content, attachments, log);
|
||||
});
|
||||
}
|
||||
|
||||
async _pickAndSendPicture() {
|
||||
try {
|
||||
this.logAndCatch("RoomViewModel.sendPicture", async log => {
|
||||
if (!this.platform.hasReadPixelPermission()) {
|
||||
alert("Please allow canvas image data access, so we can scale your images down.");
|
||||
return;
|
||||
}
|
||||
const file = await this.platform.openFile("image/*");
|
||||
if (!file) {
|
||||
log.set("cancelled", true);
|
||||
return;
|
||||
}
|
||||
if (!file.blob.mimeType.startsWith("image/")) {
|
||||
return this._sendFile(file);
|
||||
return this._sendFile(file, log);
|
||||
}
|
||||
let image = await this.platform.loadImage(file.blob);
|
||||
const limit = await this.platform.settingsStorage.getInt("sentImageSizeLimit");
|
||||
@ -386,12 +393,8 @@ export class RoomViewModel extends ViewModel {
|
||||
attachments["info.thumbnail_url"] =
|
||||
this._room.createAttachment(thumbnail.blob, file.name);
|
||||
}
|
||||
await this._room.sendEvent("m.room.message", content, attachments);
|
||||
} catch (err) {
|
||||
this._sendError = err;
|
||||
this.emitChange("error");
|
||||
console.error(err.stack);
|
||||
}
|
||||
await this._room.sendEvent("m.room.message", content, attachments, log);
|
||||
});
|
||||
}
|
||||
|
||||
get room() {
|
||||
@ -402,6 +405,10 @@ export class RoomViewModel extends ViewModel {
|
||||
return this._composerVM;
|
||||
}
|
||||
|
||||
get callViewModel() {
|
||||
return this._callViewModel;
|
||||
}
|
||||
|
||||
openDetailsPanel() {
|
||||
let path = this.navigation.path.until("room");
|
||||
path = path.with(this.navigation.segment("right-panel", true));
|
||||
@ -414,10 +421,41 @@ export class RoomViewModel extends ViewModel {
|
||||
this._composerVM.setReplyingTo(entry);
|
||||
}
|
||||
}
|
||||
|
||||
dismissError() {
|
||||
this._sendError = null;
|
||||
this.emitChange("error");
|
||||
|
||||
startCall() {
|
||||
return this.logAndCatch("RoomViewModel.startCall", async log => {
|
||||
if (!this.features.calls) {
|
||||
log.set("feature_disbled", true);
|
||||
return;
|
||||
}
|
||||
log.set("roomId", this._room.id);
|
||||
let localMedia;
|
||||
try {
|
||||
const stream = await this.platform.mediaDevices.getMediaTracks(false, true);
|
||||
localMedia = new LocalMedia().withUserMedia(stream);
|
||||
} catch (err) {
|
||||
throw new Error(`Could not get local audio and/or video stream: ${err.message}`);
|
||||
}
|
||||
const session = this.getOption("session");
|
||||
let call;
|
||||
try {
|
||||
// this will set the callViewModel above as a call will be added to callHandler.calls
|
||||
call = await session.callHandler.createCall(
|
||||
this._room.id,
|
||||
"m.video",
|
||||
"A call " + Math.round(this.platform.random() * 100),
|
||||
undefined,
|
||||
log
|
||||
);
|
||||
} catch (err) {
|
||||
throw new Error(`Could not create call: ${err.message}`);
|
||||
}
|
||||
try {
|
||||
await call.join(localMedia, log);
|
||||
} catch (err) {
|
||||
throw new Error(`Could not join call: ${err.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -189,7 +189,7 @@ import {HomeServer as MockHomeServer} from "../../../../mocks/HomeServer.js";
|
||||
// other imports
|
||||
import {BaseMessageTile} from "./tiles/BaseMessageTile.js";
|
||||
import {MappedList} from "../../../../observable/list/MappedList";
|
||||
import {ObservableValue} from "../../../../observable/ObservableValue";
|
||||
import {ObservableValue} from "../../../../observable/value";
|
||||
import {PowerLevels} from "../../../../matrix/room/PowerLevels.js";
|
||||
|
||||
export function tests() {
|
||||
|
@ -16,6 +16,7 @@ limitations under the License.
|
||||
|
||||
import {BaseObservableList} from "../../../../observable/list/BaseObservableList";
|
||||
import {sortedIndex} from "../../../../utils/sortedIndex";
|
||||
import {TileShape} from "./tiles/ITile";
|
||||
|
||||
// maps 1..n entries to 0..1 tile. Entries are what is stored in the timeline, either an event or fragmentboundary
|
||||
// for now, tileClassForEntry should be stable in whether it returns a tile or not.
|
||||
@ -33,7 +34,7 @@ export class TilesCollection extends BaseObservableList {
|
||||
}
|
||||
|
||||
_createTile(entry) {
|
||||
const Tile = this._tileOptions.tileClassForEntry(entry);
|
||||
const Tile = this._tileOptions.tileClassForEntry(entry, this._tileOptions);
|
||||
if (Tile) {
|
||||
return new Tile(entry, this._tileOptions);
|
||||
}
|
||||
@ -51,6 +52,7 @@ export class TilesCollection extends BaseObservableList {
|
||||
}
|
||||
|
||||
_populateTiles() {
|
||||
this._silent = true;
|
||||
this._tiles = [];
|
||||
let currentTile = null;
|
||||
for (let entry of this._entries) {
|
||||
@ -72,11 +74,20 @@ export class TilesCollection extends BaseObservableList {
|
||||
if (prevTile) {
|
||||
prevTile.updateNextSibling(null);
|
||||
}
|
||||
// add date headers here
|
||||
for (let idx = 0; idx < this._tiles.length; idx += 1) {
|
||||
const tile = this._tiles[idx];
|
||||
if (tile.needsDateSeparator) {
|
||||
this._addTileAt(idx, tile.createDateSeparator(), true);
|
||||
idx += 1; // tile's index moved one up, don't process it again
|
||||
}
|
||||
}
|
||||
// now everything is wired up,
|
||||
// allow tiles to emit updates
|
||||
for (const tile of this._tiles) {
|
||||
tile.setUpdateEmit(this._emitSpontanousUpdate);
|
||||
}
|
||||
this._silent = false;
|
||||
}
|
||||
|
||||
_findTileIdx(entry) {
|
||||
@ -130,25 +141,57 @@ export class TilesCollection extends BaseObservableList {
|
||||
|
||||
const newTile = this._createTile(entry);
|
||||
if (newTile) {
|
||||
if (prevTile) {
|
||||
prevTile.updateNextSibling(newTile);
|
||||
// this emits an update while the add hasn't been emitted yet
|
||||
newTile.updatePreviousSibling(prevTile);
|
||||
}
|
||||
if (nextTile) {
|
||||
newTile.updateNextSibling(nextTile);
|
||||
nextTile.updatePreviousSibling(newTile);
|
||||
}
|
||||
this._tiles.splice(tileIdx, 0, newTile);
|
||||
this.emitAdd(tileIdx, newTile);
|
||||
// add event is emitted, now the tile
|
||||
// can emit updates
|
||||
newTile.setUpdateEmit(this._emitSpontanousUpdate);
|
||||
this._addTileAt(tileIdx, newTile);
|
||||
this._evaluateDateHeaderAtIdx(tileIdx);
|
||||
}
|
||||
// find position by sort key
|
||||
// ask siblings to be included? both? yes, twice: a (insert c here) b, ask a(c), if yes ask b(a), else ask b(c)? if yes then b(a)?
|
||||
}
|
||||
|
||||
_evaluateDateHeaderAtIdx(tileIdx) {
|
||||
// consider two tiles after the inserted tile, because
|
||||
// the first of the two tiles may be a DateTile in which case,
|
||||
// we remove it after looking at the needsDateSeparator prop of the
|
||||
// next next tile
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const idx = tileIdx + i;
|
||||
if (idx >= this._tiles.length) {
|
||||
break;
|
||||
}
|
||||
const tile = this._tiles[idx];
|
||||
const prevTile = idx > 0 ? this._tiles[idx - 1] : undefined;
|
||||
const hasDateSeparator = prevTile?.shape === TileShape.DateHeader;
|
||||
if (tile.needsDateSeparator && !hasDateSeparator) {
|
||||
// adding a tile shift all the indices we need to consider
|
||||
// especially given we consider removals for the tile that
|
||||
// comes after a datetile
|
||||
tileIdx += 1;
|
||||
this._addTileAt(idx, tile.createDateSeparator());
|
||||
} else if (!tile.needsDateSeparator && hasDateSeparator) {
|
||||
// this is never triggered because needsDateSeparator is not cleared
|
||||
// when loading more items because we don't do anything once the
|
||||
// direct sibling is a DateTile
|
||||
this._removeTile(idx - 1, prevTile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_addTileAt(idx, newTile, silent = false) {
|
||||
const prevTile = idx > 0 ? this._tiles[idx - 1] : undefined;
|
||||
const nextTile = this._tiles[idx];
|
||||
prevTile?.updateNextSibling(newTile);
|
||||
newTile.updatePreviousSibling(prevTile);
|
||||
newTile.updateNextSibling(nextTile);
|
||||
nextTile?.updatePreviousSibling(newTile);
|
||||
this._tiles.splice(idx, 0, newTile);
|
||||
if (!silent) {
|
||||
this.emitAdd(idx, newTile);
|
||||
}
|
||||
// add event is emitted, now the tile
|
||||
// can emit updates
|
||||
newTile.setUpdateEmit(this._emitSpontanousUpdate);
|
||||
}
|
||||
|
||||
onUpdate(index, entry, params) {
|
||||
// if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it
|
||||
if (!this._tiles) {
|
||||
@ -210,11 +253,16 @@ export class TilesCollection extends BaseObservableList {
|
||||
this.emitRemove(tileIdx, tile);
|
||||
prevTile?.updateNextSibling(nextTile);
|
||||
nextTile?.updatePreviousSibling(prevTile);
|
||||
|
||||
if (prevTile && prevTile.shape === TileShape.DateHeader && (!nextTile || !nextTile.needsDateSeparator)) {
|
||||
this._removeTile(tileIdx - 1, prevTile);
|
||||
}
|
||||
}
|
||||
|
||||
// would also be called when unloading a part of the timeline
|
||||
onRemove(index, entry) {
|
||||
const tileIdx = this._findTileIdx(entry);
|
||||
|
||||
const tile = this._findTileAtIdx(entry, tileIdx);
|
||||
if (tile) {
|
||||
const removeTile = tile.removeEntry(entry);
|
||||
@ -268,6 +316,7 @@ export function tests() {
|
||||
constructor(entry) {
|
||||
this.entry = entry;
|
||||
this.update = null;
|
||||
this.needsDateSeparator = false;
|
||||
}
|
||||
setUpdateEmit(update) {
|
||||
this.update = update;
|
||||
@ -297,6 +346,34 @@ export function tests() {
|
||||
dispose() {}
|
||||
}
|
||||
|
||||
class DateHeaderTile extends TestTile {
|
||||
get shape() { return TileShape.DateHeader; }
|
||||
updateNextSibling(next) {
|
||||
this.next = next;
|
||||
}
|
||||
updatePreviousSibling(prev) {
|
||||
this.next?.updatePreviousSibling(prev);
|
||||
}
|
||||
compareEntry(b) {
|
||||
// important that date tiles as sorted before their next item, but after their previous sibling
|
||||
return this.next.compareEntry(b) - 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
class MessageNeedingDateHeaderTile extends TestTile {
|
||||
get shape() { return TileShape.Message; }
|
||||
|
||||
createDateSeparator() {
|
||||
return new DateHeaderTile(this.entry);
|
||||
}
|
||||
updatePreviousSibling(prev) {
|
||||
if (prev?.shape !== TileShape.DateHeader) {
|
||||
// 1 day is 10
|
||||
this.needsDateSeparator = !prev || Math.floor(prev.entry.n / 10) !== Math.floor(this.entry.n / 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"don't emit update before add": assert => {
|
||||
class UpdateOnSiblingTile extends TestTile {
|
||||
@ -355,6 +432,73 @@ export function tests() {
|
||||
});
|
||||
entries.remove(1);
|
||||
assert.deepEqual(events, ["remove", "update"]);
|
||||
},
|
||||
"date tile is added when needed when populating": assert => {
|
||||
const entries = new ObservableArray([{n: 15}]);
|
||||
const tileOptions = {
|
||||
tileClassForEntry: () => MessageNeedingDateHeaderTile,
|
||||
};
|
||||
const tiles = new TilesCollection(entries, tileOptions);
|
||||
tiles.subscribe({});
|
||||
const tilesArray = Array.from(tiles);
|
||||
assert.equal(tilesArray.length, 2);
|
||||
assert.equal(tilesArray[0].shape, TileShape.DateHeader);
|
||||
assert.equal(tilesArray[1].shape, TileShape.Message);
|
||||
},
|
||||
"date header is added when receiving addition": assert => {
|
||||
const entries = new ObservableArray([{n: 15}]);
|
||||
const tileOptions = {
|
||||
tileClassForEntry: () => MessageNeedingDateHeaderTile,
|
||||
};
|
||||
const tiles = new TilesCollection(entries, tileOptions);
|
||||
tiles.subscribe({
|
||||
onAdd() {},
|
||||
onRemove() {}
|
||||
});
|
||||
entries.insert(0, {n: 5});
|
||||
const tilesArray = Array.from(tiles);
|
||||
assert.equal(tilesArray[0].shape, TileShape.DateHeader);
|
||||
assert.equal(tilesArray[1].shape, TileShape.Message);
|
||||
assert.equal(tilesArray[2].shape, TileShape.DateHeader);
|
||||
assert.equal(tilesArray[3].shape, TileShape.Message);
|
||||
assert.equal(tilesArray.length, 4);
|
||||
},
|
||||
"date header is removed and added when loading more messages for the same day": assert => {
|
||||
const entries = new ObservableArray([{n: 15}]);
|
||||
const tileOptions = {
|
||||
tileClassForEntry: () => MessageNeedingDateHeaderTile,
|
||||
};
|
||||
const tiles = new TilesCollection(entries, tileOptions);
|
||||
tiles.subscribe({
|
||||
onAdd() {},
|
||||
onRemove() {}
|
||||
});
|
||||
entries.insert(0, {n: 12});
|
||||
const tilesArray = Array.from(tiles);
|
||||
assert.equal(tilesArray[0].shape, TileShape.DateHeader);
|
||||
assert.equal(tilesArray[1].shape, TileShape.Message);
|
||||
assert.equal(tilesArray[2].shape, TileShape.Message);
|
||||
assert.equal(tilesArray.length, 3);
|
||||
},
|
||||
"date header is removed at the end of the timeline": assert => {
|
||||
const entries = new ObservableArray([{n: 5}, {n: 15}]);
|
||||
const tileOptions = {
|
||||
tileClassForEntry: () => MessageNeedingDateHeaderTile,
|
||||
};
|
||||
const tiles = new TilesCollection(entries, tileOptions);
|
||||
let removals = 0;
|
||||
tiles.subscribe({
|
||||
onAdd() {},
|
||||
onRemove() {
|
||||
removals += 1;
|
||||
}
|
||||
});
|
||||
entries.remove(1);
|
||||
const tilesArray = Array.from(tiles);
|
||||
assert.equal(tilesArray[0].shape, TileShape.DateHeader);
|
||||
assert.equal(tilesArray[1].shape, TileShape.Message);
|
||||
assert.equal(tilesArray.length, 2);
|
||||
assert.equal(removals, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -23,7 +23,6 @@ import {copyPlaintext} from "../../../../../platform/web/dom/utils";
|
||||
export class BaseMessageTile extends SimpleTile {
|
||||
constructor(entry, options) {
|
||||
super(entry, options);
|
||||
this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null;
|
||||
this._isContinuation = false;
|
||||
this._reactions = null;
|
||||
this._replyTile = null;
|
||||
@ -55,14 +54,6 @@ export class BaseMessageTile extends SimpleTile {
|
||||
return `https://matrix.to/#/${encodeURIComponent(this.sender)}`;
|
||||
}
|
||||
|
||||
get displayName() {
|
||||
return this._entry.displayName || this.sender;
|
||||
}
|
||||
|
||||
get sender() {
|
||||
return this._entry.sender;
|
||||
}
|
||||
|
||||
get memberPanelLink() {
|
||||
return `${this.urlRouter.urlUntilSegment("room")}/member/${this.sender}`;
|
||||
}
|
||||
@ -84,12 +75,8 @@ export class BaseMessageTile extends SimpleTile {
|
||||
return this.sender;
|
||||
}
|
||||
|
||||
get date() {
|
||||
return this._date && this._date.toLocaleDateString({}, {month: "numeric", day: "numeric"});
|
||||
}
|
||||
|
||||
get time() {
|
||||
return this._date && this._date.toLocaleTimeString({}, {hour: "numeric", minute: "2-digit"});
|
||||
return this._date && this.timeFormatter.formatTime(this._date);
|
||||
}
|
||||
|
||||
get isOwn() {
|
||||
@ -145,7 +132,7 @@ export class BaseMessageTile extends SimpleTile {
|
||||
if (action?.shouldReplace || !this._replyTile) {
|
||||
this.disposeTracked(this._replyTile);
|
||||
const tileClassForEntry = this._options.tileClassForEntry;
|
||||
const ReplyTile = tileClassForEntry(replyEntry);
|
||||
const ReplyTile = tileClassForEntry(replyEntry, this._options);
|
||||
if (ReplyTile) {
|
||||
this._replyTile = new ReplyTile(replyEntry, this._options);
|
||||
}
|
||||
@ -160,8 +147,8 @@ export class BaseMessageTile extends SimpleTile {
|
||||
this._roomVM.startReply(this._entry);
|
||||
}
|
||||
|
||||
reply(msgtype, body, log = null) {
|
||||
return this._room.sendEvent("m.room.message", this._entry.reply(msgtype, body), null, log);
|
||||
createReplyContent(msgtype, body) {
|
||||
return this._entry.createReplyContent(msgtype, body);
|
||||
}
|
||||
|
||||
redact(reason, log) {
|
||||
|
180
src/domain/session/room/timeline/tiles/CallTile.js
Normal file
180
src/domain/session/room/timeline/tiles/CallTile.js
Normal file
@ -0,0 +1,180 @@
|
||||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {SimpleTile} from "./SimpleTile.js";
|
||||
import {ViewModel} from "../../../../ViewModel";
|
||||
import {LocalMedia} from "../../../../../matrix/calls/LocalMedia";
|
||||
import {CallType} from "../../../../../matrix/calls/callEventTypes";
|
||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../../../avatar";
|
||||
|
||||
// TODO: timeline entries for state events with the same state key and type
|
||||
// should also update previous entries in the timeline, so we can update the name of the call, whether it is terminated, etc ...
|
||||
|
||||
// alternatively, we could just subscribe to the GroupCall and spontanously emit an update when it updates
|
||||
|
||||
export class CallTile extends SimpleTile {
|
||||
constructor(entry, options) {
|
||||
super(entry, options);
|
||||
const calls = this.getOption("session").callHandler.calls;
|
||||
this._callSubscription = undefined;
|
||||
this._memberSizeSubscription = undefined;
|
||||
const call = calls.get(this._entry.stateKey);
|
||||
if (call && !call.isTerminated) {
|
||||
this._call = call;
|
||||
this.memberViewModels = this._setupMembersList(this._call);
|
||||
this._callSubscription = this.track(this._call.disposableOn("change", () => {
|
||||
this._onCallUpdate();
|
||||
}));
|
||||
this._memberSizeSubscription = this.track(this._call.members.observeSize().subscribe(() => {
|
||||
this.emitChange("memberCount");
|
||||
}));
|
||||
this._onCallUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
_onCallUpdate() {
|
||||
// unsubscribe when terminated
|
||||
if (this._call.isTerminated) {
|
||||
this._durationInterval = this.disposeTracked(this._durationInterval);
|
||||
this._callSubscription = this.disposeTracked(this._callSubscription);
|
||||
this._call = undefined;
|
||||
} else if (!this._durationInterval) {
|
||||
this._durationInterval = this.track(this.platform.clock.createInterval(() => {
|
||||
this.emitChange("duration");
|
||||
}, 1000));
|
||||
}
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
_setupMembersList(call) {
|
||||
return call.members.mapValues(
|
||||
(member, emitChange) => new MemberAvatarViewModel(this.childOptions({
|
||||
member,
|
||||
emitChange,
|
||||
mediaRepository: this.getOption("room").mediaRepository
|
||||
})),
|
||||
).sortValues((a, b) => a.userId.localeCompare(b.userId));
|
||||
}
|
||||
|
||||
get memberCount() {
|
||||
// TODO: emit updates for this property
|
||||
if (this._call) {
|
||||
return this._call.members.size;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
get confId() {
|
||||
return this._entry.stateKey;
|
||||
}
|
||||
|
||||
get duration() {
|
||||
if (this._call && this._call.duration) {
|
||||
return this.timeFormatter.formatDuration(this._call.duration);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
get shape() {
|
||||
return "call";
|
||||
}
|
||||
|
||||
get canJoin() {
|
||||
return this._call && !this._call.hasJoined && !this._call.usesFoci;
|
||||
}
|
||||
|
||||
get canLeave() {
|
||||
return this._call && this._call.hasJoined;
|
||||
}
|
||||
|
||||
get title() {
|
||||
if (this._call) {
|
||||
if (this.type === CallType.Video) {
|
||||
return `${this.displayName} started a video call`;
|
||||
} else {
|
||||
return `${this.displayName} started a voice call`;
|
||||
}
|
||||
} else {
|
||||
if (this.type === CallType.Video) {
|
||||
return `Video call ended`;
|
||||
} else {
|
||||
return `Voice call ended`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get typeLabel() {
|
||||
if (this._call && this._call.usesFoci) {
|
||||
return `This call uses a stream-forwarding unit, which isn't supported yet, so you can't join this call.`;
|
||||
}
|
||||
if (this.type === CallType.Video) {
|
||||
return `Video call`;
|
||||
} else {
|
||||
return `Voice call`;
|
||||
}
|
||||
}
|
||||
|
||||
get type() {
|
||||
return this._entry.event.content["m.type"];
|
||||
}
|
||||
|
||||
async join() {
|
||||
await this.logAndCatch("CallTile.join", async log => {
|
||||
if (this.canJoin) {
|
||||
const stream = await this.platform.mediaDevices.getMediaTracks(false, true);
|
||||
const localMedia = new LocalMedia().withUserMedia(stream);
|
||||
await this._call.join(localMedia, log);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async leave() {
|
||||
await this.logAndCatch("CallTile.leave", async log => {
|
||||
if (this.canLeave) {
|
||||
await this._call.leave(log);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class MemberAvatarViewModel extends ViewModel {
|
||||
get _member() {
|
||||
return this.getOption("member");
|
||||
}
|
||||
|
||||
get userId() {
|
||||
return this._member.userId;
|
||||
}
|
||||
|
||||
get avatarLetter() {
|
||||
return avatarInitials(this._member.member.name);
|
||||
}
|
||||
|
||||
get avatarColorNumber() {
|
||||
return getIdentifierColorNumber(this._member.userId);
|
||||
}
|
||||
|
||||
avatarUrl(size) {
|
||||
const {avatarUrl} = this._member.member;
|
||||
const mediaRepository = this.getOption("mediaRepository");
|
||||
return getAvatarHttpUrl(avatarUrl, size, this.platform, mediaRepository);
|
||||
}
|
||||
|
||||
get avatarTitle() {
|
||||
return this._member.member.name;
|
||||
}
|
||||
}
|
189
src/domain/session/room/timeline/tiles/DateTile.ts
Normal file
189
src/domain/session/room/timeline/tiles/DateTile.ts
Normal file
@ -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<BaseEventEntry> {
|
||||
private _emitUpdate?: EmitUpdateFn;
|
||||
private _dateString?: string;
|
||||
private _machineReadableString?: string;
|
||||
|
||||
constructor(private _firstTileInDay: ITile<BaseEventEntry>, 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<BaseEntry>): 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<BaseEntry> | 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<BaseEntry> | 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -19,71 +19,64 @@ import {UpdateAction} from "../UpdateAction.js";
|
||||
import {ConnectionError} from "../../../../../matrix/error.js";
|
||||
import {ConnectionStatus} from "../../../../../matrix/net/Reconnector";
|
||||
|
||||
// TODO: should this become an ITile and SimpleTile become EventTile?
|
||||
export class GapTile extends SimpleTile {
|
||||
constructor(entry, options) {
|
||||
super(entry, options);
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
this._waitingForConnection = false;
|
||||
this._isAtTop = true;
|
||||
this._siblingChanged = false;
|
||||
this._showSpinner = false;
|
||||
}
|
||||
|
||||
async fill() {
|
||||
get needsDateSeparator() {
|
||||
return false;
|
||||
}
|
||||
|
||||
async fill(isRetrying = false) {
|
||||
if (!this._loading && !this._entry.edgeReached) {
|
||||
this._loading = true;
|
||||
this._error = null;
|
||||
this._showSpinner = true;
|
||||
this.emitChange("isLoading");
|
||||
try {
|
||||
await this._room.fillGap(this._entry, 10);
|
||||
} catch (err) {
|
||||
console.error(`room.fillGap(): ${err.message}:\n${err.stack}`);
|
||||
this._error = err;
|
||||
if (err instanceof ConnectionError) {
|
||||
this.emitChange("error");
|
||||
/*
|
||||
We need to wait for reconnection here rather than in
|
||||
notifyVisible() because when we return/throw here
|
||||
this._loading is set to false and other queued invocations of
|
||||
this method will succeed and attempt further room.fillGap() calls -
|
||||
resulting in multiple error entries in logs and elsewhere!
|
||||
*/
|
||||
await this._waitForReconnection();
|
||||
if (!isRetrying) {
|
||||
// retry after the connection comes back
|
||||
// if this wasn't already a retry after coming back online
|
||||
return await this.fill(true);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
this.reportError(err);
|
||||
return false;
|
||||
}
|
||||
// rethrow so caller of this method
|
||||
// knows not to keep calling this for now
|
||||
throw err;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
this._showSpinner = false;
|
||||
this.emitChange("isLoading");
|
||||
}
|
||||
return true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async notifyVisible() {
|
||||
// if any error happened before (apart from being offline),
|
||||
// let the user dismiss the error before trying to backfill
|
||||
// again so we don't try to do backfill the don't succeed
|
||||
// in quick succession
|
||||
if (this.errorViewModel) {
|
||||
return;
|
||||
}
|
||||
// we do (up to 10) backfills while no new tiles have been added to the timeline
|
||||
// because notifyVisible won't be called again until something gets added to the timeline
|
||||
let depth = 0;
|
||||
let canFillMore;
|
||||
this._siblingChanged = false;
|
||||
do {
|
||||
try {
|
||||
canFillMore = await this.fill();
|
||||
}
|
||||
catch (e) {
|
||||
if (e instanceof ConnectionError) {
|
||||
canFillMore = true;
|
||||
// Don't increase depth because this gap fill was a noop
|
||||
continue;
|
||||
}
|
||||
else {
|
||||
canFillMore = false;
|
||||
}
|
||||
}
|
||||
canFillMore = await this.fill();
|
||||
depth = depth + 1;
|
||||
} while (depth < 10 && !this._siblingChanged && canFillMore && !this.isDisposed);
|
||||
}
|
||||
@ -119,7 +112,11 @@ export class GapTile extends SimpleTile {
|
||||
}
|
||||
|
||||
async _waitForReconnection() {
|
||||
this._waitingForConnection = true;
|
||||
this.emitUpdate("status");
|
||||
await this.options.client.reconnector.connectionStatus.waitFor(status => status === ConnectionStatus.Online).promise;
|
||||
this._waitingForConnection = false;
|
||||
this.emitUpdate("status");
|
||||
}
|
||||
|
||||
get shape() {
|
||||
@ -131,29 +128,19 @@ export class GapTile extends SimpleTile {
|
||||
}
|
||||
|
||||
get showSpinner() {
|
||||
return this._showSpinner;
|
||||
return this.isLoading || this._waitingForConnection;
|
||||
}
|
||||
|
||||
get error() {
|
||||
if (this._error) {
|
||||
if (this._error instanceof ConnectionError) {
|
||||
return "Waiting for reconnection";
|
||||
}
|
||||
const dir = this._entry.prev_batch ? "previous" : "next";
|
||||
return `Could not load ${dir} messages: ${this._error.message}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
get currentAction() {
|
||||
if (this.error) {
|
||||
return this.error;
|
||||
}
|
||||
else if (this.isLoading) {
|
||||
return "Loading";
|
||||
}
|
||||
else {
|
||||
return "Not Loading";
|
||||
get status() {
|
||||
const dir = this._entry.prev_batch ? "previous" : "next";
|
||||
if (this._waitingForConnection) {
|
||||
return "Waiting for connection…";
|
||||
} else if (this.errorViewModel) {
|
||||
return `Could not load ${dir} messages`;
|
||||
} else if (this.isLoading) {
|
||||
return "Loading more messages…";
|
||||
} else {
|
||||
return "Gave up loading more messages";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
58
src/domain/session/room/timeline/tiles/ITile.ts
Normal file
58
src/domain/session/room/timeline/tiles/ITile.ts
Normal file
@ -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<BaseEntry>, 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<E extends BaseEntry = BaseEntry> 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<BaseEntry>): 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<BaseEntry> | undefined): void;
|
||||
// let item know it has a new sibling
|
||||
updateNextSibling(next: ITile<BaseEntry> | undefined): void;
|
||||
notifyVisible(): void;
|
||||
get needsDateSeparator(): boolean;
|
||||
createDateSeparator(): ITile<BaseEntry> | undefined;
|
||||
get shape(): TileShape;
|
||||
}
|
@ -15,13 +15,17 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import {UpdateAction} from "../UpdateAction.js";
|
||||
import {ViewModel} from "../../../../ViewModel";
|
||||
import {ErrorReportViewModel} from "../../../../ErrorReportViewModel";
|
||||
import {TileShape} from "./ITile";
|
||||
import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js";
|
||||
import {DateTile} from "./DateTile";
|
||||
|
||||
export class SimpleTile extends ViewModel {
|
||||
export class SimpleTile extends ErrorReportViewModel {
|
||||
constructor(entry, options) {
|
||||
super(options);
|
||||
this._entry = entry;
|
||||
this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : undefined;
|
||||
this._needsDateSeparator = false;
|
||||
this._emitUpdate = undefined;
|
||||
}
|
||||
// view model props for all subclasses
|
||||
@ -37,8 +41,22 @@ export class SimpleTile extends ViewModel {
|
||||
return false;
|
||||
}
|
||||
|
||||
get hasDateSeparator() {
|
||||
return false;
|
||||
get needsDateSeparator() {
|
||||
return this._needsDateSeparator;
|
||||
}
|
||||
|
||||
createDateSeparator() {
|
||||
return new DateTile(this, this.childOptions({}));
|
||||
}
|
||||
|
||||
_updateDateSeparator(prev) {
|
||||
if (prev && prev._date && this._date) {
|
||||
this._needsDateSeparator = prev._date.getFullYear() !== this._date.getFullYear() ||
|
||||
prev._date.getMonth() !== this._date.getMonth() ||
|
||||
prev._date.getDate() !== this._date.getDate();
|
||||
} else {
|
||||
this._needsDateSeparator = !!this._date;
|
||||
}
|
||||
}
|
||||
|
||||
get id() {
|
||||
@ -92,8 +110,16 @@ export class SimpleTile extends ViewModel {
|
||||
return this._entry;
|
||||
}
|
||||
|
||||
get comparisonIsNotCommutative() {
|
||||
return false;
|
||||
}
|
||||
|
||||
compare(tile) {
|
||||
return this.upperEntry.compare(tile.upperEntry);
|
||||
if (tile.comparisonIsNotCommutative) {
|
||||
return -tile.compare(this);
|
||||
} else {
|
||||
return this.upperEntry.compare(tile.upperEntry);
|
||||
}
|
||||
}
|
||||
|
||||
compareEntry(entry) {
|
||||
@ -123,8 +149,10 @@ export class SimpleTile extends ViewModel {
|
||||
return false;
|
||||
}
|
||||
// let item know it has a new sibling
|
||||
updatePreviousSibling(/*prev*/) {
|
||||
|
||||
updatePreviousSibling(prev) {
|
||||
if (prev?.shape !== TileShape.DateHeader) {
|
||||
this._updateDateSeparator(prev);
|
||||
}
|
||||
}
|
||||
|
||||
// let item know it has a new sibling
|
||||
@ -159,4 +187,74 @@ export class SimpleTile extends ViewModel {
|
||||
get _ownMember() {
|
||||
return this._options.timeline.me;
|
||||
}
|
||||
|
||||
get displayName() {
|
||||
return this._entry.displayName || this.sender;
|
||||
}
|
||||
|
||||
get sender() {
|
||||
return this._entry.sender;
|
||||
}
|
||||
}
|
||||
|
||||
import { EventEntry } from "../../../../../matrix/room/timeline/entries/EventEntry.js";
|
||||
|
||||
export function tests() {
|
||||
return {
|
||||
"needsDateSeparator is false when previous sibling is for same date": assert => {
|
||||
const fridayEntry = new EventEntry({
|
||||
event: {
|
||||
origin_server_ts: 1669376446222,
|
||||
type: "m.room.message",
|
||||
content: {}
|
||||
}
|
||||
}, undefined);
|
||||
const thursdayEntry = new EventEntry({
|
||||
event: {
|
||||
origin_server_ts: fridayEntry.timestamp - (60 * 60 * 8 * 1000),
|
||||
type: "m.room.message",
|
||||
content: {}
|
||||
}
|
||||
}, undefined);
|
||||
const fridayTile = new SimpleTile(fridayEntry, {});
|
||||
const thursdayTile = new SimpleTile(thursdayEntry, {});
|
||||
assert.equal(fridayTile.needsDateSeparator, false);
|
||||
fridayTile.updatePreviousSibling(thursdayTile);
|
||||
assert.equal(fridayTile.needsDateSeparator, false);
|
||||
},
|
||||
"needsDateSeparator is true when previous sibling is for different date": assert => {
|
||||
const fridayEntry = new EventEntry({
|
||||
event: {
|
||||
origin_server_ts: 1669376446222,
|
||||
type: "m.room.message",
|
||||
content: {}
|
||||
}
|
||||
}, undefined);
|
||||
const thursdayEntry = new EventEntry({
|
||||
event: {
|
||||
origin_server_ts: fridayEntry.timestamp - (60 * 60 * 24 * 1000),
|
||||
type: "m.room.message",
|
||||
content: {}
|
||||
}
|
||||
}, undefined);
|
||||
const fridayTile = new SimpleTile(fridayEntry, {});
|
||||
const thursdayTile = new SimpleTile(thursdayEntry, {});
|
||||
assert.equal(fridayTile.needsDateSeparator, false);
|
||||
fridayTile.updatePreviousSibling(thursdayTile);
|
||||
assert.equal(fridayTile.needsDateSeparator, true);
|
||||
},
|
||||
"needsDateSeparator is true when previous sibling is undefined": assert => {
|
||||
const fridayEntry = new EventEntry({
|
||||
event: {
|
||||
origin_server_ts: 1669376446222,
|
||||
type: "m.room.message",
|
||||
content: {}
|
||||
}
|
||||
}, undefined);
|
||||
const fridayTile = new SimpleTile(fridayEntry, {});
|
||||
assert.equal(fridayTile.needsDateSeparator, false);
|
||||
fridayTile.updatePreviousSibling(undefined);
|
||||
assert.equal(fridayTile.needsDateSeparator, true);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -26,9 +26,11 @@ import {RoomMemberTile} from "./RoomMemberTile.js";
|
||||
import {EncryptedEventTile} from "./EncryptedEventTile.js";
|
||||
import {EncryptionEnabledTile} from "./EncryptionEnabledTile.js";
|
||||
import {MissingAttachmentTile} from "./MissingAttachmentTile.js";
|
||||
import {CallTile} from "./CallTile.js";
|
||||
|
||||
import type {SimpleTile} from "./SimpleTile.js";
|
||||
import type {ITile, TileShape} from "./ITile";
|
||||
import type {Room} from "../../../../../matrix/room/Room";
|
||||
import type {Session} from "../../../../../matrix/Session";
|
||||
import type {Timeline} from "../../../../../matrix/room/timeline/Timeline";
|
||||
import type {FragmentBoundaryEntry} from "../../../../../matrix/room/timeline/entries/FragmentBoundaryEntry";
|
||||
import type {EventEntry} from "../../../../../matrix/room/timeline/entries/EventEntry";
|
||||
@ -38,13 +40,14 @@ import type {Options as ViewModelOptions} from "../../../../ViewModel";
|
||||
export type TimelineEntry = FragmentBoundaryEntry | EventEntry | PendingEventEntry;
|
||||
export type TileClassForEntryFn = (entry: TimelineEntry) => TileConstructor | undefined;
|
||||
export type Options = ViewModelOptions & {
|
||||
session: Session,
|
||||
room: Room,
|
||||
timeline: Timeline
|
||||
tileClassForEntry: TileClassForEntryFn;
|
||||
};
|
||||
export type TileConstructor = new (entry: TimelineEntry, options: Options) => SimpleTile;
|
||||
export type TileConstructor = new (entry: TimelineEntry, options: Options) => ITile;
|
||||
|
||||
export function tileClassForEntry(entry: TimelineEntry): TileConstructor | undefined {
|
||||
export function tileClassForEntry(entry: TimelineEntry, options: Options): TileConstructor | undefined {
|
||||
if (entry.isGap) {
|
||||
return GapTile;
|
||||
} else if (entry.isPending && entry.pendingEvent.isMissingAttachments) {
|
||||
@ -86,6 +89,14 @@ export function tileClassForEntry(entry: TimelineEntry): TileConstructor | undef
|
||||
return EncryptedEventTile;
|
||||
case "m.room.encryption":
|
||||
return EncryptionEnabledTile;
|
||||
case "org.matrix.msc3401.call": {
|
||||
// if prevContent is present, it's an update to a call event, which we don't render
|
||||
// as the original event is updated through the call object which receive state event updates
|
||||
if (options.features.calls && entry.stateKey && !entry.prevContent) {
|
||||
return CallTile;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
default:
|
||||
// unknown type not rendered
|
||||
return undefined;
|
||||
|
75
src/domain/session/settings/FeaturesViewModel.ts
Normal file
75
src/domain/session/settings/FeaturesViewModel.ts
Normal file
@ -0,0 +1,75 @@
|
||||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
import type {Options as BaseOptions} from "../../ViewModel";
|
||||
import {FeatureFlag, FeatureSet} from "../../../features";
|
||||
import type {SegmentType} from "../../navigation/index";
|
||||
|
||||
export class FeaturesViewModel extends ViewModel {
|
||||
public readonly featureViewModels: ReadonlyArray<FeatureViewModel>;
|
||||
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this.featureViewModels = [
|
||||
new FeatureViewModel(this.childOptions({
|
||||
name: this.i18n`Audio/video calls`,
|
||||
description: this.i18n`Allows starting and participating in A/V calls compatible with Element Call (MSC3401). Look for the start call option in the room menu ((...) in the right corner) to start a call.`,
|
||||
feature: FeatureFlag.Calls
|
||||
})),
|
||||
new FeatureViewModel(this.childOptions({
|
||||
name: this.i18n`Cross-Signing`,
|
||||
description: this.i18n`Allows verifying the identity of people you chat with. This feature is still evolving constantly, expect things to break.`,
|
||||
feature: FeatureFlag.CrossSigning
|
||||
})),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
type FeatureOptions = BaseOptions & {
|
||||
feature: FeatureFlag,
|
||||
description: string,
|
||||
name: string
|
||||
};
|
||||
|
||||
export class FeatureViewModel extends ViewModel<SegmentType, FeatureOptions> {
|
||||
get enabled(): boolean {
|
||||
return this.features.isFeatureEnabled(this.getOption("feature"));
|
||||
}
|
||||
|
||||
async enableFeature(enabled: boolean): Promise<void> {
|
||||
let newFeatures;
|
||||
if (enabled) {
|
||||
newFeatures = this.features.withFeature(this.getOption("feature"));
|
||||
} else {
|
||||
newFeatures = this.features.withoutFeature(this.getOption("feature"));
|
||||
}
|
||||
await newFeatures.store(this.platform.settingsStorage);
|
||||
this.platform.restart();
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return `${this.getOption("feature")}`;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.getOption("name");
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
return this.getOption("description");
|
||||
}
|
||||
}
|
@ -1,206 +0,0 @@
|
||||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
import {KeyType} from "../../../matrix/ssss/index";
|
||||
import {createEnum} from "../../../utils/enum";
|
||||
|
||||
export const Status = createEnum("Enabled", "SetupKey", "SetupPhrase", "Pending", "NewVersionAvailable");
|
||||
export const BackupWriteStatus = createEnum("Writing", "Stopped", "Done", "Pending");
|
||||
|
||||
export class KeyBackupViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._session = options.session;
|
||||
this._error = null;
|
||||
this._isBusy = false;
|
||||
this._dehydratedDeviceId = undefined;
|
||||
this._status = undefined;
|
||||
this._backupOperation = this._session.keyBackup.flatMap(keyBackup => keyBackup.operationInProgress);
|
||||
this._progress = this._backupOperation.flatMap(op => op.progress);
|
||||
this.track(this._backupOperation.subscribe(() => {
|
||||
// see if needsNewKey might be set
|
||||
this._reevaluateStatus();
|
||||
this.emitChange("isBackingUp");
|
||||
}));
|
||||
this.track(this._progress.subscribe(() => this.emitChange("backupPercentage")));
|
||||
this._reevaluateStatus();
|
||||
this.track(this._session.keyBackup.subscribe(() => {
|
||||
if (this._reevaluateStatus()) {
|
||||
this.emitChange("status");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
_reevaluateStatus() {
|
||||
if (this._isBusy) {
|
||||
return false;
|
||||
}
|
||||
let status;
|
||||
const keyBackup = this._session.keyBackup.get();
|
||||
if (keyBackup) {
|
||||
status = keyBackup.needsNewKey ? Status.NewVersionAvailable : Status.Enabled;
|
||||
} else if (keyBackup === null) {
|
||||
status = this.showPhraseSetup() ? Status.SetupPhrase : Status.SetupKey;
|
||||
} else {
|
||||
status = Status.Pending;
|
||||
}
|
||||
const changed = status !== this._status;
|
||||
this._status = status;
|
||||
return changed;
|
||||
}
|
||||
|
||||
get decryptAction() {
|
||||
return this.i18n`Set up`;
|
||||
}
|
||||
|
||||
get purpose() {
|
||||
return this.i18n`set up key backup`;
|
||||
}
|
||||
|
||||
offerDehydratedDeviceSetup() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get dehydratedDeviceId() {
|
||||
return this._dehydratedDeviceId;
|
||||
}
|
||||
|
||||
get isBusy() {
|
||||
return this._isBusy;
|
||||
}
|
||||
|
||||
get backupVersion() {
|
||||
return this._session.keyBackup.get()?.version;
|
||||
}
|
||||
|
||||
get backupWriteStatus() {
|
||||
const keyBackup = this._session.keyBackup.get();
|
||||
if (!keyBackup) {
|
||||
return BackupWriteStatus.Pending;
|
||||
} else if (keyBackup.hasStopped) {
|
||||
return BackupWriteStatus.Stopped;
|
||||
}
|
||||
const operation = keyBackup.operationInProgress.get();
|
||||
if (operation) {
|
||||
return BackupWriteStatus.Writing;
|
||||
} else if (keyBackup.hasBackedUpAllKeys) {
|
||||
return BackupWriteStatus.Done;
|
||||
} else {
|
||||
return BackupWriteStatus.Pending;
|
||||
}
|
||||
}
|
||||
|
||||
get backupError() {
|
||||
return this._session.keyBackup.get()?.error?.message;
|
||||
}
|
||||
|
||||
get status() {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
get error() {
|
||||
return this._error?.message;
|
||||
}
|
||||
|
||||
showPhraseSetup() {
|
||||
if (this._status === Status.SetupKey) {
|
||||
this._status = Status.SetupPhrase;
|
||||
this.emitChange("status");
|
||||
}
|
||||
}
|
||||
|
||||
showKeySetup() {
|
||||
if (this._status === Status.SetupPhrase) {
|
||||
this._status = Status.SetupKey;
|
||||
this.emitChange("status");
|
||||
}
|
||||
}
|
||||
|
||||
async _enterCredentials(keyType, credential, setupDehydratedDevice) {
|
||||
if (credential) {
|
||||
try {
|
||||
this._isBusy = true;
|
||||
this.emitChange("isBusy");
|
||||
const key = await this._session.enableSecretStorage(keyType, credential);
|
||||
if (setupDehydratedDevice) {
|
||||
this._dehydratedDeviceId = await this._session.setupDehydratedDevice(key);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this._error = err;
|
||||
this.emitChange("error");
|
||||
} finally {
|
||||
this._isBusy = false;
|
||||
this._reevaluateStatus();
|
||||
this.emitChange("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enterSecurityPhrase(passphrase, setupDehydratedDevice) {
|
||||
this._enterCredentials(KeyType.Passphrase, passphrase, setupDehydratedDevice);
|
||||
}
|
||||
|
||||
enterSecurityKey(securityKey, setupDehydratedDevice) {
|
||||
this._enterCredentials(KeyType.RecoveryKey, securityKey, setupDehydratedDevice);
|
||||
}
|
||||
|
||||
async disable() {
|
||||
try {
|
||||
this._isBusy = true;
|
||||
this.emitChange("isBusy");
|
||||
await this._session.disableSecretStorage();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this._error = err;
|
||||
this.emitChange("error");
|
||||
} finally {
|
||||
this._isBusy = false;
|
||||
this._reevaluateStatus();
|
||||
this.emitChange("");
|
||||
}
|
||||
}
|
||||
|
||||
get isBackingUp() {
|
||||
return !!this._backupOperation.get();
|
||||
}
|
||||
|
||||
get backupPercentage() {
|
||||
const progress = this._progress.get();
|
||||
if (progress) {
|
||||
return Math.round((progress.finished / progress.total) * 100);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
get backupInProgressLabel() {
|
||||
const progress = this._progress.get();
|
||||
if (progress) {
|
||||
return this.i18n`${progress.finished} of ${progress.total}`;
|
||||
}
|
||||
return this.i18n`…`;
|
||||
}
|
||||
|
||||
cancelBackup() {
|
||||
this._backupOperation.get()?.abort();
|
||||
}
|
||||
|
||||
startBackup() {
|
||||
this._session.keyBackup.get()?.flush();
|
||||
}
|
||||
}
|
||||
|
270
src/domain/session/settings/KeyBackupViewModel.ts
Normal file
270
src/domain/session/settings/KeyBackupViewModel.ts
Normal file
@ -0,0 +1,270 @@
|
||||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
import {SegmentType} from "../../navigation/index";
|
||||
import {KeyType} from "../../../matrix/ssss/index";
|
||||
|
||||
import type {Options as BaseOptions} from "../../ViewModel";
|
||||
import type {Session} from "../../../matrix/Session";
|
||||
import type {Disposable} from "../../../utils/Disposables";
|
||||
import type {KeyBackup, Progress} from "../../../matrix/e2ee/megolm/keybackup/KeyBackup";
|
||||
import type {CrossSigning} from "../../../matrix/verification/CrossSigning";
|
||||
|
||||
export enum Status {
|
||||
Enabled,
|
||||
Setup,
|
||||
Pending,
|
||||
NewVersionAvailable
|
||||
};
|
||||
|
||||
export enum BackupWriteStatus {
|
||||
Writing,
|
||||
Stopped,
|
||||
Done,
|
||||
Pending
|
||||
};
|
||||
|
||||
type Options = {
|
||||
session: Session,
|
||||
} & BaseOptions;
|
||||
|
||||
export class KeyBackupViewModel extends ViewModel<SegmentType, Options> {
|
||||
private _error?: Error = undefined;
|
||||
private _isBusy = false;
|
||||
private _dehydratedDeviceId?: string = undefined;
|
||||
private _status = Status.Pending;
|
||||
private _backupOperationSubscription?: Disposable = undefined;
|
||||
private _keyBackupSubscription?: Disposable = undefined;
|
||||
private _progress?: Progress = undefined;
|
||||
private _setupKeyType = KeyType.RecoveryKey;
|
||||
|
||||
constructor(options) {
|
||||
super(options);
|
||||
const onKeyBackupSet = (keyBackup: KeyBackup | undefined) => {
|
||||
if (keyBackup && !this._keyBackupSubscription) {
|
||||
this._keyBackupSubscription = this.track(this._session.keyBackup.get().disposableOn("change", () => {
|
||||
this._onKeyBackupChange();
|
||||
}));
|
||||
} else if (!keyBackup && this._keyBackupSubscription) {
|
||||
this._keyBackupSubscription = this.disposeTracked(this._keyBackupSubscription);
|
||||
}
|
||||
this._onKeyBackupChange(); // update status
|
||||
};
|
||||
this.track(this._session.keyBackup.subscribe(onKeyBackupSet));
|
||||
onKeyBackupSet(this._keyBackup);
|
||||
}
|
||||
|
||||
private get _session(): Session {
|
||||
return this.getOption("session");
|
||||
}
|
||||
|
||||
private get _keyBackup(): KeyBackup | undefined {
|
||||
return this._session.keyBackup.get();
|
||||
}
|
||||
|
||||
private get _crossSigning(): CrossSigning | undefined {
|
||||
return this._session.crossSigning.get();
|
||||
}
|
||||
|
||||
private _onKeyBackupChange() {
|
||||
const keyBackup = this._keyBackup;
|
||||
if (keyBackup) {
|
||||
const {operationInProgress} = keyBackup;
|
||||
if (operationInProgress && !this._backupOperationSubscription) {
|
||||
this._backupOperationSubscription = this.track(operationInProgress.disposableOn("change", () => {
|
||||
this._progress = operationInProgress.progress;
|
||||
this.emitChange("backupPercentage");
|
||||
}));
|
||||
} else if (this._backupOperationSubscription && !operationInProgress) {
|
||||
this._backupOperationSubscription = this.disposeTracked(this._backupOperationSubscription);
|
||||
this._progress = undefined;
|
||||
}
|
||||
}
|
||||
this.emitChange("status");
|
||||
}
|
||||
|
||||
get status(): Status {
|
||||
const keyBackup = this._keyBackup;
|
||||
if (keyBackup) {
|
||||
if (keyBackup.needsNewKey) {
|
||||
return Status.NewVersionAvailable;
|
||||
} else if (keyBackup.version === undefined) {
|
||||
return Status.Pending;
|
||||
} else {
|
||||
return keyBackup.needsNewKey ? Status.NewVersionAvailable : Status.Enabled;
|
||||
}
|
||||
} else {
|
||||
return Status.Setup;
|
||||
}
|
||||
}
|
||||
|
||||
get decryptAction(): string {
|
||||
return this.i18n`Set up`;
|
||||
}
|
||||
|
||||
get purpose(): string {
|
||||
return this.i18n`set up key backup`;
|
||||
}
|
||||
|
||||
offerDehydratedDeviceSetup(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
get dehydratedDeviceId(): string | undefined {
|
||||
return this._dehydratedDeviceId;
|
||||
}
|
||||
|
||||
get isBusy(): boolean {
|
||||
return this._isBusy;
|
||||
}
|
||||
|
||||
get backupVersion(): string {
|
||||
return this._keyBackup?.version ?? "";
|
||||
}
|
||||
|
||||
get isMasterKeyTrusted(): boolean {
|
||||
return this._crossSigning?.isMasterKeyTrusted ?? false;
|
||||
}
|
||||
|
||||
get canSignOwnDevice(): boolean {
|
||||
return !!this._crossSigning;
|
||||
}
|
||||
|
||||
async signOwnDevice(): Promise<void> {
|
||||
const crossSigning = this._crossSigning;
|
||||
if (crossSigning) {
|
||||
await this.logger.run("KeyBackupViewModel.signOwnDevice", async log => {
|
||||
await crossSigning.signOwnDevice(log);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get backupWriteStatus(): BackupWriteStatus {
|
||||
const keyBackup = this._keyBackup;
|
||||
if (!keyBackup || keyBackup.version === undefined) {
|
||||
return BackupWriteStatus.Pending;
|
||||
} else if (keyBackup.hasStopped) {
|
||||
return BackupWriteStatus.Stopped;
|
||||
}
|
||||
const operation = keyBackup.operationInProgress;
|
||||
if (operation) {
|
||||
return BackupWriteStatus.Writing;
|
||||
} else if (keyBackup.hasBackedUpAllKeys) {
|
||||
return BackupWriteStatus.Done;
|
||||
} else {
|
||||
return BackupWriteStatus.Pending;
|
||||
}
|
||||
}
|
||||
|
||||
get backupError(): string | undefined {
|
||||
return this._keyBackup?.error?.message;
|
||||
}
|
||||
|
||||
get error(): string | undefined {
|
||||
return this._error?.message;
|
||||
}
|
||||
|
||||
showPhraseSetup(): void {
|
||||
if (this._status === Status.Setup) {
|
||||
this._setupKeyType = KeyType.Passphrase;
|
||||
this.emitChange("setupKeyType");
|
||||
}
|
||||
}
|
||||
|
||||
showKeySetup(): void {
|
||||
if (this._status === Status.Setup) {
|
||||
this._setupKeyType = KeyType.Passphrase;
|
||||
this.emitChange("setupKeyType");
|
||||
}
|
||||
}
|
||||
|
||||
get setupKeyType(): KeyType {
|
||||
return this._setupKeyType;
|
||||
}
|
||||
|
||||
private async _enterCredentials(keyType, credential, setupDehydratedDevice): Promise<void> {
|
||||
if (credential) {
|
||||
try {
|
||||
this._isBusy = true;
|
||||
this.emitChange("isBusy");
|
||||
const key = await this._session.enableSecretStorage(keyType, credential);
|
||||
if (setupDehydratedDevice) {
|
||||
this._dehydratedDeviceId = await this._session.setupDehydratedDevice(key);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this._error = err;
|
||||
this.emitChange("error");
|
||||
} finally {
|
||||
this._isBusy = false;
|
||||
this.emitChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enterSecurityPhrase(passphrase, setupDehydratedDevice): Promise<void> {
|
||||
return this._enterCredentials(KeyType.Passphrase, passphrase, setupDehydratedDevice);
|
||||
}
|
||||
|
||||
enterSecurityKey(securityKey, setupDehydratedDevice): Promise<void> {
|
||||
return this._enterCredentials(KeyType.RecoveryKey, securityKey, setupDehydratedDevice);
|
||||
}
|
||||
|
||||
async disable(): Promise<void> {
|
||||
try {
|
||||
this._isBusy = true;
|
||||
this.emitChange("isBusy");
|
||||
await this._session.disableSecretStorage();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this._error = err;
|
||||
this.emitChange("error");
|
||||
} finally {
|
||||
this._isBusy = false;
|
||||
this.emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
get isBackingUp(): boolean {
|
||||
return this._keyBackup?.operationInProgress !== undefined;
|
||||
}
|
||||
|
||||
get backupPercentage(): number {
|
||||
if (this._progress) {
|
||||
return Math.round((this._progress.finished / this._progress.total) * 100);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
get backupInProgressLabel(): string {
|
||||
if (this._progress) {
|
||||
return this.i18n`${this._progress.finished} of ${this._progress.total}`;
|
||||
}
|
||||
return this.i18n`…`;
|
||||
}
|
||||
|
||||
cancelBackup(): void {
|
||||
this._keyBackup?.operationInProgress?.abort();
|
||||
}
|
||||
|
||||
startBackup(): void {
|
||||
this.logger.run("KeyBackupViewModel.startBackup", log => {
|
||||
this._keyBackup?.flush(log);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -15,8 +15,9 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
import {KeyBackupViewModel} from "./KeyBackupViewModel.js";
|
||||
import {submitLogsToRageshakeServer} from "../../../domain/rageshake";
|
||||
import {KeyBackupViewModel} from "./KeyBackupViewModel";
|
||||
import {FeaturesViewModel} from "./FeaturesViewModel";
|
||||
import {submitLogsFromSessionToDefaultServer} from "../../../domain/rageshake";
|
||||
|
||||
class PushNotificationStatus {
|
||||
constructor() {
|
||||
@ -53,6 +54,7 @@ export class SettingsViewModel extends ViewModel {
|
||||
this.pushNotifications = new PushNotificationStatus();
|
||||
this._activeTheme = undefined;
|
||||
this._logsFeedbackMessage = undefined;
|
||||
this._featuresViewModel = new FeaturesViewModel(this.childOptions());
|
||||
}
|
||||
|
||||
get _session() {
|
||||
@ -125,6 +127,10 @@ export class SettingsViewModel extends ViewModel {
|
||||
return this._keyBackupViewModel;
|
||||
}
|
||||
|
||||
get featuresViewModel() {
|
||||
return this._featuresViewModel;
|
||||
}
|
||||
|
||||
get storageQuota() {
|
||||
return this._formatBytes(this._estimate?.quota);
|
||||
}
|
||||
@ -150,8 +156,14 @@ export class SettingsViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
async exportLogs() {
|
||||
const logExport = await this.logger.export();
|
||||
this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`);
|
||||
const logs = await this.exportLogsBlob();
|
||||
this.platform.saveFileAs(logs, `hydrogen-logs-${this.platform.clock.now()}.json`);
|
||||
}
|
||||
|
||||
async exportLogsBlob() {
|
||||
const persister = this.logger.reporters.find(r => typeof r.export === "function");
|
||||
const logExport = await persister.export();
|
||||
return logExport.asBlob();
|
||||
}
|
||||
|
||||
get canSendLogsToServer() {
|
||||
@ -169,29 +181,13 @@ export class SettingsViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
async sendLogsToServer() {
|
||||
const {bugReportEndpointUrl} = this.platform.config;
|
||||
if (bugReportEndpointUrl) {
|
||||
this._logsFeedbackMessage = this.i18n`Sending logs…`;
|
||||
this._logsFeedbackMessage = this.i18n`Sending logs…`;
|
||||
try {
|
||||
await submitLogsFromSessionToDefaultServer(this._session, this.platform);
|
||||
this._logsFeedbackMessage = this.i18n`Logs sent succesfully!`;
|
||||
} catch (err) {
|
||||
this._logsFeedbackMessage = err.message;
|
||||
this.emitChange();
|
||||
try {
|
||||
const logExport = await this.logger.export();
|
||||
await submitLogsToRageshakeServer(
|
||||
{
|
||||
app: "hydrogen",
|
||||
userAgent: this.platform.description,
|
||||
version: DEFINE_VERSION,
|
||||
text: `Submit logs from settings for user ${this._session.userId} on device ${this._session.deviceId}`,
|
||||
},
|
||||
logExport.asBlob(),
|
||||
bugReportEndpointUrl,
|
||||
this.platform.request
|
||||
);
|
||||
this._logsFeedbackMessage = this.i18n`Logs sent succesfully!`;
|
||||
this.emitChange();
|
||||
} catch (err) {
|
||||
this._logsFeedbackMessage = err.message;
|
||||
this.emitChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
35
src/domain/session/toast/BaseToastNotificationViewModel.ts
Normal file
35
src/domain/session/toast/BaseToastNotificationViewModel.ts
Normal file
@ -0,0 +1,35 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ErrorReportViewModel} from "../../ErrorReportViewModel";
|
||||
import {Options as BaseOptions} from "../../ViewModel";
|
||||
import type {Session} from "../../../matrix/Session.js";
|
||||
import {SegmentType} from "../../navigation";
|
||||
|
||||
export type BaseClassOptions<N extends object = SegmentType> = {
|
||||
dismiss: () => void;
|
||||
session: Session;
|
||||
} & BaseOptions<N>;
|
||||
|
||||
export abstract class BaseToastNotificationViewModel<N extends object = SegmentType, O extends BaseClassOptions<N> = BaseClassOptions<N>> extends ErrorReportViewModel<N, O> {
|
||||
constructor(options: O) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
dismiss(): void {
|
||||
this.getOption("dismiss")();
|
||||
}
|
||||
}
|
92
src/domain/session/toast/CallToastNotificationViewModel.ts
Normal file
92
src/domain/session/toast/CallToastNotificationViewModel.ts
Normal file
@ -0,0 +1,92 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import type {GroupCall} from "../../../matrix/calls/group/GroupCall";
|
||||
import type {Room} from "../../../matrix/room/Room.js";
|
||||
import {IAvatarContract, avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
|
||||
import {LocalMedia} from "../../../matrix/calls/LocalMedia";
|
||||
import {BaseClassOptions, BaseToastNotificationViewModel} from "./BaseToastNotificationViewModel";
|
||||
import {SegmentType} from "../../navigation";
|
||||
|
||||
type Options<N extends MinimumNeededSegmentType = SegmentType> = {
|
||||
call: GroupCall;
|
||||
room: Room;
|
||||
} & BaseClassOptions<N>;
|
||||
|
||||
// Since we access the room segment below, the segment type
|
||||
// needs to at least contain the room segment!
|
||||
type MinimumNeededSegmentType = {
|
||||
"room": string;
|
||||
};
|
||||
|
||||
export class CallToastNotificationViewModel<N extends MinimumNeededSegmentType = SegmentType, O extends Options<N> = Options<N>> extends BaseToastNotificationViewModel<N, O> implements IAvatarContract {
|
||||
constructor(options: O) {
|
||||
super(options);
|
||||
this.track(this.call.members.observeSize().subscribe(() => {
|
||||
this.emitChange("memberCount");
|
||||
}));
|
||||
// Dismiss the toast if the room is opened manually
|
||||
this.track(
|
||||
this.navigation.observe("room").subscribe((roomId) => {
|
||||
if ((roomId as unknown as string) === this.call.roomId) {
|
||||
this.dismiss();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async join(): Promise<void> {
|
||||
await this.logAndCatch("CallToastNotificationViewModel.join", async (log) => {
|
||||
const stream = await this.platform.mediaDevices.getMediaTracks(false, true);
|
||||
const localMedia = new LocalMedia().withUserMedia(stream);
|
||||
await this.call.join(localMedia, log);
|
||||
const url = this.urlRouter.openRoomActionUrl(this.call.roomId);
|
||||
this.urlRouter.pushUrl(url);
|
||||
});
|
||||
}
|
||||
|
||||
get call(): GroupCall {
|
||||
return this.getOption("call");
|
||||
}
|
||||
|
||||
private get room(): Room {
|
||||
return this.getOption("room");
|
||||
}
|
||||
|
||||
get roomName(): string {
|
||||
return this.room.name;
|
||||
}
|
||||
|
||||
get memberCount(): number {
|
||||
return this.call.members.size;
|
||||
}
|
||||
|
||||
get avatarLetter(): string {
|
||||
return avatarInitials(this.roomName);
|
||||
}
|
||||
|
||||
get avatarColorNumber(): number {
|
||||
return getIdentifierColorNumber(this.room.avatarColorId);
|
||||
}
|
||||
|
||||
avatarUrl(size: number): string | undefined {
|
||||
return getAvatarHttpUrl(this.room.avatarUrl, size, this.platform, this.room.mediaRepository);
|
||||
}
|
||||
|
||||
get avatarTitle(): string {
|
||||
return this.roomName;
|
||||
}
|
||||
}
|
||||
|
||||
|
96
src/domain/session/toast/ToastCollectionViewModel.ts
Normal file
96
src/domain/session/toast/ToastCollectionViewModel.ts
Normal file
@ -0,0 +1,96 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {CallToastNotificationViewModel} from "./CallToastNotificationViewModel";
|
||||
import {ObservableArray} from "../../../observable";
|
||||
import {ViewModel, Options as BaseOptions} from "../../ViewModel";
|
||||
import type {GroupCall} from "../../../matrix/calls/group/GroupCall";
|
||||
import type {Room} from "../../../matrix/room/Room.js";
|
||||
import type {Session} from "../../../matrix/Session.js";
|
||||
import type {SegmentType} from "../../navigation";
|
||||
import { RoomStatus } from "../../../lib";
|
||||
|
||||
type Options = {
|
||||
session: Session;
|
||||
} & BaseOptions;
|
||||
|
||||
export class ToastCollectionViewModel extends ViewModel<SegmentType, Options> {
|
||||
public readonly toastViewModels: ObservableArray<CallToastNotificationViewModel> = new ObservableArray();
|
||||
|
||||
constructor(options: Options) {
|
||||
super(options);
|
||||
const session = this.getOption("session");
|
||||
if (this.features.calls) {
|
||||
const callsObservableMap = session.callHandler.calls;
|
||||
this.track(callsObservableMap.subscribe(this));
|
||||
}
|
||||
}
|
||||
|
||||
async onAdd(_, call: GroupCall) {
|
||||
if (this._shouldShowNotification(call)) {
|
||||
const room = await this._findRoomForCall(call);
|
||||
const dismiss = () => {
|
||||
const idx = this.toastViewModels.array.findIndex(vm => vm.call === call);
|
||||
if (idx !== -1) {
|
||||
this.toastViewModels.remove(idx);
|
||||
}
|
||||
};
|
||||
this.toastViewModels.append(
|
||||
new CallToastNotificationViewModel(this.childOptions({ call, room, dismiss }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onRemove(_, call: GroupCall) {
|
||||
const idx = this.toastViewModels.array.findIndex(vm => vm.call === call);
|
||||
if (idx !== -1) {
|
||||
this.toastViewModels.remove(idx);
|
||||
}
|
||||
}
|
||||
|
||||
onUpdate(_, call: GroupCall) {
|
||||
const idx = this.toastViewModels.array.findIndex(vm => vm.call === call);
|
||||
if (idx !== -1) {
|
||||
this.toastViewModels.update(idx, this.toastViewModels.at(idx)!);
|
||||
}
|
||||
}
|
||||
|
||||
onReset() {
|
||||
for (let i = 0; i < this.toastViewModels.length; ++i) {
|
||||
this.toastViewModels.remove(i);
|
||||
}
|
||||
}
|
||||
|
||||
private async _findRoomForCall(call: GroupCall): Promise<Room> {
|
||||
const id = call.roomId;
|
||||
const session = this.getOption("session");
|
||||
const rooms = session.rooms;
|
||||
// Make sure that we know of this room,
|
||||
// otherwise wait for it to come through sync
|
||||
const observable = await session.observeRoomStatus(id);
|
||||
await observable.waitFor(s => s === RoomStatus.Joined).promise;
|
||||
const room = rooms.get(id);
|
||||
return room;
|
||||
}
|
||||
|
||||
private _shouldShowNotification(call: GroupCall): boolean {
|
||||
const currentlyOpenedRoomId = this.navigation.path.get("room")?.value;
|
||||
if (!call.isLoadedFromStorage && call.roomId !== currentlyOpenedRoomId && !call.usesFoci) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
55
src/features.ts
Normal file
55
src/features.ts
Normal file
@ -0,0 +1,55 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type {SettingsStorage} from "./platform/web/dom/SettingsStorage";
|
||||
|
||||
export enum FeatureFlag {
|
||||
Calls = 1 << 0,
|
||||
CrossSigning = 1 << 1
|
||||
}
|
||||
|
||||
export class FeatureSet {
|
||||
constructor(public readonly flags: number = 0) {}
|
||||
|
||||
withFeature(flag: FeatureFlag): FeatureSet {
|
||||
return new FeatureSet(this.flags | flag);
|
||||
}
|
||||
|
||||
withoutFeature(flag: FeatureFlag): FeatureSet {
|
||||
return new FeatureSet(this.flags ^ flag);
|
||||
}
|
||||
|
||||
isFeatureEnabled(flag: FeatureFlag): boolean {
|
||||
return (this.flags & flag) !== 0;
|
||||
}
|
||||
|
||||
get calls(): boolean {
|
||||
return this.isFeatureEnabled(FeatureFlag.Calls);
|
||||
}
|
||||
|
||||
get crossSigning(): boolean {
|
||||
return this.isFeatureEnabled(FeatureFlag.CrossSigning);
|
||||
}
|
||||
|
||||
static async load(settingsStorage: SettingsStorage): Promise<FeatureSet> {
|
||||
const flags = await settingsStorage.getInt("enabled_features") || 0;
|
||||
return new FeatureSet(flags);
|
||||
}
|
||||
|
||||
async store(settingsStorage: SettingsStorage): Promise<void> {
|
||||
await settingsStorage.setInt("enabled_features", this.flags);
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user