Merge branch 'master' into madlittlemods/copy-permalink

This commit is contained in:
Eric Eastwood 2023-03-27 15:30:17 -05:00
commit 371916e68a
245 changed files with 11007 additions and 3429 deletions

View File

@ -17,6 +17,7 @@ module.exports = {
"globals": { "globals": {
"DEFINE_VERSION": "readonly", "DEFINE_VERSION": "readonly",
"DEFINE_GLOBAL_HASH": "readonly", "DEFINE_GLOBAL_HASH": "readonly",
"DEFINE_PROJECT_DIR": "readonly",
// only available in sw.js // only available in sw.js
"DEFINE_UNHASHED_PRECACHED_ASSETS": "readonly", "DEFINE_UNHASHED_PRECACHED_ASSETS": "readonly",
"DEFINE_HASHED_PRECACHED_ASSETS": "readonly", "DEFINE_HASHED_PRECACHED_ASSETS": "readonly",

7
.github/CODEOWNERS vendored Normal file
View 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

View File

@ -21,10 +21,13 @@ jobs:
steps: steps:
- name: Checkout repository - 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 }} - name: Log into registry ${{ env.REGISTRY }}
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@ -32,13 +35,14 @@ jobs:
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@v3 uses: docker/metadata-action@v4
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v2 uses: docker/build-push-action@v3
with: with:
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64,linux/arm/v7

View File

@ -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 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 COPY --from=builder /app/target /usr/share/nginx/html

View File

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

View File

@ -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. 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? ## 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 ;) 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? ## 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).

View File

@ -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). 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 # FAQ
Some frequently asked questions are answered [here](doc/FAQ.md). Some frequently asked questions are answered [here](FAQ.md).

View File

@ -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

View File

@ -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 */
```

View File

@ -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

View File

@ -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)

View File

@ -80,6 +80,7 @@ Currently supported operations are:
| -------- | -------- | -------- | | -------- | -------- | -------- |
| darker | percentage | color | | darker | percentage | color |
| lighter | percentage | color | | lighter | percentage | color |
| alpha | alpha percentage | color |
## Aliases ## Aliases
It is possible give aliases to variables in the `theme.css` file: It is possible give aliases to variables in the `theme.css` file:

View File

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View 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.

View File

@ -35,15 +35,17 @@ To stop the container, simply hit `ctrl+c`.
In this repository, create a Docker image: 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 . 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 pull ghcr.io/vector-im/hydrogen-web
docker tag registry.gitlab.com/jcgruenhage/hydrogen-web hydrogen docker tag ghcr.io/vector-im/hydrogen-web hydrogen
``` ```
### Start container image ### Start container image
@ -53,6 +55,32 @@ Then, start up a container from that image:
``` ```
docker run \ docker run \
--name hydrogen \ --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 hydrogen
``` ```

15
doc/error-handling.md Normal file
View 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.

View File

@ -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. 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 ### 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 - 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

View File

@ -237,7 +237,7 @@ room.sendEvent(eventEntry.eventType, replacement);
## Replies ## Replies
```js ```js
const reply = eventEntry.reply({}); const reply = eventEntry.createReplyContent({});
room.sendEvent("m.room.message", reply); room.sendEvent("m.room.message", reply);
``` ```

View 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>) {
}
}

View File

@ -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
View 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

View File

@ -1,6 +1,6 @@
{ {
"name": "hydrogen-web", "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", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB",
"directories": { "directories": {
"doc": "doc" "doc": "doc"
@ -32,6 +32,7 @@
}, },
"homepage": "https://github.com/vector-im/hydrogen-web/#readme", "homepage": "https://github.com/vector-im/hydrogen-web/#readme",
"devDependencies": { "devDependencies": {
"@matrixdotorg/structured-logviewer": "^0.0.3",
"@playwright/test": "^1.27.1", "@playwright/test": "^1.27.1",
"@typescript-eslint/eslint-plugin": "^4.29.2", "@typescript-eslint/eslint-plugin": "^4.29.2",
"@typescript-eslint/parser": "^4.29.2", "@typescript-eslint/parser": "^4.29.2",

View File

@ -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;
}

View File

@ -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);
}
}
}

View File

@ -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>

View File

@ -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");
}
});

View File

@ -24,7 +24,13 @@ const idToPrepend = "icon-url";
function findAndReplaceUrl(decl, urlVariables, counter) { function findAndReplaceUrl(decl, urlVariables, counter) {
const value = decl.value; 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 => { parsed.walk(node => {
if (node.type !== "function" || node.value !== "url") { if (node.type !== "function" || node.value !== "url") {
return; return;

View File

@ -16,7 +16,7 @@ limitations under the License.
import {ViewModel} from "./ViewModel"; import {ViewModel} from "./ViewModel";
import {KeyType} from "../matrix/ssss/index"; import {KeyType} from "../matrix/ssss/index";
import {Status} from "./session/settings/KeyBackupViewModel.js"; import {Status} from "./session/settings/KeyBackupViewModel";
export class AccountSetupViewModel extends ViewModel { export class AccountSetupViewModel extends ViewModel {
constructor(options) { constructor(options) {

View 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;
}

View 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;
}
}
}

View 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;
}
}
}

View File

@ -158,7 +158,7 @@ export class RootViewModel extends ViewModel {
} }
_showSessionLoader(sessionId) { _showSessionLoader(sessionId) {
const client = new Client(this.platform); const client = new Client(this.platform, this.features);
client.startWithExistingSession(sessionId); client.startWithExistingSession(sessionId);
this._setSection(() => { this._setSection(() => {
this._sessionLoadViewModel = new SessionLoadViewModel(this.childOptions({ this._sessionLoadViewModel = new SessionLoadViewModel(this.childOptions({

View File

@ -78,7 +78,7 @@ export class SessionLoadViewModel extends ViewModel {
this._ready(client); this._ready(client);
} }
if (loadError) { if (loadError) {
console.error("session load error", loadError); console.error("session load error", loadError.stack);
} }
} catch (err) { } catch (err) {
this._error = err; this._error = err;

View File

@ -29,6 +29,8 @@ import type {ILogger} from "../logging/types";
import type {Navigation} from "./navigation/Navigation"; import type {Navigation} from "./navigation/Navigation";
import type {SegmentType} from "./navigation/index"; import type {SegmentType} from "./navigation/index";
import type {IURLRouter} from "./navigation/URLRouter"; 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> = { export type Options<T extends object = SegmentType> = {
platform: Platform; platform: Platform;
@ -36,6 +38,7 @@ export type Options<T extends object = SegmentType> = {
urlRouter: IURLRouter<T>; urlRouter: IURLRouter<T>;
navigation: Navigation<T>; navigation: Navigation<T>;
emitChange?: (params: any) => void; 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; 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); return Object.assign({}, this._options, explicitOptions);
} }
@ -117,7 +120,7 @@ export class ViewModel<N extends object = SegmentType, O extends Options<N> = Op
return result; return result;
} }
emitChange(changedProps: any): void { emitChange(changedProps?: any): void {
if (this._options.emitChange) { if (this._options.emitChange) {
this._options.emitChange(changedProps); this._options.emitChange(changedProps);
} else { } else {
@ -141,8 +144,16 @@ export class ViewModel<N extends object = SegmentType, O extends Options<N> = Op
return this._options.urlRouter; return this._options.urlRouter;
} }
get features(): FeatureSet {
return this._options.features;
}
get navigation(): Navigation<N> { get navigation(): Navigation<N> {
// typescript needs a little help here // typescript needs a little help here
return this._options.navigation as unknown as Navigation<N>; return this._options.navigation as unknown as Navigation<N>;
} }
get timeFormatter(): ITimeFormatter {
return this._options.platform.timeFormatter;
}
} }

View File

@ -51,10 +51,18 @@ export function getIdentifierColorNumber(id: string): number {
return (hashCode(id) % 8) + 1; return (hashCode(id) % 8) + 1;
} }
export function getAvatarHttpUrl(avatarUrl: string, cssSize: number, platform: Platform, mediaRepository: MediaRepository): string | null { export function getAvatarHttpUrl(avatarUrl: string | undefined, cssSize: number, platform: Platform, mediaRepository: MediaRepository): string | undefined {
if (avatarUrl) { if (avatarUrl) {
const imageSize = cssSize * platform.devicePixelRatio; const imageSize = cssSize * platform.devicePixelRatio;
return mediaRepository.mxcUrlThumbnail(avatarUrl, imageSize, imageSize, "crop"); return mediaRepository.mxcUrlThumbnail(avatarUrl, imageSize, imageSize, "crop");
} }
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;
} }

View File

@ -55,7 +55,7 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
const {ready, defaultHomeserver, loginToken} = options; const {ready, defaultHomeserver, loginToken} = options;
this._ready = ready; this._ready = ready;
this._loginToken = loginToken; this._loginToken = loginToken;
this._client = new Client(this.platform); this._client = new Client(this.platform, this.features);
this._homeserver = defaultHomeserver; this._homeserver = defaultHomeserver;
this._initViewModels(); this._initViewModels();
} }

View File

@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. 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; type AllowsChild<T> = (parent: Segment<T> | undefined, child: Segment<T>) => boolean;

View File

@ -144,7 +144,7 @@ export class URLRouter<T extends {session: string | boolean}> implements IURLRou
openRoomActionUrl(roomId: string): string { openRoomActionUrl(roomId: string): string {
// not a segment to navigation knowns about, so append it manually // not a segment to navigation knowns about, so append it manually
const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`; const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${encodeURIComponent(roomId)}`;
return this._history.pathAsUrl(urlPath); return this._history.pathAsUrl(urlPath);
} }

View File

@ -137,7 +137,7 @@ export function parseUrlPath(urlPath: string, currentNavPath: Path<SegmentType>,
if (type === "rooms") { if (type === "rooms") {
const roomsValue = iterator.next().value; const roomsValue = iterator.next().value;
if (roomsValue === undefined) { break; } if (roomsValue === undefined) { break; }
const roomIds = roomsValue.split(","); const roomIds = roomsValue.split(",").map(id => decodeURIComponent(id));
segments.push(new Segment(type, roomIds)); segments.push(new Segment(type, roomIds));
const selectedIndex = parseInt(iterator.next().value || "0", 10); const selectedIndex = parseInt(iterator.next().value || "0", 10);
const roomId = roomIds[selectedIndex]; const roomId = roomIds[selectedIndex];
@ -147,8 +147,9 @@ export function parseUrlPath(urlPath: string, currentNavPath: Path<SegmentType>,
segments.push(new Segment("empty-grid-tile", selectedIndex)); segments.push(new Segment("empty-grid-tile", selectedIndex));
} }
} else if (type === "open-room") { } else if (type === "open-room") {
const roomId = iterator.next().value; let roomId = iterator.next().value;
if (!roomId) { break; } if (!roomId) { break; }
roomId = decodeURIComponent(roomId);
const rooms = currentNavPath.get("rooms"); const rooms = currentNavPath.get("rooms");
if (rooms) { if (rooms) {
segments.push(roomsSegmentWithRoom(rooms, roomId, currentNavPath)); segments.push(roomsSegmentWithRoom(rooms, roomId, currentNavPath));
@ -176,8 +177,9 @@ export function parseUrlPath(urlPath: string, currentNavPath: Path<SegmentType>,
} else if (type === "details" || type === "members") { } else if (type === "details" || type === "members") {
pushRightPanelSegment(segments, type); pushRightPanelSegment(segments, type);
} else if (type === "member") { } else if (type === "member") {
const userId = iterator.next().value; let userId = iterator.next().value;
if (!userId) { break; } if (!userId) { break; }
userId = decodeURIComponent(userId);
pushRightPanelSegment(segments, type, userId); pushRightPanelSegment(segments, type, userId);
} else if (type.includes("loginToken")) { } else if (type.includes("loginToken")) {
// Special case for SSO-login with query parameter loginToken=<token> // 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)); segments.push(new Segment("sso", loginToken));
} else { } else {
// might be undefined, which will be turned into true by Segment // 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)); segments.push(new Segment(type, value));
} }
} }
@ -196,19 +202,20 @@ export function stringifyPath(path: Path<SegmentType>): string {
let urlPath = ""; let urlPath = "";
let prevSegment: Segment<SegmentType> | undefined; let prevSegment: Segment<SegmentType> | undefined;
for (const segment of path.segments) { for (const segment of path.segments) {
const encodedSegmentValue = encodeSegmentValue(segment.value);
switch (segment.type) { switch (segment.type) {
case "rooms": case "rooms":
urlPath += `/rooms/${segment.value.join(",")}`; urlPath += `/rooms/${encodedSegmentValue}`;
break; break;
case "empty-grid-tile": case "empty-grid-tile":
urlPath += `/${segment.value}`; urlPath += `/${encodedSegmentValue}`;
break; break;
case "room": case "room":
if (prevSegment?.type === "rooms") { if (prevSegment?.type === "rooms") {
const index = prevSegment.value.indexOf(segment.value); const index = prevSegment.value.indexOf(segment.value);
urlPath += `/${index}`; urlPath += `/${index}`;
} else { } else {
urlPath += `/${segment.type}/${segment.value}`; urlPath += `/${segment.type}/${encodedSegmentValue}`;
} }
break; break;
case "right-panel": case "right-panel":
@ -217,8 +224,8 @@ export function stringifyPath(path: Path<SegmentType>): string {
continue; continue;
default: default:
urlPath += `/${segment.type}`; urlPath += `/${segment.type}`;
if (segment.value && segment.value !== true) { if (encodedSegmentValue) {
urlPath += `/${segment.value}`; urlPath += `/${encodedSegmentValue}`;
} }
} }
prevSegment = segment; prevSegment = segment;
@ -226,6 +233,19 @@ export function stringifyPath(path: Path<SegmentType>): string {
return urlPath; 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() { export function tests() {
function createEmptyPath() { function createEmptyPath() {
const nav: Navigation<SegmentType> = new Navigation(allowsChild); const nav: Navigation<SegmentType> = new Navigation(allowsChild);

View File

@ -16,11 +16,15 @@ limitations under the License.
import type {BlobHandle} from "../platform/web/dom/BlobHandle"; import type {BlobHandle} from "../platform/web/dom/BlobHandle";
import type {RequestFunction} from "../platform/types/types"; 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 // see https://github.com/matrix-org/rageshake#readme
type RageshakeData = { type RageshakeData = {
// A textual description of the problem. Included in the details.log.gz file. // 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. // Application user-agent. Included in the details.log.gz file.
userAgent: string; 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. // 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. // Application version. Included in the details.log.gz file.
version: string; version: string;
// Label to attach to the github issue, and include in the details file. // 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> { 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 // 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. // 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
);
}

View File

@ -186,7 +186,7 @@ export class RoomGridViewModel extends ViewModel {
} }
import {createNavigation} from "../navigation/index"; import {createNavigation} from "../navigation/index";
import {ObservableValue} from "../../observable/ObservableValue"; import {ObservableValue} from "../../observable/value";
export function tests() { export function tests() {
class RoomVMMock { class RoomVMMock {

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ObservableValue} from "../../observable/ObservableValue"; import {ObservableValue} from "../../observable/value";
import {RoomStatus} from "../../matrix/room/common"; import {RoomStatus} from "../../matrix/room/common";
/** /**

View File

@ -30,6 +30,7 @@ import {ViewModel} from "../ViewModel";
import {RoomViewModelObservable} from "./RoomViewModelObservable.js"; import {RoomViewModelObservable} from "./RoomViewModelObservable.js";
import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js"; import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js";
import {SyncStatus} from "../../matrix/Sync.js"; import {SyncStatus} from "../../matrix/Sync.js";
import {ToastCollectionViewModel} from "./toast/ToastCollectionViewModel";
export class SessionViewModel extends ViewModel { export class SessionViewModel extends ViewModel {
constructor(options) { constructor(options) {
@ -47,6 +48,9 @@ export class SessionViewModel extends ViewModel {
this._gridViewModel = null; this._gridViewModel = null;
this._createRoomViewModel = null; this._createRoomViewModel = null;
this._joinRoomViewModel = null; this._joinRoomViewModel = null;
this._toastCollectionViewModel = this.track(new ToastCollectionViewModel(this.childOptions({
session: this._client.session,
})));
this._setupNavigation(); this._setupNavigation();
this._setupForcedLogoutOnAccessTokenInvalidation(); this._setupForcedLogoutOnAccessTokenInvalidation();
} }
@ -126,6 +130,11 @@ export class SessionViewModel extends ViewModel {
start() { start() {
this._sessionStatusViewModel.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() { get activeMiddleViewModel() {
@ -170,6 +179,10 @@ export class SessionViewModel extends ViewModel {
return this._joinRoomViewModel; return this._joinRoomViewModel;
} }
get toastCollectionViewModel() {
return this._toastCollectionViewModel;
}
_updateGrid(roomIds) { _updateGrid(roomIds) {
const changed = !(this._gridViewModel && roomIds); const changed = !(this._gridViewModel && roomIds);
const currentRoomId = this.navigation.path.get("room"); const currentRoomId = this.navigation.path.get("room");
@ -211,7 +224,7 @@ export class SessionViewModel extends ViewModel {
_createRoomViewModelInstance(roomId) { _createRoomViewModelInstance(roomId) {
const room = this._client.session.rooms.get(roomId); const room = this._client.session.rooms.get(roomId);
if (room) { if (room) {
const roomVM = new RoomViewModel(this.childOptions({room})); const roomVM = new RoomViewModel(this.childOptions({room, session: this._client.session}));
roomVM.load(); roomVM.load();
return roomVM; return roomVM;
} }
@ -228,7 +241,7 @@ export class SessionViewModel extends ViewModel {
async _createArchivedRoomViewModel(roomId) { async _createArchivedRoomViewModel(roomId) {
const room = await this._client.session.loadArchivedRoom(roomId); const room = await this._client.session.loadArchivedRoom(roomId);
if (room) { if (room) {
const roomVM = new RoomViewModel(this.childOptions({room})); const roomVM = new RoomViewModel(this.childOptions({room, session: this._client.session}));
roomVM.load(); roomVM.load();
return roomVM; return roomVM;
} }

View File

@ -17,6 +17,7 @@ limitations under the License.
import {ViewModel} from "../../ViewModel"; import {ViewModel} from "../../ViewModel";
import {RoomType} from "../../../matrix/room/common"; import {RoomType} from "../../../matrix/room/common";
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
import {UserTrust} from "../../../matrix/verification/CrossSigning";
export class MemberDetailsViewModel extends ViewModel { export class MemberDetailsViewModel extends ViewModel {
constructor(options) { constructor(options) {
@ -29,13 +30,60 @@ export class MemberDetailsViewModel extends ViewModel {
this._session = options.session; this._session = options.session;
this.track(this._powerLevelsObservable.subscribe(() => this._onPowerLevelsChange())); this.track(this._powerLevelsObservable.subscribe(() => this._onPowerLevelsChange()));
this.track(this._observableMember.subscribe( () => this._onMemberChange())); this.track(this._observableMember.subscribe( () => this._onMemberChange()));
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 name() { return this._member.name; }
get userId() { return this._member.userId; } 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 type() { return "member-details"; }
get shouldShowBackButton() { return true; } get shouldShowBackButton() { return true; }
get previousSegmentName() { return "members"; } get previousSegmentName() { return "members"; }
get role() { get role() {
@ -54,6 +102,14 @@ export class MemberDetailsViewModel extends ViewModel {
this.emitChange("role"); 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() { get avatarLetter() {
return avatarInitials(this.name); return avatarInitials(this.name);
} }

View File

@ -48,7 +48,7 @@ export class MemberTileViewModel extends ViewModel {
get detailsUrl() { get detailsUrl() {
const roomId = this.navigation.path.get("room").value; 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) { _updatePreviousName(newName) {

View 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;
}

View File

@ -17,15 +17,19 @@ limitations under the License.
import {TimelineViewModel} from "./timeline/TimelineViewModel.js"; import {TimelineViewModel} from "./timeline/TimelineViewModel.js";
import {ComposerViewModel} from "./ComposerViewModel.js" import {ComposerViewModel} from "./ComposerViewModel.js"
import {CallViewModel} from "./CallViewModel"
import {PickMapObservableValue} from "../../../observable/value";
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
import {ErrorReportViewModel} from "../../ErrorReportViewModel";
import {ViewModel} from "../../ViewModel"; import {ViewModel} from "../../ViewModel";
import {imageToInfo} from "../common.js"; 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 // 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 // this is a breaking SDK change though to make this option mandatory
import {tileClassForEntry as defaultTileClassForEntry} from "./timeline/tiles/index"; import {tileClassForEntry as defaultTileClassForEntry} from "./timeline/tiles/index";
import {joinRoom} from "../../../matrix/room/joinRoom"; import {joinRoom} from "../../../matrix/room/joinRoom";
export class RoomViewModel extends ViewModel { export class RoomViewModel extends ErrorReportViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
const {room, tileClassForEntry} = options; const {room, tileClassForEntry} = options;
@ -34,8 +38,6 @@ export class RoomViewModel extends ViewModel {
this._tileClassForEntry = tileClassForEntry ?? defaultTileClassForEntry; this._tileClassForEntry = tileClassForEntry ?? defaultTileClassForEntry;
this._tileOptions = undefined; this._tileOptions = undefined;
this._onRoomChange = this._onRoomChange.bind(this); this._onRoomChange = this._onRoomChange.bind(this);
this._timelineError = null;
this._sendError = null;
this._composerVM = null; this._composerVM = null;
if (room.isArchived) { if (room.isArchived) {
this._composerVM = this.track(new ArchivedViewModel(this.childOptions({archivedRoom: room}))); this._composerVM = this.track(new ArchivedViewModel(this.childOptions({archivedRoom: room})));
@ -44,13 +46,42 @@ export class RoomViewModel extends ViewModel {
} }
this._clearUnreadTimout = null; this._clearUnreadTimout = null;
this._closeUrl = this.urlRouter.urlUntilSegment("session"); 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() { async load() {
this._room.on("change", this._onRoomChange); this.logAndCatch("RoomViewModel.load", async log => {
try { this._room.on("change", this._onRoomChange);
const timeline = await this._room.openTimeline(); const timeline = await this._room.openTimeline(log);
this._tileOptions = this.childOptions({ this._tileOptions = this.childOptions({
session: this.getOption("session"),
roomVM: this, roomVM: this,
timeline, timeline,
tileClassForEntry: this._tileClassForEntry, tileClassForEntry: this._tileClassForEntry,
@ -60,12 +91,8 @@ export class RoomViewModel extends ViewModel {
timeline, timeline,
}))); })));
this.emitChange("timelineViewModel"); this.emitChange("timelineViewModel");
} catch (err) { await this._clearUnreadAfterDelay(log);
console.error(`room.openTimeline(): ${err.message}:\n${err.stack}`); });
this._timelineError = err;
this.emitChange("error");
}
this._clearUnreadAfterDelay();
} }
async _recreateComposerOnPowerLevelChange() { async _recreateComposerOnPowerLevelChange() {
@ -92,24 +119,28 @@ export class RoomViewModel extends ViewModel {
recreateComposer(oldCanSendMessage); recreateComposer(oldCanSendMessage);
} }
async _clearUnreadAfterDelay() { async _clearUnreadAfterDelay(log) {
if (this._room.isArchived || this._clearUnreadTimout) { if (this._room.isArchived || this._clearUnreadTimout) {
return; return;
} }
this._clearUnreadTimout = this.clock.createTimeout(2000); this._clearUnreadTimout = this.clock.createTimeout(2000);
try { try {
await this._clearUnreadTimout.elapsed(); await this._clearUnreadTimout.elapsed();
await this._room.clearUnread(); await this._room.clearUnread(log);
this._clearUnreadTimout = null; this._clearUnreadTimout = null;
} catch (err) { } catch (err) {
if (err.name !== "AbortError") { if (err.name === "AbortError") {
log.set("clearUnreadCancelled", true);
} else {
throw err; throw err;
} }
} }
} }
focus() { focus() {
this._clearUnreadAfterDelay(); this.logAndCatch("RoomViewModel.focus", async log => {
this._clearUnreadAfterDelay(log);
});
} }
dispose() { dispose() {
@ -139,16 +170,6 @@ export class RoomViewModel extends ViewModel {
get timelineViewModel() { return this._timelineVM; } get timelineViewModel() { return this._timelineVM; }
get isEncrypted() { return this._room.isEncrypted; } 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() { get avatarLetter() {
return avatarInitials(this.name); return avatarInitials(this.name);
} }
@ -191,26 +212,51 @@ export class RoomViewModel extends ViewModel {
_createTile(entry) { _createTile(entry) {
if (this._tileOptions) { if (this._tileOptions) {
const Tile = this._tileOptions.tileClassForEntry(entry); const Tile = this._tileOptions.tileClassForEntry(entry, this._tileOptions);
if (Tile) { if (Tile) {
return new Tile(entry, this._tileOptions); 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) { async _processCommandJoin(roomName) {
try { try {
const session = this._options.client.session; const session = this._options.client.session;
const roomId = await joinRoom(roomName, session); const roomId = await joinRoom(roomName, session);
this.navigation.push("room", roomId); this.navigation.push("room", roomId);
} catch (err) { } catch (err) {
this._sendError = err; this.reportError(err);
this._timelineError = null;
this.emitChange("error");
} }
} }
async _processCommand (message) { async _processCommand(message) {
let msgtype; let msgtype;
const [commandName, ...args] = message.substring(1).split(" "); const [commandName, ...args] = message.substring(1).split(" ");
switch (commandName) { switch (commandName) {
@ -223,9 +269,7 @@ export class RoomViewModel extends ViewModel {
const roomName = args[0]; const roomName = args[0];
await this._processCommandJoin(roomName); await this._processCommandJoin(roomName);
} else { } else {
this._sendError = new Error("join syntax: /join <room-id>"); this.reportError(new Error("join syntax: /join <room-id>"));
this._timelineError = null;
this.emitChange("error");
} }
break; break;
case "shrug": case "shrug":
@ -245,78 +289,44 @@ export class RoomViewModel extends ViewModel {
msgtype = "m.text"; msgtype = "m.text";
break; break;
default: default:
this._sendError = new Error(`no command name "${commandName}". To send the message instead of executing, please type "/${message}"`); this.reportError(new Error(`no command name "${commandName}". To send the message instead of executing, please type "/${message}"`));
this._timelineError = null;
this.emitChange("error");
message = undefined; 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() { _pickAndSendFile() {
try { return this.logAndCatch("RoomViewModel.sendFile", async log => {
const file = await this.platform.openFile(); const file = await this.platform.openFile();
if (!file) { if (!file) {
log.set("cancelled", true);
return; return;
} }
return this._sendFile(file); return this._sendFile(file, log);
} catch (err) { });
console.error(err);
}
} }
async _sendFile(file) { async _sendFile(file, log) {
const content = { const content = {
body: file.name, body: file.name,
msgtype: "m.file" msgtype: "m.file"
}; };
await this._room.sendEvent("m.room.message", content, { await this._room.sendEvent("m.room.message", content, {
"url": this._room.createAttachment(file.blob, file.name) "url": this._room.createAttachment(file.blob, file.name)
}); }, log);
} }
async _pickAndSendVideo() { _pickAndSendVideo() {
try { return this.logAndCatch("RoomViewModel.sendVideo", async log => {
if (!this.platform.hasReadPixelPermission()) { if (!this.platform.hasReadPixelPermission()) {
alert("Please allow canvas image data access, so we can scale your images down."); throw new Error("Please allow canvas image data access, so we can scale your images down.");
return;
} }
const file = await this.platform.openFile("video/*"); const file = await this.platform.openFile("video/*");
if (!file) { if (!file) {
return; return;
} }
if (!file.blob.mimeType.startsWith("video/")) { if (!file.blob.mimeType.startsWith("video/")) {
return this._sendFile(file); return this._sendFile(file, log);
} }
let video; let video;
try { try {
@ -344,26 +354,23 @@ export class RoomViewModel extends ViewModel {
content.info.thumbnail_info = imageToInfo(thumbnail); content.info.thumbnail_info = imageToInfo(thumbnail);
attachments["info.thumbnail_url"] = attachments["info.thumbnail_url"] =
this._room.createAttachment(thumbnail.blob, file.name); this._room.createAttachment(thumbnail.blob, file.name);
await this._room.sendEvent("m.room.message", content, attachments); await this._room.sendEvent("m.room.message", content, attachments, log);
} catch (err) { });
this._sendError = err;
this.emitChange("error");
console.error(err.stack);
}
} }
async _pickAndSendPicture() { async _pickAndSendPicture() {
try { this.logAndCatch("RoomViewModel.sendPicture", async log => {
if (!this.platform.hasReadPixelPermission()) { if (!this.platform.hasReadPixelPermission()) {
alert("Please allow canvas image data access, so we can scale your images down."); alert("Please allow canvas image data access, so we can scale your images down.");
return; return;
} }
const file = await this.platform.openFile("image/*"); const file = await this.platform.openFile("image/*");
if (!file) { if (!file) {
log.set("cancelled", true);
return; return;
} }
if (!file.blob.mimeType.startsWith("image/")) { if (!file.blob.mimeType.startsWith("image/")) {
return this._sendFile(file); return this._sendFile(file, log);
} }
let image = await this.platform.loadImage(file.blob); let image = await this.platform.loadImage(file.blob);
const limit = await this.platform.settingsStorage.getInt("sentImageSizeLimit"); const limit = await this.platform.settingsStorage.getInt("sentImageSizeLimit");
@ -386,12 +393,8 @@ export class RoomViewModel extends ViewModel {
attachments["info.thumbnail_url"] = attachments["info.thumbnail_url"] =
this._room.createAttachment(thumbnail.blob, file.name); this._room.createAttachment(thumbnail.blob, file.name);
} }
await this._room.sendEvent("m.room.message", content, attachments); await this._room.sendEvent("m.room.message", content, attachments, log);
} catch (err) { });
this._sendError = err;
this.emitChange("error");
console.error(err.stack);
}
} }
get room() { get room() {
@ -402,6 +405,10 @@ export class RoomViewModel extends ViewModel {
return this._composerVM; return this._composerVM;
} }
get callViewModel() {
return this._callViewModel;
}
openDetailsPanel() { openDetailsPanel() {
let path = this.navigation.path.until("room"); let path = this.navigation.path.until("room");
path = path.with(this.navigation.segment("right-panel", true)); path = path.with(this.navigation.segment("right-panel", true));
@ -415,9 +422,40 @@ export class RoomViewModel extends ViewModel {
} }
} }
dismissError() { startCall() {
this._sendError = null; return this.logAndCatch("RoomViewModel.startCall", async log => {
this.emitChange("error"); 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}`);
}
});
} }
} }

View File

@ -189,7 +189,7 @@ import {HomeServer as MockHomeServer} from "../../../../mocks/HomeServer.js";
// other imports // other imports
import {BaseMessageTile} from "./tiles/BaseMessageTile.js"; import {BaseMessageTile} from "./tiles/BaseMessageTile.js";
import {MappedList} from "../../../../observable/list/MappedList"; import {MappedList} from "../../../../observable/list/MappedList";
import {ObservableValue} from "../../../../observable/ObservableValue"; import {ObservableValue} from "../../../../observable/value";
import {PowerLevels} from "../../../../matrix/room/PowerLevels.js"; import {PowerLevels} from "../../../../matrix/room/PowerLevels.js";
export function tests() { export function tests() {

View File

@ -16,6 +16,7 @@ limitations under the License.
import {BaseObservableList} from "../../../../observable/list/BaseObservableList"; import {BaseObservableList} from "../../../../observable/list/BaseObservableList";
import {sortedIndex} from "../../../../utils/sortedIndex"; 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 // 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. // 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) { _createTile(entry) {
const Tile = this._tileOptions.tileClassForEntry(entry); const Tile = this._tileOptions.tileClassForEntry(entry, this._tileOptions);
if (Tile) { if (Tile) {
return new Tile(entry, this._tileOptions); return new Tile(entry, this._tileOptions);
} }
@ -51,6 +52,7 @@ export class TilesCollection extends BaseObservableList {
} }
_populateTiles() { _populateTiles() {
this._silent = true;
this._tiles = []; this._tiles = [];
let currentTile = null; let currentTile = null;
for (let entry of this._entries) { for (let entry of this._entries) {
@ -72,11 +74,20 @@ export class TilesCollection extends BaseObservableList {
if (prevTile) { if (prevTile) {
prevTile.updateNextSibling(null); 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, // now everything is wired up,
// allow tiles to emit updates // allow tiles to emit updates
for (const tile of this._tiles) { for (const tile of this._tiles) {
tile.setUpdateEmit(this._emitSpontanousUpdate); tile.setUpdateEmit(this._emitSpontanousUpdate);
} }
this._silent = false;
} }
_findTileIdx(entry) { _findTileIdx(entry) {
@ -130,25 +141,57 @@ export class TilesCollection extends BaseObservableList {
const newTile = this._createTile(entry); const newTile = this._createTile(entry);
if (newTile) { if (newTile) {
if (prevTile) { this._addTileAt(tileIdx, newTile);
prevTile.updateNextSibling(newTile); this._evaluateDateHeaderAtIdx(tileIdx);
// 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);
} }
// find position by sort key // 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)? // 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) { onUpdate(index, entry, params) {
// if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it // if an update is emitted while calling source.subscribe() from onSubscribeFirst, ignore it
if (!this._tiles) { if (!this._tiles) {
@ -210,11 +253,16 @@ export class TilesCollection extends BaseObservableList {
this.emitRemove(tileIdx, tile); this.emitRemove(tileIdx, tile);
prevTile?.updateNextSibling(nextTile); prevTile?.updateNextSibling(nextTile);
nextTile?.updatePreviousSibling(prevTile); 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 // would also be called when unloading a part of the timeline
onRemove(index, entry) { onRemove(index, entry) {
const tileIdx = this._findTileIdx(entry); const tileIdx = this._findTileIdx(entry);
const tile = this._findTileAtIdx(entry, tileIdx); const tile = this._findTileAtIdx(entry, tileIdx);
if (tile) { if (tile) {
const removeTile = tile.removeEntry(entry); const removeTile = tile.removeEntry(entry);
@ -268,6 +316,7 @@ export function tests() {
constructor(entry) { constructor(entry) {
this.entry = entry; this.entry = entry;
this.update = null; this.update = null;
this.needsDateSeparator = false;
} }
setUpdateEmit(update) { setUpdateEmit(update) {
this.update = update; this.update = update;
@ -297,6 +346,34 @@ export function tests() {
dispose() {} 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 { return {
"don't emit update before add": assert => { "don't emit update before add": assert => {
class UpdateOnSiblingTile extends TestTile { class UpdateOnSiblingTile extends TestTile {
@ -355,6 +432,73 @@ export function tests() {
}); });
entries.remove(1); entries.remove(1);
assert.deepEqual(events, ["remove", "update"]); 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);
} }
} }
} }

View File

@ -23,7 +23,6 @@ import {copyPlaintext} from "../../../../../platform/web/dom/utils";
export class BaseMessageTile extends SimpleTile { export class BaseMessageTile extends SimpleTile {
constructor(entry, options) { constructor(entry, options) {
super(entry, options); super(entry, options);
this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null;
this._isContinuation = false; this._isContinuation = false;
this._reactions = null; this._reactions = null;
this._replyTile = null; this._replyTile = null;
@ -55,14 +54,6 @@ export class BaseMessageTile extends SimpleTile {
return `https://matrix.to/#/${encodeURIComponent(this.sender)}`; return `https://matrix.to/#/${encodeURIComponent(this.sender)}`;
} }
get displayName() {
return this._entry.displayName || this.sender;
}
get sender() {
return this._entry.sender;
}
get memberPanelLink() { get memberPanelLink() {
return `${this.urlRouter.urlUntilSegment("room")}/member/${this.sender}`; return `${this.urlRouter.urlUntilSegment("room")}/member/${this.sender}`;
} }
@ -84,12 +75,8 @@ export class BaseMessageTile extends SimpleTile {
return this.sender; return this.sender;
} }
get date() {
return this._date && this._date.toLocaleDateString({}, {month: "numeric", day: "numeric"});
}
get time() { get time() {
return this._date && this._date.toLocaleTimeString({}, {hour: "numeric", minute: "2-digit"}); return this._date && this.timeFormatter.formatTime(this._date);
} }
get isOwn() { get isOwn() {
@ -145,7 +132,7 @@ export class BaseMessageTile extends SimpleTile {
if (action?.shouldReplace || !this._replyTile) { if (action?.shouldReplace || !this._replyTile) {
this.disposeTracked(this._replyTile); this.disposeTracked(this._replyTile);
const tileClassForEntry = this._options.tileClassForEntry; const tileClassForEntry = this._options.tileClassForEntry;
const ReplyTile = tileClassForEntry(replyEntry); const ReplyTile = tileClassForEntry(replyEntry, this._options);
if (ReplyTile) { if (ReplyTile) {
this._replyTile = new ReplyTile(replyEntry, this._options); this._replyTile = new ReplyTile(replyEntry, this._options);
} }
@ -160,8 +147,8 @@ export class BaseMessageTile extends SimpleTile {
this._roomVM.startReply(this._entry); this._roomVM.startReply(this._entry);
} }
reply(msgtype, body, log = null) { createReplyContent(msgtype, body) {
return this._room.sendEvent("m.room.message", this._entry.reply(msgtype, body), null, log); return this._entry.createReplyContent(msgtype, body);
} }
redact(reason, log) { redact(reason, log) {

View 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;
}
}

View 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);
}
}
}

View File

@ -19,71 +19,64 @@ import {UpdateAction} from "../UpdateAction.js";
import {ConnectionError} from "../../../../../matrix/error.js"; import {ConnectionError} from "../../../../../matrix/error.js";
import {ConnectionStatus} from "../../../../../matrix/net/Reconnector"; import {ConnectionStatus} from "../../../../../matrix/net/Reconnector";
// TODO: should this become an ITile and SimpleTile become EventTile?
export class GapTile extends SimpleTile { export class GapTile extends SimpleTile {
constructor(entry, options) { constructor(entry, options) {
super(entry, options); super(entry, options);
this._loading = false; this._loading = false;
this._error = null; this._waitingForConnection = false;
this._isAtTop = true; this._isAtTop = true;
this._siblingChanged = false; this._siblingChanged = false;
this._showSpinner = false;
} }
async fill() { get needsDateSeparator() {
return false;
}
async fill(isRetrying = false) {
if (!this._loading && !this._entry.edgeReached) { if (!this._loading && !this._entry.edgeReached) {
this._loading = true; this._loading = true;
this._error = null;
this._showSpinner = true;
this.emitChange("isLoading"); this.emitChange("isLoading");
try { try {
await this._room.fillGap(this._entry, 10); await this._room.fillGap(this._entry, 10);
} catch (err) { } catch (err) {
console.error(`room.fillGap(): ${err.message}:\n${err.stack}`);
this._error = err;
if (err instanceof ConnectionError) { 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(); 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 { } finally {
this._loading = false; this._loading = false;
this._showSpinner = false;
this.emitChange("isLoading"); this.emitChange("isLoading");
} }
return true; return true;
} }
return false; return false;
} }
async notifyVisible() { 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 // 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 // because notifyVisible won't be called again until something gets added to the timeline
let depth = 0; let depth = 0;
let canFillMore; let canFillMore;
this._siblingChanged = false; this._siblingChanged = false;
do { do {
try { canFillMore = await this.fill();
canFillMore = await this.fill();
}
catch (e) {
if (e instanceof ConnectionError) {
canFillMore = true;
// Don't increase depth because this gap fill was a noop
continue;
}
else {
canFillMore = false;
}
}
depth = depth + 1; depth = depth + 1;
} while (depth < 10 && !this._siblingChanged && canFillMore && !this.isDisposed); } while (depth < 10 && !this._siblingChanged && canFillMore && !this.isDisposed);
} }
@ -119,7 +112,11 @@ export class GapTile extends SimpleTile {
} }
async _waitForReconnection() { async _waitForReconnection() {
this._waitingForConnection = true;
this.emitUpdate("status");
await this.options.client.reconnector.connectionStatus.waitFor(status => status === ConnectionStatus.Online).promise; await this.options.client.reconnector.connectionStatus.waitFor(status => status === ConnectionStatus.Online).promise;
this._waitingForConnection = false;
this.emitUpdate("status");
} }
get shape() { get shape() {
@ -131,29 +128,19 @@ export class GapTile extends SimpleTile {
} }
get showSpinner() { get showSpinner() {
return this._showSpinner; return this.isLoading || this._waitingForConnection;
} }
get error() { get status() {
if (this._error) { const dir = this._entry.prev_batch ? "previous" : "next";
if (this._error instanceof ConnectionError) { if (this._waitingForConnection) {
return "Waiting for reconnection"; return "Waiting for connection…";
} } else if (this.errorViewModel) {
const dir = this._entry.prev_batch ? "previous" : "next"; return `Could not load ${dir} messages`;
return `Could not load ${dir} messages: ${this._error.message}`; } else if (this.isLoading) {
} return "Loading more messages…";
return null; } else {
} return "Gave up loading more messages";
get currentAction() {
if (this.error) {
return this.error;
}
else if (this.isLoading) {
return "Loading";
}
else {
return "Not Loading";
} }
} }
} }

View 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;
}

View File

@ -15,13 +15,17 @@ limitations under the License.
*/ */
import {UpdateAction} from "../UpdateAction.js"; 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 {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js";
import {DateTile} from "./DateTile";
export class SimpleTile extends ViewModel { export class SimpleTile extends ErrorReportViewModel {
constructor(entry, options) { constructor(entry, options) {
super(options); super(options);
this._entry = entry; this._entry = entry;
this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : undefined;
this._needsDateSeparator = false;
this._emitUpdate = undefined; this._emitUpdate = undefined;
} }
// view model props for all subclasses // view model props for all subclasses
@ -37,8 +41,22 @@ export class SimpleTile extends ViewModel {
return false; return false;
} }
get hasDateSeparator() { get needsDateSeparator() {
return false; 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() { get id() {
@ -92,8 +110,16 @@ export class SimpleTile extends ViewModel {
return this._entry; return this._entry;
} }
get comparisonIsNotCommutative() {
return false;
}
compare(tile) { compare(tile) {
return this.upperEntry.compare(tile.upperEntry); if (tile.comparisonIsNotCommutative) {
return -tile.compare(this);
} else {
return this.upperEntry.compare(tile.upperEntry);
}
} }
compareEntry(entry) { compareEntry(entry) {
@ -123,8 +149,10 @@ export class SimpleTile extends ViewModel {
return false; return false;
} }
// let item know it has a new sibling // 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 // let item know it has a new sibling
@ -159,4 +187,74 @@ export class SimpleTile extends ViewModel {
get _ownMember() { get _ownMember() {
return this._options.timeline.me; 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);
},
}
} }

View File

@ -26,9 +26,11 @@ import {RoomMemberTile} from "./RoomMemberTile.js";
import {EncryptedEventTile} from "./EncryptedEventTile.js"; import {EncryptedEventTile} from "./EncryptedEventTile.js";
import {EncryptionEnabledTile} from "./EncryptionEnabledTile.js"; import {EncryptionEnabledTile} from "./EncryptionEnabledTile.js";
import {MissingAttachmentTile} from "./MissingAttachmentTile.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 {Room} from "../../../../../matrix/room/Room";
import type {Session} from "../../../../../matrix/Session";
import type {Timeline} from "../../../../../matrix/room/timeline/Timeline"; import type {Timeline} from "../../../../../matrix/room/timeline/Timeline";
import type {FragmentBoundaryEntry} from "../../../../../matrix/room/timeline/entries/FragmentBoundaryEntry"; import type {FragmentBoundaryEntry} from "../../../../../matrix/room/timeline/entries/FragmentBoundaryEntry";
import type {EventEntry} from "../../../../../matrix/room/timeline/entries/EventEntry"; 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 TimelineEntry = FragmentBoundaryEntry | EventEntry | PendingEventEntry;
export type TileClassForEntryFn = (entry: TimelineEntry) => TileConstructor | undefined; export type TileClassForEntryFn = (entry: TimelineEntry) => TileConstructor | undefined;
export type Options = ViewModelOptions & { export type Options = ViewModelOptions & {
session: Session,
room: Room, room: Room,
timeline: Timeline timeline: Timeline
tileClassForEntry: TileClassForEntryFn; 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) { if (entry.isGap) {
return GapTile; return GapTile;
} else if (entry.isPending && entry.pendingEvent.isMissingAttachments) { } else if (entry.isPending && entry.pendingEvent.isMissingAttachments) {
@ -86,6 +89,14 @@ export function tileClassForEntry(entry: TimelineEntry): TileConstructor | undef
return EncryptedEventTile; return EncryptedEventTile;
case "m.room.encryption": case "m.room.encryption":
return EncryptionEnabledTile; 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: default:
// unknown type not rendered // unknown type not rendered
return undefined; return undefined;

View 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");
}
}

View File

@ -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();
}
}

View 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);
});
}
}

View File

@ -15,8 +15,9 @@ limitations under the License.
*/ */
import {ViewModel} from "../../ViewModel"; import {ViewModel} from "../../ViewModel";
import {KeyBackupViewModel} from "./KeyBackupViewModel.js"; import {KeyBackupViewModel} from "./KeyBackupViewModel";
import {submitLogsToRageshakeServer} from "../../../domain/rageshake"; import {FeaturesViewModel} from "./FeaturesViewModel";
import {submitLogsFromSessionToDefaultServer} from "../../../domain/rageshake";
class PushNotificationStatus { class PushNotificationStatus {
constructor() { constructor() {
@ -53,6 +54,7 @@ export class SettingsViewModel extends ViewModel {
this.pushNotifications = new PushNotificationStatus(); this.pushNotifications = new PushNotificationStatus();
this._activeTheme = undefined; this._activeTheme = undefined;
this._logsFeedbackMessage = undefined; this._logsFeedbackMessage = undefined;
this._featuresViewModel = new FeaturesViewModel(this.childOptions());
} }
get _session() { get _session() {
@ -125,6 +127,10 @@ export class SettingsViewModel extends ViewModel {
return this._keyBackupViewModel; return this._keyBackupViewModel;
} }
get featuresViewModel() {
return this._featuresViewModel;
}
get storageQuota() { get storageQuota() {
return this._formatBytes(this._estimate?.quota); return this._formatBytes(this._estimate?.quota);
} }
@ -150,8 +156,14 @@ export class SettingsViewModel extends ViewModel {
} }
async exportLogs() { async exportLogs() {
const logExport = await this.logger.export(); const logs = await this.exportLogsBlob();
this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`); 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() { get canSendLogsToServer() {
@ -169,29 +181,13 @@ export class SettingsViewModel extends ViewModel {
} }
async sendLogsToServer() { async sendLogsToServer() {
const {bugReportEndpointUrl} = this.platform.config; this._logsFeedbackMessage = this.i18n`Sending logs…`;
if (bugReportEndpointUrl) { try {
this._logsFeedbackMessage = this.i18n`Sending logs…`; await submitLogsFromSessionToDefaultServer(this._session, this.platform);
this._logsFeedbackMessage = this.i18n`Logs sent succesfully!`;
} catch (err) {
this._logsFeedbackMessage = err.message;
this.emitChange(); 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();
}
} }
} }

View 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")();
}
}

View 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;
}
}

View 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
View 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